diff --git a/projects/core/src/consts/README.md b/projects/core/src/consts/README.md new file mode 100644 index 000000000..dfe7dcaaf --- /dev/null +++ b/projects/core/src/consts/README.md @@ -0,0 +1,34 @@ + + +# 🧱 Core / Const — правила нейминга + +## Общая идея + +Папка `core/const` хранит все константы проекта (навигация, списки для select, статусы, роли и т.д.). +Каждая константа должна быть названа единообразно и понятно по контексту. + +--- + +## 🧩 Имена файлов + +- Формат: `feature.const.ts` +- Название — в **kebab-case**. +- Примеры: + - `navigation.const.ts` + - `selects.const.ts` + - `permissions.const.ts` + +--- + +## 🧠 Имена переменных + +- Формат: **camelCase** +- Если переменная содержит список — использовать **множественное число** +- Имя отражает назначение +- Экспорт только через `export const` + +**Примеры:** + +```ts +export const navItems = [...] +``` diff --git a/projects/core/src/consts/filter-experience.ts b/projects/core/src/consts/filter-experience.ts deleted file mode 100644 index 281b6bf83..000000000 --- a/projects/core/src/consts/filter-experience.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** @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 deleted file mode 100644 index 6c0470259..000000000 --- a/projects/core/src/consts/filter-work-format.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** @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 deleted file mode 100644 index 950b9c844..000000000 --- a/projects/core/src/consts/filter-work-schedule.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** @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/filters/feed-filter.const.ts b/projects/core/src/consts/filters/feed-filter.const.ts new file mode 100644 index 000000000..cfb85b78f --- /dev/null +++ b/projects/core/src/consts/filters/feed-filter.const.ts @@ -0,0 +1,34 @@ +/** @format */ + +export const feedFilter = [ + { + id: 1, + name: "новости проектов", + value: "projects", + icon: "projects", + }, + { + id: 2, + name: "свежие вакансии", + value: "vacancy", + icon: "suitcase", + }, + { + id: 3, + name: "новости сообщества", + value: "news", + icon: "people-bold", + }, + { + id: 4, + name: "новости программ", + value: "projects/1", + icon: "procollab", + }, + { + id: 5, + name: "образование", + value: "education", + icon: "trajectories", + }, +]; diff --git a/projects/core/src/consts/rating-filters.ts b/projects/core/src/consts/filters/rating-filter.const.ts similarity index 59% rename from projects/core/src/consts/rating-filters.ts rename to projects/core/src/consts/filters/rating-filter.const.ts index b411a41ca..4c9ea0744 100644 --- a/projects/core/src/consts/rating-filters.ts +++ b/projects/core/src/consts/filters/rating-filter.const.ts @@ -1,23 +1,23 @@ /** @format */ -export const ratingFiltersList = [ +export const ratingFilters = [ { - label: "Месяц", + label: "месяц", id: 0, value: "last_month", }, { - label: "Год", + label: "год", id: 1, value: "last_year", }, { - label: "День", + label: "день", id: 2, value: "last_day", }, { - label: "Неделя", + label: "неделя", id: 3, value: "last_week", }, diff --git a/projects/core/src/consts/filter-tags.ts b/projects/core/src/consts/filters/tags-filter.const.ts similarity index 61% rename from projects/core/src/consts/filter-tags.ts rename to projects/core/src/consts/filters/tags-filter.const.ts index b7cce02bb..7ff701ed6 100644 --- a/projects/core/src/consts/filter-tags.ts +++ b/projects/core/src/consts/filters/tags-filter.const.ts @@ -1,7 +1,6 @@ /** @format */ -export const filterTags = [ - { id: 2, label: "Все проекты", value: null }, +export const tagsFilter = [ { id: 1, label: "Оцененные", value: true }, { id: 0, label: "Не оцененные", value: false }, ]; diff --git a/projects/core/src/consts/filters/work-experience-filter.const.ts b/projects/core/src/consts/filters/work-experience-filter.const.ts new file mode 100644 index 000000000..8e0129d22 --- /dev/null +++ b/projects/core/src/consts/filters/work-experience-filter.const.ts @@ -0,0 +1,8 @@ +/** @format */ + +export const workExperienceFilter = [ + { 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/filters/work-format-filter.const.ts b/projects/core/src/consts/filters/work-format-filter.const.ts new file mode 100644 index 000000000..abcb6acb6 --- /dev/null +++ b/projects/core/src/consts/filters/work-format-filter.const.ts @@ -0,0 +1,7 @@ +/** @format */ + +export const workFormatFilter = [ + { label: "удаленная работа", value: "remote" }, + { label: "работа в офисе", value: "office" }, + { label: "смешанный формат", value: "hybrid" }, +]; diff --git a/projects/core/src/consts/filters/work-schedule-filter.const.ts b/projects/core/src/consts/filters/work-schedule-filter.const.ts new file mode 100644 index 000000000..c59ec3e0a --- /dev/null +++ b/projects/core/src/consts/filters/work-schedule-filter.const.ts @@ -0,0 +1,9 @@ +/** @format */ + +export const workScheduleFilter = [ + { 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-education.ts b/projects/core/src/consts/lists/education-info-list.const.ts similarity index 67% rename from projects/core/src/consts/list-education.ts rename to projects/core/src/consts/lists/education-info-list.const.ts index f34197734..d9dc0806c 100644 --- a/projects/core/src/consts/list-education.ts +++ b/projects/core/src/consts/lists/education-info-list.const.ts @@ -4,17 +4,17 @@ export const educationUserType = [ { id: 0, value: "Ученик", - label: "Ученик", + label: "ученик", }, { id: 1, value: "Студент", - label: "Студент", + label: "студент", }, { id: 2, value: "Выпускник", - label: "Выпускник", + label: "выпускник", }, ]; @@ -22,26 +22,26 @@ export const educationUserLevel = [ { id: 0, value: "Среднее общее образование", - label: "Среднее общее образование", + label: "среднее общее образование", }, { id: 1, value: "Среднее профессиональное образование", - label: "Среднее профессиональное образование", + label: "среднее профессиональное образование", }, { id: 2, value: "Высшее образование – бакалавриат, специалитет", - label: "Высшее образование – бакалавриат, специалитет", + label: "высшее образование – бакалавриат, специалитет", }, { id: 3, value: "Высшее образование – магистратура", - label: "Высшее образование – магистратура", + label: "высшее образование – магистратура", }, { id: 4, value: "Высшее образование – аспирантура", - label: "Высшее образование – аспирантура", + label: "высшее образование – аспирантура", }, ]; diff --git a/projects/core/src/consts/list-language.ts b/projects/core/src/consts/lists/language-info-list.const.ts similarity index 73% rename from projects/core/src/consts/list-language.ts rename to projects/core/src/consts/lists/language-info-list.const.ts index a961335fe..ab176e59d 100644 --- a/projects/core/src/consts/list-language.ts +++ b/projects/core/src/consts/lists/language-info-list.const.ts @@ -4,62 +4,62 @@ export const languageNamesList = [ { id: 0, value: "Английский", - label: "Английский", + label: "английский", }, { id: 1, value: "Испанский", - label: "Испанский", + label: "испанский", }, { id: 2, value: "Итальянский", - label: "Итальянский", + label: "итальянский", }, { id: 3, value: "Немецкий", - label: "Немецкий", + label: "немецкий", }, { id: 4, value: "Японский", - label: "Японский", + label: "японский", }, { id: 5, value: "Китайский", - label: "Китайский", + label: "китайский", }, { id: 6, value: "Арабский", - label: "Арабский", + label: "арабский", }, { id: 7, value: "Шведский", - label: "Шведский", + label: "шведский", }, { id: 8, value: "Польский", - label: "Польский", + label: "польский", }, { id: 9, value: "Чешский", - label: "Чешский", + label: "чешский", }, { id: 10, value: "Русский", - label: "Русский", + label: "русский", }, { id: 11, value: "Французский", - label: "Французский", + label: "французский", }, ]; diff --git a/projects/core/src/consts/list-direction-project.ts b/projects/core/src/consts/lists/ldirection-project-list.const.ts similarity index 66% rename from projects/core/src/consts/list-direction-project.ts rename to projects/core/src/consts/lists/ldirection-project-list.const.ts index 6a18d18c3..4363da648 100644 --- a/projects/core/src/consts/list-direction-project.ts +++ b/projects/core/src/consts/lists/ldirection-project-list.const.ts @@ -8,7 +8,7 @@ export const directionProjectList = [ { id: 0, value: "Технология", // Значение для отправки на сервер - label: "Технология", // Отображаемый текст + label: "технология", // Отображаемый текст }, { id: 1, @@ -18,31 +18,31 @@ export const directionProjectList = [ { id: 2, value: "Транспорт", - label: "Транспорт", + label: "транспорт", }, { id: 3, - value: "им Био", // Возможно опечатка, должно быть "Хим Био" - label: "Хим Био", + value: "Хим Био", + label: "хим био", }, { id: 4, value: "Дизайн", - label: "Дизайн", + label: "дизайн", }, { id: 5, value: "Мультимедиа", - label: "Мультимедиа", + label: "мультимедиа", }, { id: 6, value: "СоцТех", - label: "СоцТех", + label: "соцтех", }, { id: 7, value: "Урбанистика", - label: "Урбанистика", + label: "урбанистика", }, ]; diff --git a/projects/core/src/consts/list-mock-months.ts b/projects/core/src/consts/lists/mock-months-list.const.ts similarity index 100% rename from projects/core/src/consts/list-mock-months.ts rename to projects/core/src/consts/lists/mock-months-list.const.ts diff --git a/projects/core/src/consts/lists/resource-options-list.const.ts b/projects/core/src/consts/lists/resource-options-list.const.ts new file mode 100644 index 000000000..83335eabb --- /dev/null +++ b/projects/core/src/consts/lists/resource-options-list.const.ts @@ -0,0 +1,24 @@ +/** @format */ + +export const resourceOptionsList = [ + { + id: 1, + value: "infrastructure", + label: "инфраструктурный", + }, + { + id: 2, + value: "staff", + label: "кадровый", + }, + { + id: 3, + value: "financial", + label: "финансовый", + }, + { + id: 4, + value: "information", + label: "информационный", + }, +]; diff --git a/projects/core/src/consts/list-roles-members.ts b/projects/core/src/consts/lists/roles-members-list.const.ts similarity index 61% rename from projects/core/src/consts/list-roles-members.ts rename to projects/core/src/consts/lists/roles-members-list.const.ts index 58bff5306..cbf62aeb5 100644 --- a/projects/core/src/consts/list-roles-members.ts +++ b/projects/core/src/consts/lists/roles-members-list.const.ts @@ -3,28 +3,28 @@ export const rolesMembersList = [ { id: 0, - value: "Наставник", - label: "Наставник", + value: "наставник", + label: "наставник", }, { id: 1, value: "Руководитель проекта", - label: "Руководитель проекта", + label: "руководитель проекта", }, { id: 2, value: "Руководитель направления", - label: "Руководитель направления", + label: "руководитель направления", }, { id: 3, value: "Проектный менеджер", - label: "Проектный менеджер", + label: "проектный менеджер", }, { id: 4, value: "Руководитель трека", - label: "Руководитель трека", + label: "руководитель трека", }, { id: 5, @@ -34,6 +34,6 @@ export const rolesMembersList = [ { id: 6, value: "Участник", - label: "Участник", + label: "участник", }, ]; diff --git a/projects/core/src/consts/list-track-project.ts b/projects/core/src/consts/lists/track-project-list.const.ts similarity index 62% rename from projects/core/src/consts/list-track-project.ts rename to projects/core/src/consts/lists/track-project-list.const.ts index ec73b07e3..e03d85937 100644 --- a/projects/core/src/consts/list-track-project.ts +++ b/projects/core/src/consts/lists/track-project-list.const.ts @@ -4,26 +4,26 @@ export const trackProjectList = [ { id: 0, value: "Технологическое лидерство", - label: "Технологическое лидерство", + label: "технологическое лидерство", }, { id: 1, value: "Индустриальные", - label: "Индустриальные", + label: "индустриальные", }, { id: 2, value: "Инициативные", - label: "Инициативные", + label: "инициативные", }, { id: 3, value: "Стратегические", - label: "Стратегические", + label: "стратегические", }, { id: 4, value: "Научные", - label: "Научные", + label: "научные", }, ]; diff --git a/projects/core/src/consts/trajectoryMore.ts b/projects/core/src/consts/lists/trajectory-more-list.const.ts similarity index 88% rename from projects/core/src/consts/trajectoryMore.ts rename to projects/core/src/consts/lists/trajectory-more-list.const.ts index 63f222cfa..7a8861cbd 100644 --- a/projects/core/src/consts/trajectoryMore.ts +++ b/projects/core/src/consts/lists/trajectory-more-list.const.ts @@ -1,6 +1,6 @@ /** @format */ -export const trajectoryMore = [ +export const trajectoryMoreList = [ { label: "Работа с наставником", }, diff --git a/projects/core/src/consts/list-experience.ts b/projects/core/src/consts/lists/work-experience-list.const.ts similarity index 57% rename from projects/core/src/consts/list-experience.ts rename to projects/core/src/consts/lists/work-experience-list.const.ts index 5388df8b0..83bea1075 100644 --- a/projects/core/src/consts/list-experience.ts +++ b/projects/core/src/consts/lists/work-experience-list.const.ts @@ -1,24 +1,24 @@ /** @format */ -export const experienceList = [ +export const workExperienceList = [ { id: 0, value: "без опыта", - label: "Без опыта", + label: "без опыта", }, { id: 1, value: "до 1 года", - label: "До 1 года", + label: "до 1 года", }, { id: 2, value: "от 1 года до 3 лет", - label: "От 1 года до 3 лет", + label: "от 1 года до 3 лет", }, { id: 3, value: "от 3 лет и более", - label: "От 3 лет и более", + label: "от 3 лет и более", }, ]; diff --git a/projects/core/src/consts/list-format.ts b/projects/core/src/consts/lists/work-format-list.const.ts similarity index 56% rename from projects/core/src/consts/list-format.ts rename to projects/core/src/consts/lists/work-format-list.const.ts index a00d1ad30..3b9a58d5c 100644 --- a/projects/core/src/consts/list-format.ts +++ b/projects/core/src/consts/lists/work-format-list.const.ts @@ -1,19 +1,19 @@ /** @format */ -export const formatList = [ +export const workFormatList = [ { id: 0, value: "удаленная работа", - label: "Удаленная работа", + label: "удаленная работа", }, { id: 1, value: "работа в офисе", - label: "Работа в офисе", + label: "работа в офисе", }, { id: 2, value: "смешанная", - label: "Смешанная", + label: "смешанная", }, ]; diff --git a/projects/core/src/consts/list-schelude.ts b/projects/core/src/consts/lists/work-schelude-list.const.ts similarity index 57% rename from projects/core/src/consts/list-schelude.ts rename to projects/core/src/consts/lists/work-schelude-list.const.ts index 19217586a..729368d61 100644 --- a/projects/core/src/consts/list-schelude.ts +++ b/projects/core/src/consts/lists/work-schelude-list.const.ts @@ -1,29 +1,29 @@ /** @format */ -export const scheludeList = [ +export const workScheludeList = [ { id: 0, value: "полный рабочий день", - label: "Полный рабочий день", + label: "полный рабочий день", }, { id: 1, value: "сменный график", - label: "Сменный график", + label: "сменный график", }, { id: 2, value: "гибкий график", - label: "Гибкий график", + label: "гибкий график", }, { id: 3, value: "частичная занятость", - label: "Частичная занятость", + label: "частичная занятость", }, { id: 4, value: "стажировка", - label: "Стажировка", + label: "стажировка", }, ]; diff --git a/projects/core/src/consts/navProfileItems.ts b/projects/core/src/consts/navProfileItems.ts deleted file mode 100644 index 2d3e6aead..000000000 --- a/projects/core/src/consts/navProfileItems.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** @format */ - -export const navItems = [ - { - step: "main", - src: "/assets/images/profile/main.svg", - label: "Основные данные", - }, - { - step: "education", - src: "/assets/images/profile/education.svg", - label: "Образование", - }, - { - step: "experience", - src: "/assets/images/profile/experience.svg", - label: "Опыт", - }, - { - step: "achievements", - src: "/assets/images/profile/achievements.svg", - label: "Достижения", - }, - { - step: "skills", - src: "/assets/images/profile/skills.svg", - label: "Навыки", - }, -]; diff --git a/projects/core/src/consts/navigation/nav-profile-items.const.ts b/projects/core/src/consts/navigation/nav-profile-items.const.ts new file mode 100644 index 000000000..cba732394 --- /dev/null +++ b/projects/core/src/consts/navigation/nav-profile-items.const.ts @@ -0,0 +1,34 @@ +/** @format */ + +export const navProfileItems = [ + { + step: "main", + src: "main", + label: "основные данные", + }, + { + step: "education", + src: "in-search", + label: "образование", + }, + { + step: "experience", + src: "suitcase", + label: "работа", + }, + { + step: "achievements", + src: "medal", + label: "достижения", + }, + { + step: "skills", + src: "squiz", + label: "навыки", + }, + { + step: "settings", + src: "settings", + label: "действия", + }, +]; diff --git a/projects/core/src/consts/navProjectItems.ts b/projects/core/src/consts/navigation/nav-project-items.const.ts similarity index 50% rename from projects/core/src/consts/navProjectItems.ts rename to projects/core/src/consts/navigation/nav-project-items.const.ts index f5b7cf52f..21f82b8d4 100644 --- a/projects/core/src/consts/navProjectItems.ts +++ b/projects/core/src/consts/navigation/nav-project-items.const.ts @@ -6,35 +6,29 @@ import { EditStep } from "@office/projects/edit/services/project-step.service"; * Элементы навигации для редактирования проекта * Используется в компоненте пошагового редактирования проекта */ -export const navItems = [ +export const navProjectItems = [ { step: "main" as EditStep, // Идентификатор шага - src: "/assets/images/projects/edit/main.svg", // Путь к иконке - label: "Основные данные", // Отображаемый текст + label: "основные данные", // Отображаемый текст }, { step: "contacts" as EditStep, - src: "/assets/images/projects/edit/contacts.svg", - label: "Контакты и ссылки", + label: "партнеры и ресурсы", }, { step: "achievements" as EditStep, - src: "/assets/images/projects/edit/achievements.svg", - label: "Достижения", + label: "достижения", }, { step: "vacancies" as EditStep, - src: "/assets/images/projects/edit/vacancies.svg", - label: "Вакансии", + label: "вакансии", }, { step: "team" as EditStep, - src: "/assets/images/projects/edit/team.svg", - label: "Команда", + label: "команда", }, { step: "additional" as EditStep, - src: "/assets/images/projects/edit/additional.svg", - label: "Доп. сведения", + label: "данные для конкурсов", }, ]; diff --git a/projects/core/src/consts/note-list.ts b/projects/core/src/consts/note-list.ts deleted file mode 100644 index d1cdf33a4..000000000 --- a/projects/core/src/consts/note-list.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** @format */ - -export const noteList = [ - { - text: "Проверьте описание вакансии.", - }, - { - text: "Адаптируйте резюме.", - }, - { - text: "Напишите сопроводительное письмо.", - }, - { - text: "Проверьте грамматику и орфографию.", - }, - { - text: "Убедитесь в правильности контактной информации.", - }, - { - text: "Подготовьте дополнительные документы.", - }, - { - text: "Проверьте форматирование.", - }, - { - text: "Убедитесь в соблюдении сроков.", - }, - { - text: "Сохраните копию.", - }, - { - text: "Будьте готовы к интервью.", - }, -]; diff --git a/projects/core/src/consts/fieldsProfile.ts b/projects/core/src/consts/other/profile-fields.const.ts similarity index 97% rename from projects/core/src/consts/fieldsProfile.ts rename to projects/core/src/consts/other/profile-fields.const.ts index b6875cd17..7e571629e 100644 --- a/projects/core/src/consts/fieldsProfile.ts +++ b/projects/core/src/consts/other/profile-fields.const.ts @@ -5,7 +5,7 @@ * Определяет какие поля являются массивами, а какие строками * Используется для валидации и обработки данных профиля */ -export const fieldsProfile = [ +export const profileFields = [ // Поля-массивы (содержат несколько элементов) { key: "education", type: "array" }, // Образование { key: "workExperience", type: "array" }, // Опыт работы 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 dd71e0291..f8be4426c 100644 --- a/projects/skills/src/app/profile/home/profile-home.component.ts +++ b/projects/skills/src/app/profile/home/profile-home.component.ts @@ -8,7 +8,7 @@ import { ProgressBlockComponent } from "../shared/progress-block/progress-block. import { ActivatedRoute } from "@angular/router"; import { toSignal } from "@angular/core/rxjs-interop"; import { map, type Subscription } from "rxjs"; -import { mockMonthsList } from "projects/core/src/consts/list-mock-months"; +import { mockMonthsList } from "projects/core/src/consts/lists/mock-months-list.const"; import { ProfileService } from "../services/profile.service"; import { TrajectoryBlockComponent } from "../shared/trajectory-block/trajectory-block.component"; diff --git a/projects/skills/src/app/profile/shared/info-block/info-block.component.html b/projects/skills/src/app/profile/shared/info-block/info-block.component.html index b9ddf024a..86bddefb7 100644 --- a/projects/skills/src/app/profile/shared/info-block/info-block.component.html +++ b/projects/skills/src/app/profile/shared/info-block/info-block.component.html @@ -41,7 +41,9 @@

{{ userData.firstName }} {{ userData.lastName }}

{{ userData.points }} {{ userData.points | pluralize: ["балл", "балла", "баллов"] }} - Вернуться на procollab.ru + Вернуться на procollab.ru @@ -54,7 +56,10 @@

{{ userData.firstName }} {{ userData.lastName }}

Подписка оформлена

Погрузись в мир знаний прямо сейчас

- Начать wave diff --git a/projects/skills/src/app/profile/shared/info-block/info-block.component.scss b/projects/skills/src/app/profile/shared/info-block/info-block.component.scss index a72b471f5..393ae2cda 100644 --- a/projects/skills/src/app/profile/shared/info-block/info-block.component.scss +++ b/projects/skills/src/app/profile/shared/info-block/info-block.component.scss @@ -216,14 +216,6 @@ color: var(--grey-for-text); } - app-button { - width: 70%; - - @include responsive.apply-desktop { - width: 40%; - } - } - &__wave { position: absolute; bottom: -10%; diff --git a/projects/skills/src/app/profile/shared/skill-chooser/skill-chooser.component.html b/projects/skills/src/app/profile/shared/skill-chooser/skill-chooser.component.html index b71e57259..47c1393df 100644 --- a/projects/skills/src/app/profile/shared/skill-chooser/skill-chooser.component.html +++ b/projects/skills/src/app/profile/shared/skill-chooser/skill-chooser.component.html @@ -1,7 +1,7 @@ @if (open && !nonConfirmerModalOpen()) { - +

Выберите 5 навыков

@@ -80,6 +80,7 @@

У вас нет активной по
У вас нет активной по > Навыки

Тебе предстоит выбрать 5 навыков, развитию которых будет посвящен месяц твоей подписки. Не торопись с выбором. Отменить или поменять его будет нельзя!

- Выбрать навыки месяца wave @@ -82,6 +86,7 @@

У вас нет активной по
У вас нет активной по > - Начать + Начать
@@ -15,7 +15,10 @@

У вас нет активной траектории!

Выберите нужную вам траекторию!

- Ок

diff --git a/projects/skills/src/app/profile/shared/trajectory-block/trajectory-block.component.scss b/projects/skills/src/app/profile/shared/trajectory-block/trajectory-block.component.scss index fb123cd1b..10ec062aa 100644 --- a/projects/skills/src/app/profile/shared/trajectory-block/trajectory-block.component.scss +++ b/projects/skills/src/app/profile/shared/trajectory-block/trajectory-block.component.scss @@ -94,12 +94,4 @@ margin-bottom: 24px; color: var(--grey-for-text); } - - app-button { - width: 70%; - - @include responsive.apply-desktop { - width: 40%; - } - } } diff --git a/projects/skills/src/app/profile/students/students.component.html b/projects/skills/src/app/profile/students/students.component.html index 3a2689bfe..ec399b4dd 100644 --- a/projects/skills/src/app/profile/students/students.component.html +++ b/projects/skills/src/app/profile/students/students.component.html @@ -22,7 +22,7 @@ 'https://app.procollab.ru/office/chats/' + student.student.id + '_' + student.mentorId " > - Чат с участником @@ -92,7 +92,10 @@
- Сохранить
@@ -108,9 +111,9 @@ {{ expandedStudentId === student.student.id ? "Скрыть" : "Посмотреть" }} статистику

@if (expandedStudentId === student.student.id) { - + } @else { - + } diff --git a/projects/skills/src/app/profile/students/students.component.scss b/projects/skills/src/app/profile/students/students.component.scss index a0bb2f1f2..688de9ece 100644 --- a/projects/skills/src/app/profile/students/students.component.scss +++ b/projects/skills/src/app/profile/students/students.component.scss @@ -74,12 +74,6 @@ color: var(--dark-grey); } - app-button { - &::ng-deep .button--inline { - height: 25px; - } - } - &__stats { position: relative; top: auto; @@ -194,26 +188,6 @@ &__meeting { color: var(--dark-grey); } - - &__save { - width: 100%; - - app-button { - &::ng-deep .button--inline { - min-height: 45px; - } - } - - @include responsive.apply-desktop { - width: 13%; - - app-button { - &::ng-deep .button--inline { - min-height: 38px; - } - } - } - } } .icon { diff --git a/projects/skills/src/app/rating/general/general.component.ts b/projects/skills/src/app/rating/general/general.component.ts index 5d7f312a6..5b08a4f45 100644 --- a/projects/skills/src/app/rating/general/general.component.ts +++ b/projects/skills/src/app/rating/general/general.component.ts @@ -11,7 +11,7 @@ import { SelectComponent } from "@ui/components"; import { IconComponent } from "@uilib"; import { FormBuilder, type FormGroup, ReactiveFormsModule } from "@angular/forms"; import { RatingService } from "../services/rating.service"; -import { ratingFiltersList } from "projects/core/src/consts/rating-filters"; +import { ratingFiltersList } from "projects/core/src/consts/filters/rating-filter.const"; /** * Компонент общего рейтинга пользователей diff --git a/projects/skills/src/app/skills/list/list.component.html b/projects/skills/src/app/skills/list/list.component.html index 8799a4439..a47032003 100644 --- a/projects/skills/src/app/skills/list/list.component.html +++ b/projects/skills/src/app/skills/list/list.component.html @@ -12,7 +12,13 @@ class="search__input text-body-14" /> - Найти + Найти @if (skills()) {
@@ -40,16 +46,16 @@

Назад {{ isFromTrajectoryModal() ? "К траекториям" : "Купить" }}

Ты действительно хочешь отменить подписку?

- + Отменить stars @@ -32,7 +38,13 @@ style="color: var(--black)" >

Включим автопродление подписки?

- Включить + Включить wave
@@ -90,6 +102,8 @@

--> Купить @if(subscriptionData()?.lastSubscriptionType !== null) { - + Отменить подписку } diff --git a/projects/skills/src/app/subscription/subscription.component.scss b/projects/skills/src/app/subscription/subscription.component.scss index 42a97e7e8..0deb8a78c 100644 --- a/projects/skills/src/app/subscription/subscription.component.scss +++ b/projects/skills/src/app/subscription/subscription.component.scss @@ -95,10 +95,6 @@ flex-grow: 1; } - app-button { - margin-top: auto; - } - &--primary { background-color: var(--accent); @@ -203,13 +199,8 @@ app-button { z-index: 100; - width: 200%; @include typography.body-12; - - @include responsive.apply-desktop { - width: 40%; - } } &__stars { diff --git a/projects/skills/src/app/task/complete/complete.component.html b/projects/skills/src/app/task/complete/complete.component.html index ba185f596..d949a97ea 100644 --- a/projects/skills/src/app/task/complete/complete.component.html +++ b/projects/skills/src/app/task/complete/complete.component.html @@ -44,11 +44,13 @@

ваш результат

- В меню навыков @if (res.nextTaskId) { - Следующее задание + Следующее задание } } diff --git a/projects/skills/src/app/task/subtask/subtask.component.html b/projects/skills/src/app/task/subtask/subtask.component.html index 28d5b1487..e21b147a6 100644 --- a/projects/skills/src/app/task/subtask/subtask.component.html +++ b/projects/skills/src/app/task/subtask/subtask.component.html @@ -37,7 +37,7 @@ >

} } - Продолжить wave @@ -74,7 +74,7 @@ >

} } - Продолжить wave @@ -121,7 +121,7 @@ >

} } - Продолжить wave @@ -168,7 +168,7 @@ >

} } - Продолжить wave @@ -215,7 +215,7 @@ >

} } - Продолжить wave @@ -232,7 +232,7 @@
Всё верно! Так держать
- Продолжить + Продолжить } diff --git a/projects/skills/src/app/task/subtask/subtask.component.scss b/projects/skills/src/app/task/subtask/subtask.component.scss index 4cfd0d951..89c9bb739 100644 --- a/projects/skills/src/app/task/subtask/subtask.component.scss +++ b/projects/skills/src/app/task/subtask/subtask.component.scss @@ -31,7 +31,7 @@ display: flex; justify-content: center; padding: 15px 10px 0; - border-radius: 15px 15px 0 0; + border-radius: var(--rounded-xl); transition: all 0.2s; transform: translateY(0%); diff --git a/projects/skills/src/app/trajectories/track-career/detail/info/info.component.html b/projects/skills/src/app/trajectories/track-career/detail/info/info.component.html index ed85caf5e..954f53e35 100644 --- a/projects/skills/src/app/trajectories/track-career/detail/info/info.component.html +++ b/projects/skills/src/app/trajectories/track-career/detail/info/info.component.html @@ -57,6 +57,7 @@ " >
@if (type() === "my") { >Подробнее --> Доступно в подписке Подтверждение
Назад Поехали @@ -188,16 +191,16 @@

У вас нет активной по
Назад КупитьУ вас уже есть активн
Перейти diff --git a/projects/skills/src/app/trajectories/track-career/shared/trajectory/trajectory.component.scss b/projects/skills/src/app/trajectories/track-career/shared/trajectory/trajectory.component.scss index 18d9f4270..ae0306472 100644 --- a/projects/skills/src/app/trajectories/track-career/shared/trajectory/trajectory.component.scss +++ b/projects/skills/src/app/trajectories/track-career/shared/trajectory/trajectory.component.scss @@ -261,17 +261,6 @@ gap: 5px; align-items: center; justify-content: center; - width: 100%; - margin-top: 10px; - - app-button { - width: 100%; - border-radius: 15px; - } - - @include responsive.apply-desktop { - width: 40%; - } } &__inner { @@ -396,16 +385,6 @@ &__buttons-group { display: flex; gap: 15px; - width: 100%; - - app-button { - width: 100%; - border-radius: 15px; - } - - @include responsive.apply-desktop { - width: 55%; - } } &__confirm { 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 e0def68b5..e31625742 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 @@ -21,10 +21,10 @@ import { expandElement } from "@utils/expand-element"; import { IconComponent } from "@uilib"; import { ParseBreaksPipe, ParseLinksPipe, PluralizePipe } from "@corelib"; import { Trajectory } from "projects/skills/src/models/trajectory.model"; -import { trajectoryMore } from "projects/core/src/consts/trajectoryMore"; import { HttpErrorResponse } from "@angular/common/http"; import { BreakpointObserver } from "@angular/cdk/layout"; import { map, Observable } from "rxjs"; +import { trajectoryMoreList } from "projects/core/src/consts/lists/trajectory-more-list.const"; /** * Компонент отображения карточки траектории @@ -53,7 +53,7 @@ import { map, Observable } from "rxjs"; export class TrajectoryComponent implements AfterViewInit, OnInit { @Input() trajectory!: Trajectory; protected readonly dotsArray = Array; - protected readonly trajectoryMore = trajectoryMore; + protected readonly trajectoryMore = trajectoryMoreList; router = inject(Router); trajectoryService = inject(TrajectoriesService); diff --git a/projects/skills/src/models/step.model.ts b/projects/skills/src/models/step.model.ts index 4802ab3f4..bd61e7535 100644 --- a/projects/skills/src/models/step.model.ts +++ b/projects/skills/src/models/step.model.ts @@ -30,6 +30,7 @@ export interface InfoSlide extends BaseStep { description: string; // Дополнительное описание или контекст files: string[]; // Массив URL файлов для отображения (изображения, документы) popups: Popup[]; // Всплывающие окна для отображения после просмотра + videoUrl?: string; // Ссылка для видео } /** diff --git a/projects/skills/src/styles/_colors.scss b/projects/skills/src/styles/_colors.scss index b33cc176b..4e52a6ba6 100644 --- a/projects/skills/src/styles/_colors.scss +++ b/projects/skills/src/styles/_colors.scss @@ -4,31 +4,32 @@ :root { // ACCENT - --gradient: linear-gradient(90deg, #242424 0%, #6c27ff 50%); - --gradient-mild: linear-gradient(90deg, #501d53 0.3%, #3b1f72 22%, #6326e6 100%); - --accent: #6c27ff; - --accent-dark: #{color.adjust(#6c27ff, $blackness: 20%)}; + --gradient: linear-gradient(90deg, #242424 0%, #8a63e6 50%); + --gradient-mild: linear-gradient(90deg, #501d53 0.3%, #3b1f72 22%, #8a63e6 100%); + --accent: #8a63e6; + --accent-dark: #{color.adjust(#8a63e6, $blackness: 20%)}; --accent-mild: #{color.adjust(#6c27ff, $alpha: -0.4)}; - --accent-light: #f7f3ff; + --accent-light: #9a80e6; // GOLD --gold: #f6ff8b; --gold-dark: #f7cf4d; // GRAY - --white: #fff; - --black: #332e2d; - --dark-grey: #8c888a; + --white: #fafafa; + --black: #333; + --dark-grey: #e7e7e7; --gray: #d3d3d3; --light-gray: #f9f9f9; - --gray-for-shadow: rgb(159 159 159 / 15%); --grey-button: #e5e5e5e5; + --gray-for-shadow: rgb(159 159 159 / 15%); + --medium-grey-for-outline: #eee; --grey-for-text: #a59fb9; // FUNCTIONAL - --green: #73c66e; - --light-green: #e3f8e9; - --red: #ff5151; - --red-dark: #{color.adjust(#ff5151, $blackness: 10%)}; - --light-red: #ffd2d2; + --green: #88c9a1; + --light-green: #97ecb8; + --red: #d48a9e; + --red-dark: #{color.adjust(#d48a9e, $blackness: 10%)}; + --light-red: #e8a5b7; } diff --git a/projects/social_platform/src/app/auth/login/login.component.html b/projects/social_platform/src/app/auth/login/login.component.html index 195d0a34f..d7916f0a0 100644 --- a/projects/social_platform/src/app/auth/login/login.component.html +++ b/projects/social_platform/src/app/auth/login/login.component.html @@ -8,6 +8,7 @@

Вход

Вход

placeholder="Введите почту" > @if (email | controlError: "email") { -
+
{{ errorMessage.VALIDATION_EMAIL }}
} @if (email | controlError: "required") { -
+
{{ errorMessage.VALIDATION_REQUIRED }}
} } @if (loginForm.get("password"); as password) {
- + Вход } @if (password | controlError: "required") { -
+
{{ errorMessage.VALIDATION_REQUIRED }}
}
} @if (errorWrongAuth) { -
+
{{ errorMessage.AUTH_WRONG_AUTH }}
} Вход appViewBox="0 0 18 9" > -
diff --git a/projects/social_platform/src/app/auth/login/login.component.scss b/projects/social_platform/src/app/auth/login/login.component.scss index e9212042e..7c15ef20f 100644 --- a/projects/social_platform/src/app/auth/login/login.component.scss +++ b/projects/social_platform/src/app/auth/login/login.component.scss @@ -16,7 +16,6 @@ &__forget { display: block; - margin-top: 12px; text-align: center; @include typography.body-12; @@ -27,6 +26,13 @@ @include typography.body-14; } } + + &__password { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 12px; + } } .icon { 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 fdd77f4d1..402ca5dea 100644 --- a/projects/social_platform/src/app/auth/models/user.model.ts +++ b/projects/social_platform/src/app/auth/models/user.model.ts @@ -1,6 +1,7 @@ /** @format */ import { Project } from "@models/project.model"; +import { FileModel } from "@office/models/file.model"; import { Skill } from "@office/models/skill"; import { Program } from "@office/program/models/program.model"; @@ -24,6 +25,8 @@ export class Achievement { id!: number; title!: string; status!: string; + year!: number; + files!: string[] | FileModel[]; } export class Education { @@ -58,6 +61,7 @@ export class User { birthday!: string; avatar!: string; links!: string[]; + coverImageAddress?: string; keySkills!: string[]; skills!: Skill[]; skillsIds!: number[]; diff --git a/projects/social_platform/src/app/auth/register/register.component.html b/projects/social_platform/src/app/auth/register/register.component.html index 764eebca0..aff9c2b9f 100644 --- a/projects/social_platform/src/app/auth/register/register.component.html +++ b/projects/social_platform/src/app/auth/register/register.component.html @@ -32,6 +32,7 @@

@if (credsSubmitInitiated) { @if (email | controlError: "required") { -
+
{{ errorMessage.VALIDATION_REQUIRED }}
} @if (email | controlError: "email") { -
+
{{ errorMessage.VALIDATION_EMAIL }}
} @@ -56,6 +57,7 @@

} @if (credsSubmitInitiated) { @if (password | controlError: "required") { -
+
{{ errorMessage.VALIDATION_REQUIRED }}
} @if (password | controlError: "passwordTooShort") { -
+
@if (password.errors) { Пароль должен содержать минимум {{ password.errors["passwordTooShort"]["requiredLength"] }} символов }
} @if (password | controlError: "passwordNoUppercase") { -
+
Пароль должен содержать минимум одну заглавную букву (A-Z)
} @if (password | controlError: "passwordNoLowercase") { -
+
Пароль должен содержать минимум одну строчную букву (a-z)
} @if (password | controlError: "passwordNoNumber") { -
Пароль должен содержать минимум одну цифру (0-9)
+
Пароль должен содержать минимум одну цифру (0-9)
} @if (password | controlError: "passwordNoSpecialChar") { -
+
Пароль должен содержать минимум один специальный символ
} @if (password | controlError: "passwordHasSpaces") { -
Пароль не должен содержать пробелы
+
Пароль не должен содержать пробелы
} @if (password | controlError: "passwordHasSequence") { -
+
Пароль не должен содержать последовательности символов (123456, abcdef и т.д.)
} @if (password | controlError: "passwordHasRepeating") { -
+
Пароль не должен содержать более 2 одинаковых символов подряд
} @if (password | controlError: "unMatch") { -
+
{{ errorMessage.VALIDATION_PASSWORD_UNMATCH }}
} @@ -170,6 +173,7 @@

@if (infoSubmitInitiated) { @if (name | controlError: "required") { -
+
{{ errorMessage.VALIDATION_REQUIRED }}
} @if (name | controlError: "invalidLanguage") { -
+
{{ errorMessage.VALIDATION_LANGUAGE }}
} @@ -194,6 +198,7 @@

@if (infoSubmitInitiated) { @if (surname | controlError: "required") { -
+
{{ errorMessage.VALIDATION_REQUIRED }}
} @if (surname | controlError: "invalidLanguage") { -
+
{{ errorMessage.VALIDATION_LANGUAGE }}
} @@ -220,6 +225,7 @@

@if (infoSubmitInitiated) { @if (birthday | controlError: "required") { -
+
{{ errorMessage.VALIDATION_REQUIRED }}
} @if (birthday | controlError: "tooYoung") { -
+
@if (birthday.errors) { {{ errorMessage.MINIMAL_AGE }} {{ birthday.errors["tooYoung"]["requiredAge"] }} лет }
} @if (birthday | controlError: "tooOld") { -
+
@if (birthday.errors) { {{ errorMessage.MAXIMAL_AGE }} {{ birthday.errors["tooOld"]["requiredAge"] }} лет }
} @if (birthday | controlError: "invalidDateFormat") { -
+
{{ errorMessage.INVALID_DATE }}
} @@ -256,7 +262,7 @@

} } @if (serverErrors) { -
+
@for (e of serverErrors; track $index) {

{{ e }}

} @@ -280,6 +286,7 @@

>

opacity: !(ageAgreement && registerAgreement) ? '0.6' : '1', cursor: !(ageAgreement && registerAgreement) ? 'not-allowed' : 'pointer' }" - customTypographyClass="auth__button-typography" > Далее } @else if (step === "info") { - + Создать аккаунт } diff --git a/projects/social_platform/src/app/auth/register/register.component.scss b/projects/social_platform/src/app/auth/register/register.component.scss index dd0e54267..7606a9e60 100644 --- a/projects/social_platform/src/app/auth/register/register.component.scss +++ b/projects/social_platform/src/app/auth/register/register.component.scss @@ -90,3 +90,11 @@ color: var(--dark-grey); cursor: pointer; } + +.error { + color: var(--red) !important; + + i { + color: var(--red) !important; + } +} diff --git a/projects/social_platform/src/app/auth/reset-password/reset-password.component.html b/projects/social_platform/src/app/auth/reset-password/reset-password.component.html index 54a2eaa70..2ea805a9c 100644 --- a/projects/social_platform/src/app/auth/reset-password/reset-password.component.html +++ b/projects/social_platform/src/app/auth/reset-password/reset-password.component.html @@ -3,10 +3,11 @@

Забыли пароль?

-

Чтобы сбросить пароль, введите свой электронный адрес.

+

Чтобы сбросить пароль, введите свой электронный адрес.

@if (resetForm.get("email"); as email) {
Забыли пароль?

placeholder="Введите почту" > @if (email | controlError: "email") { -
+
{{ errorMessage.VALIDATION_EMAIL }}
} @if (email | controlError: "required") { -
+
{{ errorMessage.VALIDATION_REQUIRED }}
} @if (errorServer) { -
Аккаунт с таким email не зарегистрирован
+
Аккаунт с таким email не зарегистрирован
}
} Отправить 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 167276827..984a0e909 100644 --- a/projects/social_platform/src/app/auth/services/profile.service.ts +++ b/projects/social_platform/src/app/auth/services/profile.service.ts @@ -29,6 +29,10 @@ export class ProfileService { constructor(private apiService: ApiService) {} + getAchievements(): Observable { + return this.apiService.get(`${this.AUTH_USERS_URL}/achievements/`); + } + addAchievement(achievement: Omit): Observable { return this.apiService .post(`${this.AUTH_USERS_URL}/achievements/`, achievement) diff --git a/projects/social_platform/src/app/auth/set-password/set-password.component.html b/projects/social_platform/src/app/auth/set-password/set-password.component.html index f75e4599d..fcacf7d15 100644 --- a/projects/social_platform/src/app/auth/set-password/set-password.component.html +++ b/projects/social_platform/src/app/auth/set-password/set-password.component.html @@ -2,11 +2,12 @@
-

Новый пароль

-

Введите новый пароль

+

Новый пароль

+

Введите новый пароль

@if (passwordForm.get("password"); as password) {
Новый пароль

@if (credsSubmitInitiated) { @if (password | controlError: "required") { -
+
{{ errorMessage.VALIDATION_REQUIRED }}
} @if (password | controlError: "passwordTooShort") { -
+
@if (password.errors) { Пароль должен содержать минимум {{ password.errors["passwordTooShort"]["requiredLength"] }} символов }
} @if (password | controlError: "passwordNoUppercase") { -
+
Пароль должен содержать минимум одну заглавную букву (A-Z)
} @if (password | controlError: "passwordNoLowercase") { -
+
Пароль должен содержать минимум одну строчную букву (a-z)
} @if (password | controlError: "passwordNoNumber") { -
Пароль должен содержать минимум одну цифру (0-9)
+
Пароль должен содержать минимум одну цифру (0-9)
} @if (password | controlError: "passwordNoSpecialChar") { -
+
Пароль должен содержать минимум один специальный символ
} @if (password | controlError: "passwordHasSpaces") { -
Пароль не должен содержать пробелы
+
Пароль не должен содержать пробелы
} @if (password | controlError: "passwordHasSequence") { -
+
Пароль не должен содержать последовательности символов (123456, abcdef и т.д.)
} @if (password | controlError: "passwordHasRepeating") { -
+
Пароль не должен содержать более 2 одинаковых символов подряд
} @if (password | controlError: "unMatch") { -
+
{{ errorMessage.VALIDATION_PASSWORD_UNMATCH }}
} @@ -84,6 +85,7 @@

Новый пароль

} @if (passwordForm.get("passwordRepeated"); as passwordRepeated) {
Новый пароль

@if (credsSubmitInitiated) { @if (passwordRepeated | controlError: "required") { -
+
{{ errorMessage.VALIDATION_REQUIRED }}
} @if (passwordRepeated | controlError: "passwordTooShort") { -
+
@if (passwordRepeated.errors) { Пароль должен содержать минимум {{ passwordRepeated.errors["passwordTooShort"]["requiredLength"] }} символов }
} @if (passwordRepeated | controlError: "passwordNoUppercase") { -
+
Пароль должен содержать минимум одну заглавную букву (A-Z)
} @if (passwordRepeated | controlError: "passwordNoLowercase") { -
+
Пароль должен содержать минимум одну строчную букву (a-z)
} @if (passwordRepeated | controlError: "passwordNoNumber") { -
Пароль должен содержать минимум одну цифру (0-9)
+
Пароль должен содержать минимум одну цифру (0-9)
} @if (passwordRepeated | controlError: "passwordNoSpecialChar") { -
+
Пароль должен содержать минимум один специальный символ
} @if (passwordRepeated | controlError: "passwordHasSpaces") { -
Пароль не должен содержать пробелы
+
Пароль не должен содержать пробелы
} @if (passwordRepeated | controlError: "passwordHasSequence") { -
+
Пароль не должен содержать последовательности символов (123456, abcdef и т.д.)
} @if (passwordRepeated | controlError: "passwordHasRepeating") { -
+
Пароль не должен содержать более 2 одинаковых символов подряд
} @if (passwordRepeated | controlError: "unMatch") { -
+
{{ errorMessage.VALIDATION_PASSWORD_UNMATCH }}
} @@ -135,10 +137,18 @@

Новый пароль

}
} @if (errorRequest) { -
+
{{ errorMessage.AUTH_WRONG_AUTH }}
} - Готово + + Готово +
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 96df24d35..304f7559c 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 @@ -41,9 +41,14 @@ export class UserLinksPipe implements PipeTransform { value.includes("api.selcdn.ru/v1") ) { const valueTrimed = value.replace(/^https?:\/\//, ""); + const iconName = + value.includes("procollab_media") || value.includes("api.selcdn.ru/v1") + ? "file" + : value.includes("@") + ? "mail" + : "link"; return { - iconName: - value.includes("procollab_media") || value.includes("api.selcdn.ru/v1") ? "file" : "link", + iconName, tag: valueTrimed, }; } 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 b937ad447..b4c679926 100644 --- a/projects/social_platform/src/app/error/models/error-message.ts +++ b/projects/social_platform/src/app/error/models/error-message.ts @@ -35,11 +35,13 @@ export enum ErrorMessage { VALIDATION_LANGUAGE = "Используйте символы кириллического алфавита", VALIDATION_EMAIL = "Введенное значение не соответствует формату email", VALIDATION_PASSWORD_UNMATCH = "Пароли не совпадают", - EMPTY_AVATAR = "*Выберите фото для профиля", + EMPTY_AVATAR = "Выберите фото для профиля", VALIDATION_PATTERN = "Введите корректную ссылку, начинающуюся с http:// или https://", // Ошибки приглашений в проект USER_NOT_EXISTING = "По данной ссылке пользователь не найден", + USER_IS_LEADER = "Пользователь является лидером проекта", + USER_IS_MEMBER = "Пользователь уже является участником проекта", VALIDATION_PROFILE_LINK = "Введенное значение не соответствует формату ссылки на профиль", // Ошибки оценки проектов diff --git a/projects/social_platform/src/app/office/chat/chat-direct/chat-direct/chat-direct.component.html b/projects/social_platform/src/app/office/chat/chat-direct/chat-direct/chat-direct.component.html index d93b657f2..1172d12a8 100644 --- a/projects/social_platform/src/app/office/chat/chat-direct/chat-direct/chat-direct.component.html +++ b/projects/social_platform/src/app/office/chat/chat-direct/chat-direct/chat-direct.component.html @@ -1,7 +1,7 @@
- +
@if (chat) { diff --git a/projects/social_platform/src/app/office/chat/chat-direct/chat-direct/chat-direct.component.scss b/projects/social_platform/src/app/office/chat/chat-direct/chat-direct/chat-direct.component.scss index 42ee3c95b..0923e83ff 100644 --- a/projects/social_platform/src/app/office/chat/chat-direct/chat-direct/chat-direct.component.scss +++ b/projects/social_platform/src/app/office/chat/chat-direct/chat-direct/chat-direct.component.scss @@ -15,7 +15,7 @@ flex-grow: 1; overflow: hidden; border: 1px solid var(--medium-grey-for-outline); - border-radius: 15px; + border-radius: var(--rounded-xl); } &__header { 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 77c8d9ad3..c570f6086 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 @@ -7,11 +7,12 @@ import { ChatItem } from "@office/chat/models/chat-item.model"; import { ChatService } from "@services/chat.service"; import { ChatMessage } from "@models/chat-message.model"; import { ChatDirectService } from "@office/chat/services/chat-direct.service"; -import { ChatWindowComponent } from "@office/shared/chat-window/chat-window.component"; +import { ChatWindowComponent } from "@office/features/chat-window/chat-window.component"; import { AuthService } from "@auth/services"; import { AvatarComponent } from "@ui/components/avatar/avatar.component"; import { ApiPagination } from "@models/api-pagination.model"; import { BarComponent } from "@ui/components"; +import { BackComponent } from "@uilib"; /** * Компонент для отображения конкретного прямого чата @@ -32,7 +33,7 @@ import { BarComponent } from "@ui/components"; templateUrl: "./chat-direct.component.html", styleUrl: "./chat-direct.component.scss", standalone: true, - imports: [RouterLink, AvatarComponent, ChatWindowComponent, BarComponent], + imports: [RouterLink, AvatarComponent, ChatWindowComponent, BarComponent, BackComponent], }) export class ChatDirectComponent implements OnInit, OnDestroy { constructor( diff --git a/projects/social_platform/src/app/office/chat/chat.component.html b/projects/social_platform/src/app/office/chat/chat.component.html index de236f37c..7fc7bacef 100644 --- a/projects/social_platform/src/app/office/chat/chat.component.html +++ b/projects/social_platform/src/app/office/chat/chat.component.html @@ -2,9 +2,11 @@
+ + + +
+ {{ skill.name }} +
+ @if (skill.approves.length > 0) { + + } + + {{ isUserApproveSkill(skill, loggedUserId!) ? "убрать оценку" : "подтвердить" }} +
+
+ + + + diff --git a/projects/social_platform/src/app/office/features/approve-skill/approve-skill.component.scss b/projects/social_platform/src/app/office/features/approve-skill/approve-skill.component.scss new file mode 100644 index 000000000..203d7b070 --- /dev/null +++ b/projects/social_platform/src/app/office/features/approve-skill/approve-skill.component.scss @@ -0,0 +1,20 @@ +.approve { + &__skill { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px; + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-xl); + } + + &__text { + color: var(--dark-grey); + } + + &__approves { + display: flex; + align-items: center; + justify-content: space-between; + } +} diff --git a/projects/social_platform/src/app/office/features/approve-skill/approve-skill.component.ts b/projects/social_platform/src/app/office/features/approve-skill/approve-skill.component.ts new file mode 100644 index 000000000..3a1db27d9 --- /dev/null +++ b/projects/social_platform/src/app/office/features/approve-skill/approve-skill.component.ts @@ -0,0 +1,128 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { ChangeDetectorRef, Component, inject, Input, OnDestroy, OnInit } from "@angular/core"; +import { PluralizePipe } from "@corelib"; +import { Skill } from "@office/models/skill"; +import { AvatarComponent } from "@ui/components/avatar/avatar.component"; +import { ButtonComponent } from "@ui/components"; +import { map, of, Subscription, switchMap } from "rxjs"; +import { AuthService } from "@auth/services"; +import { ActivatedRoute } from "@angular/router"; +import { ProfileService } from "projects/skills/src/app/profile/services/profile.service"; +import { ProfileService as profileApproveSkillService } from "@auth/services/profile.service"; +import { SnackbarService } from "@ui/services/snackbar.service"; +import { HttpErrorResponse } from "@angular/common/http"; +import { ModalComponent } from "@ui/components/modal/modal.component"; +import { ApproveSkillPeopleComponent } from "@office/shared/approve-skill-people/approve-skill-people.component"; + +/** + * @params skill - информация о навыке (обязательно) + * + * Компонент на основе полученных данных о навыке + * выполняет логику подтверждения навыка + * с помощью сервисов связанных с навыками пользователя + */ +@Component({ + selector: "app-approve-skill", + styleUrl: "./approve-skill.component.scss", + templateUrl: "./approve-skill.component.html", + standalone: true, + imports: [ + CommonModule, + AvatarComponent, + PluralizePipe, + ButtonComponent, + ModalComponent, + ApproveSkillPeopleComponent, + ], +}) +export class ApproveSkillComponent implements OnInit, OnDestroy { + private readonly authService = inject(AuthService); + private readonly route = inject(ActivatedRoute); + private readonly profileApproveSkillService = inject(profileApproveSkillService); + private readonly snackbarService = inject(SnackbarService); + private readonly cdRef = inject(ChangeDetectorRef); + + // Указатель на то что пользватель подтвердил навык + isUserApproveSkill(skill: Skill, profileId: number): boolean { + return skill.approves.some(approve => approve.confirmedBy.id === profileId); + } + + // id пользователя за которого мы зарегистрировались + loggedUserId?: number; + + // переменные для работы с модальным окном для вывода ошибки с подтверждением своего навыка + approveOwnSkillModal = false; + + subscriptions: Subscription[] = []; + + // Получение данных о конкретном навыке + @Input({ required: true }) skill!: Skill; + + ngOnInit(): void { + const profileIdDataSub$ = this.authService.profile.pipe().subscribe({ + next: profile => { + this.loggedUserId = profile.id; + }, + }); + + this.subscriptions.push(profileIdDataSub$); + } + + ngOnDestroy(): void { + this.subscriptions.forEach($ => $.unsubscribe()); + } + + /** + * Подтверждение или отмена подтверждения навыка пользователя + * @param skillId - идентификатор навыка + * @param event - событие клика для предотвращения всплытия + * @param skill - объект навыка для обновления + */ + onToggleApprove(skillId: number, event: Event, skill: Skill, profileId: number) { + event.stopPropagation(); + const userId = this.route.snapshot.params["id"]; + + const isApprovedByCurrentUser = skill.approves.some(approve => { + return approve.confirmedBy.id === profileId; + }); + + if (isApprovedByCurrentUser) { + this.profileApproveSkillService.unApproveSkill(userId, skillId).subscribe(() => { + skill.approves = skill.approves.filter(approve => approve.confirmedBy.id !== profileId); + this.cdRef.markForCheck(); + }); + } else { + this.profileApproveSkillService + .approveSkill(userId, skillId) + .pipe( + switchMap(newApprove => + newApprove.confirmedBy + ? of(newApprove) + : this.authService.profile.pipe( + map(profile => ({ + ...newApprove, + confirmedBy: profile, + })) + ) + ) + ) + .subscribe({ + next: updatedApprove => { + skill.approves = [...skill.approves, updatedApprove]; + this.snackbarService.success("вы подтвердили навык"); + this.cdRef.markForCheck(); + }, + error: err => { + if (err instanceof HttpErrorResponse) { + if (err.status === 400) { + this.approveOwnSkillModal = true; + this.cdRef.markForCheck(); + } + } + }, + }); + } + } +} diff --git a/projects/social_platform/src/app/office/shared/chat-window/chat-window.component.html b/projects/social_platform/src/app/office/features/chat-window/chat-window.component.html similarity index 92% rename from projects/social_platform/src/app/office/shared/chat-window/chat-window.component.html rename to projects/social_platform/src/app/office/features/chat-window/chat-window.component.html index ec7e84ec8..733a30c2a 100644 --- a/projects/social_platform/src/app/office/shared/chat-window/chat-window.component.html +++ b/projects/social_platform/src/app/office/features/chat-window/chat-window.component.html @@ -18,9 +18,10 @@ *cdkVirtualFor="let message of messages" > +
@if (typingPersons.length) { - + @for (person of typingPersons.slice(0, 3); let last = $last; track person.userId) { {{ person.firstName }} {{ person.lastName }} @if (!last) { , } } @if (typingPersons.length > 3) { и еще {{ typingPersons.length - 3 }} @@ -36,7 +37,7 @@ (resize)="onInputResize()" (cancel)="onCancelInput()" (submit)="onSubmitMessage()" - placeholder="Введите сообщение" + placeholder="сегодня был хороший день" formControlName="messageControl" > diff --git a/projects/social_platform/src/app/office/shared/chat-window/chat-window.component.scss b/projects/social_platform/src/app/office/features/chat-window/chat-window.component.scss similarity index 62% rename from projects/social_platform/src/app/office/shared/chat-window/chat-window.component.scss rename to projects/social_platform/src/app/office/features/chat-window/chat-window.component.scss index 851b5a84e..f0f3f9384 100644 --- a/projects/social_platform/src/app/office/shared/chat-window/chat-window.component.scss +++ b/projects/social_platform/src/app/office/features/chat-window/chat-window.component.scss @@ -14,7 +14,11 @@ flex-grow: 1; max-width: 100%; overflow-y: auto; - background-color: var(--white); + border-top: 0.5px solid var(--medium-grey-for-outline); + border-right: 0.5px solid var(--medium-grey-for-outline); + border-left: 0.5px solid var(--medium-grey-for-outline); + border-top-left-radius: var(--rounded-xl); + border-top-right-radius: var(--rounded-xl); } &__message { @@ -30,9 +34,4 @@ margin-bottom: 4px; color: var(--dark-grey); } - - &__input { - padding: 0 18px 10px; - background-color: var(--white); - } } diff --git a/projects/social_platform/src/app/office/shared/chat-window/chat-window.component.spec.ts b/projects/social_platform/src/app/office/features/chat-window/chat-window.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/office/shared/chat-window/chat-window.component.spec.ts rename to projects/social_platform/src/app/office/features/chat-window/chat-window.component.spec.ts diff --git a/projects/social_platform/src/app/office/shared/chat-window/chat-window.component.ts b/projects/social_platform/src/app/office/features/chat-window/chat-window.component.ts similarity index 99% rename from projects/social_platform/src/app/office/shared/chat-window/chat-window.component.ts rename to projects/social_platform/src/app/office/features/chat-window/chat-window.component.ts index c6a919b04..bef4f4a28 100644 --- a/projects/social_platform/src/app/office/shared/chat-window/chat-window.component.ts +++ b/projects/social_platform/src/app/office/features/chat-window/chat-window.component.ts @@ -17,7 +17,7 @@ import { CdkVirtualScrollViewport, } from "@angular/cdk/scrolling"; import { ChatMessage } from "@models/chat-message.model"; -import { MessageInputComponent } from "@office/shared/message-input/message-input.component"; +import { MessageInputComponent } from "@office/features/message-input/message-input.component"; import { FormBuilder, FormGroup, ReactiveFormsModule } from "@angular/forms"; import { filter, fromEvent, noop, skip, Subscription, tap, throttleTime } from "rxjs"; import { ModalService } from "@ui/models/modal.service"; diff --git a/projects/social_platform/src/app/office/features/detail/detail.component.html b/projects/social_platform/src/app/office/features/detail/detail.component.html new file mode 100644 index 000000000..53c24ecae --- /dev/null +++ b/projects/social_platform/src/app/office/features/detail/detail.component.html @@ -0,0 +1,645 @@ + + +
+
+ @if (info()) { +
+
+ cover +
+ @if (chatService.userOnlineStatusCache | async; as cache) { + + @if (listType === 'project' || listType === 'profile') { +

+ {{ + listType === "project" + ? info().name + : listType === "profile" + ? info().firstName + " " + info().lastName + : "" + }} +

+ } } +
+
+ +
+
+ +
+ + + @if (userType() !== undefined) { @if (!isUserMember && !isUserManager) { @if + (info().name.includes("Кейс-чемпионат MIR")) { + + + зарегистрироваться + + + } @else if (info().registrationLink) { + + + зарегистрироваться + + + } @else { + + + зарегистрироваться + + + } } @if (isUserMember && !isUserManager && !isUserExpert) { + + подать проект + + + } @if ((isUserManager || isUserExpert) && isUserMember) { + + + оценка проектов + + + } + + + + {{ + info().name.includes("Технолидеры Будущего") + ? "Каталог лучших стартапов 2022/23" + : isUserManager + ? "аналитика" + : "положение" + }} + @if (isUserManager) { + + } @else { + + } + + + +
+ + @if (isUserManager || isUserExpert) { + + + проекты-участники + + + } @else { + + + узнать подробнее + + + } @if (isUserManager || isUserExpert) { + + + участники + + + } @else { + + + информация с ссылок + + + } } + + +
+
+

+ {{ + memberProjects.length > 0 ? "выберите проект для подачи" : "создай свой проект!" + }} +

+

+ {{ + memberProjects.length > 0 + ? "после выбора проекта будет создан дубликат данного проекта для заполнения под конкретный конкурс" + : "создай проект и не забудь вернуться в программу для его подачи" + }} +

+ +
+
    + @for (project of memberProjects; track project.id) { +
  • +
    + +

    + {{ project.name }} +

    +
    + +
  • + } +
+
+
+ +
+ @if (memberProjects.length > 0) { + + выбрать проект + + +

или

+ } + + создать новый проект +
+
+
+ + +
+
+

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

+
+ +

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

+ + + вперед + +
+
+
+ + + @if (isInProject) { + + рабочая зона + + } @else { + + + презентация + + + + } @if (!isInProject) { + + написать + + } @else { + + чат проекта + + } + +
+ + + команда + + + @if (isInProject) { @if (profile) { @if (profile.id === info().leader) { + + + редактировать + + + }@else { + + выйти из проекта + + } } + + +
+ + idea +

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

+ +

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

+ + хорошо +
+
+ + +
+
+

+ вы уверены, что хотите покинуть команду +

+ +
+ +
+ + покинуть команду + + + + остаться + +
+
+
+ } @else { + + вакансии + + } +
+ + + @if (profile) { @if (+profile.id === +info().id) { + продвигать + } @else { + подтвердить навыки + } @if (+profile.id === +info().id) { + мои проекты + } @else { + поделиться профилем + } + +
+ + @if (+profile.id === +info().id) { + cкачать CV + } @else { + пригласить + } @if (+profile.id !== +info().id) { + + написать + + } @else { + + + редактировать + + + } + + + @if (info().skills.length) { +
    +
    +

    подтвердить владение навыком

    + +
    + + @for (skill of info().skills; track skill.id) { +
  • + +
  • + } +
+ } +
+ } @if (profile) { @if (profile.id === info().id) { + +
+ profile unfill image +
+ +

+ Заполни все поля, чтобы использовать Procollab на максимум +

+
+

заполни все поля, чтобы иметь сильное резюме

+ + + продолжить заполнение + +
+
+ } } + + +
+
+ +

Повторите загрузку позже

+
+

+ Скачивание будет доступно через {{ errorMessageModal() }} секунд. +

+
+
+ + +
+
+ +

твое CV уже ждет тебя на почте :)

+
+

+ кстати, оно часто залетает в папку «Спам» — обязательно проверь и там тоже.
+ Технические сложности? Мы всегда на связи в Telegram — {{ "@procollab_support" }} +

+
+
+
+ + +
+
+ } +
+
diff --git a/projects/social_platform/src/app/office/features/detail/detail.component.scss b/projects/social_platform/src/app/office/features/detail/detail.component.scss new file mode 100644 index 000000000..00c844ea1 --- /dev/null +++ b/projects/social_platform/src/app/office/features/detail/detail.component.scss @@ -0,0 +1,295 @@ +/** @format */ + +@use "styles/responsive"; +@use "styles/typography"; + +$detail-bar-height: 63px; +$detail-bar-mb: 12px; + +.detail { + display: flex; + flex-direction: column; + height: 100%; + max-height: 100%; + padding-top: 20px; + overflow-y: scroll; + + &__body { + flex-grow: 1; + max-height: calc(100% - #{$detail-bar-height} - #{$detail-bar-mb}); + padding-bottom: 12px; + } +} + +.info { + $body-slide: 15px; + + position: relative; + padding: 0; + background-color: transparent; + border: none; + border-radius: $body-slide; + + &__cover { + position: relative; + height: 136px; + border-radius: 15px 15px 0 0; + + &--program { + height: 155px; + } + + img { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; + + &--program { + object-fit: contain; + object-position: top; + } + } + } + + &__body { + position: relative; + z-index: 2; + } + + &__avatar { + position: absolute; + bottom: -10px; + left: 50%; + z-index: 100; + display: block; + cursor: pointer; + background-color: var(--white); + border-radius: 50%; + + &--program { + bottom: 15px; + } + + @include responsive.apply-desktop { + transform: translate(-50%, 50%); + } + } + + &__row { + display: flex; + gap: 20px; + align-items: center; + justify-content: center; + margin-top: 2px; + + @include responsive.apply-desktop { + justify-content: unset; + margin-top: 0; + } + } + + &__title { + margin-top: 10px; + overflow: hidden; + color: var(--black); + text-align: center; + text-overflow: ellipsis; + + &--project { + transform: translateX(-31%); + } + } + + &__text { + color: var(--dark-grey); + } + + &__actions { + display: grid; + grid-template-columns: 2fr 2fr 2fr 2fr 2fr; + gap: 20px; + align-items: center; + padding: 24px 0 30px; + } + + &__presentation { + display: block; + + i { + margin-left: 15px; + color: var(--accent); + } + + app-tooltip { + position: absolute; + right: -7%; + bottom: 32%; + z-index: 1000; + } + } + + &__edit { + display: block; + } +} + +.lists { + &__section { + display: flex; + justify-content: space-between; + margin-bottom: 8px; + border-bottom: 0.5px solid var(--accent); + } + + &__icon { + color: var(--accent); + } + + &__title { + margin-bottom: 8px; + color: var(--accent); + } +} + +.project { + &__list { + display: flex; + flex-direction: column; + gap: 10px; + align-items: center; + max-height: none; + + &--scrollable { + max-height: 180px; + overflow-y: auto; + } + } + + &__item { + display: flex; + flex-grow: 1; + align-items: center; + justify-content: space-between; + width: 300px; + margin-bottom: 12px; + margin-left: 40px; + + &--info { + display: flex; + gap: 20px; + align-items: center; + } + } +} + +.approve { + min-width: 420px; + padding: 24px; +} + +.cancel { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + max-height: calc(100vh - 40px); + padding: 24px 54px; + + &__top { + display: flex; + flex-direction: column; + gap: 10px; + align-items: center; + margin-bottom: 10px; + } + + &__title { + display: flex; + text-align: center; + } + + &__text { + width: 40%; + margin-bottom: 10px; + color: var(--dark-grey); + text-align: center; + } + + &__buttons { + display: flex; + gap: 10px; + align-items: center; + margin-top: 20px; + } + + &__button { + margin-top: 20px; + } + + &__cross { + position: absolute; + top: 15px; + right: 15px; + z-index: 2; + cursor: pointer; + } +} + +.support { + padding-bottom: 110px; + + @include responsive.apply-desktop { + width: 470px; + } + + &__cross { + position: absolute; + top: 15px; + right: 15px; + z-index: 2; + cursor: pointer; + } + + &__img { + position: absolute; + right: 0; + bottom: 0; + height: 195px; + + @include responsive.apply-desktop { + height: unset; + } + } + + &__title { + color: var(--black); + text-align: center; + + @include typography.bold-body-16; + + @include responsive.apply-desktop { + @include typography.heading-4; + } + } + + &__text { + margin: 20px 0; + color: var(--black); + + @include responsive.apply-desktop { + max-width: 260px; + } + } +} + +.approves { + &__text { + color: var(--black); + white-space: nowrap; + } +} diff --git a/projects/social_platform/src/app/office/features/detail/detail.component.ts b/projects/social_platform/src/app/office/features/detail/detail.component.ts new file mode 100644 index 000000000..13ebc4198 --- /dev/null +++ b/projects/social_platform/src/app/office/features/detail/detail.component.ts @@ -0,0 +1,467 @@ +/** @format */ + +import { CommonModule, Location } from "@angular/common"; +import { ChangeDetectorRef, Component, inject, OnDestroy, OnInit, signal } from "@angular/core"; +import { ButtonComponent, InputComponent } from "@ui/components"; +import { BackComponent, IconComponent } from "@uilib"; +import { ModalComponent } from "@ui/components/modal/modal.component"; +import { ActivatedRoute, Router, RouterModule } from "@angular/router"; +import { AuthService } from "@auth/services"; +import { AvatarComponent } from "@ui/components/avatar/avatar.component"; +import { TooltipComponent } from "@ui/components/tooltip/tooltip.component"; +import { concatMap, filter, map, of, Subscription, tap } from "rxjs"; +import { User } from "@auth/models/user.model"; +import { Collaborator } from "@office/models/collaborator.model"; +import { ProjectService } from "@office/services/project.service"; +import { Project } from "@office/models/project.model"; +import { HttpErrorResponse } from "@angular/common/http"; +import { ProjectAssign } from "@office/projects/models/project-assign.model"; +import { ProjectAdditionalService } from "@office/projects/edit/services/project-additional.service"; +import { ProjectDataService } from "@office/projects/detail/services/project-data.service"; +import { ProgramDataService } from "@office/program/services/program-data.service"; +import { ChatService } from "@office/services/chat.service"; +import { calculateProfileProgress } from "@utils/calculateProgress"; +import { ProfileDataService } from "@office/profile/detail/services/profile-date.service"; +import { ProfileService } from "projects/skills/src/app/profile/services/profile.service"; +import { SnackbarService } from "@ui/services/snackbar.service"; +import { ApproveSkillComponent } from "../approve-skill/approve-skill.component"; +import { ProjectsService } from "@office/projects/services/projects.service"; + +@Component({ + selector: "app-detail", + templateUrl: "./detail.component.html", + styleUrl: "./detail.component.scss", + imports: [ + CommonModule, + RouterModule, + IconComponent, + ButtonComponent, + BackComponent, + ModalComponent, + AvatarComponent, + TooltipComponent, + InputComponent, + ApproveSkillComponent, + ], + standalone: true, +}) +export class DeatilComponent implements OnInit, OnDestroy { + private readonly authService = inject(AuthService); + private readonly route = inject(ActivatedRoute); + private readonly projectService = inject(ProjectService); + private readonly programDataService = inject(ProgramDataService); + private readonly projectDataService = inject(ProjectDataService); + private readonly projectAdditionalService = inject(ProjectAdditionalService); + private readonly snackbarService = inject(SnackbarService); + private readonly router = inject(Router); + private readonly location = inject(Location); + private readonly profileDataService = inject(ProfileDataService); + public readonly skillsProfileService = inject(ProfileService); + private readonly projectsService = inject(ProjectsService); + public readonly chatService = inject(ChatService); + private readonly cdRef = inject(ChangeDetectorRef); + + // Основные данные(типы данных, данные) + info = signal(undefined); + profile?: User; + listType: "project" | "program" | "profile" = "project"; + + // Переменная для подсказок + isTooltipVisible = false; + + tooltipText = "Заполни до конца — и открой весь функционал платформы!"; + + // Переменные для отображения данных в зависимости от url + isProjectsPage = false; + isMembersPage = false; + isProjectsRatingPage = false; + + isTeamPage = false; + isVacanciesPage = false; + isProjectChatPage = false; + + // Сторонние переменные для работы с роутингом или доп проверок + backPath?: string; + registerDateExpired?: boolean; + isInProject?: boolean; + + isSended = false; + isSubscriptionActive = signal(false); + isProfileFill = false; + + // Переменные для работы с модалкой подачи проекта + selectedProjectId = 0; + dubplicatedProjectId = 0; + memberProjects: Project[] = []; + + userType = signal(undefined); + + // Сигналы для работы с модальными окнами с текстом + assignProjectToProgramModalMessage = signal(null); + errorMessageModal = signal(""); + + // Переменные для работы с модалками + isAssignProjectToProgramModalOpen = signal(false); + showSubmitProjectModal = signal(false); + isLeaveProjectModalOpen = false; // Флаг модального окна выхода + isEditDisable = false; // Флаг недоступности редактирования + isEditDisableModal = false; // Флаг недоступности редактирования для модалки + openSupport = false; // Флаг модального окна поддержки + leaderLeaveModal = false; // Флаг модального окна предупреждения лидера + isDelayModalOpen = false; + + // Переменные для работы с подтверждением навыков + showApproveSkillModal = false; + readAllModal = false; + + subscriptions: Subscription[] = []; + + ngOnInit(): void { + const listTypeSub$ = this.route.data.subscribe(data => { + this.listType = data["listType"]; + }); + + this.initializeBackPath(); + + this.updatePageStates(); + this.location.onUrlChange(url => { + this.updatePageStates(url); + }); + + this.initializeInfo(); + + this.subscriptions.push(listTypeSub$); + } + + ngOnDestroy(): void { + this.subscriptions.forEach($ => $.unsubscribe()); + } + + // Геттеры для работы с отображением данных разного типа доступа + get isUserManager() { + if (this.listType === "program") { + return this.info().isUserManager; + } + } + + get isUserMember() { + if (this.listType === "program") { + return this.info().isUserMember; + } + } + + get isUserExpert() { + const type = this.userType(); + return type !== undefined && type !== 1; + } + + // Методы для управления состоянием ошибок через сервис + setAssignProjectToProgramError(error: { non_field_errors: string[] }): void { + this.projectAdditionalService.setAssignProjectToProgramError(error); + } + + /** + * Переключатель для модалки выбора проекта + */ + toggleSubmitProjectModal(): void { + this.showSubmitProjectModal.set(!this.showSubmitProjectModal()); + + if (!this.showSubmitProjectModal()) { + this.selectedProjectId = 0; + } + } + + /** Показать подсказку */ + showTooltip(): void { + this.isTooltipVisible = true; + } + + /** Скрыть подсказку */ + hideTooltip(): void { + this.isTooltipVisible = false; + } + + /** + * Обработчик изменения радио-кнопки для выбора проекта + */ + onProjectRadioChange(event: Event): void { + const target = event.target as HTMLInputElement; + this.selectedProjectId = +target.value; + + if (this.selectedProjectId) { + this.memberProjects.find(project => project.id === this.selectedProjectId); + } + } + + /** + * Добавление проекта на программу + */ + addProjectModal(): void { + if (!this.selectedProjectId) { + return; + } + + const selectedProject = this.memberProjects.find( + project => project.id === this.selectedProjectId + ); + + this.assignProjectToProgram(selectedProject!); + } + + addNewProject(): void { + this.projectsService.addProject(); + } + + /** Эмитим логику для привязки проекта к программе */ + /** + * Привязка проекта к программе выбранной + * Перенаправление её на редактирование "нового" проекта + */ + assignProjectToProgram(project: Project): void { + if (this.info().id) { + this.projectService + .assignProjectToProgram(project.id, Number(this.route.snapshot.params["programId"])) + .subscribe({ + next: r => { + this.dubplicatedProjectId = r.newProjectId; + this.assignProjectToProgramModalMessage.set(r); + this.isAssignProjectToProgramModalOpen.set(true); + this.toggleSubmitProjectModal(); + this.selectedProjectId = 0; + }, + + error: err => { + if (err instanceof HttpErrorResponse) { + if (err.status === 400) { + this.setAssignProjectToProgramError(err.error); + } + } + }, + }); + } + } + + closeAssignProjectToProgramModal(): void { + this.isAssignProjectToProgramModalOpen.set(false); + this.router.navigateByUrl( + `/office/projects/${this.dubplicatedProjectId}/edit?editingStep=main` + ); + } + + /** + * Закрытие модального окна выхода из проекта + */ + onCloseLeaveProjectModal(): void { + this.isLeaveProjectModalOpen = false; + } + + /** + * Закрытие модального окна для невозможности редактировать проект + */ + onUnableEditingProject(): void { + if (this.isEditDisable) { + this.isEditDisableModal = true; + } else { + this.isEditDisableModal = false; + } + } + + /** + * Выход из проекта + */ + onLeave() { + this.route.data + .pipe(map(r => r["data"][0])) + .pipe(concatMap(p => this.projectService.leave(p.id))) + .subscribe( + () => { + this.router + .navigateByUrl("/office/projects/my") + .then(() => console.debug("Route changed from ProjectInfoComponent")); + }, + () => { + this.leaderLeaveModal = true; // Показываем предупреждение для лидера + } + ); + } + + /** + * Копирование ссылки на профиль в буфер обмена + */ + onCopyLink(profileId: number): void { + let fullUrl = ""; + + // Формирование URL в зависимости от типа ресурса + fullUrl = `${location.origin}/office/profile/${profileId}/`; + + // Копирование в буфер обмена + navigator.clipboard.writeText(fullUrl).then(() => { + this.snackbarService.success("скопирован URL"); + }); + } + + openSkills: any = {}; + + /** + * Открытие модального окна с информацией о подтверждениях навыка + * @param skillId - идентификатор навыка + */ + onOpenSkill(skillId: number) { + this.openSkills[skillId] = !this.openSkills[skillId]; + } + + onCloseModal(skillId: number) { + this.openSkills[skillId] = false; + } + + /** + * Отправка CV пользователя на email + * Проверяет ограничения по времени и отправляет CV на почту пользователя + */ + sendCVEmail() { + this.authService.sendCV().subscribe({ + next: () => { + this.isSended = true; + }, + error: err => { + if (err.status === 400) { + this.isDelayModalOpen = true; + this.errorMessageModal.set(err.error.seconds_after_retry); + } + }, + }); + } + + /** + * Перенаправляет на страницу с информацией в завивисимости от listType + */ + redirectDetailInfo(): void { + switch (this.listType) { + case "profile": + this.router.navigateByUrl(`/office/profile/${this.info().id}`); + break; + + case "project": + this.router.navigateByUrl(`/office/projects/${this.info().id}`); + break; + + case "program": + this.router.navigateByUrl(`/office/program/${this.info().id}`); + break; + } + } + + /** + * Обновляет состояния страниц на основе URL + */ + private updatePageStates(url?: string): void { + const currentUrl = url || this.router.url; + + this.isProjectsPage = + currentUrl.includes("/projects") && !currentUrl.includes("/projects-rating"); + + this.isMembersPage = currentUrl.includes("/members"); + + this.isProjectsRatingPage = currentUrl.includes("/projects-rating"); + + this.isTeamPage = currentUrl.includes("/team"); + this.isVacanciesPage = currentUrl.includes("/vacancies"); + this.isProjectChatPage = currentUrl.includes("/chat"); + } + + private initializeInfo() { + if (this.listType === "project") { + const projectSub$ = this.projectDataService.project$ + .pipe(filter(project => !!project)) + .subscribe(project => { + this.info.set(project); + + if (project?.partnerProgram) { + this.isEditDisable = project.partnerProgram?.isSubmitted; + } + }); + + this.isInProfileInfo(); + + this.subscriptions.push(projectSub$); + } else if (this.listType === "program") { + const program$ = this.programDataService.program$ + .pipe( + filter(program => !!program), + tap(program => { + if (program) { + this.info.set(program); + this.registerDateExpired = Date.now() > Date.parse(program.datetimeRegistrationEnds); + } + }) + ) + .subscribe(); + + const profileDataSub$ = this.authService.profile.pipe(filter(user => !!user)).subscribe({ + next: user => { + this.userType.set(user!.userType); + this.cdRef.detectChanges(); + }, + }); + + const memeberProjects$ = this.projectService.getMy().subscribe({ + next: projects => { + this.memberProjects = projects.results.filter(project => !project.draft); + }, + }); + + this.subscriptions.push(program$); + this.subscriptions.push(memeberProjects$); + this.subscriptions.push(profileDataSub$); + } else { + const profileDataSub$ = this.profileDataService + .getProfile() + .pipe( + map(user => ({ ...user, progress: calculateProfileProgress(user!) })), + filter(user => !!user) + ) + .subscribe({ + next: user => { + this.info.set(user); + this.isProfileFill = + user.progress! < 100 ? (this.isProfileFill = true) : (this.isProfileFill = false); + }, + }); + + this.isInProfileInfo(); + + this.skillsProfileService.getSubscriptionData().subscribe(r => { + this.isSubscriptionActive.set(r.isSubscribed); + }); + + this.subscriptions.push(profileDataSub$); + } + } + + private isInProfileInfo(): void { + const profileInfoSub$ = this.authService.profile.subscribe({ + next: profile => { + this.profile = profile; + + if (this.info()) { + this.isInProject = this.info() + ?.collaborators.map((person: Collaborator) => person.userId) + .includes(profile.id); + } + }, + }); + + this.subscriptions.push(profileInfoSub$); + } + + /** + * Инициализация строки для back компонента в зависимости от типа данных + */ + private initializeBackPath(): void { + if (this.listType === "project") { + this.backPath = "/office/projects/all"; + } else if (this.listType === "program") { + this.backPath = "/office/program/all"; + } + } +} diff --git a/projects/social_platform/src/app/office/features/info-card/info-card.component.html b/projects/social_platform/src/app/office/features/info-card/info-card.component.html new file mode 100644 index 000000000..27884d4b2 --- /dev/null +++ b/projects/social_platform/src/app/office/features/info-card/info-card.component.html @@ -0,0 +1,231 @@ + + +
+
+ @if (shouldShowProjectInfo()) { +
+
+

12

+ +
+
+

12

+ +
+
+ } @if (shouldShowSubscriptionBadge()) { +
+ +
+ } + + + +
+
+
+ @if (appereance === 'empty') { + + } @else { + + } +
+
+ + @if (appereance !== 'empty') { + + } + + +
+ + +
+
+ + +

{{ info?.name }}

+ arrow + @if (section === 'subscriptions') { +

перейти к проектам

+ } @else { +

создай первый проект

+ } +
+ + + @if (type === 'projects') { @if (info.name) { @if(info.name.length > 12) { +

{{ info.name.slice(0, 12) }}...

+ } @else { +

{{ info.name }}

+ } } @if (industryService.industries | async; as industries) { +

+ @if (industryService.getIndustry(industries, info?.industry!); as industry) { + {{ industry?.name }} + } +

+ } } @else { +

{{ info?.firstName }}

+

{{ info?.lastName }}

+ @if (info?.speciality) { +

+ {{ info?.speciality }} + @if (info?.speciality && info?.birthday) { • } @if (info?.birthday) { + {{ info?.birthday! | yearsFromBirthday }} + } +

+ } } +
+ + +
+ @if (type === 'invite') { +

вас приглашает

+

{{ info?.shortDescription }}

+ } @else if (type === 'projects') { +

{{ info?.shortDescription }}

+ } @else { @if (info?.skills && info?.skills?.length) { +
    + @for ( skill of info?.skills?.slice(0, 3); track skill.id ) { +
  • + {{ + skill.name.length > 10 ? skill.name.slice(0, 10) + " " + "..." : skill.name + }} +
  • + } +
+ } } +
+
+ + +
+ @if (type === 'projects') { +
+ + проект + + + @if (info.partnerProgramId) { +
+ +
+ + @if (programProjectHovered) { +
+

проект привязан к программе

+
+ } } +
+ } @else if (type === 'members') { + + профиль + + } @else { +
+ + принять + + + отклонить + +
+ } +
+
+ + + +
+

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

+ +
+ + Отписаться + + + Отменить + +
+
+
+ + +
+

Приглашение на текущий проект было удалено

+

+ Проверьте наличие вас в списке участников проекта или обратитесь к создателю проекта, чтобы + вас заново пригласили! +

+ + Хорошо + +
+
+
diff --git a/projects/social_platform/src/app/office/features/info-card/info-card.component.scss b/projects/social_platform/src/app/office/features/info-card/info-card.component.scss new file mode 100644 index 000000000..9bb411c62 --- /dev/null +++ b/projects/social_platform/src/app/office/features/info-card/info-card.component.scss @@ -0,0 +1,257 @@ +/** @format */ + +@use "styles/responsive"; + +.card { + &__body { + position: relative; + display: flex; + flex-direction: column; + width: 156px; + height: 170px; + padding: 15px 0 12px; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-xl); + + &--empty { + background: transparent; + border: 0.5px dashed var(--accent); + } + } + + &__info { + display: flex; + flex-shrink: 0; + gap: 50px; + align-items: center; + justify-content: space-evenly; + + &--vacancies, + &--collaborators { + display: flex; + gap: 4px; + align-items: center; + color: var(--grey-for-text); + } + + &--program { + display: flex; + gap: 5px; + align-items: center; + justify-content: center; + + &-icon { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 12px; + margin-top: 6px; + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); + + i { + color: var(--accent); + } + } + } + + &--project-partner { + position: absolute; + top: 67%; + left: 30%; + padding: 3px 5px; + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); + + p { + color: var(--black); + } + } + } + + &__badge { + position: absolute; + top: 30px; + left: 0; + z-index: 1; + } + + &__subscribe-badge { + display: block; + color: var(--accent); + cursor: pointer; + } + + &__photo { + position: absolute; + top: -35px; + left: 50%; + z-index: 2; + transform: translateX(-50%); + } + + &__content { + position: relative; + display: flex; + flex: 1; + flex-direction: column; + align-items: center; + justify-content: flex-start; + padding-top: 40px; + overflow: hidden; + text-align: center; + + &--empty { + padding-top: 60px; + } + } + + &__head { + display: flex; + flex-direction: column; + flex-shrink: 0; + align-items: center; + margin-bottom: 8px; + } + + &__name { + max-width: 120px; + margin-bottom: 3px; + overflow: hidden; + color: var(--black); + text-overflow: ellipsis; + white-space: nowrap; + } + + &__additional-info { + color: var(--accent); + } + + &__industry { + padding: 2px 36px; + color: var(--light-white); + background-color: var(--green); + border-radius: var(--rounded-xl); + } + + &__description { + display: flex; + flex: 1; + flex-direction: column; + align-items: center; + width: 120px; + overflow: hidden; + color: var(--dark-grey); + text-align: center; + word-break: break-word; + + &.invite-description { + justify-content: center; + } + } + + &__user { + margin-bottom: 2px; + color: var(--grey-for-text); + + &--invite { + color: var(--accent); + } + } + + &__project { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + margin-top: auto; + border-radius: var(--rounded-xl); + + &--text { + color: var(--white); + } + } + + &__empty { + position: relative; + padding: 35px 0 12px; + margin-top: 20px; + cursor: pointer; + background: transparent; + opacity: 1; + transition: opacity 0.2s ease; + + &:hover { + opacity: 0.7; + } + + &--image { + position: absolute; + top: 15%; + right: 20%; + } + + &--name { + display: flex; + justify-content: center; + width: 90%; + margin-left: 8px; + color: var(--accent); + } + + &--icon { + color: var(--accent); + } + } + + &__skills { + display: flex; + flex-flow: wrap; + gap: 2px; + justify-content: center; + margin-bottom: 9px; + } +} + +.message-modal { + display: flex; + flex-direction: column; + align-items: center; + max-width: 402px; + + &__title { + margin: 18px 0; + color: var(--black); + text-align: center; + } + + &__text { + color: var(--dark-grey); + text-align: center; + } + + &__button { + margin-top: 18px; + } +} + +.unsubscribe-modal { + display: flex; + flex-direction: column; + align-items: center; + padding: 20px; + text-align: center; + + h3 { + margin-bottom: 16px; + color: var(--black); + } + + &__buttons { + display: flex; + gap: 12px; + margin-top: 16px; + } +} diff --git a/projects/social_platform/src/app/office/shared/project-card/project-card.component.spec.ts b/projects/social_platform/src/app/office/features/info-card/info-card.component.spec.ts similarity index 64% rename from projects/social_platform/src/app/office/shared/project-card/project-card.component.spec.ts rename to projects/social_platform/src/app/office/features/info-card/info-card.component.spec.ts index 961e58bc9..f2e3eb5e9 100644 --- a/projects/social_platform/src/app/office/shared/project-card/project-card.component.spec.ts +++ b/projects/social_platform/src/app/office/features/info-card/info-card.component.spec.ts @@ -2,29 +2,27 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { ProjectCardComponent } from "./project-card.component"; import { of } from "rxjs"; import { IndustryService } from "@services/industry.service"; -import { Project } from "@models/project.model"; +import { InfoCardComponent } from "./info-card.component"; describe("ProjectCardComponent", () => { - let component: ProjectCardComponent; - let fixture: ComponentFixture; + let component: InfoCardComponent; + let fixture: ComponentFixture; beforeEach(async () => { const industrySpy = jasmine.createSpyObj([{ industries: of([]) }]); await TestBed.configureTestingModule({ - imports: [ProjectCardComponent], + imports: [InfoCardComponent], providers: [{ provide: IndustryService, useValue: industrySpy }], }).compileComponents(); }); beforeEach(() => { - fixture = TestBed.createComponent(ProjectCardComponent); + fixture = TestBed.createComponent(InfoCardComponent); component = fixture.componentInstance; - component.project = Project.default(); fixture.detectChanges(); }); diff --git a/projects/social_platform/src/app/office/features/info-card/info-card.component.ts b/projects/social_platform/src/app/office/features/info-card/info-card.component.ts new file mode 100644 index 000000000..ce67d9a95 --- /dev/null +++ b/projects/social_platform/src/app/office/features/info-card/info-card.component.ts @@ -0,0 +1,242 @@ +/** @format */ + +import { Component, EventEmitter, inject, Input, OnInit, Output } from "@angular/core"; +import { IndustryService } from "@services/industry.service"; +import { IconComponent, ButtonComponent } from "@ui/components"; +import { AvatarComponent } from "@ui/components/avatar/avatar.component"; +import { AsyncPipe, CommonModule } from "@angular/common"; +import { ModalComponent } from "@ui/components/modal/modal.component"; +import { SubscriptionService } from "@office/services/subscription.service"; +import { InviteService } from "@office/services/invite.service"; +import { ClickOutsideModule } from "ng-click-outside"; +import { Router, RouterLink } from "@angular/router"; +import { TagComponent } from "@ui/components/tag/tag.component"; +import { YearsFromBirthdayPipe } from "@corelib"; + +/** + * Компонент карточки информации с разным наполнением, в зависимости от контекста + */ +@Component({ + selector: "app-info-card", + templateUrl: "./info-card.component.html", + styleUrl: "./info-card.component.scss", + standalone: true, + imports: [ + CommonModule, + AvatarComponent, + IconComponent, + AsyncPipe, + ModalComponent, + ButtonComponent, + ClickOutsideModule, + TagComponent, + YearsFromBirthdayPipe, + RouterLink, + ], +}) +export class InfoCardComponent implements OnInit { + private readonly inviteService = inject(InviteService); + private readonly subscriptionService = inject(SubscriptionService); + public readonly industryService = inject(IndustryService); + private readonly router = inject(Router); + + @Input() info?: any; + @Input() type: "invite" | "projects" | "members" = "projects"; + @Input() appereance: "my" | "subs" | "base" | "empty" = "base"; + @Input() section: "projects" | "subscriptions" | "other" = "projects"; + @Input() canDelete?: boolean | null = false; + @Input() isSubscribed?: boolean | null = false; + @Input() profileId?: number; + + @Output() onAcceptingInvite = new EventEmitter(); + @Output() onRejectingInvite = new EventEmitter(); + @Output() onCreate = new EventEmitter(); + + // Состояние компонента + isUnsubscribeModalOpen = false; + inviteErrorModal = false; + haveBadge = this.calculateHaveBadge(); + + programProjectHovered = false; + + ngOnInit(): void {} + + /** + * Определяет, нужно ли показывать информацию о проекте + */ + shouldShowProjectInfo(): boolean { + return this.type === "projects" && this.appereance !== "subs" && this.appereance !== "empty"; + } + + /** + * Определяет, нужно ли показывать бейдж подписки + */ + shouldShowSubscriptionBadge(): boolean { + return ( + this.appereance !== "empty" && + this.haveBadge && + this.appereance === "base" && + this.type !== "invite" && + this.type !== "members" + ); + } + + /** + * Возвращает URL для аватара + */ + getAvatarUrl(): string { + const currentImageAddress = + this.appereance === "empty" && this.section === "projects" + ? "/assets/images/projects/shared/add-project.svg" + : this.appereance === "empty" && this.section === "subscriptions" + ? "/assets/images/projects/shared/empty-subscriptions.svg" + : ""; + return this.info?.imageAddress || this.info?.avatar || currentImageAddress; + } + + /** + * Переключение подписки (универсальный метод) + */ + toggleSubscription(event: Event): void { + if (this.isSubscribed) { + this.onSubscribe(event, this.profileId!); + } else { + this.onSubscribe(event, this.profileId!); + } + } + + /** + * Обработка отклонения приглашения + */ + onRejectInvite(event: Event, inviteId: number): void { + if (!this.info || !inviteId) { + console.warn("Cannot reject invite: missing project or inviteId"); + return; + } + + this.stopEventPropagation(event); + + this.inviteService.rejectInvite(inviteId).subscribe({ + next: () => { + this.onRejectingInvite.emit(inviteId || this.info!.inviteId); + }, + error: error => { + console.error("Error rejecting invite:", error); + this.inviteErrorModal = true; + }, + }); + } + + /** + * Обработка принятия приглашения + */ + onAcceptInvite(event: Event, inviteId: number): void { + if (!this.info || !inviteId) { + console.warn("Cannot accept invite: missing project or inviteId"); + return; + } + + this.stopEventPropagation(event); + + this.inviteService.acceptInvite(inviteId).subscribe({ + next: () => { + this.onAcceptingInvite.emit(inviteId || this.info!.inviteId); + }, + error: error => { + console.error("Error accepting invite:", error); + this.inviteErrorModal = true; + }, + }); + } + + /** + * Подписка на проект или открытие модального окна отписки + */ + onSubscribe(event: Event, projectId: number): void { + if (!projectId) { + console.warn("Cannot subscribe: missing projectId"); + return; + } + + this.stopEventPropagation(event); + + if (this.isSubscribed) { + this.isUnsubscribeModalOpen = true; + return; + } + + this.subscriptionService.addSubscription(projectId).subscribe({ + next: () => { + this.isSubscribed = true; + }, + error: error => { + console.error("Error subscribing to project:", error); + }, + }); + } + + /** + * Отписка от проекта + */ + onUnsubscribe(event: Event, projectId: number): void { + if (!projectId) { + console.warn("Cannot unsubscribe: missing projectId"); + return; + } + + this.stopEventPropagation(event); + + this.subscriptionService.deleteSubscription(projectId).subscribe({ + next: () => { + this.isSubscribed = false; + this.isUnsubscribeModalOpen = false; + }, + error: error => { + console.error("Error unsubscribing from project:", error); + }, + }); + } + + /** + * Закрытие модального окна отписки + */ + onCloseUnsubscribeModal(): void { + this.isUnsubscribeModalOpen = false; + } + + /** + * Обработка создания нового проекта + */ + onCreateProject(event: Event): void { + this.stopEventPropagation(event); + this.onCreate.emit(); + } + + /** + * Остановка всплытия события + */ + private stopEventPropagation(event: Event): void { + event.stopPropagation(); + event.preventDefault(); + } + + /** + * Редирект на проеты при случае что подписки пустые + */ + redirectToProjects(): void { + this.router + .navigateByUrl(`/office/projects/all`) + .then(() => console.debug("Route change from ProjectsComponent")); + } + + /** + * Вычисление флага haveBadge + */ + private calculateHaveBadge(): boolean { + return ( + location.href.includes("/subscriptions") || + location.href.includes("/all") || + location.href.includes("/projects") + ); + } +} diff --git a/projects/social_platform/src/app/office/shared/invite-card/invite-card.component.html b/projects/social_platform/src/app/office/features/invite-card/invite-card.component.html similarity index 67% rename from projects/social_platform/src/app/office/shared/invite-card/invite-card.component.html rename to projects/social_platform/src/app/office/features/invite-card/invite-card.component.html index 8e66d16ad..ebd309348 100644 --- a/projects/social_platform/src/app/office/shared/invite-card/invite-card.component.html +++ b/projects/social_platform/src/app/office/features/invite-card/invite-card.component.html @@ -3,38 +3,34 @@ @if (invite) {
-

- {{ invite.user.firstName }} {{ invite.user.lastName }} -

- -

- {{ invite.role }} -

- -

Роль - {{ invite.specialization }}

+
+

{{ invite.role }}

+ @if(invite.isAccepted === null) { +

• Приглашение отправлено

+ } +
- @if(invite.isAccepted === null) { -

Приглашение отправлено

- } +
+ +

+ {{ invite.user.firstName }} {{ invite.user.lastName }} +

+
-
- + + + + + +
} @@ -43,12 +39,13 @@

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

Отмена - Удалить
@@ -63,13 +60,14 @@

Редактирование участника @if ((role | controlError: "required")) { -
+
{{ errorMessage.VALIDATION_REQUIRED }}
} @@ -77,19 +75,20 @@

Редактирование участника @if ((specialization | controlError: "required")) { -
+
{{ errorMessage.VALIDATION_REQUIRED }}
}

} - Сохранить diff --git a/projects/social_platform/src/app/office/shared/invite-card/invite-card.component.scss b/projects/social_platform/src/app/office/features/invite-card/invite-card.component.scss similarity index 80% rename from projects/social_platform/src/app/office/shared/invite-card/invite-card.component.scss rename to projects/social_platform/src/app/office/features/invite-card/invite-card.component.scss index 8b1250fa7..ad206af7e 100644 --- a/projects/social_platform/src/app/office/shared/invite-card/invite-card.component.scss +++ b/projects/social_platform/src/app/office/features/invite-card/invite-card.component.scss @@ -4,7 +4,8 @@ .invite { display: flex; - align-items: flex-start; + flex-direction: column; + gap: 15px; justify-content: space-between; padding: 15px 10px; background-color: var(--light-gray); @@ -19,8 +20,7 @@ } &__status { - margin: 5px 10px 0 auto; - color: var(--accent); + color: var(--dark-grey); } &__info { @@ -29,6 +29,20 @@ gap: 5px; } + &__user { + display: flex; + gap: 10px; + align-items: center; + } + + &__top { + display: flex; + gap: 15px; + padding-bottom: 5px; + margin-bottom: 15px; + border-bottom: 1px solid var(--dark-grey); + } + &__warning-modal { display: flex; flex-direction: column; @@ -51,11 +65,6 @@ } ::ng-deep { - app-button { - align-self: center; - width: 30%; - } - app-input { input { @include typography.body-12; @@ -91,8 +100,4 @@ &__edit { color: var(--dark-grey); } - - &__cross { - color: var(--red); - } } diff --git a/projects/social_platform/src/app/office/shared/invite-card/invite-card.component.spec.ts b/projects/social_platform/src/app/office/features/invite-card/invite-card.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/office/shared/invite-card/invite-card.component.spec.ts rename to projects/social_platform/src/app/office/features/invite-card/invite-card.component.spec.ts diff --git a/projects/social_platform/src/app/office/shared/invite-card/invite-card.component.ts b/projects/social_platform/src/app/office/features/invite-card/invite-card.component.ts similarity index 95% rename from projects/social_platform/src/app/office/shared/invite-card/invite-card.component.ts rename to projects/social_platform/src/app/office/features/invite-card/invite-card.component.ts index da0c96cc4..d918309b5 100644 --- a/projects/social_platform/src/app/office/shared/invite-card/invite-card.component.ts +++ b/projects/social_platform/src/app/office/features/invite-card/invite-card.component.ts @@ -7,7 +7,8 @@ import { ErrorMessage } from "@error/models/error-message"; import { Invite } from "@models/invite.model"; import { IconComponent, ButtonComponent, SelectComponent, InputComponent } from "@ui/components"; import { ModalComponent } from "@ui/components/modal/modal.component"; -import { rolesMembersList } from "projects/core/src/consts/list-roles-members"; +import { rolesMembersList } from "projects/core/src/consts/lists/roles-members-list.const"; +import { AvatarComponent } from "@ui/components/avatar/avatar.component"; /** * Компонент карточки приглашения в команду или проект @@ -39,6 +40,7 @@ import { rolesMembersList } from "projects/core/src/consts/list-roles-members"; ControlErrorPipe, ReactiveFormsModule, InputComponent, + AvatarComponent, ], }) export class InviteCardComponent implements OnInit { diff --git a/projects/social_platform/src/app/office/shared/message-input/message-input.component.html b/projects/social_platform/src/app/office/features/message-input/message-input.component.html similarity index 76% rename from projects/social_platform/src/app/office/shared/message-input/message-input.component.html rename to projects/social_platform/src/app/office/features/message-input/message-input.component.html index 4243450ef..08da963f1 100644 --- a/projects/social_platform/src/app/office/shared/message-input/message-input.component.html +++ b/projects/social_platform/src/app/office/features/message-input/message-input.component.html @@ -5,11 +5,16 @@
    @for (file of attachFiles; let index = $index; track index) {
  • - -

    {{ file.name }}

    -
    {{ +file.size | formatedFileSize }}
    - + +
    +

    {{ file.name.split(".")[0] }}

    + @if (file.type) { +
    + {{ file.type.includes("/") ? (file.type | fileType) : (file.type | uppercase) }} • + {{ +file.size | formatedFileSize }} +
    + } +
    @if (file.loading) { {{ file.name }}

} @else { } @@ -51,7 +56,7 @@

{{ file.name }}

} @if (editingMessage) {
- +
{{ editingMessage.author.firstName }} {{ editingMessage.author.lastName }} @@ -85,16 +90,16 @@

{{ file.name }}

[placeholder]="placeholder" rows="1" [value]="value.text" - class="message-input__field text-body-14" + class="message-input__field text-body-12" > -
@if (showDropModal) { @@ -108,8 +113,8 @@

{{ file.name }}

width="105px" height="105px" /> -

Перетащите сюда файлы для отправки

-

+

Перетащите сюда файлы для отправки

+

Вы можете добавить к ним комментарий или отправить отдельно

diff --git a/projects/social_platform/src/app/office/shared/message-input/message-input.component.scss b/projects/social_platform/src/app/office/features/message-input/message-input.component.scss similarity index 71% rename from projects/social_platform/src/app/office/shared/message-input/message-input.component.scss rename to projects/social_platform/src/app/office/features/message-input/message-input.component.scss index e72ed8141..f9f689b14 100644 --- a/projects/social_platform/src/app/office/shared/message-input/message-input.component.scss +++ b/projects/social_platform/src/app/office/features/message-input/message-input.component.scss @@ -6,8 +6,9 @@ $button-size: 40px; .message-input { + position: relative; padding: 12px; - border: 1px solid var(--gray); + border: 0.5px solid var(--medium-grey-for-outline); border-radius: 8px; &__control { @@ -17,9 +18,9 @@ $button-size: 40px; &__field { flex-grow: 1; - padding-right: 30px; color: var(--dark); resize: none; + background: transparent; border: none; outline: none; @@ -29,16 +30,11 @@ $button-size: 40px; } &__attach-button { - display: flex; - align-items: center; - justify-content: center; - width: $button-size; - height: $button-size; - margin-left: auto; + margin-right: 12px; color: var(--dark-grey); cursor: pointer; - border: 1px solid var(--gray); - border-radius: 8px; + border-radius: var(--rounded-xl); + opacity: 0.5; input { display: none; @@ -46,16 +42,11 @@ $button-size: 40px; } &__send-button { - display: flex; - align-items: center; - justify-content: center; - width: $button-size; - height: $button-size; - margin-left: 18px; + padding: 8px; color: var(--white); cursor: pointer; background-color: var(--accent); - border-radius: 8px; + border-radius: var(--rounded-xl); transition: background-color 0.2s; &:hover { @@ -110,11 +101,26 @@ $button-size: 40px; } .files-list { + position: absolute; + bottom: 77%; + left: 15px; display: flex; - padding: 0 0 12px; list-style-type: none; &__item { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; + padding: 24px; + background-color: var(--light-white); + border-color: var(--medium-grey-for-outline); + border-style: solid; + border-width: 0.5px 0.5px 0; + border-top-left-radius: 10px; + border-top-right-radius: 10px; + box-shadow: 4px -4px 4px rgb(51 51 51 / 20%); + &:not(:last-child) { margin-right: 18px; } @@ -122,11 +128,6 @@ $button-size: 40px; } .file { - min-width: 140px; - padding: 10px; - border: 1px solid var(--medium-grey-for-outline); - border-radius: 20px; - &--loading { .file__name { color: var(--dark-grey); @@ -138,37 +139,53 @@ $button-size: 40px; } } - &__type { - display: block; - margin-bottom: 12px; + &__info { + display: flex; + flex-direction: column; + } + + &__icon { + display: flex; + align-items: center; + align-self: center; + justify-content: center; + width: 20px; + height: 20px; + margin-right: 6px; + cursor: pointer; + border-radius: var(--rounded-xl); + transition: color 0.2s; + + &--file { + color: var(--accent); + border: 0.5px solid var(--accent); + + &:hover { + color: var(--accent-dark); + } + } + + &--basket { + color: var(--red); + border: 0.5px solid var(--red); + + &:hover { + color: var(--red-dark); + } + } } &__name { color: var(--black); - - @include typography.body-14; } - &__size { - margin-bottom: 12px; + &__meta { color: var(--dark-grey); - - @include typography.body-12; } &__loading { color: var(--accent); } - - &__basket { - color: var(--red); - cursor: pointer; - transition: color 0.2s; - - &:hover { - color: var(--red-dark); - } - } } .drop-modal { diff --git a/projects/social_platform/src/app/office/shared/message-input/message-input.component.spec.ts b/projects/social_platform/src/app/office/features/message-input/message-input.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/office/shared/message-input/message-input.component.spec.ts rename to projects/social_platform/src/app/office/features/message-input/message-input.component.spec.ts diff --git a/projects/social_platform/src/app/office/shared/message-input/message-input.component.ts b/projects/social_platform/src/app/office/features/message-input/message-input.component.ts similarity index 98% rename from projects/social_platform/src/app/office/shared/message-input/message-input.component.ts rename to projects/social_platform/src/app/office/features/message-input/message-input.component.ts index 44e6c650b..ccabd06e8 100644 --- a/projects/social_platform/src/app/office/shared/message-input/message-input.component.ts +++ b/projects/social_platform/src/app/office/features/message-input/message-input.component.ts @@ -18,6 +18,7 @@ import { AutosizeModule } from "ngx-autosize"; import { NgxMaskModule } from "ngx-mask"; import { IconComponent } from "@ui/components"; import { FormatedFileSizePipe } from "@core/pipes/formatted-file-size.pipe"; +import { UpperCasePipe } from "@angular/common"; /** * Компонент ввода сообщений для чата @@ -43,7 +44,14 @@ import { FormatedFileSizePipe } from "@core/pipes/formatted-file-size.pipe"; }, ], standalone: true, - imports: [IconComponent, NgxMaskModule, AutosizeModule, FileTypePipe, FormatedFileSizePipe], + imports: [ + IconComponent, + NgxMaskModule, + AutosizeModule, + FileTypePipe, + FormatedFileSizePipe, + UpperCasePipe, + ], }) export class MessageInputComponent implements OnInit, OnDestroy, ControlValueAccessor { /** diff --git a/projects/social_platform/src/app/office/shared/nav/nav.component.html b/projects/social_platform/src/app/office/features/nav/nav.component.html similarity index 96% rename from projects/social_platform/src/app/office/shared/nav/nav.component.html rename to projects/social_platform/src/app/office/features/nav/nav.component.html index 877993e27..cdf606ec4 100644 --- a/projects/social_platform/src/app/office/shared/nav/nav.component.html +++ b/projects/social_platform/src/app/office/features/nav/nav.component.html @@ -83,9 +83,8 @@

{{ title }}

- Траектории + Траектории
-
PRO
diff --git a/projects/social_platform/src/app/office/shared/nav/nav.component.scss b/projects/social_platform/src/app/office/features/nav/nav.component.scss similarity index 98% rename from projects/social_platform/src/app/office/shared/nav/nav.component.scss rename to projects/social_platform/src/app/office/features/nav/nav.component.scss index 5b976b3d5..2a2928c06 100644 --- a/projects/social_platform/src/app/office/shared/nav/nav.component.scss +++ b/projects/social_platform/src/app/office/features/nav/nav.component.scss @@ -138,7 +138,7 @@ align-items: center; justify-content: space-between; margin-right: 20px; - color: var(--grey-for-text); + color: var(--dark-grey); cursor: pointer; transition: color 0.2s; diff --git a/projects/social_platform/src/app/office/shared/nav/nav.component.spec.ts b/projects/social_platform/src/app/office/features/nav/nav.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/office/shared/nav/nav.component.spec.ts rename to projects/social_platform/src/app/office/features/nav/nav.component.spec.ts diff --git a/projects/social_platform/src/app/office/shared/nav/nav.component.ts b/projects/social_platform/src/app/office/features/nav/nav.component.ts similarity index 100% rename from projects/social_platform/src/app/office/shared/nav/nav.component.ts rename to projects/social_platform/src/app/office/features/nav/nav.component.ts diff --git a/projects/social_platform/src/app/office/shared/news-card/news-card.component.html b/projects/social_platform/src/app/office/features/news-card/news-card.component.html similarity index 67% rename from projects/social_platform/src/app/office/shared/news-card/news-card.component.html rename to projects/social_platform/src/app/office/features/news-card/news-card.component.html index b5b6c8f19..de444d11c 100644 --- a/projects/social_platform/src/app/office/shared/news-card/news-card.component.html +++ b/projects/social_platform/src/app/office/features/news-card/news-card.component.html @@ -7,35 +7,30 @@ [src]="feedItem.imageAddress || placeholderUrl" [alt]="feedItem.name" /> -
-
{{ feedItem.name }}
-
- {{ feedItem.datetimeCreated | dayjs: "format":"DD MMMM YYYY, HH:mm" }} -
-
+

{{ feedItem.name }}

@if (isOwner) {
- +
@if (menuOpen) {
    @if (!editMode) { -
  • Редактировать
  • +
  • редактировать
  • } -
  • Удалить
  • +
  • удалить
}
}
@if (feedItem.text) { -
+
@if (!editMode) { -

+

} @else { @if (editForm.get("text"); as text) { - + } }
} @if (editMode) { @@ -91,21 +86,26 @@ } diff --git a/projects/social_platform/src/app/office/shared/news-card/news-card.component.scss b/projects/social_platform/src/app/office/features/news-card/news-card.component.scss similarity index 82% rename from projects/social_platform/src/app/office/shared/news-card/news-card.component.scss rename to projects/social_platform/src/app/office/features/news-card/news-card.component.scss index 25e8b9fe2..d449adec1 100644 --- a/projects/social_platform/src/app/office/shared/news-card/news-card.component.scss +++ b/projects/social_platform/src/app/office/features/news-card/news-card.component.scss @@ -2,10 +2,10 @@ @use "styles/responsive"; .card { - padding: 20px; - background-color: var(--white); - border: 1px solid var(--medium-grey-for-outline); - border-radius: 15px; + padding: 24px 12px; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); &__head { display: flex; @@ -34,7 +34,7 @@ padding: 20px 0; background-color: var(--white); border: 1px solid var(--medium-grey-for-outline); - border-radius: 15px; + border-radius: var(--rounded-xl); } &__option { @@ -57,20 +57,17 @@ &__title { display: flex; + align-items: center; + } + + &__top { + display: flex; + gap: 10px; + align-items: center; } &__name { - max-width: 200px; - overflow: hidden; color: var(--black); - text-overflow: ellipsis; - white-space: nowrap; - - @include typography.bold-body-14; - - @include responsive.apply-desktop { - @include typography.bold-body-16; - } } &__date { @@ -79,6 +76,7 @@ /* stylelint-disable value-no-vendor-prefix */ &__text { + color: var(--grey-for-text); white-space: break-spaces; p { @@ -94,12 +92,6 @@ -webkit-line-clamp: unset; } } - - @include typography.body-12; - - @include responsive.apply-desktop { - @include typography.body-16; - } } /* stylelint-enable value-no-vendor-prefix */ @@ -148,7 +140,7 @@ height: 75px; color: var(--accent); background-color: var(--white); - border-radius: 15px; + border-radius: var(--rounded-xl); transition: transform 0.1s ease-in-out; transform: translate(-50%, -50%) scale(0); @@ -169,6 +161,7 @@ &__left { display: flex; + gap: 10px; align-items: center; } @@ -176,14 +169,6 @@ display: flex; align-items: center; color: var(--dark-grey); - - &:not(:last-child) { - margin-right: 5px; - } - - i { - margin-right: 3px; - } } &__like { @@ -223,11 +208,7 @@ &__actions { display: flex; - - app-button { - display: block; - margin-right: 10px; - } + gap: 10px; } &__attach { diff --git a/projects/social_platform/src/app/office/shared/news-card/news-card.component.spec.ts b/projects/social_platform/src/app/office/features/news-card/news-card.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/office/shared/news-card/news-card.component.spec.ts rename to projects/social_platform/src/app/office/features/news-card/news-card.component.spec.ts diff --git a/projects/social_platform/src/app/office/shared/news-card/news-card.component.ts b/projects/social_platform/src/app/office/features/news-card/news-card.component.ts similarity index 98% rename from projects/social_platform/src/app/office/shared/news-card/news-card.component.ts rename to projects/social_platform/src/app/office/features/news-card/news-card.component.ts index b08eba469..c93428b3a 100644 --- a/projects/social_platform/src/app/office/shared/news-card/news-card.component.ts +++ b/projects/social_platform/src/app/office/features/news-card/news-card.component.ts @@ -23,10 +23,10 @@ import { forkJoin, noop, Observable, tap } from "rxjs"; import { ButtonComponent, IconComponent } from "@ui/components"; import { FileItemComponent } from "@ui/components/file-item/file-item.component"; import { FileUploadItemComponent } from "@ui/components/file-upload-item/file-upload-item.component"; -import { ImgCardComponent } from "../img-card/img-card.component"; import { TextareaComponent } from "@ui/components/textarea/textarea.component"; import { ClickOutsideModule } from "ng-click-outside"; -import { CarouselComponent } from "../carousel/carousel.component"; +import { CarouselComponent } from "@office/shared/carousel/carousel.component"; +import { ImgCardComponent } from "@office/shared/img-card/img-card.component"; /** * Компонент карточки новости @@ -44,14 +44,14 @@ import { CarouselComponent } from "../carousel/carousel.component"; IconComponent, TextareaComponent, ReactiveFormsModule, - ImgCardComponent, FileUploadItemComponent, FileItemComponent, ButtonComponent, - CarouselComponent, DayjsPipe, FormControlPipe, ParseLinksPipe, + CarouselComponent, + ImgCardComponent, ], }) export class NewsCardComponent implements OnInit { diff --git a/projects/social_platform/src/app/office/shared/news-form/news-form.component.html b/projects/social_platform/src/app/office/features/news-form/news-form.component.html similarity index 75% rename from projects/social_platform/src/app/office/shared/news-form/news-form.component.html rename to projects/social_platform/src/app/office/features/news-form/news-form.component.html index 419382c0a..4cfcaf934 100644 --- a/projects/social_platform/src/app/office/shared/news-form/news-form.component.html +++ b/projects/social_platform/src/app/office/features/news-form/news-form.component.html @@ -2,18 +2,17 @@
- - + > +
@for (i of imagesList; track i.id) { @@ -40,7 +39,7 @@
diff --git a/projects/social_platform/src/app/office/features/news-form/news-form.component.scss b/projects/social_platform/src/app/office/features/news-form/news-form.component.scss new file mode 100644 index 000000000..c44d7434b --- /dev/null +++ b/projects/social_platform/src/app/office/features/news-form/news-form.component.scss @@ -0,0 +1,74 @@ +.form { + padding: 20px 12px 10px; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); + + &__row { + display: flex; + align-items: center; + + ::ng-deep app-input { + input { + width: 100%; + padding: 12px 105px 6px 0; + background-color: transparent; + border-bottom: 0.5px solid var(--medium-grey-for-outline); + } + } + + i { + width: 23px; + height: 23px; + padding: 6px; + color: var(--white); + background-color: var(--accent); + border-radius: var(--rounded-md); + } + } + + &__files { + display: flex; + flex-direction: column; + gap: 10px; + + &:not(:empty) { + margin-top: 20px; + } + } + + &__send { + margin-left: 20px; + color: var(--accent); + cursor: pointer; + } + + &__input { + flex-grow: 1; + resize: none; + background: transparent; + border: none; + outline: none; + } + + &__images { + display: flex; + flex-wrap: wrap; + gap: 10px; + } +} + +.footer { + padding-top: 10px; + + &__attach { + input { + display: none; + } + + i { + color: var(--dark-grey); + cursor: pointer; + } + } +} diff --git a/projects/social_platform/src/app/office/shared/news-form/news-form.component.spec.ts b/projects/social_platform/src/app/office/features/news-form/news-form.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/office/shared/news-form/news-form.component.spec.ts rename to projects/social_platform/src/app/office/features/news-form/news-form.component.spec.ts diff --git a/projects/social_platform/src/app/office/shared/news-form/news-form.component.ts b/projects/social_platform/src/app/office/features/news-form/news-form.component.ts similarity index 97% rename from projects/social_platform/src/app/office/shared/news-form/news-form.component.ts rename to projects/social_platform/src/app/office/features/news-form/news-form.component.ts index 988d1c1c0..0403349f0 100644 --- a/projects/social_platform/src/app/office/shared/news-form/news-form.component.ts +++ b/projects/social_platform/src/app/office/features/news-form/news-form.component.ts @@ -7,9 +7,9 @@ import { nanoid } from "nanoid"; import { FileService } from "@core/services/file.service"; import { forkJoin, noop, Observable, tap } from "rxjs"; import { FileUploadItemComponent } from "@ui/components/file-upload-item/file-upload-item.component"; -import { ImgCardComponent } from "../img-card/img-card.component"; -import { IconComponent } from "@ui/components"; +import { IconComponent, InputComponent } from "@ui/components"; import { AutosizeModule } from "ngx-autosize"; +import { ImgCardComponent } from "@office/shared/img-card/img-card.component"; /** * Компонент формы создания новости @@ -39,8 +39,9 @@ import { AutosizeModule } from "ngx-autosize"; ReactiveFormsModule, AutosizeModule, IconComponent, - ImgCardComponent, FileUploadItemComponent, + InputComponent, + ImgCardComponent, ], }) export class NewsFormComponent implements OnInit { diff --git a/projects/social_platform/src/app/office/shared/project-rating/components/boolean-criterion/boolean-criterion.component.html b/projects/social_platform/src/app/office/features/project-rating/components/boolean-criterion/boolean-criterion.component.html similarity index 100% rename from projects/social_platform/src/app/office/shared/project-rating/components/boolean-criterion/boolean-criterion.component.html rename to projects/social_platform/src/app/office/features/project-rating/components/boolean-criterion/boolean-criterion.component.html diff --git a/projects/social_platform/src/app/office/shared/project-rating/components/boolean-criterion/boolean-criterion.component.scss b/projects/social_platform/src/app/office/features/project-rating/components/boolean-criterion/boolean-criterion.component.scss similarity index 100% rename from projects/social_platform/src/app/office/shared/project-rating/components/boolean-criterion/boolean-criterion.component.scss rename to projects/social_platform/src/app/office/features/project-rating/components/boolean-criterion/boolean-criterion.component.scss 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/features/project-rating/components/boolean-criterion/boolean-criterion.component.ts similarity index 100% rename from projects/social_platform/src/app/office/shared/project-rating/components/boolean-criterion/boolean-criterion.component.ts rename to projects/social_platform/src/app/office/features/project-rating/components/boolean-criterion/boolean-criterion.component.ts diff --git a/projects/social_platform/src/app/office/shared/project-rating/components/range-criterion-input/range-criterion-input.component.html b/projects/social_platform/src/app/office/features/project-rating/components/range-criterion-input/range-criterion-input.component.html similarity index 74% rename from projects/social_platform/src/app/office/shared/project-rating/components/range-criterion-input/range-criterion-input.component.html rename to projects/social_platform/src/app/office/features/project-rating/components/range-criterion-input/range-criterion-input.component.html index 265825103..8473c2f3a 100644 --- a/projects/social_platform/src/app/office/shared/project-rating/components/range-criterion-input/range-criterion-input.component.html +++ b/projects/social_platform/src/app/office/features/project-rating/components/range-criterion-input/range-criterion-input.component.html @@ -12,9 +12,9 @@ (blur)="onBlur()" (paste)="onPaste($event)" (focus)="moveCursorToEnd($event)" - class="field__input" + class="field__input text-body-10" [class.field__input--error]="error" /> -  / - {{ max }} +  / + {{ max }}
diff --git a/projects/social_platform/src/app/office/shared/project-rating/components/range-criterion-input/range-criterion-input.component.scss b/projects/social_platform/src/app/office/features/project-rating/components/range-criterion-input/range-criterion-input.component.scss similarity index 83% rename from projects/social_platform/src/app/office/shared/project-rating/components/range-criterion-input/range-criterion-input.component.scss rename to projects/social_platform/src/app/office/features/project-rating/components/range-criterion-input/range-criterion-input.component.scss index 6ea0c004f..f4204ad1f 100644 --- a/projects/social_platform/src/app/office/shared/project-rating/components/range-criterion-input/range-criterion-input.component.scss +++ b/projects/social_platform/src/app/office/features/project-rating/components/range-criterion-input/range-criterion-input.component.scss @@ -10,15 +10,19 @@ .field { text-wrap: nowrap; + span { + color: var(--black); + } + &__input { - width: 24px; - height: 24px; + width: 15px; + height: 15px; color: transparent; text-align: center; text-shadow: 0 0 0 var(--black); background-color: var(--white); - border: 1px solid var(--gray); - border-radius: var(--rounded-md); + border: 0.5px solid var(--gray); + border-radius: var(--rounded-sm); outline: none; transition: all 0.2s; @@ -34,8 +38,6 @@ border-color: var(--accent); box-shadow: 0 0 6px rgb(109 40 255 / 30%); } - - @include typography.body-14; } } diff --git a/projects/social_platform/src/app/office/shared/project-rating/components/range-criterion-input/range-criterion-input.component.spec.ts b/projects/social_platform/src/app/office/features/project-rating/components/range-criterion-input/range-criterion-input.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/office/shared/project-rating/components/range-criterion-input/range-criterion-input.component.spec.ts rename to projects/social_platform/src/app/office/features/project-rating/components/range-criterion-input/range-criterion-input.component.spec.ts 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/features/project-rating/components/range-criterion-input/range-criterion-input.component.ts similarity index 100% rename from projects/social_platform/src/app/office/shared/project-rating/components/range-criterion-input/range-criterion-input.component.ts rename to projects/social_platform/src/app/office/features/project-rating/components/range-criterion-input/range-criterion-input.component.ts diff --git a/projects/social_platform/src/app/office/shared/project-rating/project-rating.component.html b/projects/social_platform/src/app/office/features/project-rating/project-rating.component.html similarity index 87% rename from projects/social_platform/src/app/office/shared/project-rating/project-rating.component.html rename to projects/social_platform/src/app/office/features/project-rating/project-rating.component.html index e1f3e2acd..78cbe6da0 100644 --- a/projects/social_platform/src/app/office/shared/project-rating/project-rating.component.html +++ b/projects/social_platform/src/app/office/features/project-rating/project-rating.component.html @@ -3,20 +3,20 @@
@for (criterion of criteria; track $index) { @if (criterion.type === "int") {
- +
} @if (criterion.type === "bool") {
- +
} }
@@ -24,6 +24,7 @@ @for (criterion of criteria; track $index) { @if (criterion.type === "str") {
diff --git a/projects/social_platform/src/app/office/features/project-rating/project-rating.component.scss b/projects/social_platform/src/app/office/features/project-rating/project-rating.component.scss new file mode 100644 index 000000000..7ab995d5c --- /dev/null +++ b/projects/social_platform/src/app/office/features/project-rating/project-rating.component.scss @@ -0,0 +1,45 @@ +@use "styles/typography"; +@use "styles/responsive"; + +:host { + width: 100%; +} + +.rating { + width: 100%; + + &__columns { + display: flex; + align-items: center; + } + + &__field { + display: flex; + gap: 10px; + align-items: center; + + label { + margin-top: 5px; + color: var(--grey-for-text); + } + } + + &__input { + grid-column: 2/3; + } + + &__comment { + margin-top: 25px; + + app-textarea { + ::ng-deep .field__input { + min-height: 66px !important; + color: var(--dark-grey); + background-color: transparent; + border-color: var(--grey-button); + + @include typography.body-10; + } + } + } +} diff --git a/projects/social_platform/src/app/office/shared/project-rating/project-rating.component.ts b/projects/social_platform/src/app/office/features/project-rating/project-rating.component.ts similarity index 97% rename from projects/social_platform/src/app/office/shared/project-rating/project-rating.component.ts rename to projects/social_platform/src/app/office/features/project-rating/project-rating.component.ts index cafae8035..b7fb1ff1e 100644 --- a/projects/social_platform/src/app/office/shared/project-rating/project-rating.component.ts +++ b/projects/social_platform/src/app/office/features/project-rating/project-rating.component.ts @@ -54,13 +54,11 @@ 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, diff --git a/projects/social_platform/src/app/office/features/response-card/response-card.component.html b/projects/social_platform/src/app/office/features/response-card/response-card.component.html new file mode 100644 index 000000000..712b9eb7f --- /dev/null +++ b/projects/social_platform/src/app/office/features/response-card/response-card.component.html @@ -0,0 +1,27 @@ + + +@if (response) { +
+
+ +
+
+

сопроводительное письмо

+ +
+ +

{{ response.whyMe }}

+ + @if(response.accompanyingFile){ + + } +
+
+} diff --git a/projects/social_platform/src/app/office/features/response-card/response-card.component.scss b/projects/social_platform/src/app/office/features/response-card/response-card.component.scss new file mode 100644 index 000000000..f13e8fa07 --- /dev/null +++ b/projects/social_platform/src/app/office/features/response-card/response-card.component.scss @@ -0,0 +1,50 @@ +/** @format */ + +@use "styles/responsive"; + +.response { + display: grid; + grid-template-columns: 4fr 6fr; + grid-gap: 20px; + + &__section { + padding: 24px; + margin-bottom: 20px; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); + } + + &__file { + margin-top: 4px; + } +} + +.lists { + &__section { + display: flex; + justify-content: space-between; + margin-bottom: 8px; + border-bottom: 0.5px solid var(--accent); + } + + &__icon { + color: var(--accent); + } + + &__title { + margin-bottom: 8px; + color: var(--accent); + } +} + +.actions { + display: flex; + gap: 12px; + + &__button { + &--inactive { + opacity: 0.7; + } + } +} diff --git a/projects/social_platform/src/app/office/shared/response-card/response-card.component.spec.ts b/projects/social_platform/src/app/office/features/response-card/response-card.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/office/shared/response-card/response-card.component.spec.ts rename to projects/social_platform/src/app/office/features/response-card/response-card.component.spec.ts diff --git a/projects/social_platform/src/app/office/shared/response-card/response-card.component.ts b/projects/social_platform/src/app/office/features/response-card/response-card.component.ts similarity index 93% rename from projects/social_platform/src/app/office/shared/response-card/response-card.component.ts rename to projects/social_platform/src/app/office/features/response-card/response-card.component.ts index e6bb47279..1f7acaca1 100644 --- a/projects/social_platform/src/app/office/shared/response-card/response-card.component.ts +++ b/projects/social_platform/src/app/office/features/response-card/response-card.component.ts @@ -10,6 +10,8 @@ import { RouterLink } from "@angular/router"; import { AsyncPipe } from "@angular/common"; import { FileItemComponent } from "@ui/components/file-item/file-item.component"; import { AuthService } from "@auth/services"; +import { ProjectVacancyCardComponent } from "@office/projects/detail/shared/project-vacancy-card/project-vacancy-card.component"; +import { IconComponent } from "@uilib"; /** * Компонент карточки отклика на вакансию @@ -37,15 +39,7 @@ import { AuthService } from "@auth/services"; templateUrl: "./response-card.component.html", styleUrl: "./response-card.component.scss", standalone: true, - imports: [ - RouterLink, - AvatarComponent, - TagComponent, - ButtonComponent, - UserRolePipe, - AsyncPipe, - FileItemComponent, - ], + imports: [IconComponent, FileItemComponent], }) export class ResponseCardComponent implements OnInit { constructor(private readonly authService: AuthService) {} diff --git a/projects/social_platform/src/app/office/features/vacancy-card/vacancy-card.component.html b/projects/social_platform/src/app/office/features/vacancy-card/vacancy-card.component.html new file mode 100644 index 000000000..575356585 --- /dev/null +++ b/projects/social_platform/src/app/office/features/vacancy-card/vacancy-card.component.html @@ -0,0 +1,38 @@ + + +@if (vacancy) { +
+
+
+

{{ vacancy.role }}

+
+
+ @if (vacancy.requiredSkills.length) { @for (skill of vacancy.requiredSkills.slice(0, 5); track + $index) { + {{ + skill.name + }} + + @if (vacancy.specialization) { + {{ + vacancy.specialization ? vacancy.specialization : "" + }} + } } @if (vacancy.requiredSkills.length > 5) { +

+ + {{ vacancy.requiredSkills.length - 5 }} +

+ } } +
+
+ +
+ + + + + + + +
+
+} diff --git a/projects/social_platform/src/app/office/features/vacancy-card/vacancy-card.component.scss b/projects/social_platform/src/app/office/features/vacancy-card/vacancy-card.component.scss new file mode 100644 index 000000000..7fb99f5df --- /dev/null +++ b/projects/social_platform/src/app/office/features/vacancy-card/vacancy-card.component.scss @@ -0,0 +1,50 @@ +/** @format */ + +.vacancy { + &__requirements { + padding: 5px 40px; + color: var(--accent); + background: transparent; + border: 1px solid var(--accent); + border-radius: var(--rounded-xxl); + + &--soft { + color: var(--white); + background-color: var(--accent); + border: none; + } + + &--more { + color: var(--accent); + } + } + + &__info { + display: flex; + flex-direction: column; + + &--text { + padding-bottom: 5px; + margin-bottom: 10px; + border-bottom: 1px solid var(--dark-grey); + } + } + + &__skills { + display: flex; + flex-flow: row wrap; + gap: 10px; + align-items: center; + } + + &__icons { + display: flex; + gap: 20px; + align-items: center; + margin-top: 10px; + + app-button { + flex-grow: 1; + } + } +} diff --git a/projects/social_platform/src/app/office/shared/vacancy-card/vacancy-card.component.spec.ts b/projects/social_platform/src/app/office/features/vacancy-card/vacancy-card.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/office/shared/vacancy-card/vacancy-card.component.spec.ts rename to projects/social_platform/src/app/office/features/vacancy-card/vacancy-card.component.spec.ts diff --git a/projects/social_platform/src/app/office/shared/vacancy-card/vacancy-card.component.ts b/projects/social_platform/src/app/office/features/vacancy-card/vacancy-card.component.ts similarity index 89% rename from projects/social_platform/src/app/office/shared/vacancy-card/vacancy-card.component.ts rename to projects/social_platform/src/app/office/features/vacancy-card/vacancy-card.component.ts index c787fef05..76adbc308 100644 --- a/projects/social_platform/src/app/office/shared/vacancy-card/vacancy-card.component.ts +++ b/projects/social_platform/src/app/office/features/vacancy-card/vacancy-card.component.ts @@ -2,7 +2,8 @@ import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; import { Vacancy } from "@models/vacancy.model"; -import { IconComponent } from "@ui/components"; +import { IconComponent, ButtonComponent } from "@ui/components"; +import { TagComponent } from "@ui/components/tag/tag.component"; /** * Компонент карточки вакансии @@ -29,7 +30,7 @@ import { IconComponent } from "@ui/components"; templateUrl: "./vacancy-card.component.html", styleUrl: "./vacancy-card.component.scss", standalone: true, - imports: [IconComponent], + imports: [IconComponent, ButtonComponent, TagComponent], }) export class VacancyCardComponent implements OnInit { constructor() {} @@ -40,10 +41,7 @@ export class VacancyCardComponent implements OnInit { skillString = ""; - ngOnInit(): void { - // Формирование строки навыков с разделителем - this.skillString = this.vacancy?.requiredSkills.map(s => s.name).join(" • ") ?? ""; - } + ngOnInit(): void {} /** * Обработчик удаления вакансии diff --git a/projects/social_platform/src/app/office/feed/feed.component.html b/projects/social_platform/src/app/office/feed/feed.component.html index 74d485713..10204d50e 100644 --- a/projects/social_platform/src/app/office/feed/feed.component.html +++ b/projects/social_platform/src/app/office/feed/feed.component.html @@ -3,7 +3,7 @@
- @for (item of feedItems(); track $index) { @if (item.typeModel === "vacancy") { + @for (item of feedItems(); track item.content.id) { @if (item.typeModel === "vacancy") { r["data"])) .subscribe((feed: ApiPagination) => { this.feedItems.set(feed.results); this.totalItemsCount.set(feed.count); + this.feedPage.set(feed.results.length); - // Настраиваем отслеживание просмотров элементов setTimeout(() => { const observer = new IntersectionObserver(this.onFeedItemView.bind(this), { root: document.querySelector(".office__body"), @@ -94,7 +66,6 @@ export class FeedComponent implements OnInit, AfterViewInit, OnDestroy { }); this.subscriptions$().push(routeData$); - // Отслеживаем изменения параметров фильтрации const queryParams$ = this.route.queryParams .pipe( map(params => params["includes"]), @@ -106,11 +77,12 @@ export class FeedComponent implements OnInit, AfterViewInit, OnDestroy { this.totalItemsCount.set(0); this.feedPage.set(0); - return this.onFetch(0, this.perFetchTake(), includes ?? ["vacancy", "project", "news"]); + return this.onFetch(0, this.perFetchTake(), includes ?? ["vacancy", "projects", "news"]); }) ) .subscribe(feed => { this.feedItems.set(feed); + this.feedPage.set(feed.length); setTimeout(() => { this.feedRoot?.nativeElement.children[0].scrollIntoView({ behavior: "smooth" }); @@ -119,20 +91,13 @@ 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); @@ -146,33 +111,20 @@ 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(0); + 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( @@ -214,16 +166,6 @@ export class FeedComponent implements OnInit, AfterViewInit, OnDestroy { } } - /** - * ОТСЛЕЖИВАНИЕ ПРОСМОТРОВ ЭЛЕМЕНТОВ - * - * ЧТО ПРИНИМАЕТ: - * @param entries - массив элементов, попавших в область видимости - * - * ЧТО ДЕЛАЕТ: - * - Отмечает новости как прочитанные при попадании в область видимости - * - Различает новости проектов и профилей - */ onFeedItemView(entries: IntersectionObserverEntry[]): void { const items = entries .map(e => { @@ -239,7 +181,6 @@ 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 @@ -247,7 +188,6 @@ export class FeedComponent implements OnInit, AfterViewInit, OnDestroy { .subscribe(noop); }); - // Отмечаем новости профилей как прочитанные profileNews.forEach(news => { if (news.typeModel !== "news") return; this.profileNewsService @@ -256,38 +196,29 @@ 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 + window.innerHeight; if (diff > 0) { - return this.onFetch( - this.feedPage() * this.perFetchTake(), - this.perFetchTake(), - this.includes() - ).pipe( + const currentOffset = this.feedItems().length; + + return this.onFetch(currentOffset, this.perFetchTake(), this.includes()).pipe( tap((feedChunk: FeedItem[]) => { - this.feedPage.update(page => page + 1); - this.feedItems.update(items => [...items, ...feedChunk]); + const existingIds = new Set(this.feedItems().map(item => item.content.id)); + const uniqueNewItems = feedChunk.filter(item => !existingIds.has(item.content.id)); + + if (uniqueNewItems.length > 0) { + this.feedPage.update(page => page + uniqueNewItems.length); + this.feedItems.update(items => [...items, ...uniqueNewItems]); + } }) ); } @@ -295,17 +226,6 @@ 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/filter/feed-filter.component.html b/projects/social_platform/src/app/office/feed/filter/feed-filter.component.html index 5156e68cf..c559a9872 100644 --- a/projects/social_platform/src/app/office/feed/filter/feed-filter.component.html +++ b/projects/social_platform/src/app/office/feed/filter/feed-filter.component.html @@ -1,10 +1,6 @@
-
-

Фильтр

- Сбросить фильтр -
@@ -27,24 +23,26 @@

Фильтр

@if (filterOpen()) { }
-Написать новость -
- Виды новостей -
    - @for (option of filterOptions; track $index) { -
  • - - {{ option.label }} -
  • - } -
+ @for (filterItem of feedFilterOptions; track $index) { +
+
+ +
+

{{ filterItem.name }}

+
+ }
diff --git a/projects/social_platform/src/app/office/feed/filter/feed-filter.component.scss b/projects/social_platform/src/app/office/feed/filter/feed-filter.component.scss index c2b1942cc..640a68b85 100644 --- a/projects/social_platform/src/app/office/feed/filter/feed-filter.component.scss +++ b/projects/social_platform/src/app/office/feed/filter/feed-filter.component.scss @@ -1,46 +1,6 @@ @use "styles/typography"; @use "styles/responsive"; -:host { - width: 100%; -} - -.desktop { - display: none; - flex-direction: column; - gap: 20px; - align-items: center; - width: 100%; - padding: 20px; - margin-bottom: 20px; - background-color: var(--white); - border: 1px solid var(--medium-grey-for-outline); - border-radius: 15px; - - @include responsive.apply-desktop { - display: flex; - } - - &__header { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - } - - &__actions { - align-self: flex-start; - width: 50%; - - app-button { - &::ng-deep .button--inline { - min-height: 38px; - padding: 0; - } - } - } -} - .mobile { display: grid; grid-template-columns: 0.6fr 0.4fr; @@ -114,7 +74,64 @@ } .filter { - width: 100%; + display: flex; + gap: 20px; + align-items: center; + justify-content: space-between; + + &__option { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 156px; + height: 66px; + padding: 40px 24px 12px; + cursor: pointer; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-xl); + + &--disable { + cursor: not-allowed; + opacity: 0.5; + } + + &--active { + i { + color: var(--light-white) !important; + } + + .filter__option--icon { + background-color: var(--accent); + } + } + + &--icon { + position: absolute; + bottom: 100%; + left: 50%; + display: flex; + align-items: center; + justify-content: center; + width: 50px; + height: 50px; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: 50%; + transform: translate(-50%, 50%); + + i { + color: var(--accent); + } + } + + p { + color: var(--grey-for-text); + text-align: center; + } + } span { display: block; 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 684a1a981..32d0ae57d 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 @@ -17,6 +17,7 @@ import { FeedService } from "@office/feed/services/feed.service"; import { User } from "@auth/models/user.model"; import { AuthService } from "@auth/services"; import { Subscription } from "rxjs"; +import { feedFilter } from "projects/core/src/consts/filters/feed-filter.const"; /** * КОМПОНЕНТ ФИЛЬТРАЦИИ ЛЕНТЫ @@ -85,9 +86,9 @@ export class FeedFilterComponent implements OnInit, OnDestroy { // Читаем активные фильтры из URL const routeSubscription = this.route.queryParams.subscribe(queries => { if (queries["includes"]) { - this.includedFilters.set(queries["includes"].split(this.feedService.FILTER_SPLIT_SYMBOL)); + this.includedFilters.set(queries["includes"]); } else { - this.includedFilters.set([]); + this.includedFilters.set(""); } }); @@ -108,14 +109,10 @@ export class FeedFilterComponent implements OnInit, OnDestroy { * - label: отображаемое название на русском языке * - value: значение для API запроса */ - filterOptions = [ - { label: "Новости", value: "news" }, - { label: "Вакансии", value: "vacancy" }, - { label: "Новости проектов", value: "project" }, - ]; + readonly feedFilterOptions = feedFilter; // Массив активных фильтров - includedFilters = signal([]); + includedFilters = signal(""); /** * ОБНОВЛЕНИЕ URL С ТЕКУЩИМИ ФИЛЬТРАМИ @@ -124,10 +121,7 @@ export class FeedFilterComponent implements OnInit, OnDestroy { * Вызывается автоматически при любом изменении фильтров. */ private updateUrl(): void { - const includesParam = - this.includedFilters().length > 0 - ? this.includedFilters().join(this.feedService.FILTER_SPLIT_SYMBOL) - : null; + const includesParam = this.includedFilters().length > 0 ? this.includedFilters() : null; this.router .navigate([], { @@ -144,27 +138,42 @@ export class FeedFilterComponent implements OnInit, OnDestroy { * ПЕРЕКЛЮЧЕНИЕ ФИЛЬТРА С МГНОВЕННЫМ ОБНОВЛЕНИЕМ URL * * ЧТО ПРИНИМАЕТ: + * @param id - id для фильтра * @param keyword - значение фильтра для переключения * * ЧТО ДЕЛАЕТ: * - Добавляет фильтр, если он не активен * - Удаляет фильтр, если он уже активен + * - Обрабатывает переключение между projects и projects/1 * - Мгновенно обновляет URL параметры */ setFilter(keyword: string): void { this.includedFilters.update(included => { - const newIncluded = [...included]; + if (keyword.startsWith("projects/")) { + // Если уже активен этот же вложенный фильтр - сбрасываем к "projects" + if (included === keyword) { + return "projects"; + } + return keyword; + } - if (newIncluded.indexOf(keyword) !== -1) { - // Удаляем фильтр, если он уже активен - const idx = newIncluded.indexOf(keyword); - newIncluded.splice(idx, 1); - } else { - // Добавляем новый фильтр - newIncluded.push(keyword); + // Если кликнули на "projects" + if (keyword === "projects") { + if (included.startsWith("projects/")) { + return "projects"; + } + + if (included === "projects") { + return ""; + } + + return "projects"; } - return newIncluded; + if (included === keyword) { + return ""; + } + return keyword; }); // Мгновенно обновляем URL @@ -180,7 +189,7 @@ export class FeedFilterComponent implements OnInit, OnDestroy { * - Возвращает ленту к состоянию по умолчанию */ resetFilter(): void { - this.includedFilters.set([]); + this.includedFilters.set(""); this.updateUrl(); } 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 066060bbc..740d0cc1b 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 @@ -70,7 +70,7 @@ export class FeedService { // Обработка различных форматов параметра type if (type.length === 0) { // Если фильтры не выбраны, загружаем все типы по умолчанию - reqType = ["vacancy", "news", "project"].join(this.FILTER_SPLIT_SYMBOL); + reqType = ["vacancy", "news", "projects"].join(this.FILTER_SPLIT_SYMBOL); } else if (Array.isArray(type)) { // Если передан массив типов, объединяем их через разделитель reqType = type.join(this.FILTER_SPLIT_SYMBOL); diff --git a/projects/social_platform/src/app/office/feed/shared/closed-vacancy/closed-vacancy.component.scss b/projects/social_platform/src/app/office/feed/shared/closed-vacancy/closed-vacancy.component.scss index e49466213..1a2c93761 100644 --- a/projects/social_platform/src/app/office/feed/shared/closed-vacancy/closed-vacancy.component.scss +++ b/projects/social_platform/src/app/office/feed/shared/closed-vacancy/closed-vacancy.component.scss @@ -5,7 +5,7 @@ padding: 20px; background-color: var(--white); border: 1px solid var(--medium-grey-for-outline); - border-radius: 15px; + border-radius: var(--rounded-xl); &__head { margin-bottom: 10px; @@ -68,7 +68,7 @@ justify-content: space-between; padding: 10px 20px; border: 1px solid var(--medium-grey-for-outline); - border-radius: 15px; + border-radius: var(--rounded-xl); } &__job { @@ -77,7 +77,7 @@ margin-bottom: 15px; text-align: center; border: 1px solid var(--medium-grey-for-outline); - border-radius: 15px; + border-radius: var(--rounded-xl); @include typography.bold-body-14; diff --git a/projects/social_platform/src/app/office/feed/shared/new-project/new-project.component.html b/projects/social_platform/src/app/office/feed/shared/new-project/new-project.component.html index 3f0595d5a..f6b9db800 100644 --- a/projects/social_platform/src/app/office/feed/shared/new-project/new-project.component.html +++ b/projects/social_platform/src/app/office/feed/shared/new-project/new-project.component.html @@ -1,25 +1,63 @@
-

- Добро пожаловать на платформу! - {{ feedItem.name }} -

- - - Посмотреть проект +
+ + +
+
+

{{ feedItem.name }}

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

+ @if (industryService.getIndustry(industries, feedItem.industry); as industry) { + + {{ industry.name }} + + } +

+ } + + +
+ +

{{ feedItem.shortDescription }}

+
+
+ +
+ поддержать проект + + перейти в проект +
diff --git a/projects/social_platform/src/app/office/feed/shared/new-project/new-project.component.scss b/projects/social_platform/src/app/office/feed/shared/new-project/new-project.component.scss index 65ea011f9..c74734080 100644 --- a/projects/social_platform/src/app/office/feed/shared/new-project/new-project.component.scss +++ b/projects/social_platform/src/app/office/feed/shared/new-project/new-project.component.scss @@ -2,48 +2,60 @@ @use "styles/responsive"; .card { - display: flex; - flex-direction: column; - align-items: center; - padding: 20px; - background-color: var(--white); - border: 1px solid var(--medium-grey-for-outline); - border-radius: 15px; - - &__title { + padding: 37px 24px 24px; + color: var(--light-white); + background-color: var(--accent); + border-radius: var(--rounded-xl); + + &__avatar { display: flex; - flex-direction: column; - gap: 4px; align-items: center; + justify-content: center; text-align: center; + } + + &__project { + display: grid; + grid-template-columns: 2fr 3fr; + grid-gap: 40px; + margin-bottom: 20px; + } - @include typography.bold-body-14; + &__info { + display: flex; + flex-direction: column; - @include responsive.apply-desktop { - @include typography.heading-3; + &:first-child { + margin-bottom: 4px; } - } - &__description { - margin-bottom: 10px; - color: var(--grey-for-text); + &--main, + &--additional { + display: flex; + align-items: center; + justify-content: space-between; + } } - &__avatar { - margin: 15px 0; + &__industry { + display: inline-flex; } - &__text { - margin-bottom: 20px; - color: var(--black); - - @include typography.body-12; + &__actions { + display: flex; + gap: 10px; + align-items: center; + width: 100%; + } - @include responsive.apply-desktop { - max-width: 580px; - margin-bottom: 5px; + &__score { + display: flex; + gap: 3px; + align-items: center; + } - @include typography.body-14; - } + &__description { + margin-top: 7px; + color: var(--white); } } 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 5d5103ec6..16a0741f1 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 @@ -2,10 +2,13 @@ import { Component, Input } from "@angular/core"; import { CommonModule } from "@angular/common"; -import { ButtonComponent } from "@ui/components"; +import { ButtonComponent, IconComponent } from "@ui/components"; import { AvatarComponent } from "@ui/components/avatar/avatar.component"; import { Router, RouterLink } from "@angular/router"; import { FeedProject } from "@office/feed/models/feed-item.model"; +import { DayjsPipe } from "@corelib"; +import { IndustryService } from "@office/services/industry.service"; +import { TagComponent } from "@ui/components/tag/tag.component"; /** * КОМПОНЕНТ НОВОГО ПРОЕКТА @@ -35,36 +38,27 @@ import { FeedProject } from "@office/feed/models/feed-item.model"; @Component({ selector: "app-new-project", standalone: true, - imports: [CommonModule, ButtonComponent, AvatarComponent, RouterLink], + imports: [ + CommonModule, + ButtonComponent, + AvatarComponent, + RouterLink, + DayjsPipe, + IconComponent, + TagComponent, + ], templateUrl: "./new-project.component.html", 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) {} + constructor(public readonly industryService: IndustryService) {} } 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 25faa2e92..066187810 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 @@ -1,71 +1,107 @@ +@if (feedItem) {
- - newsItem.name -
-
{{ feedItem.project.name }}
-
- {{ feedItem.datetimeCreated | dayjs: "format":"DD MMMM YYYY, HH:mm" }} -
+
+ vacancy-card-background + @if (feedItem.project; as project) { +
+ + +

{{ project.name }}

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

- Тебя ищут в проект - {{ - feedItem.project.name - }} -

+ } @if (feedItem; as vacancy) { +
+
+

{{ vacancy.role }}

+

{{ vacancy.datetimeCreated | dayjs: "format":"DD MM YY" }}

+
-
- @if (feedItem.requiredSkills.length; as skillsLength) { -
Необходимые навыки
- @if (feedItem.requiredSkills; as requiredSkills) { @if (requiredSkills) { -
    - @for (skill of requiredSkills.slice(0, 8); track $index) { - {{ skill.name }} - } -
- } -
- @if (requiredSkills) { -
    - @for (skill of requiredSkills.slice(8); track $index) { - {{ skill.name }} +
    + @if (vacancy.requiredSkills?.length; as skillsLength) { @if (vacancy.requiredSkills; as + requiredSkills) { @if (requiredSkills) { +
      + @for (skill of requiredSkills.slice(0, 3); track $index) { + {{ skill.name }} + } +
    } -
- } -
- } @if (skillsLength > 8) { -
- {{ readFullSkills ? "Скрыть" : "Читать полностью" }} -
- } } -
+
+ @if (requiredSkills) { +
    + @for (skill of requiredSkills.slice(8); track $index) { + {{ skill.name }} + } +
+ } +
+ } @if (skillsLength > 8) { +
+ {{ readFullSkills ? "Скрыть" : "Читать полностью" }} +
+ } } +
- @if (feedItem.description) { -
-
-

- @if (descriptionExpandable) { -
- {{ readFullDescription ? "Скрыть" : "Читать полностью" }} + @if (feedItem.description) { +
+
+

+ @if (descriptionExpandable) { +
+ {{ readFullDescription ? "Скрыть" : "Читать полностью" }} +
+ } +
}
+ }
- } -
- {{ feedItem.role }} + + @if (feedItem) { +
Откликнутьсяперейти в проект + откликнуться на вакансию
+ }
+} diff --git a/projects/social_platform/src/app/office/feed/shared/open-vacancy/open-vacancy.component.scss b/projects/social_platform/src/app/office/feed/shared/open-vacancy/open-vacancy.component.scss index 52b2f16bc..57ae20c5f 100644 --- a/projects/social_platform/src/app/office/feed/shared/open-vacancy/open-vacancy.component.scss +++ b/projects/social_platform/src/app/office/feed/shared/open-vacancy/open-vacancy.component.scss @@ -30,32 +30,90 @@ } .card { - padding: 20px; - background-color: var(--white); - border: 1px solid var(--medium-grey-for-outline); - border-radius: 15px; + position: relative; + z-index: 100; + padding: 27px 24px 24px; + overflow-y: hidden; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-xl); + + &__inner { + display: grid; + grid-template-columns: 2fr 3fr; + gap: 10px; + align-items: flex-start; + } - &__head { - margin-bottom: 10px; + &__background { + position: absolute; + top: 0; + left: 0; + z-index: 0; } - &__title { + &__industry { + display: inline-flex; + } + + &__description { + overflow-wrap: break-word; + white-space: pre-wrap; + } + + &__project { display: flex; flex-direction: column; - gap: 5px; + gap: 6px; align-items: center; - margin-bottom: 10px; + text-align: center; + } - @include typography.bold-body-16; + &__vacancy { + display: flex; + flex-direction: column; + gap: 8px; } - &__description { - overflow-wrap: break-word; - white-space: pre-wrap; + &__actions { + display: flex; + gap: 10px; + align-items: center; + margin-top: 15px; + + &:last-child { + ::ng-deep { + app-button { + .button--big { + padding: 4px 16px; + } + } + } + } } +} - &__action { - margin-top: 20px; +.lists { + &__section { + display: flex; + justify-content: space-between; + margin-bottom: 8px; + border-bottom: 0.5px solid var(--accent); + } + + &__icon { + color: var(--accent); + } + + &__title { + margin-bottom: 8px; + color: var(--accent); + } + + &__item { + display: flex; + gap: 6px; + align-items: center; } } @@ -108,51 +166,12 @@ &:hover { color: var(--accent-dark); } - - @include typography.body-14; -} - -.head { - display: flex; - align-items: center; - - &__avatar { - width: 40px; - height: 40px; - margin-right: 10px; - border-radius: 50%; - object-fit: cover; - } - - &__name { - max-width: 200px; - overflow: hidden; - color: var(--black); - text-overflow: ellipsis; - white-space: nowrap; - - @include typography.bold-body-14; - - @include responsive.apply-desktop { - @include typography.bold-body-16; - } - } - - &__date { - color: var(--dark-grey); - } } .skills { &__title { margin-bottom: 5px; color: var(--dark-grey); - - @include typography.body-14; - - @include responsive.apply-desktop { - margin-bottom: 10px; - } } &__list { @@ -169,36 +188,3 @@ @include expandable-list; } - -.action { - @include responsive.apply-desktop { - display: flex; - align-items: center; - justify-content: space-between; - padding: 10px 20px; - border: 1px solid var(--medium-grey-for-outline); - border-radius: 15px; - } - - &__job { - display: block; - padding: 20px 0; - margin-bottom: 15px; - text-align: center; - cursor: pointer; - border: 1px solid var(--medium-grey-for-outline); - border-radius: 15px; - - @include typography.bold-body-14; - - @include responsive.apply-desktop { - padding: 0; - margin-bottom: 0; - border: none; - } - } - - &__button { - width: 150px; - } -} diff --git a/projects/social_platform/src/app/office/feed/shared/open-vacancy/open-vacancy.component.ts b/projects/social_platform/src/app/office/feed/shared/open-vacancy/open-vacancy.component.ts index fef15eab3..b5e63b150 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 @@ -15,26 +15,25 @@ import { Router, RouterLink } from "@angular/router"; import { TagComponent } from "@ui/components/tag/tag.component"; import { Vacancy } from "@models/vacancy.model"; import { expandElement } from "@utils/expand-element"; +import { IndustryService } from "@office/services/industry.service"; +import { AvatarComponent } from "@ui/components/avatar/avatar.component"; +import { AdvertCardComponent } from "@office/shared/advert-card/advert-card.component"; /** - * КОМПОНЕНТ ОТКРЫТОЙ ВАКАНСИИ * * Отображает карточку активной вакансии в ленте новостей с полным функционалом. * Поддерживает развертывание/свертывание длинного контента и интерактивные элементы. * - * ОСНОВНЫЕ ФУНКЦИИ: * - Отображение полной информации о вакансии * - Развертывание/свертывание описания и списка навыков * - Навигация к детальной странице вакансии * - Форматирование текста с поддержкой ссылок и переносов строк * - Отображение тегов и навыков * - * ИНТЕРАКТИВНЫЕ ЭЛЕМЕНТЫ: * - Кнопки "Показать полностью" / "Свернуть" * - Теги навыков и требований * - Ссылки на детальную страницу * - * ИСПОЛЬЗУЕМЫЕ ПАЙПЫ: * - DayjsPipe: форматирование дат * - ParseLinksPipe: преобразование ссылок в кликабельные элементы * - ParseBreaksPipe: обработка переносов строк @@ -50,21 +49,16 @@ import { expandElement } from "@utils/expand-element"; DayjsPipe, ParseLinksPipe, ParseBreaksPipe, + AvatarComponent, + AdvertCardComponent, ], templateUrl: "./open-vacancy.component.html", styleUrl: "./open-vacancy.component.scss", }) export class OpenVacancyComponent implements AfterViewInit { - /** - * ВХОДНЫЕ ДАННЫЕ - * - * @Input feedItem - объект вакансии для отображения - * Содержит всю информацию о вакансии: название, описание, требования, навыки и т.д. - */ @Input() feedItem!: Vacancy; /** - * ССЫЛКИ НА DOM ЭЛЕМЕНТЫ * * @ViewChild skillsEl - ссылка на элемент со списком навыков * @ViewChild descEl - ссылка на элемент с описанием вакансии @@ -74,17 +68,12 @@ export class OpenVacancyComponent implements AfterViewInit { @ViewChild("skillsEl") skillsEl?: ElementRef; @ViewChild("descEl") descEl?: ElementRef; - constructor(public readonly router: Router, private readonly cdRef: ChangeDetectorRef) {} + constructor( + public readonly router: Router, + private readonly cdRef: ChangeDetectorRef, + public readonly industryService: IndustryService + ) {} - /** - * ИНИЦИАЛИЗАЦИЯ ПОСЛЕ ОТРИСОВКИ - * - * ЧТО ДЕЛАЕТ: - * - Проверяет, нужны ли кнопки развертывания для описания и навыков - * - Сравнивает высоту контента с высотой контейнера - * - Устанавливает флаги для показа кнопок "Показать полностью" - * - Запускает обнаружение изменений для обновления UI - */ ngAfterViewInit(): void { // Проверяем, превышает ли описание доступную высоту const descElement = this.descEl?.nativeElement; @@ -107,14 +96,11 @@ export class OpenVacancyComponent implements AfterViewInit { readFullSkills = false; // Развернут ли список навыков /** - * РАЗВЕРТЫВАНИЕ/СВЕРТЫВАНИЕ ОПИСАНИЯ * - * ЧТО ПРИНИМАЕТ: * @param elem - DOM элемент для анимации * @param expandedClass - CSS класс для развернутого состояния * @param isExpanded - текущее состояние (развернуто/свернуто) * - * ЧТО ДЕЛАЕТ: * - Переключает визуальное состояние описания * - Применяет анимацию развертывания/свертывания * - Обновляет флаг состояния @@ -125,9 +111,7 @@ export class OpenVacancyComponent implements AfterViewInit { } /** - * РАЗВЕРТЫВАНИЕ/СВЕРТЫВАНИЕ СПИСКА НАВЫКОВ * - * ЧТО ПРИНИМАЕТ: * @param elem - DOM элемент для анимации * @param expandedClass - CSS класс для развернутого состояния * @param isExpanded - текущее состояние (развернуто/свернуто) diff --git a/projects/social_platform/src/app/office/members/filters/members-filters.component.html b/projects/social_platform/src/app/office/members/filters/members-filters.component.html index 7328a5692..7e53a8c1a 100644 --- a/projects/social_platform/src/app/office/members/filters/members-filters.component.html +++ b/projects/social_platform/src/app/office/members/filters/members-filters.component.html @@ -2,45 +2,43 @@
-

Фильтр

- Сбросить фильтры +

фильтры

+ сбросить
-
-

Специальность

+
-
-

Навык

+
-
+
diff --git a/projects/social_platform/src/app/office/members/filters/members-filters.component.scss b/projects/social_platform/src/app/office/members/filters/members-filters.component.scss index d8b413ce8..cba283cf1 100644 --- a/projects/social_platform/src/app/office/members/filters/members-filters.component.scss +++ b/projects/social_platform/src/app/office/members/filters/members-filters.component.scss @@ -4,18 +4,8 @@ .filters { display: flex; flex-direction: column; - gap: 0; - width: 100%; - min-width: 280px; - padding: 26px; - margin-bottom: 40px; - background-color: var(--white); - border-radius: 8px; - - @include responsive.apply-desktop { - gap: 24px; - padding: 26px 16px; - } + gap: 14px; + margin-top: 10px; &__titles { display: flex; @@ -24,24 +14,19 @@ } &__clear { + color: var(--accent); cursor: pointer; } &__title { color: var(--black); - - @include typography.heading-3; - - @include responsive.apply-desktop { - @include typography.bold-body-16; - } } &__block { margin-bottom: 0; @include responsive.apply-desktop { - margin-bottom: 10px; + margin-bottom: 14px; } .filter__name { @@ -61,45 +46,15 @@ } } - &__controls { - display: grid; - grid-template-rows: 1fr; - overflow: unset; - - form { - display: flex; - flex-direction: column; - gap: 20px; - min-height: 0; - transition: visibility 0.5s; - - @include responsive.apply-desktop { - gap: 5px; - } - } - } - &__age { display: flex; gap: 10px; align-items: center; - - // width: 168px; font-size: 12px; color: var(--black); span { color: var(--dark-grey); - - @include typography.body-16; - } - } - - &__autocomplete { - flex-grow: 1; - - @include responsive.apply-desktop { - flex-grow: unset; } } } diff --git a/projects/social_platform/src/app/office/members/members.component.html b/projects/social_platform/src/app/office/members/members.component.html index 6b973c71a..b07269c27 100644 --- a/projects/social_platform/src/app/office/members/members.component.html +++ b/projects/social_platform/src/app/office/members/members.component.html @@ -1,24 +1,41 @@ -
-

Участники

-
- -
-
-
    - @for (member of members; track member.id) { - -
  • - -
  • -
    - } -
- +
+ + +
+
+
+
+ +
+ +
    + @for (member of members; track member.id) { + +
  • + +
  • +
    + } +
+
+
+ +
+ + перейти в профиль + + +
+ + + +
+
diff --git a/projects/social_platform/src/app/office/members/members.component.scss b/projects/social_platform/src/app/office/members/members.component.scss index 793d9123f..66da90707 100644 --- a/projects/social_platform/src/app/office/members/members.component.scss +++ b/projects/social_platform/src/app/office/members/members.component.scss @@ -1,28 +1,51 @@ @use "styles/responsive"; -@use "styles/typography"; -.members { - &__title { - display: none; +.page { + &__bar { + display: grid; + grid-template-columns: 8fr 2fr; + grid-gap: 20px; + justify-content: space-between; + } + + &__list { + display: grid; + flex-grow: 1; + grid-template-columns: 1fr; + row-gap: 50px; + column-gap: 20px; + align-items: flex-start; + margin-top: 50px; @include responsive.apply-desktop { - display: block; - margin-bottom: 14px; + grid-template-columns: repeat(4, 2fr); } } - &__content { + &__info { display: flex; - flex-direction: column-reverse; + justify-content: space-between; - @include responsive.apply-desktop { - flex-direction: row; - gap: 24px; - justify-content: space-between; + app-search { + width: 100%; } } - &__list { - flex-basis: 73%; + &__outlet { + display: flex; + flex-direction: column; + flex-grow: 1; + gap: 20px; + width: 100%; + margin-top: 10px; + } + + &__create { + display: inline-flex; + flex-direction: column; + } + + &__left { + width: 157px; } } 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 511c8c21c..dff7d51e5 100644 --- a/projects/social_platform/src/app/office/members/members.component.ts +++ b/projects/social_platform/src/app/office/members/members.component.ts @@ -17,6 +17,7 @@ import { concatMap, debounceTime, distinctUntilChanged, + filter, fromEvent, map, noop, @@ -39,11 +40,16 @@ import { } from "@angular/forms"; import { containerSm } from "@utils/responsive"; import { MemberService } from "@services/member.service"; -import { MemberCardComponent } from "../shared/member-card/member-card.component"; import { CommonModule } from "@angular/common"; import { SearchComponent } from "@ui/components/search/search.component"; import { MembersFiltersComponent } from "./filters/members-filters.component"; import { ApiPagination } from "@models/api-pagination.model"; +import { InfoCardComponent } from "@office/features/info-card/info-card.component"; +import { BackComponent } from "@uilib"; +import { ButtonComponent } from "@ui/components"; +import { ProfileDataService } from "@office/profile/detail/services/profile-date.service"; +import { AuthService } from "@auth/services"; +import { SoonCardComponent } from "@office/shared/soon-card/soon-card.component"; /** * Компонент для отображения списка участников с возможностью поиска и фильтрации @@ -68,8 +74,11 @@ import { ApiPagination } from "@models/api-pagination.model"; SearchComponent, CommonModule, RouterLink, - MemberCardComponent, MembersFiltersComponent, + InfoCardComponent, + BackComponent, + ButtonComponent, + SoonCardComponent, ], }) export class MembersComponent implements OnInit, OnDestroy, AfterViewInit { @@ -93,6 +102,7 @@ export class MembersComponent implements OnInit, OnDestroy, AfterViewInit { private readonly navService: NavService, private readonly fb: FormBuilder, private readonly memberService: MemberService, + private readonly authService: AuthService, private readonly cdref: ChangeDetectorRef, private readonly renderer: Renderer2 ) { @@ -126,6 +136,14 @@ export class MembersComponent implements OnInit, OnDestroy, AfterViewInit { // Устанавливаем заголовок страницы this.navService.setNavTitle("Участники"); + const profileIdSub$ = this.authService.profile.pipe(filter(user => !!user)).subscribe({ + next: user => { + this.profileId = user.id; + }, + }); + + profileIdSub$ && this.subscriptions$.push(profileIdSub$); + // Загружаем начальные данные участников из резолвера this.route.data .pipe( @@ -217,6 +235,8 @@ export class MembersComponent implements OnInit, OnDestroy, AfterViewInit { members: User[] = []; // Массив участников для отображения + profileId?: number; + searchParamsSubject$ = new BehaviorSubject>({}); // Subject для параметров поиска searchForm: FormGroup; // Форма поиска @@ -296,4 +316,8 @@ export class MembersComponent implements OnInit, OnDestroy, AfterViewInit { }) ); } + + redirectToProfile(): void { + this.router.navigateByUrl(`/office/profile/${this.profileId}`); + } } diff --git a/projects/social_platform/src/app/office/models/goals.model.ts b/projects/social_platform/src/app/office/models/goals.model.ts new file mode 100644 index 000000000..7a4aac24f --- /dev/null +++ b/projects/social_platform/src/app/office/models/goals.model.ts @@ -0,0 +1,36 @@ +/** + * Основная модель целей проекта + * Представляет цели со всей необходимой информацией + * + * Goal содержит: + * - Основную информацию (проект, ответственного, название и дату) + * - выполнена или нет цель + * - полную информацию о человеке, который ответсвенен за цель + * + * @format + */ + +class ResponsibleInfo { + id!: number; + firstName!: string; + lastName!: string; + avatar!: string | null; +} + +export class GoalPostForm { + id?: number; + title!: string; + completionDate!: string; + responsible!: number; + isDone!: boolean; +} + +export class Goal { + id!: number; + project!: number; + title!: string; + completionDate!: string; + responsible!: number; + responsibleInfo!: ResponsibleInfo; + isDone!: boolean; +} diff --git a/projects/social_platform/src/app/office/models/partner.model.ts b/projects/social_platform/src/app/office/models/partner.model.ts new file mode 100644 index 000000000..c15980716 --- /dev/null +++ b/projects/social_platform/src/app/office/models/partner.model.ts @@ -0,0 +1,22 @@ +/** @format */ + +interface Company { + id: number; + name: string; + inn: string; +} + +export interface Partner { + id: number; + projecId: number; + company: Company; + contribution: string; + decisionMaker: number; +} + +export interface PartnerPostForm { + name: string; + inn: string; + contribution: string; + decisionMaker: number; +} diff --git a/projects/social_platform/src/app/office/models/project.model.ts b/projects/social_platform/src/app/office/models/project.model.ts index c600b53b4..eead36f68 100644 --- a/projects/social_platform/src/app/office/models/project.model.ts +++ b/projects/social_platform/src/app/office/models/project.model.ts @@ -1,7 +1,10 @@ /** @format */ import { Collaborator } from "./collaborator.model"; +import { Goal } from "./goals.model"; import { PartnerProgramFields, PartnerProgramFieldsValues } from "./partner-program-fields.model"; +import { Partner } from "./partner.model"; +import { Resource } from "./resource.model"; import { Vacancy } from "./vacancy.model"; /** @@ -30,15 +33,17 @@ export class Project { id!: number; name!: string; description!: string; - track!: string; - direction!: string; + targetAudience!: string; + implementationDeadline!: string; + trl!: string; actuality!: string; - goal!: string; problem!: string; region!: string; - step!: number; shortDescription!: string; achievements!: { id: number; title: string; status: string }[]; + partners!: Partner[]; + resources!: Resource[]; + goals!: Goal[]; industry!: number; presentationAddress!: string; imageAddress!: string; @@ -51,26 +56,30 @@ export class Project { links!: string[]; draft!: boolean; leader!: number; + leaderInfo?: { firstName: string; lastName: string }; partnerProgramsTags?: string[]; partnerProgramId!: number | null; partnerProgram!: PartnerProgramInfo | null; vacancies!: Vacancy[]; isCompany!: boolean; + inviteId!: number; static default(): Project { return { id: 0, name: "string", region: "sdf", - step: 1, - track: "", - direction: "", + targetAudience: "", + implementationDeadline: "", actuality: "", - goal: "", + trl: "", problem: "", description: "string", shortDescription: "string", achievements: [{ id: 3, title: "sdf", status: "dsaf" }], + partners: [], + resources: [], + goals: [], industry: 0, viewsCount: 0, links: [], @@ -86,6 +95,7 @@ export class Project { leader: 0, vacancies: [], isCompany: true, + inviteId: 0, }; } } diff --git a/projects/social_platform/src/app/office/models/resource.model.ts b/projects/social_platform/src/app/office/models/resource.model.ts new file mode 100644 index 000000000..ca4a5542c --- /dev/null +++ b/projects/social_platform/src/app/office/models/resource.model.ts @@ -0,0 +1,16 @@ +/** @format */ + +export interface Resource { + id: number; + projectId: number; + type: "infrastructure" | "staff" | "financial" | "information"; + description: string; + partnerCompany: number; +} + +export interface ResourcePostForm { + projectId: number; + type: string; + description: string; + partnerCompany: number; +} diff --git a/projects/social_platform/src/app/office/office.component.html b/projects/social_platform/src/app/office/office.component.html index 3e3862a09..9fd78ca19 100644 --- a/projects/social_platform/src/app/office/office.component.html +++ b/projects/social_platform/src/app/office/office.component.html @@ -2,45 +2,54 @@
@if (authService.profile | async; as user) { - - @if (user !== undefined && invites !== undefined) { - - } -
-
- Траектории -
-
PRO
-
-
- }
+ background-image
- +
+ +
+

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

+

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

+

2022

+
+
+ +
+ @if (user !== undefined && invites !== undefined) { + + } + +
+
+ }
wait -

Ваш аккаунт проходит подтверждение

-

+

Ваш аккаунт проходит подтверждение

+

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

@@ -52,10 +61,8 @@

Ваш аккаунт про
-

- Приглашение на текущий проект было удалено -

-

+

Приглашение на текущий проект было удалено

+

Проверьте наличие вас в списке участников проекта или обратитесь к создателю проекта, чтобы вас заново пригласили!

diff --git a/projects/social_platform/src/app/office/office.component.scss b/projects/social_platform/src/app/office/office.component.scss index 86d90ca01..9738a8301 100644 --- a/projects/social_platform/src/app/office/office.component.scss +++ b/projects/social_platform/src/app/office/office.component.scss @@ -3,9 +3,19 @@ @use "styles/responsive"; .office { + position: relative; display: flex; height: 100%; - background-color: var(--light-gray); + background-color: var(--white); + + &__background-image { + position: absolute; + top: 0; + left: 0; + width: 100%; + max-width: 130px; + padding-right: 20px; + } &__wrapper { display: flex; @@ -14,43 +24,83 @@ min-width: 0; } - &__sidebar { - display: none; - - @include responsive.apply-desktop { - display: block; - flex-shrink: 0; - width: 234px; - height: 100vh; - } + &__top { + flex-shrink: 0; } &__body { + display: flex; flex-grow: 1; - padding: 10px 10px 0; + justify-content: center; + padding: 0 200px; overflow-y: auto; - @include responsive.apply-desktop { - padding: 20px 20px 0; + @media (max-width: 1600px) { + padding: 0 150px; + } + + @media (max-width: 1400px) { + padding: 0 100px; + } + + @media (max-width: 1200px) { + padding: 0 50px; + } + + @media (max-width: 992px) { + padding: 0 20px; + } + + @media (max-width: 768px) { + padding: 0 15px; } } - &__header { + &__inner { + display: flex; + width: 100%; + max-width: 1040px; + height: 100%; + + &--wrapper { + display: grid; + grid-template-columns: 2fr 10fr; + width: 100%; + + @media (max-width: 992px) { + grid-template-columns: 1fr; + } + } + + &--content { + flex-grow: 1; + min-width: 0; + } + } + + &__sidebar { display: none; - background-color: var(--white); @include responsive.apply-desktop { display: block; } + + &--text { + display: flex; + flex-direction: column; + gap: 2px; + width: 70%; + margin-top: 30px; + color: var(--dark-grey); + } } - &__inner { - height: 100%; - max-height: 100%; + &__header { + display: none; + background-color: var(--white); @include responsive.apply-desktop { - max-width: responsive.$container-md; - margin: 0 auto; + display: block; } } } @@ -59,52 +109,46 @@ display: flex; flex-direction: column; align-items: center; - max-width: 402px; + padding: 20px; + text-align: center; &__title { - margin: 18px 0; - color: var(--black); - text-align: center; + margin: 15px 0 10px; } &__text { - color: var(--dark-grey); - text-align: center; + max-width: 300px; + margin-bottom: 20px; } &__button { - margin-top: 18px; + min-width: 100px; } } -.pro-item { - display: flex; - align-items: center; - justify-content: space-between; - margin-top: 7px; - margin-right: 20px; - color: var(--grey-for-text); - cursor: pointer; - transition: color 0.2s; - - &:hover { - color: var(--accent); +.container { + width: 100%; + max-width: 1040px; + padding: 0 200px; + margin: 0 auto; + + @media (max-width: 1600px) { + padding: 0 150px; } - &__name { - display: flex; - align-items: center; + @media (max-width: 1400px) { + padding: 0 100px; + } - i { - margin-right: 12px; - } + @media (max-width: 1200px) { + padding: 0 50px; + } + + @media (max-width: 992px) { + padding: 0 20px; } - &__badge { - padding: 2px 10px; - margin-left: auto; - color: var(--white); - background-color: var(--accent); - border-radius: 12px; + @media (max-width: 768px) { + padding: 0 15px; } } diff --git a/projects/social_platform/src/app/office/office.component.ts b/projects/social_platform/src/app/office/office.component.ts index ededf8a03..3c82830dd 100644 --- a/projects/social_platform/src/app/office/office.component.ts +++ b/projects/social_platform/src/app/office/office.component.ts @@ -13,7 +13,7 @@ import { SnackbarComponent } from "@ui/components/snackbar/snackbar.component"; import { DeleteConfirmComponent } from "@ui/components/delete-confirm/delete-confirm.component"; import { ButtonComponent } from "@ui/components"; import { ModalComponent } from "@ui/components/modal/modal.component"; -import { NavComponent } from "./shared/nav/nav.component"; +import { NavComponent } from "./features/nav/nav.component"; import { IconComponent, ProfileControlPanelComponent, SidebarComponent } from "@uilib"; import { AsyncPipe } from "@angular/common"; import { InviteService } from "@services/invite.service"; @@ -54,21 +54,42 @@ export class OfficeComponent implements OnInit, OnDestroy { private readonly industryService: IndustryService, private readonly route: ActivatedRoute, public readonly authService: AuthService, - private readonly projectService: ProjectService, private readonly inviteService: InviteService, private readonly router: Router, public readonly chatService: ChatService ) {} + invites: Signal = toSignal( + this.route.data.pipe( + map(r => r["invites"]), + map(invites => invites.filter((invite: Invite) => invite.isAccepted === null)) + ) + ); + + profile?: User; + + waitVerificationModal = false; + waitVerificationAccepted = false; + + inviteErrorModal = false; + + navItems: { + name: string; + icon: string; + link: string; + isExternal?: boolean; + isActive?: boolean; + }[] = []; + + subscriptions$: Subscription[] = []; + ngOnInit(): void { - const globalSubscription$ = forkJoin([ - this.industryService.getAll(), - this.projectService.getProjectSteps(), - ]).subscribe(noop); + const globalSubscription$ = forkJoin([this.industryService.getAll()]).subscribe(noop); this.subscriptions$.push(globalSubscription$); const profileSub$ = this.authService.profile.subscribe(profile => { this.profile = profile; + this.buildNavItems(profile); if (!this.profile.doesCompleted()) { this.router @@ -106,31 +127,6 @@ export class OfficeComponent implements OnInit, OnDestroy { this.subscriptions$.forEach($ => $.unsubscribe()); } - invites: Signal = toSignal( - this.route.data.pipe( - map(r => r["invites"]), - map(invites => invites.filter((invite: Invite) => invite.isAccepted === null)) - ) - ); - - navItems = [ - { name: "Новости", icon: "feed", link: "feed" }, - { name: "Проекты", icon: "projects-filled", link: "projects" }, - { name: "Программы", icon: "program", link: "program" }, - { name: "Участники", icon: "people-bold", link: "members" }, - { name: "Эксперты", icon: "two-people", link: "mentors" }, - { name: "Вакансии", icon: "search-sidebar", link: "vacancies" }, - ]; - - subscriptions$: Subscription[] = []; - - waitVerificationModal = false; - waitVerificationAccepted = false; - - inviteErrorModal = false; - - profile?: User; - onAcceptWaitVerification() { this.waitVerificationAccepted = true; localStorage.setItem("waitVerificationAccepted", "true"); @@ -175,7 +171,22 @@ export class OfficeComponent implements OnInit, OnDestroy { ); } - openSkills() { - location.href = "https://skills.procollab.ru"; + private buildNavItems(profile: User) { + this.navItems = [ + { name: "мой профиль", icon: "person", link: `profile/${profile.id}` }, + { name: "новости", icon: "feed", link: "feed" }, + { name: "проекты", icon: "projects", link: "projects" }, + { name: "участники", icon: "people-bold", link: "members" }, + { name: "программы", icon: "program", link: "program" }, + { name: "вакансии", icon: "search-sidebar", link: "vacancies" }, + { + name: "траектории", + icon: "trajectories", + link: "skills", + isExternal: true, + isActive: false, + }, + { name: "чаты", icon: "message", link: "chats" }, + ]; } } diff --git a/projects/social_platform/src/app/office/office.routes.ts b/projects/social_platform/src/app/office/office.routes.ts index 7119d9a73..ed1e43d28 100644 --- a/projects/social_platform/src/app/office/office.routes.ts +++ b/projects/social_platform/src/app/office/office.routes.ts @@ -5,10 +5,7 @@ import { OfficeComponent } from "./office.component"; import { ProfileEditComponent } from "./profile/edit/edit.component"; import { MembersComponent } from "./members/members.component"; import { MembersResolver } from "./members/members.resolver"; -import { VacancySendComponent } from "./vacancy/send/send.component"; import { OfficeResolver } from "./office.resolver"; -import { MentorsComponent } from "./mentors/mentors.component"; -import { MentorsResolver } from "./mentors/mentors.resolver"; /** * Конфигурация маршрутов для модуля офиса @@ -54,10 +51,6 @@ export const OFFICE_ROUTES: Routes = [ path: "program", loadChildren: () => import("./program/program.routes").then(c => c.PROGRAM_ROUTES), }, - { - path: "vacancy/:vacancyId", - component: VacancySendComponent, - }, { path: "chats", loadChildren: () => import("./chat/chat.routes").then(c => c.CHAT_ROUTES), @@ -69,13 +62,6 @@ export const OFFICE_ROUTES: Routes = [ data: MembersResolver, }, }, - { - path: "mentors", - component: MentorsComponent, - resolve: { - data: MentorsResolver, - }, - }, { path: "profile/edit", component: ProfileEditComponent, diff --git a/projects/social_platform/src/app/office/onboarding/stage-one/stage-one.component.html b/projects/social_platform/src/app/office/onboarding/stage-one/stage-one.component.html index d6cf9e867..1ca2245dc 100644 --- a/projects/social_platform/src/app/office/onboarding/stage-one/stage-one.component.html +++ b/projects/social_platform/src/app/office/onboarding/stage-one/stage-one.component.html @@ -2,20 +2,39 @@
-

Кем хочешь работать?

- - {{ tooltipAuthText }} +
+

Кем хочешь работать?

+ +
+ +
+ закончить регистрацию позже + продолжить +
-@if (user | async; as user) { -
- @if (loggedUserId | async; as loggedUserId) { -
-
-

Обо мне

- @if (user.aboutMe; as about) { -
-

- @if (descriptionExpandable) { -
- {{ readFullDescription ? "Скрыть" : "Читать полностью" }} +@if (user) { +
+
+
+
+
+

метаданные

+
- } -
- } @if (user.skills.length) { -
    - @for (skill of user.skills; track skill.id) { -
  • - - - +
      +
    • + +

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

      +
    • - - {{ skill.name }} - - - - } -
    +
  • + +

    {{ user.city ?? "не указан" }}

    +
  • -

    - Нажимая на плюс, вы подтверждаете, что {{ user.firstName }} владеет этим - навыком -

    - } -
-
- @if (loggedUserId === user.id) { - - } -
    - @for (n of news(); track n.id) { -
  • - -
  • - } -
-
-
- } -
- @if (user.projects.length; as projectsLength) { -
-
-

Проекты

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

    {{ user.speciality ?? "не указана" }}

  • - }
- } -
- -
- -

{{ project.collaborator?.role }}

-
-
- @if (projectsLength > 3) { -
- {{ readAllProjects ? "Скрыть" : "Читать полностью" }}
- } -
- } @if (user.programs.length; as programsLength) { -
-
-

Программы

-
- + +
+
+

языки

+
-
-
    - @for (p of user.programs.slice(0, 3); track p.id) { -
  • - -
  • - } -
-
- @if (user.programs) { -
    - @for (program of user.programs.slice(3); track program.id) { -
  • - + +
      + @for (language of user.userLanguages; track $index) { +
    • +
      {{ language.languageLevel }}
      +

      {{ language.language }}

    • }
    - } -
- -
- -

{{ program.tag }}

-
-
- @if (programsLength > 3) { -
- {{ readAllPrograms ? "Скрыть" : "Читать полностью" }} -
- } -
- } @if (user.education.length; as educationLength) { -
-
-

Образование

-
- -
-
    - @for (p of user.education.slice(0, 3); track $index) { -
  • - -
  • - } -
-
- @if (user.education) { -
    - @for (educationItem of user.education.slice(3); track $index) { -
  • - + + @if (user.programs.length; as programsLength) { +
    +
      + @for (p of user.programs.slice(0, 3); track p.id) { +
    • +
    • }
    - } -
    - -

    - {{ education.entryYear }}-{{ education.completionYear }} -

    - -
    +
    + @if (user.programs) { +
      + @for (program of user.programs.slice(3); track program.id) { +
    • + +
    • + } +
    + } +
    +
    -

    - {{ education.organizationName }} -

    - -

    {{ education.description }}

    -
    - -
    -

    {{ education.educationLevel }}

    -

    {{ education.educationStatus }}

    +
    + + program logo + +
    +
    + @if (programsLength > 3) { +
    + {{ readAllPrograms ? "Скрыть" : "Читать полностью" }}
    - - @if (educationLength > 3) { -
    - {{ readAllEducation ? "Скрыть" : "Читать полностью" }} + }
    }
    - } @if (user.workExperience.length; as workExperienceLength) { -
    -
    -

    Работа

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

    - {{ workExperience.entryYear }}-{{ workExperience.completionYear }} -

    -
    -

    - {{ workExperience.organizationName }} -

    + @if (loggedUserId) { +
    +
    +
    +

    обо мне

    + +
    -
    -

    {{ workExperience.description }}

    -

    {{ workExperience.jobPosition }}

    + @if (user.aboutMe; as about) { +
    +

    + @if (descriptionExpandable) { +
    + {{ readFullDescription ? "Скрыть" : "Читать полностью" }}
    + }
    - - @if (workExperienceLength > 3) { -
    - {{ readAllWorkExperience ? "Скрыть" : "Читать полностью" }} + }
    - } -
    - } @if (user.userLanguages.length; as userLanguagesLength) { -
    -
    -

    Языки

    -
    - -
    + +
    + @for (directionItem of directions; track $index) { + + }
    -
      - @for (p of user.userLanguages.slice(0, 2); track $index) { -
    • - -
    • + +
      + @if (loggedUserId === user.id) { + } -
    -
    - @if (user.userLanguages.length > 2) {
      - @for (userLanguagesItem of user.userLanguages.slice(2); track $index) { + @for (n of news(); track n.id) {
    • - +
    • }
    - } -
    - -

    - {{ userLanguages.language }} {{ userLanguages.languageLevel }} -

    -
    - @if (userLanguagesLength > 2) { -
    - {{ readAllLanguages ? "Скрыть" : "Читать полностью" }}
    - }
    - } @if (user.achievements.length; as achievementsLength) { -
    -
    -

    Мои достижения

    -
    - + } + +
    +
    + @if (user.links.length; as linksLength) { +
    +
    +

    контакты

    + +
    +
      + @for (link of user.links.slice(0, 3); track $index) { +
    • + +
    • + } +
    +
    +
      + @for (link of user.links.slice(3); track $index) { +
    • + + +
    • + } +
    +
    + + @if (link | userLinks; as l) { + + + {{ l.tag }} + + } + + @if (linksLength > 3) { +
    + {{ readAllLinks ? "Скрыть" : "Читать полностью" }} +
    + }
    -
    -
      - @for (achievement of user.achievements.slice(0, 3); track achievement.id) { -
    • -

      {{ achievement.status }}

      -

      {{ achievement.title }}

      -
    • - } -
    -
    - @if (user.achievements.length) { -
      - @for (achievement of user.achievements.slice(3); track achievement.id) { -
    • -

      {{ achievement.status }}

      -

      {{ achievement.title }}

      -
    • + } @if (user.education.length; as educationLength) { +
      +
      +

      образование

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

      + {{ education.entryYear }} +

      + +

      + +

      {{ education.completionYear }}

      +
      + +
      +

      + {{ education.organizationName }} +

      + + {{ education.description }}
      + {{ education.educationLevel }} • {{ education.educationStatus }}
      +
      +
      + @if (educationLength > 3) { +
      + {{ readAllEducation ? "Скрыть" : "Читать полностью" }} +
      } -
    - } -
    - @if (achievementsLength > 3) { -
    - {{ readAllAchievements ? "Скрыть" : "Читать полностью" }} -
    - } -
    - } @if (user.links.length; as linksLength) { - + } @if (user.projects.length; as projectsLength) { +
    +
    +

    проекты

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

    + {{ project.name }} +

    + {{ project.collaborator?.role }} +
    +
    +
    + @if (projectsLength > 3) { +
    + {{ readAllProjects ? "Скрыть" : "Читать полностью" }} +
    + } +
    } - - @if (linksLength > 3) { -
    - {{ readAllLinks ? "Скрыть" : "Читать полностью" }}
    - }
    - }
- - - - } 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 6d73578e4..6f5c6b430 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 @@ -1,8 +1,8 @@ +/** @format */ + @use "styles/responsive"; @use "styles/typography"; -$section-padding: 24px; - @mixin expandable-list { &__remaining { display: grid; @@ -30,67 +30,49 @@ $section-padding: 24px; } } -.languages { - &__title { - margin-bottom: 12px; - color: var(--black); - } - - ul { - overflow: hidden; +.profile { + padding-bottom: 100px; - span { - overflow: hidden; - text-overflow: ellipsis; - } + @include responsive.apply-desktop { + padding-bottom: 0; } - li:not(:last-child) { - margin-bottom: 12px; + &__main { + display: grid; + grid-template-columns: 1fr; } - @include expandable-list; -} - -.main { - display: flex; - flex-direction: column; - gap: 20px; - - @include responsive.apply-desktop { - flex-direction: row; - gap: 14px; + &__details { + display: grid; + grid-template-columns: 2fr 5fr 3fr; + grid-gap: 20px; } - &__aside { + &__right { display: flex; - flex-basis: 40%; flex-direction: column; - gap: 20px; } - &__section { - position: relative; - padding: 24px; - background-color: var(--white); - border: 1px solid var(--medium-grey-for-outline); - border-radius: 15px; + &__left { + width: 157px; + } - h3 { - margin-bottom: 12px; - } + &__aside { + display: grid; + grid-row-start: 3; + gap: 20px; - &-top { - display: flex; - justify-content: space-between; + @include responsive.apply-desktop { + grid-row-start: unset; } + } - &:hover { - .badge { - height: 45px; - margin-bottom: -11px; - } - } + &__section { + padding: 24px; + margin-bottom: 20px; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); } &__info { @@ -100,39 +82,34 @@ $section-padding: 24px; } &__content { - display: flex; - flex-basis: 60%; - flex-direction: column; - gap: 16px; - } + grid-row-start: 2; - &__about { - display: flex; - flex-direction: column; - gap: 16px; - padding: 0 24px; + @include responsive.apply-desktop { + grid-row-start: unset; + } } &__news { - display: flex; - flex-direction: column; - gap: 16px; + grid-row-start: 4; - ul { - display: flex; - flex-direction: column; - gap: 16px; + @include responsive.apply-desktop { + grid-row-start: unset; } } - &__about-text { - margin-top: 12px; + &__directions { + display: grid; + grid-template-columns: 1fr 1fr 3fr; + grid-gap: 20px; + align-items: center; + margin-top: 14px; } } .info { $body-slide: 15px; + position: relative; padding: 0; background-color: transparent; border: none; @@ -151,6 +128,7 @@ $section-padding: 24px; left: 0; width: 100%; height: 100%; + object-fit: cover; } } @@ -205,40 +183,11 @@ $section-padding: 24px; } } - &__name { + &__title { overflow: hidden; color: var(--black); text-align: center; text-overflow: ellipsis; - - @include typography.heading-4; - - @include responsive.apply-desktop { - text-align: unset; - - @include typography.heading-2; - } - } - - &__text { - color: var(--dark-grey); - } - - &__industry { - margin-right: 20px; - - @include responsive.apply-desktop { - margin-right: 40px; - } - } - - &__geo { - display: flex; - align-items: center; - - i { - margin-right: 5px; - } } &__right { @@ -263,54 +212,47 @@ $section-padding: 24px; &__edit { display: block; } -} -.about { - &__head { + &__exit { display: flex; align-items: center; - justify-content: space-between; - - @include responsive.apply-desktop { - display: block; - } - } - - &__title { - color: var(--black); - } - - &__skills { - display: flex; - flex-wrap: wrap; + justify-content: center; + width: 43px; + height: 43px; + color: var(--accent); + cursor: pointer; + border: 1px solid var(--accent); + border-radius: 8px; + transition: all 0.2s; - ::ng-deep .tag { - max-width: 100%; + &:hover { + color: var(--accent-dark); + border-color: var(--accent-dark); } } +} - &__skill { - margin-right: 10px; - margin-bottom: 10px; - cursor: pointer; - } +.about { + padding: 24px; + background-color: var(--light-white); + border-radius: var(--rounded-lg); - &__views { + &__head { display: flex; align-items: center; - color: var(--gray); - - @include typography.body-12; - - @include responsive.apply-desktop { - display: none; - } + justify-content: space-between; + margin-bottom: 8px; + border-bottom: 0.5px solid var(--accent); - i { - margin-right: 5px; + &--icon { + color: var(--accent); } } + &__title { + margin-bottom: 8px; + color: var(--accent); + } /* stylelint-disable value-no-vendor-prefix */ &__text { p { @@ -341,113 +283,137 @@ $section-padding: 24px; } /* stylelint-enable value-no-vendor-prefix */ + + &__read-full { + margin-top: 2px; + color: var(--accent); + cursor: pointer; + } } -.achievements { - &__title { - margin-bottom: 12px; - color: var(--black); +.lists { + &__section { + display: flex; + justify-content: space-between; + margin-bottom: 8px; + border-bottom: 0.5px solid var(--accent); } - li { - display: block; + &__list { + display: flex; + flex-direction: column; + gap: 10px; - &:not(:last-child) { - margin-bottom: 12px; + &--line { + display: flex; + flex-flow: wrap; + gap: 10px; } } - @include expandable-list; -} - -.achievement { - &__title { - color: var(--dark-grey); + &__icon { + color: var(--accent); } - &__status { - margin-bottom: 3px; + &__title { + margin-bottom: 8px; + color: var(--accent); } -} -.links { - ul { - overflow: hidden; - - span { - overflow: hidden; - text-overflow: ellipsis; - } + &__index { + color: var(--accent); } - li:not(:last-child) { - margin-bottom: 12px; + &__logo { + border-radius: var(--rounded-xxl); } - @include expandable-list; -} + &__info { + display: flex; + flex-direction: column; -.projects { - &__title { - margin-bottom: 12px; - color: var(--black); - } + &--text { + color: var(--black) !important; + } - ul { - overflow: hidden; + &--subtext { + color: var(--grey-for-text) !important; + } - span { - overflow: hidden; - text-overflow: ellipsis; + &--more { + color: var(--accent) !important; } - } - li:not(:last-child) { - margin-bottom: 12px; + img { + border-radius: var(--rounded-xxl); + } } - @include expandable-list; -} - -.project { - display: flex; - gap: 12px; - align-items: center; - - &__info { + &__date { display: flex; + flex-direction: column; align-items: center; - justify-content: space-between; - margin-bottom: 3px; + width: 45px; + height: 45px; + padding: 5px; + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-xl); } - &__description { + &__item { display: flex; - gap: 12px; - } + gap: 6px; + align-items: center; - &__title { - text-decoration: underline; + &--status { + padding: 8px; + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-xl); + } - &--disabled { + &--title { color: var(--black); - text-decoration: none; - pointer-events: none; } - } - &__action { - color: var(--accent); - text-decoration: underline; - transition: color 0.2s; + &--more { + margin-top: 8px; + color: var(--accent); + } - &:hover { - color: var(--accent-dark); + i, + .lists__index { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding-top: 1px; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-xl); + } + + p { + color: var(--accent); + } + + span { + cursor: pointer; } } - &__role { - color: var(--dark-grey); + @include expandable-list; +} + +.news { + &__form { + display: block; + margin-top: 20px; + } + + &__item { + display: block; + margin-top: 20px; } } @@ -460,107 +426,36 @@ $section-padding: 24px; &:hover { color: var(--accent-dark); } - - @include typography.body-14; -} - -.badge { - position: relative; - top: -24px; - flex-shrink: 0; - width: 45px; - height: 34px; - margin: 0 8px 0 12px; - color: white; - border-radius: 0 0 100px 100px; - transition: all 0.5s ease; - - i { - display: flex; - align-items: center; - justify-content: center; - height: 100%; - } - - &__projects, - &__achievements { - padding-bottom: 5px; - background-color: var(--green); - box-shadow: 0 0 26px var(--green); - } - - &__organization, - &__links { - padding-bottom: 5px; - background-color: var(--accent); - box-shadow: 0 0 22px var(--accent-mild); - } -} - -.education { - display: flex; - flex-direction: column; - gap: 20px; } -.modal { +.cancel { display: flex; flex-direction: column; - justify-content: center; + width: 350px; + height: 175px; + max-height: calc(100vh - 40px); + overflow-y: auto; - &__body { + &__top { display: flex; + align-items: center; justify-content: space-between; + padding-bottom: 8px; + margin-bottom: 8px; + border-bottom: 0.5px solid var(--accent); } - &__text { - margin-left: 15px; + &__title { color: var(--accent); - text-decoration: underline; - } - - .approves { - display: flex; - flex-direction: column; - gap: 12px; - margin-top: 24px; - margin-bottom: 24px; - - // &__text { - // color: var(--black); - // @include typography.body-12; - - // @include responsive.apply-desktop { - // @include typography.body-14; - // } - // } - - &__info { - display: flex; - gap: 80px; - align-items: center; - justify-content: space-between; - width: 100%; - } - - &__left { - display: flex; - gap: 12px; - align-items: center; - } - - &__speciality { - color: var(--dark-grey); - } + text-align: center; } - &__more { + &__icon { color: var(--accent); + } - @include typography.body-14; - - @include responsive.apply-desktop { - @include typography.body-12; - } + &__text { + margin-bottom: 8px; + color: var(--black); } } diff --git a/projects/social_platform/src/app/office/profile/detail/main/main.component.ts b/projects/social_platform/src/app/office/profile/detail/main/main.component.ts index 6a794bc42..73e137696 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 @@ -6,6 +6,7 @@ import { ChangeDetectorRef, Component, ElementRef, + inject, OnDestroy, OnInit, signal, @@ -15,21 +16,30 @@ import { ActivatedRoute, RouterLink } from "@angular/router"; import { User } from "@auth/models/user.model"; import { AuthService } from "@auth/services"; import { expandElement } from "@utils/expand-element"; -import { concatMap, map, noop, Observable, of, Subscription, switchMap } from "rxjs"; +import { concatMap, filter, 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"; -import { NewsCardComponent } from "@office/shared/news-card/news-card.component"; -import { ParseBreaksPipe, ParseLinksPipe, PluralizePipe } from "projects/core"; +import { + ParseBreaksPipe, + ParseLinksPipe, + PluralizePipe, + YearsFromBirthdayPipe, +} from "projects/core"; import { UserLinksPipe } from "@core/pipes/user-links.pipe"; import { IconComponent } from "@ui/components"; import { TagComponent } from "@ui/components/tag/tag.component"; -import { AsyncPipe, NgTemplateOutlet } from "@angular/common"; +import { AsyncPipe, CommonModule, NgTemplateOutlet } from "@angular/common"; import { ProfileService } from "@auth/services/profile.service"; import { ModalComponent } from "@ui/components/modal/modal.component"; import { AvatarComponent } from "../../../../ui/components/avatar/avatar.component"; import { Skill } from "@office/models/skill"; import { HttpErrorResponse } from "@angular/common/http"; +import { NewsFormComponent } from "@office/features/news-form/news-form.component"; +import { NewsCardComponent } from "@office/features/news-card/news-card.component"; +import { ProfileDataService } from "../services/profile-date.service"; +import { SoonCardComponent } from "@office/shared/soon-card/soon-card.component"; +import { ProjectDirectionCard } from "@office/projects/detail/shared/project-direction-card/project-direction-card.component"; +import { DirectionItem, directionItemBuilder } from "@utils/helpers/directionItemBuilder"; /** * Главный компонент страницы профиля пользователя @@ -59,42 +69,65 @@ import { HttpErrorResponse } from "@angular/common/http"; styleUrl: "./main.component.scss", standalone: true, imports: [ - TagComponent, - NewsFormComponent, - NewsCardComponent, + CommonModule, IconComponent, ModalComponent, - AvatarComponent, RouterLink, NgTemplateOutlet, UserLinksPipe, ParseBreaksPipe, ParseLinksPipe, - AsyncPipe, - PluralizePipe, - AvatarComponent, + YearsFromBirthdayPipe, + NewsCardComponent, + NewsFormComponent, + ProjectDirectionCard, ], changeDetection: ChangeDetectionStrategy.OnPush, }) export class ProfileMainComponent implements OnInit, AfterViewInit, OnDestroy { - constructor( - private readonly route: ActivatedRoute, - private readonly authService: AuthService, - private readonly profileNewsService: ProfileNewsService, - private readonly profileApproveSkillService: ProfileService, - private readonly cdRef: ChangeDetectorRef - ) {} + private readonly route = inject(ActivatedRoute); + private readonly profileNewsService = inject(ProfileNewsService); + private readonly profileDataService = inject(ProfileDataService); + private readonly cdRef = inject(ChangeDetectorRef); - subscriptions$: Subscription[] = []; + user?: User; + loggedUserId?: number; - user: Observable = this.route.data.pipe(map(r => r["data"][0])); - loggedUserId: Observable = this.authService.profile.pipe(map(user => user.id)); + directions: DirectionItem[] = []; + subscriptions$: Subscription[] = []; /** * Инициализация компонента * Загружает новости пользователя и настраивает Intersection Observer для отслеживания просмотров */ ngOnInit(): void { + const profileDataSub$ = this.profileDataService + .getProfile() + .pipe(filter(user => !!user)) + .subscribe({ + next: user => { + if (user) { + this.directions = directionItemBuilder( + 2, + ["навыки", "достижения"], + ["squiz", "medal"], + [user.skills, user.achievements], + ["array", "array"] + )!; + } + this.user = user as User; + }, + }); + + const profileIdDataSub$ = this.profileDataService + .getProfileId() + .pipe(filter(userId => !!userId)) + .subscribe({ + next: profileId => { + this.loggedUserId = profileId; + }, + }); + const route$ = this.route.params .pipe( map(r => r["id"]), @@ -114,7 +147,7 @@ export class ProfileMainComponent implements OnInit, AfterViewInit, OnDestroy { }); }); }); - this.subscriptions$.push(route$); + this.subscriptions$.push(profileDataSub$, profileIdDataSub$, route$); } @ViewChild("descEl") descEl?: ElementRef; @@ -147,9 +180,8 @@ export class ProfileMainComponent implements OnInit, AfterViewInit, OnDestroy { readAllEducation = false; readAllLanguages = false; readAllWorkExperience = false; - readAllModal = false; - approveOwnSkillModal = false; + isShowModal = false; @ViewChild(NewsFormComponent) newsFormComponent?: NewsFormComponent; @ViewChild(NewsCardComponent) newsCardComponent?: NewsCardComponent; @@ -233,85 +265,7 @@ export class ProfileMainComponent implements OnInit, AfterViewInit, OnDestroy { this.readFullDescription = !isExpanded; } - /** - * Подтверждение или отмена подтверждения навыка пользователя - * @param skillId - идентификатор навыка - * @param event - событие клика для предотвращения всплытия - * @param skill - объект навыка для обновления - */ - onToggleApprove(skillId: number, event: Event, skill: Skill, profileId: number) { - event.stopPropagation(); - const userId = this.route.snapshot.params["id"]; - - const isApprovedByCurrentUser = skill.approves.some(approve => { - return approve.confirmedBy.id === profileId; - }); - - if (isApprovedByCurrentUser) { - this.profileApproveSkillService.unApproveSkill(userId, skillId).subscribe(() => { - skill.approves = skill.approves.filter(approve => approve.confirmedBy.id !== profileId); - this.cdRef.markForCheck(); - }); - } else { - this.profileApproveSkillService - .approveSkill(userId, skillId) - .pipe( - switchMap(newApprove => - newApprove.confirmedBy - ? of(newApprove) - : this.authService.profile.pipe( - map(profile => ({ - ...newApprove, - confirmedBy: profile, - })) - ) - ) - ) - .subscribe({ - next: updatedApprove => { - skill.approves = [...skill.approves, updatedApprove]; - this.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 = {}; - - /** - * Открытие модального окна с информацией о подтверждениях навыка - * @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; - } else { - this.openSkills[skillId] = event; - } - } - - onCloseModal(skillId: number) { - this.openSkills[skillId] = false; + openWorkInfoModal(): void { + this.isShowModal = true; } } 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 deleted file mode 100644 index df1108b76..000000000 --- a/projects/social_platform/src/app/office/profile/detail/profile-detail.component.html +++ /dev/null @@ -1,198 +0,0 @@ - - -@if (user$ | async; as user) { -
-
- @if (loggedUserId$ | async; as loggedUserId) { - - } @else { - - } -
- -
-
-
- -
- -
- @if (chatService.userOnlineStatusCache | async; as cache) { - - } -
-
-
-

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

- -
-
-
- @if (user.birthday) { - - {{ user.birthday | yearsFromBirthday }} - - } @if (user.birthday && user.speciality) { -  •  - } @if (user.speciality) { - - {{ user.speciality }} - - } -
- @if (user.city) { - - - {{ user.city }} - @if (user.region) { , - {{ user.region }} - } - - } -
-
- -
- -
-
- - -
-
- -

Повторите загрузку позже

-
-

- Скачивание будет доступно через {{ errorMessageModal() }} секунд. -

-
-
- - -
-
- -

Твое CV уже ждет тебя на почте :)

-
-

- Кстати, оно часто залетает в папку «Спам» — обязательно проверь и там тоже.
- Технические сложности? Мы всегда на связи в Telegram — {{ "@procollab_support" }} -

-
-
- - @if (authService.profile | async; as profile;) { @if (profile.id === user.id) { - -
- profile unfill image -
- -

- Заполни все поля, чтобы использовать Procollab на максимум -

-
-

Заполни все поля, чтобы иметь сильное резюме

- - - Продолжить заполнение - -
-
- } } - -
- -
-
-
-} 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 deleted file mode 100644 index bcd773f6a..000000000 --- a/projects/social_platform/src/app/office/profile/detail/profile-detail.component.scss +++ /dev/null @@ -1,342 +0,0 @@ -/** @format */ - -@use "styles/responsive"; -@use "styles/typography"; - -$section-padding: 24px; -$detail-bar-height: 63px; -$detail-bar-mb: 12px; - -.profile { - display: flex; - flex-direction: column; - height: 100%; - max-height: 100%; - overflow-y: scroll; - - &__bar { - height: $detail-bar-height; - margin-bottom: $detail-bar-mb; - border: 1px solid var(--medium-grey-for-outline); - border-radius: 15px; - } - - &__info { - @include responsive.apply-desktop { - grid-column: span 3; - } - } - - &__body { - display: flex; - flex-direction: column; - flex-grow: 1; - gap: 10px; - max-height: calc(100% - #{$detail-bar-height} - #{$detail-bar-mb}); - padding-bottom: 12px; - } - - &__btns { - position: relative; - display: flex; - flex-direction: column; - gap: 10px; - width: 100%; - - @include responsive.apply-desktop { - flex-direction: row; - } - } - - &__tooltip { - position: absolute; - right: 0%; - bottom: 90%; - z-index: 100; - display: none; - width: 220px; - padding: 12px; - color: var(--accent); - background-color: var(--white); - border-radius: 8px; - box-shadow: 0 0 6px var(--gray); - opacity: 0; - transition: opacity 0.3s ease-in-out; - - &--visible { - display: block; - opacity: 1; - } - } -} - -.info { - $body-slide: 15px; - - padding: 0; - background-color: transparent; - border: none; - border-radius: $body-slide; - - &__cover { - position: relative; - height: 230px; - background: url("/assets/images/office/profile/detail/cover.png"); - background-position: top; - background-size: cover; - border-radius: 15px 15px 0 0; - - @include responsive.apply-desktop { - height: 200px; - } - - img { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - width: 100%; - height: 100%; - } - } - - &__body { - position: relative; - gap: 20px; - padding: 40px 24px 24px; - margin-top: -$body-slide; - border-radius: $body-slide; - - app-button ::ng-deep .button--inline { - min-height: 38px; - } - - @include responsive.apply-desktop { - padding: 21px 19px 21px 227px; - background-color: var(--white); - border: 1px solid var(--medium-grey-for-outline); - } - } - - &__top { - display: flex; - flex-direction: column; - gap: 20px; - - @include responsive.apply-desktop { - flex-flow: row wrap; - gap: 10px; - align-items: flex-end; - } - - &-more { - display: flex; - gap: 10px; - } - } - - &__mentor { - display: flex; - align-items: center; - height: 23px; - padding: 0 20px; - color: var(--green); - border: 1px solid var(--green); - border-radius: 20px; - } - - &__achievements { - display: flex; - align-items: center; - justify-content: center; - min-height: 90px; - padding: 0 20px; - margin-top: 15px; - border: 1px solid var(--medium-grey-for-outline); - border-radius: 15px; - - p { - text-align: center; - } - - span { - color: var(--dark-grey); - } - - a { - text-decoration: underline; - text-underline-offset: 2px; - cursor: pointer; - } - } - - &__avatar { - position: absolute; - top: -39%; - bottom: $body-slide; - left: 50%; - display: block; - transform: translateX(-50%); - - @include responsive.apply-desktop { - top: 10%; - bottom: auto; - left: 21px; - transform: translate(0, -50%); - } - } - - &__row { - display: flex; - align-items: center; - justify-content: center; - margin-top: 2px; - - @include responsive.apply-desktop { - justify-content: unset; - margin-top: 0; - } - } - - &__name { - margin-bottom: 10px; - overflow: hidden; - color: var(--black); - text-align: center; - text-overflow: ellipsis; - - @include typography.heading-4; - - @include responsive.apply-desktop { - margin-bottom: 0; - text-align: unset; - - @include typography.heading-2; - } - } - - &__text { - @include responsive.apply-desktop { - flex-basis: 320px; - flex-grow: 9999; - } - } - - &__keys { - display: flex; - flex-wrap: wrap; - column-gap: 20px; - justify-content: center; - color: var(--dark-grey); - - @include responsive.apply-desktop { - justify-content: flex-start; - } - - span { - text-wrap: nowrap; - } - } - - &__industry { - margin-right: 20px; - - @include responsive.apply-desktop { - margin-right: 40px; - } - } - - &__geo { - i { - display: inline-block; - } - } - - &__right { - display: flex; - flex-direction: column; - flex-grow: 1; - gap: 20px; - justify-content: flex-end; - - @include responsive.apply-desktop { - flex-direction: row; - - & > a { - flex-grow: 1; - flex-shrink: 0; - } - } - } - - &__send-message, - &__download-cv { - @include responsive.apply-desktop { - min-width: 150px; - } - } - - &__edit { - display: block; - } -} - -.outlet { - grid-column: 1/3; - padding-bottom: 100px; - - @include responsive.apply-desktop { - padding-bottom: 60px; - } -} - -.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%; - min-width: 700px; - } - - &__cross { - position: absolute; - top: 0; - right: 0; - width: 32px; - height: 32px; - cursor: pointer; - - @include responsive.apply-desktop { - top: 8px; - right: 8px; - } - } - - &__image { - margin-bottom: 20px; - } - - &__title { - margin-bottom: 10px; - text-align: center; - } - - &__text { - text-align: center; - } - - ::ng-deep { - app-button { - display: block; - margin-top: 20px; - } - } -} diff --git a/projects/social_platform/src/app/office/profile/detail/profile-detail.component.ts b/projects/social_platform/src/app/office/profile/detail/profile-detail.component.ts deleted file mode 100644 index 29bf3b11a..000000000 --- a/projects/social_platform/src/app/office/profile/detail/profile-detail.component.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** @format */ - -import { Component, OnInit, signal } from "@angular/core"; -import { ActivatedRoute, RouterLink, RouterOutlet } from "@angular/router"; -import { map, Observable } from "rxjs"; -import { User } from "@auth/models/user.model"; -import { NavService } from "@services/nav.service"; -import { AuthService } from "@auth/services"; -import { ChatService } from "@services/chat.service"; -import { BreakpointObserver } from "@angular/cdk/layout"; -import { YearsFromBirthdayPipe } from "projects/core"; -import { BarComponent, ButtonComponent, IconComponent } from "@ui/components"; -import { AvatarComponent } from "@ui/components/avatar/avatar.component"; -import { AsyncPipe, CommonModule } from "@angular/common"; -import { ModalComponent } from "@ui/components/modal/modal.component"; -import { calculateProfileProgress } from "@utils/calculateProgress"; -import { ProfileService as SkillsProfileService } from "projects/skills/src/app/profile/services/profile.service"; - -/** - * Компонент детального просмотра профиля пользователя - * - * Основной компонент для отображения полной информации о пользователе, включая: - * - Аватар с индикатором онлайн-статуса - * - Основную информацию (имя, возраст, специальность, город) - * - Кнопки действий (написать сообщение, редактировать профиль, скачать CV) - * - Навигационную панель для переключения между разделами - * - Модальные окна для различных уведомлений - * - * Функциональность: - * - Отображение профиля текущего или другого пользователя - * - Отправка CV на email с ограничениями по времени - * - Проверка статуса подписки для доступа к функциям - * - Адаптивный дизайн для мобильных и десктопных устройств - * - Интеграция с чат-сервисом для отправки сообщений - * - * @implements OnInit - для инициализации компонента и загрузки данных - */ -@Component({ - selector: "app-profile-detail", - templateUrl: "./profile-detail.component.html", - styleUrl: "./profile-detail.component.scss", - standalone: true, - imports: [ - CommonModule, - RouterLink, - AvatarComponent, - IconComponent, - ButtonComponent, - RouterOutlet, - AsyncPipe, - YearsFromBirthdayPipe, - BarComponent, - ModalComponent, - ], -}) -export class ProfileDetailComponent implements OnInit { - constructor( - private readonly route: ActivatedRoute, - private readonly navService: NavService, - public readonly authService: AuthService, - public readonly chatService: ChatService, - public readonly skillsProfileService: SkillsProfileService, - public readonly breakpointObserver: BreakpointObserver - ) {} - - user$: Observable = this.route.data.pipe( - map(r => r["data"][0]), - map(user => ({ ...user, progress: calculateProfileProgress(user) })) - ); - - loggedUserId$: Observable = this.authService.profile.pipe(map(user => user.id)); - - isDelayModalOpen = false; - isSended = false; - isSubscriptionActive = signal(false); - - isProfileFill = false; - - tooltipText = "Заполни до конца — и открой весь функционал платформы!"; - isHintVisible = false; - - /** - * Показать подсказку для незаполненного профиля - */ - showTooltip() { - this.isHintVisible = true; - } - - /** - * Скрыть подсказку для незаполненного профиля - */ - hideTooltip() { - this.isHintVisible = false; - } - - errorMessageModal = signal(""); - desktopMode$: Observable = this.breakpointObserver - .observe("(min-width: 920px)") - .pipe(map(result => result.matches)); - - /** - * Инициализация компонента - * Настраивает заголовок навигации, проверяет статус подписки, - * определяет необходимость заполнения профиля - */ - ngOnInit(): void { - this.navService.setNavTitle("Профиль"); - - this.skillsProfileService.getSubscriptionData().subscribe(r => { - this.isSubscriptionActive.set(r.isSubscribed); - }); - - this.user$.subscribe(r => { - this.isProfileFill = - r.progress! < 100 ? (this.isProfileFill = true) : (this.isProfileFill = false); - }); - } - - /** - * Отправка CV пользователя на email - * Проверяет ограничения по времени и отправляет CV на почту пользователя - */ - sendCVEmail() { - this.authService.sendCV().subscribe({ - next: () => { - this.isSended = true; - }, - error: err => { - if (err.status === 400) { - this.isDelayModalOpen = true; - this.errorMessageModal.set(err.error.seconds_after_retry); - } - }, - }); - } -} diff --git a/projects/social_platform/src/app/office/profile/detail/profile-detail.resolver.spec.ts b/projects/social_platform/src/app/office/profile/detail/profile-detail.resolver.spec.ts deleted file mode 100644 index 217696a23..000000000 --- a/projects/social_platform/src/app/office/profile/detail/profile-detail.resolver.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; - -import { ProfileDetailResolver } from "./profile-detail.resolver"; -import { AuthService } from "@auth/services"; -import { ActivatedRouteSnapshot, convertToParamMap, RouterStateSnapshot } from "@angular/router"; -import { of } from "rxjs"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; - -describe("ProfileDetailResolver", () => { - const mockRoute = { paramMap: convertToParamMap({ id: 1 }) } as unknown as ActivatedRouteSnapshot; - beforeEach(() => { - const authSpy = jasmine.createSpyObj("authService", { getUser: of({}) }); - - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - providers: [{ provide: AuthService, useValue: authSpy }], - }); - }); - - it("should be created", () => { - const result = TestBed.runInInjectionContext(() => - ProfileDetailResolver(mockRoute, {} as RouterStateSnapshot) - ); - expect(result).toBeTruthy(); - }); -}); 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 0c92cdf5c..f28d76dd4 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 @@ -5,8 +5,10 @@ import { ActivatedRouteSnapshot, ResolveFn } from "@angular/router"; import { AuthService } from "@auth/services"; import { User } from "@auth/models/user.model"; import { SubscriptionService } from "@office/services/subscription.service"; -import { forkJoin, map } from "rxjs"; +import { forkJoin, map, mergeMap, tap } from "rxjs"; import { Project } from "@office/models/project.model"; +import { ProfileDataService } from "./services/profile-date.service"; +import { profile } from "console"; /** * Резолвер для загрузки данных профиля пользователя @@ -31,11 +33,16 @@ export const ProfileDetailResolver: ResolveFn<[User, Project[]]> = ( ) => { const authService = inject(AuthService); const subscriptionService = inject(SubscriptionService); + const profileDataService = inject(ProfileDataService); return forkJoin([ - authService.getUser(Number(route.paramMap.get("id"))), - subscriptionService - .getSubscriptions(Number(route.paramMap.get("id"))) - .pipe(map(resp => resp.results)), + authService + .getUser(Number(route.paramMap.get("id"))) + .pipe(tap(profile => profileDataService.setProfile(profile))), + + subscriptionService.getSubscriptions(Number(route.paramMap.get("id"))).pipe( + map(subs => subs.results), + tap(subs => profileDataService.setProfileSubs(subs)) + ), ]); }; diff --git a/projects/social_platform/src/app/office/profile/detail/profile-detail.routes.ts b/projects/social_platform/src/app/office/profile/detail/profile-detail.routes.ts index 9b58b587d..c78a7a867 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 @@ -1,12 +1,12 @@ /** @format */ import { Routes } from "@angular/router"; -import { ProfileDetailComponent } from "./profile-detail.component"; import { ProfileDetailResolver } from "./profile-detail.resolver"; import { ProfileMainComponent } from "./main/main.component"; import { ProfileProjectsComponent } from "./projects/projects.component"; import { ProfileMainResolver } from "./main/main.resolver"; import { ProfileNewsComponent } from "../profile-news/profile-news.component"; +import { DeatilComponent } from "@office/features/detail/detail.component"; /** * Конфигурация маршрутов для детального просмотра профиля пользователя @@ -25,10 +25,11 @@ import { ProfileNewsComponent } from "../profile-news/profile-news.component"; export const PROFILE_DETAIL_ROUTES: Routes = [ { path: "", - component: ProfileDetailComponent, + component: DeatilComponent, resolve: { data: ProfileDetailResolver, }, + data: { listType: "profile" }, children: [ { path: "", 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 baf44d358..baf56a2af 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,7 +1,7 @@ -@if (user | async; as user) { +@if (user) {
- @if (loggedUserId | async; as loggedUserId) { + @if (loggedUserId) {
@if (user.projects.length) {
@@ -13,16 +13,16 @@

}}

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

{{ @@ -32,16 +32,16 @@

}}

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

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

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 eb499837f..f1e87421b 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 @@ -1,13 +1,14 @@ /** @format */ -import { Component, OnInit } from "@angular/core"; +import { Component, inject, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute, RouterLink } from "@angular/router"; import { User } from "@auth/models/user.model"; import { AuthService } from "@auth/services"; -import { map, Observable } from "rxjs"; -import { ProjectCardComponent } from "@office/shared/project-card/project-card.component"; +import { filter, Subscription, take } from "rxjs"; import { AsyncPipe } from "@angular/common"; import { Project } from "@office/models/project.model"; +import { InfoCardComponent } from "@office/features/info-card/info-card.component"; +import { ProfileDataService } from "../services/profile-date.service"; /** * Компонент для отображения проектов пользователя @@ -29,14 +30,62 @@ import { Project } from "@office/models/project.model"; templateUrl: "./projects.component.html", styleUrl: "./projects.component.scss", standalone: true, - imports: [RouterLink, ProjectCardComponent, AsyncPipe], + imports: [RouterLink, AsyncPipe, InfoCardComponent], }) -export class ProfileProjectsComponent implements OnInit { - constructor(private readonly route: ActivatedRoute, public readonly authService: AuthService) {} +export class ProfileProjectsComponent implements OnInit, OnDestroy { + private readonly route = inject(ActivatedRoute); + private readonly profileDataService = inject(ProfileDataService); + public readonly authService = inject(AuthService); - user?: Observable = this.route.parent?.data.pipe(map(r => r["data"][0])); - subs?: Observable = this.route.parent?.data.pipe(map(r => r["data"][1])); - loggedUserId: Observable = this.authService.profile.pipe(map(user => user.id)); + ngOnInit(): void { + const profileDataSub$ = this.profileDataService + .getProfile() + .pipe( + filter(user => !!user), + take(1) + ) + .subscribe({ + next: user => { + this.user = user; + }, + }); - ngOnInit(): void {} + const profileIdDataSub$ = this.profileDataService + .getProfileId() + .pipe( + filter(profileId => !!profileId), + take(1) + ) + .subscribe({ + next: profileId => { + this.loggedUserId = profileId; + }, + }); + + const profileSubsDataSub$ = this.profileDataService + .getProfileSubs() + .pipe( + filter(subs => !!subs), + take(1) + ) + .subscribe({ + next: subs => { + this.subs = subs; + }, + }); + + profileDataSub$ && this.subscriptions.push(profileDataSub$); + profileIdDataSub$ && this.subscriptions.push(profileIdDataSub$); + profileSubsDataSub$ && this.subscriptions.push(profileSubsDataSub$); + } + + ngOnDestroy(): void { + this.subscriptions.forEach($ => $.unsubscribe()); + } + + user?: User; + loggedUserId?: number; + subs?: Project[]; + + subscriptions: Subscription[] = []; } diff --git a/projects/social_platform/src/app/office/profile/detail/services/profile-date.service.ts b/projects/social_platform/src/app/office/profile/detail/services/profile-date.service.ts new file mode 100644 index 000000000..9c53ceec0 --- /dev/null +++ b/projects/social_platform/src/app/office/profile/detail/services/profile-date.service.ts @@ -0,0 +1,53 @@ +/** @format */ + +import { Injectable } from "@angular/core"; +import { User } from "@auth/models/user.model"; +import { Project } from "@office/models/project.model"; +import { BehaviorSubject, filter, map } from "rxjs"; + +@Injectable({ + providedIn: "root", +}) +export class ProfileDataService { + private profilesSubject = new BehaviorSubject(undefined); + private profileIdSubject = new BehaviorSubject(undefined); + private profileSubsSubject = new BehaviorSubject(undefined); + + profile$ = this.profilesSubject.asObservable(); + profileId$ = this.profileIdSubject.asObservable(); + profileSubs$ = this.profileSubsSubject.asObservable(); + + setProfile(profile: User) { + this.profilesSubject.next(profile); + this.setProfileId(profile.id); + } + + setProfileId(id: number) { + this.profileIdSubject.next(id); + } + + setProfileSubs(subs: Project[]) { + this.profileSubsSubject.next(subs); + } + + getProfile() { + return this.profile$.pipe( + map(profile => profile), + filter(profile => !!profile) + ); + } + + getProfileId() { + return this.profileId$.pipe( + map(profileId => profileId), + filter(profileId => !!profileId) + ); + } + + getProfileSubs() { + return this.profileSubs$.pipe( + map(subs => subs), + filter(subs => !!subs) + ); + } +} diff --git a/projects/social_platform/src/app/office/profile/edit/edit.component.html b/projects/social_platform/src/app/office/profile/edit/edit.component.html index 7496f7ce4..b75efc90a 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 @@ -1,25 +1,73 @@ @if (profileForm.get("userType"); as currentType) { -
-
-

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

- - Назад - +
+
+ +

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

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

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

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

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

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

+ } +
+
+
} - - +
-
+
+
@if (profileForm.get("firstName"); as firstName) { -
- +
+ @if (firstName | controlError: "required") { -
+
{{ errorMessage.VALIDATION_REQUIRED }}
}
} @if (profileForm.get("lastName"); as lastName) {
- + @if (lastName | controlError: "required") { -
+
{{ errorMessage.VALIDATION_REQUIRED }}
} @@ -131,11 +167,29 @@ }
-
- @if (profileForm.get("birthday"); as birthday) { +
+ @if (profileForm.get("city"); as city) { +
+ + + @if (city | controlError: "required") { +
+ {{ errorMessage.VALIDATION_REQUIRED }} +
+ } +
+ } @if (profileForm.get("birthday"); as birthday) {
- + @if (birthday | controlError: "required") { -
+
+ {{ errorMessage.VALIDATION_REQUIRED }} +
+ } +
+ } +
+ +
+ @if (profileForm.get("userType"); as userType) { @if (userType.value !== 1) { +
+ + @if (roles | async; as options) { + + } @if (userType | controlError: "required") { +
{{ errorMessage.VALIDATION_REQUIRED }}
}
- } @if (profileForm.get("phoneNumber"); as phoneNumber) { + } } @if (profileForm.get("speciality"); as speciality) {
- - - @if (phoneNumber | controlError: "required") { -
+ +
+ +
+ @if (speciality | controlError: "required") { +
{{ errorMessage.VALIDATION_REQUIRED }}
}
}
+
-

- Формат телефона должен соответствовать одному из таких форматов: +7 XXX XXX-XX-XX | - +375XXXXXXXXX | +995 (XXX) XX-XX-XX -

- - @if (profileForm.get("speciality"); as speciality) { +
+ @if (profileForm.get("aboutMe"); as aboutMe) {
- -
- - - - -
- @if (speciality | controlError: "required") { -
+ + + @if (aboutMe | controlError: "required") { +
{{ errorMessage.VALIDATION_REQUIRED }}
}
}
-
-
- @if (profileForm.get("city"); as city) { -
- - - @if (city | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } @if (profileForm.get("userType"); as userType) { @if (userType.value !== 1) { -
- - @if (roles | async; as options) { - - } @if (userType | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } } @if (profileForm.get("aboutMe"); as aboutMe) { -
- - - @if (aboutMe | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
+

- } @if (profileForm.get("completionYear"); as completionYear) { -
- - - - - @if (completionYear | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
+ добавить контакт + + +

+ +
+ @if (profileForm.get("phoneNumber"); as phoneNumber) { +
+ + + @if (phoneNumber | controlError: "required") { +
+ {{ errorMessage.VALIDATION_REQUIRED }} +
+ } +
} - - } +
+
+ } @if (editingStep === 'education') { +
+
+ @if (showEducationFields) { +
+ @if (profileForm.get("entryYear"); as entryYear) { +
+ + + + - @if (profileForm.get("organizationName"); as organizationName) { -
- - - @if (organizationName | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} + @if (entryYear | controlError: "required") { +
+ {{ errorMessage.VALIDATION_REQUIRED }} +
+ } +
+ } @if (profileForm.get("completionYear"); as completionYear) { +
+ + + + + @if (completionYear | controlError: "required") { +
+ {{ errorMessage.VALIDATION_REQUIRED }} +
+ } +
+ }
- } - - } @if (profileForm.get("description"); as description) { -
- - - @if (description | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} + +
+ @if (profileForm.get("organizationName"); as organizationName) { +
+ + + @if (organizationName | controlError: "required") { +
+ {{ errorMessage.VALIDATION_REQUIRED }} +
+ } +
+ }
- } -
- } @if (profileForm.get("educationLevel"); as educationLevel) { -
- - - - - @if (educationLevel | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} + +
+ @if (profileForm.get("description"); as description) { +
+ + + @if (description | controlError: "required") { +
+ {{ errorMessage.VALIDATION_REQUIRED }} +
+ } +
+ }
- } -
- } @if (profileForm.get("educationStatus"); as educationStatus) { -
- - - - - @if (educationStatus | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} + +
+ @if (profileForm.get("educationLevel"); as educationLevel) { +
+ + + + + @if (educationLevel | controlError: "required") { +
+ {{ errorMessage.VALIDATION_REQUIRED }} +
+ } +
+ }
- } -
- } @if (profileForm.get("isMospolytechStudent"); as isMospolytechStudent) { -
- - - @if (isMospolytechStudent | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} + +
+ @if (profileForm.get("educationStatus"); as educationStatus) { +
+ + + + + @if (educationStatus | controlError: "required") { +
+ {{ errorMessage.VALIDATION_REQUIRED }} +
+ } +
+ }
} -
- } @if (profileForm.get("isMospolytechStudent")?.value) { @if (profileForm.get("studyGroup"); - as studyGroup) { -
- - - @if (studyGroup | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } } -
+ {{ editEducationClick ? "сохранить изменения" : "добавить образование" }} + + +
-
- - Добавить образование - - - - @if(educationItems().length || education.length){ @for (educationItem of education.value; - track $index) { -
-
-

+

+ @if(educationItems().length || education.length){ @for (educationItem of education.value; + track $index) { +
+

{{ educationItem.organizationName }} +

+

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

- + +
+
+

+ {{ educationItem.description }} +

+ +

+ {{ educationItem.educationLevel }} +

+ +

+ {{ educationItem.educationStatus }} +

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

+ + {{ editWorkClick ? "сохранить изменения" : "добавить работу" }} + + +

+ +
+ @if(workItems().length || workExperience.length){ @for (workItem of workExperience.value; + track $index) { +
+

{{ workItem.organizationName }} +

+ +

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

- + +
+
+

+ {{ workItem.description }} +

+ +

+ {{ workItem.jobPosition }} +

+
+ +
+
+ +
+ +
+ +
+
+
- - - + } }
- } }
} @if(editingStep === 'achievements'){ -
-
- -
    - @for (control of achievements.controls; track control.value.id; let i = $index) { -
  • - -
    - @if (achievements.at(i)?.get("title"); as title) { -
    - - - @if (title | controlError: "required") { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    - } -
    - } - - Удалить - - -
    - @if (achievements.at(i).get("status"); as status) { -
    - - @if (status | controlError: "required") { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    - } -
    - } - -
  • +
    +
    + @if (showAchievementsFields) { +
    + @if (profileForm.get("title"); as title) { +
    + + + @if (title | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    } -
-
+ + } +
+ +
+ @if (profileForm.get("year"); as year) { +
+ + + @if (year | controlError: "required") { +
+ {{ errorMessage.VALIDATION_REQUIRED }} +
+ } +
+ } +
+ +
+ @if (profileForm.get("status"); as status) { +
+ + + @if (status | controlError: "required") { +
+ {{ errorMessage.VALIDATION_REQUIRED }} +
+ } +
+ } +
+ +
+ @if (profileForm.get("files"); as files) { +
+ + + +

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

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

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

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

+ {{ achievementItem.title }} +

+ +

+ {{ achievementItem.year }} +

+ +

+ {{ achievementItem.status }} +

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

- {{ languageItem.language }} {{ languageItem.languageLevel }} +

+ @if(languageItems().length || userLanguages.length){ @for (languageItem of + userLanguages.value; track $index) { +
+
+

+ {{ languageItem.language }} +

+ +
+
+ +
+ +
+ +
+
+
+ +

+ {{ languageItem.languageLevel }}

-
- - - + } }
- } }
- } - - - - -
-
- - Сохранить + }
@@ -868,16 +1004,17 @@ -

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

+

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

@if (isModalErrorSkillChooseText()) { -

{{ isModalErrorSkillChooseText() }}.

+

{{ isModalErrorSkillChooseText() }}.

} @else { -

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

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

@@ -885,17 +1022,40 @@
- + +
+
+

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

+ +
+ + удалить аккаунт +
+
+ + diff --git a/projects/social_platform/src/app/office/profile/edit/edit.component.scss b/projects/social_platform/src/app/office/profile/edit/edit.component.scss index 5f1c33f01..6431da64e 100644 --- a/projects/social_platform/src/app/office/profile/edit/edit.component.scss +++ b/projects/social_platform/src/app/office/profile/edit/edit.component.scss @@ -5,14 +5,24 @@ .profile { position: relative; - overflow-y: hidden; + padding: 30px 0; + background-color: var(--white); + border-radius: var(--rounded-md); &__top { + position: sticky; + top: -50%; + left: 6%; + z-index: 100; display: flex; + gap: 12%; align-items: center; - justify-content: space-between; - margin-top: 17px; - margin-bottom: 18px; + justify-content: space-evenly; + width: 100%; + padding: 4px 0; + margin-top: 20px; + background-color: var(--light-white); + border-radius: var(--rounded-xxl); } &__title { @@ -26,141 +36,120 @@ } &__back { - width: 20%; + display: flex; + gap: 10px; + align-items: center; + cursor: pointer; } &__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; - height: 100%; - min-height: 800px; - padding: 24px; - } } &__navigation { - width: 100%; - padding: 10px 0; - margin-bottom: 22px; - border-bottom: 1px solid var(--grey-button); + margin-bottom: 35px; } &__nav { display: flex; flex-wrap: wrap; + gap: 10px; align-items: center; - justify-content: space-between; + justify-content: center; + padding: 2px 10px; + background-color: var(--medium-grey-for-outline); + border-radius: var(--rounded-xxl); + + @include responsive.apply-desktop { + gap: 0; + justify-content: space-between; + } } &__item { display: flex; - flex-direction: column; + gap: 5px; align-items: center; cursor: pointer; + + &--active { + padding: 0 8px; + margin-right: -8px; + margin-left: -8px; + background-color: var(--white); + border-radius: var(--rounded-xxl); + } } &__subtitle { - color: var(--grey-for-text); + color: var(--dark-grey); - @include typography.heading-4; + @include typography.body-12; &--active { color: var(--black); } } - &__image { - opacity: 0.5; + &__icon { + opacity: 0.1; &--active { + color: var(--accent); opacity: 1; } } - &__column { - &:first-child { - order: 1; - margin-top: 16px; - } - - @include responsive.apply-desktop { - display: flex; - gap: 90px; - justify-content: space-between; - width: 100%; + &__file { + flex-grow: 1; + min-width: 0; + max-width: 333px; - &:first-child { - order: unset; - margin-right: 50px; + ::ng-deep { + app-upload-file { + height: 80px; + padding: 10px 30px; } } } - &__row { - width: 100%; - - @include responsive.apply-desktop { - display: flex; - } - - &:not(:last-child) { - margin-top: 20px; - margin-bottom: 20px; - } - - // >* { - // flex-basis: 50%; - - // &:first-child:last-child { - // flex-basis: 100%; - // } - // } + &__slides-title { + max-width: 320px; + margin-top: 12px; + color: var(--black); + text-align: center; + } - .space { - margin-bottom: 16px; + &__slides-text { + max-width: 275px; + margin-top: 12px; + color: var(--black); + text-align: center; + opacity: 0.3; - @include responsive.apply-desktop { - margin-right: 10px; - } + &:hover { + opacity: 1; } } - &__left, - &__right { - flex-basis: 50%; - } - - .error__phone-number { - margin-bottom: 10px; + &__slides-error { + margin-top: 12px; + color: var(--red); } - // &__achievement { - // display: block; - // margin-bottom: 12px; - // } - - &__language { - margin-top: 20px; - margin-bottom: 20px; + &__slides-open-file { + color: var(--accent); + transition: color 0.2s; - &--attention { - color: var(--dark-grey); + &:hover { + color: var(--accent-dark); } } - &__add-achievement { - display: block; - margin-top: 36px; - margin-bottom: 100px; + .error__phone-number { + margin-bottom: 10px; } &__save { @@ -170,149 +159,144 @@ @include responsive.apply-desktop { z-index: 10; order: unset; - width: 255px; margin-top: auto; margin-left: auto; } + } - app-button { - align-self: flex-end; - width: 20%; - } + &__row { + display: flex; + gap: 20px; + align-items: center; + width: 100%; } - &__avatar { - margin-bottom: 24px; + &__column { + display: flex; + flex-direction: column; + gap: 12px; - .error { - margin-top: 15px; + &--date { + display: grid; + grid-template-columns: repeat(2, 3fr); + grid-gap: 20px; } } -} -.achievement-form { - &__input { - display: block; - margin-bottom: 12px; + &__action { + &--icons { + display: flex; + flex-direction: column; + gap: 10px; + align-items: center; + } } } -.achievement { - &__first-row { - display: flex; - align-items: center; - margin-top: 12px; - margin-bottom: -12px; - - > :first-child { - flex-grow: 1; - margin-bottom: 25px; - } - } +.profile__main--grid { + display: grid; + grid-template-columns: 3fr 3fr 4fr; + grid-gap: 20px; +} - &__remove { - width: 155px; - margin-left: 10px; - } +.profile__wrapper--main { + display: flex; + flex-direction: column; + gap: 12px; } -.education { - &__first-row { - display: flex; - flex-basis: 50%; - flex-direction: column; - gap: 14px; - margin-bottom: 12px; +.profile__wrapper--links { + display: grid; + grid-template-columns: 7fr 3fr; + grid-gap: 20px; +} - > :first-child { - flex-grow: 1; - } - } +.profile__wrapper--education { + display: grid; + grid-template-columns: 6fr 4fr; + grid-gap: 20px; +} - &__block { - display: flex; - flex-basis: 50%; - flex-direction: column; - gap: 20px; - margin-top: 36px; - } +.profile__wrapper--settings { + display: grid; + grid-template-columns: 4fr 6fr; +} - &__years { - display: flex; - gap: 20px; - align-items: center; - margin: 10px 0; +.profile__info--education { + display: flex; + flex-direction: column; + gap: 12px; +} - .years__left, - .years__right { - width: 50%; - } - } +.profile__education--list { + display: flex; + flex-direction: column; + gap: 20px; +} - &__info { - display: flex; - gap: 20px; - align-items: center; - justify-content: space-between; - width: 90%; - padding: 12px; - overflow: hidden; - border: 1px solid var(--medium-grey-for-outline); - border-radius: 15px; - } +.profile__language--list { + display: grid; + grid-template-columns: 2fr 2fr; + grid-gap: 20px; +} - &__text { - color: var(--dark-grey); - } +.profile__education--card { + display: flex; + align-items: center; + justify-content: space-between; +} - &__remove { - display: flex; - gap: 13px; - align-items: center; - } +.profile__education--info { + display: flex; + flex-direction: column; + gap: 5px; - .edit { - width: 10%; - color: var(--dark-grey); + :first-child { + color: var(--black); } - .basket { - color: var(--red); + :last-child & app-file-item { + margin-top: 3px; } } -.edit { - width: 10%; +.edit-icon, +.basket-icon { + width: 20px; + height: 20px; + padding: 6px; cursor: pointer; + border-radius: 50%; } -.speciality-field, -.skills-field { - display: flex; +.edit-icon { + border: 0.5px solid var(--accent); - &__input { - flex-grow: 1; - margin-right: 6px; + i { + color: var(--accent) !important; } +} - ::ng-deep { - app-autocomplete-input { - .field__input { - padding: 12px 20px; +.basket-icon { + border: 0.5px solid var(--red); - @include typography.body-16; - } - } + i { + color: var(--red) !important; } +} - &__button { - flex-shrink: 0; - width: 52px; +.education { + &__title { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 5px; + border-bottom: 0.5px solid var(--medium-grey-for-outline); } -} -.skills-basket { - max-height: 180px; + &__text { + color: var(--grey-for-text); + } } .modal { @@ -322,11 +306,6 @@ gap: 20px; align-items: center; width: 672px; - - app-button { - width: 100%; - max-width: 366px; - } } &__content { @@ -367,16 +346,10 @@ .cancel { display: flex; flex-direction: column; - align-items: center; justify-content: center; max-height: calc(100vh - 40px); - padding: 40px 0 80px; overflow-y: auto; - @include responsive.apply-desktop { - width: 80%; - } - &__cross { position: absolute; top: 0; @@ -391,8 +364,13 @@ } } + &__delete-icon { + display: flex; + justify-content: center; + margin: 36px 0; + } + &__title { - margin-bottom: 12px; text-align: center; } @@ -400,3 +378,7 @@ text-align: center; } } + +.error { + color: var(--red) !important; +} diff --git a/projects/social_platform/src/app/office/profile/edit/edit.component.ts b/projects/social_platform/src/app/office/profile/edit/edit.component.ts index ee76d2ec3..11d26d544 100644 --- a/projects/social_platform/src/app/office/profile/edit/edit.component.ts +++ b/projects/social_platform/src/app/office/profile/edit/edit.component.ts @@ -28,7 +28,7 @@ import { NavService } from "@services/nav.service"; import { EditorSubmitButtonDirective } from "@ui/directives/editor-submit-button.directive"; import { TextareaComponent } from "@ui/components/textarea/textarea.component"; import { AvatarControlComponent } from "@ui/components/avatar-control/avatar-control.component"; -import { AsyncPipe, CommonModule, Location } from "@angular/common"; +import { AsyncPipe, CommonModule } from "@angular/common"; import { Specialization } from "@office/models/specialization"; import { SpecializationsService } from "@office/services/specializations.service"; import { AutoCompleteInputComponent } from "@ui/components/autocomplete-input/autocomplete-input.component"; @@ -38,14 +38,21 @@ import { SkillsBasketComponent } from "@office/shared/skills-basket/skills-baske import { ModalComponent } from "@ui/components/modal/modal.component"; import { Skill } from "@office/models/skill"; import { SkillsService } from "@office/services/skills.service"; -import { navItems } from "projects/core/src/consts/navProfileItems"; -import { educationUserLevel, educationUserType } from "projects/core/src/consts/list-education"; -import { languageLevelsList, languageNamesList } from "projects/core/src/consts/list-language"; +import { + educationUserLevel, + educationUserType, +} from "projects/core/src/consts/lists/education-info-list.const"; +import { + languageLevelsList, + languageNamesList, +} from "projects/core/src/consts/lists/language-info-list.const"; import { transformYearStringToNumber } from "@utils/transformYear"; import { yearRangeValidators } from "@utils/yearRangeValidators"; -import { User } from "@auth/models/user.model"; -import { SwitchComponent } from "@ui/components/switch/switch.component"; -import { generateYearList } from "@utils/generate-year-list"; +import { Achievement, User } from "@auth/models/user.model"; +import { generateOptionsList } from "@utils/generate-options-list"; +import { UploadFileComponent } from "@ui/components/upload-file/upload-file.component"; +import { navProfileItems } from "projects/core/src/consts/navigation/nav-profile-items.const"; +import { FileItemComponent } from "@ui/components/file-item/file-item.component"; dayjs.extend(cpf); @@ -94,7 +101,8 @@ dayjs.extend(cpf); ModalComponent, SelectComponent, RouterModule, - SwitchComponent, + UploadFileComponent, + FileItemComponent, ], }) export class ProfileEditComponent implements OnInit, OnDestroy, AfterViewInit { @@ -118,6 +126,7 @@ export class ProfileEditComponent implements OnInit, OnDestroy, AfterViewInit { city: ["", Validators.max(100)], phoneNumber: [""], additionalRole: [null], + coverImageAddress: [null], // education organizationName: ["", Validators.max(100)], @@ -133,10 +142,17 @@ export class ProfileEditComponent implements OnInit, OnDestroy, AfterViewInit { language: [null], languageLevel: [null], + // achievements + title: [null], + status: [null], + year: [null], + files: [""], + education: this.fb.array([]), workExperience: this.fb.array([]), userLanguages: this.fb.array([]), links: this.fb.array([]), + achievements: this.fb.array([]), // work organization: ["", Validators.max(100)], @@ -148,7 +164,6 @@ export class ProfileEditComponent implements OnInit, OnDestroy, AfterViewInit { // skills speciality: ["", [Validators.required]], skills: [[]], - achievements: this.fb.array([]), avatar: [""], aboutMe: [""], typeSpecific: this.fb.group({}), @@ -179,20 +194,20 @@ export class ProfileEditComponent implements OnInit, OnDestroy, AfterViewInit { userAvatar$ && this.subscription$.push(userAvatar$); - const isMospolytechStudentSub$ = this.profileForm - .get("isMospolytechStudent") - ?.valueChanges.subscribe(isStudent => { - const studyGroup = this.profileForm.get("studyGroup"); - if (isStudent) { - studyGroup?.setValidators([Validators.required]); - } else { - studyGroup?.clearValidators(); - } + // const isMospolytechStudentSub$ = this.profileForm + // .get("isMospolytechStudent") + // ?.valueChanges.subscribe(isStudent => { + // const studyGroup = this.profileForm.get("studyGroup"); + // if (isStudent) { + // studyGroup?.setValidators([Validators.required]); + // } else { + // studyGroup?.clearValidators(); + // } - studyGroup?.updateValueAndValidity(); - }); + // studyGroup?.updateValueAndValidity(); + // }); - isMospolytechStudentSub$ && this.subscription$.push(isMospolytechStudentSub$); + // isMospolytechStudentSub$ && this.subscription$.push(isMospolytechStudentSub$); this.editingStep = this.route.snapshot.queryParams["editingStep"]; } @@ -209,10 +224,10 @@ export class ProfileEditComponent implements OnInit, OnDestroy, AfterViewInit { firstName: profile.firstName ?? "", lastName: profile.lastName ?? "", email: profile.email ?? "", - status: profile.userType ?? "", userType: profile.userType ?? 1, birthday: profile.birthday ? dayjs(profile.birthday).format("DD.MM.YYYY") : "", city: profile.city ?? "", + coverImageAddress: profile.coverImageAddress ?? "", phoneNumber: profile.phoneNumber ?? "", additionalRole: profile.v2Speciality?.name ?? "", speciality: profile.speciality ?? "", @@ -272,10 +287,20 @@ export class ProfileEditComponent implements OnInit, OnDestroy, AfterViewInit { this.cdref.detectChanges(); - profile.achievements.length && - profile.achievements?.forEach(achievement => - this.addAchievement(achievement.id, achievement.title, achievement.status) + this.achievements.clear(); + profile.achievements.forEach(achievement => { + this.achievements.push( + this.fb.group({ + id: [achievement.id], + title: [achievement.title, Validators.required], + status: [achievement.status, Validators.required], + year: [achievement.year, Validators.required], + files: [achievement.files ?? []], + }) ); + }); + + this.cdref.detectChanges(); profile.links.length && profile.links.forEach(l => this.addLink(l)); @@ -310,7 +335,8 @@ export class ProfileEditComponent implements OnInit, OnDestroy, AfterViewInit { this.subscription$.forEach($ => $.unsubscribe()); } - editingStep: "main" | "education" | "experience" | "achievements" | "skills" = "main"; + editingStep: "main" | "education" | "experience" | "achievements" | "skills" | "settings" = + "main"; profileId?: number; @@ -326,20 +352,40 @@ export class ProfileEditComponent implements OnInit, OnDestroy, AfterViewInit { skillsGroupsModalOpen = signal(false); + openGroupIndex: number | null = null; + + onGroupToggled(index: number, isOpen: boolean) { + this.openGroupIndex = isOpen ? index : null; + } + + isGroupDisabled(index: number): boolean { + return this.openGroupIndex !== null && this.openGroupIndex !== index; + } + educationItems = signal([]); workItems = signal([]); languageItems = signal([]); + achievementItems = signal([]); + isModalErrorSkillsChoose = signal(false); isModalErrorSkillChooseText = signal(""); + isModalDeleteProfile = signal(false); + editIndex = signal(null); editEducationClick = false; editWorkClick = false; editLanguageClick = false; + editAchievementsClick = false; + + showEducationFields = false; + showWorkFields = false; + showLanguageFields = false; + showAchievementsFields = false; selectedEntryYearEducationId = signal(undefined); selectedComplitionYearEducationId = signal(undefined); @@ -349,28 +395,31 @@ export class ProfileEditComponent implements OnInit, OnDestroy, AfterViewInit { selectedEntryYearWorkId = signal(undefined); selectedComplitionYearWorkId = signal(undefined); + selectedAchievementsYearId = signal(undefined); + selectedLanguageId = signal(undefined); selectedLanguageLevelId = signal(undefined); subscription$: Subscription[] = []; - readonly navItems = navItems; + readonly navProfileItems = navProfileItems; /** * Навигация между шагами редактирования профиля - * @param step - название шага ('main' | 'education' | 'experience' | 'achievements' | 'skills') + * @param step - название шага ('main' | 'education' | 'experience' | 'achievements' | 'skills' | 'settings) */ navigateStep(step: string) { this.router.navigate([], { queryParams: { editingStep: step } }); - this.editingStep = step as "main" | "education" | "experience" | "achievements" | "skills"; - } - - isStudentMosPolytech(): void { - const ctl = this.profileForm.get("isMospolytechStudent"); - ctl?.setValue(!ctl.value); + this.editingStep = step as + | "main" + | "education" + | "experience" + | "achievements" + | "skills" + | "settings"; } - readonly yearListEducation = generateYearList(55); + readonly yearListEducation = generateOptionsList(55, "years"); readonly educationStatusList = educationUserType; @@ -380,6 +429,8 @@ export class ProfileEditComponent implements OnInit, OnDestroy, AfterViewInit { readonly languageLevelList = languageLevelsList; + readonly achievementsYearList = generateOptionsList(25, "years"); + get typeSpecific(): FormGroup { return this.profileForm.get("typeSpecific") as FormGroup; } @@ -454,6 +505,11 @@ export class ProfileEditComponent implements OnInit, OnDestroy, AfterViewInit { return ["language", "languageLevel"].some(name => f.get(name)?.dirty); } + get isAchievementsDirty(): boolean { + const f = this.profileForm; + return ["title", "status", "year", "files"].some(name => f.get(name)?.dirty); + } + errorMessage = ErrorMessage; roles: Observable = this.authService.changeableRoles.pipe( @@ -463,17 +519,137 @@ export class ProfileEditComponent implements OnInit, OnDestroy, AfterViewInit { profileFormSubmitting = false; profileForm: FormGroup; - addAchievement(id?: number, title?: string, status?: string): void { - this.achievements.push( - this.fb.group({ - title: [title ?? "", [Validators.required]], - status: [status ?? "", [Validators.required]], - id: [id], - }) + // Для управления открытыми группами специализаций + openSpecializationGroup: string | null = null; + + /** + * Проверяет, есть ли открытые группы специализаций + */ + hasOpenSpecializationsGroups(): boolean { + return this.openSpecializationGroup !== null; + } + + /** + * Проверяет, должна ли группа специализаций быть отключена + * @param groupName - название группы для проверки + */ + isSpecializationGroupDisabled(groupName: string): boolean { + return this.openSpecializationGroup !== null && this.openSpecializationGroup !== groupName; + } + + /** + * Обработчик переключения группы специализаций + * @param isOpen - флаг открытия/закрытия группы + * @param groupName - название группы + */ + onSpecializationsGroupToggled(isOpen: boolean, groupName: string): void { + this.openSpecializationGroup = isOpen ? groupName : null; + } + + /** + * Добавление записи об достижении + * Валидирует форму и добавляет новую запись в массив достижений + */ + addAchievement(): void { + if (!this.showAchievementsFields) { + this.showAchievementsFields = true; + + this.profileForm.patchValue({ + title: "", + status: "", + year: null, + files: "", + }); + + return; + } + + ["title", "status", "year"].forEach(name => this.profileForm.get(name)?.clearValidators()); + ["title", "status", "year"].forEach(name => + this.profileForm.get(name)?.setValidators([Validators.required]) + ); + ["title", "status", "year"].forEach(name => + this.profileForm.get(name)?.updateValueAndValidity() ); + ["title", "status", "year"].forEach(name => this.profileForm.get(name)?.markAsTouched()); + + const achievementsYear = + typeof this.profileForm.get("year")?.value === "string" + ? +this.profileForm.get("year")?.value.slice(0, 5) + : this.profileForm.get("year")?.value; + + const achievementsItem = this.fb.group({ + id: [null], + title: this.profileForm.get("title")?.value, + status: this.profileForm.get("status")?.value, + year: achievementsYear, + files: Array.isArray(this.profileForm.get("files")?.value) + ? this.profileForm.get("files")?.value + : [this.profileForm.get("files")?.value].filter(Boolean), + }); + + if (this.editIndex() !== null) { + const existingId = this.achievements.at(this.editIndex()!).get("id")?.value; + + this.achievements.at(this.editIndex()!).patchValue({ + ...achievementsItem.value, + id: existingId, + }); + + this.achievementItems.update(items => { + const updatedItems = [...items]; + updatedItems[this.editIndex()!] = { ...achievementsItem.value, id: existingId }; + return updatedItems; + }); + + this.editIndex.set(null); + } else { + this.achievementItems.update(items => [...items, achievementsItem.value]); + this.achievements.push(achievementsItem); + } + ["title", "status", "year", "files"].forEach(name => { + this.profileForm.get(name)?.reset(); + this.profileForm.get(name)?.setValue(""); + this.profileForm.get(name)?.clearValidators(); + this.profileForm.get(name)?.markAsPristine(); + this.profileForm.get(name)?.markAsUntouched(); + this.profileForm.get(name)?.updateValueAndValidity(); + }); + + this.showAchievementsFields = false; + this.editAchievementsClick = false; + } + + /** + * Редактирование записи об достижений + * @param index - индекс записи в массиве достижений + */ + editAchievements(index: number) { + this.editAchievementsClick = true; + this.showAchievementsFields = true; + const achievementItem = this.achievements.value[index]; + + this.achievementsYearList.forEach(achievementYear => { + if (transformYearStringToNumber(achievementYear.value as string) === achievementItem.year) { + this.selectedAchievementsYearId.set(achievementYear.id); + } + }); + + this.profileForm.patchValue({ + title: achievementItem.title, + status: achievementItem.status, + year: achievementItem.year, + files: achievementItem.files, + }); + this.editIndex.set(index); } + /** + * Удаление записи об достижении + * @param i - индекс записи для удаления + */ removeAchievement(i: number): void { + this.achievementItems.update(items => items.filter((_, index) => index !== i)); this.achievements.removeAt(i); } @@ -482,6 +658,11 @@ export class ProfileEditComponent implements OnInit, OnDestroy, AfterViewInit { * Валидирует форму и добавляет новую запись в массив образования */ addEducation() { + if (!this.showEducationFields) { + this.showEducationFields = true; + return; + } + ["organizationName", "educationStatus"].forEach(name => this.profileForm.get(name)?.clearValidators() ); @@ -550,6 +731,7 @@ export class ProfileEditComponent implements OnInit, OnDestroy, AfterViewInit { this.profileForm.get(name)?.markAsPristine(); this.profileForm.get(name)?.updateValueAndValidity(); }); + this.showEducationFields = false; } this.editEducationClick = false; } @@ -560,6 +742,7 @@ export class ProfileEditComponent implements OnInit, OnDestroy, AfterViewInit { */ editEducation(index: number) { this.editEducationClick = true; + this.showEducationFields = true; const educationItem = this.education.value[index]; this.yearListEducation.forEach(entryYearWork => { @@ -615,6 +798,11 @@ export class ProfileEditComponent implements OnInit, OnDestroy, AfterViewInit { * Валидирует форму и добавляет новую запись в массив опыта работы */ addWork() { + if (!this.showWorkFields) { + this.showWorkFields = true; + return; + } + ["organization", "jobPosition"].forEach(name => this.profileForm.get(name)?.clearValidators()); ["organization", "jobPosition"].forEach(name => this.profileForm.get(name)?.setValidators([Validators.required]) @@ -677,12 +865,14 @@ export class ProfileEditComponent implements OnInit, OnDestroy, AfterViewInit { this.profileForm.get(name)?.markAsPristine(); this.profileForm.get(name)?.updateValueAndValidity(); }); + this.showWorkFields = false; } this.editWorkClick = false; } editWork(index: number) { this.editWorkClick = true; + this.showWorkFields = true; const workItem = this.workExperience.value[index]; if (workItem) { @@ -724,6 +914,11 @@ export class ProfileEditComponent implements OnInit, OnDestroy, AfterViewInit { } addLanguage() { + if (!this.showLanguageFields) { + this.showLanguageFields = true; + return; + } + const languageValue = this.profileForm.get("language")?.value; const languageLevelValue = this.profileForm.get("languageLevel")?.value; @@ -775,13 +970,14 @@ export class ProfileEditComponent implements OnInit, OnDestroy, AfterViewInit { this.profileForm.get(name)?.markAsPristine(); this.profileForm.get(name)?.updateValueAndValidity(); }); - - this.editLanguageClick = false; + this.showLanguageFields = false; } + this.editLanguageClick = false; } editLanguage(index: number) { this.editLanguageClick = true; + this.showLanguageFields = true; const languageItem = this.userLanguages.value[index]; this.languageList.forEach(language => { @@ -848,8 +1044,24 @@ export class ProfileEditComponent implements OnInit, OnDestroy, AfterViewInit { this.profileFormSubmitting = true; + const achievements = this.achievements.value.map((achievement: Achievement) => ({ + ...(achievement.id && { id: achievement.id }), + title: achievement.title, + status: achievement.status, + year: achievement.year, + fileLinks: + achievement.files && Array.isArray(achievement.files) + ? achievement.files + .map((file: any) => (typeof file === "string" ? file : file.link)) + .filter(Boolean) + : achievement.files + ? [achievement.files] + : [], + })); + const newProfile = { ...this.profileForm.value, + achievements, [this.userTypeMap[this.profileForm.value.userType]]: this.typeSpecific.value, typeSpecific: undefined, birthday: this.profileForm.value.birthday @@ -879,6 +1091,13 @@ export class ProfileEditComponent implements OnInit, OnDestroy, AfterViewInit { this.isModalErrorSkillChooseText.set(error.error.phone_number[0]); } else if (error.error.language) { this.isModalErrorSkillChooseText.set(error.error.language); + } else if (error.error.achievements) { + this.isModalErrorSkillChooseText.set(error.error.achievements[0]); + } else if (error.error.work_experience[2]) { + const errorText = error.error.work_experience[2].entry_year + ? error.error.work_experience[2].entry_year + : error.error.work_experience[2].completion_year; + this.isModalErrorSkillChooseText.set(errorText); } else { this.isModalErrorSkillChooseText.set(error.error[0]); } @@ -973,4 +1192,8 @@ export class ProfileEditComponent implements OnInit, OnDestroy, AfterViewInit { toggleSpecsGroupsModal(): void { this.specsGroupsModalOpen.update(open => !open); } + + isStringFiles(files: any[]): boolean { + return typeof files === "string"; + } } 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 a5e4550fc..d6db6a92b 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 @@ -7,7 +7,7 @@ import { ProfileNewsService } from "../detail/services/profile-news.service"; import { map, Subscription } from "rxjs"; 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"; +import { NewsCardComponent } from "@office/features/news-card/news-card.component"; /** * Компонент для отображения отдельной новости профиля в модальном окне 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.resolver.ts similarity index 87% rename from projects/social_platform/src/app/office/program/detail/detail/detail.resolver.ts rename to projects/social_platform/src/app/office/program/detail/detail.resolver.ts index e9bde86de..f0de67f2c 100644 --- a/projects/social_platform/src/app/office/program/detail/detail/detail.resolver.ts +++ b/projects/social_platform/src/app/office/program/detail/detail.resolver.ts @@ -4,6 +4,8 @@ import { inject } from "@angular/core"; import { ActivatedRouteSnapshot, ResolveFn } from "@angular/router"; import { ProgramService } from "@office/program/services/program.service"; import { Program } from "@office/program/models/program.model"; +import { tap } from "rxjs"; +import { ProgramDataService } from "../services/program-data.service"; /** * Резолвер для получения детальной информации о программе @@ -37,6 +39,9 @@ import { Program } from "@office/program/models/program.model"; */ export const ProgramDetailResolver: ResolveFn = (route: ActivatedRouteSnapshot) => { const programService = inject(ProgramService); + const programDataService = inject(ProgramDataService); - return programService.getOne(route.params["programId"]); + return programService + .getOne(route.params["programId"]) + .pipe(tap(program => programDataService.setProgram(program))); }; diff --git a/projects/social_platform/src/app/office/program/detail/detail.routes.ts b/projects/social_platform/src/app/office/program/detail/detail.routes.ts index 3f8cf7d09..1a485d175 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 @@ -1,15 +1,14 @@ /** @format */ import { Routes } from "@angular/router"; -import { ProgramDetailComponent } from "@office/program/detail/detail/detail.component"; -import { ProgramDetailResolver } from "@office/program/detail/detail/detail.resolver"; import { ProgramDetailMainComponent } from "@office/program/detail/main/main.component"; import { ProgramRegisterComponent } from "@office/program/detail/register/register.component"; import { ProgramRegisterResolver } from "@office/program/detail/register/register.resolver"; -import { ProgramProjectsComponent } from "@office/program/detail/projects/projects.component"; -import { ProgramProjectsResolver } from "@office/program/detail/projects/projects.resolver"; -import { ProgramMembersComponent } from "@office/program/detail/members/members.component"; -import { ProgramMembersResolver } from "@office/program/detail/members/members.resolver"; +import { ProgramProjectsResolver } from "@office/program/detail/list/projects.resolver"; +import { ProgramMembersResolver } from "@office/program/detail/list/members.resolver"; +import { ProgramListComponent } from "./list/list.component"; +import { ProgramDetailResolver } from "./detail.resolver"; +import { DeatilComponent } from "@office/features/detail/detail.component"; /** * Маршруты для детальной страницы программы @@ -27,10 +26,11 @@ import { ProgramMembersResolver } from "@office/program/detail/members/members.r export const PROGRAM_DETAIL_ROUTES: Routes = [ { path: "", - component: ProgramDetailComponent, + component: DeatilComponent, resolve: { data: ProgramDetailResolver, }, + data: { listType: "program" }, children: [ { path: "", @@ -38,17 +38,24 @@ export const PROGRAM_DETAIL_ROUTES: Routes = [ }, { path: "projects", - component: ProgramProjectsComponent, + component: ProgramListComponent, resolve: { data: ProgramProjectsResolver, }, + data: { listType: "projects" }, }, { path: "members", - component: ProgramMembersComponent, + component: ProgramListComponent, resolve: { data: ProgramMembersResolver, }, + data: { listType: "members" }, + }, + { + path: "projects-rating", + component: ProgramListComponent, + data: { listType: "rating" }, }, ], }, diff --git a/projects/social_platform/src/app/office/program/detail/detail/detail.component.html b/projects/social_platform/src/app/office/program/detail/detail/detail.component.html deleted file mode 100644 index 623c4ceac..000000000 --- a/projects/social_platform/src/app/office/program/detail/detail/detail.component.html +++ /dev/null @@ -1,33 +0,0 @@ - - -
-
- -
- -
- -
-
diff --git a/projects/social_platform/src/app/office/program/detail/detail/detail.component.scss b/projects/social_platform/src/app/office/program/detail/detail/detail.component.scss deleted file mode 100644 index 7761ba37b..000000000 --- a/projects/social_platform/src/app/office/program/detail/detail/detail.component.scss +++ /dev/null @@ -1,5 +0,0 @@ -.detail { - &__bar { - margin-bottom: 20px; - } -} diff --git a/projects/social_platform/src/app/office/program/detail/detail/detail.component.spec.ts b/projects/social_platform/src/app/office/program/detail/detail/detail.component.spec.ts deleted file mode 100644 index 77c846624..000000000 --- a/projects/social_platform/src/app/office/program/detail/detail/detail.component.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { ProgramDetailComponent } from "./detail.component"; -import { RouterTestingModule } from "@angular/router/testing"; - -describe("DetailComponent", () => { - let component: ProgramDetailComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [RouterTestingModule, ProgramDetailComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(ProgramDetailComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); 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 deleted file mode 100644 index 2656cbbfe..000000000 --- a/projects/social_platform/src/app/office/program/detail/detail/detail.component.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** @format */ - -import { Component, OnInit } from "@angular/core"; -import { NavService } from "@services/nav.service"; -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: [RouterOutlet, BarComponent], -}) -export class ProgramDetailComponent implements OnInit { - constructor(private readonly navService: NavService, private readonly route: ActivatedRoute) {} - - programId?: number; - - ngOnInit(): void { - this.navService.setNavTitle("Профиль программы"); - - this.programId = this.route.snapshot.params["programId"]; - } -} diff --git a/projects/social_platform/src/app/office/program/detail/detail/detail.resolver.spec.ts b/projects/social_platform/src/app/office/program/detail/detail/detail.resolver.spec.ts deleted file mode 100644 index 092985587..000000000 --- a/projects/social_platform/src/app/office/program/detail/detail/detail.resolver.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; - -import { ProgramDetailResolver } from "./detail.resolver"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; -import { ActivatedRouteSnapshot, RouterStateSnapshot } from "@angular/router"; - -describe("ProgramDetailResolver", () => { - const mockRoute = { params: { programId: 1 } } as unknown as ActivatedRouteSnapshot; - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - }); - }); - - it("should be created", () => { - const result = TestBed.runInInjectionContext(() => - ProgramDetailResolver(mockRoute, {} as RouterStateSnapshot) - ); - expect(result).toBeTruthy(); - }); -}); 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/list/list-all.resolver.ts similarity index 83% rename from projects/social_platform/src/app/office/program/detail/rate-projects/list/list-all.resolver.ts rename to projects/social_platform/src/app/office/program/detail/list/list-all.resolver.ts index 0bfcb57bd..5a7b30438 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/list/list-all.resolver.ts @@ -1,10 +1,11 @@ /** @format */ import { inject } from "@angular/core"; -import { ActivatedRouteSnapshot, ResolveFn } from "@angular/router"; +import { ActivatedRouteSnapshot, ResolveFn, Router } from "@angular/router"; 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"; +import { catchError, EMPTY } from "rxjs"; /** * Резолвер для предзагрузки проектов для оценки @@ -45,6 +46,19 @@ export const ListAllResolver: ResolveFn> = ( route: ActivatedRouteSnapshot ) => { const projectRatingService = inject(ProjectRatingService); + const router = inject(Router); - return projectRatingService.getAll(route.parent?.params["programId"], 0, 8); + return projectRatingService.getAll(route.parent?.params["programId"], 0, 8).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/list/list.component.html b/projects/social_platform/src/app/office/program/detail/list/list.component.html new file mode 100644 index 000000000..ae3628d83 --- /dev/null +++ b/projects/social_platform/src/app/office/program/detail/list/list.component.html @@ -0,0 +1,70 @@ + + +
+
+ + +
+ Фильтр + +
+ +
    + @for (listItem of searchedList; track listItem.id) { +
  • + @if (listType === 'projects' || listType === 'members') { + + + + } @else { + + } +
  • + } +
+
+ + @if (listType !== 'members') { +
+
+ @if (listType === 'projects') { +
+
+
+
+ +
+
+ } @else { +
+

фильтр

+
+ +
+
+ } +
+
+ } +
diff --git a/projects/social_platform/src/app/office/program/detail/projects/projects.component.scss b/projects/social_platform/src/app/office/program/detail/list/list.component.scss similarity index 71% rename from projects/social_platform/src/app/office/program/detail/projects/projects.component.scss rename to projects/social_platform/src/app/office/program/detail/list/list.component.scss index 97ad6ef38..0271140c3 100644 --- a/projects/social_platform/src/app/office/program/detail/projects/projects.component.scss +++ b/projects/social_platform/src/app/office/program/detail/list/list.component.scss @@ -2,16 +2,18 @@ @use "styles/typography"; .page { - &__list--wrapper { + display: grid; + grid-template-columns: 8fr 2fr; + gap: 20px; + padding-bottom: 100px; + + &__outlet { display: flex; flex-direction: column; + flex-grow: 1; gap: 20px; - margin-top: 20px; - - @include responsive.apply-desktop { - flex-direction: row; - justify-content: space-between; - } + width: 100%; + margin-top: 10px; } &__filter { @@ -29,24 +31,34 @@ &__list { display: grid; + flex-grow: 1; grid-template-columns: 1fr; - grid-gap: 20px; - width: 100%; + row-gap: 50px; + column-gap: 20px; + align-items: flex-start; + margin-top: 50px; @include responsive.apply-desktop { - grid-template-columns: repeat(2, 1fr); - gap: 20px 40px; - width: 70%; - height: 100%; + grid-template-columns: repeat(4, 2fr); + } + + &--rating { + grid-template-columns: 1fr; + row-gap: 20px; + margin-top: 0; } } - &__search { + &__create { + display: flex; + flex-direction: column; + gap: 20px; + align-items: center; + margin-top: 20px; + } + + &__left { width: 100%; - padding: 20px; - background-color: var(--white); - border: 1px solid var(--medium-grey-for-outline); - border-radius: 15px; } } @@ -60,7 +72,6 @@ @include responsive.apply-desktop { position: static; - min-width: 280px; } &__overlay { @@ -77,6 +88,23 @@ } } + &__controls { + display: flex; + flex-direction: column; + gap: 14px; + margin-top: 18px; + } + + &__tags { + display: flex; + gap: 12px; + align-items: center; + + p { + color: var(--grey-for-text); + } + } + &__bar { position: fixed; display: flex; @@ -106,7 +134,7 @@ bottom: 0; left: 0; z-index: 10; - max-height: 72vh; + min-height: 72vh; overflow-y: auto; background-color: var(--white); border-radius: var(--rounded-lg); @@ -129,7 +157,7 @@ cursor: pointer; background-color: var(--white); border: 1px solid var(--medium-medium-grey-for-outline); - border-radius: 15px; + border-radius: var(--rounded-xl); @include responsive.apply-desktop { display: none; diff --git a/projects/social_platform/src/app/office/profile/detail/profile-detail.component.spec.ts b/projects/social_platform/src/app/office/program/detail/list/list.component.spec.ts similarity index 65% rename from projects/social_platform/src/app/office/profile/detail/profile-detail.component.spec.ts rename to projects/social_platform/src/app/office/program/detail/list/list.component.spec.ts index c67d3ddf2..53995cf41 100644 --- a/projects/social_platform/src/app/office/profile/detail/profile-detail.component.spec.ts +++ b/projects/social_platform/src/app/office/program/detail/list/list.component.spec.ts @@ -2,27 +2,29 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { ProfileDetailComponent } from "./profile-detail.component"; +import { RouterTestingModule } from "@angular/router/testing"; import { of } from "rxjs"; import { AuthService } from "@auth/services"; -import { RouterTestingModule } from "@angular/router/testing"; import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { ProgramListComponent } from "./list.component"; -describe("ProfileDetailComponent", () => { - let component: ProfileDetailComponent; - let fixture: ComponentFixture; +describe("ProgramListComponent", () => { + let component: ProgramListComponent; + let fixture: ComponentFixture; beforeEach(async () => { - const authSpy = jasmine.createSpyObj("AuthService", {}, { profile: of({}) }); + const authSpy = { + profile: of({}), + }; await TestBed.configureTestingModule({ - imports: [RouterTestingModule, HttpClientTestingModule, ProfileDetailComponent], + imports: [RouterTestingModule, HttpClientTestingModule, ProgramListComponent], providers: [{ provide: AuthService, useValue: authSpy }], }).compileComponents(); }); beforeEach(() => { - fixture = TestBed.createComponent(ProfileDetailComponent); + fixture = TestBed.createComponent(ProgramListComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/projects/social_platform/src/app/office/program/detail/list/list.component.ts b/projects/social_platform/src/app/office/program/detail/list/list.component.ts new file mode 100644 index 000000000..f814bba41 --- /dev/null +++ b/projects/social_platform/src/app/office/program/detail/list/list.component.ts @@ -0,0 +1,498 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { + AfterViewInit, + ChangeDetectorRef, + Component, + ElementRef, + inject, + OnDestroy, + OnInit, + Renderer2, + ViewChild, + signal, +} from "@angular/core"; +import { + catchError, + concatMap, + debounceTime, + distinctUntilChanged, + fromEvent, + map, + noop, + of, + Subscription, + switchMap, + tap, + throttleTime, +} from "rxjs"; +import { ProjectsFilterComponent } from "@office/projects/projects-filter/projects-filter.component"; +import Fuse from "fuse.js"; +import { ActivatedRoute, Router, RouterModule } from "@angular/router"; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; +import { SearchComponent } from "@ui/components/search/search.component"; +import { User } from "@auth/models/user.model"; +import { Project } from "@office/models/project.model"; +import { RatingCardComponent } from "@office/program/shared/rating-card/rating-card.component"; +import { ProgramService } from "@office/program/services/program.service"; +import { ProjectRatingService } from "@office/program/services/project-rating.service"; +import { AuthService } from "@auth/services"; +import { SubscriptionService } from "@office/services/subscription.service"; +import { ApiPagination } from "@models/api-pagination.model"; +import { HttpParams } from "@angular/common/http"; +import { PartnerProgramFields } from "@office/models/partner-program-fields.model"; +import { CheckboxComponent } from "@ui/components"; +import { InfoCardComponent } from "@office/features/info-card/info-card.component"; +import { tagsFilter } from "projects/core/src/consts/filters/tags-filter.const"; + +@Component({ + selector: "app-list", + templateUrl: "./list.component.html", + styleUrl: "./list.component.scss", + imports: [ + CommonModule, + ReactiveFormsModule, + RouterModule, + ProjectsFilterComponent, + SearchComponent, + RatingCardComponent, + CheckboxComponent, + InfoCardComponent, + ], + standalone: true, +}) +export class ProgramListComponent implements OnInit, OnDestroy, AfterViewInit { + constructor() { + const isRatedByExpert = + this.route.snapshot.queryParams["is_rated_by_expert"] === "true" + ? true + : this.route.snapshot.queryParams["is_rated_by_expert"] === "false" + ? false + : null; + + const searchValue = + this.route.snapshot.queryParams["search"] || + this.route.snapshot.queryParams["name__contains"]; + const decodedSearchValue = searchValue ? decodeURIComponent(searchValue) : ""; + + this.searchForm = this.fb.group({ + search: [decodedSearchValue], + }); + + this.filterForm = this.fb.group({ + filterTag: [isRatedByExpert, Validators.required], + }); + } + + @ViewChild("listRoot") listRoot?: ElementRef; + @ViewChild("filterBody") filterBody!: ElementRef; + + private readonly renderer = inject(Renderer2); + private readonly fb = inject(FormBuilder); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly cdref = inject(ChangeDetectorRef); + private readonly programService = inject(ProgramService); + private readonly projectRatingService = inject(ProjectRatingService); + private readonly authService = inject(AuthService); + private readonly subscriptionService = inject(SubscriptionService); + + private previousReqQuery: Record = {}; + private availableFilters: PartnerProgramFields[] = []; + + searchForm: FormGroup; + filterForm: FormGroup; + + listTotalCount?: number; + listPage = 0; + listTake = 20; + perPage = 21; + + list: any[] = []; + searchedList: any[] = []; + profile?: User; + profileProjSubsIds?: number[]; + + isRatedByExpert = signal(undefined); + searchValue = signal(""); + + listType: "projects" | "members" | "rating" = "projects"; + + readonly ratingOptionsList = tagsFilter; + isFilterOpen = false; + + subscriptions$: Subscription[] = []; + + routerLink(linkId: number): string { + switch (this.listType) { + case "projects": + return `/office/projects/${linkId}`; + + case "members": + return `/office/profile/${linkId}`; + + default: + return ""; + } + } + + ngOnInit(): void { + this.route.data.subscribe(data => { + this.listType = data["listType"]; + }); + + console.log(this.listType); + + const routeData$ = this.route.data.pipe(map(r => r["data"])).subscribe(data => { + this.listTotalCount = data.count; + this.list = data.results; + this.searchedList = data.results; + }); + + this.subscriptions$.push(routeData$); + + this.setupSearch(); + + if (this.listType === "projects") { + this.setupProfile(); + } + + this.setupFilters(); + + if (this.listType === "rating") { + this.setupRatingQueryParams(); + } + } + + ngAfterViewInit(): void { + const target = document.querySelector(".office__body"); + if (target) { + const scrollEvent$ = fromEvent(target, "scroll") + .pipe( + debounceTime(this.listType === "rating" ? 200 : 500), + concatMap(() => this.onScroll()), + throttleTime(this.listType === "rating" ? 2000 : 500) + ) + .subscribe(noop); + + this.subscriptions$.push(scrollEvent$); + } + } + + ngOnDestroy(): void { + this.subscriptions$.forEach($ => $.unsubscribe()); + } + + private setupSearch(): void { + const searchFormSearch$ = this.searchForm + .get("search") + ?.valueChanges.pipe(debounceTime(300)) + .subscribe(search => { + this.router + .navigate([], { + queryParams: { [this.searchParamName]: search || null }, + relativeTo: this.route, + queryParamsHandling: "merge", + }) + .then(() => console.debug("QueryParams changed from ProgramListComponent")); + }); + + searchFormSearch$ && this.subscriptions$.push(searchFormSearch$); + + const querySearch$ = this.route.queryParams.pipe(map(q => q["search"])).subscribe(search => { + const searchKeys = + this.listType === "projects" || this.listType === "rating" + ? ["name"] + : ["firstName", "lastName"]; + + const fuse = new Fuse(this.list, { + keys: searchKeys, + }); + + this.searchedList = search ? fuse.search(search).map(el => el.item) : this.list; + this.cdref.detectChanges(); + }); + + querySearch$ && this.subscriptions$.push(querySearch$); + } + + private setupProfile(): void { + const profile$ = this.authService.profile + .pipe( + switchMap(p => { + this.profile = p; + return this.subscriptionService.getSubscriptions(p.id).pipe( + map(resp => { + this.profileProjSubsIds = resp.results.map(sub => sub.id); + }) + ); + }) + ) + .subscribe(); + + profile$ && this.subscriptions$.push(profile$); + } + + private setupFilters(): void { + if (this.listType !== "projects") return; + + const filtersObservable$ = 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: { offset: 0, limit: this.perPage } }); + + if (hasFilters) { + return this.programService.createProgramFilters(programId, reqQuery["filters"]).pipe( + catchError(err => { + console.error("createFilters failed, fallback to getAllProjects()", err); + return this.programService.getAllProjects(programId, params); + }) + ); + } + + return this.programService.getAllProjects(programId, params).pipe( + catchError(err => { + console.error("getAllProjects failed", err); + return this.programService.getAllProjects(programId, params); + }) + ); + } + + return of(null); + }) + ) + .subscribe(result => { + if (result && typeof result !== "number") { + this.list = result.results; + this.searchedList = result.results; + this.listTotalCount = result.count; + this.listPage = 0; + this.cdref.detectChanges(); + } + }); + + this.subscriptions$.push(filtersObservable$); + } + + private setupRatingQueryParams(): void { + const queryParams$ = this.route.queryParams + .pipe( + debounceTime(200), + tap(params => { + const isRatedByExpert = + params["is_rated_by_expert"] === "true" + ? true + : params["is_rated_by_expert"] === "false" + ? false + : undefined; + const searchValue = params["name__contains"] || ""; + + this.isRatedByExpert.set(isRatedByExpert); + this.searchValue.set(searchValue); + }), + switchMap(() => { + this.listPage = 0; + return this.onFetch(); + }) + ) + .subscribe(); + + this.subscriptions$.push(queryParams$); + } + + // Методы фильтрации + setValue(event: Event): void { + event.stopPropagation(); + this.filterForm.get("filterTag")?.setValue(!this.filterForm.get("filterTag")?.value); + + this.router.navigate([], { + queryParams: { is_rated_by_expert: this.filterForm.get("filterTag")?.value }, + relativeTo: this.route, + queryParamsHandling: "merge", + }); + } + + // Универсальный метод скролла + private onScroll() { + if (this.listTotalCount && this.list.length >= this.listTotalCount) return of({}); + + const target = document.querySelector(".office__body"); + if (!target || (this.listType !== "rating" && !this.listRoot)) return of({}); + + let shouldFetch = false; + + if (this.listType === "rating") { + // Логика для rating + const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight; + shouldFetch = scrollBottom <= 0; + } else { + // Логика для projects и members + const diff = + target.scrollTop - + this.listRoot!.nativeElement.getBoundingClientRect().height + + window.innerHeight; + const threshold = this.listType === "projects" ? -200 : 0; + shouldFetch = diff > threshold; + } + + if (shouldFetch) { + this.listPage++; + return this.onFetch(); + } + + return of({}); + } + + // Универсальный метод загрузки данных + private onFetch() { + const programId = this.route.parent?.snapshot.params["programId"]; + const offset = this.listPage * this.itemsPerPage; + + switch (this.listType) { + case "projects": + return this.programService + .getAllProjects( + programId, + new HttpParams({ fromObject: { offset, limit: this.itemsPerPage } }) + ) + .pipe( + tap((projects: ApiPagination) => { + this.listTotalCount = projects.count; + if (this.listPage === 0) { + this.list = projects.results; + } else { + this.list = [...this.list, ...projects.results]; + } + this.searchedList = this.list; + this.cdref.detectChanges(); + }) + ); + + case "members": + return this.programService.getAllMembers(programId, offset, this.itemsPerPage).pipe( + tap((members: ApiPagination) => { + this.listTotalCount = members.count; + if (this.listPage === 0) { + this.list = members.results; + } else { + this.list = [...this.list, ...members.results]; + } + this.searchedList = this.list; + this.cdref.detectChanges(); + }) + ); + + case "rating": + return this.projectRatingService + .getAll(programId, offset, this.itemsPerPage, this.isRatedByExpert(), this.searchValue()) + .pipe( + tap(({ count, results }) => { + this.listTotalCount = count; + if (this.listPage === 0) { + this.list = results; + } else { + this.list = [...this.list, ...results]; + } + this.searchedList = this.list; + this.cdref.detectChanges(); + }) + ); + + default: + return of({}); + } + } + + // Построение запроса для фильтров (только для проектов) + private buildFilterQuery(q: any): Record { + if (this.listType !== "projects") return {}; + + 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 }; + } + + onFiltersLoaded(filters: PartnerProgramFields[]): void { + this.availableFilters = filters; + } + + // Swipe логика для мобильных устройств + private swipeStartY = 0; + private swipeThreshold = 50; + private isSwiping = false; + + onSwipeStart(event: TouchEvent): void { + this.swipeStartY = event.touches[0].clientY; + this.isSwiping = true; + } + + onSwipeMove(event: TouchEvent): void { + if (!this.isSwiping) return; + + const currentY = event.touches[0].clientY; + const deltaY = currentY - this.swipeStartY; + + const progress = Math.min(deltaY / this.swipeThreshold, 1); + this.renderer.setStyle( + this.filterBody.nativeElement, + "transform", + `translateY(${progress * 100}px)` + ); + } + + onSwipeEnd(event: TouchEvent): void { + if (!this.isSwiping) return; + + const endY = event.changedTouches[0].clientY; + const deltaY = endY - this.swipeStartY; + + if (deltaY > this.swipeThreshold) { + this.closeFilter(); + } + + this.isSwiping = false; + + this.renderer.setStyle(this.filterBody.nativeElement, "transform", "translateY(0)"); + } + + closeFilter(): void { + this.isFilterOpen = false; + } + + private get itemsPerPage(): number { + return this.listType === "rating" + ? 8 + : this.listType === "projects" + ? this.perPage + : this.listTake; + } + + private get searchParamName(): string { + return this.listType === "rating" ? "name__contains" : "search"; + } +} diff --git a/projects/social_platform/src/app/office/program/detail/members/members.resolver.ts b/projects/social_platform/src/app/office/program/detail/list/members.resolver.ts similarity index 100% rename from projects/social_platform/src/app/office/program/detail/members/members.resolver.ts rename to projects/social_platform/src/app/office/program/detail/list/members.resolver.ts 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/list/projects-filter/projects-filter.component.html similarity index 70% rename from projects/social_platform/src/app/office/program/detail/projects/projects-filter/projects-filter.component.html rename to projects/social_platform/src/app/office/program/detail/list/projects-filter/projects-filter.component.html index 8a9de925c..197f468a8 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/list/projects-filter/projects-filter.component.html @@ -3,27 +3,27 @@
-

Фильтр

- Сбросить фильтры +

фильтры

+
@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 }}

+
Нет @@ -33,16 +33,17 @@

{{ field.label }}

(checkedChange)="filterForm.get(field.name)?.setValue($event)" > Да
} @case ("select") { -

{{ field.label }}

-
+ +
-
-
- cover - -
-
-
-

{{ program.name }}

-
- #{{ program.tag }} -
- - {{ program.city }} -
-
-
- -
-
- @if (!program.isUserMember) { + + @if (!program.isUserMember && !program.isUserManager) {
} @else { -
-
-
-

О программе

-
- - {{ program.viewsCount }} +
+
+ +
+ +
+
+
+

о программе

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

- @if (descriptionExpandable) { -
- {{ readFullDescription ? "Скрыть" : "Читать полностью" }} + @if (program.description) { +
+

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

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

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

@if (showProgramModalErrorMessage()) { -

+

{{ showProgramModalErrorMessage() }}

} - Хорошохорошо +
+ + + +
+
+

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

+
+ +

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

+ + понятно
diff --git a/projects/social_platform/src/app/office/program/detail/main/main.component.scss b/projects/social_platform/src/app/office/program/detail/main/main.component.scss index be57ed88c..5babebf3a 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 @@ -11,36 +11,23 @@ &__main { display: grid; grid-template-columns: 1fr; - row-gap: 8px; - align-items: start; - - @include responsive.apply-desktop { - grid-template-columns: 2fr 1fr; - grid-gap: 16px; - } } - &__aside { - display: grid; - grid-row-start: 3; - gap: 20px; - - @include responsive.apply-desktop { - grid-row-start: unset; - } + &__right { + display: flex; + flex-direction: column; } &__left { - display: flex; - flex-direction: column; - gap: 20px; + width: 157px; } &__section { padding: 24px; - background-color: var(--white); - border: 1px solid var(--medium-grey-for-outline); - border-radius: 15px; + margin-bottom: 14px; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); } &__info { @@ -57,10 +44,6 @@ } } - &__about { - padding: 0 24px; - } - &__news { grid-row-start: 4; @@ -77,66 +60,33 @@ } } - &__views-desktop { - display: none; - color: var(--dark-grey); - - @include responsive.apply-desktop { - display: flex; - align-items: center; - } - - i { - margin-right: 5px; - } - } -} - -.bar__add-project { - display: flex; - align-items: center; - cursor: pointer; - - i { - display: block; - margin-left: 6px; + &__details { + display: grid; + grid-template-columns: 2fr 5fr 3fr; + grid-gap: 20px; } } -.bar__add-project-text { - display: inline; -} - .about { + padding: 24px; + background-color: var(--light-white); + border-radius: var(--rounded-lg); + &__head { display: flex; align-items: center; justify-content: space-between; + margin-bottom: 8px; + border-bottom: 0.5px solid var(--accent); - @include responsive.apply-desktop { - display: block; + &--icon { + color: var(--accent); } } &__title { - margin-bottom: 12px; - color: var(--black); - } - - &__views { - display: flex; - align-items: center; - color: var(--gray); - - @include typography.body-12; - - @include responsive.apply-desktop { - display: none; - } - - i { - margin-right: 5px; - } + margin-bottom: 8px; + color: var(--accent); } /* stylelint-disable value-no-vendor-prefix */ @@ -170,186 +120,14 @@ /* stylelint-enable value-no-vendor-prefix */ &__read-full { - margin-top: 2px; + margin-top: 8px; color: var(--accent); cursor: pointer; } } -.info { - $body-slide: 15px; - - padding: 0; - background-color: transparent; - border: none; - border-radius: $body-slide; - - &__cover { - position: relative; - height: 230px; - border-radius: 15px 15px 0 0; - - img { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - width: 100%; - height: 100%; - object-fit: contain; - object-position: top; - } - } - - &__body { - position: relative; - z-index: 2; - padding: 40px 24px 24px; - margin-top: -$body-slide; - border-radius: $body-slide; - - @include responsive.apply-desktop { - display: flex; - flex-wrap: wrap; - gap: 20px; - align-items: flex-end; - padding-top: 10px; - padding-left: 225px; - background-color: var(--white); - border: 1px solid var(--medium-grey-for-outline); - } - } - - &__avatar { - position: absolute; - bottom: $body-slide; - left: 50%; - z-index: 3; - display: block; - background-color: var(--white); - border-radius: 50%; - transform: translateX(-50%) translateY(30px); - - @include responsive.apply-desktop { - left: 35px; - transform: translateY(50%); - } - } - - &__row { - display: flex; - gap: 20px; - align-items: center; - justify-content: center; - margin-top: 2px; - - @include responsive.apply-desktop { - justify-content: unset; - margin-top: 0; - } - } - - &__actions { - display: flex; - flex-direction: column; - flex-grow: 1; - gap: 20px; - - @include responsive.apply-desktop { - flex-direction: row; - gap: 10px; - justify-content: flex-end; - - & > a { - flex-grow: 1; - flex-shrink: 0; - } - } - - app-button ::ng-deep .button--inline { - min-height: 38px; - } - } - - &__tag { - padding: 4px 10px; - color: var(--white); - background-color: var(--accent); - border-radius: 5px; - } - - &__location { - display: flex; - align-items: center; - - i { - margin-right: 5px; - } - } - - &__title { - margin-bottom: 10px; - color: var(--black); - text-align: center; - - @include typography.heading-4; - - @include responsive.apply-desktop { - text-align: unset; - - @include typography.heading-2; - } - } - - &__text { - flex-basis: 300px; - flex-grow: 9999; - color: var(--dark-grey); - } - - &__industry { - margin-right: 20px; - - @include responsive.apply-desktop { - margin-right: 40px; - } - } - - &__geo { - display: flex; - align-items: center; - - i { - margin-right: 5px; - } - } - - &__presentation { - display: block; - margin-top: 35px; - - @include responsive.apply-desktop { - margin-top: 0; - margin-left: auto; - } - - i { - margin-left: 10px; - } - } - - &__presentation-icon { - min-width: 16px; - } - - &__edit { - display: block; - } -} - .read-more { - margin-top: 12px; + margin-top: 8px; color: var(--accent); cursor: pointer; transition: background-color 0.2s; @@ -362,21 +140,31 @@ .links { overflow: hidden; + &__section { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + border-bottom: 0.5px solid var(--accent); + } + + &__icon { + color: var(--accent); + } + &__title { - margin-bottom: 12px; + margin-bottom: 8px; + color: var(--accent); } &__item { - &:not(:last-child) { - margin-bottom: 3px; - - @include responsive.apply-desktop { - margin-bottom: 12px; - } - } + cursor: pointer; } ul { + display: flex; + flex-direction: column; + gap: 8px; overflow: hidden; span { @@ -399,36 +187,25 @@ } .cancel { + position: relative; 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 { + i { 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; + gap: 10px; + align-items: center; margin-bottom: 10px; } @@ -437,6 +214,8 @@ } &__text { + width: 40%; + color: var(--dark-grey); text-align: center; } 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 55bd31691..6c0583b1f 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 @@ -9,7 +9,7 @@ import { ViewChild, } from "@angular/core"; import { ProgramService } from "@office/program/services/program.service"; -import { ActivatedRoute, Router, RouterLink } from "@angular/router"; +import { ActivatedRoute, Router, RouterModule } from "@angular/router"; import { concatMap, fromEvent, @@ -29,15 +29,17 @@ import { ParseBreaksPipe, ParseLinksPipe } from "projects/core"; import { UserLinksPipe } from "@core/pipes/user-links.pipe"; import { ProgramNewsCardComponent } from "../shared/news-card/news-card.component"; import { ButtonComponent, IconComponent } from "@ui/components"; -import { 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 { ModalComponent } from "@ui/components/modal/modal.component"; import { MatProgressBarModule } from "@angular/material/progress-bar"; -import { AsyncPipe } from "@angular/common"; import { LoadingService } from "@office/services/loading.service"; +import { ProjectAdditionalService } from "@office/projects/edit/services/project-additional.service"; +import { SoonCardComponent } from "@office/shared/soon-card/soon-card.component"; +import { NewsFormComponent } from "@office/features/news-form/news-form.component"; +import { AsyncPipe } from "@angular/common"; +import { AvatarComponent } from "@uilib"; @Component({ selector: "app-main", @@ -45,19 +47,22 @@ import { LoadingService } from "@office/services/loading.service"; styleUrl: "./main.component.scss", standalone: true, imports: [ - AvatarComponent, IconComponent, ButtonComponent, - RouterLink, ProgramNewsCardComponent, - TagComponent, UserLinksPipe, AsyncPipe, ParseBreaksPipe, ParseLinksPipe, + ModalComponent, + MatProgressBarModule, + SoonCardComponent, NewsFormComponent, ModalComponent, MatProgressBarModule, + AvatarComponent, + TagComponent, + RouterModule, ], }) export class ProgramDetailMainComponent implements OnInit, OnDestroy { @@ -65,17 +70,27 @@ export class ProgramDetailMainComponent implements OnInit, OnDestroy { private readonly programService: ProgramService, private readonly programNewsService: ProgramNewsService, private readonly projectService: ProjectService, + private readonly projectAdditionalService: ProjectAdditionalService, private readonly router: Router, private readonly route: ActivatedRoute, private readonly cdRef: ChangeDetectorRef, private readonly loadingService: LoadingService ) {} + get isAssignProjectToProgramError() { + return this.projectAdditionalService.getIsAssignProjectToProgramError()(); + } + + get errorAssignProjectToProgramModalMessage() { + return this.projectAdditionalService.getErrorAssignProjectToProgramModalMessage(); + } + news = signal([]); totalNewsCount = signal(0); fetchLimit = signal(10); fetchPage = signal(0); + // Сигналы для работы с модальными окнами с текстом showProgramModal = signal(false); showProgramModalErrorMessage = signal(null); @@ -254,26 +269,8 @@ export class ProgramDetailMainComponent implements OnInit, OnDestroy { this.loadingService.hide(); } - 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); - }, - }); + clearAssignProjectToProgramError(): void { + this.projectAdditionalService.clearAssignProjectToProgramError(); } private loadEvent?: Observable; diff --git a/projects/social_platform/src/app/office/program/detail/members/members.component.html b/projects/social_platform/src/app/office/program/detail/members/members.component.html deleted file mode 100644 index b515e3378..000000000 --- a/projects/social_platform/src/app/office/program/detail/members/members.component.html +++ /dev/null @@ -1,14 +0,0 @@ - - -
- @if (program$ | async; as program) { - - } -
- @for (m of members; track m.id) { - - - - } -
-
diff --git a/projects/social_platform/src/app/office/program/detail/members/members.component.scss b/projects/social_platform/src/app/office/program/detail/members/members.component.scss deleted file mode 100644 index fe5b76512..000000000 --- a/projects/social_platform/src/app/office/program/detail/members/members.component.scss +++ /dev/null @@ -1,8 +0,0 @@ -.page { - &__list { - display: grid; - grid-template-columns: 1fr; - grid-gap: 20px; - margin-top: 10px; - } -} diff --git a/projects/social_platform/src/app/office/program/detail/members/members.component.spec.ts b/projects/social_platform/src/app/office/program/detail/members/members.component.spec.ts deleted file mode 100644 index aab0c5ae5..000000000 --- a/projects/social_platform/src/app/office/program/detail/members/members.component.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { ProgramMembersComponent } from "./members.component"; -import { RouterTestingModule } from "@angular/router/testing"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; - -describe("MembersComponent", () => { - let component: ProgramMembersComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [RouterTestingModule, HttpClientTestingModule, ProgramMembersComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(ProgramMembersComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); 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 deleted file mode 100644 index afef6967a..000000000 --- a/projects/social_platform/src/app/office/program/detail/members/members.component.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** @format */ - -import { - AfterViewInit, - ChangeDetectorRef, - Component, - ElementRef, - OnInit, - ViewChild, -} from "@angular/core"; -import { ActivatedRoute, RouterLink } from "@angular/router"; -import { concatMap, fromEvent, map, noop, Observable, of, tap, throttleTime } from "rxjs"; -import { Program } from "@office/program/models/program.model"; -import { User } from "@auth/models/user.model"; -import { ProgramService } from "@office/program/services/program.service"; -import { MemberCardComponent } from "@office/shared/member-card/member-card.component"; -import { ProgramHeadComponent } from "../../shared/program-head/program-head.component"; -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", - styleUrl: "./members.component.scss", - standalone: true, - imports: [ProgramHeadComponent, RouterLink, MemberCardComponent, AsyncPipe], -}) -export class ProgramMembersComponent implements OnInit, AfterViewInit { - constructor( - private readonly route: ActivatedRoute, - private readonly cdref: ChangeDetectorRef, - private readonly programService: ProgramService - ) {} - - ngOnInit(): void { - this.route.data.pipe(map(r => r["data"])).subscribe((members: ApiPagination) => { - this.membersTotalCount = members.count; - this.members = members.results; - }); - } - - ngAfterViewInit(): void { - const target = document.querySelector(".office__body"); - if (target) - fromEvent(target, "scroll") - .pipe( - concatMap(() => this.onScroll()), - throttleTime(500) - ) - .subscribe(noop); - } - - program$?: Observable = this.route.parent?.data.pipe(map(r => r["data"])); - members$: Observable = this.route.data.pipe( - map(r => r["data"]), - map(r => r["results"]) - ); - - members: User[] = []; - membersTotalCount?: number; - membersPage = 1; - membersTake = 20; - @ViewChild("membersRoot") membersRoot?: ElementRef; - - onScroll() { - if (this.membersTotalCount && this.members.length >= this.membersTotalCount) return of({}); - - const target = document.querySelector(".office__body"); - if (!target || !this.membersRoot) return of({}); - - const diff = - target.scrollTop - - this.membersRoot.nativeElement.getBoundingClientRect().height + - window.innerHeight; - - if (diff > 0) { - return this.onFetch(); - } - - return of({}); - } - - onFetch() { - return this.programService - .getAllMembers( - this.route.parent?.snapshot.params["programId"], - this.membersPage * this.membersTake, - this.membersTake - ) - .pipe( - tap((members: ApiPagination) => { - this.membersTotalCount = members.count; - this.members = [...this.members, ...members.results]; - - this.membersPage++; - - this.cdref.detectChanges(); - }) - ); - } -} diff --git a/projects/social_platform/src/app/office/program/detail/members/members.resolver.spec.ts b/projects/social_platform/src/app/office/program/detail/members/members.resolver.spec.ts deleted file mode 100644 index 66c58851c..000000000 --- a/projects/social_platform/src/app/office/program/detail/members/members.resolver.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; -import { ProgramMembersResolver } from "./members.resolver"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; -import { ActivatedRouteSnapshot, RouterStateSnapshot } from "@angular/router"; - -describe("ProgramMembersResolver", () => { - const mockRoute = { parent: { params: { programId: 1 } } } as unknown as ActivatedRouteSnapshot; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - }); - }); - - it("should be created", () => { - const result = TestBed.runInInjectionContext(() => - ProgramMembersResolver(mockRoute, {} as RouterStateSnapshot) - ); - expect(result).toBeTruthy(); - }); -}); 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 deleted file mode 100644 index 301e0e83c..000000000 --- a/projects/social_platform/src/app/office/program/detail/projects/projects.component.html +++ /dev/null @@ -1,45 +0,0 @@ - - -
- @if (program$ | async; as program) { - - - - - } -
-
- Фильтр - -
-
    - @for (p of searchedProjects; track p.id) { -
  • - - - -
  • - } -
- -
-
-
-
- -
-
-
-
diff --git a/projects/social_platform/src/app/office/program/detail/projects/projects.component.spec.ts b/projects/social_platform/src/app/office/program/detail/projects/projects.component.spec.ts deleted file mode 100644 index ca8d7a7ca..000000000 --- a/projects/social_platform/src/app/office/program/detail/projects/projects.component.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { ProgramProjectsComponent } from "./projects.component"; -import { RouterTestingModule } from "@angular/router/testing"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; - -describe("ProjectsComponent", () => { - let component: ProgramProjectsComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [RouterTestingModule, ProgramProjectsComponent, HttpClientTestingModule], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(ProgramProjectsComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); 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 deleted file mode 100644 index ef7b274fe..000000000 --- a/projects/social_platform/src/app/office/program/detail/projects/projects.component.ts +++ /dev/null @@ -1,327 +0,0 @@ -/** @format */ - -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, - Observable, - of, - Subscription, - tap, - throttleTime, -} from "rxjs"; -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, CommonModule } from "@angular/common"; -import { ProgramService } from "@office/program/services/program.service"; -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"; - -@Component({ - selector: "app-projects", - templateUrl: "./projects.component.html", - styleUrl: "./projects.component.scss", - standalone: true, - imports: [ - CommonModule, - ReactiveFormsModule, - ProgramHeadComponent, - RouterLink, - ProjectCardComponent, - IconComponent, - AsyncPipe, - 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, - 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[] = []; - - private currentFilters: Record = {}; - - ngOnInit(): void { - const routeData$ = this.route.data - .pipe( - map(r => r["data"]), - tap(r => (this.projectsTotalCount = r["count"])), - map(r => r["results"]) - ) - .subscribe({ - next: 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; - - this.currentFilters = reqQuery["filters"] || {}; - this.page = 1; - - const hasFilters = - reqQuery && reqQuery["filters"] && Object.keys(reqQuery["filters"]).length > 0; - - const params = new HttpParams({ fromObject: { offset: 0, limit: 21 } }); - - if (hasFilters) { - return this.programService - .createProgramFilters(programId, reqQuery["filters"], params) - .pipe( - catchError(err => { - console.error("createFilters failed, fallback to getAllProjects()", err); - return this.programService.getAllProjects(programId, params); - }) - ); - } - - return this.programService.getAllProjects(programId, params).pipe( - catchError(err => { - console.error("getAllProjects failed", err); - return this.programService.getAllProjects(programId, 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.projectsTotalCount = projects.count; - - this.cdref.detectChanges(); - }); - - projects$ && this.subscriptions$.push(projects$); - - this.subscriptions$.push(routeData$); - } - - ngAfterViewInit() { - const target = document.querySelector(".office__body"); - if (!target) return; - - const scroll$ = fromEvent(target, "scroll") - .pipe( - throttleTime(500), - concatMap(() => this.onScroll()) - ) - .subscribe(noop); - this.subscriptions$.push(scroll$); - } - - ngOnDestroy(): void { - 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({}); - - const target = document.querySelector(".office__body"); - if (!target || !this.projectsRoot) return of({}); - - const diff = - target.scrollTop - - this.projectsRoot.nativeElement.getBoundingClientRect().height + - window.innerHeight; - - if (diff > -200) { - return this.onFetch(); - } - - return of({}); - } - - private onFetch() { - const programId = this.route.parent?.snapshot.params["programId"]; - const offset = this.page * this.perPage; - const limit = this.perPage; - - const hasFilters = this.currentFilters && Object.keys(this.currentFilters).length > 0; - - if (hasFilters) { - const params = new HttpParams({ fromObject: { offset, limit } }); - - return this.programService.createProgramFilters(programId, this.currentFilters, params).pipe( - tap(projects => { - this.projectsTotalCount = projects.count; - this.projects = [...this.projects, ...projects.results]; - this.searchedProjects = this.projects; - this.page++; - this.cdref.detectChanges(); - }), - catchError(err => { - console.error("Pagination with filters failed", err); - return of({ results: [], count: 0 }); - }) - ); - } else { - return this.programService - .getAllProjects(programId, new HttpParams({ fromObject: { 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.spec.ts b/projects/social_platform/src/app/office/program/detail/projects/projects.resolver.spec.ts deleted file mode 100644 index c3de2576a..000000000 --- a/projects/social_platform/src/app/office/program/detail/projects/projects.resolver.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; - -import { ProgramProjectsResolver } from "./projects.resolver"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; -import { ActivatedRouteSnapshot, RouterStateSnapshot } from "@angular/router"; - -describe("ProgramProjectsResolver", () => { - const mockRoute = { parent: { params: { programId: 1 } } } as unknown as ActivatedRouteSnapshot; - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - }); - }); - - it("should be created", () => { - const result = TestBed.runInInjectionContext(() => - ProgramProjectsResolver(mockRoute, {} as RouterStateSnapshot) - ); - expect(result).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/program/detail/rate-projects/list/list-all.resolver.spec.ts b/projects/social_platform/src/app/office/program/detail/rate-projects/list/list-all.resolver.spec.ts deleted file mode 100644 index e47b298d5..000000000 --- a/projects/social_platform/src/app/office/program/detail/rate-projects/list/list-all.resolver.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; - -import { ListAllResolver } from "./list-all.resolver"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; -import { ActivatedRouteSnapshot, RouterStateSnapshot } from "@angular/router"; - -describe("ListAllResolver", () => { - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - }); - }); - - it("should be created", () => { - const result = TestBed.runInInjectionContext(() => - ListAllResolver({} as ActivatedRouteSnapshot, {} as RouterStateSnapshot) - ); - expect(result).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/program/detail/rate-projects/list/list-rated.resolver.spec.ts b/projects/social_platform/src/app/office/program/detail/rate-projects/list/list-rated.resolver.spec.ts deleted file mode 100644 index 7384d21f6..000000000 --- a/projects/social_platform/src/app/office/program/detail/rate-projects/list/list-rated.resolver.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; - -import { ListRatedResolver } from "./list-rated.resolver"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; -import { ActivatedRouteSnapshot, RouterStateSnapshot } from "@angular/router"; - -describe("ListRatedResolver", () => { - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - }); - }); - - it("should be created", () => { - const result = TestBed.runInInjectionContext(() => - ListRatedResolver({} as ActivatedRouteSnapshot, {} as RouterStateSnapshot) - ); - expect(result).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/program/detail/rate-projects/list/list.component.html b/projects/social_platform/src/app/office/program/detail/rate-projects/list/list.component.html deleted file mode 100644 index fa808063a..000000000 --- a/projects/social_platform/src/app/office/program/detail/rate-projects/list/list.component.html +++ /dev/null @@ -1,17 +0,0 @@ - - -
- @if (projects()) { -
- @for (p of projects().slice(currentIndex(), currentIndex() + 1); track p.id) { - - } -
- } -
diff --git a/projects/social_platform/src/app/office/program/detail/rate-projects/list/list.component.scss b/projects/social_platform/src/app/office/program/detail/rate-projects/list/list.component.scss deleted file mode 100644 index 5ff8790ee..000000000 --- a/projects/social_platform/src/app/office/program/detail/rate-projects/list/list.component.scss +++ /dev/null @@ -1,7 +0,0 @@ -.projects { - &__list { - display: flex; - flex-direction: column; - gap: 20px; - } -} diff --git a/projects/social_platform/src/app/office/program/detail/rate-projects/list/list.component.spec.ts b/projects/social_platform/src/app/office/program/detail/rate-projects/list/list.component.spec.ts deleted file mode 100644 index bcf4f2e89..000000000 --- a/projects/social_platform/src/app/office/program/detail/rate-projects/list/list.component.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { ListComponent } from "./list.component"; -import { RouterTestingModule } from "@angular/router/testing"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; - -describe("ListComponent", () => { - let component: ListComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [RouterTestingModule, HttpClientTestingModule, ListComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(ListComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); 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 deleted file mode 100644 index 2976a1139..000000000 --- a/projects/social_platform/src/app/office/program/detail/rate-projects/list/list.component.ts +++ /dev/null @@ -1,191 +0,0 @@ -/** @format */ - -import { AfterViewInit, Component, OnDestroy, OnInit, signal } from "@angular/core"; -import { ActivatedRoute, Router } from "@angular/router"; -import { - concatMap, - debounceTime, - fromEvent, - map, - of, - Subscription, - switchMap, - tap, - throttleTime, -} from "rxjs"; -import { RatingCardComponent } from "@office/program/shared/rating-card/rating-card.component"; -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", - styleUrl: "./list.component.scss", - standalone: true, - imports: [RatingCardComponent], -}) -export class ListComponent implements OnInit, AfterViewInit, OnDestroy { - constructor( - private readonly route: ActivatedRoute, - private readonly router: Router, - private readonly projectRatingService: ProjectRatingService - ) {} - - isListOfAll = this.router.url.includes("/all"); - isRatedByExpert = signal(undefined); - searchValue = signal(""); - - projects = signal([]); - initialProjects: ProjectRate[] = []; - currentIndex = signal(0); - - totalProjCount = signal(0); - fetchLimit = signal(8); - fetchPage = signal(0); - - subscriptions$ = signal([]); - - ngOnInit(): void { - const initProjects$ = this.route.data - .pipe( - map(r => r["data"]), - map(r => ({ projects: r["results"], count: r["count"] })) - ) - .subscribe(({ projects, count }) => { - this.initialProjects = projects; - this.projects.set(projects); - this.totalProjCount.set(count); - }); - - const queryParams$ = this.route.queryParams - .pipe( - debounceTime(200), - tap(params => { - const isRatedByExpert = - params["is_rated_by_expert"] === "true" - ? true - : params["is_rated_by_expert"] === "false" - ? false - : undefined; - const searchValue = params["name__contains"]; - - this.isRatedByExpert.set(isRatedByExpert); - this.searchValue.set(searchValue); - }), - switchMap(() => this.onFetch(this.fetchPage() * this.fetchLimit(), this.fetchLimit())) - ) - .subscribe(result => { - this.projects.set(result.results); - }); - - this.subscriptions$().push(initProjects$, queryParams$); - } - - ngAfterViewInit() { - const target = document.querySelector(".office__body"); - if (target) { - const scrollEvents$ = fromEvent(target, "scroll") - .pipe( - debounceTime(200), - concatMap(() => this.onScroll()), - throttleTime(2000) - ) - .subscribe(); - - this.subscriptions$().push(scrollEvents$); - } - } - - ngOnDestroy(): void { - this.subscriptions$().forEach($ => $.unsubscribe()); - } - - toggleProject(type: "next" | "prev"): void { - if (this.currentIndex() >= 0) { - if (type === "prev") { - this.currentIndex.update(() => this.currentIndex() - 1); - } else this.currentIndex.update(() => this.currentIndex() + 1); - } - } - - onScroll() { - if (this.projects().length >= this.totalProjCount()) { - return of({}); - } - - 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.onFetch(this.fetchPage() * this.fetchLimit(), this.fetchLimit()); - } - - onFetch(offset: number, limit: number) { - const programId = this.route.parent?.snapshot.params["programId"]; - const observable = this.projectRatingService.getAll( - programId, - offset, - limit, - this.isRatedByExpert(), - this.searchValue() - ); - - return observable.pipe( - tap(({ count, results }) => { - this.totalProjCount.set(count); - this.projects.set(results); - }) - ); - } -} 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 deleted file mode 100644 index edb657929..000000000 --- a/projects/social_platform/src/app/office/program/detail/rate-projects/rate-project.routes.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** @format */ - -import { Routes } from "@angular/router"; -import { RateProjectsComponent } from "@office/program/detail/rate-projects/rate-projects.component"; -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: "", - component: RateProjectsComponent, - children: [ - { - path: "", - redirectTo: "all", - pathMatch: "full", - }, - { - path: "all", - component: ListComponent, - resolve: { - data: ListAllResolver, - }, - }, - // { - // path: "rated", - // component: ListComponent, - // resolve: { - // data: ListRatedResolver, - // }, - // }, - ], - }, -]; diff --git a/projects/social_platform/src/app/office/program/detail/rate-projects/rate-projects.component.html b/projects/social_platform/src/app/office/program/detail/rate-projects/rate-projects.component.html deleted file mode 100644 index df87abb1c..000000000 --- a/projects/social_platform/src/app/office/program/detail/rate-projects/rate-projects.component.html +++ /dev/null @@ -1,54 +0,0 @@ - - -
- - - -
- - - -
- -
- -
-
diff --git a/projects/social_platform/src/app/office/program/detail/rate-projects/rate-projects.component.scss b/projects/social_platform/src/app/office/program/detail/rate-projects/rate-projects.component.scss deleted file mode 100644 index 7d048dffc..000000000 --- a/projects/social_platform/src/app/office/program/detail/rate-projects/rate-projects.component.scss +++ /dev/null @@ -1,100 +0,0 @@ -@use "styles/responsive"; - -.rate-project { - &__bar { - margin-bottom: 20px; - } - - .filter { - margin-bottom: 24px; - - @include responsive.apply-desktop { - display: flex; - flex-flow: column nowrap; - gap: 10px; - } - - &__left { - display: flex; - flex-basis: 65%; - gap: 10px; - margin-right: 10px; - } - - &__search { - padding: 10px; - color: var(--black); - background-color: var(--white); - border: 1px solid var(--grey-button); - border-radius: var(--rounded-lg); - } - - &__category { - display: flex; - align-items: center; - justify-content: space-between; - margin-top: 10px; - cursor: pointer; - - @include responsive.apply-desktop { - gap: 10px; - width: 44%; - margin-top: 0; - } - } - - // &__form { - // width: 55%; - // } - - &__top { - position: relative; - z-index: 100; - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - padding: 15px 12px; - color: var(--black); - background-color: var(--white); - border: 1px solid var(--grey-button); - border-radius: var(--rounded-lg); - } - - &__controls { - position: absolute; - top: 32%; - z-index: 50; - display: flex; - flex-direction: column; - gap: 12px; - width: 52%; - padding: 15px 12px; - background-color: var(--white); - border: 1px solid var(--grey-button); - border-bottom-right-radius: var(--rounded-lg); - border-bottom-left-radius: var(--rounded-lg); - - @include responsive.apply-desktop { - top: 16%; - width: 10.2%; - } - } - - &__tags { - display: flex; - gap: 15px; - align-items: center; - width: 100%; - color: var(--black); - } - - &__button { - width: 45%; - - @include responsive.apply-desktop { - width: 100%; - } - } - } -} diff --git a/projects/social_platform/src/app/office/program/detail/rate-projects/rate-projects.component.spec.ts b/projects/social_platform/src/app/office/program/detail/rate-projects/rate-projects.component.spec.ts deleted file mode 100644 index 1d7ee29d7..000000000 --- a/projects/social_platform/src/app/office/program/detail/rate-projects/rate-projects.component.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { RateProjectsComponent } from "./rate-projects.component"; -import { RouterTestingModule } from "@angular/router/testing"; - -describe("RateProjectsComponent", () => { - let component: RateProjectsComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [RouterTestingModule, RateProjectsComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(RateProjectsComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); 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 deleted file mode 100644 index f2fa73ba4..000000000 --- a/projects/social_platform/src/app/office/program/detail/rate-projects/rate-projects.component.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** @format */ - -import { Component, OnDestroy, OnInit } from "@angular/core"; -import { NavService } from "@services/nav.service"; -import { ActivatedRoute, Router, RouterOutlet } from "@angular/router"; -import { BarComponent } from "@ui/components"; -import { FormBuilder, FormGroup, ReactiveFormsModule } from "@angular/forms"; -import { debounceTime, Observable, Subscription } from "rxjs"; -import { SearchComponent } from "@ui/components/search/search.component"; - -/** - * Компонент страницы оценки проектов программы - * - * Основной компонент для экспертной оценки проектов в рамках программы. - * Предоставляет интерфейс поиска и фильтрации проектов для оценки. - * - * Функциональность: - * - Поиск проектов по названию - * - Навигационная панель - * - 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", - styleUrl: "./rate-projects.component.scss", - standalone: true, - imports: [BarComponent, RouterOutlet, ReactiveFormsModule, SearchComponent], -}) -export class RateProjectsComponent implements OnInit, OnDestroy { - constructor( - private readonly navService: NavService, - private readonly router: Router, - private readonly route: ActivatedRoute, - private readonly fb: FormBuilder - ) { - // const isRatedByExpert = - // this.route.snapshot.queryParams["is_rated_by_expert"] === "true" - // ? true - // : this.route.snapshot.queryParams["is_rated_by_expert"] === "false" - // ? false - // : null; - - const searchValue = this.route.snapshot.queryParams["name__contains"]; - const decodedSearchValue = searchValue ? decodeURIComponent(searchValue) : ""; - - this.searchForm = this.fb.group({ - search: [decodedSearchValue], - }); - - // this.filterForm = this.fb.group({ - // filterTag: [isRatedByExpert, Validators.required], - // }); - } - - searchForm: FormGroup; - // filterForm: FormGroup; - - subscriptions$: Subscription[] = []; - programId?: number; - - isOpen = false; - // readonly filterTags = filterTags; - - ngOnInit(): void { - this.navService.setNavTitle("Профиль программы"); - this.programId = this.route.snapshot.params["programId"]; - - const queryParams$ = this.route.queryParams.subscribe(params => { - const searchValue = params["name__contains"]; - this.searchForm.get("search")?.setValue(searchValue, { emitEvent: false }); - }); - - const searchValueChanges$ = this.searchForm - .get("search") - ?.valueChanges.pipe(debounceTime(500)) - .subscribe(searchValue => { - this.router.navigate([], { - queryParams: { name__contains: searchValue || null }, - relativeTo: this.route, - queryParamsHandling: "merge", - }); - }); - - this.subscriptions$.push(queryParams$); - - if (searchValueChanges$) { - this.subscriptions$.push(searchValueChanges$); - } - } - - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $?.unsubscribe()); - } - - // setValue(event: Event, tag: boolean | null) { - // event.stopPropagation(); - // this.filterForm.get("filterTag")?.setValue(tag); - // this.isOpen = false; - - // this.router.navigate([], { - // queryParams: { is_rated_by_expert: tag }, - // relativeTo: this.route, - // queryParamsHandling: "merge", - // }); - // } - - // toggleOpen(event: Event) { - // event.stopPropagation(); - // this.isOpen = !this.isOpen; - // } - - onClickOutside() { - this.isOpen = false; - } - - // onSearchClick() { - // const searchValue = this.searchForm.get("search")?.value; - - // this.router.navigate([], { - // queryParams: { name__contains: searchValue }, - // relativeTo: this.route, - // queryParamsHandling: "merge", - // }); - - // this.searchForm.get("search")?.reset(); - // } -} diff --git a/projects/social_platform/src/app/office/program/detail/register/register.component.html b/projects/social_platform/src/app/office/program/detail/register/register.component.html index 0bb3e9cf5..e39dbf3f9 100644 --- a/projects/social_platform/src/app/office/program/detail/register/register.component.html +++ b/projects/social_platform/src/app/office/program/detail/register/register.component.html @@ -9,6 +9,7 @@ @if (registerForm.get(f.key); as field) { } - Зарегистрироваться в программе + Зарегистрироваться в программе }
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 d7b88b035..57a31894c 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 @@ -5,31 +5,28 @@
-
{{ newsItem.name }}
+
{{ newsItem.name }}
@if (newsItem.pin) { - + }
-
- {{ newsItem.datetimeCreated | dayjs: "format":"DD MMMM YYYY, HH:mm" }} -
@if(isOwner) {
- +
@if (menuOpen) {
    -
  • Удалить
  • +
  • Удалить
}
}
@if (newsItem.text) { -
+

} @if (editMode) { @@ -90,21 +87,25 @@ - -
-
- -

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

-
- -

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

- - Понятно -
-
- - -
-
- -

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

-
- -

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

- - Понятно -
-
-
@@ -169,7 +123,7 @@

📢 Внимание!

@@ -213,12 +170,16 @@

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

Отмена - Отправить
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 9ae74823e..6f31674ad 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 @@ -5,18 +5,24 @@ .project { position: relative; - height: 100%; - min-height: 800px; - padding: 24px; + padding: 30px 0; background-color: var(--white); border-radius: var(--rounded-md); &__top { + position: sticky; + top: -50%; + left: 6%; + z-index: 100; display: flex; + gap: 12%; align-items: center; - justify-content: space-between; - margin-top: 17px; - margin-bottom: 18px; + justify-content: space-evenly; + width: 100%; + padding: 4px 0; + margin-top: 20px; + background-color: var(--light-white); + border-radius: var(--rounded-xxl); } &__title { @@ -30,23 +36,16 @@ } &__back { - width: 20%; + display: flex; + gap: 10px; + align-items: center; + cursor: pointer; } &__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; - } } &__inner { @@ -113,19 +112,9 @@ &__save { @include responsive.apply-desktop { - position: absolute; - right: 48px; - bottom: 48px; display: flex; gap: 10px; - justify-content: flex-end; - order: unset; - width: 90%; - - app-button { - align-self: flex-end; - width: 20%; - } + align-items: center; } } @@ -136,10 +125,6 @@ gap: 20px; align-items: center; - app-button { - width: 250px; - } - span { color: var(--red); } @@ -165,11 +150,6 @@ gap: 20px; align-items: center; width: 672px; - - app-button { - width: 100%; - max-width: 366px; - } } &__content { @@ -254,15 +234,6 @@ gap: 10px; align-items: center; margin-top: 20px; - - ::ng-deep { - app-button { - .button { - padding-right: 52px; - padding-left: 52px; - } - } - } } &__button { 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 d358e008e..f6e1ac806 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 @@ -8,13 +8,12 @@ import { OnInit, signal, } from "@angular/core"; -import { FormGroup, ReactiveFormsModule } from "@angular/forms"; +import { Form, FormArray, FormGroup, ReactiveFormsModule } from "@angular/forms"; import { ActivatedRoute, Router, RouterModule } from "@angular/router"; import { ErrorMessage } from "@error/models/error-message"; import { Invite } from "@models/invite.model"; import { Project } from "@models/project.model"; import { Skill } from "@office/models/skill"; -import { ProgramTag } from "@office/program/models/program.model"; import { ProgramService } from "@office/program/services/program.service"; import { SkillsService } from "@office/services/skills.service"; import { SkillsGroupComponent } from "@office/shared/skills-group/skills-group.component"; @@ -27,20 +26,19 @@ import { ValidationService } from "projects/core"; import { Observable, Subscription, - concatMap, distinctUntilChanged, - finalize, + forkJoin, map, + of, + switchMap, 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 { ProjectPartnerResourcesStepComponent } from "./shared/project-partner-resources-step/project-partner-resources-step.component"; import { ProjectAchievementStepComponent } from "./shared/project-achievement-step/project-achievement-step.component"; import { ProjectVacancyStepComponent } from "./shared/project-vacancy-step/project-vacancy-step.component"; import { ProjectVacancyService } from "./services/project-vacancy.service"; @@ -49,6 +47,15 @@ import { ProjectTeamService } from "./services/project-team.service"; import { ProjectAdditionalStepComponent } from "./shared/project-additional-step/project-additional-step.component"; import { ProjectAdditionalService } from "./services/project-additional.service"; import { ProjectAchievementsService } from "./services/project-achievements.service"; +import { Goal, GoalPostForm } from "@office/models/goals.model"; +import { ProjectGoalService } from "./services/project-goals.service"; +import { SnackbarService } from "@ui/services/snackbar.service"; +import { Resource, ResourcePostForm } from "@office/models/resource.model"; +import { Partner } from "@office/models/partner.model"; +import { ProjectPartnerService } from "./services/project-partner.service"; +import { ProjectResourceService } from "./services/project-resources.service"; +import { HttpErrorResponse } from "@angular/common/http"; +import { ProjectAssign } from "../models/project-assign.model"; /** * Компонент редактирования проекта @@ -59,24 +66,8 @@ import { ProjectAchievementsService } from "./services/project-achievements.serv * - Загрузка файлов (презентация, обложка, аватар) * - Создание и редактирование вакансий с навыками * - Приглашение участников в команду - * - Управление достижениями и ссылками проекта + * - Управление достижениями, ссылками и целями проекта * - Сохранение как черновик или публикация - * - * Принимает: - * - ID проекта из URL параметров - * - Данные проекта и приглашений через resolver - * - Query параметр editingStep для определения активного шага - * - * Возвращает: - * - Интерфейс редактирования с навигацией по шагам - * - Формы для ввода данных проекта - * - Модальные окна для управления навыками и приглашениями - * - * Особенности: - * - Реактивные формы с валидацией - * - Динамическое управление массивами (достижения, ссылки) - * - Интеграция с внешними сервисами (навыки, программы) - * - Поддержка автокомплита для навыков */ @Component({ selector: "app-edit", @@ -94,11 +85,11 @@ import { ProjectAchievementsService } from "./services/project-achievements.serv SkillsGroupComponent, ProjectNavigationComponent, ProjectMainStepComponent, - ProjectContactsStepComponent, ProjectAchievementStepComponent, ProjectVacancyStepComponent, ProjectTeamStepComponent, ProjectAdditionalStepComponent, + ProjectPartnerResourcesStepComponent, ], }) export class ProjectEditComponent implements OnInit, AfterViewInit, OnDestroy { @@ -110,14 +101,18 @@ export class ProjectEditComponent implements OnInit, AfterViewInit, OnDestroy { private readonly navService: NavService, private readonly validationService: ValidationService, private readonly cdRef: ChangeDetectorRef, - private readonly programService: ProgramService, private readonly projectStepService: ProjectStepService, private readonly projectFormService: ProjectFormService, private readonly projectVacancyService: ProjectVacancyService, private readonly projectTeamService: ProjectTeamService, private readonly projectAchievementsService: ProjectAchievementsService, + private readonly projectGoalsService: ProjectGoalService, + private readonly projectPartnerService: ProjectPartnerService, + private readonly projectResourceService: ProjectResourceService, + private readonly snackBarService: SnackbarService, private readonly skillsService: SkillsService, - private readonly projectAdditionalService: ProjectAdditionalService + private readonly projectAdditionalService: ProjectAdditionalService, + private readonly projectGoalService: ProjectGoalService ) {} // Получаем форму проекта из сервиса @@ -130,7 +125,7 @@ export class ProjectEditComponent implements OnInit, AfterViewInit, OnDestroy { return this.projectVacancyService.getVacancyForm(); } - // Получаем форму вакансии из сервиса + // Получаем форму дополнительных полей из сервиса get additionalForm(): FormGroup { return this.projectAdditionalService.getAdditionalForm(); } @@ -140,7 +135,7 @@ export class ProjectEditComponent implements OnInit, AfterViewInit, OnDestroy { return this.projectFormService.achievements; } - // Id редатируемой части проекта + // Id редактируемой части проекта get editIndex() { return this.projectFormService.editIndex; } @@ -172,6 +167,22 @@ export class ProjectEditComponent implements OnInit, AfterViewInit, OnDestroy { this.projectAdditionalService.clearAssignProjectToProgramError(); } + // Сигналы для работы с модальными окнами с текстом + assignProjectToProgramModalMessage = signal(null); + + // Геттеры для работы с целями + get goals(): FormArray { + return this.projectGoalsService.goals; + } + + get partners(): FormArray { + return this.projectPartnerService.partners; + } + + get resources(): FormArray { + return this.projectResourceService.resources; + } + ngOnInit(): void { this.navService.setNavTitle("Создание проекта"); @@ -190,11 +201,13 @@ export class ProjectEditComponent implements OnInit, AfterViewInit, OnDestroy { ngOnDestroy(): void { this.profile$?.unsubscribe(); this.subscriptions.forEach($ => $?.unsubscribe()); + + // Сброс состояния ProjectGoalService при уничтожении компонента + this.projectGoalService.reset(); } // Опции для программных тегов programTagsOptions: SelectComponent["options"] = []; - programTags: ProgramTag[] = []; // Id Лидера проекта leaderId = 0; @@ -210,6 +223,10 @@ export class ProjectEditComponent implements OnInit, AfterViewInit, OnDestroy { return this.projectStepService.getCurrentStep()(); } + get hasOpenSkillsGroups(): boolean { + return this.openGroupIds.size > 0; + } + // Состояние компонента isCompleted = false; isSendDescisionToPartnerProgramProject = false; @@ -227,9 +244,6 @@ export class ProjectEditComponent implements OnInit, AfterViewInit, OnDestroy { onEditClicked = signal(false); warningModalSeen = false; - // Сигналы для работы с модальными окнами с текстом - assignProjectToProgramModalMessage = signal(null); - // Observables для данных industries$ = this.industryService.industries.pipe( map(industries => @@ -237,13 +251,9 @@ export class ProjectEditComponent implements OnInit, AfterViewInit, OnDestroy { ) ); - projectSteps$: Observable = this.projectService.steps.pipe( - map(steps => steps.map(step => ({ id: step.id, label: step.name, value: step.id }))) - ); - subscriptions: (Subscription | undefined)[] = []; - profileId: number = this.route.snapshot.params["projectId"]; + profileId: number = +this.route.snapshot.params["projectId"]; // Сигналы для управления состоянием inlineSkills = signal([]); @@ -255,6 +265,7 @@ export class ProjectEditComponent implements OnInit, AfterViewInit, OnDestroy { projSubmitInitiated = false; projFormIsSubmittingAsPublished = false; projFormIsSubmittingAsDraft = false; + openGroupIds = new Set(); /** * Навигация между шагами редактирования @@ -309,6 +320,17 @@ export class ProjectEditComponent implements OnInit, AfterViewInit, OnDestroy { // Очистка основной формы this.projectFormService.clearAllValidationErrors(); this.projectAchievementsService.clearAllAchievementsErrors(this.achievements); + + // Очистка ошибок целей теперь входит в clearAllValidationErrors() ProjectFormService + } + + onGroupToggled(isOpen: boolean, skillsGroupId: number): void { + this.openGroupIds.clear(); + if (isOpen) { + this.openGroupIds.add(skillsGroupId); + } + + this.cdRef.markForCheck(); } /** @@ -396,15 +418,22 @@ export class ProjectEditComponent implements OnInit, AfterViewInit, OnDestroy { } this.setProjFormIsSubmitting(true); - this.projectService.updateProject(projectId, payload).subscribe({ - next: () => { - this.setProjFormIsSubmitting(false); - this.router.navigateByUrl(`/office/projects/my`); - }, - error: () => { - this.setProjFormIsSubmitting(false); - }, - }); + this.projectService + .updateProject(projectId, payload) + .pipe( + switchMap(() => this.saveOrEditGoals(projectId)), + switchMap(() => this.savePartners(projectId)), + switchMap(() => this.saveOrEditResources(projectId)) + ) + .subscribe({ + next: () => { + this.completeSubmitedProjectForm(projectId); + }, + error: () => { + this.setProjFormIsSubmitting(false); + this.snackBarService.error("ошибка при сохранении данных"); + }, + }); } // Методы для работы с модальными окнами @@ -425,6 +454,62 @@ export class ProjectEditComponent implements OnInit, AfterViewInit, OnDestroy { this.isAssignProjectToProgramModalOpen.set(false); } + private saveOrEditGoals(projectId: number) { + const goals = this.goals.value as Goal[]; + + const newGoals = goals.filter(g => !g.id); + const existingGoals = goals.filter(g => g.id); + + const requests: Observable[] = []; + + if (newGoals.length > 0) { + requests.push(this.projectGoalService.saveGoals(projectId, newGoals)); + } + + if (existingGoals.length > 0) { + requests.push(this.projectGoalService.editGoals(projectId, existingGoals)); + } + + if (requests.length === 0) { + return of(null); + } + + return forkJoin(requests).pipe( + tap(() => { + this.projectGoalService.syncGoalItems(this.projectGoalService.goals); + }) + ); + } + + private savePartners(projectId: number) { + const partners = this.partners.value; + + if (!partners.length) { + return of([]); + } + + return this.projectPartnerService.savePartners(projectId); + } + + private saveOrEditResources(projectId: number) { + const resources = this.resources.value; + const hasExistingResources = resources.some((r: Resource) => r.id != null); + + if (!resources.length) { + return of([]); + } + + return hasExistingResources + ? this.projectResourceService.editResources(projectId) + : this.projectResourceService.saveResources(projectId); + } + + private completeSubmitedProjectForm(projectId: number) { + this.snackBarService.success("данные успешно сохранены"); + this.setProjFormIsSubmitting(false); + this.router.navigateByUrl(`/office/projects/${projectId}`); + } + /** * Валидация дополнительных полей для публикации * Делегирует валидацию сервису @@ -433,10 +518,12 @@ export class ProjectEditComponent implements OnInit, AfterViewInit, OnDestroy { private validateAdditionalFields(): boolean { const partnerProgramFields = this.projectAdditionalService.getPartnerProgramFields(); + // Если нет дополнительных полей - пропускаем валидацию if (!partnerProgramFields?.length) { return false; } + // Проверяем только обязательные поля const hasInvalid = this.projectAdditionalService.validateRequiredFields(); if (hasInvalid) { @@ -444,7 +531,7 @@ export class ProjectEditComponent implements OnInit, AfterViewInit, OnDestroy { return true; } - // Подготавливаем поля для отправки + // Подготавливаем поля для отправки (убираем валидаторы с заполненных полей) this.projectAdditionalService.prepareFieldsForSubmit(); return false; } @@ -553,43 +640,38 @@ export class ProjectEditComponent implements OnInit, AfterViewInit, OnDestroy { } 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); - this.projectTeamService.setCollaborators(project.collaborators); - - // Инициализируем дополнительные поля через сервис - if (project.partnerProgram) { - this.isCompetitive = project.partnerProgram.canSubmit; - this.isProjectBoundToProgram = !!project.partnerProgram.programId; - - this.projectAdditionalService.initializeAdditionalForm( - project.partnerProgram?.programFields, - project.partnerProgram?.programFieldValues - ); - } + this.route.data + .pipe(map(d => d["data"])) + .subscribe( + ([project, goals, partners, resources, invites]: [ + Project, + Goal[], + Partner[], + Resource[], + Invite[] + ]) => { + // Используем сервис для инициализации данных проекта + this.projectFormService.initializeProjectData(project); + this.projectGoalService.initializeGoalsFromProject(goals); + this.projectPartnerService.initializePartnerFromProject(partners); + this.projectResourceService.initializeResourcesFromProject(resources); + this.projectTeamService.setInvites(invites); + this.projectTeamService.setCollaborators(project.collaborators); + + if (project.partnerProgram) { + this.isCompetitive = project.partnerProgram.canSubmit; + + this.projectAdditionalService.initializeAdditionalForm( + project.partnerProgram?.programFields, + project.partnerProgram?.programFieldValues + ); + } - this.projectVacancyService.setVacancies(project.vacancies); - this.projectTeamService.setInvites(invites); + this.projectVacancyService.setVacancies(project.vacancies); + this.projectTeamService.setInvites(invites); - this.cdRef.detectChanges(); - }); + 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 ab7722f59..3d48dcba9 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 @@ -7,6 +7,9 @@ import { ProjectService } from "@services/project.service"; import { Project } from "@models/project.model"; import { InviteService } from "@services/invite.service"; import { Invite } from "@models/invite.model"; +import { Goal } from "@office/models/goals.model"; +import { Partner } from "@office/models/partner.model"; +import { Resource } from "@office/models/resource.model"; /** * Resolver для загрузки данных редактирования проекта @@ -30,7 +33,7 @@ import { Invite } from "@models/invite.model"; * Применяет forkJoin для параллельной загрузки данных проекта и приглашений, * что оптимизирует время загрузки страницы. */ -export const ProjectEditResolver: ResolveFn<[Project, Invite[]]> = ( +export const ProjectEditResolver: ResolveFn<[Project, Goal[], Partner[], Resource[], Invite[]]> = ( route: ActivatedRouteSnapshot ) => { const projectService = inject(ProjectService); @@ -38,8 +41,11 @@ export const ProjectEditResolver: ResolveFn<[Project, Invite[]]> = ( const projectId = Number(route.paramMap.get("projectId")); - return forkJoin<[Project, Invite[]]>([ + return forkJoin<[Project, Goal[], Partner[], Resource[], Invite[]]>([ projectService.getOne(projectId), + projectService.getGoals(projectId), + projectService.getPartners(projectId), + projectService.getResources(projectId), inviteService.getByProject(projectId), ]); }; diff --git a/projects/social_platform/src/app/office/projects/edit/services/project-achievements.service.ts b/projects/social_platform/src/app/office/projects/edit/services/project-achievements.service.ts index c9c90f8d7..d904fad70 100644 --- 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 @@ -55,24 +55,19 @@ export class ProjectAchievementsService { this.initializeAchievementsItems(achievementsFormArray); // Считываем вводимые данные - const achievementsName = projectForm.get("achievementsName")?.value; - const achievementsPrize = projectForm.get("achievementsPrize")?.value; + const title = projectForm.get("title")?.value; + const status = projectForm.get("status")?.value; // Проверяем, что поля не пустые - if ( - !achievementsName || - !achievementsPrize || - achievementsName.trim().length === 0 || - achievementsPrize.trim().length === 0 - ) { + if (!title || !status || title.trim().length === 0 || status.trim().length === 0) { return; // Выходим из функции, если поля пустые } // Создаем FormGroup для нового достижения const achievementItem = this.fb.group({ id: achievementsFormArray.length, - title: achievementsName.trim(), - status: achievementsPrize.trim(), + title: title.trim(), + status: status.trim(), }); // Проверяем, редактируется ли существующее достижение @@ -94,11 +89,11 @@ export class ProjectAchievementsService { } // Очищаем поля ввода формы проекта - projectForm.get("achievementsName")?.reset(); - projectForm.get("achievementsName")?.setValue(""); + projectForm.get("title")?.reset(); + projectForm.get("title")?.setValue(""); - projectForm.get("achievementsPrize")?.reset(); - projectForm.get("achievementsPrize")?.setValue(""); + projectForm.get("status")?.reset(); + projectForm.get("status")?.setValue(""); } /** @@ -120,8 +115,8 @@ export class ProjectAchievementsService { // Заполняем поля формы проекта для редактирования projectForm.patchValue({ - achievementsName: source?.title || "", - achievementsPrize: source?.status || "", + achievementsName: source?.achievementsName || "", + achievementsDate: source?.achievementsDate || "", }); // Устанавливаем текущий индекс редактирования в сервисе this.projectFormService.editIndex.set(index); 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 index 1bd2bd018..7a8217e54 100644 --- 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 @@ -1,103 +1,169 @@ /** @format */ -import { inject, Injectable, signal, effect } from "@angular/core"; -import { FormArray, FormBuilder, FormGroup, FormControl } from "@angular/forms"; +import { inject, Injectable, signal } from "@angular/core"; +import { FormArray, FormBuilder, FormGroup, FormControl, Validators } from "@angular/forms"; import { ProjectFormService } from "./project-form.service"; +/** + * Сервис для управления контактами проекта. + * Предоставляет методы для добавления, редактирования, удаления ссылок, + * а также очистки ошибок валидации. + */ @Injectable({ providedIn: "root", }) export class ProjectContactsService { + /** FormBuilder для создания FormGroup элементов */ private readonly fb = inject(FormBuilder); + /** Сервис для управления индексом редактируемой ссылки */ private readonly projectFormService = inject(ProjectFormService); + /** Сигнал для хранения списка ссылок (массив объектов) */ public readonly linksItems = signal([]); + private initialized = false; - constructor() { - effect(() => { - const formArray = this.links; - if (formArray && formArray.length > 0) { - const currentSignalValue = this.linksItems(); - const formArrayValue = formArray.value; + /** + * Инициализирует сигнал linksItems из данных FormArray + * Вызывается при первом обращении к данным + */ + private initializeLinksItems(linksFormArray: FormArray): void { + if (this.initialized) return; - if (JSON.stringify(currentSignalValue) !== JSON.stringify(formArrayValue)) { - this.linksItems.set(formArrayValue); - } - } - }); + if (linksFormArray && linksFormArray.length > 0) { + // Синхронизируем сигнал с данными из FormArray + this.linksItems.set(linksFormArray.value); + } + this.initialized = true; } + /** + * Принудительно синхронизирует сигнал с FormArray + * Полезно вызывать после загрузки данных с сервера + */ + public syncLinksItems(linksFormArray: FormArray): void { + if (linksFormArray) { + this.linksItems.set(linksFormArray.value); + } + } + + /** + * Получает основную форму проекта + */ private get projectForm(): FormGroup { return this.projectFormService.getForm(); } + /** + * Получает FormArray ссылок + */ public get links(): FormArray { return this.projectForm.get("links") as FormArray; } + /** + * Получает FormControl для поля ввода ссылки + */ public get link(): FormControl { return this.projectForm.get("link") as FormControl; } /** - * Принудительная синхронизация сигнала с FormArray - * Вызывается после загрузки данных проекта + * Добавляет новую ссылку или сохраняет изменения существующей. + * @param linksFormArray FormArray, содержащий формы ссылок + * @param projectForm основная форма проекта (FormGroup) */ - public syncLinksItems(): void { - const linksFormArray = this.links; - if (linksFormArray && linksFormArray.length > 0) { - this.linksItems.set(linksFormArray.value); - } else { - this.linksItems.set([]); - } - } + public addLink(linksFormArray: FormArray, projectForm: FormGroup): void { + // Инициализируем сигнал при первом вызове + this.initializeLinksItems(linksFormArray); - public addLink(): void { - const linkValue = this.link?.value; + // Считываем вводимые данные + const linkValue = projectForm.get("link")?.value; + // Проверяем, что поле не пустое и содержит валидный URL if ( !linkValue || !linkValue.trim() || - !linkValue.includes("https://") || - !linkValue.includes("http://") + (!linkValue.includes("https://") && !linkValue.includes("http://")) ) { - return; + return; // Выходим из функции, если поле пустое или невалидное } const trimmedLink = linkValue.trim(); - const editIdx = this.projectFormService.editIndex(); + // Проверяем, редактируется ли существующая ссылка + const editIdx = this.projectFormService.editIndex(); if (editIdx !== null) { - // Режим редактирования - this.links.at(editIdx).setValue(trimmedLink); + // Обновляем массив сигналов и соответствующий контрол в FormArray this.linksItems.update(items => { const updated = [...items]; - updated[editIdx] = trimmedLink; + updated[editIdx] = trimmedLink.value; return updated; }); + linksFormArray.at(editIdx).patchValue(trimmedLink.value); + // Сбрасываем индекс редактирования this.projectFormService.editIndex.set(null); } else { - // Добавление нового элемента - this.links.push(this.fb.control(trimmedLink)); - this.linksItems.update(items => [...items, trimmedLink]); + // Добавляем новую ссылку в сигнал и FormArray + this.linksItems.update(items => [...items, trimmedLink.value]); + linksFormArray.push(this.fb.control(trimmedLink, Validators.required)); } - // Очищаем поле ввода - this.link?.reset(); - this.link?.setValue(""); + // Очищаем поле ввода формы проекта + projectForm.get("link")?.reset(); + projectForm.get("link")?.setValue(""); } - public editLink(index: number): void { - const value = this.links.value[index]; - this.projectForm.patchValue({ link: value }); + /** + * Инициализирует редактирование существующей ссылки. + * @param index индекс ссылки в списке + * @param linksFormArray FormArray ссылок + * @param projectForm основная форма проекта + */ + public editLink(index: number, linksFormArray: FormArray, projectForm: FormGroup): void { + // Инициализируем сигнал при необходимости + this.initializeLinksItems(linksFormArray); + + // Используем данные из FormArray как источник истины + const source = linksFormArray.value[index]; + + // Заполняем поле формы проекта для редактирования + projectForm.patchValue({ + link: source?.link || "", + }); + // Устанавливаем текущий индекс редактирования в сервисе this.projectFormService.editIndex.set(index); } - public removeLink(index: number): void { - this.links.removeAt(index); + /** + * Удаляет ссылку по указанному индексу. + * @param index индекс удаляемой ссылки + * @param linksFormArray FormArray ссылок + */ + public removeLink(index: number, linksFormArray: FormArray): void { + // Удаляем из сигнала и из FormArray this.linksItems.update(items => items.filter((_, i) => i !== index)); + linksFormArray.removeAt(index); + } + + /** + * Сбрасывает все ошибки валидации во всех контролах FormArray ссылок. + * @param links FormArray ссылок + */ + public clearAllLinksErrors(links: FormArray): void { + links.controls.forEach(control => { + if (control instanceof FormGroup) { + Object.keys(control.controls).forEach(key => { + control.get(key)?.setErrors(null); + }); + } + }); } + /** + * Сбрасывает состояние сервиса + * Полезно при смене проекта или очистке формы + */ public reset(): void { this.linksItems.set([]); + this.initialized = false; } } diff --git a/projects/social_platform/src/app/office/projects/edit/services/project-form.service.ts b/projects/social_platform/src/app/office/projects/edit/services/project-form.service.ts index 875a7b496..d500e168e 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 @@ -15,7 +15,6 @@ import { Project } from "@office/models/project.model"; import { ProjectService } from "@office/services/project.service"; import { stripNullish } from "@utils/stripNull"; import { concatMap, filter } from "rxjs"; - /** * Сервис для управления основной формой проекта и формой дополнительных полей партнерской программы. * Обеспечивает создание, инициализацию, валидацию, автосохранение, сброс и получение данных форм. @@ -43,22 +42,22 @@ export class ProjectFormService { imageAddress: [""], name: ["", [Validators.required]], region: ["", [Validators.required]], - step: [null, [Validators.required]], - track: [null], - direction: [null], + implementationDeadline: [null], + trl: [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)]], + actuality: ["", [Validators.maxLength(1000)]], + targetAudience: ["", [Validators.required, Validators.maxLength(500)]], + problem: ["", [Validators.required, Validators.maxLength(1000)]], partnerProgramId: [null], achievements: this.fb.array([]), - achievementsName: [""], - achievementsPrize: [""], + title: [""], + status: [""], + draft: [null], }); @@ -99,13 +98,12 @@ export class ProjectFormService { 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, + implementationDeadline: project.implementationDeadline ?? null, + targetAudience: project.targetAudience ?? null, actuality: project.actuality ?? "", - goal: project.goal ?? "", + trl: project.trl ?? "", problem: project.problem ?? "", presentationAddress: project.presentationAddress, coverImageAddress: project.coverImageAddress, @@ -117,7 +115,6 @@ export class ProjectFormService { } this.populateLinksFormArray(project.links || []); - this.populateAchievementsFormArray(project.achievements || []); } @@ -128,14 +125,12 @@ export class ProjectFormService { 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)); + linksFormArray.push(this.fb.control(link, [Validators.required])); }); } @@ -145,18 +140,25 @@ export class ProjectFormService { */ private populateAchievementsFormArray(achievements: any[]): void { const achievementsFormArray = this.projectForm.get("achievements") as FormArray; + const currentYear = new Date().getFullYear(); - // Очищаем существующие контролы while (achievementsFormArray.length !== 0) { achievementsFormArray.removeAt(0); } - // Добавляем новые контролы achievements.forEach((achievement, index) => { const achievementGroup = this.fb.group({ id: achievement.id ?? index, - title: achievement.title || "", - status: achievement.status || "", + title: [achievement.title || "", Validators.required], + status: [ + achievement.status || "", + [ + Validators.required, + Validators.min(2000), + Validators.max(currentYear), + Validators.pattern(/^\d{4}$/), + ], + ], }); achievementsFormArray.push(achievementGroup); }); @@ -207,10 +209,6 @@ export class ProjectFormService { return this.projectForm.get("industryId"); } - public get step() { - return this.projectForm.get("step"); - } - public get description() { return this.projectForm.get("description"); } @@ -219,20 +217,20 @@ export class ProjectFormService { return this.projectForm.get("actuality"); } - public get goal() { - return this.projectForm.get("goal"); + public get implementationDeadline() { + return this.projectForm.get("implementationDeadline"); } public get problem() { return this.projectForm.get("problem"); } - public get track() { - return this.projectForm.get("track"); + public get targetAudience() { + return this.projectForm.get("targetAudience"); } - public get direction() { - return this.projectForm.get("direction"); + public get trl() { + return this.projectForm.get("trl"); } public get presentationAddress() { @@ -317,8 +315,6 @@ export class ProjectFormService { public resetForms(): void { this.projectForm.reset(); this.additionalForm?.reset(); - - // Очищаем FormArray this.clearFormArrays(); } @@ -339,11 +335,14 @@ export class ProjectFormService { } /** - * Проверяет валидность обеих форм (основной и дополнительной). - * @returns true если обе формы валидны + * Проверяет валидность обеих форм (основной и дополнительной) включая цели. + * @returns true если все формы валидны */ public validateAllForms(): boolean { - return this.validateForm() && this.validateAdditionalForm(); + const mainFormValid = this.validateForm(); + const additionalFormValid = this.validateAdditionalForm(); + + return mainFormValid && additionalFormValid; } /** diff --git a/projects/social_platform/src/app/office/projects/edit/services/project-goals.service.ts b/projects/social_platform/src/app/office/projects/edit/services/project-goals.service.ts new file mode 100644 index 000000000..9aff1cc92 --- /dev/null +++ b/projects/social_platform/src/app/office/projects/edit/services/project-goals.service.ts @@ -0,0 +1,354 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { FormArray, FormBuilder, FormControl, FormGroup, Validators } from "@angular/forms"; +import { ProjectFormService } from "./project-form.service"; +import { Goal, GoalPostForm } from "@office/models/goals.model"; +import { catchError, forkJoin, map, of, tap } from "rxjs"; +import { ProjectService } from "@office/services/project.service"; + +/** + * Сервис для управления целями проекта + * Предоставляет полный набор методов для работы с целями: + * - инициализация, добавление, редактирование, удаление + * - валидация и очистка ошибок + * - управление состоянием модального окна выбора лидера + */ +@Injectable({ + providedIn: "root", +}) +export class ProjectGoalService { + private readonly fb = inject(FormBuilder); + private goalForm!: FormGroup; + private readonly projectFormService = inject(ProjectFormService); + private readonly projectService = inject(ProjectService); + public readonly goalItems = signal([]); + + /** Флаг инициализации сервиса */ + private initialized = false; + + public readonly goalLeaderShowModal = signal(false); + public readonly activeGoalIndex = signal(null); + public readonly selectedLeaderId = signal(""); + + constructor() { + this.initializeGoalForm(); + } + + private initializeGoalForm(): void { + this.goalForm = this.fb.group({ + goals: this.fb.array([]), + title: [null], + completionDate: [null], + responsible: [null], + }); + } + + /** + * Инициализирует сигнал goalItems из данных FormArray + * Вызывается при первом обращении к данным + */ + public initializeGoalItems(goalFormArray: FormArray): void { + if (this.initialized) return; + + if (goalFormArray && goalFormArray.length > 0) { + this.goalItems.set(goalFormArray.value); + } + this.initialized = true; + } + + /** + * Принудительно синхронизирует сигнал с FormArray + * Полезно вызывать после загрузки данных с сервера + */ + public syncGoalItems(goalFormArray: FormArray): void { + if (goalFormArray) { + this.goalItems.set(goalFormArray.value); + } + } + + /** + * Инициализирует цели из данных проекта + * Заполняет FormArray целей данными из проекта + */ + public initializeGoalsFromProject(goals: Goal[]): void { + const goalsFormArray = this.goals; + + while (goalsFormArray.length !== 0) { + goalsFormArray.removeAt(0); + } + + if (goals && Array.isArray(goals)) { + goals.forEach(goal => { + const goalsGroup = this.fb.group({ + id: [goal.id ?? null], + title: [goal.title || "", Validators.required], + completionDate: [goal.completionDate || "", Validators.required], + responsible: [goal.responsibleInfo?.id?.toString() || "", Validators.required], + isDone: [goal.isDone || false], + }); + goalsFormArray.push(goalsGroup); + }); + + this.syncGoalItems(goalsFormArray); + } else { + this.goalItems.set([]); + } + } + + /** + * Возвращает форму целей. + * @returns FormGroup экземпляр формы целей + */ + public getForm(): FormGroup { + return this.goalForm; + } + + /** + * Получает FormArray целей + */ + public get goals(): FormArray { + return this.goalForm.get("goals") as FormArray; + } + + /** + * Получает FormControl для поля ввода названия цели + */ + public get goalName(): FormControl { + return this.goalForm.get("title") as FormControl; + } + + /** + * Получает FormControl для поля ввода даты цели + */ + public get goalDate(): FormControl { + return this.goalForm.get("completionDate") as FormControl; + } + + /** + * Получает FormControl для поля лидера(исполнителя/ответственного) цели + */ + public get goalLeader(): FormControl { + return this.goalForm.get("responsible") as FormControl; + } + + /** + * Добавляет новую цель или сохраняет изменения существующей. + * @param goalName - название цели (опционально) + * @param goalDate - дата цели (опционально) + * @param goalLeader - лидер цели (опционально) + */ + public addGoal(goalName?: string, goalDate?: string, goalLeader?: string): void { + const goalFormArray = this.goals; + + this.initializeGoalItems(goalFormArray); + + const name = goalName || this.goalForm.get("title")?.value; + const date = goalDate || this.goalForm.get("completionDate")?.value; + const leader = goalLeader || this.goalForm.get("responsible")?.value; + + if (!name || !date || name.trim().length === 0 || date.trim().length === 0) { + return; + } + + const goalItem = this.fb.group({ + id: [null], + title: [name.trim(), Validators.required], + completionDate: [date.trim(), Validators.required], + responsible: [leader, Validators.required], + isDone: [false], + }); + + const editIdx = this.projectFormService.editIndex(); + if (editIdx !== null) { + goalFormArray.at(editIdx).patchValue(goalItem.value); + this.projectFormService.editIndex.set(null); + } else { + this.goalItems.update(items => [...items, goalItem.value]); + goalFormArray.push(goalItem); + } + + this.syncGoalItems(goalFormArray); + } + + /** + * Удаляет цель по указанному индексу. + * @param index индекс удаляемой цели + */ + public removeGoal(index: number): void { + const goalFormArray = this.goals; + + this.goalItems.update(items => items.filter((_, i) => i !== index)); + goalFormArray.removeAt(index); + } + + /** + * Получает выбранного лидера для конкретной цели + * @param goalIndex - индекс цели + * @param collaborators - список коллабораторов + */ + public getSelectedLeaderForGoal(goalIndex: number, collaborators: any[]) { + const goalFormGroup = this.goals.at(goalIndex); + const leaderId = goalFormGroup?.get("responsible")?.value; + + if (!leaderId) return null; + + return collaborators.find(collab => collab.userId.toString() === leaderId.toString()); + } + + /** + * Обработчик изменения радио-кнопки для выбора лидера + * @param event - событие изменения + */ + public onLeaderRadioChange(event: Event): void { + const target = event.target as HTMLInputElement; + this.selectedLeaderId.set(target.value); + } + + /** + * Добавляет лидера на определенную цель + */ + public addLeaderToGoal(): void { + const goalIndex = this.activeGoalIndex(); + const leaderId = this.selectedLeaderId(); + + if (goalIndex === null || !leaderId) { + return; + } + + const goalFormGroup = this.goals.at(goalIndex); + goalFormGroup?.get("responsible")?.setValue(leaderId); + + this.closeGoalLeaderModal(); + } + + /** + * Открывает модальное окно выбора лидера для конкретной цели + * @param index - индекс цели + */ + public openGoalLeaderModal(index: number): void { + this.activeGoalIndex.set(index); + + const currentLeader = this.goals.at(index)?.get("responsible")?.value; + this.selectedLeaderId.set(currentLeader || ""); + + this.goalLeaderShowModal.set(true); + } + + /** + * Закрывает модальное окно выбора лидера + */ + public closeGoalLeaderModal(): void { + this.goalLeaderShowModal.set(false); + this.activeGoalIndex.set(null); + this.selectedLeaderId.set(""); + } + + /** + * Переключает состояние модального окна выбора лидера + * @param index - индекс цели (опционально) + */ + public toggleGoalLeaderModal(index?: number): void { + if (this.goalLeaderShowModal()) { + this.closeGoalLeaderModal(); + } else if (index !== undefined) { + this.openGoalLeaderModal(index); + } + } + + /** + * Сбрасывает все ошибки валидации во всех контролах FormArray цели. + */ + public clearAllGoalsErrors(): void { + const goals = this.goals; + + goals.controls.forEach(control => { + if (control instanceof FormGroup) { + Object.keys(control.controls).forEach(key => { + control.get(key)?.setErrors(null); + }); + } + }); + } + + /** + * Получает данные всех целей для отправки на сервер + * @returns массив объектов целей + */ + public getGoalsData(): any[] { + return this.goals.value.map((g: any) => ({ + id: g.id ?? null, + title: g.title, + completionDate: g.completionDate, + responsible: + g.responsible === null || g.responsible === undefined || g.responsible === "" + ? null + : Number(g.responsible), + isDone: !!g.isDone, + })); + } + + /** + * Сохраняет только новые цели (у которых id === null) — отправляет POST. + * После ответов присваивает полученные id в соответствующие FormGroup. + * Возвращает Observable массива результатов (в порядке отправки). + */ + public saveGoals(projectId: number, newGoals: Goal[]) { + return this.projectService.addGoals(projectId, newGoals).pipe( + tap(results => { + results.forEach((createdGoal: any, idx: number) => { + const formGroup = this.goals.at(idx); + if (formGroup && createdGoal?.id != null) { + formGroup.patchValue({ id: createdGoal.id }); + } + }); + }), + catchError(err => { + console.error("Error saving goals:", err); + return of({ __error: true, err, original: newGoals }); + }) + ); + } + + public editGoals(projectId: number, existingGoals: Goal[]) { + const requests = existingGoals.map((item, idx) => { + const payload: GoalPostForm = { + id: item.id, + title: item.title, + completionDate: item.completionDate, + responsible: item.responsible, + isDone: item.isDone, + }; + + return this.projectService.editGoal(projectId, item.id, payload).pipe( + map(res => ({ res, idx })), + catchError(err => of({ __error: true, err, original: item, idx })) + ); + }); + + return forkJoin(requests); + } + + /** + * Сбрасывает состояние сервиса + * Полезно при смене проекта или очистке формы + */ + public reset(): void { + this.goalItems.set([]); + this.initialized = false; + this.closeGoalLeaderModal(); + } + + /** + * Очищает FormArray целей + */ + public clearGoalsFormArray(): void { + const goalFormArray = this.goals; + + while (goalFormArray.length !== 0) { + goalFormArray.removeAt(0); + } + + this.goalItems.set([]); + } +} diff --git a/projects/social_platform/src/app/office/projects/edit/services/project-partner.service.ts b/projects/social_platform/src/app/office/projects/edit/services/project-partner.service.ts new file mode 100644 index 000000000..d94183e54 --- /dev/null +++ b/projects/social_platform/src/app/office/projects/edit/services/project-partner.service.ts @@ -0,0 +1,263 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { FormArray, FormBuilder, FormControl, FormGroup, Validators } from "@angular/forms"; +import { Partner, PartnerPostForm } from "@office/models/partner.model"; +import { ProjectService } from "@office/services/project.service"; +import { catchError, forkJoin, map, Observable, of, tap } from "rxjs"; + +@Injectable({ + providedIn: "root", +}) +export class ProjectPartnerService { + private readonly fb = inject(FormBuilder); + private partnerForm!: FormGroup; + private readonly projectService = inject(ProjectService); + public readonly partnerItems = signal([]); + + /** Флаг инициализации сервиса */ + private initialized = false; + + constructor() { + this.initializePartnerForm(); + } + + private initializePartnerForm(): void { + this.partnerForm = this.fb.group({ + partners: this.fb.array([]), + name: [null], + inn: [null, [Validators.minLength(10), Validators.maxLength(10)]], + contribution: [null, Validators.maxLength(200)], + decisionMaker: [null], + }); + } + + /** + * Инициализирует сигнал partnerItems из данных FormArray + * Вызывается при первом обращении к данным + */ + public initializePartnerItems(partnerFormArray: FormArray): void { + if (this.initialized) return; + + if (partnerFormArray && this.partnerItems.length > 0) { + this.partnerItems.set(partnerFormArray.value); + } + + this.initialized = true; + } + + /** + * Принудительно синхронизирует сигнал с FormArray + * Полезно вызывать после загрузки данных с сервера + */ + public syncPartnerItems(partnerFormArray: FormArray): void { + if (partnerFormArray) { + this.partnerItems.set(partnerFormArray.value); + } + } + + /** + * Инициализирует партнера из данных проекта + * Заполняет FormArray целей данными из проекта + */ + public initializePartnerFromProject(partners: Partner[]): void { + const partnerFormArray = this.partners; + + while (partnerFormArray.length !== 0) { + partnerFormArray.removeAt(0); + } + + if (partners && Array.isArray(partners)) { + partners.forEach(partner => { + const partnerGroup = this.fb.group({ + id: [partner.id], + name: [partner.company.name, Validators.required], + inn: [partner.company.inn, Validators.required], + contribution: [partner.contribution, Validators.required], + company: [partner.company], + decisionMaker: [ + "https://app.procollab.ru/office/profile/" + partner.decisionMaker, + Validators.required, + ], + }); + partnerFormArray.push(partnerGroup); + }); + + this.syncPartnerItems(partnerFormArray); + } else { + this.partnerItems.set([]); + } + } + + /** + * Возвращает форму партнеров и ресурсов. + * @returns FormGroup экземпляр формы целей + */ + public getForm(): FormGroup { + return this.partnerForm; + } + + /** + * Получает FormArray партнеров и ресурсов + */ + public get partners(): FormArray { + return this.partnerForm.get("partners") as FormArray; + } + + public get partnerName(): FormControl { + return this.partnerForm.get("name") as FormControl; + } + + public get partnerINN(): FormControl { + return this.partnerForm.get("inn") as FormControl; + } + + public get partnerMention(): FormControl { + return this.partnerForm.get("contribution") as FormControl; + } + + public get partnerProfileLink(): FormControl { + return this.partnerForm.get("decisionMaker") as FormControl; + } + + /** + * Добавляет нового партнера или сохраняет изменения существующей. + * @param name - название партнера (опционально) + * @param inn - инн (опционально) + * @param contribution - вклад партнера (опционально) + * @param decisionMaker - ссылка на профиль представителя компании (опционально) + */ + public addPartner( + name?: string, + inn?: string, + contribution?: string, + decisionMaker?: string + ): void { + const partnerFormArray = this.partners; + + this.initializePartnerItems(partnerFormArray); + + const partnerName = name || this.partnerForm.get("name")?.value; + const INN = inn || this.partnerForm.get("inn")?.value; + const mention = contribution || this.partnerForm.get("contribution")?.value; + const profileLink = decisionMaker || this.partnerForm.get("decisionMaker")?.value; + + if ( + !partnerName || + !INN || + !mention || + !profileLink || + partnerName.trim().length === 0 || + mention.trim().length === 0 || + INN.trim().length === 0 || + profileLink.trim().length === 0 + ) { + return; + } + + const partnerItem = this.fb.group({ + id: [null], + name: [partnerName.trim(), Validators.required], + inn: [INN.trim(), Validators.required], + contribution: [mention, Validators.required], + decisionMaker: [profileLink, Validators.required], + }); + + this.partnerItems.update(items => [...items, partnerItem.value]); + partnerFormArray.push(partnerItem); + } + + /** + * Удаляет партнера по указанному индексу. + * @param index индекс удаляемого партнера + */ + public removePartner(index: number): void { + const partnerFormArray = this.partners; + + this.partnerItems.update(items => items.filter((_, i) => i !== index)); + partnerFormArray.removeAt(index); + } + + /** + * Сбрасывает все ошибки валидации во всех контролах FormArray партнера. + */ + public clearAllPartnerErrors(): void { + const partners = this.partners; + + partners.controls.forEach(control => { + if (control instanceof FormGroup) { + Object.keys(control.controls).forEach(key => { + control.get(key)?.setErrors(null); + }); + } + }); + } + + /** + * Получает данные всех партнеров для отправки на сервер + * @returns массив объектов партнеров + */ + public getPartnersData(): any[] { + return this.partners.value.map((partner: any) => ({ + id: partner.id ?? null, + name: partner.name, + inn: partner.inn, + contribution: partner.contribution, + decisionMaker: partner.decisionMaker, + })); + } + + /** + * Сохраняет только новых партнеров (у которых id === null) — отправляет POST. + * После ответов присваивает полученные id в соответствующие FormGroup. + * Возвращает Observable массива результатов (в порядке отправки). + */ + public savePartners(projectId: number) { + const partners = this.getPartnersData(); + + if (partners.length === 0) { + return of([]); + } + + const requests = partners.map(partner => { + const decisionMaker = Number(partner.decisionMaker.split("/").at(-1)); + + const payload: PartnerPostForm = { + name: partner.name, + inn: partner.inn, + contribution: partner.contribution, + decisionMaker, + }; + + return this.projectService.addPartner(projectId, payload).pipe( + map((res: any) => ({ res, idx: partner.id })), + catchError(err => of({ __error: true, err, original: partner })) + ); + }); + + return forkJoin(requests).pipe( + tap(results => { + results.forEach((r: any) => { + if (r && r.__error) { + console.error("Failed to post partner", r.err, "original:", r.original); + return; + } + + const created = r.res; + const idx = r.idx; + + if (created && created.id !== undefined && created.id !== null) { + const formGroup = this.partners.at(idx); + if (formGroup) { + formGroup.get("id")?.setValue(created.id); + } + } else { + console.warn("addPartner response has no id field:", r.res); + } + }); + + this.syncPartnerItems(this.partners); + }) + ); + } +} diff --git a/projects/social_platform/src/app/office/projects/edit/services/project-resources.service.ts b/projects/social_platform/src/app/office/projects/edit/services/project-resources.service.ts new file mode 100644 index 000000000..15b661535 --- /dev/null +++ b/projects/social_platform/src/app/office/projects/edit/services/project-resources.service.ts @@ -0,0 +1,276 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { FormArray, FormBuilder, FormControl, FormGroup, Validators } from "@angular/forms"; +import { Resource, ResourcePostForm } from "@office/models/resource.model"; +import { ProjectService } from "@office/services/project.service"; +import { catchError, forkJoin, map, Observable, of, tap } from "rxjs"; + +@Injectable({ + providedIn: "root", +}) +export class ProjectResourceService { + private readonly fb = inject(FormBuilder); + private readonly projectService = inject(ProjectService); + private resourceForm!: FormGroup; + public readonly resourceItems = signal([]); + + /** Флаг инициализации сервиса */ + private initialized = false; + + constructor() { + this.initializeResourceForm(); + } + + private initializeResourceForm(): void { + this.resourceForm = this.fb.group({ + resources: this.fb.array([]), + type: [null], + description: [null, Validators.maxLength(200)], + partnerCompany: [null], + }); + } + + /** + * Инициализирует сигнал resourceItems из данных FormArray + * Вызывается при первом обращении к данным + */ + public initializePartnerItems(resourceFormArray: FormArray): void { + if (this.initialized) return; + + if (resourceFormArray && this.resourceItems.length > 0) { + this.resourceItems.set(resourceFormArray.value); + } + + this.initialized = true; + } + + /** + * Принудительно синхронизирует сигнал с FormArray + * Полезно вызывать после загрузки данных с сервера + */ + public syncResourceItems(resourceFormArray: FormArray): void { + if (resourceFormArray) { + this.resourceItems.set(resourceFormArray.value); + } + } + + /** + * Инициализирует ресурсы из данных проекта + * Заполняет FormArray целей данными из проекта + */ + public initializeResourcesFromProject(resources: Resource[]): void { + const resourcesFormArray = this.resources; + + while (resourcesFormArray.length !== 0) { + resourcesFormArray.removeAt(0); + } + + if (resources && Array.isArray(resources)) { + resources.forEach(resource => { + const partnerGroup = this.fb.group({ + id: [resource.id ?? null], + type: [resource.type, Validators.required], + description: [resource.description, Validators.required], + partnerCompany: [resource.partnerCompany, Validators.required], + }); + resourcesFormArray.push(partnerGroup); + }); + + this.syncResourceItems(resourcesFormArray); + } else { + this.resourceItems.set([]); + } + } + + /** + * Возвращает форму партнеров и ресурсов. + * @returns FormGroup экземпляр формы целей + */ + public getForm(): FormGroup { + return this.resourceForm; + } + + /** + * Получает FormArray партнеров и ресурсов + */ + public get resources(): FormArray { + return this.resourceForm.get("resources") as FormArray; + } + + public get resoruceType(): FormControl { + return this.resourceForm.get("type") as FormControl; + } + + public get resoruceDescription(): FormControl { + return this.resourceForm.get("description") as FormControl; + } + + public get resourcePartner(): FormControl { + return this.resourceForm.get("partnerCompany") as FormControl; + } + + /** + * Добавляет нового ресурса или сохраняет изменения существующей. + * @param type - тип ресурса (опционально) + * @param description - описание ресурса (опционально) + * @param partnerCompany - ссылка на партнера (опционально) + */ + public addResource(type?: string, description?: string, partnerCompany?: string): void { + const resourcesFormArray = this.resources; + + this.initializePartnerItems(resourcesFormArray); + + const resourceType = type || this.resourceForm.get("type")?.value; + const resourceDescription = description || this.resourceForm.get("description")?.value; + const partner = partnerCompany || this.resourceForm.get("partnerCompany")?.value; + + if ( + !resourceType || + !resourceDescription || + !partner || + resourceType.trim().length === 0 || + resourceDescription.trim().length === 0 || + partner.trim().length === 0 + ) { + return; + } + + const resourceItem = this.fb.group({ + id: [null], + type: [resourceType.trim(), Validators.required], + description: [resourceDescription.trim(), Validators.required], + partnerCompany: [partner, Validators.required], + }); + + this.resourceItems.update(items => [...items, resourceItem.value]); + resourcesFormArray.push(resourceItem); + } + + /** + * Удаляет ресурс по указанному индексу. + * @param index индекс удаляемого партнера + */ + public removeResource(index: number): void { + const resourceFormArray = this.resources; + + this.resourceItems.update(items => items.filter((_, i) => i !== index)); + resourceFormArray.removeAt(index); + } + + /** + * Сбрасывает все ошибки валидации во всех контролах FormArray ресурса. + */ + public clearAllResourceErrors(): void { + const resources = this.resources; + + resources.controls.forEach(control => { + if (control instanceof FormGroup) { + Object.keys(control.controls).forEach(key => { + control.get(key)?.setErrors(null); + }); + } + }); + } + + /** + * Получает данные все ресурсы для отправки на сервер + * @returns массив объектов ресурсов + */ + public getResourcesData(): any[] { + return this.resources.value.map((resource: any) => ({ + id: resource.id ?? null, + type: resource.type, + description: resource.description, + partnerCompany: resource.partnerCompany, + })); + } + + /** + * Сохраняет только новых ресурсов (у которых id === null) — отправляет POST. + * После ответов присваивает полученные id в соответствующие FormGroup. + * Возвращает Observable массива результатов (в порядке отправки). + */ + public saveResources(projectId: number) { + const resources = this.getResourcesData(); + + const requests = resources.map(resource => { + const payload: Omit = { + type: resource.type, + description: resource.description, + partnerCompany: resource.partnerCompany ?? "запрос к рынку", + }; + + return this.projectService.addResource(projectId, payload).pipe( + map((res: any) => ({ res, idx: resource.idx })), + catchError(err => of({ __error: true, err, original: resource })) + ); + }); + + return forkJoin(requests).pipe( + tap(results => { + results.forEach((r: any) => { + if (r && r.__error) { + console.error("Failed to post resource", r.err, "original:", r.original); + return; + } + + const created = r.res; + const idx = r.idx; + + if (created && created.id !== undefined && created.id !== null) { + const formGroup = this.resources.at(idx); + if (formGroup) { + formGroup.get("id")?.setValue(created.id); + } + } + }); + + this.syncResourceItems(this.resources); + }) + ); + } + + public editResources(projectId: number) { + const resources = this.getResourcesData(); + console.log(resources); + + const requests = resources.map(resource => { + const payload: Omit = { + type: resource.type, + description: resource.description, + partnerCompany: resource.partnerCompany ?? "запрос к рынку", + }; + + return this.projectService.editResource(projectId, resource.id, payload).pipe( + map((res: any) => ({ res })), + catchError(err => of({ __error: true, err, original: resource })) + ); + }); + + return forkJoin(requests).pipe( + tap(results => { + results.forEach((r: any) => { + if (r && r.__error) { + console.error("Failed to add resource", r.err, "original:", r.original); + return; + } + + const created = r.res; + const idx = r.idx; + + if (created && created.id !== undefined && created.id !== null) { + const formGroup = this.resources.at(idx); + if (formGroup) { + formGroup.get("id")?.setValue(created.id); + } + } else { + console.warn("addResource response has no id field:", r.res); + } + }); + + this.syncResourceItems(this.resources); + }) + ); + } +} diff --git a/projects/social_platform/src/app/office/projects/edit/services/project-team.service.ts b/projects/social_platform/src/app/office/projects/edit/services/project-team.service.ts index becc9e491..be1597dd9 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 @@ -186,6 +186,14 @@ export class ProjectTeamService { }); } + /** + * Удаляет участника по идентификатору. + * @param collaboratorId идентификатор приглашения + */ + public removeCollaborator(collaboratorId: number): void { + this.collaborators.update(list => list.filter(i => i.userId !== collaboratorId)); + } + /** * Проверяет валидность формы приглашения. * @returns boolean true если форма валидна 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 index 586bba823..997a65f58 100644 --- 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 @@ -8,11 +8,11 @@ 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 { rolesMembersList } from "projects/core/src/consts/lists/roles-members-list.const"; import { ProjectFormService } from "./project-form.service"; +import { workExperienceList } from "projects/core/src/consts/lists/work-experience-list.const"; +import { workFormatList } from "projects/core/src/consts/lists/work-format-list.const"; +import { workScheludeList } from "projects/core/src/consts/lists/work-schelude-list.const"; /** * Сервис для управления вакансиями проекта. @@ -31,9 +31,9 @@ export class ProjectVacancyService { private readonly validationService = inject(ValidationService); /** Константы для выпадающих списков */ - public readonly experienceList = experienceList; - public readonly formatList = formatList; - public readonly scheludeList = scheludeList; + public readonly workExperienceList = workExperienceList; + public readonly workFormatList = workFormatList; + public readonly workScheludeList = workScheludeList; public readonly rolesMembersList = rolesMembersList; /** Сигналы для выбранных значений селектов */ @@ -242,20 +242,24 @@ export class ProjectVacancyService { public editVacancy(index: number): void { const item = this.vacancies()[index]; // Установка выбранных значений селектов по сопоставлению - this.experienceList.find(e => e.value === item.requiredExperience) && + this.workExperienceList.find(e => e.value === item.requiredExperience) && this.selectedRequiredExperienceId.set( - this.experienceList.find(e => e.value === item.requiredExperience)!.id + this.workExperienceList.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.workFormatList.find(f => f.value === item.workFormat) && + this.selectedWorkFormatId.set(this.workFormatList.find(f => f.value === item.workFormat)!.id); + + this.workScheludeList.find(s => s.value === item.workSchedule) && this.selectedWorkScheduleId.set( - this.scheludeList.find(s => s.value === item.workSchedule)!.id + this.workScheludeList.find(s => s.value === item.workSchedule)!.id ); + this.rolesMembersList.find(r => r.value === item.specialization) && this.selectedVacanciesSpecializationId.set( this.rolesMembersList.find(r => r.value === item.specialization)!.id ); + // Патчинг формы значениями вакансии this.vacancyForm.patchValue({ role: item.role, 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 index 541f58d4b..2cf466293 100644 --- a/projects/social_platform/src/app/office/projects/edit/shared/project-achievement-step/project-achievement-step.component.html +++ b/projects/social_platform/src/app/office/projects/edit/shared/project-achievement-step/project-achievement-step.component.html @@ -2,70 +2,81 @@
-
- @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 }} -
+
    + @for (control of achievements.controls; track control.value.id; let i = $index) { +
  • +
    + @if (achievements.at(i)?.get("title"); as achievementsName) { +
    + + + @if (achievementsName | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } @if (achievements.at(i).get("status"); as achievementsDate) { +
    + + + @if (achievementsDate | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } + + + +
    +
  • } -
- } +
-
- - Добавить достижение - + +
+ + добавить достижение + -
    - @if(achievementsItems().length || achievements.length){ @for (achievementItem of - achievements.value; track $index) { -
  • - -
  • - } } -
+ @if (!achievements.length) { + + }
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 index 2226fd28b..29e19d720 100644 --- 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 @@ -4,77 +4,55 @@ @use "styles/typography"; .project { + position: relative; + &__inner { width: 100%; margin-bottom: 25px; @include responsive.apply-desktop { display: flex; - gap: 90px; + flex-direction: column; justify-content: space-between; margin-bottom: 0; - margin-bottom: 20px; } } - &__inner > fieldset:not(:last-child) { - margin-bottom: 20px; + &__no-items { + position: absolute; + bottom: 0%; + left: 50%; } &__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; - } + &__achievements { + display: flex; + flex-direction: column; + gap: 20px; } } .achievement { - &__first-row { - display: flex; - - // align-items: flex-start; - margin-bottom: 12px; - - > :first-child { - flex-grow: 1; - } - } + display: flex; + gap: 20px; + align-items: center; &__remove { - width: 155px; - margin-left: 10px; + margin-top: 15px; } } .invite { &__item { - margin-bottom: 12px; + align-items: center; + justify-content: space-between; } &__list { @@ -87,5 +65,18 @@ .vacancy { &__submit { display: block; + + &--text { + margin-top: 2px; + margin-right: 10px; + } } } + +.error { + i { + color: var(--red) !important; + } + + color: var(--red) !important; +} 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 index 720cec5c7..96b708afd 100644 --- 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 @@ -2,7 +2,7 @@ import { CommonModule } from "@angular/common"; import { Component, inject, Input } from "@angular/core"; -import { FormArray, FormGroup, ReactiveFormsModule } from "@angular/forms"; +import { FormArray, FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; import { InputComponent, ButtonComponent } from "@ui/components"; import { LinkCardComponent } from "@office/shared/link-card/link-card.component"; import { ControlErrorPipe } from "@corelib"; @@ -31,9 +31,13 @@ export class ProjectAchievementStepComponent { private readonly projectAchievementService = inject(ProjectAchievementsService); private readonly projectFormService = inject(ProjectFormService); + private readonly fb = inject(FormBuilder); readonly errorMessage = ErrorMessage; + // Состояние для показа полей ввода + public showInputFields = false; + // Получаем форму из сервиса get projectForm(): FormGroup { return this.projectFormService.getForm(); @@ -48,8 +52,8 @@ export class ProjectAchievementStepComponent { return this.projectForm.get("achievementsName"); } - get achievementsPrize() { - return this.projectForm.get("achievementsPrize"); + get achievementsDate() { + return this.projectForm.get("achievementsDate"); } get achievementsItems() { @@ -60,10 +64,61 @@ export class ProjectAchievementStepComponent { return this.projectFormService.editIndex; } + /** + * Проверяет, есть ли достижения для отображения + */ + get hasAchievements(): boolean { + return this.achievementsItems().length > 0 || this.achievements.length > 0; + } + + /** + * Показывает поля для ввода достижения + */ + showFields(): void { + this.showInputFields = true; + } + + /** + * Скрывает поля ввода и очищает их + */ + hideFields(): void { + this.showInputFields = false; + this.clearInputFields(); + } + + /** + * Очищает поля ввода + */ + private clearInputFields(): void { + this.projectForm.get("achievementsName")?.reset(); + this.projectForm.get("achievementsName")?.setValue(""); + + if (this.editIndex() !== null) { + this.projectFormService.editIndex.set(null); + } + } + /** * Добавление достижения */ - addAchievement(): void { + addAchievement(id?: number, achievementsName?: string, achievementsDate?: string): void { + const currentYear = new Date().getFullYear(); + this.achievements.push( + this.fb.group({ + id: [id], + title: [achievementsName ?? "", [Validators.required]], + status: [ + achievementsDate ?? "", + [ + Validators.required, + Validators.min(2000), + Validators.max(currentYear), + Validators.pattern(/^\d{4}$/), + ], + ], + }) + ); + this.projectAchievementService.addAchievement(this.achievements, this.projectForm); } @@ -72,6 +127,7 @@ export class ProjectAchievementStepComponent { * @param index - индекс достижения */ editAchievement(index: number): void { + this.showInputFields = true; this.projectAchievementService.editAchievement(index, this.achievements, this.projectForm); } 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 index 202cfa3fc..d39967ea9 100644 --- a/projects/social_platform/src/app/office/projects/edit/shared/project-additional-step/project-additional-step.component.html +++ b/projects/social_platform/src/app/office/projects/edit/shared/project-additional-step/project-additional-step.component.html @@ -3,8 +3,8 @@
- @if (partnerProgramFields.length) { @for (field of partnerProgramFields; track field.id) { +
@switch (field.fieldType) { @case ("text") { @if (additionalForm.get(field.name); as control) { @@ -16,6 +16,7 @@ >{{ field.label }} @if (additionalForm.get(field.name); as control) { {{ field.label }} } } @if (additionalForm.get(field.name); as control) { @if (control | controlError: "required") { -
+
{{ errorMessage.VALIDATION_REQUIRED }}
} }
- } } + } } @else { +
+

пока ты не участвуешь ни в одной программе

+ + + программы + + + + + +
+ }
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 index c1deba1bc..6092cd72f 100644 --- 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 @@ -4,6 +4,8 @@ @use "styles/typography"; .project { + position: relative; + &__inner { width: 100%; margin-bottom: 25px; @@ -84,7 +86,7 @@ color: var(--black) !important; &--placeholder { - color: var(--grey-for-text) !important; + color: var(--dark-grey) !important; } @include typography.body-10; @@ -98,19 +100,19 @@ ::ng-deep { app-input { input { - @include typography.body-14; + @include typography.body-12; } } app-textarea { textarea { - @include typography.body-14; + @include typography.body-12; } } app-select { .field__input { - @include typography.body-14; + @include typography.body-12; } } } @@ -120,6 +122,24 @@ fieldset { margin-bottom: 10px; } + + &--no-items { + display: inline-flex; + flex-direction: column; + gap: 10px; + + app-tooltip { + position: absolute; + right: -10%; + bottom: 70%; + } + } + } + + &__no-items { + position: absolute; + bottom: 0%; + left: 50%; } } @@ -140,3 +160,11 @@ display: block; } } + +.error { + i { + color: var(--red) !important; + } + + color: var(--red) !important; +} 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 index 972f62b68..aba9d8d48 100644 --- 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 @@ -3,7 +3,12 @@ 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 { + InputComponent, + CheckboxComponent, + SelectComponent, + ButtonComponent, +} from "@ui/components"; import { TextareaComponent } from "@ui/components/textarea/textarea.component"; import { SwitchComponent } from "@ui/components/switch/switch.component"; import { ControlErrorPipe } from "@corelib"; @@ -11,6 +16,9 @@ import { ErrorMessage } from "@error/models/error-message"; import { ToSelectOptionsPipe } from "projects/core/src/lib/pipes/options-transform.pipe"; import { ProjectAdditionalService } from "../../services/project-additional.service"; import { PartnerProgramFields } from "@office/models/partner-program-fields.model"; +import { RouterLink } from "@angular/router"; +import { IconComponent } from "@uilib"; +import { TooltipComponent } from "@ui/components/tooltip/tooltip.component"; @Component({ selector: "app-project-additional-step", @@ -21,17 +29,19 @@ import { PartnerProgramFields } from "@office/models/partner-program-fields.mode CommonModule, ReactiveFormsModule, InputComponent, + IconComponent, CheckboxComponent, SwitchComponent, SelectComponent, TextareaComponent, ControlErrorPipe, ToSelectOptionsPipe, + ButtonComponent, + RouterLink, + TooltipComponent, ], }) export class ProjectAdditionalStepComponent implements OnInit { - @Input() programTagsOptions: any[] = []; - private readonly projectAdditionalService = inject(ProjectAdditionalService); private readonly cdRef = inject(ChangeDetectorRef); @@ -63,6 +73,28 @@ export class ProjectAdditionalStepComponent implements OnInit { return this.projectAdditionalService.getErrorAssignProjectToProgramModalMessage(); } + /** Наличие подсказки */ + haveHint = false; + + /** Текст для подсказки */ + tooltipText?: string; + + /** Позиция подсказки */ + tooltipPosition: "left" | "right" = "right"; + + /** Состояние видимости подсказки */ + isTooltipVisible = false; + + /** Показать подсказку */ + showTooltip(): void { + this.isTooltipVisible = true; + } + + /** Скрыть подсказку */ + hideTooltip(): void { + this.isTooltipVisible = false; + } + /** * Переключение значения для checkbox и radio полей * @param fieldType - тип поля 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 deleted file mode 100644 index be2162d5c..000000000 --- a/projects/social_platform/src/app/office/projects/edit/shared/project-contacts-step/project-contacts-step.component.html +++ /dev/null @@ -1,39 +0,0 @@ - - -
- @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.ts b/projects/social_platform/src/app/office/projects/edit/shared/project-contacts-step/project-contacts-step.component.ts deleted file mode 100644 index 0c7f42b57..000000000 --- a/projects/social_platform/src/app/office/projects/edit/shared/project-contacts-step/project-contacts-step.component.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** @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 index b2b07b095..adc83a0d1 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 @@ -1,178 +1,168 @@
-
-
-
- @if (imageAddress; as imageAddress) { -
- - @if ((imageAddress | controlError: "required") && projSubmitInitiated) { -
- {{ errorMessage.EMPTY_AVATAR }} -
- } +
+
+ @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 (presentationAddress; as presentationAddress) { +
+ + + + +

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

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

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

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

+ Презентации формата .jpg, .jpeg, .png +
Размер изображения 1280 x 230 +

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

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

+ } +
+
- } @if (step; as step) { + } @if (trl; as trl) {
- - @if (projectSteps$ | async; as steps) { - } @if (step | controlError: "required") { -
+ @if (trl | 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 (name; as name) {
- + - @if ((goal | controlError: "required") && projSubmitInitiated) { -
+ @if ((name | controlError: "required") || projSubmitInitiated) { +
{{ errorMessage.VALIDATION_REQUIRED }}
}
- } @if (problem; as problem) { + } @if (region; as region) {
- + - @if ((problem | controlError: "required") && projSubmitInitiated) { -
+ @if ((region | controlError: "required") || projSubmitInitiated) { +
{{ errorMessage.VALIDATION_REQUIRED }}
}
- } @if (trackControl; as track) { + } @if (industry; as industry) {
+ + @if (industries$ | async; as industries) { - @if ((track | controlError: "required")) { -
+ } @if (industry | controlError: "required") { +
{{ errorMessage.VALIDATION_REQUIRED }}
}
- } @if (direction; as direction) { + } @if (implementationDeadline; as implementationDeadline) {
- - @if ((direction | controlError: "required")) { -
+ + + @if ((implementationDeadline | controlError: "required") && projSubmitInitiated) { +
{{ errorMessage.VALIDATION_REQUIRED }}
} @@ -181,65 +171,280 @@
-
- @if (!isProjectBoundToProgram) { @if (authService.profile | async; as profile) { @if (profile.id - == leaderId) { @if (programTagsOptions.length) { -
- - + @if (problem; as problem) { +
+ + + @if ((problem | controlError: "required") || projSubmitInitiated) { +
+ {{ 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 (targetAudience; as targetAudience) { +
+ + + @if ((targetAudience | controlError: "required") || projSubmitInitiated) { +
+ {{ errorMessage.VALIDATION_REQUIRED }} +
+ } +
+ } +
- - Привязать проект к программе - - } } } } @if (presentationAddress; as presentationAddress) { -
- - - -

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

-

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

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

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

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

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

-

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

-

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

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

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

+
+ + + + + } + + } + + +
+
+ +

выберите ответственного

+ +
+
    + @for (collaborator of collaborators; track collaborator.userId) { +
  • +
    + +

    + {{ collaborator.firstName }} {{ collaborator.lastName }} +

    +
    + +
  • + } +
+
+
+ + + подтвердить выбор + +
+
+ + + добавить краткосрочную цель проекта + + +
+ +
+ @if (hasLinks) { + } + + + добавить ссылку на контакты и сообщества + +
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 index 225c80375..94dc20ce5 100644 --- 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 @@ -6,194 +6,210 @@ .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) { + &__grid { + display: grid; + grid-gap: 20px; + align-items: center; margin-bottom: 20px; } - &__left { - flex-basis: 50%; - margin-bottom: 20px; + &__options { + display: flex; + flex-direction: column; + width: 100%; + } - form { - width: 280px; + &__info { + display: flex; + flex-direction: column; + + &--additional { + display: flex; + gap: 20px; + align-items: center; - @include responsive.apply-desktop { - width: 600px; + fieldset { + flex-basis: 50%; } } } + &__problem, + &__description { + margin-bottom: 12px; + } + &__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; + &__image { + display: block; + margin-bottom: 20px; + + .error { + margin-top: 15px; } } - &__info { - display: flex; - flex-direction: column; + &__file { + min-width: 0; + } - @include responsive.apply-desktop { - flex-direction: row; - justify-content: space-between; - } + &__slides-title { + max-width: 320px; + margin-top: 12px; + color: var(--black); + text-align: center; } - &__right { - flex-basis: 50%; + &__slides-text { + max-width: 275px; + margin-top: 12px; + color: var(--black); + text-align: center; + opacity: 0.3; - :first-child & :not(span, fieldset, label, h4, p, i) { - margin-top: 26px; - margin-bottom: 10px; + &:hover { + opacity: 1; } + } - :last-child & :not(i, span) { - margin-top: 10px; - } + &__slides-error { + margin-top: 12px; + color: var(--red); } - &__generals { - margin-bottom: 10px; + &__slides-open-file { + color: var(--accent); + transition: color 0.2s; - :first-child { - margin-bottom: 10px; + &:hover { + color: var(--accent-dark); } } - &__tags { - margin-bottom: 10px; - } + &__links { + display: flex; + gap: 20px; + align-items: center; + width: 100%; - &__additional { - &-wrapper { + &--wrapper { display: flex; flex-direction: column; gap: 20px; width: 100%; + margin-bottom: 15px; + } - ::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; + li { + display: flex; + align-items: center; - &--placeholder { - color: var(--grey-for-text) !important; - } + form { + display: flex; + align-items: center; + width: 100%; - @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 { + width: 100%; + margin: 0; } } } - fieldset { - margin-bottom: 10px; + &--remove { + margin-top: 15px; } } - &__image { - display: block; - margin-bottom: 20px; + &__invite { + margin-top: 40px; + } +} - .error { - margin-top: 15px; - } +.error { + i { + color: var(--red) !important; } - &__avatar { - margin-bottom: 20px; + color: var(--red) !important; +} + +.cancel { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 700px; + max-width: 100%; + max-height: calc(100vh - 40px); + padding: 40px 0 80px; + overflow-y: auto; + + &__cross { + position: absolute; + top: 0; + right: 0; + cursor: pointer; + + @include responsive.apply-desktop { + top: 8px; + right: 8px; + } } - &__file { - min-width: 0; + &__top { + display: flex; + flex-direction: column; + margin-bottom: 10px; } - &__slides-title { - max-width: 320px; - margin-top: 12px; - color: var(--black); + &__title { text-align: center; } - &__slides-text { - max-width: 275px; - margin-top: 12px; - color: var(--dark-grey); + &__text { text-align: center; } - &__slides-error { - margin-top: 12px; - color: var(--red); + &__button { + margin-top: 20px; } +} - &__slides-open-file { - color: var(--accent); - transition: color 0.2s; +.invite { + &__item { + display: flex; + flex-grow: 1; + align-items: center; + justify-content: space-between; + width: 300px; + margin-bottom: 12px; - &:hover { - color: var(--accent-dark); + &--info { + display: flex; + gap: 20px; + align-items: center; + } + } + + &__team { + display: flex; + flex-direction: column; + gap: 10px; + align-items: flex-start; + justify-content: space-between; + + &--scrollable { + max-height: 180px; + overflow-y: auto; } } } 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 index 6569ef4fe..72ae92bd9 100644 --- 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 @@ -1,11 +1,27 @@ /** @format */ -import { Component, Input, Output, EventEmitter, inject, OnInit, OnDestroy } from "@angular/core"; -import { FormGroup, ReactiveFormsModule } from "@angular/forms"; +import { + Component, + Input, + Output, + EventEmitter, + inject, + OnInit, + OnDestroy, + signal, +} from "@angular/core"; +import { + FormArray, + FormBuilder, + FormGroup, + FormsModule, + ReactiveFormsModule, + Validators, +} from "@angular/forms"; import { AuthService } from "@auth/services"; import { ErrorMessage } from "@error/models/error-message"; -import { directionProjectList } from "projects/core/src/consts/list-direction-project"; -import { trackProjectList } from "projects/core/src/consts/list-track-project"; +import { directionProjectList } from "projects/core/src/consts/lists/ldirection-project-list.const"; +import { trackProjectList } from "projects/core/src/consts/lists/track-project-list.const"; import { Observable, Subscription } from "rxjs"; import { AvatarControlComponent } from "@ui/components/avatar-control/avatar-control.component"; import { InputComponent, SelectComponent, ButtonComponent } from "@ui/components"; @@ -15,6 +31,14 @@ import { AsyncPipe, CommonModule } from "@angular/common"; import { ControlErrorPipe } from "@corelib"; import { ProjectFormService } from "../../services/project-form.service"; import { IconComponent } from "@uilib"; +import { ProjectContactsService } from "../../services/project-contacts.service"; +import { ProjectGoalService } from "../../services/project-goals.service"; +import { ModalComponent } from "@ui/components/modal/modal.component"; +import { ProjectTeamService } from "../../services/project-team.service"; +import { AvatarComponent } from "@ui/components/avatar/avatar.component"; +import { ProjectService } from "@office/services/project.service"; +import { RouterLink } from "@angular/router"; +import { generateOptionsList } from "@utils/generate-options-list"; @Component({ selector: "app-project-main-step", @@ -33,43 +57,53 @@ import { IconComponent } from "@uilib"; UploadFileComponent, AsyncPipe, ControlErrorPipe, + ModalComponent, + AvatarComponent, + FormsModule, + RouterLink, ], }) export class ProjectMainStepComponent implements OnInit, OnDestroy { @Input() industries$!: Observable; - @Input() projectSteps$!: Observable; - @Input() programTagsOptions: any[] = []; @Input() leaderId = 0; @Input() projSubmitInitiated = false; @Input() projectId!: number; @Input() isProjectBoundToProgram = false; - @Output() assignToProgram = new EventEmitter(); - private subscription = new Subscription(); readonly authService = inject(AuthService); + private readonly projectService = inject(ProjectService); private readonly projectFormService = inject(ProjectFormService); + private readonly projectContactsService = inject(ProjectContactsService); + private readonly projectGoalsService = inject(ProjectGoalService); + private readonly projectTeamService = inject(ProjectTeamService); + private readonly fb = inject(FormBuilder); readonly errorMessage = ErrorMessage; readonly trackList = trackProjectList; readonly directionList = directionProjectList; + readonly trlList = generateOptionsList(9, "numbers"); + + goalLeaderShowModal = false; + activeGoalIndex = signal(null); + selectedLeaderId = ""; // Получаем форму из сервиса get projectForm(): FormGroup { return this.projectFormService.getForm(); } + get goalForm(): FormGroup { + return this.projectGoalsService.getForm(); + } + ngOnInit(): void {} ngOnDestroy(): void { this.subscription.unsubscribe(); } - onAssignToProgram(): void { - this.assignToProgram.emit(); - } - // Геттеры для удобного доступа к контролам формы get name() { return this.projectFormService.name; @@ -83,10 +117,6 @@ export class ProjectMainStepComponent implements OnInit, OnDestroy { return this.projectFormService.industry; } - get step() { - return this.projectFormService.step; - } - get description() { return this.projectFormService.description; } @@ -95,20 +125,20 @@ export class ProjectMainStepComponent implements OnInit, OnDestroy { return this.projectFormService.actuality; } - get goal() { - return this.projectFormService.goal; + get implementationDeadline() { + return this.projectFormService.implementationDeadline; } get problem() { return this.projectFormService.problem; } - get trackControl() { - return this.projectFormService.track; + get targetAudience() { + return this.projectFormService.targetAudience; } - get direction() { - return this.projectFormService.direction; + get trl() { + return this.projectFormService.trl; } get presentationAddress() { @@ -126,4 +156,166 @@ export class ProjectMainStepComponent implements OnInit, OnDestroy { get partnerProgramId() { return this.projectFormService.partnerProgramId; } + + // Геттеры для работы со ссылками + get link() { + return this.projectContactsService.link; + } + + get links(): FormArray { + return this.projectContactsService.links; + } + + get linksItems() { + return this.projectContactsService.linksItems; + } + + // Геттеры для работы с целями + get goals(): FormArray { + return this.projectGoalsService.goals; + } + + get goalItems() { + return this.projectGoalsService.goalItems; + } + + get goalName() { + return this.projectGoalsService.goalName; + } + + get goalDate() { + return this.projectGoalsService.goalDate; + } + + get goalLeader() { + return this.projectGoalsService.goalLeader; + } + + get editIndex() { + return this.projectFormService.editIndex; + } + + get collaborators() { + return this.projectTeamService.getCollaborators(); + } + + /** + * Проверяет, есть ли ссылки для отображения + */ + get hasLinks(): boolean { + return this.links.length > 0; + } + + /** + * Проверяет, есть ли цели для отображения + */ + get hasGoals(): boolean { + return this.goals.length > 0; + } + + /** + * Добавление ссылки + */ + addLink(link?: string): void { + this.links.push(this.fb.control(link ?? "", [Validators.required])); + this.projectContactsService.addLink(this.links, this.projectForm); + } + + /** + * Редактирование ссылки + * @param index - индекс ссылки + */ + editLink(index: number): void { + this.projectContactsService.editLink(index, this.links, this.projectForm); + } + + /** + * Удаление ссылки + * @param index - индекс ссылки + */ + removeLink(index: number): void { + this.projectContactsService.removeLink(index, this.links); + } + + /** + * Добавление цели + */ + addGoal(goalName?: string, goalDate?: string, goalLeader?: string): void { + this.goals.push( + this.fb.group({ + title: [goalName, [Validators.required]], + completionDate: [goalDate, [Validators.required]], + responsible: [goalLeader, [Validators.required]], + }) + ); + + this.projectGoalsService.addGoal(goalName, goalDate, goalLeader); + } + + /** + * Удаление цели + * @param index - индекс цели + */ + removeGoal(index: number, goalId: number): void { + this.projectGoalsService.removeGoal(index); + this.projectService.deleteGoals(this.projectId, goalId).subscribe(); + } + + /** + * Получить выбранного лидера для конкретной цели + */ + getSelectedLeaderForGoal(goalIndex: number) { + const goalFormGroup = this.goals.at(goalIndex); + const leaderId = goalFormGroup?.get("responsible")?.value; + + if (!leaderId) return null; + + return this.collaborators.find(collab => collab.userId.toString() === leaderId.toString()); + } + + /** + * Обработчик изменения радио-кнопки для выбора лидера + */ + onLeaderRadioChange(event: Event): void { + const target = event.target as HTMLInputElement; + this.selectedLeaderId = target.value; + } + + /** + * Добавление лидера на определенную цель + */ + addLeader(): void { + const goalIndex = this.activeGoalIndex(); + + if (goalIndex === null) { + return; + } + + if (!this.selectedLeaderId) { + return; + } + + // Устанавливаем выбранного лидера в форму + const goalFormGroup = this.goals.at(goalIndex); + goalFormGroup?.get("responsible")?.setValue(this.selectedLeaderId); + + this.toggleGoalLeaderModal(); + this.selectedLeaderId = ""; + } + + /** + * Переключатель для модалки выбора лидера + */ + toggleGoalLeaderModal(index?: number): void { + this.goalLeaderShowModal = !this.goalLeaderShowModal; + + if (index !== undefined) { + this.activeGoalIndex.set(index); + const currentLeader = this.goals.at(index)?.get("responsible")?.value; + this.selectedLeaderId = currentLeader || ""; + } else { + this.activeGoalIndex.set(null); + this.selectedLeaderId = ""; + } + } } 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 index 40816afaf..87246e721 100644 --- 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 @@ -2,14 +2,20 @@