diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 0f241fd0b..000000000 --- a/.eslintrc.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "env": { - "browser": true, - "es2021": true, - "jasmine": true - }, - "extends": ["standard", "prettier", "plugin:@typescript-eslint/recommended"], - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaVersion": "latest", - "sourceType": "module" - }, - "plugins": ["@typescript-eslint"], - "rules": { - "no-useless-constructor": "off", - "@typescript-eslint/no-empty-function": "off", - "dot-notation": "off", - "@typescript-eslint/no-explicit-any": "warn", - "@typescript-eslint/no-unused-vars": "warn", - "@typescript-eslint/no-unused-expressions": "warn" - } -} diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml new file mode 100644 index 000000000..cad11f141 --- /dev/null +++ b/.github/workflows/deploy-dev.yml @@ -0,0 +1,33 @@ +# @format + +name: Deploy to dev-stage + +on: + push: + branches: [dev] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Use Node.js 20.x + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "npm" + cache-dependency-path: package-lock.json + - run: npm ci + - run: npm run format:check + - run: npm run lint:ts + - run: npm run test:ci + - run: npm run build:pr + - name: Deploy to dev-stage + uses: burnett01/rsync-deployments@5.2.1 + with: + switches: -avzr --delete + path: dist/social_platform/ + remote_path: /home/front/app + remote_user: front + remote_key: ${{ secrets.DEPLOY_KEY_FRONT_DEV }} + remote_host: dev.procollab.ru diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index aed365a0a..47db0ebe4 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -17,15 +17,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: 18.16 + node-version: 20 cache: "npm" - run: npm ci - run: npm run format:check - run: npm run lint:ts + - run: npm run test:ci - run: npm run build:prod - name: deploy to server uses: burnett01/rsync-deployments@5.2.1 diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml deleted file mode 100644 index 0e1c44005..000000000 --- a/.github/workflows/pull_request.yml +++ /dev/null @@ -1,52 +0,0 @@ -# @format - -name: Pull request checks - -on: - pull_request: - branches: [master] - -jobs: - build: - runs-on: ubuntu-latest - permissions: - pull-requests: write - steps: - - uses: actions/checkout@v4 - - name: Use Node.js 18.x - uses: actions/setup-node@v4 - with: - node-version: 18.13 - cache: "npm" - cache-dependency-path: package-lock.json - - run: npm ci - - run: npm run format:check - - run: npm run lint:ts - - run: npm run build:pr - - name: Publish preview social - uses: netlify/actions/cli@master - id: publish_preview_social - with: - args: deploy --dir=./dist/social_platform - env: - NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} - NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_ACCESS_TOKEN }} - NETLIFY_PREVIEW_APP: true # or perhaps like this - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NETLIFY_PR_ID: ${{ github.event.pull_request.number }}-social - - name: Publish preview skills - uses: netlify/actions/cli@master - id: publish_preview_skills - with: - args: deploy --dir=./dist/skills - env: - NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SKILLS_SITE_ID }} - NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_ACCESS_TOKEN }} - NETLIFY_PREVIEW_APP: true # or perhaps like this - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NETLIFY_PR_ID: ${{ github.event.pull_request.number }}-skills - - uses: mshick/add-pr-comment@v2 - with: - message: | - Social platform url - ${{steps.publish_preview_social.outputs.NETLIFY_URL}} - Skills platform url - ${{steps.publish_preview_skills.outputs.NETLIFY_URL}} diff --git a/.gitignore b/.gitignore index 9e1738cc3..a73322b91 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,5 @@ testem.log # System files .DS_Store Thumbs.db + +*storybook.log diff --git a/README.md b/README.md index ba7fb963d..96ca2a929 100644 --- a/README.md +++ b/README.md @@ -2,28 +2,15 @@ # Procollab -This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 13.3.5. +Frontend-монорепозиторий для социальной платформы Procollab: Angular 17 приложение (`social_platform`) плюс две разделяемые библиотеки (`core`, `ui`). -## Development server +📖 **Вся документация лежит в [`docs/`](docs/).** Начни с [`docs/PROJECT.md`](docs/PROJECT.md) — там обзор воркспейса (стек, под-проекты, слои, окружения, build/CI), а дальше иди по модулям в [`docs/modules/`](docs/modules/). -Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files. +## Быстрый старт -## Code scaffolding +```bash +npm ci +npm run start:social # ng serve social_platform --open +``` -Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. - -## Build - -Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. - -## Running unit tests - -Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). - -## Running end-to-end tests - -Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities. - -## Further help - -To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page. +Полный список скриптов и команд — в [`docs/PROJECT.md`](docs/PROJECT.md). diff --git a/angular.json b/angular.json index 3593b57be..a79279333 100644 --- a/angular.json +++ b/angular.json @@ -18,12 +18,13 @@ "prefix": "app", "architect": { "build": { - "builder": "@angular-devkit/build-angular:browser", + "builder": "@angular/build:application", "options": { - "outputPath": "dist/social_platform", + "outputPath": { + "base": "dist/social_platform", + "browser": "" + }, "index": "projects/social_platform/src/index.html", - "main": "projects/social_platform/src/main.ts", - "polyfills": ["zone.js"], "tsConfig": "projects/social_platform/tsconfig.app.json", "inlineStyleLanguage": "scss", "assets": [ @@ -34,7 +35,8 @@ "scripts": [], "stylePreprocessorOptions": { "includePaths": ["projects/social_platform/src"] - } + }, + "browser": "projects/social_platform/src/main.ts" }, "configurations": { "production": { @@ -44,12 +46,11 @@ "with": "projects/social_platform/src/environments/environment.prod.ts" } ], - "budgets": [ { "type": "initial", "maximumWarning": "500kb", - "maximumError": "1mb" + "maximumError": "2mb" }, { "type": "anyComponentStyle", @@ -59,7 +60,6 @@ ], "outputHashing": "all" }, - "development": { "optimization": false, "extractLicenses": false, @@ -69,7 +69,7 @@ "defaultConfiguration": "production" }, "serve": { - "builder": "@angular-devkit/build-angular:dev-server", + "builder": "@angular/build:dev-server", "configurations": { "production": { "buildTarget": "social_platform:build:production" @@ -81,31 +81,33 @@ "defaultConfiguration": "development" }, "extract-i18n": { - "builder": "@angular-devkit/build-angular:extract-i18n", + "builder": "@angular/build:extract-i18n", "options": { "buildTarget": "social_platform:build" } }, - "test": { - "builder": "@angular-devkit/build-angular:karma", + "storybook": { + "builder": "@storybook/angular:start-storybook", "options": { - "polyfills": ["zone.js", "zone.js/testing"], - "tsConfig": "projects/social_platform/tsconfig.spec.json", - "inlineStyleLanguage": "scss", - "assets": [ - "projects/social_platform/src/favicon.ico", - "projects/social_platform/src/assets" - ], - "styles": ["projects/social_platform/src/styles.scss"], - "scripts": [], - "stylePreprocessorOptions": { - "includePaths": ["projects/social_platform/src"] - } + "configDir": "projects/social_platform/.storybook", + "browserTarget": "social_platform:build", + "compodoc": false, + "compodocArgs": ["-e", "json", "-d", "projects/social_platform"], + "port": 6006 + } + }, + "build-storybook": { + "builder": "@storybook/angular:build-storybook", + "options": { + "configDir": "projects/social_platform/.storybook", + "browserTarget": "social_platform:build", + "compodoc": false, + "compodocArgs": ["-e", "json", "-d", "projects/social_platform"], + "outputDir": "dist/storybook/social_platform" } } } }, - "core": { "projectType": "library", "root": "projects/core", @@ -113,7 +115,7 @@ "prefix": "lib", "architect": { "build": { - "builder": "@angular-devkit/build-angular:ng-packagr", + "builder": "@angular/build:ng-packagr", "options": { "project": "projects/core/ng-package.json" }, @@ -126,101 +128,6 @@ } }, "defaultConfiguration": "production" - }, - "test": { - "builder": "@angular-devkit/build-angular:karma", - "options": { - "tsConfig": "projects/core/tsconfig.spec.json", - "polyfills": ["zone.js", "zone.js/testing"] - } - } - } - }, - "skills": { - "projectType": "application", - "schematics": { - "@schematics/angular:component": { - "style": "scss" - } - }, - "root": "projects/skills", - "sourceRoot": "projects/skills/src", - "prefix": "app", - "architect": { - "build": { - "builder": "@angular-devkit/build-angular:browser", - "options": { - "outputPath": "dist/skills", - "index": "projects/skills/src/index.html", - "main": "projects/skills/src/main.ts", - "polyfills": ["zone.js"], - "tsConfig": "projects/skills/tsconfig.app.json", - "inlineStyleLanguage": "scss", - "assets": ["projects/skills/src/favicon.ico", "projects/skills/src/assets"], - "styles": ["projects/skills/src/styles.scss"], - "scripts": [], - "stylePreprocessorOptions": { - "includePaths": ["projects/skills/src"] - } - }, - "configurations": { - "production": { - "fileReplacements": [ - { - "replace": "projects/skills/src/environments/environment.ts", - "with": "projects/skills/src/environments/environment.prod.ts" - } - ], - "budgets": [ - { - "type": "initial", - "maximumWarning": "500kb", - "maximumError": "1mb" - }, - { - "type": "anyComponentStyle", - "maximumWarning": "6kb", - "maximumError": "12kb" - } - ], - "outputHashing": "all" - }, - "development": { - "optimization": false, - "extractLicenses": false, - "sourceMap": true - } - }, - "defaultConfiguration": "production" - }, - "serve": { - "builder": "@angular-devkit/build-angular:dev-server", - "configurations": { - "production": { - "buildTarget": "skills:build:production" - }, - "development": { - "buildTarget": "skills:build:development" - } - }, - "defaultConfiguration": "development" - }, - "extract-i18n": { - "builder": "@angular-devkit/build-angular:extract-i18n", - "options": { - "buildTarget": "skills:build" - } - }, - "test": { - "builder": "@angular-devkit/build-angular:karma", - "options": { - "polyfills": ["zone.js", "zone.js/testing"], - "tsConfig": "projects/skills/tsconfig.spec.json", - "inlineStyleLanguage": "scss", - "assets": ["projects/skills/src/favicon.ico", "projects/skills/src/assets"], - "styles": ["projects/skills/src/styles.scss"], - "scripts": [] - } } } }, @@ -231,7 +138,7 @@ "prefix": "ui", "architect": { "build": { - "builder": "@angular-devkit/build-angular:ng-packagr", + "builder": "@angular/build:ng-packagr", "options": { "project": "projects/ui/ng-package.json" }, @@ -244,15 +151,34 @@ } }, "defaultConfiguration": "production" - }, - "test": { - "builder": "@angular-devkit/build-angular:karma", - "options": { - "tsConfig": "projects/ui/tsconfig.spec.json", - "polyfills": ["zone.js", "zone.js/testing"] - } } } } + }, + "schematics": { + "@schematics/angular:component": { + "type": "component" + }, + "@schematics/angular:directive": { + "type": "directive" + }, + "@schematics/angular:service": { + "type": "service" + }, + "@schematics/angular:guard": { + "typeSeparator": "." + }, + "@schematics/angular:interceptor": { + "typeSeparator": "." + }, + "@schematics/angular:module": { + "typeSeparator": "." + }, + "@schematics/angular:pipe": { + "typeSeparator": "." + }, + "@schematics/angular:resolver": { + "typeSeparator": "." + } } } diff --git a/docs/PROJECT.md b/docs/PROJECT.md new file mode 100644 index 000000000..6e6e22081 --- /dev/null +++ b/docs/PROJECT.md @@ -0,0 +1,209 @@ + + +# Procollab — фронтенд-воркспейс + +Angular-монорепозиторий из трёх под-проектов: приложения `social_platform` и двух разделяемых библиотек (`core`, `ui`). Приложение деплоится в две среды: prod (с ветки `master`) и dev-stage (с ветки `dev`). + +> Документация по конкретным модулям лежит в [`docs/modules/`](modules/), документация по слоям приложения — в [`docs/social-platform/`](social-platform/), cross-cutting API-сервисы описаны в [`docs/cross-cutting.md`](cross-cutting.md), документация по библиотеке `core` — в [`docs/core/`](core/), по библиотеке `ui` — в [`docs/uilib.md`](uilib.md). Этот файл — общая карта воркспейса. + +--- + +## Стек + +| Область | Технология | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Framework | Angular 20 (standalone components, signals, zoneless change detection, control-flow `@if/@for`) | +| Язык | TypeScript 5.9 (`strict`, `noImplicitOverride`, `noPropertyAccessFromIndexSignature`, `strictTemplates`, `useDefineForClassFields: false`) | +| Состояние | Signals + собственный дискриминатор `AsyncState` (`initial` / `loading` / `success` / `failure`) | +| Async | RxJS 7.5 | +| HTTP | `ApiService` поверх `HttpClient` + `BearerTokenInterceptor` + `CamelCaseInterceptor` + `LoggingInterceptor` | +| Auth | JWT в cookies через `TokenService`; имена cookie-ключей переключаются между `accessToken`/`refreshToken` и `devAccessToken`/`devRefreshToken` в зависимости от hostname | +| WebSocket | `WebsocketService` с reconnect interval / max attempts из `environment` | +| UI primitives | `@angular/cdk` 20, `@angular/material` 20 (выборочные модули) | +| Стили | SCSS + миксины из `styles/_responsive.scss` (`apply-desktop` ≥ 1000px, `apply-tablet` 750–999px, `apply-tablet-and-above` ≥ 750px) | +| Errors / observability | `LoggerService` (уровень + timestamp), `LoggingInterceptor`, `EventBus` для domain-событий; **Sentry** через `@sentry/angular`: `initSentry()` в `main.ts` + `createErrorHandler` как `ErrorHandler` в `app.config.ts` (`tracesSampleRate: 0.2`, `replaysOnErrorSampleRate: 1.0`); `ConnectionStatusToastService` показывает toast при потере WebSocket. Кастомный `GlobalErrorHandler` остаётся в `core`, но в `app.config.ts` больше не регистрируется — его заменил Sentry-handler | +| Сборка | Angular CLI 20 + esbuild-билдер `@angular/build:application`; ng-packagr 20 для библиотек | +| Тесты | Karma + Jasmine 4 (`karma.conf.js`). Приложение zoneless, но Karma-билдер всё ещё подключает `zone.js` / `zone.js/testing` в `polyfills` | +| Lint / format | ESLint **flat-config** (`eslint.config.mjs`) с `eslint-plugin-boundaries` (контроль зависимостей между слоями), Stylelint, Prettier; `precommit` запускает `lint:scss` и `lint:ts` | +| SVG-спрайт | `svg-sprite` собирает `assets/icons/symbol/svg/sprite.css.svg` из всех файлов в `assets/icons/svg/**/*.svg` | +| Сторонние пакеты | `dayjs`, `class-transformer`, `js-cookie`, `linkifyjs`, `nanoid`, `ng-click-outside`, `ngx-mask`, `ngx-image-cropper`, `ngx-autosize`, `file-saver`, `fuse.js`, `js-base64`, `camelcase-keys`, `snakecase-keys`, `@sentry/angular` | +| Node | `>=20` (см. `engines.node` в `package.json`) | + +--- + +## Под-проекты (`angular.json` → `projects`) + +| Проект | Тип | Source root | Назначение | +| ----------------- | ----------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `social_platform` | application | `projects/social_platform/src` | Веб-приложение для пользователей. Содержит всё, что лежит в `app/`: `domain`, `api`, `infrastructure`, `ui`, `utils`, плюс ассеты в `assets/` и шрифты. Это единственный application-проект; library-проекты собираются отдельно через `ng-packagr`. | +| `core` | library | `projects/core/src` | Базовые сервисы и утилиты, которые в принципе могут переехать в любое Angular-приложение: `ApiService`, `TokenService`, HTTP-интерсепторы, пайпы, `LoggerService`, `GlobalErrorHandler`, `ValidationService`, `WebsocketService`, провайдеры (`PRODUCTION`, `API_URL`), guards (`auth-required`, `projects-edit`, `profile-edit`). Публичный API — `core/src/public-api.ts`, импортируется как `@corelib`. Константы (списки, навигация) лежат отдельно в `core/src/consts/`. | +| `ui` | library | `projects/ui/src` | Layout-компоненты, которые шарятся между приложениями (sidebar, header, profile-info, profile-control-panel, invite-manage-card, subscription-plans), плюс маленький набор примитивов (`avatar`, `back`, `icon`). Публичный API — `ui/src/public-api.ts`, импортируется как `@uilib`; модели — через `uilib/models`. | + +--- + +## TypeScript path aliases (`tsconfig.json` → `compilerOptions.paths`) + +| Alias | Указывает на | +| ------------------- | ---------------------------------------------------------- | +| `@core/*` | `projects/core/src/*` | +| `@corelib` | `projects/core/src/public-api.ts` | +| `@uilib` | `projects/ui/src/public-api.ts` | +| `uilib/models` | `projects/ui/src/models/*` | +| `@domain/*` | `projects/social_platform/src/app/domain/*` | +| `@infrastructure/*` | `projects/social_platform/src/app/infrastructure/*` | +| `@api/*` | `projects/social_platform/src/app/api/*` | +| `@ui/*` | `projects/social_platform/src/app/ui/*` | +| `@pages/*` | `projects/social_platform/src/app/ui/pages/*` | +| `@utils/*` | `projects/social_platform/src/app/utils/*` | +| `@environment` | `projects/social_platform/src/environments/environment.ts` | +| `core` / `ui` | `dist/core` / `dist/ui` (сборочные артефакты библиотек) | + +--- + +## Слои приложения (`projects/social_platform/src/app/`) + +``` +domain/ чистые типы, модели, repository ports, domain events +api/ use-cases (одна операция = один класс) + facades + UI-info services +infrastructure/ реализации репозиториев (HTTP), DTO ↔ domain adapters, DI providers +ui/ routes, pages, widgets, primitives, ui-services (loading/snackbar/nav/notification) +utils/ маленькие чистые хелперы (валидаторы, форматтеры, file-export и т. п.) +``` + +Правила зависимостей: + +``` +ui ─┬──▶ api ──▶ domain ◀── infrastructure + └────────────▶ domain (только для типов) +``` + +Никто не импортирует `infrastructure` напрямую — он подключается DI-провайдерами (`infrastructure/di/.providers.ts`), которые биндят порт к реализации. + +### Подробнее по слоям + +- **`domain/`** — каркас. На каждый домен (auth, project, vacancy, courses, …) есть папка с: + - `*.model.ts` — TypeScript-интерфейсы и классы доменной модели; + - `ports/*.repository.port.ts` — абстрактные классы (используются как DI-токены) с контрактом репозитория; + - `events/*` — domain events (если используется EventBus); + - `commands/`, `results/` — отдельные структуры команд/результатов use-case'ов в новых модулях. + +- **`api/`** — оркестрация. На каждый домен: + - `use-cases/..use-case.ts` — один класс на одну операцию, инжектит `*RepositoryPort`, возвращает `Observable` или `Observable>`; + - `facades/*.service.ts` — фасады для UI: хранят `signal>`, вызывают use-case'ы, обрабатывают результат; + - `facades/ui/*.service.ts` — UI-info-сервисы: composed `computed` сигналы поверх фасадов (готовые boolean'ы для `@if`, отфильтрованные списки, форматированные тексты и т. п.). + +- **`infrastructure/`**: + - `repository//*` — реализации портов, делают HTTP через `ApiService`, держат `EntityCache` где нужно; + - `adapters/*` — трансформации DTO ↔ domain; + - `di/.providers.ts` — `Provider[]` массив, который биндит каждый порт к его реализации (`{ provide: XRepositoryPort, useExisting: XRepository }`). + +- **`ui/`**: + - `routes//*.routes.ts` — лениво подгружаемые группы роутов; + - `pages//...` — страницы (smart-компоненты), потребляют фасады; + - `widgets/*` — переиспользуемые блоки внутри `social_platform` (виджет ≈ используется в одном-двух местах); + - `primitives/*` — атомы (input, button, modal, dropdown, …) — используются повсеместно; + - `services/` — runtime-сервисы UI: `LoadingService`, `SnackbarService`, `NavService`, `NotificationService`. + +### Cross-cutting блоки + +| Блок | Где | Что | +| -------------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `AsyncState` | `domain/shared/async-state.ts` | Дискриминатор `{ status: "initial" \| "loading" \| "success" \| "failure" }` с хелперами `initial`, `loading`, `success`, `failure`, `isSuccess`, `isLoading`, `isFailure`. Единый источник правды для состояния асинхронных операций в фасадах. | +| `Result` | `domain/shared/result.type.ts` | `ok(data)` / `fail(error)` для возврата из use-case'ов. Use-case никогда не бросает — всё через `Result`. | +| `EventBus` + domain events | `domain//events/*` + `infrastructure` listeners | Репозитории слушают доменные события и инвалидируют свой `EntityCache`. Развязывает модули между собой. | +| `EntityCache` | `infrastructure/repository/...` | In-memory cache с опциональным TTL и stale-while-revalidate. Без TTL — бесконечный кеш (ручная инвалидация). С TTL — после истечения отдаёт стухшие данные сразу и запускает фоновый re-fetch (дедуп через `inflight`). TTL заданы у `Courses.detailCache` (10 мин) и `Program` (5 мин); `Project`, `Vacancy`, `project-news`, `project-subscription`, `Courses.structureCache` — без TTL, инвалидация через `EventBus` / `invalidate()`. | +| `LoggingInterceptor` + `LoggerService` | `core/lib/interceptors`, `core/lib/services/logger` | Структурированные логи с timestamp и уровнем; DEBUG только не в проде. | +| `GlobalErrorHandler` | `core/lib/services/error` | Перехватывает необработанные ошибки и Promise rejections, логирует через `LoggerService.error("[GlobalError] ...")`. `ErrorService` и `NgZone` заинжекчены, но в текущем коде не используются. | + +--- + +## Окружения + +`fileReplacements` в `angular.json` подменяет `environment.ts` → `environment.prod.ts` для конфигурации `production`. + +| Ключ | dev (`environment.ts`) | prod (`environment.prod.ts`) | +| ---------------------------------- | --------------------------- | ---------------------------- | +| `production` | `false` | `true` | +| `apiUrl` | `https://dev.procollab.ru` | `https://api.procollab.ru` | +| `websocketUrl` | `wss://dev.procollab.ru/ws` | `wss://api.procollab.ru/ws` | +| `websocketReconnectionInterval` | `500` мс | `5000` мс | +| `websocketReconnectionMaxAttempts` | `5` | `5` | +| `sentryDns` | общий DSN | общий DSN | + +### Изоляция cookies между prod и dev-stage + +`TokenService` (`projects/core/src/lib/services/tokens/token.service.ts`) выбирает имена cookie-ключей по hostname: + +| Hostname | Access cookie | Refresh cookie | +| ------------------ | ---------------- | ----------------- | +| `dev.procollab.ru` | `devAccessToken` | `devRefreshToken` | +| Любой другой | `accessToken` | `refreshToken` | + +Это позволяет одному разработчику быть залогиненным одновременно в prod и dev-stage в одном браузере без коллизий по cookie-куки. Прод-cookies дополнительно имеют `domain: ".procollab.ru"`, `secure: true`, `sameSite: "None"` и `expires` через 30 дней (см. `getCookieOptions()`); в dev cookies остаются на текущем хосте с дефолтами браузера. + +--- + +## Сборка, запуск, тесты + +```bash +npm run start:social # ng serve social_platform --open +npm run build:social:dev # build:sprite + ng build social_platform --configuration=development +npm run build:social:prod # build:sprite + ng build social_platform --configuration=production +npm run build:pr # alias для build:social:dev (используется CI для dev-stage) +npm run build:prod # alias для build:social:prod (используется CI для prod) +npm run watch # ng build --watch development + +npm run test # ng test (Karma + Jasmine, headed Chrome) +npm run test:ci # ng test --browsers=Headless --no-watch + +npm run lint:ts # ESLint поверх projects/**/*.ts +npm run lint:scss # Stylelint поверх projects/**/*.scss --fix +npm run format # Prettier write +npm run format:check # Prettier check + +npm run build:sprite # SVG-спрайт из src/assets/icons/svg/**/*.svg +npm run docs:json # Compodoc JSON dump (legacy) +``` + +`precommit` (через пакет `pre-commit`): `lint:scss`, `lint:ts`. Hooks **не пропускаем** через `--no-verify` — если линтер падает, чиним причину. + +`build:sprite` обязательный шаг перед сборкой: он собирает `assets/icons/symbol/svg/sprite.css.svg`, на который завязан `IconComponent` (через ``). + +--- + +## CI/CD + +Два workflow в `.github/workflows/`: + +| Файл | Триггер | Что делает | +| ---------------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `deploy.yml` | `push` в `master` (или ручной `workflow_dispatch`) | `npm ci` → `format:check` → `lint:ts` → `build:prod` → `rsync` дистрибутива в `app.procollab.ru:/home/gh_deploy` (секрет `DEPLOY_KEY`). | +| `deploy-dev.yml` | `push` в `dev` | `npm ci` → `format:check` → `lint:ts` → `build:pr` → `rsync` дистрибутива в `dev.procollab.ru:/home/front/app` (секрет `DEPLOY_KEY_FRONT_DEV`). | + +Отдельного PR-preview workflow нет — раньше был `pull_request.yml` с Netlify-превью, на ветке `dev` он удалён. + +Если ломается `format:check` или `lint:ts` — деплой не пойдёт. Перед пушем в `dev` или PR в `master` локально гонять: + +```bash +npm run format:check && npm run lint:ts && npm run build:pr +``` + +--- + +## Точки входа в роутинг + +`projects/social_platform/src/app/app.routes.ts` — корневой `Routes` массив. Лениво подгружаемые группы лежат под `ui/routes//`: + +- **`auth`** — публичные экраны (`ui/routes/auth/`): login, register, email verification, password reset. +- **`office`** — закрытый shell после логина (`ui/routes/office/`). Дочерние роуты: + - `feed` — глобальная лента; + - `members` — список участников платформы; + - `vacancies` — список вакансий, детальная страница; + - `chats` — список чатов и детальные чаты (личные + проектные); + - `projects` — `dashboard`/`my`/`subscriptions`/`invites`/`all` списки + edit + детальная страница с детьми: `info`, `vacancies`, `team`, `work-section`, `chat`; + - `program` — `all`, детальная страница (`main`, `projects`, `members`, `projects-rating`) и `register`; + - `courses` — `all`, детальная страница с детьми: `info`, `lesson` (и дальнейшие lesson-children); + - `onboarding` — flow первого захода (привязан к office shell, но грузится по своему пути). +- **`error`** — `404` и общая страница ошибки. + +--- diff --git a/docs/core/consts.md b/docs/core/consts.md new file mode 100644 index 000000000..7de1c94d7 --- /dev/null +++ b/docs/core/consts.md @@ -0,0 +1,111 @@ + + +# `@corelib` — consts + +Все константы лежат в `projects/core/src/consts/` (вне `lib/`, отдельная иерархия). Не реэкспортируются из `core/src/public-api.ts` — потребители импортируют их через `@core/consts/...` (alias `@core/*` указывает на `projects/core/src/*`). + +## Структура папок + +``` +core/src/consts/ + filters/ # массивы для фильтров (feed, vacancies, ratings) — value/label/id + lists/ # справочники для select-полей (educations, languages, etc.) + navigation/ # элементы боковой/верхней навигации + other/ # всё, что не попало в другие группы (цвета, иконки, profile-fields, kanban-* — см. ниже) +``` + +--- + +## Правила нейминга + +(Сохранено из удалённого `projects/core/src/consts/README.md`.) + +### Имена файлов + +- Формат: `feature.const.ts` +- Стиль: **kebab-case** +- Примеры: `navigation.const.ts`, `selects.const.ts`, `permissions.const.ts` + +### Имена переменных + +- Стиль: **camelCase** +- Если переменная — список, имя во **множественном числе** +- Имя отражает назначение +- Экспорт только через `export const` + +```ts +export const navItems = [...]; +``` + +--- + +## `consts/lists/` — справочники для `` + +Формат каждой константы: `{ id: number, value: string, label: string }[]` (иногда + дополнительные поля). + +| Файл | Экспорт | Где используется | +| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | +| `actiion-type-list.const.ts` | `actionTypeList` (3 элемента: action/call/meet) | Канбан task-detail (выбор типа задачи). | +| `direction-project-list.const.ts` | `directionProjectList` (8 направлений: Технология, IT, Транспорт, Хим Био, Дизайн, Мультимедиа, СоцТех, Урбанистика) | Создание/редактирование проекта. | +| `education-info-list.const.ts` | `educationUserType` (3) + `educationUserLevel` (5) | Profile edit — образование. | +| `language-info-list.const.ts` | `languageNamesList` | Profile edit — языки. | +| `mock-months-list.const.ts` | `mockMonthsList` | Месяцы для дат рождения и т. п. | +| `priority-info-list.const.ts` | `priorityInfoList` (6 приоритетов: бэклог/в ближайшие часы/высокий/средний/низкий/улучшение, с цветом и `priorityType` для бэка) | Канбан task-detail (выбор приоритета). | +| `resource-options-list.const.ts` | `resourceOptionsList` | Project edit — типы ресурсов. | +| `roles-members-list.const.ts` | `rolesMembersList` | Members filters / project team. | +| `track-project-list.const.ts` | `trackProjectList` | Программные треки. | +| `work-experience-list.const.ts` | `workExperienceList` | Vacancy / profile (опыт работы). | +| `work-format-list.const.ts` | `workFormatList` | Vacancy (формат работы — онлайн/гибрид/офис). | +| `work-schelude-list.const.ts` | `workScheludeList` (опечатка) | Vacancy (график). | + +--- + +## `consts/filters/` — конфигурации фильтров + +| Файл | Экспорт | +| --------------------------------- | ---------------------- | +| `feed-filter.const.ts` | `feedFilter` | +| `rating-filter.const.ts` | `ratingFilters` | +| `tags-filter.const.ts` | `tagsFilter` | +| `work-experience-filter.const.ts` | `workExperienceFilter` | +| `work-format-filter.const.ts` | `workFormatFilter` | +| `work-schedule-filter.const.ts` | `workScheduleFilter` | + +Используются виджетами `widgets/feed-filter`, `widgets/projects-filter`, `widgets/vacancy-filter`. Структура — массив групп с фильтрами. + +--- + +## `consts/navigation/` — элементы пошагового редактирования + +| Файл | Экспорт | Что | +| ---------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `nav-profile-items.const.ts` | `navProfileItems` | Шаги редактирования профиля (`main` / `education` / `experience` / `achievements` / `additional`), каждый шаг → `{ step: EditStep, src: iconName, label: string }`. | +| `nav-project-items.const.ts` | `navProjectItems` | Шаги редактирования проекта (`main` / `contacts` / `achievements` / `vacancies` / `team` / `additional`). | + +Тип `EditStep` импортируется из `projects/social_platform/src/app/api/project/project-step.service` — это ещё одна зависимость `core` → `social_platform`. + +--- + +## `consts/other/` + +| Файл | Экспорт | Что | Статус | +| ----------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | +| `profile-fields.const.ts` | `profileFields` | Конфигурация полей профиля: `{ key, type: "array" \| "string" }[]`. Используется при сериализации формы edit. | актив | +| `quick-answers.const.ts` | `QuickAnswers` (PascalCase) | 5 готовых причин отказа от выполнения канбан-задачи. | **используется только канбаном** — модуль отключён | +| `tag-colors.const.ts` | `tagColors` | 10+ цветов для UI-тегов: `{ id, name: string, color: hex }`. | актив (используется в ``) | +| `trajectory-more.const.ts` | `trajectoryMore` | Items для меню "ещё" в траектории курсов. | актив | +| `kanban-column-info.const.ts` | `kanbanColumnInfo` | Опции dropdown для колонки канбана. | **kanban-only**, модуль отключён | +| `kanban-icons.const.ts` | `KanbanIcons` (PascalCase) | Иконки досок канбана. | **kanban-only**, модуль отключён | + +--- + +## Как добавлять новую константу + +1. Создать `.const.ts` в подходящей папке (`lists/` для справочников ` - - - } - - - diff --git a/projects/social_platform/src/app/office/courses/lesson/shared/radio-select-task/radio-select-task.component.ts b/projects/social_platform/src/app/office/courses/lesson/shared/radio-select-task/radio-select-task.component.ts deleted file mode 100644 index 1e28dc66e..000000000 --- a/projects/social_platform/src/app/office/courses/lesson/shared/radio-select-task/radio-select-task.component.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** @format */ - -import { - ChangeDetectorRef, - Component, - EventEmitter, - inject, - Input, - OnInit, - Output, - signal, -} from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { DomSanitizer, SafeResourceUrl } from "@angular/platform-browser"; -import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; -import { TruncateHtmlPipe } from "projects/core/src/lib/pipes/truncate-html.pipe"; -import { Task } from "@office/models/courses.model"; -import { resolveVideoUrlForIframe } from "@utils/video-url-embed"; -import { animateContentHeight } from "@utils/animate-content-height"; -import { isHtmlTextTruncated } from "@utils/is-html-text-truncated"; -import { FileItemComponent } from "@ui/components/file-item/file-item.component"; -import { ImagePreviewDirective } from "../image-preview/image-preview.directive"; - -@Component({ - selector: "app-radio-select-task", - standalone: true, - imports: [CommonModule, TruncatePipe, TruncateHtmlPipe, FileItemComponent, ImagePreviewDirective], - templateUrl: "./radio-select-task.component.html", - styleUrl: "./radio-select-task.component.scss", -}) -export class RadioSelectTaskComponent implements OnInit { - private readonly cdRef = inject(ChangeDetectorRef); - - @Input({ required: true }) data!: Task; - @Input() success = false; - @Input() hint = ""; - @Input() disabled = false; - - @Input() - set error(value: boolean) { - this._error.set(value); - - if (value) { - setTimeout(() => { - this.result.set({ answerId: null }); - this._error.set(false); - }, 1000); - } - } - - get error() { - return this._error(); - } - - @Output() update = new EventEmitter<{ answerId: number }>(); - - result = signal<{ answerId: number | null }>({ answerId: null }); - _error = signal(false); - readFullDescription = false; - cachedVideoUrl: SafeResourceUrl | null = null; - readonly truncateLimit = 700; - - get descriptionExpandable(): boolean { - return isHtmlTextTruncated(this.data?.bodyText, this.truncateLimit); - } - - constructor(private sanitizer: DomSanitizer) {} - - ngOnInit(): void { - const iframeUrl = resolveVideoUrlForIframe(this.data?.videoUrl); - this.cachedVideoUrl = iframeUrl - ? this.sanitizer.bypassSecurityTrustResourceUrl(iframeUrl) - : null; - } - - onToggleDescription(elem: HTMLElement): void { - animateContentHeight(elem, () => { - this.readFullDescription = !this.readFullDescription; - this.cdRef.detectChanges(); - }); - } - - onSelect(id: number) { - if (this.disabled) return; - this.result.set({ answerId: id }); - this.update.emit({ answerId: id }); - } -} diff --git a/projects/social_platform/src/app/office/courses/lesson/shared/video-task/info-task.component.html b/projects/social_platform/src/app/office/courses/lesson/shared/video-task/info-task.component.html deleted file mode 100644 index 99d6ce68a..000000000 --- a/projects/social_platform/src/app/office/courses/lesson/shared/video-task/info-task.component.html +++ /dev/null @@ -1,63 +0,0 @@ - - -
-
- @if ((data.videoUrl || data.attachmentUrl) && !data.imageUrl) { -

задание {{ data.order }}

- } - -
- @if (cachedVideoUrl) { - - } @if (data.imageUrl) { - - } - -
- @if (!data.videoUrl) { -

задание {{ data.order }}

- } - -

- {{ data.title | truncate: (cachedVideoUrl ? 80 : data.imageUrl ? 100 : 200) }} -

-
- @if (descriptionExpandable) { -
- {{ readFullDescription ? "скрыть" : "подробнее" }} -
- } @if (data.attachmentUrl) { - - } -
-
-
-
diff --git a/projects/social_platform/src/app/office/courses/lesson/shared/video-task/info-task.component.ts b/projects/social_platform/src/app/office/courses/lesson/shared/video-task/info-task.component.ts deleted file mode 100644 index 735a5eca2..000000000 --- a/projects/social_platform/src/app/office/courses/lesson/shared/video-task/info-task.component.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** @format */ - -import { ChangeDetectorRef, Component, inject, Input, OnInit } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { DomSanitizer, SafeResourceUrl } from "@angular/platform-browser"; -import { TruncateHtmlPipe } from "projects/core/src/lib/pipes/truncate-html.pipe"; -import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; -import { resolveVideoUrlForIframe } from "@utils/video-url-embed"; -import { animateContentHeight } from "@utils/animate-content-height"; -import { isHtmlTextTruncated } from "@utils/is-html-text-truncated"; -import { ImagePreviewDirective } from "../image-preview/image-preview.directive"; -import { Task } from "@office/models/courses.model"; -import { FileItemComponent } from "@ui/components/file-item/file-item.component"; - -/** - * Компонент информационного слайда с видео/изображением - * Отображает информационный контент с поддержкой различных медиа-форматов - * - * Входные параметры: - * @Input data - данные информационной задачи типа Task - * - * Функциональность: - * - Отображает текст и описание слайда - * - Поддерживает видео в iframe (YouTube, RuTube, Google Drive, прямые видео-файлы) - * - Автоматически определяет тип контента по URL/расширению файла - * - Адаптивная компоновка для разных типов медиа - */ -@Component({ - selector: "app-info-task", - standalone: true, - imports: [CommonModule, TruncateHtmlPipe, TruncatePipe, ImagePreviewDirective, FileItemComponent], - templateUrl: "./info-task.component.html", - styleUrl: "./info-task.component.scss", -}) -export class InfoTaskComponent implements OnInit { - @Input({ required: true }) data!: Task; // Данные информационной задачи - - private readonly sanitizer = inject(DomSanitizer); // Сервис для безопасной работы с HTML - private readonly cdRef = inject(ChangeDetectorRef); - - readFullDescription = false; - cachedVideoUrl: SafeResourceUrl | null = null; - readonly truncateLimit = 700; - - get descriptionExpandable(): boolean { - return isHtmlTextTruncated(this.data?.bodyText, this.truncateLimit); - } - - ngOnInit(): void { - const iframeUrl = resolveVideoUrlForIframe(this.data?.videoUrl); - this.cachedVideoUrl = iframeUrl - ? this.sanitizer.bypassSecurityTrustResourceUrl(iframeUrl) - : null; - } - - onToggleDescription(elem: HTMLElement): void { - animateContentHeight(elem, () => { - this.readFullDescription = !this.readFullDescription; - this.cdRef.detectChanges(); - }); - } -} diff --git a/projects/social_platform/src/app/office/courses/lesson/shared/write-task/write-task.component.html b/projects/social_platform/src/app/office/courses/lesson/shared/write-task/write-task.component.html deleted file mode 100644 index b30d261f2..000000000 --- a/projects/social_platform/src/app/office/courses/lesson/shared/write-task/write-task.component.html +++ /dev/null @@ -1,93 +0,0 @@ - - -
-
-

задание {{ data.order }}

- @if (!cachedVideoUrl && !data.imageUrl) { -

{{ data.title | truncate: 80 }}

- } -
- @if (cachedVideoUrl) { - - } @else if (data.imageUrl) { -
- -

{{ data.title | truncate: 50 }}

-
- } -

- @if (descriptionExpandable) { -
- {{ readFullDescription ? "скрыть" : "подробнее" }} -
- } @if (type === 'text-file' && data.attachmentUrl) { - - } -
-
- -
-

ответ

-
- -
-

- {{ currentLength() }} / {{ maxLength }} -

-
-
- - @if (type === 'text-file' && !disabled) { -
- -
- -

загрузите файл до 100 MB

-
-
- - @for (file of uploadedFiles(); track $index) { - - } -
- } -
-
diff --git a/projects/social_platform/src/app/office/courses/lesson/shared/write-task/write-task.component.spec.ts b/projects/social_platform/src/app/office/courses/lesson/shared/write-task/write-task.component.spec.ts deleted file mode 100644 index 11e8e2eb5..000000000 --- a/projects/social_platform/src/app/office/courses/lesson/shared/write-task/write-task.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { WriteTaskComponent } from "./write-task.component"; - -describe("WriteTaskComponent", () => { - let component: WriteTaskComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [WriteTaskComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(WriteTaskComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/courses/lesson/shared/write-task/write-task.component.ts b/projects/social_platform/src/app/office/courses/lesson/shared/write-task/write-task.component.ts deleted file mode 100644 index ded9ffacc..000000000 --- a/projects/social_platform/src/app/office/courses/lesson/shared/write-task/write-task.component.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** @format */ - -import { - ChangeDetectorRef, - Component, - EventEmitter, - inject, - Input, - OnInit, - Output, - signal, -} from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { DomSanitizer, SafeResourceUrl } from "@angular/platform-browser"; -import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; -import { TruncateHtmlPipe } from "projects/core/src/lib/pipes/truncate-html.pipe"; -import { UploadFileComponent } from "@ui/components/upload-file/upload-file.component"; -import { IconComponent } from "@ui/components"; -import { FileItemComponent } from "@ui/components/file-item/file-item.component"; -import { FileService } from "@core/services/file.service"; -import { Task } from "@models/courses.model"; -import { FileModel } from "@office/models/file.model"; -import { resolveVideoUrlForIframe } from "@utils/video-url-embed"; -import { animateContentHeight } from "@utils/animate-content-height"; -import { isHtmlTextTruncated } from "@utils/is-html-text-truncated"; -import { ImagePreviewDirective } from "../image-preview/image-preview.directive"; - -@Component({ - selector: "app-write-task", - standalone: true, - imports: [ - CommonModule, - TruncatePipe, - TruncateHtmlPipe, - UploadFileComponent, - IconComponent, - FileItemComponent, - ImagePreviewDirective, - ], - templateUrl: "./write-task.component.html", - styleUrl: "./write-task.component.scss", -}) -export class WriteTaskComponent implements OnInit { - private readonly fileService = inject(FileService); - private readonly sanitizer = inject(DomSanitizer); - private readonly cdRef = inject(ChangeDetectorRef); - - @Input({ required: true }) data!: Task; - @Input() type: "text" | "text-file" = "text"; - @Input() success = false; - @Input() disabled = false; - - @Output() update = new EventEmitter<{ text: string; fileUrls?: string[] }>(); - - readonly maxLength = 1000; - - uploadedFiles = signal([]); - currentLength = signal(0); - readFullDescription = false; - cachedVideoUrl: SafeResourceUrl | null = null; - private currentText = ""; - - get truncateLimit(): number { - return this.type === "text-file" ? 650 : 700; - } - - get descriptionExpandable(): boolean { - return isHtmlTextTruncated(this.data?.bodyText, this.truncateLimit); - } - - ngOnInit(): void { - const iframeUrl = resolveVideoUrlForIframe(this.data?.videoUrl); - this.cachedVideoUrl = iframeUrl - ? this.sanitizer.bypassSecurityTrustResourceUrl(iframeUrl) - : null; - } - - onToggleDescription(elem: HTMLElement): void { - animateContentHeight(elem, () => { - this.readFullDescription = !this.readFullDescription; - this.cdRef.detectChanges(); - }); - } - - onKeyUp(event: Event) { - const target = event.target as HTMLTextAreaElement; - - target.style.height = "0px"; - target.style.height = target.scrollHeight + "px"; - - this.currentText = target.value; - this.currentLength.set(target.value.length); - this.emitUpdate(); - } - - onFileUploaded(event: { url: string; name: string; size: number; mimeType: string }) { - if (!event.url) return; - - const ext = event.name.split(".").pop()?.toLowerCase() || ""; - const file: FileModel = { - name: event.name, - size: event.size, - mimeType: event.mimeType, - link: event.url, - extension: ext, - datetimeUploaded: new Date().toISOString(), - user: 0, - }; - - this.uploadedFiles.update(files => [...files, file]); - this.emitUpdate(); - } - - onFileRemoved(index: number) { - const file = this.uploadedFiles()[index]; - if (!file) return; - - this.fileService.deleteFile(file.link).subscribe({ - next: () => { - this.uploadedFiles.update(files => files.filter((_, i) => i !== index)); - this.emitUpdate(); - }, - error: () => { - this.uploadedFiles.update(files => files.filter((_, i) => i !== index)); - this.emitUpdate(); - }, - }); - } - - private emitUpdate() { - if (this.type === "text-file") { - this.update.emit({ - text: this.currentText, - fileUrls: this.uploadedFiles().map(f => f.link), - }); - } else { - this.update.emit({ text: this.currentText }); - } - } -} diff --git a/projects/social_platform/src/app/office/courses/list/list.component.html b/projects/social_platform/src/app/office/courses/list/list.component.html deleted file mode 100644 index ae371b7ce..000000000 --- a/projects/social_platform/src/app/office/courses/list/list.component.html +++ /dev/null @@ -1,13 +0,0 @@ - - -
- @if (coursesList().length) { -
- @for (course of coursesList(); track course.id) { - - - - } -
- } -
diff --git a/projects/social_platform/src/app/office/courses/list/list.component.spec.ts b/projects/social_platform/src/app/office/courses/list/list.component.spec.ts deleted file mode 100644 index 8b9a839e4..000000000 --- a/projects/social_platform/src/app/office/courses/list/list.component.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { ListComponent } from "@office/program/detail/rate-projects/list/list.component"; - -describe("ListComponent", () => { - let component: ListComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ListComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(ListComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/courses/list/list.component.ts b/projects/social_platform/src/app/office/courses/list/list.component.ts deleted file mode 100644 index 32241bcfe..000000000 --- a/projects/social_platform/src/app/office/courses/list/list.component.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** @format */ - -import { Component, inject, type OnDestroy, type OnInit, signal } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { ActivatedRoute, RouterModule } from "@angular/router"; -import { map, Subscription } from "rxjs"; -import { CourseComponent } from "../shared/course/course.component"; -import { CourseCard } from "@office/models/courses.model"; - -/** - * Компонент списка траекторий - * Отображает список доступных траекторий с поддержкой пагинации - * Поддерживает два режима: "all" (все траектории) и "my" (пользовательские) - * Реализует бесконечную прокрутку для загрузки дополнительных элементов - */ -@Component({ - selector: "app-list", - standalone: true, - imports: [CommonModule, RouterModule, CourseComponent], - templateUrl: "./list.component.html", - styleUrl: "./list.component.scss", -}) -export class CoursesListComponent implements OnInit, OnDestroy { - private readonly route = inject(ActivatedRoute); - - protected readonly coursesList = signal([]); - - private readonly subscriptions$: Subscription[] = []; - - /** - * Инициализация компонента - * Определяет тип списка (all/my) и загружает начальные данные - */ - ngOnInit(): void { - this.route.data.pipe(map(r => r["data"])).subscribe(courses => { - this.coursesList.set(courses); - }); - } - - /** - * Очистка ресурсов при уничтожении компонента - */ - ngOnDestroy(): void { - this.subscriptions$.forEach(s => s.unsubscribe()); - } -} diff --git a/projects/social_platform/src/app/office/courses/list/list.resolver.spec.ts b/projects/social_platform/src/app/office/courses/list/list.resolver.spec.ts deleted file mode 100644 index 358a69b0d..000000000 --- a/projects/social_platform/src/app/office/courses/list/list.resolver.spec.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; -import { ResolveFn } from "@angular/router"; - -import { listResolver } from "./records.resolver"; - -describe("listResolver", () => { - const executeResolver: ResolveFn = (...resolverParameters) => - TestBed.runInInjectionContext(() => listResolver(...resolverParameters)); - - beforeEach(() => { - TestBed.configureTestingModule({}); - }); - - it("should be created", () => { - expect(executeResolver).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/courses/shared/circle-progress-bar/circle-progress-bar.component.html b/projects/social_platform/src/app/office/courses/shared/circle-progress-bar/circle-progress-bar.component.html deleted file mode 100644 index 3264b1a61..000000000 --- a/projects/social_platform/src/app/office/courses/shared/circle-progress-bar/circle-progress-bar.component.html +++ /dev/null @@ -1,37 +0,0 @@ - - -
- @if (mode === 'progress') { -

{{ progress }}%

- } @else { @if (appereance === 'open') { - - } @else { - - } } -
- - - - -
- - @if (haveDate) { -

c 16.03.26

- } -
diff --git a/projects/social_platform/src/app/office/courses/shared/circle-progress-bar/circle-progress-bar.component.ts b/projects/social_platform/src/app/office/courses/shared/circle-progress-bar/circle-progress-bar.component.ts deleted file mode 100644 index 4b57daa79..000000000 --- a/projects/social_platform/src/app/office/courses/shared/circle-progress-bar/circle-progress-bar.component.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** @format */ - -import { Component, Input } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { IconComponent } from "@ui/components"; - -/** - * Компонент круглого прогресс-бара - * - * Отображает прогресс в виде круглой диаграммы с использованием SVG. - * Прогресс отображается как заполненная дуга от 0 до 100%. - * - * @example - * - */ -@Component({ - selector: "app-circle-progress-bar", - standalone: true, - imports: [CommonModule, IconComponent], - templateUrl: "./circle-progress-bar.component.html", - styleUrl: "./circle-progress-bar.component.scss", -}) -export class CircleProgressBarComponent { - /** - * Значение прогресса в процентах - * Принимает значения от 0 до 100 - * @default 0 - */ - @Input() progress = 0; - - @Input() mode: "button" | "progress" = "progress"; - - @Input() appereance?: "open" | "closed"; - - @Input() haveDate?: boolean = false; - - /** - * Радиус круга прогресс-бара в пикселях - * Используется для расчета окружности и отступов - * @default 70 - */ - radius = 70; - - /** - * Вычисляет смещение штриха для отображения прогресса - * - * Рассчитывает значение stroke-dashoffset для SVG элемента, - * которое определяет какая часть окружности будет заполнена. - * - * @returns {number} Значение смещения штриха в пикселях - */ - calculateStrokeDashOffset(): number { - const circumference = 2 * Math.PI * this.radius; // Длина окружности: 2 * π * радиус - return circumference - (this.progress / 100) * circumference; - } - - /** - * Вычисляет массив штрихов для SVG окружности - * - * Возвращает полную длину окружности, которая используется - * в качестве значения stroke-dasharray для создания пунктирной линии. - * - * @returns {number} Длина окружности в пикселях - */ - calculateStrokeDashArray(): number { - return 2 * Math.PI * this.radius; // Полная длина окружности: 2 * π * радиус - } -} diff --git a/projects/social_platform/src/app/office/courses/shared/course-about/course-about.component.html b/projects/social_platform/src/app/office/courses/shared/course-about/course-about.component.html deleted file mode 100644 index 2ec404736..000000000 --- a/projects/social_platform/src/app/office/courses/shared/course-about/course-about.component.html +++ /dev/null @@ -1,18 +0,0 @@ - - -
-
-

о курсе

- -
- @if (description) { -
-

- @if (descriptionExpandable) { -
- {{ readFullDescription ? "cкрыть" : "подробнее" }} -
- } -
- } -
diff --git a/projects/social_platform/src/app/office/courses/shared/course-about/course-about.component.ts b/projects/social_platform/src/app/office/courses/shared/course-about/course-about.component.ts deleted file mode 100644 index 685fa33d9..000000000 --- a/projects/social_platform/src/app/office/courses/shared/course-about/course-about.component.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** @format */ - -import { - AfterViewInit, - ChangeDetectorRef, - Component, - ElementRef, - inject, - Input, - ViewChild, -} from "@angular/core"; -import { IconComponent } from "@uilib"; -import { ParseBreaksPipe, ParseLinksPipe } from "@corelib"; -import { expandElement } from "@utils/expand-element"; - -@Component({ - selector: "app-course-about", - templateUrl: "./course-about.component.html", - styleUrl: "./course-about.component.scss", - standalone: true, - imports: [IconComponent, ParseBreaksPipe, ParseLinksPipe], -}) -export class CourseAboutComponent implements AfterViewInit { - @Input({ required: true }) description!: string; - @ViewChild("descEl") descEl?: ElementRef; - - private readonly cdRef = inject(ChangeDetectorRef); - - descriptionExpandable = false; - readFullDescription = false; - - ngAfterViewInit(): void { - const el = this.descEl?.nativeElement; - this.descriptionExpandable = el?.clientHeight < el?.scrollHeight; - this.cdRef.detectChanges(); - } - - onExpandDescription(elem: HTMLElement, expandedClass: string, isExpanded: boolean): void { - expandElement(elem, expandedClass, isExpanded); - this.readFullDescription = !isExpanded; - } -} diff --git a/projects/social_platform/src/app/office/courses/shared/course-module-card/course-module-card.component.html b/projects/social_platform/src/app/office/courses/shared/course-module-card/course-module-card.component.html deleted file mode 100644 index 77f899455..000000000 --- a/projects/social_platform/src/app/office/courses/shared/course-module-card/course-module-card.component.html +++ /dev/null @@ -1,71 +0,0 @@ - - -
-
-
- - -
-

Модуль {{ courseModule.order }}

- -

- {{ courseModule.title }} -

- -
-

- {{ courseModule.lessons.length }} - {{ courseModule.lessons.length | pluralize: ["урок", "урока", "уроков"] }} -

-
-
-
- - -
- - @if (courseModule.lessons.length) { -
- @if (isExpanded) { - - } @else { - - } -
- } -
- - diff --git a/projects/social_platform/src/app/office/courses/shared/course-module-card/course-module-card.component.spec.ts b/projects/social_platform/src/app/office/courses/shared/course-module-card/course-module-card.component.spec.ts deleted file mode 100644 index d43aca54f..000000000 --- a/projects/social_platform/src/app/office/courses/shared/course-module-card/course-module-card.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { SkillCardComponent } from "./course-module-card.component"; - -describe("SkillCardComponent", () => { - let component: SkillCardComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [SkillCardComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(SkillCardComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/courses/shared/course-module-card/course-module-card.component.ts b/projects/social_platform/src/app/office/courses/shared/course-module-card/course-module-card.component.ts deleted file mode 100644 index 0f7b6f6f4..000000000 --- a/projects/social_platform/src/app/office/courses/shared/course-module-card/course-module-card.component.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** @format */ - -import { Component, Input } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { AvatarComponent } from "@uilib"; -import { PluralizePipe } from "@corelib"; -import { IconComponent } from "@ui/components"; -import { CircleProgressBarComponent } from "../circle-progress-bar/circle-progress-bar.component"; -import { CourseDetail, CourseModule } from "@office/models/courses.model"; -import { RouterLink } from "@angular/router"; - -/** - * Компонент карточки навыка - * - * Отображает краткую информацию о навыке в виде карточки - * - * @Input skill - Объект с данными навыка (обязательный) - * @Input type - Тип отображения карточки: 'personal' | 'base' (по умолчанию 'base') - * - * Функциональность: - * - Отображение основной информации о навыке - * - Поддержка двух визуальных стилей - * - Индикация статуса навыка (подписка, просрочка, выполнение) - * - Плюрализация для количества уровней - * - Раскрывающийся список тем и действий - */ -@Component({ - selector: "app-course-module-card", - standalone: true, - imports: [ - CommonModule, - CircleProgressBarComponent, - IconComponent, - RouterLink, - PluralizePipe, - AvatarComponent, - ], - templateUrl: "./course-module-card.component.html", - styleUrl: "./course-module-card.component.scss", -}) -export class CourseModuleCardComponent { - @Input({ required: true }) courseModule!: CourseModule; - @Input() type: "personal" | "base" = "base"; - - isExpanded = false; - - toggleExpand(event: Event): void { - if (this.courseModule.lessons.length) { - event.stopPropagation(); - this.isExpanded = !this.isExpanded; - } - } -} diff --git a/projects/social_platform/src/app/office/courses/shared/course/course.component.html b/projects/social_platform/src/app/office/courses/shared/course/course.component.html deleted file mode 100644 index d596d0c0a..000000000 --- a/projects/social_platform/src/app/office/courses/shared/course/course.component.html +++ /dev/null @@ -1,42 +0,0 @@ - - -@if(course) { -
-
- - - {{ - isMember() - ? "для участников программы" - : isSubs() - ? "доступно по подписке" - : "доступно всем пользователям" - }} - - - course-cover -
- -
-

{{ course.title | truncate: 50 }}

-

- {{ course.dateLabel }} -

-
- -
- @if (isLock()) { - - } @else { -

- {{ course.progressStatus === "not_started" ? "начать" : "продолжить обучение" }} -

- - } -
-
-} diff --git a/projects/social_platform/src/app/office/features/approve-skill/approve-skill.component.html b/projects/social_platform/src/app/office/features/approve-skill/approve-skill.component.html deleted file mode 100644 index a2ed58313..000000000 --- a/projects/social_platform/src/app/office/features/approve-skill/approve-skill.component.html +++ /dev/null @@ -1,31 +0,0 @@ - - -
- {{ skill.name }} -
- @if (skill.approves.length > 0) { - - } - - {{ isUserApproveSkill(skill, loggedUserId!) ? "убрать оценку" : "подтвердить" }} -
-
- - - - diff --git a/projects/social_platform/src/app/office/features/approve-skill/approve-skill.component.ts b/projects/social_platform/src/app/office/features/approve-skill/approve-skill.component.ts deleted file mode 100644 index aad91d08e..000000000 --- a/projects/social_platform/src/app/office/features/approve-skill/approve-skill.component.ts +++ /dev/null @@ -1,118 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { ChangeDetectorRef, Component, inject, Input, OnDestroy, OnInit } from "@angular/core"; -import { Skill } from "@office/models/skill.model"; -import { ButtonComponent } from "@ui/components"; -import { map, of, Subscription, switchMap } from "rxjs"; -import { AuthService } from "@auth/services"; -import { ActivatedRoute } from "@angular/router"; -import { ProfileService as profileApproveSkillService } from "@auth/services/profile.service"; -import { SnackbarService } from "@ui/services/snackbar.service"; -import { HttpErrorResponse } from "@angular/common/http"; -import { ModalComponent } from "@ui/components/modal/modal.component"; -import { ApproveSkillPeopleComponent } from "@office/shared/approve-skill-people/approve-skill-people.component"; - -/** - * @params skill - информация о навыке (обязательно) - * - * Компонент на основе полученных данных о навыке - * выполняет логику подтверждения навыка - * с помощью сервисов связанных с навыками пользователя - */ -@Component({ - selector: "app-approve-skill", - styleUrl: "./approve-skill.component.scss", - templateUrl: "./approve-skill.component.html", - standalone: true, - imports: [CommonModule, ButtonComponent, ModalComponent, ApproveSkillPeopleComponent], -}) -export class ApproveSkillComponent implements OnInit, OnDestroy { - private readonly authService = inject(AuthService); - private readonly route = inject(ActivatedRoute); - private readonly profileApproveSkillService = inject(profileApproveSkillService); - private readonly snackbarService = inject(SnackbarService); - private readonly cdRef = inject(ChangeDetectorRef); - - // Указатель на то что пользватель подтвердил навык - isUserApproveSkill(skill: Skill, profileId: number): boolean { - return skill.approves.some(approve => approve.confirmedBy.id === profileId); - } - - // id пользователя за которого мы зарегистрировались - loggedUserId?: number; - - // переменные для работы с модальным окном для вывода ошибки с подтверждением своего навыка - approveOwnSkillModal = false; - - subscriptions: Subscription[] = []; - - // Получение данных о конкретном навыке - @Input({ required: true }) skill!: Skill; - - ngOnInit(): void { - const profileIdDataSub$ = this.authService.profile.pipe().subscribe({ - next: profile => { - this.loggedUserId = profile.id; - }, - }); - - this.subscriptions.push(profileIdDataSub$); - } - - ngOnDestroy(): void { - this.subscriptions.forEach($ => $.unsubscribe()); - } - - /** - * Подтверждение или отмена подтверждения навыка пользователя - * @param skillId - идентификатор навыка - * @param event - событие клика для предотвращения всплытия - * @param skill - объект навыка для обновления - */ - onToggleApprove(skillId: number, event: Event, skill: Skill, profileId: number) { - event.stopPropagation(); - const userId = this.route.snapshot.params["id"]; - - const isApprovedByCurrentUser = skill.approves.some(approve => { - return approve.confirmedBy.id === profileId; - }); - - if (isApprovedByCurrentUser) { - this.profileApproveSkillService.unApproveSkill(userId, skillId).subscribe(() => { - skill.approves = skill.approves.filter(approve => approve.confirmedBy.id !== profileId); - this.cdRef.markForCheck(); - }); - } else { - this.profileApproveSkillService - .approveSkill(userId, skillId) - .pipe( - switchMap(newApprove => - newApprove.confirmedBy - ? of(newApprove) - : this.authService.profile.pipe( - map(profile => ({ - ...newApprove, - confirmedBy: profile, - })) - ) - ) - ) - .subscribe({ - next: updatedApprove => { - skill.approves = [...skill.approves, updatedApprove]; - this.snackbarService.success("вы подтвердили навык"); - this.cdRef.markForCheck(); - }, - error: err => { - if (err instanceof HttpErrorResponse) { - if (err.status === 400) { - this.approveOwnSkillModal = true; - this.cdRef.markForCheck(); - } - } - }, - }); - } - } -} diff --git a/projects/social_platform/src/app/office/features/chat-window/chat-window.component.html b/projects/social_platform/src/app/office/features/chat-window/chat-window.component.html deleted file mode 100644 index 733a30c2a..000000000 --- a/projects/social_platform/src/app/office/features/chat-window/chat-window.component.html +++ /dev/null @@ -1,44 +0,0 @@ - - -
- - - - -
- @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 }} - {{ typingPersons.length - 3 | pluralize: ["человек", "человека", "человек"] }} - } - {{ typingPersons.length | pluralize: ["печатает", "печатают", "печатают"] }} - ... - - } - -
-
diff --git a/projects/social_platform/src/app/office/features/chat-window/chat-window.component.spec.ts b/projects/social_platform/src/app/office/features/chat-window/chat-window.component.spec.ts deleted file mode 100644 index dcaa02f7a..000000000 --- a/projects/social_platform/src/app/office/features/chat-window/chat-window.component.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { ChatWindowComponent } from "./chat-window.component"; -import { ReactiveFormsModule } from "@angular/forms"; -import { of } from "rxjs"; -import { AuthService } from "@auth/services"; -import { RouterTestingModule } from "@angular/router/testing"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; -import { NgxMaskModule } from "ngx-mask"; - -describe("ChatWindowComponent", () => { - let component: ChatWindowComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - const authSpy = { - profile: of({}), - }; - - await TestBed.configureTestingModule({ - imports: [ - ReactiveFormsModule, - RouterTestingModule, - HttpClientTestingModule, - NgxMaskModule.forRoot(), - ChatWindowComponent, - ], - providers: [{ provide: AuthService, useValue: authSpy }], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(ChatWindowComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/features/chat-window/chat-window.component.ts b/projects/social_platform/src/app/office/features/chat-window/chat-window.component.ts deleted file mode 100644 index bef4f4a28..000000000 --- a/projects/social_platform/src/app/office/features/chat-window/chat-window.component.ts +++ /dev/null @@ -1,355 +0,0 @@ -/** @format */ - -import { - AfterViewInit, - Component, - ElementRef, - EventEmitter, - Input, - OnDestroy, - OnInit, - Output, - ViewChild, -} from "@angular/core"; -import { - CdkFixedSizeVirtualScroll, - CdkVirtualForOf, - CdkVirtualScrollViewport, -} from "@angular/cdk/scrolling"; -import { ChatMessage } from "@models/chat-message.model"; -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"; -import { AuthService } from "@auth/services"; -import { User } from "@auth/models/user.model"; -import { PluralizePipe } from "projects/core"; -import { ChatMessageComponent } from "@ui/components/chat-message/chat-message.component"; - -/** - * Компонент окна чата - * Отображает список сообщений с виртуальной прокруткой и поле ввода нового сообщения - * Поддерживает редактирование, ответы на сообщения, отметки о прочтении и индикацию печатания - */ -@Component({ - selector: "app-chat-window", - templateUrl: "./chat-window.component.html", - styleUrl: "./chat-window.component.scss", - standalone: true, - imports: [ - CdkVirtualScrollViewport, - CdkFixedSizeVirtualScroll, - CdkVirtualForOf, - ChatMessageComponent, - ReactiveFormsModule, - MessageInputComponent, - PluralizePipe, - ], -}) -export class ChatWindowComponent implements OnInit, AfterViewInit, OnDestroy { - /** - * Конструктор компонента - * @param fb - FormBuilder для создания формы сообщения - * @param modalService - сервис для отображения модальных окон - * @param authService - сервис аутентификации для получения данных пользователя - */ - constructor( - private readonly fb: FormBuilder, - private readonly modalService: ModalService, - private readonly authService: AuthService - ) { - // Создание формы для ввода сообщения - this.messageForm = this.fb.group({ - messageControl: [{ text: "", filesUrl: [] }], // Контрол с текстом и файлами - }); - } - - /** Приватное поле для хранения сообщений */ - private _messages: ChatMessage[] = []; - - /** - * Сеттер для списка сообщений чата - * Обновляет список сообщений и автоматически прокручивает к низу при новых сообщениях - * Настраивает наблюдатель для отметок о прочтении - * @param value - массив сообщений чата - */ - @Input({ required: true }) set messages(value: ChatMessage[]) { - const messagesIds = this._messages.map(m => m.id); - // Находим новые сообщения - const diff = value.filter(m => { - return messagesIds.indexOf(m.id) < 0; - }); - - const noMessages = !this._messages.length; - this._messages = value; - - // Автопрокрутка к низу для новых сообщений от текущего пользователя или при первой загрузке - if ((diff.length === 1 && diff[0]?.author.id === this.profile?.id) || noMessages) { - this.scrollToBottom(); - } - - // Настройка наблюдателя для отметок о прочтении - setTimeout(() => { - const elementNode = document.querySelectorAll(".chat__message"); - elementNode.forEach(el => { - this.observer?.observe(el); - }); - }); - } - - /** - * Геттер для получения списка сообщений - * @returns массив сообщений чата - */ - get messages(): ChatMessage[] { - return this._messages; - } - - /** - * Список пользователей, которые сейчас печатают - */ - @Input() - typingPersons: { firstName: string; lastName: string; userId: number }[] = []; - - // События компонента - /** Событие отправки нового сообщения */ - @Output() submit = new EventEmitter(); - /** Событие редактирования сообщения */ - @Output() edit = new EventEmitter(); - /** Событие удаления сообщения */ - @Output() delete = new EventEmitter(); - /** Событие начала печатания */ - @Output() type = new EventEmitter(); - /** Событие запроса дополнительных сообщений */ - @Output() fetch = new EventEmitter(); - /** Событие прочтения сообщения */ - @Output() read = new EventEmitter(); - - /** - * Инициализация компонента - * Подписывается на профиль пользователя и настраивает отслеживание печатания - */ - ngOnInit(): void { - // Подписка на профиль текущего пользователя - const profile$ = this.authService.profile.subscribe(p => { - this.profile = p; - }); - this.subscriptions$.push(profile$); - - // Инициализация отслеживания печатания - this.initTypingSend(); - } - - /** - * Инициализация после отрисовки представления - * Настраивает обработчик прокрутки для загрузки истории сообщений - * Создает наблюдатель для отметок о прочтении - */ - ngAfterViewInit(): void { - if (this.viewport) { - // Подписка на события прокрутки для загрузки истории - const viewPortScroll$ = fromEvent(this.viewport?.elementRef.nativeElement, "scroll") - .pipe( - skip(1), // Пропуск первого события прокрутки - filter(() => { - const offsetTop = this.viewport?.measureScrollOffset("top"); - return offsetTop ? offsetTop <= 200 : false; // Загрузка при приближении к верху - }) - ) - .subscribe(() => { - this.fetch.emit(); // Запрос дополнительных сообщений - }); - - viewPortScroll$ && this.subscriptions$.push(viewPortScroll$); - - // Создание наблюдателя пересечений для отметок о прочтении - this.observer = new IntersectionObserver(this.onReadMessage.bind(this), { - root: this.viewport.elementRef.nativeElement, - rootMargin: "0px 0px 0px 0px", - threshold: 0, - }); - } - } - - /** - * Очистка ресурсов при уничтожении компонента - */ - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $.unsubscribe()); - } - - /** Наблюдатель пересечений для отметок о прочтении */ - observer?: IntersectionObserver; - /** Профиль текущего пользователя */ - profile?: User; - - /** - * Базовое значение контрола сообщения - */ - private readonly messageControlBaseValue = { - text: "", - filesUrl: [], - }; - - /** Форма для ввода сообщения */ - messageForm: FormGroup; - /** Сообщение, которое редактируется */ - editingMessage?: ChatMessage; - /** Сообщение, на которое отвечаем */ - replyMessage?: ChatMessage; - - /** - * Элемент виртуальной прокрутки для оптимизации отображения большого количества сообщений - */ - @ViewChild(CdkVirtualScrollViewport) viewport?: CdkVirtualScrollViewport; - - /** - * Компонент ввода сообщения - */ - @ViewChild(MessageInputComponent, { read: ElementRef }) messageInputComponent?: ElementRef; - - /** Массив подписок для очистки */ - subscriptions$: Subscription[] = []; - - /** - * Инициализация отслеживания печатания - * Отправляет событие печатания при изменении текста с задержкой - */ - private initTypingSend(): void { - const messageControlSub$ = this.messageForm - .get("messageControl") - ?.valueChanges.pipe( - throttleTime(2000), // Ограничение частоты отправки событий - tap(() => { - this.type.emit(); // Отправка события печатания - }) - ) - .subscribe(noop); - - messageControlSub$ && this.subscriptions$.push(messageControlSub$); - } - - /** - * Прокрутка к низу списка сообщений - * Использует двойной setTimeout для корректной работы с виртуальной прокруткой - */ - private scrollToBottom(): void { - // Sadly but it's work only this way - // It seems that when first scrollTo works - // It didn't render all elements so bottom 0 is not actual bottom of all comments - setTimeout(() => { - this.viewport?.scrollTo({ bottom: 0 }); - - setTimeout(() => { - this.viewport?.scrollTo({ bottom: 0 }); - }, 50); - }); - } - - /** - * Обработчик изменения размера поля ввода - * Автоматически прокручивает к низу если пользователь находится внизу чата - */ - onInputResize(): void { - if (this.viewport && this.viewport.measureScrollOffset("bottom") < 50) this.scrollToBottom(); - } - - /** - * Установка фокуса на поле ввода - */ - private focusOnInput(): void { - setTimeout(() => { - this.messageInputComponent?.nativeElement.querySelector("textarea").focus(); - }); - } - - /** - * Обработчик начала редактирования сообщения - * @param messageId - идентификатор редактируемого сообщения - */ - onEditMessage(messageId: number): void { - this.replyMessage = undefined; - this.editingMessage = this.messages.find(message => message.id === messageId); - - this.focusOnInput(); - } - - /** - * Обработчик ответа на сообщение - * @param messageId - идентификатор сообщения для ответа - */ - onReplyMessage(messageId: number): void { - this.editingMessage = undefined; - this.replyMessage = this.messages.find(message => message.id === messageId); - - this.focusOnInput(); - } - - /** - * Отмена редактирования или ответа - */ - onCancelInput(): void { - this.replyMessage = undefined; - this.editingMessage = undefined; - } - - /** - * Обработчик отправки сообщения - * Различает между редактированием существующего сообщения и отправкой нового - */ - onSubmitMessage() { - if (!this.messageForm.get("messageControl")?.value.text) return; - - if (this.editingMessage) { - // Редактирование существующего сообщения - this.edit.emit({ - text: this.messageForm.get("messageControl")?.value.text, - id: this.editingMessage.id, - }); - - this.editingMessage = undefined; - } else { - // Отправка нового сообщения - this.submit.emit({ - replyTo: this.replyMessage?.id ?? null, - text: this.messageForm.get("messageControl")?.value.text ?? "", - fileUrls: this.messageForm.get("messageControl")?.value.filesUrl ?? [], - }); - - this.replyMessage = undefined; - } - - // Очистка формы - this.messageForm.get("messageControl")?.setValue(this.messageControlBaseValue); - } - - /** - * Обработчик удаления сообщения - * Показывает модальное окно подтверждения перед удалением - * @param messageId - идентификатор удаляемого сообщения - */ - onDeleteMessage(messageId: number): void { - const deletedMessage = this.messages.find(message => message.id === messageId); - - this.modalService - .confirmDelete("Вы уверены что хотите удалить сообщение?", `"${deletedMessage?.text}"`) - .pipe(filter(Boolean)) - .subscribe(() => { - this.delete.emit(messageId); - }); - } - - /** - * Обработчик отметки сообщений как прочитанных - * Вызывается наблюдателем пересечений когда сообщение становится видимым - * @param entries - массив элементов, пересекающихся с областью видимости - */ - onReadMessage(entries: IntersectionObserverEntry[]): void { - entries.forEach(e => { - const element = e.target as HTMLElement; - // Отмечаем как прочитанное только непрочитанные сообщения - !Number.parseInt(element.dataset["beenRead"] || "1") && - this.read.emit(Number.parseInt(element.id)); - }); - } -} 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 deleted file mode 100644 index 0b903f04b..000000000 --- a/projects/social_platform/src/app/office/features/detail/detail.component.html +++ /dev/null @@ -1,825 +0,0 @@ - - -
-
- @if (info()) { -
-
- @if (appWidth > 920) { - 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 ((isProjectsPage || isProjectsRatingPage) && appWidth - < 920) { - - - назад - - - - - - - - - - - } @else { -
- @if (!isUserMember && !isUserManager) { @if (info().name.includes("Кейс-чемпионат MIR")) - { - - - зарегистрироваться - - - } @else if (info().registrationLink) { - - - зарегистрироваться - - - } @else { - - - зарегистрироваться - - - } } @if (isUserMember && !isUserManager && !isUserExpert) { - - {{ isProjectAssigned ? "вы подали проект" : "создать заявку" }} - - - } @if ((isUserManager || isUserExpert) && isUserMember) { - - - оценка проектов - - - } -
- - - - @if (appWidth > 920) { - {{ - info().name.includes("Технолидеры Будущего") - ? "Каталог лучших стартапов 2022/23" - : isUserManager - ? "аналитика" - : "положение" - }} - } @if (isUserManager && appWidth > 920) { - - } @else { - - } - - - - @if (appWidth > 920) { -
- } @if (appWidth < 920) { - - - - } @if (isUserManager || (isUserExpert && appWidth > 920)) { - - - проекты-участники - - - } @if (!isUserManager && !isUserExpert && appWidth > 920) { - - - узнать подробнее - - - } @if (isUserManager || (isUserExpert && appWidth > 920)) { - - - участники - - - } @if (appWidth < 920) { - - материалы - - } @if (!isUserManager && !isUserExpert) { - - {{ appWidth > 920 ? "перейти в курс" : "курс" }} - - } - - -
-
- -

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

-
-

{{ assignProjectToProgramModalMessage() }}.

-
-
- - -
-
-

к сожалению, программа уже завершена!

-
- - -
-
- - -
-
-

к сожалению, подача проекта уже завершена!

-
- - -
-
- - - - - - - - - } } -
- - - @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 (!showNoProjectsModal && showSendInviteModal) { - -
- @if (profileProjects().length) { -
    -
    -

    выберите проект и роль для приглашения.

    - -
    - - @for (project of profileProjects(); track project.id) { -
  • -
    - -

    - {{ project.name ?? "Проект без названия" | truncate: 20 }} -

    -
    - -
  • - } @if (inviteForm.get("role"); as role) { -
    - - @if ((role | controlError: "required")) { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    - } -
    - } - - - отправить приглашение - - -
- } -
-
- } - - -
-
-

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

-
- - - - перейти в проекты -
-
- - -
-
-

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

-
- - - - перейти в проекты -
-
- - -
-
-

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

-
- - - - перейти в проекты -
-
- - -
- - -

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

- - перейти в проекты -
-
- - } @if (profile) { @if (profile.id === info().id) { - -
- profile unfill image -
- -

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

-
-

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

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

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

-
-

- Скачивание будет доступно через несколько секунд. -

-
-
-
- - -
-
- } -
-
diff --git a/projects/social_platform/src/app/office/features/detail/detail.component.ts b/projects/social_platform/src/app/office/features/detail/detail.component.ts deleted file mode 100644 index cb9aa73cb..000000000 --- a/projects/social_platform/src/app/office/features/detail/detail.component.ts +++ /dev/null @@ -1,599 +0,0 @@ -/** @format */ - -import { CommonModule, Location } from "@angular/common"; -import { - ChangeDetectorRef, - Component, - HostListener, - inject, - OnDestroy, - OnInit, - signal, -} from "@angular/core"; -import { ButtonComponent, InputComponent } from "@ui/components"; -import { IconComponent } from "@uilib"; -import { ModalComponent } from "@ui/components/modal/modal.component"; -import { ActivatedRoute, Router, RouterModule } from "@angular/router"; -import { AuthService } from "@auth/services"; -import { AvatarComponent } from "@ui/components/avatar/avatar.component"; -import { TooltipComponent } from "@ui/components/tooltip/tooltip.component"; -import { concatMap, filter, map, Subscription, tap } from "rxjs"; -import { User } from "@auth/models/user.model"; -import { Collaborator } from "@office/models/collaborator.model"; -import { ProjectService } from "@office/services/project.service"; -import { Project } from "@office/models/project.model"; -import { ProjectAdditionalService } from "@office/projects/edit/services/project-additional.service"; -import { ProjectDataService } from "@office/projects/detail/services/project-data.service"; -import { ProgramDataService } from "@office/program/services/program-data.service"; -import { ChatService } from "@office/services/chat.service"; -import { calculateProfileProgress } from "@utils/calculateProgress"; -import { ProfileDataService } from "@office/profile/detail/services/profile-date.service"; -import { SnackbarService } from "@ui/services/snackbar.service"; -import { ApproveSkillComponent } from "../approve-skill/approve-skill.component"; -import { ProgramService } from "@office/program/services/program.service"; -import { ProjectFormService } from "@office/projects/edit/services/project-form.service"; -import { - PartnerProgramFields, - projectNewAdditionalProgramVields, -} from "@office/models/partner-program-fields.model"; -import { saveFile } from "@utils/helpers/export-file"; -import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; -import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; -import { ControlErrorPipe, ValidationService } from "@corelib"; -import { ErrorMessage } from "@error/models/error-message"; -import { InviteService } from "@office/services/invite.service"; -import { ApiPagination } from "@office/models/api-pagination.model"; -import { ProgramLinksComponent } from "../program-links/program-links.component"; - -@Component({ - selector: "app-detail", - templateUrl: "./detail.component.html", - styleUrl: "./detail.component.scss", - imports: [ - CommonModule, - RouterModule, - ReactiveFormsModule, - IconComponent, - ButtonComponent, - ModalComponent, - AvatarComponent, - TooltipComponent, - ApproveSkillComponent, - InputComponent, - TruncatePipe, - ControlErrorPipe, - ProgramLinksComponent, - ], - standalone: true, -}) -export class DeatilComponent implements OnInit, OnDestroy { - private readonly authService = inject(AuthService); - private readonly fb = inject(FormBuilder); - private readonly route = inject(ActivatedRoute); - private readonly projectService = inject(ProjectService); - private readonly programDataService = inject(ProgramDataService); - private readonly projectDataService = inject(ProjectDataService); - private readonly projectAdditionalService = inject(ProjectAdditionalService); - private readonly snackbarService = inject(SnackbarService); - protected readonly router = inject(Router); - private readonly location = inject(Location); - private readonly profileDataService = inject(ProfileDataService); - public readonly chatService = inject(ChatService); - private readonly cdRef = inject(ChangeDetectorRef); - private readonly programService = inject(ProgramService); - private readonly inviteService = inject(InviteService); - private readonly validationService = inject(ValidationService); - private readonly projectFormService = inject(ProjectFormService); - - // Основные данные(типы данных, данные) - info = signal(undefined); - profile?: User; - profileProjects = signal([]); - listType: "project" | "program" | "profile" = "project"; - - appWidth = window.innerWidth; - - @HostListener("window:resize") - onResize() { - this.appWidth = window.innerWidth; - } - - // Переменная для подсказок - isTooltipVisible = false; - - tooltipText = "Заполни до конца — и открой весь функционал платформы!"; - - // Переменные для отображения данных в зависимости от url - isProjectsPage = false; - isMembersPage = false; - isProjectsRatingPage = false; - - isTeamPage = false; - isVacanciesPage = false; - isProjectChatPage = false; - - // Сторонние переменные для работы с роутингом или доп проверок - backPath?: string; - registerDateExpired?: boolean; - submissionProjectDateExpired?: boolean; - isInProject?: boolean; - - isSended = false; - isSubscriptionActive = signal(false); - isProfileFill = false; - - // Переменные для работы с модалкой подачи проекта - selectedProjectId: number | null = null; - memberProjects: Project[] = []; - - userType = signal(undefined); - - // Сигналы для работы с модальными окнами с текстом - // assignProjectToProgramModalMessage = signal(null); - errorMessageModal = signal(""); - - additionalFields = signal([]); - - // Переменные для работы с модалками - isAssignProjectToProgramModalOpen = signal(false); - showSubmitProjectModal = signal(false); - isProgramEndedModalOpen = signal(false); - isProgramSubmissionProjectsEndedModalOpen = signal(false); - isLeaveProjectModalOpen = false; // Флаг модального окна выхода - isEditDisable = false; // Флаг недоступности редактирования - isEditDisableModal = false; // Флаг недоступности редактирования для модалки - openSupport = false; // Флаг модального окна поддержки - leaderLeaveModal = false; // Флаг модального окна предупреждения лидера - isDelayModalOpen = false; - isContactsModalOpen = false; - isMaterialsModalOpen = false; - - get contactLinks(): { label: string; url: string }[] { - return (this.info()?.links ?? []).map((link: string) => ({ label: link, url: link })); - } - - get materialLinks(): { label: string; url: string }[] { - return (this.info()?.materials ?? []).map((m: { title: string; url: string }) => ({ - label: m.title, - url: m.url, - })); - } - - // Переменные для работы с подтверждением навыков - showApproveSkillModal = false; - showSendInviteModal = false; - showNoProjectsModal = false; - showActiveInviteModal = false; - showNoInProgramModal = false; - showSuccessInviteModal = false; - readAllModal = false; - - // Сигналы для работы с модальными окнами с текстом - assignProjectToProgramModalMessage = signal(null); - - subscriptions: Subscription[] = []; - - get projectForm() { - return this.projectFormService.formModel; - } - - readonly inviteForm = this.fb.group({ - role: ["", Validators.required], - }); - - protected readonly errorMessage = ErrorMessage; - - ngOnInit(): void { - const listTypeSub$ = this.route.data.subscribe(data => { - this.listType = data["listType"]; - }); - - this.initializeBackPath(); - - this.updatePageStates(); - this.location.onUrlChange(url => { - this.updatePageStates(url); - }); - - this.initializeInfo(); - - this.subscriptions.push(listTypeSub$); - } - - ngOnDestroy(): void { - this.subscriptions.forEach($ => $.unsubscribe()); - } - - // Геттеры для работы с отображением данных разного типа доступа - get isUserManager() { - if (this.listType === "program") { - return this.info().isUserManager; - } - } - - get isUserMember() { - if (this.listType === "program") { - return this.info().isUserMember; - } - } - - get isUserExpert() { - const type = this.userType(); - return type !== undefined && type === 3; - } - - get isProjectAssigned() { - const programId = this.info()?.id; - - return this.memberProjects.some( - project => project.leader === this.profile?.id && project.partnerProgram?.id === programId - ); - } - - // Методы для управления состоянием ошибок через сервис - setAssignProjectToProgramError(error: { non_field_errors: string[] }): void { - this.projectAdditionalService.setAssignProjectToProgramError(error); - } - - /** Показать подсказку */ - showTooltip(): void { - this.isTooltipVisible = true; - } - - /** Скрыть подсказку */ - hideTooltip(): void { - this.isTooltipVisible = false; - } - - /** - * Обработчик изменения радио-кнопки для выбора проекта - */ - onProjectRadioChange(event: Event): void { - const target = event.target as HTMLInputElement; - this.selectedProjectId = +target.value; - - if (this.selectedProjectId) { - this.memberProjects.find(project => project.id === this.selectedProjectId); - } - } - - addNewProject(): void { - const newFieldsFormValues: projectNewAdditionalProgramVields[] = []; - - this.additionalFields().forEach((field: PartnerProgramFields) => { - newFieldsFormValues.push({ - field_id: field.id, - value_text: field.options.length ? field.options[0] : "'", - }); - }); - - const body = { project: this.projectForm.value, program_field_values: newFieldsFormValues }; - - this.programService.applyProjectToProgram(this.info().id, body).subscribe({ - next: r => { - this.router - .navigate([`/office/projects/${r.projectId}/edit`], { - queryParams: { editingStep: "main", fromProgram: true }, - }) - .then(() => console.debug("Route change from ProjectsComponent")); - }, - error: err => { - if (err) { - if (err.status === 400) { - this.isAssignProjectToProgramModalOpen.set(true); - this.assignProjectToProgramModalMessage.set(err.error.detail); - } - } - }, - }); - } - - /** - * Закрытие модального окна выхода из проекта - */ - onCloseLeaveProjectModal(): void { - this.isLeaveProjectModalOpen = false; - } - - /** - * Закрытие модального окна для невозможности редактировать проект - */ - onUnableEditingProject(): void { - if (this.isEditDisable) { - this.isEditDisableModal = true; - } else { - this.isEditDisableModal = false; - } - } - - /** - * Выход из проекта - */ - onLeave() { - this.route.data - .pipe(map(r => r["data"][0])) - .pipe(concatMap(p => this.projectService.leave(p.id))) - .subscribe( - () => { - this.router - .navigateByUrl("/office/projects/my") - .then(() => console.debug("Route changed from ProjectInfoComponent")); - }, - () => { - this.leaderLeaveModal = true; // Показываем предупреждение для лидера - } - ); - } - - /** - * Копирование ссылки на профиль в буфер обмена - */ - onCopyLink(profileId: number): void { - let fullUrl = ""; - - // Формирование URL в зависимости от типа ресурса - fullUrl = `${location.origin}/office/profile/${profileId}/`; - - // Копирование в буфер обмена - navigator.clipboard.writeText(fullUrl).then(() => { - this.snackbarService.success("скопирован URL"); - }); - } - - openSkills: any = {}; - - /** - * Открытие модального окна с информацией о подтверждениях навыка - * @param skillId - идентификатор навыка - */ - onOpenSkill(skillId: number) { - this.openSkills[skillId] = !this.openSkills[skillId]; - } - - onCloseModal(skillId: number) { - this.openSkills[skillId] = false; - } - - /** - * Отправка CV пользователя на email - * Проверяет ограничения по времени и отправляет CV на почту пользователя - */ - downloadCV() { - this.isSended = true; - this.authService.downloadCV().subscribe({ - next: blob => { - saveFile(blob, "cv", this.profile?.firstName + " " + this.profile?.lastName); - this.isSended = false; - }, - error: err => { - this.isSended = false; - if (err.status === 400) { - this.isDelayModalOpen = true; - } - }, - }); - } - - /** - * Открывает модалку для отправки приглашения пользователю - * Проверяет какие отрендерить проекты где profile.id === leader - */ - inviteUser(): void { - if (!this.profileProjects().length) { - this.showNoProjectsModal = true; - } else { - this.showSendInviteModal = true; - } - } - - sendInvite(): void { - const role = this.inviteForm.get("role")?.value; - const userId = this.route.snapshot.params["id"]; - - if ( - !this.validationService.getFormValidation(this.inviteForm) || - this.selectedProjectId === null - ) { - return; - } - - this.inviteService.sendForUser(userId, this.selectedProjectId, role!).subscribe({ - next: () => { - this.showSendInviteModal = false; - this.showSuccessInviteModal = true; - - this.inviteForm.reset(); - this.selectedProjectId = null; - }, - error: err => { - if (err.error.user[0].includes("проект относится к программе")) { - this.showNoInProgramModal = true; - } else if (err.error.user[0].includes("активное приглашение")) { - this.showActiveInviteModal = true; - } - }, - }); - } - - /** - * Перенаправляет на страницу с информацией в завивисимости от listType - */ - redirectDetailInfo(): void { - switch (this.listType) { - case "profile": - this.router.navigateByUrl(`/office/profile/${this.info().id}`); - break; - - case "project": - this.router.navigateByUrl(`/office/projects/${this.info().id}`); - break; - - case "program": - this.router.navigateByUrl(`/office/program/${this.info().id}`); - break; - } - } - - routingToMyProjects(): void { - this.router.navigateByUrl(`/office/projects/my`); - } - - /** - * Проверка завершения программы перед регистрацией - */ - checkPrograRegistrationEnded(event: Event): void { - const program = this.info(); - - if ( - program?.datetimeRegistrationEnds && - Date.now() > Date.parse(program.datetimeRegistrationEnds) - ) { - event.preventDefault(); - event.stopPropagation(); - this.isProgramEndedModalOpen.set(true); - } else if ( - program?.datetimeProjectSubmissionEnds && - Date.now() > Date.parse(program?.datetimeProjectSubmissionEnds) - ) { - event.preventDefault(); - event.stopPropagation(); - this.isProgramSubmissionProjectsEndedModalOpen.set(true); - } else { - this.router.navigateByUrl("/office/program/" + this.info().id + "/register"); - } - } - - /** - * Обновляет состояния страниц на основе URL - */ - private updatePageStates(url?: string): void { - const currentUrl = url || this.router.url; - - this.isProjectsPage = - currentUrl.includes("/projects") && !currentUrl.includes("/projects-rating"); - - this.isMembersPage = currentUrl.includes("/members"); - - this.isProjectsRatingPage = currentUrl.includes("/projects-rating"); - - this.isTeamPage = currentUrl.includes("/team"); - this.isVacanciesPage = currentUrl.includes("/vacancies"); - this.isProjectChatPage = currentUrl.includes("/chat"); - } - - private initializeInfo() { - if (this.listType === "project") { - const projectSub$ = this.projectDataService.project$ - .pipe(filter(project => !!project)) - .subscribe(project => { - this.info.set(project); - - if (project?.partnerProgram) { - this.isEditDisable = project.partnerProgram?.isSubmitted; - } - }); - - this.isInProfileInfo(); - - this.subscriptions.push(projectSub$); - } else if (this.listType === "program") { - const program$ = this.programDataService.program$ - .pipe( - filter(program => !!program), - tap(program => { - if (program) { - this.info.set(program); - this.loadAdditionalFields(program.id); - this.registerDateExpired = Date.now() > Date.parse(program.datetimeRegistrationEnds); - this.submissionProjectDateExpired = - Date.now() > Date.parse(program.datetimeProjectSubmissionEnds); - } - }) - ) - .subscribe(); - - const profileDataSub$ = this.authService.profile.pipe(filter(user => !!user)).subscribe({ - next: user => { - this.userType.set(user!.userType); - this.profile = user; - this.cdRef.detectChanges(); - }, - }); - - const memeberProjects$ = this.projectService.getMy().subscribe({ - next: projects => { - this.memberProjects = projects.results.filter(project => !project.draft); - }, - }); - - this.subscriptions.push(program$); - this.subscriptions.push(memeberProjects$); - this.subscriptions.push(profileDataSub$); - } else { - const profileDataSub$ = this.profileDataService - .getProfile() - .pipe( - map(user => ({ ...user, progress: calculateProfileProgress(user!) })), - filter(user => !!user) - ) - .subscribe({ - next: user => { - this.info.set(user); - this.isProfileFill = - user.progress! < 100 ? (this.isProfileFill = true) : (this.isProfileFill = false); - }, - }); - - this.isInProfileInfo(); - - const profileLeaderProjectsSub$ = this.authService.getLeaderProjects().subscribe({ - next: (projects: ApiPagination) => { - this.profileProjects.set(projects.results); - }, - }); - - this.subscriptions.push(profileDataSub$, profileLeaderProjectsSub$); - } - } - - private isInProfileInfo(): void { - const profileInfoSub$ = this.authService.profile.subscribe({ - next: profile => { - this.profile = profile; - - if (this.info() && this.listType === "project") { - this.isInProject = this.info() - ?.collaborators?.map((person: Collaborator) => person.userId) - .includes(profile.id); - } - }, - }); - - this.subscriptions.push(profileInfoSub$); - } - - /** - * Инициализация строки для back компонента в зависимости от типа данных - */ - private initializeBackPath(): void { - if (this.listType === "project") { - this.backPath = "/office/projects/all"; - } else if (this.listType === "program") { - this.backPath = "/office/program/all"; - } - } - - private loadAdditionalFields(programId: number): void { - const additionalFieldsSub$ = this.programService - .getProgramProjectAdditionalFields(programId) - .subscribe({ - next: ({ programFields }) => { - if (programFields) { - this.additionalFields.set(programFields); - } - }, - }); - - this.subscriptions.push(additionalFieldsSub$); - } -} diff --git a/projects/social_platform/src/app/office/features/info-card/info-card.component.html b/projects/social_platform/src/app/office/features/info-card/info-card.component.html deleted file mode 100644 index ef5007dc2..000000000 --- a/projects/social_platform/src/app/office/features/info-card/info-card.component.html +++ /dev/null @@ -1,277 +0,0 @@ - - -
-
- @if (shouldShowProjectInfo()) { -
-
-

12

- -
-
-

12

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

{{ info?.name }}

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

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

- } @else { -

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

- } -
- - - @if (type === 'projects' || type === 'invite') { @if (info.name) { -

{{ info.name | truncate: 12 }}

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

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

- } } @else { -

{{ info?.firstName | truncate: 10 }}

-

{{ info?.lastName | truncate: 10 }}

- @if (info?.speciality) { -

- {{ info?.speciality | truncate: 20 }} - @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 | truncate: 10 - }} -
  • - } -
- } } -
-
- - -
- @if (type === 'projects') { -
- @if (info.partnerProgram || info.draft) { @if (info.partnerProgram && info.draft) { -
- -
- - @if (programProjectHovered) { -
-

проект привязан к программе {{ info.partnerProgram.name }}

-
- } } - - - проект - - -
- -
- - @if (iconHovered) { -
-

- @if (info.draft) { проект пока еще не опубликовали. } @else { проект привязан к программе - {{ info.partnerProgram.name }} - } -

-
- } } @else { - - проект - - } -
- } @else if (type === 'members') { @if (leaderId === loggedUserId && loggedUserId !== - info?.userId) { -
- выдать роль - удалить -
- } @else { - - профиль - - } } @else { -
- - принять - - - отклонить - -
- } -
-
- - - -
-

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

- -
- - отписаться - - - отменить - -
-
-
- - -
-

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

-

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

- - Хорошо - -
-
-
diff --git a/projects/social_platform/src/app/office/features/info-card/info-card.component.spec.ts b/projects/social_platform/src/app/office/features/info-card/info-card.component.spec.ts deleted file mode 100644 index f2e3eb5e9..000000000 --- a/projects/social_platform/src/app/office/features/info-card/info-card.component.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { of } from "rxjs"; -import { IndustryService } from "@services/industry.service"; -import { InfoCardComponent } from "./info-card.component"; - -describe("ProjectCardComponent", () => { - let component: InfoCardComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - const industrySpy = jasmine.createSpyObj([{ industries: of([]) }]); - - await TestBed.configureTestingModule({ - imports: [InfoCardComponent], - providers: [{ provide: IndustryService, useValue: industrySpy }], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(InfoCardComponent); - - component = fixture.componentInstance; - - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/features/info-card/info-card.component.ts b/projects/social_platform/src/app/office/features/info-card/info-card.component.ts deleted file mode 100644 index e1fb4456c..000000000 --- a/projects/social_platform/src/app/office/features/info-card/info-card.component.ts +++ /dev/null @@ -1,251 +0,0 @@ -/** @format */ - -import { Component, EventEmitter, inject, Input, OnInit, Output, signal } from "@angular/core"; -import { IndustryService } from "@services/industry.service"; -import { IconComponent, ButtonComponent } from "@ui/components"; -import { AvatarComponent } from "@ui/components/avatar/avatar.component"; -import { AsyncPipe, CommonModule } from "@angular/common"; -import { ModalComponent } from "@ui/components/modal/modal.component"; -import { SubscriptionService } from "@office/services/subscription.service"; -import { InviteService } from "@office/services/invite.service"; -import { ClickOutsideModule } from "ng-click-outside"; -import { Router, RouterLink } from "@angular/router"; -import { TagComponent } from "@ui/components/tag/tag.component"; -import { YearsFromBirthdayPipe } from "@corelib"; -import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; - -/** - * Компонент карточки информации с разным наполнением, в зависимости от контекста - */ -@Component({ - selector: "app-info-card", - templateUrl: "./info-card.component.html", - styleUrl: "./info-card.component.scss", - standalone: true, - imports: [ - CommonModule, - AvatarComponent, - IconComponent, - AsyncPipe, - ModalComponent, - ButtonComponent, - ClickOutsideModule, - TagComponent, - YearsFromBirthdayPipe, - TruncatePipe, - RouterLink, - ], -}) -export class InfoCardComponent { - private readonly inviteService = inject(InviteService); - private readonly subscriptionService = inject(SubscriptionService); - public readonly industryService = inject(IndustryService); - private readonly router = inject(Router); - - @Input() info?: any; - @Input() type: "invite" | "projects" | "members" = "projects"; - @Input() appereance: "my" | "subs" | "base" | "empty" = "base"; - @Input() section: "projects" | "subscriptions" | "other" = "projects"; - @Input() canDelete?: boolean | null = false; - @Input() isSubscribed?: boolean | null = false; - @Input() profileId?: number; - @Input() leaderId?: number; - @Input() loggedUserId?: number; - - @Output() onAcceptingInvite = new EventEmitter(); - @Output() onRejectingInvite = new EventEmitter(); - @Output() onCreate = new EventEmitter(); - @Output() onRemoveCollaborator = new EventEmitter(); - - // Состояние компонента - isUnsubscribeModalOpen = false; - inviteErrorModal = false; - haveBadge = this.calculateHaveBadge(); - - programProjectHovered = false; - iconHovered = false; - draftProjectHovered = false; - - removeCollaboratorFromProject(userId: number): void { - this.onRemoveCollaborator.emit(userId); - } - - /** - * Определяет, нужно ли показывать информацию о проекте - */ - shouldShowProjectInfo(): boolean { - return this.type === "projects" && this.appereance !== "subs" && this.appereance !== "empty"; - } - - /** - * Определяет, нужно ли показывать бейдж подписки - */ - shouldShowSubscriptionBadge(): boolean { - return ( - this.appereance !== "empty" && - this.haveBadge && - this.appereance === "base" && - this.type !== "invite" && - this.type !== "members" - ); - } - - /** - * Возвращает URL для аватара - */ - getAvatarUrl(): string { - const currentImageAddress = - this.appereance === "empty" && this.section === "projects" - ? "/assets/images/projects/shared/add-project.svg" - : this.appereance === "empty" && this.section === "subscriptions" - ? "/assets/images/projects/shared/empty-subscriptions.svg" - : ""; - return this.info?.imageAddress || this.info?.avatar || currentImageAddress; - } - - /** - * Переключение подписки (универсальный метод) - */ - toggleSubscription(event: Event): void { - if (this.isSubscribed) { - this.onSubscribe(event, this.profileId!); - } else { - this.onSubscribe(event, this.profileId!); - } - } - - /** - * Обработка отклонения приглашения - */ - onRejectInvite(event: Event, inviteId: number): void { - if (!this.info || !inviteId) { - console.warn("Cannot reject invite: missing project or inviteId"); - return; - } - - this.stopEventPropagation(event); - - this.inviteService.rejectInvite(inviteId).subscribe({ - next: () => { - this.onRejectingInvite.emit(inviteId || this.info!.inviteId); - }, - error: error => { - console.error("Error rejecting invite:", error); - this.inviteErrorModal = true; - }, - }); - } - - /** - * Обработка принятия приглашения - */ - onAcceptInvite(event: Event, inviteId: number): void { - if (!this.info || !inviteId) { - console.warn("Cannot accept invite: missing project or inviteId"); - return; - } - - this.stopEventPropagation(event); - - this.inviteService.acceptInvite(inviteId).subscribe({ - next: () => { - this.onAcceptingInvite.emit(inviteId || this.info!.inviteId); - }, - error: error => { - console.error("Error accepting invite:", error); - this.inviteErrorModal = true; - }, - }); - } - - /** - * Подписка на проект или открытие модального окна отписки - */ - onSubscribe(event: Event, projectId: number): void { - if (!projectId) { - console.warn("Cannot subscribe: missing projectId"); - return; - } - - this.stopEventPropagation(event); - - if (this.isSubscribed) { - this.isUnsubscribeModalOpen = true; - return; - } - - this.subscriptionService.addSubscription(projectId).subscribe({ - next: () => { - this.isSubscribed = true; - }, - error: error => { - console.error("Error subscribing to project:", error); - }, - }); - } - - /** - * Отписка от проекта - */ - onUnsubscribe(event: Event, projectId: number): void { - if (!projectId) { - console.warn("Cannot unsubscribe: missing projectId"); - return; - } - - this.stopEventPropagation(event); - - this.subscriptionService.deleteSubscription(projectId).subscribe({ - next: () => { - this.isSubscribed = false; - this.isUnsubscribeModalOpen = false; - }, - error: error => { - console.error("Error unsubscribing from project:", error); - }, - }); - } - - /** - * Закрытие модального окна отписки - */ - onCloseUnsubscribeModal(): void { - this.isUnsubscribeModalOpen = false; - } - - /** - * Обработка создания нового проекта - */ - onCreateProject(event: Event): void { - this.stopEventPropagation(event); - this.onCreate.emit(); - } - - /** - * Остановка всплытия события - */ - private stopEventPropagation(event: Event): void { - event.stopPropagation(); - event.preventDefault(); - } - - /** - * Редирект на проеты при случае что подписки пустые - */ - redirectToProjects(): void { - this.router - .navigateByUrl(`/office/projects/all`) - .then(() => console.debug("Route change from ProjectsComponent")); - } - - /** - * Вычисление флага haveBadge - */ - private calculateHaveBadge(): boolean { - return ( - location.href.includes("/subscriptions") || - location.href.includes("/all") || - location.href.includes("/projects") - ); - } -} diff --git a/projects/social_platform/src/app/office/features/invite-card/invite-card.component.html b/projects/social_platform/src/app/office/features/invite-card/invite-card.component.html deleted file mode 100644 index a08e4ff6a..000000000 --- a/projects/social_platform/src/app/office/features/invite-card/invite-card.component.html +++ /dev/null @@ -1,96 +0,0 @@ - - -@if (invite) { -
-
-
-

{{ invite.role | truncate: 30 }}

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

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

- } -
- -
- -

- {{ invite.user.firstName | truncate: 15 }} {{ invite.user.lastName | truncate: 15 }} -

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

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

-
- Отмена - Удалить -
-
-
- - -
-

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

-

Роль в команде

-
- @if (inviteForm.get("role"); as role) { -
- - @if ((role | controlError: "required")) { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } @if (inviteForm.get("specialization"); as specialization) { -
- - @if ((specialization | controlError: "required")) { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } - Сохранить -
-
-
diff --git a/projects/social_platform/src/app/office/features/invite-card/invite-card.component.spec.ts b/projects/social_platform/src/app/office/features/invite-card/invite-card.component.spec.ts deleted file mode 100644 index fe6fe2b3d..000000000 --- a/projects/social_platform/src/app/office/features/invite-card/invite-card.component.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { InviteCardComponent } from "./invite-card.component"; - -describe("VacancyCardComponent", () => { - let component: InviteCardComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [InviteCardComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(InviteCardComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/features/invite-card/invite-card.component.ts b/projects/social_platform/src/app/office/features/invite-card/invite-card.component.ts deleted file mode 100644 index 8afa54445..000000000 --- a/projects/social_platform/src/app/office/features/invite-card/invite-card.component.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** @format */ - -import { Component, EventEmitter, Input, OnInit, Output, signal } from "@angular/core"; -import { FormBuilder, FormGroup, ReactiveFormsModule } from "@angular/forms"; -import { ControlErrorPipe } from "@corelib"; -import { ErrorMessage } from "@error/models/error-message"; -import { Invite } from "@models/invite.model"; -import { IconComponent, ButtonComponent, SelectComponent, InputComponent } from "@ui/components"; -import { ModalComponent } from "@ui/components/modal/modal.component"; -import { rolesMembersList } from "projects/core/src/consts/lists/roles-members-list.const"; -import { AvatarComponent } from "@ui/components/avatar/avatar.component"; -import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; - -/** - * Компонент карточки приглашения в команду или проект - * - * Функциональность: - * - Отображает информацию о приглашении (роль, специализация) - * - Позволяет редактировать роль и специализацию приглашенного пользователя - * - Предоставляет возможность удаления приглашения - * - Управляет модальными окнами для подтверждения действий - * - * Входные параметры: - * @Input invite - объект приглашения (обязательный) - * @Input type - тип приглашения: "team" или "invite" (по умолчанию "invite") - * - * Выходные события: - * @Output remove - событие удаления приглашения, передает ID приглашения - * @Output edit - событие редактирования приглашения, передает объект с ID, ролью и специализацией - */ -@Component({ - selector: "app-invite-card", - templateUrl: "./invite-card.component.html", - styleUrl: "./invite-card.component.scss", - standalone: true, - imports: [ - IconComponent, - ButtonComponent, - ModalComponent, - SelectComponent, - ControlErrorPipe, - TruncatePipe, - ReactiveFormsModule, - InputComponent, - AvatarComponent, - ], -}) -export class InviteCardComponent implements OnInit { - constructor(private readonly fb: FormBuilder) { - this.inviteForm = this.fb.group({ - role: [""], - specialization: [null], - }); - } - - readonly rolesMembersList = rolesMembersList; - - inviteForm: FormGroup; - errorMessage = ErrorMessage; - - @Input({ required: true }) invite!: Invite; - - @Output() remove = new EventEmitter(); - @Output() edit = new EventEmitter<{ inviteId: number; role: string; specialization: string }>(); - - ngOnInit(): void { - if (this.invite) { - this.inviteForm.patchValue({ - role: this.invite.role, - specialization: this.invite.specialization, - }); - } - } - - // Сигналы для управления состоянием модальных окон - isRemoveInviteModal = signal(false); - isEditInviteModal = signal(false); - - /** - * Обработчик удаления приглашения - * Предотвращает всплытие события и эмитит событие удаления - */ - onRemove(event: MouseEvent): void { - event.stopPropagation(); - event.preventDefault(); - - this.remove.emit(this.invite?.id); - } - - /** - * Обработчик редактирования приглашения - * Закрывает модальное окно и эмитит событие редактирования с данными формы - */ - onEdit(event: MouseEvent): void { - event.stopPropagation(); - event.preventDefault(); - this.isEditInviteModal.set(false); - - this.edit.emit({ - inviteId: this.invite?.id, - role: this.inviteForm.value.role, - specialization: this.inviteForm.value.specialization, - }); - } -} diff --git a/projects/social_platform/src/app/office/features/message-input/message-input.component.html b/projects/social_platform/src/app/office/features/message-input/message-input.component.html deleted file mode 100644 index 08da963f1..000000000 --- a/projects/social_platform/src/app/office/features/message-input/message-input.component.html +++ /dev/null @@ -1,123 +0,0 @@ - - -
- @if (attachFiles.length) { -
    - @for (file of attachFiles; let index = $index; track index) { -
  • - -
    -

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

    - @if (file.type) { -
    - {{ file.type.includes("/") ? (file.type | fileType) : (file.type | uppercase) }} • - {{ +file.size | formatedFileSize }} -
    - } -
    - @if (file.loading) { - - - - - - - } @else { - - } -
  • - } -
- } @if (editingMessage) { -
- -
-
- {{ editingMessage.author.firstName }} {{ editingMessage.author.lastName }} -
-
{{ editingMessage.text }}
-
- -
- } @if (replyMessage) { -
- -
-
- {{ replyMessage.author.firstName }} {{ replyMessage.author.lastName }} -
-
{{ replyMessage.text }}
-
- -
- } -
- - - -
- @if (showDropModal) { -
-
-
- drop files -

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

-

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

-
-
- } -
diff --git a/projects/social_platform/src/app/office/features/message-input/message-input.component.spec.ts b/projects/social_platform/src/app/office/features/message-input/message-input.component.spec.ts deleted file mode 100644 index 88977f183..000000000 --- a/projects/social_platform/src/app/office/features/message-input/message-input.component.spec.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { MessageInputComponent } from "./message-input.component"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; -import { AuthService } from "@auth/services"; -import { NgxMaskModule } from "ngx-mask"; - -describe("MessageInputComponent", () => { - let component: MessageInputComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - const authSpy = jasmine.createSpyObj("AuthService", ["profile"]); - - await TestBed.configureTestingModule({ - imports: [HttpClientTestingModule, MessageInputComponent, NgxMaskModule.forRoot()], - providers: [{ provide: AuthService, useValue: authSpy }], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(MessageInputComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/features/message-input/message-input.component.ts b/projects/social_platform/src/app/office/features/message-input/message-input.component.ts deleted file mode 100644 index ccabd06e8..000000000 --- a/projects/social_platform/src/app/office/features/message-input/message-input.component.ts +++ /dev/null @@ -1,361 +0,0 @@ -/** @format */ - -import { - Component, - EventEmitter, - forwardRef, - Input, - OnDestroy, - OnInit, - Output, -} from "@angular/core"; -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; -import { ChatMessage } from "@models/chat-message.model"; -import { fromEvent, map, Subscription } from "rxjs"; -import { FileService } from "@core/services/file.service"; -import { FileTypePipe } from "@ui/pipes/file-type.pipe"; -import { AutosizeModule } from "ngx-autosize"; -import { NgxMaskModule } from "ngx-mask"; -import { IconComponent } from "@ui/components"; -import { FormatedFileSizePipe } from "@core/pipes/formatted-file-size.pipe"; -import { UpperCasePipe } from "@angular/common"; - -/** - * Компонент ввода сообщений для чата - * Предоставляет расширенный интерфейс для ввода текстовых сообщений с поддержкой: - * - Автоматического изменения размера текстового поля - * - Прикрепления файлов через выбор или drag&drop - * - Редактирования существующих сообщений - * - Ответов на сообщения - * - Масок ввода для специальных форматов - * - * Реализует ControlValueAccessor для интеграции с Angular Forms - */ -@Component({ - selector: "app-message-input", - templateUrl: "./message-input.component.html", - styleUrl: "./message-input.component.scss", - providers: [ - { - // Регистрация как ControlValueAccessor для работы с формами - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => MessageInputComponent), - multi: true, - }, - ], - standalone: true, - imports: [ - IconComponent, - NgxMaskModule, - AutosizeModule, - FileTypePipe, - FormatedFileSizePipe, - UpperCasePipe, - ], -}) -export class MessageInputComponent implements OnInit, OnDestroy, ControlValueAccessor { - /** - * Конструктор компонента - * @param fileService - сервис для работы с файлами (загрузка, удаление) - */ - constructor(private readonly fileService: FileService) {} - - /** Текст placeholder для поля ввода */ - @Input() placeholder = ""; - /** Маска для форматирования ввода */ - @Input() mask = ""; - - /** Приватное поле для хранения редактируемого сообщения */ - private _editingMessage?: ChatMessage; - - /** - * Сеттер для сообщения, которое редактируется - * При установке сообщения для редактирования, его текст загружается в поле ввода - * @param message - сообщение для редактирования или undefined для отмены - */ - @Input() - set editingMessage(message: ChatMessage | undefined) { - this._editingMessage = message; - - if (message !== undefined) { - this.value.text = message.text; - } else { - this.value.text = ""; - } - } - - /** - * Геттер для получения редактируемого сообщения - * @returns сообщение для редактирования или undefined - */ - get editingMessage(): ChatMessage | undefined { - return this._editingMessage; - } - - /** Сообщение, на которое отвечаем */ - @Input() replyMessage?: ChatMessage; - - /** - * Сеттер для значения компонента - * @param value - объект с текстом и массивом URL файлов - */ - @Input() - set appValue(value: MessageInputComponent["value"]) { - this.value = value; - } - - /** - * Геттер для получения значения компонента - * @returns объект с текстом и массивом URL файлов - */ - get appValue(): MessageInputComponent["value"] { - return this.value; - } - - // События компонента - /** Событие изменения значения */ - @Output() appValueChange = new EventEmitter(); - /** Событие отправки сообщения */ - @Output() submit = new EventEmitter(); - /** Событие изменения размера поля ввода */ - @Output() resize = new EventEmitter(); - /** Событие отмены редактирования/ответа */ - @Output() cancel = new EventEmitter(); - - /** - * Инициализация компонента - * Настраивает обработчики drag&drop для загрузки файлов - */ - ngOnInit(): void { - // Обработчик события dragover для всего документа - const dragOver$ = fromEvent(document, "dragover") - .pipe() - .subscribe(this.handleDragOver.bind(this)); - dragOver$ && this.subscriptions$.push(dragOver$); - - // Обработчик события drop для всего документа - const drop$ = fromEvent(document, "drop").subscribe(this.handleDrop.bind(this)); - drop$ && this.subscriptions$.push(drop$); - } - - /** - * Очистка ресурсов при уничтожении компонента - */ - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $.unsubscribe()); - } - - /** - * Обработчик события dragover - * Предотвращает стандартное поведение и показывает модальное окно для drop - * @param event - событие dragover - */ - private handleDragOver(event: DragEvent): void { - event.stopPropagation(); - event.preventDefault(); - this.showDropModal = true; - } - - /** - * Обработчик события drop - * Обрабатывает перетаскиваемые файлы и скрывает модальное окно - * @param event - событие drop - */ - private handleDrop(event: DragEvent): void { - event.stopPropagation(); - event.preventDefault(); - - const files = event.dataTransfer?.files; - if (!files) return; - - this.addFiles(files); - this.showDropModal = false; - } - - /** Флаг отображения модального окна для drag&drop */ - showDropModal = false; - /** Массив подписок для очистки */ - subscriptions$: Subscription[] = []; - - /** Значение компонента: текст и массив URL файлов */ - value: { text: string; filesUrl: string[] } = { text: "", filesUrl: [] }; - - /** - * Обработчик ввода текста - * @param event - событие ввода - */ - onInput(event: Event): void { - const value = (event.target as HTMLInputElement).value; - const newValue = { ...this.value, text: value }; - - this.onChange(newValue); - this.appValueChange.emit(newValue); - this.value = newValue; - } - - /** - * Обработчик потери фокуса - */ - onBlur(): void { - this.onTouch(); - } - - // Методы ControlValueAccessor - /** - * Установка значения в компонент (ControlValueAccessor) - * @param value - значение для установки - */ - writeValue(value: MessageInputComponent["value"]): void { - setTimeout(() => { - this.value = value; - - // Очистка списка файлов если нет URL файлов - if (!value.filesUrl.length) { - this.attachFiles = []; - } - }); - } - - /** Функция обратного вызова для уведомления об изменениях */ - // eslint-disable-next-line no-use-before-define - onChange: (value: MessageInputComponent["value"]) => void = () => {}; - - /** - * Регистрация функции обратного вызова для изменений (ControlValueAccessor) - * @param fn - функция для вызова при изменении значения - */ - registerOnChange(fn: (v: MessageInputComponent["value"]) => void): void { - this.onChange = fn; - } - - /** Функция обратного вызова для уведомления о касании */ - onTouch: () => void = () => {}; - - /** - * Регистрация функции обратного вызова для касания (ControlValueAccessor) - * @param fn - функция для вызова при касании компонента - */ - registerOnTouched(fn: () => void): void { - this.onTouch = fn; - } - - /** Флаг отключения компонента */ - disabled = false; - - /** - * Установка состояния отключения (ControlValueAccessor) - * @param isDisabled - флаг отключения - */ - setDisabledState(isDisabled: boolean) { - this.disabled = isDisabled; - } - - /** - * Обработчик нажатия клавиш в textarea - * Обрабатывает Tab для вставки символа табуляции - * @param event - событие клавиатуры - */ - onTextareaKeydown(event: any) { - if (event.key === "Tab") { - event.preventDefault(); - const textarea = event.target as HTMLTextAreaElement; - const start = textarea.selectionStart; - const end = textarea.selectionEnd; - - // Вставка символа табуляции в позицию курсора - textarea.value = textarea.value.substring(0, start) + "\t" + textarea.value.substring(end); - textarea.selectionStart = textarea.selectionEnd = start + 1; - this.onInput(event); - } - } - - /** Массив прикрепленных файлов с метаданными */ - attachFiles: { - name: string; - size: string; - type: string; - link?: string; - loading: boolean; - }[] = []; - - /** - * Обработчик загрузки файлов через input - * @param evt - событие выбора файлов - */ - onUpload(evt: Event) { - const files = (evt.currentTarget as HTMLInputElement).files; - - if (!files?.length) { - return; - } - - this.addFiles(files); - } - - /** - * Добавление файлов для загрузки - * Создает записи в массиве attachFiles и запускает загрузку на сервер - * @param files - список файлов для загрузки - */ - private addFiles(files: FileList): void { - // Создание записей для каждого файла - for (let i = 0; i < files.length; i++) { - this.attachFiles.push({ - name: files[i].name, - size: files[i].size.toString(), - type: files[i].type, - loading: true, - }); - } - - // Загрузка каждого файла на сервер - for (let i = 0; i < files.length; i++) { - this.fileService - .uploadFile(files[i]) - .pipe(map(r => r.url)) - .subscribe({ - next: url => { - // Обновление значения компонента с новым URL файла - this.value = { - ...this.value, - filesUrl: [...this.value.filesUrl, url], - }; - - this.onChange(this.value); - - // Обновление метаданных файла - setTimeout(() => { - this.attachFiles[i].loading = false; - this.attachFiles[i].link = url; - }); - }, - complete: () => { - setTimeout(() => { - this.attachFiles[i].loading = false; - }); - }, - }); - } - } - - /** - * Удаление файла - * Удаляет файл с сервера и из списка прикрепленных файлов - * @param idx - индекс файла в массиве attachFiles - */ - onDeleteFile(idx: number): void { - const file = this.attachFiles[idx]; - if (!file || !file.link) return; - - this.fileService.deleteFile(file.link).subscribe(() => { - // Удаление из массива прикрепленных файлов - this.attachFiles.splice(idx, 1); - // Удаление URL из значения компонента - this.value.filesUrl.splice(idx, 1); - this.onChange(this.value); - }); - } - - /** Ссылка на модуль для совместимости */ - protected readonly repl = module; -} diff --git a/projects/social_platform/src/app/office/features/nav/nav.component.html b/projects/social_platform/src/app/office/features/nav/nav.component.html deleted file mode 100644 index 5d0c22b4e..000000000 --- a/projects/social_platform/src/app/office/features/nav/nav.component.html +++ /dev/null @@ -1,137 +0,0 @@ - - - diff --git a/projects/social_platform/src/app/office/features/nav/nav.component.spec.ts b/projects/social_platform/src/app/office/features/nav/nav.component.spec.ts deleted file mode 100644 index 8d4262dca..000000000 --- a/projects/social_platform/src/app/office/features/nav/nav.component.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { NavComponent } from "./nav.component"; -import { RouterTestingModule } from "@angular/router/testing"; -import { of } from "rxjs"; -import { AuthService } from "@auth/services"; -import { InviteService } from "@services/invite.service"; - -describe("NavComponent", () => { - let component: NavComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - const authSpy = { - profile: of({}), - }; - - const inviteSpy = jasmine.createSpyObj({ acceptInvite: of({}), rejectInvite: of({}) }); - - await TestBed.configureTestingModule({ - imports: [RouterTestingModule, NavComponent], - providers: [ - { provide: AuthService, useValue: authSpy }, - { provide: InviteService, useValue: inviteSpy }, - ], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(NavComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/features/nav/nav.component.ts b/projects/social_platform/src/app/office/features/nav/nav.component.ts deleted file mode 100644 index 3e8358fb4..000000000 --- a/projects/social_platform/src/app/office/features/nav/nav.component.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** @format */ - -import { ChangeDetectorRef, Component, Input, OnDestroy, OnInit } from "@angular/core"; -import { NavigationStart, Router, RouterLink, RouterLinkActive } from "@angular/router"; -import { noop, Subscription } from "rxjs"; -import { NotificationService } from "@services/notification.service"; -import { Invite } from "@models/invite.model"; -import { AuthService } from "@auth/services"; -import { InviteService } from "@services/invite.service"; -import { AsyncPipe, CommonModule } from "@angular/common"; -import { IconComponent } from "@ui/components"; -import { InviteManageCardComponent, ProfileInfoComponent } from "@uilib"; - -/** - * Компонент навигационного меню - * - * Функциональность: - * - Отображает основное навигационное меню приложения - * - Управляет мобильным меню (открытие/закрытие) - * - Показывает уведомления и приглашения - * - Обрабатывает принятие и отклонение приглашений - * - Отображает информацию о профиле пользователя - * - Автоматически закрывает мобильное меню при навигации - * - Интеграция с внешним сервисом навыков - * - Динамическое обновление заголовка страницы - * - * Входные параметры: - * @Input invites - массив приглашений пользователя - * - * Внутренние свойства: - * - mobileMenuOpen - флаг состояния мобильного меню - * - notificationsOpen - флаг состояния панели уведомлений - * - title - текущий заголовок страницы - * - subscriptions$ - массив подписок для управления памятью - * - hasInvites - вычисляемое свойство наличия непрочитанных приглашений - * - * Сервисы: - * - navService - управление навигацией и заголовками - * - notificationService - управление уведомлениями - * - inviteService - работа с приглашениями - * - authService - аутентификация и профиль пользователя - */ -@Component({ - selector: "app-nav", - templateUrl: "./nav.component.html", - styleUrl: "./nav.component.scss", - standalone: true, - imports: [ - CommonModule, - IconComponent, - RouterLink, - RouterLinkActive, - InviteManageCardComponent, - ProfileInfoComponent, - AsyncPipe, - ], -}) -export class NavComponent implements OnInit, OnDestroy { - constructor( - private readonly router: Router, - public readonly notificationService: NotificationService, - private readonly inviteService: InviteService, - public readonly authService: AuthService - ) {} - - ngOnInit(): void { - // Подписка на события роутера для закрытия мобильного меню - const routerEvents$ = this.router.events.subscribe(event => { - if (event instanceof NavigationStart) { - this.mobileMenuOpen = false; - } - }); - routerEvents$ && this.subscriptions$.push(routerEvents$); - } - - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $.unsubscribe()); - } - - @Input() invites: Invite[] = []; - - subscriptions$: Subscription[] = []; - mobileMenuOpen = false; - notificationsOpen = false; - - /** - * Проверка наличия непринятых приглашений - * Возвращает true если есть приглашения со статусом null (не принято/не отклонено) - */ - get hasInvites(): boolean { - return !!this.invites.filter(invite => invite.isAccepted === null).length; - } - - /** - * Обработчик отклонения приглашения - * Отправляет запрос на отклонение и удаляет приглашение из списка - */ - onRejectInvite(inviteId: number): void { - this.inviteService.rejectInvite(inviteId).subscribe(() => { - const index = this.invites.findIndex(invite => invite.id === inviteId); - this.invites.splice(index, 1); - - this.notificationsOpen = false; - this.mobileMenuOpen = false; - }); - } - - /** - * Обработчик принятия приглашения - * Отправляет запрос на принятие, удаляет приглашение из списка - * и перенаправляет пользователя на страницу проекта - */ - onAcceptInvite(inviteId: number): void { - this.inviteService.acceptInvite(inviteId).subscribe(() => { - const index = this.invites.findIndex(invite => invite.id === inviteId); - const invite = JSON.parse(JSON.stringify(this.invites[index])); - this.invites.splice(index, 1); - - this.notificationsOpen = false; - this.mobileMenuOpen = false; - - this.router - .navigateByUrl(`/office/projects/${invite.project.id}`) - .then(() => console.debug("Route changed from HeaderComponent")); - }); - } - - /** - * Переход на внешний сервис навыков - * Открывает новую вкладку с сервисом skills.procollab.ru - */ - openSkills() { - location.href = "https://skills.procollab.ru"; - } - - protected readonly noop = noop; -} diff --git a/projects/social_platform/src/app/office/features/news-card/news-card.component.html b/projects/social_platform/src/app/office/features/news-card/news-card.component.html deleted file mode 100644 index 082c3ed35..000000000 --- a/projects/social_platform/src/app/office/features/news-card/news-card.component.html +++ /dev/null @@ -1,134 +0,0 @@ - -
-
- - -

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

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

- } @else { @if (editForm.get("text"); as text) { - - } } -
- } @if (editMode) { -
    - @for (f of imagesEditList; track f.id) { - - } -
-
    - @for (f of filesEditList; track f.id) { - - } -
- } @if (newsTextExpandable && !editMode) { -
- {{ readMore ? "скрыть" : "подробнее" }} -
- } @if (!editMode) { - - - - } @if (!editMode && filesViewList.length) { -
- @for (f of filesViewList; track $index) { - - } -
- } @if (!editMode) { - - } @else { - - } -
diff --git a/projects/social_platform/src/app/office/features/news-card/news-card.component.scss b/projects/social_platform/src/app/office/features/news-card/news-card.component.scss deleted file mode 100644 index 7c1c6d018..000000000 --- a/projects/social_platform/src/app/office/features/news-card/news-card.component.scss +++ /dev/null @@ -1,224 +0,0 @@ -@use "styles/typography"; -@use "styles/responsive"; - -.card { - padding: 24px 12px; - background-color: var(--light-white); - border: 0.5px solid var(--medium-grey-for-outline); - border-radius: var(--rounded-lg); - - &__head { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 10px; - } - - &__menu { - position: relative; - } - - &__dots { - display: flex; - align-items: center; - justify-content: center; - width: 16px; - height: 16px; - color: var(--dark-grey); - cursor: pointer; - } - - &__options { - position: absolute; - z-index: 2; - padding: 20px 0; - background-color: var(--white); - border: 0.5px solid var(--medium-grey-for-outline); - border-radius: var(--rounded-xl); - } - - &__option { - width: 120px; - padding: 5px 20px; - cursor: pointer; - transition: background-color 0.2s; - - &:hover { - background-color: var(--light-gray); - } - } - - &__avatar { - width: 40px; - height: 40px; - margin-right: 10px; - border-radius: 50%; - object-fit: cover; - } - - &__title { - display: flex; - align-items: center; - } - - &__top { - display: flex; - gap: 10px; - align-items: center; - } - - &__name { - color: var(--black); - } - - &__date { - color: var(--dark-grey); - } - - /* stylelint-disable value-no-vendor-prefix */ - &__text { - color: var(--grey-for-text); - white-space: break-spaces; - - p { - display: -webkit-box; - overflow: hidden; - color: var(--black); - text-overflow: ellipsis; - -webkit-box-orient: vertical; - -webkit-line-clamp: 4; - transition: all 0.7s ease-in-out; - - &.expanded { - -webkit-line-clamp: unset; - } - } - } - /* stylelint-enable value-no-vendor-prefix */ - - &__edit-files { - display: flex; - flex-direction: column; - gap: 10px; - - &:not(:empty) { - margin-top: 30px; - } - } - - &__gallery { - display: flex; - flex-direction: column; - gap: 10px; - margin-top: 10px; - margin-bottom: 10px; - } - - &__files { - display: flex; - flex-direction: column; - gap: 10px; - margin-bottom: 20px; - } - - &__img { - position: relative; - - img { - width: 100%; - object-fit: cover; - } - } - - &__img-like { - position: absolute; - top: 50%; - left: 50%; - display: flex; - align-items: center; - justify-content: center; - width: 75px; - height: 75px; - color: var(--accent); - background-color: var(--white); - border-radius: var(--rounded-xl); - transition: transform 0.1s ease-in-out; - transform: translate(-50%, -50%) scale(0); - - &--show { - transform: translate(-50%, -50%) scale(1); - } - } - - &__footer { - margin-top: 10px; - } -} - -.footer { - display: flex; - align-items: center; - justify-content: space-between; - - &__left { - display: flex; - gap: 10px; - align-items: center; - } - - &__item { - display: flex; - align-items: center; - color: var(--dark-grey); - } - - &__like { - cursor: pointer; - - &--active { - color: var(--accent); - } - } -} - -.share { - color: var(--dark-grey); - - &__icon { - cursor: pointer; - } -} - -.read-more { - margin-top: 12px; - color: var(--accent); - cursor: pointer; - transition: color 0.2s; - - &:hover { - color: var(--accent-dark); - } -} - -.editor-footer { - display: flex; - justify-content: space-between; - padding-top: 10px; - margin-top: 20px; - border-top: 1px solid var(--medium-grey-for-outline); - - &__actions { - display: flex; - gap: 10px; - align-items: center; - } - - &__attach { - color: var(--dark-grey); - cursor: pointer; - - input { - display: none; - } - } -} diff --git a/projects/social_platform/src/app/office/features/news-card/news-card.component.spec.ts b/projects/social_platform/src/app/office/features/news-card/news-card.component.spec.ts deleted file mode 100644 index 220bac250..000000000 --- a/projects/social_platform/src/app/office/features/news-card/news-card.component.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { NewsCardComponent } from "./news-card.component"; -import { RouterTestingModule } from "@angular/router/testing"; -import { ReactiveFormsModule } from "@angular/forms"; -import { of } from "rxjs"; -import { ProjectNewsService } from "@office/projects/detail/services/project-news.service"; -import { AuthService } from "@auth/services"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; -import { DayjsPipe } from "projects/core"; -import { FeedNews } from "@office/projects/models/project-news.model"; - -describe("NewsCardComponent", () => { - let component: NewsCardComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - const projectNewsServiceSpy = jasmine.createSpyObj(["addNews"]); - const authSpy = { - profile: of({}), - }; - - await TestBed.configureTestingModule({ - imports: [ - RouterTestingModule, - ReactiveFormsModule, - HttpClientTestingModule, - NewsCardComponent, - DayjsPipe, - ], - providers: [ - { provide: ProjectNewsService, useValue: projectNewsServiceSpy }, - { provide: AuthService, useValue: authSpy }, - ], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(NewsCardComponent); - component = fixture.componentInstance; - component.feedItem = FeedNews.default(); - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/features/news-card/news-card.component.ts b/projects/social_platform/src/app/office/features/news-card/news-card.component.ts deleted file mode 100644 index 0801f2cb5..000000000 --- a/projects/social_platform/src/app/office/features/news-card/news-card.component.ts +++ /dev/null @@ -1,397 +0,0 @@ -/** @format */ - -import { - ChangeDetectorRef, - Component, - ElementRef, - EventEmitter, - Input, - OnInit, - Output, - ViewChild, -} from "@angular/core"; -import { FeedNews } from "@office/projects/models/project-news.model"; -import { SnackbarService } from "@ui/services/snackbar.service"; -import { ActivatedRoute, RouterLink } from "@angular/router"; -import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; -import { - DayjsPipe, - FormControlPipe, - ParseBreaksPipe, - ParseLinksPipe, - ValidationService, -} from "projects/core"; -import { FileService } from "@core/services/file.service"; -import { nanoid } from "nanoid"; -import { expandElement } from "@utils/expand-element"; -import { FileModel } from "@models/file.model"; -import { catchError, forkJoin, noop, Observable, of, tap } from "rxjs"; -import { ButtonComponent, IconComponent } from "@ui/components"; -import { FileItemComponent } from "@ui/components/file-item/file-item.component"; -import { FileUploadItemComponent } from "@ui/components/file-upload-item/file-upload-item.component"; -import { TextareaComponent } from "@ui/components/textarea/textarea.component"; -import { ClickOutsideModule } from "ng-click-outside"; -import { CarouselComponent } from "@office/shared/carousel/carousel.component"; -import { ImgCardComponent } from "@office/shared/img-card/img-card.component"; -import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; - -@Component({ - selector: "app-news-card", - templateUrl: "./news-card.component.html", - styleUrl: "./news-card.component.scss", - standalone: true, - imports: [ - ClickOutsideModule, - RouterLink, - IconComponent, - TextareaComponent, - ReactiveFormsModule, - FileUploadItemComponent, - FileItemComponent, - ButtonComponent, - DayjsPipe, - FormControlPipe, - TruncatePipe, - ParseLinksPipe, - ParseBreaksPipe, - CarouselComponent, - ImgCardComponent, - ], -}) -export class NewsCardComponent implements OnInit { - constructor( - private readonly snackbarService: SnackbarService, - private readonly route: ActivatedRoute, - private readonly fb: FormBuilder, - private readonly validationService: ValidationService, - private readonly fileService: FileService, - private readonly cdRef: ChangeDetectorRef - ) { - this.editForm = this.fb.group({ - text: ["", [Validators.required]], - }); - } - - @Input({ required: true }) feedItem!: FeedNews; - @Input({ required: true }) resourceLink!: (string | number)[]; - @Input({ required: false }) contentId?: number; - @Input() isOwner?: boolean; - - @Output() delete = new EventEmitter(); - @Output() like = new EventEmitter(); - @Output() edited = new EventEmitter(); - - placeholderUrl = "https://hwchamber.co.uk/wp-content/uploads/2022/04/avatar-placeholder.gif"; - - newsTextExpandable!: boolean; - readMore = false; - editMode = false; - editForm: FormGroup; - - // Оригинальные списки (не изменяются во время редактирования) - imagesViewList: FileModel[] = []; - filesViewList: FileModel[] = []; - - // Списки для редактирования - imagesEditList: { - id: string; - src: string; - loading: boolean; - error: boolean; - tempFile: File | null; - }[] = []; - - filesEditList: { - id: string; - src: string; - loading: boolean; - error: string; - name: string; - size: number; - type: string; - tempFile: File | null; - }[] = []; - - @ViewChild("newsTextEl") newsTextEl?: ElementRef; - - ngOnInit(): void { - this.editForm.setValue({ - text: this.feedItem.text, - }); - - const processedFiles = this.feedItem.files.map(file => { - if (typeof file === "string") { - return { - link: file, - name: "Image", - mimeType: "image/jpeg", - size: 0, - datetimeUploaded: "", - extension: "", - user: 0, - } as FileModel; - } - return file; - }); - - this.showLikes = this.feedItem.files.map(() => false); - - this.imagesViewList = processedFiles.filter(f => { - const [type] = (f.mimeType || "").split("/"); - return type === "image" || f.mimeType === "x-empty"; - }); - - this.filesViewList = processedFiles.filter(f => { - const [type] = (f.mimeType || "").split("/"); - return type !== "image" && f.mimeType !== "x-empty"; - }); - - this.initEditLists(); - } - - /** - * Инициализация списков редактирования из текущих данных - */ - private initEditLists(): void { - this.imagesEditList = this.imagesViewList.map(file => ({ - src: file.link, - id: nanoid(), - error: false, - loading: false, - tempFile: null, - })); - - this.filesEditList = this.filesViewList.map(file => ({ - src: file.link, - id: nanoid(), - error: "", - loading: false, - name: file.name, - size: file.size, - type: file.mimeType, - tempFile: null, - })); - } - - ngAfterViewInit(): void { - const newsTextElem = this.newsTextEl?.nativeElement; - this.newsTextExpandable = newsTextElem?.clientHeight < newsTextElem?.scrollHeight; - this.cdRef.detectChanges(); - } - - onCopyLink(): void { - const isProject = this.resourceLink[0].toString().includes("projects"); - let fullUrl = ""; - - if (isProject) { - fullUrl = `${location.origin}/office/projects/${this.contentId}/news/${this.feedItem.id}`; - } else { - fullUrl = `${location.origin}/office/profile/${this.contentId}/news/${this.feedItem.id}`; - } - - navigator.clipboard.writeText(fullUrl).then(() => { - this.snackbarService.success("Ссылка скопирована"); - }); - } - - menuOpen = false; - - onCloseMenu() { - this.menuOpen = false; - } - - onEditSubmit(): void { - if (!this.validationService.getFormValidation(this.editForm)) return; - - const uploadedImages = this.imagesEditList - .filter(f => f.src && !f.loading && !f.error) - .map(f => f.src); - - this.imagesViewList = this.imagesEditList - .filter(f => f.src && !f.loading && !f.error) - .map(f => ({ - link: f.src, - name: "Image", - mimeType: "image/jpeg", - size: 0, - datetimeUploaded: "", - extension: "", - user: 0, - })); - - this.filesViewList = this.filesEditList - .filter(f => f.src && !f.loading && !f.error) - .map(f => ({ - link: f.src, - name: f.name, - size: f.size, - mimeType: f.type, - datetimeUploaded: "", - extension: "", - user: 0, - })); - - this.feedItem.text = this.editForm.value.text; - - this.feedItem.files = [...this.imagesViewList, ...this.filesViewList]; - - this.edited.emit({ - ...this.editForm.value, - files: uploadedImages, - }); - - this.cdRef.detectChanges(); - this.onCloseEditMode(); - } - - onCloseEditMode() { - this.editMode = false; - - this.initEditLists(); - - this.editForm.setValue({ - text: this.feedItem.text, - }); - } - - onUploadFile(event: Event) { - const files = (event.currentTarget as HTMLInputElement).files; - if (!files) return; - - const observableArray: Observable[] = []; - - for (let i = 0; i < files.length; i++) { - const fileType = files[i].type.split("/")[0]; - - if (fileType === "image") { - const fileObj: NewsCardComponent["imagesEditList"][0] = { - id: nanoid(2), - src: "", - loading: true, - error: false, - tempFile: files[i], - }; - this.imagesEditList.push(fileObj); - - observableArray.push( - this.fileService.uploadFile(files[i]).pipe( - tap(file => { - fileObj.src = file.url; - fileObj.loading = false; - fileObj.tempFile = null; - }), - catchError(() => { - fileObj.loading = false; - fileObj.error = true; - return of(null); - }) - ) - ); - } else { - const fileObj: NewsCardComponent["filesEditList"][0] = { - id: nanoid(2), - loading: true, - error: "", - src: "", - tempFile: files[i], - name: files[i].name, - size: files[i].size, - type: files[i].type, - }; - this.filesEditList.push(fileObj); - - observableArray.push( - this.fileService.uploadFile(files[i]).pipe( - tap(file => { - fileObj.loading = false; - fileObj.src = file.url; - fileObj.tempFile = null; - }), - catchError(() => { - fileObj.loading = false; - fileObj.error = "Ошибка загрузки"; - return of(null); - }) - ) - ); - } - } - - forkJoin(observableArray).subscribe(noop); - - (event.currentTarget as HTMLInputElement).value = ""; - } - - onDeletePhoto(fId: string) { - const fileIdx = this.imagesEditList.findIndex(f => f.id === fId); - if (fileIdx === -1) return; - - if (this.imagesEditList[fileIdx].src) { - this.imagesEditList[fileIdx].loading = true; - this.fileService.deleteFile(this.imagesEditList[fileIdx].src).subscribe(() => { - this.imagesEditList.splice(fileIdx, 1); - }); - } else { - this.imagesEditList.splice(fileIdx, 1); - } - } - - onDeleteFile(fId: string) { - const fileIdx = this.filesEditList.findIndex(f => f.id === fId); - if (fileIdx === -1) return; - - if (this.filesEditList[fileIdx].src) { - this.filesEditList[fileIdx].loading = true; - this.fileService.deleteFile(this.filesEditList[fileIdx].src).subscribe(() => { - this.filesEditList.splice(fileIdx, 1); - }); - } else { - this.filesEditList.splice(fileIdx, 1); - } - } - - onRetryUpload(id: string) { - const fileObj = this.imagesEditList.find(f => f.id === id); - if (!fileObj || !fileObj.tempFile) return; - - fileObj.loading = true; - fileObj.error = false; - - this.fileService.uploadFile(fileObj.tempFile).subscribe({ - next: file => { - fileObj.src = file.url; - fileObj.loading = false; - fileObj.tempFile = null; - }, - error: () => { - fileObj.error = true; - fileObj.loading = false; - }, - }); - } - - showLikes: boolean[] = []; - lastTouch = 0; - - onTouchImg(_event: TouchEvent, imgIdx: number) { - if (Date.now() - this.lastTouch < 300) { - this.like.emit(this.feedItem.id); - this.showLikes[imgIdx] = true; - - setTimeout(() => { - this.showLikes[imgIdx] = false; - }, 1000); - } - - this.lastTouch = Date.now(); - } - - handleLike(index: number): void { - console.log("Лайк на изображении с индексом: ", index); - } - - onExpandNewsText(elem: HTMLElement, expandedClass: string, isExpanded: boolean): void { - expandElement(elem, expandedClass, isExpanded); - this.readMore = !isExpanded; - } -} diff --git a/projects/social_platform/src/app/office/features/news-form/news-form.component.html b/projects/social_platform/src/app/office/features/news-form/news-form.component.html deleted file mode 100644 index 6199dbf03..000000000 --- a/projects/social_platform/src/app/office/features/news-form/news-form.component.html +++ /dev/null @@ -1,46 +0,0 @@ - - -
-
- - -
-
- @for (i of imagesList; track i.id) { - - } -
-
- @for (f of filesList; track f.id) { - - } -
- -
diff --git a/projects/social_platform/src/app/office/features/news-form/news-form.component.spec.ts b/projects/social_platform/src/app/office/features/news-form/news-form.component.spec.ts deleted file mode 100644 index 32da5287d..000000000 --- a/projects/social_platform/src/app/office/features/news-form/news-form.component.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { NewsFormComponent } from "./news-form.component"; -import { RouterTestingModule } from "@angular/router/testing"; -import { ReactiveFormsModule } from "@angular/forms"; -import { ProjectNewsService } from "@office/projects/detail/services/project-news.service"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; -import { of } from "rxjs"; -import { AuthService } from "@auth/services"; - -describe("NewsFormComponent", () => { - let component: NewsFormComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - const projectNewsServiceSpy = jasmine.createSpyObj(["addNews"]); - const authSpy = { - profile: of({}), - }; - - await TestBed.configureTestingModule({ - imports: [ - RouterTestingModule, - ReactiveFormsModule, - HttpClientTestingModule, - NewsFormComponent, - ], - providers: [ - { provide: ProjectNewsService, useValue: projectNewsServiceSpy }, - { provide: AuthService, useValue: authSpy }, - ], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(NewsFormComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/features/news-form/news-form.component.ts b/projects/social_platform/src/app/office/features/news-form/news-form.component.ts deleted file mode 100644 index 6cb0546d6..000000000 --- a/projects/social_platform/src/app/office/features/news-form/news-form.component.ts +++ /dev/null @@ -1,246 +0,0 @@ -/** @format */ - -import { Component, EventEmitter, OnInit, Output } from "@angular/core"; -import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; -import { ValidationService } from "projects/core"; -import { nanoid } from "nanoid"; -import { FileService } from "@core/services/file.service"; -import { catchError, forkJoin, noop, Observable, of, tap } from "rxjs"; -import { FileUploadItemComponent } from "@ui/components/file-upload-item/file-upload-item.component"; -import { IconComponent, InputComponent } from "@ui/components"; -import { AutosizeModule } from "ngx-autosize"; -import { ImgCardComponent } from "@office/shared/img-card/img-card.component"; -import { TextareaComponent } from "@ui/components/textarea/textarea.component"; - -/** - * Компонент формы создания новости - * - * Функциональность: - * - Создание новой новости с текстом и прикрепленными файлами - * - Загрузка файлов через input или drag&drop, а также вставка из буфера обмена - * - Разделение файлов на изображения и документы - * - Предварительный просмотр загруженных файлов - * - Управление состояниями загрузки и ошибок для каждого файла - * - Возможность удаления и повторной загрузки файлов - * - * Выходные события: - * @Output addNews - событие добавления новости, передает объект с текстом и массивом URL файлов - * - * Внутренние свойства: - * - messageForm - форма с полем текста новости (обязательное) - * - imagesList - массив объектов изображений с состояниями загрузки - * - filesList - массив объектов файлов с состояниями загрузки - */ -@Component({ - selector: "app-news-form", - templateUrl: "./news-form.component.html", - styleUrl: "./news-form.component.scss", - standalone: true, - imports: [ - ReactiveFormsModule, - AutosizeModule, - IconComponent, - FileUploadItemComponent, - InputComponent, - ImgCardComponent, - TextareaComponent, - ], -}) -export class NewsFormComponent implements OnInit { - constructor( - private readonly fb: FormBuilder, - private readonly validationService: ValidationService, - private readonly fileService: FileService - ) { - this.messageForm = this.fb.group({ - text: ["", [Validators.required]], - }); - } - - @Output() addNews = new EventEmitter<{ text: string; files: string[] }>(); - - ngOnInit(): void {} - - messageForm: FormGroup; - - /** - * Обработчик отправки формы - * Валидирует форму и эмитит событие с данными новости - */ - onSubmit() { - if (!this.validationService.getFormValidation(this.messageForm)) { - return; - } - - this.addNews.emit({ - ...this.messageForm.value, - files: [...this.imagesList.map(f => f.src), ...this.filesList.map(f => f.src)], - }); - - this.onResetForm(); - } - - /** - * Сброс формы и очистка списков файлов - */ - onResetForm() { - this.imagesList = []; - this.filesList = []; - this.messageForm.reset(); - } - - // Массив изображений с метаданными - imagesList: { - id: string; - src: string; - loading: boolean; - error: boolean; - tempFile: File | null; - }[] = []; - - // Массив файлов с метаданными - filesList: { - id: string; - loading: boolean; - error: string; - src: string; - tempFile: File; - }[] = []; - - /** - * Загрузка файлов на сервер - * Разделяет файлы на изображения и документы, загружает параллельно - */ - uploadFiles(files: FileList) { - const observableArray: Observable[] = []; - for (let i = 0; i < files.length; i++) { - const fileType = files[i].type.split("/")[0]; - - if (fileType === "image") { - const fileObj: NewsFormComponent["imagesList"][0] = { - id: nanoid(2), - src: "", - loading: true, - error: false, - tempFile: files[0], - }; - this.imagesList.push(fileObj); - observableArray.push( - this.fileService.uploadFile(files[i]).pipe( - tap(file => { - fileObj.src = file.url; - fileObj.loading = false; - fileObj.tempFile = null; - }), - catchError(() => { - fileObj.loading = false; - fileObj.error = true; - return of(null); - }) - ) - ); - } else { - const fileObj: NewsFormComponent["filesList"][0] = { - id: nanoid(2), - loading: true, - error: "", - src: "", - tempFile: files[0], - }; - this.filesList.push(fileObj); - observableArray.push( - this.fileService.uploadFile(files[i]).pipe( - tap(file => { - fileObj.loading = false; - fileObj.src = file.url; - }), - catchError(() => { - fileObj.loading = false; - fileObj.error = "Ошибка загрузки"; - return of(null); - }) - ) - ); - } - } - - forkJoin(observableArray).subscribe(noop); - } - - /** - * Обработчик выбора файлов через input - */ - onInputFiles(event: Event) { - const files = (event.currentTarget as HTMLInputElement).files; - if (!files) return; - - this.uploadFiles(files); - } - - /** - * Обработчик вставки файлов из буфера обмена - */ - onPaste(event: ClipboardEvent) { - const files = event.clipboardData?.files; - if (!files) return; - - this.uploadFiles(files); - } - - /** - * Удаление изображения из списка - * Если файл уже загружен на сервер, удаляет его оттуда - */ - onDeletePhoto(fId: string) { - const fileIdx = this.imagesList.findIndex(f => f.id === fId); - - if (this.imagesList[fileIdx].src) { - this.imagesList[fileIdx].loading = true; - this.fileService.deleteFile(this.imagesList[fileIdx].src).subscribe(() => { - this.imagesList.splice(fileIdx, 1); - }); - } else { - this.imagesList.splice(fileIdx, 1); - } - } - - /** - * Удаление файла из списка - * Если файл уже загружен на сервер, удаляет его оттуда - */ - onDeleteFile(fId: string) { - const fileIdx = this.filesList.findIndex(f => f.id === fId); - - if (this.filesList[fileIdx].src) { - this.filesList[fileIdx].loading = true; - this.fileService.deleteFile(this.imagesList[fileIdx].src).subscribe(() => { - this.filesList.splice(fileIdx, 1); - }); - } else { - this.filesList.splice(fileIdx, 1); - } - } - - /** - * Повторная попытка загрузки изображения - * Используется при ошибке загрузки - */ - onRetryUpload(id: string) { - const fileObj = this.imagesList.find(f => f.id === id); - if (!fileObj || !fileObj.tempFile) return; - - fileObj.loading = true; - fileObj.error = false; - this.fileService.uploadFile(fileObj.tempFile).subscribe({ - next: file => { - fileObj.src = file.url; - fileObj.loading = false; - fileObj.tempFile = null; - }, - error: () => { - fileObj.error = true; - fileObj.loading = false; - }, - }); - } -} diff --git a/projects/social_platform/src/app/office/features/program-links/program-links.component.html b/projects/social_platform/src/app/office/features/program-links/program-links.component.html deleted file mode 100644 index 4ff733576..000000000 --- a/projects/social_platform/src/app/office/features/program-links/program-links.component.html +++ /dev/null @@ -1,26 +0,0 @@ - - - diff --git a/projects/social_platform/src/app/office/features/program-links/program-links.component.ts b/projects/social_platform/src/app/office/features/program-links/program-links.component.ts deleted file mode 100644 index 6fe221c0e..000000000 --- a/projects/social_platform/src/app/office/features/program-links/program-links.component.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** @format */ - -import { Component, Input } from "@angular/core"; -import { IconComponent } from "@uilib"; -import { UserLinksPipe } from "@core/pipes/user-links.pipe"; -import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; - -@Component({ - selector: "app-program-links", - templateUrl: "./program-links.component.html", - styleUrl: "./program-links.component.scss", - standalone: true, - imports: [IconComponent, UserLinksPipe, TruncatePipe], -}) -export class ProgramLinksComponent { - @Input({ required: true }) title!: string; - @Input({ required: true }) icon!: string; - @Input({ required: true }) links!: { label: string; url: string }[]; -} diff --git a/projects/social_platform/src/app/office/features/program-sidebar-card/program-sidebar-card.component.html b/projects/social_platform/src/app/office/features/program-sidebar-card/program-sidebar-card.component.html deleted file mode 100644 index 77b88001a..000000000 --- a/projects/social_platform/src/app/office/features/program-sidebar-card/program-sidebar-card.component.html +++ /dev/null @@ -1,22 +0,0 @@ - - -
-
-
- -

до {{ program.datetimeRegistrationEnds | date: "dd.MM.yy" }}

-
- - -
- -

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

-
diff --git a/projects/social_platform/src/app/office/features/program-sidebar-card/program-sidebar-card.component.ts b/projects/social_platform/src/app/office/features/program-sidebar-card/program-sidebar-card.component.ts deleted file mode 100644 index 233151650..000000000 --- a/projects/social_platform/src/app/office/features/program-sidebar-card/program-sidebar-card.component.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { Component, Input } from "@angular/core"; -import { IconComponent } from "@uilib"; -import { AvatarComponent } from "@ui/components/avatar/avatar.component"; -import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; -import { Program } from "@office/program/models/program.model"; - -@Component({ - selector: "app-program-sidebar-card", - templateUrl: "./program-sidebar-card.component.html", - styleUrl: "./program-sidebar-card.component.scss", - imports: [CommonModule, IconComponent, AvatarComponent, TruncatePipe], - standalone: true, -}) -export class ProgramSidebarCardComponent { - @Input() program!: Program; -} diff --git a/projects/social_platform/src/app/office/features/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 deleted file mode 100644 index 04404aabc..000000000 --- a/projects/social_platform/src/app/office/features/project-rating/components/boolean-criterion/boolean-criterion.component.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** @format */ - -import { Component, forwardRef, Input } from "@angular/core"; -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; -import { IconComponent } from "@ui/components"; -import { noop } from "rxjs"; - -/** - * Компонент булевого критерия оценки (чекбокс) - * - * Функциональность: - * - Отображает чекбокс для булевых критериев оценки - * - Поддерживает кастомный дизайн с иконкой галочки - * - Реализует ControlValueAccessor для интеграции с Angular Forms - * - Обрабатывает клики как по чекбоксу, так и по всей области - * - Поддержка отключенного состояния - * - * Входные параметры: - * @Input disabled - флаг отключенного состояния (по умолчанию false) - * - * Внутренние свойства: - * - isChecked - текущее состояние чекбокса (true/false) - * - onChange - функция обратного вызова для уведомления об изменениях - * - onTouched - функция обратного вызова для уведомления о касании - * - * Методы ControlValueAccessor: - * - writeValue - установка значения извне - * - registerOnChange - регистрация обработчика изменений - * - registerOnTouched - регистрация обработчика касания - * - setDisabledState - установка отключенного состояния - * - * Обработчики событий: - * - onChanged - обработка изменения состояния через input[type="checkbox"] - * - onClickLog - обработка клика по всей области компонента - */ -@Component({ - selector: "app-boolean-criterion", - templateUrl: "./boolean-criterion.component.html", - styleUrl: "./boolean-criterion.component.scss", - standalone: true, - imports: [IconComponent], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => BooleanCriterionComponent), - multi: true, - }, - ], -}) -export class BooleanCriterionComponent implements ControlValueAccessor { - @Input() disabled = false; - - isChecked = false; - onChange: (val: boolean) => void = noop; - onTouched: () => void = noop; - - writeValue(val: boolean): void { - this.isChecked = val; - } - - registerOnChange(fn: (v: boolean) => void): void { - this.onChange = fn; - } - - registerOnTouched(fn: () => void): void { - this.onTouched = fn; - } - - setDisabledState?(isDisabled: boolean): void { - this.disabled = isDisabled; - } - - /** - * Обработчик изменения состояния чекбокса - * Вызывается при клике непосредственно по input[type="checkbox"] - */ - onChanged(event: Event) { - const target = event.target as HTMLInputElement; - this.isChecked = target && target.checked; - this.onChange(this.isChecked); - this.onTouched(); - } - - /** - * Обработчик клика по всей области компонента - * Переключает состояние чекбокса при клике в любом месте компонента - */ - onClickLog() { - this.isChecked = !this.isChecked; - } -} diff --git a/projects/social_platform/src/app/office/features/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 deleted file mode 100644 index e88633980..000000000 --- a/projects/social_platform/src/app/office/features/project-rating/components/range-criterion-input/range-criterion-input.component.html +++ /dev/null @@ -1,21 +0,0 @@ - - -
- -  / - {{ max }} -
diff --git a/projects/social_platform/src/app/office/features/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 deleted file mode 100644 index 9515655c9..000000000 --- a/projects/social_platform/src/app/office/features/project-rating/components/range-criterion-input/range-criterion-input.component.ts +++ /dev/null @@ -1,135 +0,0 @@ -/** @format */ - -import { ChangeDetectorRef, Component, forwardRef, Input } from "@angular/core"; -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; - -/** - * Компонент поля ввода для числовых критериев с ограничением диапазона - * - * Функциональность: - * - Поле ввода числовых значений с ограничением максимального значения - * - Автоматическое ограничение значения при потере фокуса - * - Блокировка ввода недопустимых символов (e, E, +, -) - * - Предотвращение вставки нечисловых символов - * - Реализует ControlValueAccessor для интеграции с Angular Forms - * - Поддержка состояния ошибки для стилизации - * - Автоматическое позиционирование курсора в конец при фокусе - * - * Входные параметры: - * @Input max - максимальное допустимое значение (по умолчанию 10) - * @Input error - флаг состояния ошибки для стилизации - * - * Внутренние свойства: - * - value - текущее значение поля (number | null) - * - disabled - флаг отключенного состояния - * - * Особенности: - * - Переключение типа input с number на text для корректного позиционирования курсора - * - Валидация значения при потере фокуса с автоматической коррекцией - * - Блокировка ввода экспоненциальной записи и знаков - */ -@Component({ - selector: "app-range-criterion-input", - templateUrl: "./range-criterion-input.component.html", - styleUrl: "./range-criterion-input.component.scss", - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => RangeCriterionInputComponent), - multi: true, - }, - ], - standalone: true, -}) -export class RangeCriterionInputComponent implements ControlValueAccessor { - @Input() max = 10; - @Input() error = false; - - value!: number | null; - - constructor(private readonly cdref: ChangeDetectorRef) {} - - /** - * Обработчик ввода значения - * Парсит введенное значение и уведомляет о изменении - */ - onInput(event: Event): void { - const target = event.currentTarget as HTMLInputElement; - const value = target.value ? Number.parseInt(target.value) : null; - - this.value = value; - this.onChange(value); - } - - /** - * Обработчик вставки из буфера обмена - * Блокирует вставку нечисловых символов - */ - onPaste(event: ClipboardEvent): void { - const pasteData = event.clipboardData?.getData("text/plain"); - if (pasteData && pasteData.match(/[^0-9]/)) { - event.preventDefault(); - } - } - - /** - * Обработчик нажатия клавиш - * Блокирует ввод с��мволов экспоненциальной записи и знаков - */ - onKeydown(event: KeyboardEvent): void { - if (["e", "E", "+", "-"].some(char => event.key === char)) { - event.preventDefault(); - } - } - - /** - * Обработчик потери фокуса - * Ограничивает значение максимумом и уведомляет о касании - */ - onBlur(): void { - if (this.value) { - const val = Math.min(this.value, this.max); - - this.value = val; - this.onChange(val); - } - this.onTouch(); - } - - // Методы ControlValueAccessor - writeValue(value: number): void { - setTimeout(() => { - this.value = value; - this.cdref.detectChanges(); - }); - } - - onChange: (value: number | null) => void = () => {}; - - registerOnChange(fn: (v: number | null) => void): void { - this.onChange = fn; - } - - onTouch: () => void = () => {}; - - registerOnTouched(fn: () => void): void { - this.onTouch = fn; - } - - disabled = false; - - setDisabledState(isDisabled: boolean) { - this.disabled = isDisabled; - } - - /** - * Перемещение курсора в конец поля при фокусе - * Временно меняет тип поля для корректного позиционирования - */ - moveCursorToEnd(event: FocusEvent) { - const input = event.target as HTMLInputElement; - input.type = "text"; - input.selectionStart = input.selectionEnd = input.value.length; - input.type = "number"; - } -} diff --git a/projects/social_platform/src/app/office/features/project-rating/project-rating.component.html b/projects/social_platform/src/app/office/features/project-rating/project-rating.component.html deleted file mode 100644 index 2b04c39eb..000000000 --- a/projects/social_platform/src/app/office/features/project-rating/project-rating.component.html +++ /dev/null @@ -1,45 +0,0 @@ - -
-
- @for (criterion of criteria; track $index) { @if (criterion.type === "int") { -
- - -
- } @if (criterion.type === "bool") { -
- - -
- } } -
-
- @for (criterion of criteria; track $index) { @if (criterion.type === "str") { -
- - - @if (form.get(criterion.id.toString())?.errors?.['maxlength']) { -
- {{ errorMessage.VALIDATION_TOO_LONG }} - максимум - {{ form.get(criterion.id.toString())?.errors?.['maxlength']?.['requiredLength'] }} символов -
- } -
- } } -
-
diff --git a/projects/social_platform/src/app/office/features/project-rating/project-rating.component.ts b/projects/social_platform/src/app/office/features/project-rating/project-rating.component.ts deleted file mode 100644 index 2ecd39c27..000000000 --- a/projects/social_platform/src/app/office/features/project-rating/project-rating.component.ts +++ /dev/null @@ -1,222 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { - ChangeDetectionStrategy, - Component, - forwardRef, - Input, - OnDestroy, - signal, -} from "@angular/core"; -import { - ControlValueAccessor, - FormControl, - FormGroup, - NG_VALIDATORS, - NG_VALUE_ACCESSOR, - ReactiveFormsModule, - Validator, - Validators, - ValidationErrors, - AbstractControl, -} from "@angular/forms"; -import { TextareaComponent } from "@ui/components/textarea/textarea.component"; -import { ProjectRatingCriterion } from "@office/program/models/project-rating-criterion"; -import { noop, Subscription } from "rxjs"; -import { BooleanCriterionComponent } from "./components/boolean-criterion/boolean-criterion.component"; -import { RangeCriterionInputComponent } from "./components/range-criterion-input/range-criterion-input.component"; -import { ErrorMessage } from "@error/models/error-message"; -import { ControlErrorPipe } from "@corelib"; - -/** - * Компонент рейтинга проекта - * Предоставляет интерфейс для оценки проекта по различным критериям - * Поддерживает три типа критериев: - * - int: числовая оценка в диапазоне (например, от 1 до 10) - * - bool: булевая оценка (да/нет) - * - str: текстовый комментарий - * - * Реализует ControlValueAccessor для интеграции с Angular Forms - * и Validator для валидации введенных данных - */ -@Component({ - selector: "app-project-rating", - standalone: true, - imports: [ - CommonModule, - TextareaComponent, - RangeCriterionInputComponent, - BooleanCriterionComponent, - ReactiveFormsModule, - ], - templateUrl: "./project-rating.component.html", - styleUrl: "./project-rating.component.scss", - changeDetection: ChangeDetectionStrategy.OnPush, - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => ProjectRatingComponent), - multi: true, - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => ProjectRatingComponent), - multi: true, - }, - ], -}) -export class ProjectRatingComponent implements OnDestroy, ControlValueAccessor, Validator { - /** - * Сеттер для критериев оценки - * При установке новых критериев создает соответствующие FormControl'ы - * и настраивает отслеживание изменений - * @param val - массив критериев для оценки проекта - */ - @Input({ required: true }) - set criteria(val: ProjectRatingCriterion[]) { - if (!val) return; - this._criteria.set(val); - this.createFormControls(val); - this.trackFormValueChange(); - } - - /** - * Геттер для получения текущих критериев - * @returns массив критериев оценки - */ - get criteria(): ProjectRatingCriterion[] { - return this._criteria(); - } - - @Input() - set disabled(value: boolean) { - this._disabled = value; - if (this.form) { - value ? this.form.disable() : this.form.enable(); - } - } - - get disabled(): boolean { - return this._disabled; - } - - private _disabled = false; - - @Input() currentUserId!: number; - - /** Сигнал для хранения критериев оценки */ - _criteria = signal([]); - - /** Форма для управления всеми критериями оценки */ - form!: FormGroup; - - errorMessage = ErrorMessage; - - /** - * Объект с функциями-создателями FormControl для разных типов критериев - * Каждый тип критерия имеет свою логику создания контрола и валидации - */ - controlCreators: Record FormControl> = { - // Числовой критерий - обязательное поле - int: val => new FormControl(val, [Validators.required]), - // Булевый критерий - преобразование строки в boolean - bool: val => new FormControl(val ? JSON.parse((val as string).toLowerCase()) : false), - // Строковый критерий - без валидации (комментарии опциональны) - str: val => new FormControl(val, Validators.maxLength(50)), - }; - - /** Сигнал для хранения подписок */ - subscriptions$ = signal([]); - - // Методы ControlValueAccessor - /** Функция обратного вызова для уведомления об изменениях */ - onChange: (val: unknown) => void = noop; - /** Функция обратного вызова для уведомления о касании */ - onTouched: () => void = noop; - - /** - * Установка значения в компонент (ControlValueAccessor) - * @param val - значения для установки в форму - */ - writeValue(val: typeof this.form.value): void { - if (val) { - this.form.patchValue(val); - } - } - - /** - * Регистрация функции обратного вызова для изменений (ControlValueAccessor) - * @param fn - функция для вызова при изменении значений - */ - registerOnChange(fn: (v: unknown) => void): void { - this.onChange = fn; - } - - /** - * Регистрация функции обратного вызова для касания (ControlValueAccessor)\ - * @param fn - функция для вызова при касании компонента - */ - registerOnTouched(fn: () => void): void { - this.onTouched = fn; - } - - /** - * Валидация формы (Validator) - * Проверяет, что все обязательные критерии заполнены - * @param _ - контрол для валидации (не используется) - * @returns объект с ошибками валидации или null если валидация прошла - */ - validate(_: AbstractControl): ValidationErrors | null { - let output: ValidationErrors | null = null; - - if (this.form.invalid) { - // Проверка каждого контрола на наличие ошибок\ - Object.values(this.form.controls).forEach(control => { - if (control.errors !== null) { - output = { required: ErrorMessage.VALIDATION_UNFILLED_CRITERIA }; - } - }); - } - return output; - } - - /** - * Очистка ресурсов при уничтожении компонента - */ - ngOnDestroy(): void { - this.subscriptions$().forEach(subscription => subscription.unsubscribe()); - } - - /** - * Создание FormControl'ов для каждого критерия - * Использует соответствующий создатель контрола в зависимости от типа критерия - * @param criteria - массив критериев для создания контролов - */ - private createFormControls(criteria: ProjectRatingCriterion[]): void { - const formGroupControls: Record = {}; - - criteria.forEach(criterion => { - const controlCreator = this.controlCreators[criterion.type]; - formGroupControls[criterion.id] = controlCreator(criterion.value); - }); - - this.form = new FormGroup(formGroupControls); - - if (this.disabled) { - this.form.disable(); - } - } - - /** - * Настройка отслеживания изменений в форме - * Подписывается на изменения значений и уведомляет родительский компонент - */ - private trackFormValueChange(): void { - const trackChanged$ = this.form.valueChanges.subscribe(val => { - this.onChange(val); - }); - - this.subscriptions$().push(trackChanged$); - } -} 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 deleted file mode 100644 index 712b9eb7f..000000000 --- a/projects/social_platform/src/app/office/features/response-card/response-card.component.html +++ /dev/null @@ -1,27 +0,0 @@ - - -@if (response) { -
-
- -
-
-

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

- -
- -

{{ response.whyMe }}

- - @if(response.accompanyingFile){ - - } -
-
-} diff --git a/projects/social_platform/src/app/office/features/response-card/response-card.component.spec.ts b/projects/social_platform/src/app/office/features/response-card/response-card.component.spec.ts deleted file mode 100644 index c150a9f20..000000000 --- a/projects/social_platform/src/app/office/features/response-card/response-card.component.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { ResponseCardComponent } from "./response-card.component"; - -describe("ResponseCardComponent", () => { - let component: ResponseCardComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ResponseCardComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(ResponseCardComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/features/response-card/response-card.component.ts b/projects/social_platform/src/app/office/features/response-card/response-card.component.ts deleted file mode 100644 index 1f7acaca1..000000000 --- a/projects/social_platform/src/app/office/features/response-card/response-card.component.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** @format */ - -import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; -import { VacancyResponse } from "@models/vacancy-response.model"; -import { UserRolePipe } from "@core/pipes/user-role.pipe"; -import { ButtonComponent } from "@ui/components"; -import { TagComponent } from "@ui/components/tag/tag.component"; -import { AvatarComponent } from "@ui/components/avatar/avatar.component"; -import { RouterLink } from "@angular/router"; -import { AsyncPipe } from "@angular/common"; -import { FileItemComponent } from "@ui/components/file-item/file-item.component"; -import { AuthService } from "@auth/services"; -import { ProjectVacancyCardComponent } from "@office/projects/detail/shared/project-vacancy-card/project-vacancy-card.component"; -import { IconComponent } from "@uilib"; - -/** - * Компонент карточки отклика на вакансию - * - * Функциональность: - * - Отображает информацию об отклике на вакансию (кандидат, роль, файлы) - * - Показывает аватар и основную информацию о кандидате - * - Отображает прикрепленные файлы (резюме, портфолио) - * - Предоставляет кнопки для принятия или отклонения отклика - * - Ссылка на профиль кандидата - * - Получает ID текущего пользователя для проверки прав доступа - * - * Входные параметры: - * @Input response - объект отклика на вакансию (обязательный) - * - * Выходные события: - * @Output reject - событие отклонения отклика, передает ID отклика - * @Output accept - событие принятия отклика, передает ID отклика - * - * Внутренние свойства: - * - profileId - ID текущего пользователя для проверки прав - */ -@Component({ - selector: "app-response-card", - templateUrl: "./response-card.component.html", - styleUrl: "./response-card.component.scss", - standalone: true, - imports: [IconComponent, FileItemComponent], -}) -export class ResponseCardComponent implements OnInit { - constructor(private readonly authService: AuthService) {} - - @Input({ required: true }) response!: VacancyResponse; - @Output() reject = new EventEmitter(); - @Output() accept = new EventEmitter(); - - profileId!: number; - - ngOnInit(): void { - this.authService.getProfile().subscribe({ - next: profile => { - this.profileId = profile.id; - }, - }); - } - - /** - * Обработчик принятия отклика - * Эмитит событие с ID отклика - */ - onAccept(responseId: number) { - this.accept.emit(responseId); - } - - /** - * Обработчик отклонения отклика - * Эмитит событие с ID отклика - */ - onReject(responseId: number) { - this.reject.emit(responseId); - } -} diff --git a/projects/social_platform/src/app/office/features/vacancy-card/vacancy-card.component.html b/projects/social_platform/src/app/office/features/vacancy-card/vacancy-card.component.html deleted file mode 100644 index 4a70714a0..000000000 --- a/projects/social_platform/src/app/office/features/vacancy-card/vacancy-card.component.html +++ /dev/null @@ -1,38 +0,0 @@ - - -@if (vacancy) { -
-
-
-

{{ vacancy.role | truncate: 20 }}

-
-
- @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.spec.ts b/projects/social_platform/src/app/office/features/vacancy-card/vacancy-card.component.spec.ts deleted file mode 100644 index 1ad277235..000000000 --- a/projects/social_platform/src/app/office/features/vacancy-card/vacancy-card.component.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { VacancyCardComponent } from "./vacancy-card.component"; -import { of } from "rxjs"; -import { AuthService } from "@auth/services"; - -describe("VacancyCardComponent", () => { - let component: VacancyCardComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - const authSpy = { - roles: of([]), - }; - - await TestBed.configureTestingModule({ - imports: [VacancyCardComponent], - providers: [{ provide: AuthService, useValue: authSpy }], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(VacancyCardComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/features/vacancy-card/vacancy-card.component.ts b/projects/social_platform/src/app/office/features/vacancy-card/vacancy-card.component.ts deleted file mode 100644 index 03e2538dc..000000000 --- a/projects/social_platform/src/app/office/features/vacancy-card/vacancy-card.component.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** @format */ - -import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; -import { Vacancy } from "@models/vacancy.model"; -import { IconComponent, ButtonComponent } from "@ui/components"; -import { TagComponent } from "@ui/components/tag/tag.component"; -import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; - -/** - * Компонент карточки вакансии - * - * Функциональность: - * - Отображает информацию о вакансии (название, описание, требуемые навыки) - * - Формирует строку навыков из массива требуемых навыков - * - Предоставляет кнопки для редактирования и удаления вакансии - * - Предотвращает всплытие событий при клике на кнопки действий - * - Отображает данные в JSON формате для отладки - * - * Входные параметры: - * @Input vacancy - объект вакансии (опциональный) - * - * Выходные события: - * @Output remove - событие удаления вакансии, передает ID вакансии - * @Output edit - событие редактирования вакансии, передает ID вакансии - * - * Внутренние свойства: - * - skillString - строка с перечислением требуемых навыков через разделитель - */ -@Component({ - selector: "app-vacancy-card", - templateUrl: "./vacancy-card.component.html", - styleUrl: "./vacancy-card.component.scss", - standalone: true, - imports: [IconComponent, ButtonComponent, TagComponent, TruncatePipe], -}) -export class VacancyCardComponent implements OnInit { - constructor() {} - - @Input() vacancy?: Vacancy; - @Output() remove = new EventEmitter(); - @Output() edit = new EventEmitter(); - - skillString = ""; - - ngOnInit(): void {} - - /** - * Обработчик удаления вакансии - * Предотвращает всплытие события и эмитит событие удаления - */ - onRemove(event: MouseEvent): void { - event.stopPropagation(); - event.preventDefault(); - - this.remove.emit(this.vacancy?.id); - } - - /** - * Обработчик редактирования вакансии - * Предотвращает всплытие события и эмитит событие редактирования - */ - onEdit(event: MouseEvent): void { - event.stopPropagation(); - event.preventDefault(); - - this.edit.emit(this.vacancy?.id); - } -} diff --git a/projects/social_platform/src/app/office/feed/feed.component.html b/projects/social_platform/src/app/office/feed/feed.component.html deleted file mode 100644 index 448daf628..000000000 --- a/projects/social_platform/src/app/office/feed/feed.component.html +++ /dev/null @@ -1,46 +0,0 @@ - - -
- -
- @if (feedItems().length > 0) { @for (item of feedItems(); track item.content.id) { @if - (item.typeModel === "vacancy") { - - } @else if (item.typeModel === "project") { - - } @else if (item.typeModel === "news") { @if (item.content.contentObject && - item.content.contentObject.hasOwnProperty("email")) { - - } @else if ( item.content.contentObject && !item.content.contentObject.hasOwnProperty("email") ) - { - - } } } } @else { -
- -

в данном разделе пока нет новостей

-
- } -
-
diff --git a/projects/social_platform/src/app/office/feed/feed.component.ts b/projects/social_platform/src/app/office/feed/feed.component.ts deleted file mode 100644 index 7b2609c01..000000000 --- a/projects/social_platform/src/app/office/feed/feed.component.ts +++ /dev/null @@ -1,255 +0,0 @@ -/** @format */ - -import { - AfterViewInit, - ChangeDetectionStrategy, - Component, - ElementRef, - inject, - OnDestroy, - OnInit, - signal, - ViewChild, -} from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { NewProjectComponent } from "@office/feed/shared/new-project/new-project.component"; -import { ActivatedRoute } from "@angular/router"; -import { FeedItem, FeedItemType } from "@office/feed/models/feed-item.model"; -import { - catchError, - concatMap, - fromEvent, - map, - noop, - of, - skip, - Subscription, - tap, - throttleTime, -} from "rxjs"; -import { ApiPagination } from "@models/api-pagination.model"; -import { FeedService } from "@office/feed/services/feed.service"; -import { ProjectNewsService } from "@office/projects/detail/services/project-news.service"; -import { ProfileNewsService } from "@office/profile/detail/services/profile-news.service"; -import { FeedFilterComponent } from "@office/feed/filter/feed-filter.component"; -import { NewsCardComponent } from "@office/features/news-card/news-card.component"; -import { OpenVacancyComponent } from "./shared/open-vacancy/open-vacancy.component"; -import { IconComponent } from "@ui/components"; - -@Component({ - selector: "app-feed", - standalone: true, - imports: [ - CommonModule, - IconComponent, - NewProjectComponent, - FeedFilterComponent, - NewsCardComponent, - OpenVacancyComponent, - IconComponent, - ], - changeDetection: ChangeDetectionStrategy.OnPush, - templateUrl: "./feed.component.html", - styleUrl: "./feed.component.scss", -}) -export class FeedComponent implements OnInit, AfterViewInit, OnDestroy { - route = inject(ActivatedRoute); - projectNewsService = inject(ProjectNewsService); - profileNewsService = inject(ProfileNewsService); - feedService = inject(FeedService); - - ngOnInit() { - const routeData$ = this.route.data - .pipe(map(r => r["data"])) - .subscribe((feed: ApiPagination) => { - this.feedItems.set(feed.results); - this.totalItemsCount.set(feed.count); - this.feedPage.set(feed.results.length); - - setTimeout(() => { - const observer = new IntersectionObserver(this.onFeedItemView.bind(this), { - root: document.querySelector(".office__body"), - rootMargin: "0px 0px 0px 0px", - threshold: 0, - }); - - document.querySelectorAll(".page__item").forEach(e => { - observer.observe(e); - }); - }); - }); - this.subscriptions$().push(routeData$); - - const queryParams$ = this.route.queryParams - .pipe( - map(params => params["includes"]), - tap(includes => { - this.includes.set(includes); - }), - skip(1), - concatMap(includes => { - this.totalItemsCount.set(0); - this.feedPage.set(0); - - return this.onFetch(0, this.perFetchTake(), includes ?? ["vacancy", "project", "news"]); - }) - ) - .subscribe(feed => { - this.feedItems.set(feed); - this.feedPage.set(feed.length); - - setTimeout(() => { - this.feedRoot?.nativeElement.children[0].scrollIntoView({ behavior: "smooth" }); - }); - }); - this.subscriptions$().push(queryParams$); - } - - ngAfterViewInit() { - const target = document.querySelector(".office__body"); - if (target) { - const scrollEvents$ = fromEvent(target, "scroll") - .pipe( - concatMap(() => this.onScroll().pipe(catchError(() => of({})))), - throttleTime(500) - ) - .subscribe(noop); - - this.subscriptions$().push(scrollEvents$); - } - } - - ngOnDestroy() { - this.subscriptions$().forEach($ => $.unsubscribe()); - } - - @ViewChild("feedRoot") feedRoot?: ElementRef; - - totalItemsCount = signal(0); - feedItems = signal([]); - feedPage = signal(0); - perFetchTake = signal(20); - includes = signal([]); - - subscriptions$ = signal([]); - - onLike(newsId: number) { - const itemIdx = this.feedItems().findIndex(n => n.content.id === newsId); - - const item = this.feedItems()[itemIdx]; - if (!item || item.typeModel !== "news") return; - - if ("email" in item.content.contentObject) { - this.profileNewsService - .toggleLike( - item.content.contentObject.id as unknown as string, - newsId, - !item.content.isUserLiked - ) - .subscribe(() => { - item.content.likesCount = item.content.isUserLiked - ? item.content.likesCount - 1 - : item.content.likesCount + 1; - item.content.isUserLiked = !item.content.isUserLiked; - - this.feedItems.update(items => { - const newItems = [...items]; - newItems.splice(itemIdx, 1, item); - return newItems; - }); - }); - } else if ("leader" in item.content.contentObject) { - this.projectNewsService - .toggleLike( - item.content.contentObject.id as unknown as string, - newsId, - !item.content.isUserLiked - ) - .subscribe(() => { - item.content.likesCount = item.content.isUserLiked - ? item.content.likesCount - 1 - : item.content.likesCount + 1; - item.content.isUserLiked = !item.content.isUserLiked; - - this.feedItems.update(items => { - const newItems = [...items]; - newItems.splice(itemIdx, 1, item); - return newItems; - }); - }); - } - } - - onFeedItemView(entries: IntersectionObserverEntry[]): void { - const items = entries - .map(e => { - return Number((e.target as HTMLElement).dataset["id"]); - }) - .map(id => this.feedItems().find(item => item.content.id === id)) - .filter(Boolean) as FeedItem[]; - - const projectNews = items.filter( - item => item.typeModel === "news" && !("email" in item.content.contentObject) - ); - const profileNews = items.filter( - item => item.typeModel === "news" && "email" in item.content.contentObject - ); - - projectNews.forEach(news => { - if (news.typeModel !== "news") return; - this.projectNewsService - .readNews(news.content.contentObject.id, [news.content.id]) - .subscribe(noop); - }); - - profileNews.forEach(news => { - if (news.typeModel !== "news") return; - this.profileNewsService - .readNews(news.content.contentObject.id, [news.content.id]) - .subscribe(noop); - }); - } - - onScroll() { - if (this.totalItemsCount() && this.feedItems().length >= this.totalItemsCount()) return of({}); - - const target = document.querySelector(".office__body"); - if (!target || !this.feedRoot) return of({}); - - const diff = - target.scrollTop - - this.feedRoot.nativeElement.getBoundingClientRect().height + - window.innerHeight; - - if (diff > 0) { - const currentOffset = this.feedItems().length; - - return this.onFetch(currentOffset, this.perFetchTake(), this.includes()).pipe( - tap((feedChunk: FeedItem[]) => { - const existingIds = new Set(this.feedItems().map(item => item.content.id)); - const uniqueNewItems = feedChunk.filter(item => !existingIds.has(item.content.id)); - - if (uniqueNewItems.length > 0) { - this.feedPage.update(page => page + uniqueNewItems.length); - this.feedItems.update(items => [...items, ...uniqueNewItems]); - } - }) - ); - } - - return of({}); - } - - onFetch( - offset: number, - limit: number, - includes: FeedItemType[] = ["project", "vacancy", "news"] - ) { - return this.feedService.getFeed(offset, limit, includes).pipe( - tap(res => { - this.totalItemsCount.set(res.count); - }), - map(res => res.results) - ); - } -} diff --git a/projects/social_platform/src/app/office/feed/feed.resolver.ts b/projects/social_platform/src/app/office/feed/feed.resolver.ts deleted file mode 100644 index 8f4529270..000000000 --- a/projects/social_platform/src/app/office/feed/feed.resolver.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { ResolveFn } from "@angular/router"; -import { FeedItem } from "@office/feed/models/feed-item.model"; -import { FeedService } from "@office/feed/services/feed.service"; -import { ApiPagination } from "@models/api-pagination.model"; - -/** - * резолвер ленты новостей - * - * Этот резолвер предназначен для предварительной загрузки данных ленты новостей - * перед отображением компонента. Выполняется автоматически при навигации на маршрут. - * - * ЧТО ДЕЛАЕТ: - * - Загружает первую страницу ленты новостей (20 элементов) - * - Получает параметры фильтрации из URL (includes) - * - Возвращает пагинированный список элементов ленты - * - * @param route - объект маршрута с параметрами запроса - * - * @returns Observable> - пагинированный список элементов ленты - */ -export const FeedResolver: ResolveFn> = route => { - const feedService = inject(FeedService); - - // Загружаем первую страницу ленты (offset: 0, limit: 20) - // По умолчанию включаем вакансии, новости и проекты - return feedService.getFeed( - 0, - 20, - route.queryParams["includes"] ?? ["vacancy", "news", "project"] - ); -}; diff --git a/projects/social_platform/src/app/office/feed/feed.routes.ts b/projects/social_platform/src/app/office/feed/feed.routes.ts deleted file mode 100644 index c7047e725..000000000 --- a/projects/social_platform/src/app/office/feed/feed.routes.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** @format */ - -import { Routes } from "@angular/router"; -import { FeedComponent } from "@office/feed/feed.component"; -import { FeedResolver } from "@office/feed/feed.resolver"; - -/** - * МАРШРУТЫ ДЛЯ МОДУЛЯ ЛЕНТЫ НОВОСТЕЙ - * - * Определяет конфигурацию маршрутизации для функциональности ленты новостей. - * Настраивает связь между URL путями и компонентами, а также предварительную загрузку данных. - * - * КОНФИГУРАЦИЯ МАРШРУТА: - * - Путь: "" (корневой путь модуля) - * - Компонент: FeedComponent (основной компонент ленты) - * - Резолвер: FeedResolver (предварительная загрузка данных) - * - * ПРИНЦИП РАБОТЫ: - * 1. При навигации на маршрут ленты активируется FeedResolver - * 2. Резолвер загружает начальные данные ленты с сервера - * 3. Данные передаются в FeedComponent через свойство 'data' - * 4. Компонент отображается с предзагруженными данными - * - * ПРЕИМУЩЕСТВА ИСПОЛЬЗОВАНИЯ РЕЗОЛВЕРА: - * - Данные загружаются до отображения компонента - * - Пользователь не видит пустую страницу во время загрузки - * - Улучшенный пользовательский опыт - * - Централизованная логика загрузки данных - */ -export const FEED_ROUTES: Routes = [ - { - path: "", // Корневой путь модуля ленты - component: FeedComponent, // Основной компонент для отображения - resolve: { - data: FeedResolver, // Предварительная загрузка данных через резолвер - }, - }, -]; 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 deleted file mode 100644 index c559a9872..000000000 --- a/projects/social_platform/src/app/office/feed/filter/feed-filter.component.html +++ /dev/null @@ -1,48 +0,0 @@ - - -
- -
- -
-
-
- Фильтр -
- - @if (filterOpen()) { - - } -
-
- - -
- @for (filterItem of feedFilterOptions; track $index) { -
-
- -
-

{{ filterItem.name }}

-
- } -
-
diff --git a/projects/social_platform/src/app/office/feed/filter/feed-filter.component.ts b/projects/social_platform/src/app/office/feed/filter/feed-filter.component.ts deleted file mode 100644 index fa2020263..000000000 --- a/projects/social_platform/src/app/office/feed/filter/feed-filter.component.ts +++ /dev/null @@ -1,206 +0,0 @@ -/** @format */ - -import { animate, style, transition, trigger } from "@angular/animations"; -import { CommonModule } from "@angular/common"; -import { - ChangeDetectionStrategy, - Component, - inject, - OnDestroy, - OnInit, - signal, -} from "@angular/core"; -import { ActivatedRoute, Router, RouterLink } from "@angular/router"; -import { ButtonComponent, CheckboxComponent, IconComponent } from "@ui/components"; -import { ClickOutsideModule } from "ng-click-outside"; -import { FeedService } from "@office/feed/services/feed.service"; -import { User } from "@auth/models/user.model"; -import { AuthService } from "@auth/services"; -import { Subscription } from "rxjs"; -import { feedFilter } from "projects/core/src/consts/filters/feed-filter.const"; - -/** - * КОМПОНЕНТ ФИЛЬТРАЦИИ ЛЕНТЫ - * - * Предоставляет интерфейс для фильтрации элементов ленты по типам контента. - * Позволяет пользователю выбирать, какие типы элементов отображать в ленте. - * Обновления URL происходят мгновенно при каждом изменении фильтра. - * - * ОСНОВНЫЕ ФУНКЦИИ: - * - Отображение выпадающего меню с опциями фильтрации - * - Управление состоянием активных фильтров - * - Мгновенная синхронизация фильтров с URL параметрами - * - Применение и сброс фильтров - * - * ДОСТУПНЫЕ ФИЛЬТРЫ: - * - Новости (news) - * - Вакансии (vacancy) - * - Новости проектов (project) - */ -@Component({ - selector: "app-feed-filter", - standalone: true, - imports: [ - CommonModule, - CheckboxComponent, - ButtonComponent, - ClickOutsideModule, - IconComponent, - RouterLink, - ], - templateUrl: "./feed-filter.component.html", - styleUrl: "./feed-filter.component.scss", - changeDetection: ChangeDetectionStrategy.OnPush, - animations: [ - trigger("dropdownAnimation", [ - transition(":enter", [ - style({ opacity: 0, transform: "scaleY(0.8)" }), - animate(".12s cubic-bezier(0, 0, 0.2, 1)"), - ]), - transition(":leave", [animate(".1s linear", style({ opacity: 0 }))]), - ]), - ], -}) -export class FeedFilterComponent implements OnInit, OnDestroy { - router = inject(Router); - route = inject(ActivatedRoute); - authService = inject(AuthService); - feedService = inject(FeedService); - - profile = signal(null); - subscriptions: Subscription[] = []; - - /** - * ИНИЦИАЛИЗАЦИЯ КОМПОНЕНТА - * - * ЧТО ДЕЛАЕТ: - * - Подписывается на изменения профиля пользователя - * - Читает текущие фильтры из URL параметров - * - Инициализирует состояние фильтров - */ - ngOnInit() { - const profileSubscription = this.authService.profile.subscribe(profile => { - this.profile.set(profile); - }); - - // Читаем активные фильтры из URL - const routeSubscription = this.route.queryParams.subscribe(queries => { - if (queries["includes"]) { - this.includedFilters.set(queries["includes"]); - } else { - this.includedFilters.set(""); - } - }); - - this.subscriptions.push(profileSubscription, routeSubscription); - } - - ngOnDestroy(): void { - this.subscriptions.forEach($ => $.unsubscribe()); - } - - // Состояние выпадающего меню фильтров - filterOpen = signal(false); - - /** - * ОПЦИИ ФИЛЬТРАЦИИ - * - * Массив доступных опций для фильтрации ленты: - * - label: отображаемое название на русском языке - * - value: значение для API запроса - */ - readonly feedFilterOptions = feedFilter; - - // Массив активных фильтров - includedFilters = signal(""); - - /** - * ОБНОВЛЕНИЕ URL С ТЕКУЩИМИ ФИЛЬТРАМИ - * - * Приватный метод для обновления URL параметров. - * Вызывается автоматически при любом изменении фильтров. - */ - private updateUrl(): void { - const includesParam = this.includedFilters().length > 0 ? this.includedFilters() : null; - - this.router - .navigate([], { - queryParams: { - includes: includesParam, - }, - relativeTo: this.route, - queryParamsHandling: "merge", - }) - .then(() => console.debug("Query change from FeedFilterComponent")); - } - - /** - * ПЕРЕКЛЮЧЕНИЕ ФИЛЬТРА С МГНОВЕННЫМ ОБНОВЛЕНИЕМ URL - * - * ЧТО ПРИНИМАЕТ: - * @param id - id для фильтра - * @param keyword - значение фильтра для переключения - * - * ЧТО ДЕЛАЕТ: - * - Добавляет фильтр, если он не активен - * - Удаляет фильтр, если он уже активен - * - Обрабатывает переключение между projects и projects/1 - * - Мгновенно обновляет URL параметры - */ - setFilter(keyword: string): void { - this.includedFilters.update(included => { - if (keyword.startsWith("project/")) { - // Если уже активен этот же вложенный фильтр - сбрасываем к "projects" - if (included === keyword) { - return "project"; - } - return keyword; - } - - // Если кликнули на "projects" - if (keyword === "project") { - if (included.startsWith("project/")) { - return "project"; - } - - if (included === "project") { - return ""; - } - - return "project"; - } - - if (included === keyword) { - return ""; - } - return keyword; - }); - - // Мгновенно обновляем URL - this.updateUrl(); - } - - /** - * СБРОС ВСЕХ ФИЛЬТРОВ - * - * ЧТО ДЕЛАЕТ: - * - Очищает все активные фильтры - * - Мгновенно обновляет URL - * - Возвращает ленту к состоянию по умолчанию - */ - resetFilter(): void { - this.includedFilters.set(""); - this.updateUrl(); - } - - /** - * ЗАКРЫТИЕ ВЫПАДАЮЩЕГО МЕНЮ - * - * ЧТО ДЕЛАЕТ: - * - Закрывает выпадающее меню при клике вне его области - * - Используется директивой ClickOutside - */ - onClickOutside(): void { - this.filterOpen.set(false); - } -} diff --git a/projects/social_platform/src/app/office/feed/models/feed-item.model.ts b/projects/social_platform/src/app/office/feed/models/feed-item.model.ts deleted file mode 100644 index 14dd6cbeb..000000000 --- a/projects/social_platform/src/app/office/feed/models/feed-item.model.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** @format */ - -import { FeedNews } from "@office/projects/models/project-news.model"; -import { Vacancy } from "@models/vacancy.model"; -import { Program } from "@office/program/models/program.model"; - -/** - * МОДЕЛИ ДАННЫХ ДЛЯ ЭЛЕМЕНТОВ ЛЕНТЫ - * - * Этот файл содержит TypeScript интерфейсы и типы для элементов ленты новостей. - * Определяет структуру данных для проектов, вакансий и новостей. - * - * ОСНОВНЫЕ ТИПЫ: - * - FeedProject: данные проекта в ленте - * - FeedItemType: возможные типы элементов ленты - * - FeedItem: объединенный тип для всех элементов ленты - */ -/** - * ИНТЕРФЕЙС ПРОЕКТА В ЛЕНТЕ - * - * Описывает структуру данных проекта, отображаемого в ленте новостей - * - * ПОЛЯ: - * @property id - уникальный идентификатор проекта - * @property name - название проекта - * @property shortDescription - краткое описание проекта - * @property industry - ID отрасли проекта - * @property imageAddress - URL изображения проекта - * @property viewsCount - количество просмотров проекта - * @property leader - ID руководителя проекта - */ -export interface FeedProject { - id: number; - name: string; - shortDescription: string; - industry: number; - imageAddress: string; - viewsCount: number; - leader: number; - partnerProgram: { - id: Program["id"]; - name: Program["name"]; - } | null; -} - -/** - * ТИП ЭЛЕМЕНТА ЛЕНТЫ - * - * Определяет возможные типы контента в ленте: - * - "vacancy": вакансия - * - "news": новость - * - "project": проект - */ -export type FeedItemType = "vacancy" | "news" | "project"; - -/** - * ОБЪЕДИНЕННЫЙ ТИП ЭЛЕМЕНТА ЛЕНТЫ - * - * Дискриминированное объединение типов для всех возможных элементов ленты. - * Каждый элемент имеет поле typeModel для определения типа и соответствующий content. - * - * ВАРИАНТЫ: - * - Проект: typeModel = "project", content = FeedProject - * - Вакансия: typeModel = "vacancy", content = Vacancy - * - Новость: typeModel = "news", content = FeedNews с дополнительным contentObject - */ -export type FeedItem = - | ({ typeModel: FeedItemType } & { - typeModel: "project"; - content: FeedProject; - }) - | { typeModel: "vacancy"; content: Vacancy } - | { typeModel: "news"; content: FeedNews & { contentObject: { id: number } } }; diff --git a/projects/social_platform/src/app/office/feed/services/feed.service.ts b/projects/social_platform/src/app/office/feed/services/feed.service.ts deleted file mode 100644 index 740d0cc1b..000000000 --- a/projects/social_platform/src/app/office/feed/services/feed.service.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** @format */ - -import { Injectable } from "@angular/core"; -import { ApiService } from "projects/core"; -import { Observable } from "rxjs"; -import { FeedItem, FeedItemType } from "@office/feed/models/feed-item.model"; -import { ApiPagination } from "@models/api-pagination.model"; -import { HttpParams } from "@angular/common/http"; - -/** - * СЕРВИС ДЛЯ РАБОТЫ С ЛЕНТОЙ НОВОСТЕЙ - * - * Предоставляет методы для взаимодействия с API ленты новостей. - * Обрабатывает запросы на получение элементов ленты с поддержкой - * пагинации и фильтрации по типам контента. - * - * ОСНОВНЫЕ ФУНКЦИИ: - * - Загрузка элементов ленты с сервера - * - Поддержка пагинации (offset/limit) - * - Фильтрация по типам контента - * - Обработка параметров запроса - * - * ИСПОЛЬЗУЕТСЯ В: - * - FeedComponent для загрузки данных - * - FeedResolver для предварительной загрузки - * - FeedFilterComponent для работы с фильтрами - */ -@Injectable({ - providedIn: "root", -}) -export class FeedService { - private readonly FEED_URL = "/feed"; - - constructor(private readonly apiService: ApiService) {} - - /** - * СИМВОЛ РАЗДЕЛЕНИЯ ФИЛЬТРОВ - * - * Используется для объединения множественных фильтров в строку - * для передачи в URL параметрах и API запросах - */ - readonly FILTER_SPLIT_SYMBOL = "|"; - - /** - * ПОЛУЧЕНИЕ ЭЛЕМЕНТОВ ЛЕНТЫ - * - * Основной метод для загрузки элементов ленты с сервера. - * Поддерживает пагинацию и фильтрацию по типам контента. - * - * ЧТО ПРИНИМАЕТ: - * @param offset - смещение для пагинации (с какого элемента начинать) - * @param limit - максимальное количество элементов для загрузки - * @param type - тип(ы) элементов для фильтрации (строка или массив) - * - * ЧТО ВОЗВРАЩАЕТ: - * @returns Observable> - пагинированный ответ с элементами ленты - * - * ЛОГИКА ОБРАБОТКИ ТИПОВ: - * - Если массив пустой: загружаются все типы по умолчанию - * - Если массив: элементы объединяются через разделитель - * - Если строка: используется как есть - */ - getFeed( - offset: number, - limit: number, - type: FeedItemType[] | FeedItemType - ): Observable> { - let reqType: string; - - // Обработка различных форматов параметра type - if (type.length === 0) { - // Если фильтры не выбраны, загружаем все типы по умолчанию - reqType = ["vacancy", "news", "projects"].join(this.FILTER_SPLIT_SYMBOL); - } else if (Array.isArray(type)) { - // Если передан массив типов, объединяем их через разделитель - reqType = type.join(this.FILTER_SPLIT_SYMBOL); - } else { - // Если передана строка, используем как есть - reqType = type; - } - - // Выполняем GET запрос к API с параметрами пагинации и фильтрации - return this.apiService.get( - `${this.FEED_URL}/`, - new HttpParams({ - fromObject: { - limit, - offset, - type: reqType, - }, - }) - ); - } -} diff --git a/projects/social_platform/src/app/office/feed/shared/closed-vacancy/closed-vacancy.component.html b/projects/social_platform/src/app/office/feed/shared/closed-vacancy/closed-vacancy.component.html deleted file mode 100644 index cf60586d5..000000000 --- a/projects/social_platform/src/app/office/feed/shared/closed-vacancy/closed-vacancy.component.html +++ /dev/null @@ -1,22 +0,0 @@ - - -
-
- newsItem.name -
-
PROCOLLAB
-
- {{ "2024-19-08 12:09:17" | dayjs: "format":"DD MMMM, HH:mm" }} -
-
-
-

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

-

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

-
- Backend developer - Найден -
-
diff --git a/projects/social_platform/src/app/office/feed/shared/closed-vacancy/closed-vacancy.component.scss b/projects/social_platform/src/app/office/feed/shared/closed-vacancy/closed-vacancy.component.scss deleted file mode 100644 index 1a2c93761..000000000 --- a/projects/social_platform/src/app/office/feed/shared/closed-vacancy/closed-vacancy.component.scss +++ /dev/null @@ -1,94 +0,0 @@ -@use "styles/responsive"; -@use "styles/typography"; - -.card { - padding: 20px; - background-color: var(--white); - border: 1px solid var(--medium-grey-for-outline); - border-radius: var(--rounded-xl); - - &__head { - margin-bottom: 10px; - } - - &__title { - margin-bottom: 10px; - text-align: center; - - @include typography.bold-body-16; - } - - &__text { - color: var(--dark-grey); - text-align: center; - - @include typography.body-14; - } - - &__action { - margin-top: 20px; - } -} - -.head { - display: flex; - align-items: center; - - &__avatar { - width: 40px; - height: 40px; - margin-right: 10px; - border-radius: 50%; - object-fit: cover; - } - - &__name { - max-width: 200px; - overflow: hidden; - color: var(--black); - text-overflow: ellipsis; - white-space: nowrap; - - @include typography.bold-body-14; - - @include responsive.apply-desktop { - @include typography.bold-body-16; - } - } - - &__date { - color: var(--dark-grey); - } -} - -.action { - @include responsive.apply-desktop { - display: flex; - align-items: center; - justify-content: space-between; - padding: 10px 20px; - border: 1px solid var(--medium-grey-for-outline); - border-radius: var(--rounded-xl); - } - - &__job { - display: block; - padding: 20px 0; - margin-bottom: 15px; - text-align: center; - border: 1px solid var(--medium-grey-for-outline); - border-radius: var(--rounded-xl); - - @include typography.bold-body-14; - - @include responsive.apply-desktop { - padding: 0; - margin-bottom: 0; - border: none; - } - } - - &__button { - width: 150px; - } -} diff --git a/projects/social_platform/src/app/office/feed/shared/closed-vacancy/closed-vacancy.component.spec.ts b/projects/social_platform/src/app/office/feed/shared/closed-vacancy/closed-vacancy.component.spec.ts deleted file mode 100644 index 868b8d06e..000000000 --- a/projects/social_platform/src/app/office/feed/shared/closed-vacancy/closed-vacancy.component.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { ClosedVacancyComponent } from "./closed-vacancy.component"; -import { RouterTestingModule } from "@angular/router/testing"; - -describe("OpenVacancyComponent", () => { - let component: ClosedVacancyComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ClosedVacancyComponent, RouterTestingModule], - }).compileComponents(); - - fixture = TestBed.createComponent(ClosedVacancyComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/feed/shared/closed-vacancy/closed-vacancy.component.ts b/projects/social_platform/src/app/office/feed/shared/closed-vacancy/closed-vacancy.component.ts deleted file mode 100644 index cdc5aafc6..000000000 --- a/projects/social_platform/src/app/office/feed/shared/closed-vacancy/closed-vacancy.component.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** @format */ - -import { Component } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { ButtonComponent } from "@ui/components"; -import { DayjsPipe } from "projects/core"; -import { Router, RouterLink } from "@angular/router"; - -/** - * КОМПОНЕНТ ЗАКРЫТОЙ ВАКАНСИИ - * - * Отображает карточку закрытой (неактивной) вакансии в ленте новостей. - * Предоставляет ограниченную информацию о вакансии и указывает на её статус. - * - * ОСНОВНЫЕ ФУНКЦИИ: - * - Отображение основной информации о закрытой вакансии - * - Показ статуса "закрыто" или "неактивно" - * - Навигация к детальной странице вакансии (если доступна) - * - Форматирование дат с помощью DayjsPipe - * - * ИСПОЛЬЗУЕМЫЕ КОМПОНЕНТЫ: - * - ButtonComponent: кнопки действий - * - TagComponent: теги и метки - * - DayjsPipe: форматирование дат - * - RouterLink: навигация между страницами - * - * ОТЛИЧИЯ ОТ ОТКРЫТОЙ ВАКАНСИИ: - * - Ограниченный функционал - * - Визуальные индикаторы закрытого статуса - * - Отсутствие кнопок подачи заявки - */ -@Component({ - selector: "app-closed-vacancy", - standalone: true, - imports: [CommonModule, ButtonComponent, DayjsPipe, RouterLink], - templateUrl: "./closed-vacancy.component.html", - styleUrl: "./closed-vacancy.component.scss", -}) -export class ClosedVacancyComponent { - /** - * КОНСТРУКТОР - * - * ЧТО ПРИНИМАЕТ: - * @param router - сервис маршрутизации Angular для программной навигации - * - * НАЗНАЧЕНИЕ: - * Инициализирует компонент с доступом к сервису маршрутизации - * для возможной навигации к детальной странице вакансии - */ - constructor(public readonly router: Router) {} -} diff --git a/projects/social_platform/src/app/office/feed/shared/new-project/new-project.component.html b/projects/social_platform/src/app/office/feed/shared/new-project/new-project.component.html deleted file mode 100644 index 272a23536..000000000 --- a/projects/social_platform/src/app/office/feed/shared/new-project/new-project.component.html +++ /dev/null @@ -1,65 +0,0 @@ - - -
-
- - -
-
-

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

- -
- -
- @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.ts b/projects/social_platform/src/app/office/feed/shared/new-project/new-project.component.ts deleted file mode 100644 index f426eb8c7..000000000 --- a/projects/social_platform/src/app/office/feed/shared/new-project/new-project.component.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** @format */ - -import { Component, Input } from "@angular/core"; -import { CommonModule } from "@angular/common"; -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"; -import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; - -/** - * КОМПОНЕНТ НОВОГО ПРОЕКТА - * - * Отображает карточку нового проекта в ленте новостей. - * Предоставляет краткую информацию о проекте и возможность перехода к детальной странице. - * - * ОСНОВНЫЕ ФУНКЦИИ: - * - Отображение основной информации о проекте - * - Показ изображения проекта - * - Отображение краткого описания - * - Показ количества просмотров - * - Навигация к детальной странице проекта - * - * ОТОБРАЖАЕМАЯ ИНФОРМАЦИЯ: - * - Название проекта - * - Краткое описание - * - Изображение проекта - * - Количество просмотров - * - Информация о руководителе (через AvatarComponent) - * - * ИСПОЛЬЗУЕМЫЕ КОМПОНЕНТЫ: - * - ButtonComponent: кнопки действий - * - AvatarComponent: аватар руководителя проекта - * - RouterLink: навигация к детальной странице - */ -@Component({ - selector: "app-new-project", - standalone: true, - imports: [ - CommonModule, - ButtonComponent, - AvatarComponent, - RouterLink, - DayjsPipe, - TruncatePipe, - IconComponent, - TagComponent, - ], - templateUrl: "./new-project.component.html", - styleUrl: "./new-project.component.scss", -}) -export class NewProjectComponent { - @Input() feedItem!: FeedProject; - - /** - * - * @param router - сервис маршрутизации Angular для программной навигации - * - * Инициализирует компонент с доступом к сервису маршрутизации - * для возможной навигации к детальной странице проекта - */ - 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 deleted file mode 100644 index d273f5360..000000000 --- a/projects/social_platform/src/app/office/feed/shared/open-vacancy/open-vacancy.component.html +++ /dev/null @@ -1,111 +0,0 @@ - - -@if (feedItem) { -
-
- vacancy-card-background - @if (feedItem.project; as project) { -
- - -

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

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

- {{ vacancy.role | truncate: 30 }} -

-

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

-
- -
- @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 (requiredSkills) { -
    - @for (skill of requiredSkills.slice(8); track $index) { - {{ skill.name }} - } -
- } -
- } @if (skillsLength > 8) { -
- {{ readFullSkills ? "cкрыть" : "подробнее" }} -
- } } -
- - @if (feedItem.description) { -
-
-

- @if (descriptionExpandable) { -
- {{ readFullDescription ? "скрыть" : "подробнее" }} -
- } -
-
- } -
- } -
- - @if (feedItem) { -
- перейти в проект - откликнуться на вакансию -
- } -
-} 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 deleted file mode 100644 index 333b5f630..000000000 --- a/projects/social_platform/src/app/office/feed/shared/open-vacancy/open-vacancy.component.ts +++ /dev/null @@ -1,130 +0,0 @@ -/** @format */ - -import { - AfterViewInit, - ChangeDetectorRef, - Component, - ElementRef, - Input, - ViewChild, -} from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { ButtonComponent } from "@ui/components"; -import { DayjsPipe, ParseBreaksPipe, ParseLinksPipe } from "projects/core"; -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"; -import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; - -/** - * - * Отображает карточку активной вакансии в ленте новостей с полным функционалом. - * Поддерживает развертывание/свертывание длинного контента и интерактивные элементы. - * - * - Отображение полной информации о вакансии - * - Развертывание/свертывание описания и списка навыков - * - Навигация к детальной странице вакансии - * - Форматирование текста с поддержкой ссылок и переносов строк - * - Отображение тегов и навыков - * - * - Кнопки "Показать полностью" / "Свернуть" - * - Теги навыков и требований - * - Ссылки на детальную страницу - * - * - DayjsPipe: форматирование дат - * - ParseLinksPipe: преобразование ссылок в кликабельные элементы - * - ParseBreaksPipe: обработка переносов строк - */ -@Component({ - selector: "app-open-vacancy", - standalone: true, - imports: [ - CommonModule, - ButtonComponent, - RouterLink, - TagComponent, - DayjsPipe, - ParseLinksPipe, - ParseBreaksPipe, - TruncatePipe, - AvatarComponent, - AdvertCardComponent, - ], - templateUrl: "./open-vacancy.component.html", - styleUrl: "./open-vacancy.component.scss", -}) -export class OpenVacancyComponent implements AfterViewInit { - @Input() feedItem!: Vacancy; - - /** - * - * @ViewChild skillsEl - ссылка на элемент со списком навыков - * @ViewChild descEl - ссылка на элемент с описанием вакансии - * - * Используются для определения необходимости показа кнопок развертывания - */ - @ViewChild("skillsEl") skillsEl?: ElementRef; - @ViewChild("descEl") descEl?: ElementRef; - - constructor( - public readonly router: Router, - private readonly cdRef: ChangeDetectorRef, - public readonly industryService: IndustryService - ) {} - - ngAfterViewInit(): void { - // Проверяем, превышает ли описание доступную высоту - const descElement = this.descEl?.nativeElement; - this.descriptionExpandable = descElement?.clientHeight < descElement?.scrollHeight; - - // Проверяем, превышает ли список навыков доступную высоту - const skillsElement = this.skillsEl?.nativeElement; - this.skillsExpandable = skillsElement?.clientHeight < skillsElement?.scrollHeight; - - // Обновляем UI после изменения флагов - this.cdRef.detectChanges(); - } - - // Флаги для определения возможности развертывания контента - descriptionExpandable!: boolean; // Можно ли развернуть описание - skillsExpandable!: boolean; // Можно ли развернуть список навыков - - // Состояние развертывания контента - readFullDescription = false; // Развернуто ли описание - readFullSkills = false; // Развернут ли список навыков - - /** - * - * @param elem - DOM элемент для анимации - * @param expandedClass - CSS класс для развернутого состояния - * @param isExpanded - текущее состояние (развернуто/свернуто) - * - * - Переключает визуальное состояние описания - * - Применяет анимацию развертывания/свертывания - * - Обновляет флаг состояния - */ - onExpandDescription(elem: HTMLElement, expandedClass: string, isExpanded: boolean): void { - expandElement(elem, expandedClass, isExpanded); - this.readFullDescription = !isExpanded; - } - - /** - * - * @param elem - DOM элемент для анимации - * @param expandedClass - CSS класс для развернутого состояния - * @param isExpanded - текущее состояние (развернуто/свернуто) - * - * ЧТО ДЕЛАЕТ: - * - Переключает визуальное состояние списка навыков - * - Применяет анимацию развертывания/свертывания - * - Обновляет флаг состояния - */ - onExpandSkills(elem: HTMLElement, expandedClass: string, isExpanded: boolean): void { - expandElement(elem, expandedClass, isExpanded); - this.readFullSkills = !isExpanded; - } -} diff --git a/projects/social_platform/src/app/office/members/filters/members-filters.component.spec.ts b/projects/social_platform/src/app/office/members/filters/members-filters.component.spec.ts deleted file mode 100644 index 0a099644a..000000000 --- a/projects/social_platform/src/app/office/members/filters/members-filters.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { MembersFiltersComponent } from "./members-filters.component"; - -describe("MembersFiltersComponent ", () => { - let component: MembersFiltersComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [MembersFiltersComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(MembersFiltersComponent); - component = fixture.componentInstance; - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/members/filters/members-filters.component.ts b/projects/social_platform/src/app/office/members/filters/members-filters.component.ts deleted file mode 100644 index 6bd0bd0cb..000000000 --- a/projects/social_platform/src/app/office/members/filters/members-filters.component.ts +++ /dev/null @@ -1,174 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { - ChangeDetectionStrategy, - Component, - EventEmitter, - Input, - Output, - signal, -} from "@angular/core"; -import { RangeInputComponent } from "@ui/components/range-input/range-input.component"; -import { MembersComponent } from "@office/members/members.component"; -import { ReactiveFormsModule } from "@angular/forms"; -import { AutoCompleteInputComponent } from "@ui/components/autocomplete-input/autocomplete-input.component"; -import { Specialization } from "@office/models/specialization.model"; -import { SpecializationsService } from "@office/services/specializations.service"; -import { SkillsService } from "@office/services/skills.service"; -import { Skill } from "@office/models/skill.model"; -import { ActivatedRoute, Router } from "@angular/router"; -import { CheckboxComponent } from "../../../ui/components/checkbox/checkbox.component"; - -/** - * Компонент фильтров для списка участников - * - * Предоставляет интерфейс для фильтрации участников по следующим критериям: - * - Ключевой навык (с автодополнением) - * - Специальность (с автодополнением) - * - Возрастной диапазон (слайдер) - * - Принадлежность к МосПолитеху (чекбокс) - * - * Все изменения фильтров синхронизируются с URL параметрами - * - * @component MembersFiltersComponent - */ -@Component({ - selector: "app-members-filters", - standalone: true, - imports: [ - CommonModule, - RangeInputComponent, - ReactiveFormsModule, - AutoCompleteInputComponent, - CheckboxComponent, - ], - templateUrl: "./members-filters.component.html", - styleUrl: "./members-filters.component.scss", - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class MembersFiltersComponent { - /** - * Событие, генерируемое при изменении фильтров - * (В данный момент не используется, но может быть полезно для будущих расширений) - */ - @Output() filtersChanged = new EventEmitter(); - - /** - * Форма фильтрации, передаваемая из родительского компонента - * Содержит поля: keySkill, speciality, age, isMosPolytechStudent - */ - @Input({ required: true }) filterForm!: MembersComponent["filterForm"]; - - /** - * Сигнал с опциями специальностей для автодополнения - * Обновляется при поиске специальностей - */ - specsOptions = signal([]); - - /** - * Сигнал с опциями навыков для автодополнения - * Обновляется при поиске навыков - */ - skillsOptions = signal([]); - - /** - * Конструктор компонента - * - * @param route - Сервис для работы с активным маршрутом - * @param router - Сервис для навигации и управления URL параметрами - * @param specsService - Сервис для получения списка специальностей - * @param skillsService - Сервис для получения списка навыков - */ - constructor( - private readonly route: ActivatedRoute, - private readonly router: Router, - private readonly specsService: SpecializationsService, - private readonly skillsService: SkillsService - ) {} - - /** - * Обработчик выбора специальности из списка автодополнения - * - * @param speciality - Выбранная специальность - */ - onSelectSpec(speciality: Specialization): void { - this.filterForm.patchValue({ speciality: speciality.name }); - } - - /** - * Очищает поле специальности - */ - onClearSpecField(): void { - this.filterForm.patchValue({ speciality: "" }); - } - - /** - * Выполняет поиск специальностей по запросу для автодополнения - * - * @param query - Поисковый запрос - */ - onSearchSpec(query: string): void { - this.specsService.getSpecializationsInline(query, 1000, 0).subscribe(({ results }) => { - this.specsOptions.set(results); - }); - } - - /** - * Обработчик выбора навыка из списка автодополнения - * - * @param skill - Выбранный навык - */ - onSelectSkill(skill: Skill): void { - this.filterForm.patchValue({ keySkill: skill.name }); - } - - /** - * Очищает поле навыка - */ - onClearSkillField(): void { - this.filterForm.patchValue({ keySkill: "" }); - } - - /** - * Выполняет поиск навыков по запросу для автодополнения - * - * @param query - Поисковый запрос - */ - onSearchSkill(query: string): void { - this.skillsService.getSkillsInline(query, 1000, 0).subscribe(({ results }) => { - this.skillsOptions.set(results); - }); - } - - /** - * Переключает состояние чекбокса "Студент МосПолитеха" - */ - onToggleStudentMosPolitech(): void { - this.filterForm.patchValue({ - isMosPolytechStudent: !this.filterForm.get("isMosPolytechStudent")?.value, - }); - } - - /** - * Очищает все фильтры - * - * Удаляет все параметры фильтрации из URL и сбрасывает форму к начальному состоянию - */ - clearFilters(): void { - this.router - .navigate([], { - queryParams: { - fullname: undefined, - is_mospolytech_student: undefined, - skills__contains: undefined, - speciality__icontains: undefined, - }, - relativeTo: this.route, - queryParamsHandling: "merge", - }) - .then(() => console.log("Query change from ProjectsComponent")); - - this.filterForm.reset(); - } -} diff --git a/projects/social_platform/src/app/office/members/members.component.html b/projects/social_platform/src/app/office/members/members.component.html deleted file mode 100644 index b07269c27..000000000 --- a/projects/social_platform/src/app/office/members/members.component.html +++ /dev/null @@ -1,41 +0,0 @@ - - -
- - -
-
-
-
- -
- -
    - @for (member of members; track member.id) { - -
  • - -
  • -
    - } -
-
-
- -
- - перейти в профиль - - -
- - - -
-
-
-
diff --git a/projects/social_platform/src/app/office/members/members.component.spec.ts b/projects/social_platform/src/app/office/members/members.component.spec.ts deleted file mode 100644 index f5b1d9753..000000000 --- a/projects/social_platform/src/app/office/members/members.component.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { MembersComponent } from "./members.component"; -import { RouterTestingModule } from "@angular/router/testing"; -import { of } from "rxjs"; -import { AuthService } from "@auth/services"; -import { ReactiveFormsModule } from "@angular/forms"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; - -describe("MembersComponent", () => { - let component: MembersComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - const authSpy = jasmine.createSpyObj([{ profile: of({}) }]); - - await TestBed.configureTestingModule({ - imports: [ - RouterTestingModule, - ReactiveFormsModule, - HttpClientTestingModule, - MembersComponent, - ], - providers: [{ provide: AuthService, useValue: authSpy }], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(MembersComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/members/members.component.ts b/projects/social_platform/src/app/office/members/members.component.ts deleted file mode 100644 index d3f0f3409..000000000 --- a/projects/social_platform/src/app/office/members/members.component.ts +++ /dev/null @@ -1,324 +0,0 @@ -/** @format */ - -import { - AfterViewInit, - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - ElementRef, - OnDestroy, - OnInit, - Renderer2, - ViewChild, -} from "@angular/core"; -import { ActivatedRoute, Router, RouterLink } from "@angular/router"; -import { - BehaviorSubject, - catchError, - concatMap, - debounceTime, - distinctUntilChanged, - filter, - fromEvent, - map, - noop, - of, - skip, - Subscription, - switchMap, - take, - tap, - throttleTime, -} from "rxjs"; -import { User } from "@auth/models/user.model"; -import { NavService } from "@services/nav.service"; -import { - AbstractControl, - FormBuilder, - FormGroup, - ReactiveFormsModule, - Validators, -} from "@angular/forms"; -import { containerSm } from "@utils/responsive"; -import { MemberService } from "@services/member.service"; -import { CommonModule } from "@angular/common"; -import { SearchComponent } from "@ui/components/search/search.component"; -import { MembersFiltersComponent } from "./filters/members-filters.component"; -import { ApiPagination } from "@models/api-pagination.model"; -import { InfoCardComponent } from "@office/features/info-card/info-card.component"; -import { BackComponent } from "@uilib"; -import { ButtonComponent } from "@ui/components"; -import { ProfileDataService } from "@office/profile/detail/services/profile-date.service"; -import { AuthService } from "@auth/services"; -import { SoonCardComponent } from "@office/shared/soon-card/soon-card.component"; - -/** - * Компонент для отображения списка участников с возможностью поиска и фильтрации - * - * Основные функции: - * - Отображение списка участников в виде карточек - * - Поиск участников по имени - * - Фильтрация по навыкам, специальности, возрасту и принадлежности к МосПолитеху - * - Бесконечная прокрутка для подгрузки дополнительных участников - * - Синхронизация фильтров с URL параметрами - * - * @component MembersComponent - */ -@Component({ - selector: "app-members", - templateUrl: "./members.component.html", - styleUrl: "./members.component.scss", - changeDetection: ChangeDetectionStrategy.OnPush, - standalone: true, - imports: [ - ReactiveFormsModule, - SearchComponent, - CommonModule, - RouterLink, - MembersFiltersComponent, - InfoCardComponent, - BackComponent, - ButtonComponent, - SoonCardComponent, - ], -}) -export class MembersComponent implements OnInit, OnDestroy, AfterViewInit { - /** - * Конструктор компонента - * - * Инициализирует формы поиска и фильтрации: - * - searchForm: форма для поиска по имени участника - * - filterForm: форма для фильтрации по навыкам, специальности, возрасту и статусу студента - * - * @param route - Сервис для работы с активным маршрутом - * @param router - Сервис для навигации - * @param navService - Сервис для управления навигацией - * @param fb - FormBuilder для создания реактивных форм - * @param memberService - Сервис для работы с данными участников - * @param cdref - ChangeDetectorRef для ручного запуска обнаружения изменений - */ - constructor( - private readonly route: ActivatedRoute, - private readonly router: Router, - private readonly navService: NavService, - private readonly fb: FormBuilder, - private readonly memberService: MemberService, - private readonly authService: AuthService, - private readonly cdref: ChangeDetectorRef, - private readonly renderer: Renderer2 - ) { - // Форма поиска с обязательным полем для ввода имени - this.searchForm = this.fb.group({ - search: ["", [Validators.required]], - }); - - // Форма фильтрации с полями для различных критериев - this.filterForm = this.fb.group({ - keySkill: ["", Validators.required], // Ключевой навык - speciality: ["", Validators.required], // Специальность - age: [[null, null]], // Диапазон возраста [от, до] - isMosPolytechStudent: [false], // Является ли студентом МосПолитеха - }); - } - - /** - * Инициализация компонента - * - * Выполняет: - * - Очистку URL параметров - * - Установку заголовка навигации - * - Загрузку начальных данных из резолвера - * - Настройку подписок на изменения форм и URL параметров - */ - ngOnInit(): void { - // Очищаем URL параметры при инициализации - this.router.navigate([], { queryParams: {} }); - - // Устанавливаем заголовок страницы - this.navService.setNavTitle("Участники"); - - const profileIdSub$ = this.authService.profile.pipe(filter(user => !!user)).subscribe({ - next: user => { - this.profileId = user.id; - }, - }); - - profileIdSub$ && this.subscriptions$.push(profileIdSub$); - - // Загружаем начальные данные участников из резолвера - this.route.data - .pipe( - take(1), - map(r => r["data"]) - ) - .subscribe((members: ApiPagination) => { - this.membersTotalCount = members.count; - this.members = members.results; - }); - - // Настраиваем синхронизацию значений форм с URL параметрами - this.saveControlValue(this.searchForm.get("search"), "fullname"); - this.saveControlValue(this.filterForm.get("keySkill"), "skills__contains"); - this.saveControlValue(this.filterForm.get("speciality"), "speciality__icontains"); - this.saveControlValue(this.filterForm.get("age"), "age"); - this.saveControlValue(this.filterForm.get("isMosPolytechStudent"), "is_mospolytech_student"); - - // Подписываемся на изменения URL параметров для обновления списка участников - this.route.queryParams - .pipe( - skip(1), // Пропускаем первое значение - distinctUntilChanged(), // Игнорируем одинаковые значения - debounceTime(100), // Задержка для предотвращения частых запросов - switchMap(params => { - // Формируем параметры для API запроса - const fetchParams: Record = {}; - - if (params["fullname"]) fetchParams["fullname"] = params["fullname"]; - if (params["skills__contains"]) - fetchParams["skills__contains"] = params["skills__contains"]; - if (params["speciality__icontains"]) - fetchParams["speciality__icontains"] = params["speciality__icontains"]; - if (params["is_mospolytech_student"]) - fetchParams["is_mospolytech_student"] = params["is_mospolytech_student"]; - - // Проверяем формат параметра возраста (должен быть "число,число") - if (params["age"] && /\d+,\d+/.test(params["age"])) fetchParams["age"] = params["age"]; - - this.searchParamsSubject$.next(fetchParams); - return this.onFetch(0, 20, fetchParams); - }) - ) - .subscribe(members => { - this.members = members; - this.membersPage = 1; - this.cdref.detectChanges(); - }); - } - - /** - * Инициализация после создания представления - * - * Настраивает обработчик события прокрутки для реализации бесконечной прокрутки - */ - ngAfterViewInit(): void { - const target = document.querySelector(".office__body"); - if (target) { - const scrollEvents$ = fromEvent(target, "scroll") - .pipe( - concatMap(() => this.onScroll().pipe(catchError(() => of({})))), - throttleTime(500) - ) - .subscribe(noop); - - this.subscriptions$.push(scrollEvents$); - } - } - - /** - * Очистка ресурсов при уничтожении компонента - */ - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $?.unsubscribe()); - } - - // Константы и свойства компонента - containerSm = containerSm; // Брейкпоинт для мобильных устройств - appWidth = window.innerWidth; // Ширина окна браузера - - @ViewChild("membersRoot") membersRoot?: ElementRef; // Ссылка на корневой элемент списка - @ViewChild("filterBody") filterBody!: ElementRef; // Ссылка на элемент фильтра - - membersTotalCount?: number; // Общее количество участников - membersPage = 1; // Текущая страница для пагинации - membersTake = 20; // Количество участников на странице - - subscriptions$: Subscription[] = []; // Массив подписок для очистки - - members: User[] = []; // Массив участников для отображения - - profileId?: number; - - searchParamsSubject$ = new BehaviorSubject>({}); // Subject для параметров поиска - - searchForm: FormGroup; // Форма поиска - filterForm: FormGroup; // Форма фильтрации - - /** - * Обработчик события прокрутки для бесконечной прокрутки - * - * @returns Observable с дополнительными участниками или пустой объект - */ - onScroll() { - // Проверяем, есть ли еще участники для загрузки - if (this.membersTotalCount && this.members.length >= this.membersTotalCount) return of({}); - - const target = document.querySelector(".office__body"); - if (!target || !this.membersRoot) return of({}); - - // Вычисляем, достиг ли пользователь конца списка - const diff = - target.scrollTop - - this.membersRoot.nativeElement.getBoundingClientRect().height + - window.innerHeight; - - if (diff > 0) { - // Загружаем следующую порцию участников - return this.onFetch( - this.membersPage * this.membersTake, - this.membersTake, - this.searchParamsSubject$.value - ).pipe( - tap(membersChunk => { - this.membersPage++; - this.members = [...this.members, ...membersChunk]; - this.cdref.detectChanges(); - }) - ); - } - - return of({}); - } - - /** - * Сохраняет значение элемента формы в URL параметрах - * - * @param control - Элемент управления формы - * @param queryName - Имя параметра в URL - */ - saveControlValue(control: AbstractControl | null, queryName: string): void { - if (!control) return; - - const sub$ = control.valueChanges.subscribe(value => { - this.router - .navigate([], { - queryParams: { [queryName]: value.toString() }, - relativeTo: this.route, - queryParamsHandling: "merge", - }) - .then(() => console.debug("QueryParams changed from MembersComponent")); - }); - - this.subscriptions$.push(sub$); - } - - /** - * Выполняет запрос на получение участников с заданными параметрами - * - * @param skip - Количество записей для пропуска (для пагинации) - * @param take - Количество записей для получения - * @param params - Дополнительные параметры фильтрации - * @returns Observable - Массив участников - */ - onFetch(skip: number, take: number, params?: Record) { - return this.memberService.getMembers(skip, take, params).pipe( - map((members: ApiPagination) => { - this.membersTotalCount = members.count; - return members.results; - }) - ); - } - - redirectToProfile(): void { - this.router.navigateByUrl(`/office/profile/${this.profileId}`); - } -} diff --git a/projects/social_platform/src/app/office/members/members.resolver.spec.ts b/projects/social_platform/src/app/office/members/members.resolver.spec.ts deleted file mode 100644 index 5e37509f5..000000000 --- a/projects/social_platform/src/app/office/members/members.resolver.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; -import { MembersResolver } from "./members.resolver"; -import { MemberService } from "@services/member.service"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; -import { ActivatedRouteSnapshot, RouterStateSnapshot } from "@angular/router"; -import { of } from "rxjs"; - -describe("MembersResolver", () => { - beforeEach(() => { - const memberSpy = jasmine.createSpyObj({ getMembers: of({}) }); - - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - providers: [{ provide: MemberService, useValue: memberSpy }], - }); - }); - - it("should be created", () => { - const result = TestBed.runInInjectionContext(() => - MembersResolver({} as ActivatedRouteSnapshot, {} as RouterStateSnapshot) - ); - expect(result).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/members/members.resolver.ts b/projects/social_platform/src/app/office/members/members.resolver.ts deleted file mode 100644 index 02b9c81f5..000000000 --- a/projects/social_platform/src/app/office/members/members.resolver.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { MemberService } from "@services/member.service"; -import { ResolveFn } from "@angular/router"; -import { ApiPagination } from "@models/api-pagination.model"; -import { User } from "@auth/models/user.model"; - -/** - * Резолвер для предварительной загрузки данных участников перед переходом на страницу - * - * Этот резолвер выполняется Angular Router'ом перед активацией маршрута /members - * и загружает первую страницу участников (20 записей) для отображения - * - * @returns Promise> - Возвращает промис с пагинированным списком пользователей - */ -/** - * Функция-резолвер для загрузки участников - * - * @param route - Не используется, но доступен объект ActivatedRouteSnapshot - * @param state - Не используется, но доступен объект RouterStateSnapshot - * @returns Observable> - Наблюдаемый объект с данными участников - */ -export const MembersResolver: ResolveFn> = () => { - const memberService = inject(MemberService); - - // Загружаем первые 20 участников (skip: 0, take: 20) - return memberService.getMembers(0, 20); -}; diff --git a/projects/social_platform/src/app/office/mentors/mentors.component.html b/projects/social_platform/src/app/office/mentors/mentors.component.html deleted file mode 100644 index 5b2da3ec2..000000000 --- a/projects/social_platform/src/app/office/mentors/mentors.component.html +++ /dev/null @@ -1,13 +0,0 @@ - - -
-
    - @for (member of members; track member.id) { - -
  • - -
  • -
    - } -
-
diff --git a/projects/social_platform/src/app/office/mentors/mentors.component.scss b/projects/social_platform/src/app/office/mentors/mentors.component.scss deleted file mode 100644 index e69de29bb..000000000 diff --git a/projects/social_platform/src/app/office/mentors/mentors.component.spec.ts b/projects/social_platform/src/app/office/mentors/mentors.component.spec.ts deleted file mode 100644 index 572606bd3..000000000 --- a/projects/social_platform/src/app/office/mentors/mentors.component.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { MentorsComponent } from "./mentors.component"; -import { RouterTestingModule } from "@angular/router/testing"; -import { of } from "rxjs"; -import { AuthService } from "@auth/services"; -import { ReactiveFormsModule } from "@angular/forms"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; - -describe("MembersComponent", () => { - let component: MentorsComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - const authSpy = jasmine.createSpyObj([{ profile: of({}) }]); - - await TestBed.configureTestingModule({ - imports: [ - RouterTestingModule, - ReactiveFormsModule, - HttpClientTestingModule, - MentorsComponent, - ], - providers: [{ provide: AuthService, useValue: authSpy }], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(MentorsComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/mentors/mentors.component.ts b/projects/social_platform/src/app/office/mentors/mentors.component.ts deleted file mode 100644 index b67a692e5..000000000 --- a/projects/social_platform/src/app/office/mentors/mentors.component.ts +++ /dev/null @@ -1,167 +0,0 @@ -/** @format */ - -import { - AfterViewInit, - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - ElementRef, - OnDestroy, - OnInit, - ViewChild, -} from "@angular/core"; -import { ActivatedRoute, RouterLink } from "@angular/router"; -import { - catchError, - concatMap, - fromEvent, - map, - noop, - of, - Subscription, - tap, - throttleTime, -} from "rxjs"; -import { AuthService } from "@auth/services"; -import { User } from "@auth/models/user.model"; -import { NavService } from "@services/nav.service"; -import { FormBuilder, FormGroup, Validators } from "@angular/forms"; -import { containerSm } from "@utils/responsive"; -import { MemberService } from "@services/member.service"; -import { ApiPagination } from "@models/api-pagination.model"; - -/** - * КОМПОНЕНТ СТРАНИЦЫ МЕНТОРОВ - * - * Назначение: Отображение списка менторов с возможностью поиска и бесконечной прокрутки - * - * Что делает: - * - Отображает список менторов в виде карточек - * - Реализует бесконечную прокрутку для загрузки дополнительных менторов - * - Предоставляет форму поиска по менторам (подготовлена, но не реализована) - * - Управляет пагинацией и состоянием загрузки - * - Отслеживает события прокрутки для автоматической подгрузки - * - Устанавливает заголовок навигации - * - * Что принимает: - * - Начальные данные менторов через ActivatedRoute (из MentorsResolver) - * - События прокрутки от пользователя - * - Потенциально поисковые запросы (форма подготовлена) - * - * Что возвращает: - * - Интерфейс со списком карточек менторов - * - Форму поиска (визуально готова, функционал не подключен) - * - Автоматическую подгрузку контента при прокрутке - * - * Механизм бесконечной прокрутки: - * - Отслеживание позиции скролла в .office__body - * - Вычисление расстояния до конца списка - * - Автоматический запрос следующей страницы при приближении к концу - * - Throttling запросов (500мс) для предотвращения спама - * - * Состояние пагинации: - * - membersTotalCount: общее количество менторов - * - membersPage: текущая страница для загрузки - * - membersTake: количество записей на страницу (20) - * - members: накопительный массив всех загруженных менторов - * - * Особенности реализации: - * - ChangeDetectionStrategy.OnPush для оптимизации производительности - * - Ручное управление detectChanges() после загрузки данных - * - Отписка от подписок в ngOnDestroy для предотвращения утечек памяти - * - Responsive дизайн с учетом ширины экрана - */ -@Component({ - selector: "app-mentors", - templateUrl: "./mentors.component.html", - styleUrl: "./mentors.component.scss", - changeDetection: ChangeDetectionStrategy.OnPush, - standalone: true, - imports: [RouterLink, MemberCardComponent], -}) -export class MentorsComponent implements OnInit, OnDestroy, AfterViewInit { - constructor( - private readonly route: ActivatedRoute, - private readonly authService: AuthService, - private readonly navService: NavService, - private readonly fb: FormBuilder, - private readonly memberService: MemberService, - private readonly cdref: ChangeDetectorRef - ) { - this.searchForm = this.fb.group({ - search: ["", [Validators.required]], - }); - } - - ngOnInit(): void { - this.navService.setNavTitle("Участники"); - - this.route.data.pipe(map(r => r["data"])).subscribe((members: ApiPagination) => { - this.membersTotalCount = members.count; - - this.members = members.results; - }); - } - - ngAfterViewInit(): void { - const target = document.querySelector(".office__body"); - if (target) - fromEvent(target, "scroll") - .pipe( - concatMap(() => this.onScroll().pipe(catchError(() => of({})))), - throttleTime(500) - ) - .subscribe(noop); - } - - ngOnDestroy(): void { - [this.members$, this.searchFormSearch$].forEach($ => $?.unsubscribe()); - } - - containerSm = containerSm; - appWidth = window.innerWidth; - - @ViewChild("membersRoot") membersRoot?: ElementRef; - membersTotalCount?: number; - membersPage = 1; - membersTake = 20; - - members: User[] = []; - members$?: Subscription; - - searchForm: FormGroup; - searchFormSearch$?: Subscription; - - onScroll() { - if (this.membersTotalCount && this.members.length >= this.membersTotalCount) return of({}); - - const target = document.querySelector(".office__body"); - if (!target || !this.membersRoot) return of({}); - - const diff = - target.scrollTop - - this.membersRoot.nativeElement.getBoundingClientRect().height + - window.innerHeight; - - if (diff > 0) { - return this.onFetch(); - } - - return of({}); - } - - onFetch() { - return this.memberService - .getMentors(this.membersPage * this.membersTake, this.membersTake) - .pipe( - tap((members: ApiPagination) => { - this.membersTotalCount = members.count; - this.members = [...this.members, ...members.results]; - - this.membersPage++; - - this.cdref.detectChanges(); - }) - ); - } -} diff --git a/projects/social_platform/src/app/office/mentors/mentors.resolver.spec.ts b/projects/social_platform/src/app/office/mentors/mentors.resolver.spec.ts deleted file mode 100644 index ce614e5c5..000000000 --- a/projects/social_platform/src/app/office/mentors/mentors.resolver.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; - -import { MentorsResolver } from "./mentors.resolver"; -import { MemberService } from "@services/member.service"; -import { ActivatedRouteSnapshot, RouterStateSnapshot } from "@angular/router"; -import { of } from "rxjs"; - -describe("MentorsResolver", () => { - beforeEach(() => { - const memberSpy = jasmine.createSpyObj("memberSpy", { getMentors: of({}) }); - - TestBed.configureTestingModule({ - providers: [{ provide: MemberService, useValue: memberSpy }], - }); - }); - - it("should be created", () => { - const result = TestBed.runInInjectionContext(() => - MentorsResolver({} as ActivatedRouteSnapshot, {} as RouterStateSnapshot) - ); - expect(result).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/mentors/mentors.resolver.ts b/projects/social_platform/src/app/office/mentors/mentors.resolver.ts deleted file mode 100644 index a6e17aee3..000000000 --- a/projects/social_platform/src/app/office/mentors/mentors.resolver.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { MemberService } from "@services/member.service"; -import { ResolveFn } from "@angular/router"; -import { ApiPagination } from "@models/api-pagination.model"; -import { User } from "@auth/models/user.model"; - -/** - * РЕЗОЛВЕР СТРАНИЦЫ МЕНТОРОВ - * - * Назначение: Предзагрузка списка менторов перед отображением страницы - * - * Что делает: - * - Выполняется автоматически перед активацией маршрута страницы менторов - * - Загружает первую страницу менторов из API (20 записей) - * - Обеспечивает немедленное отображение данных без состояния загрузки - * - * Что принимает: - * - Контекст маршрута (автоматически от Angular Router) - * - Доступ к MemberService через dependency injection - * - * Что возвращает: - * - Observable> - пагинированный список менторов - * - Структура содержит: - * * results: User[] - массив пользователей-менторов - * * count: number - общее количество менторов - * * next/previous: ссылки на следующую/предыдущую страницы - * - * Параметры загрузки: - * - offset: 0 (начинаем с первой записи) - * - limit: 20 (загружаем 20 менторов за раз) - * - * Использование данных: - * - Данные доступны в компоненте через route.data['data'] - * - Используются для инициализации списка и счетчика - * - Основа для последующей пагинации при скролле - */ -export const MentorsResolver: ResolveFn> = () => { - const memberService = inject(MemberService); - - return memberService.getMentors(0, 20); -}; diff --git a/projects/social_platform/src/app/office/models/api-pagination.model.ts b/projects/social_platform/src/app/office/models/api-pagination.model.ts deleted file mode 100644 index 69329dde2..000000000 --- a/projects/social_platform/src/app/office/models/api-pagination.model.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Интерфейс для пагинации API ответов - * Используется для разбивки больших списков данных на страницы - * - * @format - * @template T - тип элементов в результирующем массиве - */ - -export interface ApiPagination { - /** Общее количество элементов во всей коллекции */ - count: number; - /** Массив элементов текущей страницы */ - results: T[]; - /** URL для получения следующей страницы (null если это последняя страница) */ - next: string; - /** URL для получения предыдущей страницы (null если это первая страница) */ - previous: string; -} diff --git a/projects/social_platform/src/app/office/models/chat-message.model.ts b/projects/social_platform/src/app/office/models/chat-message.model.ts deleted file mode 100644 index 5d54fdccf..000000000 --- a/projects/social_platform/src/app/office/models/chat-message.model.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** @format */ - -import { User } from "@auth/models/user.model"; -import * as dayjs from "dayjs"; - -/** - * Модели для системы чатов - * - * ChatFile - представляет файл, прикрепленный к сообщению - * ChatMessage - модель сообщения в чате - * - * Содержат: - * - Информацию об авторе и времени создания - * - Текст сообщения и прикрепленные файлы - * - Статусы прочтения, редактирования и удаления - * - Ссылки на сообщения для ответов - */ -export class ChatFile { - name!: string; - // TODO: switch to mimetype when back will be ready - extension!: string; - size!: number; - link!: string; - user!: number; - datetimeUploaded!: string; -} -export class ChatMessage { - id!: number; - author!: User; - isEdited!: boolean; - isRead!: boolean; - isDeleted!: boolean; - // eslint-disable-next-line no-use-before-define - replyTo!: ChatMessage | null; - text!: string; - createdAt!: string; - files!: ChatFile[]; - - static default(): ChatMessage { - return { - author: User.default(), - createdAt: dayjs().format("YYYY-MM-DD hh:mm:ss"), - isEdited: false, - isDeleted: false, - isRead: true, - replyTo: null, - files: [ - { - name: "some name", - extension: "pdf", - size: 10000, - user: 12, - link: "sdfsdf", - datetimeUploaded: dayjs().format("YYYY-MM-DD HH:mm:ss"), - }, - ], - text: "Lorem ipsum dolor sit amet, consectetur adipisicing elit.Lorem ipsum dolor sit amet, consectetur adipisicing elit. Deserunt dolor excepturi iste, sunt suscipit voluptates? A ad aliquid aspernatur, at delectus dolore dolores doloribus eligendi fuga fugit incidunt, magni possimus quasi quod sapiente sint sunt? A amet beatae doloribus dolorum est iure, maxime obcaecati perspiciatis vero. Fugit molestiae neque, omnis provident sed temporibus vel? Accusamus aliquam, amet asperiores cupiditate enim exercitationem harum hic impedit in ipsa magnam minus molestiae necessitatibus neque nisi nulla numquam optio pariatur quaerat quam, quis quisquam rem saepe sapiente sunt totam voluptate. Autem, inventore, placeat! Aperiam cumque dolor eaque minus neque quasi quis repellat. Adipisci asperiores cumque illum, in libero magni nihil quod. Beatae blanditiis distinctio expedita illo iusto libero maxime neque odio odit provident, quis totam velit voluptate. Assumenda, deleniti ex fugiat in, non perferendis perspiciatis possimus praesentium quas quasi quis quos repellendus repudiandae sequi sunt tenetur veritatis? Alias architecto dolores expedita sequi voluptatibus? A alias aperiam asperiores atque distinctio, error eum eveniet ipsam laudantium nobis omnis perferendis perspiciatis possimus quasi quia repudiandae sit unde velit? Alias enim est ipsa vel voluptatem? Accusamus commodi delectus est minus molestias natus, reprehenderit rerum sit voluptas voluptatem! A ad minima neque nulla officiis quia quisquam repellat repudiandae, sunt!", - id: 1, - }; - } -} diff --git a/projects/social_platform/src/app/office/models/courses.model.ts b/projects/social_platform/src/app/office/models/courses.model.ts deleted file mode 100644 index 83e435941..000000000 --- a/projects/social_platform/src/app/office/models/courses.model.ts +++ /dev/null @@ -1,163 +0,0 @@ -/** - * Как в базе - * - * id — ID курса (создается автоматически). - * title — название курса (до 45 символов). - * description — описание курса (до 600 символов, можно оставить пустым). - * access_type — тип доступа: для всех, для участников программы, по подписке. - * partner_program — связанная программа (может быть пустой, кроме сценария “для участников программы”). - * avatar_file — аватар курса (файл, необязательно). - * card_cover_file — обложка карточки курса в каталоге (файл, необязательно). - * header_cover_file — обложка шапки внутри страницы курса (файл, необязательно). - * start_date — дата старта курса (может быть пустой). - * end_date — дата окончания курса (может быть пустой). - * status — статус контента курса: черновик, опубликован, завершен. - * is_completed — логический флаг завершения курса. - * completed_at — дата/время завершения курса. - * datetime_created — дата/время создания. - * datetime_updated — дата/время обновления. - * - * @format - */ - -export interface CourseCard { - id: number; - title: string; - accessType: "all_users" | "program_members" | "subscription_stub"; - status: "draft" | "published" | "ended"; - avatarUrl: string; - cardCoverUrl: string; - startDate: Date; - endDate: Date; - dateLabel: string; - isAvailable: boolean; - progressStatus: "not_started" | "in_progress" | "completed" | "blocked"; - percent: number; - actionState: "start" | "continue" | "lock"; -} - -export interface CourseDetail { - id: number; - title: string; - description: string; - accessType: "all_users" | "program_members" | "subscription_stub"; - status: "draft" | "published" | "ended"; - avatarUrl: string; - headerCoverUrl: string; - startDate: Date; - endDate: Date; - dateLabel: string; - isAvailable: boolean; - partnerProgramId: number; - progressStatus: "not_started" | "in_progress" | "completed" | "blocked"; - percent: number; - analyticsStub: any; -} - -/** - * Как в базе - * - id — уникальный идентификатор модуля. - course — курс, к которому относится модуль (обязательная связь). - title — название модуля, максимум 40 символов. - avatar_file — аватар модуля (необязательный файл). - start_date — дата старта модуля (обязательная). - status — статус модуля: draft (черновик), published (опубликован) - order — порядковый номер модуля внутри курса (по нему сортируется вывод). - datetime_created — дата/время создания. - datetime_updated — дата/время последнего обновления. - * - */ - -export interface CourseLessons { - id: number; - moduleId: number; - title: string; - order: number; - status: string; - isAvailable: boolean; - progressStatus: "not_started" | "in_progress" | "completed" | "blocked"; - percent: number; - currentTaskId: number; - taskCount: number; -} - -export interface CourseModule { - id: number; - courseId: number; - title: string; - order: number; - avatarUrl: string; - startDate: Date; - status: string; - isAvailable: boolean; - progressStatus: "not_started" | "in_progress" | "completed" | "blocked"; - percent: number; - lessons: CourseLessons[]; -} - -export interface CourseStructure { - courseId: number; - progressStatus: "not_started" | "in_progress" | "completed" | "blocked"; - percent: number; - modules: CourseModule[]; -} - -/** - * Как в базе - * - id — уникальный идентификатор урока. - module — модуль, к которому относится урок (обязательная связь). - title — название урока, максимум 45 символов. - status — статус урока: draft (черновик), published (опубликован) - order — порядковый номер урока внутри модуля. - datetime_created — дата/время создания. - datetime_updated — дата/время последнего обновления. - */ - -export interface Option { - id: number; - order: number; - text: string; -} - -export interface Task { - id: number; - order: number; - title: string; - answerTitle: string; - status: string; - taskKind: "question" | "informational"; - checkType: string | null; - informationalType: string | null; - questionType: string | null; - answerType: string | null; - bodyText: string; - videoUrl: string | null; - imageUrl: string | null; - attachmentUrl: string | null; - isAvailable: boolean; - isCompleted: boolean; - options: Option[]; -} - -export interface CourseLesson { - id: number; - moduleId: number; - courseId: number; - title: string; - progressStatus: "not_started" | "in_progress" | "completed" | "blocked"; - percent: number; - currentTaskId: number; - moduleOrder: number; - tasks: Task[]; -} - -export interface TaskAnswerResponse { - answerId: number; - status: "submitted" | "pending_review"; - isCorrect: boolean; - canContinue: boolean; - nextTaskId: number | null; - submittedAt: Date; -} diff --git a/projects/social_platform/src/app/office/models/file.model.ts b/projects/social_platform/src/app/office/models/file.model.ts deleted file mode 100644 index 1b57c628d..000000000 --- a/projects/social_platform/src/app/office/models/file.model.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** @format */ - -/** - * Модель файла в системе - * Представляет загруженный пользователем файл - * - * Содержит: - * - Метаданные файла (имя, размер, тип) - * - Ссылку для скачивания - * - Информацию о загрузке (пользователь, время) - */ -export class FileModel { - datetimeUploaded!: string; - extension!: string; - link!: string; - mimeType!: string; - name!: string; - size!: number; - user!: number; - - static default() { - return { - datetimeUploaded: "string", - extension: "string", - link: "string", - mimeType: "string", - name: "string", - size: 1, - user: 1, - }; - } -} diff --git a/projects/social_platform/src/app/office/models/filter-fields.model.ts b/projects/social_platform/src/app/office/models/filter-fields.model.ts deleted file mode 100644 index 0085ea9d8..000000000 --- a/projects/social_platform/src/app/office/models/filter-fields.model.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** @format */ - -import { Observable } from "rxjs"; - -export interface UnifiedOption { - id: string | number; - label: string; - value?: any; -} - -export interface FilterFieldConfig { - /** Название поля в query параметрах */ - queryParam: string; - /** Тип поля */ - type: "checkbox" | "radio" | "select" | "range" | "switch" | "autocomplete" | "slider"; - /** Заголовок поля */ - title: string; - /** Значение по умолчанию */ - defaultValue?: any; - /** Опции для select/radio */ - options?: Array<{ id: any; label: string; value?: any }>; - /** Источник данных (Observable) */ - dataSource?: Observable; - /** Поле для отображения из источника данных */ - displayField?: string; - /** Поле значения из источника данных */ - valueField?: string; - /** Дополнительные параметры для конкретного типа */ - config?: any; -} - -export interface FilterConfig { - /** Конфигурация полей фильтра */ - fields: FilterFieldConfig[]; - /** Параметры для сброса при clearFilters */ - clearParams?: string[]; - /** Заголовок фильтра */ - title?: string; - /** Показывать ли кнопку "Сбросить фильтры" */ - showClearButton?: boolean; -} diff --git a/projects/social_platform/src/app/office/models/goals.model.ts b/projects/social_platform/src/app/office/models/goals.model.ts deleted file mode 100644 index 7a4aac24f..000000000 --- a/projects/social_platform/src/app/office/models/goals.model.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Основная модель целей проекта - * Представляет цели со всей необходимой информацией - * - * 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/industry.model.ts b/projects/social_platform/src/app/office/models/industry.model.ts deleted file mode 100644 index 35766c393..000000000 --- a/projects/social_platform/src/app/office/models/industry.model.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** @format */ - -/** - * Модель отрасли/индустрии - * Представляет сферу деятельности проекта - * - * Содержит: - * - Уникальный идентификатор - * - Название отрасли - */ -export class Industry { - id!: number; - name!: string; -} diff --git a/projects/social_platform/src/app/office/models/invite.model.ts b/projects/social_platform/src/app/office/models/invite.model.ts deleted file mode 100644 index ddcd998c5..000000000 --- a/projects/social_platform/src/app/office/models/invite.model.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** @format */ - -import { User } from "@auth/models/user.model"; -import { Project } from "./project.model"; - -/** - * Модель приглашения в проект - * Представляет приглашение пользователя для участия в проекте - * - * Содержит: - * - Информацию о проекте и роли - * - Статус принятия приглашения - * - Мотивационное письмо и специализацию - * - Данные пользователя-отправителя и получателя - */ -export class Invite { - id!: number; - datetimeCreated!: string; - datetimeUpdated!: string; - isAccepted?: boolean; - motivationalLetter?: string; - project!: Project; - role!: string; - specialization?: string; - - user!: User; - - sender!: User; -} diff --git a/projects/social_platform/src/app/office/models/notification.model.ts b/projects/social_platform/src/app/office/models/notification.model.ts deleted file mode 100644 index 71bfe3d11..000000000 --- a/projects/social_platform/src/app/office/models/notification.model.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** @format */ - -/** - * Модель уведомления пользователя - * Представляет системные уведомления и сообщения - * - * Содержит: - * - Текст уведомления - * - Время прочтения (null если не прочитано) - * - Уникальный идентификатор - */ -export class Notification { - id!: number; - text!: string; - readAt!: string | null; -} diff --git a/projects/social_platform/src/app/office/models/partner-program-fields.model.ts b/projects/social_platform/src/app/office/models/partner-program-fields.model.ts deleted file mode 100644 index 3c76f10c6..000000000 --- a/projects/social_platform/src/app/office/models/partner-program-fields.model.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Модель поля для полей проекта-участника программы - * Содержит основную информацию о полях проекта, который учавствует в программе - * - * PartnerProgramFields содержит: - * - Основную информацию (название, описание, типы для полей) - * - * PartnerProgramFieldsValues содержит: - * - Основную информацию по значениям, которые содержатся в полях которые привязаны к программе(название и значение) - * - * @format - */ - -export class PartnerProgramFields { - id!: number; - name!: string; - label!: string; - fieldType!: "text" | "textarea" | "checkbox" | "select" | "radio" | "file"; - isRequired!: boolean; - helpText!: string; - options!: string[]; - showFilter?: boolean; -} - -export class PartnerProgramFieldsValues { - fieldName!: string; - value!: string; -} - -export class projectNewAdditionalProgramVields { - field_id!: number; - value_text!: string | boolean; -} diff --git a/projects/social_platform/src/app/office/models/partner.model.ts b/projects/social_platform/src/app/office/models/partner.model.ts deleted file mode 100644 index c15980716..000000000 --- a/projects/social_platform/src/app/office/models/partner.model.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** @format */ - -interface Company { - id: number; - name: string; - inn: string; -} - -export interface Partner { - id: number; - projecId: number; - company: Company; - contribution: string; - decisionMaker: number; -} - -export interface PartnerPostForm { - name: string; - inn: string; - contribution: string; - decisionMaker: number; -} diff --git a/projects/social_platform/src/app/office/models/resource.model.ts b/projects/social_platform/src/app/office/models/resource.model.ts deleted file mode 100644 index ca4a5542c..000000000 --- a/projects/social_platform/src/app/office/models/resource.model.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** @format */ - -export interface Resource { - id: number; - projectId: number; - type: "infrastructure" | "staff" | "financial" | "information"; - description: string; - partnerCompany: number; -} - -export interface ResourcePostForm { - projectId: number; - type: string; - description: string; - partnerCompany: number; -} diff --git a/projects/social_platform/src/app/office/models/skills-group.model.ts b/projects/social_platform/src/app/office/models/skills-group.model.ts deleted file mode 100644 index 0897e1398..000000000 --- a/projects/social_platform/src/app/office/models/skills-group.model.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** @format */ - -import { Skill } from "./skill.model"; // Assuming Skill is defined in a separate file - -/** - * Интерфейс для группы навыков - * Представляет категорию навыков с вложенным списком конкретных навыков - */ -export interface SkillsGroup { - /** Уникальный идентификатор группы навыков */ - id: number; - /** Название группы навыков (например, "Программирование", "Дизайн") */ - name: string; - /** Массив навыков, входящих в данную группу */ - skills: Skill[]; -} diff --git a/projects/social_platform/src/app/office/models/specialization.model.ts b/projects/social_platform/src/app/office/models/specialization.model.ts deleted file mode 100644 index 1fa2b0ffc..000000000 --- a/projects/social_platform/src/app/office/models/specialization.model.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** @format */ - -/** - * Модель специализации пользователя - * Представляет профессиональную специализацию - * - * Содержит: - * - Уникальный идентификатор - * - Название специализации - */ -export interface Specialization { - id: number; - name: string; -} diff --git a/projects/social_platform/src/app/office/models/specializations-group.model.ts b/projects/social_platform/src/app/office/models/specializations-group.model.ts deleted file mode 100644 index 49717c780..000000000 --- a/projects/social_platform/src/app/office/models/specializations-group.model.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** @format */ - -import type { Specialization } from "./specialization.model"; - -/** - * Модель группы специализаций - * Представляет категорию специализаций с вложенным списком - * - * Содержит: - * - Название группы специализаций - * - Массив специализаций в данной группе - */ -export interface SpecializationsGroup { - id: number; - name: string; - specializations: Specialization[]; -} diff --git a/projects/social_platform/src/app/office/models/step.model.ts b/projects/social_platform/src/app/office/models/step.model.ts deleted file mode 100644 index 7df3b4b13..000000000 --- a/projects/social_platform/src/app/office/models/step.model.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** @format */ - -/** - * Base interface for all step types - * Contains common properties shared across different question types - */ -interface BaseStep { - id: number; // Unique identifier for the step -} - -/** - * Всплывающее окно с дополнительной информацией - * Отображается после завершения шага для предоставления дополнительного контекста - */ -export interface Popup { - title: string | null; // Заголовок всплывающего окна - text: string | null; // Текстовое содержимое - fileLink: string | null; // URL к связанному файлу или ресурсу - ordinalNumber: number; // Порядковый номер для сортировки -} - -/** - * Информационный слайд - * - * Отображает образовательный контент без требования взаимодействия пользователя. - * Используется для представления концепций, объяснений или инструкций. - */ -export interface InfoSlide extends BaseStep { - text: string; // Основной текстовый контент слайда - description: string; // Дополнительное описание или контекст - files: string[]; // Массив URL файлов для отображения (изображения, документы) - popups: Popup[]; // Всплывающие окна для отображения после просмотра - videoUrl?: string; // Ссылка для видео -} - -/** - * Вопрос на соединение/сопоставление - * - * Требует от пользователей сопоставления элементов из двух колонок или соединения связанных концепций. - * Проверяет понимание отношений между различными элементами. - */ -export interface ConnectQuestion extends BaseStep { - connectLeft: { id: number; text?: string; file?: string }[]; // Элементы левой колонки - connectRight: { id: number; text?: string; file?: string }[]; // Элементы правой колонки - description: string; // Инструкции по выполнению сопоставления - files: string[]; // Дополнительные файлы для контекста - isAnswered: boolean; // Был ли вопрос уже отвечен - text: string; // Основной текст вопроса - popups: Popup[]; // Всплывающие окна для отображения после ответа -} - -/** - * Структура запроса для вопросов на соединение - * Массив пар соединений, выбранных пользователем - */ -export type ConnectQuestionRequest = { leftId: number; rightId: number }[]; - -/** - * Структура ответа для вопросов на соединение - * Показывает правильность каждого соединения - */ -export type ConnectQuestionResponse = { - leftId: number; - rightId: number; - isCorrect: boolean; // Правильно ли это соединение -}[]; - -/** - * Вопрос с единственным правильным ответом - * - * Представляет вопрос с несколькими вариантами, где только один ответ правильный. - * Наиболее распространенный тип оценочного вопроса. - */ -export interface SingleQuestion extends BaseStep { - answers: { id: number; text: string }[]; // Доступные варианты ответов - description: string; // Дополнительное описание или контекст - files: string[]; // Связанные файлы (изображения, документы) - isAnswered: boolean; // Был ли вопрос уже отвечен - text: string; // Основной текст вопроса - popups: Popup[]; // Всплывающие окна для отображения после ответа - videoUrl: string; -} - -/** - * Ответ об ошибке для вопросов с единственным ответом - * Возвращается, когда пользователь выбирает неправильный ответ - */ -export interface SingleQuestionError { - correctAnswer: number; // ID правильного варианта - isCorrect: boolean; // Был ли ответ правильным (всегда false для ошибок) -} - -/** - * Вопрос на исключение - * - * Представляет несколько элементов, где пользователи должны определить, какой не принадлежит - * или какие элементы должны быть исключены из группы. - */ -export interface ExcludeQuestion extends BaseStep { - answers: { id: number; text: string }[]; // Элементы для рассмотрения - description: string; // Инструкции для задачи исключения - files: string[]; // Связанные файлы для контекста - isAnswered: boolean; // Был ли вопрос уже отвечен - text: string; // Основной текст вопроса - popups: Popup[]; // Всплывающие окна для отображения после ответа -} - -/** - * Структура ответа для вопросов на исключение - */ -export interface ExcludeQuestionResponse { - isCorrect: boolean; // Был ли ответ пользователя правильным - wrongAnswers: number[]; // ID неправильно выбранных элементов -} - -/** - * Вопрос с письменным ответом - * - * Требует от пользователей предоставления текстового ответа. - * Может использоваться для коротких ответов, эссе или отправки кода. - */ -export interface WriteQuestion extends BaseStep { - answer: string | null; // Текущий ответ пользователя (если есть) - description: string; // Инструкции или дополнительный контекст - files: string[]; // Связанные файлы для справки - text: string; // Основной текст вопроса или подсказка - popups: Popup[]; // Всплывающие окна для отображения после отправки -} - -/** - * Объединенный тип, представляющий все возможные типы шагов - * Используется для типобезопасной обработки различных вариаций шагов - */ -export type StepType = - | InfoSlide - | ConnectQuestion - | SingleQuestion - | ExcludeQuestion - | WriteQuestion; diff --git a/projects/social_platform/src/app/office/models/vacancy-response.model.ts b/projects/social_platform/src/app/office/models/vacancy-response.model.ts deleted file mode 100644 index 4350a0d4c..000000000 --- a/projects/social_platform/src/app/office/models/vacancy-response.model.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** @format */ - -import { User } from "@auth/models/user.model"; -import { FileModel } from "./file.model"; - -/** - * Модель отклика на вакансию - * Представляет ответ пользователя на размещенную вакансию в проекте - * - * Содержит: - * - Информацию о пользователе, откликнувшемся на вакансию - * - Мотивационное письмо и сопроводительные файлы - * - Статус одобрения отклика - */ -export class VacancyResponse { - id!: number; - whyMe!: string; - isApproved?: boolean; - user!: User; - vacancy!: number; - accompanyingFile!: FileModel; -} diff --git a/projects/social_platform/src/app/office/models/vacancy.model.ts b/projects/social_platform/src/app/office/models/vacancy.model.ts deleted file mode 100644 index 0030391d9..000000000 --- a/projects/social_platform/src/app/office/models/vacancy.model.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** @format */ - -import { Project } from "@models/project.model"; -import { Skill } from "./skill.model"; - -/** - * Модель вакансии в проекте - * Представляет открытую позицию для участия в проекте - * - * Содержит: - * - Описание роли и требований - * - Необходимые навыки и опыт - * - Условия работы (формат, график, зарплата) - * - Связь с проектом и статус активности - */ -export class Vacancy { - id!: number; - role!: string; - isActive!: boolean; - project!: Project; - requiredSkills!: Skill[]; - description!: string; - requiredExperience!: string; - workFormat!: string; - salary!: string; - workSchedule!: string; - specialization?: string; - datetimeCreated!: string; - datetimeUpdated!: string; - - getSkillsNames(): string[] { - return this.requiredSkills.map(s => s.name); - } -} diff --git a/projects/social_platform/src/app/office/office.component.html b/projects/social_platform/src/app/office/office.component.html deleted file mode 100644 index 5c6330611..000000000 --- a/projects/social_platform/src/app/office/office.component.html +++ /dev/null @@ -1,128 +0,0 @@ - - -
- @if (authService.profile | async; as user) { -
- background-image -
-
-
- -
-

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

-

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

-

2022

-
- -
- @if (programs().length) { @for (program of programs(); track program.id) { - - } } -
-
- -
-
- - - @if (user !== undefined && invites !== undefined) { - - } -
- - -
-
-
-
-
- } - -
- wait -

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

-

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

- - Хорошо - -
-
- - -
-
-

Привет!

-
- -
-

- Рады знакомству 🙌
- Вы находитесь на платформе procollab – здесь проходит программа, на которую вы ранее - регистрировались через форму заявки -

- -

- Ваша программа и её закрытая группа (к которой у вас автоматически есть доступ) находится - в одноименной вкладке «программы» -

- -

- На платформе есть еще много интересного: цифровой профиль, проекты, новостная лента, чаты - и другое – рекомендуем изучить -

- -

- Поздравляем с регистрацией! Желаем удачи в прохождении программы :) -

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

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

-

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

- - Хорошо - -
-
- - -
diff --git a/projects/social_platform/src/app/office/office.component.spec.ts b/projects/social_platform/src/app/office/office.component.spec.ts deleted file mode 100644 index a22eaffd4..000000000 --- a/projects/social_platform/src/app/office/office.component.spec.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { OfficeComponent } from "./office.component"; -import { IndustryService } from "@services/industry.service"; -import { RouterTestingModule } from "@angular/router/testing"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; -import { of } from "rxjs"; -import { AuthService } from "@auth/services"; - -describe("OfficeComponent", () => { - let component: OfficeComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - const authSpy = { getUserRoles: of([]), profile: of({}) }; - - await TestBed.configureTestingModule({ - imports: [RouterTestingModule, HttpClientTestingModule, OfficeComponent], - providers: [IndustryService, { provide: AuthService, useValue: authSpy }], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(OfficeComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/office.component.ts b/projects/social_platform/src/app/office/office.component.ts deleted file mode 100644 index 96d0a807f..000000000 --- a/projects/social_platform/src/app/office/office.component.ts +++ /dev/null @@ -1,241 +0,0 @@ -/** @format */ - -import { Component, OnDestroy, OnInit, signal, Signal } from "@angular/core"; -import { IndustryService } from "@services/industry.service"; -import { forkJoin, map, noop, Subscription } from "rxjs"; -import { ActivatedRoute, Router, RouterOutlet, RouterLink } from "@angular/router"; -import { Invite } from "@models/invite.model"; -import { AuthService } from "@auth/services"; -import { User } from "@auth/models/user.model"; -import { ChatService } from "@services/chat.service"; -import { SnackbarComponent } from "@ui/components/snackbar/snackbar.component"; -import { DeleteConfirmComponent } from "@ui/components/delete-confirm/delete-confirm.component"; -import { ButtonComponent } from "@ui/components"; -import { ModalComponent } from "@ui/components/modal/modal.component"; -import { NavComponent } from "./features/nav/nav.component"; -import { ProfileControlPanelComponent, SidebarComponent } from "@uilib"; -import { AsyncPipe } from "@angular/common"; -import { InviteService } from "@services/invite.service"; -import { toSignal } from "@angular/core/rxjs-interop"; -import { ProgramSidebarCardComponent } from "./features/program-sidebar-card/program-sidebar-card.component"; -import { ProgramService } from "./program/services/program.service"; -import { Program } from "./program/models/program.model"; - -/** - * Главный компонент офиса - корневой компонент рабочего пространства - * Управляет общим состоянием приложения, навигацией и модальными окнами - * - * Принимает: - * - Данные о приглашениях через резолвер - * - События от сервисов (auth, chat, invite) - * - * Возвращает: - * - Рендерит основной интерфейс офиса с сайдбаром, навигацией и роутер-аутлетом - * - Управляет модальными окнами для верификации и приглашений - */ -@Component({ - selector: "app-office", - templateUrl: "./office.component.html", - styleUrl: "./office.component.scss", - standalone: true, - imports: [ - SidebarComponent, - NavComponent, - RouterOutlet, - ModalComponent, - ButtonComponent, - DeleteConfirmComponent, - SnackbarComponent, - AsyncPipe, - RouterLink, - ProfileControlPanelComponent, - ProgramSidebarCardComponent, - ], -}) -export class OfficeComponent implements OnInit, OnDestroy { - constructor( - private readonly industryService: IndustryService, - private readonly route: ActivatedRoute, - public readonly authService: AuthService, - private readonly inviteService: InviteService, - private readonly router: Router, - public readonly chatService: ChatService, - private readonly programService: ProgramService - ) {} - - invites: Signal = toSignal( - this.route.data.pipe( - map(r => r["invites"]), - map(invites => invites.filter((invite: Invite) => invite.isAccepted === null)) - ) - ); - - profile?: User; - - waitVerificationModal = false; - waitVerificationAccepted = false; - - showRegisteredProgramModal = signal(false); - - registeredProgramToShow?: Program | null = null; - - inviteErrorModal = false; - - protected readonly programs = signal([]); - - navItems: { - name: string; - icon: string; - link: string; - isExternal?: boolean; - isActive?: boolean; - }[] = []; - - subscriptions$: Subscription[] = []; - - ngOnInit(): void { - const globalSubscription$ = forkJoin([this.industryService.getAll()]).subscribe(noop); - this.subscriptions$.push(globalSubscription$); - - const profileSub$ = this.authService.profile.subscribe(profile => { - this.profile = profile; - this.buildNavItems(profile); - - if (!this.profile.doesCompleted()) { - this.router - .navigateByUrl("/office/onboarding") - .then(() => console.debug("Route changed from OfficeComponent")); - } else if (this.profile.verificationDate === null) { - this.waitVerificationModal = true; - } - }); - this.subscriptions$.push(profileSub$); - - this.chatService.connect().subscribe(() => { - this.chatService.onSetOffline().subscribe(evt => { - this.chatService.setOnlineStatus(evt.userId, false); - }); - - this.chatService.onSetOnline().subscribe(evt => { - this.chatService.setOnlineStatus(evt.userId, true); - }); - }); - - if (!this.router.url.includes("chats")) { - this.chatService.hasUnreads().subscribe(unreads => { - this.chatService.unread$.next(unreads); - }); - } - - if (localStorage.getItem("waitVerificationAccepted") === "true") { - this.waitVerificationAccepted = true; - } - - const programsSub$ = this.programService.getActualPrograms().subscribe({ - next: ({ results: programs }) => { - const resultPrograms = programs.filter( - (program: Program) => Date.now() < Date.parse(program.datetimeRegistrationEnds) - ); - this.programs.set(resultPrograms.slice(0, 3)); - this.tryShowRegisteredProgramModal(); - }, - }); - - this.subscriptions$.push(programsSub$); - } - - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $.unsubscribe()); - } - - onAcceptWaitVerification() { - this.waitVerificationAccepted = true; - localStorage.setItem("waitVerificationAccepted", "true"); - } - - onRejectInvite(inviteId: number): void { - this.inviteService.rejectInvite(inviteId).subscribe({ - next: () => { - const index = this.invites().findIndex(invite => invite.id === inviteId); - this.invites().splice(index, 1); - }, - error: () => { - this.inviteErrorModal = true; - }, - }); - } - - onAcceptInvite(inviteId: number): void { - this.inviteService.acceptInvite(inviteId).subscribe({ - next: () => { - const index = this.invites().findIndex(invite => invite.id === inviteId); - const invite = JSON.parse(JSON.stringify(this.invites()[index])); - this.invites().splice(index, 1); - - this.router - .navigateByUrl(`/office/projects/${invite.project.id}`) - .then(() => console.debug("Route changed from SidebarComponent")); - }, - error: () => { - this.inviteErrorModal = true; - }, - }); - } - - onLogout() { - this.authService - .logout() - .subscribe(() => - this.router - .navigateByUrl("/auth") - .then(() => console.debug("Route changed from OfficeComponent")) - ); - } - - private tryShowRegisteredProgramModal(): void { - const programs = this.programs(); - if (!programs || programs.length === 0) return; - - const memberProgram = programs.find(p => p.isUserMember); - if (!memberProgram) return; - - if (this.hasSeenRegisteredProgramModal(memberProgram.id)) return; - - this.registeredProgramToShow = memberProgram; - this.showRegisteredProgramModal.set(true); - this.markSeenRegisteredProgramModal(memberProgram.id); - } - - private getRegisteredProgramSeenKey(programId: number): string { - return `program_${this.profile?.id}_registered_modal_seen_${programId}`; - } - - private hasSeenRegisteredProgramModal(programId: number): boolean { - try { - return !!localStorage.getItem(this.getRegisteredProgramSeenKey(programId)); - } catch (e) { - return false; - } - } - - private markSeenRegisteredProgramModal(programId: number): void { - try { - localStorage.setItem(this.getRegisteredProgramSeenKey(programId), "1"); - } catch (e) { - // ignore storage errors - } - } - - private buildNavItems(profile: User) { - this.navItems = [ - { name: "мой профиль", icon: "person", link: `profile/${profile.id}` }, - { name: "новости", icon: "feed", link: "feed" }, - { name: "проекты", icon: "projects", link: "projects" }, - { name: "участники", icon: "people-bold", link: "members" }, - { name: "программы", icon: "program", link: "program" }, - { name: "курсы", icon: "trajectories", link: "courses" }, - { name: "вакансии", icon: "search-sidebar", link: "vacancies" }, - // { name: "чаты", icon: "message", link: "chats" }, - ]; - } -} diff --git a/projects/social_platform/src/app/office/office.resolver.spec.ts b/projects/social_platform/src/app/office/office.resolver.spec.ts deleted file mode 100644 index 491ff120c..000000000 --- a/projects/social_platform/src/app/office/office.resolver.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; - -import { OfficeResolver } from "./office.resolver"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; -import { of } from "rxjs"; -import { AuthService } from "@auth/services"; -import { ActivatedRouteSnapshot, RouterStateSnapshot } from "@angular/router"; - -describe("OfficeResolver", () => { - beforeEach(() => { - const authSpy = { - profile: of({}), - }; - - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - providers: [{ provide: AuthService, useValue: authSpy }], - }); - }); - - it("should be created", () => { - const result = TestBed.runInInjectionContext(() => - OfficeResolver({} as ActivatedRouteSnapshot, {} as RouterStateSnapshot) - ); - expect(result).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/office.resolver.ts b/projects/social_platform/src/app/office/office.resolver.ts deleted file mode 100644 index 2e8cd588f..000000000 --- a/projects/social_platform/src/app/office/office.resolver.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { InviteService } from "@services/invite.service"; -import { Invite } from "@models/invite.model"; -import { ResolveFn } from "@angular/router"; - -/** - * Резолвер для предзагрузки приглашений пользователя - * Загружает данные о приглашениях перед инициализацией компонента офиса - * - * Принимает: - * - Контекст маршрута (неявно через Angular DI) - * - * Возвращает: - * - Observable - массив приглашений пользователя - */ -export const OfficeResolver: ResolveFn = () => { - const inviteService = inject(InviteService); - - return inviteService.getMy(); -}; diff --git a/projects/social_platform/src/app/office/office.routes.ts b/projects/social_platform/src/app/office/office.routes.ts deleted file mode 100644 index f2ea39e2e..000000000 --- a/projects/social_platform/src/app/office/office.routes.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** @format */ - -import { Routes } from "@angular/router"; -import { OfficeComponent } from "./office.component"; -import { ProfileEditComponent } from "./profile/edit/edit.component"; -import { MembersComponent } from "./members/members.component"; -import { MembersResolver } from "./members/members.resolver"; -import { OfficeResolver } from "./office.resolver"; - -/** - * Конфигурация маршрутов для модуля офиса - * Определяет все доступные пути и их компоненты в рабочем пространстве - * - * Принимает: - * - URL пути от роутера Angular - * - * Возвращает: - * - Конфигурацию маршрутов с ленивой загрузкой модулей - * - Резолверы для предзагрузки данных - */ -export const OFFICE_ROUTES: Routes = [ - { - path: "onboarding", - loadChildren: () => import("./onboarding/onboarding.routes").then(c => c.ONBOARDING_ROUTES), - }, - { - path: "", - component: OfficeComponent, - resolve: { - invites: OfficeResolver, - }, - children: [ - { - path: "", - pathMatch: "full", - redirectTo: "program", - }, - { - path: "profile/edit", - component: ProfileEditComponent, - }, - { - path: "profile/:id", - loadChildren: () => - import("./profile/detail/profile-detail.routes").then(c => c.PROFILE_DETAIL_ROUTES), - }, - { - path: "feed", - loadChildren: () => import("./feed/feed.routes").then(c => c.FEED_ROUTES), - }, - { - path: "projects", - loadChildren: () => import("./projects/projects.routes").then(c => c.PROJECTS_ROUTES), - }, - { - path: "members", - component: MembersComponent, - resolve: { - data: MembersResolver, - }, - }, - { - path: "program", - loadChildren: () => import("./program/program.routes").then(c => c.PROGRAM_ROUTES), - }, - { - path: "courses", - loadChildren: () => import("./courses/courses.routes").then(c => c.COURSES_ROUTES), - }, - { - path: "vacancies", - loadChildren: () => import("./vacancies/vacancies.routes").then(c => c.VACANCIES_ROUTES), - }, - // { - // path: "chats", - // loadChildren: () => import("./chat/chat.routes").then(c => c.CHAT_ROUTES), - // }, - { - path: "**", - redirectTo: "/error/404", - }, - ], - }, -]; diff --git a/projects/social_platform/src/app/office/onboarding/onboarding.routes.ts b/projects/social_platform/src/app/office/onboarding/onboarding.routes.ts deleted file mode 100644 index cbe8596e6..000000000 --- a/projects/social_platform/src/app/office/onboarding/onboarding.routes.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** @format */ - -import { Routes } from "@angular/router"; -import { OnboardingComponent } from "@office/onboarding/onboarding/onboarding.component"; -import { OnboardingStageZeroComponent } from "@office/onboarding/stage-zero/stage-zero.component"; -import { OnboardingStageOneComponent } from "@office/onboarding/stage-one/stage-one.component"; -import { OnboardingStageThreeComponent } from "@office/onboarding/stage-three/stage-three.component"; -import { StageOneResolver } from "./stage-one/stage-one.resolver"; -import { OnboardingStageTwoComponent } from "./stage-two/stage-two.component"; -import { StageTwoResolver } from "./stage-two/stage-two.resolver"; - -/** - * ФАЙЛ МАРШРУТИЗАЦИИ ОНБОРДИНГА - * - * Назначение: Определяет структуру маршрутов для процесса онбординга новых пользователей - * - * Что делает: - * - Настраивает иерархию маршрутов для 4 этапов онбординга (stage-0, stage-1, stage-2, stage-3) - * - Связывает каждый маршрут с соответствующим компонентом - * - Подключает резолверы для предзагрузки данных на этапах 1 и 2 - * - * Что принимает: Нет входных параметров (статическая конфигурация) - * - * Что возвращает: Массив Routes для Angular Router - * - * Структура этапов: - * - stage-0: Базовая информация профиля (фото, город, образование, опыт работы) - * - stage-1: Выбор специализации пользователя - * - stage-2: Выбор навыков пользователя - * - stage-3: Выбор типа пользователя (ментор/менти) - */ -export const ONBOARDING_ROUTES: Routes = [ - { - path: "", - component: OnboardingComponent, - children: [ - { - path: "stage-0", - component: OnboardingStageZeroComponent, - }, - { - path: "stage-1", - component: OnboardingStageOneComponent, - resolve: { - data: StageOneResolver, - }, - }, - { - path: "stage-2", - component: OnboardingStageTwoComponent, - resolve: { - data: StageTwoResolver, - }, - }, - { - path: "stage-3", - component: OnboardingStageThreeComponent, - }, - ], - }, -]; diff --git a/projects/social_platform/src/app/office/onboarding/onboarding/onboarding.component.html b/projects/social_platform/src/app/office/onboarding/onboarding/onboarding.component.html deleted file mode 100644 index 2f5931d2b..000000000 --- a/projects/social_platform/src/app/office/onboarding/onboarding/onboarding.component.html +++ /dev/null @@ -1,51 +0,0 @@ - - -
- - -
diff --git a/projects/social_platform/src/app/office/onboarding/onboarding/onboarding.component.spec.ts b/projects/social_platform/src/app/office/onboarding/onboarding/onboarding.component.spec.ts deleted file mode 100644 index 53707350e..000000000 --- a/projects/social_platform/src/app/office/onboarding/onboarding/onboarding.component.spec.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { OnboardingComponent } from "./onboarding.component"; -import { RouterTestingModule } from "@angular/router/testing"; -import { of } from "rxjs"; -import { AuthService } from "@auth/services"; - -describe("OnboardingComponent", () => { - let component: OnboardingComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - const authSpy = { - profile: of({}), - }; - - await TestBed.configureTestingModule({ - imports: [RouterTestingModule, OnboardingComponent], - providers: [{ provide: AuthService, useValue: authSpy }], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(OnboardingComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/onboarding/onboarding/onboarding.component.ts b/projects/social_platform/src/app/office/onboarding/onboarding/onboarding.component.ts deleted file mode 100644 index 4166fe1c5..000000000 --- a/projects/social_platform/src/app/office/onboarding/onboarding/onboarding.component.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** @format */ - -import { Component, OnDestroy, OnInit } from "@angular/core"; -import { ActivatedRoute, Router, RouterOutlet } from "@angular/router"; -import { Subscription } from "rxjs"; -import { OnboardingService } from "../services/onboarding.service"; - -/** - * ОСНОВНОЙ КОМПОНЕНТ ОНБОРДИНГА - * - * Назначение: Контейнер и координатор для всех этапов процесса онбординга - * - * Что делает: - * - Управляет навигацией между этапами онбординга (stage-0 до stage-3) - * - Отслеживает текущий и активный этапы процесса - * - Обеспечивает правильную последовательность прохождения этапов - * - Предоставляет интерфейс для перехода к предыдущим этапам - * - Автоматически перенаправляет в основное приложение при завершении - * - Синхронизирует состояние с OnboardingService - * - * Что принимает: - * - Данные о текущем этапе из OnboardingService.currentStage$ - * - События навигации от Angular Router - * - Пользовательские действия (клики по этапам) - * - * Что возвращает: - * - Контейнер с индикатором прогресса этапов - * - RouterOutlet для отображения компонентов текущего этапа - * - Навигационные элементы для перехода между этапами - * - * Логика навигации: - * - stage: текущий этап из URL - * - activeStage: этап, отображаемый в UI - * - Запрет перехода на будущие этапы (stage < targetStage) - * - Автоматическое перенаправление при currentStage$ = null - * - * Состояния этапов: - * - 0: Базовая информация профиля - * - 1: Выбор специализации - * - 2: Выбор навыков - * - 3: Выбор роли пользователя - * - null: Онбординг завершен, переход в /office - */ -@Component({ - selector: "app-onboarding", - templateUrl: "./onboarding.component.html", - styleUrl: "./onboarding.component.scss", - standalone: true, - imports: [RouterOutlet], -}) -export class OnboardingComponent implements OnInit, OnDestroy { - constructor( - private readonly route: ActivatedRoute, - private readonly onboardingService: OnboardingService, - private readonly router: Router - ) {} - - ngOnInit(): void { - const stage$ = this.onboardingService.currentStage$.subscribe(s => { - if (s === null) { - this.router - .navigateByUrl("/office") - .then(() => console.debug("Route changed from OnboardingComponent")); - return; - } - - if (this.router.url.includes("stage")) { - this.stage = Number.parseInt(this.router.url.split("-")[1]); - } else { - this.stage = s; - } - - this.router - .navigate([`stage-${this.stage}`], { relativeTo: this.route }) - .then(() => console.debug("Route changed from OnboardingComponent")); - }); - - this.updateStage(); - const events$ = this.router.events.subscribe(this.updateStage.bind(this)); - - this.subscriptions$.push(stage$, events$); - } - - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $.unsubscribe()); - } - - stage = 0; - activeStage = 0; - - subscriptions$: Subscription[] = []; - - updateStage(): void { - this.activeStage = Number.parseInt(this.router.url.split("-")[1]); - this.stage = Number.parseInt(this.router.url.split("-")[1]); - } - - goToStep(stage: number): void { - if (this.stage < stage) return; - - this.router - .navigate([`stage-${stage}`], { relativeTo: this.route }) - .then(() => console.debug("Route changed from OnboardingComponent")); - } -} diff --git a/projects/social_platform/src/app/office/onboarding/services/onboarding.service.spec.ts b/projects/social_platform/src/app/office/onboarding/services/onboarding.service.spec.ts deleted file mode 100644 index 85fb23bb7..000000000 --- a/projects/social_platform/src/app/office/onboarding/services/onboarding.service.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; - -import { OnboardingService } from "./onboarding.service"; -import { AuthService } from "@auth/services"; -import { of } from "rxjs"; - -describe("OnboardingService", () => { - let service: OnboardingService; - - const authSpy = jasmine.createSpyObj({}, { profile: of({}) }); - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [{ provide: AuthService, useValue: authSpy }], - }); - service = TestBed.inject(OnboardingService); - }); - - it("should be created", () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/onboarding/services/onboarding.service.ts b/projects/social_platform/src/app/office/onboarding/services/onboarding.service.ts deleted file mode 100644 index d2d674365..000000000 --- a/projects/social_platform/src/app/office/onboarding/services/onboarding.service.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** @format */ - -import { Injectable } from "@angular/core"; -import { User } from "@auth/models/user.model"; -import { AuthService } from "@auth/services"; -import { BehaviorSubject, take } from "rxjs"; - -/** - * СЕРВИС УПРАВЛЕНИЯ СОСТОЯНИЕМ ОНБОРДИНГА - * - * Назначение: Централизованное управление данными и состоянием процесса онбординга - * - * Что делает: - * - Хранит и управляет данными формы онбординга между этапами - * - Отслеживает текущий этап онбординга пользователя - * - Синхронизирует состояние с профилем пользователя из AuthService - * - Предоставляет реактивные потоки данных для компонентов - * - Обеспечивает персистентность данных при переходах между этапами - * - * Что принимает: - * - Обновления данных формы через setFormValue(updates: Partial) - * - Изменения текущего этапа через setStep(step: number | null) - * - Начальные данные профиля из AuthService при инициализации - * - * Что возвращает: - * - formValue$: Observable> - поток данных формы - * - currentStage$: Observable - поток текущего этапа - * - * Архитектурные особенности: - * - Использует BehaviorSubject для хранения состояния - * - Singleton сервис (providedIn: 'root') - * - Автоматическая инициализация из профиля пользователя - * - Реактивное программирование с RxJS - * - * Жизненный цикл данных: - * 1. Инициализация из AuthService.profile - * 2. Накопление изменений через setFormValue - * 3. Передача данных между компонентами этапов - * 4. Финальное сохранение в профиль пользователя - */ -@Injectable({ - providedIn: "root", -}) -export class OnboardingService { - constructor(private authService: AuthService) { - this.authService.profile.pipe(take(1)).subscribe(p => { - this._formValue$.next({ - avatar: p.avatar, - city: p.city, - education: p.education, - workExperience: p.workExperience, - speciality: p.speciality, - skills: p.skills, - userType: p.userType, - }); - - this._currentStage$.next(p.onboardingStage as number); - }); - } - - private _formValue$ = new BehaviorSubject>({}); - formValue$ = this._formValue$.asObservable(); - - private _currentStage$ = new BehaviorSubject(0); - currentStage$ = this._currentStage$.asObservable(); - - setFormValue(updates: Partial): void { - this.formValue$.pipe(take(1)).subscribe(fv => { - this._formValue$.next({ ...fv, ...updates }); - }); - } - - setStep(step: number | null): void { - this._currentStage$.next(step); - } -} 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 deleted file mode 100644 index 188952105..000000000 --- a/projects/social_platform/src/app/office/onboarding/stage-one/stage-one.component.html +++ /dev/null @@ -1,98 +0,0 @@ - - -
-
-
-

Кем хотите работать?

- -
- -
- закончить регистрацию позже - продолжить -
- -
-
-
-

поиск по библиотеке

- - @if (stageForm.controls["speciality"] | controlError) { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
-
-
-
-

библиотека

- -
-
-
-
    - @for (spec of nestedSpecializations$ | async; track spec.id) { -
  • - -
  • - } -
-
-
-
-
diff --git a/projects/social_platform/src/app/office/onboarding/stage-one/stage-one.component.spec.ts b/projects/social_platform/src/app/office/onboarding/stage-one/stage-one.component.spec.ts deleted file mode 100644 index fe7e552c4..000000000 --- a/projects/social_platform/src/app/office/onboarding/stage-one/stage-one.component.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { OnboardingStageOneComponent } from "./stage-one.component"; -import { of } from "rxjs"; -import { ReactiveFormsModule } from "@angular/forms"; -import { AuthService } from "@auth/services"; -import { RouterTestingModule } from "@angular/router/testing"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; - -describe("StageOneComponent", () => { - let component: OnboardingStageOneComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - const authSpy = { - profile: of({}), - saveProfile: of({}), - setOnboardingStage: of({}), - }; - - await TestBed.configureTestingModule({ - imports: [ - ReactiveFormsModule, - RouterTestingModule, - HttpClientTestingModule, - OnboardingStageOneComponent, - ], - providers: [{ provide: AuthService, useValue: authSpy }], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(OnboardingStageOneComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/onboarding/stage-one/stage-one.component.ts b/projects/social_platform/src/app/office/onboarding/stage-one/stage-one.component.ts deleted file mode 100644 index 303045aa5..000000000 --- a/projects/social_platform/src/app/office/onboarding/stage-one/stage-one.component.ts +++ /dev/null @@ -1,205 +0,0 @@ -/** @format */ - -import { ChangeDetectorRef, Component, OnDestroy, OnInit, signal } from "@angular/core"; -import { NonNullableFormBuilder, ReactiveFormsModule } from "@angular/forms"; -import { concatMap, map, Observable, Subscription, take } from "rxjs"; -import { AuthService } from "@auth/services"; -import { ControlErrorPipe, ValidationService } from "@corelib"; -import { ActivatedRoute, Router } from "@angular/router"; -import { OnboardingService } from "../services/onboarding.service"; -import { ButtonComponent, IconComponent } from "@ui/components"; -import { CommonModule } from "@angular/common"; -import { AutoCompleteInputComponent } from "@ui/components/autocomplete-input/autocomplete-input.component"; -import { SpecializationsGroup } from "@office/models/specializations-group.model"; -import { SpecializationsGroupComponent } from "@office/shared/specializations-group/specializations-group.component"; -import { Specialization } from "@office/models/specialization.model"; -import { SpecializationsService } from "@office/services/specializations.service"; -import { ErrorMessage } from "@error/models/error-message"; -import { TooltipComponent } from "@ui/components/tooltip/tooltip.component"; - -/** - * КОМПОНЕНТ ПЕРВОГО ЭТАПА ОНБОРДИНГА - * - * Назначение: Этап выбора специализации пользователя из предложенных вариантов - * - * Что делает: - * - Отображает форму для ввода/выбора специализации - * - Предоставляет автокомплит для поиска специализаций - * - Показывает группированные специализации из базы данных - * - Валидирует введенные данные - * - Сохраняет специализацию в профиле и переходит к следующему этапу - * - Предоставляет возможность пропустить этап - * - * Что принимает: - * - Данные специализаций через ActivatedRoute (из StageOneResolver) - * - Текущее состояние формы из OnboardingService - * - Пользовательский ввод в поле специализации - * - Поисковые запросы для автокомплита - * - * Что возвращает: - * - Интерфейс с полем ввода специализации - * - Список предложенных специализаций для выбора - * - Навигацию на следующий этап (stage-2) или финальный (stage-3) - * - * Особенности: - * - Использует сигналы Angular для реактивного состояния - * - Поддерживает поиск специализаций в реальном времени - * - Интегрирован с сервисом специализаций для получения данных - */ -@Component({ - selector: "app-stage-one", - templateUrl: "./stage-one.component.html", - styleUrl: "./stage-one.component.scss", - standalone: true, - imports: [ - ReactiveFormsModule, - IconComponent, - ButtonComponent, - ControlErrorPipe, - AutoCompleteInputComponent, - SpecializationsGroupComponent, - CommonModule, - TooltipComponent, - ], -}) -export class OnboardingStageOneComponent implements OnInit, OnDestroy { - constructor( - private readonly nnFb: NonNullableFormBuilder, - private readonly authService: AuthService, - private readonly onboardingService: OnboardingService, - private readonly validationService: ValidationService, - private readonly specsService: SpecializationsService, - private readonly router: Router, - private readonly route: ActivatedRoute, - private readonly cdref: ChangeDetectorRef - ) {} - - stageForm = this.nnFb.group({ - speciality: [""], - }); - - nestedSpecializations$: Observable = this.route.data.pipe( - map(r => r["data"]) - ); - - isHintAuthVisible = false; - isHintLibVisible = false; - - inlineSpecializations = signal([]); - - stageSubmitting = signal(false); - skipSubmitting = signal(false); - - errorMessage = ErrorMessage; - - subscriptions$ = signal([]); - - ngOnInit(): void { - const formValueState$ = this.onboardingService.formValue$.pipe(take(1)).subscribe(fv => { - this.stageForm.patchValue({ - speciality: fv.speciality, - }); - }); - - const formValueChange$ = this.stageForm.valueChanges.subscribe(value => { - this.onboardingService.setFormValue(value); - }); - - this.subscriptions$().push(formValueState$, formValueChange$); - } - - ngAfterViewInit(): void { - const specialityProfile$ = this.onboardingService.formValue$.subscribe(fv => { - this.stageForm.patchValue({ speciality: fv.speciality }); - }); - - this.cdref.detectChanges(); - - specialityProfile$ && this.subscriptions$().push(specialityProfile$); - } - - ngOnDestroy(): void { - this.subscriptions$().forEach($ => $.unsubscribe()); - } - - showTooltip(type: "auth" | "lib"): void { - type === "auth" ? (this.isHintAuthVisible = true) : (this.isHintLibVisible = true); - } - - hideTooltip(type: "auth" | "lib"): void { - type === "auth" ? (this.isHintAuthVisible = false) : (this.isHintLibVisible = false); - } - - // Для управления открытыми группами специализаций - openSpecializationGroup: string | null = null; - - /** - * Проверяет, есть ли открытые группы специализаций - */ - hasOpenSpecializationsGroups(): boolean { - return this.openSpecializationGroup !== null; - } - - /** - * Обработчик переключения группы специализаций - * @param isOpen - флаг открытия/закрытия группы - * @param groupName - название группы - */ - onSpecializationsGroupToggled(isOpen: boolean, groupName: string): void { - this.openSpecializationGroup = isOpen ? groupName : null; - } - - /** - * Проверяет, должна ли группа специализаций быть отключена - * @param groupName - название группы для проверки - */ - isSpecializationGroupDisabled(groupName: string): boolean { - return this.openSpecializationGroup !== null && this.openSpecializationGroup !== groupName; - } - - onSkipRegistration(): void { - if (!this.validationService.getFormValidation(this.stageForm)) { - return; - } - - this.completeRegistration(3); - } - - onSubmit(): void { - if (!this.validationService.getFormValidation(this.stageForm)) { - return; - } - - this.stageSubmitting.set(true); - - this.authService - .saveProfile(this.stageForm.value) - .pipe(concatMap(() => this.authService.setOnboardingStage(2))) - .subscribe({ - next: () => this.completeRegistration(2), - error: () => this.stageSubmitting.set(false), - }); - } - - onSelectSpec(speciality: Specialization): void { - this.stageForm.patchValue({ speciality: speciality.name }); - } - - onSearchSpec(query: string): void { - this.specsService - .getSpecializationsInline(query, 1000, 0) - .pipe(take(1)) - .subscribe(({ results }) => { - this.inlineSpecializations.set(results); - }); - } - - private completeRegistration(stage: number): void { - this.skipSubmitting.set(true); - this.onboardingService.setFormValue(this.stageForm.value); - this.router.navigateByUrl( - stage === 2 ? "/office/onboarding/stage-2" : "/office/onboarding/stage-3" - ); - this.skipSubmitting.set(false); - } -} diff --git a/projects/social_platform/src/app/office/onboarding/stage-one/stage-one.resolver.ts b/projects/social_platform/src/app/office/onboarding/stage-one/stage-one.resolver.ts deleted file mode 100644 index 220dedbad..000000000 --- a/projects/social_platform/src/app/office/onboarding/stage-one/stage-one.resolver.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { ResolveFn } from "@angular/router"; -import { SpecializationsService } from "@office/services/specializations.service"; -import { SpecializationsGroup } from "@office/models/specializations-group.model"; - -/** - * РЕЗОЛВЕР ПЕРВОГО ЭТАПА ОНБОРДИНГА - * - * Назначение: Предзагрузка данных специализаций перед отображением компонента stage-one - * - * Что делает: - * - Выполняется автоматически перед активацией маршрута stage-1 - * - Загружает иерархическую структуру специализаций из API - * - Обеспечивает доступность данных в компоненте через ActivatedRoute - * - * Что принимает: - * - Контекст маршрута (автоматически от Angular Router) - * - Доступ к SpecializationsService через dependency injection - * - * Что возвращает: - * - Observable - массив групп специализаций - * - Данные становятся доступны в компоненте через route.data['data'] - * - * Преимущества использования резолвера: - * - Данные загружаются до отображения компонента - * - Предотвращает показ пустого состояния - * - Централизованная обработка ошибок загрузки - */ -export const StageOneResolver: ResolveFn = () => { - const specializationsService = inject(SpecializationsService); - - return specializationsService.getSpecializationsNested(); -}; diff --git a/projects/social_platform/src/app/office/onboarding/stage-three/stage-three.component.spec.ts b/projects/social_platform/src/app/office/onboarding/stage-three/stage-three.component.spec.ts deleted file mode 100644 index 95e2be611..000000000 --- a/projects/social_platform/src/app/office/onboarding/stage-three/stage-three.component.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { OnboardingStageThreeComponent } from "./stage-three.component"; -import { of } from "rxjs"; -import { AuthService } from "@auth/services"; -import { OnboardingService } from "../services/onboarding.service"; -import { RouterTestingModule } from "@angular/router/testing"; - -describe("StageThreeComponent", () => { - let component: OnboardingStageThreeComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - const authSpy = jasmine.createSpyObj({ - saveProfile: of({}), - setOnboardingStage: of({}), - }); - const onboardingSpy = jasmine.createSpyObj({}, { formValue$: of({}) }); - - await TestBed.configureTestingModule({ - imports: [RouterTestingModule, OnboardingStageThreeComponent], - providers: [ - { provide: AuthService, useValue: authSpy }, - { provide: OnboardingService, useValue: onboardingSpy }, - ], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(OnboardingStageThreeComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/onboarding/stage-three/stage-three.component.ts b/projects/social_platform/src/app/office/onboarding/stage-three/stage-three.component.ts deleted file mode 100644 index 1d8421f08..000000000 --- a/projects/social_platform/src/app/office/onboarding/stage-three/stage-three.component.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** @format */ - -import { Component, OnDestroy, OnInit } from "@angular/core"; -import { AuthService } from "@auth/services"; -import { concatMap, Subscription, take } from "rxjs"; -import { Router } from "@angular/router"; -import { OnboardingService } from "@office/onboarding/services/onboarding.service"; -import { ButtonComponent } from "@ui/components"; -import { UserTypeCardComponent } from "@office/onboarding/user-type-card/user-type-card.component"; - -/** - * КОМПОНЕНТ ТРЕТЬЕГО ЭТАПА ОНБОРДИНГА - * - * Назначение: Финальный этап онбординга - выбор роли пользователя (ментор или менти) - * - * Что делает: - * - Отображает интерфейс для выбора типа пользователя - * - Валидирует выбор роли перед отправкой - * - Сохраняет выбранную роль в профиле пользователя - * - Завершает процесс онбординга и перенаправляет в основное приложение - * - Управляет состоянием загрузки и ошибок - * - * Что принимает: - * - Данные из OnboardingService (текущее состояние формы) - * - Взаимодействие пользователя (выбор роли, отправка формы) - * - * Что возвращает: - * - Визуальный интерфейс с карточками выбора роли - * - Навигацию в основное приложение после успешного завершения - * - * Состояния компонента: - * - userRole: выбранная роль (-1 = не выбрана, другие значения = конкретная роль) - * - stageTouched: флаг попытки отправки без выбора роли - * - stageSubmitting: флаг процесса отправки данных - */ -@Component({ - selector: "app-stage-three", - templateUrl: "./stage-three.component.html", - styleUrl: "./stage-three.component.scss", - standalone: true, - imports: [UserTypeCardComponent, ButtonComponent], -}) -export class OnboardingStageThreeComponent implements OnInit, OnDestroy { - constructor( - private readonly authService: AuthService, - private readonly onboardingService: OnboardingService, - private readonly router: Router - ) {} - - ngOnInit(): void { - const formValue$ = this.onboardingService.formValue$.pipe(take(1)).subscribe(fv => { - this.userRole = fv.userType ? fv.userType : -1; - }); - - this.subscriptions$.push(formValue$); - } - - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $.unsubscribe()); - } - - userRole!: number; - stageTouched = false; - stageSubmitting = false; - subscriptions$: Subscription[] = []; - - onSetRole(role: number) { - this.userRole = role; - this.onboardingService.setFormValue({ userType: role }); - } - - onSubmit() { - if (this.userRole === -1) { - this.stageTouched = true; - return; - } - - this.stageSubmitting = true; - - this.authService - .saveProfile({ userType: this.userRole }) - .pipe(concatMap(() => this.authService.setOnboardingStage(null))) - .subscribe(() => { - this.router - .navigateByUrl("/office") - .then(() => console.debug("Route changed from OnboardingStageTwo")); - }); - } -} diff --git a/projects/social_platform/src/app/office/onboarding/stage-two/stage-two.component.html b/projects/social_platform/src/app/office/onboarding/stage-two/stage-two.component.html deleted file mode 100644 index aafa8c368..000000000 --- a/projects/social_platform/src/app/office/onboarding/stage-two/stage-two.component.html +++ /dev/null @@ -1,110 +0,0 @@ - - -
-
-
-

Какими навыками вы обладаете?

- -
-
- закончить регистрацию позже - продолжить -
- -
-
-
- -
-

выбранные навыки

- -
-
-
-
-
-

библиотека

- -
-
-
-
    - @for (skillGroup of nestedSkills$ | async; track skillGroup.id) { -
  • - -
  • - } -
-
-
-
- - -
-
- -

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

-
-

{{ isChooseSkillText() }}.

-
-
-
diff --git a/projects/social_platform/src/app/office/onboarding/stage-two/stage-two.component.spec.ts b/projects/social_platform/src/app/office/onboarding/stage-two/stage-two.component.spec.ts deleted file mode 100644 index 226da34d4..000000000 --- a/projects/social_platform/src/app/office/onboarding/stage-two/stage-two.component.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { OnboardingStageTwoComponent } from "./stage-two.component"; -import { of } from "rxjs"; -import { ReactiveFormsModule } from "@angular/forms"; -import { AuthService } from "@auth/services"; -import { RouterTestingModule } from "@angular/router/testing"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; - -describe("StageTwoComponent", () => { - let component: OnboardingStageTwoComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - const authSpy = { - profile: of({}), - saveProfile: of({}), - setOnboardingStage: of({}), - }; - - await TestBed.configureTestingModule({ - imports: [ - ReactiveFormsModule, - RouterTestingModule, - HttpClientTestingModule, - OnboardingStageTwoComponent, - ], - providers: [{ provide: AuthService, useValue: authSpy }], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(OnboardingStageTwoComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/onboarding/stage-two/stage-two.component.ts b/projects/social_platform/src/app/office/onboarding/stage-two/stage-two.component.ts deleted file mode 100644 index 363d5bf21..000000000 --- a/projects/social_platform/src/app/office/onboarding/stage-two/stage-two.component.ts +++ /dev/null @@ -1,241 +0,0 @@ -/** @format */ - -import { ChangeDetectorRef, Component, OnDestroy, OnInit, signal } from "@angular/core"; -import { NonNullableFormBuilder, ReactiveFormsModule } from "@angular/forms"; -import { concatMap, map, Observable, Subscription, take } from "rxjs"; -import { AuthService } from "@auth/services"; -import { ControlErrorPipe, ValidationService } from "@corelib"; -import { ActivatedRoute, Router } from "@angular/router"; -import { OnboardingService } from "../services/onboarding.service"; -import { ButtonComponent, IconComponent } from "@ui/components"; -import { CommonModule } from "@angular/common"; -import { AutoCompleteInputComponent } from "@ui/components/autocomplete-input/autocomplete-input.component"; -import { Skill } from "@office/models/skill.model"; -import { SkillsService } from "@office/services/skills.service"; -import { SkillsGroup } from "@office/models/skills-group.model"; -import { SkillsGroupComponent } from "@office/shared/skills-group/skills-group.component"; -import { SkillsBasketComponent } from "@office/shared/skills-basket/skills-basket.component"; -import { ModalComponent } from "@ui/components/modal/modal.component"; -import { TooltipComponent } from "@ui/components/tooltip/tooltip.component"; - -/** - * КОМПОНЕНТ ВТОРОГО ЭТАПА ОНБОРДИНГА - * - * Назначение: Этап выбора навыков пользователя из каталога доступных навыков - * - * Что делает: - * - Отображает интерфейс для поиска и выбора навыков - * - Управляет корзиной выбранных навыков - * - Предоставляет группированный каталог навыков - * - Поддерживает поиск навыков в реальном времени - * - Валидирует выбранные навыки перед отправкой - * - Сохраняет навыки в профиле и переходит к следующему этапу - * - Обрабатывает ошибки валидации от сервера - * - * Что принимает: - * - Данные групп навыков через ActivatedRoute (из StageTwoResolver) - * - Текущее состояние формы из OnboardingService - * - Пользовательские действия (поиск, добавление/удаление навыков) - * - Результаты поиска от SkillsService - * - * Что возвращает: - * - Интерфейс с поиском навыков и автокомплитом - * - Группированный каталог навыков для выбора - * - Корзину выбранных навыков с возможностью удаления - * - Модальное окно с ошибками валидации - * - Навигацию на следующий этап (stage-3) - * - * Особенности работы с навыками: - * - Предотвращение дублирования навыков в корзине - * - Переключение состояния навыка (добавить/удалить) одним действием - * - Отправка только ID навыков на сервер (skillsIds) - * - Обработка серверных ошибок с отображением в модальном окне - * - * Состояния компонента: - * - searchedSkills: результаты поиска навыков - * - stageSubmitting: флаг процесса отправки - * - isChooseSkill: флаг отображения модального окна с ошибкой - */ -@Component({ - selector: "app-stage-two", - templateUrl: "./stage-two.component.html", - styleUrl: "./stage-two.component.scss", - standalone: true, - imports: [ - CommonModule, - ReactiveFormsModule, - IconComponent, - ButtonComponent, - ModalComponent, - ControlErrorPipe, - AutoCompleteInputComponent, - SkillsGroupComponent, - SkillsBasketComponent, - TooltipComponent, - ], -}) -export class OnboardingStageTwoComponent implements OnInit, OnDestroy { - constructor( - private readonly nnFb: NonNullableFormBuilder, - private readonly authService: AuthService, - private readonly onboardingService: OnboardingService, - private readonly validationService: ValidationService, - private readonly skillsService: SkillsService, - private readonly router: Router, - private readonly route: ActivatedRoute, - private readonly cdref: ChangeDetectorRef - ) {} - - stageForm = this.nnFb.group({ - skills: this.nnFb.control([]), - }); - - nestedSkills$: Observable = this.route.data.pipe(map(r => r["data"])); - - searchedSkills = signal([]); - - isHintAuthVisible = false; - isHintLibVisible = false; - - stageSubmitting = signal(false); - skipSubmitting = signal(false); - - isChooseSkill = signal(false); - isChooseSkillText = signal(""); - - subscriptions$ = signal([]); - - // Для управления открытыми группами навыков - openSkillGroup: string | null = null; - - /** - * Проверяет, есть ли открытые группы навыков - */ - hasOpenSkillsGroups(): boolean { - return this.openSkillGroup !== null; - } - - /** - * Обработчик переключения группы навыков - * @param skillName - название навыка - * @param isOpen - флаг открытия/закрытия группы - */ - onSkillGroupToggled(isOpen: boolean, skillName: string): void { - this.openSkillGroup = isOpen ? skillName : null; - } - - /** - * Проверяет, должна ли группа навыков быть отключена - * @param skillName - название навыка - */ - isSkillGroupDisabled(skillName: string): boolean { - return this.openSkillGroup !== null && this.openSkillGroup !== skillName; - } - - ngOnInit(): void { - const fv$ = this.onboardingService.formValue$ - .pipe(take(1)) - .subscribe(({ skills }) => this.stageForm.patchValue({ skills })); - - const formValueChange$ = this.stageForm.valueChanges.subscribe(value => { - this.onboardingService.setFormValue(value); - }); - - this.subscriptions$().push(fv$, formValueChange$); - } - - ngAfterViewInit(): void { - const skillsProfile$ = this.onboardingService.formValue$.subscribe(fv => { - this.stageForm.patchValue({ skills: fv.skills }); - }); - - this.cdref.detectChanges(); - - skillsProfile$ && this.subscriptions$().push(skillsProfile$); - } - - ngOnDestroy(): void { - this.subscriptions$().forEach($ => $.unsubscribe()); - } - - showTooltip(type: "auth" | "lib"): void { - type === "auth" ? (this.isHintAuthVisible = true) : (this.isHintLibVisible = true); - } - - hideTooltip(type: "auth" | "lib"): void { - type === "auth" ? (this.isHintAuthVisible = false) : (this.isHintLibVisible = false); - } - - onSkipRegistration(): void { - if (!this.validationService.getFormValidation(this.stageForm)) { - return; - } - - this.completeRegistration(3); - } - - onSubmit(): void { - if (!this.validationService.getFormValidation(this.stageForm)) { - return; - } - - this.stageSubmitting.set(true); - - const { skills } = this.stageForm.getRawValue(); - - this.authService - .saveProfile({ skillsIds: skills.map(skill => skill.id) }) - .pipe(concatMap(() => this.authService.setOnboardingStage(2))) - .subscribe({ - next: () => this.completeRegistration(3), - error: err => { - this.stageSubmitting.set(false); - if (err.status === 400) { - this.isChooseSkill.set(true); - this.isChooseSkillText.set(err.error[0]); - } - }, - }); - } - - onAddSkill(newSkill: Skill): void { - const { skills } = this.stageForm.getRawValue(); - - const isPresent = skills.some(s => s.id === newSkill.id); - - if (isPresent) return; - - this.stageForm.patchValue({ skills: [newSkill, ...skills] }); - } - - onRemoveSkill(oddSkill: Skill): void { - const { skills } = this.stageForm.getRawValue(); - - this.stageForm.patchValue({ skills: skills.filter(skill => skill.id !== oddSkill.id) }); - } - - onOptionToggled(toggledSkill: Skill): void { - const { skills } = this.stageForm.getRawValue(); - - const isPresent = skills.some(skill => skill.id === toggledSkill.id); - - if (isPresent) { - this.onRemoveSkill(toggledSkill); - } else { - this.onAddSkill(toggledSkill); - } - } - - onSearchSkill(query: string): void { - this.skillsService - .getSkillsInline(query, 1000, 0) - .subscribe(({ results }) => this.searchedSkills.set(results)); - } - - private completeRegistration(stage: number): void { - this.skipSubmitting.set(true); - this.onboardingService.setFormValue(this.stageForm.value); - stage === 3 && this.router.navigateByUrl("/office/onboarding/stage-3"); - this.skipSubmitting.set(false); - } -} diff --git a/projects/social_platform/src/app/office/onboarding/stage-two/stage-two.resolver.ts b/projects/social_platform/src/app/office/onboarding/stage-two/stage-two.resolver.ts deleted file mode 100644 index 462270cee..000000000 --- a/projects/social_platform/src/app/office/onboarding/stage-two/stage-two.resolver.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { ResolveFn } from "@angular/router"; -import { SkillsService } from "@office/services/skills.service"; -import { SkillsGroup } from "@office/models/skills-group.model"; - -/** - * РЕЗОЛВЕР ВТОРОГО ЭТАПА ОНБОРДИНГА - * - * Назначение: Предзагрузка данных навыков перед отображением компонента stage-two - * - * Что делает: - * - Выполняется автоматически перед активацией маршрута stage-2 - * - Загружает иерархическую структуру навыков из API - * - Группирует навыки по категориям для удобного отображения - * - Обеспечивает доступность данных в компоненте через ActivatedRoute - * - * Что принимает: - * - Контекст маршрута (автоматически от Angular Router) - * - Доступ к SkillsService через dependency injection - * - * Что возвращает: - * - Observable - массив групп навыков - * - Данные становятся доступны в компоненте через route.data['data'] - * - * Структура данных: - * - SkillsGroup: группа навыков с названием категории - * - Каждая группа содержит массив связанных навыков - * - Используется для организации навыков в UI по категориям - * - * Преимущества: - * - Быстрое отображение интерфейса с готовыми данными - * - Предотвращение состояния загрузки в компоненте - * - Централизованная обработка ошибок загрузки данных - */ -export const StageTwoResolver: ResolveFn = () => { - const skillsService = inject(SkillsService); - - return skillsService.getSkillsNested(); -}; diff --git a/projects/social_platform/src/app/office/onboarding/stage-zero/stage-zero.component.html b/projects/social_platform/src/app/office/onboarding/stage-zero/stage-zero.component.html deleted file mode 100644 index adb0e82d4..000000000 --- a/projects/social_platform/src/app/office/onboarding/stage-zero/stage-zero.component.html +++ /dev/null @@ -1,651 +0,0 @@ - - -@if (profile) { -
-
-

Привет, {{ profile.firstName }} {{ profile.lastName }}! ✌️

-

Расскажите о себе, чтобы ваше резюме было сильным и отражало опыт

-
-
-
- @if (stageForm.get("avatar"); as avatar) { -
-
-

Фотография профиля*

- -
- - @if (avatar | controlError: "required") { -
- {{ errorMessage.EMPTY_AVATAR }} -
- } -
- } @if (stageForm.get("city"); as city) { -
- - - - @if (city | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } -
- -
-
-
-

Образование

- -
-
- - @if (stageForm.get("educationLevel"); as educationLevel) { -
- - - - - @if (educationLevel | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } - -
- @if (stageForm.get("entryYear"); as entryYear) { -
- - - - - - @if (entryYear | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } @if (stageForm.get("completionYear"); as completionYear) { -
- - - - - @if (completionYear | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } -
- - @if (stageForm.get("organizationName"); as organizationName) { -
- - - @if (organizationName | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } @if (stageForm.get("educationStatus"); as educationStatus) { -
- - - - - @if (educationStatus | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } @if (stageForm.get("description"); as description) { -
- - - - @if (description | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } - -
- - {{ editEducationClick ? "Созранить изменения" : "Добавить образование" }} - - - - @if(educationItems().length || education.length){ @for (educationItem of education.value; - track $index) { -
-
-

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

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

Место работы

- -
-
- - @if (stageForm.get("organizationNameWork"); as organizationNameWork) { -
- - - - @if (organizationNameWork | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } - -
- @if (stageForm.get("entryYearWork"); as entryYearWork) { -
- - - - - - @if (entryYearWork | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } @if (stageForm.get("completionYearWork"); as completionYearWork) { -
- - - - - - @if (completionYearWork | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } -
- - @if (stageForm.get("jobPosition"); as jobPosition) { -
- - - @if (jobPosition | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } @if (stageForm.get("descriptionWork"); as descriptionWork) { -
- - - - @if (descriptionWork | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } - -
- - {{ editWorkClick ? "Сохранить изменения" : "Добавить место работы" }} - - - - @if(workItems().length || workExperience.length){ @for (workItem of workExperience.value; - track $index) { -
-
-

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

- -
- - - -
- } } -
-
- -
- -
    - @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 (stageForm.get("language"); as language) { -
- - - - - - @if (language | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } @if (stageForm.get("languageLevel"); as languageLevel) { -
- - - - - @if (languageLevel | controlError: "required") { -
- {{ errorMessage.VALIDATION_REQUIRED }} -
- } -
- } -
- Количество добавляемых языков не более 4-х -
- - - {{ editLanguageClick ? "Сохранить изменения" : "Добавить язык" }} - - - -
- @if(languageItems().length || userLanguages.length){ @for (languageItem of - userLanguages.value; track $index) { -
-
-

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

- -
- - - -
- } } -
-
- -
- закончить регистрацию позже - продолжить -
- - -
-
- -

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

-
-

{{ isModalErrorYearText() }}.

-
-
-
-} diff --git a/projects/social_platform/src/app/office/onboarding/stage-zero/stage-zero.component.spec.ts b/projects/social_platform/src/app/office/onboarding/stage-zero/stage-zero.component.spec.ts deleted file mode 100644 index 37edae8b3..000000000 --- a/projects/social_platform/src/app/office/onboarding/stage-zero/stage-zero.component.spec.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { OnboardingStageZeroComponent } from "./stage-zero.component"; -import { of } from "rxjs"; -import { AuthService } from "@auth/services"; -import { ReactiveFormsModule } from "@angular/forms"; -import { RouterTestingModule } from "@angular/router/testing"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; -import { NgxMaskModule } from "ngx-mask"; - -describe("StageZeroComponent", () => { - let component: OnboardingStageZeroComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - const authSpy = { - profile: of({}), - saveProfile: of({}), - setOnboardingStage: of({}), - }; - - await TestBed.configureTestingModule({ - imports: [ - ReactiveFormsModule, - RouterTestingModule, - NgxMaskModule.forRoot(), - HttpClientTestingModule, - OnboardingStageZeroComponent, - ], - providers: [{ provide: AuthService, useValue: authSpy }], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(OnboardingStageZeroComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/onboarding/stage-zero/stage-zero.component.ts b/projects/social_platform/src/app/office/onboarding/stage-zero/stage-zero.component.ts deleted file mode 100644 index c640864bc..000000000 --- a/projects/social_platform/src/app/office/onboarding/stage-zero/stage-zero.component.ts +++ /dev/null @@ -1,797 +0,0 @@ -/** @format */ - -import { ChangeDetectorRef, Component, OnDestroy, OnInit, signal } from "@angular/core"; -import { AuthService } from "@auth/services"; -import { FormArray, FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; -import { ErrorMessage } from "@error/models/error-message"; -import { ControlErrorPipe, ValidationService, YearsFromBirthdayPipe } from "projects/core"; -import { concatMap, Subscription } from "rxjs"; -import { Router } from "@angular/router"; -import { User } from "@auth/models/user.model"; -import { OnboardingService } from "../services/onboarding.service"; -import { ButtonComponent, InputComponent, SelectComponent } from "@ui/components"; -import { AvatarControlComponent } from "@ui/components/avatar-control/avatar-control.component"; -import { CommonModule } from "@angular/common"; -import { - educationUserLevel, - educationUserType, -} from "projects/core/src/consts/lists/education-info-list.const"; -import { - languageLevelsList, - languageNamesList, -} from "projects/core/src/consts/lists/language-info-list.const"; -import { IconComponent } from "@uilib"; -import { transformYearStringToNumber } from "@utils/transformYear"; -import { yearRangeValidators } from "@utils/yearRangeValidators"; -import { ModalComponent } from "@ui/components/modal/modal.component"; -import { TooltipComponent } from "@ui/components/tooltip/tooltip.component"; -import { generateOptionsList } from "@utils/generate-options-list"; - -/** - * КОМПОНЕНТ НУЛЕВОГО ЭТАПА ОНБОРДИНГА - * - * Назначение: Начальный этап сбора базовой информации профиля пользователя - * - * Что делает: - * - Собирает основную информацию: фото, город, образование, опыт работы, языки, достижения - * - Управляет сложными формами с динамическими массивами (FormArray) - * - Валидирует данные с учетом временных диапазонов (годы обучения/работы) - * - Предоставляет интерфейс для добавления/редактирования/удаления записей - * - Поддерживает загрузку аватара пользователя - * - Сохраняет данные в профиле и переходит к следующему этапу - * - * Что принимает: - * - Текущий профиль пользователя из AuthService - * - Состояние формы из OnboardingService - * - Пользовательский ввод во все поля формы - * - Файлы изображений для аватара - * - * Что возвращает: - * - Комплексный интерфейс с множественными секциями: - * * Загрузка аватара - * * Поле города - * * Управление образованием (добавление/редактирование записей) - * * Управление опытом работы - * * Управление языками - * * Управление достижениями - * - Модальные окна для ошибок валидации - * - Навигацию на следующий этап (stage-1) или финальный (stage-3) - * - * Сложные функции управления данными: - * - addEducation/editEducation/removeEducation: управление записями образования - * - addWork/editWork/removeWork: управление записями опыта работы - * - addLanguage/editLanguage/removeLanguage: управление языками - * - addAchievement/removeAchievement: управление достижениями - * - * Валидация: - * - Обязательные поля: аватар, город - * - Валидация временных диапазонов (год начала < года окончания) - * - Динамическая валидация для записей в массивах - * - * Состояние компонента: - * - Множественные сигналы для управления элементами UI - * - Отслеживание режимов редактирования для каждого типа записей - * - Управление видимостью подсказок и модальных окон - */ -@Component({ - selector: "app-stage-zero", - templateUrl: "./stage-zero.component.html", - styleUrl: "./stage-zero.component.scss", - standalone: true, - imports: [ - ReactiveFormsModule, - AvatarControlComponent, - InputComponent, - ButtonComponent, - IconComponent, - ControlErrorPipe, - SelectComponent, - ModalComponent, - CommonModule, - TooltipComponent, - ], -}) -export class OnboardingStageZeroComponent implements OnInit, OnDestroy { - constructor( - public readonly authService: AuthService, - private readonly onboardingService: OnboardingService, - private readonly fb: FormBuilder, - private readonly validationService: ValidationService, - private readonly router: Router, - private readonly cdref: ChangeDetectorRef - ) { - this.stageForm = this.fb.group({ - avatar: ["", [Validators.required]], - city: ["", [Validators.required]], - - education: this.fb.array([]), - workExperience: this.fb.array([]), - userLanguages: this.fb.array([]), - achievements: this.fb.array([]), - - // education - organizationName: [""], - entryYear: [null], - completionYear: [null], - description: [null], - educationStatus: [null], - educationLevel: [null], - - // work - organizationNameWork: [""], - entryYearWork: [null], - completionYearWork: [null], - descriptionWork: [null], - jobPosition: [null], - - // language - language: [null], - languageLevel: [null], - }); - } - - ngOnInit(): void { - const profile$ = this.authService.profile.subscribe(p => { - this.profile = p; - }); - - const formValueState$ = this.onboardingService.formValue$.subscribe(fv => { - this.stageForm.patchValue({ - avatar: fv.avatar, - city: fv.city, - education: fv.education, - workExperience: fv.workExperience, - }); - }); - - this.subscriptions$.push(profile$, formValueState$); - } - - ngAfterViewInit() { - const onboardingProfile$ = this.onboardingService.formValue$.subscribe(formValues => { - this.stageForm.patchValue({ - avatar: formValues.avatar ?? "", - city: formValues.city ?? "", - }); - - this.workExperience.clear(); - formValues.workExperience?.forEach(work => { - this.workExperience.push( - this.fb.group( - { - organizationName: work.organizationName, - entryYear: work.entryYear, - completionYear: work.completionYear, - description: work.description, - jobPosition: work.jobPosition, - }, - { - validators: yearRangeValidators("entryYear", "completionYear"), - } - ) - ); - }); - - this.education.clear(); - formValues?.education?.forEach(edu => { - this.education.push( - this.fb.group( - { - organizationName: edu.organizationName, - entryYear: edu.entryYear, - completionYear: edu.completionYear, - description: edu.description, - educationStatus: edu.educationStatus, - educationLevel: edu.educationLevel, - }, - { - validators: yearRangeValidators("entryYear", "completionYear"), - } - ) - ); - }); - - this.userLanguages.clear(); - formValues.userLanguages?.forEach(lang => { - this.userLanguages.push( - this.fb.group({ - language: lang.language, - languageLevel: lang.languageLevel, - }) - ); - }); - - this.cdref.detectChanges(); - - formValues.achievements?.length && - formValues.achievements?.forEach(achievement => - this.addAchievement(achievement.id, achievement.title, achievement.status) - ); - }); - onboardingProfile$ && this.subscriptions$.push(onboardingProfile$); - } - - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $.unsubscribe()); - } - - isHintPhotoVisible = false; - isHintCityVisible = false; - isHintEducationVisible = false; - isHintEducationDescriptionVisible = false; - isHintWorkVisible = false; - isHintWorkNameVisible = false; - isHintWorkDescriptionVisible = false; - isHintAchievementsVisible = false; - isHintLanguageVisible = false; - - readonly yearListEducation = generateOptionsList(55, "years"); - - readonly educationStatusList = educationUserType; - - readonly educationLevelList = educationUserLevel; - - readonly languageList = languageNamesList; - - readonly languageLevelList = languageLevelsList; - - stageForm: FormGroup; - errorMessage = ErrorMessage; - profile?: User; - stageSubmitting = signal(false); - skipSubmitting = signal(false); - - educationItems = signal([]); - - workItems = signal([]); - - languageItems = signal([]); - - isModalErrorYear = signal(false); - isModalErrorYearText = signal(""); - - editIndex = signal(null); - - editEducationClick = false; - editWorkClick = false; - editLanguageClick = false; - - selectedEntryYearEducationId = signal(undefined); - selectedComplitionYearEducationId = signal(undefined); - selectedEducationStatusId = signal(undefined); - selectedEducationLevelId = signal(undefined); - - selectedEntryYearWorkId = signal(undefined); - selectedComplitionYearWorkId = signal(undefined); - - selectedLanguageId = signal(undefined); - selectedLanguageLevelId = signal(undefined); - - subscriptions$: Subscription[] = []; - - get achievements(): FormArray { - return this.stageForm.get("achievements") as FormArray; - } - - get education(): FormArray { - return this.stageForm.get("education") as FormArray; - } - - get workExperience(): FormArray { - return this.stageForm.get("workExperience") as FormArray; - } - - get userLanguages(): FormArray { - return this.stageForm.get("userLanguages") as FormArray; - } - - get isEducationDirty(): boolean { - const f = this.stageForm; - return [ - "organizationName", - "entryYear", - "completionYear", - "description", - "educationStatus", - "educationLevel", - ].some(name => f.get(name)?.dirty); - } - - get isWorkDirty(): boolean { - const f = this.stageForm; - return [ - "organizationNameWork", - "entryYearWork", - "completionYearWork", - "descriptionWork", - "jobPosition", - ].some(name => f.get(name)?.dirty); - } - - get isLanguageDirty(): boolean { - const f = this.stageForm; - return ["language", "languageLevel"].some(name => f.get(name)?.dirty); - } - - showTooltip( - type: - | "photo" - | "city" - | "education" - | "educationDescription" - | "work" - | "workName" - | "workDescription" - | "achievements" - | "language" - ): void { - switch (type) { - case "photo": - this.isHintPhotoVisible = true; - break; - case "city": - this.isHintCityVisible = true; - break; - case "education": - this.isHintEducationVisible = true; - break; - case "educationDescription": - this.isHintEducationDescriptionVisible = true; - break; - case "work": - this.isHintWorkVisible = true; - break; - case "workName": - this.isHintWorkNameVisible = true; - break; - case "workDescription": - this.isHintWorkDescriptionVisible = true; - break; - case "achievements": - this.isHintAchievementsVisible = true; - break; - case "language": - this.isHintLanguageVisible = true; - break; - } - } - - hideTooltip( - type: - | "photo" - | "city" - | "education" - | "educationDescription" - | "work" - | "workName" - | "workDescription" - | "achievements" - | "language" - ): void { - switch (type) { - case "photo": - this.isHintPhotoVisible = false; - break; - case "city": - this.isHintCityVisible = false; - break; - case "education": - this.isHintEducationVisible = false; - break; - case "educationDescription": - this.isHintEducationDescriptionVisible = false; - break; - case "work": - this.isHintWorkVisible = false; - break; - case "workName": - this.isHintWorkNameVisible = false; - break; - case "workDescription": - this.isHintWorkDescriptionVisible = false; - break; - case "achievements": - this.isHintAchievementsVisible = false; - break; - case "language": - this.isHintLanguageVisible = false; - break; - } - } - - addEducation() { - ["organizationName", "educationStatus"].forEach(name => - this.stageForm.get(name)?.clearValidators() - ); - ["organizationName", "educationStatus"].forEach(name => - this.stageForm.get(name)?.setValidators([Validators.required]) - ); - ["organizationName", "educationStatus"].forEach(name => - this.stageForm.get(name)?.updateValueAndValidity() - ); - ["organizationName", "educationStatus"].forEach(name => - this.stageForm.get(name)?.markAsTouched() - ); - - const entryYear = - typeof this.stageForm.get("entryYear")?.value === "string" - ? +this.stageForm.get("entryYear")?.value.slice(0, 5) - : this.stageForm.get("entryYear")?.value; - const completionYear = - typeof this.stageForm.get("completionYear")?.value === "string" - ? +this.stageForm.get("completionYear")?.value.slice(0, 5) - : this.stageForm.get("completionYear")?.value; - - if (entryYear !== null && completionYear !== null && entryYear > completionYear) { - this.isModalErrorYear.set(true); - this.isModalErrorYearText.set("Год начала обучения должен быть меньше года окончания"); - return; - } - - const educationItem = this.fb.group({ - organizationName: this.stageForm.get("organizationName")?.value, - entryYear, - completionYear, - description: this.stageForm.get("description")?.value, - educationStatus: this.stageForm.get("educationStatus")?.value, - educationLevel: this.stageForm.get("educationLevel")?.value, - }); - - const isOrganizationValid = this.stageForm.get("organizationName")?.valid; - const isStatusValid = this.stageForm.get("educationStatus")?.valid; - - if (isOrganizationValid && isStatusValid) { - if (this.editIndex() !== null) { - this.educationItems.update(items => { - const updatedItems = [...items]; - updatedItems[this.editIndex()!] = educationItem.value; - - this.education.at(this.editIndex()!).patchValue(educationItem.value); - return updatedItems; - }); - this.editIndex.set(null); - } else { - this.educationItems.update(items => [...items, educationItem.value]); - this.education.push(educationItem); - } - [ - "organizationName", - "entryYear", - "completionYear", - "description", - "educationStatus", - "educationLevel", - ].forEach(name => { - this.stageForm.get(name)?.reset(); - this.stageForm.get(name)?.setValue(""); - this.stageForm.get(name)?.clearValidators(); - this.stageForm.get(name)?.markAsPristine(); - this.stageForm.get(name)?.updateValueAndValidity(); - }); - } - this.editEducationClick = false; - } - - editEducation(index: number) { - this.editEducationClick = true; - const educationItem = this.education.value[index]; - - this.yearListEducation.forEach(entryYearWork => { - if (transformYearStringToNumber(entryYearWork.value as string) === educationItem.entryYear) { - this.selectedEntryYearEducationId.set(entryYearWork.id); - } - }); - - this.yearListEducation.forEach(completionYearWork => { - if ( - transformYearStringToNumber(completionYearWork.value as string) === - educationItem.completionYear - ) { - this.selectedComplitionYearEducationId.set(completionYearWork.id); - } - }); - - this.educationLevelList.forEach(educationLevel => { - if (educationLevel.value === educationItem.educationLevel) { - this.selectedEducationLevelId.set(educationLevel.id); - } - }); - - this.educationStatusList.forEach(educationStatus => { - if (educationStatus.value === educationItem.educationStatus) { - this.selectedEducationStatusId.set(educationStatus.id); - } - }); - - this.stageForm.patchValue({ - organizationName: educationItem.organizationName, - entryYear: educationItem.entryYear, - completionYear: educationItem.completionYear, - description: educationItem.description, - educationStatus: educationItem.educationStatus, - educationLevel: educationItem.educationLevel, - }); - this.editIndex.set(index); - } - - removeEducation(i: number) { - this.educationItems.update(items => items.filter((_, index) => index !== i)); - - this.education.removeAt(i); - } - - addWork() { - ["organizationNameWork", "jobPosition"].forEach(name => - this.stageForm.get(name)?.clearValidators() - ); - ["organizationNameWork", "jobPosition"].forEach(name => - this.stageForm.get(name)?.setValidators([Validators.required]) - ); - ["organizationNameWork", "jobPosition"].forEach(name => - this.stageForm.get(name)?.updateValueAndValidity() - ); - ["organizationNameWork", "jobPosition"].forEach(name => - this.stageForm.get(name)?.markAsTouched() - ); - - const entryYear = - typeof this.stageForm.get("entryYearWork")?.value === "string" - ? +this.stageForm.get("entryYearWork")?.value.slice(0, 5) - : this.stageForm.get("entryYearWork")?.value; - const completionYear = - typeof this.stageForm.get("completionYearWork")?.value === "string" - ? +this.stageForm.get("completionYearWork")?.value.slice(0, 5) - : this.stageForm.get("completionYearWork")?.value; - - if (entryYear !== null && completionYear !== null && entryYear > completionYear) { - this.isModalErrorYear.set(true); - this.isModalErrorYearText.set("Год начала работы должен быть меньше года окончания"); - return; - } - - const workItem = this.fb.group({ - organizationName: this.stageForm.get("organizationNameWork")?.value, - entryYear, - completionYear, - description: this.stageForm.get("descriptionWork")?.value, - jobPosition: this.stageForm.get("jobPosition")?.value, - }); - - const isOrganizationValid = this.stageForm.get("organizationNameWork")?.valid; - const isPositionValid = this.stageForm.get("jobPosition")?.valid; - - if (isOrganizationValid && isPositionValid) { - if (this.editIndex() !== null) { - this.workItems.update(items => { - const updatedItems = [...items]; - updatedItems[this.editIndex()!] = workItem.value; - - this.workExperience.at(this.editIndex()!).patchValue(workItem.value); - return updatedItems; - }); - this.editIndex.set(null); - } else { - this.workItems.update(items => [...items, workItem.value]); - this.workExperience.push(workItem); - } - [ - "organizationNameWork", - "entryYearWork", - "completionYearWork", - "descriptionWork", - "jobPosition", - ].forEach(name => { - this.stageForm.get(name)?.reset(); - this.stageForm.get(name)?.setValue(""); - this.stageForm.get(name)?.clearValidators(); - this.stageForm.get(name)?.markAsPristine(); - this.stageForm.get(name)?.updateValueAndValidity(); - }); - } - this.editWorkClick = false; - } - - editWork(index: number) { - this.editWorkClick = true; - const workItem = this.workExperience.value[index]; - - if (workItem) { - this.yearListEducation.forEach(entryYearWork => { - if ( - transformYearStringToNumber(entryYearWork.value as string) === workItem.entryYearWork || - transformYearStringToNumber(entryYearWork.value as string) === workItem.entryYear - ) { - this.selectedEntryYearWorkId.set(entryYearWork.id); - } - }); - - this.yearListEducation.forEach(complitionYearWork => { - if ( - transformYearStringToNumber(complitionYearWork.value as string) === - workItem.completionYearWork || - transformYearStringToNumber(complitionYearWork.value as string) === - workItem.completionYear - ) { - this.selectedComplitionYearWorkId.set(complitionYearWork.id); - } - }); - - this.stageForm.patchValue({ - organizationNameWork: workItem.organization || workItem.organizationName, - entryYearWork: workItem.entryYearWork || workItem.entryYear, - completionYearWork: workItem.completionYearWork || workItem.completionYear, - descriptionWork: workItem.descriptionWork || workItem.description, - jobPosition: workItem.jobPosition, - }); - this.editIndex.set(index); - } - } - - removeWork(i: number) { - this.workItems.update(items => items.filter((_, index) => index !== i)); - - this.workExperience.removeAt(i); - } - - addLanguage() { - const languageValue = this.stageForm.get("language")?.value; - const languageLevelValue = this.stageForm.get("languageLevel")?.value; - - ["language", "languageLevel"].forEach(name => { - this.stageForm.get(name)?.clearValidators(); - }); - - if ((languageValue && !languageLevelValue) || (!languageValue && languageLevelValue)) { - ["language", "languageLevel"].forEach(name => { - this.stageForm.get(name)?.setValidators([Validators.required]); - }); - } - - ["language", "languageLevel"].forEach(name => { - this.stageForm.get(name)?.updateValueAndValidity(); - this.stageForm.get(name)?.markAsTouched(); - }); - - const isLanguageValid = this.stageForm.get("language")?.valid; - const isLanguageLevelValid = this.stageForm.get("languageLevel")?.valid; - - if (!isLanguageValid || !isLanguageLevelValid) { - return; - } - - const languageItem = this.fb.group({ - language: languageValue, - languageLevel: languageLevelValue, - }); - - if (languageValue && languageLevelValue) { - if (this.editIndex() !== null) { - this.languageItems.update(items => { - const updatedItems = [...items]; - updatedItems[this.editIndex()!] = languageItem.value; - - this.userLanguages.at(this.editIndex()!).patchValue(languageItem.value); - return updatedItems; - }); - this.editIndex.set(null); - } else { - this.languageItems.update(items => [...items, languageItem.value]); - this.userLanguages.push(languageItem); - } - ["language", "languageLevel"].forEach(name => { - this.stageForm.get(name)?.reset(); - this.stageForm.get(name)?.setValue(null); - this.stageForm.get(name)?.clearValidators(); - this.stageForm.get(name)?.markAsPristine(); - this.stageForm.get(name)?.updateValueAndValidity(); - }); - - this.editLanguageClick = false; - } - } - - editLanguage(index: number) { - this.editLanguageClick = true; - const languageItem = this.userLanguages.value[index]; - - this.languageList.forEach(language => { - if (language.value === languageItem.language) { - this.selectedLanguageId.set(language.id); - } - }); - - this.languageLevelList.forEach(languageLevel => { - if (languageLevel.value === languageItem.languageLevel) { - this.selectedLanguageLevelId.set(languageLevel.id); - } - }); - - this.stageForm.patchValue({ - language: languageItem.language, - languageLevel: languageItem.languageLevel, - }); - - this.editIndex.set(index); - } - - removeLanguage(i: number) { - this.languageItems.update(items => items.filter((_, index) => index !== i)); - - this.userLanguages.removeAt(i); - } - - addAchievement(id?: number, title?: string, status?: string): void { - this.achievements.push( - this.fb.group({ - title: [title ?? "", [Validators.required]], - status: [status ?? "", [Validators.required]], - id: [id], - }) - ); - } - - removeAchievement(i: number): void { - this.achievements.removeAt(i); - } - - onSkipRegistration(): void { - if (!this.validationService.getFormValidation(this.stageForm)) { - return; - } - - const onboardingSkipInfo = { - avatar: this.stageForm.get("avatar")?.value, - city: this.stageForm.get("city")?.value, - }; - - this.skipSubmitting.set(true); - this.authService.saveProfile(onboardingSkipInfo).subscribe({ - next: () => this.completeRegistration(3), - error: error => { - this.skipSubmitting.set(false); - this.isModalErrorYear.set(true); - this.isModalErrorYearText.set(error.error?.message || "Ошибка сохранения"); - }, - }); - } - - onSubmit(): void { - if (!this.validationService.getFormValidation(this.stageForm)) { - this.achievements.markAllAsTouched(); - return; - } - - const newStageForm = { - avatar: this.stageForm.get("avatar")?.value, - city: this.stageForm.get("city")?.value, - education: this.education.value, - workExperience: this.workExperience.value, - userLanguages: this.userLanguages.value, - achievements: this.achievements.value, - }; - - this.stageSubmitting.set(true); - this.authService - .saveProfile(newStageForm) - .pipe(concatMap(() => this.authService.setOnboardingStage(1))) - .subscribe({ - next: () => this.completeRegistration(1), - error: error => { - this.stageSubmitting.set(false); - this.isModalErrorYear.set(true); - if (error.error.language) { - this.isModalErrorYearText.set(error.error.language); - } - }, - }); - } - - private completeRegistration(stage: number): void { - this.skipSubmitting.set(true); - this.onboardingService.setFormValue(this.stageForm.value); - this.router.navigateByUrl( - stage === 1 ? "/office/onboarding/stage-1" : "/office/onboarding/stage-3" - ); - this.skipSubmitting.set(false); - } -} diff --git a/projects/social_platform/src/app/office/onboarding/user-type-card/user-type-card.component.ts b/projects/social_platform/src/app/office/onboarding/user-type-card/user-type-card.component.ts deleted file mode 100644 index c6cd50763..000000000 --- a/projects/social_platform/src/app/office/onboarding/user-type-card/user-type-card.component.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** @format */ - -import { Component, Input, OnInit } from "@angular/core"; - -/** - * КОМПОНЕНТ КАРТОЧКИ ТИПА ПОЛЬЗОВАТЕЛЯ - * - * Назначение: Переиспользуемый UI-компонент для отображения варианта выбора роли пользователя - * - * Что делает: - * - Отображает визуальную карточку с информацией о типе пользователя - * - Поддерживает активное/неактивное состояние для визуальной обратной связи - * - Используется в stage-three для выбора между ментором и менти - * - * Что принимает: - * - @Input() isActive: boolean - флаг активного состояния карточки - * (определяет визуальное выделение выбранной опции) - * - * Что возвращает: - * - Визуальный элемент карточки с соответствующими стилями - * - Реагирует на изменение состояния isActive обновлением внешнего вида - * - * Использование: - * - Встраивается в родительский компонент stage-three - * - Родитель управляет состоянием isActive в зависимости от выбора пользователя - * - Обеспечивает консистентный UI для выбора опций - */ -@Component({ - selector: "app-user-type-card", - templateUrl: "./user-type-card.component.html", - styleUrl: "./user-type-card.component.scss", - standalone: true, -}) -export class UserTypeCardComponent implements OnInit { - @Input() isActive = false; - constructor() {} - - ngOnInit(): void {} -} diff --git a/projects/social_platform/src/app/office/profile/detail/main/main.component.html b/projects/social_platform/src/app/office/profile/detail/main/main.component.html deleted file mode 100644 index a01c14966..000000000 --- a/projects/social_platform/src/app/office/profile/detail/main/main.component.html +++ /dev/null @@ -1,391 +0,0 @@ - - -@if (user) { -
-
-
-
-
-

метаданные

- -
- -
    -
  • - -

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

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

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

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

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

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

языки

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

    {{ language.language }}

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

обо мне

- -
- -
-

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

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

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

контакты

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

образование

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

- {{ education.entryYear }} -

- -

- -

{{ education.completionYear }}

-
- -
-

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

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

работа

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

{{ workExperience.entryYear }}

- -

- -

{{ workExperience.completionYear }}

-
- -
-

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

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

{{ workExperience.organizationName }}

- -
- -

{{ workExperience.description }}

- -

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

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

проекты

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

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

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

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

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

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

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

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

- } } @else { -

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

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

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

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

    - {{ item.label }} -

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

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

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

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

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

- {{ educationItem.organizationName }} -

- -

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

- -
-
-

- {{ educationItem.description }} -

- -

- {{ educationItem.educationLevel }} -

- -

- {{ educationItem.educationStatus }} -

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

- {{ workItem.organizationName }} -

- -

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

- -
-
-

- {{ workItem.description }} -

- -

- {{ workItem.jobPosition }} -

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

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

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

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

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

- {{ achievementItem.title }} -

- -

- {{ achievementItem.year }} -

- -

- {{ achievementItem.status }} -

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

- {{ languageItem.language }} -

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

- {{ languageItem.languageLevel }} -

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

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

-
- @if (isModalErrorSkillChooseText()) { -

{{ isModalErrorSkillChooseText() }}.

- } @else { -

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

- } -
-
- - -
-
-

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

- -
- - удалить аккаунт -
-
- - - - - - - - diff --git a/projects/social_platform/src/app/office/profile/edit/edit.component.scss b/projects/social_platform/src/app/office/profile/edit/edit.component.scss deleted file mode 100644 index 86a68799c..000000000 --- a/projects/social_platform/src/app/office/profile/edit/edit.component.scss +++ /dev/null @@ -1,389 +0,0 @@ -/** @format */ - -@use "styles/responsive"; -@use "styles/typography"; - -.profile { - position: relative; - 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-evenly; - width: 100%; - padding: 4px 0; - margin-top: 20px; - background-color: var(--light-white); - border-radius: var(--rounded-xxl); - } - - &__title { - display: none; - - @include responsive.apply-desktop { - display: block; - } - - @include typography.heading-1; - } - - &__back { - display: flex; - gap: 10px; - align-items: center; - cursor: pointer; - } - - &__form { - display: flex; - flex-direction: column; - color: var(--black); - } - - &__navigation { - margin-bottom: 35px; - } - - &__nav { - display: flex; - flex-wrap: wrap; - gap: 10px; - align-items: center; - 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; - 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(--dark-grey); - - @include typography.body-12; - - &--active { - color: var(--black); - } - } - - &__icon { - opacity: 0.1; - - &--active { - color: var(--accent); - opacity: 1; - } - } - - &__file { - flex-grow: 1; - min-width: 0; - max-width: 333px; - - ::ng-deep { - app-upload-file { - height: 80px; - padding: 10px 30px; - } - } - } - - &__slides-title { - max-width: 320px; - margin-top: 12px; - color: var(--black); - text-align: center; - } - - &__slides-text { - max-width: 275px; - margin-top: 12px; - color: var(--black); - text-align: center; - opacity: 0.3; - - &:hover { - opacity: 1; - } - } - - &__slides-error { - margin-top: 12px; - color: var(--red); - } - - &__slides-open-file { - color: var(--accent); - transition: color 0.2s; - - &:hover { - color: var(--accent-dark); - } - } - - .error__phone-number { - margin-bottom: 10px; - } - - &__save { - order: 3; - margin-top: 16px; - - @include responsive.apply-desktop { - z-index: 10; - order: unset; - margin-top: auto; - margin-left: auto; - } - } - - &__row { - display: flex; - gap: 20px; - align-items: center; - width: 100%; - } - - &__column { - display: flex; - flex-direction: column; - gap: 12px; - - &--date { - display: grid; - grid-template-columns: repeat(2, 3fr); - grid-gap: 20px; - } - } - - &__action { - &--icons { - display: flex; - flex-direction: column; - gap: 10px; - align-items: center; - } - } -} - -.profile__main-text--grid { - display: grid; - grid-template-columns: 0.1fr 2fr 1fr 1fr; - grid-gap: 20px; -} - -.profile__main--grid { - display: grid; - grid-template-columns: 3fr 3fr 4fr; - grid-gap: 20px; -} - -.profile__wrapper--main { - display: flex; - flex-direction: column; - gap: 12px; -} - -.profile__wrapper--links { - display: grid; - grid-template-columns: 7fr 3fr; - grid-gap: 20px; -} - -.profile__wrapper--education { - display: grid; - grid-template-columns: 6fr 4fr; - grid-gap: 20px; -} - -.profile__wrapper--settings { - display: grid; - grid-template-columns: 4fr 6fr; -} - -.profile__info--education { - display: flex; - flex-direction: column; - gap: 12px; -} - -.profile__education--list { - display: flex; - flex-direction: column; - gap: 20px; -} - -.profile__language--list { - display: grid; - grid-template-columns: 2fr 2fr; - grid-gap: 20px; -} - -.profile__education--card { - display: flex; - align-items: center; - justify-content: space-between; -} - -.profile__education--info { - display: flex; - flex-direction: column; - gap: 5px; - - :first-child { - color: var(--black); - } - - :last-child & app-file-item { - margin-top: 3px; - } -} - -.edit-icon, -.basket-icon { - width: 20px; - height: 20px; - padding: 6px; - cursor: pointer; - border-radius: 50%; -} - -.edit-icon { - border: 0.5px solid var(--accent); - - i { - color: var(--accent) !important; - } -} - -.basket-icon { - border: 0.5px solid var(--red); - - i { - color: var(--red) !important; - } -} - -.education { - &__title { - display: flex; - align-items: center; - justify-content: space-between; - padding-bottom: 5px; - border-bottom: 0.5px solid var(--medium-grey-for-outline); - } - - &__text { - color: var(--grey-for-text); - word-break: break-word; - overflow-wrap: anywhere; - white-space: wrap; - } -} - -.modal { - &__wrapper { - display: flex; - flex-direction: column; - gap: 20px; - align-items: center; - width: 672px; - } - - &__content { - display: flex; - flex-direction: column; - gap: 20px; - width: 100%; - max-width: 536px; - height: 480px; - background-color: var(--white); - border: 1px solid var(--medium-grey-for-outline); - border-radius: 8px; - box-shadow: 5px 5px 25px 0 var(--gray-for-shadow); - } - - &__specs-groups, - &__skills-groups { - height: 100%; - overflow: auto; - scrollbar-width: thin; - - ul { - display: flex; - flex-direction: column; - gap: 20px; - padding: 14px; - } - - li { - display: flex; - align-items: center; - justify-content: space-between; - cursor: pointer; - } - } -} - -.cancel { - display: flex; - flex-direction: column; - justify-content: center; - max-height: calc(100vh - 40px); - overflow-y: auto; - - &__cross { - position: absolute; - top: 0; - right: 0; - width: 32px; - height: 32px; - cursor: pointer; - - @include responsive.apply-desktop { - top: 8px; - right: 8px; - } - } - - &__delete-icon { - display: flex; - justify-content: center; - margin: 36px 0; - } - - &__title { - text-align: center; - } - - &__text { - text-align: center; - } -} diff --git a/projects/social_platform/src/app/office/profile/edit/edit.component.spec.ts b/projects/social_platform/src/app/office/profile/edit/edit.component.spec.ts deleted file mode 100644 index d8e54370b..000000000 --- a/projects/social_platform/src/app/office/profile/edit/edit.component.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** @format */ - -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { ProfileEditComponent } from "./edit.component"; -import { of } from "rxjs"; -import { AuthService } from "@auth/services"; -import { ReactiveFormsModule } from "@angular/forms"; -import { RouterTestingModule } from "@angular/router/testing"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; -import { NgxMaskModule } from "ngx-mask"; - -describe("ProfileEditComponent", () => { - let component: ProfileEditComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - const authSpy = { - profile: of({}), - changeableRoles: of([]), - }; - - await TestBed.configureTestingModule({ - imports: [ - RouterTestingModule, - ReactiveFormsModule, - HttpClientTestingModule, - NgxMaskModule.forRoot(), - ProfileEditComponent, - ], - providers: [{ provide: AuthService, useValue: authSpy }], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(ProfileEditComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/profile/edit/edit.component.ts b/projects/social_platform/src/app/office/profile/edit/edit.component.ts deleted file mode 100644 index 782789f37..000000000 --- a/projects/social_platform/src/app/office/profile/edit/edit.component.ts +++ /dev/null @@ -1,1240 +0,0 @@ -/** @format */ - -import { - AfterViewInit, - ChangeDetectorRef, - Component, - OnDestroy, - OnInit, - signal, -} from "@angular/core"; -import { AuthService } from "@auth/services"; -import { - FormArray, - FormBuilder, - FormControl, - FormGroup, - ReactiveFormsModule, - Validators, -} from "@angular/forms"; -import { ErrorMessage } from "@error/models/error-message"; -import { ButtonComponent, IconComponent, InputComponent, SelectComponent } from "@ui/components"; -import { ControlErrorPipe, ValidationService } from "projects/core"; -import { concatMap, first, map, noop, Observable, skip, Subscription } from "rxjs"; -import { ActivatedRoute, Router, RouterModule } from "@angular/router"; -import * as dayjs from "dayjs"; -import * as cpf from "dayjs/plugin/customParseFormat"; -import { NavService } from "@services/nav.service"; -import { EditorSubmitButtonDirective } from "@ui/directives/editor-submit-button.directive"; -import { TextareaComponent } from "@ui/components/textarea/textarea.component"; -import { AvatarControlComponent } from "@ui/components/avatar-control/avatar-control.component"; -import { AsyncPipe, CommonModule } from "@angular/common"; -import { Specialization } from "@office/models/specialization.model"; -import { SpecializationsService } from "@office/services/specializations.service"; -import { AutoCompleteInputComponent } from "@ui/components/autocomplete-input/autocomplete-input.component"; -import { SkillsGroupComponent } from "@office/shared/skills-group/skills-group.component"; -import { SpecializationsGroupComponent } from "@office/shared/specializations-group/specializations-group.component"; -import { SkillsBasketComponent } from "@office/shared/skills-basket/skills-basket.component"; -import { ModalComponent } from "@ui/components/modal/modal.component"; -import { Skill } from "@office/models/skill.model"; -import { SkillsService } from "@office/services/skills.service"; -import { - educationUserLevel, - educationUserType, -} from "projects/core/src/consts/lists/education-info-list.const"; -import { - languageLevelsList, - languageNamesList, -} from "projects/core/src/consts/lists/language-info-list.const"; -import { transformYearStringToNumber } from "@utils/transformYear"; -import { yearRangeValidators } from "@utils/yearRangeValidators"; -import { Achievement, User } from "@auth/models/user.model"; -import { generateOptionsList } from "@utils/generate-options-list"; -import { UploadFileComponent } from "@ui/components/upload-file/upload-file.component"; -import { navProfileItems } from "projects/core/src/consts/navigation/nav-profile-items.const"; -import { FileItemComponent } from "@ui/components/file-item/file-item.component"; - -dayjs.extend(cpf); - -/** - * Компонент редактирования профиля пользователя - * - * Этот компонент предоставляет полнофункциональную форму для редактирования профиля пользователя - * с поддержкой множественных разделов (основная информация, образование, опыт работы, достижения, навыки). - * - * Основные возможности: - * - Редактирование основной информации (имя, фамилия, дата рождения, город, телефон) - * - Управление образованием (добавление, редактирование, удаление записей об образовании) - * - Управление опытом работы (добавление, редактирование, удаление записей о работе) - * - Управление языками (добавление, редактирование, удаление языковых навыков) - * - Управление достижениями (добавление, редактирование, удаление достижений) - * - Управление навыками через автокомплит и модальные окна с группировкой - * - Загрузка и обновление аватара пользователя - * - Пошаговая навигация между разделами формы - * - Валидация всех полей формы с отображением ошибок - * - * @implements OnInit - для инициализации компонента и подписок - * @implements OnDestroy - для очистки подписок - * @implements AfterViewInit - для работы с DOM после инициализации представления - */ -@Component({ - selector: "app-profile-edit", - templateUrl: "./edit.component.html", - styleUrl: "./edit.component.scss", - standalone: true, - imports: [ - ReactiveFormsModule, - CommonModule, - InputComponent, - SelectComponent, - IconComponent, - ButtonComponent, - AvatarControlComponent, - TextareaComponent, - EditorSubmitButtonDirective, - AsyncPipe, - ControlErrorPipe, - AutoCompleteInputComponent, - SkillsBasketComponent, - SkillsGroupComponent, - SpecializationsGroupComponent, - ModalComponent, - SelectComponent, - RouterModule, - UploadFileComponent, - FileItemComponent, - ], -}) -export class ProfileEditComponent implements OnInit, OnDestroy, AfterViewInit { - constructor( - private readonly cdref: ChangeDetectorRef, - public readonly authService: AuthService, - private readonly fb: FormBuilder, - private readonly validationService: ValidationService, - private readonly specsService: SpecializationsService, - private readonly skillsService: SkillsService, - private readonly router: Router, - private readonly route: ActivatedRoute, - private readonly navService: NavService - ) { - this.profileForm = this.fb.group({ - firstName: ["", [Validators.required]], - lastName: ["", [Validators.required]], - email: ["", [Validators.email, Validators.maxLength(50)]], - userType: [0], - birthday: ["", [Validators.required]], - city: ["", [Validators.required, Validators.maxLength(100)]], - phoneNumber: ["", Validators.maxLength(12)], - additionalRole: [null], - coverImageAddress: [null], - - // education - organizationName: ["", Validators.max(100)], - entryYear: [null], - completionYear: [null], - description: [null, Validators.max(400)], - educationLevel: [null], - educationStatus: [""], - isMospolytechStudent: [false], - studyGroup: ["", Validators.max(10)], - - // language - language: [null], - languageLevel: [null], - - // achievements - title: [null], - status: [null], - year: [null], - files: [""], - - education: this.fb.array([]), - workExperience: this.fb.array([]), - userLanguages: this.fb.array([]), - links: this.fb.array([]), - achievements: this.fb.array([]), - - // work - organization: ["", Validators.maxLength(50)], - entryYearWork: [null], - completionYearWork: [null], - descriptionWork: [null, Validators.maxLength(400)], - jobPosition: [""], - - // skills - speciality: ["", [Validators.required]], - skills: [[]], - avatar: [""], - aboutMe: ["", Validators.maxLength(300)], - typeSpecific: this.fb.group({}), - }); - } - - /** - * Инициализация компонента - * Настраивает форму, подписки на изменения, валидацию и заголовок навигации - */ - ngOnInit(): void { - this.navService.setNavTitle("Редактирование профиля"); - - const userType$ = this.profileForm - .get("userType") - ?.valueChanges.pipe(skip(1), concatMap(this.changeUserType.bind(this))) - .subscribe(noop); - - userType$ && this.subscription$.push(userType$); - - const userAvatar$ = this.profileForm - .get("avatar") - ?.valueChanges.pipe( - skip(1), - concatMap(url => this.authService.saveAvatar(url)) - ) - .subscribe(noop); - - userAvatar$ && this.subscription$.push(userAvatar$); - - // const isMospolytechStudentSub$ = this.profileForm - // .get("isMospolytechStudent") - // ?.valueChanges.subscribe(isStudent => { - // const studyGroup = this.profileForm.get("studyGroup"); - // if (isStudent) { - // studyGroup?.setValidators([Validators.required]); - // } else { - // studyGroup?.clearValidators(); - // } - - // studyGroup?.updateValueAndValidity(); - // }); - - // isMospolytechStudentSub$ && this.subscription$.push(isMospolytechStudentSub$); - - this.editingStep = this.route.snapshot.queryParams["editingStep"]; - } - - /** - * Инициализация после создания представления - * Загружает данные профиля пользователя и заполняет форму - */ - ngAfterViewInit() { - const profile$ = this.authService.profile.pipe(first()).subscribe((profile: User) => { - this.profileId = profile.id; - - this.profileForm.patchValue({ - firstName: profile.firstName ?? "", - lastName: profile.lastName ?? "", - email: profile.email ?? "", - userType: profile.userType ?? 1, - birthday: profile.birthday ? dayjs(profile.birthday).format("DD.MM.YYYY") : "", - city: profile.city ?? "", - coverImageAddress: profile.coverImageAddress ?? "", - phoneNumber: profile.phoneNumber ?? "", - additionalRole: profile.v2Speciality?.name ?? "", - speciality: profile.speciality ?? "", - skills: profile.skills ?? [], - avatar: profile.avatar ?? "", - aboutMe: profile.aboutMe ?? "", - isMospolytechStudent: profile.isMospolytechStudent ?? false, - studyGroup: profile.studyGroup ?? "", - }); - - this.workExperience.clear(); - profile.workExperience.forEach(work => { - this.workExperience.push( - this.fb.group( - { - organizationName: work.organizationName, - entryYear: work.entryYear, - completionYear: work.completionYear, - description: work.description, - jobPosition: work.jobPosition, - }, - { - validators: yearRangeValidators("entryYear", "completionYear"), - } - ) - ); - }); - - this.education.clear(); - profile.education.forEach(edu => { - this.education.push( - this.fb.group( - { - organizationName: edu.organizationName, - entryYear: edu.entryYear, - completionYear: edu.completionYear, - description: edu.description, - educationStatus: edu.educationStatus, - educationLevel: edu.educationLevel, - }, - { - validators: yearRangeValidators("entryYear", "completionYear"), - } - ) - ); - }); - - this.userLanguages.clear(); - profile.userLanguages.forEach(lang => { - this.userLanguages.push( - this.fb.group({ - language: lang.language, - languageLevel: lang.languageLevel, - }) - ); - }); - - this.cdref.detectChanges(); - - this.achievements.clear(); - profile.achievements.forEach(achievement => { - this.achievements.push( - this.fb.group({ - id: [achievement.id], - title: [achievement.title, Validators.required], - status: [achievement.status, Validators.required], - year: [achievement.year, Validators.required], - files: [achievement.files ?? []], - }) - ); - }); - - this.cdref.detectChanges(); - - profile.links.length && profile.links.forEach(l => this.addLink(l)); - - if ([2, 3, 4].includes(profile.userType)) { - this.typeSpecific?.addControl("preferredIndustries", this.fb.array([])); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - profile[this.userTypeMap[profile.userType]].preferredIndustries.forEach( - (industry: string) => this.addPreferredIndustry(industry) - ); - - this.cdref.detectChanges(); - } - - if ([1, 3, 4].includes(profile.userType)) { - const userTypeData = profile.member ?? profile.mentor ?? profile.expert; - this.typeSpecific.addControl("usefulToProject", this.fb.control("")); - this.typeSpecific.get("usefulToProject")?.patchValue(userTypeData?.usefulToProject); - this.cdref.detectChanges(); - } - - this.cdref.detectChanges(); - }); - profile$ && this.subscription$.push(profile$); - } - - /** - * Очистка ресурсов при уничтожении компонента - * Отписывается от всех активных подписок - */ - ngOnDestroy(): void { - this.subscription$.forEach($ => $.unsubscribe()); - } - - editingStep: "main" | "education" | "experience" | "achievements" | "skills" | "settings" = - "main"; - - profileId?: number; - - inlineSpecs = signal([]); - - nestedSpecs$ = this.specsService.getSpecializationsNested(); - - specsGroupsModalOpen = signal(false); - - inlineSkills = signal([]); - - nestedSkills$ = this.skillsService.getSkillsNested(); - - skillsGroupsModalOpen = signal(false); - - openGroupIndex: number | null = null; - - onGroupToggled(index: number, isOpen: boolean) { - this.openGroupIndex = isOpen ? index : null; - } - - isGroupDisabled(index: number): boolean { - return this.openGroupIndex !== null && this.openGroupIndex !== index; - } - - educationItems = signal([]); - - workItems = signal([]); - - languageItems = signal([]); - - achievementItems = signal([]); - - isModalErrorSkillsChoose = signal(false); - isModalErrorSkillChooseText = signal(""); - - isModalDeleteProfile = signal(false); - - editIndex = signal(null); - - editEducationClick = false; - editWorkClick = false; - editLanguageClick = false; - editAchievementsClick = false; - - showEducationFields = false; - showWorkFields = false; - showLanguageFields = false; - showAchievementsFields = false; - - selectedEntryYearEducationId = signal(undefined); - selectedComplitionYearEducationId = signal(undefined); - selectedEducationStatusId = signal(undefined); - selectedEducationLevelId = signal(undefined); - - selectedEntryYearWorkId = signal(undefined); - selectedComplitionYearWorkId = signal(undefined); - - selectedAchievementsYearId = signal(undefined); - - selectedLanguageId = signal(undefined); - selectedLanguageLevelId = signal(undefined); - - subscription$: Subscription[] = []; - - readonly navProfileItems = navProfileItems; - - /** - * Навигация между шагами редактирования профиля - * @param step - название шага ('main' | 'education' | 'experience' | 'achievements' | 'skills' | 'settings) - */ - navigateStep(step: string) { - this.router.navigate([], { queryParams: { editingStep: step } }); - this.editingStep = step as - | "main" - | "education" - | "experience" - | "achievements" - | "skills" - | "settings"; - } - - readonly yearListEducation = generateOptionsList(55, "years").reverse(); - - readonly educationStatusList = educationUserType; - - readonly educationLevelList = educationUserLevel; - - readonly languageList = languageNamesList; - - readonly languageLevelList = languageLevelsList; - - readonly achievementsYearList = generateOptionsList(25, "years"); - - get typeSpecific(): FormGroup { - return this.profileForm.get("typeSpecific") as FormGroup; - } - - get usefulToProject(): FormControl { - return this.typeSpecific.get("usefulToProject") as FormControl; - } - - get preferredIndustries(): FormArray { - return this.typeSpecific.get("preferredIndustries") as FormArray; - } - - newPreferredIndustryTitle = ""; - - addPreferredIndustry(title?: string): void { - const fromState = title ?? this.newPreferredIndustryTitle; - if (!fromState) { - return; - } - - const control = this.fb.control(fromState, [Validators.required]); - this.preferredIndustries.push(control); - - this.newPreferredIndustryTitle = ""; - } - - removePreferredIndustry(i: number): void { - this.preferredIndustries.removeAt(i); - } - - get achievements(): FormArray { - return this.profileForm.get("achievements") as FormArray; - } - - get education(): FormArray { - return this.profileForm.get("education") as FormArray; - } - - get workExperience(): FormArray { - return this.profileForm.get("workExperience") as FormArray; - } - - get userLanguages(): FormArray { - return this.profileForm.get("userLanguages") as FormArray; - } - - get isEducationDirty(): boolean { - const f = this.profileForm; - return [ - "organizationName", - "entryYear", - "completionYear", - "description", - "educationStatus", - "educationLevel", - ].some(name => f.get(name)?.dirty); - } - - get isWorkDirty(): boolean { - const f = this.profileForm; - return [ - "organization", - "entryYearWork", - "completionYearWork", - "descriptionWork", - "jobPosition", - ].some(name => f.get(name)?.dirty); - } - - get isLanguageDirty(): boolean { - const f = this.profileForm; - return ["language", "languageLevel"].some(name => f.get(name)?.dirty); - } - - get isAchievementsDirty(): boolean { - const f = this.profileForm; - return ["title", "status", "year", "files"].some(name => f.get(name)?.dirty); - } - - errorMessage = ErrorMessage; - - roles: Observable = this.authService.changeableRoles.pipe( - map(roles => roles.map(role => ({ id: role.id, value: role.id, label: role.name }))) - ); - - profileFormSubmitting = false; - profileForm: FormGroup; - - // Для управления открытыми группами специализаций - openSpecializationGroup: string | null = null; - - /** - * Проверяет, есть ли открытые группы специализаций - */ - hasOpenSpecializationsGroups(): boolean { - return this.openSpecializationGroup !== null; - } - - /** - * Проверяет, должна ли группа специализаций быть отключена - * @param groupName - название группы для проверки - */ - isSpecializationGroupDisabled(groupName: string): boolean { - return this.openSpecializationGroup !== null && this.openSpecializationGroup !== groupName; - } - - /** - * Обработчик переключения группы специализаций - * @param isOpen - флаг открытия/закрытия группы - * @param groupName - название группы - */ - onSpecializationsGroupToggled(isOpen: boolean, groupName: string): void { - this.openSpecializationGroup = isOpen ? groupName : null; - } - - /** - * Добавление записи об достижении - * Валидирует форму и добавляет новую запись в массив достижений - */ - addAchievement(): void { - if (!this.showAchievementsFields) { - this.showAchievementsFields = true; - - this.profileForm.patchValue({ - title: "", - status: "", - year: null, - files: "", - }); - - return; - } - - ["title", "status", "year"].forEach(name => this.profileForm.get(name)?.clearValidators()); - ["title", "status", "year"].forEach(name => - this.profileForm.get(name)?.setValidators([Validators.required]) - ); - ["title", "status", "year"].forEach(name => - this.profileForm.get(name)?.updateValueAndValidity() - ); - ["title", "status", "year"].forEach(name => this.profileForm.get(name)?.markAsTouched()); - - const achievementsYear = - typeof this.profileForm.get("year")?.value === "string" - ? +this.profileForm.get("year")?.value.slice(0, 5) - : this.profileForm.get("year")?.value; - - const achievementsItem = this.fb.group({ - id: [null], - title: this.profileForm.get("title")?.value, - status: this.profileForm.get("status")?.value, - year: achievementsYear, - files: Array.isArray(this.profileForm.get("files")?.value) - ? this.profileForm.get("files")?.value - : [this.profileForm.get("files")?.value].filter(Boolean), - }); - - if (this.editIndex() !== null) { - const existingId = this.achievements.at(this.editIndex()!).get("id")?.value; - - this.achievements.at(this.editIndex()!).patchValue({ - ...achievementsItem.value, - id: existingId, - }); - - this.achievementItems.update(items => { - const updatedItems = [...items]; - updatedItems[this.editIndex()!] = { ...achievementsItem.value, id: existingId }; - return updatedItems; - }); - - this.editIndex.set(null); - } else { - this.achievementItems.update(items => [...items, achievementsItem.value]); - this.achievements.push(achievementsItem); - } - ["title", "status", "year", "files"].forEach(name => { - this.profileForm.get(name)?.reset(); - this.profileForm.get(name)?.setValue(""); - this.profileForm.get(name)?.clearValidators(); - this.profileForm.get(name)?.markAsPristine(); - this.profileForm.get(name)?.markAsUntouched(); - this.profileForm.get(name)?.updateValueAndValidity(); - }); - - this.showAchievementsFields = false; - this.editAchievementsClick = false; - } - - /** - * Редактирование записи об достижений - * @param index - индекс записи в массиве достижений - */ - editAchievements(index: number) { - this.editAchievementsClick = true; - this.showAchievementsFields = true; - const achievementItem = this.achievements.value[index]; - - this.achievementsYearList.forEach(achievementYear => { - if (transformYearStringToNumber(achievementYear.value as string) === achievementItem.year) { - this.selectedAchievementsYearId.set(achievementYear.id); - } - }); - - this.profileForm.patchValue({ - title: achievementItem.title, - status: achievementItem.status, - year: achievementItem.year, - }); - this.editIndex.set(index); - } - - /** - * Удаление записи об достижении - * @param i - индекс записи для удаления - */ - removeAchievement(i: number): void { - this.achievementItems.update(items => items.filter((_, index) => index !== i)); - this.achievements.removeAt(i); - } - - /** - * Добавление записи об образовании - * Валидирует форму и добавляет новую запись в массив образования - */ - addEducation() { - if (!this.showEducationFields) { - this.showEducationFields = true; - return; - } - - ["organizationName", "educationStatus"].forEach(name => - this.profileForm.get(name)?.clearValidators() - ); - ["organizationName", "educationStatus"].forEach(name => - this.profileForm.get(name)?.setValidators([Validators.required]) - ); - ["organizationName", "educationStatus"].forEach(name => - this.profileForm.get(name)?.updateValueAndValidity() - ); - ["organizationName", "educationStatus"].forEach(name => - this.profileForm.get(name)?.markAsTouched() - ); - - const entryYear = - typeof this.profileForm.get("entryYear")?.value === "string" - ? +this.profileForm.get("entryYear")?.value.slice(0, 5) - : this.profileForm.get("entryYear")?.value; - const completionYear = - typeof this.profileForm.get("completionYear")?.value === "string" - ? +this.profileForm.get("completionYear")?.value.slice(0, 5) - : this.profileForm.get("completionYear")?.value; - - if (entryYear !== null && completionYear !== null && entryYear > completionYear) { - this.isModalErrorSkillsChoose.set(true); - this.isModalErrorSkillChooseText.set("Год начала обучения должен быть меньше года окончания"); - return; - } - - const educationItem = this.fb.group({ - organizationName: this.profileForm.get("organizationName")?.value, - entryYear, - completionYear, - description: this.profileForm.get("description")?.value, - educationStatus: this.profileForm.get("educationStatus")?.value, - educationLevel: this.profileForm.get("educationLevel")?.value, - }); - - const isOrganizationValid = this.profileForm.get("organizationName")?.valid; - const isStatusValid = this.profileForm.get("educationStatus")?.valid; - - if (isOrganizationValid && isStatusValid) { - if (this.editIndex() !== null) { - this.educationItems.update(items => { - const updatedItems = [...items]; - updatedItems[this.editIndex()!] = educationItem.value; - - this.education.at(this.editIndex()!).patchValue(educationItem.value); - return updatedItems; - }); - this.editIndex.set(null); - } else { - this.educationItems.update(items => [...items, educationItem.value]); - this.education.push(educationItem); - } - [ - "organizationName", - "entryYear", - "completionYear", - "description", - "educationStatus", - "educationLevel", - ].forEach(name => { - this.profileForm.get(name)?.reset(); - this.profileForm.get(name)?.setValue(""); - this.profileForm.get(name)?.clearValidators(); - this.profileForm.get(name)?.markAsPristine(); - this.profileForm.get(name)?.updateValueAndValidity(); - }); - this.showEducationFields = false; - } - this.editEducationClick = false; - } - - /** - * Редактирование записи об образовании - * @param index - индекс записи в массиве образования - */ - editEducation(index: number) { - this.editEducationClick = true; - this.showEducationFields = true; - const educationItem = this.education.value[index]; - - this.yearListEducation.forEach(entryYearWork => { - if (transformYearStringToNumber(entryYearWork.value as string) === educationItem.entryYear) { - this.selectedEntryYearEducationId.set(entryYearWork.id); - } - }); - - this.yearListEducation.forEach(completionYearWork => { - if ( - transformYearStringToNumber(completionYearWork.value as string) === - educationItem.completionYear - ) { - this.selectedComplitionYearEducationId.set(completionYearWork.id); - } - }); - - this.educationLevelList.forEach(educationLevel => { - if (educationLevel.value === educationItem.educationLevel) { - this.selectedEducationLevelId.set(educationLevel.id); - } - }); - - this.educationStatusList.forEach(educationStatus => { - if (educationStatus.value === educationItem.educationStatus) { - this.selectedEducationStatusId.set(educationStatus.id); - } - }); - - this.profileForm.patchValue({ - organizationName: educationItem.organizationName, - entryYear: educationItem.entryYear, - completionYear: educationItem.completionYear, - description: educationItem.description, - educationStatus: educationItem.educationStatus, - educationLevel: educationItem.educationLevel, - }); - this.editIndex.set(index); - } - - /** - * Удаление записи об образовании - * @param i - индекс записи для удаления - */ - removeEducation(i: number) { - this.educationItems.update(items => items.filter((_, index) => index !== i)); - - this.education.removeAt(i); - } - - /** - * Добавление записи об опыте работы - * Валидирует форму и добавляет новую запись в массив опыта работы - */ - addWork() { - if (!this.showWorkFields) { - this.showWorkFields = true; - return; - } - - ["organization", "jobPosition"].forEach(name => this.profileForm.get(name)?.clearValidators()); - ["organization", "jobPosition"].forEach(name => - this.profileForm.get(name)?.setValidators([Validators.required]) - ); - ["organization", "jobPosition"].forEach(name => - this.profileForm.get(name)?.updateValueAndValidity() - ); - ["organization", "jobPosition"].forEach(name => this.profileForm.get(name)?.markAsTouched()); - - const entryYear = - typeof this.profileForm.get("entryYearWork")?.value === "string" - ? this.profileForm.get("entryYearWork")?.value.slice(0, 5) - : this.profileForm.get("entryYearWork")?.value; - const completionYear = - typeof this.profileForm.get("completionYearWork")?.value === "string" - ? this.profileForm.get("completionYearWork")?.value.slice(0, 5) - : this.profileForm.get("completionYearWork")?.value; - - if (entryYear !== null && completionYear !== null && entryYear > completionYear) { - this.isModalErrorSkillsChoose.set(true); - this.isModalErrorSkillChooseText.set("Год начала работы должен быть меньше года окончания"); - return; - } - - const workItem = this.fb.group({ - organizationName: this.profileForm.get("organization")?.value, - entryYear, - completionYear, - description: this.profileForm.get("descriptionWork")?.value, - jobPosition: this.profileForm.get("jobPosition")?.value, - }); - - const isOrganizationValid = this.profileForm.get("organization")?.valid; - const isPositionValid = this.profileForm.get("jobPosition")?.valid; - - if (isOrganizationValid && isPositionValid) { - if (this.editIndex() !== null) { - this.workItems.update(items => { - const updatedItems = [...items]; - updatedItems[this.editIndex()!] = workItem.value; - - this.workExperience.at(this.editIndex()!).patchValue(workItem.value); - return updatedItems; - }); - this.editIndex.set(null); - } else { - this.workItems.update(items => [...items, workItem.value]); - this.workExperience.push(workItem); - } - [ - "organization", - "entryYearWork", - "completionYearWork", - "descriptionWork", - "jobPosition", - ].forEach(name => { - this.profileForm.get(name)?.reset(); - this.profileForm.get(name)?.setValue(""); - this.profileForm.get(name)?.clearValidators(); - this.profileForm.get(name)?.markAsPristine(); - this.profileForm.get(name)?.updateValueAndValidity(); - }); - this.showWorkFields = false; - } - this.editWorkClick = false; - } - - editWork(index: number) { - this.editWorkClick = true; - this.showWorkFields = true; - const workItem = this.workExperience.value[index]; - - if (workItem) { - this.yearListEducation.forEach(entryYearWork => { - if ( - transformYearStringToNumber(entryYearWork.value as string) === workItem.entryYearWork || - transformYearStringToNumber(entryYearWork.value as string) === workItem.entryYear - ) { - this.selectedEntryYearWorkId.set(entryYearWork.id); - } - }); - - this.yearListEducation.forEach(complitionYearWork => { - if ( - transformYearStringToNumber(complitionYearWork.value as string) === - workItem.completionYearWork || - transformYearStringToNumber(complitionYearWork.value as string) === - workItem.completionYear - ) { - this.selectedComplitionYearWorkId.set(complitionYearWork.id); - } - }); - - this.profileForm.patchValue({ - organization: workItem.organization || workItem.organizationName, - entryYearWork: workItem.entryYearWork || workItem.entryYear, - completionYearWork: workItem.completionYearWork || workItem.completionYear, - descriptionWork: workItem.descriptionWork || workItem.description, - jobPosition: workItem.jobPosition, - }); - this.editIndex.set(index); - } - } - - removeWork(i: number) { - this.workItems.update(items => items.filter((_, index) => index !== i)); - - this.workExperience.removeAt(i); - } - - addLanguage() { - if (!this.showLanguageFields) { - this.showLanguageFields = true; - return; - } - - const languageValue = this.profileForm.get("language")?.value; - const languageLevelValue = this.profileForm.get("languageLevel")?.value; - - ["language", "languageLevel"].forEach(name => { - this.profileForm.get(name)?.clearValidators(); - }); - - if ((languageValue && !languageLevelValue) || (!languageValue && languageLevelValue)) { - ["language", "languageLevel"].forEach(name => { - this.profileForm.get(name)?.setValidators([Validators.required]); - }); - } - - ["language", "languageLevel"].forEach(name => { - this.profileForm.get(name)?.updateValueAndValidity(); - this.profileForm.get(name)?.markAsTouched(); - }); - - const isLanguageValid = this.profileForm.get("language")?.valid; - const isLanguageLevelValid = this.profileForm.get("languageLevel")?.valid; - - if (!isLanguageValid || !isLanguageLevelValid) { - return; - } - - const languageItem = this.fb.group({ - language: languageValue, - languageLevel: languageLevelValue, - }); - - if (languageValue && languageLevelValue) { - if (this.editIndex() !== null) { - this.languageItems.update(items => { - const updatedItems = [...items]; - updatedItems[this.editIndex()!] = languageItem.value; - this.userLanguages.at(this.editIndex()!).patchValue(languageItem.value); - return updatedItems; - }); - this.editIndex.set(null); - } else { - this.languageItems.update(items => [...items, languageItem.value]); - this.userLanguages.push(languageItem); - } - - ["language", "languageLevel"].forEach(name => { - this.profileForm.get(name)?.reset(); - this.profileForm.get(name)?.setValue(null); - this.profileForm.get(name)?.clearValidators(); - this.profileForm.get(name)?.markAsPristine(); - this.profileForm.get(name)?.updateValueAndValidity(); - }); - this.showLanguageFields = false; - } - this.editLanguageClick = false; - } - - editLanguage(index: number) { - this.editLanguageClick = true; - this.showLanguageFields = true; - const languageItem = this.userLanguages.value[index]; - - this.languageList.forEach(language => { - if (language.value === languageItem.language) { - this.selectedLanguageId.set(language.id); - } - }); - - this.languageLevelList.forEach(languageLevel => { - if (languageLevel.value === languageItem.languageLevel) { - this.selectedLanguageLevelId.set(languageLevel.id); - } - }); - - this.profileForm.patchValue({ - language: languageItem.language, - languageLevel: languageItem.languageLevel, - }); - - this.editIndex.set(index); - } - - removeLanguage(i: number) { - this.languageItems.update(items => items.filter((_, index) => index !== i)); - - this.userLanguages.removeAt(i); - } - - get links(): FormArray { - return this.profileForm.get("links") as FormArray; - } - - newLink = ""; - - addLink(title?: string): void { - const fromState = title ?? this.newLink; - - const control = this.fb.control(fromState, [Validators.required]); - this.links.push(control); - - this.newLink = ""; - } - - removeLink(i: number): void { - this.links.removeAt(i); - } - - private userTypeMap: { [type: number]: string } = { - 1: "member", - 2: "mentor", - 3: "expert", - 4: "investor", - }; - - /** - * Сохранение профиля пользователя - * Валидирует всю форму и отправляет данные на сервер - */ - saveProfile(): void { - this.profileForm.markAllAsTouched(); - this.profileForm.updateValueAndValidity(); - - const tempFields = [ - "organizationName", - "entryYear", - "completionYear", - "description", - "educationLevel", - "educationStatus", - "organization", - "entryYearWork", - "completionYearWork", - "descriptionWork", - "jobPosition", - "language", - "languageLevel", - "title", - "status", - "year", - "files", - "phoneNumber", - ]; - - tempFields.forEach(name => { - const control = this.profileForm.get(name); - if (control) { - control.clearValidators(); - control.updateValueAndValidity(); - } - }); - - const mainFieldsValid = ["firstName", "lastName", "birthday", "speciality", "city"].every( - name => this.profileForm.get(name)?.valid - ); - - if (!mainFieldsValid || this.profileFormSubmitting) { - this.isModalErrorSkillsChoose.set(true); - return; - } - - this.profileFormSubmitting = true; - - const achievements = this.achievements.value.map((achievement: Achievement) => ({ - ...(achievement.id && { id: achievement.id }), - title: achievement.title, - status: achievement.status, - year: achievement.year, - fileLinks: - achievement.files && Array.isArray(achievement.files) - ? achievement.files - .map((file: any) => (typeof file === "string" ? file : file.link)) - .filter(Boolean) - : achievement.files - ? [achievement.files] - : [], - })); - - const newProfile = { - ...this.profileForm.value, - achievements, - [this.userTypeMap[this.profileForm.value.userType]]: this.typeSpecific.value, - typeSpecific: undefined, - birthday: this.profileForm.value.birthday - ? dayjs(this.profileForm.value.birthday, "DD.MM.YYYY").format("YYYY-MM-DD") - : undefined, - skillsIds: this.profileForm.value.skills.map((s: Skill) => s.id), - phoneNumber: - typeof this.profileForm.value.phoneNumber === "string" - ? this.profileForm.value.phoneNumber.replace(/^([87])/, "+7") - : this.profileForm.value.phoneNumber, - }; - - console.log(newProfile); - - this.authService - .saveProfile(newProfile) - .pipe(concatMap(() => this.authService.getProfile())) - .subscribe({ - next: () => { - this.profileFormSubmitting = false; - this.router - .navigateByUrl(`/office/profile/${this.profileId}`) - .then(() => console.debug("Router Changed form ProfileEditComponent")); - }, - error: error => { - this.profileFormSubmitting = false; - this.isModalErrorSkillsChoose.set(true); - if (error.error.phone_number) { - this.isModalErrorSkillChooseText.set(error.error.phone_number[0]); - } else if (error.error.language) { - this.isModalErrorSkillChooseText.set(error.error.language); - } else if (error.error.achievements) { - this.isModalErrorSkillChooseText.set(error.error.achievements[0]); - } else if (error.error.work_experience?.[2]) { - const errorText = error.error.work_experience[2].entry_year - ? error.error.work_experience[2].entry_year - : error.error.work_experience[2].completion_year; - this.isModalErrorSkillChooseText.set(errorText); - } else if (error.error.first_name?.[0]) { - this.isModalErrorSkillChooseText.set(error.error.first_name?.[0]); - } else if (error.error.last_name?.[0]) { - this.isModalErrorSkillChooseText.set(error.error.last_name?.[0]); - } else { - this.isModalErrorSkillChooseText.set(error.error[0]); - } - }, - }); - } - - /** - * Изменение типа пользователя - * @param typeId - новый тип пользователя - * @returns Observable - результат операции изменения типа - */ - changeUserType(typeId: number): Observable { - return this.authService - .saveProfile({ - email: this.profileForm.value.email, - firstName: this.profileForm.value.firstName, - lastName: this.profileForm.value.lastName, - userType: typeId, - }) - .pipe(map(() => location.reload())); - } - - /** - * Выбор специальности из автокомплита - * @param speciality - выбранная специальность - */ - onSelectSpec(speciality: Specialization): void { - this.profileForm.patchValue({ speciality: speciality.name }); - } - - /** - * Поиск специальностей для автокомплита - * @param query - поисковый запрос - */ - onSearchSpec(query: string): void { - this.specsService.getSpecializationsInline(query, 1000, 0).subscribe(({ results }) => { - this.inlineSpecs.set(results); - }); - } - - /** - * Переключение навыка (добавление/удаление) - * @param toggledSkill - навык для переключения - */ - onToggleSkill(toggledSkill: Skill): void { - const { skills }: { skills: Skill[] } = this.profileForm.value; - - const isPresent = skills.some(skill => skill.id === toggledSkill.id); - - if (isPresent) { - this.onRemoveSkill(toggledSkill); - } else { - this.onAddSkill(toggledSkill); - } - } - - /** - * Добавление нового навыка - * @param newSkill - новый навык для добавления - */ - onAddSkill(newSkill: Skill): void { - const { skills }: { skills: Skill[] } = this.profileForm.value; - - const isPresent = skills.some(skill => skill.id === newSkill.id); - - if (isPresent) return; - - this.profileForm.patchValue({ skills: [newSkill, ...skills] }); - } - - /** - * Удаление навыка - * @param oddSkill - навык для удаления - */ - onRemoveSkill(oddSkill: Skill): void { - const { skills }: { skills: Skill[] } = this.profileForm.value; - - this.profileForm.patchValue({ skills: skills.filter(skill => skill.id !== oddSkill.id) }); - } - - onSearchSkill(query: string): void { - this.skillsService.getSkillsInline(query, 1000, 0).subscribe(({ results }) => { - this.inlineSkills.set(results); - }); - } - - toggleSkillsGroupsModal(): void { - this.skillsGroupsModalOpen.update(open => !open); - } - - toggleSpecsGroupsModal(): void { - this.specsGroupsModalOpen.update(open => !open); - } - - isStringFiles(files: any[]): boolean { - return typeof files === "string"; - } -} diff --git a/projects/social_platform/src/app/office/profile/profile-news/profile-news.component.html b/projects/social_platform/src/app/office/profile/profile-news/profile-news.component.html deleted file mode 100644 index 66149eba1..000000000 --- a/projects/social_platform/src/app/office/profile/profile-news/profile-news.component.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - @if (newsItem()) { - - } - 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 deleted file mode 100644 index d6db6a92b..000000000 --- a/projects/social_platform/src/app/office/profile/profile-news/profile-news.component.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { Component, inject, OnDestroy, OnInit, signal } from "@angular/core"; -import { ModalComponent } from "@ui/components/modal/modal.component"; -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/features/news-card/news-card.component"; - -/** - * Компонент для отображения отдельной новости профиля в модальном окне - * - * Этот компонент предназначен для детального просмотра конкретной новости пользователя - * в модальном окне поверх основной страницы профиля. Обеспечивает удобную навигацию - * между новостями без полной перезагрузки страницы. - * - * Основные возможности: - * - Отображение новости в полноэкранном модальном окне - * - Получение данных новости через резолвер маршрута - * - Автоматическое закрытие модального окна при навигации - * - Возврат к основной странице профиля при закрытии - * - Адаптивное отображение для мобильных и десктопных устройств - * - * Жизненный цикл: - * - При инициализации загружает данные новости из резолвера - * - Отображает новость в модальном окне - * - При закрытии возвращает пользователя к профилю - * - При уничтожении очищает все подписки - * - * Навигация: - * - Получает userId из родительского маршрута профиля - * - Использует newsId из параметров текущего маршрута - * - При закрытии перенаправляет на /office/profile/{userId} - * - * @implements OnInit - для инициализации и загрузки данных новости - * @implements OnDestroy - для очистки подписок и предотвращения утечек памяти - */ -@Component({ - selector: "app-profile-news", - standalone: true, - imports: [CommonModule, ModalComponent, NewsCardComponent], - templateUrl: "./profile-news.component.html", - styleUrl: "./profile-news.component.scss", -}) -export class ProfileNewsComponent implements OnInit, OnDestroy { - private readonly profileService: ProfileNewsService = inject(ProfileNewsService); - private readonly route: ActivatedRoute = inject(ActivatedRoute); - private readonly router: Router = inject(Router); - - /** ID пользователя, извлеченный из родительского маршрута профиля */ - userId = this.route.parent?.parent?.snapshot.params["id"]; - - /** Сигнал с данными отображаемой новости */ - newsItem = signal(null); - - /** Массив активных подписок для очистки при уничтожении компонента */ - subscriptions$: Subscription[] = []; - - /** - * Инициализация компонента - * Загружает данные новости из резолвера маршрута и устанавливает их в сигнал - */ - ngOnInit(): void { - const profileNewsSub$ = this.route.data.pipe(map(r => r["data"])).subscribe({ - next: (r: FeedNews) => { - this.newsItem.set(r); - }, - error: err => { - console.log(err); - }, - }); - - this.subscriptions$.push(profileNewsSub$); - } - - /** - * Очистка ресурсов при уничтожении компонента - * Отписывается от всех активных подписок для предотвращения утечек памяти - */ - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $.unsubscribe()); - } - - /** - * Обработчик изменения состояния модального окна - * При закрытии модального окна (value = false) перенаправляет пользователя - * обратно к основной странице профиля - * - * @param value - новое состояние модального окна (true - открыто, false - закрыто) - */ - onOpenChange(value: boolean): void { - if (!value) { - this.router - .navigateByUrl(`/office/profile/${this.userId}`) - .then(() => console.debug("Route changed from ProfileNewsComponent")); - } - } -} diff --git a/projects/social_platform/src/app/office/program/detail/detail.resolver.ts b/projects/social_platform/src/app/office/program/detail/detail.resolver.ts deleted file mode 100644 index f0de67f2c..000000000 --- a/projects/social_platform/src/app/office/program/detail/detail.resolver.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { ActivatedRouteSnapshot, ResolveFn } from "@angular/router"; -import { ProgramService } from "@office/program/services/program.service"; -import { Program } from "@office/program/models/program.model"; -import { tap } from "rxjs"; -import { ProgramDataService } from "../services/program-data.service"; - -/** - * Резолвер для получения детальной информации о программе - * - * Предзагружает полную информацию о программе перед отображением - * детальной страницы. Это обеспечивает мгновенное отображение - * данных программы во всех дочерних компонентах. - * - * Принимает: - * @param {ActivatedRouteSnapshot} route - Снимок маршрута с параметрами - * - * Использует: - * @param {ProgramService} programService - Инжектируемый сервис программ - * - * Логика: - * - Извлекает programId из параметров маршрута - * - Загружает детальную информацию через programService.getOne() - * - * Возвращает: - * @returns {Observable} Полная информация о программе - * - * Загружаемые данные включают: - * - Основную информацию (название, описание, даты) - * - Изображения и медиа файлы - * - Права текущего пользователя (участник, менеджер) - * - Статистику (просмотры, лайки) - * - Дополнительные материалы и ссылки - * - * Используется в: - * Родительском маршруте детальной страницы программы - */ -export const ProgramDetailResolver: ResolveFn = (route: ActivatedRouteSnapshot) => { - const programService = inject(ProgramService); - const programDataService = inject(ProgramDataService); - - return programService - .getOne(route.params["programId"]) - .pipe(tap(program => programDataService.setProgram(program))); -}; diff --git a/projects/social_platform/src/app/office/program/detail/detail.routes.ts b/projects/social_platform/src/app/office/program/detail/detail.routes.ts deleted file mode 100644 index 1a485d175..000000000 --- a/projects/social_platform/src/app/office/program/detail/detail.routes.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** @format */ - -import { Routes } from "@angular/router"; -import { ProgramDetailMainComponent } from "@office/program/detail/main/main.component"; -import { ProgramRegisterComponent } from "@office/program/detail/register/register.component"; -import { ProgramRegisterResolver } from "@office/program/detail/register/register.resolver"; -import { ProgramProjectsResolver } from "@office/program/detail/list/projects.resolver"; -import { ProgramMembersResolver } from "@office/program/detail/list/members.resolver"; -import { ProgramListComponent } from "./list/list.component"; -import { ProgramDetailResolver } from "./detail.resolver"; -import { DeatilComponent } from "@office/features/detail/detail.component"; - -/** - * Маршруты для детальной страницы программы - * - * Определяет структуру навигации внутри детальной страницы программы: - * - Основная информация (по умолчанию) - * - Список проектов программы - * - Список участников программы - * - Страница регистрации в программе - * - * Все маршруты используют резолверы для предзагрузки данных. - * - * @returns {Routes} Конфигурация маршрутов для детальной страницы программы - */ -export const PROGRAM_DETAIL_ROUTES: Routes = [ - { - path: "", - component: DeatilComponent, - resolve: { - data: ProgramDetailResolver, - }, - data: { listType: "program" }, - children: [ - { - path: "", - component: ProgramDetailMainComponent, - }, - { - path: "projects", - component: ProgramListComponent, - resolve: { - data: ProgramProjectsResolver, - }, - data: { listType: "projects" }, - }, - { - path: "members", - component: ProgramListComponent, - resolve: { - data: ProgramMembersResolver, - }, - data: { listType: "members" }, - }, - { - path: "projects-rating", - component: ProgramListComponent, - data: { listType: "rating" }, - }, - ], - }, - { - path: "register", - component: ProgramRegisterComponent, - resolve: { - data: ProgramRegisterResolver, - }, - }, -]; diff --git a/projects/social_platform/src/app/office/program/detail/list/list-all.resolver.ts b/projects/social_platform/src/app/office/program/detail/list/list-all.resolver.ts deleted file mode 100644 index 7b6db7737..000000000 --- a/projects/social_platform/src/app/office/program/detail/list/list-all.resolver.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** @format */ - -import { HttpParams } from "@angular/common/http"; -import { inject } from "@angular/core"; -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"; - -/** - * Резолвер для предзагрузки проектов для оценки - * - * Загружает первую страницу проектов программы, которые доступны - * для оценки экспертами. Предзагрузка обеспечивает мгновенное - * отображение данных в компоненте оценки. - * - * Принимает: - * @param {ActivatedRouteSnapshot} route - Снимок маршрута с параметрами - * - * Использует: - * @param {ProjectRatingService} projectRatingService - Инжектируемый сервис оценки - * - * Логика: - * - Извлекает programId из родительского маршрута - * - Загружает первые 8 проектов для оценки (skip: 0, take: 8) - * - Не применяет дополнительные фильтры - * - * Возвращает: - * @returns {Observable>} Пагинированный список проектов для оценки - * - * Данные включают: - * - Массив проектов с критериями оценки (results) - * - Общее количество проектов (count) - * - Информацию о пагинации - * - * Каждый проект содержит: - * - Основную информацию проекта - * - Массив критериев для оценки - * - Статус оценки текущим экспертом - * - Презентационные материалы - * - * Используется в: - * Маршруте "all" для списка всех проектов программы - */ -export const ListAllResolver: ResolveFn> = ( - route: ActivatedRouteSnapshot -) => { - const projectRatingService = inject(ProjectRatingService); - const router = inject(Router); - - return projectRatingService - .getAll( - route.parent?.params["programId"], - new HttpParams({ fromObject: { offset: 0, limit: 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 deleted file mode 100644 index e7bb3199f..000000000 --- a/projects/social_platform/src/app/office/program/detail/list/list.component.html +++ /dev/null @@ -1,255 +0,0 @@ - - -
-
- - - @if (appWidth < 920 && listType !== 'members') { -
-
-
- - - - сбросить -
- - @if (listType === "rating") { -
- - выгрузка оценок - - -
- - } -
- - @if (listType === 'projects') { -
- - выгрузка проектов - - - - - сданные решения - - -
- } @else { @if (appWidth > 920) { -
- - выгрузка оценок - - -
- - } } -
- } - -
    - @for (listItem of searchedList; track listItem.id) { -
  • - @if (listType === 'projects' || listType === 'members') { - - - - } @else { - - } -
  • - } -
-
- - @if (appWidth >= 920 && listType !== 'members') { -
-
- @if (listType === 'projects' || listType === 'rating') { -
-
-
-
- - - @if (listType === 'projects') { -
- - выгрузка проектов - - - - - сданные решения - - -
- } @else { @if (appWidth > 920) { -
- - выгрузка оценок - - - - - итоговые расчеты - - -
- - } } -
-
- } -
-
- } @if (listType !== 'members' && listType !== 'projects' && appWidth >= 920) { -
-
- - - @if (isHintExpertsVisible()) { -
-

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

-

подробнее

-
- } -
-
- - -
-
-

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

-
- -
-

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

- -

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

- -

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

- -

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

- -

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

-
- - спасибо, понятно -
-
- } - - - - - - применить фильтр - - -
diff --git a/projects/social_platform/src/app/office/program/detail/list/list.component.scss b/projects/social_platform/src/app/office/program/detail/list/list.component.scss deleted file mode 100644 index b969f27ff..000000000 --- a/projects/social_platform/src/app/office/program/detail/list/list.component.scss +++ /dev/null @@ -1,313 +0,0 @@ -@use "styles/responsive"; -@use "styles/typography"; - -.page { - display: grid; - grid-template-columns: 1fr; - gap: 10px; - max-width: 1280px; - padding-bottom: 100px; - margin: 0 auto; - - @include responsive.apply-desktop { - grid-template-columns: 8fr 2fr; - } - - &__outlet { - display: flex; - flex-direction: column; - flex-grow: 1; - gap: 20px; - width: 100%; - padding: 0 10px; - margin-top: 12px; - - @include responsive.apply-desktop { - padding: 0; - } - } - - &__filter { - display: none; - - &--open { - display: block; - } - - @include responsive.apply-desktop { - display: block; - margin-left: 16px; - } - } - - &__mobile-controls { - display: flex; - flex-direction: column; - gap: 12px; - - @include responsive.apply-desktop { - display: none; - } - } - - &__mobile-filter-row { - display: flex; - gap: 20px; - align-items: center; - justify-content: space-between; - } - - &__clear { - color: var(--accent); - cursor: pointer; - } - - &__mobile-export { - display: flex; - gap: 20px; - - ::ng-deep app-button { - flex: 1; - - button { - width: 100%; - } - - span { - margin-right: 5px !important; - } - } - } - - &__list { - display: grid; - grid-template-columns: repeat(2, 1fr); - row-gap: 30px; - column-gap: 12px; - align-items: flex-start; - margin-top: 30px; - - @include responsive.apply-desktop { - grid-template-columns: repeat(4, 2fr); - row-gap: 50px; - column-gap: 20px; - margin-top: 50px; - } - - &--rating { - grid-template-columns: 1fr; - row-gap: 20px; - margin-top: 0; - - @include responsive.apply-desktop { - grid-template-columns: 1fr; - } - } - } - - &__create { - display: flex; - flex-direction: column; - gap: 20px; - align-items: center; - margin-top: 20px; - } - - &__left { - position: relative; - width: 100%; - } - - &__export { - display: flex; - flex-direction: column; - gap: 10px; - align-items: center; - margin-top: 10px; - - ::ng-deep { - app-button { - span { - margin-right: 5px !important; - } - } - } - } - - &__tooltip { - position: fixed; - right: - calc( - (100vw - 1080px) / 2 - ); - bottom: 24px; - z-index: 30; - } -} - -.filter { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - z-index: 10; - - @include responsive.apply-desktop { - position: static; - } - - &__overlay { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - background-color: black; - opacity: 0.3; - - @include responsive.apply-desktop { - display: none; - } - } - - &__bar { - position: fixed; - display: flex; - width: 100%; - height: 25px; - touch-action: none; - - @include responsive.apply-desktop { - display: none; - } - - &::after { - display: block; - width: 85px; - height: 5px; - margin: auto; - content: ""; - background-color: var(--gray); - border-radius: var(--rounded-lg); - transition: transform 0.2s; - } - } - - &__body { - position: absolute; - right: 0; - bottom: 0; - left: 0; - z-index: 10; - min-height: 72vh; - overflow-y: auto; - background-color: var(--white); - border-radius: var(--rounded-lg); - transform: translateY(0%); - - @include responsive.apply-desktop { - position: static; - max-height: unset; - transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); - } - } -} - -.filter-toggle { - position: relative; - display: flex; - align-items: center; - justify-content: space-between; - padding: 15px 10px; - cursor: pointer; - background-color: var(--white); - border: 1px solid var(--medium-medium-grey-for-outline); - border-radius: var(--rounded-xl); - - @include responsive.apply-desktop { - display: none; - } -} - -.tooltip { - position: relative; - display: flex; - align-items: center; - - &__wrapper { - position: absolute; - right: 100%; - bottom: 22px; - left: auto; - width: 310px; - padding: 18px 10px 10px 16px; - background-color: var(--white); - border: 0.5px solid var(--grey-for-text); - border-radius: var(--rounded-lg) var(--rounded-lg) 0 var(--rounded-lg); - - :last-child { - color: var(--black); - cursor: pointer; - } - } - - &__text { - color: var(--grey-for-text); - - a { - color: var(--accent); - } - } -} - -.cancel { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - width: 80%; - max-height: calc(100vh - 40px); - padding: 0 24px; - overflow-y: auto; - - @include responsive.apply-desktop { - width: 605px; - max-width: 100%; - } - - &__cross { - position: absolute; - top: 0; - right: 0; - width: 32px; - height: 32px; - cursor: pointer; - - @include responsive.apply-desktop { - top: 8px; - right: 8px; - } - } - - &__top { - display: flex; - flex-direction: column; - margin-bottom: 10px; - } - - &__title { - text-align: center; - } - - &__text { - text-align: center; - - &-block { - display: flex; - flex-direction: column; - gap: 12px; - margin: 30px 0; - } - } -} diff --git a/projects/social_platform/src/app/office/program/detail/list/list.component.spec.ts b/projects/social_platform/src/app/office/program/detail/list/list.component.spec.ts deleted file mode 100644 index 53995cf41..000000000 --- a/projects/social_platform/src/app/office/program/detail/list/list.component.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { RouterTestingModule } from "@angular/router/testing"; -import { of } from "rxjs"; -import { AuthService } from "@auth/services"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; -import { ProgramListComponent } from "./list.component"; - -describe("ProgramListComponent", () => { - let component: ProgramListComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - const authSpy = { - profile: of({}), - }; - - await TestBed.configureTestingModule({ - imports: [RouterTestingModule, HttpClientTestingModule, ProgramListComponent], - providers: [{ provide: AuthService, useValue: authSpy }], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(ProgramListComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/program/detail/list/list.component.ts b/projects/social_platform/src/app/office/program/detail/list/list.component.ts deleted file mode 100644 index fc382e5de..000000000 --- a/projects/social_platform/src/app/office/program/detail/list/list.component.ts +++ /dev/null @@ -1,645 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { - AfterViewInit, - ChangeDetectorRef, - Component, - ElementRef, - HostListener, - inject, - OnDestroy, - OnInit, - Renderer2, - ViewChild, - signal, -} from "@angular/core"; -import { - catchError, - concatMap, - debounceTime, - distinctUntilChanged, - fromEvent, - map, - noop, - of, - Subscription, - switchMap, - tap, -} from "rxjs"; -import { ProjectsFilterComponent } from "@office/program/detail/list/projects-filter/projects-filter.component"; -import Fuse from "fuse.js"; -import { ActivatedRoute, Router, RouterModule } from "@angular/router"; -import { FormBuilder, FormGroup, ReactiveFormsModule } from "@angular/forms"; -import { SearchComponent } from "@ui/components/search/search.component"; -import { User } from "@auth/models/user.model"; -import { Project } from "@office/models/project.model"; -import { RatingCardComponent } from "@office/program/shared/rating-card/rating-card.component"; -import { ProgramService } from "@office/program/services/program.service"; -import { ProjectRatingService } from "@office/program/services/project-rating.service"; -import { AuthService } from "@auth/services"; -import { SubscriptionService } from "@office/services/subscription.service"; -import { ApiPagination } from "@models/api-pagination.model"; -import { HttpParams } from "@angular/common/http"; -import { PartnerProgramFields } from "@office/models/partner-program-fields.model"; -import { CheckboxComponent, ButtonComponent, IconComponent } from "@ui/components"; -import { InfoCardComponent } from "@office/features/info-card/info-card.component"; -import { tagsFilter } from "projects/core/src/consts/filters/tags-filter.const"; -import { ExportFileService } from "@office/services/export-file.service"; -import { saveFile } from "@utils/helpers/export-file"; -import { ProgramDataService } from "@office/program/services/program-data.service"; -import { TooltipComponent } from "@ui/components/tooltip/tooltip.component"; -import { ModalComponent } from "@ui/components/modal/modal.component"; - -@Component({ - selector: "app-list", - templateUrl: "./list.component.html", - styleUrl: "./list.component.scss", - imports: [ - CommonModule, - ReactiveFormsModule, - RouterModule, - ProjectsFilterComponent, - SearchComponent, - RatingCardComponent, - InfoCardComponent, - ButtonComponent, - IconComponent, - TooltipComponent, - ModalComponent, - ], - standalone: true, -}) -export class ProgramListComponent implements OnInit, OnDestroy, AfterViewInit { - constructor() { - const searchValue = - this.route.snapshot.queryParams["search"] || - this.route.snapshot.queryParams["name__contains"]; - const decodedSearchValue = searchValue ? decodeURIComponent(searchValue) : ""; - - this.searchForm = this.fb.group({ - search: [decodedSearchValue], - }); - } - - @ViewChild("listRoot") listRoot?: ElementRef; - @ViewChild("filterBody") filterBody!: ElementRef; - - private readonly renderer = inject(Renderer2); - private readonly fb = inject(FormBuilder); - private readonly route = inject(ActivatedRoute); - private readonly router = inject(Router); - private readonly cdref = inject(ChangeDetectorRef); - private readonly programService = inject(ProgramService); - private readonly programDataService = inject(ProgramDataService); - private readonly projectRatingService = inject(ProjectRatingService); - private readonly authService = inject(AuthService); - private readonly subscriptionService = inject(SubscriptionService); - private readonly exportFileService = inject(ExportFileService); - - protected availableFilters: PartnerProgramFields[] = []; - - searchForm: FormGroup; - - listTotalCount?: number; - listPage = 0; - listTake = 20; - perPage = 21; - - list: any[] = []; - searchedList: any[] = []; - profile?: User; - profileProjSubsIds?: number[]; - - isRatedByExpert = signal(undefined); - searchValue = signal(""); - - listType: "projects" | "members" | "rating" = "projects"; - - readonly ratingOptionsList = tagsFilter; - isFilterOpen = false; - readonly isFilterModalOpen = signal(false); - - appWidth = window.innerWidth; - - @HostListener("window:resize") - onResize() { - this.appWidth = window.innerWidth; - } - - readonly isHintExpertsVisible = signal(false); - readonly isHintExpertsModal = signal(false); - - protected readonly loadingExportProjects = signal(false); - protected readonly loadingExportSubmittedProjects = signal(false); - protected readonly loadingExportRates = signal(false); - protected readonly loadingExportCalculations = signal(false); - - subscriptions$: Subscription[] = []; - - routerLink(linkId: number): string { - switch (this.listType) { - case "projects": - return `/office/projects/${linkId}`; - - case "members": - return `/office/profile/${linkId}`; - - default: - return ""; - } - } - - ngOnInit(): void { - this.route.data.subscribe(data => { - this.listType = data["listType"]; - }); - - const routeData$ = this.route.data.pipe(map(r => r["data"])).subscribe(data => { - this.listTotalCount = data.count; - this.list = data.results; - this.searchedList = data.results; - }); - - this.subscriptions$.push(routeData$); - - this.setupSearch(); - - if (this.listType === "projects") { - this.setupProfile(); - } - - this.setupFilters(); - } - - ngAfterViewInit(): void { - const target = document.querySelector(".office__body"); - if (target) { - const scrollEvent$ = fromEvent(target, "scroll") - .pipe( - debounceTime(this.listType === "rating" ? 200 : 500), - switchMap(() => this.onScroll()), - catchError(err => { - console.error("Scroll error:", err); - return of({}); - }) - ) - .subscribe(noop); - - this.subscriptions$.push(scrollEvent$); - } else { - console.error(".office__body element not found"); - } - } - - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $.unsubscribe()); - } - - private setupSearch(): void { - const searchFormSearch$ = this.searchForm - .get("search") - ?.valueChanges.pipe(debounceTime(300)) - .subscribe(search => { - this.router - .navigate([], { - queryParams: { [this.searchParamName]: search || null }, - relativeTo: this.route, - queryParamsHandling: "merge", - }) - .then(() => console.debug("QueryParams changed from ProgramListComponent")); - }); - - searchFormSearch$ && this.subscriptions$.push(searchFormSearch$); - - const querySearch$ = this.route.queryParams.pipe(map(q => q["search"])).subscribe(search => { - const searchKeys = - this.listType === "projects" || this.listType === "rating" - ? ["name"] - : ["firstName", "lastName"]; - - const fuse = new Fuse(this.list, { - keys: searchKeys, - }); - - this.searchedList = search ? fuse.search(search).map(el => el.item) : this.list; - this.cdref.detectChanges(); - }); - - querySearch$ && this.subscriptions$.push(querySearch$); - } - - private setupProfile(): void { - const profile$ = this.authService.profile - .pipe( - switchMap(p => { - this.profile = p; - return this.subscriptionService.getSubscriptions(p.id).pipe( - map(resp => { - this.profileProjSubsIds = resp.results.map(sub => sub.id); - }) - ); - }) - ) - .subscribe(); - - profile$ && this.subscriptions$.push(profile$); - } - - private setupFilters(): void { - if (this.listType === "members") return; - - const filtersObservable$ = this.route.queryParams - .pipe( - distinctUntilChanged((prev, curr) => JSON.stringify(prev) === JSON.stringify(curr)), - concatMap(q => { - const { filters, extraParams } = this.buildFilterQuery(q); - const programId = this.route.parent?.snapshot.params["programId"]; - - this.listPage = 0; - - const params = new HttpParams({ - fromObject: { - offset: "0", - limit: this.itemsPerPage.toString(), - ...extraParams, - }, - }); - - if (this.listType === "rating") { - if (Object.keys(filters).length > 0) { - return this.projectRatingService.postFilters(programId, filters, params); - } - return this.projectRatingService.getAll(programId, params); - } - - if (Object.keys(filters).length > 0) { - return this.programService.createProgramFilters(programId, filters, params); - } - return this.programService.getAllProjects(programId, params); - }), - catchError(err => { - console.error("Error in setupFilters:", err); - return of({ count: 0, results: [] }); - }) - ) - .subscribe(result => { - if (!result) return; - - this.list = result.results || []; - this.searchedList = result.results || []; - this.listTotalCount = result.count; - this.listPage = 0; - this.cdref.detectChanges(); - }); - - this.subscriptions$.push(filtersObservable$); - } - - // Универсальный метод скролла - private onScroll() { - if (this.listTotalCount && this.list.length >= this.listTotalCount) { - console.log("All items loaded"); - return of({}); - } - - const target = document.querySelector(".office__body"); - if (!target) { - console.log("Target not found"); - return of({}); - } - - let shouldFetch = false; - - if (this.listType === "rating") { - const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight; - shouldFetch = scrollBottom <= 200; - console.log("Rating scroll check:", { scrollBottom, shouldFetch }); - } else { - if (!this.listRoot) return of({}); - const diff = - target.scrollTop - - this.listRoot.nativeElement.getBoundingClientRect().height + - window.innerHeight; - const threshold = this.listType === "projects" ? -200 : 0; - shouldFetch = diff > threshold; - console.log("Projects/Members scroll check:", { diff, threshold, shouldFetch }); - } - - if (shouldFetch) { - console.log("Fetching next page:", this.listPage + 1); - this.listPage++; - return this.onFetch(); - } - - return of({}); - } - - // Универсальный метод загрузки данных - // Универсальный метод загрузки данных - private onFetch() { - const programId = this.route.parent?.snapshot.params["programId"]; - const offset = this.listPage * this.itemsPerPage; - - console.log("onFetch called:", { - listType: this.listType, - programId, - offset, - itemsPerPage: this.itemsPerPage, - currentPage: this.listPage, - currentListLength: this.list.length, - }); - - // Получаем текущие query параметры для фильтров - const currentQuery = this.route.snapshot.queryParams; - const { filters, extraParams } = this.buildFilterQuery(currentQuery); - - const params = new HttpParams({ - fromObject: { - offset: offset.toString(), - limit: this.itemsPerPage.toString(), - ...extraParams, - }, - }); - - console.log("Request params:", { filters, extraParams, paramsKeys: params.keys() }); - - switch (this.listType) { - case "rating": { - const ratingRequest$ = - Object.keys(filters).length > 0 - ? this.projectRatingService.postFilters(programId, filters, params) - : this.projectRatingService.getAll(programId, params); - - return ratingRequest$.pipe( - tap(({ count, results }) => { - console.log("Rating response:", { - count, - resultsLength: results.length, - currentListLength: this.list.length, - offset, - expectedNewLength: this.list.length + results.length, - }); - - this.listTotalCount = count; - - if (this.listPage === 0) { - this.list = results; - } else { - const newResults = results.filter( - newItem => !this.list.some(existingItem => existingItem.id === newItem.id) - ); - console.log("New unique items to add:", newResults.length); - this.list = [...this.list, ...newResults]; - } - - this.searchedList = this.list; - this.cdref.detectChanges(); - }), - catchError(err => { - console.error("Error fetching ratings:", err); - this.listPage--; - return of({ count: this.listTotalCount || 0, results: [] }); - }) - ); - } - - case "projects": { - const projectsRequest$ = - Object.keys(filters).length > 0 - ? this.programService.createProgramFilters(programId, filters, params) - : this.programService.getAllProjects(programId, params); - - return projectsRequest$.pipe( - tap((projects: ApiPagination) => { - console.log("Projects response:", { - count: projects.count, - resultsLength: projects.results.length, - currentListLength: this.list.length, - offset, - }); - - this.listTotalCount = projects.count; - - if (this.listPage === 0) { - this.list = projects.results; - } else { - const newResults = projects.results.filter( - newItem => !this.list.some(existingItem => existingItem.id === newItem.id) - ); - console.log("New unique projects to add:", newResults.length); - this.list = [...this.list, ...newResults]; - } - - this.searchedList = this.list; - this.cdref.detectChanges(); - }), - catchError(err => { - console.error("Error fetching projects:", err); - this.listPage--; - return of({ count: this.listTotalCount || 0, results: [] }); - }) - ); - } - - case "members": { - return this.programService.getAllMembers(programId, offset, this.itemsPerPage).pipe( - tap((members: ApiPagination) => { - console.log("Members response:", { - count: members.count, - resultsLength: members.results.length, - currentListLength: this.list.length, - offset, - }); - - this.listTotalCount = members.count; - - if (this.listPage === 0) { - this.list = members.results; - } else { - const newResults = members.results.filter( - newItem => !this.list.some(existingItem => existingItem.id === newItem.id) - ); - console.log("New unique members to add:", newResults.length); - this.list = [...this.list, ...newResults]; - } - - this.searchedList = this.list; - this.cdref.detectChanges(); - }), - catchError(err => { - console.error("Error fetching members:", err); - this.listPage--; - return of({ count: this.listTotalCount || 0, results: [] }); - }) - ); - } - - default: - return of({ count: 0, results: [] }); - } - } - - // Построение запроса для фильтров (кроме участников) - private buildFilterQuery(q: any): { - filters: Record; - extraParams: Record; - } { - if (this.listType === "members") return { filters: {}, extraParams: {} }; - - const filters: Record = {}; - const extraParams: Record = {}; - - console.log("buildFilterQuery input:", q); - - Object.keys(q).forEach(key => { - const value = q[key]; - if (value === undefined || value === "" || value === null) return; - - if (this.listType === "rating" && (key === "search" || key === "name__contains")) { - extraParams["name__contains"] = value; - return; - } - - if (this.listType === "rating" && key === "is_rated_by_expert") { - extraParams["is_rated_by_expert"] = value; - return; - } - - filters[key] = Array.isArray(value) ? value : [value]; - }); - - return { filters, extraParams }; - } - - onFiltersLoaded(filters: PartnerProgramFields[]): void { - this.availableFilters = filters; - } - - downloadProjects(): void { - const programId = this.route.parent?.snapshot.params["programId"]; - this.loadingExportProjects.set(true); - - this.exportFileService.exportAllProjects(programId).subscribe({ - next: blob => { - saveFile(blob, "all", this.programDataService.getProgramName()); - this.loadingExportProjects.set(false); - }, - error: err => { - console.error(err); - this.loadingExportProjects.set(false); - }, - }); - } - - downloadSubmittedProjects(): void { - const programId = this.route.parent?.snapshot.params["programId"]; - this.loadingExportSubmittedProjects.set(true); - - this.exportFileService.exportSubmittedProjects(programId).subscribe({ - next: blob => { - saveFile(blob, "submitted", this.programDataService.getProgramName()); - this.loadingExportSubmittedProjects.set(false); - }, - error: () => { - this.loadingExportSubmittedProjects.set(false); - }, - }); - } - - downloadRates(): void { - const programId = this.route.parent?.snapshot.params["programId"]; - this.loadingExportRates.set(true); - - this.exportFileService.exportProgramRates(programId).subscribe({ - next: blob => { - saveFile(blob, "rates", this.programDataService.getProgramName()); - this.loadingExportRates.set(false); - }, - error: () => { - this.loadingExportRates.set(false); - }, - }); - } - - downloadCalculations(): void {} - - // Swipe логика для мобильных устройств - private swipeStartY = 0; - private swipeThreshold = 50; - private isSwiping = false; - - onSwipeStart(event: TouchEvent): void { - this.swipeStartY = event.touches[0].clientY; - this.isSwiping = true; - } - - onSwipeMove(event: TouchEvent): void { - if (!this.isSwiping) return; - - const currentY = event.touches[0].clientY; - const deltaY = currentY - this.swipeStartY; - - const progress = Math.min(deltaY / this.swipeThreshold, 1); - this.renderer.setStyle( - this.filterBody.nativeElement, - "transform", - `translateY(${progress * 100}px)` - ); - } - - onSwipeEnd(event: TouchEvent): void { - if (!this.isSwiping) return; - - const endY = event.changedTouches[0].clientY; - const deltaY = endY - this.swipeStartY; - - if (deltaY > this.swipeThreshold) { - this.closeFilter(); - } - - this.isSwiping = false; - - this.renderer.setStyle(this.filterBody.nativeElement, "transform", "translateY(0)"); - } - - closeFilter(): void { - this.isFilterOpen = false; - } - - /** - * Сброс всех активных фильтров - * Очищает все query параметры и возвращает к состоянию по умолчанию - */ - onClearFilters(): void { - this.searchForm.reset(); - - this.router - .navigate([], { - queryParams: { - search: undefined, - }, - relativeTo: this.route, - queryParamsHandling: "merge", - }) - .then(() => console.log("Query change from ProjectsComponent")); - } - - openHintModal(event: Event): void { - event.preventDefault(); - this.isHintExpertsVisible.set(false); - this.isHintExpertsModal.set(true); - } - - private get itemsPerPage(): number { - return this.listType === "rating" - ? 10 - : this.listType === "projects" - ? this.perPage - : this.listTake; - } - - private get searchParamName(): string { - return this.listType === "rating" ? "name__contains" : "search"; - } -} diff --git a/projects/social_platform/src/app/office/program/detail/list/members.resolver.ts b/projects/social_platform/src/app/office/program/detail/list/members.resolver.ts deleted file mode 100644 index 4314a4d88..000000000 --- a/projects/social_platform/src/app/office/program/detail/list/members.resolver.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { ActivatedRouteSnapshot, ResolveFn } from "@angular/router"; -import { ProgramService } from "@office/program/services/program.service"; -import { ApiPagination } from "@models/api-pagination.model"; -import { User } from "@auth/models/user.model"; - -/** - * Резолвер для предзагрузки участников программы - * - * Загружает первую страницу участников программы перед отображением - * компонента. Обеспечивает мгновенное отображение списка участников. - * - * Принимает: - * @param {ActivatedRouteSnapshot} route - Снимок маршрута с параметрами - * - * Использует: - * @param {ProgramService} programService - Инжектируемый сервис программ - * - * Логика: - * - Извлекает programId из родительского маршрута (route.parent.params) - * - Загружает первые 20 участников (skip: 0, take: 20) - * - * Возвращает: - * @returns {Observable>} Пагинированный список участников - * - * Данные включают: - * - Массив пользователей (results) - * - Общее количество участников (count) - * - Информацию о пагинации - * - * Каждый участник содержит: - * - Профильную информацию - * - Аватар и контактные данные - * - Роль в программе - * - * Используется в: - * Маршруте members для предзагрузки списка участников - */ -export const ProgramMembersResolver: ResolveFn> = ( - route: ActivatedRouteSnapshot -) => { - const programService = inject(ProgramService); - - return programService.getAllMembers(route.parent?.params["programId"], 0, 20); -}; diff --git a/projects/social_platform/src/app/office/program/detail/list/projects-filter/projects-filter.component.html b/projects/social_platform/src/app/office/program/detail/list/projects-filter/projects-filter.component.html deleted file mode 100644 index c05895953..000000000 --- a/projects/social_platform/src/app/office/program/detail/list/projects-filter/projects-filter.component.html +++ /dev/null @@ -1,63 +0,0 @@ - -
-

фильтры

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

о программе

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

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

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

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

- {{ showProgramModalErrorMessage() }} -

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

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

-
- -

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

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

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

-
- -
-

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

- -

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

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

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

- -

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

-
- - спасибо, понятно -
-
-} diff --git a/projects/social_platform/src/app/office/program/detail/main/main.component.spec.ts b/projects/social_platform/src/app/office/program/detail/main/main.component.spec.ts deleted file mode 100644 index ae5843cc8..000000000 --- a/projects/social_platform/src/app/office/program/detail/main/main.component.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { ProgramDetailMainComponent } from "./main.component"; -import { RouterTestingModule } from "@angular/router/testing"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; - -describe("MainComponent", () => { - let component: ProgramDetailMainComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [RouterTestingModule, HttpClientTestingModule, ProgramDetailMainComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(ProgramDetailMainComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/program/detail/main/main.component.ts b/projects/social_platform/src/app/office/program/detail/main/main.component.ts deleted file mode 100644 index fabde4a54..000000000 --- a/projects/social_platform/src/app/office/program/detail/main/main.component.ts +++ /dev/null @@ -1,382 +0,0 @@ -/** @format */ -import { - ChangeDetectorRef, - Component, - ElementRef, - HostListener, - OnDestroy, - OnInit, - signal, - ViewChild, -} from "@angular/core"; -import { ProgramService } from "@office/program/services/program.service"; -import { ActivatedRoute, Router, RouterModule } from "@angular/router"; -import { - concatMap, - fromEvent, - map, - noop, - Observable, - of, - Subscription, - tap, - throttleTime, -} from "rxjs"; -import { Program } from "@office/program/models/program.model"; -import { ProgramNewsService } from "@office/program/services/program-news.service"; -import { FeedNews } from "@office/projects/models/project-news.model"; -import { expandElement } from "@utils/expand-element"; -import { ParseBreaksPipe, ParseLinksPipe } from "projects/core"; -import { ProgramLinksComponent } from "@office/features/program-links/program-links.component"; -import { ProgramNewsCardComponent } from "../shared/news-card/news-card.component"; -import { ButtonComponent, IconComponent } from "@ui/components"; -import { ApiPagination } from "@models/api-pagination.model"; -import { TagComponent } from "@ui/components/tag/tag.component"; -import { ProjectService } from "@office/services/project.service"; -import { ModalComponent } from "@ui/components/modal/modal.component"; -import { MatProgressBarModule } from "@angular/material/progress-bar"; -import { LoadingService } from "@office/services/loading.service"; -import { ProjectAdditionalService } from "@office/projects/edit/services/project-additional.service"; -import { SoonCardComponent } from "@office/shared/soon-card/soon-card.component"; -import { NewsFormComponent } from "@office/features/news-form/news-form.component"; -import { AsyncPipe } from "@angular/common"; -import { AvatarComponent } from "@uilib"; -import { NewsCardComponent } from "@office/features/news-card/news-card.component"; -import { AuthService } from "@auth/services"; - -@Component({ - selector: "app-main", - templateUrl: "./main.component.html", - styleUrl: "./main.component.scss", - standalone: true, - imports: [ - IconComponent, - ButtonComponent, - ProgramNewsCardComponent, - AsyncPipe, - ParseBreaksPipe, - ParseLinksPipe, - ModalComponent, - MatProgressBarModule, - SoonCardComponent, - NewsFormComponent, - ProgramLinksComponent, - RouterModule, - ], -}) -export class ProgramDetailMainComponent implements OnInit, OnDestroy { - constructor( - private readonly programNewsService: ProgramNewsService, - private readonly projectAdditionalService: ProjectAdditionalService, - private readonly authService: AuthService, - private readonly router: Router, - private readonly route: ActivatedRoute, - private readonly cdRef: ChangeDetectorRef, - private readonly loadingService: LoadingService - ) {} - - get isAssignProjectToProgramError() { - return this.projectAdditionalService.getIsAssignProjectToProgramError()(); - } - - get errorAssignProjectToProgramModalMessage() { - return this.projectAdditionalService.getErrorAssignProjectToProgramModalMessage(); - } - - appWidth = window.innerWidth; - - @HostListener("window:resize") - onResize() { - this.appWidth = window.innerWidth; - } - - news = signal([]); - totalNewsCount = signal(0); - fetchLimit = signal(10); - fetchPage = signal(0); - - // Сигналы для работы с модальными окнами с текстом - showProgramModal = signal(false); - showProgramModalErrorMessage = signal(null); - - registeredProgramModal = signal(false); - - programId?: number; - profileId = signal(undefined); - - subscriptions$ = signal([]); - - ngOnInit(): void { - const programIdSubscription$ = this.route.params - .pipe( - map(params => params["programId"]), - tap(programId => { - this.programId = programId; - this.fetchNews(0, this.fetchLimit()); - }) - ) - .subscribe(); - - const routeModalSub$ = this.route.queryParams.subscribe(param => { - if (param["access"] === "accessDenied") { - this.loadingService.hide(); - - this.showProgramModal.set(true); - this.showProgramModalErrorMessage.set("У вас не доступа к этой вкладке!"); - - this.router.navigate([], { - relativeTo: this.route, - queryParams: { access: null }, - queryParamsHandling: "merge", - replaceUrl: true, - }); - } - }); - - const profileIdSub$ = this.authService.profile.subscribe({ - next: profile => { - this.profileId.set(profile.id); - }, - }); - - this.subscriptions$().push(profileIdSub$); - - const program$ = this.route.data - .pipe( - map(r => r["data"]), - tap(program => { - this.program = program; - this.registerDateExpired = Date.now() > Date.parse(program.datetimeRegistrationEnds); - if (program.isUserMember) { - const seen = this.hasSeenRegisteredProgramModal(program.id); - if (!seen) { - this.registeredProgramModal.set(true); - this.markSeenRegisteredProgramModal(program.id); - } - } - }), - concatMap(program => { - if (program.isUserMember) { - return this.fetchNews(0, this.fetchLimit()); - } else { - return of({} as ApiPagination); - } - }) - ) - .subscribe({ - next: news => { - if (news.results?.length) { - this.news.set(news.results); - this.totalNewsCount.set(news.count); - - setTimeout(() => { - this.setupNewsObserver(); - }, 100); - } - - this.loadingService.hide(); - }, - error: () => { - this.loadingService.hide(); - - this.showProgramModal.set(true); - this.showProgramModalErrorMessage.set("Произошла ошибка при загрузке программы"); - }, - }); - - setTimeout(() => { - this.checkDescriptionExpandable(); - this.cdRef.detectChanges(); - }, 100); - - this.loadEvent = fromEvent(window, "load"); - - this.subscriptions$().push(program$); - this.subscriptions$().push(programIdSubscription$); - this.subscriptions$().push(routeModalSub$); - } - - ngAfterViewInit() { - setTimeout(() => { - this.checkDescriptionExpandable(); - this.cdRef.detectChanges(); - }, 150); - - const target = document.querySelector(".office__body"); - if (target) { - const scrollEvents$ = fromEvent(target, "scroll") - .pipe( - concatMap(() => this.onScroll()), - throttleTime(2000) - ) - .subscribe(); - this.subscriptions$().push(scrollEvents$); - } - } - - ngOnDestroy(): void { - this.subscriptions$().forEach($ => $.unsubscribe()); - } - - onScroll() { - if (this.news().length < this.totalNewsCount()) { - return this.fetchNews(this.fetchPage() * this.fetchLimit(), this.fetchLimit()).pipe( - tap(({ results }) => { - this.news.update(news => [...news, ...results]); - if (results.length < this.fetchLimit()) { - // console.log('No more to fetch') - } else { - this.fetchPage.update(p => p + 1); - } - - setTimeout(() => { - this.setupNewsObserver(); - }, 100); - }) - ); - } - - const target = document.querySelector(".office__body"); - if (!target) return of({}); - const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight; - if (scrollBottom > 0) return of({}); - this.fetchPage.update(p => p + 1); - return this.fetchNews(this.fetchPage() * this.fetchLimit(), this.fetchLimit()); - } - - fetchNews(offset: number, limit: number) { - const programId = this.route.snapshot.params["programId"]; - return this.programNewsService.fetchNews(limit, offset, programId).pipe( - tap(({ count, results }) => { - this.totalNewsCount.set(count); - this.news.update(news => [...news, ...results]); - }) - ); - } - - @ViewChild(NewsFormComponent) newsFormComponent?: NewsFormComponent; - @ViewChild(ProgramNewsCardComponent) ProgramNewsCardComponent?: ProgramNewsCardComponent; - @ViewChild("descEl") descEl?: ElementRef; - - onNewsInVew(entries: IntersectionObserverEntry[]): void { - const ids = entries.map(e => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - return e.target.dataset.id; - }); - this.programNewsService.readNews(this.route.snapshot.params["programId"], ids).subscribe(noop); - } - - onAddNews(news: { text: string; files: string[] }): void { - this.programNewsService - .addNews(this.route.snapshot.params["programId"], news) - .subscribe(newsRes => { - this.newsFormComponent?.onResetForm(); - this.news.update(news => [newsRes, ...news]); - }); - } - - onDelete(newsId: number) { - const item = this.news().find((n: any) => n.id === newsId); - if (!item) return; - this.programNewsService.deleteNews(this.route.snapshot.params["programId"], newsId).subscribe({ - next: () => { - const index = this.news().findIndex(news => news.id === newsId); - this.news().splice(index, 1); - }, - }); - } - - onLike(newsId: number) { - const item = this.news().find((n: any) => n.id === newsId); - if (!item) return; - this.programNewsService - .toggleLike(this.route.snapshot.params["programId"], newsId, !item.isUserLiked) - .subscribe(() => { - item.likesCount = item.isUserLiked ? item.likesCount - 1 : item.likesCount + 1; - item.isUserLiked = !item.isUserLiked; - }); - } - - onEdit(news: FeedNews, newsId: number) { - this.programNewsService - .editNews(this.route.snapshot.params["programId"], newsId, news) - .subscribe({ - next: (resNews: any) => { - const newsIdx = this.news().findIndex(n => n.id === resNews.id); - this.news()[newsIdx] = resNews; - this.ProgramNewsCardComponent?.onCloseEditMode(); - }, - }); - } - - onExpandDescription(elem: HTMLElement, expandedClass: string, isExpanded: boolean): void { - expandElement(elem, expandedClass, isExpanded); - this.readFullDescription = !isExpanded; - } - - closeModal(): void { - this.showProgramModal.set(false); - this.loadingService.hide(); - } - - clearAssignProjectToProgramError(): void { - this.projectAdditionalService.clearAssignProjectToProgramError(); - } - - private loadEvent?: Observable; - - private checkDescriptionExpandable(): void { - const descElement = this.descEl?.nativeElement; - - if (!descElement || !this.program?.description) { - this.descriptionExpandable = false; - return; - } - - this.descriptionExpandable = descElement.scrollHeight > descElement.clientHeight; - } - - private getRegisteredProgramSeenKey(programId: number): string { - return `program_${this.profileId()}_modal_seen_${programId}`; - } - - private hasSeenRegisteredProgramModal(programId: number): boolean { - try { - return !!localStorage.getItem(this.getRegisteredProgramSeenKey(programId)); - } catch (e) { - return false; - } - } - - private markSeenRegisteredProgramModal(programId: number): void { - try { - localStorage.setItem(this.getRegisteredProgramSeenKey(programId), "1"); - } catch (e) {} - } - - private setupNewsObserver(): void { - const observer = new IntersectionObserver(this.onNewsInVew.bind(this), { - root: document.querySelector(".office__body"), - rootMargin: "0px 0px 0px 0px", - threshold: 0, - }); - - document.querySelectorAll(".news__item").forEach(element => { - observer.observe(element); - }); - } - - program?: Program; - registerDateExpired!: boolean; - descriptionExpandable!: boolean; - readFullDescription = false; - - get contactLinks(): { label: string; url: string }[] { - return (this.program?.links ?? []).map(link => ({ label: link, url: link })); - } - - get materialLinks(): { label: string; url: string }[] { - return (this.program?.materials ?? []).map(m => ({ label: m.title, url: m.url })); - } -} 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 deleted file mode 100644 index c62d1039a..000000000 --- a/projects/social_platform/src/app/office/program/detail/register/register.component.html +++ /dev/null @@ -1,24 +0,0 @@ - - -
- - @if (registerForm && schema) { -
- @for (f of schema | keyvalue; track f.key) { -
- - @if (registerForm.get(f.key); as field) { - - } -
- } - Зарегистрироваться в программе -
- } -
diff --git a/projects/social_platform/src/app/office/program/detail/register/register.component.scss b/projects/social_platform/src/app/office/program/detail/register/register.component.scss deleted file mode 100644 index e8f280db0..000000000 --- a/projects/social_platform/src/app/office/program/detail/register/register.component.scss +++ /dev/null @@ -1,17 +0,0 @@ -.register { - &__bar { - margin-bottom: 20px; - } -} - -.form { - padding: 24px; - background-color: var(--white); - border-radius: 5px; - - &__fieldset { - &:not(:last-child) { - margin-bottom: 20px; - } - } -} diff --git a/projects/social_platform/src/app/office/program/detail/register/register.component.spec.ts b/projects/social_platform/src/app/office/program/detail/register/register.component.spec.ts deleted file mode 100644 index be5fccf9c..000000000 --- a/projects/social_platform/src/app/office/program/detail/register/register.component.spec.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { ProgramRegisterComponent } from "./register.component"; -import { RouterTestingModule } from "@angular/router/testing"; -import { ReactiveFormsModule } from "@angular/forms"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; - -describe("RegisterComponent", () => { - let component: ProgramRegisterComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - RouterTestingModule, - ReactiveFormsModule, - HttpClientTestingModule, - ProgramRegisterComponent, - ], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(ProgramRegisterComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/program/detail/register/register.component.ts b/projects/social_platform/src/app/office/program/detail/register/register.component.ts deleted file mode 100644 index cbf3bd75e..000000000 --- a/projects/social_platform/src/app/office/program/detail/register/register.component.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** @format */ - -import { Component, OnDestroy, OnInit } from "@angular/core"; -import { ActivatedRoute, Router } from "@angular/router"; -import { map, Subscription } from "rxjs"; -import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; -import { ProgramDataSchema } from "@office/program/models/program.model"; -import { ControlErrorPipe, ValidationService } from "projects/core"; -import { ProgramService } from "@office/program/services/program.service"; -import { BarComponent, ButtonComponent, InputComponent } from "@ui/components"; -import { KeyValuePipe } from "@angular/common"; - -/** - * Компонент регистрации в программе - * - * Предоставляет форму для регистрации пользователя в программе. - * Динамически генерирует поля формы на основе схемы данных программы. - * - * Принимает: - * @param {Router} router - Для навигации после успешной регистрации - * @param {ActivatedRoute} route - Для получения данных из резолвера - * @param {FormBuilder} fb - Для создания реактивных форм - * @param {ValidationService} validationService - Для валидации форм - * @param {ProgramService} programService - Для отправки данных регистрации - * - * Данные из резолвера: - * @property {ProgramDataSchema} schema - Схема дополнительных полей программы - * - * Форма: - * @property {FormGroup} registerForm - Динамически генерируемая форма регистрации - * - * Жизненный цикл: - * - OnInit: Получает схему из резолвера и создает форму с валидаторами - * - OnDestroy: Отписывается от всех подписок - * - * Методы: - * @method onSubmit() - Обработчик отправки формы - * - Валидирует форму - * - Отправляет данные через ProgramService - * - Перенаправляет на страницу программы при успехе - * - * Возвращает: - * HTML шаблон с динамической формой регистрации - */ -@Component({ - selector: "app-register", - templateUrl: "./register.component.html", - styleUrl: "./register.component.scss", - standalone: true, - imports: [ - ReactiveFormsModule, - InputComponent, - ButtonComponent, - KeyValuePipe, - ControlErrorPipe, - BarComponent, - ], -}) -export class ProgramRegisterComponent implements OnInit, OnDestroy { - constructor( - private readonly router: Router, - private readonly route: ActivatedRoute, - private readonly fb: FormBuilder, - private readonly validationService: ValidationService, - private readonly programService: ProgramService - ) {} - - ngOnInit(): void { - const route$ = this.route.data.pipe(map(r => r["data"])).subscribe(schema => { - this.schema = schema; - - const group: Record = {}; - for (const cKey in schema) { - group[cKey] = ["", [Validators.required]]; - } - - this.registerForm = this.fb.group(group); - }); - this.subscriptions$.push(route$); - } - - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $.unsubscribe()); - } - - subscriptions$: Subscription[] = []; - - registerForm?: FormGroup; - - schema?: ProgramDataSchema; - - onSubmit(): void { - if (this.registerForm && !this.validationService.getFormValidation(this.registerForm)) { - return; - } - - this.programService - .register(this.route.snapshot.params["programId"], this.registerForm?.value) - .subscribe(() => { - this.router - .navigateByUrl(`/office/program/${this.route.snapshot.params["programId"]}`) - .then(() => console.debug("Route changed from ProgramRegisterComponent")); - }); - } -} diff --git a/projects/social_platform/src/app/office/program/detail/register/register.resolver.spec.ts b/projects/social_platform/src/app/office/program/detail/register/register.resolver.spec.ts deleted file mode 100644 index 0ffda2c0a..000000000 --- a/projects/social_platform/src/app/office/program/detail/register/register.resolver.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; -import { ProgramRegisterResolver } from "./register.resolver"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; -import { RouterTestingModule } from "@angular/router/testing"; -import { ActivatedRouteSnapshot, RouterStateSnapshot } from "@angular/router"; - -describe("ProgramRegisterResolver", () => { - const mockRoute = { params: { programId: 1 } } as unknown as ActivatedRouteSnapshot; - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule, RouterTestingModule], - }); - }); - - it("should be created", () => { - const result = TestBed.runInInjectionContext(() => - ProgramRegisterResolver(mockRoute, {} as RouterStateSnapshot) - ); - expect(result).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/program/detail/register/register.resolver.ts b/projects/social_platform/src/app/office/program/detail/register/register.resolver.ts deleted file mode 100644 index 4eebb752a..000000000 --- a/projects/social_platform/src/app/office/program/detail/register/register.resolver.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { ActivatedRouteSnapshot, ResolveFn } from "@angular/router"; -import { ProgramService } from "@office/program/services/program.service"; -import { ProgramDataSchema } from "@office/program/models/program.model"; - -/** - * Резолвер для получения схемы данных регистрации в программе - * - * Предзагружает схему дополнительных полей, которые нужно заполнить - * при регистрации в конкретной программе. Каждая программа может иметь - * свои уникальные поля для сбора информации о участниках. - * - * Принимает: - * @param {ActivatedRouteSnapshot} route - Снимок маршрута с параметрами - * - * Использует: - * @param {ProgramService} programService - Инжектируемый сервис программ - * - * Логика: - * - Извлекает programId из параметров маршрута - * - Загружает схему данных через programService.getDataSchema() - * - * Возвращает: - * @returns {Observable} Схема полей для регистрации - * - * Схема содержит: - * - Названия полей - * - Типы полей (text, email, etc.) - * - Плейсхолдеры для полей - * - Другие метаданные для генерации формы - * - * Используется в: - * Маршруте register для предзагрузки схемы формы - */ -export const ProgramRegisterResolver: ResolveFn = ( - route: ActivatedRouteSnapshot -) => { - const programService = inject(ProgramService); - - return programService.getDataSchema(route.params["programId"]); -}; diff --git a/projects/social_platform/src/app/office/program/detail/shared/news-card/news-card.component.html b/projects/social_platform/src/app/office/program/detail/shared/news-card/news-card.component.html deleted file mode 100644 index 64c953008..000000000 --- a/projects/social_platform/src/app/office/program/detail/shared/news-card/news-card.component.html +++ /dev/null @@ -1,147 +0,0 @@ - -
-
-
- -
-
-
-
{{ newsItem.name | truncate: 30 }}
-
- {{ newsItem.datetimeCreated | dayjs: "format":"DD.MM.YY" }} -
-
- @if (newsItem.pin) { - - } -
-
-
- @if(isOwner) { -
-
- -
- @if (menuOpen) { -
    - @if (!editMode) { -
  • - редактировать -
  • - } -
  • Удалить
  • -
- } -
- } -
- @if (newsItem.text) { -
- @if (!editMode) { -

- } @else { @if (editForm.get("text"); as text) { - - } } -
- } @if (editMode) { -
    - @for (f of imagesEditList; track f.id) { - - } -
-
    - @for (f of filesEditList; track f.id) { - - } -
- } @if (newsTextExpandable && !editMode) { -
- {{ readMore ? "скрыть" : "подробнее" }} -
- } @if (!editMode) { - - } @if (!editMode && filesViewList.length) { -
- @for (f of filesViewList; track $index) { - - } -
- } @if (!editMode) { - - } @else { - - } -
diff --git a/projects/social_platform/src/app/office/program/detail/shared/news-card/news-card.component.scss b/projects/social_platform/src/app/office/program/detail/shared/news-card/news-card.component.scss deleted file mode 100644 index 021a8cbc8..000000000 --- a/projects/social_platform/src/app/office/program/detail/shared/news-card/news-card.component.scss +++ /dev/null @@ -1,259 +0,0 @@ -@use "styles/responsive"; -@use "styles/typography"; - -.card { - padding: 24px 12px; - background-color: var(--light-white); - border: 0.5px solid var(--medium-grey-for-outline); - border-radius: var(--rounded-lg); - - &__head { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 10px; - } - - &__menu { - position: relative; - } - - &__dots { - display: flex; - align-items: center; - justify-content: center; - width: 16px; - height: 16px; - color: var(--black); - cursor: pointer; - } - - &__options { - position: absolute; - top: 120%; - right: 0%; - z-index: 2; - padding: 20px 0; - background-color: var(--white); - border: 0.5px solid var(--medium-grey-for-outline); - border-radius: var(--rounded-xl); - } - - &__option { - width: 120px; - padding: 5px 20px; - cursor: pointer; - transition: background-color 0.2s; - - &:hover { - background-color: var(--light-gray); - } - } - - &__avatar { - width: 40px; - height: 40px; - margin-right: 10px; - border-radius: 50%; - } - - &__title { - display: flex; - align-items: center; - } - - &__top { - display: flex; - gap: 10px; - align-items: center; - } - - &__name { - color: var(--black); - } - - &__date { - color: var(--dark-grey); - } - - &__views { - display: flex; - gap: 3px; - align-items: center; - color: var(--dark-grey); - - i { - margin-bottom: 1px; - } - } - - /* stylelint-disable value-no-vendor-prefix */ - &__text { - white-space: break-spaces; - - @include typography.body-10; - - p { - display: -webkit-box; - overflow: hidden; - color: var(--black); - text-overflow: ellipsis; - -webkit-box-orient: vertical; - -webkit-line-clamp: 4; - transition: all 0.7s ease-in-out; - - &.expanded { - -webkit-line-clamp: unset; - } - } - } - - /* stylelint-enable value-no-vendor-prefix */ - - &__edit-files { - display: flex; - flex-direction: column; - gap: 10px; - - &:not(:empty) { - margin-top: 30px; - } - } - - &__gallery { - display: flex; - flex-direction: column; - gap: 10px; - margin-top: 10px; - margin-bottom: 10px; - } - - &__files { - display: flex; - flex-direction: column; - gap: 10px; - margin-bottom: 20px; - } - - &__img { - position: relative; - - img { - width: 100%; - object-fit: cover; - } - } - - &__img-like { - position: absolute; - top: 50%; - left: 50%; - display: flex; - align-items: center; - justify-content: center; - width: 75px; - height: 75px; - color: var(--accent); - background-color: var(--white); - border-radius: var(--rounded-xl); - transition: transform 0.1s ease-in-out; - transform: translate(-50%, -50%) scale(0); - - &--show { - transform: translate(-50%, -50%) scale(1); - } - } - - &__footer { - margin-top: 10px; - } - - &__read-more { - margin-bottom: 10px; - } -} - -.footer { - display: flex; - align-items: center; - justify-content: space-between; - - &__left { - display: flex; - gap: 5px; - align-items: center; - } - - &__right { - display: flex; - gap: 5px; - align-items: center; - } - - &__item { - display: flex; - align-items: center; - color: var(--dark-grey); - - &:not(:last-child) { - margin-right: 5px; - } - - i { - margin-right: 3px; - } - } - - &__like { - cursor: pointer; - - &--active { - color: var(--accent); - } - } -} - -.share { - color: var(--dark-grey); - - &__icon { - cursor: pointer; - } -} - -.read-more { - margin-top: 12px; - color: var(--accent); - cursor: pointer; - transition: color 0.2s; - - &:hover { - color: var(--accent-dark); - } -} - -.editor-footer { - display: flex; - justify-content: space-between; - padding-top: 10px; - margin-top: 20px; - border-top: 1px solid var(--medium-grey-for-outline); - - &__actions { - display: flex; - - app-button { - display: block; - margin-right: 10px; - } - } - - &__attach { - color: var(--dark-grey); - cursor: pointer; - - input { - display: none; - } - } -} diff --git a/projects/social_platform/src/app/office/program/detail/shared/news-card/news-card.component.spec.ts b/projects/social_platform/src/app/office/program/detail/shared/news-card/news-card.component.spec.ts deleted file mode 100644 index 9dbf5bff7..000000000 --- a/projects/social_platform/src/app/office/program/detail/shared/news-card/news-card.component.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { ProgramNewsCardComponent } from "./news-card.component"; -import { RouterTestingModule } from "@angular/router/testing"; -import { ReactiveFormsModule } from "@angular/forms"; -import { of } from "rxjs"; -import { ProjectNewsService } from "@office/projects/detail/services/project-news.service"; -import { AuthService } from "@auth/services"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; -import { DayjsPipe } from "projects/core"; -import { FeedNews } from "@office/projects/models/project-news.model"; - -describe("NewsCardComponent", () => { - let component: ProgramNewsCardComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - const projectNewsServiceSpy = jasmine.createSpyObj(["addNews"]); - const authSpy = { - profile: of({}), - }; - - await TestBed.configureTestingModule({ - imports: [ - RouterTestingModule, - ReactiveFormsModule, - HttpClientTestingModule, - ProgramNewsCardComponent, - DayjsPipe, - ], - providers: [ - { provide: ProjectNewsService, useValue: projectNewsServiceSpy }, - { provide: AuthService, useValue: authSpy }, - ], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(ProgramNewsCardComponent); - component = fixture.componentInstance; - component.newsItem = FeedNews.default(); - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/program/detail/shared/news-card/news-card.component.ts b/projects/social_platform/src/app/office/program/detail/shared/news-card/news-card.component.ts deleted file mode 100644 index 30ecd5965..000000000 --- a/projects/social_platform/src/app/office/program/detail/shared/news-card/news-card.component.ts +++ /dev/null @@ -1,381 +0,0 @@ -/** @format */ - -import { - AfterViewInit, - ChangeDetectorRef, - Component, - ElementRef, - EventEmitter, - Input, - OnInit, - Output, - ViewChild, -} from "@angular/core"; -import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; -import { SnackbarService } from "@ui/services/snackbar.service"; -import { ActivatedRoute } from "@angular/router"; -import { expandElement } from "@utils/expand-element"; -import { FileModel } from "@office/models/file.model"; -import { nanoid } from "nanoid"; -import { FileService } from "@core/services/file.service"; -import { catchError, forkJoin, noop, Observable, of, tap } from "rxjs"; -import { DayjsPipe, FormControlPipe, ParseLinksPipe, ValidationService } from "projects/core"; -import { FileItemComponent } from "@ui/components/file-item/file-item.component"; -import { ButtonComponent, IconComponent } from "@ui/components"; -import { FileUploadItemComponent } from "@ui/components/file-upload-item/file-upload-item.component"; -import { ImgCardComponent } from "@office/shared/img-card/img-card.component"; -import { FeedNews } from "@office/projects/models/project-news.model"; -import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; -import { ClickOutsideModule } from "ng-click-outside"; -import { TextareaComponent } from "@ui/components/textarea/textarea.component"; - -/** - * Компонент карточки новости программы - * Отображает новость с возможностью редактирования, лайков, просмотра файлов - * Поддерживает загрузку и удаление файлов, расширение текста, копирование ссылки - */ -@Component({ - selector: "app-program-news-card", - templateUrl: "./news-card.component.html", - styleUrl: "./news-card.component.scss", - standalone: true, - imports: [ - ImgCardComponent, - FileUploadItemComponent, - IconComponent, - FileItemComponent, - ButtonComponent, - TextareaComponent, - ReactiveFormsModule, - DayjsPipe, - FormControlPipe, - TruncatePipe, - ParseLinksPipe, - ClickOutsideModule, - ], -}) -export class ProgramNewsCardComponent implements OnInit, AfterViewInit { - constructor( - private readonly snackbarService: SnackbarService, - private readonly fileService: FileService, - private readonly route: ActivatedRoute, - private readonly fb: FormBuilder, - private readonly validationService: ValidationService, - private readonly cdRef: ChangeDetectorRef - ) { - // Создание формы редактирования новости - this.editForm = this.fb.group({ - text: ["", [Validators.required]], // Текст новости - обязательное поле - }); - } - - @Input({ required: true }) newsItem!: FeedNews; - @Input() isOwner!: boolean; - @Output() delete = new EventEmitter(); - @Output() like = new EventEmitter(); - @Output() edited = new EventEmitter(); - - newsTextExpandable!: boolean; - readMore = false; - editMode = false; - editForm: FormGroup; - - /** Состояние меню действий */ - menuOpen = false; - - /** - * Закрытие меню действий - */ - onCloseMenu() { - this.menuOpen = false; - } - - // Оригинальные списки (не изменяются во время редактирования) - imagesViewList: FileModel[] = []; - filesViewList: FileModel[] = []; - - // Списки для редактирования - imagesEditList: { - id: string; - src: string; - loading: boolean; - error: boolean; - tempFile: File | null; - }[] = []; - - filesEditList: { - id: string; - src: string; - loading: boolean; - error: string; - name: string; - size: number; - type: string; - tempFile: File | null; - }[] = []; - - @ViewChild("newsTextEl") newsTextEl?: ElementRef; - - ngOnInit(): void { - // Установка текущего текста в форму редактирования - this.editForm.setValue({ - text: this.newsItem.text, - }); - - this.showLikes = this.newsItem.files.map(() => false); - - // Инициализация оригинальных списков - this.imagesViewList = this.newsItem.files.filter( - f => f.mimeType.split("/")[0] === "image" || f.mimeType.split("/")[1] === "x-empty" - ); - this.filesViewList = this.newsItem.files.filter( - f => f.mimeType.split("/")[0] !== "image" && f.mimeType.split("/")[1] !== "x-empty" - ); - - // Инициализация списков редактирования из оригинальных данных - this.initEditLists(); - } - - /** - * Инициализация списков редактирования из текущих данных - */ - private initEditLists(): void { - this.imagesEditList = this.imagesViewList.map(file => ({ - src: file.link, - id: nanoid(), - error: false, - loading: false, - tempFile: null, - })); - - this.filesEditList = this.filesViewList.map(file => ({ - src: file.link, - id: nanoid(), - error: "", - loading: false, - name: file.name, - size: file.size, - type: file.mimeType, - tempFile: null, - })); - } - - ngAfterViewInit(): void { - const newsTextElem = this.newsTextEl?.nativeElement; - this.newsTextExpandable = newsTextElem?.clientHeight < newsTextElem?.scrollHeight; - - this.cdRef.detectChanges(); - } - - onCopyLink(): void { - const programId = this.route.snapshot.params["programId"]; - - navigator.clipboard - .writeText(`https://app.procollab.ru/office/program/${programId}/news/${this.newsItem.id}`) - .then(() => { - this.snackbarService.success("Ссылка скопирована"); - }); - } - - /** - * Отправка отредактированной новости - */ - onEditSubmit(): void { - if (!this.validationService.getFormValidation(this.editForm)) return; - - // Собираем только успешно загруженные файлы - const uploadedImages = this.imagesEditList - .filter(f => f.src && !f.loading && !f.error) - .map(f => f.src); - - // Обновляем оригинальные списки на основе успешно загруженных файлов - this.imagesViewList = this.imagesEditList - .filter(f => f.src && !f.loading && !f.error) - .map(f => ({ - link: f.src, - name: "Image", - mimeType: "image/jpeg", - size: 0, - datetimeUploaded: "", - extension: "", - user: 0, - })); - - this.filesViewList = this.filesEditList - .filter(f => f.src && !f.loading && !f.error) - .map(f => ({ - link: f.src, - name: f.name, - size: f.size, - mimeType: f.type, - datetimeUploaded: "", - extension: "", - user: 0, - })); - - // Обновляем текст в newsItem для отображения - this.newsItem.text = this.editForm.value.text; - - // Обновляем файлы в newsItem - this.newsItem.files = [...this.imagesViewList, ...this.filesViewList]; - - this.edited.emit({ - ...this.editForm.value, - files: uploadedImages, - }); - - this.onCloseEditMode(); - this.cdRef.detectChanges(); - } - - /** - * Закрытие режима редактирования - */ - onCloseEditMode() { - this.editMode = false; - // Восстанавливаем списки редактирования из оригинальных данных - this.initEditLists(); - // Сбрасываем форму к исходному значению - this.editForm.setValue({ - text: this.newsItem.text, - }); - } - - onUploadFile(event: Event) { - const files = (event.currentTarget as HTMLInputElement).files; - if (!files) return; - - const observableArray: Observable[] = []; - - for (let i = 0; i < files.length; i++) { - const fileType = files[i].type.split("/")[0]; - - if (fileType === "image") { - const fileObj: ProgramNewsCardComponent["imagesEditList"][0] = { - id: nanoid(2), - src: "", - loading: true, - error: false, - tempFile: files[i], - }; - this.imagesEditList.push(fileObj); - - observableArray.push( - this.fileService.uploadFile(files[i]).pipe( - tap(file => { - fileObj.src = file.url; - fileObj.loading = false; - fileObj.tempFile = null; - }), - catchError(() => { - fileObj.loading = false; - fileObj.error = true; - return of(null); - }) - ) - ); - } else { - const fileObj: ProgramNewsCardComponent["filesEditList"][0] = { - id: nanoid(2), - loading: true, - error: "", - src: "", - tempFile: files[i], - name: files[i].name, - size: files[i].size, - type: files[i].type, - }; - this.filesEditList.push(fileObj); - - observableArray.push( - this.fileService.uploadFile(files[i]).pipe( - tap(file => { - fileObj.loading = false; - fileObj.src = file.url; - fileObj.tempFile = null; - }), - catchError(() => { - fileObj.loading = false; - fileObj.error = "Ошибка загрузки"; - return of(null); - }) - ) - ); - } - } - - forkJoin(observableArray).subscribe(noop); - - // Сбрасываем input для возможности повторной загрузки того же файла - (event.currentTarget as HTMLInputElement).value = ""; - } - - onDeletePhoto(fId: string) { - const fileIdx = this.imagesEditList.findIndex(f => f.id === fId); - if (fileIdx === -1) return; - - if (this.imagesEditList[fileIdx].src) { - this.imagesEditList[fileIdx].loading = true; - this.fileService.deleteFile(this.imagesEditList[fileIdx].src).subscribe(() => { - this.imagesEditList.splice(fileIdx, 1); - }); - } else { - this.imagesEditList.splice(fileIdx, 1); - } - } - - onDeleteFile(fId: string) { - const fileIdx = this.filesEditList.findIndex(f => f.id === fId); - if (fileIdx === -1) return; - - if (this.filesEditList[fileIdx].src) { - this.filesEditList[fileIdx].loading = true; - this.fileService.deleteFile(this.filesEditList[fileIdx].src).subscribe(() => { - this.filesEditList.splice(fileIdx, 1); - }); - } else { - this.filesEditList.splice(fileIdx, 1); - } - } - - onRetryUpload(id: string) { - const fileObj = this.imagesEditList.find(f => f.id === id); - if (!fileObj || !fileObj.tempFile) return; - - fileObj.loading = true; - fileObj.error = false; - - this.fileService.uploadFile(fileObj.tempFile).subscribe({ - next: file => { - fileObj.src = file.url; - fileObj.loading = false; - fileObj.tempFile = null; - }, - error: () => { - fileObj.error = true; - fileObj.loading = false; - }, - }); - } - - showLikes: boolean[] = []; - lastTouch = 0; - - onTouchImg(_event: TouchEvent, imgIdx: number) { - if (Date.now() - this.lastTouch < 300) { - this.like.emit(this.newsItem.id); - this.showLikes[imgIdx] = true; - - setTimeout(() => { - this.showLikes[imgIdx] = false; - }, 1000); - } - - this.lastTouch = Date.now(); - } - - onExpandNewsText(elem: HTMLElement, expandedClass: string, isExpanded: boolean): void { - expandElement(elem, expandedClass, isExpanded); - this.readMore = !isExpanded; - } -} diff --git a/projects/social_platform/src/app/office/program/main/main.component.html b/projects/social_platform/src/app/office/program/main/main.component.html deleted file mode 100644 index dec79837a..000000000 --- a/projects/social_platform/src/app/office/program/main/main.component.html +++ /dev/null @@ -1,29 +0,0 @@ - - -
- @if (programs) { -
- @for (p of searchedPrograms; track p.id) { - - - - } -
- } - -
-

фильтры

- - - - -
-
diff --git a/projects/social_platform/src/app/office/program/main/main.component.spec.ts b/projects/social_platform/src/app/office/program/main/main.component.spec.ts deleted file mode 100644 index cd87b8636..000000000 --- a/projects/social_platform/src/app/office/program/main/main.component.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { ProgramMainComponent } from "./main.component"; -import { RouterTestingModule } from "@angular/router/testing"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; - -describe("MainComponent", () => { - let component: ProgramMainComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [RouterTestingModule, HttpClientTestingModule, ProgramMainComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(ProgramMainComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/program/main/main.component.ts b/projects/social_platform/src/app/office/program/main/main.component.ts deleted file mode 100644 index 16c5eeb35..000000000 --- a/projects/social_platform/src/app/office/program/main/main.component.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** @format */ - -import { ChangeDetectorRef, Component, inject, OnDestroy, OnInit, signal } from "@angular/core"; -import { ActivatedRoute, Params, Router, RouterLink } from "@angular/router"; -import { - combineLatest, - concatMap, - distinctUntilChanged, - map, - of, - Subscription, - switchMap, -} from "rxjs"; -import { Program } from "@office/program/models/program.model"; -import { NavService } from "@office/services/nav.service"; -import Fuse from "fuse.js"; -import { CheckboxComponent, SelectComponent } from "@ui/components"; -import { generateOptionsList } from "@utils/generate-options-list"; -import { ClickOutsideModule } from "ng-click-outside"; -import { ProgramCardComponent } from "../shared/program-card/program-card.component"; -import { HttpParams } from "@angular/common/http"; -import { ProgramService } from "../services/program.service"; - -/** - * Главный компонент списка программ - * - * Отображает список всех доступных программ с функциональностью поиска. - * Поддерживает фильтрацию программ по названию в реальном времени. - * - * Принимает: - * @param {ActivatedRoute} route - Для получения данных из резолвера и query параметров - * @param {NavService} navService - Для установки заголовка навигации - * - * Данные: - * @property {Program[]} programs - Полный массив программ - * @property {Program[]} searchedPrograms - Отфильтрованный массив программ - * @property {number} programCount - Общее количество программ - * - * Поиск: - * - Использует библиотеку Fuse.js для нечеткого поиска - * - Поиск происходит по полю "name" программы - * - Реагирует на изменения query параметра "search" - * - Обновляет searchedPrograms при изменении поискового запроса - * - * Жизненный цикл: - * - OnInit: - * - Устанавливает заголовок "Программы" - * - Подписывается на изменения query параметров для поиска - * - Загружает данные из резолвера - * - OnDestroy: Отписывается от всех подписок - * - * Подписки: - * @property {Subscription[]} subscriptions$ - Массив подписок для очистки - * - * Возвращает: - * HTML шаблон со списком карточек программ и результатами поиска - */ -@Component({ - selector: "app-main", - templateUrl: "./main.component.html", - styleUrl: "./main.component.scss", - standalone: true, - imports: [ - RouterLink, - ProgramCardComponent, - CheckboxComponent, - SelectComponent, - ClickOutsideModule, - ], -}) -export class ProgramMainComponent implements OnInit, OnDestroy { - private readonly route = inject(ActivatedRoute); - private readonly router = inject(Router); - private readonly programService = inject(ProgramService); - private readonly cdref = inject(ChangeDetectorRef); - - programCount = 0; - - programs: Program[] = []; - searchedPrograms: Program[] = []; - subscriptions$: Subscription[] = []; - isPparticipating = signal(false); - - readonly programOptionsFilter = generateOptionsList(4, "strings", [ - "все", - "актуальные", - "архив", - "учавстсвовал", - ]); - - ngOnInit(): void { - const combined$ = combineLatest([ - this.route.queryParams.pipe( - map(q => ({ filter: this.buildFilterQuery(q), search: q["search"] || "" })), - distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)) - ), - ]) - .pipe( - switchMap(([{ filter, search }]) => { - this.isPparticipating.set(filter["participating"] === "true"); - - return this.programService - .getAll(0, 20, new HttpParams({ fromObject: filter })) - .pipe(map(response => ({ response, search }))); - }) - ) - .subscribe(({ response, search }) => { - this.programCount = response.count; - this.programs = response.results ?? []; - - if (search) { - const fuse = new Fuse(this.programs, { - keys: ["name"], - threshold: 0.3, - }); - this.searchedPrograms = fuse.search(search).map(el => el.item); - } else { - this.searchedPrograms = this.programs; - } - - this.cdref.detectChanges(); - }); - - this.subscriptions$.push(combined$); - } - - private buildFilterQuery(q: Params): Record { - const reqQuery: Record = {}; - - if (q["participating"]) { - reqQuery["participating"] = q["participating"]; - } - - return reqQuery; - } - - /** - * Переключает состояние чекбокса "участвую" - */ - onTogglePparticipating(): void { - const newValue = !this.isPparticipating(); - this.isPparticipating.set(newValue); - - this.router.navigate([], { - queryParams: { - participating: newValue ? "true" : null, - }, - relativeTo: this.route, - queryParamsHandling: "merge", - }); - } - - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $?.unsubscribe()); - } -} diff --git a/projects/social_platform/src/app/office/program/main/main.resolver.spec.ts b/projects/social_platform/src/app/office/program/main/main.resolver.spec.ts deleted file mode 100644 index 37761b6e9..000000000 --- a/projects/social_platform/src/app/office/program/main/main.resolver.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; - -import { ProgramMainResolver } from "./main.resolver"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; -import { ActivatedRouteSnapshot, RouterStateSnapshot } from "@angular/router"; - -describe("ProgramMainResolver", () => { - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - }); - }); - - it("should be created", () => { - const result = TestBed.runInInjectionContext(() => - ProgramMainResolver({} as ActivatedRouteSnapshot, {} as RouterStateSnapshot) - ); - expect(result).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/program/main/main.resolver.ts b/projects/social_platform/src/app/office/program/main/main.resolver.ts deleted file mode 100644 index bf91ddbe9..000000000 --- a/projects/social_platform/src/app/office/program/main/main.resolver.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { ProgramService } from "@office/program/services/program.service"; -import { ResolveFn } from "@angular/router"; -import { ApiPagination } from "@models/api-pagination.model"; -import { Program } from "@office/program/models/program.model"; - -/** - * Резолвер для предзагрузки списка программ - * - * Загружает первую страницу программ перед отображением главного - * компонента списка программ. Обеспечивает мгновенное отображение - * данных без состояния загрузки. - * - * Использует: - * @param {ProgramService} programService - Инжектируемый сервис программ - * - * Логика: - * - Загружает первые 20 программ (skip: 0, take: 20) - * - Не требует параметров маршрута - * - * Возвращает: - * @returns {Observable>} Пагинированный список программ - * - * Данные включают: - * - Массив программ (results) - * - Общее количество программ (count) - * - Информацию о пагинации - * - * Каждая программа содержит: - * - Основную информацию (название, описание, даты) - * - Изображения и медиа - * - Статистику просмотров и лайков - * - Информацию о участии пользователя - * - * Используется в: - * Главном маршруте списка программ (path: "all") - */ -export const ProgramMainResolver: ResolveFn> = () => { - const programService = inject(ProgramService); - - return programService.getAll(0, 20); -}; diff --git a/projects/social_platform/src/app/office/program/models/program-create.model.ts b/projects/social_platform/src/app/office/program/models/program-create.model.ts deleted file mode 100644 index 420e4529c..000000000 --- a/projects/social_platform/src/app/office/program/models/program-create.model.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** @format */ - -/** - * Модель для создания новой программы - * - * Содержит все необходимые поля для создания программы в системе. - * Используется при отправке POST запроса на создание программы. - * - * Свойства: - * @param {string} name - Название программы - * @param {string} imageAddress - URL изображения программы - * @param {string} datetimeRegistrationEnd - Дата и время окончания регистрации (ISO строка) - * @param {string} datetimeStarted - Дата и время начала программы (ISO строка) - * @param {string} datetimeFinished - Дата и время окончания программы (ISO строка) - */ -export class ProgramCreate { - name!: string; - imageAddress!: string; - datetimeRegistrationEnd!: string; - datetimeStarted!: string; - datetimeFinished!: string; -} diff --git a/projects/social_platform/src/app/office/program/models/program.model.ts b/projects/social_platform/src/app/office/program/models/program.model.ts deleted file mode 100644 index 25c58f1e4..000000000 --- a/projects/social_platform/src/app/office/program/models/program.model.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** @format */ - -/** - * Основная модель программы в системе - * - * Содержит полную информацию о программе, включая метаданные, - * даты проведения, статистику и права пользователя. - * - * Свойства: - * @param {number} id - Уникальный идентификатор программы - * @param {string} imageAddress - URL основного изображения - * @param {string} coverImageAddress - URL обложки программы - * @param {string} presentationAddress - URL презентации программы - * @param {string} advertisementImageAddress - URL рекламного изображения - * @param {string} name - Название программы - * @param {string} description - Полное описание программы - * @param {string} city - Город проведения программы - * @param {string} tag - Тег/категория программы - * @param {number} year - Год проведения программы - * @param {string[]} links - Массив полезных ссылок - * @param {Array<{title: string; url: string}>} materials - Материалы программы - * @param {string} shortDescription - Краткое описание программы - * @param {string} datetimeRegistrationEnds - Дата окончания регистрации - * @param {string} datetimeStarted - Дата начала программы - * @param {string} datetimeFinished - Дата окончания программы - * @param {number} viewsCount - Количество просмотров - * @param {number} likesCount - Количество лайков - * @param {boolean} isUserLiked - Лайкнул ли текущий пользователь - * @param {boolean} isUserManager - Является ли пользователь менеджером программы - * @param {boolean} isUserMember - Является ли пользователь участником программы - * - * Методы: - * @method static default() - Возвращает объект программы с дефолтными значениями - */ -export class Program { - id!: number; - imageAddress!: string; - coverImageAddress!: string; - presentationAddress!: string; - advertisementImageAddress!: string; - name!: string; - description!: string; - city!: string; - tag!: string; - year!: number; - links!: string[]; - registrationLink!: string | null; - materials!: { title: string; url: string }[]; - shortDescription!: string; - datetimeRegistrationEnds!: string; - datetimeStarted!: string; - datetimeFinished!: string; - datetimeProjectSubmissionEnds!: string; - datetimeEvaluationEnds!: string; - viewsCount!: number; - likesCount!: number; - isUserLiked!: boolean; - isUserManager!: boolean; - isUserMember!: boolean; - publishProjectsAfterFinish!: boolean; - courseId!: number | null; - courses!: { id: number; title: string; isAvailable: boolean }[]; - - static default(): Program { - return { - id: 1, - name: "", - description: "", - city: "", - imageAddress: "", - presentationAddress: "", - links: [], - materials: [], - registrationLink: null, - coverImageAddress: "", - advertisementImageAddress: "", - shortDescription: "", - datetimeRegistrationEnds: "", - datetimeStarted: "", - datetimeFinished: "", - datetimeProjectSubmissionEnds: "", - datetimeEvaluationEnds: "", - viewsCount: 1, - tag: "", - likesCount: 1, - year: 0, - isUserLiked: false, - isUserMember: false, - isUserManager: false, - publishProjectsAfterFinish: false, - courseId: null, - courses: [], - }; - } -} - -/** - * Схема данных программы для динамических полей - * - * Определяет структуру дополнительных полей программы, - * которые могут быть настроены администратором. - * - * @param {string} key - Ключ поля - * @param {object} value - Объект с типом, названием и плейсхолдером поля - */ -export class ProgramDataSchema { - [key: string]: { - type: "text"; - name: string; - placeholder: string; - }; -} - -/** - * Модель тега программы - * - * Представляет категорию или тег программы для группировки и фильтрации. - * - * @param {number} id - Уникальный идентификатор тега - * @param {string} name - Отображаемое название тега - * @param {string} tag - Системное название тега - */ -export class ProgramTag { - id!: number; - name!: string; - tag!: string; -} diff --git a/projects/social_platform/src/app/office/program/models/project-rate.ts b/projects/social_platform/src/app/office/program/models/project-rate.ts deleted file mode 100644 index d66ab8e51..000000000 --- a/projects/social_platform/src/app/office/program/models/project-rate.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** @format */ - -import { User } from "projects/ui/src/lib/models/user.model"; -import { ProjectRatingCriterion } from "./project-rating-criterion"; // Assuming this is where ProjectRatingCriterion is declared - -/** - * Интерфейс проекта для оценки - * - * Представляет проект, который может быть оценен экспертами в рамках программы. - * Содержит информацию о проекте и критерии для его оценки. - * - * Свойства: - * @param {number} id - Уникальный идентификатор проекта - * @param {string} name - Название проекта - * @param {number} leader - ID руководителя проекта - * @param {string} description - Описание проекта - * @param {string} imageAddress - URL изображения проекта - * @param {string} presentationAddress - URL презентации проекта - * @param {string} region - Регион проекта - * @param {number} viewsCount - Количество просмотров - * @param {number} industry - ID отрасли проекта - * @param {ProjectRatingCriterion[]} criterias - Массив критериев для оценки - * @param {boolean} isScored - Флаг, указывающий, оценен ли проект текущим пользователем - * @private {number | null} scoredExpertId - Флаг, что оценил - */ -export interface ProjectRate { - id: number; - name: string; - leader: number; - description: string; - imageAddress: string; - presentationAddress: string; - region: string; - viewsCount: number; - industry: number; - scored: boolean; - scoredExpertId: number | null; - ratedExperts: User[]; - ratedCount: number; - maxRates: number; - criterias: ProjectRatingCriterion[]; -} diff --git a/projects/social_platform/src/app/office/program/models/project-rating-criterion-output.ts b/projects/social_platform/src/app/office/program/models/project-rating-criterion-output.ts deleted file mode 100644 index c4463c254..000000000 --- a/projects/social_platform/src/app/office/program/models/project-rating-criterion-output.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** @format */ - -/** - * Интерфейс для отправки оценки критерия проекта - * - * Используется при отправке оценок проекта на сервер. - * Содержит ID критерия и значение оценки в унифицированном формате. - * - * @param {number} criterionId - ID критерия оценки - * @param {unknown} value - Значение оценки (может быть string, number, boolean) - */ -export interface ProjectRatingCriterionOutput { - criterionId: number; - value: unknown; -} diff --git a/projects/social_platform/src/app/office/program/models/project-rating-criterion-type.ts b/projects/social_platform/src/app/office/program/models/project-rating-criterion-type.ts deleted file mode 100644 index 536d432e7..000000000 --- a/projects/social_platform/src/app/office/program/models/project-rating-criterion-type.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** @format */ - -/** - * Тип критерия оценки проекта - * - * Определяет возможные типы критериев для оценки проектов: - * - "bool" - булевый тип (да/нет, true/false) - * - "int" - целочисленный тип (числовая оценка) - * - "str" - строковый тип (текстовый комментарий) - * - * Используется для определения типа поля ввода и валидации значений критерия. - */ -export type ProjectRatingCriterionType = "bool" | "int" | "str"; diff --git a/projects/social_platform/src/app/office/program/models/project-rating-criterion.ts b/projects/social_platform/src/app/office/program/models/project-rating-criterion.ts deleted file mode 100644 index 26b983944..000000000 --- a/projects/social_platform/src/app/office/program/models/project-rating-criterion.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** @format */ - -/** - * Интерфейс критерия оценки проекта - * - * Описывает структуру критерия, по которому оценивается проект в рамках программы. - * Критерий может быть разных типов (boolean, integer, string) с различными ограничениями. - * - * Свойства: - * @param {number} id - Уникальный идентификатор критерия - * @param {string} name - Название критерия оценки - * @param {string} description - Подробное описание критерия - * @param {ProjectRatingCriterionType} type - Тип критерия ("bool" | "int" | "str") - * @param {number | null} minValue - Минимальное значение (для числовых критериев) - * @param {number | null} maxValue - Максимальное значение (для числовых критериев) - * @param {string | number} value - Текущее значение критерия - */ -import { ProjectRatingCriterionType } from "./project-rating-criterion-type"; - -export interface ProjectRatingCriterion { - id: number; - name: string; - description: string; - type: ProjectRatingCriterionType; - minValue: number | null; - maxValue: number | null; - value: string | number; - expertId: number; -} diff --git a/projects/social_platform/src/app/office/program/program.component.ts b/projects/social_platform/src/app/office/program/program.component.ts deleted file mode 100644 index 6c8b51465..000000000 --- a/projects/social_platform/src/app/office/program/program.component.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** @format */ - -import { Component, OnDestroy, OnInit } from "@angular/core"; -import { NavService } from "@services/nav.service"; -import { ActivatedRoute, NavigationEnd, Router, RouterOutlet } from "@angular/router"; -import { Subscription } from "rxjs"; -import { FormBuilder, FormGroup, ReactiveFormsModule } from "@angular/forms"; -import { SearchComponent } from "@ui/components/search/search.component"; -import { BarComponent } from "@ui/components"; -import { ProgramService } from "./services/program.service"; -import { BackComponent } from "@uilib"; - -/** - * Основной компонент модуля "Программы" - * - * Функциональность: - * - Отображает заголовок навигации "Программы" - * - Предоставляет форму поиска программ - * - Управляет состоянием активных вкладок (My/All) - * - Обрабатывает изменения поисковых параметров в URL - * - Содержит router-outlet для дочерних компонентов - * - * Принимает: - * - NavService - для установки заголовка навигации - * - ActivatedRoute - для работы с параметрами маршрута - * - ProgramService - сервис для работы с программами - * - Router - для навигации и изменения URL параметров - * - FormBuilder - для создания реактивных форм - * - * Возвращает: - * - HTML шаблон с формой поиска и router-outlet - * - Управляет состоянием флагов isMy и isAll - */ -@Component({ - selector: "app-program", - templateUrl: "./program.component.html", - styleUrl: "./program.component.scss", - standalone: true, - imports: [ReactiveFormsModule, SearchComponent, RouterOutlet, BarComponent, BackComponent], -}) -export class ProgramComponent implements OnInit, OnDestroy { - constructor( - private readonly navService: NavService, - private readonly route: ActivatedRoute, - public readonly programService: ProgramService, - private readonly router: Router, - private readonly fb: FormBuilder - ) { - this.searchForm = this.fb.group({ - search: [""], - }); - } - - ngOnInit(): void { - this.navService.setNavTitle("Программы"); - - const searchFormSearch$ = this.searchForm.get("search")?.valueChanges.subscribe(search => { - this.router - .navigate([], { - queryParams: { search }, - relativeTo: this.route, - queryParamsHandling: "merge", - }) - .then(() => console.debug("QueryParams changed from ProjectsComponent")); - }); - - searchFormSearch$ && this.subscriptions$.push(searchFormSearch$); - - const routeUrl$ = this.router.events.subscribe(event => { - if (event instanceof NavigationEnd) { - this.isAll = location.href.includes("/all"); - } - }); - routeUrl$ && this.subscriptions$.push(routeUrl$); - } - - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $?.unsubscribe()); - } - - searchForm: FormGroup; - subscriptions$: Subscription[] = []; - - isAll = location.href.includes("/all"); -} diff --git a/projects/social_platform/src/app/office/program/program.routes.ts b/projects/social_platform/src/app/office/program/program.routes.ts deleted file mode 100644 index e1a17341e..000000000 --- a/projects/social_platform/src/app/office/program/program.routes.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** @format */ - -import { Routes } from "@angular/router"; -import { ProgramComponent } from "./program.component"; -import { ProgramMainComponent } from "./main/main.component"; -import { ProgramMainResolver } from "./main/main.resolver"; - -/** - * Конфигурация маршрутов для модуля "Программы" - * - * Описание маршрутов: - * - "" - корневой маршрут программ с дочерними маршрутами - * - "" - редирект на "/all" - * - "all" - список всех программ с резолвером данных - * - ":programId" - детальная страница программы (ленивая загрузка) - * - ":programId/projects-rating" - страница оценки проектов программы (ленивая загрузка) - * - * @returns {Routes} Массив конфигураций маршрутов для Angular Router - */ -export const PROGRAM_ROUTES: Routes = [ - { - path: "", - component: ProgramComponent, - children: [ - { - path: "", - pathMatch: "full", - redirectTo: "all", - }, - { - path: "all", - component: ProgramMainComponent, - resolve: { - data: ProgramMainResolver, - }, - }, - ], - }, - { - path: ":programId", - loadChildren: () => import("./detail/detail.routes").then(c => c.PROGRAM_DETAIL_ROUTES), - }, -]; diff --git a/projects/social_platform/src/app/office/program/services/program-data.service.ts b/projects/social_platform/src/app/office/program/services/program-data.service.ts deleted file mode 100644 index c0c1ef698..000000000 --- a/projects/social_platform/src/app/office/program/services/program-data.service.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** @format */ - -import { Injectable } from "@angular/core"; -import { BehaviorSubject } from "rxjs"; -import { Program } from "../models/program.model"; - -@Injectable({ - providedIn: "root", -}) -export class ProgramDataService { - private programSubject$ = new BehaviorSubject(undefined); - program$ = this.programSubject$.asObservable(); - - setProgram(program: Program): void { - return this.programSubject$.next(program); - } - - getProgramName(): string { - const program = this.programSubject$.value; - if (!program?.name) return ""; - return program.name; - } -} diff --git a/projects/social_platform/src/app/office/program/services/program-news.service.spec.ts b/projects/social_platform/src/app/office/program/services/program-news.service.spec.ts deleted file mode 100644 index b1b76beb9..000000000 --- a/projects/social_platform/src/app/office/program/services/program-news.service.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; - -import { ProgramNewsService } from "./program-news.service"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; - -describe("ProgramNewsService", () => { - let service: ProgramNewsService; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - }); - service = TestBed.inject(ProgramNewsService); - }); - - it("should be created", () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/program/services/program-news.service.ts b/projects/social_platform/src/app/office/program/services/program-news.service.ts deleted file mode 100644 index 9f7c51ec2..000000000 --- a/projects/social_platform/src/app/office/program/services/program-news.service.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** @format */ - -import { Injectable } from "@angular/core"; -import { ApiService } from "projects/core"; -import { forkJoin, map, Observable } from "rxjs"; -import { ApiPagination } from "@models/api-pagination.model"; -import { FeedNews } from "@office/projects/models/project-news.model"; -import { HttpParams } from "@angular/common/http"; -import { plainToInstance } from "class-transformer"; - -/** - * Сервис для работы с новостями программ - * - * Обеспечивает функциональность новостной ленты программы: - * - Загрузка новостей с пагинацией - * - Отметка новостей как прочитанных - * - Лайки/дизлайки новостей - * - Добавление новых новостей - * - * Принимает: - * @param {ApiService} apiService - Сервис для HTTP запросов - * - * Методы: - * @method fetchNews(limit: number, offset: number, programId: number) - Загружает новости программы - * @method readNews(projectId: string, newsIds: number[]) - Отмечает новости как прочитанные - * @method toggleLike(projectId: string, newsId: number, state: boolean) - Переключает лайк новости - * @method addNews(programId: number, obj: {text: string; files: string[]}) - Добавляет новую новость - * @method deleteNews(programId: number, newsId: number) - Удаляет новость - * - * @returns Соответствующие Observable для каждого метода - */ -@Injectable({ - providedIn: "root", -}) -export class ProgramNewsService { - private readonly PROGRAMS_URL = "/programs"; - - constructor(private readonly apiService: ApiService) {} - - fetchNews(limit: number, offset: number, programId: number): Observable> { - return this.apiService.get( - `${this.PROGRAMS_URL}/${programId}/news/`, - new HttpParams({ fromObject: { limit, offset } }) - ); - } - - readNews(projectId: string, newsIds: number[]): Observable { - return forkJoin( - newsIds.map(id => - this.apiService.post(`${this.PROGRAMS_URL}/${projectId}/news/${id}/set_viewed/`, {}) - ) - ); - } - - toggleLike(projectId: string, newsId: number, state: boolean): Observable { - return this.apiService.post(`${this.PROGRAMS_URL}/${projectId}/news/${newsId}/set_liked/`, { - is_liked: state, - }); - } - - addNews(programId: number, obj: { text: string; files: string[] }) { - return this.apiService - .post(`${this.PROGRAMS_URL}/${programId}/news/`, obj) - .pipe(map(r => plainToInstance(FeedNews, r))); - } - - editNews(programId: number, newsId: number, newsItem: Partial) { - return this.apiService.patch(`${this.PROGRAMS_URL}/${programId}/news/${newsId}`, newsItem); - } - - deleteNews(programId: number, newsId: number) { - return this.apiService.delete(`${this.PROGRAMS_URL}/${programId}/news/${newsId}`); - } -} diff --git a/projects/social_platform/src/app/office/program/services/program.service.spec.ts b/projects/social_platform/src/app/office/program/services/program.service.spec.ts deleted file mode 100644 index 922a404c7..000000000 --- a/projects/social_platform/src/app/office/program/services/program.service.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; - -import { ProgramService } from "./program.service"; -import { RouterTestingModule } from "@angular/router/testing"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; - -describe("ProgramService", () => { - let service: ProgramService; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [RouterTestingModule, HttpClientTestingModule], - }); - service = TestBed.inject(ProgramService); - }); - - it("should be created", () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/program/services/program.service.ts b/projects/social_platform/src/app/office/program/services/program.service.ts deleted file mode 100644 index d583f62a0..000000000 --- a/projects/social_platform/src/app/office/program/services/program.service.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** @format */ - -import { Injectable } from "@angular/core"; -import { ApiService } from "projects/core"; -import { map, Observable } from "rxjs"; -import { HttpParams } from "@angular/common/http"; -import { ProgramCreate } from "@office/program/models/program-create.model"; -import { Program, ProgramDataSchema } from "@office/program/models/program.model"; -import { Project } from "@models/project.model"; -import { ApiPagination } from "@models/api-pagination.model"; -import { User } from "@auth/models/user.model"; -import { PartnerProgramFields } from "@office/models/partner-program-fields.model"; -import { ProjectAdditionalFields } from "@office/projects/models/project-additional-fields.model"; - -/** - * Сервис для работы с программами - * - * Предоставляет методы для взаимодействия с API программ: - * - Получение списка программ с пагинацией - * - Получение детальной информации о программе - * - Создание новой программы - * - Регистрация в программе - * - Получение проектов и участников программы - * - Работа с тегами программ - * - * Принимает: - * @param {ApiService} apiService - Сервис для HTTP запросов - * - * Методы: - * @method getAll(skip: number, take: number) - Получает список программ с пагинацией - * @method getOne(programId: number) - Получает детальную информацию о программе - * @method create(program: ProgramCreate) - Создает новую программу - * @method getDataSchema(programId: number) - Получает схему дополнительных полей программы - * @method register(programId: number, additionalData: Record) - Регистрирует пользователя в программе - * @method getAllProjects(programId: number, offset: number, limit: number) - Получает проекты программы - * @method getAllMembers(programId: number, skip: number, take: number) - Получает участников программы - * @method submitCompettetiveProject(prelationId: number) - Cохранить и "подать проект" на сдачу в программу конкурсную - * @method getProgramFilters(programId: number) - Получение данных для фильтра проектов-участников по доп полям - * @method programTags() - Получает и кеширует теги программ пользователя - * - * Свойства: - * @property {BehaviorSubject} programTags$ - Реактивный поток тегов программ - */ -@Injectable({ - providedIn: "root", -}) -export class ProgramService { - private readonly PROGRAMS_URL = "/programs"; - private readonly AUTH_PUBLIC_USERS_URL = "/auth/public-users"; - - constructor(private readonly apiService: ApiService) {} - - getAll(skip: number, take: number, params?: HttpParams): Observable> { - let httpParams = new HttpParams(); - - httpParams.set("limit", take); - httpParams.set("offset", skip); - - if (params) { - params.keys().forEach(key => { - const value = params.get(key); - if (value !== null) { - httpParams = httpParams.set(key, value); - } - }); - } - - return this.apiService.get(`${this.PROGRAMS_URL}/`, httpParams); - } - - getActualPrograms(): Observable> { - return this.apiService.get(`${this.PROGRAMS_URL}/`); - } - - getOne(programId: number): Observable { - return this.apiService.get(`${this.PROGRAMS_URL}/${programId}/`); - } - - create(program: ProgramCreate): Observable { - return this.apiService.post(`${this.PROGRAMS_URL}/`, program); - } - - getDataSchema(programId: number): Observable { - return this.apiService - .get<{ dataSchema: ProgramDataSchema }>(`${this.PROGRAMS_URL}/${programId}/schema/`) - .pipe(map(r => r["dataSchema"])); - } - - register( - programId: number, - additionalData: Record - ): Observable { - return this.apiService.post(`${this.PROGRAMS_URL}/${programId}/register/`, additionalData); - } - - getAllProjects(programId: number, params?: HttpParams): Observable> { - return this.apiService.get(`${this.PROGRAMS_URL}/${programId}/projects`, params); - } - - getAllMembers(programId: number, skip: number, take: number): Observable> { - return this.apiService.get( - `${this.AUTH_PUBLIC_USERS_URL}/`, - new HttpParams({ fromObject: { partner_program: programId, limit: take, offset: skip } }) - ); - } - - getProgramFilters(programId: number): Observable { - return this.apiService.get(`${this.PROGRAMS_URL}/${programId}/filters/`); - } - - getProgramProjectAdditionalFields(programId: number): Observable { - return this.apiService.get(`${this.PROGRAMS_URL}/${programId}/projects/apply/`); - } - - // body - это форма проекта который подается + programFieldValues - applyProjectToProgram(programId: number, body: any): Observable { - return this.apiService.post(`${this.PROGRAMS_URL}/${programId}/projects/apply/`, body); - } - - createProgramFilters( - programId: number, - filters: Record, - params?: HttpParams - ): Observable> { - let url = `${this.PROGRAMS_URL}/${programId}/projects/filter/`; - - if (params) { - url += `?${params.toString()}`; - } - - return this.apiService.post(url, { filters }); - } - - submitCompettetiveProject(relationId: number): Observable { - return this.apiService.post( - `${this.PROGRAMS_URL}/partner-program-projects/${relationId}/submit/`, - {} - ); - } -} diff --git a/projects/social_platform/src/app/office/program/services/project-rating.service.spec.ts b/projects/social_platform/src/app/office/program/services/project-rating.service.spec.ts deleted file mode 100644 index 382569831..000000000 --- a/projects/social_platform/src/app/office/program/services/project-rating.service.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; - -import { ProjectRatingService } from "./project-rating.service"; -import { RouterTestingModule } from "@angular/router/testing"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; - -describe("ProjectRatingService", () => { - let service: ProjectRatingService; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [RouterTestingModule, HttpClientTestingModule], - }); - service = TestBed.inject(ProjectRatingService); - }); - - it("should be created", () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/program/services/project-rating.service.ts b/projects/social_platform/src/app/office/program/services/project-rating.service.ts deleted file mode 100644 index 5887937fc..000000000 --- a/projects/social_platform/src/app/office/program/services/project-rating.service.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** @format */ - -import { Injectable } from "@angular/core"; -import { ApiService } from "projects/core"; -import { Observable } from "rxjs"; -import { HttpParams } from "@angular/common/http"; -import { ApiPagination } from "@models/api-pagination.model"; -import { ProjectRate } from "../models/project-rate"; -import { ProjectRatingCriterion } from "../models/project-rating-criterion"; -import { ProjectRatingCriterionOutput } from "../models/project-rating-criterion-output"; - -/** - * Сервис для оценки проектов в рамках программы - * - * Предоставляет функциональность для экспертной оценки проектов: - * - Получение списка проектов для оценки с фильтрацией - * - Отправка оценок проектов - * - Преобразование данных форм в формат API - * - * Принимает: - * @param {ApiService} apiService - Сервис для HTTP запросов - * - * Методы: - * @method getAll(id, skip, take, isRatedByExpert?, nameContains?) - Получает проекты для оценки - * @param {number} id - ID программы - * @param {number} skip - Количество пропускаемых записей - * @param {number} take - Количество загружаемых записей - * @param {boolean} isRatedByExpert - Фильтр по статусу оценки экспертом - * @param {string} nameContains - Фильтр по названию проекта - * - * @method rate(projectId: number, scores: ProjectRatingCriterionOutput[]) - Отправляет оценку проекта - * - * @method formValuesToDTO(criteria, outputVals) - Преобразует данные формы в DTO - * Конвертирует объект вида {1: 'value', 2: '5', 3: true} в массив - * [{criterionId: 1, value: 'value'}, {criterionId: 2, value: 5}, ...] - * Обрабатывает типы данных согласно типу критерия (bool -> string, int -> number) - */ -@Injectable({ - providedIn: "root", -}) -export class ProjectRatingService { - private readonly RATE_PROJECT_URL = "/rate-project"; - - constructor(private readonly apiService: ApiService) {} - - getAll(programId: number, params?: HttpParams): Observable> { - return this.apiService.get(`${this.RATE_PROJECT_URL}/${programId}`, params); - } - - postFilters( - programId: number, - filters: Record, - params?: HttpParams - ): Observable> { - let url = `${this.RATE_PROJECT_URL}/${programId}`; - - if (params) { - url += `?${params.toString()}`; - } - - return this.apiService.post(url, { filters }); - } - - rate(projectId: number, scores: ProjectRatingCriterionOutput[]): Observable { - return this.apiService.post(`${this.RATE_PROJECT_URL}/rate/${projectId}`, scores); - } - - /* - функция преобразует данные из формы вида { 1: 'value', 2: '5', 3: true }, - где ключом (key) является id критерия оценки, а значение является непосредственно значением оценки, - к виду [{ criterionId: 1, value: 'value' }, { criterionId: 2, value: 5 }, { criterionId: 3, value: 'true' }], - */ - formValuesToDTO( - criteria: ProjectRatingCriterion[], - outputVals: Record - ): ProjectRatingCriterionOutput[] { - const output: ProjectRatingCriterionOutput[] = []; - - outputVals = Object.assign({}, outputVals); - - for (const key in outputVals) { - // оценки с boolean значением переводятся в "string-boolean" (true => "true") - if (typeof outputVals[key] === "boolean") { - const boolString = String(outputVals[key]); - outputVals[key] = boolString.charAt(0).toUpperCase() + boolString.slice(1); - } - // оценки с числовым значением поступают в виде string (из инпута), и их требуется привести к типу number - // поскольку типом string могут обладать не только оценки с числовым значением, но и "комментарий", - // нужно явно убедиться, что критерий именно числовой, для чего осуществляется поиск критерия по id - // в списке критериев оценки проекта и проверка его принадлежности типу "int" - if (criteria.find(c => c.id === Number(key))?.type === "int") { - outputVals[key] = Number(outputVals[key]); - } - - output.push({ criterionId: Number(key), value: outputVals[key] }); - } - - return output; - } -} diff --git a/projects/social_platform/src/app/office/program/shared/program-card/program-card.component.html b/projects/social_platform/src/app/office/program/shared/program-card/program-card.component.html deleted file mode 100644 index e102cd2b4..000000000 --- a/projects/social_platform/src/app/office/program/shared/program-card/program-card.component.html +++ /dev/null @@ -1,44 +0,0 @@ - - -@if (program) { -
-
- -
- -
-

{{ program.name }}

- -

- - {{ - registerDateExpired - ? "регистрация завершена" - : program.isUserMember - ? "ты уже участвуешь!" - : "Регистрация до " + (program.datetimeRegistrationEnds | date: "dd MMMM") - }} -

- -
-

- {{ program.datetimeStarted | date: "dd.MM.yyyy" }} -

-

-

-

- {{ program.datetimeFinished | date: "dd.MM.yyyy" }} -

- -

• для всей России

-
-
-
-} diff --git a/projects/social_platform/src/app/office/program/shared/program-card/program-card.component.ts b/projects/social_platform/src/app/office/program/shared/program-card/program-card.component.ts deleted file mode 100644 index 41aa9fd8a..000000000 --- a/projects/social_platform/src/app/office/program/shared/program-card/program-card.component.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** @format */ - -import { Component, Input, OnInit } from "@angular/core"; -import { Program } from "@office/program/models/program.model"; -import { IconComponent } from "@ui/components"; -import { AvatarComponent } from "@ui/components/avatar/avatar.component"; -import { DatePipe, NgClass } from "@angular/common"; - -/** - * Компонент карточки программы - * - * Отображает краткую информацию о программе в виде карточки для списков. - * Используется на главной странице программ и в других местах, где нужно - * показать превью программы. - * - * Принимает: - * @Input program?: Program - Объект программы для отображения - * - * Отображает: - * - Изображение программы (аватар) - * - Название программы - * - Краткое описание - * - Даты проведения (отформатированные) - * - Иконки и дополнительную информацию - * - * Использует: - * - AvatarComponent для отображения изображения - * - IconComponent для иконок - * - DatePipe как альтернативный форматтер дат - * - * Возвращает: - * HTML шаблон карточки программы с базовой информацией - */ -@Component({ - selector: "app-program-card", - templateUrl: "./program-card.component.html", - styleUrl: "./program-card.component.scss", - standalone: true, - imports: [AvatarComponent, IconComponent, DatePipe, NgClass], -}) -export class ProgramCardComponent implements OnInit { - constructor() {} - - @Input({ required: true }) program?: Program; - - ngOnInit(): void { - this.registerDateExpired = Date.now() > Date.parse(this.program!.datetimeRegistrationEnds); - } - - registerDateExpired?: boolean; -} diff --git a/projects/social_platform/src/app/office/program/shared/rating-card/rating-card.component.html b/projects/social_platform/src/app/office/program/shared/rating-card/rating-card.component.html deleted file mode 100644 index 9d2b0c0ae..000000000 --- a/projects/social_platform/src/app/office/program/shared/rating-card/rating-card.component.html +++ /dev/null @@ -1,171 +0,0 @@ - - -@if (project) { -
-
-
-
- - -
- -

{{ project.name | truncate: 15 }}

-
- - @if (industryService.industries | async; as industries) { @if - (industryService.getIndustry(industries, project.industry); as industry) { - - {{ industry.name }} - - } } -
-
- - @if (project.presentationAddress) { - - - презентация - - - - } -
- -
-
-

о проекте

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

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

{{ ratedCount() }} / {{ project.maxRates }}

- -
-
- -
-
-
-

оценка проекта

- -
- - @if (form | controlError: "required"; as error) { -
- {{ error }} -
- } -
- - @if (showRatingForm || showRatedStatus || showConfirmedState) { -
- - {{ rateButtonText }} - - - @if (showEditButton) { - - - - } -
- } - - -
-
-

подтвердите оценку

- -
- -

{{ project.name }}

-
-
- -
- @if (showConfirmRateModal()) { - - } -
- -
- - подтверждаю - - - - изменить оценку - -
-
-
-
-
-} diff --git a/projects/social_platform/src/app/office/program/shared/rating-card/rating-card.component.spec.ts b/projects/social_platform/src/app/office/program/shared/rating-card/rating-card.component.spec.ts deleted file mode 100644 index 0cb7302af..000000000 --- a/projects/social_platform/src/app/office/program/shared/rating-card/rating-card.component.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { RatingCardComponent } from "./rating-card.component"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; - -describe("RatingCardComponent", () => { - let component: RatingCardComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [RatingCardComponent, HttpClientTestingModule], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(RatingCardComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/program/shared/rating-card/rating-card.component.ts b/projects/social_platform/src/app/office/program/shared/rating-card/rating-card.component.ts deleted file mode 100644 index 8d55f75d8..000000000 --- a/projects/social_platform/src/app/office/program/shared/rating-card/rating-card.component.ts +++ /dev/null @@ -1,453 +0,0 @@ -/** @format */ - -import { - AfterViewInit, - ChangeDetectorRef, - Component, - ElementRef, - Input, - OnDestroy, - OnInit, - signal, - ViewChild, -} from "@angular/core"; -import { ButtonComponent, IconComponent } from "@ui/components"; -import { AvatarComponent } from "@ui/components/avatar/avatar.component"; -import { CommonModule } from "@angular/common"; -import { ProjectRate } from "@office/program/models/project-rate"; -import { ControlErrorPipe, ParseBreaksPipe, ParseLinksPipe } from "projects/core"; -import { expandElement } from "@utils/expand-element"; -import { - debounceTime, - filter, - finalize, - fromEvent, - map, - Observable, - Subscription, - tap, -} from "rxjs"; -import { BreakpointObserver } from "@angular/cdk/layout"; -import { IndustryService } from "@office/services/industry.service"; -import { ProjectRatingComponent } from "@office/features/project-rating/project-rating.component"; -import { FormControl, ReactiveFormsModule } from "@angular/forms"; -import { ProjectRatingService } from "@office/program/services/project-rating.service"; -import { RouterLink } from "@angular/router"; -import { TagComponent } from "@ui/components/tag/tag.component"; -import { ModalComponent } from "@ui/components/modal/modal.component"; -import { ProgramDataService } from "@office/program/services/program-data.service"; -import { AuthService } from "@auth/services"; -import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; -import { HttpResponse } from "@angular/common/http"; - -/** - * Компонент карточки оценки проекта - * - * Отображает детальную информацию о проекте и форму для его оценки экспертами. - * Поддерживает навигацию между проектами и различные типы критериев оценки. - * - * Принимает: - * @Input project: ProjectRate | null - Текущий проект для оценки - * - * Функциональность: - * - Отображение информации о проекте (название, описание, изображения) - * - Форма оценки с различными типами критериев - * - Возможность развернуть/свернуть описание проекта - * - Отправка оценки и обработка результата - * - Поддержка переоценки для пользователей, которые уже оценили - * - Блокировка оценки при достижении лимита (только для тех, кто не оценивал) - * - Адаптивный дизайн для мобильных устройств - * - * Состояния: - * @property {FormControl} form - Реактивная форма для оценки - * @property {Signal} submitLoading - Состояние загрузки при отправке - * @property {Signal} readFullDescription - Развернуто ли описание - * @property {Signal} descriptionExpandable - Можно ли развернуть описание - * @property {Signal} projectRated - Оценен ли проект (временно) - * @property {Signal} projectConfirmed - Подтверждена ли оценка окончательно - * @property {Signal} locallyRatedByCurrentUser - Оценил ли пользователь локально - * @property {Signal} ratedCount - Количество оценок проекта - */ -@Component({ - selector: "app-rating-card", - templateUrl: "./rating-card.component.html", - styleUrl: "./rating-card.component.scss", - standalone: true, - imports: [ - CommonModule, - ReactiveFormsModule, - AvatarComponent, - IconComponent, - ButtonComponent, - ParseLinksPipe, - ParseBreaksPipe, - ProjectRatingComponent, - ControlErrorPipe, - RouterLink, - TagComponent, - ModalComponent, - TruncatePipe, - ], -}) -export class RatingCardComponent implements OnInit, AfterViewInit, OnDestroy { - constructor( - public industryService: IndustryService, - private projectRatingService: ProjectRatingService, - private readonly programDataService: ProgramDataService, - private readonly authService: AuthService, - private breakpointObserver: BreakpointObserver, - private cdRef: ChangeDetectorRef - ) {} - - @Input({ required: true }) set project(proj: ProjectRate | null) { - if (!proj) return; - this._project.set(proj); - } - - get project(): ProjectRate | null { - return this._project(); - } - - @ViewChild("descEl") descEl?: ElementRef; - - _project = signal(null); - _currentIndex = signal(0); - _projects = signal([]); - - profile = signal(null); - - form = new FormControl(); - - submitLoading = signal(false); - confirmLoading = signal(false); - - readFullDescription = signal(false); - - descriptionExpandable = signal(false); - - projectRated = signal(false); - - projectConfirmed = signal(false); - - showConfirmRateModal = signal(false); - - locallyRatedByCurrentUser = signal(false); - - isProjectCriterias = signal(0); - ratedCount = signal(0); - - programDateFinished = signal(false); - - desktopMode$: Observable = this.breakpointObserver - .observe("(min-width: 920px)") - .pipe(map(result => result.matches)); - - subscriptions$ = signal([]); - - ngOnInit(): void { - if (this.project) { - const isScored = this.project?.scored || false; - this.projectConfirmed.set(isScored); - this.projectRated.set(isScored); - this.ratedCount.set(this.project.ratedCount); - } - - const program$ = this.programDataService.program$ - .pipe( - filter(program => !!program), - tap(program => { - if (program && program.datetimeFinished) { - this.programDateFinished.set(Date.now() > Date.parse(program.datetimeFinished)); - } - }) - ) - .subscribe(); - - this.subscriptions$().push(program$); - - const profileId$ = this.authService.profile.subscribe({ - next: profile => { - this.profile.set(profile); - }, - }); - - this.subscriptions$().push(profileId$); - } - - ngAfterViewInit(): void { - if (this.project) { - this.isProjectCriterias.set( - this.project?.criterias.filter(criteria => !(criteria.type === "str")).length - ); - } - - const descElement = this.descEl?.nativeElement; - this.descriptionExpandable.set(descElement?.clientHeight < descElement?.scrollHeight); - this.cdRef.detectChanges(); - - const resizeSub$ = fromEvent(window, "resize") - .pipe(debounceTime(50)) - .subscribe(() => { - this.descriptionExpandable.set(descElement?.clientHeight < descElement?.scrollHeight); - }); - - this.subscriptions$().push(resizeSub$); - } - - ngOnDestroy(): void { - this.subscriptions$().forEach($ => $.unsubscribe()); - } - - expandDescription(elem: HTMLElement, expandedClass: string, isExpanded: boolean): void { - expandElement(elem, expandedClass, isExpanded); - this.readFullDescription.set(!isExpanded); - } - - /** - * Подтверждение оценки проекта - */ - confirmRateProject(): void { - this.form.markAsTouched(); - if (this.form.invalid) return; - - const fv = this.form.getRawValue(); - const project = this.project as ProjectRate; - const submittedVal = this.projectRatingService.formValuesToDTO(project.criterias, fv); - - this.submitLoading.set(true); - - this.projectRatingService - .rate(project.id, submittedVal) - .pipe(finalize(() => this.submitLoading.set(false))) - .subscribe({ - next: () => { - const profile = this.profile(); - const project = this.project as ProjectRate; - - this.locallyRatedByCurrentUser.set(true); - this.projectRated.set(true); - this.projectConfirmed.set(true); - - let isFirstTimeRating = false; - - if (profile) { - if (!Array.isArray(project.ratedExperts)) { - project.ratedExperts = []; - } - - // Проверяем, первый ли раз пользователь оценивает - if (!project.ratedExperts.includes(profile.id)) { - project.ratedExperts = [...project.ratedExperts, profile.id]; - isFirstTimeRating = true; - } - } - - // Увеличиваем счетчик только при первой оценке - if (isFirstTimeRating) { - this.ratedCount.update(count => count + 1); - } - - this._project.set({ ...project }); - this.showConfirmRateModal.set(false); - }, - error: err => { - if (err instanceof HttpResponse) { - if (err.status === 400) { - console.error("Ошибка: достигнут максимальный лимит оценок"); - } - } - }, - }); - } - - /** - * Переоценка проекта - * Сбрасываем статусы, но НЕ удаляем пользователя из списка оценивших - * После этого пользователь может заново оценить проект - */ - redoRating(): void { - this.projectRated.set(false); - this.projectConfirmed.set(false); - // locallyRatedByCurrentUser остается true, так как пользователь уже в списке оценивших - // После сброса статусов кнопка станет "оценить проект" и откроет модалку - } - - openPresentation(url: string) { - if (url) { - window.open(url, "_blank"); - } - } - - get canEdit(): boolean { - return !this.programDateFinished(); - } - - get isCurrentUserExpert(): boolean { - const currentProfile = this.profile(); - const project = this.project; - - if (!currentProfile || !project) return false; - - const isExpertFromBackend = - !!project.scoredExpertId && project.scoredExpertId === currentProfile.id; - - const isExpertLocally = this.locallyRatedByCurrentUser(); - - return isExpertFromBackend || isExpertLocally; - } - - /** - * Проверяет, может ли пользователь оценить проект - * Условия: - * 1. Программа не завершена - * 2. Либо лимит не достигнут, либо пользователь уже оценивал (может переоценить) - */ - get canRate(): boolean { - if (this.programDateFinished()) return false; - - // Если лимит достигнут, но пользователь уже оценивал - разрешаем переоценку - if (this.isLimitReached && !this.userRatedThisProject) return false; - - return true; - } - - /** - * Текст кнопки в зависимости от состояния - */ - get rateButtonText(): string { - if (this.programDateFinished()) return "программа завершена"; - if (this.projectConfirmed() && this.userRatedThisProject) return "проект оценен"; - if (this.isLimitReached && !this.userRatedThisProject) return "лимит оценок достигнут"; - - return "оценить проект"; - } - - /** - * Показывать ли форму оценки - */ - get showRatingForm(): boolean { - return !this.projectRated() && this.canEdit; - } - - /** - * Показывать ли статус "оценено" - */ - get showRatedStatus(): boolean { - return this.projectRated() || this.projectConfirmed(); - } - - /** - * Показывать ли кнопку редактирования - * Только если пользователь оценил проект, программа не завершена - */ - get showEditButton(): boolean { - return this.projectConfirmed() && !this.programDateFinished() && this.userRatedThisProject; - } - - /** - * Проверяет, можно ли открыть модальное окно оценки - * Модальное окно открывается только для: - * 1. Первой оценки (когда пользователь не оценивал и лимит не превышен) - * 2. Переоценки (когда пользователь нажал кнопку редактирования) - * - * НЕ открывается когда проект уже оценен и пользователь просто кликает на зеленую кнопку - */ - get canOpenModal(): boolean { - // Если проект подтвержден и оценен - НЕ открываем модалку по клику на кнопку - if (this.projectConfirmed() && this.userRatedThisProject) return false; - - // В остальных случаях проверяем canRate - return this.canRate; - } - - /** - * Проверяет, оценил ли текущий пользователь этот проект - */ - get userRatedThisProject(): boolean { - const profile = this.profile(); - const project = this.project; - - if (!profile || !project) return false; - - return ( - this.locallyRatedByCurrentUser() || - (Array.isArray(project.ratedExperts) && project.ratedExperts.includes(profile.id)) - ); - } - - /** - * Должна ли кнопка быть неактивной - */ - get isButtonDisabled(): boolean { - // Если лимит достигнут и пользователь не оценивал - блокируем - if (this.isLimitReached && !this.userRatedThisProject) return true; - - // Если программа завершена - блокируем - if (this.programDateFinished()) return true; - - // В остальных случаях проверяем canRate - return !this.canRate; - } - - /** - * Цвет кнопки - */ - get buttonColor(): "green" | "primary" { - if (this.userRatedThisProject) return "green"; - return "primary"; - } - - /** - * Прозрачность кнопки - */ - get buttonOpacity(): string { - return this.isButtonDisabled ? "0.5" : "1"; - } - - /** - * Проверяет, достигнут ли лимит оценок - */ - get isLimitReached(): boolean { - return !!this.project && this.project.ratedCount >= this.project.maxRates; - } - - /** - * Показывать ли состояние "подтверждено" - */ - get showConfirmedState(): boolean { - return ( - (this.projectConfirmed() && !this.canEdit) || - (this.isLimitReached && !this.userRatedThisProject) - ); - } - - /** - * Обработка клика по кнопке оценки - */ - handleRateButtonClick(): void { - // Открываем модальное окно только если можно оценить - if (this.canOpenModal) { - this.showConfirmRateModal.set(true); - } - } - - /** - * Дополнительная проверка для визуального состояния кнопки - */ - get buttonTooltip(): string { - if (this.programDateFinished()) return "Программа завершена"; - if (this.isLimitReached && !this.userRatedThisProject) { - return "Достигнут максимальный лимит оценок"; - } - if (this.userRatedThisProject) return "Нажмите для переоценки"; - return "Нажмите для оценки проекта"; - } - - /** - * Должна ли форма в модалке быть отключена - * Форма отключена для просмотра, пользователь подтверждает без изменений - */ - get isModalFormDisabled(): boolean { - return true; // Всегда disabled в модалке для подтверждения - } -} diff --git a/projects/social_platform/src/app/office/projects/dashboard/dashboard.component.html b/projects/social_platform/src/app/office/projects/dashboard/dashboard.component.html deleted file mode 100644 index c26f9e315..000000000 --- a/projects/social_platform/src/app/office/projects/dashboard/dashboard.component.html +++ /dev/null @@ -1,13 +0,0 @@ - - -
- @for (dashboardItem of dashboardItems; track $index) { - - } -
diff --git a/projects/social_platform/src/app/office/projects/dashboard/dashboard.component.ts b/projects/social_platform/src/app/office/projects/dashboard/dashboard.component.ts deleted file mode 100644 index 374208b6a..000000000 --- a/projects/social_platform/src/app/office/projects/dashboard/dashboard.component.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { Component, inject, OnDestroy, OnInit } from "@angular/core"; -import { ActivatedRoute, Router } from "@angular/router"; -import { Project } from "@office/models/project.model"; -import { Subscription } from "rxjs"; -import { DashboardItemComponent } from "./shared/dashboardItem/dashboardItem.component"; -import { DashboardItem, dashboardItemBuilder } from "@utils/helpers/dashboardItemBuilder"; - -@Component({ - selector: "app-dashboard", - templateUrl: "./dashboard.component.html", - styleUrl: "./dashboard.component.scss", - imports: [CommonModule, DashboardItemComponent], - standalone: true, -}) -export class DashboardProjectsComponent implements OnInit, OnDestroy { - private readonly route = inject(ActivatedRoute); - - dashboardItems: DashboardItem[] = []; - profileProjSubsIds?: number[]; - - subscriptions$: Subscription[] = []; - - ngOnInit(): void { - this.route.data.subscribe({ - next: ({ data: { all, my, subs } }) => { - const allProjects = all.results.slice(0, 4); - const myProjects = my.results.slice(0, 4); - const mySubs = subs.results.slice(0, 4); - this.profileProjSubsIds = subs.results.map((project: Project) => project.id); - - this.dashboardItems = dashboardItemBuilder( - 3, - ["my", "subscriptions", "all"], - ["мои проекты", "мои подписки", "витрина проектов"], - ["main", "favourities", "folders"], - [myProjects, mySubs, allProjects] - ); - }, - }); - } - - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $.unsubscribe()); - } -} diff --git a/projects/social_platform/src/app/office/projects/dashboard/shared/dashboardItem/dashboardItem.component.html b/projects/social_platform/src/app/office/projects/dashboard/shared/dashboardItem/dashboardItem.component.html deleted file mode 100644 index d1bdd3a74..000000000 --- a/projects/social_platform/src/app/office/projects/dashboard/shared/dashboardItem/dashboardItem.component.html +++ /dev/null @@ -1,41 +0,0 @@ - - -
-
-

{{ title }}

- -
- - @if (arrayItems.length) { -
    - @for (project of arrayItems; track project.id) { - - - - } -
- } @else { -
- -
- } - - - показать раздел - - -
diff --git a/projects/social_platform/src/app/office/projects/dashboard/shared/dashboardItem/dashboardItem.component.ts b/projects/social_platform/src/app/office/projects/dashboard/shared/dashboardItem/dashboardItem.component.ts deleted file mode 100644 index 074d15676..000000000 --- a/projects/social_platform/src/app/office/projects/dashboard/shared/dashboardItem/dashboardItem.component.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { Component, inject, Input, OnInit } from "@angular/core"; -import { Project } from "@office/models/project.model"; -import { IconComponent } from "@uilib"; -import { ProjectsService } from "@office/projects/services/projects.service"; -import { RouterLink } from "@angular/router"; -import { InfoCardComponent } from "@office/features/info-card/info-card.component"; - -@Component({ - selector: "app-dashboard-item", - templateUrl: "./dashboardItem.component.html", - styleUrl: "./dashboardItem.component.scss", - standalone: true, - imports: [CommonModule, IconComponent, RouterLink, InfoCardComponent], -}) -export class DashboardItemComponent implements OnInit { - @Input() title!: string; - @Input() arrayItems!: Project[]; - @Input() iconName!: string; - @Input() sectionName!: string; - @Input() profileProjSubsIds?: number[]; - - appereance: "base" | "subs" | "my" = "base"; - - private readonly projectsService = inject(ProjectsService); - - ngOnInit(): void { - switch (this.iconName) { - case "favourities": - this.appereance = "subs"; - - break; - - case "main": - this.appereance = "my"; - break; - - default: - break; - } - } - - addProject(): void { - this.projectsService.addProject(); - } -} diff --git a/projects/social_platform/src/app/office/projects/detail/chat/chat.component.html b/projects/social_platform/src/app/office/projects/detail/chat/chat.component.html deleted file mode 100644 index c73d6fe13..000000000 --- a/projects/social_platform/src/app/office/projects/detail/chat/chat.component.html +++ /dev/null @@ -1,69 +0,0 @@ - -@if (project) { -
-
- @if (!messages.length) { -
- -

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

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

метаданные

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

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

    - } -
  • - -
  • - -

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

    -
  • - -
  • - -

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

    -
  • - -
  • - -

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

    -
  • - -
  • - -

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

    -
  • -
-
-
- -
-
-
-

о проекте

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

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

{{ direction }}

-
- - -
-
-

{{ direction }}

- -
- - @if (type === 'string') { -

{{ about }}

- } @else { @if (listType === 'profile') { @if (profileInfoType === "skills") { -
-
    - @for (aboutItem of about; track $index) { -
  • -

    {{ aboutItem?.name }}

    -
    - {{ aboutItem?.category?.name?.includes("Soft skills") ? "soft" : "hard" }} - {{ aboutItem?.category?.name }} -
    -
  • - } -
-
- } @else { -
    - @if (!isOpenInfo) { @for (aboutInfo of about; track $index) { -
  • -

    {{ aboutInfo.year }}

    - -
  • - } } @else { -
    -
    - -

    {{ currentYear }}

    -
    - - @if (achievementsInfo().length > 0) { @for (achievementInfo of achievementsInfo(); track - $index) { -
    -

    {{ achievementInfo.title }}

    -

    {{ achievementInfo.status }}

    - - @if (files.length > 0) { - - } -
    - } } -
    - } -
- } } @else { @if (projectInfoType === 'goals') { -
- @if (!isShowsConfirmGoal) { @if (about.length > 0) { @for (goalItem of about; track $index) { -
  • - -

    {{ goalItem.title }}

    -
    - @if (!goalItem.isDone) { -
    - @if (goalCompleteHoverId === goalItem.id) { - - } @else { -

    - {{ goalItem.completionDate | dayjs: "format":"DD.MM.YY" }} -

    - } -
    - } @else { - - } -
    -
  • - } } } @else { @if (selectedGoal) { -
    -

    подтвердить выполнение цели

    - -
    - - - подтверждаю - - } } -
    - } @else { -
    - @if (this.about.length > 0) { @for (partnerItem of about; track $index) { -
  • -
    -

    - {{ partnerItem.company.name | truncate: 40 }} -

    -

    {{ partnerItem.company.inn }}

    -
    - -

    - {{ partnerItem.contribution | truncate: 400 }} -

    -
  • - } } -
    - } } } -
    -
    diff --git a/projects/social_platform/src/app/office/projects/detail/shared/project-direction-card/project-direction-card.component.ts b/projects/social_platform/src/app/office/projects/detail/shared/project-direction-card/project-direction-card.component.ts deleted file mode 100644 index 5bf550fa2..000000000 --- a/projects/social_platform/src/app/office/projects/detail/shared/project-direction-card/project-direction-card.component.ts +++ /dev/null @@ -1,207 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { Component, inject, Input, OnDestroy, OnInit, signal } from "@angular/core"; -import { ModalComponent } from "@ui/components/modal/modal.component"; -import { IconComponent } from "@uilib"; -import { TagComponent } from "@ui/components/tag/tag.component"; -import { ActivatedRoute, Router } from "@angular/router"; -import { ProfileService } from "@auth/services/profile.service"; -import { map, Subscription, switchMap } from "rxjs"; -import { Achievement } from "@auth/models/user.model"; -import { FileItemComponent } from "@ui/components/file-item/file-item.component"; -import { FileModel } from "@office/models/file.model"; -import { AvatarComponent } from "@ui/components/avatar/avatar.component"; -import { DayjsPipe } from "@corelib"; -import { ButtonComponent } from "@ui/components"; -import { ProjectService } from "@office/services/project.service"; -import { Goal } from "@office/models/goals.model"; -import { EditorSubmitButtonDirective } from "@ui/directives/editor-submit-button.directive"; -import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; -@Component({ - selector: "app-project-direction-card", - templateUrl: "./project-direction-card.component.html", - styleUrl: "./project-direction-card.component.scss", - imports: [ - CommonModule, - IconComponent, - ModalComponent, - TagComponent, - FileItemComponent, - AvatarComponent, - DayjsPipe, - TruncatePipe, - ButtonComponent, - EditorSubmitButtonDirective, - ], - standalone: true, -}) -export class ProjectDirectionCard implements OnInit, OnDestroy { - @Input() direction!: string; - @Input() icon!: string; - @Input() about!: string | any[]; - @Input() type!: string; - @Input() isOwner!: boolean; - - @Input() profileInfoType?: "skills" | "achievements"; - @Input() projectInfoType?: "goals" | "partners"; - - private readonly route = inject(ActivatedRoute); - private readonly router = inject(Router); - private readonly achievementsService = inject(ProfileService); - private readonly projectService = inject(ProjectService); - - private subscriptions: Subscription[] = []; - - isShowModal = false; - isShowsConfirmGoal = false; - - isOpenInfo = false; - - goalCompleteHoverId: null | number = null; - selectedGoal: Goal | null = null; - - listType?: "profile" | "project"; - - // Поля для работы с достижениями - achievements: Pick[] = []; - files: FileModel[] = []; - achievementsInfo = signal([]); - currentYear = 0; - - mouseHover(goalId: number): void { - if (this.isOwner) { - if (goalId) { - this.goalCompleteHoverId = goalId; - } - } - } - - mouseLeave(): void { - if (this.isOwner) { - this.goalCompleteHoverId = null; - } - } - - ngOnInit(): void { - const listTypeSub$ = this.route.data.subscribe(data => { - this.listType = data["listType"]; - }); - - if (this.profileInfoType === "achievements") { - if (Array.isArray(this.about)) { - this.about = Array.from( - new Map(this.about.map(a => [a.year, { id: a.id, year: a.year }])).values() - ); - } - this.getAchievementsByYear(); - } - - this.subscriptions.push(listTypeSub$); - } - - ngOnDestroy(): void { - this.subscriptions.forEach($ => $.unsubscribe()); - } - - openConfirmModal(goal: Goal): void { - this.selectedGoal = goal; - this.isShowsConfirmGoal = true; - this.router.navigate([], { - queryParams: { goalId: goal.id }, - relativeTo: this.route, - queryParamsHandling: "merge", - }); - } - - confirmCompleteGoal(): void { - const projectId = this.route.snapshot.params["projectId"]; - const goalId = +this.route.snapshot.queryParams["goalId"]; - - if (!goalId || !Array.isArray(this.about)) return; - - const goal = this.about.find((g: Goal) => g.id === goalId); - - if (!goal) return; - - const completedGoal = { - ...goal, - isDone: true, - }; - - this.projectService.editGoal(projectId, goal.id, completedGoal).subscribe({ - next: response => { - if (Array.isArray(this.about)) { - const index = this.about.findIndex((g: Goal) => g.id === goalId); - if (index !== -1) { - this.about[index] = response; - } - } - - this.isShowsConfirmGoal = false; - this.goalCompleteHoverId = null; - this.selectedGoal = null; - - this.router.navigate([], { - queryParams: {}, - replaceUrl: true, - }); - }, - error: error => { - console.error("Ошибка при обновлении цели:", error); - }, - }); - } - - openInfo(achievementYear: string): void { - this.router.navigate([], { - queryParams: { - year: achievementYear, - }, - relativeTo: this.route, - queryParamsHandling: "merge", - }); - } - - backToYears(): void { - this.router.navigate([], { - queryParams: {}, - replaceUrl: true, - }); - - this.isOpenInfo = false; - } - - private getAchievementsByYear(): void { - const infoParamSub$ = this.route.queryParams - .pipe( - map(p => p["year"]), - switchMap(year => { - if (year) { - this.isOpenInfo = true; - this.currentYear = year; - return this.achievementsService - .getAchievements() - .pipe( - map((achievements: Achievement[]) => - achievements.filter((achievement: Achievement) => +achievement.year === +year) - ) - ); - } else { - this.isOpenInfo = false; - this.achievementsInfo.set([]); - return []; - } - }) - ) - .subscribe({ - next: achievements => { - this.achievementsInfo.set(achievements); - - this.files = achievements.flatMap(a => (a.files ?? []) as FileModel[]); - }, - }); - - this.subscriptions.push(infoParamSub$); - } -} diff --git a/projects/social_platform/src/app/office/projects/detail/shared/project-member-card/project-member-card.component.html b/projects/social_platform/src/app/office/projects/detail/shared/project-member-card/project-member-card.component.html deleted file mode 100644 index 391dcb1ca..000000000 --- a/projects/social_platform/src/app/office/projects/detail/shared/project-member-card/project-member-card.component.html +++ /dev/null @@ -1,36 +0,0 @@ - - -
  • - - -
    -

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

    -

    {{ member.role }}

    -
    -
    -
    - @if (isLeader) { - - } @else if (manageRights) { - - } -
    - - - -
  • diff --git a/projects/social_platform/src/app/office/projects/detail/shared/project-member-card/project-member-card.component.scss b/projects/social_platform/src/app/office/projects/detail/shared/project-member-card/project-member-card.component.scss deleted file mode 100644 index 0a9aab80c..000000000 --- a/projects/social_platform/src/app/office/projects/detail/shared/project-member-card/project-member-card.component.scss +++ /dev/null @@ -1,59 +0,0 @@ -.member { - position: relative; - display: flex; - align-items: center; - - &__inner { - display: flex; - align-items: center; - } - - &__avatar { - margin-right: 10px; - } - - &__name { - color: var(--black); - } - - &__speciality { - color: var(--dark-grey); - } - - &__right { - display: flex; - align-items: center; - margin-left: auto; - } - - &__star { - color: var(--accent); - } - - &__dots { - color: var(--black); - cursor: pointer; - } -} - -.menu { - padding: 20px 14px; - background-color: var(--white); - border: 1px solid var(--grey-button); - border-radius: var(--rounded-xl); - transform: translateX(-50%); - - &__item { - color: var(--dark-grey); - cursor: pointer; - transition: color 0.2s; - - &:hover { - color: var(--black); - } - - &:not(:last-child) { - margin-bottom: 8px; - } - } -} diff --git a/projects/social_platform/src/app/office/projects/detail/shared/project-member-card/project-member-card.component.spec.ts b/projects/social_platform/src/app/office/projects/detail/shared/project-member-card/project-member-card.component.spec.ts deleted file mode 100644 index d6322c05d..000000000 --- a/projects/social_platform/src/app/office/projects/detail/shared/project-member-card/project-member-card.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { ProjectMemberCardComponent } from "./project-member-card.component"; - -describe("ProjectMemberCardComponent", () => { - let component: ProjectMemberCardComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ProjectMemberCardComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(ProjectMemberCardComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/projects/detail/shared/project-member-card/project-member-card.component.ts b/projects/social_platform/src/app/office/projects/detail/shared/project-member-card/project-member-card.component.ts deleted file mode 100644 index 72ddffdd6..000000000 --- a/projects/social_platform/src/app/office/projects/detail/shared/project-member-card/project-member-card.component.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** @format */ - -import { CdkMenu, CdkMenuItem, CdkMenuTrigger } from "@angular/cdk/menu"; -import { CommonModule } from "@angular/common"; -import { Component, EventEmitter, Input, Output } from "@angular/core"; -import { RouterLink } from "@angular/router"; -import { Collaborator } from "@office/models/collaborator.model"; -import { AvatarComponent, IconComponent } from "@uilib"; - -/** - * КОМПОНЕНТ КАРТОЧКИ УЧАСТНИКА ПРОЕКТА - * - * Этот компонент отображает информацию об участнике проекта в виде карточки - * с возможностью управления (для лидера проекта). - * - * НАЗНАЧЕНИЕ: - * - Отображение информации об участнике (аватар, имя, роль) - * - Предоставление функций управления командой для лидера - * - Индикация статуса лидера проекта - * - * @params - * - member: Collaborator - объект с данными участника (обязательный) - * - isLeader: boolean - флаг, является ли участник лидером (по умолчанию false) - * - manageRights: boolean - флаг наличия прав управления у текущего пользователя (по умолчанию false) - * - * @returns - * - remove: EventEmitter - событие удаления участника из команды - * - transferOwnership: EventEmitter - событие передачи лидерства участнику - * - * ФУНКЦИОНАЛЬНОСТЬ: - * - Отображение аватара, имени и роли участника - * - Ссылка на профиль участника - * - Контекстное меню с действиями (для пользователей с правами управления) - * - Индикация лидера проекта звездочкой - * - * @returns - * - HTML-разметка карточки участника - * - События для управления командой - */ -@Component({ - selector: "app-project-member-card", - standalone: true, - imports: [ - CommonModule, - RouterLink, - IconComponent, - AvatarComponent, - CdkMenuTrigger, // Директива для триггера контекстного меню - CdkMenuItem, // Директива для элементов меню - CdkMenu, // Директива для контейнера меню - ], - templateUrl: "./project-member-card.component.html", - styleUrl: "./project-member-card.component.scss", -}) -export class ProjectMemberCardComponent { - @Input({ required: true }) member!: Collaborator; // Данные участника (обязательное поле) - @Input() isLeader = false; // Флаг лидера проекта - @Input() manageRights = false; // Флаг прав управления - - @Output() remove = new EventEmitter(); // Событие удаления участника - @Output() transferOwnership = new EventEmitter(); // Событие передачи лидерства -} diff --git a/projects/social_platform/src/app/office/projects/detail/shared/project-vacancy-card/project-vacancy-card.component.html b/projects/social_platform/src/app/office/projects/detail/shared/project-vacancy-card/project-vacancy-card.component.html deleted file mode 100644 index df67f5914..000000000 --- a/projects/social_platform/src/app/office/projects/detail/shared/project-vacancy-card/project-vacancy-card.component.html +++ /dev/null @@ -1,82 +0,0 @@ - - -
    -
    - @if (type === 'vacancies') { -
    - -
    -

    - {{ vacancy.project.name | truncate: 12 }} -

    -

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

    -
    -
    - } @else { -

    - {{ vacancy.role | truncate: 12 }} -

    - } -
    - @if (type === 'vacancies') { -

    - {{ vacancy.role | truncate: 12 }} -

    - } -
    - -

    - {{ +vacancy.salary === 0 ? "по договоренности" : vacancy.salary }} -

    -
    -
    -
    - -
    - @for (skill of vacancy.requiredSkills.slice(0, endSliceOfSkills); track $index) { - {{ skill.name }} - } @if (vacancy.requiredSkills.length > 5) { -

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

    - } -
    - -
    - @if (vacancy.description) { -
    -

    - @if (descriptionExpandable) { -
    - {{ readFullDescription ? "cкрыть" : "подробнее" }} -
    - } -
    - } -
    - -
    - подробнее - откликнуться -
    -
    diff --git a/projects/social_platform/src/app/office/projects/detail/shared/project-vacancy-card/project-vacancy-card.component.spec.ts b/projects/social_platform/src/app/office/projects/detail/shared/project-vacancy-card/project-vacancy-card.component.spec.ts deleted file mode 100644 index b16c9699f..000000000 --- a/projects/social_platform/src/app/office/projects/detail/shared/project-vacancy-card/project-vacancy-card.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { ProjectVacancyCardComponent } from "./project-vacancy-card.component"; - -describe("ProjectVacancyCardComponent", () => { - let component: ProjectVacancyCardComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ProjectVacancyCardComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(ProjectVacancyCardComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/projects/detail/shared/project-vacancy-card/project-vacancy-card.component.ts b/projects/social_platform/src/app/office/projects/detail/shared/project-vacancy-card/project-vacancy-card.component.ts deleted file mode 100644 index 34c5bc658..000000000 --- a/projects/social_platform/src/app/office/projects/detail/shared/project-vacancy-card/project-vacancy-card.component.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** @format */ -import { CommonModule } from "@angular/common"; -import { - AfterViewInit, - ChangeDetectorRef, - Component, - ElementRef, - inject, - Input, - OnInit, - ViewChild, -} from "@angular/core"; -import { RouterLink } from "@angular/router"; -import { Vacancy } from "@office/models/vacancy.model"; -import { IconComponent } from "@uilib"; -import { ButtonComponent } from "@ui/components"; -import { DayjsPipe, ParseBreaksPipe, ParseLinksPipe } from "@corelib"; -import { expandElement } from "@utils/expand-element"; -import { TagComponent } from "@ui/components/tag/tag.component"; -import { AvatarComponent } from "@ui/components/avatar/avatar.component"; -import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; - -/** - * КОМПОНЕНТ КАРТОЧКИ ВАКАНСИИ ПРОЕКТА - * - * Этот компонент отображает информацию об вакансии проекта в виде карточки - * - * НАЗНАЧЕНИЕ: - * - Отображение информации об вакансии - * - * @params - * - vacancy: Vacancy - объект с данными вакансии (обязательный) - * - * ФУНКЦИОНАЛЬНОСТЬ: - * - Отображение информации вакансии - * - Ссылка на вакансию - * - * @returns - * - HTML-разметка карточки вакансии - */ -@Component({ - selector: "app-project-vacancy-card", - standalone: true, - imports: [ - CommonModule, - RouterLink, - IconComponent, - ButtonComponent, - ParseLinksPipe, - ParseBreaksPipe, - TruncatePipe, - TagComponent, - AvatarComponent, - DayjsPipe, - ], - templateUrl: "./project-vacancy-card.component.html", - styleUrl: "./project-vacancy-card.component.scss", -}) -export class ProjectVacancyCardComponent implements OnInit, AfterViewInit { - @Input({ required: true }) vacancy!: Vacancy; // Данные вакансии (обязательное поле) - @Input() type: "vacancies" | "project" = "project"; - - @ViewChild("descEl") descEl?: ElementRef; - - private readonly cdRef = inject(ChangeDetectorRef); - - ngOnInit(): void { - if (this.type === "project") { - this.endSliceOfSkills = 5; - } else { - this.endSliceOfSkills = 3; - } - } - - descriptionExpandable!: boolean; // Флаг необходимости кнопки "Читать полностью" - readFullDescription = false; // Флаг показа всех вакансий - endSliceOfSkills = 0; - - ngAfterViewInit(): void { - const descElement = this.descEl?.nativeElement; - this.descriptionExpandable = descElement?.clientHeight < descElement?.scrollHeight; - - this.cdRef.detectChanges(); - } - - /** - * Раскрытие/сворачивание описания профиля - * @param elem - DOM элемент описания - * @param expandedClass - CSS класс для раскрытого состояния - * @param isExpanded - текущее состояние (раскрыто/свернуто) - */ - onExpandDescription(elem: HTMLElement, expandedClass: string, isExpanded: boolean): void { - expandElement(elem, expandedClass, isExpanded); - this.readFullDescription = !isExpanded; - } -} diff --git a/projects/social_platform/src/app/office/projects/detail/team/team.component.html b/projects/social_platform/src/app/office/projects/detail/team/team.component.html deleted file mode 100644 index 63e2fc80e..000000000 --- a/projects/social_platform/src/app/office/projects/detail/team/team.component.html +++ /dev/null @@ -1,15 +0,0 @@ - - -@if(team) { @if (team.length) { -
    - @for (collaborator of team; track $index) { - - } -
    -} } diff --git a/projects/social_platform/src/app/office/projects/detail/team/team.component.ts b/projects/social_platform/src/app/office/projects/detail/team/team.component.ts deleted file mode 100644 index 896071959..000000000 --- a/projects/social_platform/src/app/office/projects/detail/team/team.component.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { Component, inject, OnDestroy, OnInit, signal } from "@angular/core"; -import { IconComponent } from "@uilib"; -import { ProjectDataService } from "../services/project-data.service"; -import { InfoCardComponent } from "@office/features/info-card/info-card.component"; -import { Subscription } from "rxjs"; -import { Project } from "@office/models/project.model"; -import { ProjectService } from "@office/services/project.service"; -import { AuthService } from "@auth/services"; - -/** - * Компонент страницы команды в деательной информации о проекте - */ -@Component({ - selector: "app-project-eam", - templateUrl: "./team.component.html", - styleUrl: "./team.component.scss", - imports: [CommonModule, IconComponent, InfoCardComponent], - standalone: true, -}) -export class ProjectTeamComponent implements OnInit, OnDestroy { - private readonly projectDataService = inject(ProjectDataService); - private readonly projectService = inject(ProjectService); - private readonly authService = inject(AuthService); - - // массив пользователей в команде - team?: Project["collaborators"]; - projectId = signal(0); - loggedUserId = signal(0); - leaderId = signal(0); - - // массив подписок - subscriptions: Subscription[] = []; - - ngOnInit(): void { - // получение данных из сервиса как потока данных и подписка на них - const teamSub$ = this.projectDataService.getTeam().subscribe({ - next: team => { - this.team = team; - }, - }); - - teamSub$ && this.subscriptions.push(teamSub$); - - const projectId$ = this.projectDataService.getProjectId().subscribe({ - next: projectId => { - if (projectId) { - this.projectId.set(projectId); - } - }, - }); - - if (location.href.includes("/team")) { - const leaderId$ = this.projectDataService.getProjectLeaderId().subscribe({ - next: leaderId => { - if (leaderId) { - this.leaderId.set(leaderId); - } - }, - }); - - const currentProfileId$ = this.authService.profile.subscribe({ - next: profile => { - if (profile) { - this.loggedUserId.set(profile.id); - } - }, - }); - - this.subscriptions.push(leaderId$, currentProfileId$); - } - - this.subscriptions.push(projectId$); - } - - ngOnDestroy(): void { - this.subscriptions.forEach($ => $.unsubscribe()); - } - - removeCollaboratorFromProject(userId: number): void { - const index = this.team?.findIndex(p => p.userId === userId); - if (index !== -1) { - this.team?.splice(index!, 1); - } - - this.projectService.removeColloborator(this.projectId(), userId).subscribe(); - } -} diff --git a/projects/social_platform/src/app/office/projects/detail/vacancies/vacancies.component.html b/projects/social_platform/src/app/office/projects/detail/vacancies/vacancies.component.html deleted file mode 100644 index dcdecd2f6..000000000 --- a/projects/social_platform/src/app/office/projects/detail/vacancies/vacancies.component.html +++ /dev/null @@ -1,12 +0,0 @@ - -@if (vacancies) { @if (vacancies.length) { -
    -
      - @for (vacancy of vacancies; track vacancy.id) { -
    • - -
    • - } -
    -
    -} } diff --git a/projects/social_platform/src/app/office/projects/detail/vacancies/vacancies.component.ts b/projects/social_platform/src/app/office/projects/detail/vacancies/vacancies.component.ts deleted file mode 100644 index 548c0f678..000000000 --- a/projects/social_platform/src/app/office/projects/detail/vacancies/vacancies.component.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { Component, inject, OnDestroy, OnInit } from "@angular/core"; -import { IconComponent } from "@uilib"; -import { ProjectDataService } from "../services/project-data.service"; -import { Project } from "@office/models/project.model"; -import { Subscription } from "rxjs"; -import { ProjectVacancyCardComponent } from "../shared/project-vacancy-card/project-vacancy-card.component"; - -/** - * Компонент страницы вакансий в деательной информации о проекте - */ -@Component({ - selector: "app-vacancies", - templateUrl: "./vacancies.component.html", - styleUrl: "./vacancies.component.scss", - imports: [CommonModule, IconComponent, ProjectVacancyCardComponent], - standalone: true, -}) -export class ProjectVacanciesComponent implements OnInit, OnDestroy { - // сервис для работы с данными детальной информации проекта - private readonly projectDataService = inject(ProjectDataService); - - // массив пользователей в команде - vacancies?: Project["vacancies"]; - - // массив подписок - subscriptions: Subscription[] = []; - - ngOnInit(): void { - const vacanciesSub$ = this.projectDataService.getVacancies().subscribe({ - next: vacancies => { - this.vacancies = vacancies; - }, - }); - - vacanciesSub$ && this.subscriptions.push(vacanciesSub$); - } - - ngOnDestroy(): void { - this.subscriptions.forEach($ => $.unsubscribe()); - } -} diff --git a/projects/social_platform/src/app/office/projects/detail/work-section/responses.resolver.ts b/projects/social_platform/src/app/office/projects/detail/work-section/responses.resolver.ts deleted file mode 100644 index 2cfa12afe..000000000 --- a/projects/social_platform/src/app/office/projects/detail/work-section/responses.resolver.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { ActivatedRouteSnapshot, ResolveFn } from "@angular/router"; -import { VacancyResponse } from "@models/vacancy-response.model"; -import { VacancyService } from "@services/vacancy.service"; - -/** - * Резолвер для загрузки откликов на вакансии проекта - * - * Принимает: - * - ActivatedRouteSnapshot с родительским параметром projectId - * - * Возвращает: - * - Observable - список откликов на вакансии проекта - * - * Использует: - * - VacancyService для получения откликов по ID проекта - */ -export const ProjectResponsesResolver: ResolveFn = ( - route: ActivatedRouteSnapshot -) => { - const vacancyService = inject(VacancyService); - - return vacancyService.responsesByProject(Number(route.parent?.paramMap.get("projectId"))); -}; diff --git a/projects/social_platform/src/app/office/projects/detail/work-section/work-section.component.ts b/projects/social_platform/src/app/office/projects/detail/work-section/work-section.component.ts deleted file mode 100644 index 8197bd5d7..000000000 --- a/projects/social_platform/src/app/office/projects/detail/work-section/work-section.component.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { Component, inject, OnDestroy, OnInit } from "@angular/core"; -import { ButtonComponent } from "@ui/components"; -import { IconComponent } from "@uilib"; -import { map, Subscription } from "rxjs"; -import { ActivatedRoute } from "@angular/router"; -import { VacancyResponse } from "@office/models/vacancy-response.model"; -import { VacancyService } from "@office/services/vacancy.service"; - -@Component({ - selector: "app-work-section", - templateUrl: "./work-section.component.html", - styleUrl: "./work-section.component.scss", - imports: [CommonModule, IconComponent, ButtonComponent], - standalone: true, -}) -export class ProjectWorkSectionComponent implements OnInit, OnDestroy { - private readonly route = inject(ActivatedRoute); - private readonly vacancyService = inject(VacancyService); - private subscriptions: Subscription[] = []; - - vacancies: VacancyResponse[] = []; - - ngOnInit(): void { - const vacanciesSub$ = this.route.data.pipe(map(r => r["data"])).subscribe({ - next: (responses: VacancyResponse[]) => { - this.vacancies = responses.filter( - (response: VacancyResponse) => response.isApproved === null - ); - }, - }); - - this.subscriptions.push(vacanciesSub$); - } - - ngOnDestroy(): void { - this.subscriptions.forEach($ => $.unsubscribe()); - } - - /** - * Принятие отклика на вакансию - * @param responseId - ID отклика для принятия - */ - acceptResponse(responseId: number) { - this.vacancyService.acceptResponse(responseId).subscribe(() => { - const index = this.vacancies.findIndex(el => el.id === responseId); - this.vacancies.splice(index, 1); - }); - } - - /** - * Отклонение отклика на вакансию - * @param responseId - ID отклика для отклонения - */ - rejectResponse(responseId: number) { - this.vacancyService.rejectResponse(responseId).subscribe(() => { - const index = this.vacancies.findIndex(el => el.id === responseId); - this.vacancies.splice(index, 1); - }); - } -} diff --git a/projects/social_platform/src/app/office/projects/edit/edit.component.html b/projects/social_platform/src/app/office/projects/edit/edit.component.html deleted file mode 100644 index 2592260e4..000000000 --- a/projects/social_platform/src/app/office/projects/edit/edit.component.html +++ /dev/null @@ -1,244 +0,0 @@ - -
    -
    -
    - -

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

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

    📢 внимание!

    -

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

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

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

    -
    - - end - -

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

    -
    -
    - - -
    -
    - -

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

    -
    - -

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

    -
    -
    - - -
    -
    -

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

    -
    - -
    -

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

    - -

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

    - -

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

    - -

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

    - -

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

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

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

    -
    - -

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

    - -
    - Отмена - Отправить -
    -
    -
    diff --git a/projects/social_platform/src/app/office/projects/edit/edit.component.scss b/projects/social_platform/src/app/office/projects/edit/edit.component.scss deleted file mode 100644 index 87fbbf3c1..000000000 --- a/projects/social_platform/src/app/office/projects/edit/edit.component.scss +++ /dev/null @@ -1,249 +0,0 @@ -/** @format */ - -@use "styles/responsive"; -@use "styles/typography"; - -.project { - position: relative; - 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-evenly; - width: 100%; - padding: 4px 0; - margin-top: 20px; - background-color: var(--light-white); - border-radius: var(--rounded-xxl); - } - - &__title { - display: none; - - @include responsive.apply-desktop { - display: block; - } - - @include typography.heading-1; - } - - &__back { - display: flex; - gap: 10px; - align-items: center; - cursor: pointer; - } - - &__form { - display: flex; - flex-direction: column; - color: var(--black); - } - - &__inner { - width: 100%; - margin-bottom: 25px; - - @include responsive.apply-desktop { - display: flex; - gap: 90px; - justify-content: space-between; - margin-bottom: 0; - margin-bottom: 20px; - } - } - - &__inner > fieldset:not(:last-child) { - margin-bottom: 20px; - } - - &__left { - flex-basis: 50%; - margin-bottom: 20px; - - form { - width: 280px; - - @include responsive.apply-desktop { - width: 600px; - } - } - } - - &__right { - flex-basis: 50%; - - :first-child & :not(span, fieldset, label, h4, p, i) { - margin-top: 26px; - margin-bottom: 10px; - } - - :last-child & :not(i, span) { - margin-top: 10px; - } - } - - &__image { - display: block; - margin-bottom: 20px; - - .error { - margin-top: 15px; - } - } - - &__info { - display: flex; - justify-content: space-between; - } - - &__or { - margin: 20px 0; - color: var(--dark-grey); - } - - &__save { - @include responsive.apply-desktop { - display: flex; - gap: 10px; - align-items: center; - } - } - - &__warning-modal { - z-index: 1000; - display: flex; - flex-direction: column; - gap: 20px; - align-items: center; - - span { - color: var(--red); - } - } -} - -.skill { - i { - color: var(--red); - cursor: pointer; - } -} - -.project-bar { - padding: 9px 0; - margin-bottom: 12px; -} - -.modal { - &__wrapper { - display: flex; - flex-direction: column; - gap: 20px; - align-items: center; - width: 672px; - } - - &__content { - display: flex; - flex-direction: column; - gap: 20px; - width: 100%; - max-width: 536px; - height: 480px; - background-color: var(--white); - border: 1px solid var(--medium-grey-for-outline); - border-radius: 8px; - box-shadow: 5px 5px 25px 0 var(--gray-for-shadow); - } - - &__specs-groups, - &__skills-groups { - height: 100%; - overflow: auto; - scrollbar-width: thin; - - ul { - display: flex; - flex-direction: column; - gap: 20px; - padding: 14px; - } - - li { - display: flex; - align-items: center; - justify-content: space-between; - cursor: pointer; - } - } -} - -.cancel { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - width: 80%; - max-height: calc(100vh - 40px); - padding: 40px 0 80px; - overflow-y: auto; - - @include responsive.apply-desktop { - width: 50%; - } - - &__cross { - position: absolute; - top: 0; - right: 0; - width: 32px; - height: 32px; - cursor: pointer; - - @include responsive.apply-desktop { - top: 8px; - right: 8px; - } - } - - &__top { - display: flex; - flex-direction: column; - margin-bottom: 10px; - } - - &__title { - text-align: center; - } - - &__text { - text-align: center; - - &-block { - display: flex; - flex-direction: column; - gap: 12px; - margin: 30px 0; - } - } - - &__buttons { - display: flex; - gap: 10px; - align-items: center; - margin-top: 20px; - } - - &__button { - margin-top: 20px; - } -} diff --git a/projects/social_platform/src/app/office/projects/edit/edit.component.spec.ts b/projects/social_platform/src/app/office/projects/edit/edit.component.spec.ts deleted file mode 100644 index d96b4dc21..000000000 --- a/projects/social_platform/src/app/office/projects/edit/edit.component.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { ProjectEditComponent } from "./edit.component"; -import { RouterTestingModule } from "@angular/router/testing"; -import { ReactiveFormsModule } from "@angular/forms"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; -import { NgxMaskModule } from "ngx-mask"; -import { AuthService } from "@auth/services"; - -describe("ProjectEditComponent", () => { - let component: ProjectEditComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - const authSpy = {}; - - await TestBed.configureTestingModule({ - imports: [ - RouterTestingModule, - ReactiveFormsModule, - HttpClientTestingModule, - NgxMaskModule.forRoot(), - ProjectEditComponent, - ], - providers: [{ provide: AuthService, useValue: authSpy }], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(ProjectEditComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/projects/edit/edit.component.ts b/projects/social_platform/src/app/office/projects/edit/edit.component.ts deleted file mode 100644 index 7806eeaf4..000000000 --- a/projects/social_platform/src/app/office/projects/edit/edit.component.ts +++ /dev/null @@ -1,752 +0,0 @@ -/** @format */ - -import { - AfterViewInit, - ChangeDetectorRef, - Component, - OnDestroy, - OnInit, - signal, -} from "@angular/core"; -import { FormArray, FormGroup, ReactiveFormsModule } from "@angular/forms"; -import { ActivatedRoute, Router, RouterModule } from "@angular/router"; -import { ErrorMessage } from "@error/models/error-message"; -import { Invite } from "@models/invite.model"; -import { Project } from "@models/project.model"; -import { Skill } from "@office/models/skill.model"; -import { ProgramService } from "@office/program/services/program.service"; -import { SkillsService } from "@office/services/skills.service"; -import { SkillsGroupComponent } from "@office/shared/skills-group/skills-group.component"; -import { IndustryService } from "@services/industry.service"; -import { NavService } from "@services/nav.service"; -import { ProjectService } from "@services/project.service"; -import { ButtonComponent, IconComponent, SelectComponent } from "@ui/components"; -import { ModalComponent } from "@ui/components/modal/modal.component"; -import { ValidationService } from "projects/core"; -import { - Observable, - Subscription, - distinctUntilChanged, - forkJoin, - map, - of, - switchMap, - tap, -} from "rxjs"; -import { CommonModule, AsyncPipe } from "@angular/common"; -import { ProjectNavigationComponent } from "./shared/project-navigation/project-navigation.component"; -import { EditStep, ProjectStepService } from "./services/project-step.service"; -import { ProjectMainStepComponent } from "./shared/project-main-step/project-main-step.component"; -import { ProjectFormService } from "./services/project-form.service"; -import { ProjectPartnerResourcesStepComponent } from "./shared/project-partner-resources-step/project-partner-resources-step.component"; -import { ProjectAchievementStepComponent } from "./shared/project-achievement-step/project-achievement-step.component"; -import { ProjectVacancyStepComponent } from "./shared/project-vacancy-step/project-vacancy-step.component"; -import { ProjectVacancyService } from "./services/project-vacancy.service"; -import { ProjectTeamStepComponent } from "./shared/project-team-step/project-team-step.component"; -import { ProjectTeamService } from "./services/project-team.service"; -import { ProjectAdditionalStepComponent } from "./shared/project-additional-step/project-additional-step.component"; -import { ProjectAdditionalService } from "./services/project-additional.service"; -import { ProjectAchievementsService } from "./services/project-achievements.service"; -import { Goal } from "@office/models/goals.model"; -import { ProjectGoalService } from "./services/project-goals.service"; -import { SnackbarService } from "@ui/services/snackbar.service"; -import { Resource } from "@office/models/resource.model"; -import { Partner } from "@office/models/partner.model"; -import { ProjectPartnerService } from "./services/project-partner.service"; -import { ProjectResourceService } from "./services/project-resources.service"; - -/** - * Компонент редактирования проекта - * - * Функциональность: - * - Многошаговое редактирование проекта (основная информация, контакты, достижения, вакансии, команда) - * - Управление формами для проекта, вакансий и приглашений - * - Загрузка файлов (презентация, обложка, аватар) - * - Создание и редактирование вакансий с навыками - * - Приглашение участников в команду - * - Управление достижениями, ссылками и целями проекта - * - Сохранение как черновик или публикация - */ -@Component({ - selector: "app-edit", - templateUrl: "./edit.component.html", - styleUrl: "./edit.component.scss", - standalone: true, - imports: [ - ReactiveFormsModule, - CommonModule, - RouterModule, - IconComponent, - ButtonComponent, - ModalComponent, - AsyncPipe, - SkillsGroupComponent, - ProjectNavigationComponent, - ProjectMainStepComponent, - ProjectAchievementStepComponent, - ProjectVacancyStepComponent, - ProjectTeamStepComponent, - ProjectAdditionalStepComponent, - ProjectPartnerResourcesStepComponent, - ], - providers: [ - ProjectFormService, - ProjectVacancyService, - ProjectAdditionalService, - ProjectGoalService, - ProjectPartnerService, - ProjectResourceService, - ], -}) -export class ProjectEditComponent implements OnInit, AfterViewInit, OnDestroy { - constructor( - private readonly route: ActivatedRoute, - private readonly router: Router, - private readonly industryService: IndustryService, - protected readonly projectService: ProjectService, - private readonly navService: NavService, - private readonly validationService: ValidationService, - private readonly cdRef: ChangeDetectorRef, - private readonly projectStepService: ProjectStepService, - private readonly projectFormService: ProjectFormService, - private readonly projectVacancyService: ProjectVacancyService, - private readonly projectTeamService: ProjectTeamService, - private readonly projectAchievementsService: ProjectAchievementsService, - private readonly projectGoalsService: ProjectGoalService, - private readonly projectPartnerService: ProjectPartnerService, - private readonly projectResourceService: ProjectResourceService, - private readonly snackBarService: SnackbarService, - private readonly skillsService: SkillsService, - private readonly projectAdditionalService: ProjectAdditionalService, - private readonly programService: ProgramService, - private readonly projectGoalService: ProjectGoalService - ) {} - - // Получаем форму проекта из сервиса - get projectForm(): FormGroup { - return this.projectFormService.getForm(); - } - - // Получаем форму вакансии из сервиса - get vacancyForm(): FormGroup { - return this.projectVacancyService.getVacancyForm(); - } - - // Получаем форму дополнительных полей из сервиса - get additionalForm(): FormGroup { - return this.projectAdditionalService.getAdditionalForm(); - } - - // Получаем сигналы из сервиса - get achievements() { - return this.projectFormService.achievements; - } - - // Id редактируемой части проекта - get editIndex() { - return this.projectFormService.editIndex; - } - - // Id связи проекта и программы - get relationId() { - return this.projectFormService.relationId; - } - - // Геттеры для доступа к данным из сервиса дополнительных полей - get partnerProgramFields() { - return this.projectAdditionalService.getPartnerProgramFields(); - } - - get isAssignProjectToProgramError() { - return this.projectAdditionalService.getIsAssignProjectToProgramError()(); - } - - get errorAssignProjectToProgramModalMessage() { - return this.projectAdditionalService.getErrorAssignProjectToProgramModalMessage(); - } - - // Методы для управления состоянием ошибок через сервис - setAssignProjectToProgramError(error: { non_field_errors: string[] }): void { - this.projectAdditionalService.setAssignProjectToProgramError(error); - } - - clearAssignProjectToProgramError(): void { - this.projectAdditionalService.clearAssignProjectToProgramError(); - } - - // Геттеры для работы с целями - get goals(): FormArray { - return this.projectGoalsService.goals; - } - - get partners(): FormArray { - return this.projectPartnerService.partners; - } - - get resources(): FormArray { - return this.projectResourceService.resources; - } - - ngOnInit(): void { - this.navService.setNavTitle("Создание проекта"); - - // Получение текущего шага редактирования из query параметров - this.setupEditingStep(); - - // Получение Id лидера проекта - this.setupLeaderIdSubscription(); - } - - ngAfterViewInit(): void { - // Загрузка данных программных тегов и проекта - this.loadProgramTagsAndProject(); - } - - ngOnDestroy(): void { - this.profile$?.unsubscribe(); - this.subscriptions.forEach($ => $?.unsubscribe()); - - // Сброс состояния ProjectGoalService при уничтожении компонента - this.projectGoalService.reset(); - } - - // Опции для программных тегов - programTagsOptions: SelectComponent["options"] = []; - - // Id Лидера проекта - leaderId = 0; - - fromProgram: string | null = ""; - fromProgramOpen = signal(false); - - // Маркер того является ли проект привязанный к конкурсной программе - isCompetitive = false; - isProjectAssignToProgram = false; - - // Маркер что проект привязан - isProjectBoundToProgram = false; - - // Текущий шаг редактирования - get editingStep(): EditStep { - return this.projectStepService.getCurrentStep()(); - } - - get hasOpenSkillsGroups(): boolean { - return this.openGroupIds.size > 0; - } - - // Состояние компонента - isCompleted = false; - isSendDescisionLate = false; - isSendDescisionToPartnerProgramProject = false; - - profile$?: Subscription; - errorMessage = ErrorMessage; - - // Сигналы для работы с модальными окнами с ошибкой - errorModalMessage = signal<{ - program_name: string; - whenCanEdit: string; - daysUntilResolution: string; - } | null>(null); - - onEditClicked = signal(false); - warningModalSeen = false; - - // Observables для данных - industries$ = this.industryService.industries.pipe( - map(industries => - industries.map(industry => ({ value: industry.id, id: industry.id, label: industry.name })) - ) - ); - - subscriptions: (Subscription | undefined)[] = []; - - profileId: number = +this.route.snapshot.params["projectId"]; - - // Сигналы для управления состоянием - inlineSkills = signal([]); - nestedSkills$ = this.skillsService.getSkillsNested(); - skillsGroupsModalOpen = signal(false); - isAssignProjectToProgramModalOpen = signal(false); - - // Состояние отправки форм - projSubmitInitiated = false; - projFormIsSubmittingAsPublished = false; - projFormIsSubmittingAsDraft = false; - openGroupIds = new Set(); - - /** - * Навигация между шагами редактирования - * @param step - название шага - */ - navigateStep(step: EditStep): void { - this.projectStepService.navigateToStep(step); - } - - // /** - // * Привязка проекта к программе выбранной - // * Перенаправление её на редактирование "нового" проекта - // */ - // assignProjectToProgram(): void { - // this.projectService - // .assignProjectToProgram( - // Number(this.route.snapshot.paramMap.get("projectId")), - // this.projectForm.get("partnerProgramId")?.value - // ) - // .subscribe({ - // next: r => { - // this.assignProjectToProgramModalMessage.set(r); - // this.isAssignProjectToProgramModalOpen.set(true); - // this.router.navigateByUrl(`/office/projects/${r.newProjectId}/edit?editingStep=main`); - // }, - - // error: err => { - // if (err instanceof HttpErrorResponse) { - // if (err.status === 400) { - // this.setAssignProjectToProgramError(err.error); - // } - // } - // }, - // }); - // } - - // Методы для управления состоянием отправки форм - setIsSubmittingAsPublished(status: boolean): void { - this.projFormIsSubmittingAsPublished = status; - } - - setIsSubmittingAsDraft(status: boolean): void { - this.projFormIsSubmittingAsDraft = status; - } - - setProjFormIsSubmitting!: (status: boolean) => void; - - /** - * Очистка всех ошибок валидации - */ - clearAllValidationErrors(): void { - // Очистка основной формы - this.projectFormService.clearAllValidationErrors(); - this.projectAchievementsService.clearAllAchievementsErrors(this.achievements); - - // Очистка ошибок целей теперь входит в clearAllValidationErrors() ProjectFormService - } - - onGroupToggled(isOpen: boolean, skillsGroupId: number): void { - this.openGroupIds.clear(); - if (isOpen) { - this.openGroupIds.add(skillsGroupId); - } - - this.cdRef.markForCheck(); - } - - /** - * Удаление проекта с проверкой удаления у пользователя - */ - deleteProject(): void { - if (!confirm("Вы точно хотите удалить проект?")) { - return; - } - - const programId = this.projectForm.get("partnerProgramId")?.value; - - this.projectService.remove(Number(this.route.snapshot.paramMap.get("projectId"))).subscribe({ - next: () => { - if (this.fromProgram) { - this.router.navigateByUrl(`/office/program/${programId}`); - } else { - this.router.navigateByUrl(`/office/projects/my`); - } - }, - }); - } - - /** - * Сохранение проекта как опубликованного с проверкой доп. полей - */ - saveProjectAsPublished(): void { - this.projectForm.get("draft")?.patchValue(false); - this.setProjFormIsSubmitting = this.setIsSubmittingAsPublished; - - if (!this.isCompetitive) { - this.submitProjectForm(); - return; - } - - this.projectForm.markAllAsTouched(); - this.projectFormService.achievements.markAllAsTouched(); - - const projectValid = this.validationService.getFormValidation(this.projectForm); - const additionalValid = this.validationService.getFormValidation(this.additionalForm); - - if (!projectValid || !additionalValid) { - this.projSubmitInitiated = true; - this.cdRef.markForCheck(); - return; - } - - if (this.validateAdditionalFields()) { - this.projSubmitInitiated = true; - this.cdRef.markForCheck(); - return; - } - - this.isSendDescisionToPartnerProgramProject = true; - this.cdRef.markForCheck(); - } - - /** - * Сохранение проекта как черновика - */ - saveProjectAsDraft(): void { - this.clearAllValidationErrors(); - this.projectForm.get("draft")?.patchValue(true); - this.setProjFormIsSubmitting = this.setIsSubmittingAsDraft; - const partnerProgramId = this.projectForm.get("partnerProgramId")?.value; - this.projectForm.patchValue({ partnerProgramId }); - this.closeSendingDescisionModal(); - this.submitProjectForm(); - } - - /** - * Отправка формы проекта - */ - submitProjectForm(): void { - const isDraft = this.projectForm.get("draft")?.value === true; - - this.projectFormService.achievements.controls.forEach(achievementForm => { - achievementForm.markAllAsTouched(); - }); - - const payload = this.projectFormService.getFormValue(); - const projectId = Number(this.route.snapshot.paramMap.get("projectId")); - - if (this.vacancyForm.dirty) { - this.projectVacancyService.submitVacancy(projectId); - } - - if (isDraft) { - if ( - !this.validationService.getFormValidation(this.projectForm) || - !this.validationService.getFormValidation(this.vacancyForm) - ) { - return; - } - } else { - if ( - !this.validationService.getFormValidation(this.projectForm) || - !this.validationService.getFormValidation(this.additionalForm) || - !this.validationService.getFormValidation(this.vacancyForm) - ) { - return; - } - } - - this.setProjFormIsSubmitting(true); - this.projectService - .updateProject(projectId, payload) - .pipe( - switchMap(() => this.saveOrEditGoals(projectId)), - switchMap(() => this.savePartners(projectId)), - switchMap(() => this.saveOrEditResources(projectId)) - ) - .subscribe({ - next: () => { - this.completeSubmitedProjectForm(projectId); - }, - error: err => { - this.setProjFormIsSubmitting(false); - this.snackBarService.error("ошибка при сохранении данных"); - if (err.error["error"].includes("Срок подачи проектов в программу завершён.")) { - this.isSendDescisionLate = true; - } - }, - }); - } - - // Методы для работы с модальными окнами - closeWarningModal(): void { - this.warningModalSeen = true; - } - - closeSendingDescisionModal(): void { - this.isSendDescisionToPartnerProgramProject = false; - - const projectId = Number(this.route.snapshot.params["projectId"]); - const relationId = this.relationId(); - - this.sendAdditionalFields(projectId, relationId); - } - - closeAssignProjectToProgramModal(): void { - this.isAssignProjectToProgramModalOpen.set(false); - } - - private getFromProgramSeenKey(type: "program" | "project"): string { - if (type === "program") { - return `project_fromProgram_modal_seen_${this.profileId}`; - } else return `project_modal_seen_${this.profileId}`; - } - - private hasSeenFromProgramModal(): boolean { - try { - if (this.fromProgram) { - return !!localStorage.getItem(this.getFromProgramSeenKey("program")); - } - return !!localStorage.getItem(this.getFromProgramSeenKey("project")); - } catch (e) { - return false; - } - } - - private markSeenFromProgramModal(): void { - try { - if (this.fromProgram) { - localStorage.setItem(this.getFromProgramSeenKey("program"), "1"); - } - localStorage.setItem(this.getFromProgramSeenKey("project"), "1"); - } catch (e) {} - } - - closeFromProgramModal(): void { - this.fromProgramOpen.set(false); - this.markSeenFromProgramModal(); - } - - private saveOrEditGoals(projectId: number) { - const goals = this.goals.value as Goal[]; - - const newGoals = goals.filter(g => !g.id); - const existingGoals = goals.filter(g => g.id); - - const requests: Observable[] = []; - - if (newGoals.length > 0) { - requests.push(this.projectGoalService.saveGoals(projectId, newGoals)); - } - - if (existingGoals.length > 0) { - requests.push(this.projectGoalService.editGoals(projectId, existingGoals)); - } - - if (requests.length === 0) { - return of(null); - } - - return forkJoin(requests).pipe( - tap(() => { - this.projectGoalService.syncGoalItems(this.projectGoalService.goals); - }) - ); - } - - private savePartners(projectId: number) { - const partners = this.partners.value; - - if (!partners.length) { - return of([]); - } - - return this.projectPartnerService.savePartners(projectId); - } - - private saveOrEditResources(projectId: number) { - const resources = this.resources.value; - const hasExistingResources = resources.some((r: Resource) => r.id != null); - - if (!resources.length) { - return of([]); - } - - return hasExistingResources - ? this.projectResourceService.editResources(projectId) - : this.projectResourceService.saveResources(projectId); - } - - private completeSubmitedProjectForm(projectId: number) { - this.snackBarService.success("данные успешно сохранены"); - this.setProjFormIsSubmitting(false); - this.router.navigateByUrl(`/office/projects/${projectId}`); - } - - /** - * Валидация дополнительных полей для публикации - * Делегирует валидацию сервису - * @returns true если есть ошибки валидации - */ - private validateAdditionalFields(): boolean { - const partnerProgramFields = this.projectAdditionalService.getPartnerProgramFields(); - - // Если нет дополнительных полей - пропускаем валидацию - if (!partnerProgramFields?.length) { - return false; - } - - // Проверяем только обязательные поля - const hasInvalid = this.projectAdditionalService.validateRequiredFields(); - - if (hasInvalid) { - this.cdRef.markForCheck(); - return true; - } - - // Подготавливаем поля для отправки (убираем валидаторы с заполненных полей) - this.projectAdditionalService.prepareFieldsForSubmit(); - return false; - } - - /** - * Отправка дополнительных полей через сервис - * @param projectId - ID проекта - * @param relationId - ID связи проекта и конкурсной программы - */ - private sendAdditionalFields(projectId: number, relationId: number): void { - const isDraft = this.projectForm.get("draft")?.value === true; - this.projectAdditionalService.sendAdditionalFieldsValues(projectId).subscribe({ - next: () => { - if (!isDraft) { - this.projectAdditionalService.submitCompettetiveProject(relationId).subscribe(_ => { - this.submitProjectForm(); - }); - } - }, - error: error => { - console.error("Error sending additional fields:", error); - this.setProjFormIsSubmitting(false); - }, - }); - } - - /** - * Добавление навыка - * @param newSkill - новый навык - */ - onAddSkill(newSkill: Skill): void { - const { skills }: { skills: Skill[] } = this.vacancyForm.value; - const isPresent = skills.some(skill => skill.id === newSkill.id); - - if (isPresent) return; - - this.vacancyForm.patchValue({ skills: [newSkill, ...skills] }); - } - - /** - * Удаление навыка - * @param oddSkill - навык для удаления - */ - onRemoveSkill(oddSkill: Skill): void { - const { skills }: { skills: Skill[] } = this.vacancyForm.value; - - this.vacancyForm.patchValue({ - skills: skills.filter(skill => skill.id !== oddSkill.id), - }); - } - - /** - * Поиск навыков - * @param query - поисковый запрос - */ - onSearchSkill(query: string): void { - this.skillsService.getSkillsInline(query, 1000, 0).subscribe(({ results }) => { - this.inlineSkills.set(results); - }); - } - - /** - * Переключение навыка в списке выбранных - * @param toggledSkill - навык для переключения - */ - onToggleSkill(toggledSkill: Skill): void { - const { skills }: { skills: Skill[] } = this.vacancyForm.value; - const isPresent = skills.some(skill => skill.id === toggledSkill.id); - - if (isPresent) { - this.onRemoveSkill(toggledSkill); - } else { - this.onAddSkill(toggledSkill); - } - } - - /** - * Переключение модального окна групп навыков - */ - toggleSkillsGroupsModal(): void { - this.skillsGroupsModalOpen.update(open => !open); - } - - private setupEditingStep(): void { - const stepFromUrl = this.route.snapshot.queryParams["editingStep"] as EditStep; - if (stepFromUrl) { - this.projectStepService.setStepFromRoute(stepFromUrl); - } - - const editingStepSub$ = this.route.queryParams.subscribe(params => { - const step = params["editingStep"] as EditStep; - this.fromProgram = params["fromProgram"]; - - const seen = this.hasSeenFromProgramModal(); - if (!seen) { - this.fromProgramOpen.set(true); - this.markSeenFromProgramModal(); - } else { - this.fromProgramOpen.set(false); - } - - if (step && step !== this.editingStep) { - this.projectStepService.setStepFromRoute(step); - } - }); - - this.subscriptions.push(editingStepSub$); - } - - private setupLeaderIdSubscription(): void { - this.route.data - .pipe( - distinctUntilChanged(), - map(d => d["data"]) - ) - .subscribe(([project]: [Project]) => { - this.leaderId = project.leader; - }); - } - - private loadProgramTagsAndProject(): void { - this.route.data - .pipe(map(d => d["data"])) - .subscribe( - ([project, goals, partners, resources, invites]: [ - Project, - Goal[], - Partner[], - Resource[], - Invite[] - ]) => { - // Используем сервис для инициализации данных проекта - this.projectFormService.initializeProjectData(project); - this.projectGoalService.initializeGoalsFromProject(goals); - this.projectPartnerService.initializePartnerFromProject(partners); - this.projectResourceService.initializeResourcesFromProject(resources); - this.projectTeamService.setInvites(invites); - this.projectTeamService.setCollaborators(project.collaborators); - - if (project.partnerProgram) { - this.isCompetitive = project.partnerProgram.canSubmit; - this.isProjectAssignToProgram = !!project.partnerProgram.programId; - - this.projectAdditionalService.initializeAdditionalForm( - project.partnerProgram?.programFields, - project.partnerProgram?.programFieldValues - ); - } - - this.projectVacancyService.setVacancies(project.vacancies); - this.projectTeamService.setInvites(invites); - - this.cdRef.detectChanges(); - } - ); - } -} diff --git a/projects/social_platform/src/app/office/projects/edit/edit.resolver.spec.ts b/projects/social_platform/src/app/office/projects/edit/edit.resolver.spec.ts deleted file mode 100644 index f8c36b032..000000000 --- a/projects/social_platform/src/app/office/projects/edit/edit.resolver.spec.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; -import { ProjectEditResolver } from "./edit.resolver"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; -import { of } from "rxjs"; -import { AuthService } from "@auth/services"; -import { ActivatedRouteSnapshot, convertToParamMap, RouterStateSnapshot } from "@angular/router"; - -describe("ProjectEditResolver", () => { - const mockRoute = { - paramMap: convertToParamMap({ projectId: 1 }), - } as unknown as ActivatedRouteSnapshot; - - beforeEach(() => { - const authSpy = { - profile: of({}), - }; - - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - providers: [{ provide: AuthService, useValue: authSpy }], - }); - }); - - it("should be created", () => { - const result = TestBed.runInInjectionContext(() => - ProjectEditResolver(mockRoute, {} as RouterStateSnapshot) - ); - expect(result).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/projects/edit/edit.resolver.ts b/projects/social_platform/src/app/office/projects/edit/edit.resolver.ts deleted file mode 100644 index 3d48dcba9..000000000 --- a/projects/social_platform/src/app/office/projects/edit/edit.resolver.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { ActivatedRouteSnapshot, ResolveFn } from "@angular/router"; -import { forkJoin } from "rxjs"; -import { ProjectService } from "@services/project.service"; -import { Project } from "@models/project.model"; -import { InviteService } from "@services/invite.service"; -import { Invite } from "@models/invite.model"; -import { Goal } from "@office/models/goals.model"; -import { Partner } from "@office/models/partner.model"; -import { Resource } from "@office/models/resource.model"; - -/** - * Resolver для загрузки данных редактирования проекта - * - * Функциональность: - * - Загружает данные проекта по ID из параметров маршрута - * - Получает список приглашений для проекта - * - Объединяет данные в единый массив для компонента - * - * Принимает: - * - ActivatedRouteSnapshot с параметром projectId - * - * Возвращает: - * - Observable<[Project, Invite[]]> с данными: - * - Project: полная информация о проекте - * - Invite[]: массив приглашений в проект - * - * Используется перед загрузкой ProjectEditComponent для предварительной - * загрузки всех необходимых данных для редактирования. - * - * Применяет forkJoin для параллельной загрузки данных проекта и приглашений, - * что оптимизирует время загрузки страницы. - */ -export const ProjectEditResolver: ResolveFn<[Project, Goal[], Partner[], Resource[], Invite[]]> = ( - route: ActivatedRouteSnapshot -) => { - const projectService = inject(ProjectService); - const inviteService = inject(InviteService); - - const projectId = Number(route.paramMap.get("projectId")); - - return forkJoin<[Project, Goal[], Partner[], Resource[], Invite[]]>([ - projectService.getOne(projectId), - projectService.getGoals(projectId), - projectService.getPartners(projectId), - projectService.getResources(projectId), - inviteService.getByProject(projectId), - ]); -}; diff --git a/projects/social_platform/src/app/office/projects/edit/guards/projects-edit.guard.ts b/projects/social_platform/src/app/office/projects/edit/guards/projects-edit.guard.ts deleted file mode 100644 index 6f800c121..000000000 --- a/projects/social_platform/src/app/office/projects/edit/guards/projects-edit.guard.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { ActivatedRouteSnapshot, CanActivateFn, Router, UrlTree } from "@angular/router"; -import { Observable, of } from "rxjs"; -import { catchError, map } from "rxjs/operators"; -import { ProjectService } from "@office/services/project.service"; - -export const ProjectEditRequiredGuard: CanActivateFn = ( - route: ActivatedRouteSnapshot -): Observable => { - const router = inject(Router); - const projectService = inject(ProjectService); - - const projectId = Number(route.paramMap.get("projectId")); - if (isNaN(projectId)) { - return of(router.createUrlTree(["/office/projects/my"])); - } - - return projectService.getOne(projectId).pipe( - map(project => { - if (project.partnerProgram?.isSubmitted) { - return router.createUrlTree([`/office/projects/${projectId}`]); - } - return true; - }), - catchError(() => { - return of(router.createUrlTree([`/office/projects/${projectId}`])); - }) - ); -}; diff --git a/projects/social_platform/src/app/office/projects/edit/services/project-achievements.service.ts b/projects/social_platform/src/app/office/projects/edit/services/project-achievements.service.ts deleted file mode 100644 index d904fad70..000000000 --- a/projects/social_platform/src/app/office/projects/edit/services/project-achievements.service.ts +++ /dev/null @@ -1,158 +0,0 @@ -/** @format */ - -import { inject, Injectable, signal } from "@angular/core"; -import { FormArray, FormBuilder, FormGroup } from "@angular/forms"; -import { ProjectFormService } from "./project-form.service"; - -/** - * Сервис для управления достижениями проекта. - * Предоставляет методы для добавления, редактирования, удаления достижений, - * а также очистки ошибок валидации. - */ -@Injectable({ - providedIn: "root", -}) -export class ProjectAchievementsService { - /** FormBuilder для создания FormGroup элементов */ - private readonly fb = inject(FormBuilder); - /** Сервис для управления индексом редактируемого достижения */ - private readonly projectFormService = inject(ProjectFormService); - /** Сигнал для хранения списка достижений (массив объектов) */ - public readonly achievementsItems = signal([]); - private initialized = false; - - /** - * Инициализирует сигнал achievementsItems из данных FormArray - * Вызывается при первом обращении к данным - */ - private initializeAchievementsItems(achievementsFormArray: FormArray): void { - if (this.initialized) return; - - if (achievementsFormArray && achievementsFormArray.length > 0) { - // Синхронизируем сигнал с данными из FormArray - this.achievementsItems.set(achievementsFormArray.value); - } - this.initialized = true; - } - - /** - * Принудительно синхронизирует сигнал с FormArray - * Полезно вызывать после загрузки данных с сервера - */ - public syncAchievementsItems(achievementsFormArray: FormArray): void { - if (achievementsFormArray) { - this.achievementsItems.set(achievementsFormArray.value); - } - } - - /** - * Добавляет новое достижение или сохраняет изменения существующего. - * @param achievementsFormArray FormArray, содержащий формы достижений - * @param projectForm основная форма проекта (FormGroup) - */ - public addAchievement(achievementsFormArray: FormArray, projectForm: FormGroup): void { - // Инициализируем сигнал при первом вызове - this.initializeAchievementsItems(achievementsFormArray); - - // Считываем вводимые данные - const title = projectForm.get("title")?.value; - const status = projectForm.get("status")?.value; - - // Проверяем, что поля не пустые - if (!title || !status || title.trim().length === 0 || status.trim().length === 0) { - return; // Выходим из функции, если поля пустые - } - - // Создаем FormGroup для нового достижения - const achievementItem = this.fb.group({ - id: achievementsFormArray.length, - title: title.trim(), - status: status.trim(), - }); - - // Проверяем, редактируется ли существующее достижение - const editIdx = this.projectFormService.editIndex(); - if (editIdx !== null) { - // Обновляем массив сигналов и соответствующий контрол в FormArray - this.achievementsItems.update(items => { - const updated = [...items]; - updated[editIdx] = achievementItem.value; - return updated; - }); - achievementsFormArray.at(editIdx).patchValue(achievementItem.value); - // Сбрасываем индекс редактирования - this.projectFormService.editIndex.set(null); - } else { - // Добавляем новое достижение в сигнал и FormArray - this.achievementsItems.update(items => [...items, achievementItem.value]); - achievementsFormArray.push(achievementItem); - } - - // Очищаем поля ввода формы проекта - projectForm.get("title")?.reset(); - projectForm.get("title")?.setValue(""); - - projectForm.get("status")?.reset(); - projectForm.get("status")?.setValue(""); - } - - /** - * Инициализирует редактирование существующего достижения. - * @param index индекс достижения в списке - * @param achievementsFormArray FormArray достижений - * @param projectForm основная форма проекта - */ - public editAchievement( - index: number, - achievementsFormArray: FormArray, - projectForm: FormGroup - ): void { - // Инициализируем сигнал при необходимости - this.initializeAchievementsItems(achievementsFormArray); - - // Используем данные из FormArray как источник истины - const source = achievementsFormArray.value[index]; - - // Заполняем поля формы проекта для редактирования - projectForm.patchValue({ - achievementsName: source?.achievementsName || "", - achievementsDate: source?.achievementsDate || "", - }); - // Устанавливаем текущий индекс редактирования в сервисе - this.projectFormService.editIndex.set(index); - } - - /** - * Удаляет достижение по указанному индексу. - * @param index индекс удаляемого достижения - * @param achievementsFormArray FormArray достижений - */ - public removeAchievement(index: number, achievementsFormArray: FormArray): void { - // Удаляем из сигнала и из FormArray - this.achievementsItems.update(items => items.filter((_, i) => i !== index)); - achievementsFormArray.removeAt(index); - } - - /** - * Сбрасывает все ошибки валидации во всех контролах FormArray достижений. - * @param achievements FormArray достижений - */ - public clearAllAchievementsErrors(achievements: FormArray): void { - achievements.controls.forEach(control => { - if (control instanceof FormGroup) { - Object.keys(control.controls).forEach(key => { - control.get(key)?.setErrors(null); - }); - } - }); - } - - /** - * Сбрасывает состояние сервиса - * Полезно при смене проекта или очистке формы - */ - public reset(): void { - this.achievementsItems.set([]); - this.initialized = false; - } -} diff --git a/projects/social_platform/src/app/office/projects/edit/services/project-additional.service.ts b/projects/social_platform/src/app/office/projects/edit/services/project-additional.service.ts deleted file mode 100644 index 8b27ae100..000000000 --- a/projects/social_platform/src/app/office/projects/edit/services/project-additional.service.ts +++ /dev/null @@ -1,249 +0,0 @@ -/** @format */ - -import { inject, Injectable, signal } from "@angular/core"; -import { FormBuilder, FormControl, FormGroup, Validators } from "@angular/forms"; -import { - PartnerProgramFields, - PartnerProgramFieldsValues, - projectNewAdditionalProgramVields, -} from "@office/models/partner-program-fields.model"; -import { ProgramService } from "@office/program/services/program.service"; -import { ProjectService } from "@services/project.service"; -import { Observable } from "rxjs"; - -/** - * Сервис для управления дополнительными полями проекта в партнерской программе. - * Предоставляет методы для инициализации формы, валидации, переключения значений, - * подготовки к отправке и работы со статусами отправки и ошибок. - */ -@Injectable({ providedIn: "root" }) -export class ProjectAdditionalService { - private additionalForm!: FormGroup; - private partnerProgramFields: PartnerProgramFields[] = []; - private partnerProgramFieldsValues: PartnerProgramFieldsValues[] = []; - - private readonly fb = inject(FormBuilder); - private readonly projectService = inject(ProjectService); - private readonly programService = inject(ProgramService); - - private isSendingDecision = signal(false); - private isAssignProjectToProgramError = signal(false); - private errorAssignProjectToProgramModalMessage = signal<{ non_field_errors: string[] } | null>( - null - ); - - constructor() { - // Инициализируем пустую форму - this.additionalForm = this.fb.group({}); - } - - /** - * Возвращает форму дополнительных полей. - */ - public getAdditionalForm(): FormGroup { - return this.additionalForm; - } - - /** - * Возвращает массив описаний полей партнерской программы. - */ - public getPartnerProgramFields(): PartnerProgramFields[] { - return this.partnerProgramFields; - } - - /** - * Возвращает массив сохраненных значений полей. - */ - public getPartnerProgramFieldsValues(): PartnerProgramFieldsValues[] { - return this.partnerProgramFieldsValues; - } - - /** - * Возвращает сигнал, указывающий на процесс отправки. - */ - public getIsSendingDecision() { - return this.isSendingDecision; - } - - /** - * Возвращает сигнал, указывающий на ошибку при привязке к программе. - */ - public getIsAssignProjectToProgramError() { - return this.isAssignProjectToProgramError; - } - - /** - * Возвращает сообщение об ошибке привязки к программе. - */ - public getErrorAssignProjectToProgramModalMessage() { - return this.errorAssignProjectToProgramModalMessage; - } - - /** - * Инициализирует форму дополнительных полей согласно конфигурации и значениям. - * @param fields описание полей партнерской программы - * @param values сохраненные значения полей - */ - public initializeAdditionalForm( - fields: PartnerProgramFields[], - values: PartnerProgramFieldsValues[] = [] - ): void { - this.partnerProgramFields = fields; - this.partnerProgramFieldsValues = values; - - // Создаем новую пустую форму - this.additionalForm = this.fb.group({}); - - // Добавляем контролы для каждого поля - this.partnerProgramFields.forEach(field => { - this.getInitialValue(field, values); - const validators = field.isRequired ? [Validators.required] : []; - const initialValue = this.getInitialValue(field, values); - - this.additionalForm.addControl(field.name, new FormControl(initialValue, validators)); - - // Добавляем дополнительную валидацию по типу поля - this.addFieldTypeValidators(field); - }); - - // Применяем валидацию ко всей форме - this.additionalForm.updateValueAndValidity(); - } - - /** - * Переключает значение для checkbox и radio полей. - * @param fieldType тип поля (checkbox | radio и др.) - * @param fieldName имя контрола в форме - */ - public toggleAdditionalFormValues( - fieldType: "text" | "textarea" | "checkbox" | "select" | "radio" | "file", - fieldName: string - ): void { - if (fieldType === "checkbox" || fieldType === "radio") { - const control = this.additionalForm.get(fieldName); - if (control) { - control.setValue(!control.value); - } - } - } - - /** - * Проверяет обязательные поля на валидность и помечает их как touched. - * @returns true если есть невалидные обязательные поля - */ - public validateRequiredFields(): boolean { - this.additionalForm.updateValueAndValidity(); - this.partnerProgramFields - .filter(f => f.isRequired) - .forEach(f => this.additionalForm.get(f.name)?.markAsTouched()); - - return this.partnerProgramFields - .filter(f => f.isRequired) - .some(f => this.additionalForm.get(f.name)?.invalid); - } - - /** - * Убирает валидаторы с заполненных обязательных полей перед отправкой. - */ - public prepareFieldsForSubmit(): void { - this.partnerProgramFields - .filter(f => f.isRequired) - .forEach(f => { - const ctrl = this.additionalForm.get(f.name); - if (ctrl && ctrl.value) { - ctrl.clearValidators(); - ctrl.updateValueAndValidity({ emitEvent: false }); - } - }); - } - - /** - * Отправляет значения дополнительных полей на сервер. - * @param projectId идентификатор проекта - * @returns Observable результат запроса - */ - public sendAdditionalFieldsValues(projectId: number): Observable { - this.isSendingDecision.set(true); - const newFieldsFormValues: projectNewAdditionalProgramVields[] = []; - - this.partnerProgramFields.forEach((field: PartnerProgramFields) => { - const fieldValue = this.additionalForm.get(field.name)?.value; - newFieldsFormValues.push({ - field_id: field.id, - value_text: String(fieldValue), - }); - }); - - return this.projectService.sendNewProjectFieldsValues(projectId, newFieldsFormValues); - } - - /** - * Сабмитит проект привязанный к конкурсной программе - * @param relationId идентификатор связи - * @returns Observable результат запроса - */ - public submitCompettetiveProject(relationId: number): Observable { - return this.programService.submitCompettetiveProject(relationId); - } - - /** - * Сбрасывает флаг процесса отправки. - */ - public resetSendingState(): void { - this.isSendingDecision.set(false); - } - - /** - * Устанавливает сообщение и флаг ошибки при привязке проекта. - * @param error объект с массивом полей non_field_errors - */ - public setAssignProjectToProgramError(error: { non_field_errors: string[] }): void { - this.errorAssignProjectToProgramModalMessage.set(error); - this.isAssignProjectToProgramError.set(true); - } - - /** - * Сбрасывает сообщение и флаг ошибки при привязке проекта. - */ - public clearAssignProjectToProgramError(): void { - this.errorAssignProjectToProgramModalMessage.set(null); - this.isAssignProjectToProgramError.set(false); - } - - /** - * Вычисляет начальное значение контрола по сохраненным данным или типу поля. - * @param field описание поля - * @param values массив сохраненных значений - * @returns первоначальное значение контрола - */ - private getInitialValue(field: PartnerProgramFields, values: PartnerProgramFieldsValues[]): any { - const saved = values.find(v => v.fieldName === field.name); - if (!saved) { - return field.fieldType === "checkbox" || field.fieldType === "radio" ? false : ""; - } - - const text = saved.value.trim().toLowerCase(); - if (field.fieldType === "checkbox" || field.fieldType === "radio") { - return text === "true"; - } - return saved.value; - } - - /** - * Добавляет валидаторы по типу текстового поля. - * @param field описание поля для обработки валидаторов - */ - private addFieldTypeValidators(field: PartnerProgramFields): void { - const control = this.additionalForm.get(field.name); - if (!control) return; - - switch (field.fieldType) { - case "text": - control.addValidators([Validators.maxLength(500)]); - break; - case "textarea": - control.addValidators([Validators.maxLength(500)]); - break; - } - } -} diff --git a/projects/social_platform/src/app/office/projects/edit/services/project-contacts.service.ts b/projects/social_platform/src/app/office/projects/edit/services/project-contacts.service.ts deleted file mode 100644 index 2290af62e..000000000 --- a/projects/social_platform/src/app/office/projects/edit/services/project-contacts.service.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** @format */ - -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; - - /** - * Инициализирует сигнал linksItems из данных FormArray - * Вызывается при первом обращении к данным - */ - private initializeLinksItems(linksFormArray: FormArray): void { - if (this.initialized) return; - - if (linksFormArray && linksFormArray.length > 0) { - // Синхронизируем сигнал с данными из FormArray - this.linksItems.set(linksFormArray.value); - } - this.initialized = true; - } - - /** - * Принудительно синхронизирует сигнал с FormArray - * Полезно вызывать после загрузки данных с сервера - */ - public syncLinksItems(linksFormArray: FormArray): void { - if (linksFormArray) { - this.linksItems.set(linksFormArray.value); - } - } - - /** - * Получает основную форму проекта - */ - private get projectForm(): FormGroup { - return this.projectFormService.getForm(); - } - - /** - * Получает FormArray ссылок - */ - public get links(): FormArray { - return this.projectForm.get("links") as FormArray; - } - - /** - * Получает FormControl для поля ввода ссылки - */ - public get link(): FormControl { - return this.projectForm.get("link") as FormControl; - } - - /** - * Добавляет новую ссылку или сохраняет изменения существующей. - * @param linksFormArray FormArray, содержащий формы ссылок - * @param projectForm основная форма проекта (FormGroup) - */ - public addLink(linksFormArray: FormArray): void { - this.initializeLinksItems(linksFormArray); - linksFormArray.push(this.fb.control("", Validators.required)); - this.linksItems.update(items => [...items, ""]); - } - - /** - * Инициализирует редактирование существующей ссылки. - * @param index индекс ссылки в списке - * @param linksFormArray FormArray ссылок - * @param projectForm основная форма проекта - */ - public editLink(index: number, linksFormArray: FormArray, projectForm: FormGroup): void { - // Инициализируем сигнал при необходимости - this.initializeLinksItems(linksFormArray); - - // Используем данные из FormArray как источник истины - const source = linksFormArray.value[index]; - - // Заполняем поле формы проекта для редактирования - projectForm.patchValue({ - link: source?.link || "", - }); - // Устанавливаем текущий индекс редактирования в сервисе - this.projectFormService.editIndex.set(index); - } - - /** - * Удаляет ссылку по указанному индексу. - * @param index индекс удаляемой ссылки - * @param linksFormArray FormArray ссылок - */ - public removeLink(index: number, linksFormArray: FormArray): void { - // Удаляем из сигнала и из FormArray - this.linksItems.update(items => items.filter((_, i) => i !== index)); - linksFormArray.removeAt(index); - } - - /** - * Сбрасывает все ошибки валидации во всех контролах FormArray ссылок. - * @param links FormArray ссылок - */ - public clearAllLinksErrors(links: FormArray): void { - links.controls.forEach(control => { - if (control instanceof FormGroup) { - Object.keys(control.controls).forEach(key => { - control.get(key)?.setErrors(null); - }); - } - }); - } - - /** - * Сбрасывает состояние сервиса - * Полезно при смене проекта или очистке формы - */ - public reset(): void { - this.linksItems.set([]); - this.initialized = false; - } -} diff --git a/projects/social_platform/src/app/office/projects/edit/services/project-form.service.ts b/projects/social_platform/src/app/office/projects/edit/services/project-form.service.ts deleted file mode 100644 index ebc3e1edf..000000000 --- a/projects/social_platform/src/app/office/projects/edit/services/project-form.service.ts +++ /dev/null @@ -1,391 +0,0 @@ -/** @format */ - -import { inject, Injectable, signal } from "@angular/core"; -import { - FormBuilder, - FormGroup, - Validators, - FormArray, - FormControl, - ValidatorFn, -} from "@angular/forms"; -import { ActivatedRoute } from "@angular/router"; -import { PartnerProgramFields } from "@office/models/partner-program-fields.model"; -import { Project } from "@office/models/project.model"; -import { ProjectService } from "@office/services/project.service"; -import { optionalUrlOrMentionValidator } from "@utils/optionalUrl.validator"; -import { stripNullish } from "@utils/stripNull"; -import { concatMap, filter } from "rxjs"; -/** - * Сервис для управления основной формой проекта и формой дополнительных полей партнерской программы. - * Обеспечивает создание, инициализацию, валидацию, автосохранение, сброс и получение данных форм. - */ -@Injectable({ providedIn: "root" }) -export class ProjectFormService { - private projectForm!: FormGroup; - private additionalForm!: FormGroup; - private readonly fb = inject(FormBuilder); - private readonly route = inject(ActivatedRoute); - private readonly projectService = inject(ProjectService); - public editIndex = signal(null); - public relationId = signal(0); - - constructor() { - this.initializeForm(); - } - - formModel = (this.projectForm = this.fb.group({ - imageAddress: [""], - name: ["", [Validators.required, Validators.maxLength(256)]], - region: ["", [Validators.required, Validators.maxLength(256)]], - implementationDeadline: [null], - trl: [null], - links: this.fb.array([]), - link: ["", optionalUrlOrMentionValidator], - industryId: [undefined], - description: ["", [Validators.maxLength(800)]], - presentationAddress: [""], - coverImageAddress: [""], - actuality: ["", [Validators.maxLength(1000)]], - targetAudience: ["", [Validators.maxLength(500)]], - problem: ["", [Validators.maxLength(1000)]], - partnerProgramId: [null], - achievements: this.fb.array([]), - title: [""], - status: [""], - - draft: [null], - })); - - /** - * Создает и настраивает основную форму проекта с набором контролов и валидаторов. - * Подписывается на изменения полей 'presentationAddress' и 'coverImageAddress' для автосохранения при очищении. - */ - private initializeForm(): void { - this.projectForm = this.fb.group({ - imageAddress: [""], - name: ["", [Validators.required, Validators.maxLength(256)]], - region: ["", [Validators.required, Validators.maxLength(256)]], - implementationDeadline: [null], - trl: [null], - links: this.fb.array([]), - link: ["", optionalUrlOrMentionValidator], - industryId: [undefined], - description: ["", [Validators.maxLength(800)]], - presentationAddress: [""], - coverImageAddress: [""], - actuality: ["", [Validators.maxLength(1000)]], - targetAudience: ["", [Validators.maxLength(500)]], - problem: ["", [Validators.maxLength(400)]], - partnerProgramId: [null], - achievements: this.fb.array([]), - title: [""], - status: [""], - - draft: [null], - }); - - // Автосохранение при очистке presentationAddress - this.presentationAddress?.valueChanges - .pipe( - filter(value => !value), - concatMap(() => - this.projectService.updateProject(Number(this.route.snapshot.params["projectId"]), { - presentationAddress: "", - draft: true, - }) - ) - ) - .subscribe(); - - // Автосохранение при очистке coverImageAddress - this.coverImageAddress?.valueChanges - .pipe( - filter(value => !value), - concatMap(() => - this.projectService.updateProject(Number(this.route.snapshot.params["projectId"]), { - coverImageAddress: "", - draft: true, - }) - ) - ) - .subscribe(); - } - - /** - * Заполняет основную форму данными существующего проекта. - * @param project экземпляр Project с текущими данными - */ - public initializeProjectData(project: Project): void { - // Заполняем простые поля - this.projectForm.patchValue({ - imageAddress: project.imageAddress, - name: project.name, - region: project.region, - industryId: project.industry, - description: project.description, - implementationDeadline: project.implementationDeadline ?? null, - targetAudience: project.targetAudience ?? null, - actuality: project.actuality ?? "", - trl: project.trl ?? "", - problem: project.problem ?? "", - presentationAddress: project.presentationAddress, - coverImageAddress: project.coverImageAddress, - partnerProgramId: project.partnerProgram?.programId ?? null, - }); - - if (project.partnerProgram) { - this.relationId.set(project.partnerProgram?.programLinkId); - } - - this.populateLinksFormArray(project.links || []); - this.populateAchievementsFormArray(project.achievements || []); - } - - /** - * Заполняет FormArray ссылок данными из проекта - * @param links массив ссылок из проекта - */ - private populateLinksFormArray(links: string[]): void { - const linksFormArray = this.projectForm.get("links") as FormArray; - - while (linksFormArray.length !== 0) { - linksFormArray.removeAt(0); - } - - links.forEach(link => { - linksFormArray.push(this.fb.control(link, optionalUrlOrMentionValidator)); - }); - } - - /** - * Заполняет FormArray достижений данными из проекта - * @param achievements массив достижений из проекта - */ - private populateAchievementsFormArray(achievements: any[]): void { - const achievementsFormArray = this.projectForm.get("achievements") as FormArray; - const currentYear = new Date().getFullYear(); - - while (achievementsFormArray.length !== 0) { - achievementsFormArray.removeAt(0); - } - - achievements.forEach((achievement, index) => { - const achievementGroup = this.fb.group({ - id: achievement.id ?? index, - title: [achievement.title || "", Validators.required], - status: [ - achievement.status || "", - [ - Validators.required, - Validators.min(2000), - Validators.max(currentYear), - Validators.pattern(/^\d{4}$/), - ], - ], - }); - achievementsFormArray.push(achievementGroup); - }); - } - - /** - * Возвращает основную форму проекта. - * @returns FormGroup экземпляр формы проекта - */ - public getForm(): FormGroup { - return this.projectForm; - } - - /** - * Патчит частичные значения в основную форму. - * @param values объект с частичными значениями Project - */ - public patchFormValues(values: Partial): void { - this.projectForm.patchValue(values); - } - - /** - * Проверяет валидность основной формы проекта. - * @returns true если все контролы валидны - */ - public validateForm(): boolean { - return this.projectForm.valid; - } - - /** - * Получает текущее значение формы без null или undefined. - * @returns объект значений формы без nullish - */ - public getFormValue(): any { - const value = stripNullish(this.projectForm.value); - - if (Array.isArray(value["links"])) { - value["links"] = value["links"].map((v: string) => v?.trim()).filter((v: string) => !!v); - } - - return value; - } - - // Геттеры для быстрого доступа к контролам основной формы - public get name() { - return this.projectForm.get("name"); - } - - public get region() { - return this.projectForm.get("region"); - } - - public get industry() { - return this.projectForm.get("industryId"); - } - - public get description() { - return this.projectForm.get("description"); - } - - public get actuality() { - return this.projectForm.get("actuality"); - } - - public get implementationDeadline() { - return this.projectForm.get("implementationDeadline"); - } - - public get problem() { - return this.projectForm.get("problem"); - } - - public get targetAudience() { - return this.projectForm.get("targetAudience"); - } - - public get trl() { - return this.projectForm.get("trl"); - } - - public get presentationAddress() { - return this.projectForm.get("presentationAddress"); - } - - public get coverImageAddress() { - return this.projectForm.get("coverImageAddress"); - } - - public get imageAddress() { - return this.projectForm.get("imageAddress"); - } - - public get partnerProgramId() { - return this.projectForm.get("partnerProgramId"); - } - - public get achievements(): FormArray { - return this.projectForm.get("achievements") as FormArray; - } - - public get links(): FormArray { - return this.projectForm.get("links") as FormArray; - } - - /** - * Очищает все ошибки валидации в основной форме и в массиве достижений. - */ - public clearAllValidationErrors(): void { - Object.keys(this.projectForm.controls).forEach(ctrl => { - this.projectForm.get(ctrl)?.setErrors(null); - }); - this.clearAchievementsErrors(this.achievements); - } - - /** - * Инициализирует форму дополнительных полей программы партнерства. - * @param partnerProgramFields массив метаданных полей - */ - public initializeAdditionalForm(partnerProgramFields: PartnerProgramFields[]): void { - this.additionalForm = this.fb.group({}); - partnerProgramFields.forEach(field => { - const validators: ValidatorFn[] = []; - if (field.isRequired) validators.push(Validators.required); - if (field.fieldType === "text") validators.push(Validators.maxLength(500)); - if (field.fieldType === "textarea") validators.push(Validators.maxLength(500)); - const initialValue = field.fieldType === "checkbox" ? false : ""; - const fieldCtrl = new FormControl(initialValue, validators); - this.additionalForm.addControl(field.name, fieldCtrl); - }); - this.additionalForm.updateValueAndValidity(); - } - - /** - * Возвращает форму дополнительных полей. - * @returns FormGroup экземпляр дополнительной формы - */ - public getAdditionalForm(): FormGroup { - return this.additionalForm; - } - - /** - * Проверяет валидность дополнительной формы. - * @returns true если форма инициализирована и валидна - */ - public validateAdditionalForm(): boolean { - return this.additionalForm?.valid ?? true; - } - - /** - * Возвращает очищенные значения дополнительной формы. - * @returns объект значений без nullish - */ - public getAdditionalFormValue(): any { - return this.additionalForm ? stripNullish(this.additionalForm.value) : {}; - } - - /** - * Сбрасывает основную и дополнительную формы в первоначальное состояние. - */ - public resetForms(): void { - this.projectForm.reset(); - this.additionalForm?.reset(); - this.clearFormArrays(); - } - - /** - * Очищает все FormArray в форме - */ - private clearFormArrays(): void { - const linksArray = this.links; - const achievementsArray = this.achievements; - - while (linksArray.length !== 0) { - linksArray.removeAt(0); - } - - while (achievementsArray.length !== 0) { - achievementsArray.removeAt(0); - } - } - - /** - * Проверяет валидность обеих форм (основной и дополнительной) включая цели. - * @returns true если все формы валидны - */ - public validateAllForms(): boolean { - const mainFormValid = this.validateForm(); - const additionalFormValid = this.validateAdditionalForm(); - - return mainFormValid && additionalFormValid; - } - - /** - * Удаляет ошибки валидации внутри массива достижений. - * @param achievements FormArray достижений - */ - private clearAchievementsErrors(achievements: FormArray): void { - achievements.controls.forEach(group => { - if (group instanceof FormGroup) { - Object.keys(group.controls).forEach(name => { - group.get(name)?.setErrors(null); - }); - } - }); - } -} diff --git a/projects/social_platform/src/app/office/projects/edit/services/project-goals.service.ts b/projects/social_platform/src/app/office/projects/edit/services/project-goals.service.ts deleted file mode 100644 index 9aff1cc92..000000000 --- a/projects/social_platform/src/app/office/projects/edit/services/project-goals.service.ts +++ /dev/null @@ -1,354 +0,0 @@ -/** @format */ - -import { inject, Injectable, signal } from "@angular/core"; -import { FormArray, FormBuilder, FormControl, FormGroup, Validators } from "@angular/forms"; -import { ProjectFormService } from "./project-form.service"; -import { Goal, GoalPostForm } from "@office/models/goals.model"; -import { catchError, forkJoin, map, of, tap } from "rxjs"; -import { ProjectService } from "@office/services/project.service"; - -/** - * Сервис для управления целями проекта - * Предоставляет полный набор методов для работы с целями: - * - инициализация, добавление, редактирование, удаление - * - валидация и очистка ошибок - * - управление состоянием модального окна выбора лидера - */ -@Injectable({ - providedIn: "root", -}) -export class ProjectGoalService { - private readonly fb = inject(FormBuilder); - private goalForm!: FormGroup; - private readonly projectFormService = inject(ProjectFormService); - private readonly projectService = inject(ProjectService); - public readonly goalItems = signal([]); - - /** Флаг инициализации сервиса */ - private initialized = false; - - public readonly goalLeaderShowModal = signal(false); - public readonly activeGoalIndex = signal(null); - public readonly selectedLeaderId = signal(""); - - constructor() { - this.initializeGoalForm(); - } - - private initializeGoalForm(): void { - this.goalForm = this.fb.group({ - goals: this.fb.array([]), - title: [null], - completionDate: [null], - responsible: [null], - }); - } - - /** - * Инициализирует сигнал goalItems из данных FormArray - * Вызывается при первом обращении к данным - */ - public initializeGoalItems(goalFormArray: FormArray): void { - if (this.initialized) return; - - if (goalFormArray && goalFormArray.length > 0) { - this.goalItems.set(goalFormArray.value); - } - this.initialized = true; - } - - /** - * Принудительно синхронизирует сигнал с FormArray - * Полезно вызывать после загрузки данных с сервера - */ - public syncGoalItems(goalFormArray: FormArray): void { - if (goalFormArray) { - this.goalItems.set(goalFormArray.value); - } - } - - /** - * Инициализирует цели из данных проекта - * Заполняет FormArray целей данными из проекта - */ - public initializeGoalsFromProject(goals: Goal[]): void { - const goalsFormArray = this.goals; - - while (goalsFormArray.length !== 0) { - goalsFormArray.removeAt(0); - } - - if (goals && Array.isArray(goals)) { - goals.forEach(goal => { - const goalsGroup = this.fb.group({ - id: [goal.id ?? null], - title: [goal.title || "", Validators.required], - completionDate: [goal.completionDate || "", Validators.required], - responsible: [goal.responsibleInfo?.id?.toString() || "", Validators.required], - isDone: [goal.isDone || false], - }); - goalsFormArray.push(goalsGroup); - }); - - this.syncGoalItems(goalsFormArray); - } else { - this.goalItems.set([]); - } - } - - /** - * Возвращает форму целей. - * @returns FormGroup экземпляр формы целей - */ - public getForm(): FormGroup { - return this.goalForm; - } - - /** - * Получает FormArray целей - */ - public get goals(): FormArray { - return this.goalForm.get("goals") as FormArray; - } - - /** - * Получает FormControl для поля ввода названия цели - */ - public get goalName(): FormControl { - return this.goalForm.get("title") as FormControl; - } - - /** - * Получает FormControl для поля ввода даты цели - */ - public get goalDate(): FormControl { - return this.goalForm.get("completionDate") as FormControl; - } - - /** - * Получает FormControl для поля лидера(исполнителя/ответственного) цели - */ - public get goalLeader(): FormControl { - return this.goalForm.get("responsible") as FormControl; - } - - /** - * Добавляет новую цель или сохраняет изменения существующей. - * @param goalName - название цели (опционально) - * @param goalDate - дата цели (опционально) - * @param goalLeader - лидер цели (опционально) - */ - public addGoal(goalName?: string, goalDate?: string, goalLeader?: string): void { - const goalFormArray = this.goals; - - this.initializeGoalItems(goalFormArray); - - const name = goalName || this.goalForm.get("title")?.value; - const date = goalDate || this.goalForm.get("completionDate")?.value; - const leader = goalLeader || this.goalForm.get("responsible")?.value; - - if (!name || !date || name.trim().length === 0 || date.trim().length === 0) { - return; - } - - const goalItem = this.fb.group({ - id: [null], - title: [name.trim(), Validators.required], - completionDate: [date.trim(), Validators.required], - responsible: [leader, Validators.required], - isDone: [false], - }); - - const editIdx = this.projectFormService.editIndex(); - if (editIdx !== null) { - goalFormArray.at(editIdx).patchValue(goalItem.value); - this.projectFormService.editIndex.set(null); - } else { - this.goalItems.update(items => [...items, goalItem.value]); - goalFormArray.push(goalItem); - } - - this.syncGoalItems(goalFormArray); - } - - /** - * Удаляет цель по указанному индексу. - * @param index индекс удаляемой цели - */ - public removeGoal(index: number): void { - const goalFormArray = this.goals; - - this.goalItems.update(items => items.filter((_, i) => i !== index)); - goalFormArray.removeAt(index); - } - - /** - * Получает выбранного лидера для конкретной цели - * @param goalIndex - индекс цели - * @param collaborators - список коллабораторов - */ - public getSelectedLeaderForGoal(goalIndex: number, collaborators: any[]) { - const goalFormGroup = this.goals.at(goalIndex); - const leaderId = goalFormGroup?.get("responsible")?.value; - - if (!leaderId) return null; - - return collaborators.find(collab => collab.userId.toString() === leaderId.toString()); - } - - /** - * Обработчик изменения радио-кнопки для выбора лидера - * @param event - событие изменения - */ - public onLeaderRadioChange(event: Event): void { - const target = event.target as HTMLInputElement; - this.selectedLeaderId.set(target.value); - } - - /** - * Добавляет лидера на определенную цель - */ - public addLeaderToGoal(): void { - const goalIndex = this.activeGoalIndex(); - const leaderId = this.selectedLeaderId(); - - if (goalIndex === null || !leaderId) { - return; - } - - const goalFormGroup = this.goals.at(goalIndex); - goalFormGroup?.get("responsible")?.setValue(leaderId); - - this.closeGoalLeaderModal(); - } - - /** - * Открывает модальное окно выбора лидера для конкретной цели - * @param index - индекс цели - */ - public openGoalLeaderModal(index: number): void { - this.activeGoalIndex.set(index); - - const currentLeader = this.goals.at(index)?.get("responsible")?.value; - this.selectedLeaderId.set(currentLeader || ""); - - this.goalLeaderShowModal.set(true); - } - - /** - * Закрывает модальное окно выбора лидера - */ - public closeGoalLeaderModal(): void { - this.goalLeaderShowModal.set(false); - this.activeGoalIndex.set(null); - this.selectedLeaderId.set(""); - } - - /** - * Переключает состояние модального окна выбора лидера - * @param index - индекс цели (опционально) - */ - public toggleGoalLeaderModal(index?: number): void { - if (this.goalLeaderShowModal()) { - this.closeGoalLeaderModal(); - } else if (index !== undefined) { - this.openGoalLeaderModal(index); - } - } - - /** - * Сбрасывает все ошибки валидации во всех контролах FormArray цели. - */ - public clearAllGoalsErrors(): void { - const goals = this.goals; - - goals.controls.forEach(control => { - if (control instanceof FormGroup) { - Object.keys(control.controls).forEach(key => { - control.get(key)?.setErrors(null); - }); - } - }); - } - - /** - * Получает данные всех целей для отправки на сервер - * @returns массив объектов целей - */ - public getGoalsData(): any[] { - return this.goals.value.map((g: any) => ({ - id: g.id ?? null, - title: g.title, - completionDate: g.completionDate, - responsible: - g.responsible === null || g.responsible === undefined || g.responsible === "" - ? null - : Number(g.responsible), - isDone: !!g.isDone, - })); - } - - /** - * Сохраняет только новые цели (у которых id === null) — отправляет POST. - * После ответов присваивает полученные id в соответствующие FormGroup. - * Возвращает Observable массива результатов (в порядке отправки). - */ - public saveGoals(projectId: number, newGoals: Goal[]) { - return this.projectService.addGoals(projectId, newGoals).pipe( - tap(results => { - results.forEach((createdGoal: any, idx: number) => { - const formGroup = this.goals.at(idx); - if (formGroup && createdGoal?.id != null) { - formGroup.patchValue({ id: createdGoal.id }); - } - }); - }), - catchError(err => { - console.error("Error saving goals:", err); - return of({ __error: true, err, original: newGoals }); - }) - ); - } - - public editGoals(projectId: number, existingGoals: Goal[]) { - const requests = existingGoals.map((item, idx) => { - const payload: GoalPostForm = { - id: item.id, - title: item.title, - completionDate: item.completionDate, - responsible: item.responsible, - isDone: item.isDone, - }; - - return this.projectService.editGoal(projectId, item.id, payload).pipe( - map(res => ({ res, idx })), - catchError(err => of({ __error: true, err, original: item, idx })) - ); - }); - - return forkJoin(requests); - } - - /** - * Сбрасывает состояние сервиса - * Полезно при смене проекта или очистке формы - */ - public reset(): void { - this.goalItems.set([]); - this.initialized = false; - this.closeGoalLeaderModal(); - } - - /** - * Очищает FormArray целей - */ - public clearGoalsFormArray(): void { - const goalFormArray = this.goals; - - while (goalFormArray.length !== 0) { - goalFormArray.removeAt(0); - } - - this.goalItems.set([]); - } -} diff --git a/projects/social_platform/src/app/office/projects/edit/services/project-partner.service.ts b/projects/social_platform/src/app/office/projects/edit/services/project-partner.service.ts deleted file mode 100644 index d94183e54..000000000 --- a/projects/social_platform/src/app/office/projects/edit/services/project-partner.service.ts +++ /dev/null @@ -1,263 +0,0 @@ -/** @format */ - -import { inject, Injectable, signal } from "@angular/core"; -import { FormArray, FormBuilder, FormControl, FormGroup, Validators } from "@angular/forms"; -import { Partner, PartnerPostForm } from "@office/models/partner.model"; -import { ProjectService } from "@office/services/project.service"; -import { catchError, forkJoin, map, Observable, of, tap } from "rxjs"; - -@Injectable({ - providedIn: "root", -}) -export class ProjectPartnerService { - private readonly fb = inject(FormBuilder); - private partnerForm!: FormGroup; - private readonly projectService = inject(ProjectService); - public readonly partnerItems = signal([]); - - /** Флаг инициализации сервиса */ - private initialized = false; - - constructor() { - this.initializePartnerForm(); - } - - private initializePartnerForm(): void { - this.partnerForm = this.fb.group({ - partners: this.fb.array([]), - name: [null], - inn: [null, [Validators.minLength(10), Validators.maxLength(10)]], - contribution: [null, Validators.maxLength(200)], - decisionMaker: [null], - }); - } - - /** - * Инициализирует сигнал partnerItems из данных FormArray - * Вызывается при первом обращении к данным - */ - public initializePartnerItems(partnerFormArray: FormArray): void { - if (this.initialized) return; - - if (partnerFormArray && this.partnerItems.length > 0) { - this.partnerItems.set(partnerFormArray.value); - } - - this.initialized = true; - } - - /** - * Принудительно синхронизирует сигнал с FormArray - * Полезно вызывать после загрузки данных с сервера - */ - public syncPartnerItems(partnerFormArray: FormArray): void { - if (partnerFormArray) { - this.partnerItems.set(partnerFormArray.value); - } - } - - /** - * Инициализирует партнера из данных проекта - * Заполняет FormArray целей данными из проекта - */ - public initializePartnerFromProject(partners: Partner[]): void { - const partnerFormArray = this.partners; - - while (partnerFormArray.length !== 0) { - partnerFormArray.removeAt(0); - } - - if (partners && Array.isArray(partners)) { - partners.forEach(partner => { - const partnerGroup = this.fb.group({ - id: [partner.id], - name: [partner.company.name, Validators.required], - inn: [partner.company.inn, Validators.required], - contribution: [partner.contribution, Validators.required], - company: [partner.company], - decisionMaker: [ - "https://app.procollab.ru/office/profile/" + partner.decisionMaker, - Validators.required, - ], - }); - partnerFormArray.push(partnerGroup); - }); - - this.syncPartnerItems(partnerFormArray); - } else { - this.partnerItems.set([]); - } - } - - /** - * Возвращает форму партнеров и ресурсов. - * @returns FormGroup экземпляр формы целей - */ - public getForm(): FormGroup { - return this.partnerForm; - } - - /** - * Получает FormArray партнеров и ресурсов - */ - public get partners(): FormArray { - return this.partnerForm.get("partners") as FormArray; - } - - public get partnerName(): FormControl { - return this.partnerForm.get("name") as FormControl; - } - - public get partnerINN(): FormControl { - return this.partnerForm.get("inn") as FormControl; - } - - public get partnerMention(): FormControl { - return this.partnerForm.get("contribution") as FormControl; - } - - public get partnerProfileLink(): FormControl { - return this.partnerForm.get("decisionMaker") as FormControl; - } - - /** - * Добавляет нового партнера или сохраняет изменения существующей. - * @param name - название партнера (опционально) - * @param inn - инн (опционально) - * @param contribution - вклад партнера (опционально) - * @param decisionMaker - ссылка на профиль представителя компании (опционально) - */ - public addPartner( - name?: string, - inn?: string, - contribution?: string, - decisionMaker?: string - ): void { - const partnerFormArray = this.partners; - - this.initializePartnerItems(partnerFormArray); - - const partnerName = name || this.partnerForm.get("name")?.value; - const INN = inn || this.partnerForm.get("inn")?.value; - const mention = contribution || this.partnerForm.get("contribution")?.value; - const profileLink = decisionMaker || this.partnerForm.get("decisionMaker")?.value; - - if ( - !partnerName || - !INN || - !mention || - !profileLink || - partnerName.trim().length === 0 || - mention.trim().length === 0 || - INN.trim().length === 0 || - profileLink.trim().length === 0 - ) { - return; - } - - const partnerItem = this.fb.group({ - id: [null], - name: [partnerName.trim(), Validators.required], - inn: [INN.trim(), Validators.required], - contribution: [mention, Validators.required], - decisionMaker: [profileLink, Validators.required], - }); - - this.partnerItems.update(items => [...items, partnerItem.value]); - partnerFormArray.push(partnerItem); - } - - /** - * Удаляет партнера по указанному индексу. - * @param index индекс удаляемого партнера - */ - public removePartner(index: number): void { - const partnerFormArray = this.partners; - - this.partnerItems.update(items => items.filter((_, i) => i !== index)); - partnerFormArray.removeAt(index); - } - - /** - * Сбрасывает все ошибки валидации во всех контролах FormArray партнера. - */ - public clearAllPartnerErrors(): void { - const partners = this.partners; - - partners.controls.forEach(control => { - if (control instanceof FormGroup) { - Object.keys(control.controls).forEach(key => { - control.get(key)?.setErrors(null); - }); - } - }); - } - - /** - * Получает данные всех партнеров для отправки на сервер - * @returns массив объектов партнеров - */ - public getPartnersData(): any[] { - return this.partners.value.map((partner: any) => ({ - id: partner.id ?? null, - name: partner.name, - inn: partner.inn, - contribution: partner.contribution, - decisionMaker: partner.decisionMaker, - })); - } - - /** - * Сохраняет только новых партнеров (у которых id === null) — отправляет POST. - * После ответов присваивает полученные id в соответствующие FormGroup. - * Возвращает Observable массива результатов (в порядке отправки). - */ - public savePartners(projectId: number) { - const partners = this.getPartnersData(); - - if (partners.length === 0) { - return of([]); - } - - const requests = partners.map(partner => { - const decisionMaker = Number(partner.decisionMaker.split("/").at(-1)); - - const payload: PartnerPostForm = { - name: partner.name, - inn: partner.inn, - contribution: partner.contribution, - decisionMaker, - }; - - return this.projectService.addPartner(projectId, payload).pipe( - map((res: any) => ({ res, idx: partner.id })), - catchError(err => of({ __error: true, err, original: partner })) - ); - }); - - return forkJoin(requests).pipe( - tap(results => { - results.forEach((r: any) => { - if (r && r.__error) { - console.error("Failed to post partner", r.err, "original:", r.original); - return; - } - - const created = r.res; - const idx = r.idx; - - if (created && created.id !== undefined && created.id !== null) { - const formGroup = this.partners.at(idx); - if (formGroup) { - formGroup.get("id")?.setValue(created.id); - } - } else { - console.warn("addPartner response has no id field:", r.res); - } - }); - - this.syncPartnerItems(this.partners); - }) - ); - } -} diff --git a/projects/social_platform/src/app/office/projects/edit/services/project-resources.service.ts b/projects/social_platform/src/app/office/projects/edit/services/project-resources.service.ts deleted file mode 100644 index 15b661535..000000000 --- a/projects/social_platform/src/app/office/projects/edit/services/project-resources.service.ts +++ /dev/null @@ -1,276 +0,0 @@ -/** @format */ - -import { inject, Injectable, signal } from "@angular/core"; -import { FormArray, FormBuilder, FormControl, FormGroup, Validators } from "@angular/forms"; -import { Resource, ResourcePostForm } from "@office/models/resource.model"; -import { ProjectService } from "@office/services/project.service"; -import { catchError, forkJoin, map, Observable, of, tap } from "rxjs"; - -@Injectable({ - providedIn: "root", -}) -export class ProjectResourceService { - private readonly fb = inject(FormBuilder); - private readonly projectService = inject(ProjectService); - private resourceForm!: FormGroup; - public readonly resourceItems = signal([]); - - /** Флаг инициализации сервиса */ - private initialized = false; - - constructor() { - this.initializeResourceForm(); - } - - private initializeResourceForm(): void { - this.resourceForm = this.fb.group({ - resources: this.fb.array([]), - type: [null], - description: [null, Validators.maxLength(200)], - partnerCompany: [null], - }); - } - - /** - * Инициализирует сигнал resourceItems из данных FormArray - * Вызывается при первом обращении к данным - */ - public initializePartnerItems(resourceFormArray: FormArray): void { - if (this.initialized) return; - - if (resourceFormArray && this.resourceItems.length > 0) { - this.resourceItems.set(resourceFormArray.value); - } - - this.initialized = true; - } - - /** - * Принудительно синхронизирует сигнал с FormArray - * Полезно вызывать после загрузки данных с сервера - */ - public syncResourceItems(resourceFormArray: FormArray): void { - if (resourceFormArray) { - this.resourceItems.set(resourceFormArray.value); - } - } - - /** - * Инициализирует ресурсы из данных проекта - * Заполняет FormArray целей данными из проекта - */ - public initializeResourcesFromProject(resources: Resource[]): void { - const resourcesFormArray = this.resources; - - while (resourcesFormArray.length !== 0) { - resourcesFormArray.removeAt(0); - } - - if (resources && Array.isArray(resources)) { - resources.forEach(resource => { - const partnerGroup = this.fb.group({ - id: [resource.id ?? null], - type: [resource.type, Validators.required], - description: [resource.description, Validators.required], - partnerCompany: [resource.partnerCompany, Validators.required], - }); - resourcesFormArray.push(partnerGroup); - }); - - this.syncResourceItems(resourcesFormArray); - } else { - this.resourceItems.set([]); - } - } - - /** - * Возвращает форму партнеров и ресурсов. - * @returns FormGroup экземпляр формы целей - */ - public getForm(): FormGroup { - return this.resourceForm; - } - - /** - * Получает FormArray партнеров и ресурсов - */ - public get resources(): FormArray { - return this.resourceForm.get("resources") as FormArray; - } - - public get resoruceType(): FormControl { - return this.resourceForm.get("type") as FormControl; - } - - public get resoruceDescription(): FormControl { - return this.resourceForm.get("description") as FormControl; - } - - public get resourcePartner(): FormControl { - return this.resourceForm.get("partnerCompany") as FormControl; - } - - /** - * Добавляет нового ресурса или сохраняет изменения существующей. - * @param type - тип ресурса (опционально) - * @param description - описание ресурса (опционально) - * @param partnerCompany - ссылка на партнера (опционально) - */ - public addResource(type?: string, description?: string, partnerCompany?: string): void { - const resourcesFormArray = this.resources; - - this.initializePartnerItems(resourcesFormArray); - - const resourceType = type || this.resourceForm.get("type")?.value; - const resourceDescription = description || this.resourceForm.get("description")?.value; - const partner = partnerCompany || this.resourceForm.get("partnerCompany")?.value; - - if ( - !resourceType || - !resourceDescription || - !partner || - resourceType.trim().length === 0 || - resourceDescription.trim().length === 0 || - partner.trim().length === 0 - ) { - return; - } - - const resourceItem = this.fb.group({ - id: [null], - type: [resourceType.trim(), Validators.required], - description: [resourceDescription.trim(), Validators.required], - partnerCompany: [partner, Validators.required], - }); - - this.resourceItems.update(items => [...items, resourceItem.value]); - resourcesFormArray.push(resourceItem); - } - - /** - * Удаляет ресурс по указанному индексу. - * @param index индекс удаляемого партнера - */ - public removeResource(index: number): void { - const resourceFormArray = this.resources; - - this.resourceItems.update(items => items.filter((_, i) => i !== index)); - resourceFormArray.removeAt(index); - } - - /** - * Сбрасывает все ошибки валидации во всех контролах FormArray ресурса. - */ - public clearAllResourceErrors(): void { - const resources = this.resources; - - resources.controls.forEach(control => { - if (control instanceof FormGroup) { - Object.keys(control.controls).forEach(key => { - control.get(key)?.setErrors(null); - }); - } - }); - } - - /** - * Получает данные все ресурсы для отправки на сервер - * @returns массив объектов ресурсов - */ - public getResourcesData(): any[] { - return this.resources.value.map((resource: any) => ({ - id: resource.id ?? null, - type: resource.type, - description: resource.description, - partnerCompany: resource.partnerCompany, - })); - } - - /** - * Сохраняет только новых ресурсов (у которых id === null) — отправляет POST. - * После ответов присваивает полученные id в соответствующие FormGroup. - * Возвращает Observable массива результатов (в порядке отправки). - */ - public saveResources(projectId: number) { - const resources = this.getResourcesData(); - - const requests = resources.map(resource => { - const payload: Omit = { - type: resource.type, - description: resource.description, - partnerCompany: resource.partnerCompany ?? "запрос к рынку", - }; - - return this.projectService.addResource(projectId, payload).pipe( - map((res: any) => ({ res, idx: resource.idx })), - catchError(err => of({ __error: true, err, original: resource })) - ); - }); - - return forkJoin(requests).pipe( - tap(results => { - results.forEach((r: any) => { - if (r && r.__error) { - console.error("Failed to post resource", r.err, "original:", r.original); - return; - } - - const created = r.res; - const idx = r.idx; - - if (created && created.id !== undefined && created.id !== null) { - const formGroup = this.resources.at(idx); - if (formGroup) { - formGroup.get("id")?.setValue(created.id); - } - } - }); - - this.syncResourceItems(this.resources); - }) - ); - } - - public editResources(projectId: number) { - const resources = this.getResourcesData(); - console.log(resources); - - const requests = resources.map(resource => { - const payload: Omit = { - type: resource.type, - description: resource.description, - partnerCompany: resource.partnerCompany ?? "запрос к рынку", - }; - - return this.projectService.editResource(projectId, resource.id, payload).pipe( - map((res: any) => ({ res })), - catchError(err => of({ __error: true, err, original: resource })) - ); - }); - - return forkJoin(requests).pipe( - tap(results => { - results.forEach((r: any) => { - if (r && r.__error) { - console.error("Failed to add resource", r.err, "original:", r.original); - return; - } - - const created = r.res; - const idx = r.idx; - - if (created && created.id !== undefined && created.id !== null) { - const formGroup = this.resources.at(idx); - if (formGroup) { - formGroup.get("id")?.setValue(created.id); - } - } else { - console.warn("addResource response has no id field:", r.res); - } - }); - - this.syncResourceItems(this.resources); - }) - ); - } -} diff --git a/projects/social_platform/src/app/office/projects/edit/services/project-step.service.ts b/projects/social_platform/src/app/office/projects/edit/services/project-step.service.ts deleted file mode 100644 index 33df80461..000000000 --- a/projects/social_platform/src/app/office/projects/edit/services/project-step.service.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Сервис для управления шагами редактирования проекта. - * Обеспечивает хранение текущего шага, навигацию между шагами и синхронизацию - * состояния с URL-параметрами маршрута. - * - * @format - */ - -import { inject, Injectable, Signal, signal } from "@angular/core"; -import { Router } from "@angular/router"; - -/** Тип шага редактирования проекта */ -export type EditStep = "main" | "contacts" | "achievements" | "vacancies" | "team" | "additional"; - -@Injectable({ - providedIn: "root", -}) -export class ProjectStepService { - /** Сигнал, содержащий текущий шаг редактирования */ - private currentStep = signal("main"); - /** Ссылка на Router для изменения URL */ - private readonly router = inject(Router); - - /** - * Возвращает readonly-сигнал текущего шага. - * @returns Signal readonly-сигнал - */ - public getCurrentStep(): Signal { - return this.currentStep.asReadonly(); - } - - /** - * Устанавливает новый шаг и синхронизирует его с query-параметрами URL. - * @param step новый шаг редактирования - */ - public navigateToStep(step: EditStep): void { - this.currentStep.set(step); - this.router.navigate([], { - queryParams: { editingStep: step }, - queryParamsHandling: "merge", - }); - } - - /** - * Устанавливает шаг из параметра маршрута. Если передан некорректный шаг, - * по умолчанию выбирает 'main' и обновляет URL. - * @param step строка из URL или валидный EditStep - */ - public setStepFromRoute(step: string | EditStep): void { - const validSteps: EditStep[] = [ - "main", - "contacts", - "achievements", - "vacancies", - "team", - "additional", - ]; - - if (step && validSteps.includes(step as EditStep)) { - // Устанавливаем корректный шаг без изменения URL - this.currentStep.set(step as EditStep); - } else { - // Сбрасываем на основной шаг и обновляем URL - this.currentStep.set("main"); - this.router.navigate([], { - queryParams: { editingStep: "main" }, - queryParamsHandling: "merge", - }); - } - } -} diff --git a/projects/social_platform/src/app/office/projects/edit/services/project-team.service.ts b/projects/social_platform/src/app/office/projects/edit/services/project-team.service.ts deleted file mode 100644 index be1597dd9..000000000 --- a/projects/social_platform/src/app/office/projects/edit/services/project-team.service.ts +++ /dev/null @@ -1,241 +0,0 @@ -/** @format */ - -import { computed, inject, Injectable, signal } from "@angular/core"; -import { FormBuilder, FormGroup, Validators } from "@angular/forms"; -import { ValidationService } from "@corelib"; -import { Collaborator } from "@office/models/collaborator.model"; -import { Invite } from "@office/models/invite.model"; -import { InviteService } from "@services/invite.service"; - -/** - * Сервис для управления приглашениями участников команды проекта. - * Предоставляет функциональность для создания и валидации формы приглашения, - * отправки, редактирования и удаления приглашений, управления состоянием модального окна и ошибок. - */ -@Injectable({ providedIn: "root" }) -export class ProjectTeamService { - private inviteForm!: FormGroup; - private readonly fb = inject(FormBuilder); - private readonly inviteService = inject(InviteService); - private readonly validationService = inject(ValidationService); - - public readonly invites = signal([]); - public readonly collaborators = signal([]); - public readonly isInviteModalOpen = signal(false); - public readonly inviteNotExistingError = signal(null); - - // Состояние отправки формы - readonly inviteSubmitInitiated = signal(false); - readonly inviteFormIsSubmitting = signal(false); - - constructor() { - this.initializeInviteForm(); - } - - /** - * Создает форму приглашения с контролами role, link и specialization, устанавливая валидаторы. - */ - private initializeInviteForm(): void { - this.inviteForm = this.fb.group({ - role: ["", [Validators.required]], - link: [ - "", - [ - Validators.required, - Validators.pattern(/^http(s)?:\/\/.+(:[0-9]*)?\/office\/profile\/\d+$/), - ], - ], - specialization: [null], - }); - } - - /** - * Возвращает инстанс формы приглашения. - * @returns FormGroup inviteForm - */ - public getInviteForm(): FormGroup { - return this.inviteForm; - } - - /** - * Устанавливает список приглашений. - * @param invites массив Invite - */ - public setInvites(invites: Invite[]): void { - this.invites.set(invites); - } - - /** - * Устанавливает список команды - * @param collaborators массив Collaborator - */ - public setCollaborators(collaborators: Collaborator[]): void { - this.collaborators.set(collaborators); - } - - /** - * Возвращает текущий список команды. - * @returns Collaborator[] массив команды - */ - public getCollaborators(): Collaborator[] { - return this.collaborators(); - } - - /** - * Возвращает текущий список приглашений. - * @returns Invite[] массив приглашений - */ - public getInvites(): Invite[] { - return this.invites(); - } - - // Геттеры для контролов формы приглашения - public get role() { - return this.inviteForm.get("role"); - } - - public get link() { - return this.inviteForm.get("link"); - } - - public get specialization() { - return this.inviteForm.get("specialization"); - } - - /** - * Открывает модальное окно для отправки приглашения. - */ - public openInviteModal(): void { - this.isInviteModalOpen.set(true); - } - - /** - * Закрывает модальное окно для отправки приглашения. - */ - public closeInviteModal(): void { - this.isInviteModalOpen.set(false); - } - - /** - * Сбрасывает ошибку отсутствия пользователя при изменении ссылки. - */ - public clearLinkError(): void { - if (this.inviteNotExistingError()) { - this.inviteNotExistingError.set(null); - } - } - - /** - * Отправляет приглашение пользователю по ссылке. - * @returns результат отправки - */ - public submitInvite(projectId: number): void { - this.inviteSubmitInitiated.set(true); - // Проверка валидности формы - if (!this.validationService.getFormValidation(this.inviteForm)) { - return; - } - - this.inviteFormIsSubmitting.set(true); - - // Извлечение profileId из URL ссылки - const linkUrl = new URL(this.inviteForm.get("link")?.value); - const pathSegments = linkUrl.pathname.split("/"); - const profileId = Number(pathSegments[pathSegments.length - 1]); - - this.inviteService - .sendForUser( - profileId, - projectId, - this.inviteForm.get("role")?.value, - this.inviteForm.get("specialization")?.value - ) - .subscribe({ - next: invite => { - this.invites.update(list => [...list, invite]); - this.resetInviteForm(); - this.closeInviteModal(); - }, - error: err => { - this.inviteNotExistingError.set(err); - this.inviteFormIsSubmitting.set(false); - }, - }); - } - - /** - * Обновляет параметры существующего приглашения. - * @param params объект с inviteId, role и specialization - */ - public editInvitation(params: { inviteId: number; role: string; specialization: string }): void { - const { inviteId, role, specialization } = params; - this.inviteService.updateInvite(inviteId, role, specialization).subscribe(() => { - this.invites.update(list => - list.map(i => (i.id === inviteId ? { ...i, role, specialization } : i)) - ); - }); - } - - /** - * Удаляет приглашение по идентификатору. - * @param invitationId идентификатор приглашения - */ - public removeInvitation(invitationId: number): void { - this.inviteService.revokeInvite(invitationId).subscribe(() => { - this.invites.update(list => list.filter(i => i.id !== invitationId)); - }); - } - - /** - * Удаляет участника по идентификатору. - * @param collaboratorId идентификатор приглашения - */ - public removeCollaborator(collaboratorId: number): void { - this.collaborators.update(list => list.filter(i => i.userId !== collaboratorId)); - } - - /** - * Проверяет валидность формы приглашения. - * @returns boolean true если форма валидна - */ - public validateInviteForm(): boolean { - return this.inviteForm.valid; - } - - /** - * Возвращает текущее значение формы приглашения. - * @returns any объект значений формы - */ - public getInviteFormValue(): any { - return this.inviteForm.value; - } - - /** - * Сбрасывает форму приглашения и очищает ошибки. - */ - public resetInviteForm(): void { - this.inviteForm.reset(); - Object.keys(this.inviteForm.controls).forEach(name => { - const ctrl = this.inviteForm.get(name); - ctrl?.clearValidators(); - ctrl?.markAsPristine(); - ctrl?.updateValueAndValidity(); - }); - this.inviteNotExistingError.set(null); - this.inviteFormIsSubmitting.set(false); - } - - /** - * Настроивает динамическую валидацию для поля link: - * сбрасывает валидаторы при пустом значении и очищает ошибку. - */ - public setupDynamicValidation(): void { - this.inviteForm.get("link")?.valueChanges.subscribe(value => { - if (value === "") { - this.inviteForm.get("link")?.clearValidators(); - this.inviteForm.get("link")?.updateValueAndValidity(); - } - this.clearLinkError(); - }); - } -} diff --git a/projects/social_platform/src/app/office/projects/edit/services/project-vacancy.service.ts b/projects/social_platform/src/app/office/projects/edit/services/project-vacancy.service.ts deleted file mode 100644 index 0eb24ec3d..000000000 --- a/projects/social_platform/src/app/office/projects/edit/services/project-vacancy.service.ts +++ /dev/null @@ -1,299 +0,0 @@ -/** @format */ - -import { inject, Injectable, signal } from "@angular/core"; -import { FormBuilder, FormGroup, Validators } from "@angular/forms"; -import { ActivatedRoute } from "@angular/router"; -import { ValidationService } from "@corelib"; -import { Skill } from "@office/models/skill.model"; -import { Vacancy } from "@office/models/vacancy.model"; -import { VacancyService } from "@office/services/vacancy.service"; -import { stripNullish } from "@utils/stripNull"; -import { rolesMembersList } from "projects/core/src/consts/lists/roles-members-list.const"; -import { ProjectFormService } from "./project-form.service"; -import { workExperienceList } from "projects/core/src/consts/lists/work-experience-list.const"; -import { workFormatList } from "projects/core/src/consts/lists/work-format-list.const"; -import { workScheludeList } from "projects/core/src/consts/lists/work-schelude-list.const"; - -/** - * Сервис для управления вакансиями проекта. - * Обеспечивает создание, валидацию, отправку, - * редактирование и удаление вакансий, а также работу с формой вакансии - * и синхронизацию с API. - */ -@Injectable({ providedIn: "root" }) -export class ProjectVacancyService { - /** Форма для создания и редактирования вакансии */ - private vacancyForm!: FormGroup; - private readonly fb = inject(FormBuilder); - private readonly route = inject(ActivatedRoute); - private readonly vacancyService = inject(VacancyService); - private readonly projectFormService = inject(ProjectFormService); - private readonly validationService = inject(ValidationService); - - /** Константы для выпадающих списков */ - public readonly workExperienceList = workExperienceList; - public readonly workFormatList = workFormatList; - public readonly workScheludeList = workScheludeList; - public readonly rolesMembersList = rolesMembersList; - - /** Сигналы для выбранных значений селектов */ - public readonly selectedRequiredExperienceId = signal(undefined); - public readonly selectedWorkFormatId = signal(undefined); - public readonly selectedWorkScheduleId = signal(undefined); - public readonly selectedVacanciesSpecializationId = signal(undefined); - - // Состояние отправки формы - readonly vacancySubmitInitiated = signal(false); - readonly vacancyIsSubmitting = signal(false); - - public vacancies = signal([]); - public onEditClicked = signal(false); - - constructor() { - this.initializeVacancyForm(); - } - - /** - * Инициализирует форму вакансии с необходимыми контролами и без валидаторов. - */ - private initializeVacancyForm(): void { - this.vacancyForm = this.fb.group({ - role: [null], - skills: [[]], - description: ["", [Validators.maxLength(3500)]], - requiredExperience: [null], - workFormat: [null], - salary: [""], - workSchedule: [null], - specialization: [null], - }); - } - - /** - * Возвращает форму вакансии. - * @returns FormGroup экземпляр формы вакансии - */ - public getVacancyForm(): FormGroup { - return this.vacancyForm; - } - - /** - * Устанавливает список вакансий. - * @param vacancies массив объектов Vacancy - */ - public setVacancies(vacancies: Vacancy[]): void { - this.vacancies.set(vacancies); - } - - /** - * Возвращает текущий список вакансий. - * @returns Vacancy[] массив вакансий - */ - public getVacancies(): Vacancy[] { - return this.vacancies(); - } - - /** - * Проставляет значения в форму вакансии. - * @param values частичные поля Vacancy для патчинга - */ - public patchFormValues(values: Partial): void { - this.vacancyForm.patchValue(values); - } - - /** - * Проверяет валидность формы вакансии. - * @returns true если форма валидна - */ - public validateForm(): boolean { - return this.vacancyForm.valid; - } - - /** - * Возвращает очищенные от nullish значения формы. - * @returns объект значений формы без null и undefined - */ - public getFormValue(): any { - return stripNullish(this.vacancyForm.value); - } - - // Геттеры для быстрого доступа к контролам формы - public get role() { - return this.vacancyForm.get("role"); - } - - public get skills() { - return this.vacancyForm.get("skills"); - } - - public get description() { - return this.vacancyForm.get("description"); - } - - public get requiredExperience() { - return this.vacancyForm.get("requiredExperience"); - } - - public get workFormat() { - return this.vacancyForm.get("workFormat"); - } - - public get salary() { - return this.vacancyForm.get("salary"); - } - - public get workSchedule() { - return this.vacancyForm.get("workSchedule"); - } - - public get specialization() { - return this.vacancyForm.get("specialization"); - } - - /** - * Отправляет форму вакансии: настраивает валидаторы, проверяет форму, - * создаёт вакансию через API и сбрасывает форму. - * @returns Promise - true при успехе, false при ошибке валидации или API - */ - public submitVacancy(projectId: number) { - // Настройка валидаторов для обязательных полей - this.vacancyForm.get("role")?.setValidators([Validators.required]); - this.vacancyForm.get("skills")?.setValidators([Validators.required]); - this.vacancyForm.get("requiredExperience")?.setValidators([Validators.required]); - this.vacancyForm.get("workFormat")?.setValidators([Validators.required]); - this.vacancyForm.get("workSchedule")?.setValidators([Validators.required]); - this.vacancyForm - .get("salary") - ?.setValidators([Validators.pattern("^(\\d{1,3}( \\d{3})*|\\d+)$")]); - - // Обновление валидности и отображение ошибок - Object.keys(this.vacancyForm.controls).forEach(name => { - const ctrl = this.vacancyForm.get(name); - ctrl?.updateValueAndValidity(); - if (["role", "skills"].includes(name)) ctrl?.markAsTouched(); - }); - - this.vacancySubmitInitiated.set(true); - - // Проверка валидации формы - if (!this.validationService.getFormValidation(this.vacancyForm)) { - return; - } - - // Подготовка payload для API - this.vacancyIsSubmitting.set(true); - - const vacancy = this.vacancyForm.value; - const payload = { - ...vacancy, - requiredSkillsIds: vacancy.skills.map((s: Skill) => s.id), - salary: typeof vacancy.salary === "string" ? +vacancy.salary : null, - }; - - // Вызов API для создания вакансии - this.vacancyService.postVacancy(projectId, payload).subscribe({ - next: vacancy => { - this.vacancies.update(list => [...list, vacancy]); - this.resetVacancyForm(); - }, - error: () => { - this.vacancyIsSubmitting.set(false); - }, - }); - } - - /** - * Сбрасывает форму вакансии к начальному состоянию: - * очищает значения, валидаторы и состояния контролов, - * сбрасывает сигналы выбранных селектов. - */ - private resetVacancyForm(): void { - this.vacancyForm.reset(); - Object.keys(this.vacancyForm.controls).forEach(name => { - const ctrl = this.vacancyForm.get(name); - ctrl?.reset(name === "skills" ? [] : ""); - ctrl?.clearValidators(); - ctrl?.markAsPristine(); - ctrl?.updateValueAndValidity(); - }); - this.selectedRequiredExperienceId.set(undefined); - this.selectedWorkFormatId.set(undefined); - this.selectedWorkScheduleId.set(undefined); - this.selectedVacanciesSpecializationId.set(undefined); - this.vacancyIsSubmitting.set(false); - } - - /** - * Удаляет вакансию по её идентификатору с подтверждением пользователя. - * @param vacancyId идентификатор вакансии для удаления - */ - public removeVacancy(vacancyId: number): void { - if (!confirm("Вы точно хотите удалить вакансию?")) return; - this.vacancyService.deleteVacancy(vacancyId).subscribe(() => { - this.vacancies.update(list => list.filter(v => v.id !== vacancyId)); - }); - } - - /** - * Инициализирует редактирование вакансии по индексу в массиве: - * заполняет форму, выставляет сигналы и переключает режим редактирования. - * @param index индекс вакансии в списке vacancies - */ - public editVacancy(index: number): void { - const item = this.vacancies()[index]; - // Установка выбранных значений селектов по сопоставлению - this.workExperienceList.find(e => e.value === item.requiredExperience) && - this.selectedRequiredExperienceId.set( - this.workExperienceList.find(e => e.value === item.requiredExperience)!.id - ); - - this.workFormatList.find(f => f.value === item.workFormat) && - this.selectedWorkFormatId.set(this.workFormatList.find(f => f.value === item.workFormat)!.id); - - this.workScheludeList.find(s => s.value === item.workSchedule) && - this.selectedWorkScheduleId.set( - this.workScheludeList.find(s => s.value === item.workSchedule)!.id - ); - - this.rolesMembersList.find(r => r.value === item.specialization) && - this.selectedVacanciesSpecializationId.set( - this.rolesMembersList.find(r => r.value === item.specialization)!.id - ); - - // Патчинг формы значениями вакансии - this.vacancyForm.patchValue({ - role: item.role, - skills: item.requiredSkills, - description: item.description, - requiredExperience: item.requiredExperience, - workFormat: item.workFormat, - salary: item.salary ?? null, - workSchedule: item.workSchedule, - specialization: item.specialization, - }); - this.projectFormService.editIndex.set(index); - this.onEditClicked.set(true); - } - - /** - * Добавляет навык к списку requiredSkills, если его там нет. - * @param newSkill объект Skill для добавления - */ - public onAddSkill(newSkill: Skill): void { - const skills: Skill[] = this.vacancyForm.value.skills; - if (!skills.some(s => s.id === newSkill.id)) { - this.vacancyForm.patchValue({ skills: [newSkill, ...skills] }); - } - } - - /** - * Удаляет навык из списка requiredSkills. - * @param oldSkill объект Skill для удаления - */ - public onRemoveSkill(oldSkill: Skill): void { - const skills: Skill[] = this.vacancyForm.value.skills; - this.vacancyForm.patchValue({ - skills: skills.filter(s => s.id !== oldSkill.id), - }); - } -} diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-achievement-step/project-achievement-step.component.html b/projects/social_platform/src/app/office/projects/edit/shared/project-achievement-step/project-achievement-step.component.html deleted file mode 100644 index dd1cb1c6f..000000000 --- a/projects/social_platform/src/app/office/projects/edit/shared/project-achievement-step/project-achievement-step.component.html +++ /dev/null @@ -1,82 +0,0 @@ - - -
    -
    -
      - @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 (!achievements.length) { - - } -
    -
    diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-achievement-step/project-achievement-step.component.ts b/projects/social_platform/src/app/office/projects/edit/shared/project-achievement-step/project-achievement-step.component.ts deleted file mode 100644 index feea7d422..000000000 --- a/projects/social_platform/src/app/office/projects/edit/shared/project-achievement-step/project-achievement-step.component.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { Component, inject, Input } from "@angular/core"; -import { FormArray, FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; -import { InputComponent, ButtonComponent } from "@ui/components"; -import { ControlErrorPipe } from "@corelib"; -import { ErrorMessage } from "@error/models/error-message"; -import { ProjectFormService } from "../../services/project-form.service"; -import { ProjectAchievementsService } from "../../services/project-achievements.service"; -import { IconComponent } from "@uilib"; - -@Component({ - selector: "app-project-achievement-step", - templateUrl: "./project-achievement-step.component.html", - styleUrl: "./project-achievement-step.component.scss", - standalone: true, - imports: [ - CommonModule, - ReactiveFormsModule, - InputComponent, - ButtonComponent, - IconComponent, - ControlErrorPipe, - ], -}) -export class ProjectAchievementStepComponent { - @Input() projSubmitInitiated = false; - - private readonly projectAchievementService = inject(ProjectAchievementsService); - private readonly projectFormService = inject(ProjectFormService); - private readonly fb = inject(FormBuilder); - - readonly errorMessage = ErrorMessage; - - // Состояние для показа полей ввода - public showInputFields = false; - - // Получаем форму из сервиса - get projectForm(): FormGroup { - return this.projectFormService.getForm(); - } - - // Геттеры для FormArray и полей - get achievements(): FormArray { - return this.projectFormService.achievements; - } - - get achievementsName() { - return this.projectForm.get("achievementsName"); - } - - get achievementsDate() { - return this.projectForm.get("achievementsDate"); - } - - get achievementsItems() { - return this.projectAchievementService.achievementsItems; - } - - get editIndex() { - return this.projectFormService.editIndex; - } - - /** - * Проверяет, есть ли достижения для отображения - */ - get hasAchievements(): boolean { - return this.achievementsItems().length > 0 || this.achievements.length > 0; - } - - /** - * Показывает поля для ввода достижения - */ - showFields(): void { - this.showInputFields = true; - } - - /** - * Скрывает поля ввода и очищает их - */ - hideFields(): void { - this.showInputFields = false; - this.clearInputFields(); - } - - /** - * Очищает поля ввода - */ - private clearInputFields(): void { - this.projectForm.get("achievementsName")?.reset(); - this.projectForm.get("achievementsName")?.setValue(""); - - if (this.editIndex() !== null) { - this.projectFormService.editIndex.set(null); - } - } - - /** - * Добавление достижения - */ - addAchievement(id?: number, achievementsName?: string, achievementsDate?: string): void { - const currentYear = new Date().getFullYear(); - this.achievements.push( - this.fb.group({ - id: [id], - title: [achievementsName ?? "", [Validators.required]], - status: [ - achievementsDate ?? "", - [ - Validators.required, - Validators.min(2000), - Validators.max(currentYear), - Validators.pattern(/^\d{4}$/), - ], - ], - }) - ); - - this.projectAchievementService.addAchievement(this.achievements, this.projectForm); - } - - /** - * Редактирование достижения - * @param index - индекс достижения - */ - editAchievement(index: number): void { - this.showInputFields = true; - this.projectAchievementService.editAchievement(index, this.achievements, this.projectForm); - } - - /** - * Удаление достижения - * @param index - индекс достижения - */ - removeAchievement(index: number): void { - this.projectAchievementService.removeAchievement(index, this.achievements); - } -} diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-additional-step/project-additional-step.component.html b/projects/social_platform/src/app/office/projects/edit/shared/project-additional-step/project-additional-step.component.html deleted file mode 100644 index 9840b4478..000000000 --- a/projects/social_platform/src/app/office/projects/edit/shared/project-additional-step/project-additional-step.component.html +++ /dev/null @@ -1,127 +0,0 @@ - - -
    -
    -
    - @if (partnerProgramFields.length) { @for (field of partnerProgramFields; track field.id) { - -
    - @switch (field.fieldType) { @case ("text") { @if (additionalForm.get(field.name); as - control) { - - - } } @case ("textarea") { - - @if (additionalForm.get(field.name); as control) { - - } } @case ("checkbox") { -
    - - -
    - } @case ("radio") { @if (additionalForm.get(field.name); as control) { - - } - - } @case ("select") { - - - } } @if (additionalForm.get(field.name); as control) { @if (control | controlError: - "required") { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    - } } -
    - } } @else { @if (isProjectAssignToProgram) { -
    -

    - проект привязан к программе, но дополнительных полей для заполнения нет -

    - -
    - } @else { -
    -

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

    - - - программы - - - - - -
    - } } -
    -
    -
    diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-additional-step/project-additional-step.component.ts b/projects/social_platform/src/app/office/projects/edit/shared/project-additional-step/project-additional-step.component.ts deleted file mode 100644 index e4e0486a3..000000000 --- a/projects/social_platform/src/app/office/projects/edit/shared/project-additional-step/project-additional-step.component.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { Component, Input, OnInit, inject, ChangeDetectorRef } from "@angular/core"; -import { FormGroup, ReactiveFormsModule } from "@angular/forms"; -import { - InputComponent, - CheckboxComponent, - SelectComponent, - ButtonComponent, -} from "@ui/components"; -import { TextareaComponent } from "@ui/components/textarea/textarea.component"; -import { SwitchComponent } from "@ui/components/switch/switch.component"; -import { ControlErrorPipe } from "@corelib"; -import { ErrorMessage } from "@error/models/error-message"; -import { ToSelectOptionsPipe } from "projects/core/src/lib/pipes/options-transform.pipe"; -import { ProjectAdditionalService } from "../../services/project-additional.service"; -import { PartnerProgramFields } from "@office/models/partner-program-fields.model"; -import { RouterLink } from "@angular/router"; -import { IconComponent } from "@uilib"; -import { TooltipComponent } from "@ui/components/tooltip/tooltip.component"; - -@Component({ - selector: "app-project-additional-step", - templateUrl: "./project-additional-step.component.html", - styleUrl: "./project-additional-step.component.scss", - standalone: true, - imports: [ - CommonModule, - ReactiveFormsModule, - InputComponent, - IconComponent, - CheckboxComponent, - SwitchComponent, - SelectComponent, - TextareaComponent, - ControlErrorPipe, - ToSelectOptionsPipe, - ButtonComponent, - RouterLink, - TooltipComponent, - ], -}) -export class ProjectAdditionalStepComponent implements OnInit { - private readonly projectAdditionalService = inject(ProjectAdditionalService); - private readonly cdRef = inject(ChangeDetectorRef); - - readonly errorMessage = ErrorMessage; - - @Input() isProjectAssignToProgram?: boolean; - - ngOnInit(): void { - // Инициализация уже должна быть выполнена в родительском компоненте - this.cdRef.detectChanges(); - } - - // Геттеры для получения данных из сервиса - get additionalForm(): FormGroup { - return this.projectAdditionalService.getAdditionalForm(); - } - - get partnerProgramFields(): PartnerProgramFields[] { - return this.projectAdditionalService.getPartnerProgramFields(); - } - - get isSendingDecision() { - return this.projectAdditionalService.getIsSendingDecision(); - } - - get isAssignProjectToProgramError() { - return this.projectAdditionalService.getIsAssignProjectToProgramError(); - } - - get errorAssignProjectToProgramModalMessage() { - return this.projectAdditionalService.getErrorAssignProjectToProgramModalMessage(); - } - - /** Наличие подсказки */ - haveHint = false; - - /** Текст для подсказки */ - tooltipText?: string; - - /** Позиция подсказки */ - tooltipPosition: "left" | "right" = "right"; - - /** Состояние видимости подсказки */ - isTooltipVisible = false; - - /** Показать подсказку */ - showTooltip(): void { - this.isTooltipVisible = true; - } - - /** Скрыть подсказку */ - hideTooltip(): void { - this.isTooltipVisible = false; - } - - /** - * Переключение значения для checkbox и radio полей - * @param fieldType - тип поля - * @param fieldName - имя поля - */ - toggleAdditionalFormValues( - fieldType: "text" | "textarea" | "checkbox" | "select" | "radio" | "file", - fieldName: string - ): void { - this.projectAdditionalService.toggleAdditionalFormValues(fieldType, fieldName); - } -} diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-main-step/project-main-step.component.html b/projects/social_platform/src/app/office/projects/edit/shared/project-main-step/project-main-step.component.html deleted file mode 100644 index 8e291bab7..000000000 --- a/projects/social_platform/src/app/office/projects/edit/shared/project-main-step/project-main-step.component.html +++ /dev/null @@ -1,466 +0,0 @@ - - -
    -
    -
    - @if (imageAddress; as imageAddress) { -
    - - - @if ((imageAddress | controlError: "required") && projSubmitInitiated) { -
    - {{ errorMessage.EMPTY_AVATAR }} -
    - } -
    - } @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 (trl; as trl) { -
    - - @if (trl | controlError: "required") { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    - } -
    - } -
    - -
    - @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 (implementationDeadline; as implementationDeadline) { -
    - - - @if ((implementationDeadline | controlError: "required") && projSubmitInitiated) { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    - } -
    - } -
    -
    - - @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 (hasGoals) { - - } - - -
    -
    - -

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

    - -
    -
      - @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.ts b/projects/social_platform/src/app/office/projects/edit/shared/project-main-step/project-main-step.component.ts deleted file mode 100644 index b499a4bc1..000000000 --- a/projects/social_platform/src/app/office/projects/edit/shared/project-main-step/project-main-step.component.ts +++ /dev/null @@ -1,312 +0,0 @@ -/** @format */ - -import { Component, Input, inject, OnInit, OnDestroy, signal } from "@angular/core"; -import { - FormArray, - FormBuilder, - FormGroup, - FormsModule, - ReactiveFormsModule, - Validators, -} from "@angular/forms"; -import { AuthService } from "@auth/services"; -import { ErrorMessage } from "@error/models/error-message"; -import { directionProjectList } from "projects/core/src/consts/lists/ldirection-project-list.const"; -import { trackProjectList } from "projects/core/src/consts/lists/track-project-list.const"; -import { Observable, Subscription } from "rxjs"; -import { AvatarControlComponent } from "@ui/components/avatar-control/avatar-control.component"; -import { InputComponent, SelectComponent, ButtonComponent } from "@ui/components"; -import { TextareaComponent } from "@ui/components/textarea/textarea.component"; -import { UploadFileComponent } from "@ui/components/upload-file/upload-file.component"; -import { AsyncPipe, CommonModule } from "@angular/common"; -import { ControlErrorPipe } from "@corelib"; -import { ProjectFormService } from "../../services/project-form.service"; -import { IconComponent } from "@uilib"; -import { ProjectContactsService } from "../../services/project-contacts.service"; -import { ProjectGoalService } from "../../services/project-goals.service"; -import { ModalComponent } from "@ui/components/modal/modal.component"; -import { ProjectTeamService } from "../../services/project-team.service"; -import { AvatarComponent } from "@ui/components/avatar/avatar.component"; -import { ProjectService } from "@office/services/project.service"; -import { RouterLink } from "@angular/router"; -import { generateOptionsList } from "@utils/generate-options-list"; -import { optionalUrlOrMentionValidator } from "@utils/optionalUrl.validator"; - -@Component({ - selector: "app-project-main-step", - templateUrl: "./project-main-step.component.html", - styleUrl: "./project-main-step.component.scss", - standalone: true, - imports: [ - CommonModule, - ReactiveFormsModule, - AvatarControlComponent, - InputComponent, - SelectComponent, - IconComponent, - TextareaComponent, - ButtonComponent, - UploadFileComponent, - AsyncPipe, - ControlErrorPipe, - ModalComponent, - AvatarComponent, - FormsModule, - RouterLink, - ], -}) -export class ProjectMainStepComponent implements OnInit, OnDestroy { - @Input() industries$!: Observable; - @Input() leaderId = 0; - @Input() projSubmitInitiated = false; - @Input() projectId!: number; - @Input() isProjectBoundToProgram = false; - - private subscription = new Subscription(); - - readonly authService = inject(AuthService); - private readonly projectService = inject(ProjectService); - private readonly projectFormService = inject(ProjectFormService); - private readonly projectContactsService = inject(ProjectContactsService); - private readonly projectGoalsService = inject(ProjectGoalService); - private readonly projectTeamService = inject(ProjectTeamService); - private readonly fb = inject(FormBuilder); - - readonly errorMessage = ErrorMessage; - readonly trackList = trackProjectList; - readonly directionList = directionProjectList; - readonly trlList = generateOptionsList(9, "numbers"); - - goalLeaderShowModal = false; - activeGoalIndex = signal(null); - selectedLeaderId = ""; - - // Получаем форму из сервиса - get projectForm(): FormGroup { - return this.projectFormService.getForm(); - } - - get goalForm(): FormGroup { - return this.projectGoalsService.getForm(); - } - - ngOnInit(): void {} - - ngOnDestroy(): void { - this.subscription.unsubscribe(); - } - - // Геттеры для удобного доступа к контролам формы - get name() { - return this.projectFormService.name; - } - - get region() { - return this.projectFormService.region; - } - - get industry() { - return this.projectFormService.industry; - } - - get description() { - return this.projectFormService.description; - } - - get actuality() { - return this.projectFormService.actuality; - } - - get implementationDeadline() { - return this.projectFormService.implementationDeadline; - } - - get problem() { - return this.projectFormService.problem; - } - - get targetAudience() { - return this.projectFormService.targetAudience; - } - - get trl() { - return this.projectFormService.trl; - } - - get presentationAddress() { - return this.projectFormService.presentationAddress; - } - - get coverImageAddress() { - return this.projectFormService.coverImageAddress; - } - - get imageAddress() { - return this.projectFormService.imageAddress; - } - - get partnerProgramId() { - return this.projectFormService.partnerProgramId; - } - - // Геттеры для работы со ссылками - get link() { - return this.projectContactsService.link; - } - - get links(): FormArray { - return this.projectForm.get("links") as FormArray; - } - - // Геттеры для работы с целями - get goals(): FormArray { - return this.projectGoalsService.goals; - } - - get goalItems() { - return this.projectGoalsService.goalItems; - } - - get goalName() { - return this.projectGoalsService.goalName; - } - - get goalDate() { - return this.projectGoalsService.goalDate; - } - - get goalLeader() { - return this.projectGoalsService.goalLeader; - } - - get editIndex() { - return this.projectFormService.editIndex; - } - - get collaborators() { - return this.projectTeamService.getCollaborators(); - } - - /** - * Проверяет, есть ли ссылки для отображения - */ - get hasLinks(): boolean { - return this.links.length > 0; - } - - /** - * Проверяет, есть ли цели для отображения - */ - get hasGoals(): boolean { - return this.goals.length > 0; - } - - /** - * Добавление ссылки - */ - addLink(): void { - this.links.push(this.fb.control("", optionalUrlOrMentionValidator)); - } - - /** - * Редактирование ссылки - * @param index - индекс ссылки - */ - editLink(index: number): void { - this.projectContactsService.editLink(index, this.links, this.projectForm); - } - - /** - * Удаление ссылки - * @param index - индекс ссылки - */ - removeLink(index: number): void { - this.links.removeAt(index); - } - - /** - * Добавление цели - */ - addGoal(goalName?: string, goalDate?: string, goalLeader?: string): void { - this.goals.push( - this.fb.group({ - title: [goalName, [Validators.required]], - completionDate: [goalDate, [Validators.required]], - responsible: [goalLeader, [Validators.required]], - }) - ); - - this.projectGoalsService.addGoal(goalName, goalDate, goalLeader); - } - - /** - * Удаление цели - * @param index - индекс цели - */ - removeGoal(index: number, goalId: number): void { - this.projectGoalsService.removeGoal(index); - this.projectService.deleteGoals(this.projectId, goalId).subscribe(); - } - - /** - * Получить выбранного лидера для конкретной цели - */ - getSelectedLeaderForGoal(goalIndex: number) { - const goalFormGroup = this.goals.at(goalIndex); - const leaderId = goalFormGroup?.get("responsible")?.value; - - if (!leaderId) return null; - - return this.collaborators.find(collab => collab.userId.toString() === leaderId.toString()); - } - - /** - * Обработчик изменения радио-кнопки для выбора лидера - */ - onLeaderRadioChange(event: Event): void { - const target = event.target as HTMLInputElement; - this.selectedLeaderId = target.value; - } - - /** - * Добавление лидера на определенную цель - */ - addLeader(): void { - const goalIndex = this.activeGoalIndex(); - - if (goalIndex === null) { - return; - } - - if (!this.selectedLeaderId) { - return; - } - - // Устанавливаем выбранного лидера в форму - const goalFormGroup = this.goals.at(goalIndex); - goalFormGroup?.get("responsible")?.setValue(Number(this.selectedLeaderId)); - - this.toggleGoalLeaderModal(); - this.selectedLeaderId = ""; - } - - /** - * Переключатель для модалки выбора лидера - */ - toggleGoalLeaderModal(index?: number): void { - this.goalLeaderShowModal = !this.goalLeaderShowModal; - - if (index !== undefined) { - this.activeGoalIndex.set(index); - const currentLeader = this.goals.at(index)?.get("responsible")?.value; - this.selectedLeaderId = currentLeader || ""; - } else { - this.activeGoalIndex.set(null); - this.selectedLeaderId = ""; - } - } - - trackByIndex(index: number): number { - return index; - } -} diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-navigation/project-navigation.component.html b/projects/social_platform/src/app/office/projects/edit/shared/project-navigation/project-navigation.component.html deleted file mode 100644 index 87246e721..000000000 --- a/projects/social_platform/src/app/office/projects/edit/shared/project-navigation/project-navigation.component.html +++ /dev/null @@ -1,25 +0,0 @@ - - - diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-navigation/project-navigation.component.ts b/projects/social_platform/src/app/office/projects/edit/shared/project-navigation/project-navigation.component.ts deleted file mode 100644 index 112d15c55..000000000 --- a/projects/social_platform/src/app/office/projects/edit/shared/project-navigation/project-navigation.component.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** @format */ - -import { Component, inject, Output, EventEmitter } from "@angular/core"; -import { EditStep, ProjectStepService } from "../../services/project-step.service"; -import { IconComponent } from "@uilib"; -import { CommonModule } from "@angular/common"; -import { navProjectItems } from "projects/core/src/consts/navigation/nav-project-items.const"; - -@Component({ - selector: "app-project-navigation", - templateUrl: "./project-navigation.component.html", - styleUrl: "project-navigation.component.scss", - standalone: true, - imports: [IconComponent, CommonModule], -}) -export class ProjectNavigationComponent { - @Output() stepChange = new EventEmitter(); - - readonly navProjectItems = navProjectItems; - private stepService = inject(ProjectStepService); - - currentStep = this.stepService.getCurrentStep(); - - onStepClick(step: EditStep): void { - this.stepChange.emit(step); - } -} diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-partner-resources-step/project-partner-resources-step.component.html b/projects/social_platform/src/app/office/projects/edit/shared/project-partner-resources-step/project-partner-resources-step.component.html deleted file mode 100644 index 18ae67739..000000000 --- a/projects/social_platform/src/app/office/projects/edit/shared/project-partner-resources-step/project-partner-resources-step.component.html +++ /dev/null @@ -1,193 +0,0 @@ - - -
    -
    - @if (hasPartners) { -
      - @for (control of partners.controls; track i; let i = $index) { -
    • -
      -
      - @if (partners.at(i)?.get("name"); as name) { -
      - - - @if (name | controlError: "required") { -
      - {{ errorMessage.VALIDATION_REQUIRED }} -
      - } -
      - } @if (partners.at(i)?.get("inn"); as inn) { -
      - - - @if (inn | controlError: "required") { -
      - {{ errorMessage.VALIDATION_REQUIRED }} -
      - } -
      - } -
      - - @if (partners.at(i)?.get("contribution"); as contribution) { -
      - - - @if (contribution | controlError: "required") { -
      - {{ errorMessage.VALIDATION_REQUIRED }} -
      - } -
      - } @if (partners.at(i)?.get("decisionMaker"); as decisionMaker) { -
      - - - @if (decisionMaker | controlError: "required") { -
      - {{ errorMessage.VALIDATION_REQUIRED }} -
      - } -
      - } -
      - - - -
    • - } -
    - } - - - добавить партнера - - -
    - -
    - @if (hasResources) { -
      - @for (control of resources.controls; track i; let i = $index) { -
    • -
      - @if (resources.at(i)?.get("type"); as type) { -
      - - - @if (type | controlError: "required") { -
      - {{ errorMessage.VALIDATION_REQUIRED }} -
      - } -
      - } @if (resources.at(i)?.get("description"); as description) { -
      - - - @if (description | controlError: "required") { -
      - {{ errorMessage.VALIDATION_REQUIRED }} -
      - } -
      - } @if (resources.at(i)?.get("partnerCompany"); as partnerCompany) { -
      - - - @if (partnerCompany | controlError: "required") { -
      - {{ errorMessage.VALIDATION_REQUIRED }} -
      - } -
      - } -
      - - - -
    • - } -
    - } - - - добавить ресурс - - -
    - - @if (!partners.length && !resources.length) { - - } -
    diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-partner-resources-step/project-partner-resources-step.component.ts b/projects/social_platform/src/app/office/projects/edit/shared/project-partner-resources-step/project-partner-resources-step.component.ts deleted file mode 100644 index 169f75a8f..000000000 --- a/projects/social_platform/src/app/office/projects/edit/shared/project-partner-resources-step/project-partner-resources-step.component.ts +++ /dev/null @@ -1,179 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { Component, inject, Input, OnDestroy } from "@angular/core"; -import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; -import { ErrorMessage } from "@error/models/error-message"; -import { IconComponent } from "@uilib"; -import { ProjectPartnerService } from "../../services/project-partner.service"; -import { ProjectResourceService } from "../../services/project-resources.service"; -import { ButtonComponent, InputComponent, SelectComponent } from "@ui/components"; -import { Subscription } from "rxjs"; -import { ControlErrorPipe } from "@corelib"; -import { TextareaComponent } from "@ui/components/textarea/textarea.component"; -import { ProjectService } from "@office/services/project.service"; -import { optionsListElement } from "@utils/generate-options-list"; -import { resourceOptionsList } from "projects/core/src/consts/lists/resource-options-list.const"; - -@Component({ - selector: "app-project-partner-resources-step", - templateUrl: "./project-partner-resources-step.component.html", - styleUrl: "./project-partner-resources-step.component.scss", - standalone: true, - imports: [ - CommonModule, - ReactiveFormsModule, - IconComponent, - ButtonComponent, - InputComponent, - ControlErrorPipe, - TextareaComponent, - SelectComponent, - ], -}) -export class ProjectPartnerResourcesStepComponent implements OnDestroy { - @Input() projectId!: number; - - private readonly projectPartnerService = inject(ProjectPartnerService); - private readonly projectResourceService = inject(ProjectResourceService); - private readonly projectService = inject(ProjectService); - private readonly fb = inject(FormBuilder); - - readonly errorMessage = ErrorMessage; - private subscription = new Subscription(); - - // Получаем форму из сервиса - get partnerForm(): FormGroup { - return this.projectPartnerService.getForm(); - } - - get resourceForm(): FormGroup { - return this.projectResourceService.getForm(); - } - - ngOnDestroy(): void { - this.subscription.unsubscribe(); - } - - // Геттеры для удобного доступа к контролам формы - get resources() { - return this.projectResourceService.resources; - } - - get type() { - return this.projectResourceService.resoruceType; - } - - get description() { - return this.projectResourceService.resoruceDescription; - } - - get partnerCompany() { - return this.projectResourceService.resourcePartner; - } - - get partners() { - return this.projectPartnerService.partners; - } - - get name() { - return this.projectPartnerService.partnerName; - } - - get inn() { - return this.projectPartnerService.partnerINN; - } - - get contribution() { - return this.projectPartnerService.partnerMention; - } - - get decisionMaker() { - return this.projectPartnerService.partnerProfileLink; - } - - get hasPartners() { - return this.partners.length > 0; - } - - get hasResources() { - return this.resources.length > 0; - } - - get resourcesCompanyOptions(): optionsListElement[] { - const partners = this.partners.value || []; - - const partnerOptions: optionsListElement[] = partners.map((partner: any, index: number) => { - const id = partner?.company?.id ?? partner?.id ?? index; - const value = partner?.company?.id ?? partner?.id ?? null; - const label = partner?.name; - - return { - id, - value, - label, - } as optionsListElement; - }); - - partnerOptions.push({ - id: -1, - value: "запрос к рынку", - label: "запрос к рынку", - }); - - return partnerOptions; - } - - get resourcesTypeOptions(): optionsListElement[] { - return resourceOptionsList; - } - - /** - * Добавление партнера - */ - addPartner(name?: string, inn?: string, contribution?: string, decisionMaker?: string): void { - this.partners.push( - this.fb.group({ - name: [name, [Validators.required]], - inn: [inn, [Validators.required]], - contribution: [contribution, [Validators.required]], - decisionMaker: [decisionMaker, Validators.required], - }) - ); - - this.projectPartnerService.addPartner(name, inn, contribution, decisionMaker); - } - - /** - * Удаление партнера - * @param index - индекс партнера - */ - removePartner(index: number, partnersId: number) { - this.projectPartnerService.removePartner(index); - this.projectService.deletePartner(this.projectId, partnersId).subscribe(); - } - - /** - * Добавление ресурса - */ - addResource(type?: string, description?: string, partnerCompany?: string): void { - this.resources.push( - this.fb.group({ - type: [type, [Validators.required]], - description: [description, [Validators.required]], - partnerCompany: [partnerCompany, [Validators.required]], - }) - ); - - this.projectResourceService.addResource(type, description, partnerCompany); - } - - /** - * Удаление ресурса - * @param index - индекс ресурса - */ - removeResource(index: number, resourceId: number) { - this.projectResourceService.removeResource(index); - this.projectService.deleteResource(this.projectId, resourceId).subscribe(); - } -} diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-team-step/project-team-step.component.html b/projects/social_platform/src/app/office/projects/edit/shared/project-team-step/project-team-step.component.html deleted file mode 100644 index 5bb81d584..000000000 --- a/projects/social_platform/src/app/office/projects/edit/shared/project-team-step/project-team-step.component.html +++ /dev/null @@ -1,197 +0,0 @@ - - -
    -
    -
      - @for (collaborator of collaborators; track collaborator.userId) { -
    • - -
    • - } -
    -
    - - @if (showFields) { -
    -
    -
    - @if (link; as link) { -
    - - - @if ((link | controlError: "required") && inviteSubmitInitiated()) { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    - } @if ((link | controlError: "pattern") && inviteSubmitInitiated()) { -
    - {{ errorMessage.VALIDATION_PROFILE_LINK }} -
    - } @if (inviteNotExistingError() && inviteSubmitInitiated()) { -
    - {{ errorMessage.USER_NOT_EXISTING }} либо
    - {{ errorMessage.USER_IS_LEADER }} либо
    - {{ errorMessage.USER_IS_MEMBER }} либо
    -
    - } -
    - } @if (role; as role) { -
    - - - @if ((role | controlError: "required") && inviteSubmitInitiated()) { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    - } -
    - } - - -
    -
    - - -
    - } - - - -
    -
    - - - @if (isHintTeamVisible()) { -
    -

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

    -

    подробнее

    -
    - } -
    -
    - - -
    -
    -

    Уверены, большие дела не делаются в одиночку!

    -
    - -
    -

    - После создания проекта пригласите в команду тех, кто вместе с вами будет причастен к его - реализации -

    - -

    - Вставьте ссылку на профиль участника на платформе, а также кратко напишите зону - ответственности или (при наличии) конкретную роль в команде -

    - -

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

    -
    - - спасибо, понятно -
    -
    -
    diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-team-step/project-team-step.component.ts b/projects/social_platform/src/app/office/projects/edit/shared/project-team-step/project-team-step.component.ts deleted file mode 100644 index 9e4198e18..000000000 --- a/projects/social_platform/src/app/office/projects/edit/shared/project-team-step/project-team-step.component.ts +++ /dev/null @@ -1,194 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { Component, inject, OnInit, signal } from "@angular/core"; -import { FormGroup, ReactiveFormsModule } from "@angular/forms"; -import { InputComponent, ButtonComponent, SelectComponent } from "@ui/components"; -import { ControlErrorPipe } from "@corelib"; -import { ErrorMessage } from "@error/models/error-message"; -import { InviteCardComponent } from "@office/features/invite-card/invite-card.component"; -import { ModalComponent } from "@ui/components/modal/modal.component"; -import { ProjectTeamService } from "../../services/project-team.service"; -import { rolesMembersList } from "projects/core/src/consts/lists/roles-members-list.const"; -import { ActivatedRoute } from "@angular/router"; -import { IconComponent } from "@uilib"; -import { CollaboratorCardComponent } from "@office/shared/collaborator-card/collaborator-card.component"; -import { TooltipComponent } from "@ui/components/tooltip/tooltip.component"; -import { Collaborator } from "@office/models/collaborator.model"; - -@Component({ - selector: "app-project-team-step", - templateUrl: "./project-team-step.component.html", - styleUrl: "./project-team-step.component.scss", - standalone: true, - imports: [ - CommonModule, - ReactiveFormsModule, - InputComponent, - ButtonComponent, - IconComponent, - ControlErrorPipe, - InviteCardComponent, - CollaboratorCardComponent, - TooltipComponent, - ModalComponent, - ], -}) -export class ProjectTeamStepComponent implements OnInit { - private readonly projectTeamService = inject(ProjectTeamService); - private readonly route = inject(ActivatedRoute); - - readonly errorMessage = ErrorMessage; - - // Константы для селектов - readonly rolesMembersList = rolesMembersList; - - showFields = false; - readonly isHintTeamVisible = signal(false); - readonly isHintTeamModal = signal(false); - - ngOnInit(): void { - this.projectTeamService.setInvites(this.invites); - this.projectTeamService.setCollaborators(this.collaborators); - - // Настраиваем динамическую валидацию - this.projectTeamService.setupDynamicValidation(); - } - - // Геттеры для формы - get inviteForm(): FormGroup { - return this.projectTeamService.getInviteForm(); - } - - get role() { - return this.projectTeamService.role; - } - - get link() { - return this.projectTeamService.link; - } - - get specialization() { - return this.projectTeamService.specialization; - } - - // Геттеры для данных - get invites() { - return this.projectTeamService.getInvites(); - } - - get collaborators() { - return this.projectTeamService.getCollaborators(); - } - - get invitesFill(): boolean { - return this.invites.some(inv => inv.isAccepted === null); - } - - get isInviteModalOpen() { - return this.projectTeamService.isInviteModalOpen; - } - - get inviteNotExistingError() { - return this.projectTeamService.inviteNotExistingError; - } - - get inviteSubmitInitiated() { - return this.projectTeamService.inviteSubmitInitiated; - } - - get inviteFormIsSubmitting() { - return this.projectTeamService.inviteFormIsSubmitting; - } - - /** Наличие подсказки */ - haveHint = false; - - /** Текст для подсказки */ - tooltipText?: string; - - /** Позиция подсказки */ - tooltipPosition: "left" | "right" = "right"; - - /** Состояние видимости подсказки */ - isTooltipVisible = false; - - /** Показать подсказку */ - showTooltip(): void { - this.isTooltipVisible = true; - } - - /** Скрыть подсказку */ - hideTooltip(): void { - this.isTooltipVisible = false; - } - - /** - * Открытие блоков для создания приглашения - */ - createInvitationBlock(): void { - this.showFields = true; - } - - /** - * Открытие модального окна приглашения - */ - openInviteModal(): void { - this.projectTeamService.openInviteModal(); - } - - /** - * Закрытие модального окна приглашения - */ - closeInviteModal(): void { - this.projectTeamService.closeInviteModal(); - } - - /** - * Отправка приглашения - */ - submitInvite(): void { - const projectId = Number(this.route.snapshot.paramMap.get("projectId")); - - if (this.link?.value.trim() || this.role?.value.trim()) { - this.projectTeamService.submitInvite(projectId); - this.showFields = false; - return; - } - - this.showFields = false; - } - - /** - * Редактирование приглашения - */ - editInvitation(params: { inviteId: number; role: string; specialization: string }): void { - this.projectTeamService.editInvitation(params); - } - - /** - * Удаление приглашения - */ - removeInvitation(invitationId: number): void { - this.projectTeamService.removeInvitation(invitationId); - } - - /** - * Обработка изменения состояния модального окна - */ - onModalOpenChange(open: boolean): void { - if (!open) { - this.closeInviteModal(); - } - } - - onCollaboratorRemove(collaboratorId: number): void { - this.projectTeamService.removeCollaborator(collaboratorId); - } - - openHintModal(event: Event): void { - event.preventDefault(); - this.isHintTeamVisible.set(false); - this.isHintTeamModal.set(true); - } -} diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-vacancy-step/project-vacancy-step.component.html b/projects/social_platform/src/app/office/projects/edit/shared/project-vacancy-step/project-vacancy-step.component.html deleted file mode 100644 index 2edb1cfba..000000000 --- a/projects/social_platform/src/app/office/projects/edit/shared/project-vacancy-step/project-vacancy-step.component.html +++ /dev/null @@ -1,192 +0,0 @@ - - -
    -
    - @if (showFields) { -
    - @if (role; as role) { -
    - - - @if ((role | controlError: "required") && vacancySubmitInitiated()) { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    - } -
    - } @if (description; as description) { -
    - - - @if ((description | controlError: "required") && vacancySubmitInitiated()) { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    - } -
    - } - -
    -
    - @if (requiredExperience; as requiredExperience) { -
    - - - @if (requiredExperience | controlError: "required") { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    - } -
    - } @if (workFormat; as workFormat) { -
    - - - @if (workFormat | controlError: "required") { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    - } -
    - } -
    - -
    - @if (salary; as salary) { -
    - - - @if ((salary | controlError: "required") && vacancySubmitInitiated()) { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    - } -
    - } @if (workSchedule; as workSchedule) { -
    - - - @if (workSchedule | controlError: "required") { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    - } -
    - } -
    -
    - - - - @if (skills; as skills) { -
    - - @if (skills | controlError: "required") { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    - } -
    - } -
    - } - - {{ showFields ? "добавить вакансию" : "создать вакансию" }} - - -
    - -
    -
      - @for (vacancy of vacancies; track vacancy.id) { -
    • - -
    • - } -
    -
    - - @if (!vacancies.length) { - - } -
    diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-vacancy-step/project-vacancy-step.component.scss b/projects/social_platform/src/app/office/projects/edit/shared/project-vacancy-step/project-vacancy-step.component.scss deleted file mode 100644 index 9e82d49e5..000000000 --- a/projects/social_platform/src/app/office/projects/edit/shared/project-vacancy-step/project-vacancy-step.component.scss +++ /dev/null @@ -1,103 +0,0 @@ -/** @format */ - -@use "styles/responsive"; -@use "styles/typography"; - -.project { - position: relative; - - &__inner { - width: 100%; - margin-bottom: 25px; - - @include responsive.apply-desktop { - display: flex; - gap: 20px; - justify-content: space-between; - margin-bottom: 0; - margin-bottom: 20px; - } - } - - &__inner > fieldset:not(:last-child) { - margin-bottom: 20px; - } - - &__left { - flex-basis: 60%; - margin-bottom: 20px; - } - - &__right { - flex-basis: 30%; - - :first-child & :not(span, fieldset, label, h4, p, i) { - margin-top: 26px; - margin-bottom: 10px; - } - - :last-child & :not(i, span) { - margin-top: 10px; - } - } - - &__no-items { - position: absolute; - bottom: 0%; - left: 50%; - } -} - -.invite { - &__item { - margin-bottom: 12px; - } -} - -.vacancy { - &__item { - margin-bottom: 12px; - } - - fieldset { - margin-bottom: 12px; - } - - &__form-list { - display: flex; - flex-wrap: wrap; - } - - &__skill { - margin-bottom: 12px; - - &:not(:last-child) { - margin-right: 10px; - } - } - - &__info, - &__additional { - display: flex; - gap: 20px; - align-items: center; - - :first-child, - :last-child { - flex-basis: 50%; - } - } - - &__submit { - display: block; - } -} - -.vacancies { - display: flex; - - &__input { - flex-grow: 1; - margin-right: 6px; - } -} diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-vacancy-step/project-vacancy-step.component.ts b/projects/social_platform/src/app/office/projects/edit/shared/project-vacancy-step/project-vacancy-step.component.ts deleted file mode 100644 index b5a14958e..000000000 --- a/projects/social_platform/src/app/office/projects/edit/shared/project-vacancy-step/project-vacancy-step.component.ts +++ /dev/null @@ -1,186 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { Component, inject, Input, Output, EventEmitter, OnInit } from "@angular/core"; -import { FormGroup, ReactiveFormsModule } from "@angular/forms"; -import { InputComponent, ButtonComponent, SelectComponent } from "@ui/components"; -import { ControlErrorPipe } from "@corelib"; -import { ErrorMessage } from "@error/models/error-message"; -import { AutoCompleteInputComponent } from "@ui/components/autocomplete-input/autocomplete-input.component"; -import { SkillsBasketComponent } from "@office/shared/skills-basket/skills-basket.component"; -import { VacancyCardComponent } from "@office/features/vacancy-card/vacancy-card.component"; -import { Skill } from "@office/models/skill.model"; -import { ProjectVacancyService } from "../../services/project-vacancy.service"; -import { ActivatedRoute } from "@angular/router"; -import { IconComponent } from "@uilib"; -import { TextareaComponent } from "@ui/components/textarea/textarea.component"; - -@Component({ - selector: "app-project-vacancy-step", - templateUrl: "./project-vacancy-step.component.html", - styleUrl: "./project-vacancy-step.component.scss", - standalone: true, - imports: [ - CommonModule, - ReactiveFormsModule, - InputComponent, - ButtonComponent, - IconComponent, - ControlErrorPipe, - SelectComponent, - AutoCompleteInputComponent, - SkillsBasketComponent, - VacancyCardComponent, - TextareaComponent, - ], -}) -export class ProjectVacancyStepComponent implements OnInit { - @Input() inlineSkills: Skill[] = []; - - @Output() searchSkill = new EventEmitter(); - @Output() addSkill = new EventEmitter(); - @Output() removeSkill = new EventEmitter(); - @Output() toggleSkillsGroupsModal = new EventEmitter(); - - private readonly projectVacancyService = inject(ProjectVacancyService); - private readonly route = inject(ActivatedRoute); - - readonly errorMessage = ErrorMessage; - showFields = false; - - ngOnInit(): void { - this.projectVacancyService.setVacancies(this.vacancies); - } - - // Геттеры для формы - get vacancyForm(): FormGroup { - return this.projectVacancyService.getVacancyForm(); - } - - get role() { - return this.projectVacancyService.role; - } - - get description() { - return this.projectVacancyService.description; - } - - get requiredExperience() { - return this.projectVacancyService.requiredExperience; - } - - get workFormat() { - return this.projectVacancyService.workFormat; - } - - get salary() { - return this.projectVacancyService.salary; - } - - get workSchedule() { - return this.projectVacancyService.workSchedule; - } - - get skills() { - return this.projectVacancyService.skills; - } - - get specialization() { - return this.projectVacancyService.specialization; - } - - // Геттеры для данных - get vacancies() { - return this.projectVacancyService.getVacancies(); - } - - get experienceList() { - return this.projectVacancyService.workExperienceList; - } - - get formatList() { - return this.projectVacancyService.workFormatList; - } - - get scheludeList() { - return this.projectVacancyService.workScheludeList; - } - - get rolesMembersList() { - return this.projectVacancyService.rolesMembersList; - } - - get selectedRequiredExperienceId() { - return this.projectVacancyService.selectedRequiredExperienceId; - } - - get selectedWorkFormatId() { - return this.projectVacancyService.selectedWorkFormatId; - } - - get selectedWorkScheduleId() { - return this.projectVacancyService.selectedWorkScheduleId; - } - - get selectedVacanciesSpecializationId() { - return this.projectVacancyService.selectedVacanciesSpecializationId; - } - - get vacancySubmitInitiated() { - return this.projectVacancyService.vacancySubmitInitiated; - } - - get vacancyIsSubmitting() { - return this.projectVacancyService.vacancyIsSubmitting; - } - - /** - * Отображение блока вакансий - */ - createVacancyBlock(): void { - this.showFields = true; - } - - /** - * Отправка формы вакансии - */ - submitVacancy(): void { - const projectId = Number(this.route.snapshot.paramMap.get("projectId")); - this.projectVacancyService.submitVacancy(projectId); - } - - /** - * Удаление вакансии - */ - removeVacancy(vacancyId: number): void { - this.projectVacancyService.removeVacancy(vacancyId); - } - - /** - * Редактирование вакансии - */ - editVacancy(index: number): void { - this.projectVacancyService.editVacancy(index); - } - - /** - * Обработчики событий для навыков - */ - onSearchSkill(query: string): void { - this.searchSkill.emit(query); - } - - onAddSkill(skill: Skill): void { - this.projectVacancyService.onAddSkill(skill); - this.addSkill.emit(skill); - } - - onRemoveSkill(skill: Skill): void { - this.projectVacancyService.onRemoveSkill(skill); - this.removeSkill.emit(skill); - } - - onToggleSkillsGroupsModal(): void { - this.toggleSkillsGroupsModal.emit(); - } -} diff --git a/projects/social_platform/src/app/office/projects/list/all.resolver.spec.ts b/projects/social_platform/src/app/office/projects/list/all.resolver.spec.ts deleted file mode 100644 index 24ca08b68..000000000 --- a/projects/social_platform/src/app/office/projects/list/all.resolver.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; - -import { ProjectsAllResolver } from "./all.resolver"; -import { of } from "rxjs"; -import { ProjectService } from "@services/project.service"; -import { ActivatedRouteSnapshot, RouterStateSnapshot } from "@angular/router"; - -describe("ProjectsAllResolver", () => { - beforeEach(() => { - const projectSpy = jasmine.createSpyObj({ getAll: of([]) }); - - TestBed.configureTestingModule({ - providers: [{ provide: ProjectService, useValue: projectSpy }], - }); - }); - - it("should be created", () => { - const result = TestBed.runInInjectionContext(() => - ProjectsAllResolver({} as ActivatedRouteSnapshot, {} as RouterStateSnapshot) - ); - expect(result).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/projects/list/all.resolver.ts b/projects/social_platform/src/app/office/projects/list/all.resolver.ts deleted file mode 100644 index 99866ef48..000000000 --- a/projects/social_platform/src/app/office/projects/list/all.resolver.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { Project } from "@models/project.model"; -import { ProjectService } from "@services/project.service"; -import { ApiPagination } from "@models/api-pagination.model"; -import { HttpParams } from "@angular/common/http"; -import { ResolveFn } from "@angular/router"; - -/** - * РЕЗОЛВЕР ДЛЯ ПОЛУЧЕНИЯ ВСЕХ ПРОЕКТОВ - * - * - Предзагружает данные всех доступных проектов перед активацией маршрута - * - Обеспечивает наличие данных в компоненте на момент его инициализации - * - Используется в роутинге Angular для маршрута "все проекты" - * - * @param: - * - Неявно: внедряется ProjectService через inject() - * - Параметры маршрута и состояние роутера (не используются в данной реализации) - * - * @return: - * - Observable> - пагинированный список всех проектов - * - Первая страница с лимитом 16 проектов - * - * Логика работы: - * 1. Внедряет ProjectService через функцию inject() - * 2. Вызывает метод getAll() с параметрами пагинации (limit: 16) - * 3. Возвращает Observable, который будет разрешен перед активацией маршрута - * - * Использование: - * - Подключается к маршруту в конфигурации роутера - * - Результат доступен в компоненте через route.data['data'] - * - * - Использует функциональный подход (ResolveFn) вместо класса - * - Загружает только первые 16 проектов для оптимизации производительности - * - Дополнительные проекты загружаются по мере прокрутки (infinite scroll) - */ -export const ProjectsAllResolver: ResolveFn> = () => { - const projectService = inject(ProjectService); - - return projectService.getAll(new HttpParams({ fromObject: { limit: 16 } })); -}; diff --git a/projects/social_platform/src/app/office/projects/list/invites.resolver.ts b/projects/social_platform/src/app/office/projects/list/invites.resolver.ts deleted file mode 100644 index c280a11c4..000000000 --- a/projects/social_platform/src/app/office/projects/list/invites.resolver.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { ResolveFn } from "@angular/router"; -import { Invite } from "@office/models/invite.model"; -import { InviteService } from "@office/services/invite.service"; - -/** - * Резолвер для предзагрузки приглашений пользователя - * Загружает данные о приглашениях перед инициализацией компонента офиса - * - * Принимает: - * - Контекст маршрута (неявно через Angular DI) - * - * Возвращает: - * - Observable - массив приглашений пользователя - */ -export const ProjectsInvitesResolver: ResolveFn = () => { - const inviteService = inject(InviteService); - - return inviteService.getMy(); -}; diff --git a/projects/social_platform/src/app/office/projects/list/list.component.html b/projects/social_platform/src/app/office/projects/list/list.component.html deleted file mode 100644 index 5cfd4f614..000000000 --- a/projects/social_platform/src/app/office/projects/list/list.component.html +++ /dev/null @@ -1,26 +0,0 @@ - - -
    - @if (isAll) { -
    - Фильтр - -
    - } -
      - @for (project of projects; track project.id) { - -
    • - -
    • -
      - } -
    -
    diff --git a/projects/social_platform/src/app/office/projects/list/list.component.spec.ts b/projects/social_platform/src/app/office/projects/list/list.component.spec.ts deleted file mode 100644 index f2552c331..000000000 --- a/projects/social_platform/src/app/office/projects/list/list.component.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { ProjectsListComponent } from "./list.component"; -import { RouterTestingModule } from "@angular/router/testing"; -import { of } from "rxjs"; -import { AuthService } from "@auth/services"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; - -describe("ProjectsListComponent", () => { - let component: ProjectsListComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - const authSpy = { - profile: of({}), - }; - - await TestBed.configureTestingModule({ - imports: [RouterTestingModule, HttpClientTestingModule, ProjectsListComponent], - providers: [{ provide: AuthService, useValue: authSpy }], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(ProjectsListComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/projects/list/list.component.ts b/projects/social_platform/src/app/office/projects/list/list.component.ts deleted file mode 100644 index 0eac12ccc..000000000 --- a/projects/social_platform/src/app/office/projects/list/list.component.ts +++ /dev/null @@ -1,370 +0,0 @@ -/** @format */ - -import { - AfterViewInit, - ChangeDetectorRef, - Component, - ElementRef, - inject, - OnDestroy, - OnInit, - Renderer2, - ViewChild, -} from "@angular/core"; -import { ActivatedRoute, NavigationEnd, Params, Router, RouterLink } from "@angular/router"; -import { - catchError, - concatMap, - distinctUntilChanged, - fromEvent, - map, - noop, - of, - Subscription, - switchMap, - tap, - throttleTime, -} from "rxjs"; -import { AuthService } from "@auth/services"; -import { Project } from "@models/project.model"; -import { User } from "@auth/models/user.model"; -import { NavService } from "@services/nav.service"; -import { ProjectService } from "@services/project.service"; -import { HttpParams } from "@angular/common/http"; -import { ApiPagination } from "@models/api-pagination.model"; -import { InfoCardComponent } from "../../features/info-card/info-card.component"; -import { IconComponent } from "@ui/components"; -import { SubscriptionService } from "@office/services/subscription.service"; -import { inviteToProjectMapper } from "@utils/inviteToProjectMapper"; - -/** - * КОМПОНЕНТ СПИСКА ПРОЕКТОВ - * - * Назначение: - * - Отображает список проектов в различных режимах (все/мои/подписки) - * - Реализует функциональность поиска и фильтрации проектов - * - Обеспечивает бесконечную прокрутку для загрузки дополнительных проектов - * - Управляет состоянием фильтров на мобильных устройствах - * - * 1. Отображение проектов в виде карточек - * 2. Поиск по названию проекта (используя библиотеку Fuse.js) - * 3. Фильтрация по различным критериям (индустрия, этап, количество участников и т.д.) - * 4. Создание и удаление проектов - * 5. Подписка/отписка от проектов - * 6. Адаптивный интерфейс с поддержкой свайпов на мобильных - * - * @param: - * - Данные маршрута (route.data) - предзагруженные проекты через резолверы - * - Параметры запроса (route.queryParams) - фильтры и поисковый запрос - * - Профиль пользователя (authService.profile) - * - Подписки пользователя (subscriptionService) - * - * @return - * - Отображение списка проектов - * - Навигация к детальной странице проекта - * - Создание нового проекта - * - Удаление проекта (только для владельца) - * - * Состояние компонента: - * - projects[] - полный список проектов - * - searchedProjects[] - отфильтрованный список для отображения - * - profile - данные текущего пользователя - * - isFilterOpen - состояние панели фильтров (мобильные) - * - isAll/isMy/isSubs/isInvites - флаги текущего режима просмотра - * - * Жизненный цикл: - * - OnInit: настройка подписок, инициализация данных - * - AfterViewInit: настройка обработчика прокрутки - * - OnDestroy: отписка от всех подписок - * - * - Использует RxJS для реактивного программирования - * - Реализует паттерн "бесконечная прокрутка" для оптимизации производительности - * - Поддерживает жесты свайпа для закрытия фильтров на мобильных - * - Использует Fuse.js для нечеткого поиска по названиям проектов - * - Кэширует запросы фильтрации для избежания дублирующих HTTP-запросов - */ -@Component({ - selector: "app-list", - templateUrl: "./list.component.html", - styleUrl: "./list.component.scss", - standalone: true, - imports: [IconComponent, RouterLink, InfoCardComponent], -}) -export class ProjectsListComponent implements OnInit, AfterViewInit, OnDestroy { - private readonly renderer = inject(Renderer2); - private readonly route = inject(ActivatedRoute); - private readonly authService = inject(AuthService); - private readonly navService = inject(NavService); - private readonly projectService = inject(ProjectService); - private readonly cdref = inject(ChangeDetectorRef); - private readonly router = inject(Router); - private readonly subscriptionService = inject(SubscriptionService); - - @ViewChild("filterBody") filterBody!: ElementRef; - - ngOnInit(): void { - this.navService.setNavTitle("Проекты"); - - const routeUrl$ = this.router.events.subscribe(event => { - if (event instanceof NavigationEnd) { - this.isMy = location.href.includes("/my"); - this.isAll = location.href.includes("/all"); - this.isSubs = location.href.includes("/subsription"); - this.isInvites = location.href.includes("/invites"); - } - }); - routeUrl$ && this.subscriptions$.push(routeUrl$); - - const profile$ = this.authService.profile - .pipe( - switchMap(p => { - this.profile = p; - return this.subscriptionService.getSubscriptions(p.id).pipe( - map(resp => { - this.profileProjSubsIds = resp.results.map(sub => sub.id); - }) - ); - }) - ) - .subscribe(); - - profile$ && this.subscriptions$.push(profile$); - - const querySearch$ = this.route.queryParams - .pipe(map(q => q["name__contains"])) - .subscribe(search => { - if (search !== this.currentSearchQuery) { - this.currentSearchQuery = search; - this.currentPage = 1; - } - }); - - querySearch$ && this.subscriptions$.push(querySearch$); - - if (location.href.includes("/all")) { - const observable = this.route.queryParams.pipe( - distinctUntilChanged(), - concatMap(q => { - const reqQuery = this.buildFilterQuery(q); - - if (JSON.stringify(reqQuery) !== JSON.stringify(this.previousReqQuery)) { - try { - this.previousReqQuery = reqQuery; - return this.projectService.getAll(new HttpParams({ fromObject: reqQuery })); - } catch (e) { - console.error(e); - this.previousReqQuery = reqQuery; - return this.projectService.getAll(); - } - } - - this.previousReqQuery = reqQuery; - - return of(0); - }) - ); - - const queryIndustry$ = observable.subscribe(projects => { - if (typeof projects === "number") return; - - this.projects = projects.results; - - this.cdref.detectChanges(); - }); - - queryIndustry$ && this.subscriptions$.push(queryIndustry$); - } - - const projects$ = this.route.data.pipe(map(r => r["data"])).subscribe(projects => { - this.projectsCount = projects.count; - - if (this.isInvites) { - this.projects = inviteToProjectMapper(projects ?? []); - } else { - this.projects = projects.results ?? []; - } - }); - - projects$ && this.subscriptions$.push(projects$); - } - - ngAfterViewInit(): void { - const target = document.querySelector(".office__body"); - if (target) { - const scrollEvent$ = fromEvent(target, "scroll") - .pipe( - concatMap(() => this.onScroll().pipe(catchError(() => of({})))), - throttleTime(500) - ) - .subscribe(noop); - this.subscriptions$.push(scrollEvent$); - } - } - - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $?.unsubscribe()); - } - - private buildFilterQuery(q: Params): Record { - const reqQuery: Record = {}; - - if (q["name__contains"]) { - reqQuery["name__contains"] = q["name__contains"]; - } - if (q["industry"]) { - reqQuery["industry"] = q["industry"]; - } - if (q["step"]) { - reqQuery["step"] = q["step"]; - } - if (q["membersCount"]) { - reqQuery["collaborator__count__gte"] = q["membersCount"]; - } - if (q["anyVacancies"]) { - reqQuery["any_vacancies"] = q["anyVacancies"]; - } - if (q["is_rated_by_expert"]) { - reqQuery["is_rated_by_expert"] = q["is_rated_by_expert"]; - } - if (q["is_mospolytech"]) { - reqQuery["is_mospolytech"] = q["is_mospolytech"]; - reqQuery["partner_program"] = q["partner_program"]; - } - - return reqQuery; - } - - isFilterOpen = false; - - isAll = location.href.includes("/all"); - isMy = location.href.includes("/my"); - isSubs = location.href.includes("/subscriptions"); - isInvites = location.href.includes("/invites"); - - profile?: User; - profileProjSubsIds?: number[]; - subscriptions$: Subscription[] = []; - - projectsCount = 0; - currentPage = 1; - projectsPerFetch = 15; - projects: Project[] = []; - - currentSearchQuery?: string; - - @ViewChild("listRoot") listRoot?: ElementRef; - - private previousReqQuery: Record = {}; - - onAcceptInvite(event: number): void { - this.sliceInvitesArray(event); - } - - onRejectInvite(event: number): void { - this.sliceInvitesArray(event); - } - - private sliceInvitesArray(inviteId: number): void { - const index = this.projects.findIndex(p => p.inviteId === inviteId); - if (index !== -1) { - this.projects.splice(index, 1); - this.projectsCount = Math.max(0, this.projectsCount - 1); - } - } - - private swipeStartY = 0; - private swipeThreshold = 50; - private isSwiping = false; - - onSwipeStart(event: TouchEvent): void { - this.swipeStartY = event.touches[0].clientY; - this.isSwiping = true; - } - - onSwipeMove(event: TouchEvent): void { - if (!this.isSwiping) return; - - const currentY = event.touches[0].clientY; - const deltaY = currentY - this.swipeStartY; - - const progress = Math.min(deltaY / this.swipeThreshold, 1); - this.renderer.setStyle( - this.filterBody.nativeElement, - "transform", - `translateY(${progress * 100}px)` - ); - } - - onSwipeEnd(event: TouchEvent): void { - if (!this.isSwiping) return; - - const endY = event.changedTouches[0].clientY; - const deltaY = endY - this.swipeStartY; - - if (deltaY > this.swipeThreshold) { - this.closeFilter(); - } - - this.isSwiping = false; - - this.renderer.setStyle(this.filterBody.nativeElement, "transform", "translateY(0)"); - } - - closeFilter(): void { - this.isFilterOpen = false; - } - - private onScroll() { - if (this.isSubs || this.isInvites) { - return of({}); - } - - if (this.projectsCount && this.projects.length >= this.projectsCount) return of({}); - - const target = document.querySelector(".office__body"); - if (!target || !this.listRoot) return of({}); - - const diff = - target.scrollTop - - this.listRoot.nativeElement.getBoundingClientRect().height + - window.innerHeight; - - if (diff > 0) { - return this.onFetch(this.currentPage * this.projectsPerFetch, this.projectsPerFetch).pipe( - tap(chunk => { - this.currentPage++; - this.projects = [...this.projects, ...chunk]; - - this.cdref.detectChanges(); - }) - ); - } - - return of({}); - } - - private onFetch(skip: number, take: number) { - if (this.isAll) { - const queries = this.route.snapshot.queryParams; - - const queryParams = { - offset: skip, - limit: take, - ...this.buildFilterQuery(queries), - }; - - return this.projectService.getAll(new HttpParams({ fromObject: queryParams })).pipe( - map((projects: ApiPagination) => { - return projects.results; - }) - ); - } else { - return this.projectService.getMy().pipe( - map((projects: ApiPagination) => { - this.projectsCount = projects.count; - return projects.results; - }) - ); - } - } -} diff --git a/projects/social_platform/src/app/office/projects/list/my.resolver.spec.ts b/projects/social_platform/src/app/office/projects/list/my.resolver.spec.ts deleted file mode 100644 index 672f73f11..000000000 --- a/projects/social_platform/src/app/office/projects/list/my.resolver.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; - -import { ProjectsMyResolver } from "./my.resolver"; -import { of } from "rxjs"; -import { ProjectService } from "@services/project.service"; -import { ActivatedRouteSnapshot, RouterStateSnapshot } from "@angular/router"; - -describe("ProjectsMyResolver", () => { - beforeEach(() => { - const projectSpy = jasmine.createSpyObj({ getMy: of([]) }); - - TestBed.configureTestingModule({ - providers: [{ provide: ProjectService, useValue: projectSpy }], - }); - }); - - it("should be created", () => { - const result = TestBed.runInInjectionContext(() => - ProjectsMyResolver({} as ActivatedRouteSnapshot, {} as RouterStateSnapshot) - ); - expect(result).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/projects/list/my.resolver.ts b/projects/social_platform/src/app/office/projects/list/my.resolver.ts deleted file mode 100644 index d2547601d..000000000 --- a/projects/social_platform/src/app/office/projects/list/my.resolver.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { Project } from "@models/project.model"; -import { ProjectService } from "@services/project.service"; -import { ApiPagination } from "@models/api-pagination.model"; -import { HttpParams } from "@angular/common/http"; -import { ResolveFn } from "@angular/router"; - -/** - * РЕЗОЛВЕР ДЛЯ ПОЛУЧЕНИЯ ПРОЕКТОВ ТЕКУЩЕГО ПОЛЬЗОВАТЕЛЯ - * - * Назначение: - * - Предзагружает данные проектов, принадлежащих текущему пользователю - * - Обеспечивает наличие данных в компоненте на момент его инициализации - * - Используется в роутинге Angular для маршрута "мои проекты" - * - * @params: - * - Неявно: внедряется ProjectService через inject() - * - Параметры маршрута и состояние роутера (не используются в данной реализации) - * - * @returns: - * - Observable> - пагинированный список проектов пользователя - * - Первая страница с лимитом 16 проектов - * - * 1. Внедряет ProjectService через функцию inject() - * 2. Вызывает метод getMy() с параметрами пагинации (limit: 16) - * 3. Возвращает Observable, который будет разрешен перед активацией маршрута - * - * - Подключается к маршруту в конфигурации роутера - * - Результат доступен в компоненте через route.data['data'] - * - * Особенности: - * - Использует функциональный подход (ResolveFn) вместо класса - * - Загружает только проекты текущего авторизованного пользователя - * - Загружает только первые 16 проектов для оптимизации производительности - * - Дополнительные проекты загружаются по мере прокрутки (infinite scroll) - */ - -export const ProjectsMyResolver: ResolveFn> = () => { - const projectService = inject(ProjectService); - - return projectService.getMy(new HttpParams({ fromObject: { limit: 16 } })); -}; diff --git a/projects/social_platform/src/app/office/projects/list/subscriptions.resolver.ts b/projects/social_platform/src/app/office/projects/list/subscriptions.resolver.ts deleted file mode 100644 index 0ca78a23e..000000000 --- a/projects/social_platform/src/app/office/projects/list/subscriptions.resolver.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** @format */ - -import { ResolveFn } from "@angular/router"; -import { inject } from "@angular/core"; -import { AuthService } from "@auth/services"; -import { switchMap } from "rxjs"; -import { Project } from "@office/models/project.model"; -import { SubscriptionService } from "@office/services/subscription.service"; - -/** - * РЕЗОЛВЕР ДЛЯ ПОЛУЧЕНИЯ ПОДПИСОК ПОЛЬЗОВАТЕЛЯ - * - * Назначение: - * - Предзагружает данные проектов, на которые подписан текущий пользователь - * - Обеспечивает наличие данных о подписках в компоненте на момент его инициализации - * - Используется в роутинге Angular для маршрута "подписки" - * - * @params - * - Неявно: внедряет AuthService и SubscriptionService через inject() - * - Параметры маршрута и состояние роутера (не используются в данной реализации) - * - * @returns: - * - Observable<{ results: Project[] }> - объект с массивом проектов-подписок - * - Структура соответствует формату API для совместимости с другими резолверами - * - * 1. Внедряет AuthService и SubscriptionService через функцию inject() - * 2. Получает профиль текущего пользователя через authService.profile - * 3. Использует switchMap для переключения на запрос подписок с ID пользователя - * 4. Вызывает subscriptionService.getSubscriptions(userId) для получения подписок - * 5. Возвращает Observable с проектами, на которые подписан пользователь - * - * - Подключается к маршруту в конфигурации роутера для страницы подписок - * - Результат доступен в компоненте через route.data['data'] - * - * Особенности: - * - Использует функциональный подход (ResolveFn) вместо класса - * - Требует авторизованного пользователя для получения подписок - * - Использует switchMap для избежания вложенных подписок - * - Возвращает данные в формате, совместимом с другими резолверами проектов - * - * - AuthService - для получения данных текущего пользователя - * - SubscriptionService - для получения списка подписок пользователя - */ -export const ProjectsSubscriptionsResolver: ResolveFn<{ results: Project[] }> = () => { - const authService = inject(AuthService); - const subscriptionService = inject(SubscriptionService); - - return authService.profile.pipe(switchMap(p => subscriptionService.getSubscriptions(p.id))); -}; diff --git a/projects/social_platform/src/app/office/projects/models/project-additional-fields.model.ts b/projects/social_platform/src/app/office/projects/models/project-additional-fields.model.ts deleted file mode 100644 index 51d13c4b2..000000000 --- a/projects/social_platform/src/app/office/projects/models/project-additional-fields.model.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** @format */ - -import { PartnerProgramFields } from "@office/models/partner-program-fields.model"; - -export class ProjectAdditionalFields { - programId!: number; - canSubmit!: boolean; - submissionDeadline!: string; - programFields!: PartnerProgramFields[]; -} diff --git a/projects/social_platform/src/app/office/projects/models/project-assign.model.ts b/projects/social_platform/src/app/office/projects/models/project-assign.model.ts deleted file mode 100644 index d4e1a816b..000000000 --- a/projects/social_platform/src/app/office/projects/models/project-assign.model.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Модель ответа привязки для полей привязанных к выбранной программе - * Содержит основную информацию о полях проекта, который учавствует в программе - * - * ProjectAssign содержит: - * - Основную информацию (id дубликата проекта привязанного к программе выбранной, название программы, id программы к которой привязываем проект дубликат) - * - * ProjectAssign содержит: - * - Основную информацию по значениям, которые содержатся в ответе при привязке дубликата проекта к программе выбранной - * - * @format - */ - -export class ProjectAssign { - newProjectId!: number; - programLinkId!: number; - partnerProgram!: string; -} diff --git a/projects/social_platform/src/app/office/projects/models/project-news.model.ts b/projects/social_platform/src/app/office/projects/models/project-news.model.ts deleted file mode 100644 index 12a069c57..000000000 --- a/projects/social_platform/src/app/office/projects/models/project-news.model.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** @format */ - -import * as dayjs from "dayjs"; -import { FileModel } from "@models/file.model"; - -/** - * Модель для новостей проекта (FeedNews) - * - * Представляет структуру новостной записи в ленте проекта. - * - * Свойства: - * - id: уникальный идентификатор новости - * - name: название/заголовок новости - * - imageAddress: URL изображения для новости - * - text: текстовое содержимое новости - * - datetimeCreated: дата и время создания - * - datetimeUpdated: дата и время последнего обновления - * - viewsCount: количество просмотров - * - likesCount: количество лайков - * - files: массив прикрепленных файлов - * - isUserLiked: флаг, лайкнул ли текущий пользователь - * - pin: флаг закрепления новости (опционально) - * - * Методы: - * - static default(): возвращает объект с тестовыми данными - * для использования в разработке и тестировании - * - * Используется для отображения новостей в ленте проекта, - * управления лайками и просмотрами. - */ -export class FeedNews { - id!: number; - name!: string; - imageAddress!: string; - text!: string; - datetimeCreated!: string; - datetimeUpdated!: string; - viewsCount!: number; - likesCount!: number; - files!: FileModel[]; - isUserLiked!: boolean; - pin?: boolean; - - static default(): FeedNews { - return { - id: 13, - name: "w98ef", - imageAddress: - "https://api.selcdn.ru/v1/SEL_228194/procollab_static/6043715490745844423/9115169748862337773.jpg", - files: [FileModel.default()], - text: "so8df", - datetimeCreated: dayjs().format(), - datetimeUpdated: dayjs().format(), - viewsCount: 234, - likesCount: 234, - isUserLiked: true, - }; - } -} diff --git a/projects/social_platform/src/app/office/projects/projects-filter/projects-filter.component.html b/projects/social_platform/src/app/office/projects/projects-filter/projects-filter.component.html deleted file mode 100644 index 5c98e3fb1..000000000 --- a/projects/social_platform/src/app/office/projects/projects-filter/projects-filter.component.html +++ /dev/null @@ -1,65 +0,0 @@ - - -
    -
    -
    -

    фильтры

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

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

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

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

    -
    - } -
    - } @if (isMy || isDashboard) { - - - } @if (isAll) { -
    -
    -
    -
    - -
    -
    - } -
    -
    -
    -
    -
    diff --git a/projects/social_platform/src/app/office/projects/projects.component.spec.ts b/projects/social_platform/src/app/office/projects/projects.component.spec.ts deleted file mode 100644 index 8baa5c909..000000000 --- a/projects/social_platform/src/app/office/projects/projects.component.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** @format */ - -// import { ComponentFixture, TestBed } from "@angular/core/testing"; -// -// import { ProjectsComponent } from "./projects.component"; -// import { RouterTestingModule } from "@angular/router/testing"; -// import { HttpClientTestingModule } from "@angular/common/http/testing"; -// import { ReactiveFormsModule } from "@angular/forms"; -// import { of } from "rxjs"; -// import { AuthService } from "../../auth/services"; -// import { ProjectService } from "../services/project.service"; -// import { User } from "../../auth/models/user.model"; -// import { Project } from "../models/project.model"; -// -// describe("ProjectsComponent", () => { -// let component: ProjectsComponent; -// let fixture: ComponentFixture; -// -// beforeEach(async () => { -// const projectSpy = { -// create: of({}), -// }; -// const authSpy = { -// profile: of({}), -// }; -// -// await TestBed.configureTestingModule({ -// imports: [RouterTestingModule, HttpClientTestingModule, ReactiveFormsModule], -// providers: [ -// { providers: ProjectService, useValue: projectSpy }, -// { providers: AuthService, useValue: authSpy }, -// ], -// declarations: [ProjectsComponent], -// }).compileComponents(); -// }); -// -// beforeEach(() => { -// fixture = TestBed.createComponent(ProjectsComponent); -// component = fixture.componentInstance; -// fixture.detectChanges(); -// }); -// -// it("should create", () => { -// expect(component).toBeTruthy(); -// }); -// }); diff --git a/projects/social_platform/src/app/office/projects/projects.component.ts b/projects/social_platform/src/app/office/projects/projects.component.ts deleted file mode 100644 index 7243d9616..000000000 --- a/projects/social_platform/src/app/office/projects/projects.component.ts +++ /dev/null @@ -1,169 +0,0 @@ -/** @format */ - -import { Component, ElementRef, OnDestroy, OnInit, Renderer2, ViewChild } from "@angular/core"; -import { NavService } from "@services/nav.service"; -import { ActivatedRoute, NavigationEnd, Router, RouterOutlet } from "@angular/router"; -import { map, Subscription } from "rxjs"; -import { FormBuilder, FormGroup, ReactiveFormsModule } from "@angular/forms"; -import { SearchComponent } from "@ui/components/search/search.component"; -import { ButtonComponent, IconComponent } from "@ui/components"; -import { BarNewComponent } from "@ui/components/bar-new/bar.component"; -import { BackComponent } from "@uilib"; -import { SoonCardComponent } from "@office/shared/soon-card/soon-card.component"; -import { ProjectsFilterComponent } from "./projects-filter/projects-filter.component"; -import { Project } from "@office/models/project.model"; -import { inviteToProjectMapper } from "@utils/inviteToProjectMapper"; -import { ProjectsService } from "./services/projects.service"; -import { InfoCardComponent } from "@office/features/info-card/info-card.component"; - -/** - * Главный компонент модуля проектов - * Управляет отображением списка проектов, поиском и созданием новых проектов - * - * Принимает: - * - Счетчики проектов через резолвер - * - Параметры поиска из URL - * - * Возвращает: - * - Интерфейс управления проектами с поиском и фильтрацией - * - Навигацию между разделами "Мои", "Все", "Подписки" - */ -@Component({ - selector: "app-projects", - templateUrl: "./projects.component.html", - styleUrl: "./projects.component.scss", - standalone: true, - imports: [ - IconComponent, - ReactiveFormsModule, - SearchComponent, - ButtonComponent, - RouterOutlet, - BarNewComponent, - BackComponent, - SoonCardComponent, - ProjectsFilterComponent, - InfoCardComponent, - ], -}) -export class ProjectsComponent implements OnInit, OnDestroy { - constructor( - private readonly navService: NavService, - private readonly route: ActivatedRoute, - private readonly projectsService: ProjectsService, - private readonly router: Router, - private readonly renderer: Renderer2, - private readonly fb: FormBuilder - ) { - this.searchForm = this.fb.group({ - search: [""], - }); - } - - @ViewChild("filterBody") filterBody!: ElementRef; - - ngOnInit(): void { - this.navService.setNavTitle("Проекты"); - - this.route.data.pipe(map(r => r["data"])).subscribe({ - next: invites => { - this.allInvites = inviteToProjectMapper(invites); - this.myInvites = inviteToProjectMapper(invites.slice(0, 1)); - }, - }); - - const searchFormSearch$ = this.searchForm.get("search")?.valueChanges.subscribe(search => { - this.router - .navigate([], { - queryParams: { name__contains: search }, - relativeTo: this.route, - queryParamsHandling: "merge", - }) - .then(() => console.debug("QueryParams changed from ProjectsComponent")); - }); - - searchFormSearch$ && this.subscriptions$.push(searchFormSearch$); - - const routeUrl$ = this.router.events.subscribe(event => { - if (event instanceof NavigationEnd) { - this.isMy = location.href.includes("/my"); - this.isAll = location.href.includes("/all"); - this.isSubs = location.href.includes("/subscriptions"); - this.isInvites = location.href.includes("/invites"); - this.isDashboard = location.href.includes("/dashboard"); - } - }); - routeUrl$ && this.subscriptions$.push(routeUrl$); - } - - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $?.unsubscribe()); - } - - searchForm: FormGroup; - - myInvites: Project[] = []; - private allInvites: Project[] = []; - - isMy = location.href.includes("/my"); - isAll = location.href.includes("/all"); - isSubs = location.href.includes("/subscriptions"); - isInvites = location.href.includes("/invites"); - isDashboard = location.href.includes("/dashboard"); - - isFilterOpen = false; - - subscriptions$: Subscription[] = []; - - private swipeStartY = 0; - private swipeThreshold = 50; - private isSwiping = false; - - onSwipeStart(event: TouchEvent): void { - this.swipeStartY = event.touches[0].clientY; - this.isSwiping = true; - } - - onSwipeMove(event: TouchEvent): void { - if (!this.isSwiping) return; - - const currentY = event.touches[0].clientY; - const deltaY = currentY - this.swipeStartY; - - const progress = Math.min(deltaY / this.swipeThreshold, 1); - this.renderer.setStyle( - this.filterBody.nativeElement, - "transform", - `translateY(${progress * 100}px)` - ); - } - - onSwipeEnd(event: TouchEvent): void { - if (!this.isSwiping) return; - - const endY = event.changedTouches[0].clientY; - const deltaY = endY - this.swipeStartY; - - if (deltaY > this.swipeThreshold) { - this.closeFilter(); - } - - this.isSwiping = false; - - this.renderer.setStyle(this.filterBody.nativeElement, "transform", "translateY(0)"); - } - - acceptOrRejectInvite(inviteId: number): void { - this.allInvites = this.allInvites.filter(invite => invite.inviteId !== inviteId); - - this.myInvites = this.allInvites.slice(0, 1); - } - - closeFilter(): void { - this.isFilterOpen = false; - } - - addProject(): void { - this.projectsService.addProject(); - } -} diff --git a/projects/social_platform/src/app/office/projects/projects.resolver.spec.ts b/projects/social_platform/src/app/office/projects/projects.resolver.spec.ts deleted file mode 100644 index df5f8c3e0..000000000 --- a/projects/social_platform/src/app/office/projects/projects.resolver.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; -import { ProjectsResolver } from "./projects.resolver"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; -import { of } from "rxjs"; -import { AuthService } from "@auth/services"; -import { ActivatedRouteSnapshot, RouterStateSnapshot } from "@angular/router"; - -describe("ProjectsResolver", () => { - beforeEach(() => { - const authSpy = jasmine.createSpyObj("authService", {}, { profile: of({}) }); - - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - providers: [{ provide: AuthService, useValue: authSpy }], - }); - }); - - it("should be created", () => { - const result = TestBed.runInInjectionContext(() => - ProjectsResolver({} as ActivatedRouteSnapshot, {} as RouterStateSnapshot) - ); - expect(result).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/projects/projects.resolver.ts b/projects/social_platform/src/app/office/projects/projects.resolver.ts deleted file mode 100644 index c32dadf54..000000000 --- a/projects/social_platform/src/app/office/projects/projects.resolver.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { forkJoin, switchMap } from "rxjs"; -import { Project } from "@models/project.model"; -import { ProjectService } from "@services/project.service"; -import { AuthService } from "@auth/services"; -import { ResolveFn } from "@angular/router"; -import { SubscriptionService } from "@office/services/subscription.service"; -import { HttpParams } from "@angular/common/http"; -import { ApiPagination } from "@office/models/api-pagination.model"; - -/** - * Resolver для загрузки данных о количестве проектов - * - * Функциональность: - * - Загружает количество проектов пользователя в разных категориях - * - Получает количество подписок пользователя - * - Объединяет данные в единый объект ProjectCount - * - * Принимает: - * - Не принимает параметры (использует текущего пользователя) - * - * Возвращает: - * - Observable с данными: - * - my: количество собственных проектов - * - all: общее количество проектов в системе - * - subs: количество подписок пользователя - * - * Используется перед загрузкой ProjectsComponent для предварительной - * загрузки необходимых данных. - */ - -export interface DashboardProjectsData { - all: ApiPagination; - my: ApiPagination; - subs: ApiPagination; -} - -export const ProjectsResolver: ResolveFn = () => { - const projectService = inject(ProjectService); - const authService = inject(AuthService); - const subscriptionService = inject(SubscriptionService); - - return authService.profile.pipe( - switchMap(user => - forkJoin({ - all: projectService.getAll(new HttpParams({ fromObject: { limit: 16 } })), - my: projectService.getMy(new HttpParams({ fromObject: { limit: 16 } })), - subs: subscriptionService.getSubscriptions(user.id), - }) - ) - ); -}; diff --git a/projects/social_platform/src/app/office/projects/projects.routes.ts b/projects/social_platform/src/app/office/projects/projects.routes.ts deleted file mode 100644 index 2b9196cfe..000000000 --- a/projects/social_platform/src/app/office/projects/projects.routes.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** @format */ - -import { Routes } from "@angular/router"; -import { ProjectsComponent } from "./projects.component"; -import { ProjectsResolver } from "./projects.resolver"; -import { ProjectsListComponent } from "./list/list.component"; -import { ProjectsMyResolver } from "./list/my.resolver"; -import { ProjectsAllResolver } from "./list/all.resolver"; -import { ProjectEditComponent } from "./edit/edit.component"; -import { ProjectEditResolver } from "./edit/edit.resolver"; -import { ProjectsSubscriptionsResolver } from "./list/subscriptions.resolver"; -import { ProjectEditRequiredGuard } from "./edit/guards/projects-edit.guard"; -import { ProjectsInvitesResolver } from "./list/invites.resolver"; -import { DashboardProjectsComponent } from "./dashboard/dashboard.component"; - -/** - * Конфигурация маршрутов для модуля проектов - * - * Определяет структуру навигации: - * - * Основные маршруты: - * - '' (root) - ProjectsComponent с дочерними маршрутами: - * - 'my' - список собственных проектов - * - 'subscriptions' - список проектов по подписке - * - 'all' - список всех проектов - * - ':projectId/edit' - редактирование проекта - * - ':projectId' - детальная информация о проекте (lazy loading) - * - * Каждый маршрут имеет свой resolver для предварительной загрузки данных: - * - ProjectsResolver - загружает счетчики проектов - * - ProjectsMyResolver - загружает собственные проекты - * - ProjectsAllResolver - загружает все проекты - * - ProjectsSubscriptionsResolver - загружает проекты по подписке - * - ProjectEditResolver - загружает данные для редактирования - * - * Использует lazy loading для детальной информации о проекте - * для оптимизации загрузки приложения. - */ -export const PROJECTS_ROUTES: Routes = [ - { - path: "", - component: ProjectsComponent, - resolve: { - data: ProjectsInvitesResolver, - }, - children: [ - { - path: "", - pathMatch: "full", - redirectTo: "dashboard", - }, - { - path: "dashboard", - component: DashboardProjectsComponent, - resolve: { - data: ProjectsResolver, - }, - }, - { - path: "my", - component: ProjectsListComponent, - resolve: { - data: ProjectsMyResolver, - }, - }, - { - path: "subscriptions", - component: ProjectsListComponent, - resolve: { - data: ProjectsSubscriptionsResolver, - }, - }, - { - path: "invites", - component: ProjectsListComponent, - resolve: { - data: ProjectsInvitesResolver, - }, - }, - { - path: "all", - component: ProjectsListComponent, - resolve: { - data: ProjectsAllResolver, - }, - }, - ], - }, - { - path: ":projectId/edit", - component: ProjectEditComponent, - resolve: { - data: ProjectEditResolver, - }, - canActivate: [ProjectEditRequiredGuard], - }, - { - path: ":projectId", - loadChildren: () => import("./detail/detail.routes").then(c => c.PROJECT_DETAIL_ROUTES), - }, -]; diff --git a/projects/social_platform/src/app/office/projects/services/projects.service.ts b/projects/social_platform/src/app/office/projects/services/projects.service.ts deleted file mode 100644 index 745c0ea34..000000000 --- a/projects/social_platform/src/app/office/projects/services/projects.service.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** @format */ - -import { inject, Injectable } from "@angular/core"; -import { Router } from "@angular/router"; -import { ProjectService } from "@office/services/project.service"; - -@Injectable({ - providedIn: "root", -}) -export class ProjectsService { - private readonly projectService = inject(ProjectService); - private readonly router = inject(Router); - - addProject(): void { - this.projectService.create().subscribe(project => { - this.projectService.projectsCount.next({ - ...this.projectService.projectsCount.getValue(), - my: this.projectService.projectsCount.getValue().my + 1, - }); - - this.router - .navigate([`/office/projects/${project.id}/edit`], { - queryParams: { editingStep: "main" }, - }) - .then(() => console.debug("Route change from ProjectsComponent")); - }); - } -} diff --git a/projects/social_platform/src/app/office/services/advert.service.spec.ts b/projects/social_platform/src/app/office/services/advert.service.spec.ts deleted file mode 100644 index 6f848541c..000000000 --- a/projects/social_platform/src/app/office/services/advert.service.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; - -import { AdvertService } from "./advert.service"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; - -describe("ArticleService", () => { - let service: AdvertService; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - }); - service = TestBed.inject(AdvertService); - }); - - it("should be created", () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/services/advert.service.ts b/projects/social_platform/src/app/office/services/advert.service.ts deleted file mode 100644 index 75e7fdfe0..000000000 --- a/projects/social_platform/src/app/office/services/advert.service.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** @format */ - -import { Injectable } from "@angular/core"; -import { map, Observable } from "rxjs"; -import { New } from "@models/article.model"; -import { ApiService } from "projects/core"; -import { plainToInstance } from "class-transformer"; - -/** - * Сервис для работы с новостями и рекламными материалами - * - * Предоставляет функциональность для: - * - Получения списка всех новостей - * - Получения детальной информации о конкретной новости - * - Преобразования данных в типизированные объекты - */ -@Injectable({ - providedIn: "root", -}) -export class AdvertService { - private readonly NEWS_URL = "/news"; - - constructor(private readonly apiService: ApiService) {} - - /** - * Получает список всех новостей и рекламных материалов - * Преобразует полученные данные в массив типизированных объектов New - * - * @returns Observable - массив новостей с заголовками, содержимым и метаданными - */ - getAll(): Observable { - return this.apiService - .get(`${this.NEWS_URL}/`) - .pipe(map(adverts => plainToInstance(New, adverts))); - } - - /** - * Получает детальную информацию о конкретной новости - * Преобразует полученные данные в типизированный объект New - * - * @param advertId - уникальный идентификатор новости - * @returns Observable - объект новости со всеми полями (заголовок, содержимое, дата, автор и т.д.) - */ - getOne(advertId: number): Observable { - return this.apiService - .get(`${this.NEWS_URL}/${advertId}/`) - .pipe(map(advert => plainToInstance(New, advert))); - } -} diff --git a/projects/social_platform/src/app/office/services/analytics.service.ts b/projects/social_platform/src/app/office/services/analytics.service.ts deleted file mode 100644 index 97b56f0cd..000000000 --- a/projects/social_platform/src/app/office/services/analytics.service.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** @format */ - -import { Injectable } from "@angular/core"; - -@Injectable({ - providedIn: "root", -}) -export class AnalyticsService { - private loaded = false; - - loadAnalytics(): void { - if (this.loaded) return; - if (window.location.hostname !== "app.procollab.ru") return; - - this.loaded = true; - this.loadYandexMetrika(); - this.loadMailRuCounter("3622531"); - - if (window.location.href === "https://app.procollab.ru/auth/register") { - this.loadMailRuCounter("3543687"); - } - } - - private loadYandexMetrika(): void { - const w = window as any; - w.ym = - w.ym || - function (...args: any[]) { - (w.ym.a = w.ym.a || []).push(args); - }; - w.ym.l = new Date().getTime(); - - const script = document.createElement("script"); - script.async = true; - script.src = "https://cdn.jsdelivr.net/npm/yandex-metrica-watch/tag.js"; - document.head.appendChild(script); - - w.ym(91871365, "init", { - clickmap: true, - trackLinks: true, - accurateTrackBounce: true, - webvisor: true, - trackHash: true, - }); - } - - private loadMailRuCounter(id: string): void { - const w = window as any; - const tmr = (w._tmr = w._tmr || []); - tmr.push({ id, type: "pageView", start: new Date().getTime() }); - - if (document.getElementById("tmr-code")) return; - - const script = document.createElement("script"); - script.type = "text/javascript"; - script.async = true; - script.id = "tmr-code"; - script.src = "https://top-fwz1.mail.ru/js/code.js"; - document.head.appendChild(script); - } -} diff --git a/projects/social_platform/src/app/office/services/chat.service.spec.ts b/projects/social_platform/src/app/office/services/chat.service.spec.ts deleted file mode 100644 index 53af8d191..000000000 --- a/projects/social_platform/src/app/office/services/chat.service.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; - -import { ChatService } from "./chat.service"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; - -describe("ChatService", () => { - let service: ChatService; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - }); - service = TestBed.inject(ChatService); - }); - - it("should be created", () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/services/chat.service.ts b/projects/social_platform/src/app/office/services/chat.service.ts deleted file mode 100644 index dee4ed65d..000000000 --- a/projects/social_platform/src/app/office/services/chat.service.ts +++ /dev/null @@ -1,251 +0,0 @@ -/** @format */ - -import { HttpParams } from "@angular/common/http"; -import { Injectable } from "@angular/core"; -import { WebsocketService } from "@core/services/websocket.service"; -import { ApiPagination } from "@models/api-pagination.model"; -import { ChatFile, ChatMessage } from "@models/chat-message.model"; -import { - ChatEventType, - DeleteChatMessageDto, - EditChatMessageDto, - OnChangeStatus, - OnChatMessageDto, - OnDeleteChatMessageDto, - OnEditChatMessageDto, - OnReadChatMessageDto, - ReadChatMessageDto, - SendChatMessageDto, - TypingInChatDto, - TypingInChatEventDto, -} from "@models/chat.model"; -import { plainToInstance } from "class-transformer"; -import { ApiService, TokenService } from "projects/core"; -import { BehaviorSubject, map, Observable } from "rxjs"; - -/** - * Сервис для управления чатом в реальном времени - * - * Предоставляет функциональность для: - * - Подключения к WebSocket для обмена сообщениями в реальном времени - * - Отправки, редактирования и удаления сообщений - * - Отслеживания статуса пользователей (онлайн/оффлайн) - * - Индикации набора текста - * - Загрузки истории сообщений и файлов - * - Отметки сообщений как прочитанных - */ -@Injectable({ - providedIn: "root", -}) -export class ChatService { - private readonly CHATS_URL = "/chats"; - - constructor( - private readonly websocketService: WebsocketService, - private readonly apiService: ApiService, - private readonly tokenService: TokenService - ) {} - - /** - * Устанавливает WebSocket соединение для чата - * Использует токен доступа для аутентификации - * - * @returns Observable - завершается при успешном подключении - * @throws Error если токен не найден - */ - public connect(): Observable { - const tokens = this.tokenService.getTokens(); - if (!tokens) throw new Error("No token provided"); - - return this.websocketService.connect(`/chat/`); - } - - /** - * Кеш статусов пользователей (онлайн/оффлайн) - * Ключ - ID пользователя, значение - статус онлайн (true/false) - */ - public userOnlineStatusCache = new BehaviorSubject>({}); - - /** - * Обновляет статус пользователя в кеше - * - * @param userId - идентификатор пользователя - * @param status - статус онлайн (true - онлайн, false - оффлайн) - */ - setOnlineStatus(userId: number, status: boolean) { - this.userOnlineStatusCache.next({ ...this.userOnlineStatusCache, [userId]: status }); - } - - /** - * Подписывается на события перехода пользователей в оффлайн - * - * @returns Observable - поток событий с информацией о пользователе, ушедшем в оффлайн - */ - onSetOffline(): Observable { - return this.websocketService - .on(ChatEventType.SET_OFFLINE) - .pipe(map(status => plainToInstance(OnChangeStatus, status))); - } - - /** - * Подписывается на события перехода пользователей в онлайн - * - * @returns Observable - поток событий с информацией о пользователе, вошедшем в онлайн - */ - onSetOnline(): Observable { - return this.websocketService - .on(ChatEventType.SET_ONLINE) - .pipe(map(status => plainToInstance(OnChangeStatus, status))); - } - - /** - * Подписывается на события набора текста другими пользователями - * - * @returns Observable - поток событий с информацией о том, кто печатает - */ - onTyping(): Observable { - return this.websocketService - .on(ChatEventType.TYPING) - .pipe(map(typing => plainToInstance(TypingInChatEventDto, typing))); - } - - /** - * Отправляет событие о начале набора текста - * - * @param typing - объект с информацией о наборе текста (проект, пользователь) - */ - startTyping(typing: TypingInChatDto): void { - this.websocketService.send(ChatEventType.TYPING, typing); - } - - /** - * Отправляет команду на удаление сообщения - * - * @param deleteChatMessage - объект с идентификатором сообщения для удаления - */ - deleteMessage(deleteChatMessage: DeleteChatMessageDto): void { - this.websocketService.send(ChatEventType.DELETE_MESSAGE, deleteChatMessage); - } - - /** - * Отправляет команду на отметку сообщения как прочитанного - * - * @param readChatMessage - объект с идентификатором сообщения для отметки - */ - readMessage(readChatMessage: ReadChatMessageDto): void { - this.websocketService.send(ChatEventType.READ_MESSAGE, readChatMessage); - } - - /** - * Загружает историю сообщений для проекта с пагинацией - * - * @param projectId - идентификатор проекта - * @param count - смещение (количество пропускаемых сообщений) - * @param take - лимит сообщений для загрузки - * @returns Observable> - объект с массивом сообщений и метаданными пагинации - */ - loadMessages( - projectId: number, - count?: number, - take?: number - ): Observable> { - let queries = new HttpParams(); - if (count !== undefined) queries = queries.set("offset", count); - if (take !== undefined) queries = queries.set("limit", take); - - return this.apiService.get>( - `${this.CHATS_URL}/projects/${projectId}/messages/`, - queries - ); - } - - /** - * Загружает список файлов, отправленных в чате проекта - * - * @param projectId - идентификатор проекта - * @returns Observable - массив файлов с метаданными - */ - loadProjectFiles(projectId: number): Observable { - return this.apiService - .get(`${this.CHATS_URL}/projects/${projectId}/files`) - .pipe(map(r => plainToInstance(ChatFile, r))); - } - - /** - * BehaviorSubject для отслеживания наличия непрочитанных сообщений - */ - unread$ = new BehaviorSubject(false); - - /** - * Проверяет наличие непрочитанных сообщений у пользователя - * - * @returns Observable - true если есть непрочитанные сообщения - */ - hasUnreads(): Observable { - return this.apiService - .get<{ hasUnreads: boolean }>(`${this.CHATS_URL}/has-unreads`) - .pipe(map(r => r.hasUnreads)); - } - - /** - * Отправляет новое сообщение в чат - * - * @param message - объект сообщения с текстом, файлами и метаданными - */ - sendMessage(message: SendChatMessageDto): void { - this.websocketService.send(ChatEventType.NEW_MESSAGE, message); - } - - /** - * Подписывается на получение новых сообщений - * - * @returns Observable - поток новых сообщений - */ - onMessage(): Observable { - return this.websocketService - .on(ChatEventType.NEW_MESSAGE) - .pipe(map(message => plainToInstance(OnChatMessageDto, message))); - } - - /** - * Подписывается на события редактирования сообщений - * - * @returns Observable - поток событий редактирования сообщений - */ - onEditMessage(): Observable { - return this.websocketService - .on(ChatEventType.EDIT_MESSAGE) - .pipe(map(message => plainToInstance(OnEditChatMessageDto, message))); - } - - /** - * Подписывается на события удаления сообщений - * - * @returns Observable - поток событий удаления сообщений - */ - onDeleteMessage(): Observable { - return this.websocketService - .on(ChatEventType.DELETE_MESSAGE) - .pipe(map(message => plainToInstance(OnDeleteChatMessageDto, message))); - } - - /** - * Подписывается на события прочтения сообщений - * - * @returns Observable - поток событий прочтения сообщений - */ - onReadMessage(): Observable { - return this.websocketService - .on(ChatEventType.READ_MESSAGE) - .pipe(map(message => plainToInstance(OnReadChatMessageDto, message))); - } - - /** - * Отправляет команду на редактирование сообщения - * - * @param message - объект с идентификатором сообщения и новым содержимым - */ - editMessage(message: EditChatMessageDto): void { - this.websocketService.send(ChatEventType.EDIT_MESSAGE, message); - } -} diff --git a/projects/social_platform/src/app/office/services/industry.service.spec.ts b/projects/social_platform/src/app/office/services/industry.service.spec.ts deleted file mode 100644 index 589d0ae80..000000000 --- a/projects/social_platform/src/app/office/services/industry.service.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; - -import { IndustryService } from "./industry.service"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; - -describe("IndustryService", () => { - let service: IndustryService; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - }); - service = TestBed.inject(IndustryService); - }); - - it("should be created", () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/services/industry.service.ts b/projects/social_platform/src/app/office/services/industry.service.ts deleted file mode 100644 index 6fb9b8f1e..000000000 --- a/projects/social_platform/src/app/office/services/industry.service.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** @format */ - -import { Injectable } from "@angular/core"; -import { ApiService } from "projects/core"; -import { BehaviorSubject, catchError, map, Observable, tap, throwError } from "rxjs"; -import { Industry } from "@models/industry.model"; -import { plainToInstance } from "class-transformer"; - -/** - * Сервис для работы с отраслями (индустриями) - * - * Предоставляет функциональность для: - * - Получения списка всех доступных отраслей - * - Кеширования отраслей в памяти для быстрого доступа - * - Поиска конкретной отрасли по идентификатору - * - Обработки ошибок при загрузке данных - */ -@Injectable({ - providedIn: "root", -}) -export class IndustryService { - private readonly INDUSTRIES_URL = "/industries"; - - constructor(private readonly apiService: ApiService) {} - - /** - * BehaviorSubject для хранения списка отраслей в памяти - * Обеспечивает кеширование и реактивное обновление данных - */ - private industries$ = new BehaviorSubject([]); - - /** - * Observable для подписки на изменения списка отраслей - * @returns Observable - поток данных с отраслями - */ - industries = this.industries$.asObservable(); - - /** - * Получает список всех доступных отраслей с сервера - * Преобразует данные в типизированные объекты и кеширует их - * Обрабатывает ошибки и обновляет локальный кеш при успешной загрузке - * - * @returns Observable - массив отраслей с названиями и идентификаторами - */ - getAll(): Observable { - return this.apiService.get(`${this.INDUSTRIES_URL}/`).pipe( - catchError(err => throwError(err)), - map(industries => plainToInstance(Industry, industries)), - tap(industries => { - this.industries$.next(industries); - }) - ); - } - - /** - * Находит конкретную отрасль в переданном массиве по идентификатору - * Вспомогательный метод для поиска отрасли без дополнительных запросов к серверу - * - * @param industries - массив отраслей для поиска - * @param industryId - идентификатор искомой отрасли - * @returns Industry | undefined - найденная отрасль или undefined, если не найдена - */ - getIndustry(industries: Industry[], industryId: number): Industry | undefined { - return industries.find(industry => industry.id === industryId); - } -} diff --git a/projects/social_platform/src/app/office/services/invite.service.spec.ts b/projects/social_platform/src/app/office/services/invite.service.spec.ts deleted file mode 100644 index b4e18f320..000000000 --- a/projects/social_platform/src/app/office/services/invite.service.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; - -import { InviteService } from "./invite.service"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; -import { of } from "rxjs"; -import { AuthService } from "@auth/services"; - -describe("InviteService", () => { - let service: InviteService; - - beforeEach(() => { - const authSpy = { - profile: of({}), - }; - - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - providers: [{ provide: AuthService, useValue: authSpy }], - }); - service = TestBed.inject(InviteService); - }); - - it("should be created", () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/services/invite.service.ts b/projects/social_platform/src/app/office/services/invite.service.ts deleted file mode 100644 index b8fe6193d..000000000 --- a/projects/social_platform/src/app/office/services/invite.service.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** @format */ - -import { Injectable } from "@angular/core"; -import { ApiService } from "projects/core"; -import { concatMap, map, Observable, take } from "rxjs"; -import { plainToInstance } from "class-transformer"; -import { Invite } from "@models/invite.model"; -import { HttpParams } from "@angular/common/http"; -import { AuthService } from "@auth/services"; - -/** - * Сервис для управления приглашениями в проекты - * - * Предоставляет функциональность для: - * - Отправки приглашений пользователям в проекты - * - Принятия и отклонения приглашений - * - Отзыва отправленных приглашений - * - Обновления информации о приглашениях - * - Получения списков приглашений для пользователей и проектов - */ -@Injectable({ - providedIn: "root", -}) -export class InviteService { - private readonly INVITES_URL = "/invites"; - - constructor(private readonly apiService: ApiService) {} - - /** - * Отправляет приглашение пользователю для участия в проекте - * - * @param userId - идентификатор пользователя, которому отправляется приглашение - * @param projectId - идентификатор проекта, в который приглашается пользователь - * @param role - роль пользователя в проекте (например, "developer", "designer") - * @param specialization - специализация пользователя в проекте (необязательно) - * @returns Observable - созданное приглашение со всеми полями - */ - sendForUser( - userId: number, - projectId: number, - role: string, - specialization?: string - ): Observable { - return this.apiService - .post(`${this.INVITES_URL}/`, { user: userId, project: projectId, role, specialization }) - .pipe(map(profile => plainToInstance(Invite, profile))); - } - - /** - * Отзывает (удаляет) отправленное приглашение - * Используется отправителем приглашения для его отмены - * - * @param invitationId - идентификатор приглашения для отзыва - * @returns Observable - информация об отозванном приглашении - */ - revokeInvite(invitationId: number): Observable { - return this.apiService.delete(`${this.INVITES_URL}/${invitationId}`); - } - - /** - * Принимает приглашение в проект - * Используется получателем приглашения для присоединения к проекту - * - * @param inviteId - идентификатор приглашения для принятия - * @returns Observable - информация о принятом приглашении - */ - acceptInvite(inviteId: number): Observable { - return this.apiService.post(`${this.INVITES_URL}/${inviteId}/accept/`, {}); - } - - /** - * Отклоняет приглашение в проект - * Используется получателем приглашения для отказа от участия - * - * @param inviteId - идентификатор приглашения для отклонения - * @returns Observable - информация об отклоненном приглашении - */ - rejectInvite(inviteId: number): Observable { - return this.apiService.post(`${this.INVITES_URL}/${inviteId}/decline/`, {}); - } - - /** - * Обновляет информацию о приглашении (роль и специализацию) - * Используется отправителем для изменения условий приглашения - * - * @param inviteId - идентификатор приглашения для обновления - * @param role - новая роль в проекте - * @param specialization - новая специализация (необязательно) - * @returns Observable - обновленное приглашение - */ - updateInvite(inviteId: number, role: string, specialization?: string): Observable { - return this.apiService.patch(`${this.INVITES_URL}/${inviteId}`, { role, specialization }); - } - - /** - * Получает список приглашений для текущего пользователя - * Использует профиль текущего пользователя для фильтрации приглашений - * - * @returns Observable - массив приглашений, адресованных текущему пользователю - */ - getMy(): Observable { - return this.apiService - .get(`${this.INVITES_URL}/`) - .pipe(map(invites => plainToInstance(Invite, invites))); - } - - /** - * Получает список всех приглашений для конкретного проекта - * Используется владельцами проекта для просмотра отправленных приглашений - * - * @param projectId - идентификатор проекта - * @returns Observable - массив всех приглашений, связанных с проектом - */ - getByProject(projectId: number): Observable { - return this.apiService - .get( - `${this.INVITES_URL}/`, - new HttpParams({ fromObject: { project: projectId, user: "any" } }) - ) - .pipe(map(profiles => plainToInstance(Invite, profiles))); - } -} diff --git a/projects/social_platform/src/app/office/services/member.service.spec.ts b/projects/social_platform/src/app/office/services/member.service.spec.ts deleted file mode 100644 index adcb05050..000000000 --- a/projects/social_platform/src/app/office/services/member.service.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; - -import { MemberService } from "./member.service"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; - -describe("MemberService", () => { - let service: MemberService; - - beforeEach(() => { - const memberSpy = jasmine.createSpyObj(["getMembers"]); - - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - providers: [{ provide: MemberService, useValue: memberSpy }], - }); - service = TestBed.inject(MemberService); - }); - - it("should be created", () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/services/member.service.ts b/projects/social_platform/src/app/office/services/member.service.ts deleted file mode 100644 index 47467f72a..000000000 --- a/projects/social_platform/src/app/office/services/member.service.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** @format */ - -import { Injectable } from "@angular/core"; -import { ApiService } from "projects/core"; -import { Observable } from "rxjs"; -import { HttpParams } from "@angular/common/http"; -import { ApiPagination } from "@models/api-pagination.model"; -import { User } from "@auth/models/user.model"; - -/** - * Сервис для работы с участниками платформы - * - * Предоставляет функциональность для: - * - Получения списка участников с пагинацией и фильтрацией - * - Получения списка менторов - * - Поиска пользователей по различным критериям - * - Работы с публичными профилями пользователей - */ -@Injectable({ - providedIn: "root", -}) -export class MemberService { - private readonly AUTH_PUBLIC_USERS_URL = "/auth/public-users"; - - constructor(private readonly apiService: ApiService) {} - - /** - * Получает список участников платформы с пагинацией и дополнительными фильтрами - * По умолчанию получает только обычных пользователей (user_type: 1) - * - * @param skip - количество пропускаемых записей (offset для пагинации) - * @param take - максимальное количество записей на странице (limit) - * @param otherParams - дополнительные параметры фильтрации (навыки, специализации, опыт и т.д.) - * @returns Observable> - объект с массивом пользователей и метаданными пагинации - */ - getMembers( - skip: number, - take: number, - otherParams?: Record - ): Observable> { - let allParams = new HttpParams({ fromObject: { user_type: 1, limit: take, offset: skip } }); - if (otherParams) { - allParams = allParams.appendAll(otherParams); - } - return this.apiService.get>(`${this.AUTH_PUBLIC_USERS_URL}/`, allParams); - } - - /** - * Получает список менторов и экспертов платформы - * Включает пользователей с типами 2, 3, 4 (менторы, эксперты, консультанты) - * - * @param skip - количество пропускаемых записей (offset для пагинации) - * @param take - максимальное количество записей на странице (limit) - * @returns Observable> - объект с массивом менторов и метаданными пагинации - */ - getMentors(skip: number, take: number): Observable> { - return this.apiService.get>( - `${this.AUTH_PUBLIC_USERS_URL}/`, - new HttpParams({ fromObject: { user_type: "2,3,4", limit: take, offset: skip } }) - ); - } -} diff --git a/projects/social_platform/src/app/office/services/nav.service.spec.ts b/projects/social_platform/src/app/office/services/nav.service.spec.ts deleted file mode 100644 index 5a542c7d9..000000000 --- a/projects/social_platform/src/app/office/services/nav.service.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; - -import { NavService } from "./nav.service"; - -describe("NavService", () => { - let service: NavService; - - beforeEach(() => { - TestBed.configureTestingModule({}); - service = TestBed.inject(NavService); - }); - - it("should be created", () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/services/nav.service.ts b/projects/social_platform/src/app/office/services/nav.service.ts deleted file mode 100644 index 4da4e0ffd..000000000 --- a/projects/social_platform/src/app/office/services/nav.service.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** @format */ - -import { Injectable } from "@angular/core"; -import { distinctUntilChanged, ReplaySubject } from "rxjs"; - -/** - * Сервис для управления навигацией и заголовками страниц - * - * Предоставляет функциональность для: - * - Установки и получения заголовка текущей страницы - * - Реактивного обновления заголовков в компонентах - * - Предотвращения дублирующих обновлений заголовков - */ -@Injectable({ - providedIn: "root", -}) -export class NavService { - constructor() {} - - /** - * ReplaySubject для хранения текущего заголовка навигации - * ReplaySubject(1) означает, что новые подписчики сразу получат последнее значение - */ - navTitle$ = new ReplaySubject(1); - - /** - * Observable для подписки на изменения заголовка навигации - * distinctUntilChanged() предотвращает эмиссию одинаковых значений подряд - * @returns Observable - поток изменений заголовка страницы - */ - navTitle = this.navTitle$.asObservable().pipe(distinctUntilChanged()); - - /** - * Устанавливает новый заголовок для текущей страницы - * Уведомляет всех подписчиков об изменении заголовка - * - * @param title - новый заголовок страницы для отображения в навигации - */ - setNavTitle(title: string): void { - this.navTitle$.next(title); - } -} diff --git a/projects/social_platform/src/app/office/services/notification.service.ts b/projects/social_platform/src/app/office/services/notification.service.ts deleted file mode 100644 index f784c7b6d..000000000 --- a/projects/social_platform/src/app/office/services/notification.service.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** @format */ - -import { Injectable } from "@angular/core"; -import { BehaviorSubject, map } from "rxjs"; -import { Notification } from "@models/notification.model"; - -/** - * Сервис для управления уведомлениями пользователя - * - * Предоставляет функциональность для: - * - Хранения списка уведомлений в памяти - * - Отслеживания количества непрочитанных уведомлений - * - Реактивного обновления состояния уведомлений - */ -@Injectable({ - providedIn: "root", -}) -export class NotificationService { - constructor() {} - - /** - * BehaviorSubject для хранения списка уведомлений - * Позволяет компонентам подписываться на изменения списка уведомлений - */ - notifications = new BehaviorSubject([]); - - /** - * Observable для отслеживания количества непрочитанных уведомлений - * Автоматически пересчитывается при изменении списка уведомлений - * Фильтрует уведомления по полю readAt (если null - уведомление не прочитано) - * - * @returns Observable - количество непрочитанных уведомлений - */ - hasNotifications = this.notifications - .asObservable() - .pipe(map(notifications => notifications.filter(notification => notification.readAt).length)); -} diff --git a/projects/social_platform/src/app/office/services/project.service.spec.ts b/projects/social_platform/src/app/office/services/project.service.spec.ts deleted file mode 100644 index f14d9fd1c..000000000 --- a/projects/social_platform/src/app/office/services/project.service.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; - -import { ProjectService } from "./project.service"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; -import { of } from "rxjs"; -import { AuthService } from "@auth/services"; - -describe("ProjectService", () => { - let service: ProjectService; - - beforeEach(() => { - const authSpy = jasmine.createSpyObj([{ profile: of({}) }]); - - TestBed.configureTestingModule({ - providers: [{ provide: AuthService, useValue: authSpy }], - imports: [HttpClientTestingModule], - }); - service = TestBed.inject(ProjectService); - }); - - it("should be created", () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/services/project.service.ts b/projects/social_platform/src/app/office/services/project.service.ts deleted file mode 100644 index b99c0444a..000000000 --- a/projects/social_platform/src/app/office/services/project.service.ts +++ /dev/null @@ -1,335 +0,0 @@ -/** @format */ - -import { Injectable } from "@angular/core"; -import { BehaviorSubject, map, Observable, tap } from "rxjs"; -import { Project, ProjectCount, ProjectStep } from "@models/project.model"; -import { ApiService } from "projects/core"; -import { plainToInstance } from "class-transformer"; -import { HttpParams } from "@angular/common/http"; -import { ApiPagination } from "@models/api-pagination.model"; -import { Collaborator } from "@office/models/collaborator.model"; -import { ProjectAssign } from "@office/projects/models/project-assign.model"; -import { projectNewAdditionalProgramVields } from "@office/models/partner-program-fields.model"; -import { Goal, GoalPostForm } from "@office/models/goals.model"; -import { Partner, PartnerPostForm } from "@office/models/partner.model"; -import { Resource, ResourcePostForm } from "@office/models/resource.model"; - -/** - * Сервис для управления проектами - * - * Предоставляет функциональность для: - * - Получения списка проектов с пагинацией - * - Создания, обновления и удаления проектов - * - Управления этапами проектов - * - Работы с коллабораторами проектов - * - Получения статистики по проектам - */ -@Injectable({ - providedIn: "root", -}) -export class ProjectService { - private readonly PROJECTS_URL = "/projects"; - private readonly AUTH_USERS_URL = "/auth/users"; - - constructor(private readonly apiService: ApiService) {} - - /** - * BehaviorSubject для хранения этапов проектов - * Используется для кеширования и реактивного обновления данных об этапах - */ - readonly steps$ = new BehaviorSubject([]); - - /** - * Получает список всех проектов с пагинацией - * - * @param params - HttpParams с параметрами запроса (limit, offset, фильтры) - * @returns Observable> - объект с массивом проектов и метаданными пагинации - */ - getAll(params?: HttpParams): Observable> { - return this.apiService.get>(`${this.PROJECTS_URL}/`, params); - } - - /** - * Получает один проект по его идентификатору - * Преобразует полученные данные в экземпляр класса Project - * - * @param id - уникальный идентификатор проекта - * @returns Observable - объект проекта со всеми полями - */ - getOne(id: number): Observable { - return this.apiService - .get(`${this.PROJECTS_URL}/${id}/`) - .pipe(map(project => plainToInstance(Project, project))); - } - - /** - * Получает список проектов текущего пользователя с пагинацией - * - * @param params - HttpParams с параметрами запроса (limit, offset, фильтры) - * @returns Observable> - объект с массивом проектов пользователя и метаданными пагинации - */ - getMy(params?: HttpParams): Observable> { - return this.apiService.get>(`${this.AUTH_USERS_URL}/projects/`, params); - } - - /** - * - * @param projectId - * @param params - * @returns Создать или привязать компанию к проекту. - * Если компания с таким ИНН уже существует — создаёт или обновляет связь ProjectCompany. - * Если компании нет — создаёт новую и тут же привязывает. - */ - addPartner(projectId: number, params: PartnerPostForm) { - return this.apiService.post(`${this.PROJECTS_URL}/${projectId}/companies/`, params); - } - - /** - * Получить список всех компаний-партнёров (связей ProjectCompany) конкретного проекта. - * - * @param projectId - * - * @returns данные компании - * @returns вклад - * @returns ответственного - */ - getPartners(projectId: number): Observable { - return this.apiService.get(`${this.PROJECTS_URL}/${projectId}/companies/list/`); - } - - /** - * @param projectId - * @param companyId - * - * @returns Обновить информацию о связи проекта с компанией. - * Можно изменить вклад (contribution) и/или ответственное лицо (decision_maker). - * Компания остаётся без изменений. - */ - editParter( - projectId: number, - companyId: number, - params: Pick - ) { - return this.apiService.patch( - `${this.PROJECTS_URL}/${projectId}/companies/${companyId}/`, - params - ); - } - - /** - * @param projectId - * @param companyId - * - * @returns Удалить связь проекта с компанией. Компания в базе остаётся, удаляется только запись ProjectCompany. - */ - deletePartner(projectId: number, companyId: number) { - return this.apiService.delete(`${this.PROJECTS_URL}/${projectId}/companies/${companyId}/`); - } - - /** - * - * @param projectId - * @param params - * @returns Создать новый ресурс в проекте. - * Если partner_company указана, проверяется, что она действительно является партнёром данного проекта. - */ - addResource(projectId: number, params: Omit) { - return this.apiService.post(`${this.PROJECTS_URL}/${projectId}/resources/`, { - project_id: projectId, - ...params, - }); - } - - /** - * - * @param projectId - * @returns Получить список всех ресурсов проекта. - * Каждый ресурс содержит тип, описание и партнёра (если назначен) - */ - getResources(projectId: number): Observable { - return this.apiService.get(`${this.PROJECTS_URL}/${projectId}/resources/`); - } - - /** - * @param projectId - * @param resourceId - * - * @returns Полностью обновить данные ресурса. - * Используется, если нужно заменить все поля сразу. - */ - editResource(projectId: number, resourceId: number, params: Omit) { - return this.apiService.patch(`${this.PROJECTS_URL}/${projectId}/resources/${resourceId}/`, { - project_id: projectId, - ...params, - }); - } - - /** - * @param projectId - * @param resourceId - * - * @returns Удалить ресурс проекта. - * Удаляется только сам ресурс, проект и компании не затрагиваются. - */ - deleteResource(projectId: number, resourceId: number) { - return this.apiService.delete(`${this.PROJECTS_URL}/${projectId}/resources/${resourceId}/`); - } - - /** - * Получает список целей проекта - * - * @returns Observable - объект с массивом целей проекта - */ - getGoals(projectId: number): Observable { - return this.apiService.get(`${this.PROJECTS_URL}/${projectId}/goals/`); - } - - /** - * Отправляем цель - */ - addGoals(projectId: number, params: GoalPostForm[]) { - return this.apiService.post(`${this.PROJECTS_URL}/${projectId}/goals/`, params); - } - - /** - * Редактирование цели - */ - editGoal(projectId: number, goalId: number, params: GoalPostForm) { - return this.apiService.put(`${this.PROJECTS_URL}/${projectId}/goals/${goalId}`, params); - } - - /** - * Удаляем цель - */ - deleteGoals(projectId: number, goalId: number) { - return this.apiService.delete( - `${this.PROJECTS_URL}/${projectId}/goals/${goalId}` - ); - } - - /** - * BehaviorSubject для хранения счетчиков проектов - * Содержит количество собственных проектов, всех проектов и подписок - */ - projectsCount = new BehaviorSubject({ my: 0, all: 0, subs: 0 }); - - /** - * Observable для подписки на изменения счетчиков проектов - * @returns Observable - объект с количеством проектов разных типов - */ - projectsCount$ = this.projectsCount.asObservable(); - - /** - * Получает статистику по количеству проектов - * Преобразует данные в экземпляр класса ProjectCount - * - * @returns Observable - объект с полями my, all, subs (количество проектов) - */ - getCount(): Observable { - return this.apiService - .get(`${this.PROJECTS_URL}/count/`) - .pipe(map(count => plainToInstance(ProjectCount, count))); - } - - /** - * Удаляет проект по его идентификатору - * - * @param projectId - уникальный идентификатор проекта для удаления - * @returns Observable - завершается при успешном удалении - */ - remove(projectId: number): Observable { - return this.apiService.delete(`${this.PROJECTS_URL}/${projectId}/`); - } - - /** - * Покидает проект (удаляет текущего пользователя из коллабораторов) - * - * @param projectId - идентификатор проекта, который нужно покинуть - * @returns Observable - завершается при успешном выходе из проекта - */ - leave(projectId: Project["id"]): Observable { - return this.apiService.delete(`${this.PROJECTS_URL}/${projectId}/collaborators/leave`); - } - - /** - * Создает новый пустой проект - * Преобразует полученные данные в экземпляр класса Project - * - * @returns Observable - созданный проект со всеми полями - */ - create(): Observable { - return this.apiService - .post(`${this.PROJECTS_URL}/`, {}) - .pipe(map(project => plainToInstance(Project, project))); - } - - /** - * Ссоздаёт привязывает проект к программе с указанным ID. - * После чего в БД появляется новый проект в черновиках - * - * @param projectId - идентификатор проекта - * @param partnerProgramId - идентификатор программы, к которой привязывается проект - * @returns Observable - ответ с названием программы и инфой краткой о проекте - */ - assignProjectToProgram(projectId: number, partnerProgramId: number): Observable { - return this.apiService.post(`${this.PROJECTS_URL}/assign-to-program/`, { - project_id: projectId, - partner_program_id: partnerProgramId, - }); - } - - /** - * Ссоздаёт привязывает проект к программе с указанным ID. - * После чего в БД появляется новый проект в черновиках - * - * @param projectId - id проекта - * @param fieldId - идентификатор доп поля - * @param valueText - идентификатор программы, к которой привязывается проект - * @returns Observable - измененный проект - */ - sendNewProjectFieldsValues( - projectId: number, - newValues: projectNewAdditionalProgramVields[] - ): Observable { - return this.apiService.put(`${this.PROJECTS_URL}/${projectId}/program-fields/`, newValues); - } - - /** - * Обновляет существующий проект - * Отправляет частичные данные проекта для обновления - * - * @param projectId - идентификатор проекта для обновления - * @param newProject - объект с полями проекта для обновления (частичный) - * @returns Observable - обновленный проект со всеми полями - */ - updateProject(projectId: number, newProject: Partial): Observable { - return this.apiService - .put(`${this.PROJECTS_URL}/${projectId}/`, newProject) - .pipe(map(project => plainToInstance(Project, project))); - } - - /** - * Удаляет коллаборатора из проекта - * - * @param projectId - идентификатор проекта - * @param userId - идентификатор пользователя для удаления из коллабораторов - * @returns Observable - завершается при успешном удалении коллаборатора - */ - removeColloborator(projectId: Project["id"], userId: Collaborator["userId"]): Observable { - return this.apiService.delete(`${this.PROJECTS_URL}/${projectId}/collaborators?id=${userId}`); - } - - /** - * Передает лидерство в проекте другому пользователю - * - * @param projectId - идентификатор проекта - * @param userId - идентификатор пользователя, которому передается лидерство - * @returns Observable - завершается при успешной передаче лидерства - */ - switchLeader(projectId: Project["id"], userId: Collaborator["userId"]): Observable { - return this.apiService.patch( - `${this.PROJECTS_URL}/${projectId}/collaborators/${userId}/switch-leader/`, - {} - ); - } -} diff --git a/projects/social_platform/src/app/office/services/skills.service.ts b/projects/social_platform/src/app/office/services/skills.service.ts deleted file mode 100644 index a9f0852fb..000000000 --- a/projects/social_platform/src/app/office/services/skills.service.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** @format */ - -import { Injectable } from "@angular/core"; -import { SkillsGroup } from "../models/skills-group.model"; -import { Observable } from "rxjs"; -import { ApiService } from "@corelib"; -import { Skill } from "../models/skill.model"; -import { ApiPagination } from "@office/models/api-pagination.model"; -import { HttpParams } from "@angular/common/http"; - -/** - * Сервис для работы с навыками пользователей - * - * Предоставляет функциональность для: - * - Получения навыков в виде иерархической структуры (группы) - * - Получения навыков в виде плоского списка с поиском и пагинацией - * - Поиска навыков по названию - */ -@Injectable({ - providedIn: "root", -}) -export class SkillsService { - private readonly CORE_SKILLS_URL = "/core/skills"; - - constructor(private apiService: ApiService) {} - - /** - * Получает навыки в виде иерархической структуры (группы и подгруппы) - * Используется для отображения в виде дерева или категорий навыков - * - * @returns Observable - массив групп навыков с вложенными элементами - */ - getSkillsNested(): Observable { - return this.apiService.get(`${this.CORE_SKILLS_URL}/nested`); - } - - /** - * Получает навыки в виде плоского списка с поддержкой поиска и пагинации - * Используется для автокомплита, выпадающих списков и поиска навыков - * - * @param search - строка поиска для фильтрации по названию навыка - * @param limit - максимальное количество результатов на странице - * @param offset - количество пропускаемых результатов (для пагинации) - * @returns Observable> - объект с массивом навыков и метаданными пагинации - */ - getSkillsInline(search: string, limit: number, offset: number): Observable> { - return this.apiService.get( - `${this.CORE_SKILLS_URL}/inline`, - new HttpParams({ fromObject: { limit, offset, name__icontains: search } }) - ); - } -} diff --git a/projects/social_platform/src/app/office/services/specializations.service.ts b/projects/social_platform/src/app/office/services/specializations.service.ts deleted file mode 100644 index 786749a84..000000000 --- a/projects/social_platform/src/app/office/services/specializations.service.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** @format */ - -import { Injectable } from "@angular/core"; -import { SpecializationsGroup } from "../models/specializations-group.model"; -import { Observable } from "rxjs"; -import { ApiService } from "@corelib"; -import { Specialization } from "../models/specialization.model"; -import { ApiPagination } from "@office/models/api-pagination.model"; -import { HttpParams } from "@angular/common/http"; - -/** - * Сервис для работы со специализациями пользователей - * - * Предоставляет функциональность для: - * - Получения специализаций в виде иерархической структуры (группы) - * - Получения специализаций в виде плоского списка с поиском и пагинацией - * - Поиска специализаций по названию - */ -@Injectable({ - providedIn: "root", -}) -export class SpecializationsService { - private readonly AUTH_USERS_SPECIALIZATIONS_URL = "/auth/users/specializations"; - - constructor(private apiService: ApiService) {} - - /** - * Получает специализации в виде иерархической структуры (группы и подгруппы) - * Используется для отображения в виде дерева или категорий - * - * @returns Observable - массив групп специализаций с вложенными элементами - */ - getSpecializationsNested(): Observable { - return this.apiService.get(`${this.AUTH_USERS_SPECIALIZATIONS_URL}/nested`); - } - - /** - * Получает специализации в виде плоского списка с поддержкой поиска и пагинации - * Используется для автокомплита, выпадающих списков и поиска - * - * @param search - строка поиска для фильтрации по названию специализации - * @param limit - максимальное количество результатов на странице - * @param offset - количество пропускаемых результатов (для пагинации) - * @returns Observable> - объект с массивом специализаций и метаданными пагинации - */ - getSpecializationsInline( - search: string, - limit: number, - offset: number - ): Observable> { - return this.apiService.get( - `${this.AUTH_USERS_SPECIALIZATIONS_URL}/inline`, - new HttpParams({ fromObject: { limit, offset, name__icontains: search } }) - ); - } -} diff --git a/projects/social_platform/src/app/office/services/storage.service.ts b/projects/social_platform/src/app/office/services/storage.service.ts deleted file mode 100644 index fe2b8442d..000000000 --- a/projects/social_platform/src/app/office/services/storage.service.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** @format */ - -import { Injectable } from "@angular/core"; - -/** - * Сервис для работы с локальным хранилищем браузера - * - * Предоставляет функциональность для: - * - Сохранения данных в localStorage или sessionStorage - * - Получения данных из хранилища с автоматическим парсингом JSON - * - Работы с объектами и примитивными типами данных - * - Типизированного получения данных - */ -@Injectable({ - providedIn: "root", -}) -export class StorageService { - /** - * Сохраняет значение в указанное хранилище - * Автоматически сериализует объекты в JSON, примитивы сохраняет как есть - * - * @param key - ключ для сохранения данных - * @param value - значение для сохранения (может быть объектом или примитивом) - * @param storage - тип хранилища (по умолчанию localStorage, может быть sessionStorage) - */ - setItem(key: string, value: any, storage = localStorage) { - if (typeof value === "object") storage.setItem(key, JSON.stringify(value)); - else storage.setItem(key, value); - } - - /** - * Получает значение из указанного хранилища с типизацией - * Автоматически парсит JSON и возвращает типизированный результат - * - * @template T - тип возвращаемых данных - * @param key - ключ для получения данных - * @param storage - тип хранилища (по умолчанию localStorage, может быть sessionStorage) - * @returns T | null - типизированное значение или null, если ключ не найден - */ - getItem(key: string, storage = localStorage): T | null { - const value = storage.getItem(key); - if (!value) return null; - - const parsedValue = JSON.parse(value); - - return parsedValue as T; - } -} diff --git a/projects/social_platform/src/app/office/services/subscription.service.spec.ts b/projects/social_platform/src/app/office/services/subscription.service.spec.ts deleted file mode 100644 index b6efc39cd..000000000 --- a/projects/social_platform/src/app/office/services/subscription.service.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; -import { SubscriptionService } from "./subscription.service"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; - -describe("SubscriptionService", () => { - let service: SubscriptionService; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - }); - service = TestBed.inject(SubscriptionService); - }); - - it("should be created", () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/services/subscription.service.ts b/projects/social_platform/src/app/office/services/subscription.service.ts deleted file mode 100644 index 16785f7cb..000000000 --- a/projects/social_platform/src/app/office/services/subscription.service.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** @format */ - -import { Injectable } from "@angular/core"; -import { ApiService } from "projects/core"; -import { Observable } from "rxjs"; -import { ProjectSubscriber } from "@office/models/project-subscriber.model"; -import { ApiPagination } from "@office/models/api-pagination.model"; -import { Project } from "@office/models/project.model"; -import { HttpParams } from "@angular/common/http"; - -/** - * Сервис для управления подписками на проекты - * - * Предоставляет функциональность для: - * - Получения списка подписчиков проекта - * - Подписки на проекты и отписки от них - * - Получения списка проектов, на которые подписан пользователь - */ -@Injectable({ - providedIn: "root", -}) -export class SubscriptionService { - private readonly PROJECTS_URL = "/projects"; - private readonly AUTH_USERS_URL = "/auth/users"; - - constructor(private readonly apiService: ApiService) {} - - /** - * Получает список всех подписчиков конкретного проекта - * - * @param projectId - идентификатор проекта - * @returns Observable - массив подписчиков с информацией о пользователях - */ - getSubscribers(projectId: number): Observable { - return this.apiService.get( - `${this.PROJECTS_URL}/${projectId}/subscribers/` - ); - } - - /** - * Подписывает текущего пользователя на проект - * После подписки пользователь будет получать уведомления об обновлениях проекта - * - * @param projectId - идентификатор проекта для подписки - * @returns Observable - завершается при успешной подписке - */ - addSubscription(projectId: number): Observable { - return this.apiService.post(`${this.PROJECTS_URL}/${projectId}/subscribe/`, {}); - } - - /** - * Получает список проектов, на которые подписан указанный пользователь - * Поддерживает пагинацию и фильтрацию - * - * @param userId - идентификатор пользователя - * @param params - параметры запроса (limit, offset, фильтры) - * @returns Observable> - объект с массивом проектов и метаданными пагинации - */ - getSubscriptions(userId: number, params?: HttpParams): Observable> { - return this.apiService.get(`${this.AUTH_USERS_URL}/${userId}/subscribed_projects/`, params); - } - - /** - * Отписывает текущего пользователя от проекта - * После отписки пользователь перестанет получать уведомления об обновлениях проекта - * - * @param projectId - идентификатор проекта для отписки - * @returns Observable - завершается при успешной отписке - */ - deleteSubscription(projectId: number): Observable { - return this.apiService.post(`${this.PROJECTS_URL}/${projectId}/unsubscribe/`, {}); - } -} diff --git a/projects/social_platform/src/app/office/services/vacancy.service.spec.ts b/projects/social_platform/src/app/office/services/vacancy.service.spec.ts deleted file mode 100644 index 7295177fd..000000000 --- a/projects/social_platform/src/app/office/services/vacancy.service.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; - -import { VacancyService } from "./vacancy.service"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; - -describe("VacancyService", () => { - let service: VacancyService; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - }); - service = TestBed.inject(VacancyService); - }); - - it("should be created", () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/services/vacancy.service.ts b/projects/social_platform/src/app/office/services/vacancy.service.ts deleted file mode 100644 index fa2fcde51..000000000 --- a/projects/social_platform/src/app/office/services/vacancy.service.ts +++ /dev/null @@ -1,200 +0,0 @@ -/** @format */ - -import { Injectable } from "@angular/core"; -import { ApiService } from "projects/core"; -import { map, Observable } from "rxjs"; -import { Vacancy } from "@models/vacancy.model"; -import { plainToInstance } from "class-transformer"; -import { VacancyResponse } from "@models/vacancy-response.model"; -import { HttpParams } from "@angular/common/http"; - -/** - * Сервис для управления вакансиями и откликами на них - * - * Предоставляет функциональность для: - * - Получения списка вакансий с фильтрацией и поиском - * - Создания, обновления и удаления вакансий - * - Отправки откликов на вакансии - * - Управления откликами (принятие/отклонение) - * - Получения откликов по проектам - */ -@Injectable({ - providedIn: "root", -}) -export class VacancyService { - private readonly VACANCIES_URL = "/vacancies"; - private readonly PROJECTS_URL = "/projects"; - - constructor(private readonly apiService: ApiService) {} - - /** - * Получает список вакансий с расширенной фильтрацией - * Поддерживает фильтрацию по опыту, формату работы, зарплате и поиск по тексту - * - * @param limit - максимальное количество вакансий на странице - * @param offset - количество пропускаемых вакансий (для пагинации) - * @param projectId - фильтр по идентификатору проекта (необязательно) - * @param requiredExperience - фильтр по требуемому опыту работы (необязательно) - * @param workFormat - фильтр по формату работы (удаленно/офис/гибрид) (необязательно) - * @param workSchedule - фильтр по графику работы (полный день/частичная занятость) (необязательно) - * @param salaryMin - минимальная зарплата для фильтрации (необязательно) - * @param salaryMax - максимальная зарплата для фильтрации (необязательно) - * @param searchValue - строка поиска по названию роли (необязательно) - * @returns Observable - массив вакансий, соответствующих критериям - */ - getForProject( - limit: number, - offset: number, - projectId?: number, - requiredExperience?: string, - workFormat?: string, - workSchedule?: string, - salaryMin?: string, - salaryMax?: string, - searchValue?: string - ): any { - let params = new HttpParams().set("limit", limit.toString()).set("offset", offset.toString()); - - if (projectId !== undefined) { - params = params.set("project_id", projectId.toString()); - } - - if (requiredExperience) { - params = params.set("required_experience", requiredExperience); - } - - if (workFormat) { - params = params.set("work_format", workFormat); - } - - if (workSchedule) { - params = params.set("work_schedule", workSchedule); - } - - if (salaryMin) { - params = params.set("salary_min", salaryMin); - } - - if (salaryMax) { - params = params.set("salary_max", salaryMax); - } - - if (searchValue) { - params = params.set("role_contains", searchValue); - } - - return this.apiService - .get(`${this.VACANCIES_URL}/`, params) - .pipe(map(vacancies => plainToInstance(Vacancy, vacancies))); - } - - /** - * Получает список откликов текущего пользователя на вакансии - * - * @param limit - максимальное количество откликов на странице - * @param offset - количество пропускаемых откликов (для пагинации) - * @returns Observable - массив откликов пользователя с информацией о вакансиях - */ - getMyVacancies(limit: number, offset: number): Observable { - const params = new HttpParams(); - - params.set("limit", limit); - params.set("offset", offset); - - return this.apiService - .get(`${this.VACANCIES_URL}/responses/self`, params) - .pipe(map(vacancies => plainToInstance(VacancyResponse, vacancies))); - } - - /** - * Получает детальную информацию о конкретной вакансии - * - * @param vacancyId - идентификатор вакансии - * @returns Observable - объект вакансии со всеми полями - */ - getOne(vacancyId: number) { - return this.apiService - .get(`${this.VACANCIES_URL}/${vacancyId}`) - .pipe(map(vacancy => plainToInstance(Vacancy, vacancy))); - } - - /** - * Создает новую вакансию для проекта - * - * @param projectId - идентификатор проекта, к которому привязывается вакансия - * @param vacancy - объект вакансии с описанием, требованиями и условиями - * @returns Observable - созданная вакансия со всеми полями - */ - postVacancy(projectId: number, vacancy: Vacancy): Observable { - return this.apiService - .post(`${this.VACANCIES_URL}/`, { - ...vacancy, - project: projectId, - }) - .pipe(map(vacancy => plainToInstance(Vacancy, vacancy))); - } - - /** - * Обновляет существующую вакансию - * - * @param vacancyId - идентификатор вакансии для обновления - * @param vacancy - объект с обновленными данными вакансии - * @returns Observable - обновленная вакансия - */ - updateVacancy(vacancyId: number, vacancy: Vacancy) { - return this.apiService.patch(`${this.VACANCIES_URL}/${vacancyId}`, { ...vacancy }); - } - - /** - * Удаляет вакансию - * - * @param vacancyId - идентификатор вакансии для удаления - * @returns Observable - завершается при успешном удалении - */ - deleteVacancy(vacancyId: number): Observable { - return this.apiService.delete(`${this.VACANCIES_URL}/${vacancyId}`); - } - - /** - * Отправляет отклик на вакансию - * - * @param vacancyId - идентификатор вакансии - * @param body - объект с мотивационным письмом (поле whyMe) - * @returns Observable - завершается при успешной отправке отклика - */ - sendResponse(vacancyId: number, body: { whyMe: string }): Observable { - return this.apiService.post(`${this.VACANCIES_URL}/${vacancyId}/responses/`, body); - } - - /** - * Получает все отклики на вакансии конкретного проекта - * - * @param projectId - идентификатор проекта - * @returns Observable - массив откликов с информацией о кандидатах - */ - responsesByProject(projectId: number): Observable { - return this.apiService - .get(`${this.PROJECTS_URL}/${projectId}/responses/`) - .pipe(map(response => plainToInstance(VacancyResponse, response))); - } - - /** - * Принимает отклик кандидата на вакансию - * - * @param responseId - идентификатор отклика - * @returns Observable - завершается при успешном принятии отклика - */ - acceptResponse(responseId: number): Observable { - return this.apiService.post(`${this.VACANCIES_URL}/responses/${responseId}/accept/`, {}); - } - - /** - * Отклоняет отклик кандидата на вакансию - * - * @param responseId - идентификатор отклика - * @returns Observable - завершается при успешном отклонении отклика - */ - rejectResponse(responseId: number): Observable { - return this.apiService.post(`${this.VACANCIES_URL}/responses/${responseId}/decline/`, {}); - } -} diff --git a/projects/social_platform/src/app/office/shared/advert-card/advert-card.component.html b/projects/social_platform/src/app/office/shared/advert-card/advert-card.component.html deleted file mode 100644 index 56b7c7f66..000000000 --- a/projects/social_platform/src/app/office/shared/advert-card/advert-card.component.html +++ /dev/null @@ -1,14 +0,0 @@ - - -@if (advert) { -
    -
    - -
    Смотреть
    -
    -
    -

    {{ advert.title }}

    -

    {{ advert.shortText }}

    -
    -
    -} diff --git a/projects/social_platform/src/app/office/shared/advert-card/advert-card.component.scss b/projects/social_platform/src/app/office/shared/advert-card/advert-card.component.scss deleted file mode 100644 index 99963428f..000000000 --- a/projects/social_platform/src/app/office/shared/advert-card/advert-card.component.scss +++ /dev/null @@ -1,71 +0,0 @@ -.card { - display: flex; - flex-direction: column; - height: 100%; - padding: 24px; - background-color: var(--white); - - &__cover { - position: relative; - width: 100%; - height: 100%; - margin-bottom: 24px; - - img { - width: 100%; - max-height: 325px; - object-fit: contain; - object-position: center; - border-radius: var(--rounded-md); - } - } - - &__overlay { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - display: flex; - align-items: center; - justify-content: center; - color: var(--white); - background-image: linear-gradient(0deg, rgb(108 39 255 / 60%), rgb(108 39 255 / 60%)); - border-radius: var(--rounded-md); - opacity: 0; - transition: opacity 0.2s; - } - - &__title { - margin-bottom: 6px; - color: var(--black); - } - - &__text { - color: var(--dark-grey); - } - - &--vertical { - flex-direction: row; - - .card__body { - flex-basis: 75%; - } - - .card__cover { - flex-basis: 25%; - margin-right: 24px; - margin-bottom: 0; - - img { - height: 100%; - } - } - } - - &:hover { - .card__overlay { - opacity: 1; - } - } -} diff --git a/projects/social_platform/src/app/office/shared/advert-card/advert-card.component.spec.ts b/projects/social_platform/src/app/office/shared/advert-card/advert-card.component.spec.ts deleted file mode 100644 index 66f94ba1a..000000000 --- a/projects/social_platform/src/app/office/shared/advert-card/advert-card.component.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { AdvertCardComponent } from "./advert-card.component"; - -describe("AdvertCardComponent", () => { - let component: AdvertCardComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [AdvertCardComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(AdvertCardComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/shared/advert-card/advert-card.component.ts b/projects/social_platform/src/app/office/shared/advert-card/advert-card.component.ts deleted file mode 100644 index 205667e02..000000000 --- a/projects/social_platform/src/app/office/shared/advert-card/advert-card.component.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** @format */ - -import { Component, Input, OnInit } from "@angular/core"; -import { New } from "@models/article.model"; - -/** - * Компонент карточки рекламного объявления - * - * Функциональность: - * - Отображает рекламное объявление в виде карточки - * - Поддерживает два варианта макета: вертикальный и горизонтальный - * - Простой компонент для отображения информации без интерактивности - * - * Входные параметры: - * @Input advert - объект рекламного объявления (обязательный) - * @Input layout - тип макета карточки: "vertical" или "horizontal" (по умолчанию "vertical") - * - * Выходные события: отсутствуют - * - * Примечание: Компонент предназначен только для отображения, не содержит бизнес-логики - */ -@Component({ - selector: "app-advert-card", - templateUrl: "./advert-card.component.html", - styleUrl: "./advert-card.component.scss", - standalone: true, - imports: [], -}) -export class AdvertCardComponent implements OnInit { - constructor() {} - - @Input({ required: true }) advert!: New; - @Input() layout: "vertical" | "horizontal" = "vertical"; - - ngOnInit(): void {} -} diff --git a/projects/social_platform/src/app/office/shared/approve-skill-people/approve-skill-people.component.html b/projects/social_platform/src/app/office/shared/approve-skill-people/approve-skill-people.component.html deleted file mode 100644 index 6601a9f21..000000000 --- a/projects/social_platform/src/app/office/shared/approve-skill-people/approve-skill-people.component.html +++ /dev/null @@ -1,15 +0,0 @@ - - -
    - @if (approves.length >= 3) { @for (approve of approves.slice(0, 3); track $index) { - - } } @else { @for (approve of approves; track $index) { - - } } @if (approves.length >= 3) { -

    и ещё {{ approves.length - 3 + "подтвердили" }}

    - } @else { -

    - {{ approves.length + " " + (approves.length | pluralize: ["человек", "человека", "человек"]) }} -

    - } -
    diff --git a/projects/social_platform/src/app/office/shared/approve-skill-people/approve-skill-people.component.ts b/projects/social_platform/src/app/office/shared/approve-skill-people/approve-skill-people.component.ts deleted file mode 100644 index 984311dce..000000000 --- a/projects/social_platform/src/app/office/shared/approve-skill-people/approve-skill-people.component.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { Component, Input } from "@angular/core"; -import { PluralizePipe } from "@corelib"; -import { Skill } from "@office/models/skill.model"; -import { AvatarComponent } from "@ui/components/avatar/avatar.component"; - -@Component({ - selector: "app-approve-skill-people", - templateUrl: "./approve-skill-people.component.html", - styleUrl: "./approve-skill-people.component.scss", - imports: [CommonModule, AvatarComponent, PluralizePipe], - standalone: true, -}) -export class ApproveSkillPeopleComponent { - @Input({ required: true }) approves!: Skill["approves"]; -} diff --git a/projects/social_platform/src/app/office/shared/carousel/carousel.component.html b/projects/social_platform/src/app/office/shared/carousel/carousel.component.html deleted file mode 100644 index 9555f4ddd..000000000 --- a/projects/social_platform/src/app/office/shared/carousel/carousel.component.html +++ /dev/null @@ -1,24 +0,0 @@ - - -@if(images.length) { - -} diff --git a/projects/social_platform/src/app/office/shared/carousel/carousel.component.ts b/projects/social_platform/src/app/office/shared/carousel/carousel.component.ts deleted file mode 100644 index 47e780f78..000000000 --- a/projects/social_platform/src/app/office/shared/carousel/carousel.component.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** @format */ - -import { Component, EventEmitter, Input, type OnInit, Output } from "@angular/core"; -import { FileModel } from "@office/models/file.model"; -import { IconComponent } from "@uilib"; - -/** - * Компонент карусели для просмотра изображений - * - * Функциональность: - * - Отображает изображения в виде карусели с навигацией - * - Поддерживает навигацию вперед/назад через кнопки - * - Обработка двойного тапа для лайка изображения - * - Анимация сердечка при лайке - * - Поддерживает как объекты FileModel, так и строковые URL - * - Отображает текущий индекс изображения - * - * Входные параметры: - * @Input images - массив изображений (FileModel или строки URL) - * - * Выходные события: - * @Output like - событие лайка изображения, передает индекс изображения - * - * Внутренние свойства: - * - currentIndex - текущий индекс отображаемого изображения - * - lastTouch - время последнего касания для определения двойного тапа - * - showLike - флаг отображения анимации лайка - */ -@Component({ - selector: "app-carousel", - imports: [IconComponent], - templateUrl: "./carousel.component.html", - styleUrls: ["./carousel.component.scss"], - standalone: true, -}) -export class CarouselComponent implements OnInit { - @Input() images: Array = []; - @Output() like: EventEmitter = new EventEmitter(); - - currentIndex = 0; - lastTouch = 0; - showLike = false; - - ngOnInit(): void {} - - /** - * Переход к следующему изображению - * Использует циклическую навигацию - */ - next(): void { - if (this.images.length) { - this.currentIndex = (this.currentIndex + 1) % this.images.length; - } - } - - /** - * Переход к предыдущему изображению - * Использует циклическую навигацию - */ - prev(): void { - if (this.images.length) { - this.currentIndex = (this.currentIndex - 1 + this.images.length) % this.images.length; - } - } - - /** - * Обработчик касания изображения - * Определяет двойной тап и запускает лайк с анимацией - */ - onTouchImg(_event: TouchEvent): void { - const now = Date.now(); - if (now - this.lastTouch < 300) { - this.like.emit(this.currentIndex); - this.showLike = true; - setTimeout(() => { - this.showLike = false; - }, 1000); - } - this.lastTouch = now; - } - - /** - * Получение URL изображения - * Обрабатывает как объекты FileModel, так и строки - */ - getImageUrl(image: FileModel | string): string { - return typeof image === "string" ? image : image.link; - } - - /** - * Получение имени изображения - * Обрабатывает как объекты FileModel, так и строки - */ - getImageName(image: FileModel | string): string { - return typeof image === "string" ? "Image" : image.name || "Image"; - } -} diff --git a/projects/social_platform/src/app/office/shared/collaborator-card/collaborator-card.component.html b/projects/social_platform/src/app/office/shared/collaborator-card/collaborator-card.component.html deleted file mode 100644 index 814e71a73..000000000 --- a/projects/social_platform/src/app/office/shared/collaborator-card/collaborator-card.component.html +++ /dev/null @@ -1,26 +0,0 @@ - - -@if (collaborator) { -
    -
    - -
    -

    - {{ collaborator.firstName | truncate: 12 }} {{ collaborator.lastName | truncate: 12 }} -

    -

    {{ collaborator.role | truncate: 12 }}

    -
    - -
    - - -
    -
    -
    -} diff --git a/projects/social_platform/src/app/office/shared/collaborator-card/collaborator-card.component.spec.ts b/projects/social_platform/src/app/office/shared/collaborator-card/collaborator-card.component.spec.ts deleted file mode 100644 index a14a0ba25..000000000 --- a/projects/social_platform/src/app/office/shared/collaborator-card/collaborator-card.component.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { InviteCardComponent } from "./collaborator-card.component"; - -describe("VacancyCardComponent", () => { - let component: InviteCardComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [InviteCardComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(InviteCardComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/shared/collaborator-card/collaborator-card.component.ts b/projects/social_platform/src/app/office/shared/collaborator-card/collaborator-card.component.ts deleted file mode 100644 index cdd3e7416..000000000 --- a/projects/social_platform/src/app/office/shared/collaborator-card/collaborator-card.component.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { Component, EventEmitter, inject, Input, OnInit, Output } from "@angular/core"; -import { FormBuilder, FormGroup, ReactiveFormsModule } from "@angular/forms"; -import { ActivatedRoute } from "@angular/router"; -import { ErrorMessage } from "@error/models/error-message"; -import { Collaborator } from "@office/models/collaborator.model"; -import { ProjectService } from "@office/services/project.service"; -import { AvatarComponent } from "@ui/components/avatar/avatar.component"; -import { IconComponent } from "@uilib"; -import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; - -/** - * Компонент карточки участника команды или проект - * - * Функциональность: - * - Отображает информацию о участнике (роль, специализация) - * - * Входные параметры: - * @Input invite - объект участника (обязательный) - */ -@Component({ - selector: "app-collaborator-card", - templateUrl: "./collaborator-card.component.html", - styleUrl: "./collaborator-card.component.scss", - standalone: true, - imports: [CommonModule, ReactiveFormsModule, AvatarComponent, IconComponent, TruncatePipe], -}) -export class CollaboratorCardComponent implements OnInit { - private readonly projectService = inject(ProjectService); - private readonly route = inject(ActivatedRoute); - private readonly fb = inject(FormBuilder); - - constructor() { - this.inviteForm = this.fb.group({ - role: [""], - specializations: this.fb.array([]), - }); - } - - inviteForm: FormGroup; - errorMessage = ErrorMessage; - - @Input({ required: true }) collaborator!: Collaborator; - @Output() collaboratorRemoved = new EventEmitter(); - - ngOnInit(): void { - if (this.collaborator) { - this.inviteForm.patchValue({ - role: this.collaborator.role, - specialization: this.collaborator.skills, - }); - } - } - - onDeleteCollaborator(collaboratorId: number): void { - const projectId = this.route.snapshot.params["projectId"]; - - if (!confirm("Вы точно хотите удалить участника проекта?")) return; - - this.projectService.removeColloborator(+projectId, collaboratorId).subscribe({ - next: () => { - this.collaboratorRemoved.emit(collaboratorId); - }, - }); - } -} diff --git a/projects/social_platform/src/app/office/shared/header/header.component.html b/projects/social_platform/src/app/office/shared/header/header.component.html deleted file mode 100644 index 3b4e81d17..000000000 --- a/projects/social_platform/src/app/office/shared/header/header.component.html +++ /dev/null @@ -1,41 +0,0 @@ - - -
    -
    -
    -
    - @if ((showBall | async) || hasInvites) { -
    - } - - @if (showNotifications) { -
    -

    Уведомления

    -
      - @for (invite of invites; track invite.id) { -
    • - -
    • - } -
    -
    - } -
    -
    - @if (authService.profile | async; as user) { - - } -
    -
    -
    diff --git a/projects/social_platform/src/app/office/shared/header/header.component.ts b/projects/social_platform/src/app/office/shared/header/header.component.ts deleted file mode 100644 index 8f07acc2e..000000000 --- a/projects/social_platform/src/app/office/shared/header/header.component.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** @format */ - -import { Component, Input, OnInit } from "@angular/core"; -import { NotificationService } from "@services/notification.service"; -import { AuthService } from "@auth/services"; -import { Invite } from "@models/invite.model"; -import { InviteService } from "@services/invite.service"; -import { Router } from "@angular/router"; -import { IconComponent } from "@ui/components"; -import { AsyncPipe } from "@angular/common"; -import { ClickOutsideModule } from "ng-click-outside"; -import { InviteManageCardComponent, ProfileInfoComponent } from "@uilib"; - -/** - * Компонент заголовка приложения - * - * Функциональность: - * - Отображает верхнюю панель приложения с уведомлениями - * - Управляет отображением панели уведомлений - * - Показывает индикатор наличия уведомлений (красный шарик) - * - Обрабатывает приглашения пользователя (принятие/отклонение) - * - Отображает информацию о профиле пользователя - * - Закрывает панель уведомлений при клике вне её области - * - Перенаправляет на страницу проекта при принятии приглашения - * - * Входные параметры: - * @Input invites - массив приглашений пользователя - * - * Внутренние свойства: - * - showBall - индикатор наличия уведомлений (из NotificationService) - * - showNotifications - флаг отображения панели уведомлений - * - hasInvites - вычисляемое свойство наличия непрочитанных приглашений - * - * Сервисы: - * - notificationService - управление уведомлениями - * - authService - аутентификация и профиль пользователя - * - inviteService - работа с приглашениями - * - router - навигация по приложению - */ -@Component({ - selector: "app-header", - templateUrl: "./header.component.html", - styleUrl: "./header.component.scss", - standalone: true, - imports: [ - ClickOutsideModule, - IconComponent, - InviteManageCardComponent, - ProfileInfoComponent, - AsyncPipe, - ], -}) -export class HeaderComponent implements OnInit { - constructor( - private readonly notificationService: NotificationService, - public readonly authService: AuthService, - private readonly inviteService: InviteService, - private readonly router: Router - ) {} - - @Input() invites: Invite[] = []; - - ngOnInit(): void {} - - showBall = this.notificationService.hasNotifications; - showNotifications = false; - - /** - * Проверка наличия непринятых приглашений - * Возвращает true если есть приглашения со статусом null (не принято/не отклонено) - */ - get hasInvites(): boolean { - return !!this.invites.filter(invite => invite.isAccepted === null).length; - } - - /** - * Обработчик клика вне панели уведомлений - * Закрывает панель уведомлений - */ - onClickOutside() { - this.showNotifications = false; - } - - /** - * Обработчик отклонения приглашения - * Отправляет запрос на отклонение и удаляет приглашение из списка - */ - onRejectInvite(inviteId: number): void { - this.inviteService.rejectInvite(inviteId).subscribe(() => { - const index = this.invites.findIndex(invite => invite.id === inviteId); - this.invites.splice(index, 1); - - this.showNotifications = false; - }); - } - - /** - * Обработчик принятия приглашения - * Отправляет запрос на принятие, удаляет приглашение из списка - * и перенаправляет пользователя на страницу проекта - */ - onAcceptInvite(inviteId: number): void { - this.inviteService.acceptInvite(inviteId).subscribe(() => { - const index = this.invites.findIndex(invite => invite.id === inviteId); - const invite = JSON.parse(JSON.stringify(this.invites[index])); - this.invites.splice(index, 1); - - this.showNotifications = false; - this.router - .navigateByUrl(`/office/projects/${invite.project.id}`) - .then(() => console.debug("Route changed from HeaderComponent")); - }); - } -} diff --git a/projects/social_platform/src/app/office/shared/img-card/img-card.component.html b/projects/social_platform/src/app/office/shared/img-card/img-card.component.html deleted file mode 100644 index 18c77342f..000000000 --- a/projects/social_platform/src/app/office/shared/img-card/img-card.component.html +++ /dev/null @@ -1,51 +0,0 @@ - - -
    - @if (src && !loading && !error) { - user-generated content - } @if (error) { -
    -
    - - Ошибка загрузки -
    - -
    - } @if (loading) { - - - - - - - } -
    - -
    -
    diff --git a/projects/social_platform/src/app/office/shared/img-card/img-card.component.ts b/projects/social_platform/src/app/office/shared/img-card/img-card.component.ts deleted file mode 100644 index 29154dccf..000000000 --- a/projects/social_platform/src/app/office/shared/img-card/img-card.component.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** @format */ - -import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; -import { IconComponent } from "@ui/components"; - -/** - * Компонент карточки изображения с состояниями загрузки и ошибки - * - * Функциональность: - * - Отображает изображение по переданному URL - * - Показывает состояния загрузки и ошибки - * - Предоставляет кнопки для отмены и повторной попытки загрузки - * - * Входные параметры: - * @Input src - URL изображения (по умолчанию пустая строка) - * @Input error - флаг состояния ошибки (по умолчанию false) - * @Input loading - флаг состояния загрузки (по умолчанию false) - * - * Выходные события: - * @Output cancel - событие отмены загрузки/отображения изображения - * @Output retry - событие повторной попытки загрузки изображения - */ -@Component({ - selector: "app-img-card", - templateUrl: "./img-card.component.html", - styleUrl: "./img-card.component.scss", - standalone: true, - imports: [IconComponent], -}) -export class ImgCardComponent implements OnInit { - constructor() {} - - @Input() src = ""; - @Input() error = false; - @Input() loading = false; - - @Output() cancel = new EventEmitter(); - @Output() retry = new EventEmitter(); - - ngOnInit(): void {} -} diff --git a/projects/social_platform/src/app/office/shared/link-card/link-card.component.html b/projects/social_platform/src/app/office/shared/link-card/link-card.component.html deleted file mode 100644 index 716f4b6b2..000000000 --- a/projects/social_platform/src/app/office/shared/link-card/link-card.component.html +++ /dev/null @@ -1,20 +0,0 @@ - - -@if (data) { -
    -
    -
    -

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

    -

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

    -
    -
    -
    - - -
    -
    -} diff --git a/projects/social_platform/src/app/office/shared/link-card/link-card.component.scss b/projects/social_platform/src/app/office/shared/link-card/link-card.component.scss deleted file mode 100644 index 29f36d128..000000000 --- a/projects/social_platform/src/app/office/shared/link-card/link-card.component.scss +++ /dev/null @@ -1,34 +0,0 @@ -/** @format */ - -.vacancy { - display: flex; - align-items: center; - justify-content: space-between; - padding: 10px; - background-color: var(--light-gray); - border-radius: var(--rounded-md); - - &__role { - color: var(--black); - } - - &__requirements { - color: var(--dark-grey); - } - - &__icons { - display: flex; - gap: 15px; - align-items: center; - } - - &__basket { - color: var(--red); - cursor: pointer; - } - - &__edit { - color: var(--dark-grey); - cursor: pointer; - } -} diff --git a/projects/social_platform/src/app/office/shared/link-card/link-card.component.spec.ts b/projects/social_platform/src/app/office/shared/link-card/link-card.component.spec.ts deleted file mode 100644 index 294d83b9f..000000000 --- a/projects/social_platform/src/app/office/shared/link-card/link-card.component.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { of } from "rxjs"; -import { AuthService } from "@auth/services"; -import { LinkCardComponent } from "./link-card.component"; - -describe("VacancyCardComponent", () => { - let component: LinkCardComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - const authSpy = { - roles: of([]), - }; - - await TestBed.configureTestingModule({ - imports: [LinkCardComponent], - providers: [{ provide: AuthService, useValue: authSpy }], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(LinkCardComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/shared/link-card/link-card.component.ts b/projects/social_platform/src/app/office/shared/link-card/link-card.component.ts deleted file mode 100644 index 7418a4c40..000000000 --- a/projects/social_platform/src/app/office/shared/link-card/link-card.component.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** @format */ - -import { UpperCasePipe } from "@angular/common"; -import { Component, EventEmitter, Input, Output } from "@angular/core"; -import { IconComponent } from "@ui/components"; -import { LinkTransformPipe } from "projects/core/src/lib/pipes/link-transform.pipe"; - -/** - * Компонент карточки ссылки или достижения - * - * Функциональность: - * - Отображает ссылку или достижение в виде карточки - * - Поддерживает два типа: "link" (ссылка) и "achievement" (достижение) - * - Предоставляет кнопки для редактирования и удаления - * - Использует трансформацию ссылок через LinkTransformPipe - * - Отображает данные в формате JSON для отладки - * - * Входные параметры: - * @Input data - данные ссылки или достижения (любой объект) - * @Input type - тип карточки: "link" или "achievement" (по умолчанию "link") - * - * Выходные события: - * @Output remove - событие удаления, передает ID элемента - * @Output edit - событие редактирования, передает ID элемента - */ -@Component({ - selector: "app-link-card", - templateUrl: "./link-card.component.html", - styleUrl: "./link-card.component.scss", - standalone: true, - imports: [IconComponent, LinkTransformPipe, UpperCasePipe], -}) -export class LinkCardComponent { - constructor() {} - - @Input() data?: any; - @Input() type: "link" | "achievement" = "link"; - @Output() remove = new EventEmitter(); - @Output() edit = new EventEmitter(); - - /** - * Обработчик удаления элемента - * Предотвращает всплытие события и эмитит событие удаления - */ - onRemove(event: MouseEvent): void { - event.stopPropagation(); - event.preventDefault(); - - this.remove.emit(this.data?.id); - } - - /** - * Обработчик редактирования элемента - * Предотвращает всплытие события и эмитит событие редактирования - */ - onEdit(event: MouseEvent): void { - event.stopPropagation(); - event.preventDefault(); - - this.edit.emit(this.data?.id); - } -} diff --git a/projects/social_platform/src/app/office/shared/skills-basket/skills-basket.component.html b/projects/social_platform/src/app/office/shared/skills-basket/skills-basket.component.html deleted file mode 100644 index 406fbe7fd..000000000 --- a/projects/social_platform/src/app/office/shared/skills-basket/skills-basket.component.html +++ /dev/null @@ -1,26 +0,0 @@ - - -
    -
    - @for (skill of value(); track skill.id) { -
    - {{ skill.name }} - -
    - } @empty { -
    - - Выберите навыки -
    - } -
    -
    diff --git a/projects/social_platform/src/app/office/shared/skills-basket/skills-basket.component.ts b/projects/social_platform/src/app/office/shared/skills-basket/skills-basket.component.ts deleted file mode 100644 index 91a1a9920..000000000 --- a/projects/social_platform/src/app/office/shared/skills-basket/skills-basket.component.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { Component, forwardRef, Input, signal } from "@angular/core"; -import { Skill } from "@office/models/skill.model"; -import { IconComponent } from "@ui/components"; -import { NG_VALUE_ACCESSOR } from "@angular/forms"; -import { noop } from "rxjs"; - -/** - * Компонент корзины навыков - * Отображает выбранные навыки в виде тегов с возможностью удаления - * Используется в формах выбора навыков как визуальное представление выбранных элементов - * - * Реализует ControlValueAccessor для интеграции с Angular Forms - * Поддерживает отображение состояния ошибки валидации - */ -@Component({ - selector: "app-skills-basket", - templateUrl: "./skills-basket.component.html", - styleUrl: "./skills-basket.component.scss", - imports: [CommonModule, IconComponent], - standalone: true, - providers: [ - { - // Регистрация как ControlValueAccessor для работы с формами - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => SkillsBasketComponent), - multi: true, - }, - ], -}) -export class SkillsBasketComponent { - /** Флаг отображения состояния ошибки (красная рамка) */ - @Input() error = false; - - /** Сигнал для хранения массива выбранных навыков */ - value = signal([]); - - // Методы ControlValueAccessor - /** Функция обратного вызова для уведомления об изменениях */ - onChange: (val: Skill[]) => void = noop; - /** Функция обратного вызова для уведомления о касании */ - onTouched: () => void = noop; - - /** - * Установка значения в компонент (ControlValueAccessor) - * @param val - массив навыков для отображения в корзине - */ - writeValue(val: Skill[]): void { - if (val) { - this.value.set(val); - } - } - - /** - * Регистрация функции обратного вызова для изменений (ControlValueAccessor) - * @param fn - функция для вызова при изменении значения - */ - registerOnChange(fn: (v: unknown) => void): void { - this.onChange = fn; - } - - /** - * Регистрация функции обратного вызова для касания (ControlValueAccessor) - * @param fn - функция для вызова при касании компонента - */ - registerOnTouched(fn: () => void): void { - this.onTouched = fn; - } - - /** - * Удаление навыка из корзины - * Фильтрует массив навыков, исключая навык с указанным ID - * Уведомляет родительский компонент об изменении через onChange - * @param id - идентификатор навыка для удаления - */ - deleteSkill(id: number): void { - const filtered = this.value().filter(skill => skill.id !== id); - - this.value.set(filtered); - this.onChange(filtered); - } -} diff --git a/projects/social_platform/src/app/office/shared/skills-group/skills-group.component.html b/projects/social_platform/src/app/office/shared/skills-group/skills-group.component.html deleted file mode 100644 index 00b6ace9d..000000000 --- a/projects/social_platform/src/app/office/shared/skills-group/skills-group.component.html +++ /dev/null @@ -1,37 +0,0 @@ - - -
    -
    - {{ title }} - @if (!disabled) { - - } -
    - @if (contentVisible()) { -
    - @for (opt of options; track opt.id) { -
    -
    -
    - -
    -
    - -
    - } -
    - } -
    diff --git a/projects/social_platform/src/app/office/shared/skills-group/skills-group.component.ts b/projects/social_platform/src/app/office/shared/skills-group/skills-group.component.ts deleted file mode 100644 index c53fc5e95..000000000 --- a/projects/social_platform/src/app/office/shared/skills-group/skills-group.component.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** @format */ -import { CommonModule } from "@angular/common"; -import { - ChangeDetectionStrategy, - Component, - EventEmitter, - Input, - Output, - signal, -} from "@angular/core"; -import { IconComponent } from "@ui/components"; -import { Skill } from "@office/models/skill.model"; - -/** - * Компонент группы навыков с возможностью множественного выбора - * - * Функциональность: - * - Отображает заголовок группы навыков - * - Показывает/скрывает список навыков при клике на заголовок - * - Поддерживает множественный выбор навыков с чекбоксами - * - Синхронизирует состояние выбранных навыков с внешним состоянием - * - Использует Angular Signals для реактивности - * - Использует OnPush стратегию для оптимизации производительности - * - Поддерживает disabled состояние когда открыты другие группы - * - * Входные параметры: - * @Input options - массив доступных навыков (обязательный) - * @Input selected - массив выбранных навыков (обязательный) - * @Input title - заголовок группы навыков (обязательный) - * @Input disabled - флаг отключения взаимодействия с группой - * - * Выходные события: - * @Output optionToggled - событие переключения навыка, передает навык который был включен/выключен - * @Output groupToggled - событие переключения видимости группы - */ -@Component({ - selector: "app-skills-group", - standalone: true, - imports: [CommonModule, IconComponent], - templateUrl: "./skills-group.component.html", - styleUrl: "./skills-group.component.scss", - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class SkillsGroupComponent { - /** - * Сеттер для опций навыков - * Обновляет внутренний сигнал с массивом навыков - */ - @Input({ required: true }) set options(value: Skill[]) { - this._options.set(value); - } - - get options(): (Skill & { checked?: boolean })[] { - return this._options(); - } - - /** - * Сеттер для выбранных навыков - * Обновляет состояние выбора для каждого навыка в списке опций - */ - @Input({ required: true }) set selected(value: Skill[]) { - this._selected.set(value); - - const options = this.options.map(opt => { - return { ...opt, checked: value.some(skill => skill.id === opt.id) }; - }); - - this._options.set(options); - } - - get selected(): Skill[] { - return this._selected(); - } - - @Input({ required: true }) title!: string; - @Input() hasOpenGroups = false; - @Input() disabled = false; - @Output() groupToggled = new EventEmitter(); - @Output() optionToggled = new EventEmitter(); - - _options = signal<(Skill & { checked?: boolean })[]>([]); - _selected = signal([]); - contentVisible = signal(false); - - /** - * Переключение видимости содержимого группы - * Теперь учитывает disabled состояние - */ - toggleContentVisible() { - if (this.disabled) { - return; - } - - this.contentVisible.update(val => !val); - this.groupToggled.emit(this.contentVisible()); - } - - /** - * Обработка клика по опции навыка - * Теперь учитывает disabled состояние - */ - onOptionClick(opt: Skill) { - if (this.disabled) { - return; - } - - this.optionToggled.emit(opt); - } -} diff --git a/projects/social_platform/src/app/office/shared/soon-card/soon-card.component.ts b/projects/social_platform/src/app/office/shared/soon-card/soon-card.component.ts deleted file mode 100644 index 42824d0b8..000000000 --- a/projects/social_platform/src/app/office/shared/soon-card/soon-card.component.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { Component, Input } from "@angular/core"; -import { ButtonComponent } from "@ui/components"; -import { IconComponent } from "@uilib"; - -@Component({ - selector: "app-soon-card", - templateUrl: "./soon-card.component.html", - styleUrl: "./soon-card.component.scss", - imports: [CommonModule, IconComponent, ButtonComponent], - standalone: true, -}) -export class SoonCardComponent { - @Input({ required: true }) title!: string; - - @Input({ required: true }) description!: string; -} diff --git a/projects/social_platform/src/app/office/shared/specializations-group/specializations-group.component.html b/projects/social_platform/src/app/office/shared/specializations-group/specializations-group.component.html deleted file mode 100644 index 1d4ea544f..000000000 --- a/projects/social_platform/src/app/office/shared/specializations-group/specializations-group.component.html +++ /dev/null @@ -1,24 +0,0 @@ - - -
    -
    - {{ title }} - @if (!disabled) { - - } -
    - @if (contentVisible()) { -
    - @for (opt of options; track opt.id) { -
    - -
    - } -
    - } -
    diff --git a/projects/social_platform/src/app/office/shared/specializations-group/specializations-group.component.ts b/projects/social_platform/src/app/office/shared/specializations-group/specializations-group.component.ts deleted file mode 100644 index 47faab9bf..000000000 --- a/projects/social_platform/src/app/office/shared/specializations-group/specializations-group.component.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { - ChangeDetectionStrategy, - Component, - EventEmitter, - Input, - Output, - signal, -} from "@angular/core"; -import { IconComponent } from "@ui/components"; -import { Specialization } from "@office/models/specialization.model"; - -/** - * Компонент группы специализаций с возможностью сворачивания/разворачивания - * - * Функциональность: - * - Отображает заголовок группы специализаций - * - Показывает/скрывает список специализаций при клике на заголовок - * - Позволяет выбирать специализацию из списка - * - Использует Angular Signals для реактивности - * - Использует OnPush стратегию для оптимизации производительности - * - Поддерживает disabled состояние когда открыты другие группы - * - * Входные параметры: - * @Input title - заголовок группы специализаций (обязательный) - * @Input options - массив специализаций для отображения (обязательный) - * @Input disabled - флаг отключения взаимодействия с группой - * @Input hasOpenGroups - флаг наличия открытых групп для адаптации ширины - * - * Выходные события: - * @Output selectOption - событие выбора специализации, передает выбранную специализацию - * @Output groupToggled - событие переключения видимости группы - */ -@Component({ - selector: "app-specializations-group", - standalone: true, - imports: [CommonModule, IconComponent], - templateUrl: "./specializations-group.component.html", - styleUrl: "./specializations-group.component.scss", - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class SpecializationsGroupComponent { - @Input({ required: true }) title!: string; - @Input({ required: true }) options!: Specialization[]; - @Input() hasOpenGroups = false; - @Input() disabled = false; - @Output() selectOption = new EventEmitter(); - @Output() groupToggled = new EventEmitter(); - - contentVisible = signal(false); - - /** - * Переключение видимости содержимого группы - * Теперь учитывает disabled состояние - */ - toggleContentVisible() { - if (this.disabled) { - return; - } - - this.contentVisible.update(val => !val); - this.groupToggled.emit(this.contentVisible()); - } - - /** - * Обработчик выбора специализации - * Эмитит событие с выбранной специализацией - * Теперь учитывает disabled состояние - */ - onSelectOption(opt: Specialization) { - if (this.disabled) { - return; - } - - this.selectOption.emit(opt); - } -} diff --git a/projects/social_platform/src/app/office/vacancies/detail/info/info.component.html b/projects/social_platform/src/app/office/vacancies/detail/info/info.component.html deleted file mode 100644 index e0c8cb6e0..000000000 --- a/projects/social_platform/src/app/office/vacancies/detail/info/info.component.html +++ /dev/null @@ -1,258 +0,0 @@ - - -@if (vacancy) { -
    -
    -
    - @if (vacancy.description) { -
    -
    -

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

    - -
    -
    -

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

    навыки

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

    {{ project.name | truncate: 20 }}

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

    метаданные

    - -
    - -
      -
    • - -

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

      -
    • - -
    • - -

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

      -
    • - -
    • - -

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

      -
    • - -
    • - -

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

      -
    • - -
    • - -

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

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

    контакты

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

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

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

    или

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

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

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

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

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

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

    - перейти к вакансиям -
    -
    -} diff --git a/projects/social_platform/src/app/office/vacancies/detail/info/info.component.scss b/projects/social_platform/src/app/office/vacancies/detail/info/info.component.scss deleted file mode 100644 index 65ba97902..000000000 --- a/projects/social_platform/src/app/office/vacancies/detail/info/info.component.scss +++ /dev/null @@ -1,308 +0,0 @@ -@use "styles/responsive"; -@use "styles/typography"; - -@mixin expandable-list { - &__remaining { - display: grid; - grid-template-rows: 0fr; - overflow: hidden; - transition: all 0.5s ease-in-out; - - &--show { - grid-template-rows: 1fr; - margin-top: 12px; - } - - ul { - min-height: 0; - } - - li { - &:first-child { - margin-top: 12px; - } - - &:not(:last-child) { - margin-bottom: 12px; - } - } - } -} - -.lists { - &__section { - display: flex; - justify-content: space-between; - margin-bottom: 8px; - border-bottom: 0.5px solid var(--accent); - } - - &__list { - display: flex; - flex-direction: column; - gap: 8px; - } - - &__icon { - color: var(--accent); - } - - &__title { - margin-bottom: 8px; - color: var(--accent); - } - - &__item { - display: flex; - gap: 6px; - align-items: center; - - &--status { - padding: 8px; - border: 0.5px solid var(--medium-grey-for-outline); - border-radius: var(--rounded-xl); - } - - &--title { - color: var(--black); - } - - i { - padding: 6px; - background-color: var(--light-white); - border: 0.5px solid var(--medium-grey-for-outline); - border-radius: var(--rounded-xl); - } - - p { - color: var(--accent); - } - - span { - cursor: pointer; - } - } -} - -.vacancy { - &__content { - padding: 24px; - margin-bottom: 20px; - background-color: var(--light-white); - border: 0.5px solid var(--medium-grey-for-outline); - border-radius: var(--rounded-lg); - } - - &__right { - display: flex; - flex-direction: column; - gap: 20px; - text-align: center; - - &--title { - margin: 12px 0; - } - - &--project { - position: relative; - padding: 48px 24px 24px; - background-color: var(--light-white); - border: 0.5px solid var(--medium-grey-for-outline); - border-radius: var(--rounded-lg); - - &-image { - position: absolute; - top: -70px; - left: 50%; - display: block; - transform: translate(-50%, 50%); - } - } - } - - .skills { - &__list { - display: flex; - flex-wrap: wrap; - gap: 10px; - } - - li { - &:not(:last-child) { - margin-bottom: 12px; - } - } - - @include expandable-list; - } - - &__split { - display: grid; - grid-template-columns: 7fr 3fr; - gap: 20px; - } - - .read-more { - margin-top: 12px; - color: var(--accent); - cursor: pointer; - transition: color 0.2s; - - &:hover { - color: var(--accent-dark); - } - } - - .about { - - /* stylelint-disable value-no-vendor-prefix */ - &__text { - p { - display: -webkit-box; - overflow: hidden; - color: var(--black); - text-overflow: ellipsis; - -webkit-box-orient: vertical; - -webkit-line-clamp: 5; - transition: all 0.7s ease-in-out; - - &.expanded { - -webkit-line-clamp: unset; - } - } - - ::ng-deep a { - color: var(--accent); - text-decoration: underline; - text-decoration-color: transparent; - text-underline-offset: 3px; - transition: text-decoration-color 0.2s; - - &:hover { - text-decoration-color: var(--accent); - } - } - } - - /* stylelint-enable value-no-vendor-prefix */ - - &__read-full { - margin-top: 2px; - color: var(--accent); - cursor: pointer; - } - } - - &__form { - display: flex; - flex-direction: column; - gap: 10px; - - label { - color: var(--black); - } - - &--or { - color: var(--grey-for-text); - text-align: center; - } - - &-error { - border: 0.5px solid var(--red); - } - - &--cv { - ::ng-deep { - app-upload-file { - .control { - height: 80px; - border-radius: var(--rounded-xl); - } - } - } - - &-empty { - display: flex; - flex-direction: column; - gap: 12px; - align-items: center; - color: var(--grey-for-text); - } - } - } -} - -.cancel { - display: flex; - flex-direction: column; - width: 600px; - max-height: calc(100vh - 40px); - overflow-y: auto; - - &__top { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - } - - &__image { - display: flex; - flex-direction: column; - gap: 15px; - align-items: center; - justify-content: space-between; - - @include responsive.apply-desktop { - display: flex; - flex-direction: row; - gap: 15px; - align-items: center; - justify-content: space-between; - margin: 30px 0; - } - } - - &__cross { - position: absolute; - top: 0; - right: 0; - width: 32px; - height: 32px; - cursor: pointer; - - @include responsive.apply-desktop { - top: 8px; - right: 8px; - } - } - - &__title { - margin-top: 15px; - margin-bottom: 15px; - text-align: center; - } -} - -$succeed-modal-width: 310px; - -.succeed { - display: flex; - flex-direction: column; - align-items: center; - width: $succeed-modal-width; - - &__check { - margin-bottom: 18px; - color: var(--green); - } - - &__text { - margin-bottom: 18px; - color: var(--black); - } - - &__link { - width: $succeed-modal-width; - } -} diff --git a/projects/social_platform/src/app/office/vacancies/detail/info/info.component.spec.ts b/projects/social_platform/src/app/office/vacancies/detail/info/info.component.spec.ts deleted file mode 100644 index 6ced6bd14..000000000 --- a/projects/social_platform/src/app/office/vacancies/detail/info/info.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { InfoComponent } from "./info.component"; - -describe("InfoComponent", () => { - let component: InfoComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [InfoComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(InfoComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/vacancies/detail/info/info.component.ts b/projects/social_platform/src/app/office/vacancies/detail/info/info.component.ts deleted file mode 100644 index c1cd65256..000000000 --- a/projects/social_platform/src/app/office/vacancies/detail/info/info.component.ts +++ /dev/null @@ -1,228 +0,0 @@ -/** @format */ - -import { - ChangeDetectorRef, - Component, - ElementRef, - inject, - OnInit, - signal, - ViewChild, -} from "@angular/core"; -import { ActivatedRoute, Router, RouterModule } from "@angular/router"; -import { AuthService } from "@auth/services"; -import { - ControlErrorPipe, - ParseBreaksPipe, - ParseLinksPipe, - SubscriptionPlan, - SubscriptionPlansService, - ValidationService, -} from "@corelib"; -import { Project } from "@office/models/project.model"; -import { Vacancy } from "@office/models/vacancy.model"; -import { ProjectService } from "@office/services/project.service"; -import { ButtonComponent } from "@ui/components"; -import { ModalComponent } from "@ui/components/modal/modal.component"; -import { TagComponent } from "@ui/components/tag/tag.component"; -import { IconComponent } from "@uilib"; -import { expandElement } from "@utils/expand-element"; -import { SalaryTransformPipe } from "projects/core/src/lib/pipes/salary-transform.pipe"; -import { map, Subscription } from "rxjs"; -import { CapitalizePipe } from "projects/core/src/lib/pipes/capitalize.pipe"; -import { AvatarComponent } from "@ui/components/avatar/avatar.component"; -import { UserLinksPipe } from "@core/pipes/user-links.pipe"; -import { UploadFileComponent } from "@ui/components/upload-file/upload-file.component"; -import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; -import { ErrorMessage } from "@error/models/error-message"; -import { VacancyService } from "@office/services/vacancy.service"; -import { TextareaComponent } from "@ui/components/textarea/textarea.component"; -import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; - -/** - * Компонент отображения детальной информации о вакансии - * - * Основная функциональность: - * - Отображение полной информации о вакансии (описание, навыки, условия) - * - Показ информации о проекте, к которому относится вакансия - * - Кнопки действий: "Откликнуться" и "Прокачать себя" - * - Модальное окно с предложением подписки на обучение - * - Адаптивное отображение с возможностью сворачивания/разворачивания контента - * - * Управление контентом: - * - Автоматическое определение необходимости кнопки "Читать полностью" - * - Сворачивание длинного описания и списка навыков - * - Парсинг ссылок и переносов строк в описании - * - * Интеграция с сервисами: - * - VacancyService - получение данных вакансии через резолвер - * - ProjectService - загрузка информации о проекте - * - SubscriptionPlansService - получение планов подписки - * - AuthService - информация о текущем пользователе - * - * Жизненный цикл: - * - OnInit: загрузка данных вакансии и проекта, подписка на планы - * - AfterViewInit: определение необходимости кнопок "Читать полностью" - * - OnDestroy: отписка от всех активных подписок - * - * @property {Vacancy} vacancy - объект вакансии с полной информацией - * @property {Project} project - объект проекта, к которому относится вакансия - * @property {boolean} readFullDescription - состояние развернутого описания - * @property {boolean} readFullSkills - состояние развернутого списка навыков - * - * @selector app-detail - * @standalone true - автономный компонент - */ -@Component({ - selector: "app-detail", - standalone: true, - imports: [ - IconComponent, - TagComponent, - ButtonComponent, - ModalComponent, - RouterModule, - ReactiveFormsModule, - ParseBreaksPipe, - ParseLinksPipe, - TruncatePipe, - SalaryTransformPipe, - CapitalizePipe, - UserLinksPipe, - ControlErrorPipe, - AvatarComponent, - UploadFileComponent, - TextareaComponent, - ], - templateUrl: "./info.component.html", - styleUrl: "./info.component.scss", -}) -export class VacancyInfoComponent implements OnInit { - private readonly route = inject(ActivatedRoute); - private readonly router = inject(Router); - private readonly vacancyService = inject(VacancyService); - private readonly validationService = inject(ValidationService); - private readonly cdRef = inject(ChangeDetectorRef); - private readonly fb = inject(FormBuilder); - - constructor() { - // Создание формы отклика с валидацией - this.sendForm = this.fb.group({ - whyMe: ["", [Validators.required, Validators.minLength(20), Validators.maxLength(2000)]], - accompanyingFile: ["", Validators.required], - }); - } - - vacancy!: Vacancy; - - /** Объект с сообщениями об ошибках */ - errorMessage = ErrorMessage; - - descriptionExpandable!: boolean; - skillsExpandable!: boolean; - - /** Форма отправки отклика */ - sendForm: FormGroup; - - /** Флаг состояния отправки формы */ - sendFormIsSubmitting = false; - - /** Флаг отображения модального окна с результатом */ - resultModal = false; - - openModal = signal(false); - readFullDescription = false; - readFullSkills = false; - - private subscriptions$: Subscription[] = []; - - @ViewChild("skillsEl") skillsEl?: ElementRef; - @ViewChild("descEl") descEl?: ElementRef; - - ngOnInit(): void { - this.route.data.pipe(map(r => r["data"])).subscribe((vacancy: Vacancy) => { - this.vacancy = vacancy; - }); - - this.route.queryParams.subscribe({ - next: r => { - if (r["sendResponse"]) { - this.openModal.set(true); - } - }, - }); - } - - ngAfterViewInit(): void { - const descElement = this.descEl?.nativeElement; - this.descriptionExpandable = descElement?.clientHeight < descElement?.scrollHeight; - - const skillsElement = this.skillsEl?.nativeElement; - this.skillsExpandable = skillsElement?.clientHeight < skillsElement?.scrollHeight; - - this.cdRef.detectChanges(); - } - - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $.unsubscribe()); - } - - closeSendResponseModal(): void { - this.openModal.set(false); - - this.router.navigate([], { - queryParams: {}, - replaceUrl: true, - }); - } - - /** - * Обработчик отправки формы - * Валидирует форму и отправляет отклик на сервер - */ - onSubmit(): void { - // Проверка валидности формы - if (!this.validationService.getFormValidation(this.sendForm)) { - return; - } - - // Установка флага загрузки - this.sendFormIsSubmitting = true; - - // Отправка отклика на сервер - this.vacancyService - .sendResponse(Number(this.route.snapshot.paramMap.get("vacancyId")), this.sendForm.value) - .subscribe({ - next: () => { - // Успешная отправка - показываем модальное окно - this.sendFormIsSubmitting = false; - this.resultModal = true; - this.openModal.set(false); - }, - error: () => { - // Ошибка отправки - снимаем флаг загрузки - this.sendFormIsSubmitting = false; - }, - }); - } - - /** - * Раскрытие/сворачивание описания профиля - * @param elem - DOM элемент описания - * @param expandedClass - CSS класс для раскрытого состояния - * @param isExpanded - текущее состояние (раскрыто/свернуто) - */ - onExpandDescription(elem: HTMLElement, expandedClass: string, isExpanded: boolean): void { - expandElement(elem, expandedClass, isExpanded); - this.readFullDescription = !isExpanded; - } - - onExpandSkills(elem: HTMLElement, expandedClass: string, isExpanded: boolean): void { - expandElement(elem, expandedClass, isExpanded); - this.readFullSkills = !isExpanded; - } - - openSkills() { - location.href = "https://skills.procollab.ru"; - } -} diff --git a/projects/social_platform/src/app/office/vacancies/detail/vacancies-detail.component.html b/projects/social_platform/src/app/office/vacancies/detail/vacancies-detail.component.html deleted file mode 100644 index d32471d20..000000000 --- a/projects/social_platform/src/app/office/vacancies/detail/vacancies-detail.component.html +++ /dev/null @@ -1,11 +0,0 @@ - - -@if (vacancy) { -
    - -
    - -
    - -
    -} diff --git a/projects/social_platform/src/app/office/vacancies/detail/vacancies-detail.component.spec.ts b/projects/social_platform/src/app/office/vacancies/detail/vacancies-detail.component.spec.ts deleted file mode 100644 index 7579866e3..000000000 --- a/projects/social_platform/src/app/office/vacancies/detail/vacancies-detail.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { VacanciesDetailComponent } from "./vacancies-detail.component"; - -describe("VacanciesDetailComponent", () => { - let component: VacanciesDetailComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [VacanciesDetailComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(VacanciesDetailComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/vacancies/detail/vacancies-detail.component.ts b/projects/social_platform/src/app/office/vacancies/detail/vacancies-detail.component.ts deleted file mode 100644 index 3621c2dce..000000000 --- a/projects/social_platform/src/app/office/vacancies/detail/vacancies-detail.component.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { Component, inject, OnDestroy, OnInit } from "@angular/core"; -import { ActivatedRoute, RouterOutlet } from "@angular/router"; -import { Vacancy } from "@office/models/vacancy.model"; -import { BarComponent } from "@ui/components"; -import { map, Subscription } from "rxjs"; -import { BackComponent } from "@uilib"; - -/** - * Компонент детального просмотра вакансии - * - * Функциональность: - * - Получает данные вакансии из резолвера через ActivatedRoute - * - Отображает навигационную панель с кнопкой "Назад" - * - Содержит router-outlet для дочерних компонентов (информация о вакансии) - * - Управляет подписками для предотвращения утечек памяти - * - * Жизненный цикл: - * - OnInit: подписывается на данные маршрута и извлекает объект вакансии - * - OnDestroy: отписывается от всех активных подписок - * - * @property {Vacancy} vacancy - объект вакансии, полученный из резолвера - * @property {Subscription[]} subscriptions$ - массив подписок для управления памятью - * - * @selector app-vacancies-detail - * @standalone true - автономный компонент - */ -@Component({ - selector: "app-vacancies-detail", - standalone: true, - imports: [CommonModule, BarComponent, RouterOutlet, BackComponent], - templateUrl: "./vacancies-detail.component.html", - styleUrl: "./vacancies-detail.component.scss", -}) -export class VacanciesDetailComponent implements OnInit, OnDestroy { - route = inject(ActivatedRoute); - - subscriptions$: Subscription[] = []; - - vacancy?: Vacancy; - - ngOnInit(): void { - const vacancySub$ = this.route.data.pipe(map(r => r["data"])).subscribe(vacancy => { - this.vacancy = vacancy; - }); - - vacancySub$ && this.subscriptions$.push(vacancySub$); - } - - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $.unsubscribe()); - } -} diff --git a/projects/social_platform/src/app/office/vacancies/detail/vacancies-detail.resolver.ts b/projects/social_platform/src/app/office/vacancies/detail/vacancies-detail.resolver.ts deleted file mode 100644 index 52c14bb37..000000000 --- a/projects/social_platform/src/app/office/vacancies/detail/vacancies-detail.resolver.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { ActivatedRouteSnapshot } from "@angular/router"; -import { VacancyService } from "@office/services/vacancy.service"; - -/** - * Резолвер для загрузки детальной информации о конкретной вакансии - * - * Функциональность: - * - Извлекает ID вакансии из параметров маршрута (:vacancyId) - * - Выполняет запрос к API для получения полной информации о вакансии - * - Данные становятся доступными в компоненте до его инициализации - * - * @param {ActivatedRouteSnapshot} route - снимок активного маршрута с параметрами - * @param {VacancyService} vacancyService - сервис для работы с API вакансий - * @returns {Observable} Observable с объектом вакансии - * - * Параметры: - * - vacancyId - ID вакансии из URL параметров (например: /vacancies/123) - */ -export const VacanciesDetailResolver = (route: ActivatedRouteSnapshot) => { - const vacancyService = inject(VacancyService); - const vacancyId = route.params["vacancyId"]; - - return vacancyService.getOne(vacancyId); -}; diff --git a/projects/social_platform/src/app/office/vacancies/detail/vacancies-detail.routes.ts b/projects/social_platform/src/app/office/vacancies/detail/vacancies-detail.routes.ts deleted file mode 100644 index f440a13d9..000000000 --- a/projects/social_platform/src/app/office/vacancies/detail/vacancies-detail.routes.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** @format */ - -import { VacancyInfoComponent } from "./info/info.component"; -import { VacanciesDetailComponent } from "./vacancies-detail.component"; -import { VacanciesDetailResolver } from "./vacancies-detail.resolver"; - -/** - * Конфигурация маршрутов для детального просмотра вакансии - * - * Структура маршрутов: - * - '' (корневой) - основной компонент детального просмотра - * - resolve.data - предварительная загрузка данных вакансии через VacanciesDetailResolver - * - children - дочерние маршруты: - * * '' - компонент с информацией о вакансии (VacancyInfoComponent) - * - * Использование резолвера: - * - VacanciesDetailResolver загружает данные вакансии перед отображением компонента - * - Данные доступны в компоненте через this.route.data['data'] - * - * @returns {Routes} Массив конфигурации маршрутов для детального просмотра - */ -export const VACANCIES_DETAIL_ROUTES = [ - { - path: "", - component: VacanciesDetailComponent, - resolve: { - data: VacanciesDetailResolver, - }, - children: [ - { - path: "", - component: VacancyInfoComponent, - }, - ], - }, -]; diff --git a/projects/social_platform/src/app/office/vacancies/list/list.component.html b/projects/social_platform/src/app/office/vacancies/list/list.component.html deleted file mode 100644 index c5f851fc6..000000000 --- a/projects/social_platform/src/app/office/vacancies/list/list.component.html +++ /dev/null @@ -1,36 +0,0 @@ - - -
    - @if(type() === 'all'){ -
    - @if(vacancyList().length){ @for (vacancy of vacancyList(); track $index) { - - - } } -
    - } @if (type() === 'my') { @if (responsesList().length > 0) { @for (response of responsesList(); - track $index) { - - } - -
    -
    - -

    вы пока не отправили
    ни одного отклика :|

    -
    - - - перейти к вакансиям - -
    -
    - } @else { -
    - -

    - в данном разделе пока нет ваших откликов : - (
    - давайте это исправим -

    -
    - } } -
    diff --git a/projects/social_platform/src/app/office/vacancies/list/list.component.spec.ts b/projects/social_platform/src/app/office/vacancies/list/list.component.spec.ts deleted file mode 100644 index ab755dc02..000000000 --- a/projects/social_platform/src/app/office/vacancies/list/list.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { ListComponent } from "./VacanciesList.component"; - -describe("ListComponent", () => { - let component: ListComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ListComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(ListComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/vacancies/list/list.component.ts b/projects/social_platform/src/app/office/vacancies/list/list.component.ts deleted file mode 100644 index e387a330d..000000000 --- a/projects/social_platform/src/app/office/vacancies/list/list.component.ts +++ /dev/null @@ -1,184 +0,0 @@ -/** @format */ - -// list.component.ts -/** @format */ - -import { Component, inject, signal } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { - catchError, - concatMap, - debounceTime, - fromEvent, - map, - noop, - of, - Subscription, - switchMap, - tap, - throttleTime, -} from "rxjs"; -import { VacancyService } from "@office/services/vacancy.service"; -import { Vacancy } from "@office/models/vacancy.model"; -import { ApiPagination } from "@office/models/api-pagination.model"; -import { ActivatedRoute, Router, RouterLink } from "@angular/router"; -import { VacancyResponse } from "@office/models/vacancy-response.model"; -import { ResponseCardComponent } from "@office/features/response-card/response-card.component"; -import { ProjectVacancyCardComponent } from "@office/projects/detail/shared/project-vacancy-card/project-vacancy-card.component"; -import { ButtonComponent, IconComponent } from "@ui/components"; -import { ModalComponent } from "@ui/components/modal/modal.component"; - -@Component({ - selector: "app-vacancies-list", - standalone: true, - imports: [ - CommonModule, - ResponseCardComponent, - ProjectVacancyCardComponent, - ButtonComponent, - IconComponent, - ModalComponent, - RouterLink, - ], - templateUrl: "./list.component.html", - styleUrl: "./list.component.scss", -}) -export class VacanciesListComponent { - route = inject(ActivatedRoute); - router = inject(Router); - vacancyService = inject(VacancyService); - - totalItemsCount = signal(0); - vacancyList = signal([]); - responsesList = signal([]); - vacancyPage = signal(1); - perFetchTake = signal(20); - type = signal<"all" | "my" | null>(null); - - requiredExperience = signal(undefined); - roleContains = signal(undefined); - workFormat = signal(undefined); - workSchedule = signal(undefined); - salary = signal(undefined); - - isMyModal = signal(false); - - subscriptions$ = signal([]); - - ngOnInit() { - const urlSegment = this.router.url.split("/").slice(-1)[0]; - const trimmedSegment = urlSegment.split("?")[0]; - this.type.set(trimmedSegment as "all" | "my"); - - const routeData$ = - this.type() === "all" - ? this.route.data.pipe(map(r => r["data"])) - : this.route.data.pipe(map(r => r["data"])); - - const subscription = routeData$.subscribe( - (vacancy: ApiPagination | ApiPagination) => { - if (this.type() === "all") { - this.vacancyList.set(vacancy.results as Vacancy[]); - } else if (this.type() === "my") { - this.responsesList.set(vacancy.results as VacancyResponse[]); - } - this.totalItemsCount.set(vacancy.count); - } - ); - - const queryParams$ = this.route.queryParams - .pipe( - debounceTime(200), - tap(params => { - const requiredExperience = params["required_experience"] - ? params["required_experience"] - : undefined; - - const roleContains = params["role_contains"] || undefined; - const workFormat = params["work_format"] ? params["work_format"] : undefined; - const workSchedule = params["work_schedule"] ? params["work_schedule"] : undefined; - const salary = params["salary"] ? params["salary"] : undefined; - - this.requiredExperience.set(requiredExperience); - this.roleContains.set(roleContains); - this.workFormat.set(workFormat); - this.workSchedule.set(workSchedule); - this.salary.set(salary); - }), - switchMap(() => this.onFetch(0, 20)) - ) - .subscribe((result: any) => { - if (this.type() === "all") { - this.vacancyList.set(result.results); - } - this.totalItemsCount.set(result.count); - this.vacancyPage.set(1); - }); - - this.subscriptions$().push(subscription, queryParams$); - - this.myModalSetup(); - } - - ngAfterViewInit() { - const target = document.querySelector(".office__body"); - if (target) { - const scrollEvents$ = fromEvent(target, "scroll") - .pipe( - concatMap(() => this.onScroll().pipe(catchError(() => of({})))), - throttleTime(500) - ) - .subscribe(noop); - - this.subscriptions$().push(scrollEvents$); - } - } - - ngOnDestroy() { - this.subscriptions$().forEach(($: any) => $.unsubscribe()); - } - - private onScroll() { - if (this.totalItemsCount() && this.vacancyList().length >= this.totalItemsCount()) - return of({}); - - const target = document.querySelector(".office__body"); - if (!target) return of({}); - - const diff = target.scrollTop - target.scrollHeight + target.clientHeight; - - if (diff > 0) { - return this.onFetch(this.vacancyPage() * this.perFetchTake(), this.perFetchTake()).pipe( - tap((result: any) => { - this.vacancyPage.update(page => page + 1); - this.vacancyList.update(items => [...items, ...result.results]); - }) - ); - } - - return of({}); - } - - private onFetch(offset: number, limit: number) { - return this.vacancyService - .getForProject( - limit, - offset, - undefined, - this.requiredExperience(), - this.workFormat(), - this.workSchedule(), - this.salary(), - this.roleContains() - ) - .pipe(map(res => res)); - } - - private myModalSetup() { - if (this.type() === "my" && this.responsesList().length === 0) { - this.isMyModal.set(true); - } else { - this.isMyModal.set(false); - } - } -} diff --git a/projects/social_platform/src/app/office/vacancies/list/list.routes.ts b/projects/social_platform/src/app/office/vacancies/list/list.routes.ts deleted file mode 100644 index e3e6397c0..000000000 --- a/projects/social_platform/src/app/office/vacancies/list/list.routes.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** @format */ - -import { Routes } from "@angular/router"; -import { VacanciesListComponent } from "./list.component"; -import { VacanciesMyResolver } from "./my.resolver"; - -/** - * Конфигурация маршрутов для страницы "Мои отклики" - * - * Структура: - * - '' (корневой маршрут) - отображает компонент VacanciesListComponent - * - resolve.data - предварительная загрузка откликов через VacanciesMyResolver - * - * Особенности: - * - Используется тот же компонент VacanciesListComponent, что и для всех вакансий - * - Компонент определяет тип отображения по URL и показывает соответствующий контент - * - Резолвер загружает данные откликов пользователя перед инициализацией компонента - * - * @returns {Routes} Массив конфигурации маршрутов для страницы откликов - */ -export const VACANCY_LIST_ROUTES: Routes = [ - { - path: "", - component: VacanciesListComponent, - resolve: { - data: VacanciesMyResolver, - }, - }, -]; diff --git a/projects/social_platform/src/app/office/vacancies/list/my.resolver.ts b/projects/social_platform/src/app/office/vacancies/list/my.resolver.ts deleted file mode 100644 index 68fc83468..000000000 --- a/projects/social_platform/src/app/office/vacancies/list/my.resolver.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { ResolveFn } from "@angular/router"; -import { VacancyResponse } from "@office/models/vacancy-response.model"; -import { VacancyService } from "@office/services/vacancy.service"; - -/** - * Резолвер для загрузки откликов пользователя на вакансии - * - * Функциональность: - * - Выполняется перед активацией маршрута '/office/vacancies/my' - * - Загружает первые 20 откликов пользователя с offset 0 - * - Возвращает массив объектов VacancyResponse с информацией об откликах - * - * Использование: - * - Данные становятся доступными в компоненте через ActivatedRoute.data['data'] - * - Позволяет отобразить список вакансий, на которые пользователь уже откликнулся - * - * @param {VacancyService} vacanciesService - сервис для работы с API вакансий - * @returns {Observable} Observable с массивом откликов пользователя - * - * Параметры запроса: - * - limit: 20 - количество откликов на страницу - * - offset: 0 - смещение для пагинации - */ -export const VacanciesMyResolver: ResolveFn = () => { - const vacanciesService = inject(VacancyService); - - return vacanciesService.getMyVacancies(20, 0); -}; diff --git a/projects/social_platform/src/app/office/vacancies/shared/filter/vacancy-filter.component.html b/projects/social_platform/src/app/office/vacancies/shared/filter/vacancy-filter.component.html deleted file mode 100644 index 74d0b4155..000000000 --- a/projects/social_platform/src/app/office/vacancies/shared/filter/vacancy-filter.component.html +++ /dev/null @@ -1,96 +0,0 @@ - - -
    -
    -

    фильтры

    - сбросить -
    - - - мои отклики - - - -
    - -
    -
    -
    - фильтры -
    - - @if (filterOpen()) { - - } -
    -
    - - -
    -
    - опыт -
      - @for (option of workExperienceFilterOptions; track $index) { -
    • - - {{ option.label }} -
    • - } -
    -
    - -
    - график -
      - @for (option of workScheduleFilterOptions; track $index) { -
    • - - {{ option.label }} -
    • - } -
    -
    - -
    - формат работы -
      - @for (option of workFormatFilterOptions; track $index) { -
    • - - {{ option.label }} -
    • - } -
    -
    - -
    - заработная плата -
  • - - с зарплатой -
  • -
    -
    -
    diff --git a/projects/social_platform/src/app/office/vacancies/shared/filter/vacancy-filter.component.spec.ts b/projects/social_platform/src/app/office/vacancies/shared/filter/vacancy-filter.component.spec.ts deleted file mode 100644 index 697e7e7fe..000000000 --- a/projects/social_platform/src/app/office/vacancies/shared/filter/vacancy-filter.component.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { VacancyFilterComponent } from "./vacancy-filter.component"; -import { RouterTestingModule } from "@angular/router/testing"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; - -describe("FeedComponent", () => { - let component: VacancyFilterComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [VacancyFilterComponent, RouterTestingModule, HttpClientTestingModule], - }).compileComponents(); - - fixture = TestBed.createComponent(VacancyFilterComponent); - component = fixture.componentInstance; - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/vacancies/shared/filter/vacancy-filter.component.ts b/projects/social_platform/src/app/office/vacancies/shared/filter/vacancy-filter.component.ts deleted file mode 100644 index 3ce19cde0..000000000 --- a/projects/social_platform/src/app/office/vacancies/shared/filter/vacancy-filter.component.ts +++ /dev/null @@ -1,262 +0,0 @@ -/** @format */ - -import { animate, style, transition, trigger } from "@angular/animations"; -import { CommonModule } from "@angular/common"; -import { - ChangeDetectionStrategy, - Component, - EventEmitter, - inject, - Input, - OnInit, - Output, - signal, -} from "@angular/core"; -import { ActivatedRoute, Router, RouterLink } from "@angular/router"; -import { ButtonComponent, CheckboxComponent, IconComponent } from "@ui/components"; -import { ClickOutsideModule } from "ng-click-outside"; -import { FeedService } from "@office/feed/services/feed.service"; -import { VacancyService } from "@office/services/vacancy.service"; -import { map, Subscription, tap } from "rxjs"; -import { workFormatFilter } from "projects/core/src/consts/filters/work-format-filter.const"; -import { workScheduleFilter } from "projects/core/src/consts/filters/work-schedule-filter.const"; -import { workExperienceFilter } from "projects/core/src/consts/filters/work-experience-filter.const"; - -/** - * Компонент фильтра вакансий без использования реактивных форм - * Использует сигналы для управления состоянием полей зарплаты - */ -@Component({ - selector: "app-vacancy-filter", - standalone: true, - imports: [ - CommonModule, - CheckboxComponent, - ClickOutsideModule, - IconComponent, - ButtonComponent, - RouterLink, - ], - templateUrl: "./vacancy-filter.component.html", - styleUrl: "./vacancy-filter.component.scss", - changeDetection: ChangeDetectionStrategy.OnPush, - animations: [ - trigger("dropdownAnimation", [ - transition(":enter", [ - style({ opacity: 0, transform: "scaleY(0.8)" }), - animate(".12s cubic-bezier(0, 0, 0.2, 1)"), - ]), - transition(":leave", [animate(".1s linear", style({ opacity: 0 }))]), - ]), - ], -}) -export class VacancyFilterComponent implements OnInit { - /** Сервис роутера для навигации */ - router = inject(Router); - /** Сервис текущего маршрута */ - route = inject(ActivatedRoute); - /** Сервис ленты новостей */ - feedService = inject(FeedService); - /** Сервис для работы с вакансиями */ - vacancyService = inject(VacancyService); - - constructor() {} - - /** Приватное поле для хранения значения поиска */ - private _searchValue: string | undefined; - - /** - * Сеттер для значения поиска - * @param value - новое значение поиска - */ - @Input() set searchValue(value: string | undefined) { - this._searchValue = value; - } - - /** - * Геттер для получения значения поиска - * @returns текущее значение поиска - */ - get searchValue(): string | undefined { - return this._searchValue; - } - - /** Событие изменения значения поиска */ - @Output() searchValueChange = new EventEmitter(); - - /** Подписка на параметры запроса */ - queries$?: Subscription; - - /** Состояние открытия фильтра (для мобильной версии) */ - filterOpen = signal(false); - - /** Общее количество элементов */ - totalItemsCount = signal(0); - - // Сигналы для текущих значений фильтров - /** Текущий фильтр по опыту */ - currentExperience = signal(undefined); - /** Текущий фильтр по формату работы */ - currentWorkFormat = signal(undefined); - /** Текущий фильтр по графику работы */ - currentWorkSchedule = signal(undefined); - /** Текущая зарплата */ - currentSalary = signal(undefined); - - /** Опции фильтра по опыту работы */ - readonly workExperienceFilterOptions = workExperienceFilter; - - /** Опции фильтра по формату работы */ - readonly workFormatFilterOptions = workFormatFilter; - - /** Опции фильтра по графику работы */ - readonly workScheduleFilterOptions = workScheduleFilter; - - /** - * Инициализация компонента - */ - ngOnInit() { - // Подписка на изменения параметров запроса - this.queries$ = this.route.queryParams.subscribe(queries => { - // Синхронизация текущих значений фильтров с URL - this.currentExperience.set(queries["required_experience"]); - this.currentWorkFormat.set(queries["work_format"]); - this.currentWorkSchedule.set(queries["work_schedule"]); - this.currentSalary.set(queries["salary"]); - this.searchValue = queries["role_contains"]; - }); - } - - /** - * Установка фильтра по опыту работы - * @param event - событие клика - * @param experienceId - идентификатор выбранного опыта - */ - setExperienceFilter(event: Event, experienceId: string): void { - event.stopPropagation(); - // Переключение фильтра (снятие если уже выбран) - this.currentExperience.set( - experienceId === this.currentExperience() ? undefined : experienceId - ); - - // Обновление URL с новым параметром - this.router - .navigate([], { - queryParams: { required_experience: this.currentExperience() }, - relativeTo: this.route, - queryParamsHandling: "merge", - }) - .then(() => console.debug("Query change from ProjectsComponent")); - } - - /** - * Установка фильтра по формату работы - * @param event - событие клика - * @param formatId - идентификатор выбранного формата - */ - setWorkFormatFilter(event: Event, formatId: string): void { - event.stopPropagation(); - this.currentWorkFormat.set(formatId === this.currentWorkFormat() ? undefined : formatId); - - this.router - .navigate([], { - queryParams: { work_format: this.currentWorkFormat() }, - relativeTo: this.route, - queryParamsHandling: "merge", - }) - .then(() => console.debug("Query change from ProjectsComponent")); - } - - /** - * Установка фильтра по графику работы - * @param event - событие клика - * @param scheduleId - идентификатор выбранного графика - */ - setWorkScheduleFilter(event: Event, scheduleId: string): void { - event.stopPropagation(); - this.currentWorkSchedule.set( - scheduleId === this.currentWorkSchedule() ? undefined : scheduleId - ); - - this.router - .navigate([], { - queryParams: { - work_schedule: this.currentWorkSchedule(), - }, - relativeTo: this.route, - queryParamsHandling: "merge", - }) - .then(() => console.debug("Query change from ProjectsComponent")); - } - - /** - * Сброс всех фильтров - * Очищает все параметры фильтрации и обновляет URL - */ - resetFilter(): void { - this.currentExperience.set(undefined); - this.currentWorkFormat.set(undefined); - this.currentWorkSchedule.set(undefined); - - this.onSearchValueChanged(""); - - this.router - .navigate([], { - queryParams: { - required_experience: null, - work_format: null, - work_schedule: null, - role_contains: null, - }, - relativeTo: this.route, - queryParamsHandling: "merge", - }) - .then(() => console.debug("Filters reset from VacancyFilterComponent")); - } - - /** - * Обработчик изменения значения поиска - * @param value - новое значение поиска - */ - onSearchValueChanged(value: string) { - this.searchValueChange.emit(value); - } - - /** - * Обработчик клика вне компонента - * Закрывает мобильное меню фильтров - */ - onClickOutside(): void { - this.filterOpen.set(false); - } - - /** - * Загрузка данных с применением текущих фильтров - * @param offset - смещение для пагинации - * @param limit - количество элементов для загрузки - * @param projectId - идентификатор проекта (опционально) - * @returns Observable с отфильтрованными данными - */ - onFetch(offset: number, limit: number, projectId?: number) { - return this.vacancyService - .getForProject( - limit, - offset, - projectId, - this.currentExperience(), - this.currentWorkFormat(), - this.currentWorkSchedule(), - this.searchValue - ) - .pipe( - tap((res: any) => { - this.totalItemsCount.set(res.length); - }), - map(res => res) - ); - } - - ngOnDestroy() { - this.queries$?.unsubscribe(); - } -} diff --git a/projects/social_platform/src/app/office/vacancies/vacancies.component.html b/projects/social_platform/src/app/office/vacancies/vacancies.component.html deleted file mode 100644 index cce86b38a..000000000 --- a/projects/social_platform/src/app/office/vacancies/vacancies.component.html +++ /dev/null @@ -1,35 +0,0 @@ - - - - - -
    -
    - - -
    -
    - @if(isAll) { -
    - -
    - } - - -
    - - @if (isAll) { -
    - -
    - } -
    -
    -
    diff --git a/projects/social_platform/src/app/office/vacancies/vacancies.component.spec.ts b/projects/social_platform/src/app/office/vacancies/vacancies.component.spec.ts deleted file mode 100644 index 6ca31d246..000000000 --- a/projects/social_platform/src/app/office/vacancies/vacancies.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { VacanciesComponent } from "./vacancies.component"; - -describe("VacanciesComponent", () => { - let component: VacanciesComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [VacanciesComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(VacanciesComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/office/vacancies/vacancies.component.ts b/projects/social_platform/src/app/office/vacancies/vacancies.component.ts deleted file mode 100644 index 81b2bd86c..000000000 --- a/projects/social_platform/src/app/office/vacancies/vacancies.component.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** @format */ - -// vacancies.component.ts -/** @format */ - -import { Component, inject, OnInit } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { BarComponent } from "@ui/components"; -import { ActivatedRoute, Router, RouterOutlet } from "@angular/router"; -import { BackComponent } from "@uilib"; -import { SearchComponent } from "@ui/components/search/search.component"; -import { VacancyFilterComponent } from "./shared/filter/vacancy-filter.component"; -import { FormBuilder, FormGroup, ReactiveFormsModule } from "@angular/forms"; -import { debounceTime, distinctUntilChanged, tap } from "rxjs"; - -@Component({ - selector: "app-vacancies", - standalone: true, - imports: [ - CommonModule, - BarComponent, - RouterOutlet, - BackComponent, - SearchComponent, - VacancyFilterComponent, - ReactiveFormsModule, - ], - templateUrl: "./vacancies.component.html", - styleUrl: "./vacancies.component.scss", -}) -export class VacanciesComponent implements OnInit { - route = inject(ActivatedRoute); - router = inject(Router); - fb = inject(FormBuilder); - - searchForm: FormGroup; - - basePath = "/office/"; - - get isAll(): boolean { - return this.router.url.includes("/vacancies/all"); - } - - get isMy(): boolean { - return this.router.url.includes("/vacancies/my"); - } - - constructor() { - this.searchForm = this.fb.group({ - search: [""], - }); - } - - ngOnInit() { - this.searchForm - .get("search") - ?.valueChanges.pipe( - debounceTime(300), - distinctUntilChanged(), - tap(value => { - this.router.navigate([], { - relativeTo: this.route, - queryParams: { role_contains: value || null }, - queryParamsHandling: "merge", - }); - }) - ) - .subscribe(); - } - - onSearchSubmit() { - const value = this.searchForm.get("search")?.value; - this.router.navigate([], { - queryParams: { role_contains: value || null }, - queryParamsHandling: "merge", - relativeTo: this.route, - }); - } - - onSearhValueChanged(event: string) { - this.searchForm.get("search")?.setValue(event); - } -} diff --git a/projects/social_platform/src/app/office/vacancies/vacancies.resolver.ts b/projects/social_platform/src/app/office/vacancies/vacancies.resolver.ts deleted file mode 100644 index 4b7ffcefb..000000000 --- a/projects/social_platform/src/app/office/vacancies/vacancies.resolver.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** @format */ - -import { inject } from "@angular/core"; -import { VacancyService } from "@office/services/vacancy.service"; - -/** - * Резолвер для предзагрузки списка вакансий - * Загружает данные вакансий до активации маршрута, обеспечивая - * мгновенное отображение контента без состояния загрузки - * - * @returns Observable с данными вакансий (первые 20 элементов) - */ -export const VacanciesResolver = () => { - const vacanciesService = inject(VacancyService); - - // Загрузка первых 20 вакансий с нулевым смещением - return vacanciesService.getForProject(20, 0); -}; diff --git a/projects/social_platform/src/app/office/vacancies/vacancies.routes.ts b/projects/social_platform/src/app/office/vacancies/vacancies.routes.ts deleted file mode 100644 index 912059fd2..000000000 --- a/projects/social_platform/src/app/office/vacancies/vacancies.routes.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** @format */ - -import { Routes } from "@angular/router"; -import { VacanciesComponent } from "./vacancies.component"; -import { VacanciesResolver } from "./vacancies.resolver"; -import { VacanciesListComponent } from "./list/list.component"; - -/** - * Маршруты для модуля вакансий - * Определяет структуру навигации и загрузку данных для страниц вакансий - * - * Структура маршрутов: - * - /vacancies - корневой компонент с навигацией - * - /vacancies/all - список всех вакансий - * - /vacancies/my - список откликов пользователя - * - /vacancies/:vacancyId - детальная информация о вакансии - */ -export const VACANCIES_ROUTES: Routes = [ - { - path: "", - component: VacanciesComponent, // Корневой компонент с навигационными вкладками - children: [ - { - path: "", - redirectTo: "all", // Перенаправление на список всех вакансий по умолчанию - pathMatch: "full", - }, - { - path: "my", - loadChildren: () => import("./list/list.routes").then(c => c.VACANCY_LIST_ROUTES), - }, - { - path: "all", - component: VacanciesListComponent, // Компонент списка всех вакансий - resolve: { - data: VacanciesResolver, // Резолвер для предзагрузки данных вакансий - }, - }, - ], - }, - { - path: ":vacancyId", - loadChildren: () => - import("./detail/vacancies-detail.routes").then(c => c.VACANCIES_DETAIL_ROUTES), - }, -]; diff --git a/projects/social_platform/src/app/sentry.config.ts b/projects/social_platform/src/app/sentry.config.ts new file mode 100644 index 000000000..11c02665c --- /dev/null +++ b/projects/social_platform/src/app/sentry.config.ts @@ -0,0 +1,17 @@ +/** @format */ + +import { init } from "@sentry/angular"; +import { environment } from "@environment"; + +export function initSentry(): void { + if (!environment.production || !environment.sentryDns) return; + + init({ + dsn: environment.sentryDns, + environment: environment.production ? "production" : "development", + integrations: [], + tracesSampleRate: 0.2, + replaysSessionSampleRate: 0, + replaysOnErrorSampleRate: 1.0, + }); +} diff --git a/projects/social_platform/src/app/ui/README.md b/projects/social_platform/src/app/ui/README.md deleted file mode 100644 index c4c8136e2..000000000 --- a/projects/social_platform/src/app/ui/README.md +++ /dev/null @@ -1,189 +0,0 @@ - - -# UI Модуль - -Библиотека переиспользуемых UI компонентов для всего приложения. - -## Компоненты - -### Формы и ввод - -#### 📝 Input - -Базовый компонент поля ввода - -- Поддержка различных типов -- Валидация и отображение ошибок -- Кастомизируемые стили - -#### 📄 Textarea - -Многострочное поле ввода - -- Автоматическое изменение размера -- Подсчет символов -- Валидация - -#### 🔽 Select - -Выпадающий список - -- Поиск по опциям -- Множественный выбор -- Кастомные шаблоны опций - -#### ✅ Checkbox - -Чекбокс с кастомным дизайном - -#### 🔄 Switch - -Переключатель вкл/выкл - -#### 🎯 Autocomplete - -Поле с автодополнением - -- Поиск по мере ввода -- Кастомные шаблоны результатов - -#### 📊 Range Input - -Ползунок для выбора диапазона значений - -#### 🔢 Num Slider - -Числовой слайдер - -### Отображение данных - -#### 🖼 Avatar - -Аватар пользователя - -- Поддержка изображений и инициалов -- Различные размеры -- Статусы онлайн/оффлайн - -#### 🏷 Tag - -Тег/метка - -- Различные цвета и размеры -- Возможность удаления - -#### 📊 Bar - -Прогресс-бар - -- Анимированный прогресс -- Различные стили - -#### ⭐ Icon - -Иконки SVG - -- Библиотека иконок -- Настраиваемые размеры и цвета - -### Интерактивные элементы - -#### 🔘 Button - -Кнопка с различными стилями - -- Primary, secondary, danger стили -- Состояния загрузки -- Иконки - -#### 🔍 Search - -Поле поиска - -- Автодополнение -- История поисков - -### Медиа и файлы - -#### 📁 Upload File - -Загрузка файлов - -- Drag & drop -- Предпросмотр -- Валидация типов и размеров - -#### 📎 File Item - -Отображение загруженного файла - -- Иконки по типам файлов -- Действия (скачать, удалить) - -#### 📋 File Upload Item - -Элемент в процессе загрузки - -- Прогресс загрузки -- Возможность отмены - -### Обратная связь - -#### ⚠️ Modal - -Модальные окна - -- Различные размеры -- Анимации появления/исчезновения - -#### 🔄 Loader - -Индикатор загрузки - -- Различные анимации -- Настраиваемые размеры - -#### 📢 Snackbar - -Уведомления - -- Различные типы (успех, ошибка, предупреждение) -- Автоматическое скрытие - -#### ❌ Delete Confirm - -Подтверждение удаления - -- Модальное окно подтверждения - -### Сообщения - -#### 💬 Chat Message - -Сообщение в чате - -- Различные типы сообщений -- Временные метки -- Статусы прочтения - -## Сервисы - -### AnimationService - -Сервис для управления анимациями - -### SnackbarService - -Сервис для показа уведомлений - -## Директивы - -### EditorSubmitButtonDirective - -Директива для кнопок отправки в редакторах - -## Пайпы - -### FileTypePipe - -Определение типа файла по расширению diff --git a/projects/social_platform/src/app/ui/components/autocomplete-input/autocomplete-input.component.html b/projects/social_platform/src/app/ui/components/autocomplete-input/autocomplete-input.component.html deleted file mode 100644 index 36f347af5..000000000 --- a/projects/social_platform/src/app/ui/components/autocomplete-input/autocomplete-input.component.html +++ /dev/null @@ -1,67 +0,0 @@ - - -
    -
    - - @if (fieldToDisplayMode === "chip" && value()) { -
    - {{ $any(value())[fieldToDisplay] || value() }} - -
    - } -
    -
    - @if (loading() && !slimVersion) { - - } @if (searchIcon && slimVersion) { - - } @if (searchIcon && !slimVersion) { - - } -
    - @if (isOpen()) { -
    - @if (noResults()) { -
      -
    • - {{ "Ничего не найдено :(" }} -
    • -
    - } @else { -
      - @for (suggestion of suggestions; track $index) { -
    • - {{ fieldToDisplay ? suggestion[fieldToDisplay] : suggestion }} -
    • - } -
    - } -
    - } -
    diff --git a/projects/social_platform/src/app/ui/components/autocomplete-input/autocomplete-input.component.ts b/projects/social_platform/src/app/ui/components/autocomplete-input/autocomplete-input.component.ts deleted file mode 100644 index 71b66e79a..000000000 --- a/projects/social_platform/src/app/ui/components/autocomplete-input/autocomplete-input.component.ts +++ /dev/null @@ -1,309 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - ElementRef, - EventEmitter, - forwardRef, - Input, - Output, - signal, - ViewChild, -} from "@angular/core"; -import { IconComponent } from "@uilib"; -import { NG_VALUE_ACCESSOR } from "@angular/forms"; -import { ClickOutsideModule } from "ng-click-outside"; -import { debounce, distinctUntilChanged, fromEvent, map, of, Subscription, timer } from "rxjs"; -import { animate, style, transition, trigger } from "@angular/animations"; -import { LoaderComponent } from "@ui/components/loader/loader.component"; - -/** - * Компонент автодополнения с поиском и выбором из списка предложений. - * Реализует ControlValueAccessor для интеграции с Angular Forms. - * Поддерживает различные режимы отображения и настройки поведения. - * - * Входящие параметры: - * - suggestions: массив предложений для отображения - * - fieldToDisplayMode: режим отображения ("text" | "chip") - * - fieldToDisplay: поле объекта для отображения - * - valueField: поле для получения значения - * - forceSelect: принудительный выбор из списка - * - clearInputOnSelect: очистка поля после выбора - * - delay: задержка поиска в мс (по умолчанию 300) - * - placeholder: placeholder для поля ввода - * - searchIcon: иконка поиска - * - slimVersion: компактная версия - * - error: состояние ошибки - * - * События: - * - searchStart: начало поиска с текстом запроса - * - optionSelected: выбор опции из списка - * - inputCleared: очистка поля ввода - * - * Возвращает: - * - Выбранное значение через ControlValueAccessor - */ -@Component({ - selector: "app-autocomplete-input", - standalone: true, - imports: [CommonModule, IconComponent, ClickOutsideModule, LoaderComponent], - templateUrl: "./autocomplete-input.component.html", - styleUrl: "./autocomplete-input.component.scss", - changeDetection: ChangeDetectionStrategy.OnPush, - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => AutoCompleteInputComponent), - multi: true, - }, - ], - animations: [ - trigger("dropdownAnimation", [ - transition(":enter", [ - style({ opacity: 0, transform: "scaleY(0.8)" }), - animate(".12s cubic-bezier(0, 0, 0.2, 1)"), - ]), - transition(":leave", [animate(".1s linear", style({ opacity: 0 }))]), - ]), - ], -}) -export class AutoCompleteInputComponent { - /** Массив предложений для отображения */ - @Input({ required: true }) get suggestions(): T[] { - return this._suggestions(); - } - - set suggestions(val: T[]) { - this._suggestions.set(val); - this.handleSuggestionsChange(val); - } - - /** Режим отображения выбранного поля */ - @Input() fieldToDisplayMode: "text" | "chip" = "text"; - - /** Поле объекта для отображения */ - @Input() fieldToDisplay!: keyof T; - - /** Поле для получения значения */ - @Input() valueField!: string; - - /** Принудительный выбор из списка */ - @Input() forceSelect = false; - - /** Очистка поля после выбора */ - @Input() clearInputOnSelect = false; - - /** Задержка поиска в мс */ - @Input() delay = 300; - - /** Placeholder для поля ввода */ - @Input() placeholder = ""; - - /** Иконка поиска */ - @Input() searchIcon = "search"; - - /** Компактная версия */ - @Input() slimVersion = false; - - /** Состояние ошибки */ - @Input() error = false; - - @Output() openSkillsFunc = new EventEmitter(); - - /** Событие начала поиска */ - @Output() searchStart = new EventEmitter(); - - /** Событие выбора опции */ - @Output() optionSelected = new EventEmitter(); - - /** Событие очистки поля */ - @Output() inputCleared = new EventEmitter(); - - /** Ссылка на элемент input */ - @ViewChild("input") inputElem!: ElementRef; - - /** Текущее выбранное значение */ - value = signal(null); - - /** Значение в поле ввода */ - inputValue = signal(""); - - /** Массив предложений */ - _suggestions = signal([]); - - /** Состояние открытия выпадающего списка */ - isOpen = signal(false); - - /** Состояние загрузки */ - loading = signal(false); - - /** Состояние отсутствия результатов */ - noResults = signal(false); - - /** Состояние блокировки */ - disabled = signal(false); - - /** Массив подписок */ - subscriptions$ = signal([]); - - constructor(private readonly cdRef: ChangeDetectorRef) {} - - /** Инициализация отслеживания ввода после загрузки представления */ - ngAfterViewInit(): void { - const input$ = fromEvent(this.inputElem.nativeElement, "input") - .pipe( - map(e => (e.target as HTMLInputElement).value.trim()), - debounce(val => (val ? timer(this.delay) : of({}))), - distinctUntilChanged() - ) - .subscribe(val => this.handleSearch(val)); - - this.subscriptions$().push(input$); - } - - ngOnInit(): void {} - - /** Обработчик ввода текста */ - onInput(event: Event): void { - const value = (event.target as HTMLInputElement).value.trim(); - this.inputValue.set(value); - } - - /** Обработчик потери фокуса */ - onBlur(): void { - this.onTouch(); - } - - // Методы ControlValueAccessor - writeValue(value: any): void { - this.value.set(value?.[this.valueField] ?? value); - this.handleProgrammaticInputValueChange(value); - } - - onChange: (value: any) => void = () => {}; - - registerOnChange(fn: (v: any) => void): void { - this.onChange = fn; - } - - onTouch: () => void = () => {}; - - registerOnTouched(fn: () => void): void { - this.onTouch = fn; - } - - setDisabledState(isDisabled: boolean): void { - this.disabled.set(isDisabled); - } - - /** Обработчик нажатия Enter */ - onEnter(event: Event): void { - event.preventDefault(); - } - - /** Обработчик выбора значения из списка */ - onUpdate(event: Event, value: any): void { - event.stopPropagation(); - - const newValue = value?.[this.valueField] ?? value; - - this.value.set(newValue); - this.onChange(newValue); - this.optionSelected.emit(newValue); - - this.handleProgrammaticInputValueChange(newValue); - - this.isOpen.set(false); - } - - /** Обработчик очистки значения */ - onClearValue(event: Event): void { - event.stopPropagation(); - this.inputValue.set(""); - this.value.set(null); - this.onChange(null); - } - - /** Обработчик клика вне компонента */ - onClickOutside(): void { - const value = this.findExistingSuggestion(this.suggestions); - - if (this.forceSelect && this.isOpen() && value) { - const newValue = value?.[this.valueField] ?? value; - - this.handleProgrammaticInputValueChange(newValue); - this.value.set(newValue); - this.onChange(newValue); - } else if (this.forceSelect && this.isOpen() && !value) { - this.inputValue.set(""); - this.value.set(null); - this.onChange(null); - } - - this.isOpen.set(false); - } - - /** Обработчик поиска */ - handleSearch(query: string): void { - if (!query) { - this.isOpen.set(false); - this.cdRef.markForCheck(); - this.inputCleared.emit(); - return; - } - - this.loading.set(true); - this.searchStart.emit(query); - } - - /** Обработчик вставки текста */ - handlePaste(event: ClipboardEvent): void { - const query = event.clipboardData?.getData("text"); - - if (query) { - this.handleSearch(query.trim()); - } - } - - /** Обработчик изменения списка предложений */ - handleSuggestionsChange(suggestions: any[]): void { - if (!suggestions?.length && this.loading()) { - this.noResults.set(true); - this.isOpen.set(true); - } - - if (this.suggestions?.length) { - this.noResults.set(false); - this.isOpen.set(true); - } - - this.loading.set(false); - } - - /** Обработчик программного изменения значения поля ввода */ - handleProgrammaticInputValueChange(appValue: any): void { - if (this.fieldToDisplayMode === "chip" || this.clearInputOnSelect) { - this.inputValue.set(""); - } else { - this.inputValue.set(appValue?.[this.fieldToDisplay] ?? appValue); - } - } - - /** Поиск существующего предложения по введенному тексту */ - findExistingSuggestion(suggestions: typeof this.suggestions): any { - if (!this.fieldToDisplay) { - return suggestions.find(s => String(s).toLowerCase() === this.inputValue().toLowerCase()); - } - return suggestions.find( - s => String(s[this.fieldToDisplay]).toLowerCase() === this.inputValue().toLocaleLowerCase() - ); - } - - /** Очистка подписок при уничтожении компонента */ - ngOnDestroy(): void { - this.subscriptions$().forEach($ => $.unsubscribe()); - } -} diff --git a/projects/social_platform/src/app/ui/components/avatar-control/avatar-control.component.html b/projects/social_platform/src/app/ui/components/avatar-control/avatar-control.component.html deleted file mode 100644 index 02f0ef396..000000000 --- a/projects/social_platform/src/app/ui/components/avatar-control/avatar-control.component.html +++ /dev/null @@ -1,108 +0,0 @@ - - -
    - - - - @if (haveHint && tooltipText) { -
    - -
    - } -
    - - -
    -
    - -

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

    -
    - - @if (showCropperModalErrorMessage) { -

    - {{ showCropperModalErrorMessage }} -

    - } - -
    - -
    - -
    - Отменить - Сохранить -
    -
    -
    diff --git a/projects/social_platform/src/app/ui/components/avatar-control/avatar-control.component.spec.ts b/projects/social_platform/src/app/ui/components/avatar-control/avatar-control.component.spec.ts deleted file mode 100644 index 4343adb39..000000000 --- a/projects/social_platform/src/app/ui/components/avatar-control/avatar-control.component.spec.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { AvatarControlComponent } from "./avatar-control.component"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; -import { AuthService } from "@auth/services"; - -describe("AvatarControlComponent", () => { - let component: AvatarControlComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - const autSpy = jasmine.createSpyObj(["getTokens"]); - - await TestBed.configureTestingModule({ - imports: [HttpClientTestingModule, AvatarControlComponent], - providers: [{ provide: AuthService, useValue: autSpy }], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(AvatarControlComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/ui/components/avatar-control/avatar-control.component.ts b/projects/social_platform/src/app/ui/components/avatar-control/avatar-control.component.ts deleted file mode 100644 index 9cd7b3ed3..000000000 --- a/projects/social_platform/src/app/ui/components/avatar-control/avatar-control.component.ts +++ /dev/null @@ -1,396 +0,0 @@ -/** @format */ - -import { Component, forwardRef, Input, OnInit } from "@angular/core"; -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; -import { nanoid } from "nanoid"; -import { FileService } from "@core/services/file.service"; -import { catchError, concatMap, map, of } from "rxjs"; -import { IconComponent, ButtonComponent } from "@ui/components"; -import { LoaderComponent } from "../loader/loader.component"; -import { CommonModule } from "@angular/common"; -import { ImageCroppedEvent, ImageCropperComponent } from "ngx-image-cropper"; -import { DomSanitizer, SafeUrl } from "@angular/platform-browser"; -import { ModalComponent } from "../modal/modal.component"; -import { TooltipComponent } from "../tooltip/tooltip.component"; - -/** - * Компонент для управления аватаром пользователя. - * Реализует ControlValueAccessor для интеграции с Angular Forms. - * Позволяет загружать, обрезать, обновлять и удалять изображение аватара. - * - * Входящие параметры: - * - size: размер аватара в пикселях (по умолчанию 140) - * - error: состояние ошибки для отображения красной рамки - * - type: тип аватара ("avatar" | "project" | "profile", по умолчанию "avatar") - * - * Возвращает: - * - URL загруженного изображения через ControlValueAccessor - */ -@Component({ - selector: "app-avatar-control", - templateUrl: "./avatar-control.component.html", - styleUrl: "./avatar-control.component.scss", - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => AvatarControlComponent), - multi: true, - }, - ], - standalone: true, - imports: [ - LoaderComponent, - IconComponent, - CommonModule, - ImageCropperComponent, - ModalComponent, - ButtonComponent, - TooltipComponent, - ], -}) -export class AvatarControlComponent implements OnInit, ControlValueAccessor { - constructor(private fileService: FileService, private sanitizer: DomSanitizer) {} - - /** Размер аватара в пикселях */ - @Input() size = 140; - - /** Состояние ошибки */ - @Input() error = false; - - /** Тип аватара */ - @Input() type: "avatar" | "project" | "profile" = "avatar"; - - /** Наличие подсказки */ - @Input() haveHint = false; - - /** Текст для подсказки */ - @Input() tooltipText?: string; - - /** Позиция подсказки */ - @Input() tooltipPosition: "left" | "right" = "right"; - - /** Ширина подсказки */ - @Input() tooltipWidth = 250; - - ngOnInit(): void {} - - /** Уникальный ID для элемента input */ - controlId = nanoid(3); - - /** Состояние видимости подсказки */ - isTooltipVisible = false; - - /** Текущее значение URL изображения */ - value = ""; - - /** Показывать ли модальное окно кроппера */ - showCropperModal = false; - - /** Текст ошибки при обрезки фотографии */ - showCropperModalErrorMessage = ""; - - /** Исходное изображение для обрезки */ - imageChangedEvent: Event | null = null; - - /** Обрезанное изображение */ - croppedImage: SafeUrl = ""; - - /** Blob обрезанного изображения для загрузки */ - croppedBlob: Blob | null = null; - - /** Записывает значение URL изображения */ - writeValue(address: string) { - this.value = address; - } - - onTouch: () => void = () => {}; - - registerOnTouched(fn: any) { - this.onTouch = fn; - } - - onChange: (value: string) => void = () => {}; - - registerOnChange(fn: any) { - this.onChange = fn; - } - - /** Состояние загрузки файла */ - loading = false; - - /** Исправленное изображение в формате base64 для кроппера */ - correctedImageBase64 = ""; - - /** - * Обработчик выбора файла - открывает кроппер - */ - onFileSelected(event: Event) { - const files = (event.currentTarget as HTMLInputElement).files; - - if (!files?.length) { - return; - } - - // Обрабатываем EXIF ориентацию перед открытием кроппера - this.fixImageOrientation(files[0], () => { - // Используем исходное событие, но imageChangedEvent уже содержит исправленное изображение - this.imageChangedEvent = event; - this.showCropperModal = true; - }); - } - - /** - * Исправляет EXIF ориентацию изображения - * Решает проблему с повернутыми фотографиями со смартфонов - */ - private fixImageOrientation(file: File, onComplete: () => void) { - const reader = new FileReader(); - - reader.onload = e => { - const img = new Image(); - img.onload = () => { - // Читаем EXIF данные для определения ориентации - this.getImageOrientation(file, orientation => { - // Если ориентация нормальная (1), просто используем исходное изображение - if (orientation === 1) { - this.correctedImageBase64 = ""; - onComplete(); - return; - } - - // Ротируем изображение на Canvas - const canvas = this.rotateImage(img, orientation); - this.correctedImageBase64 = canvas.toDataURL(file.type); - onComplete(); - }); - }; - img.src = e.target?.result as string; - }; - - reader.readAsDataURL(file); - } - - /** - * Определяет EXIF ориентацию изображения - */ - private getImageOrientation(file: File, onOrientationDetected: (orientation: number) => void) { - const reader = new FileReader(); - - reader.onload = event => { - const view = new DataView(event.target?.result as ArrayBuffer); - // Проверяем JPEG маркер - if (view.byteLength < 2 || view.getUint16(0) !== 0xffd8) { - onOrientationDetected(1); // Не JPEG, используем нормальную ориентацию - return; - } - - let offset = 2; - // Ищем EXIF данные - while (offset < view.byteLength - 9) { - if (view.getUint16(offset) === 0xffe1) { - const length = view.getUint16(offset + 2) + 2; - // Проверяем EXIF идентификатор - if (view.getUint32(offset + 4) === 0x45786966 && view.getUint16(offset + 8) === 0x0000) { - const orientation = this.getExifOrientation(view, offset + 10); - onOrientationDetected(orientation); - return; - } - offset += length; - } else { - offset += 2; - } - } - onOrientationDetected(1); // EXIF не найден, используем нормальную ориентацию - }; - - reader.readAsArrayBuffer(file); - } - - /** - * Извлекает значение ориентации из EXIF данных - */ - private getExifOrientation(view: DataView, offset: number): number { - try { - const littleEndian = view.getUint16(offset) === 0x4949; - const ifdOffset = view.getUint32(offset + 4, littleEndian); - const entries = view.getUint16(offset + ifdOffset, littleEndian); - - for (let i = 0; i < entries; i++) { - const entryOffset = offset + ifdOffset + 2 + i * 12; - const tag = view.getUint16(entryOffset, littleEndian); - // 0x0112 это тег для ориентации (Orientation tag) - if (tag === 0x0112) { - const value = view.getUint32(entryOffset + 8, littleEndian); - return value > 1 && value <= 8 ? value : 1; - } - } - } catch (e) { - console.warn("Ошибка при чтении EXIF ориентации:", e); - } - return 1; - } - - /** - * Ротирует изображение на Canvas в зависимости от EXIF ориентации - */ - private rotateImage(img: HTMLImageElement, orientation: number): HTMLCanvasElement { - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - if (!ctx) { - return canvas; - } - - const [newWidth, newHeight] = [img.width, img.height]; - - switch (orientation) { - case 2: - canvas.width = newWidth; - canvas.height = newHeight; - ctx.scale(-1, 1); - ctx.drawImage(img, -newWidth, 0); - break; - case 3: - canvas.width = newWidth; - canvas.height = newHeight; - ctx.rotate(Math.PI); - ctx.drawImage(img, -newWidth, -newHeight); - break; - case 4: - canvas.width = newWidth; - canvas.height = newHeight; - ctx.scale(1, -1); - ctx.drawImage(img, 0, -newHeight); - break; - case 5: - canvas.width = newHeight; - canvas.height = newWidth; - ctx.rotate(Math.PI / 2); - ctx.scale(-1, 1); - ctx.drawImage(img, -newHeight, 0); - break; - case 6: - canvas.width = newHeight; - canvas.height = newWidth; - ctx.rotate(Math.PI / 2); - ctx.drawImage(img, 0, -newWidth); - break; - case 7: - canvas.width = newHeight; - canvas.height = newWidth; - ctx.rotate(-Math.PI / 2); - ctx.scale(-1, 1); - ctx.drawImage(img, -newHeight, -newWidth); - break; - case 8: - canvas.width = newHeight; - canvas.height = newWidth; - ctx.rotate(-Math.PI / 2); - ctx.drawImage(img, -newHeight, 0); - break; - default: - canvas.width = newWidth; - canvas.height = newHeight; - ctx.drawImage(img, 0, 0); - } - - return canvas; - } - - /** - * Обработчик обрезки изображения - */ - imageCropped(event: ImageCroppedEvent) { - if (event.objectUrl) { - this.croppedImage = this.sanitizer.bypassSecurityTrustUrl(event.objectUrl); - } - this.croppedBlob = event.blob || null; - } - - /** - * Обработчик загружено фото или нет - */ - imageLoaded() {} - - /** - * Обработчик готовности обрезки фотографии - */ - cropperReady() {} - - /** - * Обработчик ошибки загрузки - */ - loadImageFailed() { - console.error("Не удалось загрузить изображение"); - this.showCropperModalErrorMessage = "Не удалось загрузить изображение. Попробуйте ещё раз!"; - } - - /** - * Сохранить обрезанное изображение - */ - saveCroppedImage() { - if (!this.croppedBlob) { - return; - } - - this.loading = true; - this.showCropperModal = false; - - // Создаем файл из blob - const file = new File([this.croppedBlob], "cropped-avatar.jpg", { - type: "image/jpeg", - lastModified: Date.now(), - }); - - const source = this.value - ? this.fileService.deleteFile(this.value).pipe( - catchError(err => { - console.error(err); - return of({}); - }), - concatMap(() => this.fileService.uploadFile(file)), - map(r => r["url"]) - ) - : this.fileService.uploadFile(file).pipe(map(r => r.url)); - - source.subscribe(this.updateValue.bind(this)); - } - - /** - * Закрыть кроппер без сохранения - */ - closeCropper() { - this.showCropperModal = false; - this.imageChangedEvent = null; - this.croppedImage = ""; - this.croppedBlob = null; - - // Сбрасываем значение input - const input = document.getElementById(this.controlId) as HTMLInputElement; - if (input) { - input.value = ""; - } - } - - /** Показать подсказку */ - showTooltip(): void { - this.isTooltipVisible = true; - } - - /** Скрыть подсказку */ - hideTooltip(): void { - this.isTooltipVisible = false; - } - - /** - * Обновляет значение URL и уведомляет о изменении - * @param url - новый URL изображения - */ - private updateValue(url: string): void { - this.loading = false; - - this.onChange(url); - this.value = url; - - this.onTouch(); - } -} diff --git a/projects/social_platform/src/app/ui/components/avatar/avatar.component.html b/projects/social_platform/src/app/ui/components/avatar/avatar.component.html deleted file mode 100644 index c06b35990..000000000 --- a/projects/social_platform/src/app/ui/components/avatar/avatar.component.html +++ /dev/null @@ -1,33 +0,0 @@ - - -
    -
    - @if (progress) { -
    - } - - avatar - - @if (isOnline) { -
    - } -
    -
    diff --git a/projects/social_platform/src/app/ui/components/avatar/avatar.component.spec.ts b/projects/social_platform/src/app/ui/components/avatar/avatar.component.spec.ts deleted file mode 100644 index fe26e0c3a..000000000 --- a/projects/social_platform/src/app/ui/components/avatar/avatar.component.spec.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { AvatarComponent } from "./avatar.component"; - -describe("AvatarComponent", () => { - let component: AvatarComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [AvatarComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(AvatarComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); - - it("should display placeholder image if no URL is provided", () => { - const img = fixture.nativeElement.querySelector("img"); - expect(img.src).toContain(component.placeholderUrl); - }); - - it("should display provided image if URL is provided", () => { - component.url = "https://example.com/avatar.png"; - fixture.detectChanges(); - const img = fixture.nativeElement.querySelector("img"); - expect(img.src).toContain(component.url); - }); - - it("should have correct size", () => { - const div = fixture.nativeElement.querySelector(".avatar"); - expect(div.style.width).toBe(component.size + "px"); - expect(div.style.height).toBe(component.size + "px"); - const img = fixture.nativeElement.querySelector("img"); - expect(img.style.width).toBe(component.size + "px"); - expect(img.style.height).toBe(component.size + "px"); - }); - - it("should have border if hasBorder is true", () => { - component.hasBorder = true; - fixture.detectChanges(); - const div = fixture.nativeElement.querySelector(".avatar"); - expect(div.classList.contains("avatar--border")).toBeTrue(); - }); - - it("should not have border if hasBorder is false", () => { - component.hasBorder = false; - fixture.detectChanges(); - const div = fixture.nativeElement.querySelector(".avatar"); - expect(div.classList.contains("avatar--border")).toBeFalse(); - }); -}); diff --git a/projects/social_platform/src/app/ui/components/bar-new/bar.component.html b/projects/social_platform/src/app/ui/components/bar-new/bar.component.html deleted file mode 100644 index 3ca59aff2..000000000 --- a/projects/social_platform/src/app/ui/components/bar-new/bar.component.html +++ /dev/null @@ -1,25 +0,0 @@ - - -
    - -
    diff --git a/projects/social_platform/src/app/ui/components/bar-new/bar.component.spec.ts b/projects/social_platform/src/app/ui/components/bar-new/bar.component.spec.ts deleted file mode 100644 index 503f07460..000000000 --- a/projects/social_platform/src/app/ui/components/bar-new/bar.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { BarComponent } from "./bar.component"; - -describe("BarComponent", () => { - let component: BarComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [BarComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(BarComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/ui/components/bar-new/bar.component.ts b/projects/social_platform/src/app/ui/components/bar-new/bar.component.ts deleted file mode 100644 index afca50931..000000000 --- a/projects/social_platform/src/app/ui/components/bar-new/bar.component.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** @format */ - -import { Component, Input } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { RouterLink, RouterLinkActive } from "@angular/router"; -import { IconComponent } from "@uilib"; - -/** - * Отображает горизонтальный список ссылок с индикаторами активности и счетчиками. - * - * Входящие параметры: - * - links: массив объектов навигационных ссылок с настройками - * - link: URL ссылки - * - linkText: текст ссылки - * - isRouterLinkActiveOptions: настройки активности ссылки) - * - * Использование: - * - Навигация между разделами приложения - */ -@Component({ - selector: "app-bar-new", - standalone: true, - imports: [CommonModule, RouterLink, RouterLinkActive, IconComponent], - templateUrl: "./bar.component.html", - styleUrl: "./bar.component.scss", -}) -export class BarNewComponent { - constructor() {} - - /** Массив навигационных ссылок */ - @Input() links!: { - link: string; - linkText: string; - iconName: string; - isRouterLinkActiveOptions: boolean; - }[]; -} diff --git a/projects/social_platform/src/app/ui/components/bar/bar.component.html b/projects/social_platform/src/app/ui/components/bar/bar.component.html deleted file mode 100644 index 87f531019..000000000 --- a/projects/social_platform/src/app/ui/components/bar/bar.component.html +++ /dev/null @@ -1,33 +0,0 @@ - - -
    - -
    diff --git a/projects/social_platform/src/app/ui/components/bar/bar.component.spec.ts b/projects/social_platform/src/app/ui/components/bar/bar.component.spec.ts deleted file mode 100644 index 503f07460..000000000 --- a/projects/social_platform/src/app/ui/components/bar/bar.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { BarComponent } from "./bar.component"; - -describe("BarComponent", () => { - let component: BarComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [BarComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(BarComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/ui/components/bar/bar.component.ts b/projects/social_platform/src/app/ui/components/bar/bar.component.ts deleted file mode 100644 index eabb00e75..000000000 --- a/projects/social_platform/src/app/ui/components/bar/bar.component.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** @format */ - -import { Component, Input } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { RouterLink, RouterLinkActive } from "@angular/router"; -import { BackComponent } from "@uilib"; - -/** - * Компонент навигационной панели с табами и кнопкой "Назад". - * Отображает горизонтальный список ссылок с индикаторами активности и счетчиками. - * - * Входящие параметры: - * - links: массив объектов навигационных ссылок с настройками - * - link: URL ссылки - * - linkText: текст ссылки - * - isRouterLinkActiveOptions: настройки активности ссылки - * - count: количество элементов для отображения бейджа (опционально) - * - backRoute: маршрут для кнопки "Назад" (опционально) - * - backHave: показывать ли кнопку "Назад" (опционально) - * - ballHave: показывать ли индикатор в виде шарика (по умолчанию false) - * - * Использование: - * - Навигация между разделами приложения - * - Отображение количества элементов в разделах - * - Навигация назад к предыдущему экрану - */ -@Component({ - selector: "app-bar", - standalone: true, - imports: [CommonModule, RouterLink, RouterLinkActive, BackComponent], - templateUrl: "./bar.component.html", - styleUrl: "./bar.component.scss", -}) -export class BarComponent { - constructor() {} - - /** Массив навигационных ссылок */ - @Input() links!: { - link: string; - linkText: string; - isRouterLinkActiveOptions: boolean; - count?: number; - }[]; - - /** Показывать индикатор в виде шарика */ - @Input() ballHave?: boolean = false; - - /** Маршрут для кнопки "Назад" */ - @Input() backRoute?: string; - - /** Показывать кнопку "Назад" */ - @Input() backHave?: boolean; -} diff --git a/projects/social_platform/src/app/ui/components/button/button.component.html b/projects/social_platform/src/app/ui/components/button/button.component.html deleted file mode 100644 index c0d33e5cf..000000000 --- a/projects/social_platform/src/app/ui/components/button/button.component.html +++ /dev/null @@ -1,33 +0,0 @@ - - diff --git a/projects/social_platform/src/app/ui/components/button/button.component.scss b/projects/social_platform/src/app/ui/components/button/button.component.scss deleted file mode 100644 index 57d11961c..000000000 --- a/projects/social_platform/src/app/ui/components/button/button.component.scss +++ /dev/null @@ -1,154 +0,0 @@ -/** @format */ - -@use "styles/typography"; - -.button { - position: relative; - display: inline-flex; - align-items: center; - justify-content: center; - max-height: 40px; - text-align: center; - cursor: pointer; - border-radius: var(--rounded-xxl); - transition: all 0.2s; - - &:disabled { - cursor: not-allowed; - opacity: 0.5; - } - - &.button--inline { - font-weight: 400; - color: var(--white); - background: var(--accent); - border: 0.5px solid transparent; - outline: none; - - &:hover { - background-color: var(--accent-light); - box-shadow: -2px 3px 3px rgb(51 51 51 / 20%); - } - - ::ng-deep *:not(.dot-wave) { - display: block; - - &:not(:last-child) { - margin-right: 10px; - } - } - - &.button--red { - background-color: var(--red); - - &:hover { - background-color: var(--red-dark); - } - } - - &.button--gradient { - background: var(--gradient); - - &:hover { - background: var(--gradient-mild); - } - } - - &.button--grey { - color: var(--black); - background-color: var(--grey-button); - } - - &.button--green { - color: var(--white); - background-color: var(--green); - } - - &.button--gold { - color: var(--white); - background: var(--gold-dark); - } - - &.button--white { - color: var(--accent); - background: var(--white); - } - - &.button--no-border { - border: none; - } - } - - &.button--outline { - color: var(--accent); - background-color: transparent; - border: 0.5px solid var(--accent); - - &:hover { - color: var(--accent-light); - border-color: var(--accent-light); - box-shadow: -2px 3px 3px rgb(51 51 51 / 20%); - } - - &.button--red { - color: var(--red); - border-color: var(--red); - - &:hover { - color: var(--red-dark); - border-color: var(--red-dark); - } - } - - &.button--white { - color: var(--white); - background: transparent; - border: 0.5px solid var(--white); - } - - &.button--no-border { - border: none; - } - - ::ng-deep *:not(.dot-wave) { - display: block; - - &:not(:last-child) { - margin-right: 10px; - } - } - } - - &--extra-small { - width: 70px; - padding: 2px 10px; - } - - &--small { - width: 100px; - padding: 4px 24px; - - &--icon { - padding: 12px 24px; - } - } - - &--medium { - width: 157px; - padding: 4px 0; - - &--icon { - padding: 12px 60px; - } - } - - &--big { - width: 100%; - padding: 4px 24px; - - &--icon { - width: 100%; - padding: 12px 24px; - } - } -} diff --git a/projects/social_platform/src/app/ui/components/button/button.component.ts b/projects/social_platform/src/app/ui/components/button/button.component.ts deleted file mode 100644 index 14b0d7264..000000000 --- a/projects/social_platform/src/app/ui/components/button/button.component.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** @format */ - -import { Component, Input, type OnInit } from "@angular/core"; -import { LoaderComponent } from "../loader/loader.component"; -import { CommonModule } from "@angular/common"; - -/** - * Универсальный компонент кнопки с различными стилями, состояниями и встроенной подсказкой. - * Поддерживает различные цветовые схемы, индикатор загрузки, настройки внешнего вида и tooltip. - * - * Входящие параметры: - * - color: цветовая схема кнопки ("primary" | "red" | "grey" | "green" | "gold" | "gradient" | "white") - * - loader: показывать индикатор загрузки - * - hasBorder: отображать рамку кнопки - * - type: тип кнопки для HTML ("submit" | "reset" | "button") - * - appearance: стиль отображения ("inline" | "outline") - * - backgroundColor: кастомный цвет фона - * - disabled: состояние блокировки кнопки - * - customTypographyClass: кастомный CSS класс для типографики - * - tooltipText: текст подсказки - * - tooltipPosition: позиция подсказки - * - tooltipWidth: ширина подсказки - * - * Использование: - * - Вставлять контент кнопки через ng-content - * - Автоматически показывает лоадер при loader=true - * - Показывает tooltip при наведении, если указан tooltipText - */ -@Component({ - selector: "app-button", - templateUrl: "./button.component.html", - styleUrl: "./button.component.scss", - standalone: true, - imports: [CommonModule, LoaderComponent], -}) -export class ButtonComponent implements OnInit { - constructor() {} - - /** Цветовая схема кнопки */ - @Input() color: "primary" | "red" | "grey" | "green" | "gold" | "gradient" | "white" = "primary"; - - /** Показывать индикатор загрузки */ - @Input() loader = false; - - /** Размер кнопки */ - @Input() size: "extra-small" | "small" | "medium" | "big" = "small"; - - /** Отображать рамку */ - @Input() hasBorder = true; - - /** Тип HTML кнопки */ - @Input() type: "submit" | "reset" | "button" | "icon" = "button"; - - /** Стиль отображения */ - @Input() appearance: "inline" | "outline" = "inline"; - - /** Кастомный цвет фона */ - @Input() backgroundColor?: string; - - /** Состояние блокировки */ - @Input() disabled = false; - - /** Кастомный класс типографики */ - @Input() customTypographyClass?: string; - - ngOnInit(): void {} -} diff --git a/projects/social_platform/src/app/ui/components/chat-message/chat-message.component.html b/projects/social_platform/src/app/ui/components/chat-message/chat-message.component.html deleted file mode 100644 index f870f6f6d..000000000 --- a/projects/social_platform/src/app/ui/components/chat-message/chat-message.component.html +++ /dev/null @@ -1,79 +0,0 @@ - - -
    -
    - -
    -
    -
    - {{ chatMessage.author.firstName }} {{ chatMessage.author.lastName }} -
    -
    - {{ chatMessage.createdAt | dayjs: "format":"HH:mm" }} -
    -
    - @if (chatMessage.replyTo) { -
    -
    - {{ chatMessage.replyTo.author.firstName }} {{ chatMessage.replyTo.author.lastName }} -
    -

    - {{ chatMessage.replyTo.text }} -

    -
    - } -
      - @for (file of chatMessage.files; track chatMessage.id) { -
    • - -
    • - } -
    -
    {{ chatMessage.text }}
    -
    -
    -
    - -
    -
    -@if (authService.profile | async; as profile) { -
      -
    • скопировать
    • -
    • ответить
    • - @if (profile.id === chatMessage.author.id) { - -
    • редактировать
    • -
    • - удалить -
    • -
      - } -
    -} diff --git a/projects/social_platform/src/app/ui/components/chat-message/chat-message.component.spec.ts b/projects/social_platform/src/app/ui/components/chat-message/chat-message.component.spec.ts deleted file mode 100644 index 789521d33..000000000 --- a/projects/social_platform/src/app/ui/components/chat-message/chat-message.component.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { ChatMessageComponent } from "./chat-message.component"; -import { ChatMessage } from "@models/chat-message.model"; -import { AuthService } from "@auth/services"; -import { of } from "rxjs"; -import { RouterTestingModule } from "@angular/router/testing"; - -describe("ChatMessageComponent", () => { - let component: ChatMessageComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - const authSpy = { - profile: of({}), - }; - - await TestBed.configureTestingModule({ - imports: [RouterTestingModule, ChatMessageComponent], - providers: [{ provide: AuthService, useValue: authSpy }], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(ChatMessageComponent); - component = fixture.componentInstance; - component.chatMessage = ChatMessage.default(); - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/ui/components/chat-message/chat-message.component.ts b/projects/social_platform/src/app/ui/components/chat-message/chat-message.component.ts deleted file mode 100644 index 9aac541a4..000000000 --- a/projects/social_platform/src/app/ui/components/chat-message/chat-message.component.ts +++ /dev/null @@ -1,183 +0,0 @@ -/** @format */ - -import { - AfterViewInit, - Component, - ElementRef, - EventEmitter, - Input, - OnDestroy, - OnInit, - Output, - ViewChild, -} from "@angular/core"; -import { ChatMessage } from "@models/chat-message.model"; -import { SnackbarService } from "@ui/services/snackbar.service"; -import { DomPortal } from "@angular/cdk/portal"; -import { Overlay, OverlayRef } from "@angular/cdk/overlay"; -import { AuthService } from "@auth/services"; -import { DayjsPipe } from "projects/core"; -import { IconComponent } from "@ui/components"; -import { FileItemComponent } from "../file-item/file-item.component"; -import { AsyncPipe } from "@angular/common"; -import { AvatarComponent } from "../avatar/avatar.component"; -import { ClickOutsideModule } from "ng-click-outside"; - -/** - * Компонент сообщения в чате с контекстным меню и файловыми вложениями. - * Отображает сообщение пользователя с возможностью ответа, редактирования и удаления. - * - * Входящие параметры: - * - chatMessage: объект сообщения чата с текстом, автором, временем и вложениями - * - * События: - * - reply: ответ на сообщение (передает ID сообщения) - * - edit: редактирование сообщения (передает ID сообщения) - * - delete: удаление сообщения (передает ID сообщения) - * - * Функциональность: - * - Отображение аватара и информации об авторе - * - Контекстное меню по правому клику - * - Копирование текста сообщения в буфер обмена - * - Отображение файловых вложений - * - Форматирование времени отправки - * - * Использование: - * - В списках сообщений чата - * - Комментарии и обсуждения - */ -@Component({ - selector: "app-chat-message", - templateUrl: "./chat-message.component.html", - styleUrl: "./chat-message.component.scss", - standalone: true, - imports: [ - ClickOutsideModule, - AvatarComponent, - FileItemComponent, - IconComponent, - AsyncPipe, - DayjsPipe, - ], -}) -export class ChatMessageComponent implements OnInit, AfterViewInit, OnDestroy { - constructor( - private readonly snackbarService: SnackbarService, - private readonly overlay: Overlay, - public readonly authService: AuthService - ) {} - - /** Объект сообщения чата */ - @Input({ required: true }) chatMessage!: ChatMessage; - - /** Событие ответа на сообщение */ - @Output() reply = new EventEmitter(); - - /** Событие редактирования сообщения */ - @Output() edit = new EventEmitter(); - - /** Событие удаления сообщения */ - @Output() delete = new EventEmitter(); - - ngOnInit(): void {} - - /** Инициализация overlay для контекстного меню */ - ngAfterViewInit(): void { - this.overlayRef = this.overlay.create({ - hasBackdrop: false, - }); - this.portal = new DomPortal(this.contextMenu); - } - - /** Очистка ресурсов overlay */ - ngOnDestroy(): void { - this.overlayRef?.detach(); - } - - /** Ссылка на элемент контекстного меню */ - @ViewChild("contextMenu") contextMenu!: ElementRef; - - /** Ссылка на overlay */ - private overlayRef?: OverlayRef; - - /** Portal для контекстного меню */ - private portal?: DomPortal; - - /** Состояние открытия контекстного меню */ - isOpen = false; - - /** Обработчик открытия контекстного меню по правому клику */ - onOpenContextmenu(event: MouseEvent) { - event.preventDefault(); - - this.isOpen = true; - - const contextMenuHeight = this.contextMenu.nativeElement.offsetHeight; - - const positionX = event.clientX; - const positionY = - contextMenuHeight + event.clientY > window.innerHeight - ? event.clientY - contextMenuHeight - : event.clientY; - - const positionStrategy = this.overlay - .position() - .global() - .left(positionX + "px") - .top(positionY + "px"); - this.overlayRef?.updatePositionStrategy(positionStrategy); - - !this.overlayRef?.hasAttached() && this.overlayRef?.attach(this.portal); - - this.contextMenu.nativeElement.focus(); - } - - /** Закрытие контекстного меню */ - onCloseContextmenu() { - this.isOpen = false; - this.overlayRef?.detach(); - } - - /** Копирование содержимого сообщения в буфер обмена */ - onCopyContent(event: MouseEvent) { - event.stopPropagation(); - - this.isOpen = false; - this.overlayRef?.detach(); - - navigator.clipboard.writeText(this.chatMessage.text).then(() => { - this.snackbarService.success("Сообщение скопированно"); - console.debug("Text copied in ChatMessageComponent"); - }); - } - - /** Обработчик удаления сообщения */ - onDelete(event: MouseEvent) { - event.stopPropagation(); - - this.delete.emit(this.chatMessage.id); - - this.isOpen = false; - this.overlayRef?.detach(); - } - - /** Обработчик ответа на сообщение */ - onReply(event: MouseEvent) { - event.stopPropagation(); - - this.reply.emit(this.chatMessage.id); - - this.isOpen = false; - this.overlayRef?.detach(); - } - - /** Обработчик редактирования сообщения */ - onEdit(event: MouseEvent) { - event.stopPropagation(); - - this.edit.emit(this.chatMessage.id); - - this.isOpen = false; - this.overlayRef?.detach(); - } -} diff --git a/projects/social_platform/src/app/ui/components/checkbox/checkbox.component.html b/projects/social_platform/src/app/ui/components/checkbox/checkbox.component.html deleted file mode 100644 index a3e68b892..000000000 --- a/projects/social_platform/src/app/ui/components/checkbox/checkbox.component.html +++ /dev/null @@ -1,10 +0,0 @@ - -
    - -
    diff --git a/projects/social_platform/src/app/ui/components/checkbox/checkbox.component.ts b/projects/social_platform/src/app/ui/components/checkbox/checkbox.component.ts deleted file mode 100644 index d86affbf9..000000000 --- a/projects/social_platform/src/app/ui/components/checkbox/checkbox.component.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** @format */ - -import { Component, EventEmitter, Input, type OnInit, Output } from "@angular/core"; -import { IconComponent } from "@ui/components/icon/icon.component"; - -/** - * Компонент чекбокса для выбора булевых значений. - * Отображает состояние отмечен/не отмечен с иконкой галочки. - * - * Входящие параметры: - * - checked: состояние чекбокса (отмечен/не отмечен) - * - * События: - * - checkedChange: изменение состояния чекбокса - * - * Возвращает: - * - boolean значение через событие checkedChange - */ -@Component({ - selector: "app-checkbox", - templateUrl: "./checkbox.component.html", - styleUrl: "./checkbox.component.scss", - standalone: true, - imports: [IconComponent], -}) -export class CheckboxComponent implements OnInit { - /** Состояние чекбокса */ - @Input({ required: true }) checked = false; - - @Input() size?: string; - - /** Событие изменения состояния */ - @Output() checkedChange = new EventEmitter(); - - ngOnInit(): void {} -} diff --git a/projects/social_platform/src/app/ui/components/cookie-consent/cookie-consent.component.html b/projects/social_platform/src/app/ui/components/cookie-consent/cookie-consent.component.html deleted file mode 100644 index f6a180b3a..000000000 --- a/projects/social_platform/src/app/ui/components/cookie-consent/cookie-consent.component.html +++ /dev/null @@ -1,33 +0,0 @@ - - -@if (visible) { - -} diff --git a/projects/social_platform/src/app/ui/components/delete-confirm/delete-confirm.component.html b/projects/social_platform/src/app/ui/components/delete-confirm/delete-confirm.component.html deleted file mode 100644 index 4059b92b4..000000000 --- a/projects/social_platform/src/app/ui/components/delete-confirm/delete-confirm.component.html +++ /dev/null @@ -1,17 +0,0 @@ - - - - @if (settings$ | async; as settings) { -
    - -

    {{ settings.mainText }}

    -

    {{ settings.subText }}

    - - отменить - - удалить -
    - } -
    diff --git a/projects/social_platform/src/app/ui/components/file-item/file-item.component.html b/projects/social_platform/src/app/ui/components/file-item/file-item.component.html deleted file mode 100644 index aece7cfb1..000000000 --- a/projects/social_platform/src/app/ui/components/file-item/file-item.component.html +++ /dev/null @@ -1,42 +0,0 @@ - - -@if (name && link) { -
    -
    - @if (mode === 'preview') { - - } @else { - - } - -
    -
    - {{ name }} -
    - - @if (type) { -
    - {{ type.includes("/") ? (type | fileType) : (type | uppercase) }} - {{ size | formatedFileSize }} -
    - } -
    -
    - - @if (canDelete) { - - } -
    -} diff --git a/projects/social_platform/src/app/ui/components/file-item/file-item.component.spec.ts b/projects/social_platform/src/app/ui/components/file-item/file-item.component.spec.ts deleted file mode 100644 index d88277a4d..000000000 --- a/projects/social_platform/src/app/ui/components/file-item/file-item.component.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { FileItemComponent } from "./file-item.component"; -import { FileTypePipe } from "@ui/pipes/file-type.pipe"; - -describe("FileItemComponent", () => { - let component: FileItemComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [FileItemComponent, FileTypePipe], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(FileItemComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/ui/components/file-item/file-item.component.ts b/projects/social_platform/src/app/ui/components/file-item/file-item.component.ts deleted file mode 100644 index aab60452d..000000000 --- a/projects/social_platform/src/app/ui/components/file-item/file-item.component.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** @format */ -import { Component, EventEmitter, inject, Input, OnInit, Output } from "@angular/core"; -import { FileTypePipe } from "@ui/pipes/file-type.pipe"; -import { IconComponent } from "@ui/components"; -import { UpperCasePipe } from "@angular/common"; -import { FormatedFileSizePipe } from "@core/pipes/formatted-file-size.pipe"; -import { FileService } from "@core/services/file.service"; - -/** - * Компонент для отображения информации о файле. - * Показывает тип файла, название, размер и предоставляет возможность скачивания. - * - * Входящие параметры: - * - type: MIME-тип файла (по умолчанию "file") - * - name: название файла - * - size: размер файла в байтах - * - link: ссылка для скачивания файла - * - * Функциональность: - * - Отображение иконки файла по типу - * - Форматированный вывод размера файла - * - Автоматическое скачивание файла по клику - */ -@Component({ - selector: "app-file-item", - templateUrl: "./file-item.component.html", - styleUrl: "./file-item.component.scss", - standalone: true, - imports: [IconComponent, FileTypePipe, UpperCasePipe, FormatedFileSizePipe], -}) -export class FileItemComponent implements OnInit { - private readonly fileService = inject(FileService); - - @Input() canDelete = false; - - /** Режим отображения: 'default' — скачивание + удаление через сервис, 'preview' — только просмотр + удаление через Output */ - @Input() mode: "default" | "preview" = "default"; - - /** Событие удаления файла (используется в режиме preview) */ - @Output() deleted = new EventEmitter(); - - /** MIME-тип файла */ - @Input() type = "file"; - - /** Название файла */ - @Input() name = ""; - - /** Размер файла в байтах */ - @Input() size = 0; - - /** Ссылка для скачивания */ - @Input() link = ""; - - ngOnInit(): void {} - - /** Функция скачивания файла через создание временной ссылки */ - onDownloadFile(): void { - const link = document.createElement("a"); - - link.setAttribute("href", this.link); - link.setAttribute("download", this.name); - - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - } - - /** - * Удаление файла - * В режиме preview — эмитит событие наружу - * В режиме default — удаляет через FileService - */ - onDeleteFile(): void { - if (!this.link) return; - - if (this.mode === "preview") { - this.deleted.emit(); - return; - } - - this.fileService.deleteFile(this.link).subscribe(() => { - this.link = ""; - this.name = ""; - }); - } -} diff --git a/projects/social_platform/src/app/ui/components/file-upload-item/file-upload-item.component.html b/projects/social_platform/src/app/ui/components/file-upload-item/file-upload-item.component.html deleted file mode 100644 index da0b7aa12..000000000 --- a/projects/social_platform/src/app/ui/components/file-upload-item/file-upload-item.component.html +++ /dev/null @@ -1,32 +0,0 @@ - - -
    - @if (!error) { - -
    - -
    -
    -
    - {{ name }} -
    -
    - {{ type | uppercase }} {{ size | formatedFileSize }} -
    -
    -
    - } @else { -
    - - {{ error }} -
    - } @if (loading && !error) { - - } @if (!loading && !error) { -
    - -
    - } @if (error) { - - } -
    diff --git a/projects/social_platform/src/app/ui/components/file-upload-item/file-upload-item.component.ts b/projects/social_platform/src/app/ui/components/file-upload-item/file-upload-item.component.ts deleted file mode 100644 index a07d56e9e..000000000 --- a/projects/social_platform/src/app/ui/components/file-upload-item/file-upload-item.component.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** @format */ - -import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; -import { FileTypePipe } from "@ui/pipes/file-type.pipe"; -import { LoaderComponent } from "../loader/loader.component"; -import { IconComponent } from "@ui/components"; -import { UpperCasePipe } from "@angular/common"; -import { FormatedFileSizePipe } from "@core/pipes/formatted-file-size.pipe"; - -/** - * Компонент для отображения элемента загружаемого файла. - * Показывает информацию о файле, состояние загрузки и предоставляет действия для управления. - * - * Входящие параметры: - * - type: MIME-тип файла (по умолчанию "file") - * - name: имя файла - * - size: размер файла в байтах - * - link: ссылка на файл - * - loading: состояние загрузки файла - * - error: текст ошибки загрузки - * - * События: - * - delete: событие удаления файла - * - retry: событие повторной попытки загрузки - */ -@Component({ - selector: "app-file-upload-item", - templateUrl: "./file-upload-item.component.html", - styleUrl: "./file-upload-item.component.scss", - standalone: true, - imports: [IconComponent, LoaderComponent, UpperCasePipe, FileTypePipe, FormatedFileSizePipe], -}) -export class FileUploadItemComponent implements OnInit { - constructor() {} - - /** MIME-тип файла */ - @Input() type = "file"; - - /** Имя файла */ - @Input() name = ""; - - /** Размер файла в байтах */ - @Input() size = 0; - - /** Ссылка на файл */ - @Input() link = ""; - - /** Состояние загрузки */ - @Input() loading = false; - - /** Текст ошибки */ - @Input() error = ""; - - /** Событие удаления файла */ - @Output() delete = new EventEmitter(); - - /** Событие повторной попытки загрузки */ - @Output() retry = new EventEmitter(); - - ngOnInit(): void {} -} diff --git a/projects/social_platform/src/app/ui/components/icon/icon.component.spec.ts b/projects/social_platform/src/app/ui/components/icon/icon.component.spec.ts deleted file mode 100644 index b786851f6..000000000 --- a/projects/social_platform/src/app/ui/components/icon/icon.component.spec.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { By } from "@angular/platform-browser"; -import { IconComponent } from "@ui/components"; - -describe("IconComponent", () => { - let component: IconComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [IconComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(IconComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create the icon component", () => { - expect(component).toBeTruthy(); - }); - - it("should render the correct icon", () => { - component.icon = "check"; - fixture.detectChanges(); - const useElement = fixture.debugElement.query(By.css("use")).nativeElement; - expect(useElement.getAttribute("xlink:href")).toBe( - "assets/icons/symbol/svg/sprite.css.svg#check" - ); - }); - - it("should set the width and height attributes if square is not set", () => { - component.appWidth = "24"; - component.appHeight = "24"; - fixture.detectChanges(); - const svgElement = fixture.debugElement.query(By.css("svg")).nativeElement; - expect(svgElement.getAttribute("width")).toBe("24"); - expect(svgElement.getAttribute("height")).toBe("24"); - }); - - it("should set the viewBox attribute if square is set", () => { - component.appSquare = "24"; - fixture.detectChanges(); - const svgElement = fixture.debugElement.query(By.css("svg")).nativeElement; - expect(svgElement.getAttribute("viewBox")).toBe("0 0 24 24"); - }); - - it("should update the viewBox attribute when square, width or height is set", () => { - component.appSquare = "24"; - component.appWidth = "32"; - component.appHeight = "32"; - fixture.detectChanges(); - const svgElement = fixture.debugElement.query(By.css("svg")).nativeElement; - expect(svgElement.getAttribute("viewBox")).toBe("0 0 24 24"); - }); -}); diff --git a/projects/social_platform/src/app/ui/components/index.ts b/projects/social_platform/src/app/ui/components/index.ts deleted file mode 100644 index 3b120e576..000000000 --- a/projects/social_platform/src/app/ui/components/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** @format */ - -export * from "./button/button.component"; -export * from "./checkbox/checkbox.component"; -export * from "./icon/icon.component"; -export * from "./input/input.component"; -export * from "./select/select.component"; -export * from "./bar/bar.component"; diff --git a/projects/social_platform/src/app/ui/components/input/input.component.html b/projects/social_platform/src/app/ui/components/input/input.component.html deleted file mode 100644 index 26e386c86..000000000 --- a/projects/social_platform/src/app/ui/components/input/input.component.html +++ /dev/null @@ -1,96 +0,0 @@ - - -
    -
    - -
    - - @if (type === 'radio') { - - } @else if (type === 'date') { - - - } @else { - - @if (maxLength) { -
    -

    - {{ - value.length - }} - / {{ maxLength }} -

    -
    - } } @if (showErrorIcon) { - - } @else if (haveHint && tooltipText) { -
    - -
    - } - -
    - -
    -
    diff --git a/projects/social_platform/src/app/ui/components/input/input.component.spec.ts b/projects/social_platform/src/app/ui/components/input/input.component.spec.ts deleted file mode 100644 index 1c016a88c..000000000 --- a/projects/social_platform/src/app/ui/components/input/input.component.spec.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { FormsModule } from "@angular/forms"; -import { InputComponent } from "@ui/components"; -import { NgxMaskModule } from "ngx-mask"; - -describe("InputComponent", () => { - let component: InputComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [FormsModule, InputComponent, NgxMaskModule.forRoot()], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(InputComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create the component", () => { - expect(component).toBeTruthy(); - }); - - it("should set the value of the input element", () => { - const fixture = TestBed.createComponent(InputComponent); - const inputEl = fixture.nativeElement.querySelector("input"); - const component = fixture.componentInstance; - component.appValue = "test"; - fixture.detectChanges(); - expect(inputEl.value).toBe("test"); - }); - - it("should emit the input value on input", () => { - spyOn(component.appValueChange, "emit"); - const testValue = "test"; - const input = fixture.nativeElement.querySelector("input"); - input.value = testValue; - input.dispatchEvent(new Event("input")); - expect(component.appValueChange.emit).toHaveBeenCalledWith(testValue); - }); - - it("should emit enter event on enter keydown", () => { - spyOn(component.enter, "emit"); - const input = fixture.nativeElement.querySelector("input"); - input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })); - expect(component.enter.emit).toHaveBeenCalled(); - }); - - it("should call onChange function on input", () => { - spyOn(component, "onChange"); - const testValue = "test"; - const input = fixture.nativeElement.querySelector("input"); - input.value = testValue; - input.dispatchEvent(new Event("input")); - expect(component.onChange).toHaveBeenCalledWith(testValue); - }); - - it("should call onTouch function on blur", () => { - spyOn(component, "onTouch"); - const input = fixture.nativeElement.querySelector("input"); - input.dispatchEvent(new Event("blur")); - expect(component.onTouch).toHaveBeenCalled(); - }); - - it("should set the input type", () => { - const input = fixture.nativeElement.querySelector("input"); - expect(input.type).toBe("text"); - component.type = "email"; - fixture.detectChanges(); - expect(input.type).toBe("email"); - }); - - it("should set the input placeholder", () => { - const input = fixture.nativeElement.querySelector("input"); - expect(input.placeholder).toBe(""); - const testPlaceholder = "test placeholder"; - component.placeholder = testPlaceholder; - fixture.detectChanges(); - expect(input.placeholder).toBe(testPlaceholder); - }); - - it("should set the error class when error input is true", () => { - const field = fixture.nativeElement.querySelector(".field"); - expect(field.classList).not.toContain("field--error"); - component.error = true; - fixture.detectChanges(); - expect(field.classList).toContain("field--error"); - }); -}); diff --git a/projects/social_platform/src/app/ui/components/input/input.component.ts b/projects/social_platform/src/app/ui/components/input/input.component.ts deleted file mode 100644 index 69ead4dee..000000000 --- a/projects/social_platform/src/app/ui/components/input/input.component.ts +++ /dev/null @@ -1,172 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { Component, EventEmitter, forwardRef, Input, Output } from "@angular/core"; -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; -import { IconComponent } from "@ui/components"; -import { TooltipComponent } from "@ui/components/tooltip/tooltip.component"; -import { NgxMaskModule } from "ngx-mask"; -import { MatDatepickerModule } from "@angular/material/datepicker"; -import { MatInputModule } from "@angular/material/input"; -import { MatNativeDateModule } from "@angular/material/core"; -import { MatFormFieldModule } from "@angular/material/form-field"; - -@Component({ - selector: "app-input", - templateUrl: "./input.component.html", - styleUrl: "./input.component.scss", - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => InputComponent), - multi: true, - }, - ], - standalone: true, - imports: [ - CommonModule, - NgxMaskModule, - IconComponent, - TooltipComponent, - MatDatepickerModule, - MatInputModule, - MatNativeDateModule, - MatFormFieldModule, - ], -}) -export class InputComponent implements ControlValueAccessor { - @Input() placeholder = ""; - @Input() type: "text" | "password" | "email" | "tel" | "date" | "radio" = "text"; - @Input() size: "small" | "big" = "small"; - @Input() hasBorder = true; - @Input() haveHint = false; - @Input() tooltipText?: string; - @Input() tooltipPosition: "left" | "right" = "right"; - @Input() tooltipWidth = 250; - @Input() error = false; - @Input() mask = ""; - @Input() name = ""; - @Input() checked = false; - @Input() maxLength?: number; - - @Input() - set appValue(value: string | null) { - this.value = value ?? ""; - } - - get appValue(): string { - return this.value; - } - - isTooltipVisible = false; - isLengthOverflow = false; - - @Output() appValueChange = new EventEmitter(); - @Output() enter = new EventEmitter(); - @Output() change = new EventEmitter(); - - /** Обработчик для radвариант io */ - onRadioChange(event: Event): void { - if (this.type === "radio") { - const target = event.target as HTMLInputElement; - this.value = target.value; - this.onChange(this.value); - this.appValueChange.emit(this.value); - this.change.emit(event); - this.onTouch(); - } - } - - /** Обработчик изменения даты в datepicker */ - onDateChange(event: any): void { - if (this.type === "date" && event.value) { - const date = event.value as Date; - - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - const formattedDate = `${year}-${month}-${day}`; - - this.value = formattedDate; - this.onChange(formattedDate); - this.appValueChange.emit(formattedDate); - this.onTouch(); - } - } - - showTooltip(): void { - this.isTooltipVisible = true; - } - - hideTooltip(): void { - this.isTooltipVisible = false; - } - - onInput(event: Event): void { - const nextValue = (event.target as HTMLInputElement).value ?? ""; - - this.isLengthOverflow = !!this.maxLength && nextValue.length > this.maxLength; - - this.value = nextValue; - this.onChange(nextValue); - this.appValueChange.emit(nextValue); - } - - onBlur(): void { - this.onTouch(); - } - - value = ""; - - // Геттер для преобразования строковой даты в объект Date для datepicker - get dateValue(): Date | null { - if (!this.value || this.type !== "date") return null; - - const parts = this.value.split("-"); - if (parts.length === 3) { - const year = parseInt(parts[0], 10); - const month = parseInt(parts[1], 10) - 1; - const day = parseInt(parts[2], 10); - return new Date(year, month, day); - } - - return null; - } - - get showErrorIcon(): boolean { - if (this.error && !this.maxLength) { - return true; - } - - return false; - } - - writeValue(value: string | null): void { - setTimeout(() => { - this.value = value ?? ""; - }); - } - - onChange: (value: string) => void = () => {}; - - registerOnChange(fn: (v: string) => void): void { - this.onChange = fn; - } - - onTouch: () => void = () => {}; - - registerOnTouched(fn: () => void): void { - this.onTouch = fn; - } - - disabled = false; - - setDisabledState(isDisabled: boolean) { - this.disabled = isDisabled; - } - - onEnter(event: Event) { - event.preventDefault(); - this.enter.emit(); - } -} diff --git a/projects/social_platform/src/app/ui/components/loader/loader.component.html b/projects/social_platform/src/app/ui/components/loader/loader.component.html deleted file mode 100644 index 8f1c1085a..000000000 --- a/projects/social_platform/src/app/ui/components/loader/loader.component.html +++ /dev/null @@ -1,23 +0,0 @@ - -
    - @if (type === "wave") { -
    -
    -
    -
    -
    -
    - } @else if (type === "circle") { -
    -
    -
    -
    -
    -
    - } -
    diff --git a/projects/social_platform/src/app/ui/components/loader/loader.component.spec.ts b/projects/social_platform/src/app/ui/components/loader/loader.component.spec.ts deleted file mode 100644 index 48f0dd35b..000000000 --- a/projects/social_platform/src/app/ui/components/loader/loader.component.spec.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { By } from "@angular/platform-browser"; -import { LoaderComponent } from "./loader.component"; - -describe("LoaderComponent", () => { - let component: LoaderComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [LoaderComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(LoaderComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); - - it("should apply the speed input", () => { - component.speed = "2s"; - fixture.detectChanges(); - const dotWave = fixture.debugElement.query(By.css(".dot-wave")).nativeElement; - expect(dotWave.style.getPropertyValue("--speed")).toBe("2s"); - }); - - it("should apply the size input", () => { - component.size = "20px"; - fixture.detectChanges(); - const dotWave = fixture.debugElement.query(By.css(".dot-wave")).nativeElement; - expect(dotWave.style.getPropertyValue("--size")).toBe("20px"); - }); - - it("should apply the color input", () => { - component.color = "red"; - fixture.detectChanges(); - const dotWave = fixture.debugElement.query(By.css(".dot-wave")).nativeElement; - expect(dotWave.style.getPropertyValue("--color")).toBe("var(--red)"); - }); -}); diff --git a/projects/social_platform/src/app/ui/components/loader/loader.component.ts b/projects/social_platform/src/app/ui/components/loader/loader.component.ts deleted file mode 100644 index 4010005ec..000000000 --- a/projects/social_platform/src/app/ui/components/loader/loader.component.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** @format */ - -import { Component, Input, OnInit } from "@angular/core"; - -/** - * Компонент индикатора загрузки с настраиваемым внешним видом. - * Поддерживает различные типы анимации и настройки цвета, размера, скорости. - * - * Входящие параметры: - * - speed: скорость анимации (по умолчанию "1s") - * - size: размер индикатора (по умолчанию "47px") - * - color: цвет индикатора (по умолчанию "white") - * - type: тип анимации ("wave" | "circle", по умолчанию "wave") - * - * Использование: - * - Для показа состояния загрузки в кнопках, формах и других элементах - * - Настраиваемый размер и цвет под дизайн приложения - */ -@Component({ - selector: "app-loader", - templateUrl: "./loader.component.html", - styleUrl: "./loader.component.scss", - standalone: true, -}) -export class LoaderComponent implements OnInit { - constructor() {} - - /** Скорость анимации */ - @Input() speed = "1s"; - - /** Размер индикатора */ - @Input() size = "15px"; - - /** Цвет индикатора */ - @Input() color = "white"; - - /** Тип анимации */ - @Input() type: "wave" | "circle" = "circle"; - - ngOnInit(): void {} -} diff --git a/projects/social_platform/src/app/ui/components/modal/modal.component.html b/projects/social_platform/src/app/ui/components/modal/modal.component.html deleted file mode 100644 index 567450daf..000000000 --- a/projects/social_platform/src/app/ui/components/modal/modal.component.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - diff --git a/projects/social_platform/src/app/ui/components/num-slider/num-slider.component.html b/projects/social_platform/src/app/ui/components/num-slider/num-slider.component.html deleted file mode 100644 index 722596a5e..000000000 --- a/projects/social_platform/src/app/ui/components/num-slider/num-slider.component.html +++ /dev/null @@ -1,27 +0,0 @@ - - -@if (nums.sort(); as sortedNums) { -
    - @if (value !== null) { -
    -
    -
    -
    -
    - } -
    - @for (num of sortedNums; let index = $index; track index) { - - {{ num }} - - } -
    -
    -} diff --git a/projects/social_platform/src/app/ui/components/num-slider/num-slider.component.scss b/projects/social_platform/src/app/ui/components/num-slider/num-slider.component.scss deleted file mode 100644 index 11982b8a5..000000000 --- a/projects/social_platform/src/app/ui/components/num-slider/num-slider.component.scss +++ /dev/null @@ -1,54 +0,0 @@ -/** @format */ - -.num-slider { - padding-bottom: 15px; // to give space for numbers - - &__range { - position: relative; - height: 16px; - } - - &__placeholder { - position: absolute; - top: 50%; - right: 0; - left: 0; - height: 4px; - background-color: var(--light-gray); - border-radius: var(--rounded-md); - transform: translateY(-50%); - } - - &__fill { - position: absolute; - top: 50%; - right: 0; - left: 0; - width: 0; - height: 4px; - background-color: var(--accent); - border-radius: var(--rounded-md); - transform: translateY(-50%); - } - - &__button { - position: absolute; - width: 16px; - height: 16px; - cursor: pointer; - background-color: var(--accent); - border-radius: 50%; - transform: translateX(-50%); - } - - &__nums { - position: relative; - } - - &__num { - position: absolute; - font-size: 10px; - color: var(--dark-grey); - transform: translateX(-50%); - } -} diff --git a/projects/social_platform/src/app/ui/components/num-slider/num-slider.component.spec.ts b/projects/social_platform/src/app/ui/components/num-slider/num-slider.component.spec.ts deleted file mode 100644 index e6f88d78c..000000000 --- a/projects/social_platform/src/app/ui/components/num-slider/num-slider.component.spec.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { FormsModule } from "@angular/forms"; -import { NumSliderComponent } from "./num-slider.component"; - -describe("NumSliderComponent", () => { - let component: NumSliderComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [FormsModule, NumSliderComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(NumSliderComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); - - it("should set value to null when initialized", () => { - expect(component.appValue).toBeNull(); - }); - - it("should set disabled state", () => { - component.setDisabledState(true); - expect(component.disabled).toBeTrue(); - }); - - it("should change value on move", () => { - component.nums = [0, 1, 2, 3]; - component.appValue = 1; - fixture.detectChanges(); - - const rangeEl = fixture.nativeElement.querySelector(".num-slider__range"); - const rangeWidth = rangeEl.getBoundingClientRect().width; - const moveEvent = new MouseEvent("mousemove", { clientX: rangeWidth / 2 }); - rangeEl.dispatchEvent(moveEvent); - - const pointEl = fixture.nativeElement.querySelector(".num-slider__button"); - const pressEvent = new MouseEvent("mousedown"); - pointEl.dispatchEvent(pressEvent); - - fixture.detectChanges(); - expect(component.appValue).toBe(1); - expect(component.mousePressed).toBeTrue(); - }); - - it("should emit appValueChange event on stop interaction", () => { - spyOn(component.appValueChange, "emit"); - component.nums = [0, 1, 2, 3]; - component.appValue = 1; - fixture.detectChanges(); - const buttonEl = fixture.nativeElement.querySelector(".num-slider__button"); - const event = new MouseEvent("mouseup"); - buttonEl.dispatchEvent(event); - fixture.detectChanges(); - expect(component.mousePressed).toBeFalse(); - expect(component.appValueChange.emit).toHaveBeenCalled(); - }); - - it("should set elements on setElements call", () => { - component.nums = [0, 1, 2, 3]; - component.appValue = 1; - fixture.detectChanges(); - component.setElements(); - fixture.detectChanges(); - - expect(component.pointEl?.nativeElement.style.left).toEqual("33.3333%"); - expect(component.fillEl?.nativeElement.style.width).toEqual("33.3333%"); - }); -}); diff --git a/projects/social_platform/src/app/ui/components/num-slider/num-slider.component.ts b/projects/social_platform/src/app/ui/components/num-slider/num-slider.component.ts deleted file mode 100644 index 2cd12fbf9..000000000 --- a/projects/social_platform/src/app/ui/components/num-slider/num-slider.component.ts +++ /dev/null @@ -1,205 +0,0 @@ -/** @format */ - -import { - Component, - ElementRef, - EventEmitter, - forwardRef, - Input, - OnDestroy, - OnInit, - Output, - ViewChild, -} from "@angular/core"; -import { NG_VALUE_ACCESSOR } from "@angular/forms"; -import { Subscription } from "rxjs"; - -/** - * Компонент числового слайдера для выбора значения из предопределенного набора чисел. - * Реализует ControlValueAccessor для интеграции с Angular Forms. - * Поддерживает перетаскивание мышью и ка��ание для мобильных устройств. - * - * Входящие параметры: - * - appNums: массив доступных чисел для выбора - * - appValue: текущее выбранное значение - * - * События: - * - appValueChange: изменение выбранного значения - * - * Возвращает: - * - Выбранное число через ControlValueAccessor - */ -@Component({ - selector: "app-num-slider", - templateUrl: "./num-slider.component.html", - styleUrl: "./num-slider.component.scss", - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => NumSliderComponent), - multi: true, - }, - ], - standalone: true, -}) -export class NumSliderComponent implements OnInit, OnDestroy { - constructor() {} - - /** Массив доступных чисел */ - @Input() - set appNums(value: number[]) { - this.nums = value; - } - - get appNums(): number[] { - return this.nums; - } - - /** Текущее выбранное значение */ - @Input() - set appValue(value: number | null) { - this.value = !value || isNaN(value) ? this.nums.sort()[0] : value; - - setTimeout(() => { - this.setElements(); - }); - } - - get appValue(): number | null { - return this.value; - } - - /** Событие изменения значения */ - @Output() appValueChange = new EventEmitter(); - - ngOnInit(): void {} - - ngOnDestroy(): void { - this.subscriptions$.forEach($ => $.unsubscribe()); - } - - /** Ссылка на элемент точки слайдера */ - @ViewChild("pointEl") pointEl?: ElementRef; - - /** Ссылка на элемент диапазона */ - @ViewChild("rangeEl") rangeEl?: ElementRef; - - /** Ссылка на элемент заливки */ - @ViewChild("fillEl") fillEl?: ElementRef; - - /** Массив подписок */ - subscriptions$: Subscription[] = []; - - /** Текущее значение */ - value: number | null = null; - - /** Массив доступных чисел */ - nums: number[] = []; - - /** Состояние нажатия мыши */ - mousePressed = false; - - /** Обработчик потери фокуса */ - onBlur(): void { - this.onTouch(); - } - - // Методы ControlValueAccessor - onChange: (value: number) => void = () => {}; - - registerOnChange(fn: (v: number) => void): void { - this.onChange = fn; - } - - onTouch: () => void = () => {}; - - registerOnTouched(fn: () => void): void { - this.onTouch = fn; - } - - disabled = false; - - setDisabledState(isDisabled: boolean) { - this.disabled = isDisabled; - } - - /** Обработчик движения мыши/касания */ - onMove(event: MouseEvent | TouchEvent) { - if (!this.mousePressed) return; - - const range = event.currentTarget as HTMLElement; - const { width: totalWidth, x } = range.getBoundingClientRect(); - let xChange: number; - if (event instanceof MouseEvent) xChange = event.clientX - x; - else if (event instanceof TouchEvent) xChange = event.touches[0].clientX - x; - else throw Error("Non existing type"); - - if (this.pointEl && xChange > 0 && xChange < totalWidth && this.fillEl) { - this.pointEl.nativeElement.style.left = `${xChange}px`; - this.fillEl.nativeElement.style.width = `${xChange}px`; - } - - if (xChange < 0 && xChange > totalWidth) this.onStopInteraction(event); - } - - /** Получение индекса шага по координате X */ - private getStepIdxFromX(totalWidth: number, x: number): number { - const intervalWidth = Number.parseInt((totalWidth / (this.nums.length - 1)).toFixed()); - - for (let i = 0; i < this.nums.length; i++) { - const halfInterval = Number.parseInt((intervalWidth / 2).toFixed()); - const startX = i === 0 ? 0 : i * intervalWidth - halfInterval; - const endX = i === this.nums.length - 1 ? totalWidth : i * intervalWidth + halfInterval; - - if (startX < x && x < endX) { - return i; - } - } - - return 0; - } - - /** Начало взаимодействия (нажатие мыши/касание) */ - onStartInteraction() { - this.mousePressed = true; - } - - /** Получение координаты кнопки в процентах */ - private getButtonCoordinate(): number { - return this.value ? (100 / (this.nums.length - 1)) * this.nums.indexOf(this.value) : 0; - } - - /** Окончание взаимодействия */ - onStopInteraction(event: MouseEvent | TouchEvent) { - event.stopPropagation(); - - this.renderRange(); - this.stopMoving(); - } - - /** Отрисовка положения слайдера */ - private renderRange() { - if (!this.pointEl || !this.rangeEl || !this.fillEl) return; - const { width: rangeWidth, x: rangeX } = this.rangeEl.nativeElement.getBoundingClientRect(); - const { x } = this.pointEl.nativeElement.getBoundingClientRect(); - const stepIdx = this.getStepIdxFromX(rangeWidth, x - rangeX); - this.value = this.nums[stepIdx]; - - this.setElements(); - } - - /** Установка позиции элементов слайдера */ - setElements() { - if (!this.pointEl || !this.fillEl) return; - - this.pointEl.nativeElement.style.left = `${this.getButtonCoordinate()}%`; - this.fillEl.nativeElement.style.width = `${this.getButtonCoordinate()}%`; - } - - /** Завершение движения слайдера */ - private stopMoving() { - this.mousePressed = false; - this.onChange(this.value ?? 0); - this.appValueChange.emit(this.value ?? 0); - } -} diff --git a/projects/social_platform/src/app/ui/components/range-input/range-input.component.html b/projects/social_platform/src/app/ui/components/range-input/range-input.component.html deleted file mode 100644 index 3a12d7792..000000000 --- a/projects/social_platform/src/app/ui/components/range-input/range-input.component.html +++ /dev/null @@ -1,34 +0,0 @@ - - -
    - - - - - - - - - - -
    diff --git a/projects/social_platform/src/app/ui/components/range-input/range-input.component.scss b/projects/social_platform/src/app/ui/components/range-input/range-input.component.scss deleted file mode 100644 index f9f3601ab..000000000 --- a/projects/social_platform/src/app/ui/components/range-input/range-input.component.scss +++ /dev/null @@ -1,51 +0,0 @@ -.range { - display: flex; - gap: 10px; - align-items: center; - width: 100%; - color: var(--black); - - label { - margin-right: 5px; - color: var(--gray); - } - - &__divider { - flex-shrink: 0; - width: 10px; - height: 2px; - margin: 0 10px; - background-color: var(--dark-grey); - } - - &__start, - &__end { - width: 100px; - padding: 8px 10px; - background-color: var(--white); - border: 0.5px solid var(--gray); - border-radius: var(--rounded-lg); - } - - &__point { - font-size: 12px; - outline: none; - transition: all 0.2s; - - &:focus { - border-color: var(--accent); - } - } - - // stylelint-disable property-no-vendor-prefix - input { - &::-webkit-outer-spin-button, - &::-webkit-inner-spin-button { - -webkit-appearance: none; - } - - &[type="number"] { - -moz-appearance: textfield; - } - } -} diff --git a/projects/social_platform/src/app/ui/components/range-input/range-input.component.spec.ts b/projects/social_platform/src/app/ui/components/range-input/range-input.component.spec.ts deleted file mode 100644 index 87565e5be..000000000 --- a/projects/social_platform/src/app/ui/components/range-input/range-input.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** @format */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; - -import { RangeInputComponent } from "./range-input.component"; - -describe("RangeInputComponent", () => { - let component: RangeInputComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [RangeInputComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(RangeInputComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/ui/components/range-input/range-input.component.ts b/projects/social_platform/src/app/ui/components/range-input/range-input.component.ts deleted file mode 100644 index cc5441c3b..000000000 --- a/projects/social_platform/src/app/ui/components/range-input/range-input.component.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** @format */ - -import { ChangeDetectorRef, Component, forwardRef } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; -import { NgxMaskModule } from "ngx-mask"; - -/** - * Компонент для ввода диапазона значений с двумя ползунками. - * Реализует ControlValueAccessor для интеграции с Angular Forms. - * Позволяет выбрать минимальное и максимальное значение в диапазоне. - * - * Возвращает: - * - Кортеж [number, number] с минимальным и максимальным значениями - * - * Функциональность: - * - Два связанных ползунка для выбора диапазона - * - Автоматическое обновление значений при изменении - * - Поддержка маски ввода через NgxMask - */ -@Component({ - selector: "app-range-input", - standalone: true, - imports: [CommonModule, NgxMaskModule], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => RangeInputComponent), - multi: true, - }, - ], - templateUrl: "./range-input.component.html", - styleUrl: "./range-input.component.scss", -}) -export class RangeInputComponent implements ControlValueAccessor { - constructor(private readonly cdref: ChangeDetectorRef) {} - - /** Обработчик изменения левого ползунка (минимальное значение) */ - onInputLeft(event: Event): void { - const target = event.currentTarget as HTMLInputElement; - - const value = target.value; - this.value[0] = Number.parseInt(value); - this.onChange([Number.parseInt(value), this.value[1]]); - } - - /** Обработчик изменения правого ползунка (максимальное значение) */ - onInputRight(event: Event): void { - const target = event.currentTarget as HTMLInputElement; - - const value = target.value; - this.value[1] = Number.parseInt(value); - this.onChange([this.value[0], Number.parseInt(value)]); - } - - /** Обработчик потери фокуса */ - onBlur(): void { - this.onTouch(); - } - - /** Текущее значение диапазона [мин, макс] */ - value: [number, number] = [0, 0]; - - // Методы ControlValueAccessor - writeValue(value: [number, number]): void { - setTimeout(() => { - this.value = value; - this.cdref.detectChanges(); - }); - } - - onChange: (value: [number, number]) => void = () => {}; - - registerOnChange(fn: (v: [number, number]) => void): void { - this.onChange = fn; - } - - onTouch: () => void = () => {}; - - registerOnTouched(fn: () => void): void { - this.onTouch = fn; - } - - /** Состояние блокировки */ - disabled = false; - - setDisabledState(isDisabled: boolean) { - this.disabled = isDisabled; - } -} diff --git a/projects/social_platform/src/app/ui/components/search/search.component.html b/projects/social_platform/src/app/ui/components/search/search.component.html deleted file mode 100644 index 632db1087..000000000 --- a/projects/social_platform/src/app/ui/components/search/search.component.html +++ /dev/null @@ -1,26 +0,0 @@ - - - diff --git a/projects/social_platform/src/app/ui/components/select/select.component.html b/projects/social_platform/src/app/ui/components/select/select.component.html deleted file mode 100644 index 45dc784ca..000000000 --- a/projects/social_platform/src/app/ui/components/select/select.component.html +++ /dev/null @@ -1,48 +0,0 @@ - - -
    -
    - {{ - selectedId === -1 - ? "Ничего" - : selectedId || selectedId === 0 - ? getLabel(selectedId) || placeholder - : placeholder - }} - @if (error) { - - } @else { - - } -
    - @if (!isDisabled) { @if (isOpen) { -
      - @for (option of options; track option.id; let index = $index) { -
    • - {{ option.label }} - -
    • - } -
    - } } -
    diff --git a/projects/social_platform/src/app/ui/components/select/select.component.ts b/projects/social_platform/src/app/ui/components/select/select.component.ts deleted file mode 100644 index 28ad1fdb9..000000000 --- a/projects/social_platform/src/app/ui/components/select/select.component.ts +++ /dev/null @@ -1,209 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { - Component, - ElementRef, - forwardRef, - HostListener, - Input, - Renderer2, - ViewChild, -} from "@angular/core"; -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; -import { IconComponent } from "@ui/components"; -import { ClickOutsideModule } from "ng-click-outside"; - -/** - * Компонент выпадающего списка для выбора значения из предустановленных опций. - * Реализует ControlValueAccessor для интеграции с Angular Forms. - * Поддерживает навигацию с клавиатуры и автоматический скролл к выделенному элементу. - * - * Входящие параметры: - * - placeholder: текст подсказки при отсутствии выбора - * - selectedId: ID выбранной опции - * - options: массив опций для выбора с полями value, label, id - * - * Возвращает: - * - Значение выбранной опции через ControlValueAccessor - * - * Функциональность: - * - Навигация стрелками вверх/вниз - * - Выбор по Enter, закрытие по Escape - * - Автоматический скролл к выделенному элементу - * - Закрытие при клике вне компонента - */ -@Component({ - selector: "app-select", - templateUrl: "./select.component.html", - styleUrl: "./select.component.scss", - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => SelectComponent), - multi: true, - }, - ], - standalone: true, - imports: [ClickOutsideModule, IconComponent, CommonModule], -}) -export class SelectComponent implements ControlValueAccessor { - /** Текст подсказки */ - @Input() placeholder = ""; - - /** ID выбранной опции */ - @Input() selectedId?: number; - - @Input() size: "small" | "big" = "small"; - - /** Массив доступных опций */ - @Input({ required: true }) options: { - value: string | number | boolean | null; - label: string; - id: number; - }[] = []; - - @Input() error = false; - - @Input() set isDisabled(value: boolean) { - this.setDisabledState(value); - } - - get isDisabled(): boolean { - return this.disabled; - } - - /** Состояние открытия выпадающего списка */ - isOpen = false; - - /** Индекс подсвеченного элемента при навигации */ - highlightedIndex = -1; - - constructor(private readonly renderer: Renderer2) {} - - /** Ссылка на элемент выпадающего списка */ - @ViewChild("dropdown") dropdown!: ElementRef; - - /** Обработчик клавиатурных событий для навигации */ - @HostListener("document:keydown", ["$event"]) - onKeyDown(event: KeyboardEvent): void { - if (!this.isOpen || this.disabled) { - return; - } - - event.preventDefault(); - - const i = this.highlightedIndex; - - if (event.code === "ArrowUp") { - if (i < 0) this.highlightedIndex = 0; - if (i > 0) this.highlightedIndex--; - } - if (event.code === "ArrowDown") { - if (i < this.options.length - 1) { - this.highlightedIndex++; - } - } - if (event.code === "Enter") { - if (i >= 0) { - this.onUpdate(event, this.options[this.highlightedIndex].id); - } - } - if (event.code === "Escape") { - this.hideDropdown(); - } - - if (this.isOpen) { - setTimeout(() => this.trackHighlightScroll()); - } - } - - /** Автоматический скролл к выделенному элементу */ - trackHighlightScroll(): void { - const ddElem = this.dropdown.nativeElement; - - const highlightedElem = ddElem.children[this.highlightedIndex]; - - const ddBox = ddElem.getBoundingClientRect(); - const optBox = highlightedElem.getBoundingClientRect(); - - if (optBox.bottom > ddBox.bottom) { - const scrollAmount = optBox.bottom - ddBox.bottom + ddElem.scrollTop; - this.renderer.setProperty(ddElem, "scrollTop", scrollAmount); - } else if (optBox.top < ddBox.top) { - const scrollAmount = optBox.top - ddBox.top + ddElem.scrollTop; - this.renderer.setProperty(ddElem, "scrollTop", scrollAmount); - } - } - - // Методы ControlValueAccessor - writeValue(value: number | string) { - if (typeof value === "string") { - // Найти ID по значению или label - this.selectedId = this.getIdByValue(value) || this.getId(value); - } else { - this.selectedId = value; - } - } - - getIdByValue(value: string | number): number | undefined { - return this.options.find(el => el.value === value)?.id; - } - - disabled = false; - - setDisabledState(isDisabled: boolean) { - this.disabled = isDisabled; - } - - onChange: (value: string | number | null | boolean) => void = () => {}; - - registerOnChange(fn: any) { - this.onChange = fn; - } - - onTouched: () => void = () => {}; - - registerOnTouched(fn: any) { - this.onTouched = fn; - } - - /** Обработчик выбора опции */ - onUpdate(event: Event, id: number): void { - event.stopPropagation(); - if (this.disabled) { - return; - } - - this.selectedId = id; - this.onChange(this.getValue(id) ?? this.options[0].value); - - this.hideDropdown(); - } - - /** Получение текста метки по ID опции */ - getLabel(optionId: number): string | undefined { - return this.options.find(el => el.id === optionId)?.label; - } - - /** Получение значения по ID опции */ - getValue(optionId: number): string | number | null | undefined | boolean { - return this.options.find(el => el.id === optionId)?.value; - } - - /** Получение ID по тексту метки */ - getId(label: string): number | undefined { - return this.options.find(el => el.label === label)?.id; - } - - /** Скрытие выпадающего списка */ - hideDropdown() { - this.isOpen = false; - this.highlightedIndex = -1; - } - - /** Обработчик клика вне компонента */ - onClickOutside() { - this.hideDropdown(); - } -} diff --git a/projects/social_platform/src/app/ui/components/snackbar/snackbar.component.html b/projects/social_platform/src/app/ui/components/snackbar/snackbar.component.html deleted file mode 100644 index 04c97ce83..000000000 --- a/projects/social_platform/src/app/ui/components/snackbar/snackbar.component.html +++ /dev/null @@ -1,22 +0,0 @@ - - -
      - @for (snack of snacks; track snack.id) { -
    • - {{ snack.text }} - -
    • - } -
    diff --git a/projects/social_platform/src/app/ui/components/snackbar/snackbar.component.ts b/projects/social_platform/src/app/ui/components/snackbar/snackbar.component.ts deleted file mode 100644 index abb77f14f..000000000 --- a/projects/social_platform/src/app/ui/components/snackbar/snackbar.component.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** @format */ - -import { Component, OnDestroy, OnInit } from "@angular/core"; -import { SnackbarService } from "@ui/services/snackbar.service"; -import { Snack } from "@ui/models/snack.model"; -import { Subscription } from "rxjs"; -import { AnimationService } from "@ui/services/animation.service"; -import { CommonModule } from "@angular/common"; -import { IconComponent } from "@uilib"; - -/** - * Компонент для отображения всплывающих уведомлений (snackbar). - * Подписывается на сервис уведомлений и отображает их с анимацией появления/исчезновения. - * Автоматически скрывает уведомления по истечении заданного времени. - * - * Функциональность: - * - Отображение списка активных уведомлений - * - Автоматическое скрытие по таймауту - * - Анимация появления и исчезновения - * - Возможность ручного закрытия уведомлений - * - * Не принимает входящих параметров - работает через сервис SnackbarService - */ -@Component({ - selector: "app-snackbar", - templateUrl: "./snackbar.component.html", - styleUrl: "./snackbar.component.scss", - animations: [AnimationService.slideInOut], - imports: [CommonModule, IconComponent], - standalone: true, -}) -export class SnackbarComponent implements OnInit, OnDestroy { - constructor(private readonly snackbarService: SnackbarService) {} - - /** Массив активных уведомлений */ - snacks: Snack[] = []; - - /** Подписка на сервис уведомлений */ - snackbar$?: Subscription; - - /** Добавление нового уведомления */ - private addNotification(snack: Snack): void { - this.snacks.push(snack); - - if (snack.timeout !== 0) { - setTimeout(() => this.onClose(snack), snack.timeout); - } - } - - /** Подписка на уведомления при инициализации */ - ngOnInit(): void { - this.snackbar$ = this.snackbarService.snacks.subscribe(snack => this.addNotification(snack)); - } - - /** Отписка от уведомлений при уничтожении */ - ngOnDestroy(): void { - this.snackbar$?.unsubscribe(); - } - - /** Закрытие конкретного уведомления */ - onClose(snack: Snack): void { - this.snacks = this.snacks.filter(({ id }) => id !== snack.id); - } -} diff --git a/projects/social_platform/src/app/ui/components/switch/switch.component.html b/projects/social_platform/src/app/ui/components/switch/switch.component.html deleted file mode 100644 index c45873f27..000000000 --- a/projects/social_platform/src/app/ui/components/switch/switch.component.html +++ /dev/null @@ -1,5 +0,0 @@ - - -
    -
    -
    diff --git a/projects/social_platform/src/app/ui/components/switch/switch.component.ts b/projects/social_platform/src/app/ui/components/switch/switch.component.ts deleted file mode 100644 index e333af049..000000000 --- a/projects/social_platform/src/app/ui/components/switch/switch.component.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** @format */ - -import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; - -/** - * Компонент переключателя (switch) для булевых значений. - * Альтернатива чекбоксу с современным дизайном в виде ползунка. - * - * Входящие параметры: - * - checked: состояние переключателя (включен/выключен) - * - * События: - * - checkedChange: изменение состояния переключателя - * - * Возвращает: - * - boolean значение через событие checkedChange - * - * Использование: - * - Для настроек вкл/выкл - * - Булевых переключателей в формах - */ -@Component({ - selector: "app-switch", - templateUrl: "./switch.component.html", - styleUrl: "./switch.component.scss", - standalone: true, -}) -export class SwitchComponent implements OnInit { - /** Состояние переключателя */ - @Input({ required: true }) checked!: boolean; - - /** Событие изменения состояния */ - @Output() checkedChange = new EventEmitter(); - - ngOnInit(): void {} -} diff --git a/projects/social_platform/src/app/ui/components/tag/tag.component.html b/projects/social_platform/src/app/ui/components/tag/tag.component.html deleted file mode 100644 index a6eac461d..000000000 --- a/projects/social_platform/src/app/ui/components/tag/tag.component.html +++ /dev/null @@ -1,14 +0,0 @@ - - -
    - -
    diff --git a/projects/social_platform/src/app/ui/components/tag/tag.component.scss b/projects/social_platform/src/app/ui/components/tag/tag.component.scss deleted file mode 100644 index a84018718..000000000 --- a/projects/social_platform/src/app/ui/components/tag/tag.component.scss +++ /dev/null @@ -1,97 +0,0 @@ -/** @format */ - -.tag { - display: flex; - align-items: center; - justify-content: center; - width: 100%; - max-width: 300px; - padding: 2px 20px; - overflow: hidden; - color: var(--accent); - text-overflow: ellipsis; - white-space: nowrap; - border-radius: var(--rounded-xxl); - - &--inline { - color: var(--white); - border: 0.5px solid transparent; - - &.tag--primary { - color: var(--white); - background-color: var(--accent); - } - - &.tag--secondary { - background: var(--dark-grey); - border: 0.5px solid var(--grey-for-text); - } - - &.tag--accent { - color: var(--white); - background-color: var(--accent); - } - - &.tag--complete { - color: var(--white); - background-color: var(--green); - } - - &.tag--soft { - color: var(--light-white); - background: var(--gold); - border: transparent; - } - - &:not(:last-child) { - margin-right: 5px; - } - } - - &--outline { - color: var(--accent); - background: transparent; - border: 0.5px solid var(--accent); - - &.tag--primary { - color: var(--accent); - border: 0.5px solid var(--accent); - } - - &.tag--secondary { - color: var(--grey-for-text); - background: transparent; - border: 0.5px solid var(--dark-grey); - } - - &.tag--accent { - color: var(--accent); - background: transparent; - border: 0.5px solid var(--accent); - } - - &.tag--complete { - color: var(--green); - background: transparent; - border: 0.5px solid var(--green); - } - - &.tag--soft { - color: var(--gold); - background: transparent; - border: 0.5px solid var(--gold); - } - - &:not(:last-child) { - margin-right: 5px; - } - } - - ::ng-deep { - >* { - &:not(:last-child) { - margin-right: 10px; - } - } - } -} diff --git a/projects/social_platform/src/app/ui/components/tag/tag.component.ts b/projects/social_platform/src/app/ui/components/tag/tag.component.ts deleted file mode 100644 index 44cc9d36d..000000000 --- a/projects/social_platform/src/app/ui/components/tag/tag.component.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** @format */ - -import { Component, Input, OnInit } from "@angular/core"; - -/** - * Компонент тега для отображения статусов, категорий или меток. - * Поддерживает различные цветовые схемы для визуального разделения типов. - * - * Входящие параметры: - * - color: цветовая схема тега ("primary" | "accent" | "complete") - * - * Использование: - * - Отображение статусов задач, заказов - * - Категоризация контента - * - Визуальные метки и индикаторы - * - Контент передается через ng-content - */ -@Component({ - selector: "app-tag", - templateUrl: "./tag.component.html", - styleUrl: "./tag.component.scss", - standalone: true, -}) -export class TagComponent implements OnInit { - constructor() {} - - /** Цветовая схема тега */ - @Input() color: "primary" | "secondary" | "accent" | "complete" | "soft" = "primary"; - - /** Стиль отображения */ - @Input() appearance: "inline" | "outline" = "inline"; - - ngOnInit(): void {} -} diff --git a/projects/social_platform/src/app/ui/components/textarea/textarea.component.html b/projects/social_platform/src/app/ui/components/textarea/textarea.component.html deleted file mode 100644 index f8e872636..000000000 --- a/projects/social_platform/src/app/ui/components/textarea/textarea.component.html +++ /dev/null @@ -1,45 +0,0 @@ - - -
    -
    - -
    - - @if (maxLength) { -
    -

    - {{ - value.length - }} - / {{ maxLength }} -

    -
    - } @if (error) { - - } @else if (haveHint && tooltipText) { -
    - -
    - } -
    diff --git a/projects/social_platform/src/app/ui/components/tooltip/tooltip.component.html b/projects/social_platform/src/app/ui/components/tooltip/tooltip.component.html deleted file mode 100644 index 6d29f7c44..000000000 --- a/projects/social_platform/src/app/ui/components/tooltip/tooltip.component.html +++ /dev/null @@ -1,21 +0,0 @@ - - - - - {{ text }} - diff --git a/projects/social_platform/src/app/ui/components/tooltip/tooltip.component.ts b/projects/social_platform/src/app/ui/components/tooltip/tooltip.component.ts deleted file mode 100644 index db0c172bd..000000000 --- a/projects/social_platform/src/app/ui/components/tooltip/tooltip.component.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** @format */ - -import { CommonModule } from "@angular/common"; -import { Component, Input, Output, EventEmitter } from "@angular/core"; -import { IconComponent } from "@ui/components"; - -/** - * Переиспользуемый компонент подсказки с иконкой - * - * Входящие параметры: - * - text: текст подсказки - * - isVisible: состояние видимости подсказки - * - position: позиция подсказки относительно иконки - * - iconSize: размер иконки подсказки - * - tooltipWidth: ширина блока подсказки - * - customClass: дополнительные CSS классы - * - * События: - * - show: показать подсказку - * - hide: скрыть подсказку - */ -@Component({ - selector: "app-tooltip", - templateUrl: "./tooltip.component.html", - styleUrl: "./tooltip.component.scss", - standalone: true, - imports: [CommonModule, IconComponent], -}) -export class TooltipComponent { - /** Текст подсказки */ - @Input() text = ""; - - /** Состояние видимости */ - @Input() isVisible = false; - - /** Позиция подсказки */ - @Input() position: "left" | "right" = "right"; - - /** Размер иконки */ - @Input() iconSize = "16"; - - /** Ширина подсказки */ - @Input() tooltipWidth = 250; - - /** Дополнительные CSS классы */ - @Input() customClass = ""; - - /** Цвет иконки */ - @Input() color: "accent" | "grey" = "accent"; - - /** Событие показа подсказки */ - @Output() show = new EventEmitter(); - - /** Событие скрытия подсказки */ - @Output() hide = new EventEmitter(); -} diff --git a/projects/social_platform/src/app/ui/components/upload-file/upload-file.component.html b/projects/social_platform/src/app/ui/components/upload-file/upload-file.component.html deleted file mode 100644 index fe6a03023..000000000 --- a/projects/social_platform/src/app/ui/components/upload-file/upload-file.component.html +++ /dev/null @@ -1,24 +0,0 @@ - - -
    - @if (loading) { - - } @else if (!value) { -
    - - -
    - } @else { -
    -
    - -

    Файл успешно загружен

    -
    - -
    - } -
    diff --git a/projects/social_platform/src/app/ui/components/upload-file/upload-file.component.ts b/projects/social_platform/src/app/ui/components/upload-file/upload-file.component.ts deleted file mode 100644 index 2c8223666..000000000 --- a/projects/social_platform/src/app/ui/components/upload-file/upload-file.component.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** @format */ - -import { Component, EventEmitter, forwardRef, Input, OnInit, Output } from "@angular/core"; -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; -import { FileService } from "@core/services/file.service"; -import { nanoid } from "nanoid"; -import { IconComponent } from "@ui/components"; -import { SlicePipe } from "@angular/common"; -import { LoaderComponent } from "../loader/loader.component"; - -/** - * Компонент для загрузки файлов с предварительным просмотром. - * Реализует ControlValueAccessor для интеграции с Angular Forms. - * Поддерживает ограничения по типу файлов и показывает состояние загрузки. - * - * Входящие параметры: - * - accept: ограничения по типу файлов (MIME-типы) - * - error: состояние ошибки для стилизации - * - * Возвращает: - * - URL загруженного файла через ControlValueAccessor - * - * Функциональность: - * - Drag & drop и выбор файлов через диалог - * - Предварительный просмотр выбранного файла - * - Индикатор загрузки - * - Возможность удаления загруженного файла - */ -@Component({ - selector: "app-upload-file", - templateUrl: "./upload-file.component.html", - styleUrl: "./upload-file.component.scss", - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => UploadFileComponent), - multi: true, - }, - ], - standalone: true, - imports: [IconComponent, LoaderComponent], -}) -export class UploadFileComponent implements OnInit, ControlValueAccessor { - constructor(private fileService: FileService) {} - - /** Ограничения по типу файлов */ - @Input() accept = ""; - - /** Состояние ошибки */ - @Input() error = false; - - /** Режим: после загрузки сбросить в пустое состояние и не показывать "файл успешно загружен" */ - @Input() resetAfterUpload = false; - - /** Событие с данными загруженного файла (url + метаданные оригинального файла) */ - @Output() uploaded = new EventEmitter<{ - url: string; - name: string; - size: number; - mimeType: string; - }>(); - - ngOnInit(): void {} - - /** Уникальный ID для элемента input */ - controlId = nanoid(3); - - /** URL загруженного файла */ - value = ""; - - // Методы ControlValueAccessor - writeValue(url: string) { - this.value = url; - } - - onTouch: () => void = () => {}; - - registerOnTouched(fn: any) { - this.onTouch = fn; - } - - onChange: (url: string) => void = () => {}; - - registerOnChange(fn: any) { - this.onChange = fn; - } - - /** Состояние загрузки */ - loading = false; - - /** Обработчик загрузки файла */ - onUpdate(event: Event): void { - const input = event.currentTarget as HTMLInputElement; - const files = input.files; - if (!files?.length) { - return; - } - - const originalFile = files[0]; - this.loading = true; - - this.fileService.uploadFile(originalFile).subscribe(res => { - this.loading = false; - - if (this.resetAfterUpload) { - this.uploaded.emit({ - url: res.url, - name: originalFile.name, - size: originalFile.size, - mimeType: originalFile.type, - }); - input.value = ""; - } else { - this.value = res.url; - this.onChange(res.url); - } - }); - } - - /** Обработчик удаления файла */ - onRemove(): void { - this.fileService.deleteFile(this.value).subscribe({ - next: () => { - this.value = ""; - - this.onTouch(); - this.onChange(""); - }, - error: () => { - this.value = ""; - - this.onTouch(); - this.onChange(""); - }, - }); - } -} diff --git a/projects/social_platform/src/app/ui/models/snack.model.ts b/projects/social_platform/src/app/ui/models/snack.model.ts deleted file mode 100644 index c411c6715..000000000 --- a/projects/social_platform/src/app/ui/models/snack.model.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** @format */ - -/** - * Модель для уведомлений snackbar. - * Определяет структуру данных для всплывающих уведомлений в приложении. - * - * Свойства: - * - id: уникальный идентификатор уведомления - * - text: текст уведомления для отображения - * - timeout: время отображения в миллисекундах - * - type: тип уведомления (ошибка, успех, информация) - */ -export class Snack { - /** Уникальный идентификатор уведомления */ - id!: string; - - /** Текст уведомления */ - text!: string; - - /** Время отображения в миллисекундах */ - timeout!: number; - - /** Тип уведомления */ - type!: "error" | "success" | "info"; -} diff --git a/projects/social_platform/src/app/auth/auth.component.html b/projects/social_platform/src/app/ui/pages/auth/auth.component.html similarity index 100% rename from projects/social_platform/src/app/auth/auth.component.html rename to projects/social_platform/src/app/ui/pages/auth/auth.component.html diff --git a/projects/social_platform/src/app/auth/auth.component.scss b/projects/social_platform/src/app/ui/pages/auth/auth.component.scss similarity index 100% rename from projects/social_platform/src/app/auth/auth.component.scss rename to projects/social_platform/src/app/ui/pages/auth/auth.component.scss diff --git a/projects/social_platform/src/app/auth/auth.component.spec.ts b/projects/social_platform/src/app/ui/pages/auth/auth.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/auth/auth.component.spec.ts rename to projects/social_platform/src/app/ui/pages/auth/auth.component.spec.ts diff --git a/projects/social_platform/src/app/ui/pages/auth/auth.component.ts b/projects/social_platform/src/app/ui/pages/auth/auth.component.ts new file mode 100644 index 000000000..1e16857dc --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/auth/auth.component.ts @@ -0,0 +1,18 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, OnInit } from "@angular/core"; +import { RouterOutlet } from "@angular/router"; + +/** Корневой компонент страниц аутентификации с router-outlet. */ +@Component({ + selector: "app-auth", + templateUrl: "./auth.component.html", + styleUrl: "./auth.component.scss", + imports: [RouterOutlet], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AuthComponent implements OnInit { + constructor() {} + + ngOnInit(): void {} +} diff --git a/projects/social_platform/src/app/auth/confirm-email/confirm-email.component.html b/projects/social_platform/src/app/ui/pages/auth/confirm-email/confirm-email.component.html similarity index 100% rename from projects/social_platform/src/app/auth/confirm-email/confirm-email.component.html rename to projects/social_platform/src/app/ui/pages/auth/confirm-email/confirm-email.component.html diff --git a/projects/social_platform/src/app/auth/confirm-email/confirm-email.component.scss b/projects/social_platform/src/app/ui/pages/auth/confirm-email/confirm-email.component.scss similarity index 100% rename from projects/social_platform/src/app/auth/confirm-email/confirm-email.component.scss rename to projects/social_platform/src/app/ui/pages/auth/confirm-email/confirm-email.component.scss diff --git a/projects/social_platform/src/app/ui/pages/auth/confirm-email/confirm-email.component.spec.ts b/projects/social_platform/src/app/ui/pages/auth/confirm-email/confirm-email.component.spec.ts new file mode 100644 index 000000000..55192904c --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/auth/confirm-email/confirm-email.component.spec.ts @@ -0,0 +1,53 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { ConfirmEmailComponent } from "./confirm-email.component"; +import { AuthRepository } from "@infrastructure/repository/auth/auth.repository"; +import { provideRouter } from "@angular/router"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { of } from "rxjs"; +import { API_URL, PRODUCTION } from "@corelib"; +import { TokenService } from "@corelib"; + +describe("ConfirmEmailComponent", () => { + let component: ConfirmEmailComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const authSpy = { getTokens: vi.fn(), memTokens: vi.fn() }; + const authPortSpy = { + login: of({} as any), + logout: of(undefined), + fetchProfile: of({} as any), + fetchUserRoles: of([]), + fetchChangeableRoles: of([]), + fetchLeaderProjects: of({} as any), + resendEmail: of({} as any), + }; + const tokenSpy = { getTokens: vi.fn().mockReturnValue(null), memTokens: vi.fn() }; + + await TestBed.configureTestingModule({ + imports: [ConfirmEmailComponent, HttpClientTestingModule], + providers: [ + { provide: AuthRepository, useValue: authSpy }, + { provide: AuthRepositoryPort, useValue: authPortSpy }, + { provide: TokenService, useValue: tokenSpy }, + { provide: API_URL, useValue: "" }, + { provide: PRODUCTION, useValue: false }, + provideRouter([]), + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ConfirmEmailComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/auth/confirm-email/confirm-email.component.ts b/projects/social_platform/src/app/ui/pages/auth/confirm-email/confirm-email.component.ts new file mode 100644 index 000000000..9725c6aca --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/auth/confirm-email/confirm-email.component.ts @@ -0,0 +1,22 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, inject, OnInit } from "@angular/core"; +import { AuthEmailService } from "@api/auth/facades/auth-email.service"; + +/** Обрабатывает подтверждение email через токены из URL. */ +@Component({ + selector: "app-confirm-email", + templateUrl: "./confirm-email.component.html", + styleUrl: "./confirm-email.component.scss", + providers: [AuthEmailService], + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ConfirmEmailComponent implements OnInit { + private readonly authEmailService = inject(AuthEmailService); + + ngOnInit(): void { + this.authEmailService.initializationTokens(); + } +} diff --git a/projects/social_platform/src/app/auth/confirm-password-reset/confirm-password-reset.component.html b/projects/social_platform/src/app/ui/pages/auth/confirm-password-reset/confirm-password-reset.component.html similarity index 100% rename from projects/social_platform/src/app/auth/confirm-password-reset/confirm-password-reset.component.html rename to projects/social_platform/src/app/ui/pages/auth/confirm-password-reset/confirm-password-reset.component.html diff --git a/projects/social_platform/src/app/auth/confirm-password-reset/confirm-password-reset.component.scss b/projects/social_platform/src/app/ui/pages/auth/confirm-password-reset/confirm-password-reset.component.scss similarity index 100% rename from projects/social_platform/src/app/auth/confirm-password-reset/confirm-password-reset.component.scss rename to projects/social_platform/src/app/ui/pages/auth/confirm-password-reset/confirm-password-reset.component.scss diff --git a/projects/social_platform/src/app/ui/pages/auth/confirm-password-reset/confirm-password-reset.component.spec.ts b/projects/social_platform/src/app/ui/pages/auth/confirm-password-reset/confirm-password-reset.component.spec.ts new file mode 100644 index 000000000..c5d3f409b --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/auth/confirm-password-reset/confirm-password-reset.component.spec.ts @@ -0,0 +1,48 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { ConfirmPasswordResetComponent } from "./confirm-password-reset.component"; +import { provideRouter } from "@angular/router"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { of } from "rxjs"; +import { AuthUIInfoService } from "@api/auth/facades/ui/auth-ui-info.service"; +import { PRODUCTION } from "@corelib"; + +describe("ConfirmPasswordResetComponent", () => { + let component: ConfirmPasswordResetComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const authPortSpy = { + login: of({} as any), + logout: of(undefined), + fetchProfile: of({} as any), + fetchUserRoles: of([]), + fetchChangeableRoles: of([]), + fetchLeaderProjects: of({} as any), + resetPassword: of(undefined), + setPassword: of(undefined), + }; + + await TestBed.configureTestingModule({ + imports: [ConfirmPasswordResetComponent], + providers: [ + { provide: AuthRepositoryPort, useValue: authPortSpy }, + { provide: AuthUIInfoService, useValue: {} }, + { provide: PRODUCTION, useValue: false }, + provideRouter([]), + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ConfirmPasswordResetComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/auth/confirm-password-reset/confirm-password-reset.component.ts b/projects/social_platform/src/app/ui/pages/auth/confirm-password-reset/confirm-password-reset.component.ts new file mode 100644 index 000000000..5b96be168 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/auth/confirm-password-reset/confirm-password-reset.component.ts @@ -0,0 +1,22 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, inject, OnInit } from "@angular/core"; +import { AsyncPipe } from "@angular/common"; +import { AuthPasswordService } from "@api/auth/facades/auth-password.service"; + +/** Страница подтверждения отправки письма для сброса пароля. */ +@Component({ + selector: "app-confirm-password-reset", + templateUrl: "./confirm-password-reset.component.html", + styleUrl: "./confirm-password-reset.component.scss", + providers: [AuthPasswordService], + imports: [AsyncPipe], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ConfirmPasswordResetComponent implements OnInit { + private readonly authPasswordService = inject(AuthPasswordService); + + protected readonly email = this.authPasswordService.email; + + ngOnInit(): void {} +} diff --git a/projects/social_platform/src/app/ui/pages/auth/email-verification/email-verification.component.html b/projects/social_platform/src/app/ui/pages/auth/email-verification/email-verification.component.html new file mode 100644 index 000000000..d645335ef --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/auth/email-verification/email-verification.component.html @@ -0,0 +1,41 @@ + + +
    + +
    +
    +
    +

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

    +

    + Для продолжения регистрации и заполнения резюме подтверди свою почту, для этого мы + отправили тебе письмо со ссылкой подтверждения +

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

    Не получил письмо? Проверь папку спам или

    + +
    + } +
    + email +
    +
    diff --git a/projects/social_platform/src/app/auth/email-verification/email-verification.component.scss b/projects/social_platform/src/app/ui/pages/auth/email-verification/email-verification.component.scss similarity index 100% rename from projects/social_platform/src/app/auth/email-verification/email-verification.component.scss rename to projects/social_platform/src/app/ui/pages/auth/email-verification/email-verification.component.scss diff --git a/projects/social_platform/src/app/ui/pages/auth/email-verification/email-verification.component.spec.ts b/projects/social_platform/src/app/ui/pages/auth/email-verification/email-verification.component.spec.ts new file mode 100644 index 000000000..6fae14c96 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/auth/email-verification/email-verification.component.spec.ts @@ -0,0 +1,50 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { EmailVerificationComponent } from "./email-verification.component"; +import { AuthRepository } from "@infrastructure/repository/auth/auth.repository"; +import { provideRouter } from "@angular/router"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { of } from "rxjs"; +import { API_URL, PRODUCTION } from "@corelib"; + +describe("EmailVerificationComponent", () => { + let component: EmailVerificationComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const authSpy = { memTokens: vi.fn() }; + const authPortSpy = { + login: of({} as any), + logout: of(undefined), + fetchProfile: of({} as any), + fetchUserRoles: of([]), + fetchChangeableRoles: of([]), + fetchLeaderProjects: of({} as any), + resendEmail: of({} as any), + }; + + await TestBed.configureTestingModule({ + imports: [EmailVerificationComponent, HttpClientTestingModule], + providers: [ + { provide: AuthRepository, useValue: authSpy }, + { provide: AuthRepositoryPort, useValue: authPortSpy }, + { provide: API_URL, useValue: "" }, + { provide: PRODUCTION, useValue: false }, + provideRouter([]), + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(EmailVerificationComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/auth/email-verification/email-verification.component.ts b/projects/social_platform/src/app/ui/pages/auth/email-verification/email-verification.component.ts new file mode 100644 index 000000000..4a08a1c5b --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/auth/email-verification/email-verification.component.ts @@ -0,0 +1,31 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, inject, OnInit } from "@angular/core"; +import { IconComponent } from "@ui/primitives"; +import { AuthEmailService } from "@api/auth/facades/auth-email.service"; +import { CommonModule } from "@angular/common"; + +/** Страница ожидания подтверждения email с возможностью повторной отправки. */ +@Component({ + selector: "app-email-verification", + templateUrl: "./email-verification.component.html", + styleUrl: "./email-verification.component.scss", + providers: [AuthEmailService], + imports: [CommonModule, IconComponent], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EmailVerificationComponent implements OnInit { + private readonly authEmailService = inject(AuthEmailService); + + protected readonly counter = this.authEmailService.counter; + + ngOnInit(): void { + this.authEmailService.initializationEmail(); + + this.authEmailService.initializationTimer(); + } + + onResend(): void { + this.authEmailService.onResend(); + } +} diff --git a/projects/social_platform/src/app/ui/pages/auth/login/login.component.html b/projects/social_platform/src/app/ui/pages/auth/login/login.component.html new file mode 100644 index 000000000..8af73ee84 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/auth/login/login.component.html @@ -0,0 +1,169 @@ + + + diff --git a/projects/social_platform/src/app/auth/login/login.component.scss b/projects/social_platform/src/app/ui/pages/auth/login/login.component.scss similarity index 100% rename from projects/social_platform/src/app/auth/login/login.component.scss rename to projects/social_platform/src/app/ui/pages/auth/login/login.component.scss diff --git a/projects/social_platform/src/app/ui/pages/auth/login/login.component.spec.ts b/projects/social_platform/src/app/ui/pages/auth/login/login.component.spec.ts new file mode 100644 index 000000000..0ee8dea94 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/auth/login/login.component.spec.ts @@ -0,0 +1,64 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { LoginComponent } from "./login.component"; +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { AuthRepository } from "@infrastructure/repository/auth/auth.repository"; +import { InputComponent } from "@ui/primitives"; +import { provideNgxMask } from "ngx-mask"; +import { provideRouter } from "@angular/router"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { ProjectSubscriptionRepositoryPort } from "@domain/project/ports/project-subscription.repository.port"; +import { of } from "rxjs"; +import { API_URL, PRODUCTION } from "@corelib"; + +describe("LoginComponent", () => { + let component: LoginComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const authSpy = { login: vi.fn(), memTokens: vi.fn(), clearTokens: vi.fn() }; + const authPortSpy = { + login: of({} as any), + logout: of(undefined), + fetchProfile: of({} as any), + fetchUserRoles: of([]), + fetchChangeableRoles: of([]), + fetchLeaderProjects: of({} as any), + }; + + await TestBed.configureTestingModule({ + imports: [ + FormsModule, + ReactiveFormsModule, + LoginComponent, + InputComponent, + HttpClientTestingModule, + ], + providers: [ + { provide: AuthRepository, useValue: authSpy }, + { provide: AuthRepositoryPort, useValue: authPortSpy }, + { provide: API_URL, useValue: "" }, + { provide: PRODUCTION, useValue: false }, + { + provide: ProjectSubscriptionRepositoryPort, + useValue: { getSubscriptions: of({ results: [], count: 0 }) }, + }, + provideRouter([]), + provideNgxMask(), + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(LoginComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/auth/login/login.component.ts b/projects/social_platform/src/app/ui/pages/auth/login/login.component.ts new file mode 100644 index 000000000..797df8cce --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/auth/login/login.component.ts @@ -0,0 +1,70 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, computed, inject, OnInit } from "@angular/core"; +import { ReactiveFormsModule } from "@angular/forms"; +import { ErrorMessage } from "@core/lib/models/error/error-message"; +import { ControlErrorPipe, TokenService } from "@corelib"; +import { RouterLink } from "@angular/router"; +import { ButtonComponent, IconComponent, InputComponent } from "@ui/primitives"; +import { CommonModule } from "@angular/common"; +import { TooltipComponent } from "@ui/primitives/tooltip/tooltip.component"; +import { ClickOutsideModule } from "ng-click-outside"; +import { AuthUIInfoService } from "@api/auth/facades/ui/auth-ui-info.service"; +import { AuthLoginService } from "@api/auth/facades/auth-login.service"; +import { TooltipInfoService } from "@api/tooltip/tooltip-info.service"; +import { AppRoutes } from "@api/paths/app-routes"; + +/** Форма входа пользователя в систему. */ +@Component({ + selector: "app-login", + templateUrl: "./login.component.html", + styleUrl: "./login.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + ReactiveFormsModule, + RouterLink, + InputComponent, + ButtonComponent, + IconComponent, + ControlErrorPipe, + TooltipComponent, + ClickOutsideModule, + ], + providers: [AuthLoginService, AuthUIInfoService, TooltipInfoService], +}) +export class LoginComponent implements OnInit { + private readonly authLoginService = inject(AuthLoginService); + private readonly authUIInfoService = inject(AuthUIInfoService); + private readonly tokenService = inject(TokenService); + private readonly tooltipInfoService = inject(TooltipInfoService); + + protected readonly loginForm = this.authUIInfoService.loginForm; + protected readonly loginIsSubmitting = this.authUIInfoService.loginIsSubmitting; + + protected readonly errorWrongAuth = this.authUIInfoService.errorWrongAuth; + + protected readonly errorMessage = ErrorMessage; + + protected readonly showPassword = this.authUIInfoService.showPassword; + protected readonly isHintLoginVisible = computed(() => + this.tooltipInfoService.isVisible("login"), + ); + protected readonly AppRoutes = AppRoutes; + + ngOnInit(): void { + this.tokenService.clearTokens(); + } + + toggleTooltip(): void { + this.tooltipInfoService.toggleTooltip("login"); + } + + toggleShowPassword() { + this.authUIInfoService.toggleShowPassword("login"); + } + + onSubmit() { + this.authLoginService.onSubmit(); + } +} diff --git a/projects/social_platform/src/app/ui/pages/auth/register/register.component.html b/projects/social_platform/src/app/ui/pages/auth/register/register.component.html new file mode 100644 index 000000000..3df853cf0 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/auth/register/register.component.html @@ -0,0 +1,359 @@ + + +
    +
    + +
    +
    + +
    + @if (registerForm.get("firstName"); as name) { +
    + + + @if ((name | controlError) && registerIsSubmitting()) { + + @if (name | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } + @if (name | controlError: "invalidLanguage") { +
    + {{ errorMessage.VALIDATION_LANGUAGE }} +
    + } +
    + } +
    + } + @if (registerForm.get("lastName"); as surname) { +
    + + + @if ((surname | controlError) && registerIsSubmitting()) { + + @if (surname | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } + @if (surname | controlError: "invalidLanguage") { +
    + {{ errorMessage.VALIDATION_LANGUAGE }} +
    + } +
    + } +
    + } +
    + + @if (registerForm.get("birthday"); as birthday) { +
    + + + @if ((birthday | controlError) && registerIsSubmitting()) { + + @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 }} +
    + } +
    + } +
    + } + @if (registerForm.get("phoneNumber"); as phoneNumber) { +
    + + + + @if (phoneNumber | controlError) { + + @if (phoneNumber | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } + @if (phoneNumber | controlError: "pattern") { +
    Неверный формат телефона
    + } +
    + } +
    + } + @if (registerForm.get("email"); as email) { +
    + + + @if ((email | controlError) && registerIsSubmitting()) { + + @if (email | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } + @if (email | controlError: "email") { +
    + {{ errorMessage.VALIDATION_EMAIL }} +
    + } +
    + } +
    + } + @if (registerForm.get("password"); as password) { +
    + + + @if (showPassword()) { + + } @else { + + } + +
    + +
    + + + @if (showPasswordRepeat()) { + + } @else { + + } + +
    + + @if (password | controlError) { + + @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)
    + } + @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 }} +
    + } +
    + } + } +
    + + @if (serverErrors()) { +
    + @for (e of serverErrors(); track $index) { +

    {{ e }}

    + } +
    + } + +
    + + + я прочитал соглашение и даю согласие на + обработку персональных данных + +
    + +
    + + нажимая на кнопку подтверждаете, что вам больше 14 лет +
    + + + зарегистрироваться + + +

    + если есть аккаунт? - войдите +

    + + +
    + + +

    Привет!

    + +

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

    + +

    + Как только твой аккаунт будет подтвержден, ты получишь уведомление на почту. +

    + +

    + Мы уже решаем эту проблему, чтобы сделать использование платформы максимально комфортным + для тебя ❤️ +
    +
    + По любым вопросам пиши в + аккаунт поддержки в Telegram +

    +
    +
    +
    +
    diff --git a/projects/social_platform/src/app/ui/pages/auth/register/register.component.scss b/projects/social_platform/src/app/ui/pages/auth/register/register.component.scss new file mode 100644 index 000000000..7fd2e2a82 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/auth/register/register.component.scss @@ -0,0 +1,105 @@ +/** @format */ + +@use "styles/typography"; +@use "styles/responsive"; + +.register { + height: 100vh; + + &__policy-link { + color: var(--accent); + cursor: pointer; + } + + &__agreement { + display: flex; + align-items: center; + margin-top: 25px; + cursor: pointer; + + @include typography.body-12; + + @include responsive.apply-desktop { + margin-top: 20px; + } + + app-checkbox { + margin-right: 15px; + } + } + + &__greeting { + max-width: 333px; + margin-bottom: 20px; + + @include responsive.apply-desktop { + margin-bottom: 36px; + } + } + + &__repeated-password { + margin-top: 6px; + } + + &__text { + margin-bottom: 30px; + + @include responsive.apply-desktop { + margin-bottom: 20px; + } + } + + &__cross { + position: absolute; + top: 0; + right: 0; + width: 32px; + height: 32px; + cursor: pointer; + + @include responsive.apply-desktop { + top: 8px; + right: 8px; + } + } + + &__title { + margin-bottom: 20px; + color: var(--black); + inline-size: 280px; + + @include typography.heading-3; + + @include responsive.apply-desktop { + inline-size: 500px; + } + } +} + +.cancel { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + width: 100%; + max-width: 600px; + max-height: calc(100vh - 40px); + padding: 20px 0; + + @include responsive.apply-desktop { + padding: 20px 0; + } +} + +.icon { + color: var(--dark-grey); + cursor: pointer; +} + +.error { + color: var(--red) !important; + + i { + color: var(--red) !important; + } +} diff --git a/projects/social_platform/src/app/ui/pages/auth/register/register.component.spec.ts b/projects/social_platform/src/app/ui/pages/auth/register/register.component.spec.ts new file mode 100644 index 000000000..b4a6de347 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/auth/register/register.component.spec.ts @@ -0,0 +1,59 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { RegisterComponent } from "./register.component"; +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { AuthRepository } from "@infrastructure/repository/auth/auth.repository"; +import { InputComponent } from "@ui/primitives"; +import { provideNgxMask } from "ngx-mask"; +import { provideRouter } from "@angular/router"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { of } from "rxjs"; +import { API_URL, PRODUCTION } from "@corelib"; + +describe("RegisterComponent", () => { + let component: RegisterComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const authSpy = { login: vi.fn(), memTokens: vi.fn(), clearTokens: vi.fn() }; + const authPortSpy = { + login: of({} as any), + logout: of(undefined), + fetchProfile: of({} as any), + fetchUserRoles: of([]), + fetchChangeableRoles: of([]), + fetchLeaderProjects: of({} as any), + }; + + return await TestBed.configureTestingModule({ + imports: [ + FormsModule, + ReactiveFormsModule, + RegisterComponent, + InputComponent, + HttpClientTestingModule, + ], + providers: [ + { provide: AuthRepository, useValue: authSpy }, + { provide: AuthRepositoryPort, useValue: authPortSpy }, + { provide: API_URL, useValue: "" }, + { provide: PRODUCTION, useValue: false }, + provideRouter([]), + provideNgxMask(), + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(RegisterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/auth/register/register.component.ts b/projects/social_platform/src/app/ui/pages/auth/register/register.component.ts new file mode 100644 index 000000000..07abe7b42 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/auth/register/register.component.ts @@ -0,0 +1,77 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, inject, OnInit } from "@angular/core"; +import { initial } from "@domain/shared/async-state"; +import { ReactiveFormsModule } from "@angular/forms"; +import { ControlErrorPipe, TokenService } from "@corelib"; +import { ErrorMessage } from "@core/lib/models/error/error-message"; +import { RouterLink } from "@angular/router"; +import { ButtonComponent, CheckboxComponent, InputComponent } from "@ui/primitives"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { IconComponent } from "@uilib"; +import { CommonModule } from "@angular/common"; +import { AuthRegisterService } from "@api/auth/facades/auth-register.service"; +import { AuthUIInfoService } from "@api/auth/facades/ui/auth-ui-info.service"; +import { AppRoutes } from "@api/paths/app-routes"; + +/** Двухэтапная форма регистрации нового пользователя. */ +@Component({ + selector: "app-login", + templateUrl: "./register.component.html", + styleUrl: "./register.component.scss", + imports: [ + CommonModule, + ReactiveFormsModule, + InputComponent, + CheckboxComponent, + ButtonComponent, + ModalComponent, + RouterLink, + IconComponent, + ControlErrorPipe, + ], + providers: [AuthRegisterService, AuthUIInfoService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RegisterComponent implements OnInit { + private readonly authRegisterService = inject(AuthRegisterService); + private readonly authUIInfoService = inject(AuthUIInfoService); + private readonly tokenService = inject(TokenService); + protected readonly AppRoutes = AppRoutes; + + ngOnInit(): void { + this.tokenService.clearTokens(); + } + + protected readonly registerForm = this.authUIInfoService.registerForm; + protected readonly registerIsSubmitting = this.authUIInfoService.registerIsSubmitting; + + protected readonly registerAgreement = this.authUIInfoService.registerAgreement; + protected readonly ageAgreement = this.authUIInfoService.ageAgreement; + + protected readonly showPassword = this.authUIInfoService.showPassword; + protected readonly showPasswordRepeat = this.authUIInfoService.showPasswordRepeat; + + protected readonly isUserCreationModalError = this.authUIInfoService.isUserCreationModalError; + + protected readonly serverErrors = this.authRegisterService.serverErrors; + + protected readonly errorMessage = ErrorMessage; + + toggleShowPassword(type: "repeat" | "first") { + this.authUIInfoService.toggleShowPassword("register", type); + } + + onSendForm(): void { + this.authRegisterService.onSendForm(); + } + + dismissCreationError(): void { + this.authUIInfoService.register$.set(initial()); + } + + downloadPolicy(event: Event): void { + event.stopPropagation(); + this.authRegisterService.downloadPolicy(); + } +} diff --git a/projects/social_platform/src/app/ui/pages/auth/reset-password/reset-password.component.html b/projects/social_platform/src/app/ui/pages/auth/reset-password/reset-password.component.html new file mode 100644 index 000000000..dc8adeb63 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/auth/reset-password/reset-password.component.html @@ -0,0 +1,42 @@ + + +
    +
    +

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

    +

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

    + @if (resetForm.get("email"); as email) { +
    + + @if (email | controlError: "email") { +
    + {{ errorMessage.VALIDATION_EMAIL }} +
    + } + @if (email | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } + @if (errorServer()) { +
    Аккаунт с таким email не зарегистрирован
    + } +
    + } + + Отправить + +
    +
    diff --git a/projects/social_platform/src/app/auth/reset-password/reset-password.component.scss b/projects/social_platform/src/app/ui/pages/auth/reset-password/reset-password.component.scss similarity index 100% rename from projects/social_platform/src/app/auth/reset-password/reset-password.component.scss rename to projects/social_platform/src/app/ui/pages/auth/reset-password/reset-password.component.scss diff --git a/projects/social_platform/src/app/ui/pages/auth/reset-password/reset-password.component.spec.ts b/projects/social_platform/src/app/ui/pages/auth/reset-password/reset-password.component.spec.ts new file mode 100644 index 000000000..e73eb7ca2 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/auth/reset-password/reset-password.component.spec.ts @@ -0,0 +1,50 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { ResetPasswordComponent } from "./reset-password.component"; +import { ReactiveFormsModule } from "@angular/forms"; +import { of } from "rxjs"; +import { AuthRepository } from "@infrastructure/repository/auth/auth.repository"; +import { provideNgxMask } from "ngx-mask"; +import { provideRouter } from "@angular/router"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; + +describe("ResetPasswordComponent", () => { + let component: ResetPasswordComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const authSpy = { resetPassword: vi.fn().mockReturnValue(of({})) }; + const authPortSpy = { + login: of({} as any), + logout: of(undefined), + fetchProfile: of({} as any), + fetchUserRoles: of([]), + fetchChangeableRoles: of([]), + fetchLeaderProjects: of({} as any), + resetPassword: of(undefined), + setPassword: of(undefined), + }; + + await TestBed.configureTestingModule({ + imports: [ReactiveFormsModule, ResetPasswordComponent], + providers: [ + { provide: AuthRepository, useValue: authSpy }, + { provide: AuthRepositoryPort, useValue: authPortSpy }, + provideRouter([]), + provideNgxMask(), + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ResetPasswordComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/auth/reset-password/reset-password.component.ts b/projects/social_platform/src/app/ui/pages/auth/reset-password/reset-password.component.ts new file mode 100644 index 000000000..ff3833591 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/auth/reset-password/reset-password.component.ts @@ -0,0 +1,35 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, inject, OnInit } from "@angular/core"; +import { ReactiveFormsModule } from "@angular/forms"; +import { ErrorMessage } from "@core/lib/models/error/error-message"; +import { ControlErrorPipe } from "@corelib"; +import { ButtonComponent, InputComponent } from "@ui/primitives"; +import { AuthUIInfoService } from "@api/auth/facades/ui/auth-ui-info.service"; +import { AuthPasswordService } from "@api/auth/facades/auth-password.service"; + +/** Форма запроса сброса пароля по email. */ +@Component({ + selector: "app-reset-password", + templateUrl: "./reset-password.component.html", + styleUrl: "./reset-password.component.scss", + providers: [AuthPasswordService, AuthUIInfoService], + imports: [ReactiveFormsModule, InputComponent, ButtonComponent, ControlErrorPipe], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ResetPasswordComponent implements OnInit { + private readonly authUIInfoService = inject(AuthUIInfoService); + private readonly authPasswordService = inject(AuthPasswordService); + + ngOnInit(): void {} + + protected readonly resetForm = this.authUIInfoService.resetForm; + protected readonly isSubmitting = this.authUIInfoService.isSubmitting; + protected readonly errorServer = this.authUIInfoService.errorServer; + + protected readonly errorMessage = ErrorMessage; + + onSubmit(): void { + this.authPasswordService.onSubmitResetPassword(); + } +} diff --git a/projects/social_platform/src/app/ui/pages/auth/set-password/set-password.component.html b/projects/social_platform/src/app/ui/pages/auth/set-password/set-password.component.html new file mode 100644 index 000000000..5ee24640e --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/auth/set-password/set-password.component.html @@ -0,0 +1,178 @@ + + +
    +
    +

    Новый пароль

    +

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

    + @if (passwordForm.get("password"); as password) { +
    + + @if (showPassword()) { + + } @else { + + } + + @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)
    + } + @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 }} +
    + } +
    + } +
    + } + @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)
    + } + @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 }} +
    + } +
    + } +
    + } + @if (errorRequest()) { +
    + {{ errorMessage.AUTH_WRONG_AUTH }} +
    + } + + Готово + +
    +
    diff --git a/projects/social_platform/src/app/auth/set-password/set-password.component.scss b/projects/social_platform/src/app/ui/pages/auth/set-password/set-password.component.scss similarity index 100% rename from projects/social_platform/src/app/auth/set-password/set-password.component.scss rename to projects/social_platform/src/app/ui/pages/auth/set-password/set-password.component.scss diff --git a/projects/social_platform/src/app/ui/pages/auth/set-password/set-password.component.spec.ts b/projects/social_platform/src/app/ui/pages/auth/set-password/set-password.component.spec.ts new file mode 100644 index 000000000..12a7cdbc6 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/auth/set-password/set-password.component.spec.ts @@ -0,0 +1,50 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { SetPasswordComponent } from "./set-password.component"; +import { ReactiveFormsModule } from "@angular/forms"; +import { AuthRepository } from "@infrastructure/repository/auth/auth.repository"; +import { of } from "rxjs"; +import { provideNgxMask } from "ngx-mask"; +import { provideRouter } from "@angular/router"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; + +describe("SetPasswordComponent", () => { + let component: SetPasswordComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const authSpy = { setPassword: vi.fn().mockReturnValue(of({})) }; + const authPortSpy = { + login: of({} as any), + logout: of(undefined), + fetchProfile: of({} as any), + fetchUserRoles: of([]), + fetchChangeableRoles: of([]), + fetchLeaderProjects: of({} as any), + resetPassword: of(undefined), + setPassword: of(undefined), + }; + + await TestBed.configureTestingModule({ + imports: [ReactiveFormsModule, SetPasswordComponent], + providers: [ + { provide: AuthRepository, useValue: authSpy }, + { provide: AuthRepositoryPort, useValue: authPortSpy }, + provideRouter([]), + provideNgxMask(), + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(SetPasswordComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/auth/set-password/set-password.component.ts b/projects/social_platform/src/app/ui/pages/auth/set-password/set-password.component.ts new file mode 100644 index 000000000..37792dd5f --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/auth/set-password/set-password.component.ts @@ -0,0 +1,45 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, inject, OnInit } from "@angular/core"; +import { ReactiveFormsModule } from "@angular/forms"; +import { ControlErrorPipe } from "@corelib"; +import { ErrorMessage } from "@core/lib/models/error/error-message"; +import { ButtonComponent, InputComponent } from "@ui/primitives"; +import { AuthPasswordService } from "@api/auth/facades/auth-password.service"; +import { AuthUIInfoService } from "@api/auth/facades/ui/auth-ui-info.service"; + +/** Форма установки нового пароля после сброса. */ +@Component({ + selector: "app-set-password", + templateUrl: "./set-password.component.html", + styleUrl: "./set-password.component.scss", + providers: [AuthPasswordService, AuthUIInfoService], + imports: [ReactiveFormsModule, InputComponent, ButtonComponent, ControlErrorPipe], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SetPasswordComponent implements OnInit { + private readonly authPasswordService = inject(AuthPasswordService); + private readonly authUIInfoService = inject(AuthUIInfoService); + + protected readonly passwordForm = this.authUIInfoService.passwordForm; + + protected readonly isSubmitting = this.authUIInfoService.isSubmitting; + protected readonly errorRequest = this.authUIInfoService.errorRequest; + protected readonly credsSubmitInitiated = this.authUIInfoService.credsSubmitInitiated; + + protected readonly errorMessage = ErrorMessage; + + protected readonly showPassword = this.authUIInfoService.showPassword; + + ngOnInit(): void { + this.authPasswordService.init(); + } + + toggleShowPassword() { + this.authUIInfoService.toggleShowPassword("login"); + } + + onSubmit() { + this.authPasswordService.onSubmitSetPassword(); + } +} diff --git a/projects/social_platform/src/app/ui/pages/chat/chat-card/chat-card.component.html b/projects/social_platform/src/app/ui/pages/chat/chat-card/chat-card.component.html new file mode 100644 index 000000000..7bd42f0fa --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/chat/chat-card/chat-card.component.html @@ -0,0 +1,35 @@ + + +
    +
    + +
    +
    + {{ chat().name }} +
    + @if (chat().lastMessage && chat().lastMessage.text) { +
    + +
    {{ chat().lastMessage.text }}
    +
    + } +
    +
    + @if (chat().lastMessage && chat().lastMessage.text) { +
    +

    {{ chat().lastMessage.createdAt | dayjs: "format" : "DD MMMM" }}

    + @if (isUnread()) { +
    + } +
    + } +
    diff --git a/projects/social_platform/src/app/office/chat/shared/chat-card/chat-card.component.scss b/projects/social_platform/src/app/ui/pages/chat/chat-card/chat-card.component.scss similarity index 100% rename from projects/social_platform/src/app/office/chat/shared/chat-card/chat-card.component.scss rename to projects/social_platform/src/app/ui/pages/chat/chat-card/chat-card.component.scss diff --git a/projects/social_platform/src/app/ui/pages/chat/chat-card/chat-card.component.ts b/projects/social_platform/src/app/ui/pages/chat/chat-card/chat-card.component.ts new file mode 100644 index 000000000..363393b8a --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/chat/chat-card/chat-card.component.ts @@ -0,0 +1,38 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, input } from "@angular/core"; +import { DayjsPipe } from "@corelib"; +import { AvatarComponent } from "@ui/primitives/avatar/avatar.component"; +import { ChatListItem } from "@domain/chat/chat-item.model"; + +/** + * Компонент карточки чата для отображения в списке чатов + * + * Отображает: + * - Аватар чата/собеседника + * - Название чата + * - Последнее сообщение с аватаром автора + * - Дату последнего сообщения + * - Индикатор непрочитанных сообщений + * + * @selector app-chat-card + * @templateUrl ./chat-card.component.html + * @styleUrl ./chat-card.component.scss + */ +@Component({ + selector: "app-chat-card", + templateUrl: "./chat-card.component.html", + styleUrl: "./chat-card.component.scss", + imports: [AvatarComponent, DayjsPipe], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ChatCardComponent { + /** Данные чата для отображения */ + readonly chat = input.required(); + + /** Флаг последнего элемента в списке (для стилизации) */ + readonly isLast = input(false); + + /** Флаг непрочитанного сообщения — передаётся из фасада через родительский компонент */ + readonly isUnread = input(false); +} diff --git a/projects/social_platform/src/app/ui/pages/chat/chat-direct/chat-direct.component.html b/projects/social_platform/src/app/ui/pages/chat/chat-direct/chat-direct.component.html new file mode 100644 index 000000000..e8ba4a75f --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/chat/chat-direct/chat-direct.component.html @@ -0,0 +1,27 @@ + + +
    + + +
    + @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/ui/pages/chat/chat-direct/chat-direct.component.scss similarity index 100% rename from projects/social_platform/src/app/office/chat/chat-direct/chat-direct/chat-direct.component.scss rename to projects/social_platform/src/app/ui/pages/chat/chat-direct/chat-direct.component.scss diff --git a/projects/social_platform/src/app/ui/pages/chat/chat-direct/chat-direct.component.spec.ts b/projects/social_platform/src/app/ui/pages/chat/chat-direct/chat-direct.component.spec.ts new file mode 100644 index 000000000..3d107f6d4 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/chat/chat-direct/chat-direct.component.spec.ts @@ -0,0 +1,84 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ChatDirectComponent } from "./chat-direct.component"; +import { provideRouter } from "@angular/router"; +import { signal } from "@angular/core"; +import { of } from "rxjs"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { ChatDirectInfoService } from "@api/chat/facades/chat-direct-info.service"; +import { ChatDirectUIInfoService } from "@api/chat/facades/ui/chat-direct-ui-info.service"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { API_URL } from "@corelib"; +import { ProjectSubscriptionRepositoryPort } from "@domain/project/ports/project-subscription.repository.port"; +import { provideNgxMask } from "ngx-mask"; + +describe("ChatDirectComponent", () => { + let component: ChatDirectComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const chatDirectInfoServiceSpy = { + typingPersons: signal([]), + chat: signal(undefined), + messages: signal([]), + initializationChatDirect: vi.fn(), + destroy: vi.fn(), + onSubmitMessage: vi.fn(), + onEditMessage: vi.fn(), + onDeleteMessage: vi.fn(), + onFetchMessages: vi.fn(), + onType: vi.fn(), + onReadMessage: vi.fn(), + }; + + const chatDirectUIInfoServiceSpy = { + currentUserId: signal(undefined), + fetching: signal(false), + }; + + await TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, ChatDirectComponent], + providers: [ + provideRouter([]), + provideNgxMask(), + { + provide: AuthRepositoryPort, + useValue: { + fetchProfile: of({}), + fetchUserRoles: of([]), + fetchChangeableRoles: of([]), + fetchLeaderProjects: of({}), + }, + }, + { provide: API_URL, useValue: "" }, + { + provide: ProjectSubscriptionRepositoryPort, + useValue: { getSubscriptions: of({ results: [], count: 0 }) }, + }, + ], + }) + .overrideComponent(ChatDirectComponent, { + remove: { + providers: [ChatDirectInfoService, ChatDirectUIInfoService], + }, + add: { + providers: [ + { provide: ChatDirectInfoService, useValue: chatDirectInfoServiceSpy }, + { provide: ChatDirectUIInfoService, useValue: chatDirectUIInfoServiceSpy }, + ], + }, + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ChatDirectComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/chat/chat-direct/chat-direct.component.ts b/projects/social_platform/src/app/ui/pages/chat/chat-direct/chat-direct.component.ts new file mode 100644 index 000000000..6d0f8334a --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/chat/chat-direct/chat-direct.component.ts @@ -0,0 +1,116 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, inject, OnDestroy, OnInit } from "@angular/core"; +import { RouterLink } from "@angular/router"; +import { ChatWindowComponent } from "@ui/widgets/chat-window/chat-window.component"; +import { AvatarComponent } from "@ui/primitives/avatar/avatar.component"; +import { BackComponent } from "@uilib"; +import { ChatDirectInfoService } from "@api/chat/facades/chat-direct-info.service"; +import { ChatDirectUIInfoService } from "@api/chat/facades/ui/chat-direct-ui-info.service"; +import { AppRoutes } from "@api/paths/app-routes"; + +/** + * Компонент для отображения конкретного прямого чата + * + * Функциональность: + * - Отображение сообщений чата с пагинацией + * - Отправка, редактирование и удаление сообщений + * - Обработка событий WebSocket (новые сообщения, печатание, редактирование, удаление, прочтение) + * - Индикация печатающих пользователей + * - Прочтение сообщений + * + * @selector app-chat-direct + * @templateUrl ./chat-direct.component.html + * @styleUrl ./chat-direct.component.scss + */ +@Component({ + selector: "app-chat-direct", + templateUrl: "./chat-direct.component.html", + styleUrl: "./chat-direct.component.scss", + imports: [RouterLink, AvatarComponent, ChatWindowComponent, BackComponent], + providers: [ChatDirectInfoService, ChatDirectUIInfoService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ChatDirectComponent implements OnInit, OnDestroy { + private readonly ChatDirectInfoService = inject(ChatDirectInfoService); + private readonly ChatDirectUIInfoService = inject(ChatDirectUIInfoService); + + /** + * Инициализация компонента + * - Загружает данные чата из резолвера + * - Загружает первую порцию сообщений + * - Инициализирует обработчики WebSocket событий + * - Получает ID текущего пользователя + */ + ngOnInit(): void { + this.ChatDirectInfoService.initializationChatDirect("direct"); + } + + /** + * Очистка подписок при уничтожении компонента + */ + ngOnDestroy(): void { + this.ChatDirectInfoService.destroy(); + } + + /** ID текущего пользователя */ + protected readonly currentUserId = this.ChatDirectUIInfoService.currentUserId; + + /** Список пользователей, которые сейчас печатают */ + protected readonly typingPersons = this.ChatDirectInfoService.typingPersons; + + /** Данные текущего чата */ + protected readonly chat = this.ChatDirectInfoService.chat; + + protected readonly AppRoutes = AppRoutes; + + /** Массив сообщений чата */ + protected readonly messages = this.ChatDirectInfoService.messages; + + /** Флаг процесса загрузки сообщений */ + protected readonly fetching = this.ChatDirectUIInfoService.fetching; + + /** + * Обработчик отправки нового сообщения + * @param message - Объект сообщения с текстом, файлами и ответом + */ + onSubmitMessage(message: any): void { + this.ChatDirectInfoService.onSubmitMessage(message); + } + + /** + * Обработчик редактирования сообщения + * @param message - Объект сообщения с новым текстом и ID + */ + onEditMessage(message: any): void { + this.ChatDirectInfoService.onEditMessage(message); + } + + /** + * Обработчик удаления сообщения + * @param messageId - ID удаляемого сообщения + */ + onDeleteMessage(messageId: number): void { + this.ChatDirectInfoService.onDeleteMessage(messageId); + } + + onFetchMessages(): void { + this.ChatDirectInfoService.onFetchMessages(); + } + + /** + * Обработчик события печатания + * Отправляет уведомление о том, что пользователь печатает + */ + onType() { + this.ChatDirectInfoService.onType(); + } + + /** + * Обработчик прочтения сообщения + * @param messageId - ID прочитанного сообщения + */ + onReadMessage(messageId: number) { + this.ChatDirectInfoService.onReadMessage(messageId); + } +} diff --git a/projects/social_platform/src/app/ui/pages/chat/chat-direct/chat-direct.resolver.spec.ts b/projects/social_platform/src/app/ui/pages/chat/chat-direct/chat-direct.resolver.spec.ts new file mode 100644 index 000000000..e239c1e94 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/chat/chat-direct/chat-direct.resolver.spec.ts @@ -0,0 +1,29 @@ +/** @format */ + +import { TestBed } from "@angular/core/testing"; +import { ChatDirectResolver } from "./chat-direct.resolver"; +import { ActivatedRouteSnapshot, RouterStateSnapshot, provideRouter } from "@angular/router"; +import { of } from "rxjs"; +import { ChatGroupsRepositoryPort } from "@domain/chat/ports/chat-groups.port"; + +describe("ChatDirectResolver", () => { + const mockRoute = { params: { chatId: 1 } } as unknown as ActivatedRouteSnapshot; + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideRouter([]), + { + provide: ChatGroupsRepositoryPort, + useValue: { getChats: () => of([]), getChat: () => of({}) }, + }, + ], + }); + }); + + it("should be created", () => { + const result = TestBed.runInInjectionContext(() => + ChatDirectResolver(mockRoute, {} as RouterStateSnapshot), + ); + expect(result).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/chat/chat-direct/chat-direct.resolver.ts b/projects/social_platform/src/app/ui/pages/chat/chat-direct/chat-direct.resolver.ts new file mode 100644 index 000000000..9c461d911 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/chat/chat-direct/chat-direct.resolver.ts @@ -0,0 +1,15 @@ +/** @format */ + +import { inject } from "@angular/core"; +import { ActivatedRouteSnapshot, ResolveFn } from "@angular/router"; +import { ChatItem } from "@domain/chat/chat-item.model"; +import { ChatGroupsRepositoryPort } from "@domain/chat/ports/chat-groups.port"; + +/** Предзагружает данные конкретного прямого чата. */ +export const ChatDirectResolver: ResolveFn = (route: ActivatedRouteSnapshot) => { + const chatGroupsRepository = inject(ChatGroupsRepositoryPort); + + const chatId = route.params["chatId"]; + + return chatGroupsRepository.getChat(chatId); +}; diff --git a/projects/social_platform/src/app/ui/pages/chat/chat-groups.resolver.spec.ts b/projects/social_platform/src/app/ui/pages/chat/chat-groups.resolver.spec.ts new file mode 100644 index 000000000..92cff3b8c --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/chat/chat-groups.resolver.spec.ts @@ -0,0 +1,28 @@ +/** @format */ + +import { TestBed } from "@angular/core/testing"; +import { ChatGroupsResolver } from "./chat-groups.resolver"; +import { ActivatedRouteSnapshot, RouterStateSnapshot, provideRouter } from "@angular/router"; +import { of } from "rxjs"; +import { ChatGroupsRepositoryPort } from "@domain/chat/ports/chat-groups.port"; + +describe("ChatGroupsResolver", () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideRouter([]), + { + provide: ChatGroupsRepositoryPort, + useValue: { getChats: () => of([]), getChat: () => of({}) }, + }, + ], + }); + }); + + it("should be created", () => { + const result = TestBed.runInInjectionContext(() => + ChatGroupsResolver({} as ActivatedRouteSnapshot, {} as RouterStateSnapshot), + ); + expect(result).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/chat/chat-groups.resolver.ts b/projects/social_platform/src/app/ui/pages/chat/chat-groups.resolver.ts new file mode 100644 index 000000000..43af9a8d2 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/chat/chat-groups.resolver.ts @@ -0,0 +1,13 @@ +/** @format */ + +import { inject } from "@angular/core"; +import { ResolveFn } from "@angular/router"; +import { ChatListItem } from "@domain/chat/chat-item.model"; +import { ChatGroupsRepositoryPort } from "@domain/chat/ports/chat-groups.port"; + +/** Предзагружает список групповых чатов пользователя. */ +export const ChatGroupsResolver: ResolveFn = () => { + const chatGroupsRepository = inject(ChatGroupsRepositoryPort); + + return chatGroupsRepository.getChats("projects"); +}; diff --git a/projects/social_platform/src/app/ui/pages/chat/chat.component.html b/projects/social_platform/src/app/ui/pages/chat/chat.component.html new file mode 100644 index 000000000..2a2534da1 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/chat/chat.component.html @@ -0,0 +1,35 @@ + + +
    +
    + + + +
    + +
    + @for (c of chats | async; track c.id; let last = $last) { + + } +
    +
    diff --git a/projects/social_platform/src/app/office/chat/chat.component.scss b/projects/social_platform/src/app/ui/pages/chat/chat.component.scss similarity index 100% rename from projects/social_platform/src/app/office/chat/chat.component.scss rename to projects/social_platform/src/app/ui/pages/chat/chat.component.scss diff --git a/projects/social_platform/src/app/ui/pages/chat/chat.component.spec.ts b/projects/social_platform/src/app/ui/pages/chat/chat.component.spec.ts new file mode 100644 index 000000000..788f4836f --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/chat/chat.component.spec.ts @@ -0,0 +1,52 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ChatComponent } from "./chat.component"; +import { provideRouter } from "@angular/router"; +import { signal } from "@angular/core"; +import { of } from "rxjs"; +import { ChatInfoService } from "@api/chat/facades/chat-info.service"; +import { ChatUIInfoService } from "@api/chat/facades/ui/chat-ui-info.service"; + +describe("ChatComponent", () => { + let component: ChatComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const chatInfoServiceSpy = { + chats: of([]), + initializationChats: vi.fn(), + destroy: vi.fn(), + onGotoChat: vi.fn(), + }; + + const chatUIInfoServiceSpy = { + chatsData: signal([]), + }; + + await TestBed.configureTestingModule({ + imports: [ChatComponent], + providers: [provideRouter([])], + }) + .overrideComponent(ChatComponent, { + remove: { providers: [ChatInfoService, ChatUIInfoService] }, + add: { + providers: [ + { provide: ChatInfoService, useValue: chatInfoServiceSpy }, + { provide: ChatUIInfoService, useValue: chatUIInfoServiceSpy }, + ], + }, + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ChatComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/chat/chat.component.ts b/projects/social_platform/src/app/ui/pages/chat/chat.component.ts new file mode 100644 index 000000000..a7865a8e6 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/chat/chat.component.ts @@ -0,0 +1,45 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, inject, OnDestroy, OnInit } from "@angular/core"; +import { ChatCardComponent } from "./chat-card/chat-card.component"; +import { AsyncPipe } from "@angular/common"; +import { BarComponent } from "@ui/primitives"; +import { BackComponent } from "@uilib"; +import { ChatInfoService } from "@api/chat/facades/chat-info.service"; +import { ChatUIInfoService } from "@api/chat/facades/ui/chat-ui-info.service"; + +/** + * Компонент списка чатов - отображает все чаты пользователя + * Управляет отображением прямых и групповых чатов с сортировкой по непрочитанным + * + * Принимает: + * - Данные чатов через резолвер + * - События новых сообщений через WebSocket + * + * Возвращает: + * - Отсортированный список чатов с индикаторами непрочитанных сообщений + * - Навигацию к конкретным чатам + */ +@Component({ + selector: "app-chat", + templateUrl: "./chat.component.html", + styleUrl: "./chat.component.scss", + imports: [ChatCardComponent, AsyncPipe, BarComponent, BackComponent], + providers: [ChatInfoService, ChatUIInfoService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ChatComponent implements OnInit { + private readonly chatInfoService = inject(ChatInfoService); + private readonly ChatUIInfoService = inject(ChatUIInfoService); + + protected readonly chatsData = this.ChatUIInfoService.chatsData; + protected readonly chats = this.chatInfoService.chats; + + ngOnInit(): void { + this.chatInfoService.initializationChats(); + } + + onGotoChat(id: string | number) { + this.chatInfoService.onGotoChat(id); + } +} diff --git a/projects/social_platform/src/app/ui/pages/chat/chat.resolver.spec.ts b/projects/social_platform/src/app/ui/pages/chat/chat.resolver.spec.ts new file mode 100644 index 000000000..9d647129e --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/chat/chat.resolver.spec.ts @@ -0,0 +1,28 @@ +/** @format */ + +import { TestBed } from "@angular/core/testing"; +import { ChatResolver } from "./chat.resolver"; +import { ActivatedRouteSnapshot, RouterStateSnapshot, provideRouter } from "@angular/router"; +import { of } from "rxjs"; +import { ChatGroupsRepositoryPort } from "@domain/chat/ports/chat-groups.port"; + +describe("ChatResolver", () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideRouter([]), + { + provide: ChatGroupsRepositoryPort, + useValue: { getChats: () => of([]), getChat: () => of({}) }, + }, + ], + }); + }); + + it("should be created", () => { + const result = TestBed.runInInjectionContext(() => + ChatResolver({} as ActivatedRouteSnapshot, {} as RouterStateSnapshot), + ); + expect(result).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/chat/chat.resolver.ts b/projects/social_platform/src/app/ui/pages/chat/chat.resolver.ts new file mode 100644 index 000000000..ac19df554 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/chat/chat.resolver.ts @@ -0,0 +1,13 @@ +/** @format */ + +import { inject } from "@angular/core"; +import { ResolveFn } from "@angular/router"; +import { ChatListItem } from "@domain/chat/chat-item.model"; +import { ChatGroupsRepositoryPort } from "@domain/chat/ports/chat-groups.port"; + +/** Предзагружает список прямых чатов пользователя. */ +export const ChatResolver: ResolveFn = () => { + const chatGroupsRepository = inject(ChatGroupsRepositoryPort); + + return chatGroupsRepository.getChats("direct"); +}; diff --git a/projects/social_platform/src/app/office/courses/courses.component.html b/projects/social_platform/src/app/ui/pages/courses/courses.component.html similarity index 100% rename from projects/social_platform/src/app/office/courses/courses.component.html rename to projects/social_platform/src/app/ui/pages/courses/courses.component.html diff --git a/projects/social_platform/src/app/office/courses/courses.component.scss b/projects/social_platform/src/app/ui/pages/courses/courses.component.scss similarity index 100% rename from projects/social_platform/src/app/office/courses/courses.component.scss rename to projects/social_platform/src/app/ui/pages/courses/courses.component.scss diff --git a/projects/social_platform/src/app/ui/pages/courses/courses.component.ts b/projects/social_platform/src/app/ui/pages/courses/courses.component.ts new file mode 100644 index 000000000..b241d8ef4 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/courses/courses.component.ts @@ -0,0 +1,36 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, inject } from "@angular/core"; +import { RouterModule } from "@angular/router"; +import { BackComponent } from "@uilib"; +import { SearchComponent } from "@ui/primitives/search/search.component"; +import { FormBuilder, FormGroup, ReactiveFormsModule } from "@angular/forms"; +import { SoonCardComponent } from "@ui/primitives/soon-card/soon-card.component"; + +/** Контейнер модуля карьерных траекторий. */ +@Component({ + selector: "app-track-career", + imports: [ + CommonModule, + RouterModule, + BackComponent, + SearchComponent, + ReactiveFormsModule, + SoonCardComponent, + ], + templateUrl: "./courses.component.html", + styleUrl: "./courses.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CoursesComponent { + private readonly fb = inject(FormBuilder); + + constructor() { + this.searchForm = this.fb.group({ + search: [""], + }); + } + + searchForm: FormGroup; +} diff --git a/projects/social_platform/src/app/ui/pages/courses/courses.resolver.ts b/projects/social_platform/src/app/ui/pages/courses/courses.resolver.ts new file mode 100644 index 000000000..951ac32e4 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/courses/courses.resolver.ts @@ -0,0 +1,11 @@ +/** @format */ + +import { inject } from "@angular/core"; +import { GetCoursesUseCase } from "@api/courses/use-cases/get-courses.use-case"; +import { map } from "rxjs"; + +export const CoursesResolver = () => { + const getCoursesUseCase = inject(GetCoursesUseCase); + + return getCoursesUseCase.execute().pipe(map(result => (result.ok ? result.value : []))); +}; diff --git a/projects/social_platform/src/app/ui/pages/courses/detail/course-detail.component.html b/projects/social_platform/src/app/ui/pages/courses/detail/course-detail.component.html new file mode 100644 index 000000000..177050b50 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/courses/detail/course-detail.component.html @@ -0,0 +1,110 @@ + + +
    +
    + @if (loading()) { +
    + +
    + } @else if (course()) { +
    + @if (showCover) { +
    + cover + +
    + + + @if (!isTaskDetail() && !isMobile) { +

    + {{ course()!.title }} +

    + } +
    +
    + } + +
    +
    + @if (showBackOnly) { + + назад + + + @if (currentLesson()) { +
    +

    модуль {{ currentLesson()!.moduleOrder }}

    +

    урок {{ currentLessonOrder() }}

    +
    + } + + + о курсе + + } @else { + @if (showAnalyticsButton) { + аналитика + } + @if (showAboutButton) { + + о курсе + + } + + + назад + + + @if (showProgramButton) { + вернуться в программу + } + } +
    +
    +
    + + + + + + + } +
    +
    diff --git a/projects/social_platform/src/app/office/courses/detail/course-detail.component.scss b/projects/social_platform/src/app/ui/pages/courses/detail/course-detail.component.scss similarity index 100% rename from projects/social_platform/src/app/office/courses/detail/course-detail.component.scss rename to projects/social_platform/src/app/ui/pages/courses/detail/course-detail.component.scss diff --git a/projects/social_platform/src/app/ui/pages/courses/detail/course-detail.component.ts b/projects/social_platform/src/app/ui/pages/courses/detail/course-detail.component.ts new file mode 100644 index 000000000..7a1099fdf --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/courses/detail/course-detail.component.ts @@ -0,0 +1,87 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, HostListener, inject, OnInit } from "@angular/core"; +import { RouterOutlet } from "@angular/router"; +import { AvatarComponent } from "@ui/primitives/avatar/avatar.component"; +import { ButtonComponent } from "@ui/primitives"; +import { LoaderComponent } from "@ui/primitives/loader/loader.component"; +import { CourseDetailInfoService } from "@api/courses/facades/course-detail-info.service"; +import { CourseDetailUIInfoService } from "@api/courses/facades/ui/course-detail-ui-info.service"; +import { CourseAboutComponent } from "@ui/widgets/course-about/course-about.component"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; + +/** Shell детальной страницы курса со структурой модулей и дочерним router-outlet. */ +@Component({ + selector: "app-course-detail", + imports: [ + CommonModule, + RouterOutlet, + AvatarComponent, + ButtonComponent, + LoaderComponent, + CourseAboutComponent, + ModalComponent, + ], + templateUrl: "./course-detail.component.html", + styleUrl: "./course-detail.component.scss", + providers: [CourseDetailInfoService, CourseDetailUIInfoService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CourseDetailComponent implements OnInit { + private readonly courseDetailInfoService = inject(CourseDetailInfoService); + private readonly courseDetailUIInfoService = inject(CourseDetailUIInfoService); + + protected readonly loading = this.courseDetailUIInfoService.loading; + protected readonly course = this.courseDetailUIInfoService.course; + protected readonly courseModules = this.courseDetailUIInfoService.courseModules; + protected readonly isDisabled = this.courseDetailUIInfoService.isDisabled; + protected readonly isTaskDetail = this.courseDetailUIInfoService.isTaskDetail; + protected readonly currentLesson = this.courseDetailUIInfoService.currentLesson; + protected readonly currentLessonOrder = this.courseDetailUIInfoService.currentLessonOrder; + + isAboutModalOpen = false; + + protected appWidth = window.innerWidth; + + @HostListener("window:resize") + onResize() { + this.appWidth = window.innerWidth; + } + + ngOnInit(): void { + this.courseDetailInfoService.init(); + } + + redirectDetailInfo(courseId?: number): void { + this.courseDetailInfoService.redirectDetailInfo(courseId); + } + + redirectToProgram(): void { + this.courseDetailInfoService.redirectToProgram(); + } + + get isMobile(): boolean { + return this.appWidth < 1000; + } + + get showCover(): boolean { + return !this.isTaskDetail() || !this.isMobile; + } + + get showAboutButton(): boolean { + return this.isMobile && !this.isTaskDetail(); + } + + get showBackOnly(): boolean { + return this.isTaskDetail() && this.isMobile; + } + + get showAnalyticsButton(): boolean { + return this.isMobile && !this.isTaskDetail(); + } + + get showProgramButton(): boolean { + return !this.showBackOnly; + } +} diff --git a/projects/social_platform/src/app/ui/pages/courses/detail/course-detail.resolver.ts b/projects/social_platform/src/app/ui/pages/courses/detail/course-detail.resolver.ts new file mode 100644 index 000000000..6cf229ad0 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/courses/detail/course-detail.resolver.ts @@ -0,0 +1,32 @@ +/** @format */ + +import { inject } from "@angular/core"; +import type { ActivatedRouteSnapshot } from "@angular/router"; +import { Router } from "@angular/router"; +import { EMPTY, map, switchMap } from "rxjs"; +import { GetCourseDetailUseCase } from "@api/courses/use-cases/get-course-detail.use-case"; +import { GetCourseStructureUseCase } from "@api/courses/use-cases/get-course-structure.use-case"; +import { AppRoutes } from "@api/paths/app-routes"; + +/** Резолвер: загружает детали курса и его структуру; редиректит на список, если курс недоступен. */ +export const CoursesDetailResolver = (route: ActivatedRouteSnapshot) => { + const getCourseDetailUseCase = inject(GetCourseDetailUseCase); + const getCourseStructureUseCase = inject(GetCourseStructureUseCase); + const router = inject(Router); + const courseId = route.parent?.params["courseId"]; + + return getCourseDetailUseCase.execute(courseId).pipe( + switchMap(detailResult => { + const detail = detailResult.ok ? detailResult.value : null; + + if (!detail?.isAvailable) { + router.navigateByUrl(AppRoutes.courses.list()); + return EMPTY; + } + + return getCourseStructureUseCase + .execute(courseId) + .pipe(map(structureResult => [detail, structureResult.ok ? structureResult.value : null])); + }), + ); +}; diff --git a/projects/social_platform/src/app/ui/pages/courses/detail/info/course-module-card/circle-progress-bar/circle-progress-bar.component.html b/projects/social_platform/src/app/ui/pages/courses/detail/info/course-module-card/circle-progress-bar/circle-progress-bar.component.html new file mode 100644 index 000000000..939b3fd42 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/courses/detail/info/course-module-card/circle-progress-bar/circle-progress-bar.component.html @@ -0,0 +1,49 @@ + + +
    + @if (mode() === "progress") { +

    {{ progress() }}%

    + } @else { + @if (appereance() === "open") { + + } @else { + + } + } +
    + + + + +
    + + @if (haveDate()) { +

    c 16.03.26

    + } +
    diff --git a/projects/social_platform/src/app/office/courses/shared/circle-progress-bar/circle-progress-bar.component.scss b/projects/social_platform/src/app/ui/pages/courses/detail/info/course-module-card/circle-progress-bar/circle-progress-bar.component.scss similarity index 100% rename from projects/social_platform/src/app/office/courses/shared/circle-progress-bar/circle-progress-bar.component.scss rename to projects/social_platform/src/app/ui/pages/courses/detail/info/course-module-card/circle-progress-bar/circle-progress-bar.component.scss diff --git a/projects/social_platform/src/app/office/courses/shared/circle-progress-bar/circle-progress-bar.component.spec.ts b/projects/social_platform/src/app/ui/pages/courses/detail/info/course-module-card/circle-progress-bar/circle-progress-bar.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/office/courses/shared/circle-progress-bar/circle-progress-bar.component.spec.ts rename to projects/social_platform/src/app/ui/pages/courses/detail/info/course-module-card/circle-progress-bar/circle-progress-bar.component.spec.ts diff --git a/projects/social_platform/src/app/ui/pages/courses/detail/info/course-module-card/circle-progress-bar/circle-progress-bar.component.ts b/projects/social_platform/src/app/ui/pages/courses/detail/info/course-module-card/circle-progress-bar/circle-progress-bar.component.ts new file mode 100644 index 000000000..20d7aae4e --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/courses/detail/info/course-module-card/circle-progress-bar/circle-progress-bar.component.ts @@ -0,0 +1,31 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, input, Input } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { IconComponent } from "@ui/primitives"; + +/** Круглый SVG-прогресс-бар. */ +@Component({ + selector: "app-circle-progress-bar", + imports: [CommonModule, IconComponent], + templateUrl: "./circle-progress-bar.component.html", + styleUrl: "./circle-progress-bar.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CircleProgressBarComponent { + readonly progress = input(0); + readonly mode = input<"button" | "progress">("progress"); + readonly appereance = input<"open" | "closed">(); + readonly haveDate = input(false); + + protected readonly radius = 70; + + calculateStrokeDashOffset(): number { + const circumference = 2 * Math.PI * this.radius; // Длина окружности: 2 * π * радиус + return circumference - (this.progress() / 100) * circumference; + } + + calculateStrokeDashArray(): number { + return 2 * Math.PI * this.radius; // Полная длина окружности: 2 * π * радиус + } +} diff --git a/projects/social_platform/src/app/ui/pages/courses/detail/info/course-module-card/course-module-card.component.html b/projects/social_platform/src/app/ui/pages/courses/detail/info/course-module-card/course-module-card.component.html new file mode 100644 index 000000000..90687e58f --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/courses/detail/info/course-module-card/course-module-card.component.html @@ -0,0 +1,73 @@ + + +
    +
    +
    + + +
    +

    Модуль {{ courseModule().order }}

    + +

    + {{ courseModule().title }} +

    + +
    +

    + {{ courseModule().lessons.length }} + {{ courseModule().lessons.length | pluralize: ["урок", "урока", "уроков"] }} +

    +
    +
    +
    + + +
    + + @if (courseModule().lessons.length) { +
    + @if (isExpanded) { + + } @else { + + } +
    + } +
    + + diff --git a/projects/social_platform/src/app/office/courses/shared/course-module-card/course-module-card.component.scss b/projects/social_platform/src/app/ui/pages/courses/detail/info/course-module-card/course-module-card.component.scss similarity index 100% rename from projects/social_platform/src/app/office/courses/shared/course-module-card/course-module-card.component.scss rename to projects/social_platform/src/app/ui/pages/courses/detail/info/course-module-card/course-module-card.component.scss diff --git a/projects/social_platform/src/app/ui/pages/courses/detail/info/course-module-card/course-module-card.component.spec.ts b/projects/social_platform/src/app/ui/pages/courses/detail/info/course-module-card/course-module-card.component.spec.ts new file mode 100644 index 000000000..9252e02fe --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/courses/detail/info/course-module-card/course-module-card.component.spec.ts @@ -0,0 +1,139 @@ +/** @format */ + +import { TestBed, ComponentFixture } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { provideRouter } from "@angular/router"; +import { CourseModuleCardComponent } from "./course-module-card.component"; +import { CourseModule } from "@domain/courses/courses.model"; + +function buildModule(overrides: Partial = {}): CourseModule { + return { + id: 1, + courseId: 100, + title: "Основы", + order: 1, + avatarUrl: "", + startDate: new Date(), + status: "", + isAvailable: true, + progressStatus: "in_progress", + percent: 50, + lessons: [], + ...overrides, + }; +} + +describe("CourseModuleCardComponent", () => { + let fixture: ComponentFixture; + let component: CourseModuleCardComponent; + + async function setup(courseModule: CourseModule): Promise { + await TestBed.configureTestingModule({ + imports: [CourseModuleCardComponent], + providers: [provideRouter([])], // нужен для [routerLink] внутри шаблона + }).compileComponents(); + + fixture = TestBed.createComponent(CourseModuleCardComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput("courseModule", courseModule); + fixture.detectChanges(); + } + + describe("рендер данных из @Input", () => { + it("отображает номер модуля и заголовок", async () => { + await setup(buildModule({ order: 3, title: "Продвинутая алгебра" })); + + const title = fixture.debugElement.query(By.css(".skill-card__title")); + const text = fixture.debugElement.query(By.css(".skill-card__text")); + + expect(title.nativeElement.textContent).toContain("Модуль 3"); + expect(text.nativeElement.textContent).toContain("Продвинутая алгебра"); + }); + + it("выводит количество уроков с корректной плюрализацией", async () => { + await setup( + buildModule({ + lessons: [ + { id: 1, order: 1, title: "a", taskCount: 0, percent: 0, isAvailable: true } as never, + { id: 2, order: 2, title: "b", taskCount: 0, percent: 0, isAvailable: true } as never, + ], + }), + ); + + const level = fixture.debugElement.query(By.css(".skill-card__level")); + // Плюрализация: 2 → "урока" (от 2 до 4). + expect(level.nativeElement.textContent).toContain("2 урока"); + }); + }); + + describe("expand-toggle виден только для модулей с уроками", () => { + it("не рендерит стрелку expand, если уроков нет", async () => { + await setup(buildModule({ lessons: [] })); + + const toggle = fixture.debugElement.query(By.css(".skill-card__expand")); + expect(toggle).toBeNull(); + }); + + it("рендерит стрелку expand, если есть хотя бы один урок", async () => { + await setup( + buildModule({ + lessons: [ + { id: 1, order: 1, title: "a", taskCount: 0, percent: 0, isAvailable: true } as never, + ], + }), + ); + + const toggle = fixture.debugElement.query(By.css(".skill-card__expand")); + expect(toggle).not.toBeNull(); + }); + }); + + describe("клик переключает isExpanded", () => { + it("клик по карточке с уроками — isExpanded из false становится true, второй клик — обратно", async () => { + await setup( + buildModule({ + lessons: [ + { id: 1, order: 1, title: "a", taskCount: 0, percent: 0, isAvailable: true } as never, + ], + }), + ); + expect(component.isExpanded).toBe(false); + + const wrapper = fixture.debugElement.query(By.css(".skill-card--wrapper")); + wrapper.triggerEventHandler("click", new MouseEvent("click")); + fixture.detectChanges(); + expect(component.isExpanded).toBe(true); + + wrapper.triggerEventHandler("click", new MouseEvent("click")); + fixture.detectChanges(); + expect(component.isExpanded).toBe(false); + }); + + it("клик по карточке без уроков — isExpanded остаётся false", async () => { + await setup(buildModule({ lessons: [] })); + + const wrapper = fixture.debugElement.query(By.css(".skill-card--wrapper")); + wrapper.triggerEventHandler("click", new MouseEvent("click")); + fixture.detectChanges(); + + expect(component.isExpanded).toBe(false); + }); + }); + + describe("список уроков в expandable", () => { + it("рендерит li под каждый урок", async () => { + await setup( + buildModule({ + lessons: [ + { id: 1, order: 1, title: "a", taskCount: 1, percent: 0, isAvailable: true } as never, + { id: 2, order: 2, title: "b", taskCount: 2, percent: 0, isAvailable: true } as never, + { id: 3, order: 3, title: "c", taskCount: 3, percent: 0, isAvailable: true } as never, + ], + }), + ); + + const items = fixture.debugElement.queryAll(By.css(".expandable__item")); + expect(items.length).toBe(3); + }); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/courses/detail/info/course-module-card/course-module-card.component.ts b/projects/social_platform/src/app/ui/pages/courses/detail/info/course-module-card/course-module-card.component.ts new file mode 100644 index 000000000..8242ba7d9 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/courses/detail/info/course-module-card/course-module-card.component.ts @@ -0,0 +1,39 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, input } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { AvatarComponent } from "@uilib"; +import { PluralizePipe } from "@corelib"; +import { IconComponent } from "@ui/primitives"; +import { CircleProgressBarComponent } from "./circle-progress-bar/circle-progress-bar.component"; +import { CourseModule } from "@domain/courses/courses.model"; +import { RouterLink } from "@angular/router"; + +/** Карточка модуля курса с прогрессом и списком уроков. */ +@Component({ + selector: "app-course-module-card", + imports: [ + CommonModule, + CircleProgressBarComponent, + IconComponent, + RouterLink, + PluralizePipe, + AvatarComponent, + ], + templateUrl: "./course-module-card.component.html", + styleUrl: "./course-module-card.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CourseModuleCardComponent { + readonly courseModule = input.required(); + readonly type = input<"personal" | "base">("base"); + + isExpanded = false; + + toggleExpand(event: Event): void { + if (this.courseModule().lessons.length) { + event.stopPropagation(); + this.isExpanded = !this.isExpanded; + } + } +} diff --git a/projects/social_platform/src/app/ui/pages/courses/detail/info/info.component.html b/projects/social_platform/src/app/ui/pages/courses/detail/info/info.component.html new file mode 100644 index 000000000..4954edb08 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/courses/detail/info/info.component.html @@ -0,0 +1,66 @@ + + +@if (courseStructure()) { +
    +
    +
    + @if (appWidth > 1000) { +
    + +
    + } + +
    +
    +

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

    +

    {{ courseStructure()!.percent }}%

    +
    +
    + +
    + @for (courseModule of courseStructure()!.modules; track courseModule) { + + } @empty { +

    На данный момент модулей нет!

    + } +
    +
    + + @if (appWidth > 1000) { +
    + +
    + } +
    +
    + + +
    +

    + {{ isCourseCompleted() ? "ты прошел курс!" : "ты прошел модуль!" }} +

    + + complete module image + + отлично +
    +
    +
    +} diff --git a/projects/social_platform/src/app/office/courses/detail/info/info.component.scss b/projects/social_platform/src/app/ui/pages/courses/detail/info/info.component.scss similarity index 100% rename from projects/social_platform/src/app/office/courses/detail/info/info.component.scss rename to projects/social_platform/src/app/ui/pages/courses/detail/info/info.component.scss diff --git a/projects/social_platform/src/app/ui/pages/courses/detail/info/info.component.ts b/projects/social_platform/src/app/ui/pages/courses/detail/info/info.component.ts new file mode 100644 index 000000000..3825490b8 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/courses/detail/info/info.component.ts @@ -0,0 +1,44 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, HostListener, inject } from "@angular/core"; +import { RouterModule } from "@angular/router"; +import { CommonModule } from "@angular/common"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { ButtonComponent } from "@ui/primitives"; +import { SoonCardComponent } from "@ui/primitives/soon-card/soon-card.component"; +import { CourseModuleCardComponent } from "./course-module-card/course-module-card.component"; +import { CourseDetailUIInfoService } from "@api/courses/facades/ui/course-detail-ui-info.service"; +import { CourseAboutComponent } from "@ui/widgets/course-about/course-about.component"; + +/** Информационная вкладка курса с описанием, модулями и about-модалкой. */ +@Component({ + selector: "app-detail", + imports: [ + RouterModule, + CommonModule, + SoonCardComponent, + ModalComponent, + ButtonComponent, + CourseModuleCardComponent, + CourseAboutComponent, + ], + templateUrl: "./info.component.html", + styleUrl: "./info.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CourseInfoComponent { + protected appWidth = window.innerWidth; + + @HostListener("window:resize") + onResize() { + this.appWidth = window.innerWidth; + } + + private readonly courseDetailUIInfoService = inject(CourseDetailUIInfoService); + + protected readonly courseStructure = this.courseDetailUIInfoService.courseStructure; + protected readonly courseDetail = this.courseDetailUIInfoService.course; + protected readonly courseModules = this.courseDetailUIInfoService.courseModules; + protected readonly isCompleteModule = this.courseDetailUIInfoService.isCompleteModule; + protected readonly isCourseCompleted = this.courseDetailUIInfoService.isCourseCompleted; +} diff --git a/projects/social_platform/src/app/ui/pages/courses/lesson/complete/complete.component.html b/projects/social_platform/src/app/ui/pages/courses/lesson/complete/complete.component.html new file mode 100644 index 000000000..1be3b0653 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/courses/lesson/complete/complete.component.html @@ -0,0 +1,31 @@ + + +
    +
    + complete img + +
    +

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

    +

    урок пройден

    +
    + + @if (appWidth > 1000) { + отлично + } +
    +
    +@if (appWidth < 1000) { + отлично +} diff --git a/projects/social_platform/src/app/office/courses/lesson/complete/complete.component.scss b/projects/social_platform/src/app/ui/pages/courses/lesson/complete/complete.component.scss similarity index 76% rename from projects/social_platform/src/app/office/courses/lesson/complete/complete.component.scss rename to projects/social_platform/src/app/ui/pages/courses/lesson/complete/complete.component.scss index ee674d96f..6d762f11f 100644 --- a/projects/social_platform/src/app/office/courses/lesson/complete/complete.component.scss +++ b/projects/social_platform/src/app/ui/pages/courses/lesson/complete/complete.component.scss @@ -2,6 +2,7 @@ @use "styles/typography"; .complete { + position: relative; min-height: 543px; padding: 24px; background-color: var(--light-white); @@ -25,4 +26,14 @@ margin-top: 20px; margin-bottom: 20px; } + + &__button { + position: absolute; + top: 84%; + width: 90%; + + @include responsive.apply-desktop { + position: static; + } + } } diff --git a/projects/social_platform/src/app/ui/pages/courses/lesson/complete/complete.component.spec.ts b/projects/social_platform/src/app/ui/pages/courses/lesson/complete/complete.component.spec.ts new file mode 100644 index 000000000..73a76e48d --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/courses/lesson/complete/complete.component.spec.ts @@ -0,0 +1,85 @@ +/** @format */ + +import { TestBed, ComponentFixture } from "@angular/core/testing"; +import { ActivatedRoute, Router } from "@angular/router"; +import { By } from "@angular/platform-browser"; +import { TaskCompleteComponent } from "./complete.component"; +import { AppRoutes } from "@api/paths/app-routes"; + +function makeFakeRouteWithCourseId(courseId: string | null): ActivatedRoute { + const snapshot = { + paramMap: { + get: (key: string) => (key === "courseId" ? courseId : null), + }, + }; + const level3 = { snapshot }; + const level2 = { parent: level3 }; + const level1 = { parent: level2 }; + return { parent: level1 } as unknown as ActivatedRoute; +} + +describe("TaskCompleteComponent", () => { + let fixture: ComponentFixture; + let component: TaskCompleteComponent; + let routerSpy: any; + + async function setup(courseId: string | null): Promise { + routerSpy = { navigateByUrl: vi.fn() }; + + await TestBed.configureTestingModule({ + imports: [TaskCompleteComponent], + providers: [ + { provide: Router, useValue: routerSpy }, + { provide: ActivatedRoute, useValue: makeFakeRouteWithCourseId(courseId) }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(TaskCompleteComponent); + component = fixture.componentInstance; + + fixture.detectChanges(); + } + + describe("ngOnInit", () => { + it("выставляет courseId в сигнал, если параметр есть в родительском route", async () => { + await setup("42"); + + expect(component.courseId()).toBe(42); + }); + + it("выставляет null, если courseId невалидный (не число)", async () => { + await setup("abc"); + + expect(component.courseId()).toBeNull(); + }); + + it("выставляет 0 (не null!), если courseId отсутствует в snapshot", async () => { + await setup(null); + + expect(component.courseId()).toBe(0); + }); + }); + + describe("routeToCourses (клик по кнопке)", () => { + it("навигирует на детальную страницу курса, когда courseId известен", async () => { + await setup("7"); + + const button = fixture.debugElement.query(By.css("app-button")); + expect(button, "кнопка должна быть в DOM").toBeTruthy(); + + button.triggerEventHandler("click", null); + + expect(routerSpy.navigateByUrl).toHaveBeenCalledTimes(1); + expect(routerSpy.navigateByUrl).toHaveBeenCalledWith(AppRoutes.courses.detail(7)); + }); + + it("навигирует на список курсов, когда courseId недоступен", async () => { + await setup(null); + + const button = fixture.debugElement.query(By.css("app-button")); + button.triggerEventHandler("click", null); + + expect(routerSpy.navigateByUrl).toHaveBeenCalledWith(AppRoutes.courses.list()); + }); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/courses/lesson/complete/complete.component.ts b/projects/social_platform/src/app/ui/pages/courses/lesson/complete/complete.component.ts new file mode 100644 index 000000000..cea4038db --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/courses/lesson/complete/complete.component.ts @@ -0,0 +1,45 @@ +/** @format */ + +import { + ChangeDetectionStrategy, + Component, + HostListener, + inject, + OnInit, + signal, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { ButtonComponent } from "@ui/primitives"; +import { ActivatedRoute, Router } from "@angular/router"; +import { AppRoutes } from "@api/paths/app-routes"; + +/** Страница завершения задачи с результатами. */ +@Component({ + selector: "app-complete", + imports: [CommonModule, ButtonComponent], + templateUrl: "./complete.component.html", + styleUrl: "./complete.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TaskCompleteComponent implements OnInit { + route = inject(ActivatedRoute); + router = inject(Router); + courseId = signal(null); + + protected appWidth = window.innerWidth; + + @HostListener("window:resize") + onResize() { + this.appWidth = window.innerWidth; + } + + ngOnInit(): void { + const courseId = Number(this.route.parent?.parent?.parent?.snapshot.paramMap.get("courseId")); + this.courseId.set(isNaN(courseId) ? null : courseId); + } + + routeToCourses(): void { + const id = this.courseId(); + this.router.navigateByUrl(id ? AppRoutes.courses.detail(id) : AppRoutes.courses.list()); + } +} diff --git a/projects/social_platform/src/app/ui/pages/courses/lesson/lesson.component.html b/projects/social_platform/src/app/ui/pages/courses/lesson/lesson.component.html new file mode 100644 index 000000000..38cc38dc8 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/courses/lesson/lesson.component.html @@ -0,0 +1,119 @@ + + +@if (lessonInfo()) { +
    +
    + @if (appWidth >= 1000) { +
    +

    модуль {{ lessonInfo()?.moduleOrder }}

    +

    урок {{ lessonOrder() }}

    +
    + } + +
    + @for (task of tasks(); track task.id) { +
    +

    {{ task.order }}

    +
    + } +
    +
    + + @if (loading()) { +
    + +
    + } @else { +
    + + + @if (!isComplete() && currentTask(); as task) { +
    + + @if ( + task.answerType === null && + (task.informationalType === "text" || + task.informationalType === "video_text" || + task.informationalType === "text_image") + ) { + + } + + + @if (task.answerType === "text" || task.answerType === "text_and_files") { + + } + + + @if (task.answerType === "multiple_choice") { + + } + + + @if (task.answerType === "single_choice") { + + } + + + @if (task.answerType === "files") { + + } + +
    +
    + + + {{ + isLastTask() + ? "завершить урок" + : currentTask()?.answerType === null + ? "отправить задание" + : "отправить ответ" + }} + +
    +
    + } +
    + } +
    +} diff --git a/projects/social_platform/src/app/office/courses/lesson/lesson.component.scss b/projects/social_platform/src/app/ui/pages/courses/lesson/lesson.component.scss similarity index 100% rename from projects/social_platform/src/app/office/courses/lesson/lesson.component.scss rename to projects/social_platform/src/app/ui/pages/courses/lesson/lesson.component.scss diff --git a/projects/social_platform/src/app/ui/pages/courses/lesson/lesson.component.ts b/projects/social_platform/src/app/ui/pages/courses/lesson/lesson.component.ts new file mode 100644 index 000000000..ed748506f --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/courses/lesson/lesson.component.ts @@ -0,0 +1,98 @@ +/** @format */ + +import { + ChangeDetectionStrategy, + Component, + HostListener, + inject, + OnDestroy, + OnInit, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { RouterOutlet } from "@angular/router"; +import { Task } from "@domain/courses/courses.model"; +import { ButtonComponent } from "@ui/primitives"; +import { InfoTaskComponent } from "./shared/video-task/info-task.component"; +import { WriteTaskComponent } from "./shared/write-task/write-task.component"; +import { ExcludeTaskComponent } from "./shared/exclude-task/exclude-task.component"; +import { RadioSelectTaskComponent } from "./shared/radio-select-task/radio-select-task.component"; +import { FileTaskComponent } from "./shared/file-task/file-task.component"; +import { LoaderComponent } from "@ui/primitives/loader/loader.component"; +import { LessonInfoService } from "@api/courses/facades/lesson-info.service"; +import { LessonUIInfoService } from "@api/courses/facades/ui/lesson-ui-info.service"; + +/** Страница прохождения урока, выбирающая компонент задачи по текущему типу ответа. */ +@Component({ + selector: "app-lesson", + imports: [ + CommonModule, + RouterOutlet, + ButtonComponent, + InfoTaskComponent, + WriteTaskComponent, + ExcludeTaskComponent, + RadioSelectTaskComponent, + FileTaskComponent, + LoaderComponent, + ], + templateUrl: "./lesson.component.html", + styleUrl: "./lesson.component.scss", + providers: [LessonInfoService, LessonUIInfoService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class LessonComponent implements OnInit, OnDestroy { + private readonly lessonInfoService = inject(LessonInfoService); + private readonly lessonUIInfoService = inject(LessonUIInfoService); + + protected appWidth = window.innerWidth; + + @HostListener("window:resize") + onResize() { + this.appWidth = window.innerWidth; + } + + protected readonly lessonInfo = this.lessonUIInfoService.lessonInfo; + protected readonly isComplete = this.lessonUIInfoService.isComplete; + protected readonly currentTask = this.lessonUIInfoService.currentTask; + protected readonly tasks = this.lessonUIInfoService.tasks; + protected readonly isLastTask = this.lessonUIInfoService.isLastTask; + protected readonly isSubmitDisabled = this.lessonUIInfoService.isSubmitDisabled; + protected readonly loader = this.lessonUIInfoService.loader; + protected readonly loading = this.lessonUIInfoService.loading; + protected readonly success = this.lessonUIInfoService.success; + protected readonly hasError = this.lessonUIInfoService.hasError; + protected readonly isViewingCompleted = this.lessonUIInfoService.isViewingCompleted; + protected readonly lessonOrder = this.lessonUIInfoService.lessonOrder; + + ngOnInit(): void { + this.lessonInfoService.init(); + } + + ngOnDestroy(): void { + this.lessonInfoService.destroy(); + } + + isCurrent(taskId: number): boolean { + return this.lessonUIInfoService.currentTaskId() === taskId; + } + + isDone(task: Task): boolean { + return this.lessonUIInfoService.isDone(task); + } + + isClickable(task: Task): boolean { + return this.lessonUIInfoService.isClickable(task); + } + + onSelectTask(task: Task): void { + this.lessonInfoService.onSelectTask(task); + } + + onAnswerChange(value: any): void { + this.lessonInfoService.onAnswerChange(value); + } + + onSubmitAnswer(): void { + this.lessonInfoService.onSubmitAnswer(); + } +} diff --git a/projects/social_platform/src/app/ui/pages/courses/lesson/lesson.resolver.ts b/projects/social_platform/src/app/ui/pages/courses/lesson/lesson.resolver.ts new file mode 100644 index 000000000..7b3b0aca7 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/courses/lesson/lesson.resolver.ts @@ -0,0 +1,16 @@ +/** @format */ + +import type { ResolveFn } from "@angular/router"; +import { inject } from "@angular/core"; +import { CourseLesson } from "@domain/courses/courses.model"; +import { GetCourseLessonUseCase } from "@api/courses/use-cases/get-course-lesson.use-case"; +import { map } from "rxjs"; + +export const lessonDetailResolver: ResolveFn = route => { + const getCourseLessonUseCase = inject(GetCourseLessonUseCase); + const lessonId = route.params["lessonId"]; + + return getCourseLessonUseCase + .execute(lessonId) + .pipe(map(result => (result.ok ? result.value : null))); +}; diff --git a/projects/social_platform/src/app/ui/pages/courses/lesson/shared/exclude-task/exclude-task.component.html b/projects/social_platform/src/app/ui/pages/courses/lesson/shared/exclude-task/exclude-task.component.html new file mode 100644 index 000000000..2e8daa98f --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/courses/lesson/shared/exclude-task/exclude-task.component.html @@ -0,0 +1,64 @@ + + +
    +
    +

    задание {{ data().order }}

    +
    + @if (cachedVideoUrl) { + + } @else if (data().imageUrl) { + exclude-image +

    {{ data().title | truncate: 50 }}

    + } + @if (!data().imageUrl) { +

    {{ data().answerTitle | truncate: 110 }}

    + } + +

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

    ответ

    +
      +

      {{ data().answerTitle | truncate: 110 }}

      + @for (op of data().options; track op.id) { +
    • + + +
    • + } +
    +
    +
    diff --git a/projects/social_platform/src/app/office/courses/lesson/shared/exclude-task/exclude-task.component.scss b/projects/social_platform/src/app/ui/pages/courses/lesson/shared/exclude-task/exclude-task.component.scss similarity index 100% rename from projects/social_platform/src/app/office/courses/lesson/shared/exclude-task/exclude-task.component.scss rename to projects/social_platform/src/app/ui/pages/courses/lesson/shared/exclude-task/exclude-task.component.scss diff --git a/projects/social_platform/src/app/office/courses/lesson/shared/exclude-task/exclude-task.component.spec.ts b/projects/social_platform/src/app/ui/pages/courses/lesson/shared/exclude-task/exclude-task.component.spec.ts similarity index 86% rename from projects/social_platform/src/app/office/courses/lesson/shared/exclude-task/exclude-task.component.spec.ts rename to projects/social_platform/src/app/ui/pages/courses/lesson/shared/exclude-task/exclude-task.component.spec.ts index ee3dd5200..034848bba 100644 --- a/projects/social_platform/src/app/office/courses/lesson/shared/exclude-task/exclude-task.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/courses/lesson/shared/exclude-task/exclude-task.component.spec.ts @@ -1,7 +1,6 @@ /** @format */ import { ComponentFixture, TestBed } from "@angular/core/testing"; - import { ExcludeTaskComponent } from "./exclude-task.component"; describe("ExcludeTaskComponent", () => { @@ -15,6 +14,11 @@ describe("ExcludeTaskComponent", () => { fixture = TestBed.createComponent(ExcludeTaskComponent); component = fixture.componentInstance; + fixture.componentRef.setInput("data", { + id: 1, + text: "Test", + options: [], + }); fixture.detectChanges(); }); diff --git a/projects/social_platform/src/app/ui/pages/courses/lesson/shared/exclude-task/exclude-task.component.ts b/projects/social_platform/src/app/ui/pages/courses/lesson/shared/exclude-task/exclude-task.component.ts new file mode 100644 index 000000000..7eecaeccd --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/courses/lesson/shared/exclude-task/exclude-task.component.ts @@ -0,0 +1,94 @@ +/** @format */ + +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + inject, + input, + Input, + OnInit, + output, + signal, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { DomSanitizer, SafeResourceUrl } from "@angular/platform-browser"; +import { CheckboxComponent } from "@ui/primitives"; +import { Task } from "@domain/courses/courses.model"; +import { resolveVideoUrlForIframe } from "@utils/video-url-embed"; +import { animateContentHeight } from "@utils/animate-content-height"; +import { isHtmlTextTruncated } from "@utils/is-html-text-truncated"; +import { ImagePreviewDirective } from "../image-preview/image-preview.directive"; +import { TruncateHtmlPipe, TruncatePipe } from "@core/public-api"; + +/** Задача на исключение лишнего с множественным выбором. */ +@Component({ + selector: "app-exclude-task", + imports: [CommonModule, TruncatePipe, TruncateHtmlPipe, CheckboxComponent, ImagePreviewDirective], + templateUrl: "./exclude-task.component.html", + styleUrl: "./exclude-task.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ExcludeTaskComponent implements OnInit { + private readonly sanitizer = inject(DomSanitizer); + private readonly cdRef = inject(ChangeDetectorRef); + + readonly data = input.required(); + readonly hint = input(); + + readonly success = input(false); + readonly disabled = input(false); + + readonly update = output(); + + @Input() + set error(value: boolean) { + this._error.set(value); + + if (value) { + setTimeout(() => { + this.result.set([]); + this._error.set(false); + }, 1000); + } + } + + get error() { + return this._error(); + } + + result = signal([]); + _error = signal(false); + readFullDescription = false; + cachedVideoUrl: SafeResourceUrl | null = null; + readonly truncateLimit = 700; + + get descriptionExpandable(): boolean { + return isHtmlTextTruncated(this.data()?.bodyText, this.truncateLimit); + } + + ngOnInit(): void { + const iframeUrl = resolveVideoUrlForIframe(this.data()?.videoUrl); + this.cachedVideoUrl = iframeUrl + ? this.sanitizer.bypassSecurityTrustResourceUrl(iframeUrl) + : null; + } + + onToggleDescription(elem: HTMLElement): void { + animateContentHeight(elem, () => { + this.readFullDescription = !this.readFullDescription; + this.cdRef.detectChanges(); + }); + } + + onSelect(id: number) { + if (this.disabled()) return; + if (this.result().includes(id)) { + this.result.set(this.result().filter(i => i !== id)); + } else { + this.result.set([...this.result(), id]); + } + + this.update.emit(this.result()); + } +} diff --git a/projects/social_platform/src/app/ui/pages/courses/lesson/shared/file-task/file-task.component.html b/projects/social_platform/src/app/ui/pages/courses/lesson/shared/file-task/file-task.component.html new file mode 100644 index 000000000..3345987bc --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/courses/lesson/shared/file-task/file-task.component.html @@ -0,0 +1,92 @@ + + +
    +
    +

    задание {{ data().order }}

    + @if (!data().videoUrl && !data().imageUrl && data().attachmentUrl) { +

    {{ data().title | truncate: 80 }}

    + } +
    + @if (cachedVideoUrl) { + + } + @if (data().imageUrl) { + + } +
    + @if (data().imageUrl) { +

    {{ data().title | truncate: 80 }}

    + } +
    + + @if (descriptionExpandable) { +
    + {{ readFullDescription ? "скрыть" : "подробнее" }} +
    + } +
    + + @if (data().attachmentUrl) { + + } +
    + + @if (hint().length) { +
    + } +
    + +
    +

    ответ

    +

    {{ data().answerTitle | truncate: 80 }}

    + + @if (!disabled()) { +
    + +
    + +

    загрузите файл до 100 MB

    +
    +
    + + @for (file of uploadedFiles(); track $index) { + + } +
    + } +
    +
    diff --git a/projects/social_platform/src/app/office/courses/lesson/shared/file-task/file-task.component.scss b/projects/social_platform/src/app/ui/pages/courses/lesson/shared/file-task/file-task.component.scss similarity index 100% rename from projects/social_platform/src/app/office/courses/lesson/shared/file-task/file-task.component.scss rename to projects/social_platform/src/app/ui/pages/courses/lesson/shared/file-task/file-task.component.scss diff --git a/projects/social_platform/src/app/ui/pages/courses/lesson/shared/file-task/file-task.component.ts b/projects/social_platform/src/app/ui/pages/courses/lesson/shared/file-task/file-task.component.ts new file mode 100644 index 000000000..b795b85ab --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/courses/lesson/shared/file-task/file-task.component.ts @@ -0,0 +1,135 @@ +/** @format */ + +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + EventEmitter, + inject, + input, + Input, + OnInit, + output, + Output, + signal, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { DomSanitizer, SafeResourceUrl } from "@angular/platform-browser"; +import { UploadFileComponent } from "@ui/primitives/upload-file/upload-file.component"; +import { IconComponent } from "@ui/primitives"; +import { FileItemComponent } from "@ui/primitives/file-item/file-item.component"; +import { FileService } from "@core/lib/services/file/file.service"; +import { Task } from "@domain/courses/courses.model"; +import { FileModel } from "@domain/file/file.model"; +import { resolveVideoUrlForIframe } from "@utils/video-url-embed"; +import { animateContentHeight } from "@utils/animate-content-height"; +import { isHtmlTextTruncated } from "@utils/is-html-text-truncated"; +import { ImagePreviewDirective } from "../image-preview/image-preview.directive"; +import { TruncateHtmlPipe, TruncatePipe } from "@core/public-api"; + +/** Файловый ответ на задачу курса с превью вложений и сбросом при ошибке. */ +@Component({ + selector: "app-file-task", + imports: [ + CommonModule, + TruncatePipe, + TruncateHtmlPipe, + UploadFileComponent, + IconComponent, + FileItemComponent, + ImagePreviewDirective, + ], + templateUrl: "./file-task.component.html", + styleUrl: "./file-task.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class FileTaskComponent implements OnInit { + private readonly fileService = inject(FileService); + private readonly sanitizer = inject(DomSanitizer); + private readonly cdRef = inject(ChangeDetectorRef); + + readonly data = input.required(); + readonly success = input(false); + readonly hint = input(""); + readonly disabled = input(false); + + @Input() + set error(value: boolean) { + this._error.set(value); + + if (value) { + setTimeout(() => { + // Ошибочный ответ очищает выбранные файлы для повторной попытки. + this.uploadedFiles.set([]); + this._error.set(false); + this.update.emit([]); + }, 1000); + } + } + + get error() { + return this._error(); + } + + readonly update = output(); + + _error = signal(false); + uploadedFiles = signal([]); + readFullDescription = false; + cachedVideoUrl: SafeResourceUrl | null = null; + readonly truncateLimit = 700; + + get descriptionExpandable(): boolean { + return isHtmlTextTruncated(this.data()?.bodyText, this.truncateLimit); + } + + ngOnInit(): void { + const iframeUrl = resolveVideoUrlForIframe(this.data()?.videoUrl); + this.cachedVideoUrl = iframeUrl + ? this.sanitizer.bypassSecurityTrustResourceUrl(iframeUrl) + : null; + } + + onToggleDescription(elem: HTMLElement): void { + animateContentHeight(elem, () => { + this.readFullDescription = !this.readFullDescription; + this.cdRef.detectChanges(); + }); + } + + onFileUploaded(event: { url: string; name: string; size: number; mimeType: string }) { + const ext = event.name.split(".").pop()?.toLowerCase() || ""; + const file: FileModel = { + name: event.name, + size: event.size, + mimeType: event.mimeType, + link: event.url, + extension: ext, + datetimeUploaded: new Date().toISOString(), + user: 0, + }; + + this.uploadedFiles.update(files => [...files, file]); + this.emitLinks(); + } + + onFileRemoved(index: number) { + const file = this.uploadedFiles()[index]; + if (!file) return; + + this.fileService.deleteFile(file.link).subscribe({ + next: () => { + this.uploadedFiles.update(files => files.filter((_, i) => i !== index)); + this.emitLinks(); + }, + error: () => { + this.uploadedFiles.update(files => files.filter((_, i) => i !== index)); + this.emitLinks(); + }, + }); + } + + private emitLinks() { + this.update.emit(this.uploadedFiles().map(f => f.link)); + } +} diff --git a/projects/social_platform/src/app/office/courses/lesson/shared/image-preview/image-preview.directive.ts b/projects/social_platform/src/app/ui/pages/courses/lesson/shared/image-preview/image-preview.directive.ts similarity index 100% rename from projects/social_platform/src/app/office/courses/lesson/shared/image-preview/image-preview.directive.ts rename to projects/social_platform/src/app/ui/pages/courses/lesson/shared/image-preview/image-preview.directive.ts diff --git a/projects/social_platform/src/app/office/courses/lesson/shared/image-preview/image-preview.scss b/projects/social_platform/src/app/ui/pages/courses/lesson/shared/image-preview/image-preview.scss similarity index 100% rename from projects/social_platform/src/app/office/courses/lesson/shared/image-preview/image-preview.scss rename to projects/social_platform/src/app/ui/pages/courses/lesson/shared/image-preview/image-preview.scss diff --git a/projects/social_platform/src/app/ui/pages/courses/lesson/shared/radio-select-task/radio-select-task.component.html b/projects/social_platform/src/app/ui/pages/courses/lesson/shared/radio-select-task/radio-select-task.component.html new file mode 100644 index 000000000..5f01d923c --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/courses/lesson/shared/radio-select-task/radio-select-task.component.html @@ -0,0 +1,73 @@ + + +
    +
    +

    задание {{ data().order }}

    + @if (cachedVideoUrl) { + + } + @if (data().imageUrl) { +
    + +

    {{ data().title | truncate: 50 }}

    +
    + } + @if (!data().imageUrl) { +

    {{ data().title | truncate: 110 }}

    + } + +

    + @if (descriptionExpandable) { +
    + {{ readFullDescription ? "скрыть" : "подробнее" }} +
    + } + @if (data().attachmentUrl) { + + } + @if (hint().length) { +
    + } +
    + +
    +

    ответ

    +
      +

      {{ data().answerTitle | truncate: 110 }}

      + @for (op of data().options; track op.id) { +
    • + + +
    • + } +
    +
    +
    diff --git a/projects/social_platform/src/app/office/courses/lesson/shared/radio-select-task/radio-select-task.component.scss b/projects/social_platform/src/app/ui/pages/courses/lesson/shared/radio-select-task/radio-select-task.component.scss similarity index 100% rename from projects/social_platform/src/app/office/courses/lesson/shared/radio-select-task/radio-select-task.component.scss rename to projects/social_platform/src/app/ui/pages/courses/lesson/shared/radio-select-task/radio-select-task.component.scss diff --git a/projects/social_platform/src/app/office/courses/lesson/shared/radio-select-task/radio-select-task.component.spec.ts b/projects/social_platform/src/app/ui/pages/courses/lesson/shared/radio-select-task/radio-select-task.component.spec.ts similarity index 86% rename from projects/social_platform/src/app/office/courses/lesson/shared/radio-select-task/radio-select-task.component.spec.ts rename to projects/social_platform/src/app/ui/pages/courses/lesson/shared/radio-select-task/radio-select-task.component.spec.ts index b9990be75..d4853d778 100644 --- a/projects/social_platform/src/app/office/courses/lesson/shared/radio-select-task/radio-select-task.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/courses/lesson/shared/radio-select-task/radio-select-task.component.spec.ts @@ -1,7 +1,6 @@ /** @format */ import { ComponentFixture, TestBed } from "@angular/core/testing"; - import { RadioSelectTaskComponent } from "./radio-select-task.component"; describe("RadioSelectTaskComponent", () => { @@ -15,6 +14,11 @@ describe("RadioSelectTaskComponent", () => { fixture = TestBed.createComponent(RadioSelectTaskComponent); component = fixture.componentInstance; + fixture.componentRef.setInput("data", { + id: 1, + text: "Test", + options: [], + }); fixture.detectChanges(); }); diff --git a/projects/social_platform/src/app/ui/pages/courses/lesson/shared/radio-select-task/radio-select-task.component.ts b/projects/social_platform/src/app/ui/pages/courses/lesson/shared/radio-select-task/radio-select-task.component.ts new file mode 100644 index 000000000..974a5558b --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/courses/lesson/shared/radio-select-task/radio-select-task.component.ts @@ -0,0 +1,92 @@ +/** @format */ + +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + EventEmitter, + inject, + input, + Input, + OnInit, + output, + Output, + signal, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { DomSanitizer, SafeResourceUrl } from "@angular/platform-browser"; +import { Task } from "@domain/courses/courses.model"; +import { resolveVideoUrlForIframe } from "@utils/video-url-embed"; +import { animateContentHeight } from "@utils/animate-content-height"; +import { isHtmlTextTruncated } from "@utils/is-html-text-truncated"; +import { FileItemComponent } from "@ui/primitives/file-item/file-item.component"; +import { ImagePreviewDirective } from "../image-preview/image-preview.directive"; +import { TruncateHtmlPipe, TruncatePipe } from "@core/public-api"; + +/** Компонент задачи с одним вариантом ответа и локальным сбросом выбора при ошибке. */ +@Component({ + selector: "app-radio-select-task", + imports: [CommonModule, TruncatePipe, TruncateHtmlPipe, FileItemComponent, ImagePreviewDirective], + templateUrl: "./radio-select-task.component.html", + styleUrl: "./radio-select-task.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RadioSelectTaskComponent implements OnInit { + private readonly cdRef = inject(ChangeDetectorRef); + + readonly data = input.required(); + readonly success = input(false); + readonly hint = input(""); + readonly disabled = input(false); + + @Input() + set error(value: boolean) { + this._error.set(value); + + if (value) { + setTimeout(() => { + // Ошибочный ответ сбрасывает выбранный вариант. + this.result.set({ answerId: null }); + this._error.set(false); + }, 1000); + } + } + + get error() { + return this._error(); + } + + readonly update = output<{ answerId: number }>(); + + result = signal<{ answerId: number | null }>({ answerId: null }); + _error = signal(false); + readFullDescription = false; + cachedVideoUrl: SafeResourceUrl | null = null; + readonly truncateLimit = 700; + + get descriptionExpandable(): boolean { + return isHtmlTextTruncated(this.data()?.bodyText, this.truncateLimit); + } + + constructor(private sanitizer: DomSanitizer) {} + + ngOnInit(): void { + const iframeUrl = resolveVideoUrlForIframe(this.data()?.videoUrl); + this.cachedVideoUrl = iframeUrl + ? this.sanitizer.bypassSecurityTrustResourceUrl(iframeUrl) + : null; + } + + onToggleDescription(elem: HTMLElement): void { + animateContentHeight(elem, () => { + this.readFullDescription = !this.readFullDescription; + this.cdRef.detectChanges(); + }); + } + + onSelect(id: number) { + if (this.disabled()) return; + this.result.set({ answerId: id }); + this.update.emit({ answerId: id }); + } +} diff --git a/projects/social_platform/src/app/ui/pages/courses/lesson/shared/video-task/info-task.component.html b/projects/social_platform/src/app/ui/pages/courses/lesson/shared/video-task/info-task.component.html new file mode 100644 index 000000000..8a50a08ef --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/courses/lesson/shared/video-task/info-task.component.html @@ -0,0 +1,65 @@ + + +
    +
    + @if ((data().videoUrl || data().attachmentUrl) && !data().imageUrl) { +

    задание {{ data().order }}

    + } + +
    + @if (cachedVideoUrl) { + + } + @if (data().imageUrl) { + + } + +
    + @if (!data().videoUrl) { +

    задание {{ data().order }}

    + } + +

    + {{ data().title | truncate: (cachedVideoUrl ? 80 : data().imageUrl ? 100 : 200) }} +

    +
    + @if (descriptionExpandable) { +
    + {{ readFullDescription ? "скрыть" : "подробнее" }} +
    + } + @if (data().attachmentUrl) { + + } +
    +
    +
    +
    diff --git a/projects/social_platform/src/app/office/courses/lesson/shared/video-task/info-task.component.scss b/projects/social_platform/src/app/ui/pages/courses/lesson/shared/video-task/info-task.component.scss similarity index 100% rename from projects/social_platform/src/app/office/courses/lesson/shared/video-task/info-task.component.scss rename to projects/social_platform/src/app/ui/pages/courses/lesson/shared/video-task/info-task.component.scss diff --git a/projects/social_platform/src/app/office/courses/lesson/shared/video-task/info-task.component.spec.ts b/projects/social_platform/src/app/ui/pages/courses/lesson/shared/video-task/info-task.component.spec.ts similarity index 80% rename from projects/social_platform/src/app/office/courses/lesson/shared/video-task/info-task.component.spec.ts rename to projects/social_platform/src/app/ui/pages/courses/lesson/shared/video-task/info-task.component.spec.ts index 5f50e0add..b842def9b 100644 --- a/projects/social_platform/src/app/office/courses/lesson/shared/video-task/info-task.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/courses/lesson/shared/video-task/info-task.component.spec.ts @@ -1,7 +1,6 @@ /** @format */ import { ComponentFixture, TestBed } from "@angular/core/testing"; - import { InfoTaskComponent } from "./info-task.component"; describe("VideoTaskComponent", () => { @@ -15,6 +14,12 @@ describe("VideoTaskComponent", () => { fixture = TestBed.createComponent(InfoTaskComponent); component = fixture.componentInstance; + fixture.componentRef.setInput("data", { + id: 1, + text: "Test", + videoUrl: "https://example.com/video.mp4", + bodyText: "", + }); fixture.detectChanges(); }); diff --git a/projects/social_platform/src/app/ui/pages/courses/lesson/shared/video-task/info-task.component.ts b/projects/social_platform/src/app/ui/pages/courses/lesson/shared/video-task/info-task.component.ts new file mode 100644 index 000000000..e3260bbbd --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/courses/lesson/shared/video-task/info-task.component.ts @@ -0,0 +1,56 @@ +/** @format */ + +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + inject, + input, + OnInit, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { DomSanitizer, SafeResourceUrl } from "@angular/platform-browser"; +import { resolveVideoUrlForIframe } from "@utils/video-url-embed"; +import { animateContentHeight } from "@utils/animate-content-height"; +import { isHtmlTextTruncated } from "@utils/is-html-text-truncated"; +import { ImagePreviewDirective } from "../image-preview/image-preview.directive"; +import { Task } from "@domain/courses/courses.model"; +import { FileItemComponent } from "@ui/primitives/file-item/file-item.component"; +import { TruncatePipe, TruncateHtmlPipe } from "@corelib"; + +/** Информационный слайд задачи с видео и текстом. */ +@Component({ + selector: "app-info-task", + imports: [CommonModule, TruncateHtmlPipe, TruncatePipe, ImagePreviewDirective, FileItemComponent], + templateUrl: "./info-task.component.html", + styleUrl: "./info-task.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class InfoTaskComponent implements OnInit { + readonly data = input.required(); + + private readonly sanitizer = inject(DomSanitizer); + private readonly cdRef = inject(ChangeDetectorRef); + + readFullDescription = false; + cachedVideoUrl: SafeResourceUrl | null = null; + readonly truncateLimit = 700; + + get descriptionExpandable(): boolean { + return isHtmlTextTruncated(this.data()?.bodyText, this.truncateLimit); + } + + ngOnInit(): void { + const iframeUrl = resolveVideoUrlForIframe(this.data()?.videoUrl); + this.cachedVideoUrl = iframeUrl + ? this.sanitizer.bypassSecurityTrustResourceUrl(iframeUrl) + : null; + } + + onToggleDescription(elem: HTMLElement): void { + animateContentHeight(elem, () => { + this.readFullDescription = !this.readFullDescription; + this.cdRef.detectChanges(); + }); + } +} diff --git a/projects/social_platform/src/app/ui/pages/courses/lesson/shared/write-task/write-task.component.html b/projects/social_platform/src/app/ui/pages/courses/lesson/shared/write-task/write-task.component.html new file mode 100644 index 000000000..4bd20e8f7 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/courses/lesson/shared/write-task/write-task.component.html @@ -0,0 +1,94 @@ + + +
    +
    +

    задание {{ data().order }}

    + @if (!cachedVideoUrl && !data().imageUrl) { +

    {{ data().title | truncate: 80 }}

    + } +
    + @if (cachedVideoUrl) { + + } @else if (data().imageUrl) { +
    + +

    {{ data().title | truncate: 50 }}

    +
    + } +

    + @if (descriptionExpandable) { +
    + {{ readFullDescription ? "скрыть" : "подробнее" }} +
    + } + @if (type() === "text-file" && data().attachmentUrl) { + + } +
    +
    + +
    +

    ответ

    +
    + +
    +

    + {{ currentLength() }} / {{ maxLength }} +

    +
    +
    + + @if (type() === "text-file" && !disabled()) { +
    + +
    + +

    загрузите файл до 100 MB

    +
    +
    + + @for (file of uploadedFiles(); track $index) { + + } +
    + } +
    +
    diff --git a/projects/social_platform/src/app/office/courses/lesson/shared/write-task/write-task.component.scss b/projects/social_platform/src/app/ui/pages/courses/lesson/shared/write-task/write-task.component.scss similarity index 100% rename from projects/social_platform/src/app/office/courses/lesson/shared/write-task/write-task.component.scss rename to projects/social_platform/src/app/ui/pages/courses/lesson/shared/write-task/write-task.component.scss diff --git a/projects/social_platform/src/app/ui/pages/courses/lesson/shared/write-task/write-task.component.spec.ts b/projects/social_platform/src/app/ui/pages/courses/lesson/shared/write-task/write-task.component.spec.ts new file mode 100644 index 000000000..037a26b1e --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/courses/lesson/shared/write-task/write-task.component.spec.ts @@ -0,0 +1,31 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { WriteTaskComponent } from "./write-task.component"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { API_URL, PRODUCTION } from "@corelib"; + +describe("WriteTaskComponent", () => { + let component: WriteTaskComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [WriteTaskComponent, HttpClientTestingModule], + providers: [ + { provide: API_URL, useValue: "" }, + { provide: PRODUCTION, useValue: false }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(WriteTaskComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput("data", { order: 1, bodyText: "test", videoUrl: "" } as any); + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/courses/lesson/shared/write-task/write-task.component.ts b/projects/social_platform/src/app/ui/pages/courses/lesson/shared/write-task/write-task.component.ts new file mode 100644 index 000000000..537505df4 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/courses/lesson/shared/write-task/write-task.component.ts @@ -0,0 +1,143 @@ +/** @format */ + +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + EventEmitter, + inject, + input, + Input, + OnInit, + output, + Output, + signal, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { DomSanitizer, SafeResourceUrl } from "@angular/platform-browser"; +import { UploadFileComponent } from "@ui/primitives/upload-file/upload-file.component"; +import { IconComponent } from "@ui/primitives"; +import { FileItemComponent } from "@ui/primitives/file-item/file-item.component"; +import { FileService } from "@core/lib/services/file/file.service"; +import { Task } from "@domain/courses/courses.model"; +import { FileModel } from "@domain/file/file.model"; +import { resolveVideoUrlForIframe } from "@utils/video-url-embed"; +import { animateContentHeight } from "@utils/animate-content-height"; +import { isHtmlTextTruncated } from "@utils/is-html-text-truncated"; +import { ImagePreviewDirective } from "../image-preview/image-preview.directive"; +import { TruncateHtmlPipe, TruncatePipe } from "@corelib"; + +/** Поле ответа для текстовой задачи и задачи с текстом + файлами. */ +@Component({ + selector: "app-write-task", + imports: [ + CommonModule, + TruncatePipe, + TruncateHtmlPipe, + UploadFileComponent, + IconComponent, + FileItemComponent, + ImagePreviewDirective, + ], + templateUrl: "./write-task.component.html", + styleUrl: "./write-task.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class WriteTaskComponent implements OnInit { + private readonly fileService = inject(FileService); + private readonly sanitizer = inject(DomSanitizer); + private readonly cdRef = inject(ChangeDetectorRef); + + readonly data = input.required(); + readonly success = input(false); + readonly disabled = input(false); + readonly type = input<"text" | "text-file">("text"); + + readonly update = output<{ text: string; fileUrls?: string[] }>(); + + readonly maxLength = 1000; + + uploadedFiles = signal([]); + currentLength = signal(0); + readFullDescription = false; + cachedVideoUrl: SafeResourceUrl | null = null; + private currentText = ""; + + get truncateLimit(): number { + return this.type() === "text-file" ? 650 : 700; + } + + get descriptionExpandable(): boolean { + return isHtmlTextTruncated(this.data()?.bodyText, this.truncateLimit); + } + + ngOnInit(): void { + const iframeUrl = resolveVideoUrlForIframe(this.data()?.videoUrl); + this.cachedVideoUrl = iframeUrl + ? this.sanitizer.bypassSecurityTrustResourceUrl(iframeUrl) + : null; + } + + onToggleDescription(elem: HTMLElement): void { + animateContentHeight(elem, () => { + this.readFullDescription = !this.readFullDescription; + this.cdRef.detectChanges(); + }); + } + + onKeyUp(event: Event) { + const target = event.target as HTMLTextAreaElement; + + target.style.height = "0px"; + target.style.height = target.scrollHeight + "px"; + + this.currentText = target.value; + this.currentLength.set(target.value.length); + this.emitUpdate(); + } + + onFileUploaded(event: { url: string; name: string; size: number; mimeType: string }) { + if (!event.url) return; + + const ext = event.name.split(".").pop()?.toLowerCase() || ""; + const file: FileModel = { + name: event.name, + size: event.size, + mimeType: event.mimeType, + link: event.url, + extension: ext, + datetimeUploaded: new Date().toISOString(), + user: 0, + }; + + this.uploadedFiles.update(files => [...files, file]); + this.emitUpdate(); + } + + onFileRemoved(index: number) { + const file = this.uploadedFiles()[index]; + if (!file) return; + + this.fileService.deleteFile(file.link).subscribe({ + next: () => { + this.uploadedFiles.update(files => files.filter((_, i) => i !== index)); + this.emitUpdate(); + }, + error: () => { + this.uploadedFiles.update(files => files.filter((_, i) => i !== index)); + this.emitUpdate(); + }, + }); + } + + private emitUpdate() { + if (this.type() === "text-file") { + this.update.emit({ + text: this.currentText, + fileUrls: this.uploadedFiles().map(f => f.link), + }); + } else { + this.update.emit({ text: this.currentText }); + } + } +} diff --git a/projects/social_platform/src/app/ui/pages/courses/list/course/course.component.html b/projects/social_platform/src/app/ui/pages/courses/list/course/course.component.html new file mode 100644 index 000000000..5b69e0ea0 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/courses/list/course/course.component.html @@ -0,0 +1,42 @@ + + +@if (course()) { +
    +
    + + + {{ + isMember() + ? "для участников программы" + : isSubs() + ? "доступно по подписке" + : "доступно всем пользователям" + }} + + + course-cover +
    + +
    +

    {{ course().title | truncate: 50 }}

    +

    + {{ course().dateLabel }} +

    +
    + +
    + @if (isLock()) { + + } @else { +

    + {{ course().progressStatus === "not_started" ? "начать" : "продолжить обучение" }} +

    + + } +
    +
    +} diff --git a/projects/social_platform/src/app/office/courses/shared/course/course.component.scss b/projects/social_platform/src/app/ui/pages/courses/list/course/course.component.scss similarity index 96% rename from projects/social_platform/src/app/office/courses/shared/course/course.component.scss rename to projects/social_platform/src/app/ui/pages/courses/list/course/course.component.scss index 5f73c704f..682b0e4ad 100644 --- a/projects/social_platform/src/app/office/courses/shared/course/course.component.scss +++ b/projects/social_platform/src/app/ui/pages/courses/list/course/course.component.scss @@ -41,11 +41,12 @@ } &__image { - width: 100%; + width: 333px; max-width: 333px; - height: 100%; + height: 137px; max-height: 137px; border-radius: var(--rounded-lg); + object-fit: cover; } &__info { diff --git a/projects/social_platform/src/app/office/courses/shared/course/course.component.spec.ts b/projects/social_platform/src/app/ui/pages/courses/list/course/course.component.spec.ts similarity index 76% rename from projects/social_platform/src/app/office/courses/shared/course/course.component.spec.ts rename to projects/social_platform/src/app/ui/pages/courses/list/course/course.component.spec.ts index ab7e087af..085cae948 100644 --- a/projects/social_platform/src/app/office/courses/shared/course/course.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/courses/list/course/course.component.spec.ts @@ -14,6 +14,14 @@ describe("CourseComponent", () => { fixture = TestBed.createComponent(CourseComponent); component = fixture.componentInstance; + fixture.componentRef.setInput("course", { + id: 1, + name: "Test", + description: "", + accessType: "free", + actionState: "active", + skills: [], + }); fixture.detectChanges(); }); diff --git a/projects/social_platform/src/app/office/courses/shared/course/course.component.ts b/projects/social_platform/src/app/ui/pages/courses/list/course/course.component.ts similarity index 75% rename from projects/social_platform/src/app/office/courses/shared/course/course.component.ts rename to projects/social_platform/src/app/ui/pages/courses/list/course/course.component.ts index 166337651..d7269b4a8 100644 --- a/projects/social_platform/src/app/office/courses/shared/course/course.component.ts +++ b/projects/social_platform/src/app/ui/pages/courses/list/course/course.component.ts @@ -1,12 +1,12 @@ /** @format */ import { CommonModule } from "@angular/common"; -import { Component, Input, OnInit, signal } from "@angular/core"; +import { ChangeDetectionStrategy, Component, input, Input, OnInit, signal } from "@angular/core"; import { RouterModule } from "@angular/router"; -import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; -import { IconComponent, ButtonComponent } from "@ui/components"; -import { AvatarComponent } from "@ui/components/avatar/avatar.component"; -import { CourseCard } from "@office/models/courses.model"; +import { IconComponent, ButtonComponent } from "@ui/primitives"; +import { AvatarComponent } from "@ui/primitives/avatar/avatar.component"; +import { CourseCard } from "@domain/courses/courses.model"; +import { TruncatePipe } from "@corelib"; /** * Компонент отображения карточки траектории @@ -18,7 +18,6 @@ import { CourseCard } from "@office/models/courses.model"; */ @Component({ selector: "app-course", - standalone: true, imports: [ CommonModule, RouterModule, @@ -26,13 +25,13 @@ import { CourseCard } from "@office/models/courses.model"; IconComponent, AvatarComponent, ButtonComponent, - IconComponent, ], templateUrl: "./course.component.html", styleUrl: "./course.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, }) export class CourseComponent implements OnInit { - @Input() course!: CourseCard; + readonly course = input.required(); ngOnInit(): void { this.accessType(); @@ -45,7 +44,7 @@ export class CourseComponent implements OnInit { protected readonly isSubs = signal(false); private accessType() { - switch (this.course.accessType) { + switch (this.course().accessType) { case "program_members": { this.isMember.set(true); break; @@ -58,7 +57,7 @@ export class CourseComponent implements OnInit { } private actions() { - switch (this.course.actionState) { + switch (this.course().actionState) { case "lock": { this.isLock.set(true); break; diff --git a/projects/social_platform/src/app/ui/pages/courses/list/list.component.html b/projects/social_platform/src/app/ui/pages/courses/list/list.component.html new file mode 100644 index 000000000..d6735a9e6 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/courses/list/list.component.html @@ -0,0 +1,17 @@ + + +
    + @if (loading()) { +
    + +
    + } @else if (coursesList().length) { +
    + @for (course of coursesList(); track course.id) { + + + + } +
    + } +
    diff --git a/projects/social_platform/src/app/office/courses/list/list.component.scss b/projects/social_platform/src/app/ui/pages/courses/list/list.component.scss similarity index 100% rename from projects/social_platform/src/app/office/courses/list/list.component.scss rename to projects/social_platform/src/app/ui/pages/courses/list/list.component.scss diff --git a/projects/social_platform/src/app/ui/pages/courses/list/list.component.ts b/projects/social_platform/src/app/ui/pages/courses/list/list.component.ts new file mode 100644 index 000000000..1028a6292 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/courses/list/list.component.ts @@ -0,0 +1,33 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, inject, OnInit } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { RouterModule } from "@angular/router"; +import { CourseComponent } from "./course/course.component"; +import { LoaderComponent } from "@ui/primitives/loader/loader.component"; +import { CoursesListInfoService } from "@api/courses/facades/courses-list-info.service"; +import { CoursesListUIInfoService } from "@api/courses/facades/ui/courses-list-ui-info.service"; +import { AppRoutes } from "@api/paths/app-routes"; + +/** Страница списка курсов. */ +@Component({ + selector: "app-list", + imports: [CommonModule, RouterModule, CourseComponent, LoaderComponent], + templateUrl: "./list.component.html", + styleUrl: "./list.component.scss", + providers: [CoursesListInfoService, CoursesListUIInfoService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CoursesListComponent implements OnInit { + private readonly coursesListInfoService = inject(CoursesListInfoService); + private readonly coursesListUIInfoService = inject(CoursesListUIInfoService); + + protected readonly coursesList = this.coursesListUIInfoService.coursesList; + protected readonly loading = this.coursesListUIInfoService.loading; + + protected readonly AppRoutes = AppRoutes; + + ngOnInit(): void { + this.coursesListInfoService.init(); + } +} diff --git a/projects/social_platform/src/app/error/code/error-code.component.html b/projects/social_platform/src/app/ui/pages/error/error-code/error-code.component.html similarity index 100% rename from projects/social_platform/src/app/error/code/error-code.component.html rename to projects/social_platform/src/app/ui/pages/error/error-code/error-code.component.html diff --git a/projects/social_platform/src/app/error/code/error-code.component.scss b/projects/social_platform/src/app/ui/pages/error/error-code/error-code.component.scss similarity index 100% rename from projects/social_platform/src/app/error/code/error-code.component.scss rename to projects/social_platform/src/app/ui/pages/error/error-code/error-code.component.scss diff --git a/projects/social_platform/src/app/error/code/error-code.component.spec.ts b/projects/social_platform/src/app/ui/pages/error/error-code/error-code.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/error/code/error-code.component.spec.ts rename to projects/social_platform/src/app/ui/pages/error/error-code/error-code.component.spec.ts diff --git a/projects/social_platform/src/app/ui/pages/error/error-code/error-code.component.ts b/projects/social_platform/src/app/ui/pages/error/error-code/error-code.component.ts new file mode 100644 index 000000000..dd4ae9601 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/error/error-code/error-code.component.ts @@ -0,0 +1,23 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, OnInit } from "@angular/core"; +import { ActivatedRoute, RouterLink } from "@angular/router"; +import { map } from "rxjs"; +import { AsyncPipe } from "@angular/common"; + +/** Отображает страницу ошибки с динамическим кодом из URL. */ +@Component({ + selector: "app-code", + templateUrl: "./error-code.component.html", + styleUrl: "./error-code.component.scss", + imports: [RouterLink, AsyncPipe], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ErrorCodeComponent implements OnInit { + // Observable с кодом ошибки, извлеченным из URL параметра 'code' + errorCode = this.activatedRoute.params.pipe(map(r => r["code"])); + + constructor(private readonly activatedRoute: ActivatedRoute) {} + + ngOnInit(): void {} +} diff --git a/projects/social_platform/src/app/ui/pages/error/error.component.html b/projects/social_platform/src/app/ui/pages/error/error.component.html new file mode 100644 index 000000000..2768edaf3 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/error/error.component.html @@ -0,0 +1,15 @@ + + +
    + + +
    diff --git a/projects/social_platform/src/app/error/error.component.scss b/projects/social_platform/src/app/ui/pages/error/error.component.scss similarity index 100% rename from projects/social_platform/src/app/error/error.component.scss rename to projects/social_platform/src/app/ui/pages/error/error.component.scss diff --git a/projects/social_platform/src/app/error/error.component.spec.ts b/projects/social_platform/src/app/ui/pages/error/error.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/error/error.component.spec.ts rename to projects/social_platform/src/app/ui/pages/error/error.component.spec.ts diff --git a/projects/social_platform/src/app/ui/pages/error/error.component.ts b/projects/social_platform/src/app/ui/pages/error/error.component.ts new file mode 100644 index 000000000..0ff421629 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/error/error.component.ts @@ -0,0 +1,18 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, OnInit } from "@angular/core"; +import { RouterLink, RouterOutlet } from "@angular/router"; + +/** Контейнер страниц ошибок с общим layout. */ +@Component({ + selector: "app-error", + templateUrl: "./error.component.html", + styleUrl: "./error.component.scss", + imports: [RouterLink, RouterOutlet], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ErrorComponent implements OnInit { + constructor() {} + + ngOnInit(): void {} +} diff --git a/projects/social_platform/src/app/ui/pages/error/not-found/error-not-found.component.html b/projects/social_platform/src/app/ui/pages/error/not-found/error-not-found.component.html new file mode 100644 index 000000000..c363faabe --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/error/not-found/error-not-found.component.html @@ -0,0 +1,13 @@ + + +
    +
    + Страница не найдена +

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

    +
    +
    diff --git a/projects/social_platform/src/app/error/not-found/error-not-found.component.scss b/projects/social_platform/src/app/ui/pages/error/not-found/error-not-found.component.scss similarity index 100% rename from projects/social_platform/src/app/error/not-found/error-not-found.component.scss rename to projects/social_platform/src/app/ui/pages/error/not-found/error-not-found.component.scss diff --git a/projects/social_platform/src/app/error/not-found/error-not-found.component.spec.ts b/projects/social_platform/src/app/ui/pages/error/not-found/error-not-found.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/error/not-found/error-not-found.component.spec.ts rename to projects/social_platform/src/app/ui/pages/error/not-found/error-not-found.component.spec.ts diff --git a/projects/social_platform/src/app/ui/pages/error/not-found/error-not-found.component.ts b/projects/social_platform/src/app/ui/pages/error/not-found/error-not-found.component.ts new file mode 100644 index 000000000..d240eed5a --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/error/not-found/error-not-found.component.ts @@ -0,0 +1,17 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, OnInit } from "@angular/core"; + +/** Страница ошибки 404. */ +@Component({ + selector: "app-not-found", + templateUrl: "./error-not-found.component.html", + styleUrl: "./error-not-found.component.scss", + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ErrorNotFoundComponent implements OnInit { + constructor() {} + + ngOnInit(): void {} +} diff --git a/projects/social_platform/src/app/ui/pages/feed/feed.component.html b/projects/social_platform/src/app/ui/pages/feed/feed.component.html new file mode 100644 index 000000000..bf435a607 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/feed/feed.component.html @@ -0,0 +1,38 @@ + + +
    + +
    + @if (feedItems().length > 0) { + @for (item of feedItems(); track item.content.id) { + @if (item.typeModel === "vacancy") { + + } @else if (item.typeModel === "project") { + + } @else if (item.typeModel === "news") { + + } + } + } @else { +
    + +

    в данном разделе пока нет новостей

    +
    + } +
    +
    diff --git a/projects/social_platform/src/app/office/feed/feed.component.scss b/projects/social_platform/src/app/ui/pages/feed/feed.component.scss similarity index 100% rename from projects/social_platform/src/app/office/feed/feed.component.scss rename to projects/social_platform/src/app/ui/pages/feed/feed.component.scss diff --git a/projects/social_platform/src/app/ui/pages/feed/feed.component.ts b/projects/social_platform/src/app/ui/pages/feed/feed.component.ts new file mode 100644 index 000000000..764ac0709 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/feed/feed.component.ts @@ -0,0 +1,79 @@ +/** @format */ + +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + computed, + ElementRef, + inject, + OnDestroy, + OnInit, + viewChild, + ViewChild, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { NewsCardComponent } from "@ui/widgets/news-card/news-card.component"; +import { OpenVacancyComponent } from "./open-vacancy/open-vacancy.component"; +import { IconComponent } from "@ui/primitives"; +import { NewProjectComponent } from "./new-project/new-project.component"; +import { FeedFilterComponent } from "@ui/widgets/feed-filter/feed-filter.component"; +import { FeedInfoService } from "@api/feed/facades/feed-info.service"; +import { FeedUIInfoService } from "@api/feed/facades/ui/feed-ui-info.service"; +import { AppRoutes } from "@api/paths/app-routes"; +import { ProjectTeamUIService } from "@api/project/facades/edit/ui/project-team-ui.service"; + +/** Страница ленты активности. */ +@Component({ + selector: "app-feed", + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: "./feed.component.html", + styleUrl: "./feed.component.scss", + imports: [ + CommonModule, + IconComponent, + NewProjectComponent, + FeedFilterComponent, + NewsCardComponent, + OpenVacancyComponent, + IconComponent, + ], + providers: [FeedInfoService, FeedUIInfoService, ProjectTeamUIService], +}) +export class FeedComponent implements OnInit, AfterViewInit, OnDestroy { + readonly feedRoot = viewChild>("feedRoot"); + + private readonly feedInfoService = inject(FeedInfoService); + private readonly feedUIInfoService = inject(FeedUIInfoService); + + protected readonly feedItems = this.feedUIInfoService.feedItems; + protected readonly AppRoutes = AppRoutes; + + /** Ссылка-маршрут для карточки новости в зависимости от типа источника. */ + protected resourceLink(content: any): (string | number)[] { + if (content.contentObject && "email" in content.contentObject) { + return [AppRoutes.profile.detail(content.contentObject.id)]; + } + + return [AppRoutes.projects.detail(content.contentObject.id)]; + } + + ngOnInit() { + this.feedInfoService.initializationFeedNews(this.feedRoot()!); + } + + ngAfterViewInit() { + const target = document.querySelector(".office__body") as HTMLElement; + if (target || this.feedRoot) { + this.feedInfoService.initScroll(target, this.feedRoot()!); + } + } + + ngOnDestroy() { + this.feedInfoService.destroy(); + } + + onLike(newsId: number) { + this.feedInfoService.onLike(newsId); + } +} diff --git a/projects/social_platform/src/app/ui/pages/feed/feed.resolver.ts b/projects/social_platform/src/app/ui/pages/feed/feed.resolver.ts new file mode 100644 index 000000000..108db63a3 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/feed/feed.resolver.ts @@ -0,0 +1,33 @@ +/** @format */ + +import { inject } from "@angular/core"; +import { ResolveFn } from "@angular/router"; +import { map } from "rxjs"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { FeedItem, FeedItemType } from "@domain/feed/feed-item.model"; +import { FetchFeedUseCase } from "@api/feed/use-cases/fetch-feed.use-case"; +import { FILTER_SPLIT_SYMBOL } from "@core/consts/other/filter-split-symbol.const"; + +const DEFAULT_FEED_TYPES: FeedItemType[] = ["vacancy", "news", "project"]; + +/** Предзагружает ленту новостей. */ +export const FeedResolver: ResolveFn> = route => { + const fetchFeedUseCase = inject(FetchFeedUseCase); + + // Загружаем первую страницу ленты (offset: 0, limit: 20) + // По умолчанию включаем вакансии, новости и проекты + return fetchFeedUseCase + .execute(0, 20, route.queryParams["includes"] ?? DEFAULT_FEED_TYPES.join(FILTER_SPLIT_SYMBOL)) + .pipe( + map(result => + result.ok + ? result.value + : { + count: 0, + results: [], + next: "", + previous: "", + }, + ), + ); +}; diff --git a/projects/social_platform/src/app/ui/pages/feed/new-project/new-project.component.html b/projects/social_platform/src/app/ui/pages/feed/new-project/new-project.component.html new file mode 100644 index 000000000..efbf27e9e --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/feed/new-project/new-project.component.html @@ -0,0 +1,63 @@ + + +
    +
    + + +
    +
    +

    + {{ feedItem().name | truncate: 30 }} +

    + +
    + +
    +

    + @if (industryRepository.getOne(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/ui/pages/feed/new-project/new-project.component.scss similarity index 100% rename from projects/social_platform/src/app/office/feed/shared/new-project/new-project.component.scss rename to projects/social_platform/src/app/ui/pages/feed/new-project/new-project.component.scss diff --git a/projects/social_platform/src/app/ui/pages/feed/new-project/new-project.component.ts b/projects/social_platform/src/app/ui/pages/feed/new-project/new-project.component.ts new file mode 100644 index 000000000..ddb19c4fd --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/feed/new-project/new-project.component.ts @@ -0,0 +1,28 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, input, Input } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { ButtonComponent, IconComponent } from "@ui/primitives"; +import { AvatarComponent } from "@ui/primitives/avatar/avatar.component"; +import { Router, RouterLink } from "@angular/router"; +import { TagComponent } from "@ui/primitives/tag/tag.component"; +import { TruncatePipe, DayjsPipe } from "@corelib"; +import { FeedProject } from "@domain/feed/feed-item.model"; +import { AppRoutes } from "@api/paths/app-routes"; +import { IndustryRepositoryPort } from "@domain/industry/ports/industry.repository.port"; + +/** Карточка нового проекта в ленте новостей. */ +@Component({ + selector: "app-new-project", + imports: [CommonModule, ButtonComponent, AvatarComponent, RouterLink, TruncatePipe, TagComponent], + templateUrl: "./new-project.component.html", + styleUrl: "./new-project.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NewProjectComponent { + readonly feedItem = input.required(); + + protected readonly AppRoutes = AppRoutes; + + constructor(public readonly industryRepository: IndustryRepositoryPort) {} +} diff --git a/projects/social_platform/src/app/ui/pages/feed/open-vacancy/open-vacancy.component.html b/projects/social_platform/src/app/ui/pages/feed/open-vacancy/open-vacancy.component.html new file mode 100644 index 000000000..0b903ae13 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/feed/open-vacancy/open-vacancy.component.html @@ -0,0 +1,113 @@ + + +@if (feedItem()) { +
    +
    + vacancy-card-background + @if (feedItem().project; as project) { +
    + + +

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

    + + @if (industryRepositoryGetOne(project.industry); as industry) { + + {{ industry.name }} + + } +
    + } + @if (feedItem(); as vacancy) { +
    +
    +

    + {{ vacancy.role | truncate: 30 }} +

    +

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

    +
    + +
    + @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 (requiredSkills) { +
      + @for (skill of requiredSkills.slice(8); track $index) { + {{ skill.name }} + } +
    + } +
    + } + @if (skillsLength > 8) { +
    + {{ readFullSkills ? "cкрыть" : "подробнее" }} +
    + } + } +
    + + @if (feedItem().description) { +
    +
    +

    + @if (descriptionExpandable()) { +
    + {{ readFullDescription() ? "скрыть" : "подробнее" }} +
    + } +
    +
    + } +
    + } +
    + + @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/ui/pages/feed/open-vacancy/open-vacancy.component.scss similarity index 100% rename from projects/social_platform/src/app/office/feed/shared/open-vacancy/open-vacancy.component.scss rename to projects/social_platform/src/app/ui/pages/feed/open-vacancy/open-vacancy.component.scss diff --git a/projects/social_platform/src/app/ui/pages/feed/open-vacancy/open-vacancy.component.ts b/projects/social_platform/src/app/ui/pages/feed/open-vacancy/open-vacancy.component.ts new file mode 100644 index 000000000..b8210a370 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/feed/open-vacancy/open-vacancy.component.ts @@ -0,0 +1,77 @@ +/** @format */ + +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + ElementRef, + inject, + input, + Input, + viewChild, + ViewChild, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { ButtonComponent } from "@ui/primitives"; +import { Router, RouterLink } from "@angular/router"; +import { TagComponent } from "@ui/primitives/tag/tag.component"; +import { Vacancy } from "@domain/vacancy/vacancy.model"; +import { AvatarComponent } from "@ui/primitives/avatar/avatar.component"; +import { TruncatePipe, DayjsPipe, ParseBreaksPipe, ParseLinksPipe } from "@corelib"; +import { AppRoutes } from "@api/paths/app-routes"; +import { IndustryRepositoryPort } from "@domain/industry/ports/industry.repository.port"; +import { ExpandService } from "@api/expand/expand.service"; + +/** Карточка вакансии в ленте с поддержкой разворачивания контента. */ +@Component({ + selector: "app-open-vacancy", + imports: [ + CommonModule, + ButtonComponent, + RouterLink, + TagComponent, + DayjsPipe, + ParseLinksPipe, + ParseBreaksPipe, + TruncatePipe, + AvatarComponent, + ], + templateUrl: "./open-vacancy.component.html", + styleUrl: "./open-vacancy.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ExpandService], +}) +export class OpenVacancyComponent implements AfterViewInit { + readonly feedItem = input.required(); + + private readonly expandService = inject(ExpandService); + protected readonly AppRoutes = AppRoutes; + private readonly industryRepository = inject(IndustryRepositoryPort); + + private descEl = viewChild("descEl"); + + readFullSkills = false; + + protected readonly descriptionExpandable = this.expandService.descriptionExpandable; + protected readonly readFullDescription = this.expandService.readFullDescription; + protected readonly industryRepositoryGetOne = (id: number) => this.industryRepository.getOne(id); + + ngAfterViewInit(): void { + setTimeout(() => { + this.expandService.checkExpandable("description", true, this.descEl()); + }); + } + + protected onExpandDescription(elem: HTMLElement): void { + this.expandService.onExpand( + "description", + elem, + "expanded", + this.expandService.readFullDescription(), + ); + } + + protected toggleSkills(): void { + this.readFullSkills = !this.readFullSkills; + } +} diff --git a/projects/social_platform/src/app/office/members/filters/members-filters.component.html b/projects/social_platform/src/app/ui/pages/members/members-filters/members-filters.component.html similarity index 98% rename from projects/social_platform/src/app/office/members/filters/members-filters.component.html rename to projects/social_platform/src/app/ui/pages/members/members-filters/members-filters.component.html index 7e53a8c1a..e905af83d 100644 --- a/projects/social_platform/src/app/office/members/filters/members-filters.component.html +++ b/projects/social_platform/src/app/ui/pages/members/members-filters/members-filters.component.html @@ -7,7 +7,7 @@
    -
    +
    { + let component: MembersFiltersComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const searchesServiceSpy = { + inlineSpecs: signal([]), + inlineSkills: signal([]), + onSearchSpec: vi.fn(), + onSearchSkill: vi.fn(), + }; + + const loggerServiceSpy = { + info: vi.fn(), + }; + + await TestBed.configureTestingModule({ + imports: [MembersFiltersComponent, HttpClientTestingModule], + providers: [ + provideRouter([]), + { provide: SearchesService, useValue: searchesServiceSpy }, + { provide: LoggerService, useValue: loggerServiceSpy }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(MembersFiltersComponent); + component = fixture.componentInstance; + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/members/members-filters/members-filters.component.ts b/projects/social_platform/src/app/ui/pages/members/members-filters/members-filters.component.ts new file mode 100644 index 000000000..455874ec3 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/members/members-filters/members-filters.component.ts @@ -0,0 +1,90 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + inject, + input, + Input, + output, + Output, +} from "@angular/core"; +import { ReactiveFormsModule } from "@angular/forms"; +import { AutoCompleteInputComponent } from "@ui/primitives/autocomplete-input/autocomplete-input.component"; +import { Specialization } from "@domain/specializations/specialization.model"; +import { ActivatedRoute, Router } from "@angular/router"; +import { MembersComponent } from "@ui/pages/members/members.component"; +import { Skill } from "@domain/skills/skill.model"; +import { SearchesService } from "@api/searches/searches.service"; +import { LoggerService } from "@corelib"; + +/** Фильтры для списка участников с синхронизацией через URL. */ +@Component({ + selector: "app-members-filters", + imports: [CommonModule, ReactiveFormsModule, AutoCompleteInputComponent], + templateUrl: "./members-filters.component.html", + styleUrl: "./members-filters.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MembersFiltersComponent { + readonly filterForm = input.required(); + readonly filtersChanged = output(); + + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly searchesService = inject(SearchesService); + private readonly loggerService = inject(LoggerService); + + protected readonly specsOptions = this.searchesService.inlineSpecs; + + protected readonly skillsOptions = this.searchesService.inlineSkills; + + onSelectSpec(speciality: Specialization): void { + this.filterForm().patchValue({ speciality: speciality.name }); + } + + onClearSpecField(): void { + this.filterForm().patchValue({ speciality: "" }); + } + + onSearchSpec(query: string): void { + this.searchesService.onSearchSpec(query); + } + + onSelectSkill(skill: Skill): void { + this.filterForm().patchValue({ keySkill: skill.name }); + } + + onClearSkillField(): void { + this.filterForm().patchValue({ keySkill: "" }); + } + + onSearchSkill(query: string): void { + this.searchesService.onSearchSkill(query); + } + + onToggleStudentMosPolitech(): void { + this.filterForm().patchValue({ + isMosPolytechStudent: !this.filterForm().get("isMosPolytechStudent")?.value, + }); + } + + clearFilters(): void { + this.router + .navigate([], { + queryParams: { + fullname: undefined, + is_mospolytech_student: undefined, + skills__contains: undefined, + speciality__icontains: undefined, + }, + relativeTo: this.route, + queryParamsHandling: "merge", + }) + .then(() => this.loggerService.info("Query change from ProjectsComponent")); + + this.filterForm().reset(); + } +} diff --git a/projects/social_platform/src/app/ui/pages/members/members.component.html b/projects/social_platform/src/app/ui/pages/members/members.component.html new file mode 100644 index 000000000..bda89fa41 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/members/members.component.html @@ -0,0 +1,41 @@ + + +
    + + +
    +
    +
    + + + + +
      + @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/ui/pages/members/members.component.scss similarity index 100% rename from projects/social_platform/src/app/office/members/members.component.scss rename to projects/social_platform/src/app/ui/pages/members/members.component.scss diff --git a/projects/social_platform/src/app/ui/pages/members/members.component.spec.ts b/projects/social_platform/src/app/ui/pages/members/members.component.spec.ts new file mode 100644 index 000000000..79776770d --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/members/members.component.spec.ts @@ -0,0 +1,94 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { MembersComponent } from "./members.component"; +import { provideRouter } from "@angular/router"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { MembersInfoService } from "@api/member/facades/members-info.service"; +import { MembersUIInfoService } from "@api/member/facades/ui/members-ui-info.service"; +import { ProfileDetailUIInfoService } from "@api/profile/facades/detail/ui/profile-detail-ui-info.service"; +import { of } from "rxjs"; +import { signal } from "@angular/core"; +import { initial } from "@domain/shared/async-state"; +import { FormBuilder } from "@angular/forms"; +import { SkillsRepositoryPort } from "@domain/skills/ports/skills.repository.port"; +import { SpecializationsRepositoryPort } from "@domain/specializations/ports/specializations.repository.port"; + +describe("MembersComponent", () => { + let component: MembersComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const authPortSpy = { + login: of({} as any), + logout: of(undefined), + fetchProfile: of({} as any), + fetchUserRoles: of([]), + fetchChangeableRoles: of([]), + fetchLeaderProjects: of({} as any), + }; + + const membersInfoServiceSpy = { + initializationMembers: vi.fn(), + initScroll: vi.fn(), + destroy: vi.fn(), + redirectToProfile: vi.fn(), + }; + + const fb = new FormBuilder(); + const membersUIInfoServiceSpy = { + members: signal([]), + members$: signal(initial()), + searchForm: fb.group({ search: [""] }), + filterForm: fb.group({ keySkill: [""], speciality: [""] }), + }; + + const profileDetailUIInfoServiceSpy = { + user: undefined, + loggedUserId: 0, + profileId: 0, + }; + + await TestBed.configureTestingModule({ + imports: [MembersComponent], + providers: [ + { provide: AuthRepositoryPort, useValue: authPortSpy }, + { + provide: SkillsRepositoryPort, + useValue: { + getSkillsNested: () => of([]), + getSkillsInline: () => of({ results: [], count: 0, next: "", previous: "" }), + }, + }, + { + provide: SpecializationsRepositoryPort, + useValue: { + getSpecializationsInline: () => of({ results: [], count: 0, next: "", previous: "" }), + }, + }, + provideRouter([]), + ], + }) + .overrideComponent(MembersComponent, { + remove: { + providers: [MembersInfoService, MembersUIInfoService, ProfileDetailUIInfoService], + }, + add: { + providers: [ + { provide: MembersInfoService, useValue: membersInfoServiceSpy }, + { provide: MembersUIInfoService, useValue: membersUIInfoServiceSpy }, + { provide: ProfileDetailUIInfoService, useValue: profileDetailUIInfoServiceSpy }, + ], + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(MembersComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/members/members.component.ts b/projects/social_platform/src/app/ui/pages/members/members.component.ts new file mode 100644 index 000000000..0cd7ddb39 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/members/members.component.ts @@ -0,0 +1,76 @@ +/** @format */ + +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + ElementRef, + inject, + OnInit, + viewChild, +} from "@angular/core"; +import { RouterLink } from "@angular/router"; +import { ReactiveFormsModule } from "@angular/forms"; +import { containerSm } from "@utils/responsive"; +import { CommonModule } from "@angular/common"; +import { SearchComponent } from "@ui/primitives/search/search.component"; +import { MembersFiltersComponent } from "./members-filters/members-filters.component"; +import { InfoCardComponent } from "@ui/widgets/info-card/info-card.component"; +import { BackComponent } from "@uilib"; +import { ButtonComponent } from "@ui/primitives"; +import { SoonCardComponent } from "@ui/primitives/soon-card/soon-card.component"; +import { MembersInfoService } from "@api/member/facades/members-info.service"; +import { MembersUIInfoService } from "@api/member/facades/ui/members-ui-info.service"; +import { AppRoutes } from "@api/paths/app-routes"; +import { ProfileDetailUIInfoService } from "@api/profile/facades/detail/ui/profile-detail-ui-info.service"; + +/** Список участников с поиском, фильтрацией и бесконечной прокруткой. */ +@Component({ + selector: "app-members", + templateUrl: "./members.component.html", + styleUrl: "./members.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + ReactiveFormsModule, + SearchComponent, + CommonModule, + RouterLink, + MembersFiltersComponent, + InfoCardComponent, + BackComponent, + ButtonComponent, + SoonCardComponent, + ], + providers: [MembersInfoService, MembersUIInfoService, ProfileDetailUIInfoService], +}) +export class MembersComponent implements OnInit, AfterViewInit { + readonly membersRoot = viewChild | undefined>("membersRoot"); // Ссылка на корневой элемент списка + + private readonly membersInfoService = inject(MembersInfoService); + private readonly membersUIInfoService = inject(MembersUIInfoService); + + protected readonly members = this.membersUIInfoService.members; + + // Константы и свойства компонента + protected readonly searchForm = this.membersUIInfoService.searchForm; // Форма поиска + protected readonly filterForm = this.membersUIInfoService.filterForm; // Форма фильтрации + + protected readonly containerSm = containerSm; // Брейкпоинт для мобильных устройств + protected readonly appWidth = window.innerWidth; // Ширина окна браузера + protected readonly AppRoutes = AppRoutes; + + ngOnInit(): void { + this.membersInfoService.initializationMembers(); + } + + ngAfterViewInit(): void { + const target = document.querySelector(".office__body") as HTMLElement; + if (target && this.membersRoot()) { + this.membersInfoService.initScroll(target, this.membersRoot()!); + } + } + + redirectToProfile(): void { + this.membersInfoService.redirectToProfile(); + } +} diff --git a/projects/social_platform/src/app/ui/pages/members/members.resolver.ts b/projects/social_platform/src/app/ui/pages/members/members.resolver.ts new file mode 100644 index 000000000..000b367de --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/members/members.resolver.ts @@ -0,0 +1,27 @@ +/** @format */ + +import { inject } from "@angular/core"; +import { ResolveFn } from "@angular/router"; +import { map } from "rxjs"; +import { GetMembersUseCase } from "@api/member/use-cases/get-members.use-case"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { User } from "@domain/auth/user.model"; + +/** Предзагружает список участников. */ +export const MembersResolver: ResolveFn> = () => { + const getMembersUseCase = inject(GetMembersUseCase); + + // Загружаем первые 20 участников (skip: 0, take: 20) + return getMembersUseCase.execute(0, 20).pipe( + map(result => + result.ok + ? result.value + : { + count: 0, + results: [], + next: "", + previous: "", + }, + ), + ); +}; diff --git a/projects/social_platform/src/app/ui/pages/office/delete-confirm/delete-confirm.component.html b/projects/social_platform/src/app/ui/pages/office/delete-confirm/delete-confirm.component.html new file mode 100644 index 000000000..cbb584e74 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/office/delete-confirm/delete-confirm.component.html @@ -0,0 +1,17 @@ + + + + @if (settings$ | async; as settings) { +
    + +

    {{ settings.mainText }}

    +

    {{ settings.subText }}

    + + отменить + + удалить +
    + } +
    diff --git a/projects/social_platform/src/app/ui/components/delete-confirm/delete-confirm.component.scss b/projects/social_platform/src/app/ui/pages/office/delete-confirm/delete-confirm.component.scss similarity index 100% rename from projects/social_platform/src/app/ui/components/delete-confirm/delete-confirm.component.scss rename to projects/social_platform/src/app/ui/pages/office/delete-confirm/delete-confirm.component.scss diff --git a/projects/social_platform/src/app/ui/components/delete-confirm/delete-confirm.component.spec.ts b/projects/social_platform/src/app/ui/pages/office/delete-confirm/delete-confirm.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/ui/components/delete-confirm/delete-confirm.component.spec.ts rename to projects/social_platform/src/app/ui/pages/office/delete-confirm/delete-confirm.component.spec.ts diff --git a/projects/social_platform/src/app/ui/components/delete-confirm/delete-confirm.component.ts b/projects/social_platform/src/app/ui/pages/office/delete-confirm/delete-confirm.component.ts similarity index 83% rename from projects/social_platform/src/app/ui/components/delete-confirm/delete-confirm.component.ts rename to projects/social_platform/src/app/ui/pages/office/delete-confirm/delete-confirm.component.ts index 112ffa3ac..22e2a436d 100644 --- a/projects/social_platform/src/app/ui/components/delete-confirm/delete-confirm.component.ts +++ b/projects/social_platform/src/app/ui/pages/office/delete-confirm/delete-confirm.component.ts @@ -1,10 +1,10 @@ /** @format */ -import { Component, OnInit } from "@angular/core"; -import { ModalService } from "@ui/models/modal.service"; -import { ButtonComponent, IconComponent } from "@ui/components"; +import { ChangeDetectionStrategy, Component, OnInit } from "@angular/core"; +import { ModalService } from "@ui/primitives/modal/modal.service"; +import { ButtonComponent, IconComponent } from "@ui/primitives"; import { AsyncPipe } from "@angular/common"; -import { ModalComponent } from "../modal/modal.component"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; /** * Компонент диалога подтверждения удаления. @@ -22,8 +22,8 @@ import { ModalComponent } from "../modal/modal.component"; selector: "app-delete-confirm", templateUrl: "./delete-confirm.component.html", styleUrl: "./delete-confirm.component.scss", - standalone: true, imports: [ModalComponent, IconComponent, ButtonComponent, AsyncPipe], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class DeleteConfirmComponent implements OnInit { constructor(public readonly modalService: ModalService) {} diff --git a/projects/social_platform/src/app/ui/pages/office/nav/nav.component.html b/projects/social_platform/src/app/ui/pages/office/nav/nav.component.html new file mode 100644 index 000000000..79ea6ffe0 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/office/nav/nav.component.html @@ -0,0 +1,143 @@ + + + diff --git a/projects/social_platform/src/app/office/features/nav/nav.component.scss b/projects/social_platform/src/app/ui/pages/office/nav/nav.component.scss similarity index 100% rename from projects/social_platform/src/app/office/features/nav/nav.component.scss rename to projects/social_platform/src/app/ui/pages/office/nav/nav.component.scss diff --git a/projects/social_platform/src/app/ui/pages/office/nav/nav.component.ts b/projects/social_platform/src/app/ui/pages/office/nav/nav.component.ts new file mode 100644 index 000000000..72f27249f --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/office/nav/nav.component.ts @@ -0,0 +1,157 @@ +/** @format */ + +import { InviteManageCardComponent, ProfileInfoComponent } from "@uilib"; +import { NotificationService } from "@ui/services/notification/notification.service"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { AuthInfoService } from "@api/auth/facades/auth-info.service"; +import { AppRoutes } from "@api/paths/app-routes"; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DestroyRef, + Input, + OnDestroy, + OnInit, + inject, +} from "@angular/core"; +import { AsyncPipe, CommonModule } from "@angular/common"; +import { IconComponent } from "@ui/primitives"; +import { NavigationStart, Router, RouterLink, RouterLinkActive } from "@angular/router"; +import { NavService } from "@api/shared/nav.service"; +import { InviteInfoService } from "@api/invite/facades/invite-info.service"; + +/** + * Компонент навигационного меню + * + * Функциональность: + * - Отображает основное навигационное меню приложения + * - Управляет мобильным меню (открытие/закрытие) + * - Показывает уведомления и приглашения + * - Обрабатывает принятие и отклонение приглашений + * - Отображает информацию о профиле пользователя + * - Автоматически закрывает мобильное меню при навигации + * - Интеграция с внешним сервисом навыков + * - Динамическое обновление заголовка страницы + * + * Входные параметры: + * @Input invites - массив приглашений пользователя + * + * Внутренние свойства: + * - mobileMenuOpen - флаг состояния мобильного меню + * - notificationsOpen - флаг состояния панели уведомлений + * - title - текущий заголовок страницы + * - subscriptions$ - массив подписок для управления памятью + * - hasInvites - вычисляемое свойство наличия непрочитанных приглашений + * + * Сервисы: + * - navService - управление навигацией и заголовками + * - notificationService - управление уведомлениями + * - inviteService - работа с приглашениями + * - authService - аутентификация и профиль пользователя + */ +@Component({ + selector: "app-nav", + templateUrl: "./nav.component.html", + styleUrl: "./nav.component.scss", + imports: [ + CommonModule, + IconComponent, + RouterLink, + RouterLinkActive, + InviteManageCardComponent, + ProfileInfoComponent, + AsyncPipe, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NavComponent implements OnInit, OnDestroy { + private readonly logger = inject(LoggerService); + private readonly destroyRef = inject(DestroyRef); + private readonly navService = inject(NavService); + + readonly invites = this.inviteInfoService.invites; + + constructor( + private readonly router: Router, + public readonly notificationService: NotificationService, + public readonly authRepository: AuthInfoService, + private readonly inviteInfoService: InviteInfoService, + private readonly cdref: ChangeDetectorRef, + ) {} + + ngOnInit(): void { + // Подписка на события роутера для закрытия мобильного меню + this.router.events.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(event => { + if (event instanceof NavigationStart) { + this.mobileMenuOpen = false; + } + }); + + // Подписка на изменения заголовка страницы + this.navService.navTitle.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(title => { + this.title = title; + this.cdref.detectChanges(); + }); + } + + ngOnDestroy(): void {} + + mobileMenuOpen = false; + notificationsOpen = false; + title = ""; + protected readonly AppRoutes = AppRoutes; + + /** + * Проверка наличия непринятых приглашений + * Возвращает true если есть приглашения со статусом null (не принято/не отклонено) + */ + get hasInvites(): boolean { + return this.invites().some(i => i.isAccepted === null); + } + + /** + * Обработчик отклонения приглашения + * Отправляет запрос на отклонение и удаляет приглашение из списка + */ + onRejectInvite(inviteId: number): void { + this.inviteInfoService + .rejectInviteAction(inviteId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(result => { + if (!result.ok) { + return; + } + + this.notificationsOpen = false; + this.mobileMenuOpen = false; + }); + } + + /** + * Обработчик принятия приглашения + * Отправляет запрос на принятие, удаляет приглашение из списка + * и перенаправляет пользователя на страницу проекта + */ + onAcceptInvite(inviteId: number): void { + const invite = this.invites().find(i => i.id === inviteId); + if (!invite) return; + + this.inviteInfoService + .acceptInviteAction(inviteId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(result => { + if (!result.ok) { + return; + } + + this.notificationsOpen = false; + this.mobileMenuOpen = false; + + this.router + .navigateByUrl(AppRoutes.projects.detail(invite.project.id)) + .then(() => this.logger.debug("Route changed from HeaderComponent")); + }); + } +} diff --git a/projects/social_platform/src/app/ui/pages/office/office.component.html b/projects/social_platform/src/app/ui/pages/office/office.component.html new file mode 100644 index 000000000..470af74a1 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/office/office.component.html @@ -0,0 +1,137 @@ + + +
    + @if (profile()) { +
    + background-image +
    +
    +
    + +
    +

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

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

    {{ currentYear() }}

    +
    + +
    + @if (programs().length) { + @for (program of programs(); track program.id) { + + } + } +
    +
    + +
    +
    + + + @if (profile() !== undefined && invites() !== undefined) { + + } +
    + +
    +
    +
    +
    +
    + } + +
    + wait +

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

    +

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

    + + Хорошо + +
    +
    + + +
    +
    +

    Привет!

    +
    + +
    +

    + Рады знакомству 🙌
    + Вы находитесь на платформе procollab – здесь проходит программа, на которую вы ранее + регистрировались через форму заявки +

    + +

    + Ваша программа и её закрытая группа (к которой у вас автоматически есть доступ) находится + в одноименной вкладке «программы» +

    + +

    + На платформе есть еще много интересного: цифровой профиль, проекты, новостная лента, чаты + и другое – рекомендуем изучить +

    + +

    + Поздравляем с регистрацией! Желаем удачи в прохождении программы :) +

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

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

    +

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

    + + Хорошо + +
    +
    + + +
    diff --git a/projects/social_platform/src/app/office/office.component.scss b/projects/social_platform/src/app/ui/pages/office/office.component.scss similarity index 98% rename from projects/social_platform/src/app/office/office.component.scss rename to projects/social_platform/src/app/ui/pages/office/office.component.scss index f1e93123b..db6fd13bf 100644 --- a/projects/social_platform/src/app/office/office.component.scss +++ b/projects/social_platform/src/app/ui/pages/office/office.component.scss @@ -128,6 +128,10 @@ } } + &__policy-link { + cursor: pointer; + } + &__header { display: none; background-color: var(--white); diff --git a/projects/social_platform/src/app/ui/pages/office/office.component.spec.ts b/projects/social_platform/src/app/ui/pages/office/office.component.spec.ts new file mode 100644 index 000000000..7b6545546 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/office/office.component.spec.ts @@ -0,0 +1,110 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { provideRouter } from "@angular/router"; +import { signal } from "@angular/core"; +import { of } from "rxjs"; +import { OfficeComponent } from "./office.component"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { OfficeInfoService } from "@api/office/facades/office-info.service"; +import { OfficeUIInfoService } from "@api/office/facades/ui/office-ui-info.service"; +import { AuthUIInfoService } from "@api/auth/facades/ui/auth-ui-info.service"; +import { AuthRegisterService } from "@api/auth/facades/auth-register.service"; +import { ChatUnreadStateService } from "@api/chat/chat-unread-state.service"; +import { ProgramShellInfoService } from "@api/program/facades/program-shell-info.service"; +import { ProfileInfoService } from "@api/profile/facades/profile-info.service"; + +describe("OfficeComponent", () => { + let component: OfficeComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const authPortSpy = { + login: of({} as any), + logout: of(undefined), + fetchProfile: of({} as any), + fetchUserRoles: of([]), + fetchChangeableRoles: of([]), + fetchLeaderProjects: of({} as any), + }; + + const officeInfoServiceSpy = { + initializationOffice: vi.fn(), + destroy: vi.fn(), + onRejectInvite: vi.fn(), + onAcceptInvite: vi.fn(), + onLogout: vi.fn(), + invites: signal([]), + }; + + const officeUIInfoServiceSpy = { + waitVerificationModal: signal(false), + waitVerificationAccepted: signal(false), + inviteErrorModal: signal(false), + navItems: signal([]), + applyAcceptWaitVerification: vi.fn(), + }; + + const authUIInfoServiceSpy = {}; + + const authRegisterServiceSpy = { + downloadPolicy: vi.fn(), + }; + + const chatUnreadStateSpy = { + hasUnreads: signal(false), + ensureLoaded: vi.fn(), + markRead: vi.fn(), + }; + + const programShellInfoServiceSpy = { + actualPrograms: signal([]), + ensureProgramsLoaded: vi + .fn() + .mockReturnValue(of({ ok: true, value: { results: [], count: 0 } })), + invalidatePrograms: vi.fn(), + }; + + const profileInfoServiceSpy = { + profile: signal(null), + }; + + await TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, OfficeComponent], + providers: [{ provide: AuthRepositoryPort, useValue: authPortSpy }, provideRouter([])], + }) + .overrideComponent(OfficeComponent, { + remove: { + providers: [ + OfficeInfoService, + OfficeUIInfoService, + AuthUIInfoService, + AuthRegisterService, + ], + }, + add: { + providers: [ + { provide: OfficeInfoService, useValue: officeInfoServiceSpy }, + { provide: OfficeUIInfoService, useValue: officeUIInfoServiceSpy }, + { provide: AuthUIInfoService, useValue: authUIInfoServiceSpy }, + { provide: AuthRegisterService, useValue: authRegisterServiceSpy }, + { provide: ChatUnreadStateService, useValue: chatUnreadStateSpy }, + { provide: ProgramShellInfoService, useValue: programShellInfoServiceSpy }, + { provide: ProfileInfoService, useValue: profileInfoServiceSpy }, + ], + }, + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(OfficeComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/office/office.component.ts b/projects/social_platform/src/app/ui/pages/office/office.component.ts new file mode 100644 index 000000000..d0d261f4c --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/office/office.component.ts @@ -0,0 +1,142 @@ +/** @format */ + +import { Component, OnInit, signal, effect, inject, ChangeDetectionStrategy } from "@angular/core"; +import { RouterLink, RouterOutlet } from "@angular/router"; +import { ProgramSidebarCardComponent } from "./program-sidebar-card/program-sidebar-card.component"; +import { ButtonComponent } from "@ui/primitives"; +import { DeleteConfirmComponent } from "./delete-confirm/delete-confirm.component"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { NavComponent } from "./nav/nav.component"; +import { SnackbarComponent } from "./snackbar/snackbar.component"; +import { ProfileControlPanelComponent, SidebarComponent } from "@uilib"; +import { Program } from "@domain/program/program.model"; +import { OfficeInfoService } from "@api/office/facades/office-info.service"; +import { OfficeUIInfoService } from "@api/office/facades/ui/office-ui-info.service"; +import { AppRoutes } from "@api/paths/app-routes"; +import { ChatUnreadStateService } from "@api/chat/chat-unread-state.service"; +import { AuthRegisterService } from "@api/auth/facades/auth-register.service"; +import { AuthUIInfoService } from "@api/auth/facades/ui/auth-ui-info.service"; +import { ProgramShellInfoService } from "@api/program/facades/program-shell-info.service"; +import { ProfileInfoService } from "@api/profile/facades/profile-info.service"; + +/** Корневой компонент рабочего пространства с навигацией и управлением состоянием. */ +@Component({ + selector: "app-office", + templateUrl: "./office.component.html", + styleUrl: "./office.component.scss", + imports: [ + SidebarComponent, + NavComponent, + RouterOutlet, + ModalComponent, + ButtonComponent, + DeleteConfirmComponent, + SnackbarComponent, + RouterLink, + ProfileControlPanelComponent, + ProgramSidebarCardComponent, + ], + providers: [OfficeInfoService, OfficeUIInfoService, AuthUIInfoService, AuthRegisterService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class OfficeComponent implements OnInit { + private readonly officeInfoService = inject(OfficeInfoService); + private readonly officeUIInfoService = inject(OfficeUIInfoService); + private readonly authRegisterService = inject(AuthRegisterService); + public readonly chatUnreadState = inject(ChatUnreadStateService); + private readonly programShellInfoService = inject(ProgramShellInfoService); + private readonly profileInfoService = inject(ProfileInfoService); + + protected readonly profile = this.profileInfoService.profile; + + protected readonly invites = this.officeInfoService.invites; + + protected readonly waitVerificationModal = this.officeUIInfoService.waitVerificationModal; + protected readonly waitVerificationAccepted = this.officeUIInfoService.waitVerificationAccepted; + + protected readonly inviteErrorModal = this.officeUIInfoService.inviteErrorModal; + + protected readonly programs = this.programShellInfoService.actualPrograms; + + protected readonly navItems = this.officeUIInfoService.navItems; + protected readonly AppRoutes = AppRoutes; + + protected currentYear = signal(new Date().getFullYear()); + + protected readonly showRegisteredProgramModal = signal(false); + + protected registeredProgramToShow?: Program | null = null; + + private readonly _ = effect(() => { + const programs = this.programs(); + if (programs && programs.length) { + this.tryShowRegisteredProgramModal(); + } + }); + + ngOnInit(): void { + this.officeInfoService.initializationOffice(); + + if (localStorage.getItem("waitVerificationAccepted") === "true") { + this.waitVerificationAccepted.set(true); + } + + this.programShellInfoService.ensureProgramsLoaded(); + } + + onAcceptWaitVerification() { + this.officeUIInfoService.applyAcceptWaitVerification(); + } + + onRejectInvite(inviteId: number): void { + this.officeInfoService.onRejectInvite(inviteId); + } + + onAcceptInvite(inviteId: number): void { + this.officeInfoService.onAcceptInvite(inviteId); + } + + onLogout() { + this.programShellInfoService.invalidatePrograms(); + this.officeInfoService.onLogout(); + } + + downloadPolicy(event: Event): void { + event.stopPropagation(); + this.authRegisterService.downloadPolicy(); + } + + private tryShowRegisteredProgramModal(): void { + const programs = this.programs(); + if (!programs || programs.length === 0) return; + + const memberProgram = programs.find(p => p.isUserMember); + if (!memberProgram) return; + + if (this.hasSeenRegisteredProgramModal(memberProgram.id)) return; + + this.registeredProgramToShow = memberProgram; + this.showRegisteredProgramModal.set(true); + this.markSeenRegisteredProgramModal(memberProgram.id); + } + + private getRegisteredProgramSeenKey(programId: number): string { + return `program_${this.profile()?.id}_registered_modal_seen_${programId}`; + } + + private hasSeenRegisteredProgramModal(programId: number): boolean { + try { + return !!localStorage.getItem(this.getRegisteredProgramSeenKey(programId)); + } catch (e) { + return false; + } + } + + private markSeenRegisteredProgramModal(programId: number): void { + try { + localStorage.setItem(this.getRegisteredProgramSeenKey(programId), "1"); + } catch (e) { + // ignore storage errors + } + } +} diff --git a/projects/social_platform/src/app/ui/pages/office/program-sidebar-card/program-sidebar-card.component.html b/projects/social_platform/src/app/ui/pages/office/program-sidebar-card/program-sidebar-card.component.html new file mode 100644 index 000000000..a6ac4e94e --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/office/program-sidebar-card/program-sidebar-card.component.html @@ -0,0 +1,22 @@ + + +
    +
    +
    + +

    до {{ program().datetimeRegistrationEnds | date: "dd.MM.yy" }}

    +
    + + +
    + +

    + {{ program().name | truncate: 30 }} +

    +
    diff --git a/projects/social_platform/src/app/office/features/program-sidebar-card/program-sidebar-card.component.scss b/projects/social_platform/src/app/ui/pages/office/program-sidebar-card/program-sidebar-card.component.scss similarity index 100% rename from projects/social_platform/src/app/office/features/program-sidebar-card/program-sidebar-card.component.scss rename to projects/social_platform/src/app/ui/pages/office/program-sidebar-card/program-sidebar-card.component.scss diff --git a/projects/social_platform/src/app/ui/pages/office/program-sidebar-card/program-sidebar-card.component.ts b/projects/social_platform/src/app/ui/pages/office/program-sidebar-card/program-sidebar-card.component.ts new file mode 100644 index 000000000..1854b0291 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/office/program-sidebar-card/program-sidebar-card.component.ts @@ -0,0 +1,20 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, input } from "@angular/core"; +import { IconComponent } from "@uilib"; +import { AvatarComponent } from "@ui/primitives/avatar/avatar.component"; +import { TruncatePipe } from "@corelib"; +import { Program } from "@domain/program/program.model"; + +/** Карточка программы в боковой панели офиса. */ +@Component({ + selector: "app-program-sidebar-card", + templateUrl: "./program-sidebar-card.component.html", + styleUrl: "./program-sidebar-card.component.scss", + imports: [CommonModule, IconComponent, AvatarComponent, TruncatePipe], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProgramSidebarCardComponent { + readonly program = input.required(); +} diff --git a/projects/social_platform/src/app/ui/services/animation.service.spec.ts b/projects/social_platform/src/app/ui/pages/office/snackbar/animation/animation.service.spec.ts similarity index 100% rename from projects/social_platform/src/app/ui/services/animation.service.spec.ts rename to projects/social_platform/src/app/ui/pages/office/snackbar/animation/animation.service.spec.ts diff --git a/projects/social_platform/src/app/ui/services/animation.service.ts b/projects/social_platform/src/app/ui/pages/office/snackbar/animation/animation.service.ts similarity index 100% rename from projects/social_platform/src/app/ui/services/animation.service.ts rename to projects/social_platform/src/app/ui/pages/office/snackbar/animation/animation.service.ts diff --git a/projects/social_platform/src/app/ui/pages/office/snackbar/snackbar.component.html b/projects/social_platform/src/app/ui/pages/office/snackbar/snackbar.component.html new file mode 100644 index 000000000..088c67919 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/office/snackbar/snackbar.component.html @@ -0,0 +1,22 @@ + + +
      + @for (snack of snacks; track snack.id) { +
    • + {{ snack.text }} + +
    • + } +
    diff --git a/projects/social_platform/src/app/ui/components/snackbar/snackbar.component.scss b/projects/social_platform/src/app/ui/pages/office/snackbar/snackbar.component.scss similarity index 100% rename from projects/social_platform/src/app/ui/components/snackbar/snackbar.component.scss rename to projects/social_platform/src/app/ui/pages/office/snackbar/snackbar.component.scss diff --git a/projects/social_platform/src/app/ui/components/snackbar/snackbar.component.spec.ts b/projects/social_platform/src/app/ui/pages/office/snackbar/snackbar.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/ui/components/snackbar/snackbar.component.spec.ts rename to projects/social_platform/src/app/ui/pages/office/snackbar/snackbar.component.spec.ts diff --git a/projects/social_platform/src/app/ui/pages/office/snackbar/snackbar.component.ts b/projects/social_platform/src/app/ui/pages/office/snackbar/snackbar.component.ts new file mode 100644 index 000000000..2236eb6fe --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/office/snackbar/snackbar.component.ts @@ -0,0 +1,74 @@ +/** @format */ + +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DestroyRef, + OnDestroy, + OnInit, + inject, +} from "@angular/core"; +import { SnackbarService } from "@domain/shared/snackbar.service"; +import { Snack } from "@domain/shared/snack.model"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { AnimationService } from "./animation/animation.service"; +import { CommonModule } from "@angular/common"; +import { IconComponent } from "@uilib"; + +/** + * Компонент для отображения всплывающих уведомлений (snackbar). + * Подписывается на сервис уведомлений и отображает их с анимацией появления/исчезновения. + * Автоматически скрывает уведомления по истечении заданного времени. + * + * Функциональность: + * - Отображение списка активных уведомлений + * - Автоматическое скрытие по таймауту + * - Анимация появления и исчезновения + * - Возможность ручного закрытия уведомлений + * + * Не принимает входящих параметров - работает через сервис SnackbarService + */ +@Component({ + selector: "app-snackbar", + templateUrl: "./snackbar.component.html", + styleUrl: "./snackbar.component.scss", + animations: [AnimationService.slideInOut], + imports: [CommonModule, IconComponent], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SnackbarComponent implements OnInit, OnDestroy { + private readonly destroyRef = inject(DestroyRef); + private readonly cdr = inject(ChangeDetectorRef); + + constructor(private readonly snackbarService: SnackbarService) {} + + /** Массив активных уведомлений */ + snacks: Snack[] = []; + + /** Добавление нового уведомления */ + private addNotification(snack: Snack): void { + this.snacks = [...this.snacks, snack]; + this.cdr.markForCheck(); + + if (snack.timeout !== 0) { + setTimeout(() => this.onClose(snack), snack.timeout); + } + } + + /** Подписка на уведомления при инициализации */ + ngOnInit(): void { + this.snackbarService.snacks + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(snack => this.addNotification(snack)); + } + + /** Отписка от уведомлений при уничтожении */ + ngOnDestroy(): void {} + + /** Закрытие конкретного уведомления */ + onClose(snack: Snack): void { + this.snacks = this.snacks.filter(({ id }) => id !== snack.id); + this.cdr.markForCheck(); + } +} diff --git a/projects/social_platform/src/app/ui/pages/onboarding/onboarding.component.html b/projects/social_platform/src/app/ui/pages/onboarding/onboarding.component.html new file mode 100644 index 000000000..1e57c30a6 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/onboarding/onboarding.component.html @@ -0,0 +1,57 @@ + + +
    + + +
    diff --git a/projects/social_platform/src/app/office/onboarding/onboarding/onboarding.component.scss b/projects/social_platform/src/app/ui/pages/onboarding/onboarding.component.scss similarity index 100% rename from projects/social_platform/src/app/office/onboarding/onboarding/onboarding.component.scss rename to projects/social_platform/src/app/ui/pages/onboarding/onboarding.component.scss diff --git a/projects/social_platform/src/app/ui/pages/onboarding/onboarding.component.spec.ts b/projects/social_platform/src/app/ui/pages/onboarding/onboarding.component.spec.ts new file mode 100644 index 000000000..cf823ad2f --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/onboarding/onboarding.component.spec.ts @@ -0,0 +1,50 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { OnboardingComponent } from "./onboarding.component"; +import { provideRouter } from "@angular/router"; +import { EMPTY, of } from "rxjs"; +import { AuthRepository } from "@infrastructure/repository/auth/auth.repository"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { OnboardingService } from "@api/onboarding/onboarding.service"; + +describe("OnboardingComponent", () => { + let component: OnboardingComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const authSpy = { + profile: of({}), + }; + + const authPortSpy = { + fetchProfile: of({}), + fetchUserRoles: of([]), + fetchChangeableRoles: of([]), + }; + + const onboardingSpy = { currentStage$: EMPTY }; + + await TestBed.configureTestingModule({ + imports: [OnboardingComponent, HttpClientTestingModule], + providers: [ + { provide: AuthRepository, useValue: authSpy }, + { provide: AuthRepositoryPort, useValue: authPortSpy }, + { provide: OnboardingService, useValue: onboardingSpy }, + provideRouter([]), + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(OnboardingComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/onboarding/onboarding.component.ts b/projects/social_platform/src/app/ui/pages/onboarding/onboarding.component.ts new file mode 100644 index 000000000..bd31cd938 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/onboarding/onboarding.component.ts @@ -0,0 +1,34 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, inject, OnInit } from "@angular/core"; +import { RouterOutlet } from "@angular/router"; +import { OnboardingInfoService } from "@api/onboarding/facades/onboarding-info.service"; +import { OnboardingUIInfoService } from "@api/onboarding/facades/stages/ui/onboarding-ui-info.service"; + +/** Контейнер и координатор этапов онбординга — управляет навигацией и последовательностью прохождения. */ +@Component({ + selector: "app-onboarding", + templateUrl: "./onboarding.component.html", + styleUrl: "./onboarding.component.scss", + providers: [OnboardingInfoService, OnboardingUIInfoService], + imports: [RouterOutlet], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class OnboardingComponent implements OnInit { + private readonly onboardingInfoService = inject(OnboardingInfoService); + + protected readonly stage = this.onboardingInfoService.stage; + protected readonly activeStage = this.onboardingInfoService.activeStage; + + ngOnInit(): void { + this.onboardingInfoService.initializationOnboarding(); + } + + updateStage(): void { + this.onboardingInfoService.updateStage(); + } + + goToStep(stage: number): void { + this.onboardingInfoService.goToStep(stage); + } +} diff --git a/projects/social_platform/src/app/ui/pages/onboarding/stage-one/stage-one.component.html b/projects/social_platform/src/app/ui/pages/onboarding/stage-one/stage-one.component.html new file mode 100644 index 000000000..d689c7d5d --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/onboarding/stage-one/stage-one.component.html @@ -0,0 +1,94 @@ + + +
    +
    +
    +

    Кем хотите работать?

    + +
    + +
    + закончить регистрацию позже + продолжить +
    + +
    +
    +
    +

    поиск по библиотеке

    + + @if (stageForm.controls["speciality"] | controlError) { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    +
    +
    +
    +

    библиотека

    + +
    +
    +
    +
      + @for (spec of nestedSpecializations$ | async; track spec.id) { +
    • + +
    • + } +
    +
    +
    +
    +
    diff --git a/projects/social_platform/src/app/office/onboarding/stage-one/stage-one.component.scss b/projects/social_platform/src/app/ui/pages/onboarding/stage-one/stage-one.component.scss similarity index 100% rename from projects/social_platform/src/app/office/onboarding/stage-one/stage-one.component.scss rename to projects/social_platform/src/app/ui/pages/onboarding/stage-one/stage-one.component.scss diff --git a/projects/social_platform/src/app/ui/pages/onboarding/stage-one/stage-one.component.spec.ts b/projects/social_platform/src/app/ui/pages/onboarding/stage-one/stage-one.component.spec.ts new file mode 100644 index 000000000..67df36674 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/onboarding/stage-one/stage-one.component.spec.ts @@ -0,0 +1,71 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { OnboardingStageOneComponent } from "./stage-one.component"; +import { EMPTY, of } from "rxjs"; +import { ReactiveFormsModule } from "@angular/forms"; +import { AuthRepository } from "@infrastructure/repository/auth/auth.repository"; +import { provideRouter } from "@angular/router"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { SkillsRepositoryPort } from "@domain/skills/ports/skills.repository.port"; +import { SpecializationsRepositoryPort } from "@domain/specializations/ports/specializations.repository.port"; +import { OnboardingService } from "@api/onboarding/onboarding.service"; +import { ProfileInfoService } from "@api/profile/facades/profile-info.service"; +import { signal } from "@angular/core"; + +describe("StageOneComponent", () => { + let component: OnboardingStageOneComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const authSpy = { + profile: of({}), + saveProfile: of({}), + setOnboardingStage: of({}), + }; + + const authPortSpy = { + fetchProfile: of({}), + fetchUserRoles: of([]), + fetchChangeableRoles: of([]), + updateProfile: of({}), + }; + + const skillsSpy = { + getSkillsNested: of([]), + getSkillsInline: of({ count: 0, results: [], next: "", previous: "" }), + }; + + const specializationsSpy = { + getSpecializationsNested: of([]), + getSpecializationsInline: of({ count: 0, results: [], next: "", previous: "" }), + }; + + const onboardingSpy = { formValue$: EMPTY, setFormValue: () => {} }; + + await TestBed.configureTestingModule({ + imports: [ReactiveFormsModule, HttpClientTestingModule, OnboardingStageOneComponent], + providers: [ + { provide: AuthRepository, useValue: authSpy }, + { provide: AuthRepositoryPort, useValue: authPortSpy }, + { provide: SkillsRepositoryPort, useValue: skillsSpy }, + { provide: SpecializationsRepositoryPort, useValue: specializationsSpy }, + { provide: OnboardingService, useValue: onboardingSpy }, + { provide: ProfileInfoService, useValue: { profile: signal(null) } }, + provideRouter([]), + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(OnboardingStageOneComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/onboarding/stage-one/stage-one.component.ts b/projects/social_platform/src/app/ui/pages/onboarding/stage-one/stage-one.component.ts new file mode 100644 index 000000000..271fda9b6 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/onboarding/stage-one/stage-one.component.ts @@ -0,0 +1,105 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, inject, OnInit } from "@angular/core"; +import { ReactiveFormsModule } from "@angular/forms"; +import { ControlErrorPipe } from "@corelib"; +import { ButtonComponent } from "@ui/primitives"; +import { CommonModule } from "@angular/common"; +import { AutoCompleteInputComponent } from "@ui/primitives/autocomplete-input/autocomplete-input.component"; +import { SpecializationsGroupComponent } from "@ui/widgets/specializations-group/specializations-group.component"; +import { Specialization } from "@domain/specializations/specialization.model"; +import { ErrorMessage } from "@core/lib/models/error/error-message"; +import { TooltipComponent } from "@ui/primitives/tooltip/tooltip.component"; +import { OnboardingStageOneUIInfoService } from "@api/onboarding/facades/stages/ui/onboarding-stage-one-ui-info.service"; +import { OnboardingStageOneInfoService } from "@api/onboarding/facades/stages/onboarding-stage-one-info.service"; +import { OnboardingUIInfoService } from "@api/onboarding/facades/stages/ui/onboarding-ui-info.service"; +import { TooltipInfoService } from "@api/tooltip/tooltip-info.service"; + +/** Этап онбординга — выбор специализации с автокомплитом и группированным списком. */ +@Component({ + selector: "app-stage-one", + templateUrl: "./stage-one.component.html", + styleUrl: "./stage-one.component.scss", + imports: [ + ReactiveFormsModule, + ButtonComponent, + ControlErrorPipe, + AutoCompleteInputComponent, + SpecializationsGroupComponent, + CommonModule, + TooltipComponent, + ], + providers: [ + OnboardingStageOneInfoService, + OnboardingStageOneUIInfoService, + OnboardingUIInfoService, + TooltipInfoService, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class OnboardingStageOneComponent implements OnInit { + private readonly onboardingStageOneInfoService = inject(OnboardingStageOneInfoService); + private readonly onboardingStageOneUIInfoService = inject(OnboardingStageOneUIInfoService); + private readonly onboardingUIInfoService = inject(OnboardingUIInfoService); + private readonly tooltipInfoService = inject(TooltipInfoService); + + protected readonly stageForm = this.onboardingStageOneUIInfoService.stageForm; + + protected readonly isHintAuthVisible = this.tooltipInfoService.isVisible; + protected readonly isHintLibVisible = this.tooltipInfoService.isVisible; + + protected readonly inlineSpecializations = + this.onboardingStageOneInfoService.inlineSpecializations; + + protected readonly stageSubmitting = this.onboardingUIInfoService.stageSubmitting; + protected readonly skipSubmitting = this.onboardingUIInfoService.skipSubmitting; + + // Для управления открытыми группами специализаций + protected readonly openSpecializationGroup = + this.onboardingStageOneUIInfoService.openSpecializationGroup; + + protected readonly nestedSpecializations$ = + this.onboardingStageOneInfoService.nestedSpecializations$; + + protected readonly errorMessage = ErrorMessage; + + ngOnInit(): void { + this.onboardingStageOneInfoService.initializationFormValues(); + } + + ngAfterViewInit(): void { + this.onboardingStageOneInfoService.initializationSpeciality(); + } + + toggleTooltip(key: "auth" | "lib"): void { + this.tooltipInfoService.toggleTooltip(key); + } + + // + protected readonly hasOpenSpecializationsGroups = + this.onboardingStageOneUIInfoService.hasOpenSpecializationsGroups; + + // + onSpecializationsGroupToggled(isOpen: boolean, groupName: string): void { + this.onboardingStageOneUIInfoService.onSpecializationsGroupToggled(isOpen, groupName); + } + isSpecializationGroupDisabled(groupName: string): boolean { + return this.onboardingStageOneUIInfoService.isSpecializationGroupDisabled(groupName); + } + + onSkipRegistration(): void { + this.onboardingStageOneInfoService.onSkipRegistration(); + } + + onSubmit(): void { + this.onboardingStageOneInfoService.onSubmit(); + } + + onSelectSpec(speciality: Specialization): void { + this.onboardingStageOneInfoService.onSelectSpec(speciality); + } + + onSearchSpec(query: string): void { + this.onboardingStageOneInfoService.onSearchSpec(query); + } +} diff --git a/projects/social_platform/src/app/ui/pages/onboarding/stage-one/stage-one.resolver.ts b/projects/social_platform/src/app/ui/pages/onboarding/stage-one/stage-one.resolver.ts new file mode 100644 index 000000000..f18561dde --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/onboarding/stage-one/stage-one.resolver.ts @@ -0,0 +1,16 @@ +/** @format */ + +import { inject } from "@angular/core"; +import { ResolveFn } from "@angular/router"; +import { GetSpecializationsNestedUseCase } from "@api/specializations/use-cases/get-specializations-nested.use-case"; +import { SpecializationsGroup } from "@domain/specializations/specializations-group.model"; +import { EMPTY, map } from "rxjs"; + +/** Предзагружает иерархическую структуру специализаций для онбординга. */ +export const StageOneResolver: ResolveFn = () => { + const getSpecializationsNestedUseCase = inject(GetSpecializationsNestedUseCase); + + return getSpecializationsNestedUseCase + .execute() + .pipe(map(result => (result.ok ? result.value : []))); +}; diff --git a/projects/social_platform/src/app/office/onboarding/stage-three/stage-three.component.html b/projects/social_platform/src/app/ui/pages/onboarding/stage-three/stage-three.component.html similarity index 80% rename from projects/social_platform/src/app/office/onboarding/stage-three/stage-three.component.html rename to projects/social_platform/src/app/ui/pages/onboarding/stage-three/stage-three.component.html index d6127819f..4592824ba 100644 --- a/projects/social_platform/src/app/office/onboarding/stage-three/stage-three.component.html +++ b/projects/social_platform/src/app/ui/pages/onboarding/stage-three/stage-three.component.html @@ -5,7 +5,7 @@

    Выберите вашу роль

    - + Участник Участник на платформе - это школьник или студент, который может зарегистрировать свой проект @@ -13,7 +13,7 @@

    Выберите вашу роль

    читать новости
    - + Инвестор На платформе инвестор - человек, который владеет финансами и может как профинансировать @@ -21,7 +21,7 @@

    Выберите вашу роль

    проекты.
    - + Ментор На платформе ментор - человек, который открыт дать помощь инновационной молодежи, к ментору @@ -29,7 +29,7 @@

    Выберите вашу роль

    поддержать его.
    - + Эксперт На платформе эксперт - человек, который открыт дать экспертизу из какой-либо сферы. К @@ -38,12 +38,12 @@

    Выберите вашу роль

    - Продолжить - @if (stageTouched && userRole === -1) { -

    - Вы ничего не выбрали, выберите подходящий пункт и нажмите “продолжить” -

    + @if (stageTouched() && userRole() === -1) { +

    + Вы ничего не выбрали, выберите подходящий пункт и нажмите “продолжить” +

    }
    diff --git a/projects/social_platform/src/app/office/onboarding/stage-three/stage-three.component.scss b/projects/social_platform/src/app/ui/pages/onboarding/stage-three/stage-three.component.scss similarity index 100% rename from projects/social_platform/src/app/office/onboarding/stage-three/stage-three.component.scss rename to projects/social_platform/src/app/ui/pages/onboarding/stage-three/stage-three.component.scss diff --git a/projects/social_platform/src/app/ui/pages/onboarding/stage-three/stage-three.component.spec.ts b/projects/social_platform/src/app/ui/pages/onboarding/stage-three/stage-three.component.spec.ts new file mode 100644 index 000000000..a9d521156 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/onboarding/stage-three/stage-three.component.spec.ts @@ -0,0 +1,50 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { OnboardingStageThreeComponent } from "./stage-three.component"; +import { of } from "rxjs"; +import { AuthRepository } from "@infrastructure/repository/auth/auth.repository"; +import { OnboardingService } from "@api/onboarding/onboarding.service"; +import { provideRouter } from "@angular/router"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { ProjectSubscriptionRepositoryPort } from "@domain/project/ports/project-subscription.repository.port"; + +describe("StageThreeComponent", () => { + let component: OnboardingStageThreeComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const authSpy = { + saveProfile: vi.fn().mockReturnValue(of({})), + setOnboardingStage: vi.fn().mockReturnValue(of({})), + }; + const onboardingSpy = { formValue$: of({}) }; + + const authPortSpy = { updateProfile: of({}) }; + + await TestBed.configureTestingModule({ + imports: [OnboardingStageThreeComponent], + providers: [ + { provide: AuthRepository, useValue: authSpy }, + { provide: OnboardingService, useValue: onboardingSpy }, + { provide: AuthRepositoryPort, useValue: authPortSpy }, + { + provide: ProjectSubscriptionRepositoryPort, + useValue: { getSubscriptions: of({ results: [], count: 0 }) }, + }, + provideRouter([]), + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(OnboardingStageThreeComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/onboarding/stage-three/stage-three.component.ts b/projects/social_platform/src/app/ui/pages/onboarding/stage-three/stage-three.component.ts new file mode 100644 index 000000000..823a02379 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/onboarding/stage-three/stage-three.component.ts @@ -0,0 +1,43 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, inject, OnInit } from "@angular/core"; +import { ButtonComponent } from "@ui/primitives"; +import { UserTypeCardComponent } from "./user-type-card/user-type-card.component"; +import { OnboardingStageThreeUIInfoService } from "@api/onboarding/facades/stages/ui/onboarding-stage-three-ui-info.service"; +import { OnboardingStageThreeInfoService } from "@api/onboarding/facades/stages/onboarding-stage-three-info.service"; +import { OnboardingUIInfoService } from "@api/onboarding/facades/stages/ui/onboarding-ui-info.service"; + +/** Финальный этап онбординга — выбор роли пользователя. */ +@Component({ + selector: "app-stage-three", + templateUrl: "./stage-three.component.html", + styleUrl: "./stage-three.component.scss", + imports: [UserTypeCardComponent, ButtonComponent], + providers: [ + OnboardingStageThreeInfoService, + OnboardingStageThreeUIInfoService, + OnboardingUIInfoService, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class OnboardingStageThreeComponent implements OnInit { + private readonly onboardingStageThreeInfoService = inject(OnboardingStageThreeInfoService); + private readonly onboardingStageThreeUIInfoService = inject(OnboardingStageThreeUIInfoService); + private readonly onboardingUIInfoService = inject(OnboardingUIInfoService); + + protected readonly userRole = this.onboardingStageThreeUIInfoService.userRole; + protected readonly stageTouched = this.onboardingUIInfoService.stageTouched; + protected readonly stageSubmitting = this.onboardingUIInfoService.stageSubmitting; + + ngOnInit(): void { + this.onboardingStageThreeInfoService.initializationFormValues(); + } + + onSetRole(role: number) { + this.onboardingStageThreeUIInfoService.applySetRole(role); + } + + onSubmit() { + this.onboardingStageThreeInfoService.onSubmit(); + } +} diff --git a/projects/social_platform/src/app/office/onboarding/user-type-card/user-type-card.component.html b/projects/social_platform/src/app/ui/pages/onboarding/stage-three/user-type-card/user-type-card.component.html similarity index 80% rename from projects/social_platform/src/app/office/onboarding/user-type-card/user-type-card.component.html rename to projects/social_platform/src/app/ui/pages/onboarding/stage-three/user-type-card/user-type-card.component.html index 6e1d8e391..7a45ad45a 100644 --- a/projects/social_platform/src/app/office/onboarding/user-type-card/user-type-card.component.html +++ b/projects/social_platform/src/app/ui/pages/onboarding/stage-three/user-type-card/user-type-card.component.html @@ -1,8 +1,8 @@ -
  • +
  • -
    +
    diff --git a/projects/social_platform/src/app/office/onboarding/user-type-card/user-type-card.component.scss b/projects/social_platform/src/app/ui/pages/onboarding/stage-three/user-type-card/user-type-card.component.scss similarity index 100% rename from projects/social_platform/src/app/office/onboarding/user-type-card/user-type-card.component.scss rename to projects/social_platform/src/app/ui/pages/onboarding/stage-three/user-type-card/user-type-card.component.scss diff --git a/projects/social_platform/src/app/office/onboarding/user-type-card/user-type-card.component.spec.ts b/projects/social_platform/src/app/ui/pages/onboarding/stage-three/user-type-card/user-type-card.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/office/onboarding/user-type-card/user-type-card.component.spec.ts rename to projects/social_platform/src/app/ui/pages/onboarding/stage-three/user-type-card/user-type-card.component.spec.ts diff --git a/projects/social_platform/src/app/ui/pages/onboarding/stage-three/user-type-card/user-type-card.component.ts b/projects/social_platform/src/app/ui/pages/onboarding/stage-three/user-type-card/user-type-card.component.ts new file mode 100644 index 000000000..41be876af --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/onboarding/stage-three/user-type-card/user-type-card.component.ts @@ -0,0 +1,15 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, input } from "@angular/core"; + +/** Карточка выбора типа пользователя на этапе онбординга. */ +@Component({ + selector: "app-user-type-card", + templateUrl: "./user-type-card.component.html", + styleUrl: "./user-type-card.component.scss", + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class UserTypeCardComponent { + readonly isActive = input(false); +} diff --git a/projects/social_platform/src/app/ui/pages/onboarding/stage-two/stage-two.component.html b/projects/social_platform/src/app/ui/pages/onboarding/stage-two/stage-two.component.html new file mode 100644 index 000000000..7e70ab5cb --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/onboarding/stage-two/stage-two.component.html @@ -0,0 +1,106 @@ + + +
    +
    +
    +

    Какими навыками вы обладаете?

    + +
    +
    + закончить регистрацию позже + продолжить +
    + +
    +
    +
    + +
    +

    выбранные навыки

    + +
    +
    +
    +
    +
    +

    библиотека

    + +
    +
    +
    +
      + @for (skillGroup of nestedSkills$ | async; track skillGroup.id) { +
    • + +
    • + } +
    +
    +
    +
    + + +
    +
    + +

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

    +
    +

    {{ isChooseSkillText() }}.

    +
    +
    +
    diff --git a/projects/social_platform/src/app/office/onboarding/stage-two/stage-two.component.scss b/projects/social_platform/src/app/ui/pages/onboarding/stage-two/stage-two.component.scss similarity index 100% rename from projects/social_platform/src/app/office/onboarding/stage-two/stage-two.component.scss rename to projects/social_platform/src/app/ui/pages/onboarding/stage-two/stage-two.component.scss diff --git a/projects/social_platform/src/app/ui/pages/onboarding/stage-two/stage-two.component.spec.ts b/projects/social_platform/src/app/ui/pages/onboarding/stage-two/stage-two.component.spec.ts new file mode 100644 index 000000000..bdab4c649 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/onboarding/stage-two/stage-two.component.spec.ts @@ -0,0 +1,71 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { OnboardingStageTwoComponent } from "./stage-two.component"; +import { EMPTY, of } from "rxjs"; +import { ReactiveFormsModule } from "@angular/forms"; +import { AuthRepository } from "@infrastructure/repository/auth/auth.repository"; +import { provideRouter } from "@angular/router"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { SkillsRepositoryPort } from "@domain/skills/ports/skills.repository.port"; +import { SpecializationsRepositoryPort } from "@domain/specializations/ports/specializations.repository.port"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { OnboardingService } from "@api/onboarding/onboarding.service"; +import { ProfileInfoService } from "@api/profile/facades/profile-info.service"; +import { signal } from "@angular/core"; + +describe("StageTwoComponent", () => { + let component: OnboardingStageTwoComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const authSpy = { + profile: of({}), + saveProfile: of({}), + setOnboardingStage: of({}), + }; + + const authPortSpy = { + fetchProfile: of({}), + fetchUserRoles: of([]), + fetchChangeableRoles: of([]), + updateProfile: of({}), + }; + + const skillsSpy = { + getSkillsNested: () => of([]), + getSkillsInline: () => of({ count: 0, results: [], next: "", previous: "" }), + }; + + const specializationsSpy = { + getSpecializationsNested: () => of([]), + getSpecializationsInline: () => of({ count: 0, results: [], next: "", previous: "" }), + }; + + const onboardingSpy = { formValue$: EMPTY, setFormValue: () => {} }; + + await TestBed.configureTestingModule({ + imports: [ReactiveFormsModule, HttpClientTestingModule, OnboardingStageTwoComponent], + providers: [ + { provide: AuthRepository, useValue: authSpy }, + { provide: AuthRepositoryPort, useValue: authPortSpy }, + { provide: SkillsRepositoryPort, useValue: skillsSpy }, + { provide: SpecializationsRepositoryPort, useValue: specializationsSpy }, + { provide: OnboardingService, useValue: onboardingSpy }, + { provide: ProfileInfoService, useValue: { profile: signal(null) } }, + provideRouter([]), + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(OnboardingStageTwoComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/onboarding/stage-two/stage-two.component.ts b/projects/social_platform/src/app/ui/pages/onboarding/stage-two/stage-two.component.ts new file mode 100644 index 000000000..32effbde3 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/onboarding/stage-two/stage-two.component.ts @@ -0,0 +1,116 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, inject, OnInit } from "@angular/core"; +import { ReactiveFormsModule } from "@angular/forms"; +import { ControlErrorPipe } from "@corelib"; +import { ButtonComponent, IconComponent } from "@ui/primitives"; +import { CommonModule } from "@angular/common"; +import { AutoCompleteInputComponent } from "@ui/primitives/autocomplete-input/autocomplete-input.component"; +import { SkillsGroupComponent } from "@ui/widgets/skills-group/skills-group.component"; +import { SkillsBasketComponent } from "@ui/widgets/skills-basket/skills-basket.component"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { TooltipComponent } from "@ui/primitives/tooltip/tooltip.component"; +import { Skill } from "@domain/skills/skill.model"; +import { OnboardingUIInfoService } from "@api/onboarding/facades/stages/ui/onboarding-ui-info.service"; +import { OnboardingStageTwoUIInfoService } from "@api/onboarding/facades/stages/ui/onboarding-stage-two-ui-info.service"; +import { OnboardingStageTwoInfoService } from "@api/onboarding/facades/stages/onboarding-stage-two-info.service"; +import { TooltipInfoService } from "@api/tooltip/tooltip-info.service"; +import { SearchesService } from "@api/searches/searches.service"; + +/** Этап онбординга — выбор навыков с каталогом и корзиной. */ +@Component({ + selector: "app-stage-two", + templateUrl: "./stage-two.component.html", + styleUrl: "./stage-two.component.scss", + imports: [ + CommonModule, + ReactiveFormsModule, + IconComponent, + ButtonComponent, + ModalComponent, + ControlErrorPipe, + AutoCompleteInputComponent, + SkillsGroupComponent, + SkillsBasketComponent, + TooltipComponent, + ], + providers: [ + OnboardingStageTwoInfoService, + OnboardingStageTwoUIInfoService, + OnboardingUIInfoService, + TooltipInfoService, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class OnboardingStageTwoComponent implements OnInit { + private readonly onboardingStageTwoInfoService = inject(OnboardingStageTwoInfoService); + private readonly onboardingStageTwoUIInfoService = inject(OnboardingStageTwoUIInfoService); + private readonly onboardingUIInfoService = inject(OnboardingUIInfoService); + private readonly tooltipInfoService = inject(TooltipInfoService); + private readonly searchesService = inject(SearchesService); + + protected readonly stageForm = this.onboardingStageTwoUIInfoService.stageForm; + + protected readonly nestedSkills$ = this.searchesService.getSkillsNested(); + + protected readonly searchedSkills = this.onboardingStageTwoUIInfoService.searchedSkills; + + // Для управления открытыми группами навыков + protected readonly openSkillGroup = this.onboardingStageTwoUIInfoService.openSkillGroup; + + protected readonly isHintAuthVisible = this.tooltipInfoService.isVisible; + protected readonly isHintLibVisible = this.tooltipInfoService.isVisible; + + protected readonly stageSubmitting = this.onboardingUIInfoService.stageSubmitting; + protected readonly skipSubmitting = this.onboardingUIInfoService.skipSubmitting; + + protected readonly isChooseSkill = this.onboardingStageTwoUIInfoService.isChooseSkill; + protected readonly isChooseSkillText = this.onboardingStageTwoUIInfoService.isChooseSkillText; + + // + protected readonly hasOpenSkillsGroups = this.onboardingStageTwoUIInfoService.hasOpenSkillsGroups; + + onSkillGroupToggled(isOpen: boolean, skillName: string): void { + this.onboardingStageTwoUIInfoService.onSkillGroupToggled(isOpen, skillName); + } + + isSkillGroupDisabled(skillName: string): boolean { + return this.onboardingStageTwoUIInfoService.isSkillGroupDisabled(skillName); + } + + ngOnInit(): void { + this.onboardingStageTwoInfoService.initializationFormValues(); + } + + ngAfterViewInit(): void { + this.onboardingStageTwoInfoService.initializationSkills(); + } + + toggleTooltip(key: "auth" | "lib"): void { + this.tooltipInfoService.toggleTooltip(key); + } + + onSkipRegistration(): void { + this.onboardingStageTwoInfoService.onSkipRegistration(); + } + + onSubmit(): void { + this.onboardingStageTwoInfoService.onSubmit(); + } + + onAddSkill(newSkill: Skill): void { + this.onboardingStageTwoInfoService.onAddSkill(newSkill); + } + + onRemoveSkill(oddSkill: Skill): void { + this.onboardingStageTwoInfoService.onRemoveSkill(oddSkill); + } + + onOptionToggled(toggledSkill: Skill): void { + this.onboardingStageTwoInfoService.onOptionToggled(toggledSkill); + } + + onSearchSkill(query: string): void { + this.onboardingStageTwoInfoService.onSearchSkill(query); + } +} diff --git a/projects/social_platform/src/app/ui/pages/onboarding/stage-two/stage-two.resolver.ts b/projects/social_platform/src/app/ui/pages/onboarding/stage-two/stage-two.resolver.ts new file mode 100644 index 000000000..ee401f65f --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/onboarding/stage-two/stage-two.resolver.ts @@ -0,0 +1,13 @@ +/** @format */ + +import { inject } from "@angular/core"; +import { ResolveFn } from "@angular/router"; +import { SkillsGroup } from "@domain/skills/skills-group.model"; +import { SearchesService } from "@api/searches/searches.service"; + +/** Предзагружает иерархическую структуру навыков для этапа онбординга. */ +export const StageTwoResolver: ResolveFn = () => { + const searchesService = inject(SearchesService); + + return searchesService.getSkillsNested(); +}; diff --git a/projects/social_platform/src/app/ui/pages/onboarding/stage-zero/stage-zero.component.html b/projects/social_platform/src/app/ui/pages/onboarding/stage-zero/stage-zero.component.html new file mode 100644 index 000000000..2b406c377 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/onboarding/stage-zero/stage-zero.component.html @@ -0,0 +1,657 @@ + + +@if (profile()) { +
    +
    +

    Привет, {{ profile()!.firstName }} {{ profile()!.lastName }}! ✌️

    +

    Расскажите о себе, чтобы ваше резюме было сильным и отражало опыт

    +
    +
    +
    + @if (stageForm.get("avatar"); as avatar) { +
    +
    +

    Фотография профиля*

    + +
    + + @if (avatar | controlError: "required") { +
    + {{ errorMessage.EMPTY_AVATAR }} +
    + } +
    + } + @if (stageForm.get("city"); as city) { +
    + + + + @if (city | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } +
    + +
    +
    +
    +

    Образование

    + +
    +
    + + @if (stageForm.get("educationLevel"); as educationLevel) { +
    + + + + + @if (educationLevel | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } + +
    + @if (stageForm.get("entryYear"); as entryYear) { +
    + + + + + + @if (entryYear | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } + @if (stageForm.get("completionYear"); as completionYear) { +
    + + + + + @if (completionYear | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } +
    + + @if (stageForm.get("organizationName"); as organizationName) { +
    + + + @if (organizationName | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } + @if (stageForm.get("educationStatus"); as educationStatus) { +
    + + + + + @if (educationStatus | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } + @if (stageForm.get("description"); as description) { +
    + + + + @if (description | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } + +
    + + {{ editEducationClick() ? "Созранить изменения" : "Добавить образование" }} + + + + @if (educationItems().length || education.length) { + @for (educationItem of education.value; track $index) { +
    +
    +

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

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

    Место работы

    + +
    +
    + + @if (stageForm.get("organizationNameWork"); as organizationNameWork) { +
    + + + + @if (organizationNameWork | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } + +
    + @if (stageForm.get("entryYearWork"); as entryYearWork) { +
    + + + + + + @if (entryYearWork | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } + @if (stageForm.get("completionYearWork"); as completionYearWork) { +
    + + + + + + @if (completionYearWork | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } +
    + + @if (stageForm.get("jobPosition"); as jobPosition) { +
    + + + @if (jobPosition | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } + @if (stageForm.get("descriptionWork"); as descriptionWork) { +
    + + + + @if (descriptionWork | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } + +
    + + {{ editWorkClick() ? "Сохранить изменения" : "Добавить место работы" }} + + + + @if (workItems().length || workExperience.length) { + @for (workItem of workExperience.value; track $index) { +
    +
    +

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

    + +
    + + + +
    + } + } +
    +
    + +
    + +
      + @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 (stageForm.get("language"); as language) { +
    + + + + + + @if (language | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } + @if (stageForm.get("languageLevel"); as languageLevel) { +
    + + + + + @if (languageLevel | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } +
    + Количество добавляемых языков не более 4-х +
    + + + {{ editLanguageClick() ? "Сохранить изменения" : "Добавить язык" }} + + + +
    + @if (languageItems().length || userLanguages.length) { + @for (languageItem of userLanguages.value; track $index) { +
    +
    +

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

    + +
    + + + +
    + } + } +
    +
    + +
    + закончить регистрацию позже + продолжить +
    + + +
    +
    + +

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

    +
    +

    {{ isModalErrorYearText() }}.

    +
    +
    +
    +} diff --git a/projects/social_platform/src/app/office/onboarding/stage-zero/stage-zero.component.scss b/projects/social_platform/src/app/ui/pages/onboarding/stage-zero/stage-zero.component.scss similarity index 100% rename from projects/social_platform/src/app/office/onboarding/stage-zero/stage-zero.component.scss rename to projects/social_platform/src/app/ui/pages/onboarding/stage-zero/stage-zero.component.scss diff --git a/projects/social_platform/src/app/ui/pages/onboarding/stage-zero/stage-zero.component.spec.ts b/projects/social_platform/src/app/ui/pages/onboarding/stage-zero/stage-zero.component.spec.ts new file mode 100644 index 000000000..6078ea306 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/onboarding/stage-zero/stage-zero.component.spec.ts @@ -0,0 +1,56 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { OnboardingStageZeroComponent } from "./stage-zero.component"; +import { of } from "rxjs"; +import { AuthRepository } from "@infrastructure/repository/auth/auth.repository"; +import { ReactiveFormsModule } from "@angular/forms"; +import { provideRouter } from "@angular/router"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { provideNgxMask } from "ngx-mask"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { ProjectSubscriptionRepositoryPort } from "@domain/project/ports/project-subscription.repository.port"; + +describe("StageZeroComponent", () => { + let component: OnboardingStageZeroComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const authSpy = { + profile: of({}), + saveProfile: of({}), + setOnboardingStage: of({}), + }; + + const authPortSpy = { + fetchProfile: of({}), + fetchUserRoles: of([]), + fetchChangeableRoles: of([]), + }; + + await TestBed.configureTestingModule({ + imports: [ReactiveFormsModule, HttpClientTestingModule, OnboardingStageZeroComponent], + providers: [ + { provide: AuthRepository, useValue: authSpy }, + { provide: AuthRepositoryPort, useValue: authPortSpy }, + { + provide: ProjectSubscriptionRepositoryPort, + useValue: { getSubscriptions: of({ results: [], count: 0 }) }, + }, + provideNgxMask(), + provideRouter([]), + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(OnboardingStageZeroComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/onboarding/stage-zero/stage-zero.component.ts b/projects/social_platform/src/app/ui/pages/onboarding/stage-zero/stage-zero.component.ts new file mode 100644 index 000000000..a36016d67 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/onboarding/stage-zero/stage-zero.component.ts @@ -0,0 +1,213 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, inject, OnInit } from "@angular/core"; +import { ReactiveFormsModule } from "@angular/forms"; +import { ErrorMessage } from "@core/lib/models/error/error-message"; +import { ControlErrorPipe } from "@corelib"; +import { ButtonComponent, InputComponent, SelectComponent } from "@ui/primitives"; +import { CommonModule } from "@angular/common"; +import { IconComponent } from "@uilib"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { TooltipComponent } from "@ui/primitives/tooltip/tooltip.component"; +import { generateOptionsList } from "@utils/generate-options-list"; +import { OnboardingStageZeroInfoService } from "@api/onboarding/facades/stages/onboarding-stage-zero-info.service"; +import { OnboardingStageZeroUIInfoService } from "@api/onboarding/facades/stages/ui/onboarding-stage-zero-ui-info.service"; +import { OnboardingUIInfoService } from "@api/onboarding/facades/stages/ui/onboarding-ui-info.service"; +import { TooltipInfoService, TooltipKey } from "@api/tooltip/tooltip-info.service"; +import { AvatarControlComponent } from "@ui/primitives/avatar-control/avatar-control.component"; + +/** Начальный этап онбординга — сбор базовой информации профиля (фото, город, образование, опыт, языки, достижения). */ +@Component({ + selector: "app-stage-zero", + templateUrl: "./stage-zero.component.html", + styleUrl: "./stage-zero.component.scss", + imports: [ + ReactiveFormsModule, + InputComponent, + ButtonComponent, + IconComponent, + ControlErrorPipe, + SelectComponent, + ModalComponent, + CommonModule, + TooltipComponent, + AvatarControlComponent, + ], + providers: [ + OnboardingStageZeroInfoService, + OnboardingStageZeroUIInfoService, + OnboardingUIInfoService, + TooltipInfoService, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class OnboardingStageZeroComponent implements OnInit { + private readonly onboardingStageZeroInfoService = inject(OnboardingStageZeroInfoService); + private readonly onboardingStageZeroUIInfoService = inject(OnboardingStageZeroUIInfoService); + private readonly onboardingUIInfoService = inject(OnboardingUIInfoService); + private readonly tooltipInfoService = inject(TooltipInfoService); + + protected readonly isHintPhotoVisible = this.tooltipInfoService.isVisible; + protected readonly isHintCityVisible = this.tooltipInfoService.isVisible; + protected readonly isHintEducationVisible = this.tooltipInfoService.isVisible; + protected readonly isHintEducationDescriptionVisible = this.tooltipInfoService.isVisible; + + protected readonly isHintWorkVisible = this.tooltipInfoService.isVisible; + protected readonly isHintWorkNameVisible = this.tooltipInfoService.isVisible; + protected readonly isHintWorkDescriptionVisible = this.tooltipInfoService.isVisible; + + protected readonly isHintAchievementsVisible = this.tooltipInfoService.isVisible; + protected readonly isHintLanguageVisible = this.tooltipInfoService.isVisible; + + protected readonly yearListEducation = generateOptionsList(55, "years"); + + protected readonly educationStatusList = + this.onboardingStageZeroUIInfoService.educationStatusList; + + protected readonly educationLevelList = this.onboardingStageZeroUIInfoService.educationStatusList; + + protected readonly languageList = this.onboardingStageZeroUIInfoService.languageList; + protected readonly languageLevelList = this.onboardingStageZeroUIInfoService.languageLevelList; + + protected readonly stageForm = this.onboardingStageZeroUIInfoService.stageForm; + protected readonly profile = this.onboardingStageZeroUIInfoService.profile; + protected readonly stageSubmitting = this.onboardingUIInfoService.stageSubmitting; + protected readonly skipSubmitting = this.onboardingUIInfoService.skipSubmitting; + + protected readonly educationItems = this.onboardingStageZeroUIInfoService.educationItems; + + protected readonly workItems = this.onboardingStageZeroUIInfoService.workItems; + + protected readonly languageItems = this.onboardingStageZeroUIInfoService.languageItems; + + protected readonly isModalErrorYear = this.onboardingStageZeroUIInfoService.isModalErrorYear; + protected readonly isModalErrorYearText = + this.onboardingStageZeroUIInfoService.isModalErrorYearText; + + protected readonly editIndex = this.onboardingStageZeroUIInfoService.editIndex; + + protected readonly editEducationClick = this.onboardingStageZeroUIInfoService.editEducationClick; + protected readonly editWorkClick = this.onboardingStageZeroUIInfoService.editWorkClick; + protected readonly editLanguageClick = this.onboardingStageZeroUIInfoService.editLanguageClick; + + protected readonly selectedEntryYearEducationId = + this.onboardingStageZeroUIInfoService.selectedEntryYearEducationId; + + protected readonly selectedComplitionYearEducationId = + this.onboardingStageZeroUIInfoService.selectedComplitionYearEducationId; + + protected readonly selectedEducationStatusId = + this.onboardingStageZeroUIInfoService.selectedEducationStatusId; + + protected readonly selectedEducationLevelId = + this.onboardingStageZeroUIInfoService.selectedEducationLevelId; + + protected readonly selectedEntryYearWorkId = + this.onboardingStageZeroUIInfoService.selectedEntryYearWorkId; + + protected readonly selectedComplitionYearWorkId = + this.onboardingStageZeroUIInfoService.selectedComplitionYearWorkId; + + protected readonly selectedLanguageId = this.onboardingStageZeroUIInfoService.selectedLanguageId; + protected readonly selectedLanguageLevelId = + this.onboardingStageZeroUIInfoService.selectedLanguageLevelId; + + protected readonly errorMessage = ErrorMessage; + + protected readonly achievements = this.onboardingStageZeroUIInfoService.achievements; + protected readonly education = this.onboardingStageZeroUIInfoService.education; + protected readonly workExperience = this.onboardingStageZeroUIInfoService.workExperience; + protected readonly userLanguages = this.onboardingStageZeroUIInfoService.userLanguages; + + get isEducationDirty(): boolean { + const f = this.stageForm; + return [ + "organizationName", + "entryYear", + "completionYear", + "description", + "educationStatus", + "educationLevel", + ].some(name => f.get(name)?.dirty); + } + + get isWorkDirty(): boolean { + const f = this.stageForm; + return [ + "organizationNameWork", + "entryYearWork", + "completionYearWork", + "descriptionWork", + "jobPosition", + ].some(name => f.get(name)?.dirty); + } + + get isLanguageDirty(): boolean { + const f = this.stageForm; + return ["language", "languageLevel"].some(name => f.get(name)?.dirty); + } + + ngOnInit(): void { + this.onboardingStageZeroInfoService.initializationStageZero(); + } + + ngAfterViewInit() { + this.onboardingStageZeroInfoService.initializationFormValues(); + } + + toggleTooltip(key: TooltipKey): void { + this.tooltipInfoService.toggleTooltip(key); + } + + addEducation() { + this.onboardingStageZeroUIInfoService.addEducation(); + } + + editEducation(index: number) { + this.onboardingStageZeroUIInfoService.editEducation(index); + } + + removeEducation(i: number) { + this.onboardingStageZeroUIInfoService.removeEducation(i); + } + + addWork() { + this.onboardingStageZeroUIInfoService.addWork(); + } + + editWork(index: number) { + this.onboardingStageZeroUIInfoService.editWork(index); + } + + removeWork(i: number) { + this.onboardingStageZeroUIInfoService.removeWork(i); + } + + addLanguage() { + this.onboardingStageZeroUIInfoService.addLanguage(); + } + + editLanguage(index: number) { + this.onboardingStageZeroUIInfoService.editLanguage(index); + } + + removeLanguage(i: number) { + this.onboardingStageZeroUIInfoService.removeLanguage(i); + } + + addAchievement(id?: number, title?: string, status?: string): void { + this.onboardingStageZeroUIInfoService.addAchievement(id, title, status); + } + + removeAchievement(i: number): void { + this.onboardingStageZeroUIInfoService.removeAchievement(i); + } + + onSkipRegistration(): void { + this.onboardingStageZeroInfoService.onSkipRegistration(); + } + + onSubmit(): void { + this.onboardingStageZeroInfoService.onSubmit(); + } +} diff --git a/projects/social_platform/src/app/ui/pages/profile/detail/main/components/profile-left-side/profile-left-side.component.html b/projects/social_platform/src/app/ui/pages/profile/detail/main/components/profile-left-side/profile-left-side.component.html new file mode 100644 index 000000000..b979d3127 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/profile/detail/main/components/profile-left-side/profile-left-side.component.html @@ -0,0 +1,100 @@ + + +
    +
    +
    +

    метаданные

    + +
    + +
      +
    • + +

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

      +
    • + + @if (user()!.personal.city) { +
    • + +

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

      +
    • + } + @if (user()!.personal.speciality) { +
    • + +

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

      +
    • + } +
    +
    + + @if (user()!.relations.userLanguages.length > 0) { +
    +
    +

    языки

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

      {{ language.language }}

      +
    • + } +
    +
    + } + @if (user()!.relations.programs.length; as programsLength) { +
    +
      + @for (p of user()!.relations.programs.slice(0, 3); track p.id) { +
    • + +
    • + } +
    +
    + @if (user()!.relations.programs) { +
      + @for (program of user()!.relations.programs.slice(3); track program.id) { +
    • + +
    • + } +
    + } +
    + +
    +
    + + program logo + +
    +
    +
    + @if (programsLength > 3) { +
    + {{ readAllPrograms()["programs"] ? "скрыть" : "подробнее" }} +
    + } +
    + } +
    diff --git a/projects/social_platform/src/app/ui/pages/profile/detail/main/components/profile-left-side/profile-left-side.component.scss b/projects/social_platform/src/app/ui/pages/profile/detail/main/components/profile-left-side/profile-left-side.component.scss new file mode 100644 index 000000000..04f396bbd --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/profile/detail/main/components/profile-left-side/profile-left-side.component.scss @@ -0,0 +1,166 @@ +@mixin expandable-list { + &__remaining { + display: grid; + grid-template-rows: 0fr; + overflow: hidden; + transition: all 0.5s ease-in-out; + + &--show { + grid-template-rows: 1fr; + } + + ul { + min-height: 0; + } + + li { + &:first-child { + margin-top: 12px; + } + + &:not(:last-child) { + margin-bottom: 12px; + } + } + } +} + +.profile { + &__left { + width: 157px; + } + + &__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); + } +} + +.lists { + &__section { + display: flex; + justify-content: space-between; + margin-bottom: 8px; + border-bottom: 0.5px solid var(--accent); + } + + &__list { + display: flex; + flex-direction: column; + gap: 10px; + + &--line { + display: flex; + flex-flow: wrap; + gap: 10px; + } + } + + &__icon { + color: var(--accent); + } + + &__title { + margin-bottom: 8px; + color: var(--accent); + } + + &__index { + color: var(--accent); + } + + &__logo { + border-radius: var(--rounded-xxl); + } + + &__info { + display: flex; + flex-direction: column; + + &--text { + color: var(--black) !important; + } + + &--subtext { + color: var(--grey-for-text) !important; + } + + &--more { + color: var(--accent) !important; + } + + img { + border-radius: var(--rounded-xxl); + } + } + + &__date { + display: flex; + flex-direction: column; + align-items: center; + width: 45px; + height: 45px; + padding: 5px; + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-xl); + } + + &__item { + display: flex; + gap: 6px; + align-items: center; + color: var(--grey-for-text); + + &--status { + padding: 8px; + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-xl); + } + + &--title { + color: var(--black); + } + + &--more { + margin-top: 8px; + color: var(--accent); + } + + i, + .lists__index { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding-top: 1px; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-xl); + } + + p { + color: var(--accent); + } + + span { + cursor: pointer; + } + } + + @include expandable-list; +} + +.read-more { + margin-top: 12px; + color: var(--accent); + cursor: pointer; + transition: color 0.2s; + + &:hover { + color: var(--accent-dark); + } +} diff --git a/projects/social_platform/src/app/ui/pages/profile/detail/main/components/profile-left-side/profile-left-side.component.ts b/projects/social_platform/src/app/ui/pages/profile/detail/main/components/profile-left-side/profile-left-side.component.ts new file mode 100644 index 000000000..762c3ea17 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/profile/detail/main/components/profile-left-side/profile-left-side.component.ts @@ -0,0 +1,35 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + inject, + input, + Input, + WritableSignal, +} from "@angular/core"; +import { RouterModule } from "@angular/router"; +import { YearsFromBirthdayPipe, TruncatePipe } from "@corelib"; +import { IconComponent } from "@ui/primitives"; +import { ExpandService } from "@api/expand/expand.service"; +import { User } from "@domain/auth/user.model"; +import { AppRoutes } from "@api/paths/app-routes"; + +/** Левая колонка профиля: аватар и основные данные. */ +@Component({ + selector: "app-profile-left-side", + templateUrl: "./profile-left-side.component.html", + styleUrl: "./profile-left-side.component.scss", + imports: [CommonModule, RouterModule, IconComponent, YearsFromBirthdayPipe, TruncatePipe], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProfileLeftSideComponent { + readonly user = input.required(); + + protected readonly expandService = inject(ExpandService); + + protected readonly readAllPrograms = this.expandService.readAll; + + protected readonly AppRoutes = AppRoutes; +} diff --git a/projects/social_platform/src/app/ui/pages/profile/detail/main/components/profile-mid-side/profile-mid-side.component.html b/projects/social_platform/src/app/ui/pages/profile/detail/main/components/profile-mid-side/profile-mid-side.component.html new file mode 100644 index 000000000..22e26bff2 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/profile/detail/main/components/profile-mid-side/profile-mid-side.component.html @@ -0,0 +1,82 @@ + + +@if (loggedUserId()) { +
    + @if (user()!.personal.aboutMe; as about) { +
    +
    +

    обо мне

    + +
    + +
    +

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

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

    + заполнить +
    + } + } @else { +
    + @if (loggedUserId() === user()!.id) { + + } +
      + @for (n of news(); track n.id) { +
    • + +
    • + } +
    +
    + } +
    +} diff --git a/projects/social_platform/src/app/ui/pages/profile/detail/main/components/profile-mid-side/profile-mid-side.component.scss b/projects/social_platform/src/app/ui/pages/profile/detail/main/components/profile-mid-side/profile-mid-side.component.scss new file mode 100644 index 000000000..191b34566 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/profile/detail/main/components/profile-mid-side/profile-mid-side.component.scss @@ -0,0 +1,127 @@ +@use "styles/responsive"; + +.profile { + &__content { + grid-row-start: 2; + min-width: 0; + word-break: break-word; + + @include responsive.apply-desktop { + grid-row-start: unset; + } + } + + &__news { + grid-row-start: 4; + + @include responsive.apply-desktop { + grid-row-start: unset; + } + } + + &__directions { + display: grid; + grid-template-columns: 1fr 1fr 3fr; + grid-gap: 20px; + align-items: center; + margin-top: 14px; + } + + &__empty { + display: flex; + flex-direction: column; + gap: 24px; + align-items: center; + + &--text { + color: var(--grey-for-text); + } + + i { + color: var(--grey-for-text); + } + } +} + +.about { + padding: 24px; + background-color: var(--light-white); + border-radius: var(--rounded-lg); + + &__head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + border-bottom: 0.5px solid var(--accent); + + &--icon { + color: var(--accent); + } + } + + &__title { + margin-bottom: 8px; + color: var(--accent); + } + /* stylelint-disable value-no-vendor-prefix */ + &__text { + p { + display: -webkit-box; + overflow: hidden; + color: var(--black); + text-overflow: ellipsis; + word-break: break-word; + transition: all 0.7s ease-in-out; + -webkit-box-orient: vertical; + -webkit-line-clamp: 5; + + &.expanded { + -webkit-line-clamp: unset; + } + } + + ::ng-deep a { + color: var(--accent); + text-decoration: underline; + text-decoration-color: transparent; + text-underline-offset: 3px; + transition: text-decoration-color 0.2s; + + &:hover { + text-decoration-color: var(--accent); + } + } + } + + /* stylelint-enable value-no-vendor-prefix */ + + &__read-full { + margin-top: 2px; + color: var(--accent); + cursor: pointer; + } +} + +.news { + &__form { + display: block; + margin-top: 20px; + } + + &__item { + display: block; + margin-top: 20px; + } +} + +.read-more { + margin-top: 12px; + color: var(--accent); + cursor: pointer; + transition: color 0.2s; + + &:hover { + color: var(--accent-dark); + } +} diff --git a/projects/social_platform/src/app/ui/pages/profile/detail/main/components/profile-mid-side/profile-mid-side.component.ts b/projects/social_platform/src/app/ui/pages/profile/detail/main/components/profile-mid-side/profile-mid-side.component.ts new file mode 100644 index 000000000..3155ad594 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/profile/detail/main/components/profile-mid-side/profile-mid-side.component.ts @@ -0,0 +1,110 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + inject, + input, + signal, + viewChild, +} from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { finalize } from "rxjs"; +import { RouterModule } from "@angular/router"; +import { ParseBreaksPipe, ParseLinksPipe } from "@corelib"; +import { IconComponent, ButtonComponent } from "@ui/primitives"; +import { NewsCardComponent } from "@ui/widgets/news-card/news-card.component"; +import { NewsFormComponent } from "@ui/widgets/news-form/news-form.component"; +import { ProjectDirectionCard } from "@ui/widgets/project-direction-card/project-direction-card.component"; +import { ExpandService } from "@api/expand/expand.service"; +import { NewsInfoService } from "@api/news/news-info.service"; +import { ProfileDetailInfoService } from "@api/profile/facades/detail/profile-detail-info.service"; +import { ProfileDetailUIInfoService } from "@api/profile/facades/detail/ui/profile-detail-ui-info.service"; +import { User } from "@domain/auth/user.model"; +import { ProfileNews } from "@domain/profile/profile-news.model"; +import { AppRoutes } from "@api/paths/app-routes"; + +/** Центральная колонка профиля: о себе, навыки, новости. */ +@Component({ + selector: "app-profile-mid-side", + templateUrl: "./profile-mid-side.component.html", + styleUrl: "./profile-mid-side.component.scss", + imports: [ + CommonModule, + IconComponent, + RouterModule, + NewsCardComponent, + ButtonComponent, + NewsFormComponent, + ProjectDirectionCard, + ParseLinksPipe, + ParseBreaksPipe, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProfileMidSideComponent { + readonly newsFormComponent = viewChild(NewsFormComponent); + readonly newsCardComponent = viewChild(NewsCardComponent); + + readonly user = input.required(); + + private readonly profileDetailInfoService = inject(ProfileDetailInfoService); + private readonly profileDetailUIInfoService = inject(ProfileDetailUIInfoService); + private readonly newsInfoService = inject(NewsInfoService); + private readonly expandService = inject(ExpandService); + + protected readonly loggedUserId = this.profileDetailUIInfoService.loggedUserId; + protected readonly isProfileEmpty = this.profileDetailUIInfoService.isProfileEmpty; + + protected readonly directions = this.profileDetailUIInfoService.directions; + protected readonly news = this.newsInfoService.news; + + protected readonly newsPending = signal(false); + + protected readonly descriptionExpandable = this.expandService.descriptionExpandable; + protected readonly readFullDescription = this.expandService.readFullDescription; + + private readonly destroyRef$ = inject(DestroyRef); + + protected readonly AppRoutes = AppRoutes; + + onAddNews(news: { text: string; files: string[] }): void { + this.newsPending.set(true); + this.profileDetailInfoService + .onAddNews(news) + .pipe( + finalize(() => this.newsPending.set(false)), + takeUntilDestroyed(this.destroyRef$), + ) + .subscribe({ + next: () => this.newsFormComponent()?.onResetForm(), + }); + } + + onDeleteNews(newsId: number): void { + this.profileDetailInfoService.onDeleteNews(newsId); + } + + onLike(newsId: number) { + this.profileDetailInfoService.onLike(newsId); + } + + onEditNews(news: ProfileNews, newsItemId: number) { + this.profileDetailInfoService + .onEditNews(news, newsItemId) + .pipe(takeUntilDestroyed(this.destroyRef$)) + .subscribe({ + next: () => this.newsCardComponent()?.onCloseEditMode(), + }); + } + + onNewsInView(entries: IntersectionObserverEntry[]): void { + this.profileDetailInfoService.onNewsInView(entries); + } + + onExpandDescription(elem: HTMLElement, expandedClass: string, isExpanded: boolean): void { + this.expandService.onExpand("description", elem, expandedClass, isExpanded); + } +} diff --git a/projects/social_platform/src/app/ui/pages/profile/detail/main/components/profile-right-side/profile-right-side.component.html b/projects/social_platform/src/app/ui/pages/profile/detail/main/components/profile-right-side/profile-right-side.component.html new file mode 100644 index 000000000..302f1b1ee --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/profile/detail/main/components/profile-right-side/profile-right-side.component.html @@ -0,0 +1,235 @@ + + +
    +
    + @if (user()!.personal.links.length; as linksLength) { +
    +
    +

    контакты

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

    образование

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

    + {{ education.entryYear }} +

    + +

    + +

    {{ education.completionYear }}

    +
    + +
    +

    + {{ education.organizationName | truncate: 20 }} +

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

    работа

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

    {{ workExperience.entryYear }}

    + +

    + +

    {{ workExperience.completionYear }}

    +
    + +
    +

    + {{ workExperience.organizationName | truncate: 20 }} +

    + + {{ + workExperience.jobPosition | truncate: 30 + }} + + подробнее +
    + + +
    +
    +

    {{ workExperience.organizationName }}

    + +
    + +

    {{ workExperience.description }}

    + +

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

    +
    +
    +
    + @if (workExperienceLength > 3) { +
    + {{ readAll()["workExperience"] ? "скрыть" : "подробнее" }} +
    + } +
    + } + @if (user()!.relations.projects.length; as projectsLength) { +
    +
    +

    проекты

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

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

    + {{ project.collaborator?.role }} +
    +
    +
    + @if (projectsLength > 3) { +
    + {{ readAll()["projects"] ? "скрыть" : "подробнее" }} +
    + } +
    + } +
    +
    diff --git a/projects/social_platform/src/app/ui/pages/profile/detail/main/components/profile-right-side/profile-right-side.component.scss b/projects/social_platform/src/app/ui/pages/profile/detail/main/components/profile-right-side/profile-right-side.component.scss new file mode 100644 index 000000000..3f4226437 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/profile/detail/main/components/profile-right-side/profile-right-side.component.scss @@ -0,0 +1,211 @@ +@use "styles/responsive"; + +@mixin expandable-list { + &__remaining { + display: grid; + grid-template-rows: 0fr; + overflow: hidden; + transition: all 0.5s ease-in-out; + + &--show { + grid-template-rows: 1fr; + } + + ul { + min-height: 0; + } + + li { + &:first-child { + margin-top: 12px; + } + + &:not(:last-child) { + margin-bottom: 12px; + } + } + } +} + +.profile { + &__right { + display: flex; + flex-direction: column; + } + + &__aside { + display: grid; + grid-row-start: 3; + gap: 20px; + + @include responsive.apply-desktop { + grid-row-start: unset; + } + } + + &__section { + padding: 24px; + margin-bottom: 20px; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); + } +} + +.lists { + &__section { + display: flex; + justify-content: space-between; + margin-bottom: 8px; + border-bottom: 0.5px solid var(--accent); + } + + &__list { + display: flex; + flex-direction: column; + gap: 10px; + + &--line { + display: flex; + flex-flow: wrap; + gap: 10px; + } + } + + &__icon { + color: var(--accent); + } + + &__title { + margin-bottom: 8px; + color: var(--accent); + } + + &__index { + color: var(--accent); + } + + &__logo { + border-radius: var(--rounded-xxl); + } + + &__info { + display: flex; + flex-direction: column; + + &--text { + color: var(--black) !important; + } + + &--subtext { + color: var(--grey-for-text) !important; + } + + &--more { + color: var(--accent) !important; + } + + img { + border-radius: var(--rounded-xxl); + } + } + + &__date { + display: flex; + flex-direction: column; + align-items: center; + min-width: 45px; + min-height: 45px; + padding: 5px; + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-xl); + } + + &__item { + display: flex; + gap: 6px; + align-items: center; + color: var(--grey-for-text); + + &--status { + padding: 8px; + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-xl); + } + + &--title { + color: var(--black); + } + + &--more { + margin-top: 8px; + color: var(--accent); + } + + i, + .lists__index { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding-top: 1px; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-xl); + } + + p { + color: var(--accent); + } + + span { + cursor: pointer; + } + } + + @include expandable-list; +} + +.read-more { + margin-top: 12px; + color: var(--accent); + cursor: pointer; + transition: color 0.2s; + + &:hover { + color: var(--accent-dark); + } +} + +.cancel { + display: flex; + flex-direction: column; + width: 350px; + height: 175px; + max-height: calc(100vh - 40px); + overflow-y: auto; + + &__top { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 8px; + margin-bottom: 8px; + border-bottom: 0.5px solid var(--accent); + } + + &__title { + color: var(--accent); + text-align: center; + } + + &__icon { + color: var(--accent); + } + + &__text { + margin-bottom: 8px; + color: var(--black); + } +} diff --git a/projects/social_platform/src/app/ui/pages/profile/detail/main/components/profile-right-side/profile-right-side.component.ts b/projects/social_platform/src/app/ui/pages/profile/detail/main/components/profile-right-side/profile-right-side.component.ts new file mode 100644 index 000000000..e4dc93528 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/profile/detail/main/components/profile-right-side/profile-right-side.component.ts @@ -0,0 +1,52 @@ +/** @format */ + +import { CommonModule, NgTemplateOutlet } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + inject, + input, + Input, + WritableSignal, +} from "@angular/core"; +import { IconComponent } from "@ui/primitives"; +import { UserLinksPipe, TruncatePipe } from "@corelib"; +import { ExpandService } from "@api/expand/expand.service"; +import { User } from "@domain/auth/user.model"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { ProfileDetailUIInfoService } from "@api/profile/facades/detail/ui/profile-detail-ui-info.service"; +import { RouterModule } from "@angular/router"; +import { AppRoutes } from "@api/paths/app-routes"; + +/** Правая колонка профиля: дополнительные блоки. */ +@Component({ + selector: "app-profile-right-side", + templateUrl: "./profile-right-side.component.html", + styleUrl: "./profile-right-side.component.scss", + imports: [ + CommonModule, + IconComponent, + RouterModule, + NgTemplateOutlet, + UserLinksPipe, + TruncatePipe, + ModalComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProfileRightSideComponent { + readonly user = input.required(); + + private readonly profileDetailUIInfoService = inject(ProfileDetailUIInfoService); + protected readonly expandService = inject(ExpandService); + + protected readonly readAll = this.expandService.readAll; + + protected readonly AppRoutes = AppRoutes; + + protected readonly isShowModal = this.profileDetailUIInfoService.isShowModal; + + openWorkInfoModal(): void { + this.profileDetailUIInfoService.applyOpenWorkInfoModal(); + } +} diff --git a/projects/social_platform/src/app/ui/pages/profile/detail/main/main.component.html b/projects/social_platform/src/app/ui/pages/profile/detail/main/main.component.html new file mode 100644 index 000000000..1551face9 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/profile/detail/main/main.component.html @@ -0,0 +1,13 @@ + + +@if (user()) { +
    +
    + + + + + +
    +
    +} diff --git a/projects/social_platform/src/app/ui/pages/profile/detail/main/main.component.scss b/projects/social_platform/src/app/ui/pages/profile/detail/main/main.component.scss new file mode 100644 index 000000000..fd5c48f61 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/profile/detail/main/main.component.scss @@ -0,0 +1,17 @@ +/** @format */ + +@use "styles/responsive"; + +.profile { + padding-bottom: 100px; + + @include responsive.apply-desktop { + padding-bottom: 0; + } + + &__details { + display: grid; + grid-template-columns: 2fr 5fr 3fr; + grid-gap: 20px; + } +} diff --git a/projects/social_platform/src/app/ui/pages/profile/detail/main/main.component.spec.ts b/projects/social_platform/src/app/ui/pages/profile/detail/main/main.component.spec.ts new file mode 100644 index 000000000..1344e7247 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/profile/detail/main/main.component.spec.ts @@ -0,0 +1,100 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { provideRouter } from "@angular/router"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { signal } from "@angular/core"; +import { of } from "rxjs"; +import { ProfileMainComponent } from "./main.component"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { ProfileDetailInfoService } from "@api/profile/facades/detail/profile-detail-info.service"; +import { ProfileDetailUIInfoService } from "@api/profile/facades/detail/ui/profile-detail-ui-info.service"; +import { ExpandService } from "@api/expand/expand.service"; +import { ProfileInfoService } from "@api/profile/facades/profile-info.service"; +import { API_URL } from "@corelib"; + +describe("MainComponent", () => { + let component: ProfileMainComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const profileDetailInfoServiceSpy = { + initializationProfile: vi.fn(), + initCheckDescription: vi.fn(), + destroy: vi.fn(), + }; + + const profileDetailUIInfoServiceSpy = { + user: signal({ + id: 1, + firstName: "Test", + personal: { birthday: "2000-01-01", links: [] }, + relations: { + progress: 100, + skills: [], + achievements: [], + userLanguages: [], + programs: [], + education: [], + workExperience: [], + projects: [], + }, + } as any), + loggedUserId: signal(1), + profileId: signal(1), + isProfileEmpty: signal(false), + isProfileFill: signal(false), + directions: signal([]), + isShowModal: signal(false), + }; + + const expandServiceSpy = { + descriptionExpandable: signal(false), + readFullDescription: signal(""), + readAll: signal(""), + onExpand: vi.fn(), + }; + + await TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, ProfileMainComponent], + providers: [ + provideRouter([]), + { + provide: AuthRepositoryPort, + useValue: { + fetchProfile: of({}), + fetchUserRoles: of([]), + fetchChangeableRoles: of([]), + fetchLeaderProjects: of({}), + }, + }, + { provide: ExpandService, useValue: expandServiceSpy }, + { provide: ProfileInfoService, useValue: { profile: signal(null) } }, + { provide: API_URL, useValue: "" }, + ], + }) + .overrideComponent(ProfileMainComponent, { + remove: { + providers: [ProfileDetailInfoService, ProfileDetailUIInfoService, ExpandService], + }, + add: { + providers: [ + { provide: ProfileDetailInfoService, useValue: profileDetailInfoServiceSpy }, + { provide: ProfileDetailUIInfoService, useValue: profileDetailUIInfoServiceSpy }, + { provide: ExpandService, useValue: expandServiceSpy }, + ], + }, + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ProfileMainComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/profile/detail/main/main.component.ts b/projects/social_platform/src/app/ui/pages/profile/detail/main/main.component.ts new file mode 100644 index 000000000..00adf03ba --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/profile/detail/main/main.component.ts @@ -0,0 +1,54 @@ +/** @format */ + +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + ElementRef, + inject, + OnDestroy, + OnInit, + viewChild, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { ProfileDetailInfoService } from "@api/profile/facades/detail/profile-detail-info.service"; +import { ProfileDetailUIInfoService } from "@api/profile/facades/detail/ui/profile-detail-ui-info.service"; +import { ExpandService } from "@api/expand/expand.service"; +import { ProfileLeftSideComponent } from "./components/profile-left-side/profile-left-side.component"; +import { ProfileRightSideComponent } from "./components/profile-right-side/profile-right-side.component"; +import { ProfileMidSideComponent } from "./components/profile-mid-side/profile-mid-side.component"; + +/** Главная страница профиля: информация, новости, навыки, проекты. */ +@Component({ + selector: "app-profile-main", + templateUrl: "./main.component.html", + styleUrl: "./main.component.scss", + imports: [ + CommonModule, + ProfileLeftSideComponent, + ProfileRightSideComponent, + ProfileMidSideComponent, + ], + providers: [ProfileDetailInfoService, ProfileDetailUIInfoService, ExpandService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProfileMainComponent implements OnInit, AfterViewInit, OnDestroy { + readonly descEl = viewChild("descEl"); + + private readonly profileDetailInfoService = inject(ProfileDetailInfoService); + private readonly profileDetailUIInfoService = inject(ProfileDetailUIInfoService); + + protected readonly user = this.profileDetailUIInfoService.user; + + ngOnInit(): void { + this.profileDetailInfoService.initializationProfile(); + } + + ngAfterViewInit(): void { + this.profileDetailInfoService.initCheckDescription(this.descEl()); + } + + ngOnDestroy(): void { + this.profileDetailInfoService.destroy(); + } +} diff --git a/projects/social_platform/src/app/ui/pages/profile/detail/main/main.resolver.ts b/projects/social_platform/src/app/ui/pages/profile/detail/main/main.resolver.ts new file mode 100644 index 000000000..aa0b02f5a --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/profile/detail/main/main.resolver.ts @@ -0,0 +1,23 @@ +/** @format */ + +import { ActivatedRouteSnapshot, ResolveFn } from "@angular/router"; +import { inject } from "@angular/core"; +import { map } from "rxjs"; +import { ProfileNews } from "@domain/profile/profile-news.model"; +import { GetProfileNewsDetailUseCase } from "@api/profile/use-cases/get-profile-news-detail.use-case"; + +/** Предзагружает детальную информацию о новости профиля. */ +export const ProfileMainResolver: ResolveFn = (route: ActivatedRouteSnapshot) => { + const getProfileNewsDetailUseCase = inject(GetProfileNewsDetailUseCase); + + const userId = route.parent?.paramMap.get("id"); + const newsId = route.paramMap.get("newsId"); + + if (!userId || !newsId) { + throw new Error("Required parameters are missing"); + } + + return getProfileNewsDetailUseCase + .execute(userId, newsId) + .pipe(map(result => (result.ok ? result.value : new ProfileNews()))); +}; diff --git a/projects/social_platform/src/app/ui/pages/profile/detail/profile-detail.resolver.ts b/projects/social_platform/src/app/ui/pages/profile/detail/profile-detail.resolver.ts new file mode 100644 index 000000000..369f0fcba --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/profile/detail/profile-detail.resolver.ts @@ -0,0 +1,23 @@ +/** @format */ + +import { inject } from "@angular/core"; +import { ActivatedRouteSnapshot, ResolveFn } from "@angular/router"; +import { User } from "@domain/auth/user.model"; +import { forkJoin, map, tap } from "rxjs"; +import { calculateProfileProgress } from "@utils/calculateProgress"; +import { AuthInfoService } from "@api/auth/facades/auth-info.service"; + +/** Предзагружает данные профиля пользователя. */ +export const ProfileDetailResolver: ResolveFn<{ user: User }> = (route: ActivatedRouteSnapshot) => { + const authRepository = inject(AuthInfoService); + + return forkJoin({ + user: authRepository.fetchUser(Number(route.paramMap.get("id"))).pipe( + map(user => { + const result = Object.assign(new User(), user); + result.relations.progress = calculateProfileProgress(result); + return result; + }), + ), + }); +}; diff --git a/projects/social_platform/src/app/ui/pages/profile/detail/profile-news/profile-news.component.html b/projects/social_platform/src/app/ui/pages/profile/detail/profile-news/profile-news.component.html new file mode 100644 index 000000000..5a362cb5f --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/profile/detail/profile-news/profile-news.component.html @@ -0,0 +1,11 @@ + + + + @if (newsItem()) { + + } + diff --git a/projects/social_platform/src/app/office/profile/profile-news/profile-news.component.scss b/projects/social_platform/src/app/ui/pages/profile/detail/profile-news/profile-news.component.scss similarity index 100% rename from projects/social_platform/src/app/office/profile/profile-news/profile-news.component.scss rename to projects/social_platform/src/app/ui/pages/profile/detail/profile-news/profile-news.component.scss diff --git a/projects/social_platform/src/app/office/profile/profile-news/profile-news.component.spec.ts b/projects/social_platform/src/app/ui/pages/profile/detail/profile-news/profile-news.component.spec.ts similarity index 81% rename from projects/social_platform/src/app/office/profile/profile-news/profile-news.component.spec.ts rename to projects/social_platform/src/app/ui/pages/profile/detail/profile-news/profile-news.component.spec.ts index 882f59229..7b3ce04fe 100644 --- a/projects/social_platform/src/app/office/profile/profile-news/profile-news.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/profile/detail/profile-news/profile-news.component.spec.ts @@ -3,6 +3,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ProfileNewsComponent } from "./profile-news.component"; +import { provideRouter } from "@angular/router"; describe("ProfileNewsComponent", () => { let component: ProfileNewsComponent; @@ -11,11 +12,15 @@ describe("ProfileNewsComponent", () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [ProfileNewsComponent], + providers: [provideRouter([])], }).compileComponents(); fixture = TestBed.createComponent(ProfileNewsComponent); component = fixture.componentInstance; - fixture.detectChanges(); + }); + + afterEach(() => { + fixture?.destroy(); }); it("should create", () => { diff --git a/projects/social_platform/src/app/ui/pages/profile/detail/profile-news/profile-news.component.ts b/projects/social_platform/src/app/ui/pages/profile/detail/profile-news/profile-news.component.ts new file mode 100644 index 000000000..04dcd0630 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/profile/detail/profile-news/profile-news.component.ts @@ -0,0 +1,70 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + inject, + OnDestroy, + OnInit, + signal, +} from "@angular/core"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { map } from "rxjs"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { ActivatedRoute, Router } from "@angular/router"; +import { NewsCardComponent } from "@ui/widgets/news-card/news-card.component"; +import { FeedNews } from "@domain/news/project-news.model"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; +import { AppRoutes } from "@api/paths/app-routes"; + +/** Отображает новость профиля в модальном окне. */ +@Component({ + selector: "app-profile-news", + imports: [CommonModule, ModalComponent, NewsCardComponent], + templateUrl: "./profile-news.component.html", + styleUrl: "./profile-news.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProfileNewsComponent implements OnInit, OnDestroy { + private readonly destroyRef = inject(DestroyRef); + private readonly route: ActivatedRoute = inject(ActivatedRoute); + private readonly router: Router = inject(Router); + private readonly loggerService = inject(LoggerService); + protected readonly AppRoutes = AppRoutes; + + /** ID пользователя, извлеченный из родительского маршрута профиля */ + userId = this.route.parent?.parent?.snapshot.params["id"]; + + /** Сигнал с данными отображаемой новости */ + newsItem = signal(null); + + ngOnInit(): void { + this.route.data + .pipe( + map(r => r["data"]), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe({ + next: (r: FeedNews) => { + this.newsItem.set(r); + }, + error: err => { + this.loggerService.info(err); + }, + }); + + this.loggerService.info("", this.newsItem()); + } + + ngOnDestroy(): void {} + + onOpenChange(value: boolean): void { + if (!value) { + this.router + .navigateByUrl(AppRoutes.profile.detail(this.userId)) + .then(() => this.loggerService.debug("Route changed from ProfileNewsComponent")); + } + } +} diff --git a/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-achievements-step/profile-achievements-step.component.html b/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-achievements-step/profile-achievements-step.component.html new file mode 100644 index 000000000..dee5a9419 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-achievements-step/profile-achievements-step.component.html @@ -0,0 +1,160 @@ + + +
    +
    + @if (showAchievementsFields()) { +
    + @if (profileForm.get("title"); as title) { +
    + + + @if (title | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } +
    + +
    + @if (profileForm.get("year"); as year) { +
    + + + @if (year | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } +
    + +
    + @if (profileForm.get("status"); as status) { +
    + + + @if (status | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } +
    + +
    + @if (profileForm.get("files"); as files) { +
    + + + +

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

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

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

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

    + {{ achievementItem.title | truncate: 50 }} +

    + +

    + {{ achievementItem.year }} +

    + +

    + {{ achievementItem.status | truncate: 150 }} +

    + + @if (achievementItem.files?.length) { + @if (isStringFiles(achievementItem.files)) { + + + } @else { + @for (file of achievementItem.files; track $index) { + + + } + } + } +
    + +
    +
    + +
    + +
    + +
    +
    +
    +
    + } + } +
    +
    diff --git a/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-achievements-step/profile-achievements-step.component.scss b/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-achievements-step/profile-achievements-step.component.scss new file mode 100644 index 000000000..9969091c4 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-achievements-step/profile-achievements-step.component.scss @@ -0,0 +1,114 @@ +.profile__wrapper--education { + display: grid; + grid-template-columns: 6fr 4fr; + grid-gap: 20px; +} + +.profile__info--education { + display: flex; + flex-direction: column; + gap: 12px; +} + +.profile__education--list { + display: flex; + flex-direction: column; + gap: 20px; +} + +.profile__language--list { + display: grid; + grid-template-columns: 2fr 2fr; + grid-gap: 20px; +} + +.profile__wrapper--links { + display: grid; + grid-template-columns: 7fr 3fr; + grid-gap: 20px; +} + +.profile__education--card { + display: flex; + align-items: center; + justify-content: space-between; +} + +.profile__education--info { + display: flex; + flex-direction: column; + gap: 5px; + + :first-child { + color: var(--black); + } + + :last-child & app-file-item { + margin-top: 3px; + } +} + +.edit-icon, +.basket-icon { + width: 20px; + height: 20px; + padding: 6px; + cursor: pointer; + border-radius: 50%; +} + +.edit-icon { + border: 0.5px solid var(--accent); + + i { + color: var(--accent) !important; + } +} + +.basket-icon { + border: 0.5px solid var(--red); + + i { + color: var(--red) !important; + } +} + +.education { + &__title { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 5px; + border-bottom: 0.5px solid var(--medium-grey-for-outline); + } + + &__text { + color: var(--grey-for-text); + word-break: break-word; + overflow-wrap: anywhere; + white-space: wrap; + } +} + +.profile { + &__column { + display: flex; + flex-direction: column; + gap: 12px; + + &--date { + display: grid; + grid-template-columns: repeat(2, 3fr); + grid-gap: 20px; + } + } + + &__action { + &--icons { + display: flex; + flex-direction: column; + gap: 10px; + align-items: center; + } + } +} diff --git a/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-achievements-step/profile-achievements-step.component.ts b/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-achievements-step/profile-achievements-step.component.ts new file mode 100644 index 000000000..3695dd720 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-achievements-step/profile-achievements-step.component.ts @@ -0,0 +1,74 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, inject, input, Input } from "@angular/core"; +import { InputComponent, SelectComponent, ButtonComponent } from "@ui/primitives"; +import { TextareaComponent } from "@ui/primitives/textarea/textarea.component"; +import { UploadFileComponent } from "@ui/primitives/upload-file/upload-file.component"; +import { FileItemComponent } from "@ui/primitives/file-item/file-item.component"; +import { ErrorMessage } from "@core/lib/models/error/error-message"; +import { ProfileFormService } from "@api/profile/facades/edit/profile-form.service"; +import { ReactiveFormsModule } from "@angular/forms"; +import { ProfileEditAchievementsInfoService } from "@api/profile/facades/edit/profile-edit-achievements-info.service"; +import { IconComponent } from "@uilib"; +import { TruncatePipe, ControlErrorPipe } from "@corelib"; + +/** Шаг редактирования достижений в общей форме профиля. */ +@Component({ + selector: "app-profile-achievements-step", + templateUrl: "./profile-achievements-step.component.html", + styleUrl: "./profile-achievements-step.component.scss", + imports: [ + CommonModule, + InputComponent, + IconComponent, + SelectComponent, + TextareaComponent, + UploadFileComponent, + ButtonComponent, + FileItemComponent, + ReactiveFormsModule, + ControlErrorPipe, + TruncatePipe, + ], + providers: [ProfileEditAchievementsInfoService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProfileAchievementsStepComponent { + readonly isAchievementsDirty = input.required(); + protected readonly errorMessage = ErrorMessage; + + private readonly profileFormService = inject(ProfileFormService); + private readonly profileEditAchievementsInfoService = inject(ProfileEditAchievementsInfoService); + + protected readonly profileForm = this.profileFormService.getForm(); + protected readonly achievementItems = this.profileEditAchievementsInfoService.achievementItems; + protected readonly achievements = this.profileFormService.achievements; + + protected readonly achievementsYearList = this.profileFormService.achievementsYearList; + + protected readonly showAchievementsFields = + this.profileEditAchievementsInfoService.showAchievementsFields; + + protected readonly editAchievementsClick = + this.profileEditAchievementsInfoService.editAchievementsClick; + + protected readonly selectedAchievementsYearId = + this.profileEditAchievementsInfoService.selectedAchievementsYearId; + + protected isStringFiles(files: any[]): boolean { + return typeof files === "string"; + } + + protected addAchievement(): void { + this.profileEditAchievementsInfoService.addAchievement(); + } + + protected editAchievements(index: number): void { + this.profileEditAchievementsInfoService.editAchievements(index); + } + + protected removeAchievement(index: number): void { + this.profileEditAchievementsInfoService.removeAchievement(index); + } +} diff --git a/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-education-step/profile-education-step.component.html b/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-education-step/profile-education-step.component.html new file mode 100644 index 000000000..754e859c6 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-education-step/profile-education-step.component.html @@ -0,0 +1,197 @@ + + +
    +
    + @if (showEducationFields()) { +
    + @if (profileForm.get("entryYear"); as entryYear) { +
    + + + + + + @if (entryYear | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } + @if (profileForm.get("completionYear"); as completionYear) { +
    + + + + + @if (completionYear | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } +
    + +
    + @if (profileForm.get("organizationName"); as organizationName) { +
    + + + @if (organizationName | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } +
    + +
    + @if (profileForm.get("description"); as description) { +
    + + + @if (description | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } +
    + +
    + @if (profileForm.get("educationLevel"); as educationLevel) { +
    + + + + + @if (educationLevel | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } +
    + +
    + @if (profileForm.get("educationStatus"); as educationStatus) { +
    + + + + + @if (educationStatus | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } +
    + } + + + {{ editEducationClick() ? "сохранить изменения" : "добавить образование" }} + + +
    + +
    + @if (educationItems().length || education.length) { + @for (educationItem of education.value; track $index) { +
    +

    + {{ educationItem.organizationName | truncate: 50 }} +

    + +

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

    + +
    +
    +

    + {{ educationItem.description | truncate: 150 }} +

    + +

    + {{ educationItem.educationLevel }} +

    + +

    + {{ educationItem.educationStatus }} +

    +
    + +
    +
    + +
    + +
    + +
    +
    +
    +
    + } + } +
    +
    diff --git a/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-education-step/profile-education-step.component.scss b/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-education-step/profile-education-step.component.scss new file mode 100644 index 000000000..88a620146 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-education-step/profile-education-step.component.scss @@ -0,0 +1,108 @@ +.profile__wrapper--education { + display: grid; + grid-template-columns: 6fr 4fr; + grid-gap: 20px; +} + +.profile__info--education { + display: flex; + flex-direction: column; + gap: 12px; +} + +.profile__education--list { + display: flex; + flex-direction: column; + gap: 20px; +} + +.profile__language--list { + display: grid; + grid-template-columns: 2fr 2fr; + grid-gap: 20px; +} + +.profile__education--card { + display: flex; + align-items: center; + justify-content: space-between; +} + +.profile__education--info { + display: flex; + flex-direction: column; + gap: 5px; + + :first-child { + color: var(--black); + } + + :last-child & app-file-item { + margin-top: 3px; + } +} + +.edit-icon, +.basket-icon { + width: 20px; + height: 20px; + padding: 6px; + cursor: pointer; + border-radius: 50%; +} + +.edit-icon { + border: 0.5px solid var(--accent); + + i { + color: var(--accent) !important; + } +} + +.basket-icon { + border: 0.5px solid var(--red); + + i { + color: var(--red) !important; + } +} + +.education { + &__title { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 5px; + border-bottom: 0.5px solid var(--medium-grey-for-outline); + } + + &__text { + color: var(--grey-for-text); + word-break: break-word; + overflow-wrap: anywhere; + white-space: wrap; + } +} + +.profile { + &__column { + display: flex; + flex-direction: column; + gap: 12px; + + &--date { + display: grid; + grid-template-columns: repeat(2, 3fr); + grid-gap: 20px; + } + } + + &__action { + &--icons { + display: flex; + flex-direction: column; + gap: 10px; + align-items: center; + } + } +} diff --git a/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-education-step/profile-education-step.component.ts b/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-education-step/profile-education-step.component.ts new file mode 100644 index 000000000..1a87a5cc3 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-education-step/profile-education-step.component.ts @@ -0,0 +1,76 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, inject, input, Input } from "@angular/core"; +import { ReactiveFormsModule } from "@angular/forms"; +import { IconComponent } from "@uilib"; +import { ErrorMessage } from "@core/lib/models/error/error-message"; +import { SelectComponent, ButtonComponent, InputComponent } from "@ui/primitives"; +import { ProfileFormService } from "@api/profile/facades/edit/profile-form.service"; +import { ProfileEditEducationInfoService } from "@api/profile/facades/edit/profile-edit-education-info.service"; +import { ProfileDetailUIInfoService } from "@api/profile/facades/detail/ui/profile-detail-ui-info.service"; +import { TruncatePipe, ControlErrorPipe } from "@corelib"; + +/** Шаг редактирования образования в общей форме профиля. */ +@Component({ + selector: "app-profile-education-step", + templateUrl: "./profile-education-step.component.html", + styleUrl: "./profile-education-step.component.scss", + imports: [ + CommonModule, + IconComponent, + SelectComponent, + ButtonComponent, + InputComponent, + ReactiveFormsModule, + ControlErrorPipe, + TruncatePipe, + ], + providers: [ProfileDetailUIInfoService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProfileEducationStepComponent { + readonly isEducationDirty = input.required(); + + protected readonly errorMessage = ErrorMessage; + + private readonly profileFormService = inject(ProfileFormService); + private readonly profileEditEducationInfoService = inject(ProfileEditEducationInfoService); + + protected readonly profileForm = this.profileFormService.getForm(); + protected readonly education = this.profileFormService.education; + protected readonly educationItems = this.profileEditEducationInfoService.educationItems; + + protected readonly yearListEducation = this.profileFormService.yearListEducation; + protected readonly yearListEducationWithPresent = + this.profileFormService.yearListEducationWithPresent; + protected readonly educationLevelList = this.profileFormService.educationLevelList; + protected readonly educationStatusList = this.profileFormService.educationStatusList; + + protected readonly showEducationFields = this.profileEditEducationInfoService.showEducationFields; + protected readonly editEducationClick = this.profileEditEducationInfoService.editEducationClick; + + protected readonly selectedEntryYearEducationId = + this.profileEditEducationInfoService.selectedEntryYearEducationId; + + protected readonly selectedComplitionYearEducationId = + this.profileEditEducationInfoService.selectedComplitionYearEducationId; + + protected readonly selectedEducationLevelId = + this.profileEditEducationInfoService.selectedEducationLevelId; + + protected readonly selectedEducationStatusId = + this.profileEditEducationInfoService.selectedEducationStatusId; + + protected addEducation(): void { + this.profileEditEducationInfoService.addEducation(); + } + + protected editEducation(index: number): void { + this.profileEditEducationInfoService.editEducation(index); + } + + protected removeEducation(index: number): void { + this.profileEditEducationInfoService.removeEducation(index); + } +} diff --git a/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-experience-step/profile-experience-step.component.html b/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-experience-step/profile-experience-step.component.html new file mode 100644 index 000000000..f6f74d52e --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-experience-step/profile-experience-step.component.html @@ -0,0 +1,177 @@ + + +
    +
    + @if (showWorkFields()) { +
    +
    + @if (profileForm.get("entryYearWork"); as entryYearWork) { +
    + + + + + + @if (entryYearWork | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } + @if (profileForm.get("completionYearWork"); as completionYearWork) { +
    + + + + + + @if (completionYearWork | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } +
    +
    + +
    + @if (profileForm.get("organization"); as organization) { +
    + + + @if (organization | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } +
    + +
    + @if (profileForm.get("jobPosition"); as jobPosition) { +
    + + + @if (jobPosition | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } +
    + +
    + @if (profileForm.get("descriptionWork"); as descriptionWork) { +
    + + + @if (descriptionWork | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } +
    + } + + + {{ editWorkClick() ? "сохранить изменения" : "добавить работу" }} + + +
    + +
    + @if (workItems().length || workExperience.length) { + @for (workItem of workExperience.value; track $index) { +
    +

    + {{ workItem.organizationName | truncate: 50 }} +

    + +

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

    + +
    +
    +

    + {{ workItem.description | truncate: 150 }} +

    + +

    + {{ workItem.jobPosition | truncate: 50 }} +

    +
    + +
    +
    + +
    + +
    + +
    +
    +
    +
    + } + } +
    +
    diff --git a/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-experience-step/profile-experience-step.component.scss b/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-experience-step/profile-experience-step.component.scss new file mode 100644 index 000000000..88a620146 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-experience-step/profile-experience-step.component.scss @@ -0,0 +1,108 @@ +.profile__wrapper--education { + display: grid; + grid-template-columns: 6fr 4fr; + grid-gap: 20px; +} + +.profile__info--education { + display: flex; + flex-direction: column; + gap: 12px; +} + +.profile__education--list { + display: flex; + flex-direction: column; + gap: 20px; +} + +.profile__language--list { + display: grid; + grid-template-columns: 2fr 2fr; + grid-gap: 20px; +} + +.profile__education--card { + display: flex; + align-items: center; + justify-content: space-between; +} + +.profile__education--info { + display: flex; + flex-direction: column; + gap: 5px; + + :first-child { + color: var(--black); + } + + :last-child & app-file-item { + margin-top: 3px; + } +} + +.edit-icon, +.basket-icon { + width: 20px; + height: 20px; + padding: 6px; + cursor: pointer; + border-radius: 50%; +} + +.edit-icon { + border: 0.5px solid var(--accent); + + i { + color: var(--accent) !important; + } +} + +.basket-icon { + border: 0.5px solid var(--red); + + i { + color: var(--red) !important; + } +} + +.education { + &__title { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 5px; + border-bottom: 0.5px solid var(--medium-grey-for-outline); + } + + &__text { + color: var(--grey-for-text); + word-break: break-word; + overflow-wrap: anywhere; + white-space: wrap; + } +} + +.profile { + &__column { + display: flex; + flex-direction: column; + gap: 12px; + + &--date { + display: grid; + grid-template-columns: repeat(2, 3fr); + grid-gap: 20px; + } + } + + &__action { + &--icons { + display: flex; + flex-direction: column; + gap: 10px; + align-items: center; + } + } +} diff --git a/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-experience-step/profile-experience-step.component.ts b/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-experience-step/profile-experience-step.component.ts new file mode 100644 index 000000000..0d70c6195 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-experience-step/profile-experience-step.component.ts @@ -0,0 +1,68 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, inject, input, Input } from "@angular/core"; +import { SelectComponent, InputComponent, ButtonComponent } from "@ui/primitives"; +import { TextareaComponent } from "@ui/primitives/textarea/textarea.component"; +import { ErrorMessage } from "@core/lib/models/error/error-message"; +import { ProfileFormService } from "@api/profile/facades/edit/profile-form.service"; +import { ReactiveFormsModule } from "@angular/forms"; +import { ProfileEditExperienceInfoService } from "@api/profile/facades/edit/profile-edit-experience-info.service"; +import { IconComponent } from "@uilib"; +import { TruncatePipe, ControlErrorPipe } from "@corelib"; + +/** Шаг редактирования профиля: опыт работы. */ +@Component({ + selector: "app-profile-experience-step", + templateUrl: "./profile-experience-step.component.html", + styleUrl: "./profile-experience-step.component.scss", + imports: [ + CommonModule, + SelectComponent, + InputComponent, + IconComponent, + TextareaComponent, + ButtonComponent, + ControlErrorPipe, + ReactiveFormsModule, + TruncatePipe, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProfileExperienceStepComponent { + readonly isWorkDirty = input.required(); + + private readonly profileFormService = inject(ProfileFormService); + private readonly profileEditExperienceInfoService = inject(ProfileEditExperienceInfoService); + + protected readonly profileForm = this.profileFormService.getForm(); + protected readonly workExperience = this.profileFormService.workExperience; + protected readonly workItems = this.profileEditExperienceInfoService.workItems; + + protected readonly yearListEducation = this.profileFormService.yearListEducation; + protected readonly yearListEducationWithPresent = + this.profileFormService.yearListEducationWithPresent; + + protected readonly showWorkFields = this.profileEditExperienceInfoService.showWorkFields; + protected readonly editWorkClick = this.profileEditExperienceInfoService.editWorkClick; + + protected readonly selectedEntryYearWorkId = + this.profileEditExperienceInfoService.selectedEntryYearWorkId; + + protected readonly selectedComplitionYearWorkId = + this.profileEditExperienceInfoService.selectedComplitionYearWorkId; + + protected readonly errorMessage = ErrorMessage; + + addWork(): void { + this.profileEditExperienceInfoService.addWork(); + } + + editWork(index: number): void { + this.profileEditExperienceInfoService.editWork(index); + } + + removeWork(index: number): void { + this.profileEditExperienceInfoService.removeWork(index); + } +} diff --git a/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-main-step/profile-main-step.component.html b/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-main-step/profile-main-step.component.html new file mode 100644 index 000000000..496ba44af --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-main-step/profile-main-step.component.html @@ -0,0 +1,259 @@ + + +
    +
    + @if (avatar) { +
    + +
    + + @if (avatar | controlError: "required") { +
    + {{ errorMessage.EMPTY_AVATAR }} +
    + } +
    +
    + } + +
    + +
    +
    + @if (firstName) { +
    + + + @if (firstName | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } + @if (lastName) { +
    + + + @if (lastName | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } +
    + +
    + @if (city) { +
    + + + @if (city | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } + @if (birthday) { +
    + + + @if (birthday | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } +
    + +
    + @if (userType) { + @if (userType.value !== 1) { +
    + + @if (roles(); as options) { + + } + @if (userType | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } + } + @if (speciality) { +
    + +
    + +
    + @if (speciality | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } +
    +
    + +
    + @if (aboutMe) { +
    + + + @if (aboutMe | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } +
    + + +
    diff --git a/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-main-step/profile-main-step.component.scss b/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-main-step/profile-main-step.component.scss new file mode 100644 index 000000000..a20e346f1 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-main-step/profile-main-step.component.scss @@ -0,0 +1,109 @@ +.profile__wrapper--main { + display: flex; + flex-direction: column; + gap: 12px; +} + +.profile__main--grid { + display: grid; + grid-template-columns: 3fr 3fr 4fr; + grid-gap: 20px; +} + +.profile__wrapper--links { + display: grid; + grid-template-columns: 7fr 3fr; + grid-gap: 20px; +} + +.profile { + &__row { + display: flex; + gap: 20px; + align-items: center; + width: 100%; + } + + &__file { + flex-grow: 1; + min-width: 0; + max-width: 333px; + + ::ng-deep { + app-upload-file { + height: 80px; + padding: 10px 30px; + } + } + } + + &__slides-title { + max-width: 320px; + margin-top: 12px; + color: var(--black); + text-align: center; + } + + &__slides-text { + max-width: 275px; + margin-top: 12px; + color: var(--black); + text-align: center; + opacity: 0.3; + + &:hover { + opacity: 1; + } + } + + &__slides-error { + margin-top: 12px; + color: var(--red); + } + + &__slides-open-file { + color: var(--accent); + transition: color 0.2s; + + &:hover { + color: var(--accent-dark); + } + } + + &__column { + fieldset { + position: relative; + padding-bottom: 20px; + + .error { + position: absolute; + bottom: 2px; + left: 0; + margin-top: 0; + } + } + + display: flex; + flex-direction: column; + gap: 12px; + + &--date { + display: grid; + grid-template-columns: repeat(2, 3fr); + grid-gap: 20px; + } + } + + &__action { + &--icons { + display: flex; + flex-direction: column; + gap: 10px; + align-items: center; + } + } +} + +.error__phone-number { + margin-bottom: 10px; +} diff --git a/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-main-step/profile-main-step.component.ts b/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-main-step/profile-main-step.component.ts new file mode 100644 index 000000000..ea42c000a --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-main-step/profile-main-step.component.ts @@ -0,0 +1,80 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { Component, inject, ChangeDetectionStrategy, output } from "@angular/core"; +import { InputComponent, ButtonComponent, SelectComponent } from "@ui/primitives"; +import { TextareaComponent } from "@ui/primitives/textarea/textarea.component"; +import { AutosizeModule } from "ngx-autosize"; +import { AutoCompleteInputComponent } from "@ui/primitives/autocomplete-input/autocomplete-input.component"; +import { UploadFileComponent } from "@ui/primitives/upload-file/upload-file.component"; +import { AvatarControlComponent } from "@ui/primitives/avatar-control/avatar-control.component"; +import { ControlErrorPipe } from "@corelib"; +import { ErrorMessage } from "@core/lib/models/error/error-message"; +import { ProfileFormService } from "@api/profile/facades/edit/profile-form.service"; +import { ReactiveFormsModule } from "@angular/forms"; +import { SearchesService } from "@api/searches/searches.service"; +import { IconComponent } from "@uilib"; + +/** Основной шаг редактирования профиля: личные данные, аватар и ключевые навыки. */ +@Component({ + selector: "app-profile-main-step", + templateUrl: "./profile-main-step.component.html", + styleUrl: "./profile-main-step.component.scss", + imports: [ + CommonModule, + IconComponent, + InputComponent, + ButtonComponent, + TextareaComponent, + AutosizeModule, + AutoCompleteInputComponent, + SelectComponent, + UploadFileComponent, + AvatarControlComponent, + ControlErrorPipe, + ReactiveFormsModule, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProfileMainStepComponent { + readonly openSpecsGroupsModal = output(); + + private readonly profileFormService = inject(ProfileFormService); + private readonly searchesService = inject(SearchesService); + + protected readonly profileForm = this.profileFormService.getForm(); + + protected readonly avatar = this.profileFormService.avatar; + protected readonly coverImageAddress = this.profileFormService.coverImageAddress; + protected readonly firstName = this.profileFormService.firstName; + protected readonly lastName = this.profileFormService.lastName; + protected readonly city = this.profileFormService.city; + protected readonly birthday = this.profileFormService.birthday; + protected readonly userType = this.profileFormService.userType; + protected readonly speciality = this.profileFormService.speciality; + protected readonly aboutMe = this.profileFormService.aboutMe; + protected readonly phoneNumber = this.profileFormService.phoneNumber; + + protected readonly roles = this.profileFormService.roles; + protected readonly inlineSpecs = this.searchesService.inlineSpecs; + + protected readonly links = this.profileFormService.links; + + protected readonly errorMessage = ErrorMessage; + + protected addLink(title?: string): void { + this.profileFormService.addLink(title); + } + + protected removeLink(index: number): void { + this.profileFormService.removeLink(index); + } + + protected onSearchSpec(query: string): void { + this.searchesService.onSearchSpec(query); + } + + protected toggleSpecsGroupsModal(): void { + this.openSpecsGroupsModal.emit(); + } +} diff --git a/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-skills-step/profile-skills-step.component.html b/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-skills-step/profile-skills-step.component.html new file mode 100644 index 000000000..b49d6c5f7 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-skills-step/profile-skills-step.component.html @@ -0,0 +1,131 @@ + + +
    +
    +
    + +
    + +
    +
    + +
    + @if (profileForm.get("skills"); as skills) { +
    + +
    + } +
    +
    + +
    + @if (showLanguageFields()) { +
    + @if (profileForm.get("language"); as language) { +
    + + + + + + @if (language | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } + @if (profileForm.get("languageLevel"); as languageLevel) { +
    + + + + + + @if (languageLevel | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } +
    + } + + количество добавляемых языков не более 4-х + + {{ editLanguageClick() ? "сохранить изменения" : "добавить язык" }} + + + +
    + @if (languageItems().length || userLanguages.length) { + @for (languageItem of userLanguages.value; track $index) { +
    +
    +

    + {{ languageItem.language }} +

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

    + {{ languageItem.languageLevel }} +

    +
    + } + } +
    +
    +
    diff --git a/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-skills-step/profile-skills-step.component.scss b/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-skills-step/profile-skills-step.component.scss new file mode 100644 index 000000000..1b7077acf --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-skills-step/profile-skills-step.component.scss @@ -0,0 +1,114 @@ +.profile__main--grid { + display: grid; + grid-template-columns: 3fr 3fr 4fr; + grid-gap: 20px; +} + +.profile__wrapper--education { + display: grid; + grid-template-columns: 6fr 4fr; + grid-gap: 20px; +} + +.profile__info--education { + display: flex; + flex-direction: column; + gap: 12px; +} + +.profile__education--list { + display: flex; + flex-direction: column; + gap: 20px; +} + +.profile__language--list { + display: grid; + grid-template-columns: 2fr 2fr; + grid-gap: 20px; +} + +.profile__education--card { + display: flex; + align-items: center; + justify-content: space-between; +} + +.profile__education--info { + display: flex; + flex-direction: column; + gap: 5px; + + :first-child { + color: var(--black); + } + + :last-child & app-file-item { + margin-top: 3px; + } +} + +.edit-icon, +.basket-icon { + width: 20px; + height: 20px; + padding: 6px; + cursor: pointer; + border-radius: 50%; +} + +.edit-icon { + border: 0.5px solid var(--accent); + + i { + color: var(--accent) !important; + } +} + +.basket-icon { + border: 0.5px solid var(--red); + + i { + color: var(--red) !important; + } +} + +.education { + &__title { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 5px; + border-bottom: 0.5px solid var(--medium-grey-for-outline); + } + + &__text { + color: var(--grey-for-text); + word-break: break-word; + overflow-wrap: anywhere; + white-space: wrap; + } +} + +.profile { + &__column { + display: flex; + flex-direction: column; + gap: 12px; + + &--date { + display: grid; + grid-template-columns: repeat(2, 3fr); + grid-gap: 20px; + } + } + + &__action { + &--icons { + display: flex; + flex-direction: column; + gap: 10px; + align-items: center; + } + } +} diff --git a/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-skills-step/profile-skills-step.component.ts b/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-skills-step/profile-skills-step.component.ts new file mode 100644 index 000000000..67f2af787 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/profile/edit/components/profile-skills-step/profile-skills-step.component.ts @@ -0,0 +1,98 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { + Component, + inject, + Input, + Output, + EventEmitter, + ChangeDetectionStrategy, + input, + output, +} from "@angular/core"; +import { AutoCompleteInputComponent } from "@ui/primitives/autocomplete-input/autocomplete-input.component"; +import { SkillsBasketComponent } from "@ui/widgets/skills-basket/skills-basket.component"; +import { SelectComponent, ButtonComponent } from "@ui/primitives"; +import { ReactiveFormsModule } from "@angular/forms"; +import { ControlErrorPipe } from "@corelib"; +import { ProfileFormService } from "@api/profile/facades/edit/profile-form.service"; +import { ErrorMessage } from "@core/lib/models/error/error-message"; +import { ProfileEditSkillsInfoService } from "@api/profile/facades/edit/profile-edit-skills-info.service"; +import { Skill } from "@domain/skills/skill.model"; +import { IconComponent } from "@uilib"; +import { SearchesService } from "@api/searches/searches.service"; + +/** Шаг редактирования профиля: навыки. */ +@Component({ + selector: "app-profile-skills-step", + templateUrl: "./profile-skills-step.component.html", + styleUrl: "./profile-skills-step.component.scss", + imports: [ + CommonModule, + AutoCompleteInputComponent, + SkillsBasketComponent, + SelectComponent, + ButtonComponent, + ReactiveFormsModule, + ControlErrorPipe, + IconComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProfileSkillsStepComponent { + readonly isLanguageDirty = input.required(); + + readonly openSkillsGroupsModal = output(); + + private readonly profileFormService = inject(ProfileFormService); + private readonly profileEditSkillsInfoService = inject(ProfileEditSkillsInfoService); + private readonly searchesService = inject(SearchesService); + + protected readonly profileForm = this.profileFormService.getForm(); + protected readonly languageItems = this.profileEditSkillsInfoService.languageItems; + protected readonly userLanguages = this.profileFormService.userLanguages; + + protected readonly languageList = this.profileFormService.languageList; + protected readonly languageLevelList = this.profileFormService.languageLevelList; + + protected readonly showLanguageFields = this.profileEditSkillsInfoService.showLanguageFields; + protected readonly editLanguageClick = this.profileEditSkillsInfoService.editLanguageClick; + + protected readonly selectedLanguageLevelId = + this.profileEditSkillsInfoService.selectedLanguageLevelId; + + protected readonly selectedLanguageId = this.profileEditSkillsInfoService.selectedLanguageId; + + protected readonly inlineSkills = this.searchesService.inlineSkills; + + protected readonly errorMessage = ErrorMessage; + + protected addLanguage(): void { + this.profileEditSkillsInfoService.addLanguage(); + } + + protected editLanguage(index: number): void { + this.profileEditSkillsInfoService.editLanguage(index); + } + + protected removeLanguage(index: number): void { + this.profileEditSkillsInfoService.removeLanguage(index); + } + + protected onToggleSkill(toggledSkill: Skill): void { + this.searchesService.onToggleSkill(toggledSkill, this.profileForm); + } + + protected onAddSkill(newSkill: Skill): void { + this.searchesService.onAddSkill(newSkill, this.profileForm); + } + + protected toggleSkillsGroupsModal(): void { + this.openSkillsGroupsModal.emit(); + } + + protected onSearchSkill(query: string): void { + this.searchesService.onSearchSkill(query); + } +} diff --git a/projects/social_platform/src/app/ui/pages/profile/edit/edit.component.html b/projects/social_platform/src/app/ui/pages/profile/edit/edit.component.html new file mode 100644 index 000000000..cea9504c9 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/profile/edit/edit.component.html @@ -0,0 +1,221 @@ + + +@if (profileForm.get("userType"); as currentType) { +
    +
    + +

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

    +
    + +
    + + cохранить +
    +
    + +
    +
    + + +
    + @if (editingStep() === "main") { + @defer { + + } + } + @if (editingStep() === "education") { + @defer { + + } + } + @if (editingStep() === "experience") { + @defer { + + } + } + @if (editingStep() === "achievements") { + @defer { + + } + } + @if (editingStep() === "skills") { + @defer { + + } + } @else if (editingStep() === "settings") { +
    + удалить профиль +
    + } +
    +
    +
    +} + + +
    +
    + +

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

    +
    + @if (isModalErrorSkillChooseText()) { +

    {{ isModalErrorSkillChooseText() }}.

    + } @else { +

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

    + } +
    +
    + + +
    +
    +

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

    + +
    + + удалить аккаунт +
    +
    + +@defer (when specsGroupsModalOpen()) { + + + +} + +@defer (when skillsGroupsModalOpen()) { + + + +} diff --git a/projects/social_platform/src/app/ui/pages/profile/edit/edit.component.scss b/projects/social_platform/src/app/ui/pages/profile/edit/edit.component.scss new file mode 100644 index 000000000..58a21e46b --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/profile/edit/edit.component.scss @@ -0,0 +1,402 @@ +/** @format */ + +@use "styles/responsive"; +@use "styles/typography"; + +.profile { + position: relative; + 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-evenly; + width: 100%; + padding: 4px 0; + margin-top: 20px; + background-color: var(--light-white); + border-radius: var(--rounded-xxl); + } + + &__title { + display: none; + + @include responsive.apply-desktop { + display: block; + } + + @include typography.heading-1; + } + + &__back { + display: flex; + gap: 10px; + align-items: center; + cursor: pointer; + } + + &__form { + display: flex; + flex-direction: column; + color: var(--black); + } + + &__navigation { + margin-bottom: 35px; + } + + &__nav { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + 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; + 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(--dark-grey); + + @include typography.body-12; + + &--active { + color: var(--black); + } + } + + &__icon { + opacity: 0.1; + + &--active { + color: var(--accent); + opacity: 1; + } + } + + &__file { + flex-grow: 1; + min-width: 0; + max-width: 333px; + + ::ng-deep { + app-upload-file { + height: 80px; + padding: 10px 30px; + } + } + } + + &__slides-title { + max-width: 320px; + margin-top: 12px; + color: var(--black); + text-align: center; + } + + &__slides-text { + max-width: 275px; + margin-top: 12px; + color: var(--black); + text-align: center; + opacity: 0.3; + + &:hover { + opacity: 1; + } + } + + &__slides-error { + margin-top: 12px; + color: var(--red); + } + + &__slides-open-file { + color: var(--accent); + transition: color 0.2s; + + &:hover { + color: var(--accent-dark); + } + } + + .error__phone-number { + margin-bottom: 10px; + } + + &__save { + order: 3; + margin-top: 16px; + + @include responsive.apply-desktop { + z-index: 10; + order: unset; + margin-top: auto; + margin-left: auto; + } + } + + &__row { + display: flex; + gap: 20px; + align-items: center; + width: 100%; + } + + &__column { + display: flex; + flex-direction: column; + gap: 12px; + + fieldset { + position: relative; + padding-bottom: 20px; + + .error { + position: absolute; + bottom: 2px; + left: 0; + margin-top: 0; + } + } + + &--date { + display: grid; + grid-template-columns: repeat(2, 3fr); + grid-gap: 20px; + } + } + + &__action { + &--icons { + display: flex; + flex-direction: column; + gap: 10px; + align-items: center; + } + } +} + +.profile__main-text--grid { + display: grid; + grid-template-columns: 0.1fr 2fr 1fr 1fr; + grid-gap: 20px; +} + +.profile__main--grid { + display: grid; + grid-template-columns: 3fr 3fr 4fr; + grid-gap: 20px; +} + +.profile__wrapper--main { + display: flex; + flex-direction: column; + gap: 12px; +} + +.profile__wrapper--links { + display: grid; + grid-template-columns: 7fr 3fr; + grid-gap: 20px; +} + +.profile__wrapper--education { + display: grid; + grid-template-columns: 6fr 4fr; + grid-gap: 20px; +} + +.profile__wrapper--settings { + display: grid; + grid-template-columns: 4fr 6fr; +} + +.profile__info--education { + display: flex; + flex-direction: column; + gap: 12px; +} + +.profile__education--list { + display: flex; + flex-direction: column; + gap: 20px; +} + +.profile__language--list { + display: grid; + grid-template-columns: 2fr 2fr; + grid-gap: 20px; +} + +.profile__education--card { + display: flex; + align-items: center; + justify-content: space-between; +} + +.profile__education--info { + display: flex; + flex-direction: column; + gap: 5px; + + :first-child { + color: var(--black); + } + + :last-child & app-file-item { + margin-top: 3px; + } +} + +.edit-icon, +.basket-icon { + width: 20px; + height: 20px; + padding: 6px; + cursor: pointer; + border-radius: 50%; +} + +.edit-icon { + border: 0.5px solid var(--accent); + + i { + color: var(--accent) !important; + } +} + +.basket-icon { + border: 0.5px solid var(--red); + + i { + color: var(--red) !important; + } +} + +.education { + &__title { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 5px; + border-bottom: 0.5px solid var(--medium-grey-for-outline); + } + + &__text { + color: var(--grey-for-text); + word-break: break-word; + overflow-wrap: anywhere; + white-space: wrap; + } +} + +.modal { + &__wrapper { + display: flex; + flex-direction: column; + gap: 20px; + align-items: center; + width: 672px; + } + + &__content { + display: flex; + flex-direction: column; + gap: 20px; + width: 100%; + max-width: 536px; + height: 480px; + background-color: var(--white); + border: 1px solid var(--medium-grey-for-outline); + border-radius: 8px; + box-shadow: 5px 5px 25px 0 var(--gray-for-shadow); + } + + &__specs-groups, + &__skills-groups { + position: relative; + height: 100%; + overflow: auto; + scrollbar-width: thin; + + ul { + display: flex; + flex-direction: column; + gap: 20px; + padding: 14px; + } + + li { + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + } + } +} + +.cancel { + display: flex; + flex-direction: column; + justify-content: center; + max-height: calc(100vh - 40px); + overflow-y: auto; + + &__cross { + position: absolute; + top: 0; + right: 0; + width: 32px; + height: 32px; + cursor: pointer; + + @include responsive.apply-desktop { + top: 8px; + right: 8px; + } + } + + &__delete-icon { + display: flex; + justify-content: center; + margin: 36px 0; + } + + &__title { + text-align: center; + } + + &__text { + text-align: center; + } +} diff --git a/projects/social_platform/src/app/ui/pages/profile/edit/edit.component.spec.ts b/projects/social_platform/src/app/ui/pages/profile/edit/edit.component.spec.ts new file mode 100644 index 000000000..8c9a92764 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/profile/edit/edit.component.spec.ts @@ -0,0 +1,75 @@ +/** @format */ + +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { ProfileEditComponent } from "./edit.component"; +import { of } from "rxjs"; +import { AuthRepository } from "@infrastructure/repository/auth/auth.repository"; +import { ReactiveFormsModule } from "@angular/forms"; +import { provideRouter } from "@angular/router"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { provideNgxMask } from "ngx-mask"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { SpecializationsRepositoryPort } from "@domain/specializations/ports/specializations.repository.port"; +import { SkillsRepositoryPort } from "@domain/skills/ports/skills.repository.port"; +import { ProjectRepositoryPort } from "@domain/project/ports/project.repository.port"; +import { ProjectSubscriptionRepositoryPort } from "@domain/project/ports/project-subscription.repository.port"; +import { API_URL } from "@corelib"; + +describe("ProfileEditComponent", () => { + let component: ProfileEditComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const authSpy = { + profile: of({}), + changeableRoles: of([]), + }; + + const authPortSpy = { + fetchProfile: of({}), + fetchUserRoles: of([]), + fetchChangeableRoles: of([]), + }; + + const specializationsSpy = { + getSpecializationsNested: () => of([]), + getSpecializationsInline: () => of({ count: 0, results: [], next: "", previous: "" }), + }; + + const skillsSpy = { + getSkillsNested: () => of([]), + getSkillsInline: () => of({ count: 0, results: [], next: "", previous: "" }), + }; + + await TestBed.configureTestingModule({ + imports: [ReactiveFormsModule, HttpClientTestingModule, ProfileEditComponent], + providers: [ + { provide: AuthRepository, useValue: authSpy }, + { provide: AuthRepositoryPort, useValue: authPortSpy }, + { provide: SpecializationsRepositoryPort, useValue: specializationsSpy }, + { provide: SkillsRepositoryPort, useValue: skillsSpy }, + { provide: ProjectRepositoryPort, useValue: {} }, + { + provide: ProjectSubscriptionRepositoryPort, + useValue: { getSubscriptions: of({ results: [], count: 0 }) }, + }, + { provide: API_URL, useValue: "" }, + provideNgxMask(), + provideRouter([]), + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ProfileEditComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/profile/edit/edit.component.ts b/projects/social_platform/src/app/ui/pages/profile/edit/edit.component.ts new file mode 100644 index 000000000..b3bad5346 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/profile/edit/edit.component.ts @@ -0,0 +1,259 @@ +/** @format */ + +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + computed, + inject, + OnInit, + signal, +} from "@angular/core"; +import { isLoading } from "@domain/shared/async-state"; +import { ReactiveFormsModule } from "@angular/forms"; +import { ErrorMessage } from "@core/lib/models/error/error-message"; +import { ButtonComponent, IconComponent } from "@ui/primitives"; +import { RouterModule } from "@angular/router"; +import { EditorSubmitButtonDirective } from "./editor-submit-button.directive"; +import { AsyncPipe, CommonModule } from "@angular/common"; +import { Specialization } from "@domain/specializations/specialization.model"; +import { SkillsGroupComponent } from "@ui/widgets/skills-group/skills-group.component"; +import { SpecializationsGroupComponent } from "@ui/widgets/specializations-group/specializations-group.component"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { navProfileItems } from "@core/consts/navigation/nav-profile-items.const"; +import { Skill } from "@domain/skills/skill.model"; +import { ProfileFormService } from "@api/profile/facades/edit/profile-form.service"; +import { ProjectStepService } from "@api/project/project-step.service"; +import { ProfileEditInfoService } from "@api/profile/facades/edit/profile-edit-info.service"; +import { OnboardingStageOneUIInfoService } from "@api/onboarding/facades/stages/ui/onboarding-stage-one-ui-info.service"; +import { OnboardingStageOneInfoService } from "@api/onboarding/facades/stages/onboarding-stage-one-info.service"; +import { ProjectVacancyUIService } from "@api/project/facades/edit/ui/project-vacancy-ui.service"; +import { ProfileEditEducationInfoService } from "@api/profile/facades/edit/profile-edit-education-info.service"; +import { ProfileEditExperienceInfoService } from "@api/profile/facades/edit/profile-edit-experience-info.service"; +import { ProfileEditSkillsInfoService } from "@api/profile/facades/edit/profile-edit-skills-info.service"; +import { ProjectNavigationComponent } from "@ui/pages/projects/edit/components/project-navigation/project-navigation.component"; +import { ProfileMainStepComponent } from "./components/profile-main-step/profile-main-step.component"; +import { ProfileEducationStepComponent } from "./components/profile-education-step/profile-education-step.component"; +import { ProfileExperienceStepComponent } from "./components/profile-experience-step/profile-experience-step.component"; +import { ProfileAchievementsStepComponent } from "./components/profile-achievements-step/profile-achievements-step.component"; +import { ProfileSkillsStepComponent } from "./components/profile-skills-step/profile-skills-step.component"; +import { OnboardingUIInfoService } from "@api/onboarding/facades/stages/ui/onboarding-ui-info.service"; +import { ProjectsEditUIInfoService } from "@api/project/facades/edit/ui/projects-edit-ui-info.service"; +import { ToggleFieldsInfoService } from "@api/toggle-fields/toggle-fields-info.service"; +import { AppRoutes } from "@api/paths/app-routes"; +import { SearchesService } from "@api/searches/searches.service"; +import { EditStep } from "@core/lib/models/edit-step"; +import { GetSpecializationsNestedUseCase } from "@api/specializations/use-cases/get-specializations-nested.use-case"; +import { map } from "rxjs"; +import { ProfileEditAchievementsInfoService } from "@api/profile/facades/edit/profile-edit-achievements-info.service"; + +/** Многошаговая форма редактирования профиля пользователя. */ +@Component({ + selector: "app-profile-edit", + templateUrl: "./edit.component.html", + styleUrl: "./edit.component.scss", + imports: [ + ReactiveFormsModule, + CommonModule, + IconComponent, + ButtonComponent, + EditorSubmitButtonDirective, + AsyncPipe, + SkillsGroupComponent, + SpecializationsGroupComponent, + ModalComponent, + RouterModule, + ProjectNavigationComponent, + ProfileMainStepComponent, + ProfileEducationStepComponent, + ProfileExperienceStepComponent, + ProfileAchievementsStepComponent, + ProfileSkillsStepComponent, + ], + providers: [ + OnboardingStageOneUIInfoService, + OnboardingStageOneInfoService, + OnboardingUIInfoService, + ProfileEditInfoService, + ProfileEditAchievementsInfoService, + ProfileEditEducationInfoService, + ProfileEditExperienceInfoService, + ProfileEditSkillsInfoService, + ProjectVacancyUIService, + ProjectsEditUIInfoService, + ToggleFieldsInfoService, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProfileEditComponent implements OnInit, AfterViewInit { + private readonly profileFormService = inject(ProfileFormService); + private readonly profileEditInfoService = inject(ProfileEditInfoService); + private readonly projectStepService = inject(ProjectStepService); + private readonly getSpecializationsNestedUseCase = inject(GetSpecializationsNestedUseCase); + private readonly searchesService = inject(SearchesService); + private readonly projectVacancyUIService = inject(ProjectVacancyUIService); + private readonly onboardingStageOneUIInfoService = inject(OnboardingStageOneUIInfoService); + private readonly onboardingStageOneInfoService = inject(OnboardingStageOneInfoService); + private readonly profileEditEducationInfoService = inject(ProfileEditEducationInfoService); + private readonly profileEditExperienceInfoService = inject(ProfileEditExperienceInfoService); + private readonly profileEditSkillsInfoService = inject(ProfileEditSkillsInfoService); + + protected readonly profileForm = this.profileFormService.getForm(); + + ngOnInit(): void { + this.profileEditInfoService.initializationEditInfo(); + } + + ngAfterViewInit() { + this.profileFormService.initializeProfileData(); + } + + readonly editingStep = this.projectStepService.currentStep; + + protected readonly profileId = this.profileEditInfoService.profileId; + + protected readonly AppRoutes = AppRoutes; + + protected readonly inlineSpecs = this.profileFormService.inlineSpecs; + + protected readonly nestedSpecs$ = this.getSpecializationsNestedUseCase + .execute() + .pipe(map(result => (result.ok ? result.value : []))); + + protected readonly specsGroupsModalOpen = signal(false); + + protected readonly inlineSkills = this.searchesService.inlineSkills; + + protected readonly nestedSkills$ = this.searchesService.getSkillsNested(); + + protected readonly skillsGroupsModalOpen = this.projectVacancyUIService.skillsGroupsModalOpen; + + protected readonly openGroupIndex = this.profileEditInfoService.openGroupIndex; + + protected readonly editEducationClick = this.profileEditEducationInfoService.editEducationClick; + protected readonly editWorkClick = this.profileEditExperienceInfoService.editWorkClick; + protected readonly editLanguageClick = this.profileEditSkillsInfoService.editLanguageClick; + + onGroupToggled(index: number, isOpen: boolean) { + this.profileEditInfoService.onGroupToggled(index, isOpen); + } + + isGroupDisabled(index: number): boolean { + return this.profileEditInfoService.isGroupDisabled(index); + } + + protected readonly isModalErrorSkillsChoose = + this.profileEditInfoService.isModalErrorSkillsChoose; + + protected readonly isModalErrorSkillChooseText = + this.profileEditInfoService.isModalErrorSkillChooseText; + + protected readonly isModalDeleteProfile = signal(false); + + protected readonly editIndex = this.profileEditInfoService.editIndex; + + readonly navProfileItems = navProfileItems; + + navigateStep(step: EditStep) { + this.projectStepService.navigateToStep(step); + } + + protected readonly typeSpecific = this.profileFormService.typeSpecific; + + protected readonly usefulToProject = this.profileFormService.usefulToProject; + + protected readonly preferredIndustries = this.profileFormService.preferredIndustries; + + protected readonly newPreferredIndustryTitle = this.profileFormService.newPreferredIndustryTitle; + + addPreferredIndustry(title?: string): void { + this.profileFormService.addPreferredIndustry(title); + } + + removePreferredIndustry(i: number): void { + this.profileFormService.removePreferredIndustry(i); + } + + get isEducationDirty(): boolean { + const f = this.profileForm; + return [ + "organizationName", + "entryYear", + "completionYear", + "description", + "educationStatus", + "educationLevel", + ].some(name => f.get(name)?.dirty); + } + + get isWorkDirty(): boolean { + const f = this.profileForm; + return [ + "organization", + "entryYearWork", + "completionYearWork", + "descriptionWork", + "jobPosition", + ].some(name => f.get(name)?.dirty); + } + + get isLanguageDirty(): boolean { + const f = this.profileForm; + return ["language", "languageLevel"].some(name => f.get(name)?.dirty); + } + + get isAchievementsDirty(): boolean { + const f = this.profileForm; + return ["title", "status", "year", "files"].some(name => f.get(name)?.dirty); + } + + protected readonly errorMessage = ErrorMessage; + + protected readonly roles = this.profileFormService.roles; + + protected readonly profileFormSubmitting = computed(() => + isLoading(this.profileEditInfoService.profileFormSubmitting$()), + ); + + // Для управления открытыми группами специализаций + protected readonly openSpecializationGroup = + this.onboardingStageOneUIInfoService.openSpecializationGroup; + protected readonly hasOpenSpecializationsGroups = + this.onboardingStageOneUIInfoService.hasOpenSpecializationsGroups; + + isSpecializationGroupDisabled(groupName: string): boolean { + return this.onboardingStageOneUIInfoService.isSpecializationGroupDisabled(groupName); + } + + onSpecializationsGroupToggled(isOpen: boolean, groupName: string): void { + this.onboardingStageOneUIInfoService.onSpecializationsGroupToggled(isOpen, groupName); + } + + saveProfile(): void { + this.profileEditInfoService.saveProfile(); + } + + onSelectSpec(speciality: Specialization): void { + this.onboardingStageOneInfoService.onSelectSpec(speciality); + } + + onToggleSkill(toggledSkill: Skill): void { + this.searchesService.onToggleSkill(toggledSkill, this.profileForm); + } + + onAddSkill(newSkill: Skill): void { + this.searchesService.onAddSkill(newSkill, this.profileForm); + } + + onRemoveSkill(oddSkill: Skill): void { + this.searchesService.onRemoveSkill(oddSkill, this.profileForm); + } + + toggleSkillsGroupsModal(): void { + this.skillsGroupsModalOpen.update(open => !open); + } + + toggleSpecsGroupsModal(): void { + this.specsGroupsModalOpen.update(open => !open); + } +} diff --git a/projects/social_platform/src/app/ui/directives/editor-submit-button.directive.spec.ts b/projects/social_platform/src/app/ui/pages/profile/edit/editor-submit-button.directive.spec.ts similarity index 100% rename from projects/social_platform/src/app/ui/directives/editor-submit-button.directive.spec.ts rename to projects/social_platform/src/app/ui/pages/profile/edit/editor-submit-button.directive.spec.ts diff --git a/projects/social_platform/src/app/ui/directives/editor-submit-button.directive.ts b/projects/social_platform/src/app/ui/pages/profile/edit/editor-submit-button.directive.ts similarity index 93% rename from projects/social_platform/src/app/ui/directives/editor-submit-button.directive.ts rename to projects/social_platform/src/app/ui/pages/profile/edit/editor-submit-button.directive.ts index 4585a23da..b7ad83dff 100644 --- a/projects/social_platform/src/app/ui/directives/editor-submit-button.directive.ts +++ b/projects/social_platform/src/app/ui/pages/profile/edit/editor-submit-button.directive.ts @@ -1,8 +1,8 @@ /** @format */ -import { AfterViewInit, Directive, Input, OnDestroy, ViewContainerRef } from "@angular/core"; -import { fromEvent, Subscription } from "rxjs"; +import { AfterViewInit, Directive, input, Input, OnDestroy, ViewContainerRef } from "@angular/core"; import { containerSm } from "@utils/responsive"; +import { fromEvent, Subscription } from "rxjs"; /** * Директива для управления позиционированием кнопки сохранения в редакторе @@ -21,7 +21,7 @@ export class EditorSubmitButtonDirective implements AfterViewInit, OnDestroy { constructor(private readonly viewRef: ViewContainerRef) {} /** Селектор контейнера для отслеживания позиции */ - @Input() containerSelector = "profile"; + readonly containerSelector = input("profile"); /** Инициализация отслеживания скролла после загрузки представления */ ngAfterViewInit(): void { @@ -41,7 +41,7 @@ export class EditorSubmitButtonDirective implements AfterViewInit, OnDestroy { /** Инициализация отслеживания скролла и позиционирования кнопки */ initSaveButtonScroller(): void { const scroller = document.querySelector(".office__body"); - const container = document.querySelector(this.containerSelector); + const container = document.querySelector(this.containerSelector()); const topBar = document.querySelector(".office__top"); if (!scroller || !container || !topBar) return; diff --git a/projects/social_platform/src/app/ui/pages/program/detail/detail.resolver.ts b/projects/social_platform/src/app/ui/pages/program/detail/detail.resolver.ts new file mode 100644 index 000000000..2f0177ba7 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/detail.resolver.ts @@ -0,0 +1,19 @@ +/** @format */ + +import { inject } from "@angular/core"; +import { ActivatedRouteSnapshot, ResolveFn } from "@angular/router"; +import { ProgramDetailMainUIInfoService } from "@api/program/facades/detail/ui/program-detail-main-ui-info.service"; +import { Program } from "@domain/program/program.model"; +import { map, tap } from "rxjs"; +import { GetProgramUseCase } from "@api/program/use-cases/get-program.use-case"; + +/** Предзагружает детальную информацию о программе. */ +export const ProgramDetailResolver: ResolveFn = (route: ActivatedRouteSnapshot) => { + const getProgramUseCase = inject(GetProgramUseCase); + const programDetailMainUIInfoService = inject(ProgramDetailMainUIInfoService); + + return getProgramUseCase.execute(route.params["programId"]).pipe( + map(result => (result.ok ? result.value : new Program())), + tap(program => programDetailMainUIInfoService.applyFormatingProgramData(program)), + ); +}; diff --git a/projects/social_platform/src/app/ui/pages/program/detail/list/list-all.resolver.ts b/projects/social_platform/src/app/ui/pages/program/detail/list/list-all.resolver.ts new file mode 100644 index 000000000..04f02b9df --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/list/list-all.resolver.ts @@ -0,0 +1,39 @@ +/** @format */ + +import { HttpParams } from "@angular/common/http"; +import { inject } from "@angular/core"; +import { ActivatedRouteSnapshot, ResolveFn, Router } from "@angular/router"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { ProjectRate } from "@domain/project/project-rate"; +import { catchError, EMPTY, map } from "rxjs"; +import { GetProjectRatingsUseCase } from "@api/program/use-cases/get-project-ratings.use-case"; + +/** Предзагружает проекты для оценки с критериями и пагинацией. */ +export const ListAllResolver: ResolveFn> = ( + route: ActivatedRouteSnapshot, +) => { + const getProjectRatingsUseCase = inject(GetProjectRatingsUseCase); + const router = inject(Router); + + return getProjectRatingsUseCase + .execute( + route.parent?.params["programId"], + new HttpParams({ fromObject: { offset: 0, limit: 8 } }), + ) + .pipe( + map(result => (result.ok ? result.value : { count: 0, results: [], next: "", previous: "" })), + ) + .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/ui/pages/program/detail/list/list.component.html b/projects/social_platform/src/app/ui/pages/program/detail/list/list.component.html new file mode 100644 index 000000000..c07bb3312 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/list/list.component.html @@ -0,0 +1,261 @@ + + +
    +
    + + + @if (appWidth < 1000 && listType() !== "members") { +
    +
    +
    + + + + сбросить +
    + + @if (listType() === "rating") { +
    + + выгрузка оценок + + +
    + } +
    + + @if (listType() === "projects") { +
    + + выгрузка проектов + + + + + сданные решения + + +
    + } @else { + @if (appWidth > 1000) { +
    + + выгрузка оценок + + +
    + } + } +
    + } + +
      + @for (listItem of searchedList(); track listItem.id) { +
    • + @if (listType() === "projects" || listType() === "members") { + + + + } @else { + + } +
    • + } +
    +
    + + @if (appWidth >= 1000 && listType() !== "members") { +
    +
    + @if (listType() === "projects" || listType() === "rating") { +
    +
    +
    +
    + + + @if (listType() === "projects") { +
    + + выгрузка проектов + + + + + сданные решения + + +
    + } @else { +
    + + выгрузка оценок + + + + + итоговые расчеты + + +
    + } +
    +
    + } +
    +
    + } + @if (listType() !== "members" && listType() !== "projects" && appWidth >= 1000) { +
    +
    + + + @if (tooltipInfoService.isVisible("hint-experts")) { +
    +

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

    +

    подробнее

    +
    + } +
    +
    + + @defer (when isHintExpertsModal()) { + +
    +
    +

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

    +
    + +
    +

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

    + +

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

    + +

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

    + +

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

    + +

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

    +
    + + спасибо, понятно +
    +
    + } + + @defer (when isFilterOpen()) { + + + + + применить фильтр + + + } + } +
    diff --git a/projects/social_platform/src/app/ui/pages/program/detail/list/list.component.scss b/projects/social_platform/src/app/ui/pages/program/detail/list/list.component.scss new file mode 100644 index 000000000..b3b2de4d7 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/list/list.component.scss @@ -0,0 +1,313 @@ +@use "styles/responsive"; +@use "styles/typography"; + +.page { + display: grid; + grid-template-columns: 1fr; + gap: 10px; + max-width: 1280px; + padding-bottom: 100px; + margin: 0 auto; + + @include responsive.apply-desktop { + grid-template-columns: 8fr 2fr; + } + + &__outlet { + display: flex; + flex-direction: column; + flex-grow: 1; + gap: 20px; + width: 100%; + padding: 0 10px; + margin-top: 12px; + + @include responsive.apply-desktop { + padding: 0; + } + } + + &__filter { + display: none; + + &--open { + display: block; + } + + @include responsive.apply-desktop { + display: block; + margin-left: 16px; + } + } + + &__mobile-controls { + display: flex; + flex-direction: column; + gap: 12px; + + @include responsive.apply-desktop { + display: none; + } + } + + &__mobile-filter-row { + display: flex; + gap: 20px; + align-items: center; + justify-content: space-between; + } + + &__clear { + color: var(--accent); + cursor: pointer; + } + + &__mobile-export { + display: flex; + gap: 20px; + + ::ng-deep app-button { + flex: 1; + + button { + width: 100%; + } + + span { + margin-right: 5px !important; + } + } + } + + &__list { + display: grid; + grid-template-columns: repeat(2, 1fr); + row-gap: 50px; + column-gap: 12px; + align-items: flex-start; + margin-top: 30px; + + @include responsive.apply-desktop { + grid-template-columns: repeat(4, 2fr); + row-gap: 50px; + column-gap: 20px; + margin-top: 50px; + } + + &--rating { + grid-template-columns: 1fr; + row-gap: 20px; + margin-top: 0; + + @include responsive.apply-desktop { + grid-template-columns: 1fr; + } + } + } + + &__create { + display: flex; + flex-direction: column; + gap: 20px; + align-items: center; + margin-top: 20px; + } + + &__left { + position: relative; + width: 100%; + } + + &__export { + display: flex; + flex-direction: column; + gap: 10px; + align-items: center; + margin-top: 10px; + + ::ng-deep { + app-button { + span { + margin-right: 5px !important; + } + } + } + } + + &__tooltip { + position: fixed; + right: + calc( + (100vw - 1080px) / 2 + ); + bottom: 24px; + z-index: 30; + } +} + +.filter { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 10; + + @include responsive.apply-desktop { + position: static; + } + + &__overlay { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + background-color: black; + opacity: 0.3; + + @include responsive.apply-desktop { + display: none; + } + } + + &__bar { + position: fixed; + display: flex; + width: 100%; + height: 25px; + touch-action: none; + + @include responsive.apply-desktop { + display: none; + } + + &::after { + display: block; + width: 85px; + height: 5px; + margin: auto; + content: ""; + background-color: var(--gray); + border-radius: var(--rounded-lg); + transition: transform 0.2s; + } + } + + &__body { + position: absolute; + right: 0; + bottom: 0; + left: 0; + z-index: 10; + min-height: 72vh; + overflow-y: auto; + background-color: var(--white); + border-radius: var(--rounded-lg); + transform: translateY(0%); + + @include responsive.apply-desktop { + position: static; + max-height: unset; + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); + } + } +} + +.filter-toggle { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + padding: 15px 10px; + cursor: pointer; + background-color: var(--white); + border: 1px solid var(--medium-medium-grey-for-outline); + border-radius: var(--rounded-xl); + + @include responsive.apply-desktop { + display: none; + } +} + +.tooltip { + position: relative; + display: flex; + align-items: center; + + &__wrapper { + position: absolute; + right: 100%; + bottom: 22px; + left: auto; + width: 310px; + padding: 18px 10px 10px 16px; + background-color: var(--white); + border: 0.5px solid var(--grey-for-text); + border-radius: var(--rounded-lg) var(--rounded-lg) 0 var(--rounded-lg); + + :last-child { + color: var(--black); + cursor: pointer; + } + } + + &__text { + color: var(--grey-for-text); + + a { + color: var(--accent); + } + } +} + +.cancel { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 80%; + max-height: calc(100vh - 40px); + padding: 0 24px; + overflow-y: auto; + + @include responsive.apply-desktop { + width: 605px; + max-width: 100%; + } + + &__cross { + position: absolute; + top: 0; + right: 0; + width: 32px; + height: 32px; + cursor: pointer; + + @include responsive.apply-desktop { + top: 8px; + right: 8px; + } + } + + &__top { + display: flex; + flex-direction: column; + margin-bottom: 10px; + } + + &__title { + text-align: center; + } + + &__text { + text-align: center; + + &-block { + display: flex; + flex-direction: column; + gap: 12px; + margin: 30px 0; + } + } +} diff --git a/projects/social_platform/src/app/ui/pages/program/detail/list/list.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/list/list.component.spec.ts new file mode 100644 index 000000000..f4d7385c2 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/list/list.component.spec.ts @@ -0,0 +1,115 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { provideRouter } from "@angular/router"; +import { signal } from "@angular/core"; +import { of } from "rxjs"; +import { FormBuilder } from "@angular/forms"; +import { initial } from "@domain/shared/async-state"; +import { ProgramListComponent } from "./list.component"; +import { ProgramDetailListInfoService } from "@api/program/facades/detail/program-detail-list-info.service"; +import { ProgramDetailListUIInfoService } from "@api/program/facades/detail/ui/program-detail-list-ui-info.service"; +import { ProgramProjectsFilterInfoService } from "./program-projects-filter/service/program-projects-filter-info.service"; +import { ExportFileInfoService } from "@api/export-file/facades/export-file-info.service"; +import { SwipeService } from "@api/swipe/swipe.service"; +import { TooltipInfoService } from "@api/tooltip/tooltip-info.service"; + +describe("ProgramListComponent", () => { + let component: ProgramListComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const programDetailListInfoServiceSpy = { + initializeSearchForm: vi.fn(), + initializationListData: vi.fn(), + initScroll: vi.fn(), + destroy: vi.fn(), + }; + + const fb = new FormBuilder(); + const programDetailListUIInfoServiceSpy = { + searchForm: fb.group({ search: [""] }), + listType: signal("projects"), + searchedList: signal([]), + profileProjSubsIds: signal([]), + routerLink: vi.fn(), + applySetAvailableFilters: vi.fn(), + isHintExpertsModal: signal(false), + }; + + const programProjectsFilterInfoServiceSpy = { + initializationProgramProjectsFilter: vi.fn(), + clearFilters: vi.fn(), + filters: signal([]), + filterForm: fb.group({}), + }; + + const exportFileInfoServiceSpy = { + loadingExports$: signal(initial()), + loadingExports: signal(false), + downloadProjects: vi.fn(), + downloadSubmittedProjects: vi.fn(), + downloadRates: vi.fn(), + }; + + const swipeServiceSpy = { + isFilterOpen: signal(false), + onSwipeStart: vi.fn(), + onSwipeMove: vi.fn(), + onSwipeEnd: vi.fn(), + closeFilter: vi.fn(), + }; + + const tooltipInfoServiceSpy = { + isTooltipVisible: signal>({}), + isVisible: vi.fn().mockReturnValue(false), + show: vi.fn(), + hide: vi.fn(), + toggleTooltip: vi.fn(), + }; + + await TestBed.configureTestingModule({ + imports: [ProgramListComponent], + providers: [provideRouter([])], + }) + .overrideComponent(ProgramListComponent, { + remove: { + providers: [ + ProgramDetailListInfoService, + ProgramDetailListUIInfoService, + ProgramProjectsFilterInfoService, + ExportFileInfoService, + SwipeService, + TooltipInfoService, + ], + }, + add: { + providers: [ + { provide: ProgramDetailListInfoService, useValue: programDetailListInfoServiceSpy }, + { + provide: ProgramDetailListUIInfoService, + useValue: programDetailListUIInfoServiceSpy, + }, + { + provide: ProgramProjectsFilterInfoService, + useValue: programProjectsFilterInfoServiceSpy, + }, + { provide: ExportFileInfoService, useValue: exportFileInfoServiceSpy }, + { provide: SwipeService, useValue: swipeServiceSpy }, + { provide: TooltipInfoService, useValue: tooltipInfoServiceSpy }, + ], + }, + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ProgramListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/program/detail/list/list.component.ts b/projects/social_platform/src/app/ui/pages/program/detail/list/list.component.ts new file mode 100644 index 000000000..7d4610d7a --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/list/list.component.ts @@ -0,0 +1,169 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + computed, + ElementRef, + HostListener, + inject, + OnInit, + viewChild, + ViewChild, +} from "@angular/core"; +import { isLoading } from "@domain/shared/async-state"; +import { RouterModule } from "@angular/router"; +import { ReactiveFormsModule } from "@angular/forms"; +import { SearchComponent } from "@ui/primitives/search/search.component"; +import { ProgramProjectsFilterComponent } from "./program-projects-filter/program-projects-filter.component"; +import { RatingCardComponent } from "./rating-card/rating-card.component"; +import { InfoCardComponent } from "@ui/widgets/info-card/info-card.component"; +import { ButtonComponent } from "@ui/primitives"; +import { IconComponent } from "@uilib"; +import { PartnerProgramFields } from "@domain/program/partner-program-fields.model"; +import { tagsFilter } from "@core/consts/filters/tags-filter.const"; +import { ProgramDetailListUIInfoService } from "@api/program/facades/detail/ui/program-detail-list-ui-info.service"; +import { ProgramDetailListInfoService } from "@api/program/facades/detail/program-detail-list-info.service"; +import { ExportFileInfoService } from "@api/export-file/facades/export-file-info.service"; +import { SwipeService } from "@api/swipe/swipe.service"; +import { ProgramProjectsFilterInfoService } from "./program-projects-filter/service/program-projects-filter-info.service"; +import { TooltipComponent } from "@ui/primitives/tooltip/tooltip.component"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { TooltipInfoService } from "@api/tooltip/tooltip-info.service"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; + +/** Вкладка списка программы: проекты/участники с фильтрами. */ +@Component({ + selector: "app-list", + templateUrl: "./list.component.html", + styleUrl: "./list.component.scss", + imports: [ + CommonModule, + ReactiveFormsModule, + RouterModule, + ProgramProjectsFilterComponent, + SearchComponent, + RatingCardComponent, + InfoCardComponent, + ButtonComponent, + IconComponent, + TooltipComponent, + ModalComponent, + ], + providers: [ + ProgramDetailListInfoService, + ProgramDetailListUIInfoService, + ProgramProjectsFilterInfoService, + ExportFileInfoService, + SwipeService, + TooltipInfoService, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProgramListComponent implements OnInit, AfterViewInit { + readonly listRoot = viewChild | undefined>("listRoot"); + readonly filterBody = viewChild>("filterBody"); + + constructor() { + this.programDetailListInfoService.initializeSearchForm(); + } + + private readonly programDetailListInfoService = inject(ProgramDetailListInfoService); + private readonly programDetailListUIInfoService = inject(ProgramDetailListUIInfoService); + private readonly programProjectsFilterInfoService = inject(ProgramProjectsFilterInfoService); + private readonly exportFileInfoService = inject(ExportFileInfoService); + protected readonly tooltipInfoService = inject(TooltipInfoService); + private readonly swipeService = inject(SwipeService); + private readonly logger = inject(LoggerService); + + protected readonly searchForm = this.programDetailListUIInfoService.searchForm; + + protected readonly listType = this.programDetailListUIInfoService.listType; + protected readonly searchedList = this.programDetailListUIInfoService.searchedList; + protected readonly profileProjSubsIds = this.programDetailListUIInfoService.profileProjSubsIds; + + protected readonly loadingExports = computed(() => + isLoading(this.exportFileInfoService.loadingExports$()), + ); + + protected readonly isFilterOpen = this.swipeService.isFilterOpen; + protected readonly ratingOptionsList = tagsFilter; + + protected readonly isHintExpertsModal = this.programDetailListUIInfoService.isHintExpertsModal; + + protected appWidth = window.innerWidth; + + @HostListener("window:resize") + onResize() { + this.appWidth = window.innerWidth; + } + + ngOnInit(): void { + this.programDetailListInfoService.initializationListData(); + this.programProjectsFilterInfoService.initializationProgramProjectsFilter(); + } + + ngAfterViewInit(): void { + const target = document.querySelector(".office__body") as HTMLElement; + if (target || this.listRoot()) { + this.programDetailListInfoService.initScroll(target, this.listRoot()!); + } else { + this.logger.error(".office__body element not found"); + } + } + + routerLink(linkId: number): string { + return this.programDetailListUIInfoService.routerLink(linkId); + } + + onFiltersLoaded(filters: PartnerProgramFields[]): void { + this.programDetailListUIInfoService.applySetAvailableFilters(filters); + } + + downloadProjects(): void { + this.exportFileInfoService.downloadProjects(); + } + + downloadSubmittedProjects(): void { + this.exportFileInfoService.downloadSubmittedProjects(); + } + + downloadRates(): void { + this.exportFileInfoService.downloadRates(); + } + + downloadCalculations(): void {} + + onSwipeStart(event: TouchEvent): void { + this.swipeService.onSwipeStart(event); + } + + onSwipeMove(event: TouchEvent): void { + this.swipeService.onSwipeMove(event, this.filterBody()!); + } + + onSwipeEnd(event: TouchEvent): void { + this.swipeService.onSwipeEnd(event, this.filterBody()!); + } + + closeFilter(): void { + this.swipeService.closeFilter(); + } + + openHintModal(event: Event): void { + event.preventDefault(); + this.tooltipInfoService.toggleTooltip("hint-experts"); + this.programDetailListUIInfoService.applyHintModalOpen(); + } + + /** + * Сброс всех активных фильтров. Делегируем в filter-сервис, который полностью очищает + * query (navigateByUrl на pathname без query). Mobile-кнопка "сбросить" и (clear) от + * filter-component используют один и тот же путь — гонок navigate'ов нет. + */ + onClearFilters(): void { + this.programProjectsFilterInfoService.clearFilters(); + } +} diff --git a/projects/social_platform/src/app/ui/pages/program/detail/list/members.resolver.ts b/projects/social_platform/src/app/ui/pages/program/detail/list/members.resolver.ts new file mode 100644 index 000000000..9afda8259 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/list/members.resolver.ts @@ -0,0 +1,21 @@ +/** @format */ + +import { inject } from "@angular/core"; +import { ActivatedRouteSnapshot, ResolveFn } from "@angular/router"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { User } from "@domain/auth/user.model"; +import { map } from "rxjs"; +import { GetAllMembersUseCase } from "@api/program/use-cases/get-all-members.use-case"; + +/** Предзагружает первую страницу участников программы. */ +export const ProgramMembersResolver: ResolveFn> = ( + route: ActivatedRouteSnapshot, +) => { + const getAllMembersUseCase = inject(GetAllMembersUseCase); + + return getAllMembersUseCase + .execute(route.parent?.params["programId"], 0, 20) + .pipe( + map(result => (result.ok ? result.value : { count: 0, results: [], next: "", previous: "" })), + ); +}; diff --git a/projects/social_platform/src/app/ui/pages/program/detail/list/program-projects-filter/program-projects-filter.component.html b/projects/social_platform/src/app/ui/pages/program/detail/list/program-projects-filter/program-projects-filter.component.html new file mode 100644 index 000000000..6227ab725 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/list/program-projects-filter/program-projects-filter.component.html @@ -0,0 +1,73 @@ + +
    +

    фильтры

    + cбросить +
    + +@if (filters()?.length) { +
    +
    + @if (filters()?.length && filterForm.controls) { + @for (field of filters(); track field.id) { + @if (filterForm.get(field.name)) { +
    + @switch (field.fieldType) { + @case ("checkbox") { + +
    + + {{ field.label }} +
    + } + @case ("radio") { + +
    + + Нет + + + + Да + +
    + } + @case ("select") { + +
    + +
    + } + } +
    + } + } + } + @if (listType() === "rating") { + + } +
    +
    +} diff --git a/projects/social_platform/src/app/office/program/detail/list/projects-filter/projects-filter.component.scss b/projects/social_platform/src/app/ui/pages/program/detail/list/program-projects-filter/program-projects-filter.component.scss similarity index 100% rename from projects/social_platform/src/app/office/program/detail/list/projects-filter/projects-filter.component.scss rename to projects/social_platform/src/app/ui/pages/program/detail/list/program-projects-filter/program-projects-filter.component.scss diff --git a/projects/social_platform/src/app/ui/pages/program/detail/list/program-projects-filter/program-projects-filter.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/list/program-projects-filter/program-projects-filter.component.spec.ts new file mode 100644 index 000000000..3e72c70b3 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/list/program-projects-filter/program-projects-filter.component.spec.ts @@ -0,0 +1,60 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { provideRouter } from "@angular/router"; +import { signal } from "@angular/core"; +import { ProgramProjectsFilterComponent } from "./program-projects-filter.component"; +import { ProgramDetailListUIInfoService } from "@api/program/facades/detail/ui/program-detail-list-ui-info.service"; +import { ProgramProjectsFilterInfoService } from "./service/program-projects-filter-info.service"; + +describe("ProjectsFilterComponent", () => { + let component: ProgramProjectsFilterComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const programDetailListUIInfoServiceSpy = { + listType: signal("projects"), + }; + + const programProjectsFilterInfoServiceSpy = { + filterForm: signal(null), + filters: signal([]), + toggleAdditionalFormValues: vi.fn(), + setValue: vi.fn(), + clearFilters: vi.fn(), + }; + + await TestBed.configureTestingModule({ + imports: [ProgramProjectsFilterComponent], + providers: [provideRouter([])], + }) + .overrideComponent(ProgramProjectsFilterComponent, { + remove: { + providers: [ProgramDetailListUIInfoService, ProgramProjectsFilterInfoService], + }, + add: { + providers: [ + { + provide: ProgramDetailListUIInfoService, + useValue: programDetailListUIInfoServiceSpy, + }, + { + provide: ProgramProjectsFilterInfoService, + useValue: programProjectsFilterInfoServiceSpy, + }, + ], + }, + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ProgramProjectsFilterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/program/detail/list/program-projects-filter/program-projects-filter.component.ts b/projects/social_platform/src/app/ui/pages/program/detail/list/program-projects-filter/program-projects-filter.component.ts new file mode 100644 index 000000000..7d45cd72c --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/list/program-projects-filter/program-projects-filter.component.ts @@ -0,0 +1,65 @@ +/** @format */ + +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + inject, + output, + Output, +} from "@angular/core"; +import { SwitchComponent } from "@ui/primitives/switch/switch.component"; +import { CheckboxComponent, SelectComponent } from "@ui/primitives"; +import { ToSelectOptionsPipe } from "@corelib"; +import { CommonModule } from "@angular/common"; +import { ReactiveFormsModule } from "@angular/forms"; +import { ProgramDetailListUIInfoService } from "@api/program/facades/detail/ui/program-detail-list-ui-info.service"; +import { ProgramProjectsFilterInfoService } from "./service/program-projects-filter-info.service"; + +/** Фильтрация списка проектов с синхронизацией состояния через URL. */ +@Component({ + selector: "app-program-projects-filter", + templateUrl: "./program-projects-filter.component.html", + styleUrl: "./program-projects-filter.component.scss", + imports: [ + CommonModule, + ReactiveFormsModule, + CheckboxComponent, + SwitchComponent, + SelectComponent, + ToSelectOptionsPipe, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProgramProjectsFilterComponent { + readonly clear = output(); + + private readonly programDetailListUIInfoService = inject(ProgramDetailListUIInfoService); + private readonly programProjectsFilterInfoService = inject(ProgramProjectsFilterInfoService); + + protected readonly listType = this.programDetailListUIInfoService.listType; + + // Инициализация формы для фильтра + protected readonly filterForm = this.programProjectsFilterInfoService.filterForm; + + // Массив фильтров по дополнительным полям привязанным к конкретной программе + protected readonly filters = this.programProjectsFilterInfoService.filters; + + toggleAdditionalFormValues( + fieldType: "text" | "textarea" | "checkbox" | "select" | "radio" | "file", + fieldName: string, + ): void { + this.programProjectsFilterInfoService.toggleAdditionalFormValues(fieldType, fieldName); + } + + // Методы фильтрации + setValue(event: Event): void { + this.programProjectsFilterInfoService.setValue(event); + } + + clearFilters(): void { + this.programProjectsFilterInfoService.clearFilters(); + + this.clear.emit(); + } +} diff --git a/projects/social_platform/src/app/ui/pages/program/detail/list/program-projects-filter/service/program-projects-filter-info.service.ts b/projects/social_platform/src/app/ui/pages/program/detail/list/program-projects-filter/service/program-projects-filter-info.service.ts new file mode 100644 index 000000000..196e20dc2 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/list/program-projects-filter/service/program-projects-filter-info.service.ts @@ -0,0 +1,194 @@ +/** @format */ + +import { DestroyRef, inject, Injectable, signal } from "@angular/core"; +import { FormBuilder, FormControl, FormGroup, Validators } from "@angular/forms"; +import { ActivatedRoute, Router } from "@angular/router"; +import { ProgramDetailListUIInfoService } from "@api/program/facades/detail/ui/program-detail-list-ui-info.service"; +import { GetProgramFiltersUseCase } from "@api/program/use-cases/get-program-filters.use-case"; +import { PartnerProgramFields } from "@domain/program/partner-program-fields.model"; +import { debounceTime, distinctUntilChanged, shareReplay } from "rxjs"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; + +@Injectable() +export class ProgramProjectsFilterInfoService { + private readonly fb = inject(FormBuilder); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly logger = inject(LoggerService); + private readonly destroyRef = inject(DestroyRef); + + private readonly programDetailListUIInfoService = inject(ProgramDetailListUIInfoService); + + private readonly getProgramFiltersUseCase = inject(GetProgramFiltersUseCase); + + filterForm: FormGroup = this.fb.group({}); + + // Массив фильтров по дополнительным полям привязанным к конкретной программе + readonly filters = signal(null); + + protected readonly listType = this.programDetailListUIInfoService.listType; + + private initialized = false; + + initializationProgramProjectsFilter(): void { + // Идемпотентность: ProgramListComponent — провайдер сервиса, повторный вход возможен + // только при необычных потоках (HMR/тесты). Один guard вместо inflight+filters проверок. + if (this.initialized) return; + if (this.listType() !== "projects" && this.listType() !== "rating") return; + + this.initialized = true; + + const programId = this.route.parent?.snapshot.params["programId"]; + this.getProgramFiltersUseCase + .execute(Number(programId)) + .pipe(shareReplay({ bufferSize: 1, refCount: true }), takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: result => { + if (!result.ok) { + this.logger.error("Error loading program filters:", result.error.cause); + this.initialized = false; // дать шанс ретраю на следующий init + return; + } + + this.filters.set(result.value); + this.initializeFilterForm(); + this.restoreFiltersFromUrl(); + this.subscribeToFormChanges(); + }, + }); + } + + /** + * Переключение значения для checkbox и radio полей + * @param fieldType - тип поля + * @param fieldName - имя поля + */ + toggleAdditionalFormValues( + fieldType: "text" | "textarea" | "checkbox" | "select" | "radio" | "file", + fieldName: string, + ): void { + if (fieldType === "checkbox" || fieldType === "radio") { + const control = this.filterForm.get(fieldName); + if (control) { + control.setValue(!control.value); + } + } + } + + // Методы фильтрации + setValue(event: Event): void { + event.stopPropagation(); + const control = this.filterForm.get("is_rated_by_expert"); + if (control) { + control.setValue(!control.value); + } + } + + /** + * Сброс всех активных фильтров + * Очищает все query параметры и возвращает к состоянию по умолчанию + */ + clearFilters(): void { + // Полный сброс формы без эмита valueChanges — иначе через debounce уйдёт ещё один navigate. + this.filterForm.reset({}, { emitEvent: false }); + + // navigateByUrl на pathname без query — самый надёжный способ очистить query целиком. + // router.navigate({ queryParams: {} }) в некоторых конфигурациях не сериализуется в очистку + // (Router считает пустой объект "ничего не менять"), отсюда и эффект "сброс не работает". + const path = this.router.url.split("?")[0]; + this.router.navigateByUrl(path).then(() => this.logger.info("Filters cleared")); + } + + private initializeFilterForm(): void { + const formControls: { [key: string]: FormControl } = {}; + + this.filters()?.forEach(field => { + const validators = field.isRequired ? [Validators.required] : []; + const initialValue = + field.fieldType === "checkbox" || field.fieldType === "radio" ? false : ""; + formControls[field.name] = new FormControl(initialValue, validators); + }); + + if (this.listType() === "rating") { + const isRatedByExpert = + this.route.snapshot.queryParams["is_rated_by_expert"] === "true" + ? true + : this.route.snapshot.queryParams["is_rated_by_expert"] === "false" + ? false + : null; + + formControls["is_rated_by_expert"] = new FormControl(isRatedByExpert); + } + + Object.keys(this.filterForm.controls).forEach(key => { + this.filterForm.removeControl(key); + }); + Object.keys(formControls).forEach(key => { + this.filterForm.addControl(key, formControls[key]); + }); + } + + private restoreFiltersFromUrl(): void { + this.route.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(queries => { + Object.keys(queries).forEach(key => { + const control = this.filterForm.get(key); + if (control && queries[key] !== undefined) { + const field = this.filters()?.find(f => f.name === key); + if (field && (field.fieldType === "checkbox" || field.fieldType === "radio")) { + control.setValue(queries[key] === "true", { emitEvent: false }); + } else { + control.setValue(queries[key], { emitEvent: false }); + } + } + }); + }); + } + + private subscribeToFormChanges(): void { + this.filterForm.valueChanges + .pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) + .subscribe(formValue => { + this.updateQueryParams(formValue); + }); + } + + private updateQueryParams(formValue: any): void { + const currentParams = { ...this.route.snapshot.queryParams }; + + Object.keys(formValue).forEach(fieldName => { + const value = formValue[fieldName]; + + const field = this.filters()?.find(f => f.name === fieldName); + if (this.shouldAddToQueryParams(value, field?.fieldType)) { + currentParams[fieldName] = value; + } else { + delete currentParams[fieldName]; + } + }); + + this.router + .navigate([], { + queryParams: currentParams, + relativeTo: this.route, + }) + .then(() => { + this.logger.info("Query params updated:", currentParams); + }); + } + + private shouldAddToQueryParams( + value: any, + fieldType?: "text" | "textarea" | "checkbox" | "select" | "radio" | "file", + ): boolean { + if (fieldType === "checkbox" || fieldType === "radio") { + return value === true; + } + + if (fieldType === "select" || fieldType === "text" || fieldType === "textarea") { + return value !== null && value !== undefined && value !== ""; + } + + return !!value; + } +} diff --git a/projects/social_platform/src/app/ui/pages/program/detail/list/projects.resolver.ts b/projects/social_platform/src/app/ui/pages/program/detail/list/projects.resolver.ts new file mode 100644 index 000000000..de5e35832 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/list/projects.resolver.ts @@ -0,0 +1,52 @@ +/** @format */ + +import { inject } from "@angular/core"; +import { ActivatedRouteSnapshot, ResolveFn, Router } from "@angular/router"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { HttpParams } from "@angular/common/http"; +import { catchError, EMPTY, map, of } from "rxjs"; +import { Project } from "@domain/project/project.model"; +import { GetAllProjectsUseCase } from "@api/program/use-cases/get-all-projects.use-case"; +import { CreateProgramFiltersUseCase } from "@api/program/use-cases/create-program-filters.use-case"; + +/** Предзагружает проекты программы с фильтрацией. */ +export const ProgramProjectsResolver: ResolveFn> = ( + route: ActivatedRouteSnapshot, +) => { + const getAllProjectsUseCase = inject(GetAllProjectsUseCase); + const createProgramFiltersUseCase = inject(CreateProgramFiltersUseCase); + const programId = route.parent?.params["programId"]; + const router = inject(Router); + const qp = route.queryParams; + const filters: Record = {}; + Object.keys(qp).forEach(k => { + if (qp[k] !== undefined && qp[k] !== "" && k !== "search") { + filters[k] = Array.isArray(qp[k]) ? qp[k] : [qp[k]]; + } + }); + + const params = new HttpParams({ fromObject: { offset: 0, limit: 21 } }); + const req$ = + Object.keys(filters).length > 0 + ? createProgramFiltersUseCase.execute(programId, filters, params) + : getAllProjectsUseCase.execute(programId, params); + return req$ + .pipe( + map(result => (result.ok ? result.value : { count: 0, results: [], next: "", previous: "" })), + ) + .pipe( + catchError(error => { + if (error.status === 403) { + router.navigate([], { + queryParams: { access: "accessDenied" }, + queryParamsHandling: "merge", + replaceUrl: true, + }); + + return EMPTY; + } + + return of>({ count: 0, results: [], next: "", previous: "" }); + }), + ); +}; diff --git a/projects/social_platform/src/app/office/features/project-rating/components/boolean-criterion/boolean-criterion.component.html b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/components/boolean-criterion/boolean-criterion.component.html similarity index 100% rename from projects/social_platform/src/app/office/features/project-rating/components/boolean-criterion/boolean-criterion.component.html rename to projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/components/boolean-criterion/boolean-criterion.component.html diff --git a/projects/social_platform/src/app/office/features/project-rating/components/boolean-criterion/boolean-criterion.component.scss b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/components/boolean-criterion/boolean-criterion.component.scss similarity index 100% rename from projects/social_platform/src/app/office/features/project-rating/components/boolean-criterion/boolean-criterion.component.scss rename to projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/components/boolean-criterion/boolean-criterion.component.scss diff --git a/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/components/boolean-criterion/boolean-criterion.component.ts b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/components/boolean-criterion/boolean-criterion.component.ts new file mode 100644 index 000000000..67df32583 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/components/boolean-criterion/boolean-criterion.component.ts @@ -0,0 +1,56 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, forwardRef, Input } from "@angular/core"; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; +import { IconComponent } from "@ui/primitives"; +import { noop } from "rxjs"; + +/** Чекбокс для булевых критериев оценки с интеграцией через ControlValueAccessor. */ +@Component({ + selector: "app-boolean-criterion", + templateUrl: "./boolean-criterion.component.html", + styleUrl: "./boolean-criterion.component.scss", + imports: [IconComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => BooleanCriterionComponent), + multi: true, + }, + ], +}) +export class BooleanCriterionComponent implements ControlValueAccessor { + @Input() disabled = false; + + isChecked = false; + onChange: (val: boolean) => void = noop; + onTouched: () => void = noop; + + writeValue(val: boolean): void { + this.isChecked = val; + } + + registerOnChange(fn: (v: boolean) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState?(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + onChanged(event: Event) { + const target = event.target as HTMLInputElement; + this.isChecked = target && target.checked; + this.onChange(this.isChecked); + this.onTouched(); + } + + onClickLog() { + this.isChecked = !this.isChecked; + } +} diff --git a/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/components/range-criterion-input/range-criterion-input.component.html b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/components/range-criterion-input/range-criterion-input.component.html new file mode 100644 index 000000000..2951da7d9 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/components/range-criterion-input/range-criterion-input.component.html @@ -0,0 +1,21 @@ + + +
    + +  / + {{ max() }} +
    diff --git a/projects/social_platform/src/app/office/features/project-rating/components/range-criterion-input/range-criterion-input.component.scss b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/components/range-criterion-input/range-criterion-input.component.scss similarity index 100% rename from projects/social_platform/src/app/office/features/project-rating/components/range-criterion-input/range-criterion-input.component.scss rename to projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/components/range-criterion-input/range-criterion-input.component.scss diff --git a/projects/social_platform/src/app/office/features/project-rating/components/range-criterion-input/range-criterion-input.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/components/range-criterion-input/range-criterion-input.component.spec.ts similarity index 87% rename from projects/social_platform/src/app/office/features/project-rating/components/range-criterion-input/range-criterion-input.component.spec.ts rename to projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/components/range-criterion-input/range-criterion-input.component.spec.ts index 0b9b49d5c..814830a66 100644 --- a/projects/social_platform/src/app/office/features/project-rating/components/range-criterion-input/range-criterion-input.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/components/range-criterion-input/range-criterion-input.component.spec.ts @@ -3,6 +3,8 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { FormsModule } from "@angular/forms"; import { RangeCriterionInputComponent } from "./range-criterion-input.component"; +import { provideRouter } from "@angular/router"; +import { provideNgxMask } from "ngx-mask"; describe("RangeCriterionInputComponent", () => { let component: RangeCriterionInputComponent; @@ -11,6 +13,7 @@ describe("RangeCriterionInputComponent", () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [FormsModule, RangeCriterionInputComponent], + providers: [provideRouter([]), provideNgxMask()], }).compileComponents(); }); @@ -34,7 +37,7 @@ describe("RangeCriterionInputComponent", () => { }); it("should call onChange function on input", () => { - spyOn(component, "onChange"); + vi.spyOn(component, "onChange"); const testValue = 1; const input = fixture.nativeElement.querySelector("input"); input.value = testValue; @@ -43,7 +46,7 @@ describe("RangeCriterionInputComponent", () => { }); it("should call onTouch function on blur", () => { - spyOn(component, "onTouch"); + vi.spyOn(component, "onTouch"); const input = fixture.nativeElement.querySelector("input"); input.dispatchEvent(new Event("blur")); expect(component.onTouch).toHaveBeenCalled(); @@ -52,7 +55,7 @@ describe("RangeCriterionInputComponent", () => { it("should set the error class when error input is true", () => { const field = fixture.nativeElement.querySelector(".field"); expect(field.classList).not.toContain("field--error"); - component.error = true; + fixture.componentRef.setInput("error", true); fixture.detectChanges(); expect(field.classList).toContain("field--error"); }); diff --git a/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/components/range-criterion-input/range-criterion-input.component.ts b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/components/range-criterion-input/range-criterion-input.component.ts new file mode 100644 index 000000000..6d2772be4 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/components/range-criterion-input/range-criterion-input.component.ts @@ -0,0 +1,99 @@ +/** @format */ + +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + forwardRef, + input, + Input, +} from "@angular/core"; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; + +/** Поле ввода числовых критериев с ограничением диапазона и ControlValueAccessor. */ +@Component({ + selector: "app-range-criterion-input", + templateUrl: "./range-criterion-input.component.html", + styleUrl: "./range-criterion-input.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => RangeCriterionInputComponent), + multi: true, + }, + ], + standalone: true, +}) +export class RangeCriterionInputComponent implements ControlValueAccessor { + readonly max = input(10); + readonly error = input(false); + + value!: number | null; + + constructor(private readonly cdref: ChangeDetectorRef) {} + + onInput(event: Event): void { + const target = event.currentTarget as HTMLInputElement; + const value = target.value ? Number.parseInt(target.value) : null; + + this.value = value; + this.onChange(value); + } + + onPaste(event: ClipboardEvent): void { + const pasteData = event.clipboardData?.getData("text/plain"); + if (pasteData && pasteData.match(/[^0-9]/)) { + event.preventDefault(); + } + } + + onKeydown(event: KeyboardEvent): void { + if (["e", "E", "+", "-"].some(char => event.key === char)) { + event.preventDefault(); + } + } + + onBlur(): void { + if (this.value) { + const val = Math.min(this.value, this.max()); + + this.value = val; + this.onChange(val); + } + this.onTouch(); + } + + // Методы ControlValueAccessor + writeValue(value: number): void { + setTimeout(() => { + this.value = value; + this.cdref.detectChanges(); + }); + } + + onChange: (value: number | null) => void = () => {}; + + registerOnChange(fn: (v: number | null) => void): void { + this.onChange = fn; + } + + onTouch: () => void = () => {}; + + registerOnTouched(fn: () => void): void { + this.onTouch = fn; + } + + disabled = false; + + setDisabledState(isDisabled: boolean) { + this.disabled = isDisabled; + } + + moveCursorToEnd(event: FocusEvent) { + const input = event.target as HTMLInputElement; + input.type = "text"; + input.selectionStart = input.selectionEnd = input.value.length; + input.type = "number"; + } +} diff --git a/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/project-rating.component.html b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/project-rating.component.html new file mode 100644 index 000000000..4fecf2165 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/project-rating.component.html @@ -0,0 +1,51 @@ + +
    +
    + @for (criterion of criteria; track $index) { + @if (criterion.type === "int") { +
    + + +
    + } + @if (criterion.type === "bool") { +
    + + +
    + } + } +
    +
    + @for (criterion of criteria; track $index) { + @if (criterion.type === "str") { +
    + + + @if (form.get(criterion.id.toString())?.errors?.["maxlength"]) { +
    + {{ errorMessage.VALIDATION_TOO_LONG }} + максимум + {{ form.get(criterion.id.toString())?.errors?.["maxlength"]?.["requiredLength"] }} + символов +
    + } +
    + } + } +
    +
    diff --git a/projects/social_platform/src/app/office/features/project-rating/project-rating.component.scss b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/project-rating.component.scss similarity index 100% rename from projects/social_platform/src/app/office/features/project-rating/project-rating.component.scss rename to projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/project-rating.component.scss diff --git a/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/project-rating.component.ts b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/project-rating.component.ts new file mode 100644 index 000000000..576aafeca --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/project-rating/project-rating.component.ts @@ -0,0 +1,166 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + forwardRef, + input, + Input, + OnDestroy, + signal, +} from "@angular/core"; +import { + ControlValueAccessor, + FormControl, + FormGroup, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ReactiveFormsModule, + Validator, + Validators, + ValidationErrors, + AbstractControl, +} from "@angular/forms"; +import { TextareaComponent } from "@ui/primitives/textarea/textarea.component"; +import { noop, Subscription } from "rxjs"; +import { BooleanCriterionComponent } from "./components/boolean-criterion/boolean-criterion.component"; +import { RangeCriterionInputComponent } from "./components/range-criterion-input/range-criterion-input.component"; +import { ControlErrorPipe } from "@corelib"; +import { ProjectRatingCriterion } from "@domain/project/project-rating-criterion"; +import { ErrorMessage } from "@core/lib/models/error/error-message"; + +/** Оценка проекта по числовым, булевым и текстовым критериям через ControlValueAccessor. */ +@Component({ + selector: "app-project-rating", + imports: [ + CommonModule, + TextareaComponent, + RangeCriterionInputComponent, + BooleanCriterionComponent, + ReactiveFormsModule, + ], + templateUrl: "./project-rating.component.html", + styleUrl: "./project-rating.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ProjectRatingComponent), + multi: true, + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => ProjectRatingComponent), + multi: true, + }, + ], +}) +export class ProjectRatingComponent implements OnDestroy, ControlValueAccessor, Validator { + @Input({ required: true }) + set criteria(val: ProjectRatingCriterion[]) { + if (!val) return; + this._criteria.set(val); + this.createFormControls(val); + this.trackFormValueChange(); + } + + get criteria(): ProjectRatingCriterion[] { + return this._criteria(); + } + + @Input() + set disabled(value: boolean) { + this._disabled = value; + if (this.form) { + value ? this.form.disable() : this.form.enable(); + } + } + + get disabled(): boolean { + return this._disabled; + } + + private _disabled = false; + + readonly currentUserId = input(); + + /** Сигнал для хранения критериев оценки */ + _criteria = signal([]); + + /** Форма для управления всеми критериями оценки */ + form!: FormGroup; + + errorMessage = ErrorMessage; + + /** Creates FormControls per criterion type. */ + controlCreators: Record FormControl> = { + // Числовой критерий - обязательное поле + int: val => new FormControl(val, [Validators.required]), + // Булевый критерий - преобразование строки в boolean + bool: val => new FormControl(val ? JSON.parse((val as string).toLowerCase()) : false), + // Строковый критерий - без валидации (комментарии опциональны) + str: val => new FormControl(val, Validators.maxLength(50)), + }; + + /** Сигнал для хранения подписок */ + subscriptions$ = signal([]); + + onChange: (val: unknown) => void = noop; + onTouched: () => void = noop; + + writeValue(val: typeof this.form.value): void { + if (val) { + this.form.patchValue(val); + } + } + + registerOnChange(fn: (v: unknown) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + validate(_: AbstractControl): ValidationErrors | null { + let output: ValidationErrors | null = null; + + if (this.form.invalid) { + // Проверка каждого контрола на наличие ошибок\ + Object.values(this.form.controls).forEach(control => { + if (control.errors !== null) { + output = { required: ErrorMessage.VALIDATION_UNFILLED_CRITERIA }; + } + }); + } + return output; + } + + ngOnDestroy(): void { + this.subscriptions$().forEach(subscription => subscription.unsubscribe()); + } + + private createFormControls(criteria: ProjectRatingCriterion[]): void { + const formGroupControls: Record = {}; + + criteria.forEach(criterion => { + const controlCreator = this.controlCreators[criterion.type]; + formGroupControls[criterion.id] = controlCreator(criterion.value); + }); + + this.form = new FormGroup(formGroupControls); + + if (this.disabled) { + this.form.disable(); + } + } + + private trackFormValueChange(): void { + const trackChanged$ = this.form.valueChanges.subscribe(val => { + this.onChange(val); + }); + + this.subscriptions$().push(trackChanged$); + } +} diff --git a/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/rating-card.component.html b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/rating-card.component.html new file mode 100644 index 000000000..d4d645ff5 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/rating-card.component.html @@ -0,0 +1,164 @@ + + +@if (project) { +
    +
    +
    +
    + + +
    + +

    {{ project.name | truncate: 15 }}

    +
    + + @if (industryRepositoryGetOne(project.industry); as industry) { + + {{ industry.name }} + + } +
    +
    + + @if (project.presentationAddress) { + + + презентация + + + + } +
    + +
    +
    +

    о проекте

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

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

    {{ ratedCount() }} / {{ project.maxRates }}

    + +
    +
    + +
    +
    +
    +

    оценка проекта

    + +
    + + @if (form() | controlError: "required"; as error) { +
    + {{ error }} +
    + } +
    + + @if (showRatingForm() || showRatedStatus() || showConfirmedState()) { +
    + + {{ rateButtonText() }} + + + @if (showEditButton()) { + + + + } +
    + } + + +
    +
    +

    подтвердите оценку

    + +
    + +

    {{ project.name }}

    +
    +
    + +
    + @if (showConfirmRateModal()) { + + } +
    + +
    + + подтверждаю + + + + изменить оценку + +
    +
    +
    +
    +
    +} diff --git a/projects/social_platform/src/app/office/program/shared/rating-card/rating-card.component.scss b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/rating-card.component.scss similarity index 100% rename from projects/social_platform/src/app/office/program/shared/rating-card/rating-card.component.scss rename to projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/rating-card.component.scss diff --git a/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/rating-card.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/rating-card.component.spec.ts new file mode 100644 index 000000000..5da3b4fb6 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/rating-card.component.spec.ts @@ -0,0 +1,62 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { RatingCardComponent } from "./rating-card.component"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { provideRouter } from "@angular/router"; +import { of } from "rxjs"; +import { signal } from "@angular/core"; +import { ProjectRatingRepositoryPort } from "@domain/project/ports/project-rating.repository.port"; +import { ProgramDetailMainUIInfoService } from "@api/program/facades/detail/ui/program-detail-main-ui-info.service"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { IndustryRepositoryPort } from "@domain/industry/ports/industry.repository.port"; +import { ProjectSubscriptionRepositoryPort } from "@domain/project/ports/project-subscription.repository.port"; + +describe("RatingCardComponent", () => { + let component: RatingCardComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const projectRatingSpy = { getAll: vi.fn(), postFilters: vi.fn(), rate: vi.fn() }; + + const programDetailMainSpy = { program: of({}) }; + + const authPortSpy = { + fetchProfile: of({}), + fetchUserRoles: of([]), + fetchChangeableRoles: of([]), + }; + + const industrySpy = { + industries: signal([]), + getAll: () => of([]), + getOne: () => undefined, + }; + + await TestBed.configureTestingModule({ + imports: [RatingCardComponent, HttpClientTestingModule], + providers: [ + { provide: ProjectRatingRepositoryPort, useValue: projectRatingSpy }, + { provide: ProgramDetailMainUIInfoService, useValue: programDetailMainSpy }, + { provide: AuthRepositoryPort, useValue: authPortSpy }, + { provide: IndustryRepositoryPort, useValue: industrySpy }, + { + provide: ProjectSubscriptionRepositoryPort, + useValue: { getSubscriptions: of({ results: [], count: 0 }) }, + }, + provideRouter([]), + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(RatingCardComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/rating-card.component.ts b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/rating-card.component.ts new file mode 100644 index 000000000..ea841c2f8 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/rating-card.component.ts @@ -0,0 +1,145 @@ +/** @format */ + +import { + AfterViewInit, + Component, + ElementRef, + Input, + ViewChild, + inject, + ChangeDetectionStrategy, + viewChild, +} from "@angular/core"; +import { ButtonComponent, IconComponent } from "@ui/primitives"; +import { AvatarComponent } from "@ui/primitives/avatar/avatar.component"; +import { CommonModule } from "@angular/common"; +import { BreakpointObserver } from "@angular/cdk/layout"; +import { ProjectRatingComponent } from "./project-rating/project-rating.component"; +import { FormControl, ReactiveFormsModule } from "@angular/forms"; +import { RouterLink } from "@angular/router"; +import { TagComponent } from "@ui/primitives/tag/tag.component"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { TruncatePipe, ControlErrorPipe, ParseBreaksPipe, ParseLinksPipe } from "@corelib"; +import { ProjectRate } from "@domain/project/project-rate"; +import { AppRoutes } from "@api/paths/app-routes"; +import { IndustryRepositoryPort } from "@domain/industry/ports/industry.repository.port"; +import { RatingCardService } from "./services/rating-card.service"; +import { ExpandService } from "@api/expand/expand.service"; +import { map } from "rxjs"; + +/** Карточка оценки проекта экспертом: форма с критериями, навигация, переоценка. */ +@Component({ + selector: "app-rating-card", + templateUrl: "./rating-card.component.html", + styleUrl: "./rating-card.component.scss", + imports: [ + CommonModule, + ReactiveFormsModule, + AvatarComponent, + IconComponent, + ButtonComponent, + ParseLinksPipe, + ParseBreaksPipe, + ProjectRatingComponent, + ControlErrorPipe, + RouterLink, + TagComponent, + ModalComponent, + TruncatePipe, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [RatingCardService, ExpandService], +}) +export class RatingCardComponent implements AfterViewInit { + private readonly ratingCardService = inject(RatingCardService); + private readonly expandService = inject(ExpandService); + private readonly breakpointObserver = inject(BreakpointObserver); + + protected readonly AppRoutes = AppRoutes; + private readonly industryRepository = inject(IndustryRepositoryPort); + + private readonly descEl = viewChild("descEl"); + + @Input({ required: true }) set project(proj: ProjectRate | null) { + this.ratingCardService.initProject(proj); + } + + get project(): ProjectRate | null { + return this.ratingCardService.project(); + } + + protected readonly desktopMode$ = this.breakpointObserver + .observe("(min-width: 1000px)") + .pipe(map(result => result.matches)); + + protected readonly descriptionExpandable = this.expandService.descriptionExpandable; + protected readonly readFullDescription = this.expandService.readFullDescription; + + protected readonly ratedCount = this.ratingCardService.ratedCount; + protected readonly isProjectCriterias = this.ratingCardService.isProjectCriterias; + protected readonly form = this.ratingCardService.form; + protected readonly profile = this.ratingCardService.profile; + protected readonly projectRated = this.ratingCardService.projectRated; + protected readonly projectConfirmed = this.ratingCardService.projectConfirmed; + protected readonly isRatedByCurrentUser = this.ratingCardService.isRatedByCurrentUser; + protected readonly programDateFinished = this.ratingCardService.programDateFinished; + protected readonly showRatingForm = this.ratingCardService.showRatingForm; + protected readonly showRatedStatus = this.ratingCardService.showRatedStatus; + protected readonly showConfirmedState = this.ratingCardService.showConfirmedState; + protected readonly submitLoading = this.ratingCardService.submitLoading; + protected readonly confirmLoading = this.ratingCardService.confirmLoading; + protected readonly isButtonDisabled = this.ratingCardService.isButtonDisabled; + protected readonly buttonOpacity = this.ratingCardService.buttonOpacity; + protected readonly buttonColor = this.ratingCardService.buttonColor; + protected readonly buttonTooltip = this.ratingCardService.buttonTooltip; + protected readonly rateButtonText = this.ratingCardService.rateButtonText; + protected readonly showEditButton = this.ratingCardService.showEditButton; + protected readonly showConfirmRateModal = this.ratingCardService.showConfirmRateModal; + + protected readonly industryRepositoryGetOne = (id: number) => this.industryRepository.getOne(id); + + ngAfterViewInit(): void { + setTimeout(() => { + this.expandService.checkExpandable("description", true, this.descEl()); + }); + } + + protected onExpandDescription(elem: HTMLElement): void { + this.expandService.onExpand( + "description", + elem, + "expanded", + this.expandService.readFullDescription(), + ); + } + + protected openPresentation(url: string): void { + if (url) { + window.open(url, "_blank"); + } + } + + protected handleRateButtonClick(): void { + if (this.ratingCardService.canOpenModal()) { + this.ratingCardService.showConfirmRateModal.set(true); + } + } + + protected confirmRateProject(): void { + this.ratingCardService.form().markAsTouched(); + if (this.ratingCardService.form().invalid) return; + this.ratingCardService.confirmRateProject(); + } + + protected redoRating(): void { + this.ratingCardService.redoRating(); + } + + protected toggleConfirmRateModal(): void { + this.ratingCardService.showConfirmRateModal.set(!this.ratingCardService.showConfirmRateModal()); + } + + protected closeConfirmRateModal(): void { + this.ratingCardService.showConfirmRateModal.set(false); + } +} diff --git a/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/services/rating-card.service.ts b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/services/rating-card.service.ts new file mode 100644 index 000000000..9a0ef5a46 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/list/rating-card/services/rating-card.service.ts @@ -0,0 +1,194 @@ +/** @format */ + +import { computed, inject, Injectable, signal } from "@angular/core"; +import { RateProjectUseCase } from "@api/program/use-cases/rate-project.use-case"; +import { ProgramDetailMainUIInfoService } from "@api/program/facades/detail/ui/program-detail-main-ui-info.service"; +import { ProfileInfoService } from "@api/profile/facades/profile-info.service"; +import { ProjectRate } from "@domain/project/project-rate"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; +import { HttpResponse } from "@angular/common/http"; +import { finalize } from "rxjs"; +import { FormControl } from "@angular/forms"; + +/** Сервис бизнес-логики карточки оценки проекта экспертом. */ +@Injectable() +export class RatingCardService { + private readonly rateProjectUseCase = inject(RateProjectUseCase); + private readonly programDetailMainUIInfoService = inject(ProgramDetailMainUIInfoService); + private readonly profileInfoService = inject(ProfileInfoService); + private readonly logger = inject(LoggerService); + + readonly profile = this.profileInfoService.profile; + readonly programDateFinished = this.programDetailMainUIInfoService.registerDateExpired; + readonly program = this.programDetailMainUIInfoService.program; + + readonly project = signal(null); + readonly form = signal(new FormControl()); + + readonly submitLoading = signal(false); + readonly confirmLoading = signal(false); + readonly showConfirmRateModal = signal(false); + readonly locallyRatedByCurrentUser = signal(false); + readonly projectRated = signal(false); + readonly projectConfirmed = signal(false); + readonly ratedCount = signal(0); + + readonly isProjectCriterias = computed(() => { + const p = this.project(); + if (!p) return 0; + return p.criterias.filter(c => c.type !== "str").length; + }); + + readonly isCurrentUserExpert = computed(() => { + const currentProfile = this.profile(); + const p = this.project(); + if (!currentProfile || !p) return false; + + const isExpertFromBackend = !!p.scoredExpertId && p.scoredExpertId === currentProfile.id; + + return isExpertFromBackend || this.locallyRatedByCurrentUser(); + }); + + readonly isRatedByCurrentUser = computed(() => { + const currentUser = this.profile(); + const p = this.project(); + if (!currentUser || !p) return false; + + return p.ratedExperts.some(user => user.id === currentUser.id); + }); + + readonly userRatedThisProject = computed(() => { + return ( + this.locallyRatedByCurrentUser() || + (this.project()?.ratedExperts && this.isRatedByCurrentUser()) + ); + }); + + readonly isLimitReached = computed(() => { + const p = this.project(); + return !!p && p.ratedCount >= p.maxRates; + }); + + readonly canEdit = computed(() => !this.programDateFinished()); + + readonly canRate = computed(() => { + if (this.programDateFinished()) return false; + if (this.isLimitReached() && !this.userRatedThisProject()) return false; + return true; + }); + + readonly canOpenModal = computed(() => { + if (this.projectConfirmed() && this.userRatedThisProject()) return false; + return this.canRate(); + }); + + readonly rateButtonText = computed(() => { + if (this.programDateFinished()) return "программа завершена"; + if (this.projectConfirmed() && this.userRatedThisProject()) return "проект оценен"; + if (this.isLimitReached() && !this.userRatedThisProject()) return "лимит оценок достигнут"; + return "оценить проект"; + }); + + readonly showRatingForm = computed(() => !this.projectRated() && this.canEdit()); + + readonly showRatedStatus = computed(() => this.projectRated() || this.projectConfirmed()); + + readonly showEditButton = computed( + () => this.projectConfirmed() && !this.programDateFinished() && this.userRatedThisProject(), + ); + + readonly isButtonDisabled = computed(() => { + if (this.isLimitReached() && !this.userRatedThisProject()) return true; + if (this.programDateFinished()) return true; + return !this.canRate(); + }); + + readonly buttonColor = computed<"green" | "primary">(() => + this.userRatedThisProject() ? "green" : "primary", + ); + + readonly buttonOpacity = computed(() => (this.isButtonDisabled() ? "0.5" : "1")); + + readonly showConfirmedState = computed( + () => + (this.projectConfirmed() && !this.canEdit()) || + (this.isLimitReached() && !this.userRatedThisProject()), + ); + + readonly buttonTooltip = computed(() => { + if (this.programDateFinished()) return "Программа завершена"; + if (this.isLimitReached() && !this.userRatedThisProject()) + return "Достигнут максимальный лимит оценок"; + if (this.userRatedThisProject()) return "Нажмите для переоценки"; + return "Нажмите для оценки проекта"; + }); + + readonly isModalFormDisabled = computed(() => true); + + /** Инициализация начального состояния проекта. */ + initProject(project: ProjectRate | null): void { + if (!project) return; + this.project.set(project); + const isScored = project.scored || false; + this.projectConfirmed.set(isScored); + this.projectRated.set(isScored); + this.ratedCount.set(project.ratedCount); + } + + /** Подтверждение оценки проекта. */ + confirmRateProject(): void { + const fv = this.form().getRawValue(); + const p = this.project() as ProjectRate; + + this.submitLoading.set(true); + + this.rateProjectUseCase + .execute(p.id, p.criterias, fv) + .pipe(finalize(() => this.submitLoading.set(false))) + .subscribe({ + next: result => { + if (!result.ok) { + if (result.error.cause instanceof HttpResponse) { + if (result.error.cause.status === 400) { + this.logger.error("Ошибка: достигнут максимальный лимит оценок"); + } + } + return; + } + + const profile = this.profile(); + const proj = this.project() as ProjectRate; + + this.locallyRatedByCurrentUser.set(true); + this.projectRated.set(true); + this.projectConfirmed.set(true); + + let isFirstTimeRating = false; + + if (profile) { + if (!Array.isArray(proj.ratedExperts)) { + proj.ratedExperts = []; + } + + if (!proj.ratedExperts.some(user => user.id === profile.id)) { + proj.ratedExperts = [...proj.ratedExperts, profile]; + isFirstTimeRating = true; + } + } + + if (isFirstTimeRating) { + this.ratedCount.update(count => count + 1); + } + + this.project.set({ ...proj }); + this.showConfirmRateModal.set(false); + }, + }); + } + + /** Сброс статусов для переоценки. */ + redoRating(): void { + this.projectRated.set(false); + this.projectConfirmed.set(false); + } +} diff --git a/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.html b/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.html new file mode 100644 index 000000000..79012d263 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.html @@ -0,0 +1,189 @@ + + +@if (program()) { +
    +
    + + @if (!program()!.isUserMember && !program()!.isUserManager) { +
    + +
    + } @else { +
    + @if (appWidth > 1000) { +
    + +
    + } + +
    +
    +
    +

    о программе

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

    + @if (descriptionExpandable()) { +
    + {{ readFullDescription() ? "cкрыть" : "подробнее" }} +
    + } +
    + } +
    +
    + @if (program()!.isUserManager) { + + } + @for (n of news(); track n.id) { + + } +
    +
    + + @if (appWidth > 1000) { +
    + @if (program()!.isUserMember && program()!.links?.length && program()!.links) { + + } + @if (program()!.isUserMember && program()!.materials?.length) { + + } +
    + } +
    + } +
    +
    + + +
    +
    + +

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

    +
    + + @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/ui/pages/program/detail/main/main.component.scss similarity index 100% rename from projects/social_platform/src/app/office/program/detail/main/main.component.scss rename to projects/social_platform/src/app/ui/pages/program/detail/main/main.component.scss diff --git a/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.spec.ts new file mode 100644 index 000000000..b10ba24c6 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.spec.ts @@ -0,0 +1,93 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ProgramDetailMainComponent } from "./main.component"; +import { provideRouter } from "@angular/router"; +import { signal } from "@angular/core"; +import { of } from "rxjs"; +import { ProgramDetailMainService } from "@api/program/facades/detail/program-detail-main-info.service"; +import { ProgramDetailMainUIInfoService } from "@api/program/facades/detail/ui/program-detail-main-ui-info.service"; +import { ExpandService } from "@api/expand/expand.service"; +import { NewsInfoService } from "@api/news/news-info.service"; +import { ProjectAdditionalService } from "@api/project/facades/edit/project-additional.service"; + +describe("MainComponent", () => { + let component: ProgramDetailMainComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const programDetailMainServiceSpy = { + initializationProgramDetailMain: vi.fn(), + initScroll: vi.fn(), + destroy: vi.fn(), + onAddNews: vi.fn().mockReturnValue(of({ ok: true })), + onDelete: vi.fn(), + onLike: vi.fn(), + onEdit: vi.fn().mockReturnValue(of({ ok: true })), + closeModal: vi.fn(), + }; + + const programDetailMainUIInfoServiceSpy = { + program: signal(undefined), + showProgramModal: signal(false), + showProgramModalErrorMessage: signal(""), + registeredProgramModal: signal(false), + contactLinks: signal([]), + materialLinks: signal([]), + }; + + const expandServiceSpy = { + descriptionExpandable: signal(false), + readFullDescription: signal(""), + onExpand: vi.fn(), + }; + + const newsInfoServiceSpy = { + news: signal([]), + }; + + const projectAdditionalServiceSpy = { + isSend$: signal(null), + errorAssignProjectToProgramModalMessage: signal(""), + clearAssignProjectToProgramError: vi.fn(), + }; + + await TestBed.configureTestingModule({ + imports: [ProgramDetailMainComponent], + providers: [provideRouter([])], + }) + .overrideComponent(ProgramDetailMainComponent, { + remove: { + providers: [ + ProgramDetailMainService, + ExpandService, + NewsInfoService, + ProjectAdditionalService, + ], + }, + add: { + providers: [ + { provide: ProgramDetailMainService, useValue: programDetailMainServiceSpy }, + { + provide: ProgramDetailMainUIInfoService, + useValue: programDetailMainUIInfoServiceSpy, + }, + { provide: ExpandService, useValue: expandServiceSpy }, + { provide: NewsInfoService, useValue: newsInfoServiceSpy }, + { provide: ProjectAdditionalService, useValue: projectAdditionalServiceSpy }, + ], + }, + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ProgramDetailMainComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.ts b/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.ts new file mode 100644 index 000000000..9fd58b7c6 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.ts @@ -0,0 +1,161 @@ +/** @format */ +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + ElementRef, + HostListener, + inject, + OnDestroy, + OnInit, + signal, + viewChild, +} from "@angular/core"; +import { isFailure } from "@domain/shared/async-state"; +import { RouterModule } from "@angular/router"; +import { ButtonComponent, IconComponent } from "@ui/primitives"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { MatProgressBarModule } from "@angular/material/progress-bar"; +import { SoonCardComponent } from "@ui/primitives/soon-card/soon-card.component"; +import { NewsFormComponent } from "@ui/widgets/news-form/news-form.component"; +import { NewsCardComponent } from "@ui/widgets/news-card/news-card.component"; +import { ParseBreaksPipe, ParseLinksPipe } from "@corelib"; +import { FeedNews } from "@domain/news/project-news.model"; +import { ProgramDetailMainUIInfoService } from "@api/program/facades/detail/ui/program-detail-main-ui-info.service"; +import { ProgramDetailMainService } from "@api/program/facades/detail/program-detail-main-info.service"; +import { ExpandService } from "@api/expand/expand.service"; +import { NewsInfoService } from "@api/news/news-info.service"; +import { ProjectAdditionalService } from "@api/project/facades/edit/project-additional.service"; +import { AppRoutes } from "@api/paths/app-routes"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { finalize } from "rxjs"; +import { ProgramLinksComponent } from "@ui/widgets/program-links/program-links.component"; + +/** Страница основной вкладки программы с описанием, ссылками и новостями. */ +@Component({ + selector: "app-main", + templateUrl: "./main.component.html", + styleUrl: "./main.component.scss", + imports: [ + IconComponent, + ButtonComponent, + ParseBreaksPipe, + ParseLinksPipe, + ModalComponent, + MatProgressBarModule, + SoonCardComponent, + NewsFormComponent, + ModalComponent, + MatProgressBarModule, + RouterModule, + NewsCardComponent, + ProgramLinksComponent, + ], + providers: [ProgramDetailMainService, ExpandService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProgramDetailMainComponent implements OnInit, OnDestroy { + readonly newsFormComponent = viewChild(NewsFormComponent); + readonly ProgramNewsCardComponent = viewChild(NewsCardComponent); + readonly descEl = viewChild("descEl"); + + private readonly projectAdditionalService = inject(ProjectAdditionalService); + private readonly programDetailMainService = inject(ProgramDetailMainService); + private readonly programDetailMainUIInfoService = inject(ProgramDetailMainUIInfoService); + private readonly newsInfoService = inject(NewsInfoService); + private readonly expandService = inject(ExpandService); + private readonly destroyRef = inject(DestroyRef); + + protected readonly isAssignProjectToProgramError = computed(() => + isFailure(this.projectAdditionalService.isSend$()), + ); + + protected readonly errorAssignProjectToProgramModalMessage = + this.projectAdditionalService.errorAssignProjectToProgramModalMessage; + + protected readonly descriptionExpandable = this.expandService.descriptionExpandable; + protected readonly readFullDescription = this.expandService.readFullDescription; + + protected readonly program = this.programDetailMainUIInfoService.program; + protected readonly news = this.newsInfoService.news; + + protected readonly newsPending = signal(false); + + protected readonly AppRoutes = AppRoutes; + + protected readonly showProgramModal = this.programDetailMainUIInfoService.showProgramModal; + protected readonly showProgramModalErrorMessage = + this.programDetailMainUIInfoService.showProgramModalErrorMessage; + + protected readonly registeredProgramModal = + this.programDetailMainUIInfoService.registeredProgramModal; + + protected readonly contactLinks = this.programDetailMainUIInfoService.contactLinks; + protected readonly materialLinks = this.programDetailMainUIInfoService.materialLinks; + + protected appWidth = window.innerWidth; + + @HostListener("window:resize") + onResize() { + this.appWidth = window.innerWidth; + } + + ngOnInit(): void { + this.programDetailMainService.initializationProgramDetailMain(this.descEl()); + } + + ngAfterViewInit() { + const target = document.querySelector(".office__body") as HTMLElement; + + if (target || this.descEl) { + this.programDetailMainService.initScroll(target, this.descEl()); + } + } + + ngOnDestroy(): void { + this.programDetailMainService.destroy(); + } + + onAddNews(news: { text: string; files: string[] }): void { + this.newsPending.set(true); + this.programDetailMainService + .onAddNews(news) + .pipe( + finalize(() => this.newsPending.set(false)), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe({ + next: () => this.newsFormComponent()?.onResetForm(), + }); + } + + onDelete(newsId: number) { + this.programDetailMainService.onDelete(newsId); + } + + onLike(newsId: number) { + this.programDetailMainService.onLike(newsId); + } + + onEdit(news: FeedNews, newsId: number) { + this.programDetailMainService + .onEdit(news, newsId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => this.ProgramNewsCardComponent()?.onCloseEditMode(), + }); + } + + onExpandDescription(elem: HTMLElement, expandedClass: string, isExpanded: boolean): void { + this.expandService.onExpand("description", elem, expandedClass, isExpanded); + } + + closeModal(): void { + this.programDetailMainService.closeModal(); + } + + clearAssignProjectToProgramError(): void { + this.projectAdditionalService.clearAssignProjectToProgramError(); + } +} diff --git a/projects/social_platform/src/app/ui/pages/program/detail/register/register.component.html b/projects/social_platform/src/app/ui/pages/program/detail/register/register.component.html new file mode 100644 index 000000000..b5f036c32 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/register/register.component.html @@ -0,0 +1,24 @@ + + +
    + + @if (registerForm && schema) { +
    + @for (f of schema | keyvalue; track f.key) { +
    + + @if (registerForm.get(f.key); as field) { + + } +
    + } + Зарегистрироваться в программе +
    + } +
    diff --git a/projects/social_platform/src/app/ui/pages/program/detail/register/register.component.scss b/projects/social_platform/src/app/ui/pages/program/detail/register/register.component.scss new file mode 100644 index 000000000..089025b06 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/register/register.component.scss @@ -0,0 +1,17 @@ +.register { + &__bar { + margin-bottom: 20px; + } +} + +.form { + padding: 24px; + background-color: var(--white); + border-radius: var(--rounded-md); + + &__fieldset { + &:not(:last-child) { + margin-bottom: 20px; + } + } +} diff --git a/projects/social_platform/src/app/ui/pages/program/detail/register/register.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/register/register.component.spec.ts new file mode 100644 index 000000000..1a8ab511e --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/register/register.component.spec.ts @@ -0,0 +1,132 @@ +/** @format */ + +import { TestBed, ComponentFixture } from "@angular/core/testing"; +import { ActivatedRoute, Router } from "@angular/router"; +import { By } from "@angular/platform-browser"; +import { of } from "rxjs"; +import { provideNgxMask } from "ngx-mask"; +import { ValidationService } from "@corelib"; +import { ProgramRegisterComponent } from "./register.component"; +import { RegisterProgramUseCase } from "@api/program/use-cases/register-program.use-case"; +import { AppRoutes } from "@api/paths/app-routes"; +import { ok, fail } from "@domain/shared/result.type"; + +const SCHEMA = { + city: { name: "Город", placeholder: "Москва" }, + age: { name: "Возраст", placeholder: "18" }, +} as const; + +function makeFakeRoute(programId: string, schema: unknown = SCHEMA): ActivatedRoute { + return { + data: of({ data: schema }), + snapshot: { params: { programId } }, + } as unknown as ActivatedRoute; +} + +describe("ProgramRegisterComponent", () => { + let fixture: ComponentFixture; + let component: ProgramRegisterComponent; + let routerSpy: any; + let useCaseSpy: any; + let validationSpy: any; + + async function setup( + options: { + programId?: string; + schema?: unknown; + formValid?: boolean; + } = {}, + ): Promise { + const { programId = "42", schema = SCHEMA, formValid = true } = options; + + routerSpy = { navigateByUrl: vi.fn() }; + routerSpy.navigateByUrl.mockResolvedValue(true); + + useCaseSpy = { execute: vi.fn() }; + // По умолчанию use-case отвечает успехом. Тест, которому нужно поражение, переопределит. + useCaseSpy.execute.mockReturnValue(of(ok({} as never))); + + validationSpy = { getFormValidation: vi.fn() }; + validationSpy.getFormValidation.mockReturnValue(formValid); + + await TestBed.configureTestingModule({ + imports: [ProgramRegisterComponent], + providers: [ + { provide: Router, useValue: routerSpy }, + { provide: ActivatedRoute, useValue: makeFakeRoute(programId, schema) }, + { provide: RegisterProgramUseCase, useValue: useCaseSpy }, + { provide: ValidationService, useValue: validationSpy }, + provideNgxMask(), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ProgramRegisterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + } + + describe("ngOnInit строит форму из schema резолвера", () => { + it("создаёт FormGroup с полем под каждый ключ schema", async () => { + await setup(); + + expect(component.registerForm).toBeDefined(); + expect(component.registerForm!.contains("city")).toBe(true); + expect(component.registerForm!.contains("age")).toBe(true); + }); + + it("рендерит столько app-input, сколько полей в schema", async () => { + await setup(); + + const inputs = fixture.debugElement.queryAll(By.css("app-input")); + expect(inputs.length).toBe(Object.keys(SCHEMA).length); + }); + }); + + describe("onSubmit", () => { + it("не вызывает use-case, если форма невалидна", async () => { + await setup({ formValid: false }); + + component.onSubmit(); + + expect(useCaseSpy.execute).not.toHaveBeenCalled(); + }); + + it("вызывает use-case с programId и значениями формы", async () => { + await setup({ programId: "99" }); + + component.registerForm!.patchValue({ city: "Москва", age: "25" }); + component.onSubmit(); + + expect(useCaseSpy.execute).toHaveBeenCalledExactlyOnceWith(99, { city: "Москва", age: "25" }); + }); + + it("навигирует на детальную страницу программы при успехе use-case", async () => { + await setup({ programId: "99" }); + + component.onSubmit(); + + expect(routerSpy.navigateByUrl).toHaveBeenCalledExactlyOnceWith( + AppRoutes.program.detail("99"), + ); + }); + + it("не навигирует, если use-case вернул failure", async () => { + await setup(); + useCaseSpy.execute.mockReturnValue(of(fail({ kind: "register_program_error" as const }))); + + component.onSubmit(); + + expect(routerSpy.navigateByUrl).not.toHaveBeenCalled(); + }); + + it("срабатывает по клику на кнопку submit в DOM", async () => { + await setup({ programId: "99" }); + + const button = fixture.debugElement.query(By.css("app-button")); + button.triggerEventHandler("click", null); + + expect(useCaseSpy.execute).toHaveBeenCalledTimes(1); + expect(routerSpy.navigateByUrl).toHaveBeenCalledWith(AppRoutes.program.detail("99")); + }); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/program/detail/register/register.component.ts b/projects/social_platform/src/app/ui/pages/program/detail/register/register.component.ts new file mode 100644 index 000000000..bb2fd6b44 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/register/register.component.ts @@ -0,0 +1,83 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, OnInit, inject, DestroyRef } from "@angular/core"; +import { ActivatedRoute, Router } from "@angular/router"; +import { map } from "rxjs"; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; +import { ControlErrorPipe, ValidationService } from "@corelib"; +import { BarComponent, ButtonComponent, InputComponent } from "@ui/primitives"; +import { KeyValuePipe } from "@angular/common"; +import { RegisterProgramUseCase } from "@api/program/use-cases/register-program.use-case"; +import { ProgramDataSchema } from "@domain/program/program.model"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { AppRoutes } from "@api/paths/app-routes"; + +/** Форма регистрации пользователя в программе с динамическими полями на основе схемы. */ +@Component({ + selector: "app-register", + templateUrl: "./register.component.html", + styleUrl: "./register.component.scss", + imports: [ + ReactiveFormsModule, + InputComponent, + ButtonComponent, + KeyValuePipe, + ControlErrorPipe, + BarComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProgramRegisterComponent implements OnInit { + private readonly logger = inject(LoggerService); + private readonly destroyRef = inject(DestroyRef); + private readonly registerProgramUseCase = inject(RegisterProgramUseCase); + + constructor( + private readonly router: Router, + private readonly route: ActivatedRoute, + private readonly fb: FormBuilder, + private readonly validationService: ValidationService, + ) {} + + registerForm?: FormGroup; + + schema?: ProgramDataSchema; + + ngOnInit(): void { + this.route.data + .pipe( + map(r => r["data"]), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(schema => { + this.schema = schema; + + const group: Record = {}; + for (const cKey in schema) { + group[cKey] = ["", [Validators.required]]; + } + + this.registerForm = this.fb.group(group); + }); + } + + onSubmit(): void { + if (this.registerForm && !this.validationService.getFormValidation(this.registerForm)) { + return; + } + + this.registerProgramUseCase + .execute(Number(this.route.snapshot.params["programId"]), this.registerForm?.value ?? {}) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(result => { + if (!result.ok) { + return; + } + + this.router + .navigateByUrl(AppRoutes.program.detail(this.route.snapshot.params["programId"])) + .then(() => this.logger.debug("Route changed from ProgramRegisterComponent")); + }); + } +} diff --git a/projects/social_platform/src/app/ui/pages/program/detail/register/register.resolver.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/register/register.resolver.spec.ts new file mode 100644 index 000000000..0b857c6f3 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/register/register.resolver.spec.ts @@ -0,0 +1,29 @@ +/** @format */ + +import { TestBed } from "@angular/core/testing"; +import { ProgramRegisterResolver } from "./register.resolver"; +import { ActivatedRouteSnapshot, RouterStateSnapshot, provideRouter } from "@angular/router"; +import { of } from "rxjs"; +import { GetProgramDataSchemaUseCase } from "@api/program/use-cases/get-program-data-schema.use-case"; + +describe("ProgramRegisterResolver", () => { + const mockRoute = { params: { programId: 1 } } as unknown as ActivatedRouteSnapshot; + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideRouter([]), + { + provide: GetProgramDataSchemaUseCase, + useValue: { execute: () => of({ ok: true, value: {} }) }, + }, + ], + }); + }); + + it("should be created", () => { + const result = TestBed.runInInjectionContext(() => + ProgramRegisterResolver(mockRoute, {} as RouterStateSnapshot), + ); + expect(result).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/program/detail/register/register.resolver.ts b/projects/social_platform/src/app/ui/pages/program/detail/register/register.resolver.ts new file mode 100644 index 000000000..6b4157164 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/register/register.resolver.ts @@ -0,0 +1,18 @@ +/** @format */ + +import { inject } from "@angular/core"; +import { ActivatedRouteSnapshot, ResolveFn } from "@angular/router"; +import { ProgramDataSchema } from "@domain/program/program.model"; +import { map } from "rxjs"; +import { GetProgramDataSchemaUseCase } from "@api/program/use-cases/get-program-data-schema.use-case"; + +/** Предзагружает схему полей регистрации в программе. */ +export const ProgramRegisterResolver: ResolveFn = ( + route: ActivatedRouteSnapshot, +) => { + const getProgramDataSchemaUseCase = inject(GetProgramDataSchemaUseCase); + + return getProgramDataSchemaUseCase + .execute(route.params["programId"]) + .pipe(map(result => (result.ok ? result.value : new ProgramDataSchema()))); +}; diff --git a/projects/social_platform/src/app/ui/pages/program/main/main.component.html b/projects/social_platform/src/app/ui/pages/program/main/main.component.html new file mode 100644 index 000000000..e1e053c61 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/main/main.component.html @@ -0,0 +1,29 @@ + + +
    + @if (programs()) { +
    + @for (p of programs(); track p.id) { + + + + } +
    + } + +
    +

    фильтры

    + + + + +
    +
    diff --git a/projects/social_platform/src/app/office/program/main/main.component.scss b/projects/social_platform/src/app/ui/pages/program/main/main.component.scss similarity index 100% rename from projects/social_platform/src/app/office/program/main/main.component.scss rename to projects/social_platform/src/app/ui/pages/program/main/main.component.scss diff --git a/projects/social_platform/src/app/ui/pages/program/main/main.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/main/main.component.spec.ts new file mode 100644 index 000000000..a19ac95ec --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/main/main.component.spec.ts @@ -0,0 +1,52 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ProgramMainComponent } from "./main.component"; +import { provideRouter } from "@angular/router"; +import { signal } from "@angular/core"; +import { ProgramMainInfoService } from "@api/program/facades/program-main-info.service"; +import { ProgramMainUIInfoService } from "@api/program/facades/ui/program-main-ui-info.service"; + +describe("MainComponent", () => { + let component: ProgramMainComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const programMainInfoServiceSpy = { + initializationMainPrograms: vi.fn(), + destroy: vi.fn(), + onTogglePparticipating: vi.fn(), + }; + + const programMainUIInfoServiceSpy = { + programs: signal([]), + isPparticipating: signal(false), + programOptionsFilter: [], + }; + + await TestBed.configureTestingModule({ + imports: [ProgramMainComponent], + providers: [provideRouter([])], + }) + .overrideComponent(ProgramMainComponent, { + remove: { providers: [ProgramMainInfoService, ProgramMainUIInfoService] }, + add: { + providers: [ + { provide: ProgramMainInfoService, useValue: programMainInfoServiceSpy }, + { provide: ProgramMainUIInfoService, useValue: programMainUIInfoServiceSpy }, + ], + }, + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ProgramMainComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/program/main/main.component.ts b/projects/social_platform/src/app/ui/pages/program/main/main.component.ts new file mode 100644 index 000000000..cf33fd3e9 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/main/main.component.ts @@ -0,0 +1,46 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, inject, OnInit } from "@angular/core"; +import { RouterLink } from "@angular/router"; +import { CheckboxComponent, SelectComponent } from "@ui/primitives"; +import { ClickOutsideModule } from "ng-click-outside"; +import { ProgramCardComponent } from "./program-card/program-card.component"; +import { ProgramMainUIInfoService } from "@api/program/facades/ui/program-main-ui-info.service"; +import { ProgramMainInfoService } from "@api/program/facades/program-main-info.service"; +import { AppRoutes } from "@api/paths/app-routes"; + +/** Отображает список программ с поиском и фильтрацией. */ +@Component({ + selector: "app-main", + templateUrl: "./main.component.html", + styleUrl: "./main.component.scss", + imports: [ + RouterLink, + ProgramCardComponent, + CheckboxComponent, + SelectComponent, + ClickOutsideModule, + ], + providers: [ProgramMainInfoService, ProgramMainUIInfoService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProgramMainComponent implements OnInit { + private readonly programMainUIInfoService = inject(ProgramMainUIInfoService); + private readonly programMainInfoService = inject(ProgramMainInfoService); + + protected readonly programs = this.programMainUIInfoService.programs; + protected readonly isPparticipating = this.programMainUIInfoService.isPparticipating; + protected readonly programOptionsFilter = this.programMainUIInfoService.programOptionsFilter; + protected readonly AppRoutes = AppRoutes; + + ngOnInit(): void { + this.programMainInfoService.initializationMainPrograms(); + } + + /** + * Переключает состояние чекбокса "участвую" + */ + onTogglePparticipating(): void { + this.programMainInfoService.onTogglePparticipating(); + } +} diff --git a/projects/social_platform/src/app/ui/pages/program/main/program-card/program-card.component.html b/projects/social_platform/src/app/ui/pages/program/main/program-card/program-card.component.html new file mode 100644 index 000000000..44b2272c1 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/main/program-card/program-card.component.html @@ -0,0 +1,44 @@ + + +@if (program()) { +
    +
    + +
    + +
    +

    {{ program().name }}

    + +

    + + {{ + registerDateExpired + ? "регистрация завершена" + : program().isUserMember + ? "ты уже участвуешь!" + : "Регистрация до " + (program().datetimeRegistrationEnds | date: "dd MMMM") + }} +

    + +
    +

    + {{ program().datetimeStarted | date: "dd.MM.yyyy" }} +

    +

    -

    +

    + {{ program().datetimeFinished | date: "dd.MM.yyyy" }} +

    + +

    • для всей России

    +
    +
    +
    +} diff --git a/projects/social_platform/src/app/office/program/shared/program-card/program-card.component.scss b/projects/social_platform/src/app/ui/pages/program/main/program-card/program-card.component.scss similarity index 100% rename from projects/social_platform/src/app/office/program/shared/program-card/program-card.component.scss rename to projects/social_platform/src/app/ui/pages/program/main/program-card/program-card.component.scss diff --git a/projects/social_platform/src/app/office/program/shared/program-card/program-card.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/main/program-card/program-card.component.spec.ts similarity index 78% rename from projects/social_platform/src/app/office/program/shared/program-card/program-card.component.spec.ts rename to projects/social_platform/src/app/ui/pages/program/main/program-card/program-card.component.spec.ts index 425a106a6..9d4d92341 100644 --- a/projects/social_platform/src/app/office/program/shared/program-card/program-card.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/program/main/program-card/program-card.component.spec.ts @@ -1,7 +1,6 @@ /** @format */ import { ComponentFixture, TestBed } from "@angular/core/testing"; - import { ProgramCardComponent } from "./program-card.component"; describe("ProgramCardComponent", () => { @@ -12,11 +11,14 @@ describe("ProgramCardComponent", () => { await TestBed.configureTestingModule({ imports: [ProgramCardComponent], }).compileComponents(); - }); - beforeEach(() => { fixture = TestBed.createComponent(ProgramCardComponent); component = fixture.componentInstance; + fixture.componentRef.setInput("program", { + id: 1, + name: "Test Program", + datetimeRegistrationEnds: new Date(Date.now() + 86400000).toISOString(), + }); fixture.detectChanges(); }); diff --git a/projects/social_platform/src/app/ui/pages/program/main/program-card/program-card.component.ts b/projects/social_platform/src/app/ui/pages/program/main/program-card/program-card.component.ts new file mode 100644 index 000000000..c9f69ed0d --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/main/program-card/program-card.component.ts @@ -0,0 +1,24 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, input, Input, OnInit } from "@angular/core"; +import { AvatarComponent } from "@ui/primitives/avatar/avatar.component"; +import { DatePipe, NgClass } from "@angular/common"; +import { Program } from "@domain/program/program.model"; + +/** Карточка программы с краткой информацией для списков. */ +@Component({ + selector: "app-program-card", + templateUrl: "./program-card.component.html", + styleUrl: "./program-card.component.scss", + imports: [AvatarComponent, DatePipe, NgClass], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProgramCardComponent implements OnInit { + readonly program = input.required(); + + ngOnInit(): void { + this.registerDateExpired = Date.now() > Date.parse(this.program().datetimeRegistrationEnds); + } + + registerDateExpired?: boolean; +} diff --git a/projects/social_platform/src/app/office/program/program.component.html b/projects/social_platform/src/app/ui/pages/program/program.component.html similarity index 100% rename from projects/social_platform/src/app/office/program/program.component.html rename to projects/social_platform/src/app/ui/pages/program/program.component.html diff --git a/projects/social_platform/src/app/office/program/program.component.scss b/projects/social_platform/src/app/ui/pages/program/program.component.scss similarity index 100% rename from projects/social_platform/src/app/office/program/program.component.scss rename to projects/social_platform/src/app/ui/pages/program/program.component.scss diff --git a/projects/social_platform/src/app/office/program/program.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/program.component.spec.ts similarity index 82% rename from projects/social_platform/src/app/office/program/program.component.spec.ts rename to projects/social_platform/src/app/ui/pages/program/program.component.spec.ts index 8baa5c909..75bd9012b 100644 --- a/projects/social_platform/src/app/office/program/program.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/program/program.component.spec.ts @@ -7,8 +7,8 @@ // import { HttpClientTestingModule } from "@angular/common/http/testing"; // import { ReactiveFormsModule } from "@angular/forms"; // import { of } from "rxjs"; -// import { AuthService } from "../../auth/services"; -// import { ProjectService } from "../services/project.service"; +// import { AuthRepository } from "@infrastructure/repository/auth/auth.repository"; +// import { ProjectRepository } from "../services/project.service"; // import { User } from "../../auth/models/user.model"; // import { Project } from "../models/project.model"; // @@ -27,8 +27,8 @@ // await TestBed.configureTestingModule({ // imports: [RouterTestingModule, HttpClientTestingModule, ReactiveFormsModule], // providers: [ -// { providers: ProjectService, useValue: projectSpy }, -// { providers: AuthService, useValue: authSpy }, +// { providers: ProjectRepository, useValue: projectSpy }, +// { providers: AuthRepository, useValue: authSpy }, // ], // declarations: [ProjectsComponent], // }).compileComponents(); diff --git a/projects/social_platform/src/app/ui/pages/program/program.component.ts b/projects/social_platform/src/app/ui/pages/program/program.component.ts new file mode 100644 index 000000000..99d9174f3 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/program.component.ts @@ -0,0 +1,29 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, inject, OnInit } from "@angular/core"; +import { RouterOutlet } from "@angular/router"; +import { ReactiveFormsModule } from "@angular/forms"; +import { SearchComponent } from "@ui/primitives/search/search.component"; +import { BackComponent } from "@uilib"; +import { ProgramInfoService } from "@api/program/facades/program-info.service"; +import { ProgramMainUIInfoService } from "@api/program/facades/ui/program-main-ui-info.service"; + +/** Контейнер модуля программ с поиском и навигацией. */ +@Component({ + selector: "app-program", + templateUrl: "./program.component.html", + styleUrl: "./program.component.scss", + imports: [ReactiveFormsModule, SearchComponent, RouterOutlet, BackComponent], + providers: [ProgramInfoService, ProgramMainUIInfoService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProgramComponent implements OnInit { + private readonly programInfoService = inject(ProgramInfoService); + private readonly programMainUIInfoService = inject(ProgramMainUIInfoService); + + protected readonly searchForm = this.programMainUIInfoService.searchForm; + + ngOnInit(): void { + this.programInfoService.initializationPrograms(); + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/bar-new/bar.component.html b/projects/social_platform/src/app/ui/pages/projects/bar-new/bar.component.html new file mode 100644 index 000000000..21ef99f2f --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/bar-new/bar.component.html @@ -0,0 +1,25 @@ + + +
    + +
    diff --git a/projects/social_platform/src/app/ui/components/bar-new/bar.component.scss b/projects/social_platform/src/app/ui/pages/projects/bar-new/bar.component.scss similarity index 100% rename from projects/social_platform/src/app/ui/components/bar-new/bar.component.scss rename to projects/social_platform/src/app/ui/pages/projects/bar-new/bar.component.scss diff --git a/projects/social_platform/src/app/ui/pages/projects/bar-new/bar.component.spec.ts b/projects/social_platform/src/app/ui/pages/projects/bar-new/bar.component.spec.ts new file mode 100644 index 000000000..51ea3f8f0 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/bar-new/bar.component.spec.ts @@ -0,0 +1,24 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { BarNewComponent } from "./bar.component"; + +describe("BarNewComponent", () => { + let component: BarNewComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [BarNewComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(BarNewComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput("links", []); + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/projects/bar-new/bar.component.ts b/projects/social_platform/src/app/ui/pages/projects/bar-new/bar.component.ts new file mode 100644 index 000000000..d41eaae3a --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/bar-new/bar.component.ts @@ -0,0 +1,26 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, input, Input } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { RouterLink, RouterLinkActive } from "@angular/router"; +import { IconComponent } from "@uilib"; + +/** Горизонтальный список навигационных ссылок с индикаторами активности. */ +@Component({ + selector: "app-bar-new", + imports: [CommonModule, RouterLink, RouterLinkActive, IconComponent], + templateUrl: "./bar.component.html", + styleUrl: "./bar.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BarNewComponent { + /** Массив навигационных ссылок */ + readonly links = input.required< + { + link: string; + linkText: string; + iconName: string; + isRouterLinkActiveOptions: boolean; + }[] + >(); +} diff --git a/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboard.component.html b/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboard.component.html new file mode 100644 index 000000000..1e4d077d4 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboard.component.html @@ -0,0 +1,14 @@ + + +
    + @for (dashboardItem of dashboardItems(); track $index) { + + } +
    diff --git a/projects/social_platform/src/app/office/projects/dashboard/dashboard.component.scss b/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboard.component.scss similarity index 100% rename from projects/social_platform/src/app/office/projects/dashboard/dashboard.component.scss rename to projects/social_platform/src/app/ui/pages/projects/dashboard/dashboard.component.scss diff --git a/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboard.component.ts b/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboard.component.ts new file mode 100644 index 000000000..aa6f8960c --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboard.component.ts @@ -0,0 +1,38 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, inject, OnInit } from "@angular/core"; +import { DashboardItemComponent } from "./dashboardItem/dashboardItem.component"; +import { ProjectsDashboardInfoService } from "@api/project/facades/dashboard/projects-dashboard-info.service"; +import { ProjectsDashboardUIInfoService } from "@api/project/facades/dashboard/ui/projects-dashboard-ui-info.service"; +import { ProgramDetailListUIInfoService } from "@api/program/facades/detail/ui/program-detail-list-ui-info.service"; + +/** Дашборд проектов пользователя. */ +@Component({ + selector: "app-dashboard", + templateUrl: "./dashboard.component.html", + styleUrl: "./dashboard.component.scss", + imports: [CommonModule, DashboardItemComponent], + providers: [ + ProjectsDashboardInfoService, + ProjectsDashboardUIInfoService, + ProgramDetailListUIInfoService, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DashboardProjectsComponent implements OnInit { + private readonly projectsDashboardInfoService = inject(ProjectsDashboardInfoService); + private readonly projectsDashboardUIInfoService = inject(ProjectsDashboardUIInfoService); + private readonly programDetailListUIInfoService = inject(ProgramDetailListUIInfoService); + + protected readonly dashboardItems = this.projectsDashboardUIInfoService.dashboardItems; + protected readonly profileProjSubsIds = this.programDetailListUIInfoService.profileProjSubsIds; + + ngOnInit(): void { + this.projectsDashboardInfoService.initializationDashboardItems(); + } + + onAddProject(): void { + this.projectsDashboardInfoService.addProject(); + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboardItem/dashboardItem.component.html b/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboardItem/dashboardItem.component.html new file mode 100644 index 000000000..276f76950 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboardItem/dashboardItem.component.html @@ -0,0 +1,50 @@ + + +
    +
    +

    {{ title() }}

    + +
    + + @if (arrayItems().length) { +
      + @for (project of arrayItems(); track project.id) { + + + + } +
    + } @else { +
    + +
    + } + + + показать раздел + + +
    diff --git a/projects/social_platform/src/app/office/projects/dashboard/shared/dashboardItem/dashboardItem.component.scss b/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboardItem/dashboardItem.component.scss similarity index 100% rename from projects/social_platform/src/app/office/projects/dashboard/shared/dashboardItem/dashboardItem.component.scss rename to projects/social_platform/src/app/ui/pages/projects/dashboard/dashboardItem/dashboardItem.component.scss diff --git a/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboardItem/dashboardItem.component.ts b/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboardItem/dashboardItem.component.ts new file mode 100644 index 000000000..4d4505499 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/dashboard/dashboardItem/dashboardItem.component.ts @@ -0,0 +1,54 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + input, + Input, + OnInit, + output, + Output, +} from "@angular/core"; +import { IconComponent } from "@uilib"; +import { RouterLink } from "@angular/router"; +import { InfoCardComponent } from "@ui/widgets/info-card/info-card.component"; +import { Project } from "@domain/project/project.model"; +import { AppRoutes } from "@api/paths/app-routes"; + +/** Карточка проекта в дашборде. */ +@Component({ + selector: "app-dashboard-item", + templateUrl: "./dashboardItem.component.html", + styleUrl: "./dashboardItem.component.scss", + imports: [CommonModule, IconComponent, RouterLink, InfoCardComponent], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DashboardItemComponent implements OnInit { + readonly title = input.required(); + readonly arrayItems = input.required(); + readonly iconName = input.required(); + readonly sectionName = input.required(); + readonly profileProjSubsIds = input(); + + readonly addProjectClick = output(); + + appereance: "base" | "subs" | "my" = "base"; + protected readonly AppRoutes = AppRoutes; + + ngOnInit(): void { + switch (this.iconName()) { + case "favourities": + this.appereance = "subs"; + break; + + case "main": + this.appereance = "my"; + break; + + default: + break; + } + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/chat/chat.component.html b/projects/social_platform/src/app/ui/pages/projects/detail/chat/chat.component.html new file mode 100644 index 000000000..d33967ced --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/chat/chat.component.html @@ -0,0 +1,69 @@ + +@if (project()) { +
    +
    + @if (!messages().length) { +
    + +

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

    +
    + } + + +
    + + +
    +} diff --git a/projects/social_platform/src/app/office/projects/detail/chat/chat.component.scss b/projects/social_platform/src/app/ui/pages/projects/detail/chat/chat.component.scss similarity index 100% rename from projects/social_platform/src/app/office/projects/detail/chat/chat.component.scss rename to projects/social_platform/src/app/ui/pages/projects/detail/chat/chat.component.scss diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/chat/chat.component.spec.ts b/projects/social_platform/src/app/ui/pages/projects/detail/chat/chat.component.spec.ts new file mode 100644 index 000000000..6ed2dbc32 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/chat/chat.component.spec.ts @@ -0,0 +1,91 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { ProjectChatComponent } from "./chat.component"; +import { ReactiveFormsModule } from "@angular/forms"; +import { MessageInputComponent } from "@ui/widgets/message-input/message-input.component"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { AuthRepository } from "@infrastructure/repository/auth/auth.repository"; +import { of } from "rxjs"; +import { provideRouter } from "@angular/router"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { signal } from "@angular/core"; +import { initial } from "@domain/shared/async-state"; +import { ChatDirectInfoService } from "@api/chat/facades/chat-direct-info.service"; +import { ChatDirectUIInfoService } from "@api/chat/facades/ui/chat-direct-ui-info.service"; + +describe("ChatComponent", () => { + let component: ProjectChatComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const authSpy = { + profile: of({}), + }; + const authPortSpy = { + login: of({} as any), + logout: of(undefined), + fetchProfile: of({} as any), + fetchUserRoles: of([]), + fetchChangeableRoles: of([]), + fetchLeaderProjects: of({} as any), + }; + + const chatDirectInfoServiceSpy = { + initializationChatDirect: vi.fn(), + initializationChatFiles: vi.fn(), + destroy: vi.fn(), + onFetchMessages: vi.fn(), + onSubmitMessage: vi.fn(), + onEditMessage: vi.fn(), + onDeleteMessage: vi.fn(), + }; + + const chatDirectUIInfoServiceSpy = { + chatFiles: signal([]), + currentUserId: signal(0), + messages: signal([]), + typingPersons: signal([]), + isAsideMobileShown: signal(false), + fetching: signal(false), + onToggleMobileAside: vi.fn(), + }; + + await TestBed.configureTestingModule({ + providers: [ + provideRouter([]), + { provide: AuthRepository, useValue: authSpy }, + { provide: AuthRepositoryPort, useValue: authPortSpy }, + ], + imports: [ + ReactiveFormsModule, + HttpClientTestingModule, + ProjectChatComponent, + MessageInputComponent, + ], + }) + .overrideComponent(ProjectChatComponent, { + remove: { + providers: [ChatDirectInfoService, ChatDirectUIInfoService], + }, + add: { + providers: [ + { provide: ChatDirectInfoService, useValue: chatDirectInfoServiceSpy }, + { provide: ChatDirectUIInfoService, useValue: chatDirectUIInfoServiceSpy }, + ], + }, + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ProjectChatComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/chat/chat.component.ts b/projects/social_platform/src/app/ui/pages/projects/detail/chat/chat.component.ts new file mode 100644 index 000000000..f04114edf --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/chat/chat.component.ts @@ -0,0 +1,105 @@ +/** @format */ + +import { + ChangeDetectionStrategy, + Component, + inject, + OnDestroy, + OnInit, + viewChild, +} from "@angular/core"; +import { NavService } from "@api/shared/nav.service"; +import { RouterLink } from "@angular/router"; +import { MessageInputComponent } from "@ui/widgets/message-input/message-input.component"; +import { ChatWindowComponent } from "@ui/widgets/chat-window/chat-window.component"; +import { FileItemComponent } from "@ui/primitives/file-item/file-item.component"; +import { IconComponent } from "@ui/primitives"; +import { AvatarComponent } from "@ui/primitives/avatar/avatar.component"; +import { ProjectsDetailUIInfoService } from "@api/project/facades/detail/ui/projects-detail-ui.service"; +import { ChatDirectInfoService } from "@api/chat/facades/chat-direct-info.service"; +import { ChatDirectUIInfoService } from "@api/chat/facades/ui/chat-direct-ui-info.service"; +import { AppRoutes } from "@api/paths/app-routes"; + +/** Чат проекта с сообщениями, файлами и управлением. */ +@Component({ + selector: "app-chat", + templateUrl: "./chat.component.html", + styleUrl: "./chat.component.scss", + imports: [ + AvatarComponent, + IconComponent, + ChatWindowComponent, + RouterLink, + FileItemComponent, + MessageInputComponent, + ], + providers: [ChatDirectInfoService, ChatDirectUIInfoService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProjectChatComponent implements OnInit, OnDestroy { + readonly messageInputComponent = viewChild(MessageInputComponent); + + private readonly navService = inject(NavService); + private readonly projectsDetailUIInfoService = inject(ProjectsDetailUIInfoService); + private readonly chatDirectInfoService = inject(ChatDirectInfoService); + private readonly chatDirectUIInfoService = inject(ChatDirectUIInfoService); + + /** Данные проекта */ + readonly project = this.projectsDetailUIInfoService.project; + + /** Все файлы, загруженные в чат */ + protected readonly chatFiles = this.chatDirectUIInfoService.chatFiles; + + /** ID текущего пользователя */ + protected readonly currentUserId = this.chatDirectUIInfoService.currentUserId; + + /** Все сообщения чата */ + protected readonly messages = this.chatDirectUIInfoService.messages; + + protected readonly AppRoutes = AppRoutes; + + /** Список пользователей, которые сейчас печатают */ + protected readonly typingPersons = this.chatDirectUIInfoService.typingPersons; + + /** Флаг отображения боковой панели на мобильных устройствах */ + protected readonly isAsideMobileShown = this.chatDirectUIInfoService.isAsideMobileShown; + + /** Флаг процесса загрузки сообщений */ + protected readonly fetching = this.chatDirectUIInfoService.fetching; + + ngOnInit(): void { + this.navService.setNavTitle("Чат проекта"); + + this.chatDirectInfoService.initializationChatDirect("project"); + + this.chatDirectInfoService.initializationChatFiles(); + } + + ngOnDestroy(): void { + this.chatDirectInfoService.destroy(); + } + + onToggleMobileAside(): void { + this.chatDirectUIInfoService.onToggleMobileAside(); + } + + /** Загрузка дополнительных сообщений при прокрутке */ + onFetchMessages(): void { + this.chatDirectInfoService.onFetchMessages(); + } + + /** Отправка нового сообщения */ + onSubmitMessage(message: any): void { + this.chatDirectInfoService.onSubmitMessage(message); + } + + /** Редактирование существующего сообщения */ + onEditMessage(message: any): void { + this.chatDirectInfoService.onEditMessage(message); + } + + /** Удаление сообщения */ + onDeleteMessage(messageId: number): void { + this.chatDirectInfoService.onDeleteMessage(messageId); + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/chat/chat.resolver.spec.ts b/projects/social_platform/src/app/ui/pages/projects/detail/chat/chat.resolver.spec.ts new file mode 100644 index 000000000..d3a326d25 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/chat/chat.resolver.spec.ts @@ -0,0 +1,42 @@ +/** @format */ + +import { TestBed } from "@angular/core/testing"; +import { ProjectChatResolver } from "./chat.resolver"; +import { + ActivatedRouteSnapshot, + convertToParamMap, + RouterStateSnapshot, + provideRouter, +} from "@angular/router"; +import { of } from "rxjs"; +import { GetProjectUseCase } from "@api/project/use-cases/get-project.use-case"; +import { ProjectsDetailUIInfoService } from "@api/project/facades/detail/ui/projects-detail-ui.service"; + +describe("ProjectChatResolver", () => { + const mockRoute = { + parent: { paramMap: convertToParamMap({ projectId: 1 }) }, + } as unknown as ActivatedRouteSnapshot; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideRouter([]), + { + provide: GetProjectUseCase, + useValue: { execute: () => of({ ok: true, value: {} }) }, + }, + { + provide: ProjectsDetailUIInfoService, + useValue: { applySetProject: vi.fn() }, + }, + ], + }); + }); + + it("should be created", () => { + const result = TestBed.runInInjectionContext(() => + ProjectChatResolver(mockRoute, {} as RouterStateSnapshot), + ); + expect(result).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/chat/chat.resolver.ts b/projects/social_platform/src/app/ui/pages/projects/detail/chat/chat.resolver.ts new file mode 100644 index 000000000..2810d5ec1 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/chat/chat.resolver.ts @@ -0,0 +1,27 @@ +/** @format */ + +import { inject } from "@angular/core"; +import { ActivatedRouteSnapshot, ResolveFn } from "@angular/router"; +import { of, switchMap } from "rxjs"; +import { Project } from "@domain/project/project.model"; +import { ProjectsDetailUIInfoService } from "@api/project/facades/detail/ui/projects-detail-ui.service"; +import { GetProjectUseCase } from "@api/project/use-cases/get-project.use-case"; + +/** Предзагружает данные проекта для чата. */ +export const ProjectChatResolver: ResolveFn = (route: ActivatedRouteSnapshot) => { + const getProjectUseCase = inject(GetProjectUseCase); + const projectsDetailUIInfoService = inject(ProjectsDetailUIInfoService); + const id = Number(route.parent?.paramMap.get("projectId")); + + return getProjectUseCase.execute(id).pipe( + switchMap(result => { + if (!result.ok) { + return of(new Project()); + } + + const project = result.value; + projectsDetailUIInfoService.applySetProject(project); + return of(project); + }), + ); +}; diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/detail.resolver.ts b/projects/social_platform/src/app/ui/pages/projects/detail/detail.resolver.ts new file mode 100644 index 000000000..f1eadaf59 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/detail.resolver.ts @@ -0,0 +1,42 @@ +/** @format */ + +import { inject } from "@angular/core"; +import { ActivatedRouteSnapshot, ResolveFn } from "@angular/router"; +import { map, of, switchMap } from "rxjs"; +import { ProjectSubscriber } from "@domain/project/project-subscriber.model"; +import { Project } from "@domain/project/project.model"; +import { ProjectsDetailUIInfoService } from "@api/project/facades/detail/ui/projects-detail-ui.service"; +import { GetProjectUseCase } from "@api/project/use-cases/get-project.use-case"; +import { GetProjectSubscribersUseCase } from "@api/project/use-cases/get-project-subscribers.use-case"; + +/** Предзагружает данные проекта и его подписчиков. */ +export const ProjectDetailResolver: ResolveFn<[Project, ProjectSubscriber[]]> = ( + route: ActivatedRouteSnapshot, +) => { + const getProjectUseCase = inject(GetProjectUseCase); + const getProjectSubscribersUseCase = inject(GetProjectSubscribersUseCase); + const projectsDetailUIInfoService = inject(ProjectsDetailUIInfoService); + + return getProjectUseCase.execute(Number(route.paramMap.get("projectId"))).pipe( + switchMap(result => { + if (!result.ok) { + return of([new Project(), []] as [Project, ProjectSubscriber[]]); + } + + const project = result.value; + projectsDetailUIInfoService.applySetProject(project); + + return getProjectSubscribersUseCase + .execute(project.id) + .pipe( + map( + subscribersResult => + [project, subscribersResult.ok ? subscribersResult.value : []] as [ + Project, + ProjectSubscriber[], + ], + ), + ); + }), + ); +}; diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/info/components/projects-left-side/projects-left-side.component.html b/projects/social_platform/src/app/ui/pages/projects/detail/info/components/projects-left-side/projects-left-side.component.html new file mode 100644 index 000000000..afb0c38fb --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/info/components/projects-left-side/projects-left-side.component.html @@ -0,0 +1,48 @@ + + +
    +
    +
    +

    метаданные

    + +
    + +
      +
    • + +

      + @if (industryRepository.getOne(project()!.industry); as industry) { + {{ industry?.name }} + } +

      +
    • + +
    • + +

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

      +
    • + +
    • + +

      {{ project()!.trl ?? "0" }}

      +
    • + +
    • + +

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

      +
    • + +
    • + +

      + {{ project()!.leaderInfo?.lastName | truncate: 10 }} + {{ project()!.leaderInfo?.firstName | truncate: 10 }} +

      +
    • +
    +
    +
    diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/info/components/projects-left-side/projects-left-side.component.scss b/projects/social_platform/src/app/ui/pages/projects/detail/info/components/projects-left-side/projects-left-side.component.scss new file mode 100644 index 000000000..35e7f3f4e --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/info/components/projects-left-side/projects-left-side.component.scss @@ -0,0 +1,97 @@ +@mixin expandable-list { + &__remaining { + display: grid; + grid-template-rows: 0fr; + overflow: hidden; + transition: all 0.5s ease-in-out; + + &--show { + grid-template-rows: 1fr; + } + + ul { + min-height: 0; + } + + li { + &:first-child { + margin-top: 12px; + } + + &:not(:last-child) { + margin-bottom: 12px; + } + } + } +} + +.project { + &__left { + width: 157px; + } + + &__section { + padding: 24px; + margin-bottom: 14px; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); + } +} + +.lists { + &__section { + display: flex; + justify-content: space-between; + margin-bottom: 8px; + border-bottom: 0.5px solid var(--accent); + } + + &__list { + display: flex; + flex-direction: column; + gap: 8px; + } + + &__icon { + color: var(--accent); + } + + &__title { + margin-bottom: 8px; + color: var(--accent); + } + + &__item { + display: flex; + gap: 6px; + align-items: center; + + &--status { + padding: 8px; + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-xl); + } + + &--title { + color: var(--black); + } + + i { + padding: 6px; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-xl); + } + + p { + color: var(--accent); + } + + span { + cursor: pointer; + } + } + + @include expandable-list; +} diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/info/components/projects-left-side/projects-left-side.component.ts b/projects/social_platform/src/app/ui/pages/projects/detail/info/components/projects-left-side/projects-left-side.component.ts new file mode 100644 index 000000000..07b2321a0 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/info/components/projects-left-side/projects-left-side.component.ts @@ -0,0 +1,25 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, inject, input } from "@angular/core"; +import { RouterModule } from "@angular/router"; +import { IconComponent } from "@ui/primitives"; +import { TruncatePipe } from "@corelib"; +import { Project } from "@domain/project/project.model"; +import { AppRoutes } from "@api/paths/app-routes"; +import { IndustryRepositoryPort } from "@domain/industry/ports/industry.repository.port"; + +/** Левая колонка детали проекта. */ +@Component({ + selector: "app-projects-left-side", + templateUrl: "./projects-left-side.component.html", + styleUrl: "./projects-left-side.component.scss", + imports: [CommonModule, RouterModule, IconComponent, TruncatePipe], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProjectsLeftSideComponent { + readonly project = input.required(); + + protected readonly industryRepository = inject(IndustryRepositoryPort); + protected readonly AppRoutes = AppRoutes; +} diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/info/components/projects-mid-side/projects-mid-side.component.html b/projects/social_platform/src/app/ui/pages/projects/detail/info/components/projects-mid-side/projects-mid-side.component.html new file mode 100644 index 000000000..92e180a7d --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/info/components/projects-mid-side/projects-mid-side.component.html @@ -0,0 +1,62 @@ + + +
    +
    +
    +

    о проекте

    + +
    + @if (project()!.description) { +
    +

    + @if (descriptionExpandable()) { +
    + {{ readFullDescription() ? "скрыть" : "подробнее" }} +
    + } +
    + } +
    + + @if (profile()) { +
    + @if (project()!.leader === profile()!.id) { + + } + +
    + @for (directionItem of directions(); track $index) { + + } +
    + + @for (n of news(); track n.id) { + + } +
    + } +
    diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/info/components/projects-mid-side/projects-mid-side.component.scss b/projects/social_platform/src/app/ui/pages/projects/detail/info/components/projects-mid-side/projects-mid-side.component.scss new file mode 100644 index 000000000..bca329b78 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/info/components/projects-mid-side/projects-mid-side.component.scss @@ -0,0 +1,113 @@ +@use "styles/responsive"; + +.project { + &__content { + grid-row-start: 2; + min-width: 0; + + @include responsive.apply-desktop { + grid-row-start: unset; + } + } + + &__news { + grid-row-start: 4; + + @include responsive.apply-desktop { + grid-row-start: unset; + } + } + + &__directions { + display: grid; + grid-template-columns: repeat(5, 1fr); + grid-gap: 10px; + align-items: center; + margin-top: 24px; + } +} + +.about { + padding: 24px; + background-color: var(--light-white); + border-radius: var(--rounded-lg); + + &__head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + border-bottom: 0.5px solid var(--accent); + + &--icon { + color: var(--accent); + } + } + + &__title { + margin-bottom: 8px; + color: var(--accent); + } + /* stylelint-disable value-no-vendor-prefix */ + &__text { + p { + display: -webkit-box; + overflow: hidden; + line-height: 1.5; + color: var(--black); + text-overflow: ellipsis; + word-break: break-word; + transition: all 0.7s ease-in-out; + -webkit-box-orient: vertical; + -webkit-line-clamp: 5; + + &.expanded { + display: block; + -webkit-line-clamp: unset; + } + } + + ::ng-deep a { + color: var(--accent); + text-decoration: underline; + text-decoration-color: transparent; + text-underline-offset: 3px; + transition: text-decoration-color 0.2s; + + &:hover { + text-decoration-color: var(--accent); + } + } + } + + /* stylelint-enable value-no-vendor-prefix */ + + &__read-full { + margin-top: 2px; + color: var(--accent); + cursor: pointer; + } +} + +.news { + &__form { + display: block; + margin-top: 20px; + } + + &__item { + display: block; + margin-top: 20px; + } +} + +.read-more { + margin-top: 12px; + color: var(--accent); + cursor: pointer; + transition: color 0.2s; + + &:hover { + color: var(--accent-dark); + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/info/components/projects-mid-side/projects-mid-side.component.ts b/projects/social_platform/src/app/ui/pages/projects/detail/info/components/projects-mid-side/projects-mid-side.component.ts new file mode 100644 index 000000000..b914828fa --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/info/components/projects-mid-side/projects-mid-side.component.ts @@ -0,0 +1,121 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + ElementRef, + inject, + input, + signal, + viewChild, +} from "@angular/core"; +import { NewsFormComponent } from "@ui/widgets/news-form/news-form.component"; +import { ProjectDirectionCard } from "@ui/widgets/project-direction-card/project-direction-card.component"; +import { NewsCardComponent } from "@ui/widgets/news-card/news-card.component"; +import { NewsInfoService } from "@api/news/news-info.service"; +import { ProjectsDetailUIInfoService } from "@api/project/facades/detail/ui/projects-detail-ui.service"; +import { ExpandService } from "@api/expand/expand.service"; +import { Project } from "@domain/project/project.model"; +import { AppRoutes } from "@api/paths/app-routes"; +import { ProjectsDetailService } from "@api/project/facades/detail/projects-detail.service"; +import { ParseBreaksPipe, ParseLinksPipe } from "@corelib"; +import { FeedNews } from "@domain/news/project-news.model"; +import { Collaborator } from "@domain/project/collaborator.model"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { finalize } from "rxjs"; +import { ProfileInfoService } from "@api/profile/facades/profile-info.service"; + +/** Центральная колонка детали проекта: описание, новости. */ +@Component({ + selector: "app-projects-mid-side", + templateUrl: "./projects-mid-side.component.html", + styleUrl: "./projects-mid-side.component.scss", + imports: [ + CommonModule, + NewsFormComponent, + ProjectDirectionCard, + NewsCardComponent, + ParseLinksPipe, + ParseBreaksPipe, + ], + providers: [ProjectsDetailService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProjectsMidSideComponent { + readonly project = input.required(); + + // Ссылки на элементы DOM + readonly newsEl = viewChild("newsEl"); + readonly contentEl = viewChild("contentEl"); + readonly descEl = viewChild("descEl"); + + // Ссылки на дочерние компоненты + readonly newsFormComponent = viewChild(NewsFormComponent); + readonly newsCardComponent = viewChild(NewsCardComponent); + + private readonly destroyRef = inject(DestroyRef); + private readonly projectsDetailService = inject(ProjectsDetailService); + private readonly newsInfoService = inject(NewsInfoService); + private readonly projectsDetailUIInfoService = inject(ProjectsDetailUIInfoService); + private readonly profileInfoService = inject(ProfileInfoService); + private readonly expandService = inject(ExpandService); + + protected readonly directions = this.projectsDetailUIInfoService.directions; + + protected readonly AppRoutes = AppRoutes; + + // Состояние компонента + protected readonly profile = this.profileInfoService.profile; + protected readonly news = this.newsInfoService.news; // Массив новостей + protected readonly newsPending = signal(false); + protected readonly readFullDescription = this.expandService.readFullDescription; // Флаг развернутого описания + protected readonly descriptionExpandable = this.expandService.descriptionExpandable; // Флаг необходимости кнопки "Читать полностью" + + onNewsInVew(entries: IntersectionObserverEntry[]): void { + this.projectsDetailService.onNewsInVew(entries); + } + + onAddNews(news: { text: string; files: string[] }): void { + this.newsPending.set(true); + this.projectsDetailService + .onAddNews(news) + .pipe( + finalize(() => this.newsPending.set(false)), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe({ + next: () => this.newsFormComponent()?.onResetForm(), + }); + } + + onDeleteNews(newsId: number): void { + this.projectsDetailService.onDeleteNews(newsId); + } + + onLike(newsId: number) { + this.projectsDetailService.onLike(newsId); + } + + onEditNews(news: FeedNews, newsItemId: number) { + this.projectsDetailService + .onEditNews(news, newsItemId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => this.newsCardComponent()?.onCloseEditMode(), + }); + } + + onRemoveMember(id: Collaborator["userId"]) { + this.projectsDetailService.onRemoveMember(id); + } + + onTransferOwnership(id: Collaborator["userId"]) { + this.projectsDetailService.onTransferOwnership(id); + } + + onExpandDescription(elem: HTMLElement, expandedClass: string, isExpanded: boolean): void { + this.expandService.onExpand("description", elem, expandedClass, isExpanded); + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/info/components/projects-right-side/projects-right-side.component.html b/projects/social_platform/src/app/ui/pages/projects/detail/info/components/projects-right-side/projects-right-side.component.html new file mode 100644 index 000000000..733e790c4 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/info/components/projects-right-side/projects-right-side.component.html @@ -0,0 +1,106 @@ + + +
    + +
    diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/info/components/projects-right-side/projects-right-side.component.scss b/projects/social_platform/src/app/ui/pages/projects/detail/info/components/projects-right-side/projects-right-side.component.scss new file mode 100644 index 000000000..ca7b00f53 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/info/components/projects-right-side/projects-right-side.component.scss @@ -0,0 +1,121 @@ +@use "styles/responsive"; + +@mixin expandable-list { + &__remaining { + display: grid; + grid-template-rows: 0fr; + overflow: hidden; + transition: all 0.5s ease-in-out; + + &--show { + grid-template-rows: 1fr; + } + + ul { + min-height: 0; + } + + li { + &:first-child { + margin-top: 12px; + } + + &:not(:last-child) { + margin-bottom: 12px; + } + } + } +} + +.project { + &__right { + display: flex; + flex-direction: column; + } + + &__section { + padding: 24px; + margin-bottom: 14px; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); + } + + &__aside { + display: grid; + grid-row-start: 3; + gap: 20px; + + @include responsive.apply-desktop { + grid-row-start: unset; + } + } +} + +.read-more { + margin-top: 12px; + color: var(--accent); + cursor: pointer; + transition: color 0.2s; + + &:hover { + color: var(--accent-dark); + } +} + +.lists { + &__section { + display: flex; + justify-content: space-between; + margin-bottom: 8px; + border-bottom: 0.5px solid var(--accent); + } + + &__list { + display: flex; + flex-direction: column; + gap: 8px; + } + + &__icon { + color: var(--accent); + } + + &__title { + margin-bottom: 8px; + color: var(--accent); + } + + &__item { + display: flex; + gap: 6px; + align-items: center; + + &--status { + padding: 8px; + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-xl); + } + + &--title { + color: var(--black); + } + + i { + padding: 6px; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-xl); + } + + p { + color: var(--accent); + } + + span { + cursor: pointer; + } + } + + @include expandable-list; +} diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/info/components/projects-right-side/projects-right-side.component.ts b/projects/social_platform/src/app/ui/pages/projects/detail/info/components/projects-right-side/projects-right-side.component.ts new file mode 100644 index 000000000..05e6f3827 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/info/components/projects-right-side/projects-right-side.component.ts @@ -0,0 +1,31 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + inject, + input, + Input, + WritableSignal, +} from "@angular/core"; +import { IconComponent } from "@uilib"; +import { TruncatePipe, UserLinksPipe } from "@corelib"; +import { ExpandService } from "@api/expand/expand.service"; +import { Project } from "@domain/project/project.model"; + +/** Правая колонка детали проекта: команда, вакансии. */ +@Component({ + selector: "app-projects-right-side", + templateUrl: "./projects-right-side.component.html", + styleUrl: "./projects-right-side.component.scss", + imports: [CommonModule, IconComponent, UserLinksPipe, TruncatePipe], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProjectsRightSideComponent { + readonly project = input.required(); + + protected readonly expandService = inject(ExpandService); + + protected readonly readAllAchievements = this.expandService.readAll()["achievements"]; // Флаг показа всех достижений +} diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/info/info.component.html b/projects/social_platform/src/app/ui/pages/projects/detail/info/info.component.html new file mode 100644 index 000000000..f2b1a112f --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/info/info.component.html @@ -0,0 +1,17 @@ + + +@if (project()) { +
    +
    +
    + + + + + +
    +
    + + +
    +} diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/info/info.component.scss b/projects/social_platform/src/app/ui/pages/projects/detail/info/info.component.scss new file mode 100644 index 000000000..c6ad05499 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/info/info.component.scss @@ -0,0 +1,22 @@ +/** @format */ + +@use "styles/responsive"; + +.project { + padding-bottom: 100px; + + @include responsive.apply-desktop { + padding-bottom: 0; + } + + &__main { + display: grid; + grid-template-columns: 1fr; + } + + &__details { + display: grid; + grid-template-columns: 2fr 5fr 3fr; + grid-gap: 20px; + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/info/info.component.spec.ts b/projects/social_platform/src/app/ui/pages/projects/detail/info/info.component.spec.ts new file mode 100644 index 000000000..608fbe46c --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/info/info.component.spec.ts @@ -0,0 +1,88 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { ProjectInfoComponent } from "./info.component"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { of } from "rxjs"; +import { AuthRepository } from "@infrastructure/repository/auth/auth.repository"; +import { ProjectNewsRepository as ProjectNewsService } from "@infrastructure/repository/project/project-news.repository"; +import { ReactiveFormsModule } from "@angular/forms"; +import { provideRouter } from "@angular/router"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { signal } from "@angular/core"; +import { ProjectsDetailService } from "@api/project/facades/detail/projects-detail.service"; +import { ProfileDetailUIInfoService } from "@api/profile/facades/detail/ui/profile-detail-ui-info.service"; +import { ExpandService } from "@api/expand/expand.service"; + +describe("ProjectInfoComponent", () => { + let component: ProjectInfoComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const authSpy = { + profile: of({}), + }; + const projectNewsServiceSpy = { fetchNews: vi.fn().mockReturnValue(of({})) }; + const authPortSpy = { + login: of({} as any), + logout: of(undefined), + fetchProfile: of({} as any), + fetchUserRoles: of([]), + fetchChangeableRoles: of([]), + fetchLeaderProjects: of({} as any), + }; + + const projectsDetailServiceSpy = { + initializationProjectInfo: vi.fn(), + initCheckDescription: vi.fn(), + destroy: vi.fn(), + }; + + const projectsDetailUIInfoServiceSpy = { + project: signal(undefined), + }; + + const profileDetailUIInfoServiceSpy = { + user: signal(undefined), + loggedUserId: signal(0), + profileId: signal(0), + applySetLoggedUserId: vi.fn(), + }; + + const expandServiceSpy = { expanded: signal({}) }; + + await TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, ReactiveFormsModule, ProjectInfoComponent], + providers: [ + provideRouter([]), + { provide: AuthRepository, useValue: authSpy }, + { provide: AuthRepositoryPort, useValue: authPortSpy }, + { provide: ProjectNewsService, useValue: projectNewsServiceSpy }, + ], + }) + .overrideComponent(ProjectInfoComponent, { + remove: { + providers: [ProjectsDetailService, ProfileDetailUIInfoService, ExpandService], + }, + add: { + providers: [ + { provide: ProjectsDetailService, useValue: projectsDetailServiceSpy }, + { provide: ProfileDetailUIInfoService, useValue: profileDetailUIInfoServiceSpy }, + { provide: ExpandService, useValue: expandServiceSpy }, + ], + }, + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ProjectInfoComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/info/info.component.ts b/projects/social_platform/src/app/ui/pages/projects/detail/info/info.component.ts new file mode 100644 index 000000000..b80c1b6ae --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/info/info.component.ts @@ -0,0 +1,55 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + inject, + OnDestroy, + OnInit, +} from "@angular/core"; +import { RouterOutlet } from "@angular/router"; +import { ProjectsDetailUIInfoService } from "@api/project/facades/detail/ui/projects-detail-ui.service"; +import { ProjectsDetailService } from "@api/project/facades/detail/projects-detail.service"; +import { ProfileDetailUIInfoService } from "@api/profile/facades/detail/ui/profile-detail-ui-info.service"; +import { ExpandService } from "@api/expand/expand.service"; +import { ProjectsLeftSideComponent } from "./components/projects-left-side/projects-left-side.component"; +import { ProjectsRightSideComponent } from "./components/projects-right-side/projects-right-side.component"; +import { ProjectsMidSideComponent } from "./components/projects-mid-side/projects-mid-side.component"; + +/** Детальная информация о проекте: команда, новости, вакансии, подписка. */ +@Component({ + selector: "app-detail", + templateUrl: "./info.component.html", + styleUrl: "./info.component.scss", + imports: [ + RouterOutlet, + RouterOutlet, + CommonModule, + ProjectsLeftSideComponent, + ProjectsRightSideComponent, + ProjectsMidSideComponent, + ], + providers: [ProjectsDetailService, ProfileDetailUIInfoService, ExpandService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProjectInfoComponent implements OnInit, AfterViewInit, OnDestroy { + private readonly projectsDetailService = inject(ProjectsDetailService); + private readonly projectsDetailUIInfoService = inject(ProjectsDetailUIInfoService); + + // Данные о проекте + protected readonly project = this.projectsDetailUIInfoService.project; + + ngOnInit(): void { + this.projectsDetailService.initializationProjectInfo(); + } + + ngAfterViewInit(): void { + this.projectsDetailService.initCheckDescription(); + } + + ngOnDestroy(): void { + this.projectsDetailService.destroy(); + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/info/info.resolver.spec.ts b/projects/social_platform/src/app/ui/pages/projects/detail/info/info.resolver.spec.ts new file mode 100644 index 000000000..235999d16 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/info/info.resolver.spec.ts @@ -0,0 +1,37 @@ +/** @format */ + +import { TestBed } from "@angular/core/testing"; +import { ProjectInfoResolver } from "./info.resolver"; +import { + ActivatedRouteSnapshot, + convertToParamMap, + RouterStateSnapshot, + provideRouter, +} from "@angular/router"; +import { of } from "rxjs"; +import { GetVacanciesUseCase } from "@api/vacancy/use-cases/get-vacancies.use-case"; + +describe("ProjectInfoResolver", () => { + const mockRoute = { + paramMap: convertToParamMap({ projectId: 1 }), + } as unknown as ActivatedRouteSnapshot; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideRouter([]), + { + provide: GetVacanciesUseCase, + useValue: { execute: () => of({ ok: true, value: [] }) }, + }, + ], + }); + }); + + it("should be created", () => { + const result = TestBed.runInInjectionContext(() => + ProjectInfoResolver(mockRoute, {} as RouterStateSnapshot), + ); + expect(result).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/info/info.resolver.ts b/projects/social_platform/src/app/ui/pages/projects/detail/info/info.resolver.ts new file mode 100644 index 000000000..182eb0cd5 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/info/info.resolver.ts @@ -0,0 +1,18 @@ +/** @format */ + +import { inject } from "@angular/core"; +import { ActivatedRouteSnapshot, ResolveFn } from "@angular/router"; +import { Vacancy } from "@domain/vacancy/vacancy.model"; +import { map } from "rxjs"; +import { GetVacanciesUseCase } from "@api/vacancy/use-cases/get-vacancies.use-case"; + +/** Предзагружает вакансии проекта. */ +export const ProjectInfoResolver: ResolveFn = (route: ActivatedRouteSnapshot) => { + const getVacanciesUseCase = inject(GetVacanciesUseCase); + const projectId = Number(route.paramMap.get("projectId")); // Извлечение ID проекта из параметров + + // Возвращаем Observable с вакансиями проекта + return getVacanciesUseCase + .execute({ limit: 20, offset: 0, projectId }) + .pipe(map(result => (result.ok ? result.value : []))); +}; diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/actions/kanban-board-actions.component.html b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/actions/kanban-board-actions.component.html new file mode 100644 index 000000000..418604208 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/actions/kanban-board-actions.component.html @@ -0,0 +1,19 @@ + + + +
    +
    + +
    + +
    + +
    +
    + + @if (isLeader()) { +
    + +
    + } +
    diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/actions/kanban-board-actions.component.scss b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/actions/kanban-board-actions.component.scss new file mode 100644 index 000000000..778d07907 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/actions/kanban-board-actions.component.scss @@ -0,0 +1,49 @@ +.kanban { + &__sidebar { + &-item { + display: flex; + align-items: center; + align-self: center; + justify-content: center; + padding: 12px; + cursor: pointer; + border-radius: var(--rounded-xxl); + } + + &--add-project { + background-color: var(--light-white); + border: 0.5px solid var(--accent); + + i { + color: var(--accent); + } + } + } + + &__actions { + display: flex; + flex-direction: column; + gap: 15px; + margin-bottom: 15px; + } +} + +.actions { + &__person { + background-color: var(--accent-light); + border: 0.5px solid var(--accent); + + i { + color: var(--accent); + } + } + + &__priority { + background-color: var(--green-light); + border: 0.5px solid var(--green); + + i { + color: var(--green); + } + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/actions/kanban-board-actions.component.ts b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/actions/kanban-board-actions.component.ts new file mode 100644 index 000000000..8efa8012e --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/actions/kanban-board-actions.component.ts @@ -0,0 +1,26 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { Component, EventEmitter, inject, Output } from "@angular/core"; +import { IconComponent } from "@uilib"; +import { KanbanBoardDetailInfoService } from "@api/kanban/kanban-board-detail-info.service"; + +/** Канбан (модуль отключён): действия над доской. */ +@Component({ + selector: "app-kanban-board-actions", + templateUrl: "./kanban-board-actions.component.html", + styleUrl: "./kanban-board-actions.component.scss", + imports: [CommonModule, IconComponent], + standalone: true, +}) +export class KanbanBoardActionsComponent { + @Output() openCreation = new EventEmitter(); + + private readonly kanbanBoardDetailInfoService = inject(KanbanBoardDetailInfoService); + readonly isLeader = this.kanbanBoardDetailInfoService.isLeader; + + addBoard(event: MouseEvent): void { + event.stopPropagation(); + this.openCreation.emit(true); + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/cancel-task-form/cancel-task-form.component.html b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/cancel-task-form/cancel-task-form.component.html new file mode 100644 index 000000000..72b395c3e --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/cancel-task-form/cancel-task-form.component.html @@ -0,0 +1,50 @@ + + +
    + @if (cancelTaskForm.get("description"); as description) { +
    + + @if (description | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } + @if (description | controlError: "maxlength") { +
    + {{ errorMessage.VALIDATION_TOO_LONG }} + @if (description.errors) { + {{ description.errors["maxlength"]["requiredLength"] }} + } +
    + } + @if (description | controlError: "minlength") { +
    + {{ errorMessage.VALIDATION_TOO_SHORT }} + @if (description.errors) { + {{ description.errors["minlength"]["requiredLength"] }} + } +
    + } +
    + } + + + +
    + @for (answer of quickAnswers; track $index) { + {{ + answer.title + }} + } +
    + + отменить результат выполнения +
    diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/cancel-task-form/cancel-task-form.component.scss b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/cancel-task-form/cancel-task-form.component.scss new file mode 100644 index 000000000..eb9e36131 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/cancel-task-form/cancel-task-form.component.scss @@ -0,0 +1,32 @@ +@use "styles/typography"; + +.cancel { + &__form { + display: flex; + flex-direction: column; + gap: 10px; + + label { + color: var(--grey-for-text); + } + } + + &__quick-answers { + display: flex; + flex-wrap: wrap; + gap: 10px; + + ::ng-deep { + app-tag { + .tag { + padding: 7px 10px; + cursor: pointer; + + p { + @include typography.body-12; + } + } + } + } + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/cancel-task-form/cancel-task-form.component.ts b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/cancel-task-form/cancel-task-form.component.ts new file mode 100644 index 000000000..74b6f26a7 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/cancel-task-form/cancel-task-form.component.ts @@ -0,0 +1,62 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { Component, EventEmitter, inject, Output } from "@angular/core"; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; +import { ControlErrorPipe, ValidationService } from "@corelib"; +import { ButtonComponent } from "@ui/primitives"; +import { ErrorMessage } from "@core/lib/models/error/error-message"; +import { TextareaComponent } from "@ui/primitives/textarea/textarea.component"; +import { QuickAnswers } from "@core/consts/other/quick-answers.const"; +import { TagComponent } from "@ui/primitives/tag/tag.component"; + +/** Канбан (модуль отключён): форма отмены задачи. */ +@Component({ + selector: "app-cancel-task-form", + templateUrl: "./cancel-task-form.component.html", + styleUrl: "./cancel-task-form.component.scss", + imports: [ + CommonModule, + ReactiveFormsModule, + ButtonComponent, + ControlErrorPipe, + TextareaComponent, + TagComponent, + ], + standalone: true, +}) +export class CancelTaskFormComponent { + @Output() submit = new EventEmitter(); + + private readonly fb = inject(FormBuilder); + private readonly validationService = inject(ValidationService); + + constructor() { + this.cancelTaskForm = this.fb.group({ + description: ["", Validators.required], + }); + } + + errorMessage = ErrorMessage; + cancelTaskForm: FormGroup; + readonly quickAnswers = QuickAnswers; + + sendFormIsSubmitting = false; + + pickAnAnswer(title: string): void { + const description = this.cancelTaskForm.get("description")?.value; + this.cancelTaskForm.patchValue({ + description: (description.length ? description : null) + " " + title, + }); + } + + onSubmit(event: Event): void { + if (!this.validationService.getFormValidation(this.cancelTaskForm)) return; + + this.sendFormIsSubmitting = true; + + // TODO логика отправки формы для отмены выполнения задачи + event.stopPropagation(); + this.submit.emit(); + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/create-board-form/create-board-form.component.html b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/create-board-form/create-board-form.component.html new file mode 100644 index 000000000..b117a2104 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/create-board-form/create-board-form.component.html @@ -0,0 +1,67 @@ + + +
    +
    +
    + @if (createBoardForm.get("title"); as title) { + + @if (title | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } + } +
    + +
    + @if (createBoardForm.get("color"); as color) { + @for (colorItem of tagColors; track $index) { +
    + } + } +
    + +
    + @if (createBoardForm.get("description"); as description) { + + @if (description | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } + @if (description | controlError: "maxlength") { +
    + {{ errorMessage.VALIDATION_TOO_LONG }} + @if (description.errors) { + {{ description.errors["maxlength"]["requiredLength"] }} + } +
    + } + @if (description | controlError: "minlength") { +
    + {{ errorMessage.VALIDATION_TOO_SHORT }} + @if (description.errors) { + {{ description.errors["minlength"]["requiredLength"] }} + } +
    + } + } +
    + +
    + @if (createBoardForm.get("icon"); as icon) { + @for (icon of kanbanIcons.slice(0, 13); track $index) { + + } + } +
    +
    +
    diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/create-board-form/create-board-form.component.scss b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/create-board-form/create-board-form.component.scss new file mode 100644 index 000000000..00a543994 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/create-board-form/create-board-form.component.scss @@ -0,0 +1,87 @@ +@use "styles/typography"; + +.create-board { + position: relative; + z-index: 1000; + width: 151px; + padding: 10px 22px 10px 30px; + background-color: var(--light-white); + border-radius: var(--rounded-md); + + &__form { + display: flex; + flex-direction: column; + gap: 4px; + } + + &__title { + input { + width: 93px; + height: 14px; + padding: 2px 4px; + } + } + + &__colors { + display: flex; + gap: 3px; + align-items: center; + } + + &__pick-color { + width: 9px; + height: 9px; + cursor: pointer; + border-radius: var(--rounded-sm); + } + + &__pick-color, + i { + cursor: pointer; + } + + &__description { + textarea { + width: 93px; + height: 49px; + } + } + + input, + textarea { + display: flex; + align-items: flex-start; + justify-content: flex-start; + padding: 5px; + resize: none; + background-color: #d9d9d9; + border: none; + border-radius: var(--rounded-sm); + outline: none; + + &::placeholder { + @include typography.body-6; + + display: flex; + align-items: flex-start; + justify-content: flex-start; + } + + &:focus { + border-color: none; + box-shadow: none; + } + } + + &__icons { + display: grid; + grid-template-rows: 1fr 1fr; + grid-template-columns: repeat(8, 1fr); + grid-gap: 7px; + + i, + svg { + color: var(--black); + } + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/create-board-form/create-board-form.component.ts b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/create-board-form/create-board-form.component.ts new file mode 100644 index 000000000..0ecb6217c --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/create-board-form/create-board-form.component.ts @@ -0,0 +1,63 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { Component, inject, OnDestroy, OnInit } from "@angular/core"; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; +import { Subscription } from "rxjs"; +import { IconComponent } from "@ui/primitives"; +import { ControlErrorPipe } from "@corelib"; +import { ErrorMessage } from "@core/lib/models/error/error-message"; +import { tagColors } from "@core/consts/other/tag-colors.const"; +import { KanbanIcons } from "@core/consts/other/kanban-icons.const"; + +/** Канбан (модуль отключён): форма создания доски. */ +@Component({ + selector: "app-create-board-form", + templateUrl: "./create-board-form.component.html", + styleUrl: "./create-board-form.component.scss", + imports: [CommonModule, ReactiveFormsModule, ControlErrorPipe, IconComponent], + standalone: true, +}) +export class CreateBoardFormComponent implements OnInit, OnDestroy { + private readonly fb = inject(FormBuilder); + + constructor() { + this.createBoardForm = this.fb.group({ + title: ["", [Validators.required, Validators.maxLength(20)]], + description: [null, Validators.maxLength(200)], + color: [null], + icon: [null], + }); + } + + get tagColors() { + return tagColors; + } + + get kanbanIcons() { + return KanbanIcons; + } + + createBoardForm: FormGroup; + errorMessage = ErrorMessage; + subscriptions: Subscription[] = []; + + ngOnInit(): void {} + + ngOnDestroy(): void { + this.subscriptions.forEach($ => $.unsubscribe()); + } + + pickOption(control: "color" | "icon", value: string): void { + this.createBoardForm.patchValue({ [control]: value }); + } + + private resetForm(): void { + this.createBoardForm.reset({ + title: "", + description: null, + color: null, + icon: null, + }); + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/create-tag-form/create-tag-form.component.html b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/create-tag-form/create-tag-form.component.html new file mode 100644 index 000000000..e829ce924 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/create-tag-form/create-tag-form.component.html @@ -0,0 +1,31 @@ + + +
    + +
    + + @if (openPickColors) { +
    +
    + @for (colorItem of tagColors; track $index) { +
    + } +
    +
    + } +
    diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/create-tag-form/create-tag-form.component.scss b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/create-tag-form/create-tag-form.component.scss new file mode 100644 index 000000000..4e5800558 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/create-tag-form/create-tag-form.component.scss @@ -0,0 +1,71 @@ +.create-tag { + &__form { + display: flex; + gap: 5px; + align-items: center; + } + + &__pick-color { + position: relative; + width: 9px; + height: 9px; + cursor: pointer; + border-radius: var(--rounded-sm); + } + + &__input { + padding: 3px 2px 2px 10px; + outline: none; + transition: all 0.2s; + + &::placeholder { + color: var(--dark-grey); + } + + &:focus { + border-color: var(--accent); + } + } + + &__colors { + position: absolute; + right: -12%; + bottom: -25%; + padding: 7px; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-md); + + &-wrapper { + display: grid; + grid-template-rows: repeat(2, 1fr); + grid-template-columns: repeat(4, 1fr); + gap: 3px; + } + } +} + +.field { + &__option { + display: flex; + gap: 5px; + align-items: center; + justify-content: space-between; + cursor: pointer; + transition: color 0.2s ease-in-out; + + &--add-object { + display: flex; + align-items: center; + justify-content: center; + width: 80px; + cursor: pointer; + border: 0.5px solid var(--accent); + border-radius: var(--rounded-xl); + + i { + color: var(--accent); + } + } + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/create-tag-form/create-tag-form.component.ts b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/create-tag-form/create-tag-form.component.ts new file mode 100644 index 000000000..a25ff7846 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/create-tag-form/create-tag-form.component.ts @@ -0,0 +1,114 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { + Component, + EventEmitter, + inject, + Input, + OnChanges, + OnInit, + Output, + SimpleChanges, +} from "@angular/core"; +import { tagColors } from "@core/consts/other/tag-colors.const"; +import { + FormBuilder, + FormGroup, + FormsModule, + ReactiveFormsModule, + Validators, +} from "@angular/forms"; +import { TagDto } from "@domain/kanban/dto/tag.model.dto"; + +/** Канбан (модуль отключён): форма создания тега. */ +@Component({ + selector: "app-create-tag-form", + templateUrl: "./create-tag-form.component.html", + styleUrl: "./create-tag-form.component.scss", + imports: [CommonModule, FormsModule, ReactiveFormsModule], +}) +export class CreateTagFormComponent implements OnInit, OnChanges { + @Input() editingTag: TagDto | null = null; + @Output() createTag = new EventEmitter(); + @Output() updateTag = new EventEmitter(); + + private readonly fb = inject(FormBuilder); + + constructor() { + this.tagForm = this.fb.group({ + tagName: ["", [Validators.required, Validators.maxLength(30)]], + tagColor: [tagColors[0].name, [Validators.required]], + }); + } + + tagForm: FormGroup; + openPickColors = false; + + get tagColors() { + return tagColors; + } + + get selectedColor(): string { + const colorName = this.tagForm.get("tagColor")?.value; + const found = tagColors.find(c => c.name === colorName); + return found ? found.color : tagColors[0].color; + } + + get isEditMode(): boolean { + return !!this.editingTag; + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes["editingTag"]) { + this.initFormFromEditingTag(); + } + } + + ngOnInit(): void { + this.initFormFromEditingTag(); + } + + selectTagColor(colorName: string) { + this.tagForm.patchValue({ tagColor: colorName }); + this.openPickColors = false; + } + + confirmCreateTag(event: Event) { + event.stopPropagation(); + event.preventDefault(); + + const { tagName, tagColor } = this.tagForm.value; + const tagData: TagDto = { + name: tagName, + color: tagColor, + }; + + if (!tagName?.trim() || !tagColor) return; + + if (this.isEditMode && this.editingTag?.id) { + this.updateTag.emit({ ...tagData, id: this.editingTag.id }); + } else { + this.createTag.emit(tagData); + } + this.resetForm(); + } + + private resetForm(): void { + this.tagForm.reset({ + tagName: "", + tagColor: tagColors[0].name, + }); + } + + private initFormFromEditingTag() { + if (this.editingTag) { + this.tagForm.patchValue({ + tagName: this.editingTag.name, + tagColor: this.editingTag.color, + }); + } else { + this.resetForm(); + } + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/sidebar/kanban-board-sidebar.component.html b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/sidebar/kanban-board-sidebar.component.html new file mode 100644 index 000000000..b0d972708 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/sidebar/kanban-board-sidebar.component.html @@ -0,0 +1,46 @@ + + +
    + @if (projectBoardInfo()) { +
    +
    + +
    + + @if (isBoardInfoOpen()) { +
    + @if (boardInfo(); as boardInfo) { +
    +

    {{ boardInfo.name }}

    +
    + +

    {{ boardInfo.description }}

    + } +
    + } + + +
    + } + @if (isCreateBoardForm()) { +
    + +
    + } + + +
    diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/sidebar/kanban-board-sidebar.component.scss b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/sidebar/kanban-board-sidebar.component.scss new file mode 100644 index 000000000..7b486da1a --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/sidebar/kanban-board-sidebar.component.scss @@ -0,0 +1,54 @@ +.kanban { + &__sidebar { + position: relative; + display: flex; + flex-direction: column; + gap: 15px; + align-items: center; + justify-content: flex-start; + width: 68px; + height: 60vh; + max-height: 373px; + padding: 15px; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); + + &-info { + position: absolute; + top: 0%; + left: 110%; + width: 245px; + padding: 24px; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); + + p { + line-break: anywhere; + white-space: pre-wrap; + } + } + } + + &__create-board { + position: absolute; + bottom: 0%; + left: 110%; + } +} + +.lists { + &__section { + display: flex; + justify-content: space-between; + width: 100%; + margin-bottom: 8px; + border-bottom: 0.5px solid var(--accent); + } + + &__title { + margin-bottom: 8px; + color: var(--accent); + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/sidebar/kanban-board-sidebar.component.ts b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/sidebar/kanban-board-sidebar.component.ts new file mode 100644 index 000000000..07094e750 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/sidebar/kanban-board-sidebar.component.ts @@ -0,0 +1,130 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { Component, inject, Input, signal } from "@angular/core"; +import { AvatarComponent } from "@ui/primitives/avatar/avatar.component"; +import { KanbanBoardActionsComponent } from "../actions/kanban-board-actions.component"; +import { DropdownComponent } from "@ui/primitives/dropdown/dropdown.component"; +import { ClickOutsideModule } from "ng-click-outside"; +import { Router } from "@angular/router"; +import { KanbanBoardInfoService } from "@api/kanban/kanban-board-info.service"; +import { CreateBoardFormComponent } from "../create-board-form/create-board-form.component"; +import { Project } from "@domain/project/project.model"; +import { ProjectsDetailUIInfoService } from "@api/project/facades/detail/ui/projects-detail-ui.service"; + +/** Канбан (модуль отключён): боковая панель досок. */ +@Component({ + selector: "app-kanban-board-sidebar", + templateUrl: "./kanban-board-sidebar.component.html", + styleUrl: "./kanban-board-sidebar.component.scss", + imports: [ + CommonModule, + AvatarComponent, + KanbanBoardActionsComponent, + DropdownComponent, + ClickOutsideModule, + CreateBoardFormComponent, + ], + standalone: true, +}) +export class KanbanBoardSidebarComponent { + private readonly projectsDetailUIInfoService = inject(ProjectsDetailUIInfoService); + private readonly kanbanBoardInfoService = inject(KanbanBoardInfoService); + private readonly router = inject(Router); + + readonly boardInfo = this.kanbanBoardInfoService.boardInfo; + readonly isFirstBoard = this.kanbanBoardInfoService.isFirstBoard; + readonly projectBoardInfo = this.projectsDetailUIInfoService.project; + + isContextMenuOpen = signal(false); + isBoardInfoOpen = signal(false); + isCreateBoardForm = signal(false); + + get contextMenuOptions() { + return [ + { + id: 1, + label: "выгрузка доски", + value: "", + }, + { + id: 2, + label: "архив выполнено", + value: "", + }, + { + id: 3, + label: "выгрузка архива выполнено", + value: "", + }, + { + id: 4, + label: "информация о доске", + value: "", + }, + { + id: 5, + label: "редактировать", + value: "", + }, + ]; + } + + onContextSelect(option: any, state: boolean) { + switch (option) { + case 1: { + break; + } + + case 2: { + this.navigateTo("archive", this.projectBoardInfo()!); + break; + } + + case 4: { + this.isBoardInfoOpen.set(true); + break; + } + + case 5: { + break; + } + } + + this.isContextMenuOpen.set(state); + } + + onMouseDown(event: MouseEvent, board: any): void { + event.stopPropagation(); + + if (event.button === 2 || event.ctrlKey) { + event.preventDefault(); + this.isContextMenuOpen.set(true); + return; + } + + if (event.button === 0) { + this.navigateTo("project", this.projectBoardInfo()!); + + this.kanbanBoardInfoService.setSelectedBoard(board.id); + } + } + + onBoardCreate(openCreation: boolean): void { + this.isCreateBoardForm.set(openCreation); + } + + private navigateTo(type: "project" | "archive", projectBoardInfo: Project): void { + switch (type) { + case "project": { + this.router.navigate(["office/projects/" + projectBoardInfo.id + "/kanban/board/"]); + break; + } + + case "archive": { + this.router.navigate(["office/projects/" + projectBoardInfo.id + "/kanban/archive"]); + break; + } + } + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/task/detail/badge/badge.component.html b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/task/detail/badge/badge.component.html new file mode 100644 index 000000000..36473f908 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/task/detail/badge/badge.component.html @@ -0,0 +1,14 @@ + + +
    + + + +
    diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/task/detail/badge/badge.component.scss b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/task/detail/badge/badge.component.scss new file mode 100644 index 000000000..5b16d4943 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/task/detail/badge/badge.component.scss @@ -0,0 +1,24 @@ +.badge { + padding: 4px; + border-style: solid; + border-width: 0.5px; + border-radius: var(--rounded-xl); + + &--green { + color: var(--green); + background-color: var(--green-light); + border-color: var(--green); + } + + &--red { + color: var(--red); + background-color: var(--red-light); + border-color: var(--red); + } + + &--gold { + color: var(--gold); + background-color: var(--gold-light); + border-color: var(--gold); + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/task/detail/badge/badge.component.ts b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/task/detail/badge/badge.component.ts new file mode 100644 index 000000000..355cf37fd --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/task/detail/badge/badge.component.ts @@ -0,0 +1,18 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, Input } from "@angular/core"; + +/** Канбан (модуль отключён): бейдж задачи. */ +@Component({ + selector: "app-badge", + templateUrl: "./badge.component.html", + styleUrl: "./badge.component.scss", + imports: [CommonModule], + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BadgeComponent { + @Input() color: "green" | "red" | "gold" = "red"; + @Input() type: "deadline" | "start" = "deadline"; +} diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/task/detail/task-detail.component.html b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/task/detail/task-detail.component.html new file mode 100644 index 000000000..c1f6f1d9f --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/task/detail/task-detail.component.html @@ -0,0 +1,837 @@ + + +
    +
    +
    + @if (!isTaskResult()) { + @if (isExternal() || ((isLeader() || isCreator()) && !isPerformer() && !isResponsible())) { + ожидание результата + } @else if (isResponsible() || isPerformer()) { + прикрепить результа + } + } @else { + @if (isCommented) { + {{ + isCommentedByLeader && !isLeader() ? "комментарий лидера" : "вы прокомментировали" + }} + } + @if (isLeaderAcceptResult()) { + @if (!isExternal()) { +
    + } + + {{ isLeader() ? "вы приняли работу" : "результат принят" }} + } @else { + @if (isLeader() && !isLeaderLeaveComment()) { + приемка + } + @if (!isCommentedByLeader) { + @if (isResponsible() || isPerformer()) { + ожидание проверки + } + } + } + + результат задачи + } +
    + +
    + @if (taskDetailForm.get("type"); as type) { +
    + + +
    + } + @if (taskDetailForm.get("priority"); as priority) { +
    +
    + +
    + } + @if (isCreator() || isLeader()) { +
    + + +
    + } +
    +
    + +
    + @if (taskDetailForm.get("title")?.value; as title) { +

    {{ title }}

    + } + @if (taskDetailForm.get("score")?.value; as score) { +
    + +

    {{ score }}

    +
    + } +
    + +
    + @if (taskDetailInfo()?.creator; as creator) { +
    + +

    + {{ creator.firstName + " " + creator.lastName[0] }} +

    +
    + } + +

    {{ taskDetailInfo()?.datetimeCreated | date: "dd.MM.yyyy HH:mm" }}

    +
    + +
    + divider image +
    + +
    + @if (taskDetailForm.get("responsible"); as responsible) { +
    +
    + +
    ответственный
    +
    + +
    + @if (responsible.value) { +
    + + + @if (showEditAvatarIcon) { + + } + + +
    + +

    + {{ responsible.value.name }} +

    + } @else { +
    + + +
    + } +
    +
    + } + @if (taskDetailForm.get("performers"); as performers) { +
    +
    + +
    исполнители
    +
    + +
    + @if (performers.value && performers.value.length >= 0 && performers.value.length <= 7) { +
    + @for (performer of performers.value; track $index) { + + } +
    + } + @if (!(performers.value && performers.value.length === 7)) { +
    + + +
    + } +
    +
    + } + @if (taskDetailForm.get("startDate"); as startDate) { +
    +
    + +

    начало

    +
    + +
    + @if (!showEditStartDatePicker) { +

    + {{ startDate.value | date: "dd.MM.yy" }} +

    + } @else { + + } + + {{ statusOfTask.text }} +
    +
    + } + @if (taskDetailForm.get("deadline"); as deadline) { +
    +
    + +

    дедлайн

    +
    + +
    + @if (!showEditDeadlineDatePicker) { +

    + {{ deadline.value | date: "dd.MM.yy" }} +

    + } @else { + + } + + {{ + remainingDaysDeadline() + + " " + + (remainingDaysDeadline() | pluralize: ["день", "дня", "дней"]) + }} +
    +
    + } +
    + +
    + @if (taskDetailForm.get("tags"); as tags) { +
    +
    + +

    тег

    +
    + + @if (tags.value.length > 0) { +
    + @for (tag of tags.value; track tag.id) { + {{ "#" + tag.title }} + } +
    + } + @if (tags.value.length < 3) { +
    + + +
    + } +
    + } + @if (taskDetailForm.get("goal"); as goal) { +
    +
    + +

    цель

    +
    + + @if (goal.value) { + {{ + goal.value.title.length > 20 + ? goal.value.title.slice(0, 15) + "..." + : goal.value.title + }} + + +
    +

    вы хотите изменить цель задачи?

    + +
    + отмена + изменить +
    +
    +
    + } @else { +
    + +
    + } + +
    + } + @if (taskDetailForm.get("skills"); as skills) { +
    +
    + +

    навыки

    +
    + + @if (skills.value.length > 0) { +
    + @for (skill of skills.value; track skill.id) { + {{ + skill.name.length > 20 ? skill.name.slice(0, 17) + "..." : skill.name + }} + } +
    + } + @if (skills.value.length < 3) { +
    + +
    + } +
    + } +
    + + + + + +
    + divider image +
    + + @if (taskDetailForm.get("description"); as description) { +
    +
    + @if (isChangeDescriptionText()) { +

    + @if (descriptionExpandable) { +
    + {{ readFullDescription ? "cкрыть" : "подробнее" }} +
    + } + } @else { + + } +
    +
    + } + +
    + @if (taskDetailForm.get("files")?.value; as files) { + @if (files.length > 0) { + @for (file of files; track $index) { + + } + } + @if (files.length < 3) { +
    + + +

    файл

    +
    + } + } +
    + +
    + divider image +
    + +
    + +
    +

    {{ message.createdAt | date: "dd MMMM yyyy" }}

    +
    + +
    +
    + + {{ message.author?.firstName }} {{ message.author?.lastName }} + + {{ + message.createdAt | date: "HH:MM" + }} +
    + +

    {{ message.text }}

    +
    +
    +
    +
    + +
    + @if (messageForm.get("text"); as text) { +
    + + +
    + + + +
    +
    + } +
    +
    +
    + + +
    +
    +

    результат выполнения задачи

    + +
    + +
    + @if (sendResultForm.get("description"); as description) { +
    + + @if (description | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } + @if (description | controlError: "maxlength") { +
    + {{ errorMessage.VALIDATION_TOO_LONG }} + @if (description.errors) { + {{ description.errors["maxlength"]["requiredLength"] }} + } +
    + } + @if (description | controlError: "minlength") { +
    + {{ errorMessage.VALIDATION_TOO_SHORT }} + @if (description.errors) { + {{ description.errors["minlength"]["requiredLength"] }} + } +
    + } +
    + } + @if (sendResultForm.get("accompanyingFile"); as accompanyingFile) { +
    + + +
    + +

    + файл в любом формате
    весом до 100МБ +

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

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

    + } +
    +
    +
    + } + + {{ + taskDetailInfo()?.result ? "сохранить изменения в результате работы" : "завершить задачу" + }} +
    +
    +
    + + +
    +
    +

    результат выполнения задачи

    + +
    + + @if (taskDetailInfo()?.result; as result) { +

    {{ result.description }}

    + + + } +
    +
    + + +
    +

    принять выполнение задачи

    + + + +
    + принять + + комментировать +
    +
    +
    + + +
    +

    вы уверены, что хотите снять приемку работы?

    + + + + удалить результат +
    +
    + + +
    +

    + вы уверены, что хотите удалить текущий результат работы +

    + + + + отменить приемку работы +
    +
    + + +
    +

    + вы уверены, что исправили результат по комментариям от лидера? +

    + + + + комментарии решены +
    +
    diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/task/detail/task-detail.component.scss b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/task/detail/task-detail.component.scss new file mode 100644 index 000000000..d4a1e1ec9 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/task/detail/task-detail.component.scss @@ -0,0 +1,573 @@ +@use "styles/typography"; + +.kanban { + &__detail { + position: absolute; + top: 0%; + right: 0%; + width: 100%; + max-width: 422px; + padding: 24px; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); + + &-top { + display: flex; + gap: 20px; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; + + &-menu { + display: flex; + gap: 10px; + align-items: center; + } + + &--accept { + width: 23px; + height: 23px; + cursor: pointer; + border: 0.5px solid var(--accent); + border-radius: var(--rounded-md); + } + } + + &-buttons { + display: flex; + gap: 12px; + align-items: center; + } + + &-type, + &-priority-wrapper, + &-delete-wrapper { + cursor: pointer; + } + + &-priority { + width: 15px; + height: 15px; + background-color: var(--green); + border-radius: var(--rounded-xxl); + } + + &-general { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; + margin-bottom: 5px; + border-bottom: 0.5px solid var(--accent); + + &-score { + display: flex; + gap: 3px; + align-items: center; + } + + p { + color: var(--accent) !important; + } + } + + p { color: var(--grey-for-text); } + + &-info { + display: flex; + gap: 10px; + align-items: flex-start; + margin-top: 15px; + + &-name { + display: flex; + gap: 3px; + align-items: center; + + ::ng-deep { + app-input { + .field { + width: 44px !important; + height: 23px !important; + } + + .field__input { + max-width: 44px; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; + + &:focus { + border-color: 0; + box-shadow: none; + } + + @include typography.body-10; + } + } + } + + &--date { + display: flex; + gap: 3px; + align-items: center; + + p, + i { + color: var(--green-dark) !important; + } + } + + h6 { + color: var(--accent); + } + + p { + color: var(--accent); + } + } + + &-list { + display: flex; + flex-direction: column; + gap: 4px; + } + } + + &-info-wrapper { + display: flex; + flex-direction: column; + gap: 5px; + + app-tag { + cursor: pointer; + } + } + + &-add-responsible { + padding: 8px; + cursor: pointer; + border: 0.5px dashed var(--accent); + border-radius: var(--rounded-xxl); + } + + &-avatar { + position: relative; + cursor: pointer; + + i { + position: absolute; + top: 50%; + left: 50%; + color: var(--light-white) !important; + transform: translate(-50%, -50%); + } + } + + &-performers { + display: flex; + gap: 4px; + align-items: center; + margin-left: 12px; + + app-avatar { + margin-left: -12px; + } + } + + &-add-object { + display: flex; + align-items: center; + align-self: center; + justify-content: center; + width: 100%; + padding: 2px 48px; + cursor: pointer; + border: 0.5px solid var(--accent); + border-radius: var(--rounded-xl); + + ::ng-deep { + app-tag { + .tag { + max-width: 100px; + } + } + } + } + + &-description { + margin: 12px 0; + + ::ng-deep { + app-textarea { + .field__input { + min-height: 40px; + padding: 12px 12px 0; + margin-left: -10px; + background-color: transparent; + border: none; + border-radius: 0; + + &:focus { + border-color: none; + box-shadow: none; + } + } + } + } + } + + &-files { + display: flex; + gap: 10px; + align-items: center; + margin-bottom: 10px; + } + + &-file { + width: 116px; + + &--empty { + display: flex; + gap: 5px; + align-items: center; + } + } + + &-comments { + display: flex; + flex-direction: column; + margin-top: 20px; + } + + i { + color: var(--accent); + } + } + + &__divider { + width: 100%; + margin-top: 5px; + } +} + +.messages { + &__wrapper { + margin-bottom: 12px; + } + + &__date { + color: var(--grey-for-text); + text-align: center; + } +} + +.message { + display: flex; + gap: 10px; + align-items: flex-start; + + &__wrapper { + display: flex; + flex-direction: column; + flex-grow: 1; + gap: 5px; + width: 100%; + } + + &__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + } + + &__author { + color: var(--accent) !important; + } + + &__time { + color: var(--grey-for-text) !important; + } + + p { + color: var(--black); + } +} + +.about { + /* stylelint-disable value-no-vendor-prefix */ + &__text { + p { + display: -webkit-box; + overflow: hidden; + color: var(--grey-for-text); + text-overflow: ellipsis; + -webkit-box-orient: vertical; + -webkit-line-clamp: 5; + transition: all 0.7s ease-in-out; + + &.expanded { + -webkit-line-clamp: unset; + } + } + + ::ng-deep a { + color: var(--accent); + text-decoration: underline; + text-decoration-color: transparent; + text-underline-offset: 3px; + transition: text-decoration-color 0.2s; + + &:hover { + text-decoration-color: var(--accent); + } + } + } + + /* stylelint-enable value-no-vendor-prefix */ + + &__read-full { + margin-top: 2px; + color: var(--accent); + cursor: pointer; + } +} + +.read-more { + margin-top: 12px; + color: var(--accent); + cursor: pointer; + transition: color 0.2s; + + &:hover { + color: var(--accent-dark); + } +} + +.form { + display: flex; + align-items: center; + justify-content: space-between; + width: 422px; + height: 28px; + padding: 10px 24px; + margin-bottom: -25px; + margin-left: -25px; + 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; + width: 100%; + + ::ng-deep { + app-input { + .field__input { + width: 100%; + color: var(--grey-for-text) !important; + background-color: transparent; + } + } + } + } + + &__files { + display: flex; + flex-direction: column; + gap: 10px; + + &:not(:empty) { + margin-top: 20px; + } + } + + &__actions { + display: flex; + gap: 5px; + align-items: center; + cursor: pointer; + + i { + color: var(--light-white); + } + + input { + display: none; + } + } + + &__attach { + i { + color: var(--dark-grey); + opacity: 0.5; + } + } + + &__send { + width: 15px; + height: 15px; + padding: 4px; + color: var(--accent); + cursor: pointer; + background-color: var(--accent); + border-radius: var(--rounded-sm); + + i { + color: var(--white); + } + } + + &__input { + flex-grow: 1; + resize: none; + background: transparent; + border: none; + outline: none; + } +} + +.result { + &__form { + display: flex; + flex-direction: column; + gap: 10px; + + label { + color: var(--black); + } + + &-error { + border: 0.5px solid var(--red); + } + + &--cv { + &-empty { + display: flex; + flex-direction: column; + gap: 12px; + align-items: center; + color: var(--grey-for-text); + } + } + } +} + +.lists { + &__section { + display: flex; + justify-content: space-between; + width: 100%; + margin-bottom: 8px; + border-bottom: 0.5px solid var(--accent); + } + + &__icon { + color: var(--accent); + } + + &__title { + margin-bottom: 8px; + color: var(--accent); + } +} + +.modal { + &__wrapper { + display: flex; + flex-direction: column; + gap: 20px; + align-items: center; + width: 672px; + } + + &__content { + display: flex; + flex-direction: column; + gap: 20px; + width: 100%; + max-width: 536px; + height: 480px; + background-color: var(--white); + border: 1px solid var(--medium-grey-for-outline); + border-radius: 8px; + box-shadow: 5px 5px 25px 0 var(--gray-for-shadow); + } + + &__specs-groups, + &__skills-groups { + height: 100%; + overflow: auto; + scrollbar-width: thin; + + ul { + display: flex; + flex-direction: column; + gap: 20px; + padding: 14px; + + @for $i from 1 through 8 { + > li:nth-child(#{$i}) { + ::ng-deep app-skills-group .content--open { + top: 0; + margin-top: -#{($i - 1) * 50}px; + } + } + } + } + + li { + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + } + } +} + +.cancel { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + max-height: calc(100vh - 40px); + padding: 24px; + overflow-y: auto; + + &__title { + text-align: center; + } + + &__text { + color: var(--grey-for-text) !important; + } + + &__buttons { + display: flex; + gap: 10px; + align-items: center; + } + + &__button { + margin-top: 20px; + } + + &__icon { + margin: 36px 0; + } + + &__footer { + display: flex; + align-items: center; + width: 100%; + + &-actions { + display: flex; + gap: 20px; + align-items: center; + } + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/task/detail/task-detail.component.ts b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/task/detail/task-detail.component.ts new file mode 100644 index 000000000..43c6e7d2a --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/task/detail/task-detail.component.ts @@ -0,0 +1,818 @@ +/** @format */ + +import { CommonModule, DatePipe } from "@angular/common"; +import { + AfterViewInit, + ChangeDetectorRef, + Component, + ElementRef, + EventEmitter, + inject, + OnDestroy, + OnInit, + Output, + signal, + viewChild, +} from "@angular/core"; +import { InputComponent, ButtonComponent } from "@ui/primitives"; +import { FileItemComponent } from "@ui/primitives/file-item/file-item.component"; +import { TextareaComponent } from "@ui/primitives/textarea/textarea.component"; +import { TagComponent } from "@ui/primitives/tag/tag.component"; +import { BadgeComponent } from "./badge/badge.component"; +import { AvatarComponent } from "@ui/primitives/avatar/avatar.component"; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; +import { + ControlErrorPipe, + ParseBreaksPipe, + ParseLinksPipe, + PluralizePipe, + ValidationService, +} from "@corelib"; +import { expandElement } from "@utils/expand-element"; +import { IconComponent } from "@uilib"; +import { nanoid } from "nanoid"; +import { DropdownComponent } from "@ui/primitives/dropdown/dropdown.component"; +import { priorityInfoList } from "@core/consts/lists/priority-info-list.const"; +import { ClickOutsideModule } from "ng-click-outside"; +import { actionTypeList } from "@core/consts/lists/action-type-list.const"; +import { SkillsGroupComponent } from "@ui/widgets/skills-group/skills-group.component"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { map, Subscription } from "rxjs"; +import { TaskDetail } from "@domain/kanban/task.model"; +import { daysUntil } from "@utils/days-until"; +import { KanbanBoardService } from "@api/kanban/kanban-board.service"; +import { getPriorityType } from "@utils/getPriorityType"; +import { getActionType } from "@utils/getActionType"; +import { ActivatedRoute } from "@angular/router"; +import { FileService } from "@core/lib/services/file/file.service"; +import { UploadFileComponent } from "@ui/primitives/upload-file/upload-file.component"; +import { ErrorMessage } from "@core/lib/models/error/error-message"; +import { SnackbarService } from "@domain/shared/snackbar.service"; +import { ChatMessage } from "@domain/chat/chat-message.model"; +import { + CdkFixedSizeVirtualScroll, + CdkVirtualForOf, + CdkVirtualScrollViewport, +} from "@angular/cdk/scrolling"; +import { TagDto } from "@domain/kanban/dto/tag.model.dto"; +import { PerformerDto } from "@domain/kanban/dto/performer.model.dto"; +import { KanbanBoardDetailInfoService } from "@api/kanban/kanban-board-detail-info.service"; +import { Skill } from "@domain/skills/skill.model"; +import { ProjectsDetailUIInfoService } from "@api/project/facades/detail/ui/projects-detail-ui.service"; +import { SkillsRepositoryPort } from "@domain/skills/ports/skills.repository.port"; + +/** Канбан (модуль отключён): детальная карточка задачи. */ +@Component({ + selector: "app-task-detail", + templateUrl: "./task-detail.component.html", + styleUrl: "./task-detail.component.scss", + imports: [ + CommonModule, + ReactiveFormsModule, + InputComponent, + IconComponent, + FileItemComponent, + TextareaComponent, + TagComponent, + BadgeComponent, + AvatarComponent, + ButtonComponent, + ParseLinksPipe, + ParseBreaksPipe, + DatePipe, + DropdownComponent, + ClickOutsideModule, + SkillsGroupComponent, + ModalComponent, + ControlErrorPipe, + PluralizePipe, + UploadFileComponent, + CdkFixedSizeVirtualScroll, + CdkVirtualScrollViewport, + CdkVirtualForOf, + ], + standalone: true, +}) +export class TaskDetailComponent implements OnInit, AfterViewInit, OnDestroy { + @Output() delete = new EventEmitter(); + + readonly descEl = viewChild("descEl"); + /** Ссылка на viewport для автопрокрутки */ + readonly viewport = viewChild(CdkVirtualScrollViewport); + + private readonly skillsRepository = inject(SkillsRepositoryPort); + private readonly kanbanBoardService = inject(KanbanBoardService); + private readonly kanbanBoardDetailInfoService = inject(KanbanBoardDetailInfoService); + private readonly projectsDetailUIInfoService = inject(ProjectsDetailUIInfoService); + private readonly fileService = inject(FileService); + private readonly snackbarService = inject(SnackbarService); + private readonly validationService = inject(ValidationService); + private readonly route = inject(ActivatedRoute); + private readonly cdRef = inject(ChangeDetectorRef); + + constructor(private readonly fb: FormBuilder) { + this.taskDetailForm = this.fb.group({ + title: ["", Validators.required], + responsible: [], + performers: [], + startDate: [null], + deadline: [null], + tags: [[]], + goal: [null], + skills: [[]], + description: [null], + files: [[]], + priority: [null], + status: [null], + type: [null], + score: [null], + tagsLib: [[]], + }); + + this.sendResultForm = this.fb.group({ + description: ["", Validators.required, Validators.maxLength(200)], + accompanyingFile: ["", Validators.required], + }); + + this.messageForm = this.fb.group({ + text: [""], + files: [[]], + }); + } + + taskDetailForm: FormGroup; + + messageForm: FormGroup; + /** Сообщение, на которое отвечаем */ + replyMessage?: ChatMessage; + messages = signal([ + // { + // id: 1, + // text: "123", + // author: { + // id: 11, + // avatar: "https://api.selcdn.ru/v1/SEL_228194/procollab_media/5388035211510428528/2458680223122098610_2202079899633949339.webp", + // firstName: "Егоg", + // lastName: "Токареg", + // }, + // createdAt: new Date().toISOString(), + // isRead: false, + // replyTo: this.replyMessage?.id ?? null, + // files: [], + // } + ]); + + isChangeDescriptionText = signal(false); + + remainingDaysDeadline = signal(0); + + editingTag: TagDto | null = null; + + /** Уникальный ID для элемента input */ + controlId = nanoid(3); + + /** Объект с сообщениями об ошибках */ + errorMessage = ErrorMessage; + + descriptionExpandable!: boolean; // Флаг необходимости кнопки "подробнее" + readFullDescription = false; // Флаг показа описания + + isActionTypeOpen = false; + isPriorityTypeOpen = false; + isDeleteTypeOpen = false; + isResponsiblePickOpen = false; + isPerformersPickOpen = false; + isGoalPickOpen = false; + isTagsPickOpen = false; + + isCommentedClick = false; + isCompletedTask = false; + + creatingTag = false; + loadingFile = false; + sendFormIsSubmitting = false; + + showEditAvatarIcon = false; + showEditDeadlineDatePicker = false; + showEditStartDatePicker = false; + showChangeGoalModal = false; + + showAttachResultModal = false; + showResultModal = false; + showAcceptResultModal = false; + showUnAcceptResultModal = false; + showDeleteResultModal = false; + showLeaderCommentedResultModal = false; + + skillsGroupsModalOpen = signal(false); + nestedSkills$ = this.skillsRepository.getSkillsNested(); + openGroupIds = new Set(); + openGroupIndex: number | null = null; + + /** Массив прикрепленных файлов с метаданными */ + attachFiles: { + name: string; + size: string; + type: string; + link?: string; + loading: boolean; + }[] = []; + + /** Форма отправки результата */ + sendResultForm: FormGroup; + + getPriorityType = getPriorityType; + getActionType = getActionType; + + subscriptions: Subscription[] = []; + + readonly taskDetailInfo = this.kanbanBoardDetailInfoService.taskDetail; + readonly leaderId = this.kanbanBoardDetailInfoService.leaderId; + readonly collaborators = this.projectsDetailUIInfoService.collaborators; + readonly goals = this.projectsDetailUIInfoService.goals; + readonly isLeaderLeaveComment = this.kanbanBoardDetailInfoService.isLeaderLeaveComment; + readonly isLeader = this.kanbanBoardDetailInfoService.isLeader; + readonly isPerformer = this.kanbanBoardDetailInfoService.isPerformer; + readonly isResponsible = this.kanbanBoardDetailInfoService.isResponsible; + readonly isCreator = this.kanbanBoardDetailInfoService.isCreator; + readonly isExternal = this.kanbanBoardDetailInfoService.isExternal; + readonly isTaskResult = this.kanbanBoardDetailInfoService.isTaskResult; + readonly isLeaderAcceptResult = this.kanbanBoardDetailInfoService.isLeaderAcceptResult; + + get actionTypeOptions() { + return actionTypeList; + } + + get priorityTypeOptions() { + return priorityInfoList + .map(priority => ({ + id: priority.id, + label: priority.label, + value: priority.priorityType, + additionalInfo: priority.priorityType.toString(), + })) + .reverse(); + } + + get responsiblePickOpenOptions() { + const collabs = this.collaborators(); + return collabs?.length + ? collabs.map(collaborator => ({ + id: collaborator.userId, + label: collaborator.firstName + " " + collaborator.lastName[0], + value: collaborator.userId, + additionalInfo: collaborator.avatar, + })) + : []; + } + + get goalPickOptions() { + const items = this.goals(); + return items?.length + ? items.map(goal => ({ + id: goal.id, + label: goal.title, + value: goal.id, + additionalInfo: goal.title, + })) + : []; + } + + get tagsPickOptions() { + const tagsLib = this.taskDetailForm.get("tagsLib")?.value || []; + return [...tagsLib]; + } + + get priorityDeleteOptions() { + return [ + { + id: 1, + label: "удалить задачу", + value: "delete", + }, + ]; + } + + get hasOpenSkillsGroups(): boolean { + return this.openGroupIndex !== null; + } + + get selectedSkills(): Skill[] { + return this.taskDetailForm.getRawValue().skills || []; + } + + get statusOfTask() { + const start = new Date(this.taskDetailForm.value.startDate); + const deadline = new Date(this.taskDetailForm.value.deadline); + + const status = this.getTaskStatus(start, deadline); + + let days: number | null = null; + + if (status === "ожидание") { + days = daysUntil(start); + } else if (status === "началась") { + days = daysUntil(deadline); + } + + const color = status === "закончена" ? "red" : this.getColorByDays(days ?? 0); + + return { + text: status, + color, + }; + } + + get isCommented() { + return this.messages().length > 0 && this.isLeaderLeaveComment(); + } + + get isCommentedByLeader() { + return ( + this.messages().some((comments: ChatMessage) => comments.author.id === this.leaderId()) && + this.isLeaderLeaveComment() + ); + } + + ngOnInit(): void { + this.updateDescriptionState(); + } + + ngAfterViewInit(): void { + const todayDate = new Date(); + + this.initializeTaskDetailInfo(todayDate); + this.checkDescriptionHeigth(); + } + + ngOnDestroy(): void { + this.subscriptions.forEach($ => $.unsubscribe()); + } + + onDeleteTaskGoal(): void { + this.taskDetailForm.patchValue({ goal: null }); + this.isGoalPickOpen = false; + } + + onDeleteTaskTag(tagId: number): void { + const tags = this.taskDetailForm.get("tags")?.value || []; + + const remainingTags = tags.filter((tag: TagDto) => tag.id !== tagId); + this.taskDetailForm.patchValue({ tags: remainingTags }); + + this.isTagsPickOpen = false; + } + + onEditTaskTag(tagId: number): void { + this.isTagsPickOpen = !this.isTagsPickOpen; + this.creatingTag = true; + + const tags: TagDto[] = this.taskDetailForm.get("tags")?.value || []; + const tag = tags.find((tag: TagDto) => tag.id === tagId); + + if (tag) { + this.editingTag = { + id: tag.id, + name: tag.name, + color: tag.color, + }; + } + } + + onUpdateTag({ id, name, color }: TagDto): void { + const tagsLib = [...(this.taskDetailForm.get("tagsLib")?.value || [])]; + const libIndex = tagsLib.findIndex((tag: TagDto) => tag.id === id); + + if (libIndex !== -1) { + tagsLib[libIndex] = { + ...tagsLib[libIndex], + label: name, + value: name, + additionalInfo: color, + }; + + this.taskDetailForm.patchValue({ tagsLib: [...tagsLib] }); + } + + const tags: TagDto[] = [...(this.taskDetailForm.get("tags")?.value || [])]; + const tagIndex = tags.findIndex((tag: TagDto) => tag.id === id); + + if (tagIndex !== -1) { + tags[tagIndex] = { + ...tags[tagIndex], + name, + color, + }; + this.taskDetailForm.patchValue({ tags: [...tags] }); + } + + this.editingTag = null; + this.creatingTag = false; + } + + createTag({ name, color }: { name: string; color: string }): void { + const { tagsLib } = this.taskDetailForm.value; + const tagInfo = { id: tagsLib.length + 1, label: name, value: name, additionalInfo: color }; + tagsLib.setValue([...tagsLib, tagInfo]); + } + + onTypeSelect( + type: "type" | "priority" | "responsible" | "performers" | "goal" | "tags" | "delete", + state: boolean, + typeId?: number, + ) { + switch (type) { + case "type": + if (this.isLeader() || this.isCreator()) { + this.isActionTypeOpen = state; + this.taskDetailForm.patchValue({ action: typeId }); + } + + break; + + case "priority": + if (this.isLeader() || this.isCreator()) { + this.isPriorityTypeOpen = state; + this.taskDetailForm.patchValue({ priority: typeId }); + } + + break; + + case "responsible": { + if (this.isLeader() || this.isCreator()) { + this.isResponsiblePickOpen = state; + + if (typeId !== undefined) { + if (!this.collaborators()) return; + + const responsible = this.collaborators()?.find( + collaborator => collaborator.userId === typeId, + ); + this.taskDetailForm.patchValue({ + responsible: { + id: responsible?.userId, + avatar: responsible?.avatar, + name: responsible?.firstName + " " + responsible?.lastName[0], + }, + }); + } + } + break; + } + + case "performers": { + if (this.isResponsible() || this.isLeader() || this.isCreator()) { + this.isPerformersPickOpen = state; + + if (typeId !== undefined) { + if (!this.collaborators()) return; + + const collaborator = this.collaborators()?.find( + collaborator => collaborator.userId === typeId, + ); + const currentPerformers: PerformerDto[] = + this.taskDetailForm.get("performers")?.value || []; + const payload = { + id: collaborator?.userId, + avatar: collaborator?.avatar, + name: collaborator?.firstName + " " + collaborator?.lastName[0], + }; + + if (currentPerformers.some((performer: PerformerDto) => performer.id === payload.id)) + return; + + this.taskDetailForm.patchValue({ + performers: [...currentPerformers, payload], + }); + } + } + break; + } + + case "goal": { + if (this.isLeader() || this.isCreator()) { + this.isGoalPickOpen = state; + + if (typeId !== undefined) { + if (!this.goals()) return; + + const goal = this.goals()?.find(goal => goal.id === typeId); + if (goal) this.taskDetailForm.patchValue({ goal: { id: goal.id, title: goal.title } }); + } + } + + break; + } + + case "tags": { + if (this.isLeader() || this.isCreator()) { + this.isTagsPickOpen = state; + + if (!state) { + this.editingTag = null; + this.creatingTag = false; + } + + if (typeId !== undefined) { + const tag = this.tagsPickOptions.find((tag: TagDto) => tag.id === typeId); + const payload = { id: tag?.id, title: tag?.label, color: tag?.additionalInfo }; + + const currentTags = this.taskDetailForm.get("tags")?.value || []; + if (currentTags.some((tag: TagDto) => tag.id === payload.id)) return; + this.taskDetailForm.patchValue({ tags: [...currentTags, payload] }); + } else { + this.editingTag = null; + this.creatingTag = false; + } + } + + break; + } + + case "delete": { + this.isDeleteTypeOpen = state; + + if (typeId !== undefined) { + const taskId = +this.route.snapshot.queryParams["taskId"]; + + if (this.isCreator() || this.isLeader()) { + this.kanbanBoardDetailInfoService.requestDeleteTask(taskId); + } + } + + break; + } + + default: + break; + } + } + + onChangeText(event: MouseEvent): void { + event.stopPropagation(); + this.isChangeDescriptionText.set(false); + + setTimeout(() => this.checkDescriptionHeigth(), 0); + } + + onExpandDescription(elem: HTMLElement, expandedClass: string, isExpanded: boolean): void { + expandElement(elem, expandedClass, isExpanded); + this.readFullDescription = !isExpanded; + } + + onUpdate(event: Event): void { + const input = event.currentTarget as HTMLInputElement; + const file = input.files?.[0]; + if (!file) { + return; + } + + this.loadingFile = true; + + this.fileService.uploadFile(file).subscribe(url => { + const currentFiles = this.taskDetailForm.get("files")?.value || []; + + const newFile = { + name: file.name.split, + link: url, + extension: file.type, + size: file.size, + }; + + this.taskDetailForm.get("files")?.setValue([...currentFiles, newFile]); + input.value = ""; + + this.loadingFile = false; + }); + } + + onUpload(evt: Event) { + const files = (evt.currentTarget as HTMLInputElement).files; + + if (!files?.length) { + return; + } + + this.addFiles(files); + } + + private addFiles(files: FileList): void { + for (let i = 0; i < files.length; i++) { + this.attachFiles.push({ + name: files[i].name, + size: files[i].size.toString(), + type: files[i].type, + loading: true, + }); + } + + for (let i = 0; i < files.length; i++) { + this.fileService + .uploadFile(files[i]) + .pipe(map(r => r.url)) + .subscribe({ + next: url => { + setTimeout(() => { + this.attachFiles[i].loading = false; + this.attachFiles[i].link = url; + }); + }, + complete: () => { + setTimeout(() => { + this.attachFiles[i].loading = false; + }); + }, + }); + } + } + + onToggleSkill(toggledSkill: Skill): void { + const { skills }: { skills: Skill[] } = this.taskDetailForm.value; + const isPresent = skills.some(skill => skill.id === toggledSkill.id); + + if (isPresent) { + this.onRemoveSkill(toggledSkill); + } else { + if (skills.length < 3) { + this.onAddSkill(toggledSkill); + } + } + } + + onGroupToggled(isOpen: boolean, skillsGroupId: number): void { + this.openGroupIndex = isOpen ? skillsGroupId : null; + this.cdRef.markForCheck(); + } + + isGroupDisabled(skillsGroupId: number): boolean { + return this.openGroupIndex !== null && this.openGroupIndex !== skillsGroupId; + } + + toggleSkillsGroupsModal(): void { + this.skillsGroupsModalOpen.update(open => !open); + } + + onSubmit(): void { + if (!this.validationService.getFormValidation(this.sendResultForm)) { + return; + } + + this.sendFormIsSubmitting = true; + + // TODO: Отправка отклика на сервер + // this.snackbarService.success("результат работы успешно прикреплен"); + + this.showAttachResultModal = false; + this.showResultModal = false; + } + + onSubmitMessage(): void { + const text = this.messageForm.get("text")?.value?.trim(); + + if (!text) return; + + const newMessage = { + id: Date.now(), + text, + author: this.kanbanBoardDetailInfoService.currentUser(), + createdAt: new Date().toISOString(), + isRead: false, + replyTo: this.replyMessage?.id ?? null, + files: this.messageForm.get("files")?.value || [], + }; + + this.messages.update(messages => [...messages, newMessage]); + + this.messageForm.reset({ + text: "", + files: [], + }); + + this.replyMessage = undefined; + this.scrollToBottom(); + } + + onEnterKeyDown(event: Event): void { + const keyboardEvent = event as KeyboardEvent; + + if (keyboardEvent.key === "Enter" && !keyboardEvent.shiftKey) { + event.preventDefault(); + this.onSubmitMessage(); + } + } + + onReplyMessage(messageId: number): void { + this.replyMessage = this.messages().find(message => message.id === messageId); + } + + onCancelReply(): void { + this.replyMessage = undefined; + } + + private updateDescriptionState() { + const descriptionControl = this.taskDetailForm.get("description"); + + if (!(this.isLeader() || this.isCreator())) { + descriptionControl?.disable(); + } else { + descriptionControl?.enable(); + } + } + + private onAddSkill(newSkill: Skill): void { + const { skills }: { skills: Skill[] } = this.taskDetailForm.value; + const isPresent = skills.some(skill => skill.id === newSkill.id); + + if (isPresent) return; + + this.taskDetailForm.patchValue({ skills: [newSkill, ...skills] }); + this.cdRef.markForCheck(); + } + + scrollToBottom(): void { + setTimeout(() => { + this.viewport()?.scrollTo({ bottom: 0 }); + }, 50); + } + + private onRemoveSkill(oddSkill: Skill): void { + const { skills }: { skills: Skill[] } = this.taskDetailForm.value; + + this.taskDetailForm.patchValue({ + skills: skills.filter(skill => skill.id !== oddSkill.id), + }); + this.cdRef.markForCheck(); + } + + private checkDescriptionHeigth(): void { + const descElement = this.descEl()?.nativeElement; + this.descriptionExpandable = descElement?.clientHeight < descElement?.scrollHeight; + + this.cdRef.detectChanges(); + } + + private initializeTaskDetailInfo(todayDate: Date): void { + const startDateDefault = new Date(todayDate); + startDateDefault.setDate(todayDate.getDate() + 1); + + const deadlineDefault = new Date(todayDate); + deadlineDefault.setDate(todayDate.getDate() + 2); + + const taskId = this.route.snapshot.queryParams["taskId"]; + + const taskDetailInfo$ = this.kanbanBoardService.getTaskById(taskId).subscribe({ + next: (taskDetailInfo: TaskDetail) => { + this.kanbanBoardDetailInfoService.setTaskDetailInfo(taskDetailInfo); + + this.taskDetailForm.patchValue({ + title: taskDetailInfo.title ?? "", + type: taskDetailInfo.type ?? 1, + priority: taskDetailInfo.priority ?? 1, + responsible: taskDetailInfo.responsible ?? null, + performers: taskDetailInfo.performers ?? [], + startDate: taskDetailInfo.datetimeTaskStart ?? startDateDefault, + deadline: taskDetailInfo.deadlineDate + ? new Date(taskDetailInfo.deadlineDate) + : deadlineDefault, + tags: taskDetailInfo.tags ?? [], + goal: taskDetailInfo.goal ?? null, + skills: taskDetailInfo.requiredSkills ?? [], + description: taskDetailInfo.description ?? null, + files: taskDetailInfo.files ?? [], + score: taskDetailInfo.score ?? 1, + creator: taskDetailInfo.creator ?? this.kanbanBoardDetailInfoService.currentUser(), + createdAt: taskDetailInfo.datetimeCreated ?? new Date(), + }); + + if (taskDetailInfo.result) { + this.sendResultForm.patchValue({ + description: taskDetailInfo.result.description, + accompanyingFile: taskDetailInfo.result.accompanyingFile, + }); + } + + this.remainingDaysDeadline.set( + daysUntil( + taskDetailInfo.deadlineDate ? new Date(taskDetailInfo.deadlineDate) : deadlineDefault, + ), + ); + }, + }); + + this.subscriptions.push(taskDetailInfo$); + } + + private getColorByDays(days: number): "red" | "gold" | "green" { + if (days <= 3) return "red"; + if (days <= 7) return "gold"; + return "green"; + } + + private getTaskStatus(start: Date, deadline: Date) { + const now = new Date(); + + if (now < start) return "ожидание"; + if (now > deadline) return "закончена"; + return "началась"; + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/task/kanban-task.component.html b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/task/kanban-task.component.html new file mode 100644 index 000000000..f5a7d4624 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/task/kanban-task.component.html @@ -0,0 +1,102 @@ + + +
    +
    + {{ task.title }} + @if (task.description) { +

    {{ task.description }}

    + } + @if ( + task.responsible || (task.performers && task.performers.length > 0) || task.files || task.type + ) { +
    +
    + @if (task.responsible; as responsible) { +
    + + +
    + } +
    + + @if (task.performers; as performers) { + @if (performers.length > 0) { + @for (performer of performers; track $index) { + + } + } + } +
    +
    + + @if (task.files || task.type) { +
    + + +
    + } +
    + } + @if (task.deadlineDate || task.tags || task.goal) { +
    +
    + @if (task.deadlineDate) { + @if (!isArchivePage()) { +
    + +

    + {{ task.deadlineDate }} +

    +
    + } @else if (diffDays() || task.deadlineDate || task.startDate) { + {{ + diffDays() + " " + (diffDays()! | pluralize: ["день", "дня", "дней"]) + }} + } + } + @if (isArchivePage() && isOverdue()) { + просрочено + } + @if (task.tags[0]; as tag) { + {{ "#" + tag.name }} + } + @if (!isArchivePage()) { + @if (task.goal; as goal) { + {{ goal.title }} + } + } +
    + + @if (isLeader()) { + отменить + } +
    + } +
    + +
    + @if (task.priority) { +
    + } + +
    +
    + + +
    +
    +

    отмена выполнения задачи

    + +
    + + +
    +
    diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/task/kanban-task.component.scss b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/task/kanban-task.component.scss new file mode 100644 index 000000000..647607425 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/task/kanban-task.component.scss @@ -0,0 +1,165 @@ +:host { + width: 100%; +} + +.kanban { + &__task { + display: flex; + gap: 10px; + width: 100%; + max-width: 245px; + padding: 12px; + cursor: pointer; + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); + transition: opacity 0.2s ease-in; + + &:hover { + opacity: 0.7; + } + + &-left { + display: flex; + flex-direction: column; + flex-grow: 1; + gap: 5px; + align-items: flex-start; + } + + &-right { + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-between; + } + + &-mid { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + } + + &-priority { + width: 6px; + height: 6px; + border-radius: var(--rounded-xxl); + } + + &-deadline, + &-bottom { + display: flex; + gap: 2px; + align-items: center; + + p { + color: var(--accent) !important; + } + + ::ng-deep { + app-tag { + .tag { + padding: 0; + } + } + } + } + + &-bottom--start { + display: flex; + gap: 4px; + align-items: center; + } + + &-bottom--completed { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + } + + &-cancel { + ::ng-deep { + app-tag { + .tag { + padding: 0; + margin: 0; + } + } + } + } + + &-people { + display: flex; + gap: 3px; + align-items: center; + align-items: flex-start; + } + + &-responsible, + &-deadline, + &-performers { + display: flex; + gap: 2px; + align-items: center; + + app-avatar { + margin-left: -8px; + } + + i { + min-width: 8px; + margin-right: 6px; + } + } + + &-icons { + display: flex; + gap: 5px; + align-items: center; + } + + span { + color: var(--black); + } + + p { + color: var(--grey-for-text); + } + + i { + color: var(--accent); + cursor: pointer; + } + } +} + +.lists { + &__section { + display: flex; + justify-content: space-between; + width: 100%; + margin-bottom: 8px; + border-bottom: 0.5px solid var(--accent); + } + + &__icon { + color: var(--accent); + } + + &__title { + margin-bottom: 8px; + color: var(--accent); + } +} + +.cancel { + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: center; + width: 600px; + max-height: calc(100vh - 40px); + padding: 24px; + overflow-y: auto; +} diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/task/kanban-task.component.ts b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/task/kanban-task.component.ts new file mode 100644 index 000000000..51c380701 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/components/task/kanban-task.component.ts @@ -0,0 +1,53 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { Component, computed, inject, Input } from "@angular/core"; +import { IconComponent } from "@uilib"; +import { AvatarComponent } from "@ui/primitives/avatar/avatar.component"; +import { TagComponent } from "@ui/primitives/tag/tag.component"; +import { RouterModule } from "@angular/router"; +import { getPriorityType } from "@utils/getPriorityType"; +import { TaskPreview } from "@domain/kanban/task.model"; +import { KanbanBoardDetailInfoService } from "@api/kanban/kanban-board-detail-info.service"; +import { PluralizePipe } from "@corelib"; +import { CancelTaskFormComponent } from "../cancel-task-form/cancel-task-form.component"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { AppRoutes } from "@api/paths/app-routes"; + +/** Канбан (модуль отключён): карточка задачи в колонке. */ +@Component({ + selector: "app-kanban-task", + templateUrl: "./kanban-task.component.html", + styleUrl: "./kanban-task.component.scss", + imports: [ + CommonModule, + RouterModule, + IconComponent, + AvatarComponent, + TagComponent, + PluralizePipe, + CancelTaskFormComponent, + ModalComponent, + ], + standalone: true, +}) +export class KanbanTaskComponent { + @Input({ required: true }) task!: TaskPreview; + + private readonly kanbanBoardDetailInfoService = inject(KanbanBoardDetailInfoService); + + readonly isArchivePage = this.kanbanBoardDetailInfoService.isArchivePage; + readonly isOverdue = this.kanbanBoardDetailInfoService.isOverdue; + readonly isLeader = this.kanbanBoardDetailInfoService.isLeader; + readonly diffDays = this.kanbanBoardDetailInfoService.diffDaysOfCompletedTask; + + isCancelFormOpen = false; + + getPriorityType = getPriorityType; + + protected readonly AppRoutes = AppRoutes; + + onToggleCancelTaskForm(): void { + this.isCancelFormOpen = !this.isCancelFormOpen; + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/kanban/kanban.component.html b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/kanban.component.html new file mode 100644 index 000000000..2de7489e8 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/kanban.component.html @@ -0,0 +1,15 @@ + + +
    +
    + + +
    + +
    + + @if (isTaskDetailOpen()) { + + } +
    +
    diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/kanban/kanban.component.scss b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/kanban.component.scss new file mode 100644 index 000000000..eb5d932b1 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/kanban.component.scss @@ -0,0 +1,17 @@ +.kanban { + &__wrapper { + position: relative; + display: grid; + grid-template-columns: 1fr 10fr 1fr; + grid-gap: 20px; + } + + &__main { + display: flex; + flex-direction: column; + } + + &__right { + position: relative; + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/kanban/kanban.component.ts b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/kanban.component.ts new file mode 100644 index 000000000..c70ea5c98 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/kanban.component.ts @@ -0,0 +1,47 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { Component, inject, OnInit, signal } from "@angular/core"; +import { RouterOutlet, ActivatedRoute } from "@angular/router"; +import { KanbanBoardSidebarComponent } from "./components/sidebar/kanban-board-sidebar.component"; +import { TaskDetailComponent } from "./components/task/detail/task-detail.component"; +import { KanbanBoardDetailInfoService } from "@api/kanban/kanban-board-detail-info.service"; +import { Subscription } from "rxjs"; +import { ClickOutsideModule } from "ng-click-outside"; + +/** Канбан (модуль отключён): корневой компонент доски. */ +@Component({ + selector: "app-kanban", + templateUrl: "./kanban.component.html", + styleUrl: "./kanban.component.scss", + imports: [ + CommonModule, + RouterOutlet, + KanbanBoardSidebarComponent, + TaskDetailComponent, + ClickOutsideModule, + ], + standalone: true, +}) +export class KanbanComponent implements OnInit { + private readonly kanbanBoardDetailInfoService = inject(KanbanBoardDetailInfoService); + + readonly isTaskDetailOpen = signal(false); + private readonly subscriptions: Subscription[] = []; + + ngOnInit(): void { + const detailInfoUrl$ = this.kanbanBoardDetailInfoService.route.queryParams.subscribe(params => { + this.isTaskDetailOpen.set(!!params["taskId"]); + }); + + this.subscriptions.push(detailInfoUrl$); + } + + ngOnDestroy(): void { + this.subscriptions.forEach($ => $.unsubscribe()); + } + + closeDetailTask(): void { + this.kanbanBoardDetailInfoService.closeDetailTask(); + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/kanban/kanban.resolver.ts b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/kanban.resolver.ts new file mode 100644 index 000000000..6705aa8c2 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/kanban.resolver.ts @@ -0,0 +1,24 @@ +/** @format */ + +import { inject } from "@angular/core"; +import { KanbanBoardService } from "@api/kanban/kanban-board.service"; +import { ActivatedRouteSnapshot, Router } from "@angular/router"; +import { catchError, map, of } from "rxjs"; + +export const KanbanBoardResolver = (route: ActivatedRouteSnapshot): any => { + const kanbanBoardService = inject(KanbanBoardService); + const router = inject(Router); + + const projectId = Number(route.parent?.params["projectId"]); + if (!projectId) return of(router.createUrlTree(["/projects"])); + + return kanbanBoardService.getBoardByProjectId(projectId).pipe( + map((board: any) => { + const kanbanId = board[0].id; + if (!kanbanId) return router.createUrlTree(["/projects", projectId, "work-section"]); + + return router.createUrlTree(["/projects", projectId, "kanban", kanbanId]); + }), + catchError(() => of(router.createUrlTree(["/projects"]))), + ); +}; diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/kanban/pages/archive/kanban-archive.component.html b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/pages/archive/kanban-archive.component.html new file mode 100644 index 000000000..01bcb93ed --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/pages/archive/kanban-archive.component.html @@ -0,0 +1,15 @@ + + +
    + + +
    + @for (task of completedTasks(); track task.id) { + + } +
    +
    diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/kanban/pages/archive/kanban-archive.component.scss b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/pages/archive/kanban-archive.component.scss new file mode 100644 index 000000000..7d4e58f02 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/pages/archive/kanban-archive.component.scss @@ -0,0 +1,8 @@ +.kanban { + &__tasks { + display: grid; + grid-template-columns: 3fr 3fr 3fr; + grid-gap: 20px; + margin-top: 20px; + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/kanban/pages/archive/kanban-archive.component.ts b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/pages/archive/kanban-archive.component.ts new file mode 100644 index 000000000..582f14cf8 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/pages/archive/kanban-archive.component.ts @@ -0,0 +1,102 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { Component, inject, OnDestroy, OnInit, signal } from "@angular/core"; +import { FormBuilder, FormGroup, ReactiveFormsModule } from "@angular/forms"; +import { SearchComponent } from "@ui/primitives/search/search.component"; +import { Subscription } from "rxjs"; +import { ActivatedRoute } from "@angular/router"; +import { KanbanTaskComponent } from "../../components/task/kanban-task.component"; +import { KanbanBoardDetailInfoService } from "@api/kanban/kanban-board-detail-info.service"; +import { TaskPreview } from "@domain/kanban/task.model"; + +/** Канбан (модуль отключён): страница архива задач. */ +@Component({ + selector: "app-kanban-archive", + templateUrl: "./kanban-archive.component.html", + styleUrl: "./kanban-archive.component.scss", + imports: [CommonModule, SearchComponent, ReactiveFormsModule, KanbanTaskComponent], + standalone: true, +}) +export class KanbanArhiveComponent implements OnInit, OnDestroy { + private readonly fb = inject(FormBuilder); + private readonly route = inject(ActivatedRoute); + private readonly kanbanBoardDetailInfoService = inject(KanbanBoardDetailInfoService); + + completedTasks = signal([]); + isTaskDetailOpen = signal(false); + + constructor() { + this.searchForm = this.fb.group({ + search: [""], + }); + } + + searchForm: FormGroup; + + subscriptions: Subscription[] = []; + + ngOnInit(): void { + const detailInfoUrl$ = this.kanbanBoardDetailInfoService.route.queryParams.subscribe(params => { + this.isTaskDetailOpen.set(!!params["taskId"]); + }); + + this.subscriptions.push(detailInfoUrl$); + + const completedTasks$ = this.route.data.subscribe({ + next: tasks => { + this.completedTasks.set(tasks as TaskPreview[]); + }, + }); + + this.subscriptions.push(completedTasks$); + + const mockCompleteTasks = [ + { + id: 11, + title: "123", + description: + "Сейчас, чтобы создался аккаунт внтури скиллз, пользователю обязательно надо войти внутрь вкладки траектории и еще раз залогиниться...", + priority: 5, + type: 2, + responsible: { + id: 12, + avatar: "", + }, + performers: [ + { + id: 12, + avatar: "", + }, + { + id: 25, + avatar: "", + }, + { + id: 26, + avatar: "", + }, + ], + tags: [ + { + id: 2, + name: "123", + color: "accent", + }, + ], + score: 10, + filtes: [], + }, + ]; + + this.completedTasks.set(mockCompleteTasks); + } + + openDetailTask(taskId: number): void { + this.kanbanBoardDetailInfoService.openDetailTask(taskId); + } + + ngOnDestroy(): void { + this.subscriptions.forEach($ => $.unsubscribe()); + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/kanban/pages/board/kanban-board.component.html b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/pages/board/kanban-board.component.html new file mode 100644 index 000000000..209d7d0a6 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/pages/board/kanban-board.component.html @@ -0,0 +1,114 @@ + + +
    + @for (boardColumn of boardColumns(); track $index) { +
    + @if (selectedColumnId === boardColumn.id && editColumn) { + @if (taskForm.get("columnTitle"); as columnTitle) { + + + } + } @else { +
    +

    {{ boardColumn.tasks.length }}

    +
    + @if (boardColumn.locked) { + + } + @if (!(selectedColumnId === boardColumn.id && editColumn)) { +

    {{ boardColumn.name }}

    + } +
    + +
    + + + @if (isColumnInfoOpen && selectedColumnId === boardColumn.id) { + + } +
    +
    + } + +
    + @for (task of boardColumn.tasks; track task.id) { + + } + @if (addTaskClick) { + @if (selectedColumnId === boardColumn.id) { + @if (taskForm.get("taskTitle"); as taskTitle) { +
    + +
    + } + } + } +
    + + @if (isLeader || isExternal) { + + } +
    + } + +
    +
    + +
    +
    +
    diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/kanban/pages/board/kanban-board.component.scss b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/pages/board/kanban-board.component.scss new file mode 100644 index 000000000..6d3971844 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/pages/board/kanban-board.component.scss @@ -0,0 +1,123 @@ +.kanban { + &__column { + flex: 0 0 240px; + min-width: 240px; + max-width: 240px; + + ::ng-deep { + app-input { + .field__input { + text-align: center; + } + + .field__input--border { + border: 0.5px solid var(--accent); + + &:focus { + border-color: none; + box-shadow: none; + } + } + + .field__input--big { + padding: 5px 12px; + } + + .field__input::placeholder { + text-align: center; + } + + .field__input::placeholder::input-placeholder { + text-align: center; + } + } + } + + &-wrapper { + display: flex; + gap: 20px; + overflow-y: hidden; + } + + &-locked { + display: flex; + gap: 5px; + align-items: center; + } + + &-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 5px 12px; + border: 0.5px solid var(--accent); + border-radius: var(--rounded-xl); + + &--empty { + justify-content: center; + cursor: pointer; + } + } + + &--add-task { + display: flex; + align-items: center; + align-self: center; + justify-content: center; + margin-top: 20px; + } + + i, + p { + color: var(--accent); + cursor: pointer; + } + } + + &__tasks { + display: flex; + flex-direction: column; + gap: 20px; + align-items: center; + min-height: 20px; + margin-top: 20px; + overflow-y: scroll; + } + + &__task { + width: 100%; + max-width: 245px; + padding: 6px; + cursor: pointer; + background-color: var(--white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); + + ::ng-deep { + app-input { + .field__input { + padding-left: 5px; + text-align: start; + background: transparent; + } + + .field__input--border { + border: none; + + &:focus { + border-color: none; + box-shadow: none; + } + } + + .field__input::placeholder { + text-align: inherit; + } + + .field__input::placeholder::input-placeholder { + text-align: inherit; + } + } + } + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/kanban/pages/board/kanban-board.component.ts b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/pages/board/kanban-board.component.ts new file mode 100644 index 000000000..dc5295d36 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/kanban/pages/board/kanban-board.component.ts @@ -0,0 +1,341 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { + ChangeDetectorRef, + Component, + computed, + inject, + OnDestroy, + OnInit, + signal, +} from "@angular/core"; +import { InputComponent } from "@ui/primitives"; +import { DropdownComponent } from "@ui/primitives/dropdown/dropdown.component"; +import { + CdkDrag, + CdkDragDrop, + CdkDropList, + moveItemInArray, + transferArrayItem, +} from "@angular/cdk/drag-drop"; +import { kanbanColumnInfo } from "@core/consts/other/kanban-column-info.const"; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; +import { ActivatedRoute } from "@angular/router"; +import { Subscription } from "rxjs"; +import { IconComponent } from "@uilib"; +import { ControlErrorPipe } from "@corelib"; +import { KanbanTaskComponent } from "../../components/task/kanban-task.component"; +import { KanbanBoardDetailInfoService } from "@api/kanban/kanban-board-detail-info.service"; +import { Column } from "@domain/kanban/column.model"; +import { TaskPreview } from "@domain/kanban/task.model"; + +/** Канбан (модуль отключён): страница доски с колонками. */ +@Component({ + selector: "app-kanban-board", + templateUrl: "./kanban-board.component.html", + styleUrl: "./kanban-board.component.scss", + imports: [ + CommonModule, + IconComponent, + KanbanTaskComponent, + ReactiveFormsModule, + DropdownComponent, + InputComponent, + ControlErrorPipe, + CdkDropList, + CdkDrag, + ], + standalone: true, +}) +export class KanbanBoardComponent implements OnInit, OnDestroy { + private readonly kanbanBoardDetailInfoService = inject(KanbanBoardDetailInfoService); + private readonly route = inject(ActivatedRoute); + private readonly fb = inject(FormBuilder); + private readonly cdRef = inject(ChangeDetectorRef); + private readonly subscriptions: Subscription[] = []; + + constructor() { + this.taskForm = this.fb.group({ + columnTitle: ["", Validators.required], + taskTitle: ["", Validators.required], + }); + } + + boardColumns = signal([]); + + connectedLists = computed(() => this.boardColumns().map(c => "tasks-column-" + c.id)); + + isTaskDetailOpen = signal(false); + + isColumnInfoOpen = false; + selectedColumnId: number | null = null; + + editColumn = false; + addTaskClick = false; + + addingTaskColumnId: number | null = null; + + taskForm: FormGroup; + + get columnInfoOptions() { + return kanbanColumnInfo; + } + + readonly isLeader = this.kanbanBoardDetailInfoService.isLeader(); + readonly isExternal = this.kanbanBoardDetailInfoService.isExternal(); + + ngOnInit(): void { + const detailInfoUrl$ = this.kanbanBoardDetailInfoService.route.queryParams.subscribe(params => { + this.isTaskDetailOpen.set(!!params["taskId"]); + }); + + this.subscriptions.push(detailInfoUrl$); + + this.kanbanBoardDetailInfoService.onTaskDelete().subscribe(taskId => { + this.onDeleteTask(taskId); + }); + + const boardInfo$ = this.route.data.subscribe({ + next: columns => { + this.boardColumns.set(columns as Column[]); + }, + }); + + this.subscriptions.push(boardInfo$); + + const mockColumns = [ + { + id: 0, + name: "бэклог", + order: 0, + locked: true, + tasks: [ + { + id: 1, + title: "собрать требования", + description: + "Сейчас, чтобы создался аккаунт внтури скиллз, пользователю обязательно надо войти внутрь вкладки траектории и еще раз залогиниться...", + priority: 3, + columnId: 0, + }, + { id: 2, title: "создать дизайн макеты", columnId: 0 }, + ], + }, + { + id: 1, + locked: false, + order: 1, + name: "в работе", + tasks: [ + { id: 3, title: "настроить API", columnId: 1 }, + { id: 4, title: "подключить WebSocket", columnId: 1 }, + ], + }, + { + id: 2, + locked: false, + order: 2, + name: "Готово", + tasks: [ + { id: 5, title: "верстка страницы входа", columnId: 2 }, + { id: 6, title: "настроить Dockerfile", columnId: 2 }, + ], + }, + ]; + this.boardColumns.set(mockColumns); + } + + ngOnDestroy(): void { + this.subscriptions.forEach($ => $.unsubscribe()); + } + + toggleDropDown(columnId: number): void { + const sameColumn = this.selectedColumnId === columnId; + + if (sameColumn) { + this.isColumnInfoOpen = !this.isColumnInfoOpen; + return; + } + + this.selectedColumnId = columnId; + this.isColumnInfoOpen = true; + + if (this.editColumn) { + this.editColumn = false; + } + } + + onTypeSelect(option: any, state: boolean, columnId?: number): void { + if (!option) { + this.isColumnInfoOpen = state; + return; + } + this.selectedColumnId = columnId!; + + switch (option) { + case 1: + this.editColumn = true; + break; + + case 2: + this.deleteColumn(); + break; + } + + this.isColumnInfoOpen = state; + } + + startAddingTask(columnId: number): void { + if (this.isTaskDetailOpen()) return; + + this.addTaskClick = true; + this.selectedColumnId = columnId; + } + + confirmAddTask(): void { + const title = this.taskForm.get("taskTitle")?.value; + + if (!title) return; + + const newTask = { + id: Date.now(), + title, + }; + + this.boardColumns.update(columns => + columns.map(column => + column.id === this.selectedColumnId + ? { ...column, tasks: [...column.tasks, newTask] } + : column, + ), + ); + + this.cancelAddingTask(); + } + + cancelAddingTask(): void { + this.selectedColumnId = null; + this.addTaskClick = false; + this.taskForm.patchValue({ taskTitle: "" }); + } + + openDetailTask(taskId: number): void { + this.kanbanBoardDetailInfoService.openDetailTask(taskId); + } + + onDeleteTask(taskId: number): void { + this.boardColumns.update(columns => + columns.map(col => ({ + ...col, + tasks: col.tasks.filter((task: TaskPreview) => task.id !== taskId), + })), + ); + + this.kanbanBoardDetailInfoService.closeDetailTask(); + this.cdRef.detectChanges(); + } + + addColumn(): void { + const columnTitle = this.taskForm.get("columnTitle")?.value; + + const newColumn = { + id: Date.now(), + name: columnTitle, + locked: false, + order: this.boardColumns().length, + tasks: [], + }; + + this.boardColumns.update(columns => [...columns, newColumn]); + + this.editColumn = true; + this.selectedColumnId = this.boardColumns()[this.boardColumns().length - 1].id; + } + + deleteColumn(): void { + if (this.selectedColumnId === 0) return; + + this.boardColumns.update(columns => + columns.filter(column => column.id !== this.selectedColumnId), + ); + } + + confirmRenameColumn() { + const title = this.taskForm.get("columnTitle")?.value; + + if (!title) return; + + this.boardColumns.update(columns => + columns.map(column => + column.id === this.selectedColumnId ? { ...column, name: title } : column, + ), + ); + + this.selectedColumnId = null; + this.editColumn = false; + + this.taskForm.reset(); + } + + cancelRenamColumn(): void { + this.selectedColumnId = null; + this.editColumn = false; + this.taskForm.patchValue({ columnTitle: "" }); + } + + onDropTask(event: CdkDragDrop): void { + const columns = this.boardColumns(); + + const prevColumn = columns.find(col => col.tasks === event.previousContainer.data); + const currColumn = columns.find(col => col.tasks === event.container.data); + + if (!prevColumn || !currColumn) return; + + if (event.previousContainer === event.container) { + moveItemInArray(currColumn.tasks, event.previousIndex, event.currentIndex); + } else { + transferArrayItem( + prevColumn.tasks, + currColumn.tasks, + event.previousIndex, + event.currentIndex, + ); + } + + this.boardColumns.set([...columns]); + } + + onDropColumn(event: CdkDragDrop): void { + if (event.previousIndex === event.currentIndex) { + return; + } + + const columns = [...this.boardColumns()]; + + if (columns[event.previousIndex].id === 0) { + return; + } + + if (event.currentIndex === 0) { + return; + } + + if (columns[event.previousIndex].locked || columns[event.previousIndex].id === 0) { + return; + } + + moveItemInArray(columns, event.previousIndex, event.currentIndex); + + const updatedColumns = columns.map((col, index) => ({ + ...col, + order: index, + })); + + this.boardColumns.set(updatedColumns); + } + + columnDropPredicate = (index: number): boolean => { + return index !== 0; + }; +} diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/news-detail/news-detail.component.html b/projects/social_platform/src/app/ui/pages/projects/detail/news-detail/news-detail.component.html new file mode 100644 index 000000000..f1d2c036c --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/news-detail/news-detail.component.html @@ -0,0 +1,11 @@ + + + + @if (newsItem | async; as item) { + + } + diff --git a/projects/social_platform/src/app/office/projects/detail/news-detail/news-detail.component.scss b/projects/social_platform/src/app/ui/pages/projects/detail/news-detail/news-detail.component.scss similarity index 100% rename from projects/social_platform/src/app/office/projects/detail/news-detail/news-detail.component.scss rename to projects/social_platform/src/app/ui/pages/projects/detail/news-detail/news-detail.component.scss diff --git a/projects/social_platform/src/app/office/projects/detail/news-detail/news-detail.component.spec.ts b/projects/social_platform/src/app/ui/pages/projects/detail/news-detail/news-detail.component.spec.ts similarity index 93% rename from projects/social_platform/src/app/office/projects/detail/news-detail/news-detail.component.spec.ts rename to projects/social_platform/src/app/ui/pages/projects/detail/news-detail/news-detail.component.spec.ts index 11b93d79d..08cdf8288 100644 --- a/projects/social_platform/src/app/office/projects/detail/news-detail/news-detail.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/projects/detail/news-detail/news-detail.component.spec.ts @@ -18,7 +18,10 @@ describe("NewsDetailComponent", () => { beforeEach(() => { fixture = TestBed.createComponent(NewsDetailComponent); component = fixture.componentInstance; - fixture.detectChanges(); + }); + + afterEach(() => { + fixture?.destroy(); }); it("should create", () => { diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/news-detail/news-detail.component.ts b/projects/social_platform/src/app/ui/pages/projects/detail/news-detail/news-detail.component.ts new file mode 100644 index 000000000..9ffcc4f78 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/news-detail/news-detail.component.ts @@ -0,0 +1,45 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, OnInit, inject } from "@angular/core"; +import { ActivatedRoute, Router } from "@angular/router"; +import { map, Observable } from "rxjs"; +import { AsyncPipe } from "@angular/common"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { NewsCardComponent } from "@ui/widgets/news-card/news-card.component"; +import { FeedNews } from "@domain/news/project-news.model"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; +import { AppRoutes } from "@api/paths/app-routes"; + +/** Детальный просмотр новости проекта в модальном окне. */ +@Component({ + selector: "app-news-detail", + templateUrl: "./news-detail.component.html", + styleUrl: "./news-detail.component.scss", + imports: [ModalComponent, AsyncPipe, NewsCardComponent], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NewsDetailComponent implements OnInit { + private readonly logger = inject(LoggerService); + protected readonly AppRoutes = AppRoutes; + + constructor( + private readonly route: ActivatedRoute, // Сервис для работы с активным маршрутом + private readonly router: Router, // Сервис для навигации + ) {} + + // Observable с данными новости из резолвера + newsItem: Observable = this.route.data.pipe(map(r => r["data"])); + + ngOnInit(): void {} + + onOpenChange(value: boolean) { + if (!value) { + // Получаем ID проекта из родительского маршрута + const projectId = this.route.parent?.snapshot.params["projectId"]; + // Навигируем обратно к странице проекта + this.router + .navigateByUrl(AppRoutes.projects.detail(projectId)) + .then(() => this.logger.debug("Route changed from NewsDetailComponent")); + } + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/news-detail/news-detail.resolver.spec.ts b/projects/social_platform/src/app/ui/pages/projects/detail/news-detail/news-detail.resolver.spec.ts new file mode 100644 index 000000000..961dec4b8 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/news-detail/news-detail.resolver.spec.ts @@ -0,0 +1,33 @@ +/** @format */ + +import { TestBed } from "@angular/core/testing"; +import { NewsDetailResolver } from "./news-detail.resolver"; +import { ActivatedRouteSnapshot, RouterStateSnapshot, provideRouter } from "@angular/router"; +import { of } from "rxjs"; +import { GetProjectNewsDetailUseCase } from "@api/project/use-cases/get-project-news-detail.use-case"; + +describe("NewsDetailResolver", () => { + const mockRoute = { + params: { newsId: 1 }, + parent: { params: { projectId: 1 } }, + } as unknown as ActivatedRouteSnapshot; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideRouter([]), + { + provide: GetProjectNewsDetailUseCase, + useValue: { execute: () => of({ ok: true, value: {} }) }, + }, + ], + }); + }); + + it("should be created", () => { + const result = TestBed.runInInjectionContext(() => + NewsDetailResolver(mockRoute, {} as RouterStateSnapshot), + ); + expect(result).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/news-detail/news-detail.resolver.ts b/projects/social_platform/src/app/ui/pages/projects/detail/news-detail/news-detail.resolver.ts new file mode 100644 index 000000000..8b4dd84e1 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/news-detail/news-detail.resolver.ts @@ -0,0 +1,22 @@ +/** @format */ + +import { inject } from "@angular/core"; +import { ActivatedRouteSnapshot, ResolveFn } from "@angular/router"; +import { of, switchMap } from "rxjs"; +import { FeedNews } from "@domain/news/project-news.model"; +import { GetProjectNewsDetailUseCase } from "@api/project/use-cases/get-project-news-detail.use-case"; + +/** Предзагружает детальную информацию о новости проекта. */ +export const NewsDetailResolver: ResolveFn = (route: ActivatedRouteSnapshot) => { + const getProjectNewsDetailUseCase = inject(GetProjectNewsDetailUseCase); + + // Извлекаем ID проекта из родительского маршрута + const projectId = route.parent?.params["projectId"]; + // Извлекаем ID новости из текущего маршрута + const newsId = route.params["newsId"]; + + // Возвращаем Observable с детальной информацией о новости + return getProjectNewsDetailUseCase + .execute(projectId, newsId) + .pipe(switchMap(result => of(result.ok ? result.value : new FeedNews()))); +}; diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/team/team.component.html b/projects/social_platform/src/app/ui/pages/projects/detail/team/team.component.html new file mode 100644 index 000000000..da920cd1a --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/team/team.component.html @@ -0,0 +1,17 @@ + + +@if (collaborators()) { + @if (collaborators()?.length) { +
    + @for (collaborator of collaborators(); track $index) { + + } +
    + } +} diff --git a/projects/social_platform/src/app/office/projects/detail/team/team.component.scss b/projects/social_platform/src/app/ui/pages/projects/detail/team/team.component.scss similarity index 100% rename from projects/social_platform/src/app/office/projects/detail/team/team.component.scss rename to projects/social_platform/src/app/ui/pages/projects/detail/team/team.component.scss diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/team/team.component.ts b/projects/social_platform/src/app/ui/pages/projects/detail/team/team.component.ts new file mode 100644 index 000000000..89c3e9228 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/team/team.component.ts @@ -0,0 +1,51 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + inject, + OnDestroy, + OnInit, + signal, +} from "@angular/core"; +import { InfoCardComponent } from "@ui/widgets/info-card/info-card.component"; +import { ProjectsDetailService } from "@api/project/facades/detail/projects-detail.service"; +import { ProjectsDetailUIInfoService } from "@api/project/facades/detail/ui/projects-detail-ui.service"; +import { ProfileDetailUIInfoService } from "@api/profile/facades/detail/ui/profile-detail-ui-info.service"; +import { ExpandService } from "@api/expand/expand.service"; + +/** + * Компонент страницы команды в деательной информации о проекте + */ +@Component({ + selector: "app-project-eam", + templateUrl: "./team.component.html", + styleUrl: "./team.component.scss", + imports: [CommonModule, InfoCardComponent], + providers: [ProjectsDetailService, ProfileDetailUIInfoService, ExpandService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProjectTeamComponent implements OnInit, OnDestroy { + private readonly projectsDetailService = inject(ProjectsDetailService); + private readonly projectsDetailUIInfoService = inject(ProjectsDetailUIInfoService); + private readonly profileDetailUIInfoService = inject(ProfileDetailUIInfoService); + + // массив пользователей в команде + protected readonly collaborators = this.projectsDetailUIInfoService.collaborators; + protected readonly projectId = this.projectsDetailUIInfoService.projectId; + protected readonly loggedUserId = this.profileDetailUIInfoService.loggedUserId; + protected readonly leaderId = this.projectsDetailUIInfoService.leaderId; + + ngOnInit(): void { + this.projectsDetailService.initializationTeam(); + } + + ngOnDestroy(): void { + this.projectsDetailService.destroy(); + } + + removeCollaboratorFromProject(userId: number): void { + this.projectsDetailService.removeCollaboratorFromProject(userId); + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/vacancies/vacancies.component.html b/projects/social_platform/src/app/ui/pages/projects/detail/vacancies/vacancies.component.html new file mode 100644 index 000000000..9cd7d4d0e --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/vacancies/vacancies.component.html @@ -0,0 +1,14 @@ + +@if (vacancies()) { + @if (vacancies()?.length) { +
    +
      + @for (vacancy of vacancies(); track vacancy.id) { +
    • + +
    • + } +
    +
    + } +} diff --git a/projects/social_platform/src/app/office/projects/detail/vacancies/vacancies.component.scss b/projects/social_platform/src/app/ui/pages/projects/detail/vacancies/vacancies.component.scss similarity index 100% rename from projects/social_platform/src/app/office/projects/detail/vacancies/vacancies.component.scss rename to projects/social_platform/src/app/ui/pages/projects/detail/vacancies/vacancies.component.scss diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/vacancies/vacancies.component.ts b/projects/social_platform/src/app/ui/pages/projects/detail/vacancies/vacancies.component.ts new file mode 100644 index 000000000..1b9c90965 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/vacancies/vacancies.component.ts @@ -0,0 +1,34 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, inject, OnDestroy, OnInit } from "@angular/core"; +import { ProjectVacancyCardComponent } from "@ui/widgets/project-vacancy-card/project-vacancy-card.component"; +import { ProjectsDetailUIInfoService } from "@api/project/facades/detail/ui/projects-detail-ui.service"; +import { ProjectsDetailService } from "@api/project/facades/detail/projects-detail.service"; +import { ProfileDetailUIInfoService } from "@api/profile/facades/detail/ui/profile-detail-ui-info.service"; +import { ExpandService } from "@api/expand/expand.service"; + +/** + * Компонент страницы вакансий в деательной информации о проекте + */ +@Component({ + selector: "app-vacancies", + templateUrl: "./vacancies.component.html", + styleUrl: "./vacancies.component.scss", + imports: [CommonModule, ProjectVacancyCardComponent], + providers: [ProjectsDetailService, ProfileDetailUIInfoService, ExpandService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProjectVacanciesComponent implements OnInit, OnDestroy { + private readonly projectsDetailUIInfoService = inject(ProjectsDetailUIInfoService); + private readonly projectsDetailService = inject(ProjectsDetailService); + + // массив пользователей в команде + protected readonly vacancies = this.projectsDetailUIInfoService.vacancies; + + ngOnInit(): void {} + + ngOnDestroy(): void { + this.projectsDetailService.destroy(); + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/work-section/responses.resolver.ts b/projects/social_platform/src/app/ui/pages/projects/detail/work-section/responses.resolver.ts new file mode 100644 index 000000000..81989c6ba --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/work-section/responses.resolver.ts @@ -0,0 +1,18 @@ +/** @format */ + +import { inject } from "@angular/core"; +import { ActivatedRouteSnapshot, ResolveFn } from "@angular/router"; +import { VacancyResponse } from "@domain/vacancy/vacancy-response.model"; +import { map } from "rxjs"; +import { GetProjectResponsesUseCase } from "@api/vacancy/use-cases/get-project-responses.use-case"; + +/** Предзагружает отклики на вакансии проекта. */ +export const ProjectResponsesResolver: ResolveFn = ( + route: ActivatedRouteSnapshot, +) => { + const getProjectResponsesUseCase = inject(GetProjectResponsesUseCase); + + return getProjectResponsesUseCase + .execute(Number(route.parent?.paramMap.get("projectId"))) + .pipe(map(result => (result.ok ? result.value : []))); +}; diff --git a/projects/social_platform/src/app/office/projects/detail/work-section/work-section.component.html b/projects/social_platform/src/app/ui/pages/projects/detail/work-section/work-section.component.html similarity index 87% rename from projects/social_platform/src/app/office/projects/detail/work-section/work-section.component.html rename to projects/social_platform/src/app/ui/pages/projects/detail/work-section/work-section.component.html index 2df97a011..7185e32dd 100644 --- a/projects/social_platform/src/app/office/projects/detail/work-section/work-section.component.html +++ b/projects/social_platform/src/app/ui/pages/projects/detail/work-section/work-section.component.html @@ -8,9 +8,11 @@

    мои задачи

    - открыть доску задач +
    @@ -30,7 +32,9 @@

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

    - @if (vacancies.length) { @for (vacancy of vacancies; track vacancy.id) { } } + @if (vacancies().length) { + @for (vacancy of vacancies(); track vacancy.id) {} + } diff --git a/projects/social_platform/src/app/office/projects/detail/work-section/work-section.component.scss b/projects/social_platform/src/app/ui/pages/projects/detail/work-section/work-section.component.scss similarity index 100% rename from projects/social_platform/src/app/office/projects/detail/work-section/work-section.component.scss rename to projects/social_platform/src/app/ui/pages/projects/detail/work-section/work-section.component.scss diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/work-section/work-section.component.ts b/projects/social_platform/src/app/ui/pages/projects/detail/work-section/work-section.component.ts new file mode 100644 index 000000000..4bda91ea6 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/detail/work-section/work-section.component.ts @@ -0,0 +1,42 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, inject, OnInit } from "@angular/core"; +import { ButtonComponent } from "@ui/primitives"; +import { IconComponent } from "@uilib"; +import { ProjectsDetailWorkSectionInfoService } from "@api/project/facades/detail/work-section/projects-detail-work-section-info.service"; +import { ProjectsDetailWorkSectionUIInfoService } from "@api/project/facades/detail/work-section/ui/projects-detail-work-section-ui-info.service"; + +/** Секция откликов проекта: список и обработка откликов. */ +@Component({ + selector: "app-work-section", + templateUrl: "./work-section.component.html", + styleUrl: "./work-section.component.scss", + imports: [CommonModule, IconComponent, ButtonComponent], + providers: [ProjectsDetailWorkSectionInfoService, ProjectsDetailWorkSectionUIInfoService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProjectWorkSectionComponent implements OnInit { + private readonly projectsDetailWorkSectionInfoService = inject( + ProjectsDetailWorkSectionInfoService, + ); + + private readonly projectsDetailWorkSectionUIInfoService = inject( + ProjectsDetailWorkSectionUIInfoService, + ); + + protected readonly vacancies = this.projectsDetailWorkSectionUIInfoService.vacancies; + protected readonly projectId = this.projectsDetailWorkSectionInfoService.projectId; + + ngOnInit(): void { + this.projectsDetailWorkSectionInfoService.initializationWorkSection(); + } + + acceptResponse(responseId: number) { + this.projectsDetailWorkSectionInfoService.acceptResponse(responseId); + } + + rejectResponse(responseId: number) { + this.projectsDetailWorkSectionInfoService.rejectResponse(responseId); + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-achievement-step/project-achievement-step.component.html b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-achievement-step/project-achievement-step.component.html new file mode 100644 index 000000000..c39429bdb --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-achievement-step/project-achievement-step.component.html @@ -0,0 +1,83 @@ + + +
    +
    + @if (hasAchievements()) { +
      + @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 (!hasAchievements()) { + + } +
    +
    diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-achievement-step/project-achievement-step.component.scss b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-achievement-step/project-achievement-step.component.scss similarity index 100% rename from projects/social_platform/src/app/office/projects/edit/shared/project-achievement-step/project-achievement-step.component.scss rename to projects/social_platform/src/app/ui/pages/projects/edit/components/project-achievement-step/project-achievement-step.component.scss diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-achievement-step/project-achievement-step.component.ts b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-achievement-step/project-achievement-step.component.ts new file mode 100644 index 000000000..e79bcfa09 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-achievement-step/project-achievement-step.component.ts @@ -0,0 +1,88 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, inject, input, OnInit } from "@angular/core"; +import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; +import { InputComponent, ButtonComponent } from "@ui/primitives"; +import { ControlErrorPipe } from "@corelib"; +import { ErrorMessage } from "@core/lib/models/error/error-message"; +import { ProjectFormService } from "@api/project/facades/edit/project-form.service"; +import { IconComponent } from "@uilib"; +import { ProjectAchievementsService } from "@api/project/facades/edit/project-achievements.service"; +import { ToggleFieldsInfoService } from "@api/toggle-fields/toggle-fields-info.service"; + +/** Шаг редактирования проекта: достижения. */ +@Component({ + selector: "app-project-achievement-step", + templateUrl: "./project-achievement-step.component.html", + styleUrl: "./project-achievement-step.component.scss", + imports: [ + CommonModule, + ReactiveFormsModule, + InputComponent, + ButtonComponent, + IconComponent, + ControlErrorPipe, + ], + providers: [ToggleFieldsInfoService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProjectAchievementStepComponent implements OnInit { + readonly projSubmitInitiated = input(false); + + private readonly projectAchievementService = inject(ProjectAchievementsService); + private readonly toggleFieldsInfoService = inject(ToggleFieldsInfoService); + private readonly projectFormService = inject(ProjectFormService); + private readonly fb = inject(FormBuilder); + + // Получаем форму из сервиса + protected readonly projectForm = this.projectFormService.getForm(); + + // Состояние для показа полей ввода + protected readonly showInputFields = this.toggleFieldsInfoService.showInputFields; + + // Геттеры для FormArray и полей + protected readonly achievements = this.projectFormService.achievements; + protected readonly achievementsItems = this.projectAchievementService.achievementsItems; + + protected readonly editIndex = this.projectFormService.editIndex; + + /** Проверяет, есть ли достижения для отображения. */ + protected readonly hasAchievements = this.projectAchievementService.hasAchievements; + + protected readonly errorMessage = ErrorMessage; + + ngOnInit(): void { + this.projectAchievementService.syncAchievementsItems(this.achievements); + } + + addAchievement(id?: number, achievementsName?: string, achievementsDate?: string): void { + const currentYear = new Date().getFullYear(); + this.achievements.push( + this.fb.group({ + id: [id], + title: [achievementsName ?? "", [Validators.required]], + status: [ + achievementsDate ?? "", + [ + Validators.required, + Validators.min(2000), + Validators.max(currentYear), + Validators.pattern(/^\d{4}$/), + ], + ], + }), + ); + + this.projectAchievementService.addAchievement(this.achievements); + } + + editAchievement(index: number): void { + this.toggleFieldsInfoService.showFields(); + this.projectAchievementService.editAchievement(index, this.achievements); + } + + removeAchievement(index: number): void { + this.projectAchievementService.removeAchievement(index, this.achievements); + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-additional-step/project-additional-step.component.html b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-additional-step/project-additional-step.component.html new file mode 100644 index 000000000..c3361bc29 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-additional-step/project-additional-step.component.html @@ -0,0 +1,142 @@ + + +
    +
    +
    + @if (partnerProgramFields().length) { + @for (field of partnerProgramFields(); track field.id) { + +
    + @switch (field.fieldType) { + @case ("text") { + @if (additionalForm.get(field.name); as control) { + + + } + } + @case ("textarea") { + + @if (additionalForm.get(field.name); as control) { + + } + } + @case ("checkbox") { +
    + + +
    + } + @case ("radio") { + @if (additionalForm.get(field.name); as control) { + + } + + } + @case ("select") { + + + } + } + @if (additionalForm.get(field.name); as control) { + @if (control | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } + } +
    + } + } @else { + @if (isProjectAssignToProgram()) { +
    +

    + проект привязан к программе, но дополнительных полей для заполнения нет +

    + +
    + } @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/ui/pages/projects/edit/components/project-additional-step/project-additional-step.component.scss similarity index 100% rename from projects/social_platform/src/app/office/projects/edit/shared/project-additional-step/project-additional-step.component.scss rename to projects/social_platform/src/app/ui/pages/projects/edit/components/project-additional-step/project-additional-step.component.scss diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-additional-step/project-additional-step.component.ts b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-additional-step/project-additional-step.component.ts new file mode 100644 index 000000000..9c9e933ba --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-additional-step/project-additional-step.component.ts @@ -0,0 +1,107 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { + Component, + computed, + Input, + OnInit, + inject, + ChangeDetectorRef, + ChangeDetectionStrategy, + input, +} from "@angular/core"; +import { isFailure, isLoading } from "@domain/shared/async-state"; +import { ReactiveFormsModule } from "@angular/forms"; +import { + InputComponent, + CheckboxComponent, + SelectComponent, + ButtonComponent, +} from "@ui/primitives"; +import { TextareaComponent } from "@ui/primitives/textarea/textarea.component"; +import { SwitchComponent } from "@ui/primitives/switch/switch.component"; +import { ControlErrorPipe, ToSelectOptionsPipe } from "@corelib"; +import { ErrorMessage } from "@core/lib/models/error/error-message"; +import { RouterLink } from "@angular/router"; +import { IconComponent } from "@uilib"; +import { TooltipComponent } from "@ui/primitives/tooltip/tooltip.component"; +import { ProjectAdditionalService } from "@api/project/facades/edit/project-additional.service"; +import { TooltipInfoService } from "@api/tooltip/tooltip-info.service"; +import { AppRoutes } from "@api/paths/app-routes"; + +/** Шаг редактирования проекта: дополнительные поля программы. */ +@Component({ + selector: "app-project-additional-step", + templateUrl: "./project-additional-step.component.html", + styleUrl: "./project-additional-step.component.scss", + imports: [ + CommonModule, + ReactiveFormsModule, + InputComponent, + IconComponent, + CheckboxComponent, + SwitchComponent, + SelectComponent, + TextareaComponent, + ControlErrorPipe, + ToSelectOptionsPipe, + ButtonComponent, + RouterLink, + TooltipComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProjectAdditionalStepComponent implements OnInit { + private readonly projectAdditionalService = inject(ProjectAdditionalService); + private readonly tooltipInfoService = inject(TooltipInfoService); + + private readonly cdRef = inject(ChangeDetectorRef); + + readonly isProjectAssignToProgram = input(); + + ngOnInit(): void { + // Инициализация уже должна быть выполнена в родительском компоненте + this.cdRef.detectChanges(); + } + + protected readonly AppRoutes = AppRoutes; + + // Геттеры для получения данных из сервиса + protected readonly additionalForm = this.projectAdditionalService.getAdditionalForm(); + + protected readonly partnerProgramFields = this.projectAdditionalService.partnerProgramFields; + protected readonly isSendingDecision = computed(() => + isLoading(this.projectAdditionalService.isSend$()), + ); + + protected readonly isAssignProjectToProgramError = computed(() => + isFailure(this.projectAdditionalService.isSend$()), + ); + + protected readonly errorAssignProjectToProgramModalMessage = + this.projectAdditionalService.errorAssignProjectToProgramModalMessage; + + /** Наличие подсказки */ + protected readonly haveHint = this.tooltipInfoService.haveHint; + + /** Позиция подсказки */ + protected readonly tooltipPosition = this.tooltipInfoService.tooltipPosition; + + /** Состояние видимости подсказки */ + protected readonly isTooltipVisible = this.tooltipInfoService.isVisible; + + protected readonly errorMessage = ErrorMessage; + + /** Показать подсказку */ + toggleTooltip(): void { + this.tooltipInfoService.toggleTooltip("base"); + } + + toggleAdditionalFormValues( + fieldType: "text" | "textarea" | "checkbox" | "select" | "radio" | "file", + fieldName: string, + ): void { + this.projectAdditionalService.toggleAdditionalFormValues(fieldType, fieldName); + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.html b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.html new file mode 100644 index 000000000..92379a449 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.html @@ -0,0 +1,468 @@ + + +
    +
    +
    + @if (imageAddress; as imageAddress) { +
    + + + @if ((imageAddress | controlError: "required") && projSubmitInitiated()) { +
    + {{ errorMessage.EMPTY_AVATAR }} +
    + } +
    + } + @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 (trl; as trl) { +
    + + @if (trl | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } +
    + +
    + @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 (implementationDeadline; as implementationDeadline) { +
    + + + @if ((implementationDeadline | controlError: "required") && projSubmitInitiated()) { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } +
    +
    + + @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 (hasGoals()) { + + } + + +
    +
    + +

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

    + +
    +
      + @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/ui/pages/projects/edit/components/project-main-step/project-main-step.component.scss similarity index 100% rename from projects/social_platform/src/app/office/projects/edit/shared/project-main-step/project-main-step.component.scss rename to projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.scss diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.ts b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.ts new file mode 100644 index 000000000..63f3af871 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.ts @@ -0,0 +1,173 @@ +/** @format */ + +import { Component, inject, OnInit, ChangeDetectionStrategy, input } from "@angular/core"; +import { FormBuilder, FormsModule, ReactiveFormsModule, Validators } from "@angular/forms"; +import { ErrorMessage } from "@core/lib/models/error/error-message"; +import { directionProjectList } from "@core/consts/lists/direction-project-list.const"; +import { trackProjectList } from "@core/consts/lists/track-project-list.const"; +import { AvatarControlComponent } from "@ui/primitives/avatar-control/avatar-control.component"; +import { InputComponent, SelectComponent, ButtonComponent } from "@ui/primitives"; +import { TextareaComponent } from "@ui/primitives/textarea/textarea.component"; +import { UploadFileComponent } from "@ui/primitives/upload-file/upload-file.component"; +import { AsyncPipe, CommonModule } from "@angular/common"; +import { ControlErrorPipe } from "@corelib"; +import { ProjectFormService } from "@api/project/facades/edit/project-form.service"; +import { IconComponent } from "@uilib"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { AvatarComponent } from "@ui/primitives/avatar/avatar.component"; +import { RouterLink } from "@angular/router"; +import { AppRoutes } from "@api/paths/app-routes"; +import { generateOptionsList } from "@utils/generate-options-list"; +import { ProjectsEditInfoService } from "@api/project/facades/edit/projects-edit-info.service"; +import { ProjectsEditUIInfoService } from "@api/project/facades/edit/ui/projects-edit-ui-info.service"; +import { ProjectGoalsUIService } from "@api/project/facades/edit/ui/project-goals-ui.service"; +import { ProjectGoalService } from "@api/project/facades/edit/project-goals.service"; +import { ProjectContactsService } from "@api/project/facades/edit/project-contacts.service"; +import { ProjectTeamUIService } from "@api/project/facades/edit/ui/project-team-ui.service"; + +/** Шаг редактирования проекта: основная информация. */ +@Component({ + selector: "app-project-main-step", + templateUrl: "./project-main-step.component.html", + styleUrl: "./project-main-step.component.scss", + imports: [ + CommonModule, + ReactiveFormsModule, + AvatarControlComponent, + InputComponent, + SelectComponent, + IconComponent, + TextareaComponent, + ButtonComponent, + UploadFileComponent, + AsyncPipe, + ControlErrorPipe, + ModalComponent, + AvatarComponent, + FormsModule, + RouterLink, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProjectMainStepComponent implements OnInit { + readonly projSubmitInitiated = input(false); + + private readonly fb = inject(FormBuilder); + private readonly projectFormService = inject(ProjectFormService); + private readonly projectTeamUIService = inject(ProjectTeamUIService); + + private readonly projectsEditInfoService = inject(ProjectsEditInfoService); + private readonly projectsEditUIInfoService = inject(ProjectsEditUIInfoService); + + private readonly projectGoalService = inject(ProjectGoalService); + private readonly projectGoalsUIService = inject(ProjectGoalsUIService); + + private readonly projectContactsService = inject(ProjectContactsService); + + protected readonly projectId = this.projectsEditInfoService.profileId; + + // Получаем форму из сервиса + protected readonly projectForm = this.projectsEditInfoService.projectForm; + protected readonly goalForm = this.projectGoalService.getForm(); + + // Id Лидера проекта + protected readonly leaderId = this.projectsEditUIInfoService.leaderId; + protected readonly industries$ = this.projectsEditInfoService.industries$; + + protected readonly goalLeaderShowModal = this.projectGoalsUIService.goalLeaderShowModal; + protected readonly activeGoalIndex = this.projectGoalsUIService.activeGoalIndex; + protected readonly selectedLeaderId = this.projectGoalsUIService.selectedLeaderId; + + protected readonly errorMessage = ErrorMessage; + protected readonly trackList = trackProjectList; + protected readonly directionList = directionProjectList; + protected readonly trlList = generateOptionsList(9, "numbers"); + + // Геттеры для удобного доступа к контролам формы + protected readonly name = this.projectFormService.name; + protected readonly region = this.projectFormService.region; + + protected readonly industry = this.projectFormService.industry; + protected readonly description = this.projectFormService.description; + + protected readonly actuality = this.projectFormService.actuality; + protected readonly implementationDeadline = this.projectFormService.implementationDeadline; + + protected readonly problem = this.projectFormService.problem; + protected readonly targetAudience = this.projectFormService.targetAudience; + protected readonly trl = this.projectFormService.trl; + + protected readonly partnerProgramId = this.projectFormService.partnerProgramId; + protected readonly presentationAddress = this.projectFormService.presentationAddress; + + protected readonly coverImageAddress = this.projectFormService.coverImageAddress; + protected readonly imageAddress = this.projectFormService.imageAddress; + + // Геттеры для работы со ссылками + protected readonly link = this.projectContactsService.link; + protected readonly links = this.projectContactsService.links; + + // Геттеры для работы с целями + protected readonly goals = this.projectGoalService.goals; + protected readonly goalItems = this.projectGoalsUIService.goalItems; + protected readonly goalName = this.projectGoalService.goalName; + protected readonly goalDate = this.projectGoalService.goalDate; + protected readonly goalLeader = this.projectGoalService.goalLeader; + protected readonly editIndex = this.projectFormService.editIndex; + protected readonly collaborators = this.projectTeamUIService.collaborators; + + protected readonly AppRoutes = AppRoutes; + + protected readonly hasLinks = this.projectContactsService.hasLinks; + protected readonly hasGoals = this.projectGoalsUIService.hasGoals; + + ngOnInit(): void {} + + addLink(): void { + this.projectContactsService.addLink(this.links); + } + + editLink(index: number): void { + this.projectContactsService.editLink(index, this.links, this.projectForm); + } + + 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.projectGoalService.addGoal(goalName, goalDate, goalLeader); + } + + removeGoal(index: number, goalId: number): void { + this.projectGoalService.removeGoal(index, goalId, this.projectId()); + } + + getSelectedLeaderForGoal(goalIndex: number) { + return this.projectGoalService.getSelectedLeaderForGoal(goalIndex, this.collaborators()); + } + + onLeaderRadioChange(event: Event): void { + this.projectGoalsUIService.applyOnLeaderRadioChange(event); + } + + addLeader(): void { + this.projectGoalsUIService.applyAddLeaderToGoal(this.goals); + } + + toggleGoalLeaderModal(index?: number): void { + this.projectGoalsUIService.applyToggleGoalLeaderModal(this.goals, index); + } + + protected trackByIndex(index: number): number { + return index; + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-navigation/project-navigation.component.html b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-navigation/project-navigation.component.html new file mode 100644 index 000000000..de1f69197 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-navigation/project-navigation.component.html @@ -0,0 +1,28 @@ + + + diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-navigation/project-navigation.component.scss b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-navigation/project-navigation.component.scss similarity index 100% rename from projects/social_platform/src/app/office/projects/edit/shared/project-navigation/project-navigation.component.scss rename to projects/social_platform/src/app/ui/pages/projects/edit/components/project-navigation/project-navigation.component.scss diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-navigation/project-navigation.component.ts b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-navigation/project-navigation.component.ts new file mode 100644 index 000000000..bf4900bbd --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-navigation/project-navigation.component.ts @@ -0,0 +1,38 @@ +/** @format */ + +import { + Component, + inject, + Output, + EventEmitter, + Input, + ChangeDetectionStrategy, + input, + output, +} from "@angular/core"; +import { ProjectStepService } from "@api/project/project-step.service"; +import { IconComponent } from "@uilib"; +import { CommonModule } from "@angular/common"; +import { Navigation } from "@core/lib/models/navigation.model"; +import { EditStep } from "@core/lib/models/edit-step"; + +/** Навигация по шагам формы проекта. */ +@Component({ + selector: "app-project-navigation", + templateUrl: "./project-navigation.component.html", + styleUrl: "project-navigation.component.scss", + imports: [IconComponent, CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProjectNavigationComponent { + readonly navItems = input.required(); + readonly stepChange = output(); + + private stepService = inject(ProjectStepService); + + protected readonly currentStep = this.stepService.currentStep; + + onStepClick(step: EditStep): void { + this.stepChange.emit(step); + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-partner-resources-step/project-partner-resources-step.component.html b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-partner-resources-step/project-partner-resources-step.component.html new file mode 100644 index 000000000..3aadba3ee --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-partner-resources-step/project-partner-resources-step.component.html @@ -0,0 +1,197 @@ + + +
    +
    + @if (hasPartners()) { +
      + @for (control of partners.controls; track i; let i = $index) { +
    • +
      +
      + @if (partners.at(i)?.get("name"); as name) { +
      + + + @if (name | controlError: "required") { +
      + {{ errorMessage.VALIDATION_REQUIRED }} +
      + } +
      + } + @if (partners.at(i)?.get("inn"); as inn) { +
      + + + @if (inn | controlError: "required") { +
      + {{ errorMessage.VALIDATION_REQUIRED }} +
      + } +
      + } +
      + + @if (partners.at(i)?.get("contribution"); as contribution) { +
      + + + @if (contribution | controlError: "required") { +
      + {{ errorMessage.VALIDATION_REQUIRED }} +
      + } +
      + } + @if (partners.at(i)?.get("decisionMaker"); as decisionMaker) { +
      + + + @if (decisionMaker | controlError: "required") { +
      + {{ errorMessage.VALIDATION_REQUIRED }} +
      + } +
      + } +
      + + + +
    • + } +
    + } + + + добавить партнера + + +
    + +
    + @if (hasResources()) { +
      + @for (control of resources.controls; track i; let i = $index) { +
    • +
      + @if (resources.at(i)?.get("type"); as type) { +
      + + + @if (type | controlError: "required") { +
      + {{ errorMessage.VALIDATION_REQUIRED }} +
      + } +
      + } + @if (resources.at(i)?.get("description"); as description) { +
      + + + @if (description | controlError: "required") { +
      + {{ errorMessage.VALIDATION_REQUIRED }} +
      + } +
      + } + @if (resources.at(i)?.get("partnerCompany"); as partnerCompany) { +
      + + + @if (partnerCompany | controlError: "required") { +
      + {{ errorMessage.VALIDATION_REQUIRED }} +
      + } +
      + } +
      + + + +
    • + } +
    + } + + + добавить ресурс + + +
    + + @if (!partners.length && !resources.length) { + + } +
    diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-partner-resources-step/project-partner-resources-step.component.scss b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-partner-resources-step/project-partner-resources-step.component.scss similarity index 100% rename from projects/social_platform/src/app/office/projects/edit/shared/project-partner-resources-step/project-partner-resources-step.component.scss rename to projects/social_platform/src/app/ui/pages/projects/edit/components/project-partner-resources-step/project-partner-resources-step.component.scss diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-partner-resources-step/project-partner-resources-step.component.ts b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-partner-resources-step/project-partner-resources-step.component.ts new file mode 100644 index 000000000..9fb1ca50e --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-partner-resources-step/project-partner-resources-step.component.ts @@ -0,0 +1,121 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, inject } from "@angular/core"; +import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; +import { ErrorMessage } from "@core/lib/models/error/error-message"; +import { IconComponent } from "@uilib"; +import { ButtonComponent, InputComponent, SelectComponent } from "@ui/primitives"; +import { ControlErrorPipe } from "@corelib"; +import { TextareaComponent } from "@ui/primitives/textarea/textarea.component"; +import { optionsListElement } from "@utils/generate-options-list"; +import { resourceOptionsList } from "@core/consts/lists/resource-options-list.const"; +import { ProjectsEditInfoService } from "@api/project/facades/edit/projects-edit-info.service"; +import { ProjectPartnerService } from "@api/project/facades/edit/project-partner.service"; +import { ProjectResourceService } from "@api/project/facades/edit/project-resources.service"; + +/** Шаг редактирования проекта: партнёры и ресурсы. */ +@Component({ + selector: "app-project-partner-resources-step", + templateUrl: "./project-partner-resources-step.component.html", + styleUrl: "./project-partner-resources-step.component.scss", + imports: [ + CommonModule, + ReactiveFormsModule, + IconComponent, + ButtonComponent, + InputComponent, + ControlErrorPipe, + TextareaComponent, + SelectComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProjectPartnerResourcesStepComponent { + private readonly fb = inject(FormBuilder); + + private readonly projectPartnerService = inject(ProjectPartnerService); + private readonly projectResourceService = inject(ProjectResourceService); + private readonly projectsEditInfoService = inject(ProjectsEditInfoService); + + protected readonly projectId = this.projectsEditInfoService.profileId; + + // Получаем форму из сервиса + protected readonly partnerForm = this.projectPartnerService.getForm(); + protected readonly resourceForm = this.projectResourceService.getForm(); + + // Геттеры для удобного доступа к контролам формы + protected readonly resources = this.projectResourceService.resources; + protected readonly type = this.projectResourceService.resoruceType; + protected readonly description = this.projectResourceService.resoruceDescription; + protected readonly partnerCompany = this.projectResourceService.resourcePartner; + protected readonly partners = this.projectPartnerService.partners; + + protected readonly name = this.projectPartnerService.partnerName; + protected readonly inn = this.projectPartnerService.partnerINN; + protected readonly contribution = this.projectPartnerService.partnerMention; + protected readonly decisionMaker = this.projectPartnerService.partnerProfileLink; + + protected readonly hasPartners = this.projectPartnerService.hasPartners; + protected readonly hasResources = this.projectResourceService.hasResources; + + protected readonly resourcesTypeOptions = resourceOptionsList; + protected readonly errorMessage = ErrorMessage; + + get resourcesCompanyOptions(): optionsListElement[] { + const partners = this.partners.value || []; + + const partnerOptions: optionsListElement[] = partners.map((partner: any, index: number) => { + const id = partner?.company?.id ?? partner?.id ?? index; + const value = partner?.company?.id ?? partner?.id ?? null; + const label = partner?.name; + + return { + id, + value, + label, + } as optionsListElement; + }); + + partnerOptions.push({ + id: -1, + value: "запрос к рынку", + label: "запрос к рынку", + }); + + return partnerOptions; + } + + addPartner(name?: string, inn?: string, contribution?: string, decisionMaker?: string): void { + this.partners.push( + this.fb.group({ + name: [name, [Validators.required]], + inn: [inn, [Validators.required]], + contribution: [contribution, [Validators.required]], + decisionMaker: [decisionMaker, Validators.required], + }), + ); + + this.projectPartnerService.addPartner(name, inn, contribution, decisionMaker); + } + + removePartner(index: number, partnersId: number) { + this.projectPartnerService.removePartner(index, partnersId, this.projectId()); + } + + addResource(type?: string, description?: string, partnerCompany?: string): void { + this.resources.push( + this.fb.group({ + type: [type, [Validators.required]], + description: [description, [Validators.required]], + partnerCompany: [partnerCompany, [Validators.required]], + }), + ); + + this.projectResourceService.addResource(type, description, partnerCompany); + } + + removeResource(index: number, resourceId: number) { + this.projectResourceService.removeResource(index, resourceId, this.projectId()); + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.html b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.html new file mode 100644 index 000000000..6ed7a2af1 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.html @@ -0,0 +1,26 @@ + + +@if (collaborator()) { +
    +
    + +
    +

    + {{ collaborator().firstName | truncate: 12 }} {{ collaborator().lastName | truncate: 12 }} +

    +

    {{ collaborator().role | truncate: 12 }}

    +
    + +
    + + +
    +
    +
    +} diff --git a/projects/social_platform/src/app/office/shared/collaborator-card/collaborator-card.component.scss b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.scss similarity index 100% rename from projects/social_platform/src/app/office/shared/collaborator-card/collaborator-card.component.scss rename to projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.scss diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.spec.ts b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.spec.ts new file mode 100644 index 000000000..30e418306 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.spec.ts @@ -0,0 +1,42 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { CollaboratorCardComponent } from "./collaborator-card.component"; +import { provideRouter } from "@angular/router"; +import { RemoveProjectCollaboratorUseCase } from "@api/project/use-cases/remove-project-collaborator.use-case"; + +describe("CollaboratorCardComponent", () => { + let component: CollaboratorCardComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CollaboratorCardComponent], + providers: [ + provideRouter([]), + { + provide: RemoveProjectCollaboratorUseCase, + useValue: { execute: vi.fn() }, + }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(CollaboratorCardComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput("collaborator", { + userId: 1, + firstName: "Test", + lastName: "User", + role: "Developer", + skills: [], + avatar: "", + }); + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.ts b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.ts new file mode 100644 index 000000000..038e9a82a --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.ts @@ -0,0 +1,80 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + EventEmitter, + inject, + input, + Input, + OnInit, + output, + Output, +} from "@angular/core"; +import { FormBuilder, FormGroup, ReactiveFormsModule } from "@angular/forms"; +import { ActivatedRoute } from "@angular/router"; +import { ErrorMessage } from "@core/lib/models/error/error-message"; +import { Collaborator } from "@domain/project/collaborator.model"; +import { AvatarComponent } from "@ui/primitives/avatar/avatar.component"; +import { IconComponent } from "@uilib"; +import { TruncatePipe } from "@corelib"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { RemoveProjectCollaboratorUseCase } from "@api/project/use-cases/remove-project-collaborator.use-case"; + +/** Карточка участника команды проекта. */ +@Component({ + selector: "app-collaborator-card", + templateUrl: "./collaborator-card.component.html", + styleUrl: "./collaborator-card.component.scss", + imports: [CommonModule, ReactiveFormsModule, AvatarComponent, IconComponent, TruncatePipe], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CollaboratorCardComponent implements OnInit { + private readonly removeProjectCollaboratorUseCase = inject(RemoveProjectCollaboratorUseCase); + private readonly route = inject(ActivatedRoute); + private readonly fb = inject(FormBuilder); + private readonly destroyRef = inject(DestroyRef); + + constructor() { + this.inviteForm = this.fb.group({ + role: [""], + specializations: this.fb.array([]), + }); + } + + inviteForm: FormGroup; + errorMessage = ErrorMessage; + + readonly collaborator = input.required(); + readonly collaboratorRemoved = output(); + + ngOnInit(): void { + if (this.collaborator()) { + this.inviteForm.patchValue({ + role: this.collaborator().role, + specialization: this.collaborator().skills, + }); + } + } + + onDeleteCollaborator(collaboratorId: number): void { + const projectId = this.route.snapshot.params["projectId"]; + + if (!confirm("Вы точно хотите удалить участника проекта?")) return; + + this.removeProjectCollaboratorUseCase + .execute(+projectId, collaboratorId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: result => { + if (!result.ok) { + return; + } + + this.collaboratorRemoved.emit(result.value); + }, + }); + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/invite-card/invite-card.component.html b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/invite-card/invite-card.component.html new file mode 100644 index 000000000..46673b918 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/invite-card/invite-card.component.html @@ -0,0 +1,97 @@ + + +@if (invite()) { +
    +
    +
    +

    {{ invite().role | truncate: 30 }}

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

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

    + } +
    + +
    + +

    + {{ invite().user.firstName | truncate: 15 }} {{ invite().user.lastName | truncate: 15 }} +

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

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

    +
    + Отмена + Удалить +
    +
    +
    + + +
    +

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

    +

    Роль в команде

    +
    + @if (inviteForm.get("role"); as role) { +
    + + @if (role | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } + @if (inviteForm.get("specialization"); as specialization) { +
    + + @if (specialization | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } + Сохранить +
    +
    +
    diff --git a/projects/social_platform/src/app/office/features/invite-card/invite-card.component.scss b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/invite-card/invite-card.component.scss similarity index 100% rename from projects/social_platform/src/app/office/features/invite-card/invite-card.component.scss rename to projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/invite-card/invite-card.component.scss diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/invite-card/invite-card.component.spec.ts b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/invite-card/invite-card.component.spec.ts new file mode 100644 index 000000000..aaa0527c2 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/invite-card/invite-card.component.spec.ts @@ -0,0 +1,42 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { InviteCardComponent } from "./invite-card.component"; +import { provideNgxMask } from "ngx-mask"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { of } from "rxjs"; + +describe("VacancyCardComponent", () => { + let component: InviteCardComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [InviteCardComponent], + providers: [ + provideNgxMask(), + { + provide: AuthRepositoryPort, + useValue: { + fetchProfile: () => of({}), + fetchUserRoles: () => of([]), + fetchChangeableRoles: () => of([]), + fetchLeaderProjects: () => of({}), + }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(InviteCardComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput("invite", { + id: 1, + user: { id: 1, firstName: "Test", lastName: "User", personal: { avatar: "" } }, + }); + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/invite-card/invite-card.component.ts b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/invite-card/invite-card.component.ts new file mode 100644 index 000000000..6f0f011a2 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/invite-card/invite-card.component.ts @@ -0,0 +1,90 @@ +/** @format */ + +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + input, + Input, + OnInit, + output, + Output, + signal, +} from "@angular/core"; +import { FormBuilder, FormGroup, ReactiveFormsModule } from "@angular/forms"; +import { ErrorMessage } from "@core/lib/models/error/error-message"; +import { Invite } from "@domain/invite/invite.model"; +import { IconComponent, ButtonComponent, SelectComponent, InputComponent } from "@ui/primitives"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { rolesMembersList } from "@core/consts/lists/roles-members-list.const"; +import { AvatarComponent } from "@ui/primitives/avatar/avatar.component"; +import { TruncatePipe, ControlErrorPipe } from "@corelib"; + +/** Карточка приглашения в команду с редактированием и удалением. */ +@Component({ + selector: "app-invite-card", + templateUrl: "./invite-card.component.html", + styleUrl: "./invite-card.component.scss", + imports: [ + IconComponent, + ButtonComponent, + ModalComponent, + SelectComponent, + ControlErrorPipe, + TruncatePipe, + ReactiveFormsModule, + InputComponent, + AvatarComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class InviteCardComponent implements OnInit { + constructor(private readonly fb: FormBuilder) { + this.inviteForm = this.fb.group({ + role: [""], + specialization: [null], + }); + } + + readonly rolesMembersList = rolesMembersList; + + inviteForm: FormGroup; + errorMessage = ErrorMessage; + + readonly invite = input.required(); + + readonly remove = output(); + readonly edit = output<{ inviteId: number; role: string; specialization: string }>(); + + ngOnInit(): void { + if (this.invite()) { + this.inviteForm.patchValue({ + role: this.invite().role, + specialization: this.invite().specialization, + }); + } + } + + // Сигналы для управления состоянием модальных окон + isRemoveInviteModal = signal(false); + isEditInviteModal = signal(false); + + onRemove(event: MouseEvent): void { + event.stopPropagation(); + event.preventDefault(); + + this.remove.emit(this.invite()?.id); + } + + onEdit(event: MouseEvent): void { + event.stopPropagation(); + event.preventDefault(); + this.isEditInviteModal.set(false); + + this.edit.emit({ + inviteId: this.invite()?.id, + role: this.inviteForm.value.role, + specialization: this.inviteForm.value.specialization, + }); + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/project-team-step.component.html b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/project-team-step.component.html new file mode 100644 index 000000000..f0151668d --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/project-team-step.component.html @@ -0,0 +1,206 @@ + + +
    +
    +
      + @for (collaborator of collaborators(); track collaborator.userId) { +
    • + +
    • + } +
    +
    + + @if (showInputFields()) { +
    +
    +
    + @if (link; as link) { +
    + + + @if ((link | controlError: "required") && inviteSubmitInitiated()) { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } + @if ((link | controlError: "pattern") && inviteSubmitInitiated()) { +
    + {{ errorMessage.VALIDATION_PROFILE_LINK }} +
    + } + @if (inviteNotExistingError() && inviteSubmitInitiated()) { +
    + {{ errorMessage.USER_NOT_EXISTING }} либо
    + {{ errorMessage.USER_IS_LEADER }} либо
    + {{ errorMessage.USER_IS_MEMBER }} либо
    +
    + } +
    + } + @if (role; as role) { +
    + + + @if ((role | controlError: "required") && inviteSubmitInitiated()) { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } + + +
    +
    + + +
    + } + + + +
    +
    + + + @if (isHintTeamVisible("team")) { +
    +

    + Напишите зону ответственности участника, приглашаемого в команду +

    +

    подробнее

    +
    + } +
    +
    + + +
    +
    +

    Уверены, большие дела не делаются в одиночку!

    +
    + +
    +

    + После создания проекта пригласите в команду тех, кто вместе с вами будет причастен к его + реализации +

    + +

    + Вставьте ссылку на профиль участника на платформе, а также кратко напишите зону + ответственности или (при наличии) конкретную роль в команде +

    + +

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

    +
    + + спасибо, понятно +
    +
    +
    diff --git a/projects/social_platform/src/app/office/projects/edit/shared/project-team-step/project-team-step.component.scss b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/project-team-step.component.scss similarity index 100% rename from projects/social_platform/src/app/office/projects/edit/shared/project-team-step/project-team-step.component.scss rename to projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/project-team-step.component.scss diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/project-team-step.component.ts b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/project-team-step.component.ts new file mode 100644 index 000000000..8f9a844fe --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/project-team-step.component.ts @@ -0,0 +1,143 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, inject, OnInit } from "@angular/core"; +import { ReactiveFormsModule } from "@angular/forms"; +import { InputComponent, ButtonComponent } from "@ui/primitives"; +import { ControlErrorPipe } from "@corelib"; +import { ErrorMessage } from "@core/lib/models/error/error-message"; +import { InviteCardComponent } from "./invite-card/invite-card.component"; +import { rolesMembersList } from "@core/consts/lists/roles-members-list.const"; +import { IconComponent } from "@uilib"; +import { CollaboratorCardComponent } from "./collaborator-card/collaborator-card.component"; +import { TooltipComponent } from "@ui/primitives/tooltip/tooltip.component"; +import { ToggleFieldsInfoService } from "@api/toggle-fields/toggle-fields-info.service"; +import { TooltipInfoService } from "@api/tooltip/tooltip-info.service"; +import { ProjectTeamService } from "@api/project/facades/edit/project-team.service"; +import { ProjectTeamUIService } from "@api/project/facades/edit/ui/project-team-ui.service"; +import { ProjectsEditInfoService } from "@api/project/facades/edit/projects-edit-info.service"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; + +/** Шаг редактирования проекта: команда. */ +@Component({ + selector: "app-project-team-step", + templateUrl: "./project-team-step.component.html", + styleUrl: "./project-team-step.component.scss", + imports: [ + CommonModule, + ReactiveFormsModule, + InputComponent, + ButtonComponent, + IconComponent, + ControlErrorPipe, + InviteCardComponent, + CollaboratorCardComponent, + TooltipComponent, + ModalComponent, + ], + providers: [ToggleFieldsInfoService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProjectTeamStepComponent implements OnInit { + private readonly projectsEditInfoService = inject(ProjectsEditInfoService); + private readonly projectTeamService = inject(ProjectTeamService); + private readonly projectTeamUIService = inject(ProjectTeamUIService); + protected readonly tooltipInfoService = inject(TooltipInfoService); + private readonly toggleFieldsInfoService = inject(ToggleFieldsInfoService); + + // Константы для селектов + protected readonly rolesMembersList = rolesMembersList; + protected readonly showInputFields = this.toggleFieldsInfoService.showInputFields; + + // Геттеры для формы + protected readonly inviteForm = this.projectTeamUIService.inviteForm; + + protected readonly role = this.projectTeamUIService.role; + protected readonly link = this.projectTeamUIService.link; + protected readonly specialization = this.projectTeamUIService.specialization; + + // Геттеры для данных + protected readonly invites = this.projectTeamUIService.invites; + protected readonly collaborators = this.projectTeamUIService.collaborators; + protected readonly invitesFill = this.projectTeamUIService.invitesFill; + + protected readonly isInviteModalOpen = this.projectTeamUIService.isInviteModalOpen; + protected readonly inviteNotExistingError = this.projectTeamUIService.inviteNotExistingError; + protected readonly inviteSubmitInitiated = this.projectTeamUIService.inviteSubmitInitiated; + protected readonly inviteFormIsSubmitting = this.projectTeamUIService.inviteFormIsSubmitting; + + protected readonly projectId = this.projectsEditInfoService.profileId; + + /** Наличие подсказки */ + protected readonly haveHint = this.tooltipInfoService.haveHint; + + protected isHintTeamVisible = this.tooltipInfoService.isVisible; + protected readonly isHintTeamModal = this.projectTeamUIService.isHintTeamModal; + + /** Позиция подсказки */ + protected readonly tooltipPosition = this.tooltipInfoService.tooltipPosition; + + /** Состояние видимости подсказки */ + protected readonly isTooltipVisible = this.tooltipInfoService.isVisible; + + protected readonly errorMessage = ErrorMessage; + + ngOnInit(): void { + // Настраиваем динамическую валидацию + this.projectTeamService.setupDynamicValidation(); + } + + /** Показать подсказку */ + toggleTooltip(key: "base" | "team"): void { + this.tooltipInfoService.toggleTooltip(key); + } + + /** + * Открытие блоков для создания приглашения + */ + createInvitationBlock(): void { + this.toggleFieldsInfoService.showFields(); + } + + openInviteModal(): void { + this.projectTeamUIService.applyOpenInviteModal(); + } + + closeInviteModal(): void { + this.projectTeamUIService.applyCloseInviteModal(); + } + + submitInvite(): void { + if (this.link?.value!.trim() || this.role?.value!.trim()) { + this.projectTeamService.submitInvite(this.projectId()); + this.toggleFieldsInfoService.hideFields(); + return; + } + + this.toggleFieldsInfoService.showFields(); + } + + editInvitation(params: { inviteId: number; role: string; specialization: string }): void { + this.projectTeamService.editInvitation(params); + } + + removeInvitation(invitationId: number): void { + this.projectTeamService.removeInvitation(invitationId); + } + + onModalOpenChange(open: boolean): void { + if (!open) { + this.closeInviteModal(); + } + } + + onCollaboratorRemove(collaboratorId: number): void { + this.projectTeamUIService.applyRemoveCollaborator(collaboratorId); + } + + openHintModal(event: Event): void { + event.preventDefault(); + this.tooltipInfoService.toggleTooltip("team"); + this.projectTeamUIService.applyOpenHintModal(); + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-vacancy-step/project-vacancy-step.component.html b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-vacancy-step/project-vacancy-step.component.html new file mode 100644 index 000000000..3accf2311 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-vacancy-step/project-vacancy-step.component.html @@ -0,0 +1,219 @@ + + +
    +
    + @if (showInputFields()) { +
    + @if (role; as role) { +
    + + + @if ((role | controlError: "required") && vacancySubmitInitiated()) { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } + @if (description) { +
    + + + @if ((description | controlError: "required") && vacancySubmitInitiated()) { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } + +
    +
    + @if (requiredExperience; as requiredExperience) { +
    + + + @if (requiredExperience | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } + @if (workFormat; as workFormat) { +
    + + + @if (workFormat | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } +
    + +
    + @if (salary; as salary) { +
    + + + @if ((salary | controlError: "required") && vacancySubmitInitiated()) { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } + @if (workSchedule; as workSchedule) { +
    + + + @if (workSchedule | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } +
    +
    + + + + @if (skills; as skills) { +
    + + @if (skills | controlError: "required") { +
    + {{ errorMessage.VALIDATION_REQUIRED }} +
    + } +
    + } +
    + } + + {{ showInputFields() ? "добавить вакансию" : "создать вакансию" }} + + +
    + +
    +
      + @for (vacancy of vacancies(); track vacancy.id) { +
    • + +
    • + } +
    +
    + + @if (!vacancies().length) { + + } +
    + + + + diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-vacancy-step/project-vacancy-step.component.scss b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-vacancy-step/project-vacancy-step.component.scss new file mode 100644 index 000000000..45c3b52a2 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-vacancy-step/project-vacancy-step.component.scss @@ -0,0 +1,147 @@ +/** @format */ + +@use "styles/responsive"; +@use "styles/typography"; + +.project { + position: relative; + + &__inner { + width: 100%; + margin-bottom: 25px; + + @include responsive.apply-desktop { + display: flex; + gap: 20px; + justify-content: space-between; + margin-bottom: 0; + margin-bottom: 20px; + } + } + + &__inner > fieldset:not(:last-child) { + margin-bottom: 20px; + } + + &__left { + flex-basis: 60%; + margin-bottom: 20px; + } + + &__right { + flex-basis: 30%; + + :first-child & :not(span, fieldset, label, h4, p, i, ul, li) { + margin-top: 26px; + margin-bottom: 10px; + } + + :last-child & :not(i, span) { + margin-top: 10px; + } + } + + &__no-items { + position: absolute; + bottom: 0%; + left: 50%; + } +} + +.invite { + &__item { + margin-bottom: 12px; + } +} + +.vacancy { + &__item { + margin-bottom: 12px; + } + + fieldset { + margin-bottom: 12px; + } + + &__form-list { + display: flex; + flex-wrap: wrap; + } + + &__skill { + margin-bottom: 12px; + + &:not(:last-child) { + margin-right: 10px; + } + } + + &__info, + &__additional { + display: flex; + gap: 20px; + align-items: center; + + :first-child, + :last-child { + flex-basis: 50%; + } + } + + &__submit { + display: block; + } +} + +.vacancies { + display: flex; + + &__input { + flex-grow: 1; + margin-right: 6px; + } +} + +.modal { + &__wrapper { + display: flex; + flex-direction: column; + gap: 20px; + align-items: center; + width: 672px; + } + + &__content { + display: flex; + flex-direction: column; + gap: 20px; + width: 100%; + max-width: 536px; + height: 480px; + background-color: var(--white); + border: 1px solid var(--medium-grey-for-outline); + border-radius: 8px; + box-shadow: 5px 5px 25px 0 var(--gray-for-shadow); + } + + &__specs-groups, + &__skills-groups { + height: 100%; + overflow: auto; + scrollbar-width: thin; + + ul { + display: flex; + flex-direction: column; + gap: 20px; + padding: 14px; + } + + li { + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + } + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-vacancy-step/project-vacancy-step.component.ts b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-vacancy-step/project-vacancy-step.component.ts new file mode 100644 index 000000000..73bd92503 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-vacancy-step/project-vacancy-step.component.ts @@ -0,0 +1,140 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, inject, OnInit } from "@angular/core"; +import { ReactiveFormsModule } from "@angular/forms"; +import { + InputComponent, + ButtonComponent, + SelectComponent, + TextareaComponent, +} from "@ui/primitives"; +import { ControlErrorPipe } from "@corelib"; +import { ErrorMessage } from "@core/lib/models/error/error-message"; +import { AutoCompleteInputComponent } from "@ui/primitives/autocomplete-input/autocomplete-input.component"; +import { SkillsBasketComponent } from "@ui/widgets/skills-basket/skills-basket.component"; +import { VacancyCardComponent } from "@ui/widgets/vacancy-card/vacancy-card.component"; +import { IconComponent } from "@uilib"; +import { Skill } from "@domain/skills/skill.model"; +import { ProjectsEditInfoService } from "@api/project/facades/edit/projects-edit-info.service"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { SkillsGroupComponent } from "@ui/widgets/skills-group/skills-group.component"; +import { ProjectVacancyUIService } from "@api/project/facades/edit/ui/project-vacancy-ui.service"; +import { ToggleFieldsInfoService } from "@api/toggle-fields/toggle-fields-info.service"; +import { ProjectVacancyService } from "@api/project/facades/edit/project-vacancy.service"; +import { SearchesService } from "@api/searches/searches.service"; + +/** Шаг редактирования проекта: вакансии. */ +@Component({ + selector: "app-project-vacancy-step", + templateUrl: "./project-vacancy-step.component.html", + styleUrl: "./project-vacancy-step.component.scss", + imports: [ + CommonModule, + ReactiveFormsModule, + InputComponent, + ButtonComponent, + IconComponent, + ControlErrorPipe, + SelectComponent, + TextareaComponent, + AutoCompleteInputComponent, + SkillsBasketComponent, + VacancyCardComponent, + ModalComponent, + SkillsGroupComponent, + ], + providers: [ProjectsEditInfoService, ProjectVacancyService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProjectVacancyStepComponent { + private readonly projectVacancyInfoService = inject(ProjectVacancyService); + private readonly projectVacancyUIService = inject(ProjectVacancyUIService); + private readonly projectsEditInfoService = inject(ProjectsEditInfoService); + private readonly searchesService = inject(SearchesService); + private readonly toggleFieldsInfoService = inject(ToggleFieldsInfoService); + + // Геттеры для формы + protected readonly vacancyForm = this.projectVacancyUIService.vacancyForm; + + protected readonly role = this.projectVacancyUIService.role; + protected readonly description = this.projectVacancyUIService.description; + protected readonly requiredExperience = this.projectVacancyUIService.requiredExperience; + protected readonly workFormat = this.projectVacancyUIService.workFormat; + protected readonly salary = this.projectVacancyUIService.salary; + protected readonly workSchedule = this.projectVacancyUIService.workSchedule; + protected readonly skills = this.projectVacancyUIService.skills; + protected readonly specialization = this.projectVacancyUIService.specialization; + + // Геттеры для данных + protected readonly vacancies = this.projectVacancyUIService.vacancies; + + protected readonly experienceList = this.projectVacancyUIService.workExperienceList; + protected readonly formatList = this.projectVacancyUIService.workFormatList; + protected readonly scheludeList = this.projectVacancyUIService.workScheduledList; + protected readonly rolesMembersList = this.projectVacancyUIService.rolesMembersList; + + protected readonly selectedRequiredExperienceId = + this.projectVacancyUIService.selectedRequiredExperienceId; + + protected readonly selectedWorkFormatId = this.projectVacancyUIService.selectedWorkFormatId; + protected readonly selectedWorkScheduleId = this.projectVacancyUIService.selectedWorkScheduleId; + protected readonly selectedVacanciesSpecializationId = + this.projectVacancyUIService.selectedVacanciesSpecializationId; + + protected readonly vacancySubmitInitiated = this.projectVacancyUIService.vacancySubmitInitiated; + protected readonly vacancyIsSubmitting = this.projectVacancyUIService.vacancyIsSubmittingFlag; + + protected readonly inlineSkills = this.projectsEditInfoService.inlineSkills; + protected readonly projectId = this.projectsEditInfoService.profileId; + protected readonly showInputFields = this.toggleFieldsInfoService.showInputFields; + + // Сигналы для управления состоянием + protected readonly nestedSkills$ = this.projectsEditInfoService.nestedSkills$; + protected readonly skillsGroupsModalOpen = this.projectVacancyUIService.skillsGroupsModalOpen; + + protected readonly hasOpenSkillsGroups = this.projectsEditInfoService.hasOpenSkillsGroups; + protected readonly openGroupIds = this.projectsEditInfoService.openGroupIds; + + protected readonly errorMessage = ErrorMessage; + + createVacancyBlock(): void { + this.toggleFieldsInfoService.showFields(); + } + + submitVacancy(): void { + this.projectVacancyInfoService.submitVacancy(this.projectId()); + } + + removeVacancy(vacancyId: number): void { + this.projectVacancyInfoService.removeVacancy(vacancyId); + } + + editVacancy(index: number): void { + this.projectVacancyUIService.applyEditVacancy(index); + } + + onAddSkill(newSkill: Skill): void { + this.searchesService.onAddSkill(newSkill, this.vacancyForm); + } + + onRemoveSkill(oddSkill: Skill): void { + this.searchesService.onRemoveSkill(oddSkill, this.vacancyForm); + } + + onToggleSkill(toggledSkill: Skill): void { + this.searchesService.onToggleSkill(toggledSkill, this.vacancyForm); + } + + onSearchSkill(query: string): void { + this.projectsEditInfoService.onSearchSkill(query); + } + + onToggleSkillsGroupsModal(): void { + this.skillsGroupsModalOpen.update(open => !open); + } + + onGroupToggled(isOpen: boolean, skillsGroupId: number): void { + this.projectsEditInfoService.onGroupToggled(isOpen, skillsGroupId); + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.html b/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.html new file mode 100644 index 000000000..2bbcdd901 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.html @@ -0,0 +1,249 @@ + +
    +
    +
    + +

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

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

    📢 внимание!

    +

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

    + понятно +
    +
    +
    +
    + +@defer (when isCompleted()) { + +
    +
    + +

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

    +
    + + end + +

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

    +
    +
    +} + +@defer (when isSendDescisionLate()) { + +
    +
    + +

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

    +
    + +

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

    +
    +
    +} + +@defer (when fromProgramOpen()) { + +
    +
    +

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

    +
    + +
    +

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

    + +

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

    + +

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

    + +

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

    + +

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

    +
    + + спасибо, понятно +
    +
    +} + +@defer (when isSendDescisionToPartnerProgramProject()) { + +
    +
    + + end +

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

    +
    + +

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

    + +
    + Отмена + Отправить +
    +
    +
    +} diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.scss b/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.scss new file mode 100644 index 000000000..0b7f1bb9c --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.scss @@ -0,0 +1,205 @@ +/** @format */ + +@use "styles/responsive"; +@use "styles/typography"; + +.project { + position: relative; + 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-evenly; + width: 100%; + padding: 4px 0; + margin-top: 20px; + background-color: var(--light-white); + border-radius: var(--rounded-xxl); + } + + &__title { + display: none; + + @include responsive.apply-desktop { + display: block; + } + + @include typography.heading-1; + } + + &__back { + display: flex; + gap: 10px; + align-items: center; + cursor: pointer; + } + + &__form { + display: flex; + flex-direction: column; + color: var(--black); + } + + &__inner { + width: 100%; + margin-bottom: 25px; + + @include responsive.apply-desktop { + display: flex; + gap: 90px; + justify-content: space-between; + margin-bottom: 0; + margin-bottom: 20px; + } + } + + &__inner > fieldset:not(:last-child) { + margin-bottom: 20px; + } + + &__left { + flex-basis: 50%; + margin-bottom: 20px; + + form { + width: 280px; + + @include responsive.apply-desktop { + width: 600px; + } + } + } + + &__right { + flex-basis: 50%; + + :first-child & :not(span, fieldset, label, h4, p, i) { + margin-top: 26px; + margin-bottom: 10px; + } + + :last-child & :not(i, span) { + margin-top: 10px; + } + } + + &__image { + display: block; + margin-bottom: 20px; + + .error { + margin-top: 15px; + } + } + + &__info { + display: flex; + justify-content: space-between; + } + + &__or { + margin: 20px 0; + color: var(--dark-grey); + } + + &__save { + @include responsive.apply-desktop { + display: flex; + gap: 10px; + align-items: center; + } + } + + &__warning-modal { + z-index: 1000; + display: flex; + flex-direction: column; + gap: 20px; + align-items: center; + + span { + color: var(--red); + } + } +} + +.skill { + i { + color: var(--red); + cursor: pointer; + } +} + +.project-bar { + padding: 9px 0; + margin-bottom: 12px; +} + +.cancel { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 80%; + max-height: calc(100vh - 40px); + padding: 40px 0 80px; + overflow-y: auto; + + @include responsive.apply-desktop { + width: 50%; + } + + &__cross { + position: absolute; + top: 0; + right: 0; + width: 32px; + height: 32px; + cursor: pointer; + + @include responsive.apply-desktop { + top: 8px; + right: 8px; + } + } + + &__top { + display: flex; + flex-direction: column; + margin-bottom: 10px; + } + + &__title { + text-align: center; + } + + &__text { + text-align: center; + + &-block { + display: flex; + flex-direction: column; + gap: 12px; + margin: 30px 0; + } + } + + &__buttons { + display: flex; + gap: 10px; + align-items: center; + margin-top: 20px; + } + + &__button { + margin-top: 20px; + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.spec.ts b/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.spec.ts new file mode 100644 index 000000000..8c133365a --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.spec.ts @@ -0,0 +1,149 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ProjectEditComponent } from "./edit.component"; +import { provideRouter } from "@angular/router"; +import { ReactiveFormsModule, FormBuilder } from "@angular/forms"; +import { provideNgxMask } from "ngx-mask"; +import { signal } from "@angular/core"; +import { ProjectFormService } from "@api/project/facades/edit/project-form.service"; +import { ProjectVacancyService } from "@api/project/facades/edit/project-vacancy.service"; +import { ProjectVacancyUIService } from "@api/project/facades/edit/ui/project-vacancy-ui.service"; +import { ProjectTeamService } from "@api/project/facades/edit/project-team.service"; +import { ProjectTeamUIService } from "@api/project/facades/edit/ui/project-team-ui.service"; +import { ProjectAdditionalService } from "@api/project/facades/edit/project-additional.service"; +import { ProjectGoalService } from "@api/project/facades/edit/project-goals.service"; +import { ProjectAchievementsService } from "@api/project/facades/edit/project-achievements.service"; +import { ProjectPartnerService } from "@api/project/facades/edit/project-partner.service"; +import { ProjectResourceService } from "@api/project/facades/edit/project-resources.service"; +import { ProjectsEditInfoService } from "@api/project/facades/edit/projects-edit-info.service"; +import { ProjectsEditUIInfoService } from "@api/project/facades/edit/ui/projects-edit-ui-info.service"; +import { TooltipInfoService } from "@api/tooltip/tooltip-info.service"; +import { ToggleFieldsInfoService } from "@api/toggle-fields/toggle-fields-info.service"; +import { ProjectStepService } from "@api/project/project-step.service"; + +describe("ProjectEditComponent", () => { + let component: ProjectEditComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const fb = new FormBuilder(); + const projectsEditInfoServiceSpy = { + initializationEditInfo: vi.fn(), + loadProgramTagsAndProject: vi.fn(), + destroy: vi.fn(), + deleteProject: vi.fn(), + saveProjectAsPublished: vi.fn(), + saveProjectAsDraft: vi.fn(), + submitProjectForm: vi.fn(), + closeSendingDescisionModal: vi.fn(), + projectForm: fb.group({ name: [""] }), + additionalForm: fb.group({}), + profileId: signal(0), + projSubmitInitiated: signal(false), + projFormIsSubmittingAsPublished: signal(false), + projFormIsSubmittingAsDraft: signal(false), + }; + + const projectsEditUIInfoServiceSpy = { + fromProgram: signal(false), + fromProgramOpen: signal(false), + isCompetitive: signal(false), + isProjectAssignToProgram: signal(false), + isProjectBoundToProgram: signal(false), + isCompleted: signal(false), + isSendDescisionLate: signal(false), + isSendDescisionToPartnerProgramProject: signal(false), + errorModalMessage: signal(""), + onEditClicked: signal(false), + warningModalSeen: signal(false), + applyCloseWarningModal: vi.fn(), + }; + + const projectStepServiceSpy = { + currentStep: signal(0), + navigateToStep: vi.fn(), + }; + + const projectVacancyUIServiceSpy = { + vacancyForm: fb.group({}), + }; + + const projectGoalServiceSpy = { + reset: vi.fn(), + }; + + const emptySpy = { execute: vi.fn() }; + + await TestBed.configureTestingModule({ + imports: [ReactiveFormsModule, ProjectEditComponent], + providers: [provideNgxMask(), provideRouter([])], + }) + .overrideComponent(ProjectEditComponent, { + remove: { + providers: [ + ProjectsEditInfoService, + ProjectsEditUIInfoService, + ProjectStepService, + ProjectVacancyUIService, + ProjectGoalService, + ProjectFormService, + ProjectVacancyService, + ProjectTeamService, + ProjectTeamUIService, + ProjectAdditionalService, + ProjectAchievementsService, + ProjectPartnerService, + ProjectResourceService, + TooltipInfoService, + ToggleFieldsInfoService, + ], + }, + add: { + providers: [ + { + provide: ProjectsEditInfoService, + useValue: projectsEditInfoServiceSpy, + }, + { + provide: ProjectsEditUIInfoService, + useValue: projectsEditUIInfoServiceSpy, + }, + { + provide: ProjectStepService, + useValue: projectStepServiceSpy, + }, + { + provide: ProjectVacancyUIService, + useValue: projectVacancyUIServiceSpy, + }, + { + provide: ProjectGoalService, + useValue: projectGoalServiceSpy, + }, + { provide: ProjectFormService, useValue: emptySpy }, + { provide: ProjectVacancyService, useValue: emptySpy }, + { provide: ProjectTeamService, useValue: emptySpy }, + { provide: ProjectTeamUIService, useValue: emptySpy }, + { provide: ProjectAdditionalService, useValue: emptySpy }, + { provide: ProjectAchievementsService, useValue: emptySpy }, + { provide: ProjectPartnerService, useValue: emptySpy }, + { provide: ProjectResourceService, useValue: emptySpy }, + { provide: TooltipInfoService, useValue: emptySpy }, + { provide: ToggleFieldsInfoService, useValue: emptySpy }, + ], + }, + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ProjectEditComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.ts b/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.ts new file mode 100644 index 000000000..779370e37 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.ts @@ -0,0 +1,185 @@ +/** @format */ + +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + inject, + OnDestroy, + OnInit, +} from "@angular/core"; +import { ReactiveFormsModule } from "@angular/forms"; +import { RouterModule } from "@angular/router"; +import { ErrorMessage } from "@core/lib/models/error/error-message"; +import { ButtonComponent, IconComponent } from "@ui/primitives"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { CommonModule } from "@angular/common"; +import { ProjectNavigationComponent } from "./components/project-navigation/project-navigation.component"; +import { ProjectStepService } from "@api/project/project-step.service"; +import { ProjectMainStepComponent } from "./components/project-main-step/project-main-step.component"; +import { ProjectFormService } from "@api/project/facades/edit/project-form.service"; +import { ProjectPartnerResourcesStepComponent } from "./components/project-partner-resources-step/project-partner-resources-step.component"; +import { ProjectAchievementStepComponent } from "./components/project-achievement-step/project-achievement-step.component"; +import { ProjectVacancyStepComponent } from "./components/project-vacancy-step/project-vacancy-step.component"; +import { ProjectTeamStepComponent } from "./components/project-team-step/project-team-step.component"; +import { ProjectAdditionalStepComponent } from "./components/project-additional-step/project-additional-step.component"; +import { navProjectItems } from "@core/consts/navigation/nav-project-items.const"; +import { ProjectsEditInfoService } from "@api/project/facades/edit/projects-edit-info.service"; +import { ProjectsEditUIInfoService } from "@api/project/facades/edit/ui/projects-edit-ui-info.service"; +import { ProjectVacancyUIService } from "@api/project/facades/edit/ui/project-vacancy-ui.service"; +import { ProjectAdditionalService } from "@api/project/facades/edit/project-additional.service"; +import { ProjectGoalService } from "@api/project/facades/edit/project-goals.service"; +import { ProjectPartnerService } from "@api/project/facades/edit/project-partner.service"; +import { ProjectResourceService } from "@api/project/facades/edit/project-resources.service"; +import { ProjectAchievementsService } from "@api/project/facades/edit/project-achievements.service"; +import { ProjectVacancyService } from "@api/project/facades/edit/project-vacancy.service"; +import { ProjectTeamUIService } from "@api/project/facades/edit/ui/project-team-ui.service"; +import { ProjectTeamService } from "@api/project/facades/edit/project-team.service"; +import { TooltipInfoService } from "@api/tooltip/tooltip-info.service"; +import { ToggleFieldsInfoService } from "@api/toggle-fields/toggle-fields-info.service"; +import { AppRoutes } from "@api/paths/app-routes"; +import { EditStep } from "@core/lib/models/edit-step"; + +/** Многошаговое редактирование проекта. */ +@Component({ + selector: "app-edit", + templateUrl: "./edit.component.html", + styleUrl: "./edit.component.scss", + imports: [ + ReactiveFormsModule, + CommonModule, + RouterModule, + IconComponent, + ButtonComponent, + ModalComponent, + ProjectNavigationComponent, + ProjectMainStepComponent, + ProjectAchievementStepComponent, + ProjectVacancyStepComponent, + ProjectTeamStepComponent, + ProjectAdditionalStepComponent, + ProjectPartnerResourcesStepComponent, + ], + providers: [ + ProjectFormService, + ProjectVacancyService, + ProjectVacancyUIService, + ProjectTeamService, + ProjectTeamUIService, + ProjectAdditionalService, + ProjectGoalService, + ProjectAchievementsService, + ProjectPartnerService, + ProjectResourceService, + ProjectsEditInfoService, + ProjectsEditUIInfoService, + TooltipInfoService, + ToggleFieldsInfoService, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProjectEditComponent implements OnInit, AfterViewInit, OnDestroy { + private readonly projectsEditInfoService = inject(ProjectsEditInfoService); + protected readonly AppRoutes = AppRoutes; + private readonly projectsEditUIInfoService = inject(ProjectsEditUIInfoService); + + private readonly projectStepService = inject(ProjectStepService); + private readonly projectVacancyUIService = inject(ProjectVacancyUIService); + private readonly projectGoalService = inject(ProjectGoalService); + + // Получаем форму проекта из сервиса + protected readonly projectForm = this.projectsEditInfoService.projectForm; + + // Получаем форму вакансии из сервиса + protected readonly vacancyForm = this.projectVacancyUIService.vacancyForm; + + // Получаем форму дополнительных полей из сервиса + protected readonly additionalForm = this.projectsEditInfoService.additionalForm; + + protected readonly fromProgram = this.projectsEditUIInfoService.fromProgram; + protected readonly fromProgramOpen = this.projectsEditUIInfoService.fromProgramOpen; + + // Маркер того является ли проект привязанный к конкурсной программе + protected readonly isCompetitive = this.projectsEditUIInfoService.isCompetitive; + protected readonly isProjectAssignToProgram = + this.projectsEditUIInfoService.isProjectAssignToProgram; + + // Маркер что проект привязан + protected readonly isProjectBoundToProgram = + this.projectsEditUIInfoService.isProjectBoundToProgram; + + // Текущий шаг редактирования + protected readonly editingStep = this.projectStepService.currentStep; + + // Состояние компонента + protected readonly isCompleted = this.projectsEditUIInfoService.isCompleted; + protected readonly isSendDescisionLate = this.projectsEditUIInfoService.isSendDescisionLate; + protected readonly isSendDescisionToPartnerProgramProject = + this.projectsEditUIInfoService.isSendDescisionToPartnerProgramProject; + + // Сигналы для работы с модальными окнами с ошибкой + protected readonly errorModalMessage = this.projectsEditUIInfoService.errorModalMessage; + + protected readonly onEditClicked = this.projectsEditUIInfoService.onEditClicked; + protected readonly warningModalSeen = this.projectsEditUIInfoService.warningModalSeen; + + protected readonly profileId = this.projectsEditInfoService.profileId; + + // Состояние отправки форм + protected readonly projSubmitInitiated = this.projectsEditInfoService.projSubmitInitiated; + protected readonly projFormIsSubmittingAsPublished = + this.projectsEditInfoService.projFormIsSubmittingAsPublished; + + protected readonly projFormIsSubmittingAsDraft = + this.projectsEditInfoService.projFormIsSubmittingAsDraft; + + protected readonly navProjectItems = navProjectItems; + protected readonly errorMessage = ErrorMessage; + + ngOnInit(): void { + this.projectsEditInfoService.initializationEditInfo(); + } + + ngAfterViewInit(): void { + // Загрузка данных программных тегов и проекта + this.projectsEditInfoService.loadProgramTagsAndProject(); + } + + ngOnDestroy(): void { + // Сброс состояния ProjectGoalService при уничтожении компонента + this.projectGoalService.reset(); + } + + navigateStep(step: EditStep): void { + this.projectStepService.navigateToStep(step); + } + + deleteProject(): void { + if (!confirm("Вы точно хотите удалить проект?")) { + return; + } + + this.projectsEditInfoService.deleteProject(); + } + + saveProjectAsPublished(): void { + this.projectsEditInfoService.saveProjectAsPublished(); + } + + saveProjectAsDraft(): void { + this.projectsEditInfoService.saveProjectAsDraft(); + } + + submitProjectForm(): void { + this.projectsEditInfoService.submitProjectForm(); + } + + // Методы для работы с модальными окнами + closeWarningModal(): void { + this.projectsEditUIInfoService.applyCloseWarningModal(); + } + + closeSendingDescisionModal(): void { + this.projectsEditInfoService.closeSendingDescisionModal(); + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/edit.resolver.spec.ts b/projects/social_platform/src/app/ui/pages/projects/edit/edit.resolver.spec.ts new file mode 100644 index 000000000..01994462b --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/edit/edit.resolver.spec.ts @@ -0,0 +1,44 @@ +/** @format */ + +import { TestBed } from "@angular/core/testing"; +import { ProjectEditResolver } from "./edit.resolver"; +import { + provideRouter, + ActivatedRouteSnapshot, + convertToParamMap, + RouterStateSnapshot, +} from "@angular/router"; +import { of } from "rxjs"; +import { GetProjectUseCase } from "@api/project/use-cases/get-project.use-case"; +import { GetProjectGoalsUseCase } from "@api/project/use-cases/get-project-goals.use-case"; +import { GetProjectPartnersUseCase } from "@api/project/use-cases/get-project-partners.use-case"; +import { GetProjectResourcesUseCase } from "@api/project/use-cases/get-project-resources.use-case"; +import { GetProjectInvitesUseCase } from "@api/invite/use-cases/get-project-invites.use-case"; + +describe("ProjectEditResolver", () => { + const mockRoute = { + paramMap: convertToParamMap({ projectId: 1 }), + } as unknown as ActivatedRouteSnapshot; + + beforeEach(() => { + const okResult = (value: any) => of({ ok: true, value }); + + TestBed.configureTestingModule({ + providers: [ + provideRouter([]), + { provide: GetProjectUseCase, useValue: { execute: () => okResult({}) } }, + { provide: GetProjectGoalsUseCase, useValue: { execute: () => okResult([]) } }, + { provide: GetProjectPartnersUseCase, useValue: { execute: () => okResult([]) } }, + { provide: GetProjectResourcesUseCase, useValue: { execute: () => okResult([]) } }, + { provide: GetProjectInvitesUseCase, useValue: { execute: () => okResult([]) } }, + ], + }); + }); + + it("should be created", () => { + const result = TestBed.runInInjectionContext(() => + ProjectEditResolver(mockRoute, {} as RouterStateSnapshot), + ); + expect(result).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/edit.resolver.ts b/projects/social_platform/src/app/ui/pages/projects/edit/edit.resolver.ts new file mode 100644 index 000000000..f136aeede --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/edit/edit.resolver.ts @@ -0,0 +1,44 @@ +/** @format */ + +import { inject } from "@angular/core"; +import { ActivatedRouteSnapshot, ResolveFn } from "@angular/router"; +import { forkJoin, map } from "rxjs"; +import { Invite } from "@domain/invite/invite.model"; +import { Project } from "@domain/project/project.model"; +import { Goal } from "@domain/project/goals.model"; +import { Partner } from "@domain/project/partner.model"; +import { Resource } from "@domain/project/resource.model"; +import { GetProjectInvitesUseCase } from "@api/invite/use-cases/get-project-invites.use-case"; +import { GetProjectUseCase } from "@api/project/use-cases/get-project.use-case"; +import { GetProjectGoalsUseCase } from "@api/project/use-cases/get-project-goals.use-case"; +import { GetProjectPartnersUseCase } from "@api/project/use-cases/get-project-partners.use-case"; +import { GetProjectResourcesUseCase } from "@api/project/use-cases/get-project-resources.use-case"; + +/** Предзагружает данные проекта, целей, партнёров, ресурсов и приглашений для редактирования. */ +export const ProjectEditResolver: ResolveFn<[Project, Goal[], Partner[], Resource[], Invite[]]> = ( + route: ActivatedRouteSnapshot, +) => { + const getProjectUseCase = inject(GetProjectUseCase); + const getProjectGoalsUseCase = inject(GetProjectGoalsUseCase); + const getProjectPartnersUseCase = inject(GetProjectPartnersUseCase); + const getProjectResourcesUseCase = inject(GetProjectResourcesUseCase); + const getProjectInvitesUseCase = inject(GetProjectInvitesUseCase); + + const projectId = Number(route.paramMap.get("projectId")); + + return forkJoin<[Project, Goal[], Partner[], Resource[], Invite[]]>([ + getProjectUseCase + .execute(projectId) + .pipe(map(result => (result.ok ? result.value : new Project()))), + getProjectGoalsUseCase.execute(projectId).pipe(map(result => (result.ok ? result.value : []))), + getProjectPartnersUseCase + .execute(projectId) + .pipe(map(result => (result.ok ? result.value : []))), + getProjectResourcesUseCase + .execute(projectId) + .pipe(map(result => (result.ok ? result.value : []))), + getProjectInvitesUseCase + .execute(projectId) + .pipe(map(result => (result.ok ? result.value : []))), + ]); +}; diff --git a/projects/social_platform/src/app/ui/pages/projects/list/all.resolver.spec.ts b/projects/social_platform/src/app/ui/pages/projects/list/all.resolver.spec.ts new file mode 100644 index 000000000..75885733b --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/list/all.resolver.spec.ts @@ -0,0 +1,34 @@ +/** @format */ + +import { TestBed } from "@angular/core/testing"; +import { ProjectsAllResolver } from "./all.resolver"; +import { ActivatedRouteSnapshot, RouterStateSnapshot, provideRouter } from "@angular/router"; +import { of } from "rxjs"; +import { GetAllProjectsUseCase } from "@api/project/use-cases/get-all-projects.use-case"; + +describe("ProjectsAllResolver", () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideRouter([]), + { + provide: GetAllProjectsUseCase, + useValue: { + execute: () => + of({ + ok: true, + value: { count: 0, results: [], next: "", previous: "" }, + }), + }, + }, + ], + }); + }); + + it("should be created", () => { + const result = TestBed.runInInjectionContext(() => + ProjectsAllResolver({} as ActivatedRouteSnapshot, {} as RouterStateSnapshot), + ); + expect(result).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/projects/list/all.resolver.ts b/projects/social_platform/src/app/ui/pages/projects/list/all.resolver.ts new file mode 100644 index 000000000..052ebb788 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/list/all.resolver.ts @@ -0,0 +1,29 @@ +/** @format */ + +import { inject } from "@angular/core"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { HttpParams } from "@angular/common/http"; +import { ResolveFn } from "@angular/router"; +import { map } from "rxjs"; +import { Project } from "@domain/project/project.model"; +import { GetAllProjectsUseCase } from "@api/project/use-cases/get-all-projects.use-case"; + +/** Предзагружает список всех проектов. */ +export const ProjectsAllResolver: ResolveFn> = () => { + const getAllProjectsUseCase = inject(GetAllProjectsUseCase); + + return getAllProjectsUseCase + .execute(new HttpParams({ fromObject: { offset: 0, limit: 16 } })) + .pipe( + map(result => + result.ok + ? result.value + : { + count: 0, + results: [], + next: "", + previous: "", + }, + ), + ); +}; diff --git a/projects/social_platform/src/app/ui/pages/projects/list/list.component.html b/projects/social_platform/src/app/ui/pages/projects/list/list.component.html new file mode 100644 index 000000000..c50769bf4 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/list/list.component.html @@ -0,0 +1,26 @@ + + +
    + @if (isAll()) { +
    + Фильтр + +
    + } +
      + @for (project of projects(); track project.inviteId) { + +
    • + +
    • +
      + } +
    +
    diff --git a/projects/social_platform/src/app/office/projects/list/list.component.scss b/projects/social_platform/src/app/ui/pages/projects/list/list.component.scss similarity index 100% rename from projects/social_platform/src/app/office/projects/list/list.component.scss rename to projects/social_platform/src/app/ui/pages/projects/list/list.component.scss diff --git a/projects/social_platform/src/app/ui/pages/projects/list/list.component.spec.ts b/projects/social_platform/src/app/ui/pages/projects/list/list.component.spec.ts new file mode 100644 index 000000000..b9df54f06 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/list/list.component.spec.ts @@ -0,0 +1,117 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { ProjectsListComponent } from "./list.component"; +import { of } from "rxjs"; +import { AuthRepository } from "@infrastructure/repository/auth/auth.repository"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { provideRouter } from "@angular/router"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { signal } from "@angular/core"; +import { initial } from "@domain/shared/async-state"; +import { ProjectsListInfoService } from "@api/project/facades/list/projects-list-info.service"; +import { ProjectsInfoService } from "@api/project/facades/projects-info.service"; +import { ProgramDetailListInfoService } from "@api/program/facades/detail/program-detail-list-info.service"; +import { ProgramDetailListUIInfoService } from "@api/program/facades/detail/ui/program-detail-list-ui-info.service"; +import { OfficeInfoService } from "@api/office/facades/office-info.service"; +import { OfficeUIInfoService } from "@api/office/facades/ui/office-ui-info.service"; +import { SwipeService } from "@api/swipe/swipe.service"; + +describe("ProjectsListComponent", () => { + let component: ProjectsListComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const authSpy = { + profile: of({}), + }; + const authPortSpy = { + login: of({} as any), + logout: of(undefined), + fetchProfile: of({} as any), + fetchUserRoles: of([]), + fetchChangeableRoles: of([]), + fetchLeaderProjects: of({} as any), + }; + + const projectsListInfoServiceSpy = { + initializationProjectsList: vi.fn(), + initScroll: vi.fn(), + destroy: vi.fn(), + projects: signal([]), + }; + + const projectsInfoServiceSpy = { + isAll: signal(false), + isMy: signal(false), + isSubs: signal(false), + isInvites: signal(false), + }; + + const programDetailListUIInfoServiceSpy = { + profileProjSubsIds: signal([]), + }; + + const officeInfoServiceSpy = { onAcceptInvite: vi.fn(), onRejectInvite: vi.fn() }; + + const officeUIInfoServiceSpy = {}; + + const swipeServiceSpy = { + isFilterOpen: signal(false), + onSwipeStart: vi.fn(), + onSwipeMove: vi.fn(), + onSwipeEnd: vi.fn(), + closeFilter: vi.fn(), + }; + + const programDetailListInfoServiceSpy = { init: vi.fn(), destroy: vi.fn() }; + + await TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, ProjectsListComponent], + providers: [ + provideRouter([]), + { provide: AuthRepository, useValue: authSpy }, + { provide: AuthRepositoryPort, useValue: authPortSpy }, + ], + }) + .overrideComponent(ProjectsListComponent, { + remove: { + providers: [ + ProjectsListInfoService, + ProjectsInfoService, + ProgramDetailListInfoService, + ProgramDetailListUIInfoService, + OfficeInfoService, + OfficeUIInfoService, + SwipeService, + ], + }, + add: { + providers: [ + { provide: ProjectsListInfoService, useValue: projectsListInfoServiceSpy }, + { provide: ProjectsInfoService, useValue: projectsInfoServiceSpy }, + { provide: ProgramDetailListInfoService, useValue: programDetailListInfoServiceSpy }, + { + provide: ProgramDetailListUIInfoService, + useValue: programDetailListUIInfoServiceSpy, + }, + { provide: OfficeInfoService, useValue: officeInfoServiceSpy }, + { provide: OfficeUIInfoService, useValue: officeUIInfoServiceSpy }, + { provide: SwipeService, useValue: swipeServiceSpy }, + ], + }, + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ProjectsListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/projects/list/list.component.ts b/projects/social_platform/src/app/ui/pages/projects/list/list.component.ts new file mode 100644 index 000000000..fed041abf --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/list/list.component.ts @@ -0,0 +1,97 @@ +/** @format */ + +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + ElementRef, + inject, + OnInit, + viewChild, +} from "@angular/core"; +import { RouterLink } from "@angular/router"; +import { IconComponent } from "@ui/primitives"; +import { InfoCardComponent } from "@ui/widgets/info-card/info-card.component"; +import { ProjectsListInfoService } from "@api/project/facades/list/projects-list-info.service"; +import { SwipeService } from "@api/swipe/swipe.service"; +import { ProjectsInfoService } from "@api/project/facades/projects-info.service"; +import { OfficeInfoService } from "@api/office/facades/office-info.service"; +import { ProgramDetailListUIInfoService } from "@api/program/facades/detail/ui/program-detail-list-ui-info.service"; +import { ProgramDetailListInfoService } from "@api/program/facades/detail/program-detail-list-info.service"; +import { OfficeUIInfoService } from "@api/office/facades/ui/office-ui-info.service"; +import { AppRoutes } from "@api/paths/app-routes"; + +/** Отображает список проектов с поиском, фильтрацией и бесконечной прокруткой. */ +@Component({ + selector: "app-list", + templateUrl: "./list.component.html", + styleUrl: "./list.component.scss", + imports: [IconComponent, RouterLink, InfoCardComponent], + providers: [ + ProjectsListInfoService, + ProjectsInfoService, + ProgramDetailListInfoService, + ProgramDetailListUIInfoService, + OfficeInfoService, + OfficeUIInfoService, + SwipeService, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProjectsListComponent implements OnInit, AfterViewInit { + readonly filterBody = viewChild>("filterBody"); + readonly listRoot = viewChild>("listRoot"); + + private readonly projectsListInfoService = inject(ProjectsListInfoService); + private readonly projectsInfoService = inject(ProjectsInfoService); + private readonly programDetailListUIInfoService = inject(ProgramDetailListUIInfoService); + private readonly officeInfoService = inject(OfficeInfoService); + private readonly swipeService = inject(SwipeService); + + protected readonly isFilterOpen = this.swipeService.isFilterOpen; + + protected readonly projects = this.projectsListInfoService.projects; + protected readonly profileProjSubsIds = this.programDetailListUIInfoService.profileProjSubsIds; + + protected readonly isAll = this.projectsInfoService.isAll; + protected readonly isMy = this.projectsInfoService.isMy; + protected readonly isSubs = this.projectsInfoService.isSubs; + protected readonly isInvites = this.projectsInfoService.isInvites; + + protected readonly AppRoutes = AppRoutes; + + ngOnInit(): void { + this.projectsListInfoService.initializationProjectsList(); + } + + ngAfterViewInit(): void { + const target = document.querySelector(".office__body") as HTMLElement; + if (target || this.listRoot()) { + this.projectsListInfoService.initScroll(target, this.listRoot()!); + } + } + + onAcceptInvite(event: number): void { + this.officeInfoService.onAcceptInvite(event); + } + + onRejectInvite(event: number): void { + this.officeInfoService.onRejectInvite(event); + } + + onSwipeStart(event: TouchEvent): void { + this.swipeService.onSwipeStart(event); + } + + onSwipeMove(event: TouchEvent): void { + this.swipeService.onSwipeMove(event, this.filterBody()!); + } + + onSwipeEnd(event: TouchEvent): void { + this.swipeService.onSwipeEnd(event, this.filterBody()!); + } + + closeFilter(): void { + this.swipeService.closeFilter(); + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/list/my.resolver.spec.ts b/projects/social_platform/src/app/ui/pages/projects/list/my.resolver.spec.ts new file mode 100644 index 000000000..a6c90c9a2 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/list/my.resolver.spec.ts @@ -0,0 +1,34 @@ +/** @format */ + +import { TestBed } from "@angular/core/testing"; +import { ProjectsMyResolver } from "./my.resolver"; +import { ActivatedRouteSnapshot, RouterStateSnapshot, provideRouter } from "@angular/router"; +import { of } from "rxjs"; +import { GetMyProjectsUseCase } from "@api/project/use-cases/get-my-projects.use-case"; + +describe("ProjectsMyResolver", () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideRouter([]), + { + provide: GetMyProjectsUseCase, + useValue: { + execute: () => + of({ + ok: true, + value: { count: 0, results: [], next: "", previous: "" }, + }), + }, + }, + ], + }); + }); + + it("should be created", () => { + const result = TestBed.runInInjectionContext(() => + ProjectsMyResolver({} as ActivatedRouteSnapshot, {} as RouterStateSnapshot), + ); + expect(result).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/projects/list/my.resolver.ts b/projects/social_platform/src/app/ui/pages/projects/list/my.resolver.ts new file mode 100644 index 000000000..0bb2a98b2 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/list/my.resolver.ts @@ -0,0 +1,30 @@ +/** @format */ + +import { inject } from "@angular/core"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { HttpParams } from "@angular/common/http"; +import { ResolveFn } from "@angular/router"; +import { map } from "rxjs"; +import { Project } from "@domain/project/project.model"; +import { GetMyProjectsUseCase } from "@api/project/use-cases/get-my-projects.use-case"; + +/** Предзагружает проекты текущего пользователя. */ + +export const ProjectsMyResolver: ResolveFn> = () => { + const getMyProjectsUseCase = inject(GetMyProjectsUseCase); + + return getMyProjectsUseCase + .execute(new HttpParams({ fromObject: { offset: 0, limit: 16 } })) + .pipe( + map(result => + result.ok + ? result.value + : { + count: 0, + results: [], + next: "", + previous: "", + }, + ), + ); +}; diff --git a/projects/social_platform/src/app/ui/pages/projects/projects.component.html b/projects/social_platform/src/app/ui/pages/projects/projects.component.html new file mode 100644 index 000000000..1fcc04c36 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/projects.component.html @@ -0,0 +1,116 @@ + + +
    +
    + + +
    +
    +
    + +
    + + @if (!isDashboard()) { + + + } + + +
    + +
    + + создать проект + + + +
    + @if (isDashboard()) { +
    +
    +

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

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

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

    +
    + } +
    + } + @if (isMy() || isDashboard()) { + + + } + @if (isAll()) { +
    +
    +
    +
    + +
    +
    + } +
    +
    +
    +
    +
    diff --git a/projects/social_platform/src/app/office/projects/projects.component.scss b/projects/social_platform/src/app/ui/pages/projects/projects.component.scss similarity index 100% rename from projects/social_platform/src/app/office/projects/projects.component.scss rename to projects/social_platform/src/app/ui/pages/projects/projects.component.scss diff --git a/projects/social_platform/src/app/ui/pages/projects/projects.component.spec.ts b/projects/social_platform/src/app/ui/pages/projects/projects.component.spec.ts new file mode 100644 index 000000000..75bd9012b --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/projects.component.spec.ts @@ -0,0 +1,46 @@ +/** @format */ + +// import { ComponentFixture, TestBed } from "@angular/core/testing"; +// +// import { ProjectsComponent } from "./projects.component"; +// import { RouterTestingModule } from "@angular/router/testing"; +// import { HttpClientTestingModule } from "@angular/common/http/testing"; +// import { ReactiveFormsModule } from "@angular/forms"; +// import { of } from "rxjs"; +// import { AuthRepository } from "@infrastructure/repository/auth/auth.repository"; +// import { ProjectRepository } from "../services/project.service"; +// import { User } from "../../auth/models/user.model"; +// import { Project } from "../models/project.model"; +// +// describe("ProjectsComponent", () => { +// let component: ProjectsComponent; +// let fixture: ComponentFixture; +// +// beforeEach(async () => { +// const projectSpy = { +// create: of({}), +// }; +// const authSpy = { +// profile: of({}), +// }; +// +// await TestBed.configureTestingModule({ +// imports: [RouterTestingModule, HttpClientTestingModule, ReactiveFormsModule], +// providers: [ +// { providers: ProjectRepository, useValue: projectSpy }, +// { providers: AuthRepository, useValue: authSpy }, +// ], +// declarations: [ProjectsComponent], +// }).compileComponents(); +// }); +// +// beforeEach(() => { +// fixture = TestBed.createComponent(ProjectsComponent); +// component = fixture.componentInstance; +// fixture.detectChanges(); +// }); +// +// it("should create", () => { +// expect(component).toBeTruthy(); +// }); +// }); diff --git a/projects/social_platform/src/app/ui/pages/projects/projects.component.ts b/projects/social_platform/src/app/ui/pages/projects/projects.component.ts new file mode 100644 index 000000000..3edf5631e --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/projects.component.ts @@ -0,0 +1,97 @@ +/** @format */ + +import { + ChangeDetectionStrategy, + Component, + ElementRef, + inject, + OnInit, + viewChild, + ViewChild, +} from "@angular/core"; +import { RouterOutlet } from "@angular/router"; +import { ReactiveFormsModule } from "@angular/forms"; +import { SearchComponent } from "@ui/primitives/search/search.component"; +import { ButtonComponent, IconComponent } from "@ui/primitives"; +import { BarNewComponent } from "./bar-new/bar.component"; +import { BackComponent } from "@uilib"; +import { SoonCardComponent } from "@ui/primitives/soon-card/soon-card.component"; +import { InfoCardComponent } from "@ui/widgets/info-card/info-card.component"; +import { ProjectsUIInfoService } from "@api/project/facades/ui/projects-ui-info.service"; +import { ProjectsInfoService } from "@api/project/facades/projects-info.service"; +import { SwipeService } from "@api/swipe/swipe.service"; +import { ProjectsFilterComponent } from "@ui/widgets/projects-filter/projects-filter.component"; +import { OfficeInfoService } from "@api/office/facades/office-info.service"; + +/** Контейнер модуля проектов с поиском, фильтрацией и навигацией по разделам. */ +@Component({ + selector: "app-projects", + templateUrl: "./projects.component.html", + styleUrl: "./projects.component.scss", + imports: [ + IconComponent, + ReactiveFormsModule, + SearchComponent, + ButtonComponent, + RouterOutlet, + BarNewComponent, + BackComponent, + SoonCardComponent, + ProjectsFilterComponent, + InfoCardComponent, + ], + providers: [ProjectsInfoService, ProjectsUIInfoService, SwipeService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProjectsComponent implements OnInit { + readonly filterBody = viewChild>("filterBody"); + + private readonly projectsInfoService = inject(ProjectsInfoService); + private readonly projectsUIInfoService = inject(ProjectsUIInfoService); + private readonly officeInfoService = inject(OfficeInfoService); + private readonly swipeService = inject(SwipeService); + + ngOnInit(): void { + this.projectsInfoService.initializationProjects(); + } + + protected readonly searchForm = this.projectsUIInfoService.searchForm; + + protected readonly myInvites = this.projectsUIInfoService.myInvites; + + protected readonly isMy = this.projectsInfoService.isMy; + protected readonly isAll = this.projectsInfoService.isAll; + protected readonly isSubs = this.projectsInfoService.isSubs; + protected readonly isInvites = this.projectsInfoService.isInvites; + protected readonly isDashboard = this.projectsInfoService.isDashboard; + + protected readonly isFilterOpen = this.swipeService.isFilterOpen; + + onSwipeStart(event: TouchEvent): void { + this.swipeService.onSwipeStart(event); + } + + onSwipeMove(event: TouchEvent): void { + this.swipeService.onSwipeMove(event, this.filterBody()!); + } + + onSwipeEnd(event: TouchEvent): void { + this.swipeService.onSwipeEnd(event, this.filterBody()!); + } + + closeFilter(): void { + this.swipeService.closeFilter(); + } + + addProject(): void { + this.projectsInfoService.addProject(); + } + + onAcceptInvite(inviteId: number): void { + this.officeInfoService.onAcceptInvite(inviteId); + } + + onRejectInvite(inviteId: number): void { + this.officeInfoService.onRejectInvite(inviteId); + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/projects.resolver.spec.ts b/projects/social_platform/src/app/ui/pages/projects/projects.resolver.spec.ts new file mode 100644 index 000000000..4e0b9c517 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/projects.resolver.spec.ts @@ -0,0 +1,59 @@ +/** @format */ + +import { TestBed } from "@angular/core/testing"; +import { ProjectsResolver } from "./projects.resolver"; +import { ActivatedRouteSnapshot, RouterStateSnapshot, provideRouter } from "@angular/router"; +import { signal } from "@angular/core"; +import { of } from "rxjs"; +import { ProfileInfoService } from "@api/profile/facades/profile-info.service"; +import { GetAllProjectsUseCase } from "@api/project/use-cases/get-all-projects.use-case"; +import { GetMyProjectsUseCase } from "@api/project/use-cases/get-my-projects.use-case"; +import { GetProjectSubscriptionsUseCase } from "@api/project/use-cases/get-project-subscriptions.use-case"; + +describe("ProjectsResolver", () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideRouter([]), + { provide: ProfileInfoService, useValue: { profile: signal(null) } }, + { + provide: GetAllProjectsUseCase, + useValue: { + execute: () => + of({ + ok: true, + value: { count: 0, results: [], next: "", previous: "" }, + }), + }, + }, + { + provide: GetMyProjectsUseCase, + useValue: { + execute: () => + of({ + ok: true, + value: { count: 0, results: [], next: "", previous: "" }, + }), + }, + }, + { + provide: GetProjectSubscriptionsUseCase, + useValue: { + execute: () => + of({ + ok: true, + value: { count: 0, results: [], next: "", previous: "" }, + }), + }, + }, + ], + }); + }); + + it("should be created", () => { + const result = TestBed.runInInjectionContext(() => + ProjectsResolver({} as ActivatedRouteSnapshot, {} as RouterStateSnapshot), + ); + expect(result).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/projects/projects.resolver.ts b/projects/social_platform/src/app/ui/pages/projects/projects.resolver.ts new file mode 100644 index 000000000..a686bec14 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/projects.resolver.ts @@ -0,0 +1,52 @@ +/** @format */ + +import { inject, Injector } from "@angular/core"; +import { forkJoin, map, switchMap } from "rxjs"; +import { ResolveFn } from "@angular/router"; +import { HttpParams } from "@angular/common/http"; +import { ApiPagination } from "@domain/other/api-pagination.model"; +import { Project } from "@domain/project/project.model"; +import { GetAllProjectsUseCase } from "@api/project/use-cases/get-all-projects.use-case"; +import { GetMyProjectsUseCase } from "@api/project/use-cases/get-my-projects.use-case"; +import { GetProjectSubscriptionsUseCase } from "@api/project/use-cases/get-project-subscriptions.use-case"; +import { AuthInfoService } from "@api/auth/facades/auth-info.service"; +import { ProfileInfoService } from "@api/profile/facades/profile-info.service"; +import { toObservable } from "@angular/core/rxjs-interop"; + +/** Resolver: предзагружает количество проектов (my/all/subs). */ + +export interface DashboardProjectsData { + all: ApiPagination; + my: ApiPagination; + subs: ApiPagination; +} + +export const ProjectsResolver: ResolveFn = () => { + const injector = inject(Injector); + const profileInfoService = inject(ProfileInfoService); + const getAllProjectsUseCase = inject(GetAllProjectsUseCase); + const getMyProjectsUseCase = inject(GetMyProjectsUseCase); + const getProjectSubscriptionsUseCase = inject(GetProjectSubscriptionsUseCase); + const emptyProjectsPage = (): ApiPagination => ({ + count: 0, + next: "", + previous: "", + results: [], + }); + + return toObservable(profileInfoService.profile, { injector }).pipe( + switchMap(user => + forkJoin({ + all: getAllProjectsUseCase + .execute(new HttpParams({ fromObject: { offset: 0, limit: 16 } })) + .pipe(map(result => (result.ok ? result.value : emptyProjectsPage()))), + my: getMyProjectsUseCase + .execute(new HttpParams({ fromObject: { offset: 0, limit: 16 } })) + .pipe(map(result => (result.ok ? result.value : emptyProjectsPage()))), + subs: getProjectSubscriptionsUseCase + .execute(user!.id) + .pipe(map(result => (result.ok ? result.value : emptyProjectsPage()))), + }), + ), + ); +}; diff --git a/projects/social_platform/src/app/ui/pages/vacancies/detail/info/components/vacancies-left-side/vacancies-left-side.component.html b/projects/social_platform/src/app/ui/pages/vacancies/detail/info/components/vacancies-left-side/vacancies-left-side.component.html new file mode 100644 index 000000000..e70f03a65 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/vacancies/detail/info/components/vacancies-left-side/vacancies-left-side.component.html @@ -0,0 +1,61 @@ + + +
    + @if (vacancy()!.description) { +
    +
    +

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

    + +
    +
    +

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

    навыки

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

    {{ project.name | truncate: 20 }}

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

    метаданные

    + +
    + +
      +
    • + +

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

      +
    • + +
    • + +

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

      +
    • + +
    • + +

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

      +
    • + +
    • + +

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

      +
    • + +
    • + +

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

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

    контакты

    + +
    + + +
    + } +
    +} diff --git a/projects/social_platform/src/app/ui/pages/vacancies/detail/info/components/vacancies-right-side/vacancies-right-side.component.scss b/projects/social_platform/src/app/ui/pages/vacancies/detail/info/components/vacancies-right-side/vacancies-right-side.component.scss new file mode 100644 index 000000000..01286d01e --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/vacancies/detail/info/components/vacancies-right-side/vacancies-right-side.component.scss @@ -0,0 +1,145 @@ +@use "styles/responsive"; + +.vacancy { + &__content { + padding: 24px; + margin-bottom: 20px; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); + } + + &__right { + display: flex; + flex-direction: column; + gap: 20px; + text-align: center; + + &--title { + margin: 12px 0; + } + + &--project { + position: relative; + padding: 48px 24px 24px; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); + + &-image { + position: absolute; + top: -70px; + left: 50%; + display: block; + transform: translate(-50%, 50%); + } + } + } +} + +.cancel { + display: flex; + flex-direction: column; + width: 600px; + max-height: calc(100vh - 40px); + overflow-y: auto; + + &__top { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + } + + &__image { + display: flex; + flex-direction: column; + gap: 15px; + align-items: center; + justify-content: space-between; + + @include responsive.apply-desktop { + display: flex; + flex-direction: row; + gap: 15px; + align-items: center; + justify-content: space-between; + margin: 30px 0; + } + } + + &__cross { + position: absolute; + top: 0; + right: 0; + width: 32px; + height: 32px; + cursor: pointer; + + @include responsive.apply-desktop { + top: 8px; + right: 8px; + } + } + + &__title { + margin-top: 15px; + margin-bottom: 15px; + text-align: center; + } +} + +.lists { + &__section { + display: flex; + justify-content: space-between; + margin-bottom: 8px; + border-bottom: 0.5px solid var(--accent); + } + + &__list { + display: flex; + flex-direction: column; + gap: 8px; + } + + &__icon { + color: var(--accent); + } + + &__title { + margin-bottom: 8px; + color: var(--accent); + } + + &__item { + display: flex; + gap: 6px; + align-items: center; + + &--status { + padding: 8px; + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-xl); + } + + &--title { + color: var(--black); + } + + i { + padding: 6px; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-xl); + } + + p { + color: var(--accent); + } + + span { + cursor: pointer; + } + } +} diff --git a/projects/social_platform/src/app/ui/pages/vacancies/detail/info/components/vacancies-right-side/vacancies-right-side.component.ts b/projects/social_platform/src/app/ui/pages/vacancies/detail/info/components/vacancies-right-side/vacancies-right-side.component.ts new file mode 100644 index 000000000..2e92cc2ee --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/vacancies/detail/info/components/vacancies-right-side/vacancies-right-side.component.ts @@ -0,0 +1,51 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + inject, + input, + Input, + output, + Output, + WritableSignal, +} from "@angular/core"; +import { AvatarComponent } from "@ui/primitives/avatar/avatar.component"; +import { ButtonComponent, IconComponent } from "@ui/primitives"; +import { UserLinksPipe, TruncatePipe, CapitalizePipe, SalaryTransformPipe } from "@corelib"; +import { RouterModule } from "@angular/router"; +import { Vacancy } from "@domain/vacancy/vacancy.model"; +import { ReactiveFormsModule } from "@angular/forms"; +import { AppRoutes } from "@api/paths/app-routes"; + +/** Правая колонка детали вакансии. */ +@Component({ + selector: "app-vacancies-right-side", + templateUrl: "./vacancies-right-side.component.html", + styleUrl: "./vacancies-right-side.component.scss", + imports: [ + CommonModule, + AvatarComponent, + ButtonComponent, + ReactiveFormsModule, + RouterModule, + UserLinksPipe, + TruncatePipe, + CapitalizePipe, + IconComponent, + SalaryTransformPipe, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class VacanciesRightSideComponent { + protected readonly AppRoutes = AppRoutes; + + readonly vacancy = input.required(); + readonly sendResponse = output(); + + onSendResponseClick(): void { + this.sendResponse.emit(); + } +} diff --git a/projects/social_platform/src/app/ui/pages/vacancies/detail/info/info.component.html b/projects/social_platform/src/app/ui/pages/vacancies/detail/info/info.component.html new file mode 100644 index 000000000..5eba5e6a5 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/vacancies/detail/info/info.component.html @@ -0,0 +1,116 @@ + + +@if (vacancy()) { +
    +
    + + + +
    +
    + + +
    + +

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

    + перейти к вакансиям +
    +
    + + @defer (when openModal()) { + +
    +
    +

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

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

    или

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

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

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

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

    + } +
    +
    +
    + } + + отправить отклик +
    +
    +
    + } +} diff --git a/projects/social_platform/src/app/ui/pages/vacancies/detail/info/info.component.scss b/projects/social_platform/src/app/ui/pages/vacancies/detail/info/info.component.scss new file mode 100644 index 000000000..347534ead --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/vacancies/detail/info/info.component.scss @@ -0,0 +1,186 @@ +@use "styles/responsive"; +@use "styles/typography"; + +.vacancy { + &__content { + padding: 24px; + margin-bottom: 20px; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); + } + + &__split { + display: grid; + grid-template-columns: 7fr 3fr; + gap: 20px; + } + + &__form { + display: flex; + flex-direction: column; + gap: 10px; + + label { + color: var(--black); + } + + &--or { + color: var(--grey-for-text); + text-align: center; + } + + &-error { + border: 0.5px solid var(--red); + } + + &--cv { + ::ng-deep { + app-upload-file { + .control { + height: 80px; + border-radius: var(--rounded-xl); + } + } + } + + &-empty { + display: flex; + flex-direction: column; + gap: 12px; + align-items: center; + color: var(--grey-for-text); + } + } + } +} + +$succeed-modal-width: 310px; + +.succeed { + display: flex; + flex-direction: column; + align-items: center; + width: $succeed-modal-width; + + &__check { + margin-bottom: 18px; + color: var(--green); + } + + &__text { + margin-bottom: 18px; + color: var(--black); + } + + &__link { + width: $succeed-modal-width; + } +} + +.cancel { + display: flex; + flex-direction: column; + width: 600px; + max-height: calc(100vh - 40px); + overflow-y: auto; + + &__top { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + } + + &__image { + display: flex; + flex-direction: column; + gap: 15px; + align-items: center; + justify-content: space-between; + + @include responsive.apply-desktop { + display: flex; + flex-direction: row; + gap: 15px; + align-items: center; + justify-content: space-between; + margin: 30px 0; + } + } + + &__cross { + position: absolute; + top: 0; + right: 0; + width: 32px; + height: 32px; + cursor: pointer; + + @include responsive.apply-desktop { + top: 8px; + right: 8px; + } + } + + &__title { + margin-top: 15px; + margin-bottom: 15px; + text-align: center; + } +} + +.lists { + &__section { + display: flex; + justify-content: space-between; + margin-bottom: 8px; + border-bottom: 0.5px solid var(--accent); + } + + &__list { + display: flex; + flex-direction: column; + gap: 8px; + } + + &__icon { + color: var(--accent); + } + + &__title { + margin-bottom: 8px; + color: var(--accent); + } + + &__item { + display: flex; + gap: 6px; + align-items: center; + + &--status { + padding: 8px; + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-xl); + } + + &--title { + color: var(--black); + } + + i { + padding: 6px; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-xl); + } + + p { + color: var(--accent); + } + + span { + cursor: pointer; + } + } +} diff --git a/projects/social_platform/src/app/ui/pages/vacancies/detail/info/info.component.ts b/projects/social_platform/src/app/ui/pages/vacancies/detail/info/info.component.ts new file mode 100644 index 000000000..c85c985e2 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/vacancies/detail/info/info.component.ts @@ -0,0 +1,79 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, inject, OnInit } from "@angular/core"; +import { RouterModule } from "@angular/router"; +import { ButtonComponent } from "@ui/primitives"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { IconComponent } from "@uilib"; +import { ReactiveFormsModule } from "@angular/forms"; +import { VacancyDetailInfoService } from "@api/vacancy/facades/vacancy-detail-info.service"; +import { VacancyDetailUIInfoService } from "@api/vacancy/facades/ui/vacancy-detail-ui-info.service"; +import { VacanciesRightSideComponent } from "./components/vacancies-right-side/vacancies-right-side.component"; +import { VacanciesLeftSideComponent } from "./components/vacancies-left-side/vacancies-left-side.component"; +import { AppRoutes } from "@api/paths/app-routes"; +import { TextareaComponent } from "@ui/primitives/textarea/textarea.component"; +import { ErrorMessage } from "@core/lib/models/error/error-message"; +import { ControlErrorPipe } from "@corelib"; +import { UploadFileComponent } from "@ui/primitives/upload-file/upload-file.component"; + +/** Отображает детальную информацию о вакансии с возможностью отклика. */ +@Component({ + selector: "app-detail", + templateUrl: "./info.component.html", + styleUrl: "./info.component.scss", + imports: [ + IconComponent, + ButtonComponent, + ModalComponent, + RouterModule, + ReactiveFormsModule, + VacanciesRightSideComponent, + VacanciesLeftSideComponent, + TextareaComponent, + ControlErrorPipe, + UploadFileComponent, + ], + providers: [VacancyDetailInfoService, VacancyDetailUIInfoService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class VacancyInfoComponent implements OnInit { + private readonly vacancyDetailInfoService = inject(VacancyDetailInfoService); + private readonly vacancyDetailUIInfoService = inject(VacancyDetailUIInfoService); + + protected readonly vacancy = this.vacancyDetailUIInfoService.vacancy; + + protected readonly AppRoutes = AppRoutes; + + /** Флаг отображения модального окна с результатом */ + protected readonly resultModal = this.vacancyDetailUIInfoService.resultModal; + protected readonly openModal = this.vacancyDetailUIInfoService.openModal; + + /** Форма отправки отклика */ + protected readonly sendForm = this.vacancyDetailUIInfoService.sendForm; + protected readonly sendFormIsSubmitting = + this.vacancyDetailUIInfoService.sendFormIsSubmittingFlag; + + /** Объект с сообщениями об ошибках */ + protected readonly errorMessage = ErrorMessage; + + ngOnInit(): void { + this.vacancyDetailInfoService.initializeDetailInfo(); + this.vacancyDetailInfoService.initializeDetailInfoQueryParams(); + } + + onOpenResponseModal(): void { + this.vacancyDetailUIInfoService.applyResponseModalOpen(); + } + + onSubmit(): void { + this.vacancyDetailInfoService.submitVacancyResponse(); + } + + closeSendResponseModal(): void { + this.vacancyDetailInfoService.closeSendResponseModal(); + } + + protected openSkills() { + location.href = "https://skills.procollab.ru"; + } +} diff --git a/projects/social_platform/src/app/ui/pages/vacancies/detail/vacancies-detail.component.html b/projects/social_platform/src/app/ui/pages/vacancies/detail/vacancies-detail.component.html new file mode 100644 index 000000000..08a65f777 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/vacancies/detail/vacancies-detail.component.html @@ -0,0 +1,11 @@ + + +@if (vacancy()) { +
    + +
    + +
    + +
    +} diff --git a/projects/social_platform/src/app/office/vacancies/detail/vacancies-detail.component.scss b/projects/social_platform/src/app/ui/pages/vacancies/detail/vacancies-detail.component.scss similarity index 100% rename from projects/social_platform/src/app/office/vacancies/detail/vacancies-detail.component.scss rename to projects/social_platform/src/app/ui/pages/vacancies/detail/vacancies-detail.component.scss diff --git a/projects/social_platform/src/app/ui/pages/vacancies/detail/vacancies-detail.component.spec.ts b/projects/social_platform/src/app/ui/pages/vacancies/detail/vacancies-detail.component.spec.ts new file mode 100644 index 000000000..9ce775ca2 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/vacancies/detail/vacancies-detail.component.spec.ts @@ -0,0 +1,51 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { VacanciesDetailComponent } from "./vacancies-detail.component"; +import { provideRouter } from "@angular/router"; +import { VacancyDetailInfoService } from "@api/vacancy/facades/vacancy-detail-info.service"; +import { VacancyDetailUIInfoService } from "@api/vacancy/facades/ui/vacancy-detail-ui-info.service"; +import { ExpandService } from "@api/expand/expand.service"; +import { signal } from "@angular/core"; + +describe("VacanciesDetailComponent", () => { + let component: VacanciesDetailComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const vacancyDetailInfoServiceSpy = { initializeDetailInfo: vi.fn(), destroy: vi.fn() }; + + const vacancyDetailUIInfoServiceSpy = { + vacancy: signal(undefined), + }; + + const expandServiceSpy = { expanded: signal({}) }; + + await TestBed.configureTestingModule({ + imports: [VacanciesDetailComponent], + providers: [provideRouter([])], + }) + .overrideComponent(VacanciesDetailComponent, { + remove: { + providers: [VacancyDetailInfoService, VacancyDetailUIInfoService, ExpandService], + }, + add: { + providers: [ + { provide: VacancyDetailInfoService, useValue: vacancyDetailInfoServiceSpy }, + { provide: VacancyDetailUIInfoService, useValue: vacancyDetailUIInfoServiceSpy }, + { provide: ExpandService, useValue: expandServiceSpy }, + ], + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(VacanciesDetailComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/vacancies/detail/vacancies-detail.component.ts b/projects/social_platform/src/app/ui/pages/vacancies/detail/vacancies-detail.component.ts new file mode 100644 index 000000000..1b3cf31c3 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/vacancies/detail/vacancies-detail.component.ts @@ -0,0 +1,29 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, inject, OnInit } from "@angular/core"; +import { BackComponent } from "@uilib"; +import { VacancyDetailInfoService } from "@api/vacancy/facades/vacancy-detail-info.service"; +import { VacancyDetailUIInfoService } from "@api/vacancy/facades/ui/vacancy-detail-ui-info.service"; +import { ExpandService } from "@api/expand/expand.service"; +import { RouterOutlet } from "@angular/router"; + +/** Контейнер детальной страницы вакансии с навигацией и router-outlet. */ +@Component({ + selector: "app-vacancies-detail", + templateUrl: "./vacancies-detail.component.html", + styleUrl: "./vacancies-detail.component.scss", + imports: [CommonModule, RouterOutlet, BackComponent], + providers: [VacancyDetailInfoService, VacancyDetailUIInfoService, ExpandService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class VacanciesDetailComponent implements OnInit { + private readonly vacancyDetailInfoService = inject(VacancyDetailInfoService); + private readonly vacancyDetailUIInfoService = inject(VacancyDetailUIInfoService); + + protected readonly vacancy = this.vacancyDetailUIInfoService.vacancy; + + ngOnInit(): void { + this.vacancyDetailInfoService.initializeDetailInfo(); + } +} diff --git a/projects/social_platform/src/app/ui/pages/vacancies/detail/vacancies-detail.resolver.ts b/projects/social_platform/src/app/ui/pages/vacancies/detail/vacancies-detail.resolver.ts new file mode 100644 index 000000000..bd46e0ac0 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/vacancies/detail/vacancies-detail.resolver.ts @@ -0,0 +1,16 @@ +/** @format */ + +import { inject } from "@angular/core"; +import { ActivatedRouteSnapshot } from "@angular/router"; +import { map } from "rxjs"; +import { GetVacancyDetailUseCase } from "@api/vacancy/use-cases/get-vacancy-detail.use-case"; + +/** Предзагружает детальную информацию о вакансии. */ +export const VacanciesDetailResolver = (route: ActivatedRouteSnapshot) => { + const getVacancyDetailUseCase = inject(GetVacancyDetailUseCase); + const vacancyId = route.params["vacancyId"]; + + return getVacancyDetailUseCase + .execute(vacancyId) + .pipe(map(result => (result.ok ? result.value : null))); +}; diff --git a/projects/social_platform/src/app/ui/pages/vacancies/list/list.component.html b/projects/social_platform/src/app/ui/pages/vacancies/list/list.component.html new file mode 100644 index 000000000..ea7ecad4b --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/vacancies/list/list.component.html @@ -0,0 +1,46 @@ + + +
    + @if (type() === "all") { +
    + @if (vacancyList().length) { + @for (vacancy of vacancyList(); track $index) { + + + } + } +
    + } + @if (type() === "my") { + @if (responsesList().length > 0) { + @for (response of responsesList(); track $index) { + + } + +
    +
    + +

    + вы пока не отправили
    ни одного отклика :| +

    +
    + + + перейти к вакансиям + +
    +
    + } @else { +
    + +

    + в данном разделе пока нет ваших откликов : - (
    + давайте это + исправим +

    +
    + } + } +
    diff --git a/projects/social_platform/src/app/office/vacancies/list/list.component.scss b/projects/social_platform/src/app/ui/pages/vacancies/list/list.component.scss similarity index 100% rename from projects/social_platform/src/app/office/vacancies/list/list.component.scss rename to projects/social_platform/src/app/ui/pages/vacancies/list/list.component.scss diff --git a/projects/social_platform/src/app/ui/pages/vacancies/list/list.component.spec.ts b/projects/social_platform/src/app/ui/pages/vacancies/list/list.component.spec.ts new file mode 100644 index 000000000..6e9599358 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/vacancies/list/list.component.spec.ts @@ -0,0 +1,82 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { signal } from "@angular/core"; +import { VacanciesListComponent } from "./list.component"; +import { VacancyInfoService } from "@api/vacancy/facades/vacancy-info.service"; +import { VacancyUIInfoService } from "@api/vacancy/facades/ui/vacancy-ui-info.service"; +import { provideRouter } from "@angular/router"; + +describe("VacanciesListComponent", () => { + let component: VacanciesListComponent; + let fixture: ComponentFixture; + let vacancyInfoService: any; + let vacancyUIInfoService: any; + + beforeEach(async () => { + const infServiceSpy = { init: vi.fn(), initScroll: vi.fn(), destroy: vi.fn() }; + + const uiServiceSpy = { + listType: signal("all"), + vacancyList: signal([]), + responsesList: signal([]), + isMyModal: signal(false), + }; + + await TestBed.configureTestingModule({ + imports: [VacanciesListComponent], + providers: [provideRouter([])], + }) + .overrideComponent(VacanciesListComponent, { + remove: { + providers: [VacancyInfoService, VacancyUIInfoService], + }, + add: { + providers: [ + { provide: VacancyInfoService, useValue: infServiceSpy }, + { provide: VacancyUIInfoService, useValue: uiServiceSpy }, + ], + }, + }) + .compileComponents(); + + vacancyInfoService = infServiceSpy; + vacancyUIInfoService = uiServiceSpy; + + fixture = TestBed.createComponent(VacanciesListComponent); + component = fixture.componentInstance; + }); + + it("должен вызвать vacancyInfoService.init() при инициализации компонента", () => { + fixture.detectChanges(); + + expect(vacancyInfoService.init).toHaveBeenCalledTimes(1); + }); + + it("должен вызвать vacancyInfoService.initScroll() с элементом при ngAfterViewInit", () => { + const scrollElement = document.createElement("div"); + scrollElement.className = "office__body"; + document.body.appendChild(scrollElement); + + fixture.detectChanges(); + fixture.detectChanges(); + + expect(vacancyInfoService.initScroll).toHaveBeenCalled(); + + document.body.removeChild(scrollElement); + }); + + it("должен иметь доступ к сигналам из vacancyUIInfoService", () => { + fixture.detectChanges(); + + expect(component["type"]).toBeDefined(); + expect(component["vacancyList"]).toBeDefined(); + expect(component["responsesList"]).toBeDefined(); + expect(component["isMyModal"]).toBeDefined(); + }); + + it("should create", () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/vacancies/list/list.component.ts b/projects/social_platform/src/app/ui/pages/vacancies/list/list.component.ts new file mode 100644 index 000000000..c3d021a24 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/vacancies/list/list.component.ts @@ -0,0 +1,59 @@ +/** @format */ + +// list.component.ts +/** @format */ + +import { ChangeDetectionStrategy, Component, inject, signal } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { RouterLink } from "@angular/router"; +import { ButtonComponent, IconComponent } from "@ui/primitives"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { ProjectVacancyCardComponent } from "@ui/widgets/project-vacancy-card/project-vacancy-card.component"; +import { ResponseCardComponent } from "./response-card/response-card.component"; +import { VacancyUIInfoService } from "@api/vacancy/facades/ui/vacancy-ui-info.service"; +import { VacancyInfoService } from "@api/vacancy/facades/vacancy-info.service"; +import { AppRoutes } from "@api/paths/app-routes"; + +/** Страница списка вакансий. */ +@Component({ + selector: "app-vacancies-list", + templateUrl: "./list.component.html", + styleUrl: "./list.component.scss", + imports: [ + CommonModule, + ResponseCardComponent, + ProjectVacancyCardComponent, + ButtonComponent, + IconComponent, + ModalComponent, + RouterLink, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [VacancyInfoService, VacancyUIInfoService], +}) +export class VacanciesListComponent { + private readonly vacancyInfoService = inject(VacancyInfoService); + private readonly vacancyUIInfoService = inject(VacancyUIInfoService); + + protected readonly type = this.vacancyUIInfoService.listType; + protected readonly vacancyList = this.vacancyUIInfoService.vacancyList; + protected readonly responsesList = this.vacancyUIInfoService.responsesList; + protected readonly isMyModal = this.vacancyUIInfoService.isMyModal; + + protected readonly AppRoutes = AppRoutes; + + ngOnInit() { + this.vacancyInfoService.init(); + } + + ngAfterViewInit() { + const target = document.querySelector(".office__body") as HTMLElement; + if (target) { + this.vacancyInfoService.initScroll(target); + } + } + + ngOnDestroy() { + this.vacancyInfoService.destroy(); + } +} diff --git a/projects/social_platform/src/app/ui/pages/vacancies/list/my.resolver.ts b/projects/social_platform/src/app/ui/pages/vacancies/list/my.resolver.ts new file mode 100644 index 000000000..9ebfb3fcb --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/vacancies/list/my.resolver.ts @@ -0,0 +1,14 @@ +/** @format */ + +import { inject } from "@angular/core"; +import { ResolveFn } from "@angular/router"; +import { VacancyResponse } from "@domain/vacancy/vacancy-response.model"; +import { map } from "rxjs"; +import { GetMyVacanciesUseCase } from "@api/vacancy/use-cases/get-my-vacancies.use-case"; + +/** Предзагружает отклики пользователя на вакансии. */ +export const VacanciesMyResolver: ResolveFn = () => { + const getMyVacanciesUseCase = inject(GetMyVacanciesUseCase); + + return getMyVacanciesUseCase.execute(20, 0).pipe(map(result => (result.ok ? result.value : []))); +}; diff --git a/projects/social_platform/src/app/ui/pages/vacancies/list/response-card/response-card.component.html b/projects/social_platform/src/app/ui/pages/vacancies/list/response-card/response-card.component.html new file mode 100644 index 000000000..1fb742f4d --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/vacancies/list/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/ui/pages/vacancies/list/response-card/response-card.component.scss similarity index 100% rename from projects/social_platform/src/app/office/features/response-card/response-card.component.scss rename to projects/social_platform/src/app/ui/pages/vacancies/list/response-card/response-card.component.scss diff --git a/projects/social_platform/src/app/ui/pages/vacancies/list/response-card/response-card.component.spec.ts b/projects/social_platform/src/app/ui/pages/vacancies/list/response-card/response-card.component.spec.ts new file mode 100644 index 000000000..efa6e2e6d --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/vacancies/list/response-card/response-card.component.spec.ts @@ -0,0 +1,52 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ResponseCardComponent } from "./response-card.component"; +import { provideRouter } from "@angular/router"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { ProjectSubscriptionRepositoryPort } from "@domain/project/ports/project-subscription.repository.port"; +import { of } from "rxjs"; + +describe("ResponseCardComponent", () => { + let component: ResponseCardComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const authPortSpy = { + login: vi.fn().mockReturnValue(of({} as any)), + logout: vi.fn().mockReturnValue(of(undefined)), + fetchProfile: vi.fn().mockReturnValue(of({ id: 1, firstName: "Test" })), + fetchUser: vi.fn().mockReturnValue(of({} as any)), + fetchUserRoles: vi.fn().mockReturnValue(of([])), + fetchChangeableRoles: vi.fn().mockReturnValue(of([])), + fetchLeaderProjects: vi.fn().mockReturnValue(of({} as any)), + }; + + await TestBed.configureTestingModule({ + imports: [ResponseCardComponent], + providers: [ + provideRouter([]), + { provide: AuthRepositoryPort, useValue: authPortSpy }, + { + provide: ProjectSubscriptionRepositoryPort, + useValue: { getSubscriptions: of({ results: [], count: 0 }) }, + }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ResponseCardComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput("response", { + id: 1, + user: { id: 1, firstName: "Test", lastName: "User" }, + status: "pending", + }); + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/vacancies/list/response-card/response-card.component.ts b/projects/social_platform/src/app/ui/pages/vacancies/list/response-card/response-card.component.ts new file mode 100644 index 000000000..069092419 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/vacancies/list/response-card/response-card.component.ts @@ -0,0 +1,57 @@ +/** @format */ + +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + EventEmitter, + inject, + input, + Input, + OnInit, + output, + Output, +} from "@angular/core"; +import { VacancyResponse } from "@domain/vacancy/vacancy-response.model"; +import { FileItemComponent } from "@ui/primitives/file-item/file-item.component"; +import { IconComponent } from "@uilib"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { AuthInfoService } from "@api/auth/facades/auth-info.service"; + +/** Карточка отклика на вакансию с информацией о кандидате и действиями. */ +@Component({ + selector: "app-response-card", + templateUrl: "./response-card.component.html", + styleUrl: "./response-card.component.scss", + imports: [IconComponent, FileItemComponent], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ResponseCardComponent implements OnInit { + private readonly authRepository = inject(AuthInfoService); + private readonly destroyRef = inject(DestroyRef); + + readonly response = input.required(); + readonly reject = output(); + readonly accept = output(); + + profileId!: number; + + ngOnInit(): void { + this.authRepository + .fetchProfile() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: profile => { + this.profileId = profile.id; + }, + }); + } + + onAccept(responseId: number) { + this.accept.emit(responseId); + } + + onReject(responseId: number) { + this.reject.emit(responseId); + } +} diff --git a/projects/social_platform/src/app/ui/pages/vacancies/vacancies.component.html b/projects/social_platform/src/app/ui/pages/vacancies/vacancies.component.html new file mode 100644 index 000000000..5c8bb207f --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/vacancies/vacancies.component.html @@ -0,0 +1,35 @@ + + + + + +
    +
    + + +
    +
    + @if (isAll() === "all") { +
    + +
    + } + + +
    + + @if (isAll() === "all") { +
    + +
    + } +
    +
    +
    diff --git a/projects/social_platform/src/app/office/vacancies/vacancies.component.scss b/projects/social_platform/src/app/ui/pages/vacancies/vacancies.component.scss similarity index 100% rename from projects/social_platform/src/app/office/vacancies/vacancies.component.scss rename to projects/social_platform/src/app/ui/pages/vacancies/vacancies.component.scss diff --git a/projects/social_platform/src/app/ui/pages/vacancies/vacancies.component.spec.ts b/projects/social_platform/src/app/ui/pages/vacancies/vacancies.component.spec.ts new file mode 100644 index 000000000..4d84dbbe6 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/vacancies/vacancies.component.spec.ts @@ -0,0 +1,76 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { VacanciesComponent } from "./vacancies.component"; +import { provideRouter } from "@angular/router"; +import { VacancyInfoService } from "@api/vacancy/facades/vacancy-info.service"; +import { VacancyUIInfoService } from "@api/vacancy/facades/ui/vacancy-ui-info.service"; +import { VacancyFilterInfoService } from "@ui/widgets/vacancy-filter/service/vacancy-filter-info.service"; +import { signal } from "@angular/core"; +import { FormBuilder } from "@angular/forms"; +import { of } from "rxjs"; +import { VacancyRepositoryPort } from "@domain/vacancy/ports/vacancy.repository.port"; + +const vacancyRepositorySpy = { + getVacancies: vi.fn().mockReturnValue(of({ results: [], count: 0, next: "", previous: "" })), + getOne: vi.fn().mockReturnValue(of({})), +}; + +describe("VacanciesComponent", () => { + let component: VacanciesComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const vacancyInfoServiceSpy = { + initializationSearchValueForm: vi.fn(), + init: vi.fn(), + destroy: vi.fn(), + onSearchSubmit: vi.fn(), + }; + + const fb = new FormBuilder(); + const vacancyUIInfoServiceSpy = { + searchForm: fb.group({ search: [""] }), + listType: signal<"all" | "my" | null>("all"), + applySearhValueChanged: vi.fn(), + }; + + const vacancyFilterInfoServiceSpy = { + filterForm: fb.group({}), + initFilterForm: vi.fn(), + clearFilters: vi.fn(), + }; + + await TestBed.configureTestingModule({ + imports: [VacanciesComponent], + providers: [ + provideRouter([]), + { provide: VacancyFilterInfoService, useValue: vacancyFilterInfoServiceSpy }, + { + provide: VacancyRepositoryPort, + useValue: { getVacancies: () => of({ results: [], count: 0, next: "", previous: "" }) }, + }, + ], + }) + .overrideComponent(VacanciesComponent, { + remove: { + providers: [VacancyInfoService, VacancyUIInfoService], + }, + add: { + providers: [ + { provide: VacancyInfoService, useValue: vacancyInfoServiceSpy }, + { provide: VacancyUIInfoService, useValue: vacancyUIInfoServiceSpy }, + ], + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(VacanciesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/vacancies/vacancies.component.ts b/projects/social_platform/src/app/ui/pages/vacancies/vacancies.component.ts new file mode 100644 index 000000000..5c56fcadf --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/vacancies/vacancies.component.ts @@ -0,0 +1,56 @@ +/** @format */ + +// vacancies.component.ts +/** @format */ + +import { ChangeDetectionStrategy, Component, inject, OnInit } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { Router, RouterOutlet } from "@angular/router"; +import { BackComponent } from "@uilib"; +import { SearchComponent } from "@ui/primitives/search/search.component"; +import { ReactiveFormsModule } from "@angular/forms"; +import { VacancyFilterComponent } from "@ui/widgets/vacancy-filter/vacancy-filter.component"; +import { VacancyInfoService } from "@api/vacancy/facades/vacancy-info.service"; +import { VacancyUIInfoService } from "@api/vacancy/facades/ui/vacancy-ui-info.service"; + +/** Раздел вакансий: shell с router-outlet. */ +@Component({ + selector: "app-vacancies", + templateUrl: "./vacancies.component.html", + styleUrl: "./vacancies.component.scss", + imports: [ + CommonModule, + RouterOutlet, + BackComponent, + SearchComponent, + VacancyFilterComponent, + ReactiveFormsModule, + ], + providers: [VacancyInfoService, VacancyUIInfoService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class VacanciesComponent implements OnInit { + private readonly vacancyInfoService = inject(VacancyInfoService); + private readonly vacancyUIInfoService = inject(VacancyUIInfoService); + + protected readonly searchForm = this.vacancyUIInfoService.searchForm; + protected readonly isAll = this.vacancyUIInfoService.listType; + protected readonly basePath = "/office/"; + + ngOnInit() { + this.vacancyInfoService.initializationSearchValueForm(); + this.vacancyInfoService.init(); + } + + ngOnDestroy(): void { + this.vacancyInfoService.destroy(); + } + + onSearchSubmit() { + this.vacancyInfoService.onSearchSubmit(); + } + + onSearhValueChanged(event: string) { + this.vacancyUIInfoService.applySearhValueChanged(event); + } +} diff --git a/projects/social_platform/src/app/ui/pages/vacancies/vacancies.resolver.ts b/projects/social_platform/src/app/ui/pages/vacancies/vacancies.resolver.ts new file mode 100644 index 000000000..fc6855930 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/vacancies/vacancies.resolver.ts @@ -0,0 +1,15 @@ +/** @format */ + +import { inject } from "@angular/core"; +import { map } from "rxjs"; +import { GetVacanciesUseCase } from "@api/vacancy/use-cases/get-vacancies.use-case"; + +/** Предзагружает список вакансий. */ +export const VacanciesResolver = () => { + const getVacanciesUseCase = inject(GetVacanciesUseCase); + + // Загрузка первых 20 вакансий с нулевым смещением + return getVacanciesUseCase + .execute({ limit: 20, offset: 0 }) + .pipe(map(result => (result.ok ? result.value : []))); +}; diff --git a/projects/social_platform/src/app/ui/primitives/autocomplete-input/autocomplete-input.component.html b/projects/social_platform/src/app/ui/primitives/autocomplete-input/autocomplete-input.component.html new file mode 100644 index 000000000..fcf21fba2 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/autocomplete-input/autocomplete-input.component.html @@ -0,0 +1,69 @@ + + +
    +
    + + @if (fieldToDisplayMode() === "chip" && value()) { +
    + {{ $any(value())[fieldToDisplay()] || value() }} + +
    + } +
    +
    + @if (loading() && !slimVersion()) { + + } + @if (searchIcon() && slimVersion()) { + + } + @if (searchIcon() && !slimVersion()) { + + } +
    + @if (isOpen()) { +
    + @if (noResults()) { +
      +
    • + {{ "Ничего не найдено :(" }} +
    • +
    + } @else { +
      + @for (suggestion of suggestions; track $index) { +
    • + {{ fieldToDisplay() ? suggestion[fieldToDisplay()] : suggestion }} +
    • + } +
    + } +
    + } +
    diff --git a/projects/social_platform/src/app/ui/components/autocomplete-input/autocomplete-input.component.scss b/projects/social_platform/src/app/ui/primitives/autocomplete-input/autocomplete-input.component.scss similarity index 100% rename from projects/social_platform/src/app/ui/components/autocomplete-input/autocomplete-input.component.scss rename to projects/social_platform/src/app/ui/primitives/autocomplete-input/autocomplete-input.component.scss diff --git a/projects/social_platform/src/app/ui/primitives/autocomplete-input/autocomplete-input.component.ts b/projects/social_platform/src/app/ui/primitives/autocomplete-input/autocomplete-input.component.ts new file mode 100644 index 000000000..d6d5d2ea0 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/autocomplete-input/autocomplete-input.component.ts @@ -0,0 +1,221 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + ElementRef, + forwardRef, + inject, + Input, + input, + output, + signal, + viewChild, +} from "@angular/core"; +import { IconComponent } from "@uilib"; +import { NG_VALUE_ACCESSOR } from "@angular/forms"; +import { ClickOutsideModule } from "ng-click-outside"; +import { debounce, distinctUntilChanged, fromEvent, map, of, timer } from "rxjs"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { animate, style, transition, trigger } from "@angular/animations"; +import { LoaderComponent } from "../loader/loader.component"; +import { Skill } from "@domain/skills/skill.model"; + +@Component({ + selector: "app-autocomplete-input", + imports: [CommonModule, IconComponent, ClickOutsideModule, LoaderComponent], + templateUrl: "./autocomplete-input.component.html", + styleUrl: "./autocomplete-input.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AutoCompleteInputComponent), + multi: true, + }, + ], + animations: [ + trigger("dropdownAnimation", [ + transition(":enter", [ + style({ opacity: 0, transform: "scaleY(0.8)" }), + animate(".12s cubic-bezier(0, 0, 0.2, 1)"), + ]), + transition(":leave", [animate(".1s linear", style({ opacity: 0 }))]), + ]), + ], +}) +export class AutoCompleteInputComponent { + @Input({ required: true }) set suggestions(val: T[]) { + this._suggestions.set(val); + this.handleSuggestionsChange(val); + } + get suggestions(): T[] { + return this._suggestions(); + } + + readonly fieldToDisplayMode = input<"text" | "chip">("text"); + readonly fieldToDisplay = input.required(); + readonly valueField = input(); + readonly forceSelect = input(false); + readonly clearInputOnSelect = input(false); + readonly delay = input(300); + readonly placeholder = input(""); + readonly searchIcon = input("search"); + readonly slimVersion = input(false); + readonly error = input(false); + + readonly openSkillsFunc = output(); + readonly searchStart = output(); + readonly optionSelected = output(); + readonly inputCleared = output(); + + readonly inputElem = viewChild>("input"); + + value = signal(null); + inputValue = signal(""); + _suggestions = signal([]); + isOpen = signal(false); + loading = signal(false); + noResults = signal(false); + disabled = signal(false); + + private readonly destroyRef = inject(DestroyRef); + + ngOnInit(): void {} + + ngAfterViewInit(): void { + fromEvent(this.inputElem()!.nativeElement, "input") + .pipe( + map(e => (e.target as HTMLInputElement).value.trim()), + debounce(val => (val ? timer(this.delay()) : of({}))), + distinctUntilChanged(), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(val => this.handleSearch(val)); + } + + onInput(event: Event): void { + const value = (event.target as HTMLInputElement).value.trim(); + this.inputValue.set(value); + } + + onBlur(): void { + this.onTouch(); + } + + writeValue(value: any): void { + this.value.set(value?.[this.valueField()!] ?? value); + this.handleProgrammaticInputValueChange(value); + } + + onChange: (value: any) => void = () => {}; + + registerOnChange(fn: (v: any) => void): void { + this.onChange = fn; + } + + onTouch: () => void = () => {}; + + registerOnTouched(fn: () => void): void { + this.onTouch = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled.set(isDisabled); + } + + onEnter(event: Event): void { + event.preventDefault(); + } + + onUpdate(event: Event, value: any): void { + event.stopPropagation(); + + const newValue = value?.[this.valueField()!] ?? value; + + this.value.set(newValue); + this.onChange(newValue); + this.optionSelected.emit(newValue); + + this.handleProgrammaticInputValueChange(newValue); + + this.isOpen.set(false); + } + + onClearValue(event: Event): void { + event.stopPropagation(); + this.inputValue.set(""); + this.value.set(null); + this.onChange(null); + } + + onClickOutside(): void { + const value = this.findExistingSuggestion(this.suggestions); + + if (this.forceSelect() && this.isOpen() && value) { + const newValue = value?.[this.valueField()!] ?? value; + + this.handleProgrammaticInputValueChange(newValue); + this.value.set(newValue); + this.onChange(newValue); + } else if (this.forceSelect() && this.isOpen() && !value) { + this.inputValue.set(""); + this.value.set(null); + this.onChange(null); + } + + this.isOpen.set(false); + } + + handleSearch(query: string): void { + if (!query) { + this.isOpen.set(false); + this.inputCleared.emit(); + return; + } + + this.loading.set(true); + this.searchStart.emit(query); + } + + handlePaste(event: ClipboardEvent): void { + const query = event.clipboardData?.getData("text"); + + if (query) { + this.handleSearch(query.trim()); + } + } + + handleSuggestionsChange(suggestions: any[]): void { + if (!suggestions?.length && this.loading()) { + this.noResults.set(true); + this.isOpen.set(true); + } + + if (this.suggestions?.length) { + this.noResults.set(false); + this.isOpen.set(true); + } + + this.loading.set(false); + } + + handleProgrammaticInputValueChange(appValue: any): void { + if (this.fieldToDisplayMode() === "chip" || this.clearInputOnSelect()) { + this.inputValue.set(""); + } else { + this.inputValue.set(appValue?.[this.fieldToDisplay()] ?? appValue); + } + } + + findExistingSuggestion(suggestions: typeof this.suggestions): any { + if (!this.fieldToDisplay) { + return suggestions.find(s => String(s).toLowerCase() === this.inputValue().toLowerCase()); + } + return suggestions.find( + s => String(s[this.fieldToDisplay()]).toLowerCase() === this.inputValue().toLocaleLowerCase(), + ); + } +} diff --git a/projects/social_platform/src/app/ui/primitives/avatar-control/avatar-control.component.html b/projects/social_platform/src/app/ui/primitives/avatar-control/avatar-control.component.html new file mode 100644 index 000000000..3f1a89cd6 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/avatar-control/avatar-control.component.html @@ -0,0 +1,110 @@ + + +
    + + + + @if (haveHint() && tooltipText()) { +
    + +
    + } +
    + + +
    +
    + +

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

    +
    + + @if (showCropperModalErrorMessage) { +

    + {{ showCropperModalErrorMessage }} +

    + } + +
    + +
    + +
    + Отменить + Сохранить +
    +
    +
    diff --git a/projects/social_platform/src/app/ui/components/avatar-control/avatar-control.component.scss b/projects/social_platform/src/app/ui/primitives/avatar-control/avatar-control.component.scss similarity index 100% rename from projects/social_platform/src/app/ui/components/avatar-control/avatar-control.component.scss rename to projects/social_platform/src/app/ui/primitives/avatar-control/avatar-control.component.scss diff --git a/projects/social_platform/src/app/ui/primitives/avatar-control/avatar-control.component.spec.ts b/projects/social_platform/src/app/ui/primitives/avatar-control/avatar-control.component.spec.ts new file mode 100644 index 000000000..5974036d3 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/avatar-control/avatar-control.component.spec.ts @@ -0,0 +1,38 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { AvatarControlComponent } from "./avatar-control.component"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { API_URL } from "@corelib"; +import { of } from "rxjs"; + +describe("AvatarControlComponent", () => { + let component: AvatarControlComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const authSpy = { + fetchProfile: of({}), + }; + + await TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, AvatarControlComponent], + providers: [ + { provide: AuthRepositoryPort, useValue: authSpy }, + { provide: API_URL, useValue: "" }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(AvatarControlComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/primitives/avatar-control/avatar-control.component.stories.ts b/projects/social_platform/src/app/ui/primitives/avatar-control/avatar-control.component.stories.ts new file mode 100644 index 000000000..1df6ad9a7 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/avatar-control/avatar-control.component.stories.ts @@ -0,0 +1,125 @@ +/** @format */ + +import { Meta, StoryObj, moduleMetadata, applicationConfig } from "@storybook/angular"; +import { ReactiveFormsModule, FormControl } from "@angular/forms"; +import { provideHttpClient } from "@angular/common/http"; +import { API_URL } from "@corelib"; +import { AvatarControlComponent } from "./avatar-control.component"; + +const meta: Meta = { + title: "UI/PRIMITIVES/AvatarControl", + component: AvatarControlComponent, + tags: ["autodocs"], + decorators: [ + applicationConfig({ + providers: [provideHttpClient(), { provide: API_URL, useValue: "" }], + }), + moduleMetadata({ + imports: [ReactiveFormsModule], + }), + ], + argTypes: { + size: { control: "number" }, + error: { control: "boolean" }, + type: { control: "select", options: ["avatar", "project", "profile"] }, + }, + render: args => ({ + props: { + ...args, + control: new FormControl(""), + }, + template: ``, + }), +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { size: 140, type: "avatar" }, + render: args => ({ + props: { + ...args, + control: new FormControl("https://i.pravatar.cc/300?img=12"), + }, + template: ``, + }), +}; + +export const Project: Story = { + args: { size: 100, type: "project" }, + render: args => ({ + props: { + ...args, + control: new FormControl("https://picsum.photos/seed/project/200/200"), + }, + template: ``, + }), +}; + +export const Profile: Story = { + args: { size: 160, type: "profile" }, + render: args => ({ + props: { + ...args, + control: new FormControl("https://i.pravatar.cc/300?img=32"), + }, + template: ``, + }), +}; + +export const Empty: Story = { + args: { size: 140, type: "avatar" }, +}; + +export const WithError: Story = { + args: { size: 140, type: "avatar", error: true }, + render: args => ({ + props: { + ...args, + control: new FormControl(""), + }, + template: ``, + }), +}; + +export const Small: Story = { + args: { size: 80, type: "avatar" }, + render: args => ({ + props: { + ...args, + control: new FormControl("https://i.pravatar.cc/300?img=47"), + }, + template: ``, + }), +}; diff --git a/projects/social_platform/src/app/ui/primitives/avatar-control/avatar-control.component.ts b/projects/social_platform/src/app/ui/primitives/avatar-control/avatar-control.component.ts new file mode 100644 index 000000000..829f071e1 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/avatar-control/avatar-control.component.ts @@ -0,0 +1,374 @@ +/** @format */ + +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DestroyRef, + forwardRef, + inject, + input, +} from "@angular/core"; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; +import { nanoid } from "nanoid"; +import { FileService } from "@core/lib/services/file/file.service"; +import { catchError, concatMap, map, of } from "rxjs"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { CommonModule } from "@angular/common"; +import { ImageCroppedEvent, ImageCropperComponent } from "ngx-image-cropper"; +import { DomSanitizer, SafeUrl } from "@angular/platform-browser"; +import { LoggerService } from "@corelib"; +import { LoaderComponent } from "../loader/loader.component"; +import { ModalComponent } from "../modal/modal.component"; +import { ButtonComponent } from "../button/button.component"; +import { TooltipComponent } from "../tooltip/tooltip.component"; +import { IconComponent } from "../icon/icon.component"; + +/** + * Компонент для управления аватаром пользователя. + * Реализует ControlValueAccessor для интеграции с Angular Forms. + * Позволяет загружать, обрезать, обновлять и удалять изображение аватара. + * + * Входящие параметры: + * - size: размер аватара в пикселях (по умолчанию 140) + * - error: состояние ошибки для отображения красной рамки + * - type: тип аватара ("avatar" | "project" | "profile", по умолчанию "avatar") + * + * Возвращает: + * - URL загруженного изображения через ControlValueAccessor + */ +@Component({ + selector: "app-avatar-control", + templateUrl: "./avatar-control.component.html", + styleUrl: "./avatar-control.component.scss", + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AvatarControlComponent), + multi: true, + }, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + LoaderComponent, + IconComponent, + CommonModule, + ImageCropperComponent, + ModalComponent, + ButtonComponent, + TooltipComponent, + ], +}) +export class AvatarControlComponent implements ControlValueAccessor { + private readonly destroyRef = inject(DestroyRef); + + constructor( + private fileService: FileService, + private sanitizer: DomSanitizer, + private readonly loggerService: LoggerService, + private readonly cdr: ChangeDetectorRef, + ) {} + + /** Размер аватара в пикселях */ + size = input(140); + + /** Состояние ошибки */ + error = input(false); + + /** Тип аватара */ + type = input<"avatar" | "project" | "profile">("avatar"); + + /** Наличие подсказки */ + haveHint = input(false); + + /** Текст для подсказки */ + tooltipText = input(); + + /** Позиция подсказки */ + tooltipPosition = input<"left" | "right">("right"); + + /** Ширина подсказки */ + tooltipWidth = input(250); + + /** Уникальный ID для элемента input */ + controlId = nanoid(3); + + /** Состояние видимости подсказки */ + isTooltipVisible = false; + + /** Текущее значение URL изображения */ + value = ""; + + /** Показывать ли модальное окно кроппера */ + showCropperModal = false; + + /** Текст ошибки при обрезки фотографии */ + showCropperModalErrorMessage = ""; + + /** Исходное изображение для обрезки */ + imageChangedEvent: Event | null = null; + + /** Обрезанное изображение */ + croppedImage: SafeUrl = ""; + + /** Blob обрезанного изображения для загрузки */ + croppedBlob: Blob | null = null; + + /** Записывает значение URL изображения */ + writeValue(address: string) { + this.value = address; + this.cdr.markForCheck(); + } + + onTouch: () => void = () => {}; + + registerOnTouched(fn: any) { + this.onTouch = fn; + } + + onChange: (value: string) => void = () => {}; + + registerOnChange(fn: any) { + this.onChange = fn; + } + + /** Состояние загрузки файла */ + loading = false; + + /** Исправленное изображение в формате base64 для кроппера */ + correctedImageBase64 = ""; + + /** + * Обработчик выбора файла - открывает кроппер + */ + onFileSelected(event: Event) { + const files = (event.currentTarget as HTMLInputElement).files; + + if (!files?.length) { + return; + } + + this.fixImageOrientation(files[0], () => { + this.imageChangedEvent = event; + this.showCropperModal = true; + this.cdr.markForCheck(); + }); + } + + private fixImageOrientation(file: File, onComplete: () => void) { + const reader = new FileReader(); + + reader.onload = e => { + const img = new Image(); + img.onload = () => { + this.getImageOrientation(file, orientation => { + if (orientation === 1) { + this.correctedImageBase64 = ""; + onComplete(); + return; + } + + const canvas = this.rotateImage(img, orientation); + this.correctedImageBase64 = canvas.toDataURL(file.type); + onComplete(); + }); + }; + img.src = e.target?.result as string; + }; + + reader.readAsDataURL(file); + } + + private getImageOrientation(file: File, onOrientationDetected: (orientation: number) => void) { + const reader = new FileReader(); + + reader.onload = event => { + const view = new DataView(event.target?.result as ArrayBuffer); + if (view.byteLength < 2 || view.getUint16(0) !== 0xffd8) { + onOrientationDetected(1); + return; + } + + let offset = 2; + while (offset < view.byteLength - 9) { + if (view.getUint16(offset) === 0xffe1) { + const length = view.getUint16(offset + 2) + 2; + if (view.getUint32(offset + 4) === 0x45786966 && view.getUint16(offset + 8) === 0x0000) { + const orientation = this.getExifOrientation(view, offset + 10); + onOrientationDetected(orientation); + return; + } + offset += length; + } else { + offset += 2; + } + } + onOrientationDetected(1); + }; + + reader.readAsArrayBuffer(file); + } + + private getExifOrientation(view: DataView, offset: number): number { + try { + const littleEndian = view.getUint16(offset) === 0x4949; + const ifdOffset = view.getUint32(offset + 4, littleEndian); + const entries = view.getUint16(offset + ifdOffset, littleEndian); + + for (let i = 0; i < entries; i++) { + const entryOffset = offset + ifdOffset + 2 + i * 12; + const tag = view.getUint16(entryOffset, littleEndian); + if (tag === 0x0112) { + const value = view.getUint32(entryOffset + 8, littleEndian); + return value > 1 && value <= 8 ? value : 1; + } + } + } catch (e) { + console.warn("Ошибка при чтении EXIF ориентации:", e); + } + return 1; + } + + private rotateImage(img: HTMLImageElement, orientation: number): HTMLCanvasElement { + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + if (!ctx) { + return canvas; + } + + const [newWidth, newHeight] = [img.width, img.height]; + + switch (orientation) { + case 2: + canvas.width = newWidth; + canvas.height = newHeight; + ctx.scale(-1, 1); + ctx.drawImage(img, -newWidth, 0); + break; + case 3: + canvas.width = newWidth; + canvas.height = newHeight; + ctx.rotate(Math.PI); + ctx.drawImage(img, -newWidth, -newHeight); + break; + case 4: + canvas.width = newWidth; + canvas.height = newHeight; + ctx.scale(1, -1); + ctx.drawImage(img, 0, -newHeight); + break; + case 5: + canvas.width = newHeight; + canvas.height = newWidth; + ctx.rotate(Math.PI / 2); + ctx.scale(-1, 1); + ctx.drawImage(img, -newHeight, 0); + break; + case 6: + canvas.width = newHeight; + canvas.height = newWidth; + ctx.rotate(Math.PI / 2); + ctx.drawImage(img, 0, -newWidth); + break; + case 7: + canvas.width = newHeight; + canvas.height = newWidth; + ctx.rotate(-Math.PI / 2); + ctx.scale(-1, 1); + ctx.drawImage(img, -newHeight, -newWidth); + break; + case 8: + canvas.width = newHeight; + canvas.height = newWidth; + ctx.rotate(-Math.PI / 2); + ctx.drawImage(img, -newHeight, 0); + break; + default: + canvas.width = newWidth; + canvas.height = newHeight; + ctx.drawImage(img, 0, 0); + } + + return canvas; + } + + imageCropped(event: ImageCroppedEvent) { + if (event.objectUrl) { + this.croppedImage = this.sanitizer.bypassSecurityTrustUrl(event.objectUrl); + } + this.croppedBlob = event.blob || null; + } + + imageLoaded() {} + + cropperReady() {} + + loadImageFailed() { + this.loggerService.error("Не удалось загрузить изображение"); + this.showCropperModalErrorMessage = "Не удалось загрузить изображение. Попробуйте ещё раз!"; + this.cdr.markForCheck(); + } + + saveCroppedImage() { + if (!this.croppedBlob) { + return; + } + + this.loading = true; + this.showCropperModal = false; + this.cdr.markForCheck(); + + const file = new File([this.croppedBlob], "cropped-avatar.jpg", { + type: "image/jpeg", + lastModified: Date.now(), + }); + + const source = this.value + ? this.fileService.deleteFile(this.value).pipe( + catchError(err => { + this.loggerService.error(err); + return of({}); + }), + concatMap(() => this.fileService.uploadFile(file)), + map(r => r["url"]), + takeUntilDestroyed(this.destroyRef), + ) + : this.fileService.uploadFile(file).pipe( + map(r => r.url), + takeUntilDestroyed(this.destroyRef), + ); + + source.subscribe(this.updateValue.bind(this)); + } + + closeCropper() { + this.showCropperModal = false; + this.imageChangedEvent = null; + this.croppedImage = ""; + this.croppedBlob = null; + this.cdr.markForCheck(); + + const input = document.getElementById(this.controlId) as HTMLInputElement; + if (input) { + input.value = ""; + } + } + + showTooltip(): void { + this.isTooltipVisible = true; + } + + hideTooltip(): void { + this.isTooltipVisible = false; + } + + private updateValue(url: string): void { + this.loading = false; + + this.onChange(url); + this.value = url; + + this.onTouch(); + this.cdr.markForCheck(); + } +} diff --git a/projects/social_platform/src/app/ui/primitives/avatar/avatar.component.html b/projects/social_platform/src/app/ui/primitives/avatar/avatar.component.html new file mode 100644 index 000000000..24b4ffe6e --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/avatar/avatar.component.html @@ -0,0 +1,33 @@ + + +
    +
    + @if (progress()) { +
    + } + + avatar + + @if (isOnline()) { +
    + } +
    +
    diff --git a/projects/social_platform/src/app/ui/components/avatar/avatar.component.scss b/projects/social_platform/src/app/ui/primitives/avatar/avatar.component.scss similarity index 100% rename from projects/social_platform/src/app/ui/components/avatar/avatar.component.scss rename to projects/social_platform/src/app/ui/primitives/avatar/avatar.component.scss diff --git a/projects/social_platform/src/app/ui/primitives/avatar/avatar.component.spec.ts b/projects/social_platform/src/app/ui/primitives/avatar/avatar.component.spec.ts new file mode 100644 index 000000000..e795b6003 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/avatar/avatar.component.spec.ts @@ -0,0 +1,71 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { AvatarComponent } from "./avatar.component"; + +describe("AvatarComponent", () => { + let component: AvatarComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AvatarComponent], + }).compileComponents(); + }); + + it("should create", () => { + fixture = TestBed.createComponent(AvatarComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput("url", ""); + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it("should display placeholder image if no URL is provided", () => { + fixture = TestBed.createComponent(AvatarComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput("url", ""); + fixture.detectChanges(); + const img = fixture.nativeElement.querySelector("img"); + expect(img.src).toContain(component.placeholderUrl); + }); + + it("should display provided image if URL is provided", () => { + fixture = TestBed.createComponent(AvatarComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput("url", "https://example.com/avatar.png"); + fixture.detectChanges(); + const img = fixture.nativeElement.querySelector("img"); + expect(img.src).toContain("https://example.com/avatar.png"); + }); + + it("should have correct size", () => { + fixture = TestBed.createComponent(AvatarComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput("url", ""); + fixture.detectChanges(); + const img = fixture.nativeElement.querySelector("img"); + expect(img.style.width).toBe(component.size() + "px"); + expect(img.style.height).toBe(component.size() + "px"); + }); + + it("should have border if hasBorder is true", () => { + fixture = TestBed.createComponent(AvatarComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput("url", ""); + fixture.componentRef.setInput("hasBorder", true); + fixture.detectChanges(); + const div = fixture.nativeElement.querySelector(".avatar > div"); + expect(div.classList.contains("avatar--border")).toBe(true); + }); + + it("should not have border if hasBorder is false", () => { + fixture = TestBed.createComponent(AvatarComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput("url", ""); + fixture.componentRef.setInput("hasBorder", false); + fixture.detectChanges(); + const div = fixture.nativeElement.querySelector(".avatar > div"); + expect(div.classList.contains("avatar--border")).toBe(false); + }); +}); diff --git a/projects/social_platform/src/app/ui/primitives/avatar/avatar.component.stories.ts b/projects/social_platform/src/app/ui/primitives/avatar/avatar.component.stories.ts new file mode 100644 index 000000000..4e7868c4d --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/avatar/avatar.component.stories.ts @@ -0,0 +1,67 @@ +/** @format */ + +import { Meta, StoryObj } from "@storybook/angular"; +import { AvatarComponent } from "@uilib"; + +const meta: Meta = { + title: "UI/PRIMITIVES/Avatar", + component: AvatarComponent, + tags: ["autodocs"], + argTypes: { + url: { control: "text" }, + size: { control: "number" }, + hasBorder: { control: "boolean" }, + isOnline: { control: "boolean" }, + onlineBadgeSize: { control: "number" }, + onlineBadgeBorder: { control: "number" }, + onlineBadgeOffset: { control: "number" }, + }, + render: args => ({ + props: args, + template: ` + + + `, + }), +}; + +export default meta; + +type Story = StoryObj; + +export const Primary: Story = { + args: { + url: "", + size: 50, + hasBorder: false, + isOnline: false, + }, +}; + +export const Online: Story = { + args: { + url: "", + size: 50, + hasBorder: false, + isOnline: true, + onlineBadgeSize: 16, + onlineBadgeBorder: 3, + onlineBadgeOffset: 0, + }, +}; + +export const HasBorder: Story = { + args: { + url: "", + size: 50, + hasBorder: true, + }, +}; diff --git a/projects/social_platform/src/app/ui/components/avatar/avatar.component.ts b/projects/social_platform/src/app/ui/primitives/avatar/avatar.component.ts similarity index 81% rename from projects/social_platform/src/app/ui/components/avatar/avatar.component.ts rename to projects/social_platform/src/app/ui/primitives/avatar/avatar.component.ts index 1a499949e..10dff3481 100644 --- a/projects/social_platform/src/app/ui/components/avatar/avatar.component.ts +++ b/projects/social_platform/src/app/ui/primitives/avatar/avatar.component.ts @@ -1,6 +1,6 @@ /** @format */ -import { Component, Input, type OnInit } from "@angular/core"; +import { ChangeDetectionStrategy, Component, input } from "@angular/core"; /** * Компонент для отображения аватара пользователя. @@ -26,38 +26,35 @@ import { Component, Input, type OnInit } from "@angular/core"; templateUrl: "./avatar.component.html", styleUrl: "./avatar.component.scss", standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class AvatarComponent implements OnInit { +export class AvatarComponent { /** URL изображения аватара */ - @Input({ required: true }) url?: string; + url = input.required(); /** Размер аватара в пикселях */ - @Input() size = 50; + size = input(50); /** Отображать рамку */ - @Input() hasBorder = false; + hasBorder = input(false); - @Input() borderColor: "dark-grey" | "white" | "black" | "accent" = "white"; + borderColor = input<"dark-grey" | "white" | "black" | "accent">("white"); /** Показывать индикатор онлайн статуса */ - @Input() isOnline = false; + isOnline = input(false); /** Значение прогресса (0-100) */ - @Input() progress?: number; + progress = input(); /** Размер индикатора онлайн статуса */ - @Input() onlineBadgeSize = 16; + onlineBadgeSize = input(16); /** Толщина рамки индикатора */ - @Input() onlineBadgeBorder = 3; + onlineBadgeBorder = input(3); /** Смещение индикатора от края */ - @Input() onlineBadgeOffset = 0; + onlineBadgeOffset = input(0); /** URL placeholder изображения по умолчанию */ placeholderUrl = "https://hwchamber.co.uk/wp-content/uploads/2022/04/avatar-placeholder.gif"; - - constructor() {} - - ngOnInit(): void {} } diff --git a/projects/social_platform/src/app/ui/primitives/bar/bar.component.html b/projects/social_platform/src/app/ui/primitives/bar/bar.component.html new file mode 100644 index 000000000..ea71a98ac --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/bar/bar.component.html @@ -0,0 +1,33 @@ + + +
    + +
    diff --git a/projects/social_platform/src/app/ui/components/bar/bar.component.scss b/projects/social_platform/src/app/ui/primitives/bar/bar.component.scss similarity index 100% rename from projects/social_platform/src/app/ui/components/bar/bar.component.scss rename to projects/social_platform/src/app/ui/primitives/bar/bar.component.scss diff --git a/projects/social_platform/src/app/ui/primitives/bar/bar.component.spec.ts b/projects/social_platform/src/app/ui/primitives/bar/bar.component.spec.ts new file mode 100644 index 000000000..8e4f23c19 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/bar/bar.component.spec.ts @@ -0,0 +1,25 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { BarComponent } from "./bar.component"; + +describe("BarComponent", () => { + let component: BarComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [BarComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(BarComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput("links", []); + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/primitives/bar/bar.component.stories.ts b/projects/social_platform/src/app/ui/primitives/bar/bar.component.stories.ts new file mode 100644 index 000000000..619cca46d --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/bar/bar.component.stories.ts @@ -0,0 +1,30 @@ +/** @format */ + +import { Meta, StoryObj, applicationConfig } from "@storybook/angular"; +import { provideRouter } from "@angular/router"; +import { BarComponent } from "./bar.component"; + +const meta: Meta = { + title: "UI/PRIMITIVES/Bar", + component: BarComponent, + tags: ["autodocs"], + // Внутри RouterLink/RouterLinkActive — нужен провайдер роутера. + decorators: [applicationConfig({ providers: [provideRouter([])] })], + render: args => ({ + props: args, + template: ``, + }), +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + ballHave: false, + links: [ + { link: "/feed", linkText: "Лента", isRouterLinkActiveOptions: false }, + { link: "/projects", linkText: "Проекты", isRouterLinkActiveOptions: false, count: 3 }, + ], + }, +}; diff --git a/projects/social_platform/src/app/ui/primitives/bar/bar.component.ts b/projects/social_platform/src/app/ui/primitives/bar/bar.component.ts new file mode 100644 index 000000000..dc681d128 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/bar/bar.component.ts @@ -0,0 +1,55 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, input } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { RouterLink, RouterLinkActive } from "@angular/router"; +import { BackComponent } from "@uilib"; + +/** + * Компонент навигационной панели с табами и кнопкой "Назад". + * Отображает горизонтальный список ссылок с индикаторами активности и счетчиками. + * + * Входящие параметры: + * - links: массив объектов навигационных ссылок с настройками + * - link: URL ссылки + * - linkText: текст ссылки + * - isRouterLinkActiveOptions: настройки активности ссылки + * - count: количество элементов для отображения бейджа (опционально) + * - backRoute: маршрут для кнопки "Назад" (опционально) + * - backHave: показывать ли кнопку "Назад" (опционально) + * - ballHave: показывать ли индикатор в виде шарика (по умолчанию false) + * + * Использование: + * - Навигация между разделами приложения + * - Отображение количества элементов в разделах + * - Навигация назад к предыдущему экрану + */ + +interface BarLinks { + link: string; + linkText: string; + isRouterLinkActiveOptions: boolean; + count?: number; +} + +/** Примитив: индикатор-полоса (progress/bar). */ +@Component({ + selector: "app-bar", + imports: [CommonModule, RouterLink, RouterLinkActive, BackComponent], + templateUrl: "./bar.component.html", + styleUrl: "./bar.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BarComponent { + /** Массив навигационных ссылок */ + links = input.required(); + + /** Показывать индикатор в виде шарика */ + ballHave = input(false); + + /** Маршрут для кнопки "Назад" */ + backRoute = input(); + + /** Показывать кнопку "Назад" */ + backHave = input(); +} diff --git a/projects/social_platform/src/app/ui/primitives/button/button.component.html b/projects/social_platform/src/app/ui/primitives/button/button.component.html new file mode 100644 index 000000000..a0ab4c656 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/button/button.component.html @@ -0,0 +1,33 @@ + + diff --git a/projects/social_platform/src/app/ui/primitives/button/button.component.scss b/projects/social_platform/src/app/ui/primitives/button/button.component.scss new file mode 100644 index 000000000..6c27a701c --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/button/button.component.scss @@ -0,0 +1,163 @@ +/** @format */ + +@use "styles/typography"; + +.button { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + max-height: 40px; + text-align: center; + cursor: pointer; + border-radius: var(--rounded-xxl); + transition: all 0.2s; + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } + + &.button--inline { + font-weight: 400; + color: var(--white); + background: var(--accent); + border: 0.5px solid transparent; + outline: none; + + &:hover { + background-color: var(--accent-medium); + box-shadow: -2px 3px 3px rgb(51 51 51 / 20%); + } + + ::ng-deep *:not(.dot-wave) { + display: block; + + &:not(:last-child) { + margin-right: 10px; + } + } + + &.button--red { + background-color: var(--red); + + &:hover { + background-color: var(--red-dark); + } + } + + &.button--gradient { + background: var(--gradient); + + &:hover { + background: var(--gradient-mild); + } + } + + &.button--grey { + color: var(--black); + background-color: var(--grey-button); + } + + &.button--green { + color: var(--white); + background-color: var(--green); + } + + &.button--gold { + color: var(--white); + background: var(--gold-dark); + } + + &.button--white { + color: var(--accent); + background: var(--white); + } + + &.button--no-border { + border: none; + } + } + + &.button--outline { + color: var(--accent); + background: transparent; + border: 0.5px solid var(--accent); + + &:hover { + color: var(--accent-medium); + border-color: var(--accent-medium); + box-shadow: -2px 3px 3px rgb(51 51 51 / 20%); + } + + &.button--red { + color: var(--red); + border-color: var(--red); + + &:hover { + color: var(--red-dark); + border-color: var(--red-dark); + } + } + + &.button--gold { + color: var(--gold); + border-color: var(--gold); + } + + &.button--white { + color: var(--white); + border: 0.5px solid var(--white); + } + + &.button--green { + color: var(--green); + border: 0.5px solid var(--green); + } + + &.button--no-border { + border: none; + } + + ::ng-deep *:not(.dot-wave) { + display: block; + + &:not(:last-child) { + margin-right: 10px; + } + } + } + + &--extra-small { + width: 70px; + padding: 2px 10px; + } + + &--small { + width: 100px; + padding: 4px 24px; + + &--icon { + padding: 12px 24px; + } + } + + &--medium { + width: 157px; + padding: 4px 0; + + &--icon { + padding: 12px 60px; + } + } + + &--big { + width: 100%; + padding: 4px 24px; + + &--icon { + width: 100%; + padding: 12px 24px; + } + } +} diff --git a/projects/social_platform/src/app/ui/components/button/button.component.spec.ts b/projects/social_platform/src/app/ui/primitives/button/button.component.spec.ts similarity index 83% rename from projects/social_platform/src/app/ui/components/button/button.component.spec.ts rename to projects/social_platform/src/app/ui/primitives/button/button.component.spec.ts index 6dd0acb08..5809fa3fc 100644 --- a/projects/social_platform/src/app/ui/components/button/button.component.spec.ts +++ b/projects/social_platform/src/app/ui/primitives/button/button.component.spec.ts @@ -2,7 +2,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; -import { ButtonComponent } from "@ui/components"; +import { ButtonComponent } from "@ui/primitives"; describe("ButtonComponent", () => { let component: ButtonComponent; @@ -24,7 +24,7 @@ describe("ButtonComponent", () => { }); it("should set the button type", () => { - component.type = "submit"; + fixture.componentRef.setInput("type", "submit"); fixture.detectChanges(); const button = fixture.debugElement.query(By.css("button")).nativeElement; @@ -32,7 +32,7 @@ describe("ButtonComponent", () => { }); it("should set the button color", () => { - component.color = "red"; + fixture.componentRef.setInput("color", "red"); fixture.detectChanges(); const button = fixture.debugElement.query(By.css("button")).nativeElement; @@ -40,7 +40,7 @@ describe("ButtonComponent", () => { }); it("should set the button appearance", () => { - component.appearance = "outline"; + fixture.componentRef.setInput("appearance", "outline"); fixture.detectChanges(); const button = fixture.debugElement.query(By.css("button")).nativeElement; @@ -48,7 +48,7 @@ describe("ButtonComponent", () => { }); it("should show the loader when loader input is true", () => { - component.loader = true; + fixture.componentRef.setInput("loader", true); fixture.detectChanges(); const loader = fixture.debugElement.query(By.css("app-loader")); @@ -59,13 +59,13 @@ describe("ButtonComponent", () => { }); it("should show the content when loader input is false", () => { - component.loader = false; + fixture.componentRef.setInput("loader", false); fixture.detectChanges(); let loader = fixture.debugElement.query(By.css("app-loader")); expect(loader).toBeFalsy(); - component.loader = true; + fixture.componentRef.setInput("loader", true); fixture.detectChanges(); loader = fixture.debugElement.query(By.css("app-loader")); diff --git a/projects/social_platform/src/app/ui/primitives/button/button.component.stories.ts b/projects/social_platform/src/app/ui/primitives/button/button.component.stories.ts new file mode 100644 index 000000000..a87f9af8d --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/button/button.component.stories.ts @@ -0,0 +1,57 @@ +/** @format */ + +import type { Meta, StoryObj } from "@storybook/angular"; +import { ButtonComponent } from "./button.component"; + +const meta: Meta = { + title: "UI/PRIMITIVES/Button", + component: ButtonComponent, + tags: ["autodocs"], + argTypes: { + color: { + control: "select", + options: ["primary", "red", "grey", "green", "gold", "gradient", "white"], + }, + size: { control: "select", options: ["extra-small", "small", "medium", "big"] }, + appearance: { control: "inline-radio", options: ["inline", "outline"] }, + type: { control: "select", options: ["submit", "reset", "button", "icon"] }, + loader: { control: "boolean" }, + hasBorder: { control: "boolean" }, + disabled: { control: "boolean" }, + backgroundColor: { control: "color" }, + customTypographyClass: { control: "text" }, + }, + render: args => ({ + props: args, + template: `Кнопка`, + }), +}; +export default meta; + +type Story = StoryObj; + +export const Primary: Story = { + args: { color: "primary", size: "medium", appearance: "inline" }, +}; + +export const Outline: Story = { + args: { color: "primary", size: "medium", appearance: "outline" }, +}; + +export const Loading: Story = { + args: { color: "primary", size: "medium", loader: true }, +}; + +export const Disabled: Story = { + args: { color: "primary", size: "medium", disabled: true }, +}; diff --git a/projects/social_platform/src/app/ui/primitives/button/button.component.ts b/projects/social_platform/src/app/ui/primitives/button/button.component.ts new file mode 100644 index 000000000..a7ba3107c --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/button/button.component.ts @@ -0,0 +1,59 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, input } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { LoaderComponent } from "../loader/loader.component"; + +/** + * Универсальный компонент кнопки с различными стилями, состояниями и встроенной подсказкой. + * Поддерживает различные цветовые схемы, индикатор загрузки, настройки внешнего вида и tooltip. + * + * Входящие параметры: + * - color: цветовая схема кнопки ("primary" | "red" | "grey" | "green" | "gold" | "gradient" | "white") + * - loader: показывать индикатор загрузки + * - hasBorder: отображать рамку кнопки + * - type: тип кнопки для HTML ("submit" | "reset" | "button") + * - appearance: стиль отображения ("inline" | "outline") + * - backgroundColor: кастомный цвет фона + * - disabled: состояние блокировки кнопки + * - customTypographyClass: кастомный CSS класс для типографики + * + * Использование: + * - Вставлять контент кнопки через ng-content + * - Автоматически показывает лоадер при loader=true + */ +@Component({ + selector: "app-button", + templateUrl: "./button.component.html", + styleUrl: "./button.component.scss", + imports: [CommonModule, LoaderComponent], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ButtonComponent { + /** Цветовая схема кнопки */ + color = input<"primary" | "red" | "grey" | "green" | "gold" | "gradient" | "white">("primary"); + + /** Показывать индикатор загрузки */ + loader = input(false); + + /** Размер кнопки */ + size = input<"extra-small" | "small" | "medium" | "big">("small"); + + /** Отображать рамку */ + hasBorder = input(true); + + /** Тип HTML кнопки */ + type = input<"submit" | "reset" | "button" | "icon">("button"); + + /** Стиль отображения */ + appearance = input<"inline" | "outline">("inline"); + + /** Кастомный цвет фона */ + backgroundColor = input(); + + /** Состояние блокировки */ + disabled = input(false); + + /** Кастомный класс типографики */ + customTypographyClass = input(); +} diff --git a/projects/social_platform/src/app/ui/primitives/checkbox/checkbox.component.html b/projects/social_platform/src/app/ui/primitives/checkbox/checkbox.component.html new file mode 100644 index 000000000..91aadc1ea --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/checkbox/checkbox.component.html @@ -0,0 +1,10 @@ + +
    + +
    diff --git a/projects/social_platform/src/app/ui/components/checkbox/checkbox.component.scss b/projects/social_platform/src/app/ui/primitives/checkbox/checkbox.component.scss similarity index 100% rename from projects/social_platform/src/app/ui/components/checkbox/checkbox.component.scss rename to projects/social_platform/src/app/ui/primitives/checkbox/checkbox.component.scss diff --git a/projects/social_platform/src/app/ui/components/checkbox/checkbox.component.spec.ts b/projects/social_platform/src/app/ui/primitives/checkbox/checkbox.component.spec.ts similarity index 82% rename from projects/social_platform/src/app/ui/components/checkbox/checkbox.component.spec.ts rename to projects/social_platform/src/app/ui/primitives/checkbox/checkbox.component.spec.ts index b7516178a..232576785 100644 --- a/projects/social_platform/src/app/ui/components/checkbox/checkbox.component.spec.ts +++ b/projects/social_platform/src/app/ui/primitives/checkbox/checkbox.component.spec.ts @@ -2,7 +2,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; -import { CheckboxComponent } from "@ui/components"; +import { CheckboxComponent } from "@ui/primitives"; describe("CheckboxComponent", () => { let component: CheckboxComponent; @@ -25,16 +25,17 @@ describe("CheckboxComponent", () => { }); it("should emit the checked value when the field is clicked", () => { - spyOn(component.checkedChange, "emit"); + const emitSpy = vi.fn(); + component.checked.subscribe(emitSpy); const field = fixture.debugElement.query(By.css(".field")); field.triggerEventHandler("click", null); - expect(component.checkedChange.emit).toHaveBeenCalledWith(true); + expect(emitSpy).toHaveBeenCalledWith(true); }); it('should add the "field--checked" class when checked is true', () => { - component.checked = true; + fixture.componentRef.setInput("checked", true); fixture.detectChanges(); const field = fixture.debugElement.query(By.css(".field")); @@ -42,7 +43,7 @@ describe("CheckboxComponent", () => { }); it('should not add the "field--checked" class when checked is false', () => { - component.checked = false; + fixture.componentRef.setInput("checked", false); fixture.detectChanges(); const field = fixture.debugElement.query(By.css(".field")); diff --git a/projects/social_platform/src/app/ui/primitives/checkbox/checkbox.component.stories.ts b/projects/social_platform/src/app/ui/primitives/checkbox/checkbox.component.stories.ts new file mode 100644 index 000000000..e345c181f --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/checkbox/checkbox.component.stories.ts @@ -0,0 +1,20 @@ +/** @format */ + +import { Meta, StoryObj } from "@storybook/angular"; +import { CheckboxComponent } from "./checkbox.component"; + +const meta: Meta = { + title: "UI/PRIMITIVES/Checkbox", + component: CheckboxComponent, + tags: ["autodocs"], + argTypes: { + checked: { control: "boolean" }, + size: { control: "text" }, + }, +}; +export default meta; + +type Story = StoryObj; + +export const Unchecked: Story = { args: { checked: false } }; +export const Checked: Story = { args: { checked: true } }; diff --git a/projects/social_platform/src/app/ui/primitives/checkbox/checkbox.component.ts b/projects/social_platform/src/app/ui/primitives/checkbox/checkbox.component.ts new file mode 100644 index 000000000..20f176cdd --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/checkbox/checkbox.component.ts @@ -0,0 +1,31 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, input, model } from "@angular/core"; +import { IconComponent } from "../icon/icon.component"; + +/** + * Компонент чекбокса для выбора булевых значений. + * Отображает состояние отмечен/не отмечен с иконкой галочки. + * + * Входящие параметры: + * - checked: состояние чекбокса (отмечен/не отмечен) + * + * События: + * - checkedChange: изменение состояния чекбокса + * + * Возвращает: + * - boolean значение через событие checkedChange + */ +@Component({ + selector: "app-checkbox", + templateUrl: "./checkbox.component.html", + styleUrl: "./checkbox.component.scss", + imports: [IconComponent], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CheckboxComponent { + /** Состояние чекбокса */ + checked = model(false); + + size = input(); +} diff --git a/projects/social_platform/src/app/ui/primitives/dropdown/dropdown.component.html b/projects/social_platform/src/app/ui/primitives/dropdown/dropdown.component.html new file mode 100644 index 000000000..7fd297647 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/dropdown/dropdown.component.html @@ -0,0 +1,73 @@ + + +
    + +
    + + +
      + @for (option of options(); track option.id; let index = $index) { +
    • +
      + @if (option.additionalInfo) { + @switch (type()) { + @case ("icons") { + + } + @case ("avatars") { + + } + @case ("shapes") { +
      + } + @case ("goals") { + {{ + option.label.length > 20 ? option.label.slice(0, 17) + "..." : option.label + }} + } + @case ("tags") { + #{{ + option.label.length > 15 ? option.label.slice(0, 12) + "..." : option.label + }} + } + } + } +
      + + @if (type() !== "tags" && type() !== "goals") { +

      {{ option.label }}

      + } + + +
    • + } + @if (type() === "tags") { + @if (!creatingTag()) { +
    • + +
    • + } @else { + + } + } +
    +
    diff --git a/projects/social_platform/src/app/ui/primitives/dropdown/dropdown.component.scss b/projects/social_platform/src/app/ui/primitives/dropdown/dropdown.component.scss new file mode 100644 index 000000000..cf8f2b9fe --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/dropdown/dropdown.component.scss @@ -0,0 +1,88 @@ +.field { + &__options { + z-index: 1000; + display: flex; + flex-direction: column; + gap: 5px; + align-items: flex-start; + width: 100%; + max-height: 200px; + padding: 12px; + overflow-y: auto; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); + } + + &__option { + display: flex; + gap: 5px; + align-items: center; + justify-content: space-between; + cursor: pointer; + transition: color 0.2s ease-in-out; + + &--add-object { + display: flex; + align-items: center; + justify-content: center; + width: 80px; + padding: 3px; + cursor: pointer; + border: 0.5px solid var(--accent); + border-radius: var(--rounded-xl); + + i { + color: var(--accent); + } + } + + &:hover { + p { + color: var(--accent); + } + } + + &--additional { + display: flex; + gap: 5px; + align-items: center; + + i { + color: var(--accent); + } + + app-tag { + width: 80px; + } + } + + &-priority { + width: 15px; + height: 15px; + border-radius: var(--rounded-xxl); + } + + &--highlighted { + background-color: var(--light-gray); + } + + &--point { + opacity: 0; + transition: + opacity 0.2s ease-in-out, + color 0.2s ease-in-out; + + &:hover { + p { + color: var(--accent); + } + + &--point { + color: var(--accent); + opacity: 1; + } + } + } + } +} diff --git a/projects/social_platform/src/app/ui/primitives/dropdown/dropdown.component.stories.ts b/projects/social_platform/src/app/ui/primitives/dropdown/dropdown.component.stories.ts new file mode 100644 index 000000000..e8a61c81f --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/dropdown/dropdown.component.stories.ts @@ -0,0 +1,70 @@ +/** @format */ + +import { Meta, StoryObj, moduleMetadata, applicationConfig } from "@storybook/angular"; +import { OverlayModule } from "@angular/cdk/overlay"; +import { ClickOutsideModule } from "ng-click-outside"; +import { DropdownComponent } from "./dropdown.component"; + +const sampleOptions = [ + { id: 1, label: "Вариант один", value: "one" }, + { id: 2, label: "Вариант два", value: "two" }, + { id: 3, label: "Вариант три", value: "three" }, + { id: 4, label: "Очень длинный вариант для проверки переполнения", value: "four" }, +]; + +const meta: Meta = { + title: "UI/PRIMITIVES/Dropdown", + component: DropdownComponent, + tags: ["autodocs"], + decorators: [ + applicationConfig({ + providers: [], + }), + moduleMetadata({ + imports: [OverlayModule, ClickOutsideModule], + }), + ], + argTypes: { + type: { control: "select", options: ["text", "icons", "avatars", "shapes", "tags", "goals"] }, + isOpen: { control: "boolean" }, + highlightedIndex: { control: "number" }, + colorText: { control: "inline-radio", options: ["grey", "red"] }, + }, + render: args => ({ + props: { ...args, options: sampleOptions }, + template: ` +
    + + + +
    + `, + }), +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { type: "text", isOpen: true, highlightedIndex: -1, colorText: "grey" }, +}; + +export const Highlighted: Story = { + args: { type: "text", isOpen: true, highlightedIndex: 1, colorText: "grey" }, +}; + +export const RedText: Story = { + args: { type: "text", isOpen: true, colorText: "red" }, +}; + +export const Closed: Story = { + args: { type: "text", isOpen: false, colorText: "grey" }, +}; diff --git a/projects/social_platform/src/app/ui/primitives/dropdown/dropdown.component.ts b/projects/social_platform/src/app/ui/primitives/dropdown/dropdown.component.ts new file mode 100644 index 000000000..a9b5ffbef --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/dropdown/dropdown.component.ts @@ -0,0 +1,131 @@ +/** @format */ + +import { ConnectedPosition, OverlayModule } from "@angular/cdk/overlay"; +import { CommonModule } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + ElementRef, + Input, + input, + model, + output, + viewChild, +} from "@angular/core"; +import { getPriorityType } from "@utils/getPriorityType"; +import { ClickOutsideModule } from "ng-click-outside"; +import { TagDto } from "@domain/kanban/dto/tag.model.dto"; +import { CreateTagFormComponent } from "@ui/pages/projects/detail/kanban/components/create-tag-form/create-tag-form.component"; +import { TagComponent } from "../tag/tag.component"; +import { AvatarComponent } from "../avatar/avatar.component"; +import { IconComponent } from "../icon/icon.component"; + +/** Примитив: выпадающий список. */ +@Component({ + selector: "app-dropdown", + imports: [ + CommonModule, + OverlayModule, + AvatarComponent, + IconComponent, + TagComponent, + ClickOutsideModule, + CreateTagFormComponent, + ], + templateUrl: "./dropdown.component.html", + styleUrl: "./dropdown.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DropdownComponent { + /** Состояние для определения списка элементов */ + options = input< + { + id: number; + label: string; + value: string | number | boolean | null; + additionalInfo?: any; + }[] + >([]); + + type = input<"icons" | "avatars" | "shapes" | "tags" | "goals" | "text">("text"); + + /** Состояние для открытия списка выпадающего */ + @Input() isOpen = false; + + /** режим создания тега */ + creatingTag = model(false); + + /** Состояние для выделения элемента списка выпадающего */ + highlightedIndex = input(-1); + + colorText = input<"grey" | "red">("grey"); + + editingTag = input(null); + + updateTag = output(); + + /** Событие для выбора элемента */ + select = output(); + + /** Событие для логики при клике вне списка выпадающего */ + outside = output(); + + tagInfo = output<{ name: string; color: string }>(); + + dropdown = viewChild("dropdown"); + + getPriorityType = getPriorityType; + + positions: ConnectedPosition[] = [ + { + originX: "start", + originY: "bottom", + overlayX: "start", + overlayY: "top", + offsetY: 4, + }, + { + originX: "start", + originY: "top", + overlayX: "start", + overlayY: "bottom", + offsetY: -4, + }, + ]; + + /** Метод для выбора элемента и emit для родительского компонента */ + onSelect(event: Event, id: number) { + event.stopPropagation(); + this.select.emit(id); + } + + /** Метод для клика вне списка выпадающего */ + onClickOutside() { + this.outside.emit(); + } + + startCreatingTag(event: Event) { + event.stopPropagation(); + this.creatingTag.set(true); + } + + onConfirmUpdateTag(tagData: TagDto): void { + this.updateTag.emit(tagData); + this.creatingTag.set(false); + } + + onConfirmCreateTag(tagInfo: { name: string; color: string }): void { + this.tagInfo.emit(tagInfo); + this.creatingTag.set(false); + } + + getTextColor(colorText: "grey" | "red") { + switch (colorText) { + case "red": + return "color: var(--red)"; + + case "grey": + return "color: var(--grey-for-text)"; + } + } +} diff --git a/projects/social_platform/src/app/ui/primitives/file-item/file-item.component.html b/projects/social_platform/src/app/ui/primitives/file-item/file-item.component.html new file mode 100644 index 000000000..bcb7d2d7a --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/file-item/file-item.component.html @@ -0,0 +1,42 @@ + + +@if (name && link) { +
    +
    + @if (mode() === "preview") { + + } @else { + + } + +
    +
    + {{ name }} +
    + + @if (type()) { +
    + {{ type()!.includes("/") ? (type()! | fileType) : (type()! | uppercase) }} + {{ size() | formatedFileSize }} +
    + } +
    +
    + + @if (canDelete()) { + + } +
    +} diff --git a/projects/social_platform/src/app/ui/components/file-item/file-item.component.scss b/projects/social_platform/src/app/ui/primitives/file-item/file-item.component.scss similarity index 100% rename from projects/social_platform/src/app/ui/components/file-item/file-item.component.scss rename to projects/social_platform/src/app/ui/primitives/file-item/file-item.component.scss diff --git a/projects/social_platform/src/app/ui/primitives/file-item/file-item.component.spec.ts b/projects/social_platform/src/app/ui/primitives/file-item/file-item.component.spec.ts new file mode 100644 index 000000000..ff7ba59c2 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/file-item/file-item.component.spec.ts @@ -0,0 +1,30 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { FileItemComponent } from "./file-item.component"; +import { FileTypePipe } from "@ui/pipes/file-type.pipe"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { API_URL } from "@corelib"; + +describe("FileItemComponent", () => { + let component: FileItemComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, FileItemComponent, FileTypePipe], + providers: [{ provide: API_URL, useValue: "" }], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(FileItemComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/primitives/file-item/file-item.component.stories.ts b/projects/social_platform/src/app/ui/primitives/file-item/file-item.component.stories.ts new file mode 100644 index 000000000..1c694b1e2 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/file-item/file-item.component.stories.ts @@ -0,0 +1,92 @@ +/** @format */ + +import { Meta, StoryObj, applicationConfig } from "@storybook/angular"; +import { of } from "rxjs"; +import { FileService } from "@core/lib/services/file/file.service"; +import { FileItemComponent } from "./file-item.component"; + +class MockFileService { + uploadFile = () => of({ url: "https://example.com/mock-file.pdf" }); + deleteFile = () => of({ success: true as const }); +} + +const meta: Meta = { + title: "UI/PRIMITIVES/FileItem", + component: FileItemComponent, + tags: ["autodocs"], + decorators: [ + applicationConfig({ + providers: [{ provide: FileService, useClass: MockFileService }], + }), + ], + argTypes: { + type: { control: "text" }, + name: { control: "text" }, + size: { control: "number" }, + link: { control: "text" }, + mode: { control: "inline-radio", options: ["default", "preview"] }, + canDelete: { control: "boolean" }, + }, + render: args => ({ + props: args, + template: ` +
    + +
    + `, + }), +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + type: "pdf", + name: "Документ.pdf", + size: 2457600, + mode: "default", + canDelete: false, + link: "https://example.com/Документ.pdf", + }, +}; + +export const WithDelete: Story = { + args: { + type: "pdf", + name: "Удаляемый.pdf", + size: 1048576, + mode: "default", + canDelete: true, + link: "https://example.com/Удаляемый.pdf", + }, +}; + +export const Preview: Story = { + args: { + type: "image", + name: "Фото.jpg", + size: 3145728, + mode: "preview", + canDelete: true, + link: "https://example.com/Фото.jpg", + }, +}; + +export const SmallFile: Story = { + args: { + type: "txt", + name: "readme.txt", + size: 2048, + mode: "default", + canDelete: false, + link: "https://example.com/readme.txt", + }, +}; diff --git a/projects/social_platform/src/app/ui/primitives/file-item/file-item.component.ts b/projects/social_platform/src/app/ui/primitives/file-item/file-item.component.ts new file mode 100644 index 000000000..3d439b53b --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/file-item/file-item.component.ts @@ -0,0 +1,100 @@ +/** @format */ + +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + inject, + Input, + input, + output, +} from "@angular/core"; +import { FileTypePipe } from "@ui/pipes/file-type.pipe"; +import { UpperCasePipe } from "@angular/common"; +import { FileService } from "@core/lib/services/file/file.service"; +import { FormatedFileSizePipe } from "@corelib"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { IconComponent } from "../icon/icon.component"; + +/** + * Компонент для отображения информации о файле. + * Показывает тип файла, название, размер и предоставляет возможность скачивания. + * + * Входящие параметры: + * - type: MIME-тип файла (по умолчанию "file") + * - name: название файла + * - size: размер файла в байтах + * - link: ссылка для скачивания файла + * + * Функциональность: + * - Отображение иконки файла по типу + * - Форматированный вывод размера файла + * - Автоматическое скачивание файла по клику + */ +@Component({ + selector: "app-file-item", + templateUrl: "./file-item.component.html", + styleUrl: "./file-item.component.scss", + imports: [IconComponent, FileTypePipe, UpperCasePipe, FormatedFileSizePipe], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class FileItemComponent { + private readonly fileService = inject(FileService); + private readonly destroyRef = inject(DestroyRef); + + canDelete = input(false); + + /** Режим отображения: 'default' — скачивание + удаление через сервис, 'preview' — только просмотр + удаление через Output */ + mode = input<"default" | "preview">("default"); + + /** Событие удаления файла (используется в режиме preview) */ + deleted = output(); + + /** MIME-тип файла */ + type = input("file"); + + /** Название файла */ + @Input() name = ""; + + /** Размер файла в байтах */ + size = input(0); + + /** Ссылка для скачивания */ + @Input() link: string | null = ""; + + /** Функция скачивания файла через создание временной ссылки */ + onDownloadFile(): void { + const link = document.createElement("a"); + + if (!this.link) return; + + link.setAttribute("href", this.link); + link.setAttribute("download", this.name); + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + + /** + * Удаление файла + * В режиме preview — эмитит событие наружу + * В режиме default — удаляет через FileService + */ + onDeleteFile(): void { + if (!this.link) return; + + if (this.mode() === "preview") { + this.deleted.emit(); + return; + } + + this.fileService + .deleteFile(this.link) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.link = ""; + this.name = ""; + }); + } +} diff --git a/projects/social_platform/src/app/ui/primitives/file-upload-item/file-upload-item.component.html b/projects/social_platform/src/app/ui/primitives/file-upload-item/file-upload-item.component.html new file mode 100644 index 000000000..652df83ce --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/file-upload-item/file-upload-item.component.html @@ -0,0 +1,35 @@ + + +
    + @if (!error()) { + +
    + +
    +
    +
    + {{ name() }} +
    +
    + {{ type()! | uppercase }} {{ size() | formatedFileSize }} +
    +
    +
    + } @else { +
    + + {{ error() }} +
    + } + @if (loading() && !error()) { + + } + @if (!loading() && !error()) { +
    + +
    + } + @if (error()) { + + } +
    diff --git a/projects/social_platform/src/app/ui/components/file-upload-item/file-upload-item.component.scss b/projects/social_platform/src/app/ui/primitives/file-upload-item/file-upload-item.component.scss similarity index 100% rename from projects/social_platform/src/app/ui/components/file-upload-item/file-upload-item.component.scss rename to projects/social_platform/src/app/ui/primitives/file-upload-item/file-upload-item.component.scss diff --git a/projects/social_platform/src/app/ui/components/file-upload-item/file-upload-item.component.spec.ts b/projects/social_platform/src/app/ui/primitives/file-upload-item/file-upload-item.component.spec.ts similarity index 77% rename from projects/social_platform/src/app/ui/components/file-upload-item/file-upload-item.component.spec.ts rename to projects/social_platform/src/app/ui/primitives/file-upload-item/file-upload-item.component.spec.ts index bfcc40b79..c1380a766 100644 --- a/projects/social_platform/src/app/ui/components/file-upload-item/file-upload-item.component.spec.ts +++ b/projects/social_platform/src/app/ui/primitives/file-upload-item/file-upload-item.component.spec.ts @@ -1,9 +1,9 @@ /** @format */ import { ComponentFixture, TestBed } from "@angular/core/testing"; - import { FileUploadItemComponent } from "./file-upload-item.component"; -import { RouterTestingModule } from "@angular/router/testing"; +import { provideRouter } from "@angular/router"; +import { CommonModule } from "@angular/common"; describe("FileUploadItemComponent", () => { let component: FileUploadItemComponent; @@ -11,7 +11,8 @@ describe("FileUploadItemComponent", () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [RouterTestingModule, FileUploadItemComponent], + imports: [CommonModule, FileUploadItemComponent], + providers: [provideRouter([])], }).compileComponents(); }); diff --git a/projects/social_platform/src/app/ui/primitives/file-upload-item/file-upload-item.component.stories.ts b/projects/social_platform/src/app/ui/primitives/file-upload-item/file-upload-item.component.stories.ts new file mode 100644 index 000000000..cdac2c973 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/file-upload-item/file-upload-item.component.stories.ts @@ -0,0 +1,97 @@ +/** @format */ + +import { Meta, StoryObj } from "@storybook/angular"; +import { FileUploadItemComponent } from "./file-upload-item.component"; + +const meta: Meta = { + title: "UI/PRIMITIVES/FileUploadItem", + component: FileUploadItemComponent, + tags: ["autodocs"], + argTypes: { + type: { control: "text" }, + name: { control: "text" }, + size: { control: "number" }, + link: { control: "text" }, + loading: { control: "boolean" }, + error: { control: "text" }, + }, + render: args => ({ + props: { + ...args, + onDelete: () => console.log("[FileUploadItem] delete"), + onRetry: () => console.log("[FileUploadItem] retry"), + }, + template: ` +
    + +
    + `, + }), +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + type: "pdf", + name: "Документ.pdf", + size: 2457600, + link: "#", + loading: false, + error: "", + }, +}; + +export const Image: Story = { + args: { + type: "image", + name: "Фото.jpg", + size: 1048576, + link: "#", + loading: false, + error: "", + }, +}; + +export const Doc: Story = { + args: { + type: "doc", + name: "Отчёт.docx", + size: 524288, + link: "#", + loading: false, + error: "", + }, +}; + +export const Loading: Story = { + args: { + type: "pdf", + name: "Загрузка.pdf", + size: 0, + link: "", + loading: true, + error: "", + }, +}; + +export const Error: Story = { + args: { + type: "file", + name: "Ошибка.txt", + size: 0, + link: "", + loading: false, + error: "Не удалось загрузить файл", + }, +}; diff --git a/projects/social_platform/src/app/ui/primitives/file-upload-item/file-upload-item.component.ts b/projects/social_platform/src/app/ui/primitives/file-upload-item/file-upload-item.component.ts new file mode 100644 index 000000000..2a74c2dca --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/file-upload-item/file-upload-item.component.ts @@ -0,0 +1,64 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, input, output } from "@angular/core"; +import { FileTypePipe } from "@ui/pipes/file-type.pipe"; +import { LoaderComponent } from "../loader/loader.component"; +import { NgIf, UpperCasePipe } from "@angular/common"; +import { FormatedFileSizePipe } from "@corelib"; +import { IconComponent } from "../icon/icon.component"; + +/** + * Компонент для отображения элемента загружаемого файла. + * Показывает информацию о файле, состояние загрузки и предоставляет действия для управления. + * + * Входящие параметры: + * - type: MIME-тип файла (по умолчанию "file") + * - name: имя файла + * - size: размер файла в байтах + * - link: ссылка на файл + * - loading: состояние загрузки файла + * - error: текст ошибки загрузки + * + * События: + * - delete: событие удаления файла + * - retry: событие повторной попытки загрузки + */ +@Component({ + selector: "app-file-upload-item", + templateUrl: "./file-upload-item.component.html", + styleUrl: "./file-upload-item.component.scss", + imports: [ + IconComponent, + LoaderComponent, + NgIf, + UpperCasePipe, + FileTypePipe, + FormatedFileSizePipe, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class FileUploadItemComponent { + /** MIME-тип файла */ + type = input("file"); + + /** Имя файла */ + name = input(""); + + /** Размер файла в байтах */ + size = input(0); + + /** Ссылка на файл */ + link = input(""); + + /** Состояние загрузки */ + loading = input(false); + + /** Текст ошибки */ + error = input(""); + + /** Событие удаления файла */ + delete = output(); + + /** Событие повторной попытки загрузки */ + retry = output(); +} diff --git a/projects/social_platform/src/app/ui/components/icon/icon.component.html b/projects/social_platform/src/app/ui/primitives/icon/icon.component.html similarity index 90% rename from projects/social_platform/src/app/ui/components/icon/icon.component.html rename to projects/social_platform/src/app/ui/primitives/icon/icon.component.html index a9be57890..d2c1bc46f 100644 --- a/projects/social_platform/src/app/ui/components/icon/icon.component.html +++ b/projects/social_platform/src/app/ui/primitives/icon/icon.component.html @@ -6,5 +6,5 @@ [attr.height]="square || height" [attr.viewBox]="viewBox" > - + diff --git a/projects/social_platform/src/app/ui/components/icon/icon.component.scss b/projects/social_platform/src/app/ui/primitives/icon/icon.component.scss similarity index 100% rename from projects/social_platform/src/app/ui/components/icon/icon.component.scss rename to projects/social_platform/src/app/ui/primitives/icon/icon.component.scss diff --git a/projects/social_platform/src/app/ui/primitives/icon/icon.component.spec.ts b/projects/social_platform/src/app/ui/primitives/icon/icon.component.spec.ts new file mode 100644 index 000000000..63accade7 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/icon/icon.component.spec.ts @@ -0,0 +1,79 @@ +/** @format */ + +import { Component } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { IconComponent } from "@ui/primitives"; + +// IconComponent — атрибутная директива-компонент ([appIcon]); componentRef.setInput +// к ней не применяется, поэтому тестируем через host с шаблонными биндингами. +@Component({ + standalone: true, + imports: [IconComponent], + template: ``, +}) +class IconHostComponent { + icon = "check"; + appSquare = ""; + appWidth = ""; + appHeight = ""; +} + +describe("IconComponent", () => { + let fixture: ComponentFixture; + let host: IconHostComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [IconHostComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(IconHostComponent); + host = fixture.componentInstance; + }); + + it("should create the icon component", () => { + fixture.detectChanges(); + expect(fixture.debugElement.query(By.directive(IconComponent))).toBeTruthy(); + }); + + it("should render the correct icon", () => { + host.icon = "check"; + fixture.detectChanges(); + const useElement = fixture.debugElement.query(By.css("use")).nativeElement; + expect(useElement.getAttribute("xlink:href")).toBe( + "assets/icons/symbol/svg/sprite.css.svg#check", + ); + }); + + it("should set the width and height attributes if square is not set", () => { + host.appWidth = "24"; + host.appHeight = "24"; + fixture.detectChanges(); + const svgElement = fixture.debugElement.query(By.css("svg")).nativeElement; + expect(svgElement.getAttribute("width")).toBe("24"); + expect(svgElement.getAttribute("height")).toBe("24"); + }); + + it("should set the viewBox attribute if square is set", () => { + host.appSquare = "24"; + fixture.detectChanges(); + const svgElement = fixture.debugElement.query(By.css("svg")).nativeElement; + expect(svgElement.getAttribute("viewBox")).toBe("0 0 24 24"); + }); + + it("should update the viewBox attribute when square, width or height is set", () => { + host.appSquare = "24"; + host.appWidth = "32"; + host.appHeight = "32"; + fixture.detectChanges(); + const svgElement = fixture.debugElement.query(By.css("svg")).nativeElement; + expect(svgElement.getAttribute("viewBox")).toBe("0 0 24 24"); + }); +}); diff --git a/projects/social_platform/src/app/ui/primitives/icon/icon.component.stories.ts b/projects/social_platform/src/app/ui/primitives/icon/icon.component.stories.ts new file mode 100644 index 000000000..9aac0dee0 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/icon/icon.component.stories.ts @@ -0,0 +1,287 @@ +/** @format */ + +import { Meta, StoryObj } from "@storybook/angular"; +import { IconComponent } from "./icon.component"; + +const meta: Meta = { + title: "UI/PRIMITIVES/Icon", + component: IconComponent, + tags: ["autodocs"], + argTypes: { + icon: { + control: "select", + options: [ + "academic-hat", + "accent-error", + "achievements", + "add-person", + "additional", + "anchor", + "arrow-down", + "arrow-no-body", + "arrow-wide", + "arrowright", + "attach", + "basket", + "bea", + "beb", + "bell", + "book", + "box", + "calendar", + "chats", + "check", + "circle-check", + "command", + "comment", + "contacts", + "cross", + "deadline", + "dots", + "download", + "dsa", + "dsb", + "dsc", + "edit-pen", + "edit-pen-old", + "efficiency", + "empty-chat", + "empty-mail", + "error", + "eye", + "eye-off", + "favourites", + "feed", + "file", + "file-success", + "file_arch", + "file_csv", + "file_doc", + "file_file", + "file_image", + "file_jpeg", + "file_mp3", + "file_mp4", + "file_pdf", + "file_png", + "file_ppt", + "file_svg", + "file_txt", + "file_video", + "file_webp", + "file_xls", + "file_xlsx", + "filter", + "folder", + "folders", + "geo-point", + "goal", + "graph", + "hand", + "hashtag", + "hint", + "in-search", + "inline-check", + "key", + "label", + "left-arrow", + "like", + "link", + "lock", + "logout", + "logout2", + "logout3", + "mail", + "main", + "medal", + "medal-outlined", + "menu-burger", + "menu-cross", + "message", + "message-inline", + "pen", + "people", + "people-bold", + "people-filled", + "person", + "phone", + "pin", + "plus", + "procollab", + "program", + "projects", + "reload", + "reply", + "rocket", + "sad-smile", + "search", + "search-sidebar", + "send", + "settings", + "share", + "slide", + "smile", + "spinner", + "squiz", + "star", + "straight-face", + "subscribe-badge", + "suitcase", + "task", + "team", + "telegram", + "trajectories", + "triangle", + "two-people", + "unsubscribe-badge", + "upload", + "vacancies", + "views", + "vk", + "work", + "world-wide", + ], + }, + appSquare: { control: "text" }, + appWidth: { control: "text" }, + appHeight: { control: "text" }, + appViewBox: { control: "text" }, + }, + render: args => ({ + props: args, + template: ``, + }), +}; +export default meta; + +type Story = StoryObj; + +export const AcademicHat: Story = { args: { icon: "academic-hat", appSquare: "24" } }; +export const AccentError: Story = { args: { icon: "accent-error", appSquare: "24" } }; +export const Achievements: Story = { args: { icon: "achievements", appSquare: "24" } }; +export const AddPerson: Story = { args: { icon: "add-person", appSquare: "24" } }; +export const Additional: Story = { args: { icon: "additional", appSquare: "24" } }; +export const Anchor: Story = { args: { icon: "anchor", appSquare: "24" } }; +export const ArrowDown: Story = { args: { icon: "arrow-down", appSquare: "24" } }; +export const ArrowNoBody: Story = { args: { icon: "arrow-no-body", appSquare: "24" } }; +export const ArrowWide: Story = { args: { icon: "arrow-wide", appSquare: "24" } }; +export const Arrowright: Story = { args: { icon: "arrowright", appSquare: "24" } }; +export const Attach: Story = { args: { icon: "attach", appSquare: "24" } }; +export const Basket: Story = { args: { icon: "basket", appSquare: "24" } }; +export const Bea: Story = { args: { icon: "bea", appSquare: "24" } }; +export const Beb: Story = { args: { icon: "beb", appSquare: "24" } }; +export const Bell: Story = { args: { icon: "bell", appSquare: "24" } }; +export const Book: Story = { args: { icon: "book", appSquare: "24" } }; +export const Box: Story = { args: { icon: "box", appSquare: "24" } }; +export const Calendar: Story = { args: { icon: "calendar", appSquare: "24" } }; +export const Chats: Story = { args: { icon: "chats", appSquare: "24" } }; +export const Check: Story = { args: { icon: "check", appSquare: "24" } }; +export const CircleCheck: Story = { args: { icon: "circle-check", appSquare: "24" } }; +export const Command: Story = { args: { icon: "command", appSquare: "24" } }; +export const Comment: Story = { args: { icon: "comment", appSquare: "24" } }; +export const Contacts: Story = { args: { icon: "contacts", appSquare: "24" } }; +export const Cross: Story = { args: { icon: "cross", appSquare: "24" } }; +export const Deadline: Story = { args: { icon: "deadline", appSquare: "24" } }; +export const Dots: Story = { args: { icon: "dots", appSquare: "24" } }; +export const Download: Story = { args: { icon: "download", appSquare: "24" } }; +export const Dsa: Story = { args: { icon: "dsa", appSquare: "24" } }; +export const Dsb: Story = { args: { icon: "dsb", appSquare: "24" } }; +export const Dsc: Story = { args: { icon: "dsc", appSquare: "24" } }; +export const EditPen: Story = { args: { icon: "edit-pen", appSquare: "24" } }; +export const EditPenOld: Story = { args: { icon: "edit-pen-old", appSquare: "24" } }; +export const Efficiency: Story = { args: { icon: "efficiency", appSquare: "24" } }; +export const EmptyChat: Story = { args: { icon: "empty-chat", appSquare: "24" } }; +export const EmptyMail: Story = { args: { icon: "empty-mail", appSquare: "24" } }; +export const Error: Story = { args: { icon: "error", appSquare: "24" } }; +export const Eye: Story = { args: { icon: "eye", appSquare: "24" } }; +export const EyeOff: Story = { args: { icon: "eye-off", appSquare: "24" } }; +export const Favourites: Story = { args: { icon: "favourites", appSquare: "24" } }; +export const Feed: Story = { args: { icon: "feed", appSquare: "24" } }; +export const File: Story = { args: { icon: "file", appSquare: "24" } }; +export const FileSuccess: Story = { args: { icon: "file-success", appSquare: "24" } }; +export const FileArch: Story = { args: { icon: "file_arch", appSquare: "24" } }; +export const FileCsv: Story = { args: { icon: "file_csv", appSquare: "24" } }; +export const FileDoc: Story = { args: { icon: "file_doc", appSquare: "24" } }; +export const FileFile: Story = { args: { icon: "file_file", appSquare: "24" } }; +export const FileImage: Story = { args: { icon: "file_image", appSquare: "24" } }; +export const FileJpeg: Story = { args: { icon: "file_jpeg", appSquare: "24" } }; +export const FileMp3: Story = { args: { icon: "file_mp3", appSquare: "24" } }; +export const FileMp4: Story = { args: { icon: "file_mp4", appSquare: "24" } }; +export const FilePdf: Story = { args: { icon: "file_pdf", appSquare: "24" } }; +export const FilePng: Story = { args: { icon: "file_png", appSquare: "24" } }; +export const FilePpt: Story = { args: { icon: "file_ppt", appSquare: "24" } }; +export const FileSvg: Story = { args: { icon: "file_svg", appSquare: "24" } }; +export const FileTxt: Story = { args: { icon: "file_txt", appSquare: "24" } }; +export const FileVideo: Story = { args: { icon: "file_video", appSquare: "24" } }; +export const FileWebp: Story = { args: { icon: "file_webp", appSquare: "24" } }; +export const FileXls: Story = { args: { icon: "file_xls", appSquare: "24" } }; +export const FileXlsx: Story = { args: { icon: "file_xlsx", appSquare: "24" } }; +export const Filter: Story = { args: { icon: "filter", appSquare: "24" } }; +export const Folder: Story = { args: { icon: "folder", appSquare: "24" } }; +export const Folders: Story = { args: { icon: "folders", appSquare: "24" } }; +export const GeoPoint: Story = { args: { icon: "geo-point", appSquare: "24" } }; +export const Goal: Story = { args: { icon: "goal", appSquare: "24" } }; +export const Graph: Story = { args: { icon: "graph", appSquare: "24" } }; +export const Hand: Story = { args: { icon: "hand", appSquare: "24" } }; +export const Hashtag: Story = { args: { icon: "hashtag", appSquare: "24" } }; +export const Hint: Story = { args: { icon: "hint", appSquare: "24" } }; +export const InSearch: Story = { args: { icon: "in-search", appSquare: "24" } }; +export const InlineCheck: Story = { args: { icon: "inline-check", appSquare: "24" } }; +export const Key: Story = { args: { icon: "key", appSquare: "24" } }; +export const Label: Story = { args: { icon: "label", appSquare: "24" } }; +export const LeftArrow: Story = { args: { icon: "left-arrow", appSquare: "24" } }; +export const Like: Story = { args: { icon: "like", appSquare: "24" } }; +export const Link: Story = { args: { icon: "link", appSquare: "24" } }; +export const Lock: Story = { args: { icon: "lock", appSquare: "24" } }; +export const Logout: Story = { args: { icon: "logout", appSquare: "24" } }; +export const Logout2: Story = { args: { icon: "logout2", appSquare: "24" } }; +export const Logout3: Story = { args: { icon: "logout3", appSquare: "24" } }; +export const Mail: Story = { args: { icon: "mail", appSquare: "24" } }; +export const Main: Story = { args: { icon: "main", appSquare: "24" } }; +export const Medal: Story = { args: { icon: "medal", appSquare: "24" } }; +export const MedalOutlined: Story = { args: { icon: "medal-outlined", appSquare: "24" } }; +export const MenuBurger: Story = { args: { icon: "menu-burger", appSquare: "24" } }; +export const MenuCross: Story = { args: { icon: "menu-cross", appSquare: "24" } }; +export const Message: Story = { args: { icon: "message", appSquare: "24" } }; +export const MessageInline: Story = { args: { icon: "message-inline", appSquare: "24" } }; +export const Pen: Story = { args: { icon: "pen", appSquare: "24" } }; +export const People: Story = { args: { icon: "people", appSquare: "24" } }; +export const PeopleBold: Story = { args: { icon: "people-bold", appSquare: "24" } }; +export const PeopleFilled: Story = { args: { icon: "people-filled", appSquare: "24" } }; +export const Person: Story = { args: { icon: "person", appSquare: "24" } }; +export const Phone: Story = { args: { icon: "phone", appSquare: "24" } }; +export const Pin: Story = { args: { icon: "pin", appSquare: "24" } }; +export const Plus: Story = { args: { icon: "plus", appSquare: "24" } }; +export const Procollab: Story = { args: { icon: "procollab", appSquare: "24" } }; +export const Program: Story = { args: { icon: "program", appSquare: "24" } }; +export const Projects: Story = { args: { icon: "projects", appSquare: "24" } }; +export const Reload: Story = { args: { icon: "reload", appSquare: "24" } }; +export const Reply: Story = { args: { icon: "reply", appSquare: "24" } }; +export const Rocket: Story = { args: { icon: "rocket", appSquare: "24" } }; +export const SadSmile: Story = { args: { icon: "sad-smile", appSquare: "24" } }; +export const Search: Story = { args: { icon: "search", appSquare: "24" } }; +export const SearchSidebar: Story = { args: { icon: "search-sidebar", appSquare: "24" } }; +export const Send: Story = { args: { icon: "send", appSquare: "24" } }; +export const Settings: Story = { args: { icon: "settings", appSquare: "24" } }; +export const Share: Story = { args: { icon: "share", appSquare: "24" } }; +export const Slide: Story = { args: { icon: "slide", appSquare: "24" } }; +export const Smile: Story = { args: { icon: "smile", appSquare: "24" } }; +export const Spinner: Story = { args: { icon: "spinner", appSquare: "24" } }; +export const Squiz: Story = { args: { icon: "squiz", appSquare: "24" } }; +export const Star: Story = { args: { icon: "star", appSquare: "24" } }; +export const StraightFace: Story = { args: { icon: "straight-face", appSquare: "24" } }; +export const SubscribeBadge: Story = { args: { icon: "subscribe-badge", appSquare: "24" } }; +export const Suitcase: Story = { args: { icon: "suitcase", appSquare: "24" } }; +export const Task: Story = { args: { icon: "task", appSquare: "24" } }; +export const Team: Story = { args: { icon: "team", appSquare: "24" } }; +export const Telegram: Story = { args: { icon: "telegram", appSquare: "24" } }; +export const Trajectories: Story = { args: { icon: "trajectories", appSquare: "24" } }; +export const Triangle: Story = { args: { icon: "triangle", appSquare: "24" } }; +export const TwoPeople: Story = { args: { icon: "two-people", appSquare: "24" } }; +export const UnsubscribeBadge: Story = { args: { icon: "unsubscribe-badge", appSquare: "24" } }; +export const Upload: Story = { args: { icon: "upload", appSquare: "24" } }; +export const Vacancies: Story = { args: { icon: "vacancies", appSquare: "24" } }; +export const Views: Story = { args: { icon: "views", appSquare: "24" } }; +export const Vk: Story = { args: { icon: "vk", appSquare: "24" } }; +export const Work: Story = { args: { icon: "work", appSquare: "24" } }; +export const WorldWide: Story = { args: { icon: "world-wide", appSquare: "24" } }; diff --git a/projects/social_platform/src/app/ui/components/icon/icon.component.ts b/projects/social_platform/src/app/ui/primitives/icon/icon.component.ts similarity index 93% rename from projects/social_platform/src/app/ui/components/icon/icon.component.ts rename to projects/social_platform/src/app/ui/primitives/icon/icon.component.ts index aac947c28..ca7943e11 100644 --- a/projects/social_platform/src/app/ui/components/icon/icon.component.ts +++ b/projects/social_platform/src/app/ui/primitives/icon/icon.component.ts @@ -1,6 +1,6 @@ /** @format */ -import { Component, Input, OnInit } from "@angular/core"; +import { ChangeDetectionStrategy, Component, input, Input, OnInit } from "@angular/core"; /** * Компонент для отображения SVG иконок с настраиваемыми параметрами. @@ -25,6 +25,7 @@ import { Component, Input, OnInit } from "@angular/core"; templateUrl: "./icon.component.html", styleUrl: "./icon.component.scss", standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, }) export class IconComponent implements OnInit { /** Размер квадратной иконки */ @@ -82,7 +83,7 @@ export class IconComponent implements OnInit { } /** Имя иконки для отображения */ - @Input({ required: true }) icon!: string; + readonly icon = input.required(); square?: string; viewBox?: string; diff --git a/projects/social_platform/src/app/ui/primitives/img-card/img-card.component.html b/projects/social_platform/src/app/ui/primitives/img-card/img-card.component.html new file mode 100644 index 000000000..20a4a6e27 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/img-card/img-card.component.html @@ -0,0 +1,59 @@ + + +
    + @if (src() && !loading() && !error()) { + user-generated content + } + @if (error()) { +
    +
    + + Ошибка загрузки +
    + +
    + } + @if (loading()) { + + + + + + + } +
    + +
    +
    diff --git a/projects/social_platform/src/app/office/shared/img-card/img-card.component.scss b/projects/social_platform/src/app/ui/primitives/img-card/img-card.component.scss similarity index 100% rename from projects/social_platform/src/app/office/shared/img-card/img-card.component.scss rename to projects/social_platform/src/app/ui/primitives/img-card/img-card.component.scss diff --git a/projects/social_platform/src/app/office/shared/img-card/img-card.component.spec.ts b/projects/social_platform/src/app/ui/primitives/img-card/img-card.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/office/shared/img-card/img-card.component.spec.ts rename to projects/social_platform/src/app/ui/primitives/img-card/img-card.component.spec.ts diff --git a/projects/social_platform/src/app/ui/primitives/img-card/img-card.component.stories.ts b/projects/social_platform/src/app/ui/primitives/img-card/img-card.component.stories.ts new file mode 100644 index 000000000..98669a2a9 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/img-card/img-card.component.stories.ts @@ -0,0 +1,26 @@ +/** @format */ + +import { Meta, StoryObj } from "@storybook/angular"; +import { ImgCardComponent } from "./img-card.component"; + +const meta: Meta = { + title: "UI/PRIMITIVES/ImgCard", + component: ImgCardComponent, + tags: ["autodocs"], + argTypes: { + src: { control: "text" }, + loading: { control: "boolean" }, + error: { control: "boolean" }, + }, +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { src: "https://picsum.photos/seed/procollab/240/160", loading: false, error: false }, +}; + +export const Loading: Story = { args: { src: "", loading: true, error: false } }; + +export const ErrorState: Story = { args: { src: "", loading: false, error: true } }; diff --git a/projects/social_platform/src/app/ui/primitives/img-card/img-card.component.ts b/projects/social_platform/src/app/ui/primitives/img-card/img-card.component.ts new file mode 100644 index 000000000..fb3a5c54f --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/img-card/img-card.component.ts @@ -0,0 +1,37 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, input, output } from "@angular/core"; +import { IconComponent } from "../icon/icon.component"; + +/** + * Компонент карточки изображения с состояниями загрузки и ошибки + * + * Функциональность: + * - Отображает изображение по переданному URL + * - Показывает состояния загрузки и ошибки + * - Предоставляет кнопки для отмены и повторной попытки загрузки + * + * Входные параметры: + * input src - URL изображения (по умолчанию пустая строка) + * input error - флаг состояния ошибки (по умолчанию false) + * input loading - флаг состояния загрузки (по умолчанию false) + * + * Выходные события: + * output cancel - событие отмены загрузки/отображения изображения + * output retry - событие повторной попытки загрузки изображения + */ +@Component({ + selector: "app-img-card", + templateUrl: "./img-card.component.html", + styleUrl: "./img-card.component.scss", + imports: [IconComponent], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ImgCardComponent { + src = input(""); + error = input(false); + loading = input(false); + + cancel = output(); + retry = output(); +} diff --git a/projects/social_platform/src/app/ui/primitives/index.ts b/projects/social_platform/src/app/ui/primitives/index.ts new file mode 100644 index 000000000..c474d9200 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/index.ts @@ -0,0 +1,24 @@ +/** @format */ + +export * from "./autocomplete-input/autocomplete-input.component"; +export * from "./avatar/avatar.component"; +export * from "./avatar-control/avatar-control.component"; +export * from "./bar/bar.component"; +export * from "./button/button.component"; +export * from "./checkbox/checkbox.component"; +export * from "./dropdown/dropdown.component"; +export * from "./file-item/file-item.component"; +export * from "./file-upload-item/file-upload-item.component"; +export * from "./icon/icon.component"; +export * from "./img-card/img-card.component"; +export * from "./input/input.component"; +export * from "./loader/loader.component"; +export * from "./modal/modal.component"; +export * from "./search/search.component"; +export * from "./select/select.component"; +export * from "./soon-card/soon-card.component"; +export * from "./switch/switch.component"; +export * from "./tag/tag.component"; +export * from "./textarea/textarea.component"; +export * from "./tooltip/tooltip.component"; +export * from "./upload-file/upload-file.component"; diff --git a/projects/social_platform/src/app/ui/primitives/input/input.component.html b/projects/social_platform/src/app/ui/primitives/input/input.component.html new file mode 100644 index 000000000..7a0fc2f35 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/input/input.component.html @@ -0,0 +1,104 @@ + + +
    +
    + +
    + + @if (type() === "radio") { + + } @else if (type() === "date") { + + + } @else { + + @if (maxLength()) { +
    +

    + {{ + value.length + }} + / {{ maxLength() }} +

    +
    + } + } + @if (showErrorIcon) { + + } @else if (haveHint() && tooltipText()) { +
    + +
    + } + +
    + +
    +
    diff --git a/projects/social_platform/src/app/ui/components/input/input.component.scss b/projects/social_platform/src/app/ui/primitives/input/input.component.scss similarity index 100% rename from projects/social_platform/src/app/ui/components/input/input.component.scss rename to projects/social_platform/src/app/ui/primitives/input/input.component.scss diff --git a/projects/social_platform/src/app/ui/primitives/input/input.component.spec.ts b/projects/social_platform/src/app/ui/primitives/input/input.component.spec.ts new file mode 100644 index 000000000..78c7a94b6 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/input/input.component.spec.ts @@ -0,0 +1,119 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { FormsModule } from "@angular/forms"; +import { InputComponent } from "@ui/primitives"; +import { provideNgxMask } from "ngx-mask"; + +describe("InputComponent", () => { + let component: InputComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [FormsModule, InputComponent], + providers: [provideNgxMask()], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(InputComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create the component", () => { + expect(component).toBeTruthy(); + }); + + it("should set the value of the input element", () => { + const fixture = TestBed.createComponent(InputComponent); + fixture.componentRef.setInput("appValue", "test"); + fixture.detectChanges(); + const inputEl = fixture.nativeElement.querySelector("input"); + expect(inputEl.value).toBe("test"); + }); + + it("should set the input type", () => { + const input = fixture.nativeElement.querySelector("input"); + expect(input.type).toBe("text"); + fixture.componentRef.setInput("type", "email"); + fixture.detectChanges(); + expect(input.type).toBe("email"); + }); + + it("should set the input placeholder", () => { + const input = fixture.nativeElement.querySelector("input"); + expect(input.placeholder).toBe(""); + const testPlaceholder = "test placeholder"; + fixture.componentRef.setInput("placeholder", testPlaceholder); + fixture.detectChanges(); + expect(input.placeholder).toBe(testPlaceholder); + }); + + it("should set the error class when error input is true", () => { + const field = fixture.nativeElement.querySelector(".field"); + expect(field.classList).not.toContain("field--error"); + fixture.componentRef.setInput("error", true); + fixture.detectChanges(); + expect(field.classList).toContain("field--error"); + }); + + it("should emit the input value on input", () => { + const emitSpy = vi.fn(); + component.appValue.subscribe(emitSpy); + const testValue = "test"; + const input = fixture.nativeElement.querySelector("input"); + input.value = testValue; + input.dispatchEvent(new Event("input")); + expect(emitSpy).toHaveBeenCalledWith(testValue); + }); + + it("should emit enter event on enter keydown", () => { + vi.spyOn(component.enter, "emit"); + const input = fixture.nativeElement.querySelector("input"); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })); + expect(component.enter.emit).toHaveBeenCalled(); + }); + + it("should call onChange function on input", () => { + vi.spyOn(component, "onChange"); + const testValue = "test"; + const input = fixture.nativeElement.querySelector("input"); + input.value = testValue; + input.dispatchEvent(new Event("input")); + expect(component.onChange).toHaveBeenCalledWith(testValue); + }); + + it("should call onTouch function on blur", () => { + vi.spyOn(component, "onTouch"); + const input = fixture.nativeElement.querySelector("input"); + input.dispatchEvent(new Event("blur")); + expect(component.onTouch).toHaveBeenCalled(); + }); + + it("should set the input type via setInput", () => { + const input = fixture.nativeElement.querySelector("input"); + expect(input.type).toBe("text"); + fixture.componentRef.setInput("type", "email"); + fixture.detectChanges(); + expect(input.type).toBe("email"); + }); + + it("should set the input placeholder via setInput", () => { + const input = fixture.nativeElement.querySelector("input"); + expect(input.placeholder).toBe(""); + const testPlaceholder = "test placeholder"; + fixture.componentRef.setInput("placeholder", testPlaceholder); + fixture.detectChanges(); + expect(input.placeholder).toBe(testPlaceholder); + }); + + it("should set the error class when error input is true via setInput", () => { + const field = fixture.nativeElement.querySelector(".field"); + expect(field.classList).not.toContain("field--error"); + fixture.componentRef.setInput("error", true); + fixture.detectChanges(); + expect(field.classList).toContain("field--error"); + }); +}); diff --git a/projects/social_platform/src/app/ui/primitives/input/input.component.stories.ts b/projects/social_platform/src/app/ui/primitives/input/input.component.stories.ts new file mode 100644 index 000000000..376d2d060 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/input/input.component.stories.ts @@ -0,0 +1,95 @@ +/** @format */ + +import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; +import { ReactiveFormsModule, FormControl } from "@angular/forms"; +import { InputComponent } from "./input.component"; + +const meta: Meta = { + title: "UI/PRIMITIVES/Input", + component: InputComponent, + tags: ["autodocs"], + decorators: [ + moduleMetadata({ + imports: [ReactiveFormsModule], + }), + ], + argTypes: { + placeholder: { control: "text" }, + type: { control: "select", options: ["text", "password", "email", "tel", "date", "radio"] }, + size: { control: "inline-radio", options: ["small", "big"] }, + hasBorder: { control: "boolean" }, + error: { control: "boolean" }, + disabled: { control: "boolean" }, + maxLength: { control: "number" }, + mask: { control: "text" }, + }, + render: args => ({ + props: { + ...args, + control: new FormControl(""), + }, + template: ``, + }), +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { placeholder: "Введите текст", type: "text", size: "small" }, +}; + +export const Big: Story = { + args: { placeholder: "Большое поле", type: "text", size: "big" }, +}; + +export const Password: Story = { + args: { placeholder: "Пароль", type: "password", size: "small" }, +}; + +export const Email: Story = { + args: { placeholder: "email@example.com", type: "email", size: "small" }, +}; + +export const WithError: Story = { + args: { placeholder: "Ошибка", type: "text", size: "small", error: true }, +}; + +export const WithMaxLength: Story = { + args: { placeholder: "Макс. 20 символов", type: "text", size: "small", maxLength: 20 }, +}; + +export const WithMask: Story = { + args: { + placeholder: "+7 (000) 000-00-00", + type: "tel", + size: "small", + mask: "+7 (000) 000-00-00", + }, +}; + +export const Disabled: Story = { + args: { placeholder: "Заблокировано", type: "text", size: "small" }, + decorators: [moduleMetadata({ imports: [ReactiveFormsModule] })], + render: args => ({ + props: { + ...args, + control: new FormControl({ value: "", disabled: true }), + }, + template: ``, + }), +}; diff --git a/projects/social_platform/src/app/ui/primitives/input/input.component.ts b/projects/social_platform/src/app/ui/primitives/input/input.component.ts new file mode 100644 index 000000000..0ddddec90 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/input/input.component.ts @@ -0,0 +1,195 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + effect, + ElementRef, + forwardRef, + input, + model, + output, + viewChild, +} from "@angular/core"; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; +import { TooltipComponent } from "@ui/primitives/tooltip/tooltip.component"; +import { NgxMaskDirective } from "ngx-mask"; +import { MatDatepickerModule } from "@angular/material/datepicker"; +import { MatInputModule } from "@angular/material/input"; +import { MatNativeDateModule } from "@angular/material/core"; +import { MatFormFieldModule } from "@angular/material/form-field"; +import { IconComponent } from "../icon/icon.component"; + +/** Примитив: текстовое поле ввода с ControlValueAccessor. */ +@Component({ + selector: "app-input", + templateUrl: "./input.component.html", + styleUrl: "./input.component.scss", + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => InputComponent), + multi: true, + }, + ], + imports: [ + CommonModule, + NgxMaskDirective, + IconComponent, + TooltipComponent, + MatDatepickerModule, + MatInputModule, + MatNativeDateModule, + MatFormFieldModule, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class InputComponent implements ControlValueAccessor { + constructor(private readonly cdr: ChangeDetectorRef) { + effect(() => { + this.value = this.appValue(); + this.cdr.markForCheck(); + }); + } + + placeholder = input(""); + type = input<"text" | "password" | "email" | "tel" | "date" | "radio">("text"); + size = input<"small" | "big">("small"); + hasBorder = input(true); + haveHint = input(false); + tooltipText = input(); + tooltipPosition = input<"left" | "right">("right"); + tooltipWidth = input(250); + error = input(false); + mask = input(""); + name = input(""); + checked = input(false); + maxLength = input(); + + // Two-way binding через model() + appValue = model(""); + + nativeInput = viewChild>("nativeInput"); + + isTooltipVisible = false; + isLengthOverflow = false; + + enter = output(); + change = output(); + + /** Обработчик для radвариант io */ + onRadioChange(event: Event): void { + if (this.type() === "radio") { + const target = event.target as HTMLInputElement; + this.value = target.value; + this.onChange(this.value); + this.appValue.set(this.value); + this.change.emit(event); + this.onTouch(); + } + } + + /** Обработчик изменения даты в datepicker */ + onDateChange(event: any): void { + if (this.type() === "date" && event.value) { + const date = event.value as Date; + + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const formattedDate = `${year}-${month}-${day}`; + + this.value = formattedDate; + this.onChange(formattedDate); + this.appValue.set(formattedDate); + this.onTouch(); + } + } + + showTooltip(): void { + this.isTooltipVisible = true; + } + + hideTooltip(): void { + this.isTooltipVisible = false; + } + + onInput(event: Event): void { + const nextValue = (event.target as HTMLInputElement).value ?? ""; + + this.isLengthOverflow = !!this.maxLength() && nextValue.length > this.maxLength()!; + + this.value = nextValue; + this.onChange(nextValue); + this.appValue.set(nextValue); + } + + onBlur(): void { + this.onTouch(); + } + + value = ""; + + // Геттер для преобразования строковой даты в объект Date для datepicker + get dateValue(): Date | null { + if (!this.value || this.type() !== "date") return null; + + const parts = this.value.split("-"); + if (parts.length === 3) { + const year = parseInt(parts[0], 10); + const month = parseInt(parts[1], 10) - 1; + const day = parseInt(parts[2], 10); + return new Date(year, month, day); + } + + return null; + } + + get showErrorIcon(): boolean { + if (this.error() && !this.maxLength()) { + return true; + } + + return false; + } + + writeValue(value: string | null): void { + setTimeout(() => { + this.value = value ?? ""; + this.appValue.set(this.value); + this.cdr.markForCheck(); + }); + } + + onChange: (value: string) => void = () => {}; + + registerOnChange(fn: (v: string) => void): void { + this.onChange = fn; + } + + onTouch: () => void = () => {}; + + registerOnTouched(fn: () => void): void { + this.onTouch = fn; + } + + disabled = false; + + setDisabledState(isDisabled: boolean) { + this.disabled = isDisabled; + this.cdr.markForCheck(); + } + + focusInput(): void { + if (document.activeElement !== this.nativeInput()?.nativeElement) { + this.nativeInput()?.nativeElement.focus(); + } + } + + onEnter(event: Event) { + event.preventDefault(); + this.enter.emit(); + } +} diff --git a/projects/social_platform/src/app/ui/primitives/loader/loader.component.html b/projects/social_platform/src/app/ui/primitives/loader/loader.component.html new file mode 100644 index 000000000..f2205ee41 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/loader/loader.component.html @@ -0,0 +1,32 @@ + +
    + @if (type() === "wave") { +
    +
    +
    +
    +
    +
    + } @else if (type() === "circle") { +
    +
    +
    +
    +
    +
    + } +
    diff --git a/projects/social_platform/src/app/ui/components/loader/loader.component.scss b/projects/social_platform/src/app/ui/primitives/loader/loader.component.scss similarity index 100% rename from projects/social_platform/src/app/ui/components/loader/loader.component.scss rename to projects/social_platform/src/app/ui/primitives/loader/loader.component.scss diff --git a/projects/social_platform/src/app/ui/primitives/loader/loader.component.spec.ts b/projects/social_platform/src/app/ui/primitives/loader/loader.component.spec.ts new file mode 100644 index 000000000..103e45abe --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/loader/loader.component.spec.ts @@ -0,0 +1,53 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { LoaderComponent } from "./loader.component"; + +describe("LoaderComponent", () => { + let component: LoaderComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LoaderComponent], + }).compileComponents(); + }); + + it("should create", () => { + fixture = TestBed.createComponent(LoaderComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it("should apply the speed input", () => { + fixture = TestBed.createComponent(LoaderComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput("type", "wave"); + fixture.componentRef.setInput("speed", "2s"); + fixture.detectChanges(); + const dotWave = fixture.debugElement.query(By.css(".dot-wave")).nativeElement; + expect(dotWave.style.getPropertyValue("--speed")).toBe("2s"); + }); + + it("should apply the size input", () => { + fixture = TestBed.createComponent(LoaderComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput("type", "wave"); + fixture.componentRef.setInput("size", "20px"); + fixture.detectChanges(); + const dotWave = fixture.debugElement.query(By.css(".dot-wave")).nativeElement; + expect(dotWave.style.getPropertyValue("--size")).toBe("20px"); + }); + + it("should apply the color input", () => { + fixture = TestBed.createComponent(LoaderComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput("type", "wave"); + fixture.componentRef.setInput("color", "red"); + fixture.detectChanges(); + const dotWave = fixture.debugElement.query(By.css(".dot-wave")).nativeElement; + expect(dotWave.style.getPropertyValue("--color")).toBe("var(--red)"); + }); +}); diff --git a/projects/social_platform/src/app/ui/primitives/loader/loader.component.stories.ts b/projects/social_platform/src/app/ui/primitives/loader/loader.component.stories.ts new file mode 100644 index 000000000..b28f84bff --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/loader/loader.component.stories.ts @@ -0,0 +1,28 @@ +/** @format */ + +import { Meta, StoryObj } from "@storybook/angular"; +import { LoaderComponent } from "./loader.component"; + +const meta: Meta = { + title: "UI/PRIMITIVES/Loader", + component: LoaderComponent, + tags: ["autodocs"], + argTypes: { + type: { control: "inline-radio", options: ["wave", "circle"] }, + color: { control: "color" }, + size: { control: "text" }, + speed: { control: "text" }, + }, +}; +export default meta; + +type Story = StoryObj; + +// color по умолчанию white — на белом фоне не видно, задаём видимый. +export const Circle: Story = { + args: { type: "circle", color: "#6c5ce7", size: "47px", speed: "1s" }, +}; + +export const Wave: Story = { + args: { type: "wave", color: "#6c5ce7", size: "47px", speed: "1s" }, +}; diff --git a/projects/social_platform/src/app/ui/primitives/loader/loader.component.ts b/projects/social_platform/src/app/ui/primitives/loader/loader.component.ts new file mode 100644 index 000000000..bfe074fbc --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/loader/loader.component.ts @@ -0,0 +1,38 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, input } from "@angular/core"; + +/** + * Компонент индикатора загрузки с настраиваемым внешним видом. + * Поддерживает различные типы анимации и настройки цвета, размера, скорости. + * + * Входящие параметры: + * - speed: скорость анимации (по умолчанию "1s") + * - size: размер индикатора (по умолчанию "47px") + * - color: цвет индикатора (по умолчанию "white") + * - type: тип анимации ("wave" | "circle", по умолчанию "wave") + * + * Использование: + * - Для показа состояния загрузки в кнопках, формах и других элементах + * - Настраиваемый размер и цвет под дизайн приложения + */ +@Component({ + selector: "app-loader", + templateUrl: "./loader.component.html", + styleUrl: "./loader.component.scss", + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class LoaderComponent { + /** Скорость анимации */ + speed = input("1s"); + + /** Размер индикатора */ + size = input("15px"); + + /** Цвет индикатора */ + color = input("white"); + + /** Тип анимации */ + type = input<"wave" | "circle">("circle"); +} diff --git a/projects/social_platform/src/app/ui/primitives/modal/modal.component.html b/projects/social_platform/src/app/ui/primitives/modal/modal.component.html new file mode 100644 index 000000000..312f1946d --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/modal/modal.component.html @@ -0,0 +1,14 @@ + + + + diff --git a/projects/social_platform/src/app/ui/components/modal/modal.component.scss b/projects/social_platform/src/app/ui/primitives/modal/modal.component.scss similarity index 100% rename from projects/social_platform/src/app/ui/components/modal/modal.component.scss rename to projects/social_platform/src/app/ui/primitives/modal/modal.component.scss diff --git a/projects/social_platform/src/app/ui/components/modal/modal.component.spec.ts b/projects/social_platform/src/app/ui/primitives/modal/modal.component.spec.ts similarity index 82% rename from projects/social_platform/src/app/ui/components/modal/modal.component.spec.ts rename to projects/social_platform/src/app/ui/primitives/modal/modal.component.spec.ts index 568ce457e..7db37382a 100644 --- a/projects/social_platform/src/app/ui/components/modal/modal.component.spec.ts +++ b/projects/social_platform/src/app/ui/primitives/modal/modal.component.spec.ts @@ -11,7 +11,7 @@ import { ModalComponent } from "./modal.component";
    Hello, world!
    `, - imports: [OverlayModule], + imports: [OverlayModule, ModalComponent], }) class TestHostComponent { @ViewChild(ModalComponent) modalComponent!: ModalComponent; @@ -27,8 +27,7 @@ describe("ModalComponent", () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [OverlayModule, ModalComponent], - declarations: [TestHostComponent], + imports: [OverlayModule, ModalComponent, TestHostComponent], }).compileComponents(); fixture = TestBed.createComponent(TestHostComponent); @@ -45,7 +44,8 @@ describe("ModalComponent", () => { }); it("should create the modal overlay when modalTemplate is available", () => { - hostComponent.modalComponent.modalTemplate = {} as any; + const mockTplRef = { elementRef: { nativeElement: document.createElement("div") } } as any; + (hostComponent.modalComponent as any).modalTemplate = () => mockTplRef; hostComponent.modalComponent.ngAfterViewInit(); expect(hostComponent.modalComponent.overlayRef).toBeTruthy(); }); diff --git a/projects/social_platform/src/app/ui/primitives/modal/modal.component.stories.ts b/projects/social_platform/src/app/ui/primitives/modal/modal.component.stories.ts new file mode 100644 index 000000000..01c1ce283 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/modal/modal.component.stories.ts @@ -0,0 +1,55 @@ +/** @format */ + +import { Meta, StoryObj, moduleMetadata, applicationConfig } from "@storybook/angular"; +import { Overlay } from "@angular/cdk/overlay"; +import { ModalComponent } from "./modal.component"; + +const meta: Meta = { + title: "UI/PRIMITIVES/Modal", + component: ModalComponent, + tags: ["autodocs"], + decorators: [ + applicationConfig({ + providers: [Overlay], + }), + ], + argTypes: { + color: { control: "inline-radio", options: ["primary", "gradient"] }, + open: { control: "boolean" }, + }, + render: args => ({ + props: { + ...args, + onClose: () => {}, + }, + template: ` +
    + +
    +

    Заголовок модалки

    +

    Контент модального окна

    + +
    +
    +
    + `, + }), +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { open: true, color: "primary" }, +}; + +export const Gradient: Story = { + args: { open: true, color: "gradient" }, +}; + +export const Closed: Story = { + args: { open: false, color: "primary" }, +}; diff --git a/projects/social_platform/src/app/ui/components/modal/modal.component.ts b/projects/social_platform/src/app/ui/primitives/modal/modal.component.ts similarity index 81% rename from projects/social_platform/src/app/ui/components/modal/modal.component.ts rename to projects/social_platform/src/app/ui/primitives/modal/modal.component.ts index c109e9c1e..8acb51104 100644 --- a/projects/social_platform/src/app/ui/components/modal/modal.component.ts +++ b/projects/social_platform/src/app/ui/primitives/modal/modal.component.ts @@ -2,14 +2,15 @@ import { AfterViewInit, + ChangeDetectionStrategy, Component, EventEmitter, + input, Input, OnDestroy, - OnInit, - Output, + output, TemplateRef, - ViewChild, + viewChild, ViewContainerRef, } from "@angular/core"; import { Overlay, OverlayRef } from "@angular/cdk/overlay"; @@ -41,22 +42,22 @@ import { CommonModule } from "@angular/common"; selector: "app-modal", templateUrl: "./modal.component.html", styleUrl: "./modal.component.scss", - standalone: true, imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class ModalComponent implements OnInit, AfterViewInit, OnDestroy { +export class ModalComponent implements AfterViewInit, OnDestroy { constructor( private readonly overlay: Overlay, - private readonly viewContainerRef: ViewContainerRef + private readonly viewContainerRef: ViewContainerRef, ) {} /** Цветовая схема модального окна */ - @Input() color?: "primary" | "gradient" = "primary"; + color = input<"primary" | "gradient">("primary"); /** Дополнительный CSS-класс для modal__body */ - @Input() bodyClass?: string; + bodyClass = input(); - /** Состояние открытия модального окна */ + /** Состояние открытия модального окна — setter input, нельзя конвертировать в signal */ @Input({ required: true }) set open(value: boolean) { setTimeout(() => { if (value) this.overlayRef?.attach(this.portal); @@ -69,15 +70,14 @@ export class ModalComponent implements OnInit, AfterViewInit, OnDestroy { } /** Событие изменения состояния открытия */ - @Output() openChange = new EventEmitter(); - - ngOnInit(): void {} + openChange = output(); /** Инициализация overlay после загрузки представления */ ngAfterViewInit(): void { - if (this.modalTemplate) { + const tpl = this.modalTemplate(); + if (tpl) { this.overlayRef = this.overlay.create({}); - this.portal = new TemplatePortal(this.modalTemplate, this.viewContainerRef); + this.portal = new TemplatePortal(tpl, this.viewContainerRef); } } @@ -87,7 +87,7 @@ export class ModalComponent implements OnInit, AfterViewInit, OnDestroy { } /** Ссылка на шаблон модального окна */ - @ViewChild("modalTemplate") modalTemplate?: TemplateRef; + modalTemplate = viewChild>("modalTemplate"); /** Portal для проекции контента */ portal?: TemplatePortal; diff --git a/projects/social_platform/src/app/ui/models/modal.service.spec.ts b/projects/social_platform/src/app/ui/primitives/modal/modal.service.spec.ts similarity index 100% rename from projects/social_platform/src/app/ui/models/modal.service.spec.ts rename to projects/social_platform/src/app/ui/primitives/modal/modal.service.spec.ts diff --git a/projects/social_platform/src/app/ui/models/modal.service.ts b/projects/social_platform/src/app/ui/primitives/modal/modal.service.ts similarity index 100% rename from projects/social_platform/src/app/ui/models/modal.service.ts rename to projects/social_platform/src/app/ui/primitives/modal/modal.service.ts diff --git a/projects/social_platform/src/app/ui/primitives/search/search.component.html b/projects/social_platform/src/app/ui/primitives/search/search.component.html new file mode 100644 index 000000000..95a887c88 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/search/search.component.html @@ -0,0 +1,27 @@ + + + diff --git a/projects/social_platform/src/app/ui/components/search/search.component.scss b/projects/social_platform/src/app/ui/primitives/search/search.component.scss similarity index 100% rename from projects/social_platform/src/app/ui/components/search/search.component.scss rename to projects/social_platform/src/app/ui/primitives/search/search.component.scss diff --git a/projects/social_platform/src/app/ui/components/search/search.component.spec.ts b/projects/social_platform/src/app/ui/primitives/search/search.component.spec.ts similarity index 86% rename from projects/social_platform/src/app/ui/components/search/search.component.spec.ts rename to projects/social_platform/src/app/ui/primitives/search/search.component.spec.ts index 420fb8b7d..6e1b3b24b 100644 --- a/projects/social_platform/src/app/ui/components/search/search.component.spec.ts +++ b/projects/social_platform/src/app/ui/primitives/search/search.component.spec.ts @@ -7,7 +7,7 @@ import { SearchComponent } from "./search.component"; describe("SearchComponent", () => { let component: SearchComponent; let fixture: ComponentFixture; - let onSwitchSearchSpy: jasmine.Spy; + let onSwitchSearchSpy: any; beforeEach(async () => { await TestBed.configureTestingModule({ @@ -18,7 +18,7 @@ describe("SearchComponent", () => { beforeEach(() => { fixture = TestBed.createComponent(SearchComponent); component = fixture.componentInstance; - onSwitchSearchSpy = spyOn(component, "onSwitchSearch").and.callThrough(); + onSwitchSearchSpy = vi.spyOn(component, "onSwitchSearch"); fixture.detectChanges(); }); @@ -27,7 +27,7 @@ describe("SearchComponent", () => { }); it("should not call onSwitchSearch when clicked and openable is false", () => { - component.openable = false; + fixture.componentRef.setInput("openable", false); const searchDiv = fixture.nativeElement.querySelector(".search__other"); searchDiv.dispatchEvent(new Event("click")); fixture.detectChanges(); diff --git a/projects/social_platform/src/app/ui/primitives/search/search.component.stories.ts b/projects/social_platform/src/app/ui/primitives/search/search.component.stories.ts new file mode 100644 index 000000000..47045e763 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/search/search.component.stories.ts @@ -0,0 +1,65 @@ +/** @format */ + +import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; +import { ReactiveFormsModule, FormControl } from "@angular/forms"; +import { SearchComponent } from "./search.component"; + +const meta: Meta = { + title: "UI/PRIMITIVES/Search", + component: SearchComponent, + tags: ["autodocs"], + decorators: [ + moduleMetadata({ + imports: [ReactiveFormsModule], + }), + ], + argTypes: { + placeholder: { control: "text" }, + type: { control: "select", options: ["text", "password", "email"] }, + openable: { control: "boolean" }, + error: { control: "boolean" }, + }, + render: args => ({ + props: { + ...args, + control: new FormControl(""), + }, + template: ``, + }), +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { placeholder: "Поиск...", openable: true }, +}; + +export const AlwaysOpen: Story = { + args: { placeholder: "Всегда открыт", openable: false }, +}; + +export const WithError: Story = { + args: { placeholder: "Ошибка", openable: true, error: true }, +}; + +export const WithValue: Story = { + args: { placeholder: "Поиск", openable: true }, + render: args => ({ + props: { + ...args, + control: new FormControl("запрос"), + }, + template: ``, + }), +}; diff --git a/projects/social_platform/src/app/ui/components/search/search.component.ts b/projects/social_platform/src/app/ui/primitives/search/search.component.ts similarity index 77% rename from projects/social_platform/src/app/ui/components/search/search.component.ts rename to projects/social_platform/src/app/ui/primitives/search/search.component.ts index 9603c397a..d5bdeccd5 100644 --- a/projects/social_platform/src/app/ui/components/search/search.component.ts +++ b/projects/social_platform/src/app/ui/primitives/search/search.component.ts @@ -1,18 +1,20 @@ /** @format */ import { + ChangeDetectionStrategy, + ChangeDetectorRef, Component, ElementRef, - EventEmitter, forwardRef, - Input, - OnInit, - Output, - ViewChild, + inject, + input, + model, + output, + viewChild, } from "@angular/core"; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; -import { IconComponent } from "@ui/components"; import { ClickOutsideModule } from "ng-click-outside"; +import { IconComponent } from "../icon/icon.component"; /** * Компонент поля поиска с возможностью раскрытия/сворачивания. @@ -24,7 +26,7 @@ import { ClickOutsideModule } from "ng-click-outside"; * - type: тип поля ввода ("text" | "password" | "email") * - error: состояние ошибки для стилизации * - mask: маска для форматирования ввода - * - openable: возможность сворачивания ��оля (по умолчанию true) + * - openable: возможность сворачивания поля (по умолчанию true) * - appValue: значение поля (двустороннее связывание) * * События: @@ -38,6 +40,7 @@ import { ClickOutsideModule } from "ng-click-outside"; selector: "app-search", templateUrl: "./search.component.html", styleUrl: "./search.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, providers: [ { provide: NG_VALUE_ACCESSOR, @@ -45,58 +48,49 @@ import { ClickOutsideModule } from "ng-click-outside"; multi: true, }, ], - standalone: true, imports: [ClickOutsideModule, IconComponent], }) -export class SearchComponent implements OnInit, ControlValueAccessor { +export class SearchComponent implements ControlValueAccessor { /** Текст подсказки */ - @Input() placeholder = ""; + placeholder = input(""); /** Тип поля ввода */ - @Input() type: "text" | "password" | "email" = "text"; + type = input<"text" | "password" | "email">("text"); /** Состояние ошибки */ - @Input() error = false; + error = input(false); /** Маска для форматирования */ - @Input() mask = ""; + mask = input(""); /** Возможность сворачивания поля */ - @Input() openable = true; + openable = input(true); /** Двустороннее связывание значения */ - @Input() - set appValue(value: string) { - this.value = value; - } - - get appValue(): string { - return this.value; - } - - /** Событие изменения значения */ - @Output() appValueChange = new EventEmitter(); + appValue = model(""); /** Событие нажатия Enter */ - @Output() enter = new EventEmitter(); - - ngOnInit(): void { - this.open = !this.openable; - } + enter = output(); /** Ссылка на поле ввода */ - @ViewChild("inputEl") inputEl?: ElementRef; + inputEl = viewChild>("inputEl"); /** Состояние раскрытия поля */ open = false; + private readonly cdRef = inject(ChangeDetectorRef); + + constructor() { + this.open = !this.openable(); + } /** Переключение состояния раскрытия поля поиска */ onSwitchSearch(value: boolean): void { - if (this.openable) this.open = value; + if (this.openable()) this.open = value; if (value) { setTimeout(() => { - this.inputEl?.nativeElement.focus(); + this.inputEl()?.nativeElement.focus(); + this.cdRef.markForCheck(); }); } } @@ -105,7 +99,7 @@ export class SearchComponent implements OnInit, ControlValueAccessor { onInput(event: Event): void { const value = (event.target as HTMLInputElement).value; this.onChange(value); - this.appValueChange.emit(value); + this.appValue.set(value); } /** Обработчик потери фокуса */ @@ -120,6 +114,8 @@ export class SearchComponent implements OnInit, ControlValueAccessor { writeValue(value: string): void { setTimeout(() => { this.value = value; + this.appValue.set(value); + this.cdRef.markForCheck(); }); } diff --git a/projects/social_platform/src/app/ui/primitives/select/select.component.html b/projects/social_platform/src/app/ui/primitives/select/select.component.html new file mode 100644 index 000000000..86532f3f1 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/select/select.component.html @@ -0,0 +1,43 @@ + + +
    +
    + {{ + selectedId === -1 + ? "Ничего" + : selectedId || selectedId === 0 + ? getLabel(selectedId) || placeholder() + : placeholder() + }} + @if (error()) { + + } @else { + + } +
    + @if (!disabled) { + + } +
    diff --git a/projects/social_platform/src/app/ui/components/select/select.component.scss b/projects/social_platform/src/app/ui/primitives/select/select.component.scss similarity index 97% rename from projects/social_platform/src/app/ui/components/select/select.component.scss rename to projects/social_platform/src/app/ui/primitives/select/select.component.scss index b0e07eaa5..a762c3b18 100644 --- a/projects/social_platform/src/app/ui/components/select/select.component.scss +++ b/projects/social_platform/src/app/ui/primitives/select/select.component.scss @@ -84,10 +84,6 @@ &:hover { color: var(--accent); } - - &--point { - color: var(--accent); - } } &__arrow { diff --git a/projects/social_platform/src/app/ui/components/select/select.component.spec.ts b/projects/social_platform/src/app/ui/primitives/select/select.component.spec.ts similarity index 84% rename from projects/social_platform/src/app/ui/components/select/select.component.spec.ts rename to projects/social_platform/src/app/ui/primitives/select/select.component.spec.ts index 9ae08b4bc..69616b7e5 100644 --- a/projects/social_platform/src/app/ui/components/select/select.component.spec.ts +++ b/projects/social_platform/src/app/ui/primitives/select/select.component.spec.ts @@ -2,7 +2,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { FormsModule } from "@angular/forms"; -import { SelectComponent } from "@ui/components"; +import { SelectComponent } from "@ui/primitives"; describe("SelectComponent", () => { let component: SelectComponent; @@ -17,6 +17,7 @@ describe("SelectComponent", () => { beforeEach(() => { fixture = TestBed.createComponent(SelectComponent); component = fixture.componentInstance; + fixture.componentRef.setInput("options", []); fixture.detectChanges(); }); @@ -26,7 +27,7 @@ describe("SelectComponent", () => { it("should display the placeholder text when no option is selected", () => { const placeholder = "Select an option"; - component.placeholder = placeholder; + fixture.componentRef.setInput("placeholder", placeholder); fixture.detectChanges(); const inputElement = fixture.nativeElement.querySelector(".field__input"); expect(inputElement.textContent.trim()).toBe(placeholder); @@ -38,25 +39,24 @@ describe("SelectComponent", () => { { value: "option2", label: "Option 2", id: 2 }, ]; const selectedOption = options[1]; + fixture.componentRef.setInput("options", options); component.writeValue(selectedOption.id); - component.options = options; fixture.detectChanges(); const inputElement = fixture.nativeElement.querySelector(".field__input"); expect(inputElement.textContent.trim()).toBe(selectedOption.label); }); it("should update the selected option and emit a value when an option is clicked", () => { - component.isOpen = true; const options = [ { value: "option1", label: "Option 1", id: 1 }, { value: "option2", label: "Option 2", id: 2 }, ]; const selectedOption = options[0]; - spyOn(component, "onChange"); - component.options = options; + vi.spyOn(component, "onChange"); + fixture.componentRef.setInput("options", options); + component.isOpen = true; fixture.detectChanges(); - const optionElement = fixture.nativeElement.querySelector(".field__option"); - optionElement.click(); + component.onUpdate(selectedOption.id); expect(component.selectedId).toBe(selectedOption.id); expect(component.onChange).toHaveBeenCalledWith(selectedOption.value); }); diff --git a/projects/social_platform/src/app/ui/primitives/select/select.component.stories.ts b/projects/social_platform/src/app/ui/primitives/select/select.component.stories.ts new file mode 100644 index 000000000..dbf60a03a --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/select/select.component.stories.ts @@ -0,0 +1,75 @@ +/** @format */ + +import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; +import { ReactiveFormsModule, FormControl } from "@angular/forms"; +import { SelectComponent } from "./select.component"; + +const sampleOptions = [ + { id: 1, value: "apple", label: "Яблоко" }, + { id: 2, value: "banana", label: "Банан" }, + { id: 3, value: "cherry", label: "Вишня" }, + { id: 4, value: "grape", label: "Виноград" }, + { id: 5, value: "kiwi", label: "Киви" }, +]; + +const meta: Meta = { + title: "UI/PRIMITIVES/Select", + component: SelectComponent, + tags: ["autodocs"], + decorators: [ + moduleMetadata({ + imports: [ReactiveFormsModule], + }), + ], + argTypes: { + placeholder: { control: "text" }, + size: { control: "inline-radio", options: ["small", "big"] }, + error: { control: "boolean" }, + }, + render: args => ({ + props: { + ...args, + options: sampleOptions, + control: new FormControl(""), + }, + template: ``, + }), +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { placeholder: "Выберите fruit", size: "small" }, +}; + +export const Big: Story = { + args: { placeholder: "Большой селект", size: "big" }, +}; + +export const WithError: Story = { + args: { placeholder: "Ошибка", size: "small", error: true }, +}; + +export const WithValue: Story = { + args: { placeholder: "Выбрано", size: "small" }, + render: args => ({ + props: { + ...args, + options: sampleOptions, + control: new FormControl("banana"), + }, + template: ``, + }), +}; diff --git a/projects/social_platform/src/app/ui/primitives/select/select.component.ts b/projects/social_platform/src/app/ui/primitives/select/select.component.ts new file mode 100644 index 000000000..caa5a8dab --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/select/select.component.ts @@ -0,0 +1,266 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + forwardRef, + HostListener, + Input, + input, + Renderer2, + viewChild, +} from "@angular/core"; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; +import { ClickOutsideModule } from "ng-click-outside"; +import { DropdownComponent } from "../dropdown/dropdown.component"; +import { IconComponent } from "../icon/icon.component"; + +/** + * Компонент выпадающего списка для выбора значения из предустановленных опций. + * Реализует ControlValueAccessor для интеграции с Angular Forms. + * Поддерживает навигацию с клавиатуры и автоматический скролл к выделенному элементу. + * + * Входящие параметры: + * - placeholder: текст подсказки при отсутствии выбора + * - selectedId: ID выбранной опции + * - options: массив опций для выбора с полями value, label, id + * + * Возвращает: + * - Значение выбранной опции через ControlValueAccessor + * + * Функциональность: + * - Навигация стрелками вверх/вниз + * - Выбор по Enter, закрытие по Escape + * - Автоматический скролл к выделенному элементу + * - Закрытие при клике вне компонента + */ +@Component({ + selector: "app-select", + templateUrl: "./select.component.html", + styleUrl: "./select.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => SelectComponent), + multi: true, + }, + ], + imports: [ClickOutsideModule, IconComponent, CommonModule, DropdownComponent], +}) +export class SelectComponent implements ControlValueAccessor { + /** Текст подсказки */ + placeholder = input(""); + + /** ID выбранной опции */ + @Input() selectedId?: number; + + size = input<"small" | "big">("small"); + + /** Массив доступных опций */ + options = input.required< + { + value: string | number | boolean | null; + label: string; + id: number; + }[] + >(); + + error = input(false); + + @Input() set isDisabled(value: boolean) { + this.setDisabledState(value); + } + + get isDisabled(): boolean { + return this.disabled; + } + + /** Состояние открытия выпадающего списка */ + isOpen = false; + + /** Индекс подсвеченного элемента при навигации */ + highlightedIndex = -1; + + constructor( + private readonly renderer: Renderer2, + private readonly cdr: ChangeDetectorRef, + ) {} + + /** Ссылка на элемент выпадающего списка */ + dropdown = viewChild>("dropdown"); + + /** Обработчик клавиатурных событий для навигации */ + @HostListener("document:keydown", ["$event"]) + onKeyDown(event: KeyboardEvent): void { + if (!this.isOpen || this.disabled) { + return; + } + + event.preventDefault(); + + const i = this.highlightedIndex; + + if (event.code === "ArrowUp") { + if (i < 0) this.highlightedIndex = 0; + if (i > 0) this.highlightedIndex--; + } + if (event.code === "ArrowDown") { + if (i < this.options().length - 1) { + this.highlightedIndex++; + } + } + if (event.code === "Enter") { + if (i >= 0) { + this.onUpdate(this.options()[this.highlightedIndex].id); + } + } + if (event.code === "Escape") { + this.hideDropdown(); + } + + if (this.isOpen) { + setTimeout(() => this.trackHighlightScroll()); + } + } + + /** Автоматический скролл к выделенному элементу */ + trackHighlightScroll(): void { + const ddElem = this.dropdown()?.nativeElement; + if (!ddElem) return; + + const highlightedElem = ddElem.children[this.highlightedIndex]; + + const ddBox = ddElem.getBoundingClientRect(); + const optBox = highlightedElem.getBoundingClientRect(); + + if (optBox.bottom > ddBox.bottom) { + const scrollAmount = optBox.bottom - ddBox.bottom + ddElem.scrollTop; + this.renderer.setProperty(ddElem, "scrollTop", scrollAmount); + } else if (optBox.top < ddBox.top) { + const scrollAmount = optBox.top - ddBox.top + ddElem.scrollTop; + this.renderer.setProperty(ddElem, "scrollTop", scrollAmount); + } + } + + // Методы ControlValueAccessor + writeValue(value: number | string | null) { + if (value === null || value === undefined || value === "") { + this.selectedId = undefined; + this.cdr.markForCheck(); + return; + } + + const optionByValue = this.options().find(option => option.value === value); + if (optionByValue) { + this.selectedId = optionByValue.id; + this.cdr.markForCheck(); + return; + } + + const yearValue = this.extractYear(value); + if (yearValue !== null) { + const yearOption = this.options().find( + option => this.extractYear(option.value) === yearValue, + ); + if (yearOption) { + this.selectedId = yearOption.id; + this.cdr.markForCheck(); + return; + } + } + + if (typeof value === "number") { + this.selectedId = this.options().some(option => option.id === value) ? value : undefined; + this.cdr.markForCheck(); + return; + } + + this.selectedId = this.getIdByValue(value) || this.getId(value); + this.cdr.markForCheck(); + } + + getIdByValue(value: string | number): number | undefined { + return this.options().find(el => el.value === value)?.id; + } + + private extractYear(value: unknown): number | null { + if (typeof value === "number" && Number.isInteger(value) && value >= 1900 && value <= 3000) { + return value; + } + + if (typeof value !== "string") { + return null; + } + + const match = value.match(/\d{4}/); + if (!match) { + return null; + } + + const year = Number(match[0]); + if (!Number.isInteger(year) || year < 1900 || year > 3000) { + return null; + } + + return year; + } + + disabled = false; + + setDisabledState(isDisabled: boolean) { + this.disabled = isDisabled; + this.cdr.markForCheck(); + } + + onChange: (value: string | number | null | boolean) => void = () => {}; + + registerOnChange(fn: any) { + this.onChange = fn; + } + + onTouched: () => void = () => {}; + + registerOnTouched(fn: any) { + this.onTouched = fn; + } + + /** Обработчик выбора опции */ + onUpdate(id: number): void { + if (this.disabled) return; + + this.selectedId = id; + this.onChange(this.getValue(id) ?? this.options()[0].value); + + this.hideDropdown(); + } + + /** Получение текста метки по ID опции */ + getLabel(optionId: number): string | undefined { + return this.options().find(el => el.id === optionId)?.label; + } + + /** Получение значения по ID опции */ + getValue(optionId: number): string | number | null | undefined | boolean { + return this.options().find(el => el.id === optionId)?.value; + } + + /** Получение ID по тексту метки */ + getId(label: string): number | undefined { + return this.options().find(el => el.label === label)?.id; + } + + /** Скрытие выпадающего списка */ + hideDropdown() { + this.isOpen = false; + this.highlightedIndex = -1; + } + + /** Обработчик клика вне компонента */ + onClickOutside() { + this.hideDropdown(); + } +} diff --git a/projects/social_platform/src/app/office/shared/soon-card/soon-card.component.html b/projects/social_platform/src/app/ui/primitives/soon-card/soon-card.component.html similarity index 83% rename from projects/social_platform/src/app/office/shared/soon-card/soon-card.component.html rename to projects/social_platform/src/app/ui/primitives/soon-card/soon-card.component.html index c7b98059f..36fd22db1 100644 --- a/projects/social_platform/src/app/office/shared/soon-card/soon-card.component.html +++ b/projects/social_platform/src/app/ui/primitives/soon-card/soon-card.component.html @@ -13,8 +13,8 @@
    -

    {{ title }}

    -

    {{ description }}

    +

    {{ title() }}

    +

    {{ description() }}

    diff --git a/projects/social_platform/src/app/office/shared/soon-card/soon-card.component.scss b/projects/social_platform/src/app/ui/primitives/soon-card/soon-card.component.scss similarity index 100% rename from projects/social_platform/src/app/office/shared/soon-card/soon-card.component.scss rename to projects/social_platform/src/app/ui/primitives/soon-card/soon-card.component.scss diff --git a/projects/social_platform/src/app/ui/primitives/soon-card/soon-card.component.stories.ts b/projects/social_platform/src/app/ui/primitives/soon-card/soon-card.component.stories.ts new file mode 100644 index 000000000..33cbb793c --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/soon-card/soon-card.component.stories.ts @@ -0,0 +1,21 @@ +/** @format */ + +import { Meta, StoryObj } from "@storybook/angular"; +import { SoonCardComponent } from "./soon-card.component"; + +const meta: Meta = { + title: "UI/PRIMITIVES/SoonCard", + component: SoonCardComponent, + tags: ["autodocs"], + argTypes: { + title: { control: "text" }, + description: { control: "text" }, + }, +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { title: "Скоро", description: "Этот раздел ещё в разработке" }, +}; diff --git a/projects/social_platform/src/app/ui/primitives/soon-card/soon-card.component.ts b/projects/social_platform/src/app/ui/primitives/soon-card/soon-card.component.ts new file mode 100644 index 000000000..bdb13ec10 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/soon-card/soon-card.component.ts @@ -0,0 +1,19 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, input } from "@angular/core"; +import { ButtonComponent } from "../button/button.component"; +import { IconComponent } from "../icon/icon.component"; + +/** Примитив: карточка-заглушка «скоро». */ +@Component({ + selector: "app-soon-card", + templateUrl: "./soon-card.component.html", + styleUrl: "./soon-card.component.scss", + imports: [CommonModule, IconComponent, ButtonComponent], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SoonCardComponent { + title = input.required(); + description = input.required(); +} diff --git a/projects/social_platform/src/app/ui/primitives/switch/switch.component.html b/projects/social_platform/src/app/ui/primitives/switch/switch.component.html new file mode 100644 index 000000000..4e9df8683 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/switch/switch.component.html @@ -0,0 +1,5 @@ + + +
    +
    +
    diff --git a/projects/social_platform/src/app/ui/components/switch/switch.component.scss b/projects/social_platform/src/app/ui/primitives/switch/switch.component.scss similarity index 100% rename from projects/social_platform/src/app/ui/components/switch/switch.component.scss rename to projects/social_platform/src/app/ui/primitives/switch/switch.component.scss diff --git a/projects/social_platform/src/app/ui/components/switch/switch.component.spec.ts b/projects/social_platform/src/app/ui/primitives/switch/switch.component.spec.ts similarity index 81% rename from projects/social_platform/src/app/ui/components/switch/switch.component.spec.ts rename to projects/social_platform/src/app/ui/primitives/switch/switch.component.spec.ts index 27aea95f2..22f58cf89 100644 --- a/projects/social_platform/src/app/ui/components/switch/switch.component.spec.ts +++ b/projects/social_platform/src/app/ui/primitives/switch/switch.component.spec.ts @@ -23,22 +23,23 @@ describe("SwitchComponent", () => { expect(component).toBeTruthy(); }); - it("should emit checkedChange when clicked", async () => { - spyOn(component.checkedChange, "emit"); + it("should emit checkedChange when clicked", () => { + const emitSpy = vi.fn(); + component.checked.subscribe(emitSpy); const switchElement = fixture.nativeElement.querySelector(".switch"); switchElement.click(); - expect(component.checkedChange.emit).toHaveBeenCalledWith(true); + expect(emitSpy).toHaveBeenCalledWith(true); }); it('should apply the "switch--active" class when checked is true', () => { - component.checked = true; + fixture.componentRef.setInput("checked", true); fixture.detectChanges(); const switchElement = fixture.nativeElement.querySelector(".switch"); expect(switchElement.classList.contains("switch--active")).toBe(true); }); it('should not apply the "switch--active" class when checked is false', () => { - component.checked = false; + fixture.componentRef.setInput("checked", false); fixture.detectChanges(); const switchElement = fixture.nativeElement.querySelector(".switch"); expect(switchElement.classList.contains("switch--active")).toBe(false); diff --git a/projects/social_platform/src/app/ui/primitives/switch/switch.component.stories.ts b/projects/social_platform/src/app/ui/primitives/switch/switch.component.stories.ts new file mode 100644 index 000000000..9cc8337e2 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/switch/switch.component.stories.ts @@ -0,0 +1,19 @@ +/** @format */ + +import { Meta, StoryObj } from "@storybook/angular"; +import { SwitchComponent } from "./switch.component"; + +const meta: Meta = { + title: "UI/PRIMITIVES/Switch", + component: SwitchComponent, + tags: ["autodocs"], + argTypes: { + checked: { control: "boolean" }, + }, +}; +export default meta; + +type Story = StoryObj; + +export const Off: Story = { args: { checked: false } }; +export const On: Story = { args: { checked: true } }; diff --git a/projects/social_platform/src/app/ui/primitives/switch/switch.component.ts b/projects/social_platform/src/app/ui/primitives/switch/switch.component.ts new file mode 100644 index 000000000..cf5272d59 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/switch/switch.component.ts @@ -0,0 +1,32 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, model } from "@angular/core"; + +/** + * Компонент переключателя (switch) для булевых значений. + * Альтернатива чекбоксу с современным дизайном в виде ползунка. + * + * Входящие параметры: + * - checked: состояние переключателя (включен/выключен) + * + * События: + * - checkedChange: изменение состояния переключателя + * + * Возвращает: + * - boolean значение через событие checkedChange + * + * Использование: + * - Для настроек вкл/выкл + * - Булевых переключателей в формах + */ +@Component({ + selector: "app-switch", + templateUrl: "./switch.component.html", + styleUrl: "./switch.component.scss", + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SwitchComponent { + /** Состояние переключателя */ + checked = model(false); +} diff --git a/projects/social_platform/src/app/ui/primitives/tag/tag.component.html b/projects/social_platform/src/app/ui/primitives/tag/tag.component.html new file mode 100644 index 000000000..96e08f6aa --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/tag/tag.component.html @@ -0,0 +1,32 @@ + + +
    +

    + +
    + @if (canEdit()) { + + } + @if (canDelete()) { + + } +
    +
    diff --git a/projects/social_platform/src/app/ui/primitives/tag/tag.component.scss b/projects/social_platform/src/app/ui/primitives/tag/tag.component.scss new file mode 100644 index 000000000..89905e535 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/tag/tag.component.scss @@ -0,0 +1,181 @@ +/** @format */ + +.tag { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + max-width: 120px; + padding: 2px 20px; + overflow: hidden; + color: var(--accent); + text-overflow: ellipsis; + white-space: nowrap; + border-radius: var(--rounded-xxl); + + p { + margin: 0 10px; + } + + &--inline { + color: var(--white); + border: 0.5px solid transparent; + + &.tag--secondary { + background: var(--dark-grey); + border: 0.5px solid var(--grey-for-text); + } + + &.tag--accent { + color: var(--white); + background-color: var(--accent); + } + + &.tag--accent-medium { + color: var(--white); + background-color: var(--accent-medium); + } + + &.tag--blue-dark { + color: var(--white); + background-color: var(--blue-dark); + } + + &.tag--complete { + color: var(--white); + background-color: var(--green); + } + + &.tag--complete-dark { + color: var(--white); + background-color: var(--green-dark); + } + + &.tag--red { + color: var(--white); + background-color: var(--red); + } + + &.tag--cyan { + color: var(--white); + background-color: var(--cyan); + } + + &.tag--soft { + color: var(--light-white); + background: var(--gold); + border: transparent; + } + + &.tag--days { + color: var(--accent); + background-color: var(--light-white); + border: 0.5px solid var(--accent); + } + + &.tag--overdue { + color: var(--red); + background-color: var(--light-white); + border: 0.5px solid var(--red); + } + + &:not(:last-child) { + margin-right: 5px; + } + } + + &--outline { + color: var(--accent); + background: transparent; + border: 0.5px solid var(--accent); + + &.tag--primary { + color: var(--accent); + border: 0.5px solid var(--accent); + } + + &.tag--secondary { + color: var(--grey-for-text); + background: transparent; + border: 0.5px solid var(--dark-grey); + } + + &.tag--accent { + color: var(--accent); + background: transparent; + border: 0.5px solid var(--accent); + } + + &.tag--accent-medium { + color: var(--white); + background: transparent; + border: 0.5px solid var(--accent-medium); + } + + &.tag--blue-dark { + color: var(--white); + background: transparent; + border: 0.5px solid var(--blue-dark); + } + + &.tag--complete { + color: var(--green); + background: transparent; + border: 0.5px solid var(--green); + } + + &.tag--complete-dark { + color: var(--white); + background: transparent; + border: 0.5px solid var(--green-dark); + } + + &.tag--red { + color: var(--white); + background: transparent; + border: 0.5px solid var(--red); + } + + &.tag--cyan { + color: var(--white); + background: transparent; + border: 0.5px solid var(--cyan); + } + + &.tag--soft { + color: var(--gold); + background: transparent; + border: 0.5px solid var(--gold); + } + + &.tag--answer { + color: var(--grey-for-text); + background: transparent; + border: 0.5px solid var(--grey-for-text); + } + + &:not(:last-child) { + margin-right: 5px; + } + } + + &__icons { + position: absolute; + top: 30%; + right: 5%; + display: flex; + flex-shrink: 0; + gap: 3px; + align-items: center; + + i { + cursor: pointer; + opacity: 1; + + &:hover { + opacity: 0.7; + } + } + } +} diff --git a/projects/social_platform/src/app/ui/components/tag/tag.component.spec.ts b/projects/social_platform/src/app/ui/primitives/tag/tag.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/ui/components/tag/tag.component.spec.ts rename to projects/social_platform/src/app/ui/primitives/tag/tag.component.spec.ts diff --git a/projects/social_platform/src/app/ui/primitives/tag/tag.component.stories.ts b/projects/social_platform/src/app/ui/primitives/tag/tag.component.stories.ts new file mode 100644 index 000000000..5cd9285a7 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/tag/tag.component.stories.ts @@ -0,0 +1,39 @@ +/** @format */ + +import { Meta, StoryObj } from "@storybook/angular"; +import { TagComponent } from "./tag.component"; + +const meta: Meta = { + title: "UI/PRIMITIVES/Tag", + component: TagComponent, + tags: ["autodocs"], + argTypes: { + color: { + control: "select", + options: [ + "primary", + "secondary", + "accent", + "accent-medium", + "blue-dark", + "cyan", + "red", + "complete", + "complete-dark", + "soft", + ], + }, + appearance: { control: "inline-radio", options: ["inline", "outline"] }, + }, + render: args => ({ + props: args, + template: `Метка`, + }), +}; +export default meta; + +type Story = StoryObj; + +export const Primary: Story = { args: { color: "primary", appearance: "inline" } }; +export const Outline: Story = { args: { color: "complete", appearance: "outline" } }; +export const Red: Story = { args: { color: "red", appearance: "inline" } }; diff --git a/projects/social_platform/src/app/ui/primitives/tag/tag.component.ts b/projects/social_platform/src/app/ui/primitives/tag/tag.component.ts new file mode 100644 index 000000000..ab9f6a9ac --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/tag/tag.component.ts @@ -0,0 +1,99 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, computed, effect, input, output } from "@angular/core"; +import { tagColors } from "@core/consts/other/tag-colors.const"; +import { NgStyle } from "@angular/common"; +import { IconComponent } from "../icon/icon.component"; + +/** + * Компонент тега для отображения статусов, категорий или меток. + * Поддерживает различные цветовые схемы для визуального разделения типов. + * + * Входящие параметры: + * - color: цветовая схема тега ("primary" | "accent" | "complete") + * + * Использование: + * - Отображение статусов задач, заказов + * - Категоризация контента + * - Визуальные метки и индикаторы + * - Контент передается через ng-content + */ +@Component({ + selector: "app-tag", + templateUrl: "./tag.component.html", + styleUrl: "./tag.component.scss", + imports: [IconComponent, NgStyle], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TagComponent { + /** Цветовая схема тега */ + color = input< + | "primary" + | "secondary" + | "accent" + | "accent-medium" + | "blue-dark" + | "cyan" + | "red" + | "complete" + | "complete-dark" + | "soft" + >("primary"); + + type = input<"days" | "overdue" | "answer">(); + + /** Стиль отображения */ + appearance = input<"inline" | "outline">("inline"); + + /** Возможность редактирования */ + canEdit = input(); + + /** Возможность удаления */ + canDelete = input(); + + isKanbanTag = input(false); + + /** Событие для возможности удаления */ + delete = output(); + + /** Событие для возможности редактирования */ + edit = output(); + + get tagColors() { + return tagColors; + } + + additionalTagColor = ""; + + constructor() { + effect(() => { + this.mappingAdditionalTagColors(); + }); + } + + /** Метод для вызова удаления элемента */ + onDelete(event: MouseEvent): void { + event.preventDefault(); + event.stopPropagation(); + this.delete.emit(); + } + + /** Метод для вызова редактирования элемента */ + onEdit(event: MouseEvent): void { + event.preventDefault(); + event.stopPropagation(); + this.edit.emit(); + } + + private mappingAdditionalTagColors(): void { + if (!this.isKanbanTag()) { + this.additionalTagColor = ""; + return; + } + + const found = tagColors.find(tagColor => tagColor.name === this.color()); + if (found) { + this.additionalTagColor = found.color; + } + } +} diff --git a/projects/social_platform/src/app/ui/primitives/textarea/textarea.component.html b/projects/social_platform/src/app/ui/primitives/textarea/textarea.component.html new file mode 100644 index 000000000..18f8f294e --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/textarea/textarea.component.html @@ -0,0 +1,51 @@ + + +
    +
    + +
    + + @if (maxLength()) { +
    +

    + {{ + value.length + }} + / {{ maxLength() }} +

    +
    + } + @if (error()) { + + } @else if (haveHint() && tooltipText()) { +
    + +
    + } +
    diff --git a/projects/social_platform/src/app/ui/components/textarea/textarea.component.scss b/projects/social_platform/src/app/ui/primitives/textarea/textarea.component.scss similarity index 93% rename from projects/social_platform/src/app/ui/components/textarea/textarea.component.scss rename to projects/social_platform/src/app/ui/primitives/textarea/textarea.component.scss index b38a2e15c..0652a1aa9 100644 --- a/projects/social_platform/src/app/ui/components/textarea/textarea.component.scss +++ b/projects/social_platform/src/app/ui/primitives/textarea/textarea.component.scss @@ -6,7 +6,17 @@ .field { position: relative; + &--small { + min-width: 245px; + } + + &--big { + width: 100%; + } + &__input { + display: block; + width: 100%; min-height: 162px; padding: 12px 18px; color: var(--black); diff --git a/projects/social_platform/src/app/ui/components/textarea/textarea.component.spec.ts b/projects/social_platform/src/app/ui/primitives/textarea/textarea.component.spec.ts similarity index 89% rename from projects/social_platform/src/app/ui/components/textarea/textarea.component.spec.ts rename to projects/social_platform/src/app/ui/primitives/textarea/textarea.component.spec.ts index 5687e45fd..da070b81b 100644 --- a/projects/social_platform/src/app/ui/components/textarea/textarea.component.spec.ts +++ b/projects/social_platform/src/app/ui/primitives/textarea/textarea.component.spec.ts @@ -7,7 +7,7 @@ import { TextareaComponent } from "./textarea.component"; describe("TextareaComponent", () => { let component: TextareaComponent; let fixture: ComponentFixture; - let onChangeSpy: jasmine.Spy; + let onChangeSpy: any; beforeEach(async () => { await TestBed.configureTestingModule({ @@ -18,7 +18,7 @@ describe("TextareaComponent", () => { beforeEach(() => { fixture = TestBed.createComponent(TextareaComponent); component = fixture.componentInstance; - onChangeSpy = spyOn(component, "onChange"); + onChangeSpy = vi.spyOn(component, "onChange"); fixture.detectChanges(); }); @@ -34,7 +34,7 @@ describe("TextareaComponent", () => { }); it("should set touched on blur", () => { - spyOn(component, "onTouch"); + vi.spyOn(component, "onTouch"); const inputEl = fixture.nativeElement.querySelector("textarea"); inputEl.dispatchEvent(new Event("blur")); expect(component.onTouch).toHaveBeenCalled(); @@ -48,7 +48,7 @@ describe("TextareaComponent", () => { }); it("should prevent enter", () => { - const preventDefaultSpy = jasmine.createSpy("preventDefault"); + const preventDefaultSpy = vi.fn(); const event = { preventDefault: preventDefaultSpy } as any; component.preventEnter(event); expect(preventDefaultSpy).toHaveBeenCalled(); diff --git a/projects/social_platform/src/app/ui/primitives/textarea/textarea.component.stories.ts b/projects/social_platform/src/app/ui/primitives/textarea/textarea.component.stories.ts new file mode 100644 index 000000000..c59a07336 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/textarea/textarea.component.stories.ts @@ -0,0 +1,75 @@ +/** @format */ + +import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; +import { ReactiveFormsModule, FormControl } from "@angular/forms"; +import { TextareaComponent } from "./textarea.component"; + +const meta: Meta = { + title: "UI/PRIMITIVES/Textarea", + component: TextareaComponent, + tags: ["autodocs"], + decorators: [ + moduleMetadata({ + imports: [ReactiveFormsModule], + }), + ], + argTypes: { + placeholder: { control: "text" }, + size: { control: "inline-radio", options: ["small", "big"] }, + error: { control: "boolean" }, + maxLength: { control: "number" }, + }, + render: args => ({ + props: { + ...args, + control: new FormControl(""), + }, + template: ` +
    + +
    `, + }), +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { placeholder: "Введите сообщение...", size: "small" }, +}; + +export const Big: Story = { + args: { placeholder: "Большое поле", size: "big" }, +}; + +export const WithError: Story = { + args: { placeholder: "Ошибка", size: "small", error: true }, +}; + +export const WithMaxLength: Story = { + args: { placeholder: "Макс. 100 символов", size: "small", maxLength: 100 }, +}; + +export const WithValue: Story = { + args: { placeholder: "С текстом", size: "small" }, + render: args => ({ + props: { + ...args, + control: new FormControl("Пример текста\nВторая строка"), + }, + template: ` +
    + +
    `, + }), +}; diff --git a/projects/social_platform/src/app/ui/components/textarea/textarea.component.ts b/projects/social_platform/src/app/ui/primitives/textarea/textarea.component.ts similarity index 79% rename from projects/social_platform/src/app/ui/components/textarea/textarea.component.ts rename to projects/social_platform/src/app/ui/primitives/textarea/textarea.component.ts index 31afde32b..69981e9b4 100644 --- a/projects/social_platform/src/app/ui/components/textarea/textarea.component.ts +++ b/projects/social_platform/src/app/ui/primitives/textarea/textarea.component.ts @@ -1,11 +1,20 @@ /** @format */ -import { Component, EventEmitter, forwardRef, Input, OnInit, Output } from "@angular/core"; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + forwardRef, + inject, + input, + model, + output, +} from "@angular/core"; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; -import { IconComponent } from "@uilib"; import { AutosizeModule } from "ngx-autosize"; import { TooltipComponent } from "../tooltip/tooltip.component"; import { NgStyle } from "@angular/common"; +import { IconComponent } from "../icon/icon.component"; /** * Компонент многострочного поля ввода с автоматическим изменением размера. @@ -29,6 +38,7 @@ import { NgStyle } from "@angular/common"; selector: "app-textarea", templateUrl: "./textarea.component.html", styleUrl: "./textarea.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, providers: [ { provide: NG_VALUE_ACCESSOR, @@ -36,59 +46,47 @@ import { NgStyle } from "@angular/common"; multi: true, }, ], - standalone: true, imports: [AutosizeModule, IconComponent, TooltipComponent, NgStyle], }) -export class TextareaComponent implements OnInit, ControlValueAccessor { +export class TextareaComponent implements ControlValueAccessor { + private readonly cdr = inject(ChangeDetectorRef); + /** Текст подсказки */ - @Input() placeholder = ""; + placeholder = input(""); /** Тип поля (наследуется от базового компонента) */ - @Input() type: "text" | "password" | "email" = "text"; + type = input<"text" | "password" | "email">("text"); /** Наличие подсказки */ - @Input() haveHint = false; + haveHint = input(false); /** Текст для подсказки */ - @Input() tooltipText?: string; + tooltipText = input(); /** Позиция подсказки */ - @Input() tooltipPosition: "left" | "right" = "right"; + tooltipPosition = input<"left" | "right">("right"); /** Ширина подсказки */ - @Input() tooltipWidth = 250; + tooltipWidth = input(250); - @Input() maxLength?: number; + maxLength = input(); /** Состояние ошибки */ - @Input() error = false; + error = input(false); - @Input() size: "small" | "big" = "small"; + size = input<"small" | "big">("small"); /** Маска (наследуется, но не используется) */ - @Input() mask = ""; + mask = input(""); /** Двустороннее связывание текста */ - @Input() set text(value: string) { - this.value = value ?? ""; - } - - get text(): string { - return this.value; - } - - /** Событие изменения текста */ - @Output() textChange = new EventEmitter(); - - constructor() {} - - ngOnInit(): void {} + text = model(""); /** Обработчик ввода текста */ onInput(event: Event): void { const nextValue = (event.target as HTMLInputElement).value ?? ""; - if (this.maxLength && nextValue.length > this.maxLength) { + if (this.maxLength() && nextValue.length > this.maxLength()!) { this.isLengthOverflow = true; } else { this.isLengthOverflow = false; @@ -96,7 +94,7 @@ export class TextareaComponent implements OnInit, ControlValueAccessor { this.value = nextValue; this.onChange(nextValue); - this.textChange.emit(nextValue); + this.text.set(nextValue); } /** Обработчик потери фокуса */ @@ -124,6 +122,9 @@ export class TextareaComponent implements OnInit, ControlValueAccessor { // Методы ControlValueAccessor writeValue(value: string): void { this.value = value ?? ""; + this.text.set(this.value); + this.cdr.markForCheck(); + this.isLengthOverflow = false; } onChange: (value: string) => void = () => {}; @@ -143,6 +144,7 @@ export class TextareaComponent implements OnInit, ControlValueAccessor { setDisabledState(isDisabled: boolean) { this.disabled = isDisabled; + this.cdr.markForCheck(); } /** Предотвращение перехода на новую строку по Enter */ diff --git a/projects/social_platform/src/app/ui/primitives/tooltip/tooltip.component.html b/projects/social_platform/src/app/ui/primitives/tooltip/tooltip.component.html new file mode 100644 index 000000000..7438d4e61 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/tooltip/tooltip.component.html @@ -0,0 +1,21 @@ + + + + + {{ text() }} + diff --git a/projects/social_platform/src/app/ui/components/tooltip/tooltip.component.scss b/projects/social_platform/src/app/ui/primitives/tooltip/tooltip.component.scss similarity index 100% rename from projects/social_platform/src/app/ui/components/tooltip/tooltip.component.scss rename to projects/social_platform/src/app/ui/primitives/tooltip/tooltip.component.scss diff --git a/projects/social_platform/src/app/ui/primitives/tooltip/tooltip.component.stories.ts b/projects/social_platform/src/app/ui/primitives/tooltip/tooltip.component.stories.ts new file mode 100644 index 000000000..1aeabfc9e --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/tooltip/tooltip.component.stories.ts @@ -0,0 +1,91 @@ +/** @format */ + +import { Meta, StoryObj } from "@storybook/angular"; +import { TooltipComponent } from "./tooltip.component"; + +const meta: Meta = { + title: "UI/PRIMITIVES/Tooltip", + component: TooltipComponent, + tags: ["autodocs"], + argTypes: { + text: { control: "text" }, + isVisible: { control: "boolean" }, + position: { control: "inline-radio", options: ["left", "right"] }, + iconSize: { control: "text" }, + tooltipWidth: { control: "number" }, + color: { control: "inline-radio", options: ["accent", "grey"] }, + }, + render: args => ({ + props: args, + template: ` +
    + +
    + `, + }), +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + text: "Это подсказка с текстом", + isVisible: true, + position: "right", + iconSize: "16", + tooltipWidth: 250, + color: "accent", + }, +}; + +export const Left: Story = { + args: { + text: "Подсказка слева", + isVisible: true, + position: "left", + iconSize: "16", + tooltipWidth: 200, + color: "accent", + }, +}; + +export const Grey: Story = { + args: { + text: "Серая подсказка", + isVisible: true, + position: "right", + iconSize: "16", + tooltipWidth: 250, + color: "grey", + }, +}; + +export const Hidden: Story = { + args: { + text: "Скрыта", + isVisible: false, + position: "right", + iconSize: "16", + tooltipWidth: 250, + color: "accent", + }, +}; + +export const SmallIcon: Story = { + args: { + text: "Маленькая иконка", + isVisible: true, + position: "right", + iconSize: "12", + tooltipWidth: 200, + color: "accent", + }, +}; diff --git a/projects/social_platform/src/app/ui/primitives/tooltip/tooltip.component.ts b/projects/social_platform/src/app/ui/primitives/tooltip/tooltip.component.ts new file mode 100644 index 000000000..2ba82013a --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/tooltip/tooltip.component.ts @@ -0,0 +1,56 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { Component, input, output, ChangeDetectionStrategy } from "@angular/core"; +import { IconComponent } from "../icon/icon.component"; + +/** + * Переиспользуемый компонент подсказки с иконкой + * + * Входящие параметры: + * - text: текст подсказки + * - isVisible: состояние видимости подсказки + * - position: позиция подсказки относительно иконки + * - iconSize: размер иконки подсказки + * - tooltipWidth: ширина блока подсказки + * - customClass: дополнительные CSS классы + * + * События: + * - show: показать подсказку + * - hide: скрыть подсказку + */ +@Component({ + selector: "app-tooltip", + templateUrl: "./tooltip.component.html", + styleUrl: "./tooltip.component.scss", + imports: [CommonModule, IconComponent], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TooltipComponent { + /** Текст подсказки */ + text = input(""); + + /** Состояние видимости */ + isVisible = input(false); + + /** Позиция подсказки */ + position = input<"left" | "right">("right"); + + /** Размер иконки */ + iconSize = input("16"); + + /** Ширина подсказки */ + tooltipWidth = input(250); + + /** Дополнительные CSS классы */ + customClass = input(""); + + /** Цвет иконки */ + color = input<"accent" | "grey">("accent"); + + /** Событие показа подсказки */ + show = output(); + + /** Событие скрытия подсказки */ + hide = output(); +} diff --git a/projects/social_platform/src/app/ui/primitives/upload-file/upload-file.component.html b/projects/social_platform/src/app/ui/primitives/upload-file/upload-file.component.html new file mode 100644 index 000000000..cc5a2e05d --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/upload-file/upload-file.component.html @@ -0,0 +1,24 @@ + + +
    + @if (loading) { + + } @else if (!value) { +
    + + +
    + } @else { +
    +
    + +

    Файл успешно загружен

    +
    + +
    + } +
    diff --git a/projects/social_platform/src/app/ui/components/upload-file/upload-file.component.scss b/projects/social_platform/src/app/ui/primitives/upload-file/upload-file.component.scss similarity index 100% rename from projects/social_platform/src/app/ui/components/upload-file/upload-file.component.scss rename to projects/social_platform/src/app/ui/primitives/upload-file/upload-file.component.scss diff --git a/projects/social_platform/src/app/ui/components/upload-file/upload-file.component.spec.ts b/projects/social_platform/src/app/ui/primitives/upload-file/upload-file.component.spec.ts similarity index 78% rename from projects/social_platform/src/app/ui/components/upload-file/upload-file.component.spec.ts rename to projects/social_platform/src/app/ui/primitives/upload-file/upload-file.component.spec.ts index c00643ec0..058417926 100644 --- a/projects/social_platform/src/app/ui/components/upload-file/upload-file.component.spec.ts +++ b/projects/social_platform/src/app/ui/primitives/upload-file/upload-file.component.spec.ts @@ -3,19 +3,19 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { FormsModule } from "@angular/forms"; import { UploadFileComponent } from "./upload-file.component"; -import { FileService } from "@core/services/file.service"; +import { FileService } from "projects/core/src/lib/services/file/file.service"; import { of } from "rxjs"; describe("UploadFileComponent", () => { let component: UploadFileComponent; let fixture: ComponentFixture; - let fileServiceSpy: jasmine.SpyObj; + let fileServiceSpy: any; beforeEach(() => { - fileServiceSpy = jasmine.createSpyObj("FileService", { - uploadFile: of({}), - deleteFile: of({}), - }); + fileServiceSpy = { + uploadFile: vi.fn().mockReturnValue(of({})), + deleteFile: vi.fn().mockReturnValue(of({})), + }; TestBed.configureTestingModule({ imports: [FormsModule, UploadFileComponent], @@ -32,7 +32,7 @@ describe("UploadFileComponent", () => { }); it("should upload file and emit change event", () => { - spyOn(component, "onUpdate"); + vi.spyOn(component, "onUpdate"); const input = fixture.nativeElement.querySelector("input[type=file]"); const event = new Event("change"); @@ -42,10 +42,10 @@ describe("UploadFileComponent", () => { }); it("should clear value and emit change event when delete button is clicked", () => { - spyOn(component, "onTouch"); - spyOn(component, "onChange"); + vi.spyOn(component, "onTouch"); + vi.spyOn(component, "onChange"); - component.value = "http://example.com/image.png"; + component.writeValue("http://example.com/image.png"); fixture.detectChanges(); const button = fixture.nativeElement.querySelector(".file__basket"); diff --git a/projects/social_platform/src/app/ui/primitives/upload-file/upload-file.component.stories.ts b/projects/social_platform/src/app/ui/primitives/upload-file/upload-file.component.stories.ts new file mode 100644 index 000000000..60e36da91 --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/upload-file/upload-file.component.stories.ts @@ -0,0 +1,70 @@ +/** @format */ + +import { Meta, StoryObj, moduleMetadata, applicationConfig } from "@storybook/angular"; +import { ReactiveFormsModule, FormControl } from "@angular/forms"; +import { of } from "rxjs"; +import { FileService } from "@core/lib/services/file/file.service"; +import { UploadFileComponent } from "./upload-file.component"; + +class MockFileService { + uploadFile = () => of({ url: "https://example.com/mock-file.pdf" }); + deleteFile = () => of({ success: true as const }); +} + +const meta: Meta = { + title: "UI/PRIMITIVES/UploadFile", + component: UploadFileComponent, + tags: ["autodocs"], + decorators: [ + applicationConfig({ + providers: [{ provide: FileService, useClass: MockFileService }], + }), + moduleMetadata({ + imports: [ReactiveFormsModule], + }), + ], + argTypes: { + accept: { control: "text" }, + error: { control: "boolean" }, + resetAfterUpload: { control: "boolean" }, + }, + render: args => ({ + props: { + ...args, + control: new FormControl(""), + }, + template: ` +
    + +
    +

    Нажмите или перетащите файл

    +
    +
    +
    + `, + }), +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { accept: "", error: false, resetAfterUpload: false }, +}; + +export const WithAccept: Story = { + args: { accept: "image/*,.pdf", error: false, resetAfterUpload: false }, +}; + +export const WithError: Story = { + args: { accept: "", error: true, resetAfterUpload: false }, +}; + +export const ResetAfterUpload: Story = { + args: { accept: "", error: false, resetAfterUpload: true }, +}; diff --git a/projects/social_platform/src/app/ui/primitives/upload-file/upload-file.component.ts b/projects/social_platform/src/app/ui/primitives/upload-file/upload-file.component.ts new file mode 100644 index 000000000..97b1c55df --- /dev/null +++ b/projects/social_platform/src/app/ui/primitives/upload-file/upload-file.component.ts @@ -0,0 +1,170 @@ +/** @format */ + +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DestroyRef, + forwardRef, + inject, + input, + output, +} from "@angular/core"; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; +import { HttpErrorResponse } from "@angular/common/http"; +import { FileService } from "@core/lib/services/file/file.service"; +import { SnackbarService } from "@domain/shared/snackbar.service"; +import { nanoid } from "nanoid"; +import { LoaderComponent } from "../loader/loader.component"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { IconComponent } from "../icon/icon.component"; + +/** + * Компонент для загрузки файлов с предварительным просмотром. + * Реализует ControlValueAccessor для интеграции с Angular Forms. + * Поддерживает ограничения по типу файлов и показывает состояние загрузки. + * + * Входящие параметры: + * - accept: ограничения по типу файлов (MIME-типы) + * - error: состояние ошибки для стилизации + * + * Возвращает: + * - URL загруженного файла через ControlValueAccessor + * + * Функциональность: + * - Drag & drop и выбор файлов через диалог + * - Предварительный просмотр выбранного файла + * - Индикатор загрузки + * - Возможность удаления загруженного файла + */ +@Component({ + selector: "app-upload-file", + templateUrl: "./upload-file.component.html", + styleUrl: "./upload-file.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => UploadFileComponent), + multi: true, + }, + ], + imports: [IconComponent, LoaderComponent], +}) +export class UploadFileComponent implements ControlValueAccessor { + private readonly fileService = inject(FileService); + private readonly snackbarService = inject(SnackbarService); + private readonly destroyRef = inject(DestroyRef); + private readonly cdr = inject(ChangeDetectorRef); + + /** Ограничения по типу файлов */ + accept = input(""); + + /** Состояние ошибки */ + error = input(false); + + /** Режим: после загрузки сбросить в пустое состояние и не показывать "файл успешно загружен" */ + resetAfterUpload = input(false); + + /** Событие с данными загруженного файла (url + метаданные оригинального файла) */ + uploaded = output<{ + url: string; + name: string; + size: number; + mimeType: string; + }>(); + + /** Уникальный ID для элемента input */ + controlId = nanoid(3); + + /** URL загруженного файла */ + value = ""; + + // Методы ControlValueAccessor + writeValue(url: string) { + this.value = url; + this.cdr.markForCheck(); + } + + onTouch: () => void = () => {}; + + registerOnTouched(fn: any) { + this.onTouch = fn; + } + + onChange: (url: string) => void = () => {}; + + registerOnChange(fn: any) { + this.onChange = fn; + } + + /** Состояние загрузки */ + loading = false; + + /** Обработчик загрузки файла */ + onUpdate(event: Event): void { + const input = event.currentTarget as HTMLInputElement; + const files = input.files; + if (!files?.length) { + return; + } + + const originalFile = files[0]; + this.loading = true; + this.cdr.markForCheck(); + + this.fileService + .uploadFile(originalFile) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: res => { + this.loading = false; + + this.value = res.url; + this.onChange(res.url); + this.cdr.markForCheck(); + this.uploaded.emit({ + url: res.url, + name: originalFile.name, + size: originalFile.size, + mimeType: originalFile.type, + }); + }, + error: (err: HttpErrorResponse) => { + this.loading = false; + this.cdr.markForCheck(); + + if (err.status === 413) { + this.snackbarService.error( + "Файл превышает допустимый размер. Уменьшите или измените размер файла и попробуйте снова.", + ); + } else { + this.snackbarService.error("Ошибка загрузки файла. Попробуйте ещё раз."); + } + }, + }); + } + + /** Обработчик удаления файла */ + onRemove(): void { + this.fileService + .deleteFile(this.value) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.value = ""; + + this.onTouch(); + this.onChange(""); + this.cdr.markForCheck(); + }, + error: () => { + this.value = ""; + + this.onTouch(); + this.onChange(""); + this.cdr.markForCheck(); + }, + }); + } +} diff --git a/projects/social_platform/src/app/ui/routes/auth/auth.routes.ts b/projects/social_platform/src/app/ui/routes/auth/auth.routes.ts new file mode 100644 index 000000000..19328864c --- /dev/null +++ b/projects/social_platform/src/app/ui/routes/auth/auth.routes.ts @@ -0,0 +1,54 @@ +/** @format */ + +import { Routes } from "@angular/router"; +import { AuthComponent } from "../../pages/auth/auth.component"; +import { ResetPasswordComponent } from "@ui/pages/auth/reset-password/reset-password.component"; +import { SetPasswordComponent } from "@ui/pages/auth/set-password/set-password.component"; +import { ConfirmPasswordResetComponent } from "@ui/pages/auth/confirm-password-reset/confirm-password-reset.component"; +import { LoginComponent } from "@ui/pages/auth/login/login.component"; +import { RegisterComponent } from "@ui/pages/auth/register/register.component"; +import { EmailVerificationComponent } from "@ui/pages/auth/email-verification/email-verification.component"; +import { ConfirmEmailComponent } from "@ui/pages/auth/confirm-email/confirm-email.component"; + +/** Конфигурация маршрутов для модуля аутентификации. */ +export const AUTH_ROUTES: Routes = [ + { + path: "", + component: AuthComponent, + children: [ + { + path: "", + pathMatch: "full", + redirectTo: "login", + }, + { + path: "login", + component: LoginComponent, + }, + { + path: "register", + component: RegisterComponent, + }, + { + path: "verification/email", + component: EmailVerificationComponent, + }, + { + path: "reset_password/send_email", + component: ResetPasswordComponent, + }, + { + path: "reset_password", + component: SetPasswordComponent, + }, + { + path: "reset_password/confirm", + component: ConfirmPasswordResetComponent, + }, + ], + }, + { + path: "verification", + component: ConfirmEmailComponent, + }, +]; diff --git a/projects/social_platform/src/app/ui/routes/chat/chat-direct.routes.ts b/projects/social_platform/src/app/ui/routes/chat/chat-direct.routes.ts new file mode 100644 index 000000000..353cd6d33 --- /dev/null +++ b/projects/social_platform/src/app/ui/routes/chat/chat-direct.routes.ts @@ -0,0 +1,17 @@ +/** @format */ + +import { Routes } from "@angular/router"; +import { ChatDirectComponent } from "@ui/pages/chat/chat-direct/chat-direct.component"; +import { ChatDirectResolver } from "@ui/pages/chat/chat-direct/chat-direct.resolver"; + +/** Конфигурация маршрутов для модуля прямого чата. */ +export const CHAT_DIRECT_ROUTES: Routes = [ + { + // Корневой маршрут модуля - отображает компонент прямого чата + path: "", + component: ChatDirectComponent, + resolve: { + data: ChatDirectResolver, // Предварительно загружает данные чата + }, + }, +]; diff --git a/projects/social_platform/src/app/ui/routes/chat/chat.routes.ts b/projects/social_platform/src/app/ui/routes/chat/chat.routes.ts new file mode 100644 index 000000000..8cbe18f91 --- /dev/null +++ b/projects/social_platform/src/app/ui/routes/chat/chat.routes.ts @@ -0,0 +1,33 @@ +/** @format */ + +import { Routes } from "@angular/router"; +import { ChatGroupsResolver } from "@ui/pages/chat/chat-groups.resolver"; +import { ChatComponent } from "@ui/pages/chat/chat.component"; +import { ChatResolver } from "@ui/pages/chat/chat.resolver"; + +/** Маршруты для модуля чатов. */ +export const CHAT_ROUTES: Routes = [ + { + path: "", + pathMatch: "full", + redirectTo: "directs", + }, + { + path: "directs", + component: ChatComponent, + resolve: { + data: ChatResolver, + }, + }, + { + path: "groups", + component: ChatComponent, + resolve: { + data: ChatGroupsResolver, + }, + }, + { + path: ":chatId", + loadChildren: () => import("../chat/chat-direct.routes").then(c => c.CHAT_DIRECT_ROUTES), + }, +]; diff --git a/projects/social_platform/src/app/ui/routes/courses/course-detail.routes.ts b/projects/social_platform/src/app/ui/routes/courses/course-detail.routes.ts new file mode 100644 index 000000000..f6eced339 --- /dev/null +++ b/projects/social_platform/src/app/ui/routes/courses/course-detail.routes.ts @@ -0,0 +1,29 @@ +/** @format */ + +import type { Routes } from "@angular/router"; +import { LessonGuard } from "@core/lib/guards/lesson/lesson.guard"; +import { CourseDetailComponent } from "@ui/pages/courses/detail/course-detail.component"; +import { CoursesDetailResolver } from "@ui/pages/courses/detail/course-detail.resolver"; +import { CourseInfoComponent } from "@ui/pages/courses/detail/info/info.component"; + +export const COURSE_DETAIL_ROUTES: Routes = [ + { + path: "", + component: CourseDetailComponent, + runGuardsAndResolvers: "always", + resolve: { + data: CoursesDetailResolver, + }, + children: [ + { + path: "", + component: CourseInfoComponent, + }, + { + path: "lesson", + loadChildren: () => import("./lesson.routes").then(m => m.LESSON_ROUTES), + canActivate: [LessonGuard], + }, + ], + }, +]; diff --git a/projects/social_platform/src/app/ui/routes/courses/courses.routes.ts b/projects/social_platform/src/app/ui/routes/courses/courses.routes.ts new file mode 100644 index 000000000..e8b9b6bcb --- /dev/null +++ b/projects/social_platform/src/app/ui/routes/courses/courses.routes.ts @@ -0,0 +1,33 @@ +/** @format */ + +import { Routes } from "@angular/router"; +import { CoursesListComponent } from "../../pages/courses/list/list.component"; +import { CoursesComponent } from "../../pages/courses/courses.component"; +import { CoursesResolver } from "../../pages/courses/courses.resolver"; + +/** Конфигурация маршрутов для модуля карьерных траекторий. */ + +export const COURSES_ROUTES: Routes = [ + { + path: "", + component: CoursesComponent, + children: [ + { + path: "", + redirectTo: "all", + pathMatch: "full", + }, + { + path: "all", + component: CoursesListComponent, + resolve: { + data: CoursesResolver, + }, + }, + ], + }, + { + path: ":courseId", + loadChildren: () => import("./course-detail.routes").then(c => c.COURSE_DETAIL_ROUTES), + }, +]; diff --git a/projects/social_platform/src/app/ui/routes/courses/lesson.routes.ts b/projects/social_platform/src/app/ui/routes/courses/lesson.routes.ts new file mode 100644 index 000000000..64a3588e7 --- /dev/null +++ b/projects/social_platform/src/app/ui/routes/courses/lesson.routes.ts @@ -0,0 +1,23 @@ +/** @format */ + +import type { Routes } from "@angular/router"; +import { LessonComponent } from "../../pages/courses/lesson/lesson.component"; +import { TaskCompleteComponent } from "../../pages/courses/lesson/complete/complete.component"; +import { lessonDetailResolver } from "../../pages/courses/lesson/lesson.resolver"; + +/** Конфигурация маршрутов для модуля уроков. */ +export const LESSON_ROUTES: Routes = [ + { + path: ":lessonId", + component: LessonComponent, + resolve: { + data: lessonDetailResolver, + }, + children: [ + { + path: "results", + component: TaskCompleteComponent, + }, + ], + }, +]; diff --git a/projects/social_platform/src/app/ui/routes/error/error.routes.ts b/projects/social_platform/src/app/ui/routes/error/error.routes.ts new file mode 100644 index 000000000..351a01886 --- /dev/null +++ b/projects/social_platform/src/app/ui/routes/error/error.routes.ts @@ -0,0 +1,24 @@ +/** @format */ + +import { Routes } from "@angular/router"; +import { ErrorCodeComponent } from "@ui/pages/error/error-code/error-code.component"; +import { ErrorComponent } from "@ui/pages/error/error.component"; +import { ErrorNotFoundComponent } from "@ui/pages/error/not-found/error-not-found.component"; + +/** Конфигурация маршрутов для модуля ошибок. */ +export const ERROR_ROUTES: Routes = [ + { + path: "", + component: ErrorComponent, // Родительский компонент-контейнер + children: [ + { + path: "404", // Статический маршрут для 404 ошибки + component: ErrorNotFoundComponent, + }, + { + path: ":code", // Динамический маршрут для любого кода ошибки + component: ErrorCodeComponent, + }, + ], + }, +]; diff --git a/projects/social_platform/src/app/ui/routes/feed/feed.routes.ts b/projects/social_platform/src/app/ui/routes/feed/feed.routes.ts new file mode 100644 index 000000000..6c2597222 --- /dev/null +++ b/projects/social_platform/src/app/ui/routes/feed/feed.routes.ts @@ -0,0 +1,16 @@ +/** @format */ + +import { Routes } from "@angular/router"; +import { FeedComponent } from "@ui/pages/feed/feed.component"; +import { FeedResolver } from "@ui/pages/feed/feed.resolver"; + +/** Маршруты для модуля ленты новостей. */ +export const FEED_ROUTES: Routes = [ + { + path: "", // Корневой путь модуля ленты + component: FeedComponent, // Основной компонент для отображения + resolve: { + data: FeedResolver, // Предварительная загрузка данных через резолвер + }, + }, +]; diff --git a/projects/social_platform/src/app/ui/routes/kanban/kanban-detail.routes.ts b/projects/social_platform/src/app/ui/routes/kanban/kanban-detail.routes.ts new file mode 100644 index 000000000..e3c4aa43a --- /dev/null +++ b/projects/social_platform/src/app/ui/routes/kanban/kanban-detail.routes.ts @@ -0,0 +1,17 @@ +/** @format */ + +import { Routes } from "@angular/router"; +// import { KanbanArhiveComponent } from "@ui/pages/projects/detail/kanban/pages/archive/kanban-archive.component"; +// import { KanbanBoardComponent } from "@ui/pages/projects/detail/kanban/pages/board/kanban-board.component"; + +export const KANBAN_DETAIL_ROUTES: Routes = [ + // { path: "", pathMatch: "full", redirectTo: "board" }, + // { + // path: "board", + // component: KanbanBoardComponent, + // }, + // { + // path: "archive", + // component: KanbanArhiveComponent, + // }, +]; diff --git a/projects/social_platform/src/app/ui/routes/kanban/kanban.routes.ts b/projects/social_platform/src/app/ui/routes/kanban/kanban.routes.ts new file mode 100644 index 000000000..b8b1c234b --- /dev/null +++ b/projects/social_platform/src/app/ui/routes/kanban/kanban.routes.ts @@ -0,0 +1,10 @@ +/** @format */ + +import { Routes } from "@angular/router"; + +export const KANBAN_ROUTES: Routes = [ + // { + // path: ":kanbanId", + // loadChildren: () => import("./kanban-detail.routes").then(c => c.KANBAN_DETAIL_ROUTES), + // }, +]; diff --git a/projects/social_platform/src/app/ui/routes/office/office.routes.ts b/projects/social_platform/src/app/ui/routes/office/office.routes.ts new file mode 100644 index 000000000..358de3b57 --- /dev/null +++ b/projects/social_platform/src/app/ui/routes/office/office.routes.ts @@ -0,0 +1,80 @@ +/** @format */ + +import { Routes } from "@angular/router"; +import { OfficeComponent } from "../../pages/office/office.component"; +import { ProfileEditComponent } from "../../pages/profile/edit/edit.component"; +import { MembersComponent } from "@ui/pages/members/members.component"; +import { MembersResolver } from "@ui/pages/members/members.resolver"; + +/** Конфигурация маршрутов для модуля офиса. */ +export const OFFICE_ROUTES: Routes = [ + { + path: "onboarding", + loadChildren: () => import("../onboarding/onboarding.routes").then(c => c.ONBOARDING_ROUTES), + }, + { + path: "", + component: OfficeComponent, + children: [ + { + path: "", + pathMatch: "full", + redirectTo: "program", + }, + { + path: "feed", + loadChildren: () => import("../feed/feed.routes").then(c => c.FEED_ROUTES), + }, + { + path: "vacancies", + loadChildren: () => import("../vacancy/vacancies.routes").then(c => c.VACANCIES_ROUTES), + }, + { + path: "projects", + loadChildren: () => import("../projects/projects.routes").then(c => c.PROJECTS_ROUTES), + }, + { + path: "program", + loadChildren: () => import("../program/program.routes").then(c => c.PROGRAM_ROUTES), + }, + // { + // path: "chats", + // loadChildren: () => import("../chat/chat.routes").then(c => c.CHAT_ROUTES), + // }, + { + path: "courses", + loadChildren: () => import("../courses/courses.routes").then(c => c.COURSES_ROUTES), + }, + { + path: "members", + component: MembersComponent, + resolve: { + data: MembersResolver, + }, + }, + { + path: "profile/edit", + component: ProfileEditComponent, + }, + { + path: "profile/:id", + loadChildren: () => + import("../profile/profile-detail.routes").then(c => c.PROFILE_DETAIL_ROUTES), + }, + { + path: "courses", + loadChildren: () => + import("../courses/course-detail.routes").then(c => c.COURSE_DETAIL_ROUTES), + }, + { + path: "vacancies", + loadChildren: () => + import("../vacancy/vacancies-detail.routes").then(c => c.VACANCIES_DETAIL_ROUTES), + }, + { + path: "**", + redirectTo: "/error/404", + }, + ], + }, +]; diff --git a/projects/social_platform/src/app/ui/routes/onboarding/onboarding.routes.ts b/projects/social_platform/src/app/ui/routes/onboarding/onboarding.routes.ts new file mode 100644 index 000000000..53dc2858a --- /dev/null +++ b/projects/social_platform/src/app/ui/routes/onboarding/onboarding.routes.ts @@ -0,0 +1,42 @@ +/** @format */ + +import { Routes } from "@angular/router"; +import { StageOneResolver } from "../../pages/onboarding/stage-one/stage-one.resolver"; +import { OnboardingStageTwoComponent } from "../../pages/onboarding/stage-two/stage-two.component"; +import { StageTwoResolver } from "../../pages/onboarding/stage-two/stage-two.resolver"; +import { OnboardingComponent } from "@ui/pages/onboarding/onboarding.component"; +import { OnboardingStageZeroComponent } from "@ui/pages/onboarding/stage-zero/stage-zero.component"; +import { OnboardingStageOneComponent } from "@ui/pages/onboarding/stage-one/stage-one.component"; +import { OnboardingStageThreeComponent } from "@ui/pages/onboarding/stage-three/stage-three.component"; + +/** Конфигурация маршрутов для онбординга. */ +export const ONBOARDING_ROUTES: Routes = [ + { + path: "", + component: OnboardingComponent, + children: [ + { + path: "stage-0", + component: OnboardingStageZeroComponent, + }, + { + path: "stage-1", + component: OnboardingStageOneComponent, + resolve: { + data: StageOneResolver, + }, + }, + { + path: "stage-2", + component: OnboardingStageTwoComponent, + resolve: { + data: StageTwoResolver, + }, + }, + { + path: "stage-3", + component: OnboardingStageThreeComponent, + }, + ], + }, +]; diff --git a/projects/social_platform/src/app/ui/routes/profile/profile-detail.routes.ts b/projects/social_platform/src/app/ui/routes/profile/profile-detail.routes.ts new file mode 100644 index 000000000..c59e4fc30 --- /dev/null +++ b/projects/social_platform/src/app/ui/routes/profile/profile-detail.routes.ts @@ -0,0 +1,39 @@ +/** @format */ + +import { Routes } from "@angular/router"; +import { ProfileDetailResolver } from "../../pages/profile/detail/profile-detail.resolver"; +import { ProfileMainComponent } from "../../pages/profile/detail/main/main.component"; +import { ProfileMainResolver } from "../../pages/profile/detail/main/main.resolver"; +import { DeatilComponent } from "@ui/widgets/detail/detail.component"; +import { ProfileNewsComponent } from "@ui/pages/profile/detail/profile-news/profile-news.component"; +import { ProgramDetailMainUIInfoService } from "@api/program/facades/detail/ui/program-detail-main-ui-info.service"; + +/** Маршруты детальной страницы профиля: информация, новости, проекты. */ +export const PROFILE_DETAIL_ROUTES: Routes = [ + { + path: "", + component: DeatilComponent, + providers: [ProgramDetailMainUIInfoService], + resolve: { + data: ProfileDetailResolver, + }, + // Без этого Angular Router не перезапускает резолвер при смене :id в SPA-навигации + // между профилями (тот же компонент, разные params) — на экране остаются данные + // предыдущего юзера, applyInitProfile не вызывается. + runGuardsAndResolvers: "always", + data: { listType: "profile" }, + children: [ + { + path: "", + component: ProfileMainComponent, + }, + { + path: "news/:newsId", + component: ProfileNewsComponent, + resolve: { + data: ProfileMainResolver, + }, + }, + ], + }, +]; diff --git a/projects/social_platform/src/app/ui/routes/program/detail.routes.ts b/projects/social_platform/src/app/ui/routes/program/detail.routes.ts new file mode 100644 index 000000000..c3f8e3907 --- /dev/null +++ b/projects/social_platform/src/app/ui/routes/program/detail.routes.ts @@ -0,0 +1,62 @@ +/** @format */ + +import { Routes } from "@angular/router"; +import { ProgramListComponent } from "../../pages/program/detail/list/list.component"; +import { ProgramDetailResolver } from "../../pages/program/detail/detail.resolver"; +import { DeatilComponent } from "@ui/widgets/detail/detail.component"; +import { ProgramDetailMainComponent } from "@ui/pages/program/detail/main/main.component"; +import { ProgramProjectsResolver } from "@ui/pages/program/detail/list/projects.resolver"; +import { ProgramMembersResolver } from "@ui/pages/program/detail/list/members.resolver"; +import { ProgramRegisterComponent } from "@ui/pages/program/detail/register/register.component"; +import { ProgramRegisterResolver } from "@ui/pages/program/detail/register/register.resolver"; +import { ProgramDetailMainUIInfoService } from "@api/program/facades/detail/ui/program-detail-main-ui-info.service"; + +/** Маршруты детальной страницы программы: информация, проекты, участники, регистрация. */ +export const PROGRAM_DETAIL_ROUTES: Routes = [ + { + path: "", + component: DeatilComponent, + providers: [ProgramDetailMainUIInfoService], + resolve: { + data: ProgramDetailResolver, + }, + // Перезапуск резолвера при смене :programId в SPA-навигации между программами + // (иначе остаются данные предыдущей программы). + runGuardsAndResolvers: "always", + data: { listType: "program" }, + children: [ + { + path: "", + component: ProgramDetailMainComponent, + }, + { + path: "projects", + component: ProgramListComponent, + resolve: { + data: ProgramProjectsResolver, + }, + data: { listType: "projects" }, + }, + { + path: "members", + component: ProgramListComponent, + resolve: { + data: ProgramMembersResolver, + }, + data: { listType: "members" }, + }, + { + path: "projects-rating", + component: ProgramListComponent, + data: { listType: "rating" }, + }, + ], + }, + { + path: "register", + component: ProgramRegisterComponent, + resolve: { + data: ProgramRegisterResolver, + }, + }, +]; diff --git a/projects/social_platform/src/app/ui/routes/program/program.routes.ts b/projects/social_platform/src/app/ui/routes/program/program.routes.ts new file mode 100644 index 000000000..ba3996e76 --- /dev/null +++ b/projects/social_platform/src/app/ui/routes/program/program.routes.ts @@ -0,0 +1,27 @@ +/** @format */ + +import { Routes } from "@angular/router"; +import { ProgramComponent } from "../../pages/program/program.component"; +import { ProgramMainComponent } from "../../pages/program/main/main.component"; +/** Конфигурация маршрутов для модуля "Программы". */ +export const PROGRAM_ROUTES: Routes = [ + { + path: "", + component: ProgramComponent, + children: [ + { + path: "", + pathMatch: "full", + redirectTo: "all", + }, + { + path: "all", + component: ProgramMainComponent, + }, + ], + }, + { + path: ":programId", + loadChildren: () => import("./detail.routes").then(c => c.PROGRAM_DETAIL_ROUTES), + }, +]; diff --git a/projects/social_platform/src/app/ui/routes/projects/detail.routes.ts b/projects/social_platform/src/app/ui/routes/projects/detail.routes.ts new file mode 100644 index 000000000..1516fea70 --- /dev/null +++ b/projects/social_platform/src/app/ui/routes/projects/detail.routes.ts @@ -0,0 +1,83 @@ +/** @format */ + +import { Routes } from "@angular/router"; +import { ProjectInfoComponent } from "../../pages/projects/detail/info/info.component"; +import { ProjectInfoResolver } from "../../pages/projects/detail/info/info.resolver"; +import { ProjectResponsesResolver } from "../../pages/projects/detail/work-section/responses.resolver"; +import { ProjectChatComponent } from "../../pages/projects/detail/chat/chat.component"; +import { ProjectTeamComponent } from "../../pages/projects/detail/team/team.component"; +import { ProjectVacanciesComponent } from "../../pages/projects/detail/vacancies/vacancies.component"; +import { DeatilComponent } from "@ui/widgets/detail/detail.component"; +import { ProjectWorkSectionComponent } from "../../pages/projects/detail/work-section/work-section.component"; +// import { KanbanBoardResolver } from "../../pages/projects/detail/kanban/kanban.resolver"; +// import { KanbanBoardGuard } from "../../../../../../core/src/lib/guards/kanban/kanban.guard"; +// import { KanbanComponent } from "../../pages/projects/detail/kanban/kanban.component"; +import { ProjectDetailResolver } from "@ui/pages/projects/detail/detail.resolver"; +import { NewsDetailComponent } from "@ui/pages/projects/detail/news-detail/news-detail.component"; +import { NewsDetailResolver } from "@ui/pages/projects/detail/news-detail/news-detail.resolver"; +import { ProjectChatResolver } from "@ui/pages/projects/detail/chat/chat.resolver"; +import { ProgramDetailMainUIInfoService } from "@api/program/facades/detail/ui/program-detail-main-ui-info.service"; + +/** Маршруты детальной страницы проекта: info, chat, team, vacancies, news. */ +export const PROJECT_DETAIL_ROUTES: Routes = [ + { + path: "", + component: DeatilComponent, + providers: [ProgramDetailMainUIInfoService], + resolve: { + data: ProjectDetailResolver, + }, + // Перезапуск резолвера при смене :projectId в SPA-навигации между проектами + // (иначе остаются данные предыдущего проекта). + runGuardsAndResolvers: "always", + data: { listType: "project" }, + children: [ + { + path: "", + component: ProjectInfoComponent, + resolve: { + data: ProjectInfoResolver, + }, + children: [ + { + path: "news/:newsId", + component: NewsDetailComponent, + resolve: { + data: NewsDetailResolver, + }, + }, + ], + }, + { + path: "vacancies", + component: ProjectVacanciesComponent, + }, + { + path: "team", + component: ProjectTeamComponent, + }, + { + path: "work-section", + component: ProjectWorkSectionComponent, + resolve: { + data: ProjectResponsesResolver, + }, + }, + // { + // path: "kanban", + // canActivate: [KanbanBoardGuard], + // component: KanbanComponent, + // resolve: { data: KanbanBoardResolver }, + // loadChildren: () => import("../kanban/kanban.routes").then(c => c.KANBAN_ROUTES), + // runGuardsAndResolvers: "always", + // }, + { + path: "chat", + component: ProjectChatComponent, + resolve: { + data: ProjectChatResolver, + }, + }, + ], + }, +]; diff --git a/projects/social_platform/src/app/ui/routes/projects/projects.routes.ts b/projects/social_platform/src/app/ui/routes/projects/projects.routes.ts new file mode 100644 index 000000000..2707264f9 --- /dev/null +++ b/projects/social_platform/src/app/ui/routes/projects/projects.routes.ts @@ -0,0 +1,68 @@ +/** @format */ + +import { Routes } from "@angular/router"; +import { ProjectsComponent } from "../../pages/projects/projects.component"; +import { ProjectsResolver } from "../../pages/projects/projects.resolver"; +import { ProjectsListComponent } from "../../pages/projects/list/list.component"; +import { ProjectsMyResolver } from "../../pages/projects/list/my.resolver"; +import { ProjectsAllResolver } from "../../pages/projects/list/all.resolver"; +import { ProjectEditComponent } from "../../pages/projects/edit/edit.component"; +import { ProjectEditResolver } from "../../pages/projects/edit/edit.resolver"; +import { ProjectEditRequiredGuard } from "../../../../../../core/src/lib/guards/projects-edit/projects-edit.guard"; +import { DashboardProjectsComponent } from "../../pages/projects/dashboard/dashboard.component"; + +/** Маршруты модуля проектов: dashboard, список, редактирование, детали (lazy). */ +export const PROJECTS_ROUTES: Routes = [ + { + path: "", + component: ProjectsComponent, + children: [ + { + path: "", + pathMatch: "full", + redirectTo: "dashboard", + }, + { + path: "dashboard", + component: DashboardProjectsComponent, + resolve: { + data: ProjectsResolver, + }, + }, + { + path: "my", + component: ProjectsListComponent, + resolve: { + data: ProjectsMyResolver, + }, + }, + { + path: "subscriptions", + component: ProjectsListComponent, + }, + { + path: "invites", + component: ProjectsListComponent, + }, + { + path: "all", + component: ProjectsListComponent, + resolve: { + data: ProjectsAllResolver, + }, + }, + ], + }, + { + path: ":projectId/edit", + component: ProjectEditComponent, + resolve: { + data: ProjectEditResolver, + }, + canActivate: [ProjectEditRequiredGuard], + }, + { + path: ":projectId", + loadChildren: () => import("./detail.routes").then(c => c.PROJECT_DETAIL_ROUTES), + }, +]; diff --git a/projects/social_platform/src/app/ui/routes/vacancy/list.routes.ts b/projects/social_platform/src/app/ui/routes/vacancy/list.routes.ts new file mode 100644 index 000000000..e23b3c682 --- /dev/null +++ b/projects/social_platform/src/app/ui/routes/vacancy/list.routes.ts @@ -0,0 +1,16 @@ +/** @format */ + +import { Routes } from "@angular/router"; +import { VacanciesListComponent } from "@ui/pages/vacancies/list/list.component"; +import { VacanciesMyResolver } from "@ui/pages/vacancies/list/my.resolver"; + +/** Конфигурация маршрутов для страницы "Мои отклики". */ +export const VACANCY_LIST_ROUTES: Routes = [ + { + path: "", + component: VacanciesListComponent, + resolve: { + data: VacanciesMyResolver, + }, + }, +]; diff --git a/projects/social_platform/src/app/ui/routes/vacancy/vacancies-detail.routes.ts b/projects/social_platform/src/app/ui/routes/vacancy/vacancies-detail.routes.ts new file mode 100644 index 000000000..659948a26 --- /dev/null +++ b/projects/social_platform/src/app/ui/routes/vacancy/vacancies-detail.routes.ts @@ -0,0 +1,22 @@ +/** @format */ + +import { VacancyInfoComponent } from "../../pages/vacancies/detail/info/info.component"; +import { VacanciesDetailComponent } from "../../pages/vacancies/detail/vacancies-detail.component"; +import { VacanciesDetailResolver } from "../../pages/vacancies/detail/vacancies-detail.resolver"; + +/** Конфигурация маршрутов для детального просмотра вакансии. */ +export const VACANCIES_DETAIL_ROUTES = [ + { + path: "", + component: VacanciesDetailComponent, + resolve: { + data: VacanciesDetailResolver, + }, + children: [ + { + path: "", + component: VacancyInfoComponent, + }, + ], + }, +]; diff --git a/projects/social_platform/src/app/ui/routes/vacancy/vacancies.routes.ts b/projects/social_platform/src/app/ui/routes/vacancy/vacancies.routes.ts new file mode 100644 index 000000000..63af6030c --- /dev/null +++ b/projects/social_platform/src/app/ui/routes/vacancy/vacancies.routes.ts @@ -0,0 +1,36 @@ +/** @format */ + +import { Routes } from "@angular/router"; +import { VacanciesListComponent } from "@ui/pages/vacancies/list/list.component"; +import { VacanciesComponent } from "@ui/pages/vacancies/vacancies.component"; +import { VacanciesResolver } from "@ui/pages/vacancies/vacancies.resolver"; + +/** Маршруты для модуля вакансий. */ +export const VACANCIES_ROUTES: Routes = [ + { + path: "", + component: VacanciesComponent, // Корневой компонент с навигационными вкладками + children: [ + { + path: "", + redirectTo: "all", // Перенаправление на список всех вакансий по умолчанию + pathMatch: "full", + }, + { + path: "my", + loadChildren: () => import("./list.routes").then(c => c.VACANCY_LIST_ROUTES), + }, + { + path: "all", + component: VacanciesListComponent, // Компонент списка всех вакансий + resolve: { + data: VacanciesResolver, // Резолвер для предзагрузки данных вакансий + }, + }, + ], + }, + { + path: ":vacancyId", + loadChildren: () => import("./vacancies-detail.routes").then(c => c.VACANCIES_DETAIL_ROUTES), + }, +]; diff --git a/projects/social_platform/src/app/office/services/notification.service.spec.ts b/projects/social_platform/src/app/ui/services/notification/notification.service.spec.ts similarity index 100% rename from projects/social_platform/src/app/office/services/notification.service.spec.ts rename to projects/social_platform/src/app/ui/services/notification/notification.service.spec.ts diff --git a/projects/social_platform/src/app/ui/services/notification/notification.service.ts b/projects/social_platform/src/app/ui/services/notification/notification.service.ts new file mode 100644 index 000000000..f81062823 --- /dev/null +++ b/projects/social_platform/src/app/ui/services/notification/notification.service.ts @@ -0,0 +1,19 @@ +/** @format */ + +import { Injectable } from "@angular/core"; +import { BehaviorSubject, map } from "rxjs"; +import { Notification } from "@domain/other/notification.model"; + +/** Сервис для управления уведомлениями пользователя. */ +@Injectable({ + providedIn: "root", +}) +export class NotificationService { + constructor() {} + + notifications = new BehaviorSubject([]); + + hasNotifications = this.notifications + .asObservable() + .pipe(map(notifications => notifications.filter(notification => !notification.readAt).length)); +} diff --git a/projects/social_platform/src/app/ui/services/snackbar.service.spec.ts b/projects/social_platform/src/app/ui/services/snackbar.service.spec.ts deleted file mode 100644 index 10b343f8d..000000000 --- a/projects/social_platform/src/app/ui/services/snackbar.service.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; - -import { SnackbarService } from "./snackbar.service"; - -describe("SnackbarService", () => { - let service: SnackbarService; - - beforeEach(() => { - TestBed.configureTestingModule({}); - service = TestBed.inject(SnackbarService); - }); - - it("should be created", () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/projects/social_platform/src/app/ui/services/snackbar.service.ts b/projects/social_platform/src/app/ui/services/snackbar.service.ts deleted file mode 100644 index e8be8bb55..000000000 --- a/projects/social_platform/src/app/ui/services/snackbar.service.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** @format */ - -import { Injectable } from "@angular/core"; -import { distinctUntilChanged, Subject } from "rxjs"; -import { Snack } from "@ui/models/snack.model"; -import { nanoid } from "nanoid"; - -/** - * Сервис для управления всплывающими уведомлениями (snackbar). - * Предоставляет методы для отображения различных типов уведомлений. - * - * Методы: - * - success: показывает успешное уведомление (зеленое) - * - error: показывает уведомление об ошибке (красное) - * - info: показывает информационное уведомление (синее) - * - * Все методы принимают: - * - text: текст уведомления - * - options: настройки (timeout - время отображения в мс, по умолчанию 5000) - */ -@Injectable({ - providedIn: "root", -}) -export class SnackbarService { - constructor() {} - - /** Subject для управления потоком уведомлений */ - private readonly snacks$ = new Subject(); - - /** Observable поток уведомлений для подписки компонентами */ - snacks = this.snacks$.asObservable().pipe(distinctUntilChanged()); - - /** - * Показывает успешное уведомление - * @param text - текст уведомления - * @param options - настройки отображения - */ - success(text: string, options: { timeout: number } = { timeout: 5000 }): void { - this.snacks$.next({ id: nanoid(), text, timeout: options.timeout, type: "success" }); - } - - /** - * Показывает уведомление об ошибке - * @param text - текст уведомления - * @param options - настройки отображения - */ - error(text: string, options: { timeout: number } = { timeout: 5000 }): void { - this.snacks$.next({ id: nanoid(), text, timeout: options.timeout, type: "error" }); - } - - /** - * Показывает информационное уведомление - * @param text - текст уведомления - * @param options - настройки отображения - */ - info(text: string, options: { timeout: number } = { timeout: 5000 }): void { - this.snacks$.next({ id: nanoid(), text, timeout: options.timeout, type: "info" }); - } -} diff --git a/projects/social_platform/src/app/ui/widgets/chat-window/chat-message/chat-message.component.html b/projects/social_platform/src/app/ui/widgets/chat-window/chat-message/chat-message.component.html new file mode 100644 index 000000000..51fdb5d46 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/chat-window/chat-message/chat-message.component.html @@ -0,0 +1,83 @@ + + +
    +
    + +
    +
    +
    + {{ chatMessage().author.firstName }} {{ chatMessage().author.lastName }} +
    +
    + {{ chatMessage().createdAt | dayjs: "format" : "HH:mm" }} +
    +
    + @if (chatMessage().replyTo) { +
    +
    + {{ chatMessage().replyTo?.author?.firstName }} + {{ chatMessage().replyTo?.author?.lastName }} +
    +

    + {{ chatMessage().replyTo?.text }} +

    +
    + } +
      + @for (file of chatMessage().files; track chatMessage().id) { +
    • + +
    • + } +
    +
    {{ chatMessage().text }}
    +
    +
    +
    + +
    +
    +@if (profile()) { +
      +
    • скопировать
    • +
    • ответить
    • + @if (profile()!.id === chatMessage().author.id) { + +
    • редактировать
    • +
    • + удалить +
    • +
      + } +
    +} diff --git a/projects/social_platform/src/app/ui/components/chat-message/chat-message.component.scss b/projects/social_platform/src/app/ui/widgets/chat-window/chat-message/chat-message.component.scss similarity index 100% rename from projects/social_platform/src/app/ui/components/chat-message/chat-message.component.scss rename to projects/social_platform/src/app/ui/widgets/chat-window/chat-message/chat-message.component.scss diff --git a/projects/social_platform/src/app/ui/widgets/chat-window/chat-message/chat-message.component.spec.ts b/projects/social_platform/src/app/ui/widgets/chat-window/chat-message/chat-message.component.spec.ts new file mode 100644 index 000000000..33d6bc0db --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/chat-window/chat-message/chat-message.component.spec.ts @@ -0,0 +1,68 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ChatMessageComponent } from "./chat-message.component"; +import { ChatMessage } from "@domain/chat/chat-message.model"; +import { of } from "rxjs"; +import { provideRouter } from "@angular/router"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { OverlayModule } from "@angular/cdk/overlay"; +import { ProjectSubscriptionRepositoryPort } from "@domain/project/ports/project-subscription.repository.port"; +import { API_URL } from "@corelib"; + +describe("ChatMessageComponent", () => { + let component: ChatMessageComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const authSpy = { + fetchProfile: of({ id: 1, firstName: "Test", lastName: "User" }), + fetchUserRoles: of([]), + fetchChangeableRoles: of([]), + fetchLeaderProjects: of({ results: [], count: 0 }), + }; + + await TestBed.configureTestingModule({ + imports: [OverlayModule, ChatMessageComponent], + providers: [ + { provide: AuthRepositoryPort, useValue: authSpy }, + { provide: API_URL, useValue: "" }, + { + provide: ProjectSubscriptionRepositoryPort, + useValue: { getSubscriptions: of({ results: [], count: 0 }) }, + }, + provideRouter([]), + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ChatMessageComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput("chatMessage", { + id: 1, + author: { + id: 1, + firstName: "Test", + lastName: "User", + isOnline: false, + relations: { isOnline: false, progress: 100 }, + personal: { avatar: "" }, + skills: [], + specializations: [], + }, + isEdited: false, + isRead: false, + isDeleted: false, + replyTo: null, + text: "", + createdAt: "", + files: [], + } as unknown as ChatMessage); + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/widgets/chat-window/chat-message/chat-message.component.ts b/projects/social_platform/src/app/ui/widgets/chat-window/chat-message/chat-message.component.ts new file mode 100644 index 000000000..eccdba935 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/chat-window/chat-message/chat-message.component.ts @@ -0,0 +1,139 @@ +/** @format */ + +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + ElementRef, + EventEmitter, + OnDestroy, + OnInit, + Output, + inject, + input, + output, + viewChild, +} from "@angular/core"; +import { ChatMessage } from "@domain/chat/chat-message.model"; +import { SnackbarService } from "@domain/shared/snackbar.service"; +import { DomPortal } from "@angular/cdk/portal"; +import { Overlay, OverlayRef } from "@angular/cdk/overlay"; +import { DayjsPipe } from "@corelib"; +import { IconComponent } from "@ui/primitives"; +import { FileItemComponent } from "@ui/primitives/file-item/file-item.component"; +import { AvatarComponent } from "@ui/primitives/avatar/avatar.component"; +import { ClickOutsideModule } from "ng-click-outside"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; +import { ProfileInfoService } from "@api/profile/facades/profile-info.service"; + +/** Компонент сообщения чата с контекстным меню и файловыми вложениями. */ +@Component({ + selector: "app-chat-message", + templateUrl: "./chat-message.component.html", + styleUrl: "./chat-message.component.scss", + imports: [ClickOutsideModule, AvatarComponent, FileItemComponent, IconComponent, DayjsPipe], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ChatMessageComponent implements OnInit, AfterViewInit, OnDestroy { + private readonly logger = inject(LoggerService); + private readonly snackbarService = inject(SnackbarService); + private readonly overlay = inject(Overlay); + private readonly profileInfoSerivce = inject(ProfileInfoService); + + protected readonly profile = this.profileInfoSerivce.profile; + + readonly chatMessage = input.required(); + + readonly reply = output(); + readonly edit = output(); + readonly delete = output(); + + ngOnInit(): void {} + + ngAfterViewInit(): void { + this.overlayRef = this.overlay.create({ + hasBackdrop: false, + }); + this.portal = new DomPortal(this.contextMenu()!); + } + + ngOnDestroy(): void { + this.overlayRef?.detach(); + } + + readonly contextMenu = viewChild>("contextMenu"); + + private overlayRef?: OverlayRef; + private portal?: DomPortal; + + isOpen = false; + + onOpenContextmenu(event: MouseEvent) { + event.preventDefault(); + + this.isOpen = true; + + const contextMenuHeight = this.contextMenu()!.nativeElement.offsetHeight; + + const positionX = event.clientX; + const positionY = + contextMenuHeight + event.clientY > window.innerHeight + ? event.clientY - contextMenuHeight + : event.clientY; + + const positionStrategy = this.overlay + .position() + .global() + .left(positionX + "px") + .top(positionY + "px"); + this.overlayRef?.updatePositionStrategy(positionStrategy); + + !this.overlayRef?.hasAttached() && this.overlayRef?.attach(this.portal); + + this.contextMenu()!.nativeElement.focus(); + } + + onCloseContextmenu() { + this.isOpen = false; + this.overlayRef?.detach(); + } + + onCopyContent(event: MouseEvent) { + event.stopPropagation(); + + this.isOpen = false; + this.overlayRef?.detach(); + + navigator.clipboard.writeText(this.chatMessage().text).then(() => { + this.snackbarService.success("Сообщение скопированно"); + this.logger.debug("Text copied in ChatMessageComponent"); + }); + } + + onDelete(event: MouseEvent) { + event.stopPropagation(); + + this.delete.emit(this.chatMessage().id); + + this.isOpen = false; + this.overlayRef?.detach(); + } + + onReply(event: MouseEvent) { + event.stopPropagation(); + + this.reply.emit(this.chatMessage().id); + + this.isOpen = false; + this.overlayRef?.detach(); + } + + onEdit(event: MouseEvent) { + event.stopPropagation(); + + this.edit.emit(this.chatMessage().id); + + this.isOpen = false; + this.overlayRef?.detach(); + } +} diff --git a/projects/social_platform/src/app/ui/widgets/chat-window/chat-window.component.html b/projects/social_platform/src/app/ui/widgets/chat-window/chat-window.component.html new file mode 100644 index 000000000..6ce7dbc12 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/chat-window/chat-window.component.html @@ -0,0 +1,49 @@ + + +
    + + + + +
    + @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 }} + {{ typingPersons.length - 3 | pluralize: ["человек", "человека", "человек"] }} + } + {{ typingPersons.length | pluralize: ["печатает", "печатают", "печатают"] }} + ... + + } + +
    +
    diff --git a/projects/social_platform/src/app/office/features/chat-window/chat-window.component.scss b/projects/social_platform/src/app/ui/widgets/chat-window/chat-window.component.scss similarity index 100% rename from projects/social_platform/src/app/office/features/chat-window/chat-window.component.scss rename to projects/social_platform/src/app/ui/widgets/chat-window/chat-window.component.scss diff --git a/projects/social_platform/src/app/ui/widgets/chat-window/chat-window.component.spec.ts b/projects/social_platform/src/app/ui/widgets/chat-window/chat-window.component.spec.ts new file mode 100644 index 000000000..d0dc2697d --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/chat-window/chat-window.component.spec.ts @@ -0,0 +1,51 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { ChatWindowComponent } from "./chat-window.component"; +import { ReactiveFormsModule } from "@angular/forms"; +import { of } from "rxjs"; +import { provideRouter } from "@angular/router"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { provideNgxMask } from "ngx-mask"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { API_URL } from "@corelib"; +import { ProjectSubscriptionRepositoryPort } from "@domain/project/ports/project-subscription.repository.port"; + +describe("ChatWindowComponent", () => { + let component: ChatWindowComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const authSpy = { + fetchProfile: of({}), + fetchUserRoles: of([]), + fetchChangeableRoles: of([]), + fetchLeaderProjects: of({ results: [], count: 0 }), + }; + + await TestBed.configureTestingModule({ + imports: [ReactiveFormsModule, HttpClientTestingModule, ChatWindowComponent], + providers: [ + { provide: AuthRepositoryPort, useValue: authSpy }, + { provide: API_URL, useValue: "" }, + { + provide: ProjectSubscriptionRepositoryPort, + useValue: { getSubscriptions: of({ results: [], count: 0 }) }, + }, + provideNgxMask(), + provideRouter([]), + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ChatWindowComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/widgets/chat-window/chat-window.component.ts b/projects/social_platform/src/app/ui/widgets/chat-window/chat-window.component.ts new file mode 100644 index 000000000..c4024517a --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/chat-window/chat-window.component.ts @@ -0,0 +1,247 @@ +/** @format */ + +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + DestroyRef, + ElementRef, + EventEmitter, + inject, + Input, + OnDestroy, + OnInit, + output, + Output, + viewChild, +} from "@angular/core"; +import { + CdkFixedSizeVirtualScroll, + CdkVirtualForOf, + CdkVirtualScrollViewport, +} from "@angular/cdk/scrolling"; +import { ChatMessage } from "@domain/chat/chat-message.model"; +import { MessageInputComponent } from "@ui/widgets/message-input/message-input.component"; +import { FormBuilder, FormGroup, ReactiveFormsModule } from "@angular/forms"; +import { filter, fromEvent, noop, skip, tap, throttleTime } from "rxjs"; +import { ModalService } from "@ui/primitives/modal/modal.service"; +import { PluralizePipe } from "@corelib"; +import { ChatMessageComponent } from "./chat-message/chat-message.component"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { ProfileInfoService } from "@api/profile/facades/profile-info.service"; + +/** Окно чата: список сообщений с виртуальной прокруткой и поле ввода. */ +@Component({ + selector: "app-chat-window", + templateUrl: "./chat-window.component.html", + styleUrl: "./chat-window.component.scss", + imports: [ + CdkVirtualScrollViewport, + CdkFixedSizeVirtualScroll, + CdkVirtualForOf, + ChatMessageComponent, + ReactiveFormsModule, + MessageInputComponent, + PluralizePipe, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ChatWindowComponent implements OnInit, AfterViewInit, OnDestroy { + private readonly destroyRef = inject(DestroyRef); + private readonly fb = inject(FormBuilder); + private readonly modalService = inject(ModalService); + private readonly profileInfoService = inject(ProfileInfoService); + + constructor() { + this.messageForm = this.fb.group({ + messageControl: [{ text: "", filesUrl: [] }], + }); + } + + private _messages: ChatMessage[] = []; + + @Input({ required: true }) set messages(value: ChatMessage[]) { + const messagesIds = this._messages.map(m => m.id); + // Находим новые сообщения + const diff = value.filter(m => { + return messagesIds.indexOf(m.id) < 0; + }); + + const noMessages = !this._messages.length; + this._messages = value; + + // Автопрокрутка к низу для новых сообщений от текущего пользователя или при первой загрузке + if ((diff.length === 1 && diff[0]?.author.id === this.profile()?.id) || noMessages) { + this.scrollToBottom(); + } + + // Настройка наблюдателя для отметок о прочтении + setTimeout(() => { + const elementNode = document.querySelectorAll(".chat__message"); + elementNode.forEach(el => { + this.observer?.observe(el); + }); + }); + } + + get messages(): ChatMessage[] { + return this._messages; + } + + @Input() + typingPersons: { firstName: string; lastName: string; userId: number }[] = []; + + readonly submit = output(); + readonly edit = output(); + readonly delete = output(); + readonly type = output(); + readonly fetch = output(); + readonly read = output(); + + ngOnInit(): void { + // Инициализация отслеживания печатания + this.initTypingSend(); + } + + ngAfterViewInit(): void { + if (this.viewport()) { + // Подписка на события прокрутки для загрузки истории + fromEvent(this.viewport()!.elementRef.nativeElement, "scroll") + .pipe( + skip(1), // Пропуск первого события прокрутки + filter(() => { + const offsetTop = this.viewport()?.measureScrollOffset("top"); + return offsetTop ? offsetTop <= 200 : false; // Загрузка при приближении к верху + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(() => { + this.fetch.emit(); // Запрос дополнительных сообщений + }); + + // Создание наблюдателя пересечений для отметок о прочтении + this.observer = new IntersectionObserver(this.onReadMessage.bind(this), { + root: this.viewport()!.elementRef.nativeElement, + rootMargin: "0px 0px 0px 0px", + threshold: 0, + }); + } + } + + ngOnDestroy(): void { + this.observer?.disconnect(); + } + + protected observer?: IntersectionObserver; + protected readonly profile = this.profileInfoService.profile; + + private readonly messageControlBaseValue = { + text: "", + filesUrl: [], + }; + + messageForm: FormGroup; + editingMessage?: ChatMessage; + replyMessage?: ChatMessage; + + readonly viewport = viewChild(CdkVirtualScrollViewport); + readonly messageInputComponent = viewChild(MessageInputComponent, { read: ElementRef }); + + /** Отправляет событие печатания при изменении текста с задержкой. */ + private initTypingSend(): void { + this.messageForm + .get("messageControl") + ?.valueChanges.pipe( + throttleTime(2000), // Ограничение частоты отправки событий + tap(() => { + this.type.emit(); // Отправка события печатания + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(noop); + } + + /** Прокрутка к низу (двойной setTimeout для виртуальной прокрутки). */ + scrollToBottom(): void { + setTimeout(() => { + this.viewport()?.scrollTo({ bottom: 0 }); + }, 50); + } + + onInputResize(): void { + if (this.viewport() && this.viewport()!.measureScrollOffset("bottom") < 50) + this.scrollToBottom(); + } + + private focusOnInput(): void { + setTimeout(() => { + this.messageInputComponent()?.nativeElement.querySelector("textarea").focus(); + }); + } + + onEditMessage(messageId: number): void { + this.replyMessage = undefined; + this.editingMessage = this.messages.find(message => message.id === messageId); + + this.focusOnInput(); + } + + onReplyMessage(messageId: number): void { + this.editingMessage = undefined; + this.replyMessage = this.messages.find(message => message.id === messageId); + + this.focusOnInput(); + } + + onCancelInput(): void { + this.replyMessage = undefined; + this.editingMessage = undefined; + } + + onSubmitMessage() { + if (!this.messageForm.get("messageControl")?.value.text) return; + + if (this.editingMessage) { + // Редактирование существующего сообщения + this.edit.emit({ + text: this.messageForm.get("messageControl")?.value.text, + id: this.editingMessage.id, + }); + + this.editingMessage = undefined; + } else { + // Отправка нового сообщения + this.submit.emit({ + replyTo: this.replyMessage?.id ?? null, + text: this.messageForm.get("messageControl")?.value.text ?? "", + fileUrls: this.messageForm.get("messageControl")?.value.filesUrl ?? [], + }); + + this.replyMessage = undefined; + } + + // Очистка формы + this.messageForm.get("messageControl")?.setValue(this.messageControlBaseValue); + } + + /** Удаляет сообщение с модалкой подтверждения. */ + onDeleteMessage(messageId: number): void { + const deletedMessage = this.messages.find(message => message.id === messageId); + + this.modalService + .confirmDelete("Вы уверены что хотите удалить сообщение?", `"${deletedMessage?.text}"`) + .pipe(filter(Boolean), takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.delete.emit(messageId); + }); + } + + onReadMessage(entries: IntersectionObserverEntry[]): void { + entries.forEach(e => { + const element = e.target as HTMLElement; + // Отмечаем как прочитанное только непрочитанные сообщения + !Number.parseInt(element.dataset["beenRead"] || "1") && + this.read.emit(Number.parseInt(element.id)); + }); + } +} diff --git a/projects/social_platform/src/app/ui/widgets/cookie-consent/cookie-consent.component.html b/projects/social_platform/src/app/ui/widgets/cookie-consent/cookie-consent.component.html new file mode 100644 index 000000000..5682d7fea --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/cookie-consent/cookie-consent.component.html @@ -0,0 +1,33 @@ + + +@if (visible) { + +} diff --git a/projects/social_platform/src/app/ui/components/cookie-consent/cookie-consent.component.scss b/projects/social_platform/src/app/ui/widgets/cookie-consent/cookie-consent.component.scss similarity index 100% rename from projects/social_platform/src/app/ui/components/cookie-consent/cookie-consent.component.scss rename to projects/social_platform/src/app/ui/widgets/cookie-consent/cookie-consent.component.scss diff --git a/projects/social_platform/src/app/ui/components/cookie-consent/cookie-consent.component.ts b/projects/social_platform/src/app/ui/widgets/cookie-consent/cookie-consent.component.ts similarity index 75% rename from projects/social_platform/src/app/ui/components/cookie-consent/cookie-consent.component.ts rename to projects/social_platform/src/app/ui/widgets/cookie-consent/cookie-consent.component.ts index d251c24fa..f7791fb21 100644 --- a/projects/social_platform/src/app/ui/components/cookie-consent/cookie-consent.component.ts +++ b/projects/social_platform/src/app/ui/widgets/cookie-consent/cookie-consent.component.ts @@ -1,16 +1,16 @@ /** @format */ -import { Component, OnInit } from "@angular/core"; -import { CheckboxComponent } from "@ui/components/checkbox/checkbox.component"; -import { ButtonComponent } from "@ui/components/button/button.component"; -import { AnalyticsService } from "../../../office/services/analytics.service"; +import { ChangeDetectionStrategy, Component, OnInit } from "@angular/core"; +import { AnalyticsService } from "@api/analytics/analytics.service"; +import { ButtonComponent, CheckboxComponent } from "@ui/primitives"; +/** Виджет согласия на cookie. */ @Component({ selector: "app-cookie-consent", templateUrl: "./cookie-consent.component.html", styleUrl: "./cookie-consent.component.scss", - standalone: true, imports: [CheckboxComponent, ButtonComponent], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class CookieConsentComponent implements OnInit { visible = false; diff --git a/projects/social_platform/src/app/ui/widgets/course-about/course-about.component.html b/projects/social_platform/src/app/ui/widgets/course-about/course-about.component.html new file mode 100644 index 000000000..1c4c1ce07 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/course-about/course-about.component.html @@ -0,0 +1,18 @@ + + +
    +
    +

    о курсе

    + +
    + @if (description()) { +
    +

    + @if (descriptionExpandable()) { +
    + {{ readFullDescription() ? "cкрыть" : "подробнее" }} +
    + } +
    + } +
    diff --git a/projects/social_platform/src/app/office/courses/shared/course-about/course-about.component.scss b/projects/social_platform/src/app/ui/widgets/course-about/course-about.component.scss similarity index 100% rename from projects/social_platform/src/app/office/courses/shared/course-about/course-about.component.scss rename to projects/social_platform/src/app/ui/widgets/course-about/course-about.component.scss diff --git a/projects/social_platform/src/app/ui/widgets/course-about/course-about.component.ts b/projects/social_platform/src/app/ui/widgets/course-about/course-about.component.ts new file mode 100644 index 000000000..9972de8eb --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/course-about/course-about.component.ts @@ -0,0 +1,49 @@ +/** @format */ + +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + ElementRef, + inject, + input, + Input, + viewChild, +} from "@angular/core"; +import { IconComponent } from "@uilib"; +import { ParseBreaksPipe, ParseLinksPipe } from "@corelib"; +import { ExpandService } from "@api/expand/expand.service"; + +/** Виджет «о курсе»: описание курса в модалке/блоке. */ +@Component({ + selector: "app-course-about", + templateUrl: "./course-about.component.html", + styleUrl: "./course-about.component.scss", + imports: [IconComponent, ParseBreaksPipe, ParseLinksPipe], + providers: [ExpandService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CourseAboutComponent implements AfterViewInit { + readonly description = input.required(); + private readonly descEl = viewChild("descEl"); + + private readonly expandService = inject(ExpandService); + + protected readonly descriptionExpandable = this.expandService.descriptionExpandable; + protected readonly readFullDescription = this.expandService.readFullDescription; + + ngAfterViewInit(): void { + setTimeout(() => { + this.expandService.checkExpandable("description", true, this.descEl()); + }); + } + + protected onExpandDescription(elem: HTMLElement): void { + this.expandService.onExpand( + "description", + elem, + "expanded", + this.expandService.readFullDescription(), + ); + } +} diff --git a/projects/social_platform/src/app/ui/widgets/detail/approve-skill/approve-skill-people/approve-skill-people.component.html b/projects/social_platform/src/app/ui/widgets/detail/approve-skill/approve-skill-people/approve-skill-people.component.html new file mode 100644 index 000000000..ff7e9d6e6 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/detail/approve-skill/approve-skill-people/approve-skill-people.component.html @@ -0,0 +1,17 @@ + + +
    + @for (approve of approves().slice(0, 3); track $index) { + + } + + @if (approves().length > 3) { +

    + {{ + approves().length + + " " + + (approves().length | pluralize: ["человек", "человека", "человек"]) + }} +

    + } +
    diff --git a/projects/social_platform/src/app/office/shared/approve-skill-people/approve-skill-people.component.scss b/projects/social_platform/src/app/ui/widgets/detail/approve-skill/approve-skill-people/approve-skill-people.component.scss similarity index 100% rename from projects/social_platform/src/app/office/shared/approve-skill-people/approve-skill-people.component.scss rename to projects/social_platform/src/app/ui/widgets/detail/approve-skill/approve-skill-people/approve-skill-people.component.scss diff --git a/projects/social_platform/src/app/ui/widgets/detail/approve-skill/approve-skill-people/approve-skill-people.component.ts b/projects/social_platform/src/app/ui/widgets/detail/approve-skill/approve-skill-people/approve-skill-people.component.ts new file mode 100644 index 000000000..e504048f0 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/detail/approve-skill/approve-skill-people/approve-skill-people.component.ts @@ -0,0 +1,19 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, input, Input } from "@angular/core"; +import { PluralizePipe } from "@corelib"; +import { AvatarComponent } from "@ui/primitives/avatar/avatar.component"; +import { Skill } from "@domain/skills/skill.model"; + +/** Виджет: люди, подтвердившие навык. */ +@Component({ + selector: "app-approve-skill-people", + templateUrl: "./approve-skill-people.component.html", + styleUrl: "./approve-skill-people.component.scss", + imports: [CommonModule, AvatarComponent, PluralizePipe], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ApproveSkillPeopleComponent { + readonly approves = input.required(); +} diff --git a/projects/social_platform/src/app/ui/widgets/detail/approve-skill/approve-skill.component.html b/projects/social_platform/src/app/ui/widgets/detail/approve-skill/approve-skill.component.html new file mode 100644 index 000000000..1ac590cac --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/detail/approve-skill/approve-skill.component.html @@ -0,0 +1,31 @@ + + +
    + {{ skill().name }} +
    + @if (skill().approves.length > 0) { + + } + + {{ isUserApproveSkill(skill()) ? "убрать оценку" : "подтвердить" }} +
    +
    + + + + diff --git a/projects/social_platform/src/app/office/features/approve-skill/approve-skill.component.scss b/projects/social_platform/src/app/ui/widgets/detail/approve-skill/approve-skill.component.scss similarity index 100% rename from projects/social_platform/src/app/office/features/approve-skill/approve-skill.component.scss rename to projects/social_platform/src/app/ui/widgets/detail/approve-skill/approve-skill.component.scss diff --git a/projects/social_platform/src/app/ui/widgets/detail/approve-skill/approve-skill.component.ts b/projects/social_platform/src/app/ui/widgets/detail/approve-skill/approve-skill.component.ts new file mode 100644 index 000000000..c1b98fb5f --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/detail/approve-skill/approve-skill.component.ts @@ -0,0 +1,40 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, inject, input, Input, OnInit } from "@angular/core"; +import { ButtonComponent } from "@ui/primitives"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { ApproveSkillPeopleComponent } from "./approve-skill-people/approve-skill-people.component"; +import { Skill } from "@domain/skills/skill.model"; +import { ApproveskillInfoService } from "./services/approve-skill-info.service"; +import { ApproveSkillUIInfoService } from "./services/approve-skill-ui-info.service"; + +/** Компонент подтверждения навыка пользователя. */ +@Component({ + selector: "app-approve-skill", + styleUrl: "./approve-skill.component.scss", + templateUrl: "./approve-skill.component.html", + imports: [CommonModule, ButtonComponent, ModalComponent, ApproveSkillPeopleComponent], + providers: [ApproveskillInfoService, ApproveSkillUIInfoService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ApproveSkillComponent implements OnInit { + private readonly approveskillInfoService = inject(ApproveskillInfoService); + private readonly approveSkillUIInfoService = inject(ApproveSkillUIInfoService); + + isUserApproveSkill(skill: Skill): boolean { + return this.approveskillInfoService.isUserApproveSkill(skill); + } + + protected readonly approveOwnSkillModal = this.approveSkillUIInfoService.approveOwnSkillModal; + + readonly skill = input.required(); + + ngOnInit(): void { + this.approveskillInfoService.init(); + } + + onToggleApprove(skillId: number, event: Event, skill: Skill) { + this.approveskillInfoService.onToggleApprove(skillId, event, skill); + } +} diff --git a/projects/social_platform/src/app/ui/widgets/detail/approve-skill/services/approve-skill-info.service.ts b/projects/social_platform/src/app/ui/widgets/detail/approve-skill/services/approve-skill-info.service.ts new file mode 100644 index 000000000..e769f4719 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/detail/approve-skill/services/approve-skill-info.service.ts @@ -0,0 +1,113 @@ +/** @format */ + +import { ChangeDetectorRef, DestroyRef, inject, Injectable, Injector } from "@angular/core"; +import { ActivatedRoute } from "@angular/router"; +import { Approve, Skill } from "@domain/skills/skill.model"; +import { map, of, switchMap } from "rxjs"; +import { HttpErrorResponse } from "@angular/common/http"; +import { ApproveSkillUIInfoService } from "./approve-skill-ui-info.service"; +import { ProfileDetailUIInfoService } from "@api/profile/facades/detail/ui/profile-detail-ui-info.service"; +import { UnapproveSkillUseCase } from "@api/skills/use-cases/unapprove-skill.use-case"; +import { ApproveSkillUseCase } from "@api/skills/use-cases/approve-skill.use-case"; +import { ok } from "@domain/shared/result.type"; +import { ProfileInfoService } from "@api/profile/facades/profile-info.service"; +import { takeUntilDestroyed, toObservable } from "@angular/core/rxjs-interop"; + +@Injectable() +export class ApproveskillInfoService { + private readonly route = inject(ActivatedRoute); + private readonly injector = inject(Injector); + private readonly destroyRef = inject(DestroyRef); + private readonly cdr = inject(ChangeDetectorRef); + + private readonly profileInfoService = inject(ProfileInfoService); + private readonly approveSkillUIInfoService = inject(ApproveSkillUIInfoService); + private readonly profileDetailUIInfoService = inject(ProfileDetailUIInfoService); + + private readonly unapproveSkillUseCase = inject(UnapproveSkillUseCase); + private readonly approveSkillUseCase = inject(ApproveSkillUseCase); + + private readonly loggedUserId = this.profileDetailUIInfoService.loggedUserId; + private readonly profile = this.profileInfoService.profile; + + init(): void { + this.profileDetailUIInfoService.applySetLoggedUserId("logged", this.profile()!.id); + } + + // Указатель на то что пользватель подтвердил навык + isUserApproveSkill(skill: Skill): boolean { + return skill.approves.some(approve => approve.confirmedBy.id === this.loggedUserId()); + } + + unapproveSkill(userId: number, skillId: number, skill: Skill): void { + this.unapproveSkillUseCase.execute(userId, skillId).subscribe({ + next: result => { + if (result.ok) { + skill.approves = skill.approves.filter( + approve => approve.confirmedBy.id !== this.loggedUserId(), + ); + + this.cdr.markForCheck(); + } + }, + }); + } + + approveSkill(userId: number, skillId: number, skill: Skill): void { + this.approveSkillUseCase + .execute(userId, skillId) + .pipe( + switchMap(result => { + if (!result.ok) return of(result); + if (result.value.confirmedBy) return of(result); + + return toObservable(this.profile, { injector: this.injector }).pipe( + map(profile => + ok({ + ...result.value, + confirmedBy: { + id: profile!.id, + firstName: profile!.firstName, + lastName: profile!.lastName, + avatar: profile!.personal.avatar, + speciality: profile!.personal.speciality, + v2Speciality: profile!.personal.v2Speciality, + }, + } as Approve), + ), + ); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe({ + next: result => { + if (result.ok) { + this.approveSkillUIInfoService.applyApprovedSkills(skill, result.value); + this.cdr.markForCheck(); + } + }, + error: err => { + if (err instanceof HttpErrorResponse) { + if (err.status === 400) { + this.approveSkillUIInfoService.applyOpenErrorModal(); + } + } + }, + }); + } + + onToggleApprove(skillId: number, event: Event, skill: Skill) { + event.stopPropagation(); + const userId = this.route.snapshot.params["id"]; + + const isApprovedByCurrentUser = skill.approves.some(approve => { + return approve.confirmedBy.id === this.loggedUserId(); + }); + + if (isApprovedByCurrentUser) { + this.unapproveSkill(userId, skillId, skill); + } else { + this.approveSkill(userId, skillId, skill); + } + } +} diff --git a/projects/social_platform/src/app/ui/widgets/detail/approve-skill/services/approve-skill-ui-info.service.ts b/projects/social_platform/src/app/ui/widgets/detail/approve-skill/services/approve-skill-ui-info.service.ts new file mode 100644 index 000000000..54977b279 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/detail/approve-skill/services/approve-skill-ui-info.service.ts @@ -0,0 +1,22 @@ +/** @format */ + +import { inject, Injectable, signal } from "@angular/core"; +import { SnackbarService } from "@domain/shared/snackbar.service"; +import { Approve, Skill } from "@domain/skills/skill.model"; + +@Injectable() +export class ApproveSkillUIInfoService { + private readonly snackbarService = inject(SnackbarService); + + // переменные для работы с модальным окном для вывода ошибки с подтверждением своего навыка + approveOwnSkillModal = signal(false); + + applyApprovedSkills(skill: Skill, updatedApprove: Approve): void { + skill.approves = [...skill.approves, updatedApprove]; + this.snackbarService.success("вы подтвердили навык"); + } + + applyOpenErrorModal(): void { + this.approveOwnSkillModal.set(true); + } +} diff --git a/projects/social_platform/src/app/ui/widgets/detail/detail.component.html b/projects/social_platform/src/app/ui/widgets/detail/detail.component.html new file mode 100644 index 000000000..7b7d84f36 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/detail/detail.component.html @@ -0,0 +1,943 @@ + + +
    +
    + @if (info()) { +
    +
    + @if (appWidth > 1000) { + cover + } + +
    + +
    + @if (chatStateService.userOnlineStatusCache | async; as cache) { + + @if (listType() === "project" || listType() === "profile") { +

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

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

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

    +
    +

    + {{ assignProjectToProgramModalMessage() }}. +

    +
    +
    + } + + @defer (when isProgramEndedModalOpen()) { + +
    +
    +

    + к сожалению, программа уже завершена! +

    +
    + + +
    +
    + } + + @defer (when isProgramSubmissionProjectsEndedModalOpen()) { + +
    +
    +

    + к сожалению, подача проекта уже завершена! +

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

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

    + +

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

    + + хорошо +
    +
    + } + + @defer (when isLeaveProjectModalOpen()) { + +
    +
    +

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

    + +
    + +
    + + покинуть команду + + + + остаться + +
    +
    +
    + } + } @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 { + + + редактировать + + + } + + @defer (when showApproveSkillModal()) { + + @if (info().skills.length) { +
      +
      +

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

      + +
      + + @for (skill of info().skills; track skill.id) { +
    • + +
    • + } +
    + } +
    + } + + @defer (when !showNoProjectsModal() && showSendInviteModal()) { + +
    + @if (profileProjects().length) { +
      +
      +

      + выберите проект и роль для приглашения. +

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

      + {{ project.name ?? "Проект без названия" | truncate: 20 }} +

      +
      + +
    • + } + @if (inviteForm.get("role"); as role) { +
      + + @if (role | controlError: "required") { +
      + {{ errorMessage.VALIDATION_REQUIRED }} +
      + } +
      + } + + + отправить приглашение + + +
    + } +
    +
    + } + + @defer (when showNoProjectsModal()) { + +
    +
    +

    + вы не являетесь лидером ни в одном проекте +

    +
    + + + + перейти в проекты +
    +
    + } + + @defer (when showActiveInviteModal()) { + +
    +
    +

    + У данного участника уже есть активное приглашение в проект +

    +
    + + + + перейти в проекты +
    +
    + } + + @defer (when showNoInProgramModal()) { + +
    +
    +

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

    +
    + + + + перейти в проекты +
    +
    + } + + @defer (when showSuccessInviteModal()) { + +
    + + +

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

    + + перейти в проекты +
    +
    + } + } + @if (profile()) { + @if (profile()!.id === info().id) { + @defer (when isProfileFill()) { + +
    + profile unfill image +
    + +

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

    +
    +

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

    + + + продолжить заполнение + +
    +
    + } + } + } + + @defer (when isDelayModalOpen()) { + +
    +
    + +

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

    +
    +

    + Скачивание будет доступно через несколько секунд. +

    +
    +
    + } +
    + + +
    +
    + } +
    +
    diff --git a/projects/social_platform/src/app/office/features/detail/detail.component.scss b/projects/social_platform/src/app/ui/widgets/detail/detail.component.scss similarity index 100% rename from projects/social_platform/src/app/office/features/detail/detail.component.scss rename to projects/social_platform/src/app/ui/widgets/detail/detail.component.scss diff --git a/projects/social_platform/src/app/ui/widgets/detail/detail.component.ts b/projects/social_platform/src/app/ui/widgets/detail/detail.component.ts new file mode 100644 index 000000000..a8fa26d9f --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/detail/detail.component.ts @@ -0,0 +1,256 @@ +/** @format */ + +import { CommonModule, Location } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + HostListener, + inject, + OnDestroy, + OnInit, +} from "@angular/core"; +import { ButtonComponent, InputComponent } from "@ui/primitives"; +import { IconComponent } from "@uilib"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { Router, RouterModule } from "@angular/router"; +import { AvatarComponent } from "@ui/primitives/avatar/avatar.component"; +import { TooltipComponent } from "@ui/primitives/tooltip/tooltip.component"; +import { ReactiveFormsModule } from "@angular/forms"; +import { ApproveSkillComponent } from "./approve-skill/approve-skill.component"; +import { TruncatePipe, ControlErrorPipe } from "@corelib"; +import { ProjectFormService } from "@api/project/project-form.service"; +import { ErrorMessage } from "@core/lib/models/error/error-message"; +import { ProjectAdditionalService } from "@api/project/facades/edit/project-additional.service"; +import { TooltipInfoService } from "@api/tooltip/tooltip-info.service"; +import { DetailInfoService } from "./services/detail-info.service"; +import { DetailProfileInfoService } from "./services/profile/detail-profile-info.service"; +import { DetailProgramInfoService } from "./services/program/detail-program-info.service"; +import { DetailProjectInfoService } from "./services/project/detail-project-info.service"; +import { Program } from "@domain/program/program.model"; +import { ProfileDetailUIInfoService } from "@api/profile/facades/detail/ui/profile-detail-ui-info.service"; +import { ChatStateService } from "@domain/shared/chat-state.service"; +import { ProgramLinksComponent } from "@ui/widgets/program-links/program-links.component"; +import { AppRoutes } from "@api/paths/app-routes"; +import { ProjectTeamUIService } from "@api/project/facades/edit/ui/project-team-ui.service"; + +/** Виджет детального просмотра сущности. */ +@Component({ + selector: "app-detail", + templateUrl: "./detail.component.html", + styleUrl: "./detail.component.scss", + imports: [ + CommonModule, + RouterModule, + ReactiveFormsModule, + IconComponent, + ButtonComponent, + ModalComponent, + AvatarComponent, + TooltipComponent, + ApproveSkillComponent, + InputComponent, + TruncatePipe, + ControlErrorPipe, + ProgramLinksComponent, + ], + providers: [ + ProfileDetailUIInfoService, + ProjectTeamUIService, + DetailInfoService, + DetailProfileInfoService, + DetailProjectInfoService, + DetailProgramInfoService, + ProjectAdditionalService, + TooltipInfoService, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DeatilComponent implements OnInit, OnDestroy { + protected readonly AppRoutes = AppRoutes; + private readonly projectAdditionalService = inject(ProjectAdditionalService); + protected readonly location = inject(Location); + protected readonly router = inject(Router); + public readonly chatStateService = inject(ChatStateService); + private readonly projectFormService = inject(ProjectFormService); + private readonly tooltipInfoService = inject(TooltipInfoService); + + private readonly detailInfoService = inject(DetailInfoService); + private readonly detailProfileInfoService = inject(DetailProfileInfoService); + private readonly detailProgramInfoService = inject(DetailProgramInfoService); + private readonly detailProjectInfoService = inject(DetailProjectInfoService); + + protected readonly info = this.detailInfoService.info; + protected readonly profile = this.detailProfileInfoService.profile; + protected readonly profileProjects = this.detailProfileInfoService.profileProjects; + protected readonly listType = this.detailInfoService.listType; + + // Переменная для подсказок + protected readonly isTooltipVisible = this.tooltipInfoService.isVisible; + + // Переменные для отображения данных в зависимости от url + protected readonly isProjectsPage = this.detailProgramInfoService.isProjectsPage; + protected readonly isMembersPage = this.detailProgramInfoService.isMembersPage; + protected readonly isProjectsRatingPage = this.detailProgramInfoService.isProjectsRatingPage; + + protected readonly isTeamPage = this.detailProjectInfoService.isTeamPage; + protected readonly isVacanciesPage = this.detailProjectInfoService.isVacanciesPage; + protected readonly isProjectChatPage = this.detailProjectInfoService.isProjectChatPage; + protected readonly isProjectWorkSectionPage = + this.detailProjectInfoService.isProjectWorkSectionPage; + + protected readonly isKanbanBoardPage = this.detailProjectInfoService.isKanbanBoardPage; + protected readonly isGantDiagramPage = this.detailProjectInfoService.isGantDiagramPage; + + // Сторонние переменные для работы с роутингом или доп проверок + protected readonly backPath = this.detailInfoService.backPath; + protected readonly registerDateExpired = this.detailProgramInfoService.registerDateExpired; + protected readonly submissionProjectDateExpired = + this.detailProjectInfoService.submissionProjectDateExpired; + + protected readonly isInProject = this.detailInfoService.isInProject; + protected readonly queryCourseId = this.detailInfoService.queryCourseId; + + protected readonly isSended = this.detailProfileInfoService.isSended; + protected readonly isProfileFill = this.detailProfileInfoService.isProfileFill; + + // Переменные для работы с модалкой подачи проекта + protected readonly selectedProjectId = this.detailProfileInfoService.selectedProjectId; + protected readonly memberProjects = this.detailProfileInfoService.memberProjects; + + protected readonly userType = this.detailInfoService.userType; + + // Сигналы для работы с модальными окнами с текстом + protected readonly errorMessageModal = this.detailInfoService.errorMessageModal; + + protected readonly additionalFields = this.detailProgramInfoService.additionalFields; + + // Переменные для работы с модалками + protected readonly isAssignProjectToProgramModalOpen = + this.detailProgramInfoService.isAssignProjectToProgramModalOpen; + + protected readonly isProgramEndedModalOpen = + this.detailProgramInfoService.isProgramEndedModalOpen; + + protected readonly isProgramSubmissionProjectsEndedModalOpen = + this.detailProgramInfoService.isProgramSubmissionProjectsEndedModalOpen; + + protected readonly isLeaveProjectModalOpen = + this.detailProjectInfoService.isLeaveProjectModalOpen; // Флаг модального окна выхода + + protected readonly isEditDisable = this.detailProjectInfoService.isEditDisable; // Флаг недоступности редактирования + protected readonly isEditDisableModal = this.detailProjectInfoService.isEditDisableModal; // Флаг недоступности редактирования для модалки + protected readonly openSupport = this.detailProjectInfoService.openSupport; // Флаг модального окна поддержки + protected readonly leaderLeaveModal = this.detailProjectInfoService.leaderLeaveModal; // Флаг модального окна предупреждения лидера + protected readonly isDelayModalOpen = this.detailProfileInfoService.isDelayModalOpen; + + // Переменные для работы с подтверждением навыков + protected readonly showApproveSkillModal = this.detailProfileInfoService.showApproveSkillModal; + protected readonly showSendInviteModal = this.detailProfileInfoService.showSendInviteModal; + protected readonly showNoProjectsModal = this.detailProfileInfoService.showNoProjectsModal; + protected readonly showActiveInviteModal = this.detailProfileInfoService.showActiveInviteModal; + protected readonly showNoInProgramModal = this.detailProfileInfoService.showNoInProgramModal; + protected readonly showSuccessInviteModal = this.detailProfileInfoService.showSuccessInviteModal; + + protected readonly openSkills = this.detailProfileInfoService.openSkills; + + // Геттеры для работы с отображением данных разного типа доступа + protected readonly isUserManager = this.detailInfoService.isUserManager; + protected readonly isUserMember = this.detailInfoService.isUserMember; + protected readonly isUserExpert = this.detailInfoService.isUserExpert; + protected readonly isProjectAssigned = this.detailInfoService.isProjectAssigned; + + // Сигналы для работы с модальными окнами с текстом + protected readonly assignProjectToProgramModalMessage = + this.detailProgramInfoService.assignProjectToProgramModalMessage; + + protected readonly projectForm = this.projectFormService.getForm(); + + protected readonly inviteForm = this.detailProfileInfoService.inviteForm; + + protected readonly errorMessage = ErrorMessage; + + protected readonly isContactsModalOpen = this.detailInfoService.isContactsModalOpen; + protected readonly isMaterialsModalOpen = this.detailInfoService.isMaterialsModalOpen; + protected readonly contactLinks = this.detailInfoService.contactLinks; + protected readonly materialLinks = this.detailInfoService.materialLinks; + + protected appWidth = window.innerWidth; + + @HostListener("window:resize") + onResize() { + this.appWidth = window.innerWidth; + } + + ngOnInit(): void { + this.detailInfoService.initializationDetail(); + } + + ngOnDestroy(): void { + this.detailInfoService.destroy(); + } + + // Методы для управления состоянием ошибок через сервис + setAssignProjectToProgramError(error: { non_field_errors: string[] }): void { + this.projectAdditionalService.setAssignProjectToProgramError(error); + } + + toggleTooltip(): void { + this.tooltipInfoService.toggleTooltip("base"); + } + + onProjectRadioChange(event: Event): void { + this.detailProfileInfoService.onProjectRadioChange(event); + } + + addNewProject(programId: number): void { + this.detailProgramInfoService.addNewProject(programId); + } + + onCloseLeaveProjectModal(): void { + this.detailProjectInfoService.onCloseLeaveProjectModal(); + } + + onUnableEditingProject(): void { + this.detailProjectInfoService.onUnableEditingProject(); + } + + onLeave() { + this.detailProjectInfoService.onLeave(); + } + + onCopyLink(profileId: number): void { + this.detailProfileInfoService.onCopyLink(profileId); + } + + onOpenSkill(skillId: number) { + this.openSkills[skillId] = !this.openSkills[skillId]; + } + + onCloseModal(skillId: number) { + this.openSkills[skillId] = false; + } + + downloadCV() { + this.detailProfileInfoService.downloadCV(); + } + + inviteUser(): void { + this.detailProfileInfoService.inviteUser(); + } + + sendInvite(): void { + this.detailProfileInfoService.sendInvite(); + } + + redirectDetailInfo(): void { + this.detailInfoService.redirectDetailInfo(); + } + + routingToMyProjects(): void { + this.detailProjectInfoService.routingToMyProjects(); + } + + checkPrograRegistrationEnded(event: Event, program: Program): void { + this.detailProgramInfoService.checkPrograRegistrationEnded(event, program); + } +} diff --git a/projects/social_platform/src/app/ui/widgets/detail/services/detail-info.service.ts b/projects/social_platform/src/app/ui/widgets/detail/services/detail-info.service.ts new file mode 100644 index 000000000..48b891e7c --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/detail/services/detail-info.service.ts @@ -0,0 +1,237 @@ +/** @format */ + +import { Location } from "@angular/common"; +import { computed, DestroyRef, inject, Injectable, Injector, signal } from "@angular/core"; +import { takeUntilDestroyed, toObservable } from "@angular/core/rxjs-interop"; +import { ActivatedRoute, Router } from "@angular/router"; +import { User } from "@domain/auth/user.model"; +import { ProfileDetailUIInfoService } from "@api/profile/facades/detail/ui/profile-detail-ui-info.service"; +import { ProgramDetailMainUIInfoService } from "@api/program/facades/detail/ui/program-detail-main-ui-info.service"; +import { ProjectsDetailUIInfoService } from "@api/project/facades/detail/ui/projects-detail-ui.service"; +import { ProjectFormService } from "@api/project/project-form.service"; +import { Collaborator } from "@domain/project/collaborator.model"; +import { filter } from "rxjs"; +import { DetailProfileInfoService } from "./profile/detail-profile-info.service"; +import { DetailProjectInfoService } from "./project/detail-project-info.service"; +import { DetailProgramInfoService } from "./program/detail-program-info.service"; +import { GetMyProjectsUseCase } from "@api/project/use-cases/get-my-projects.use-case"; +import { AppRoutes } from "@api/paths/app-routes"; + +@Injectable() +export class DetailInfoService { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly injector = inject(Injector); + private readonly location = inject(Location); + private readonly destroyRef = inject(DestroyRef); + + private readonly projectFormService = inject(ProjectFormService); + private readonly programDetailMainUIInfoService = inject(ProgramDetailMainUIInfoService); + private readonly projectsDetailUIInfoService = inject(ProjectsDetailUIInfoService); + private readonly profileDetailUIInfoService = inject(ProfileDetailUIInfoService); + + private readonly detailProfileInfoService = inject(DetailProfileInfoService); + private readonly detailProjectInfoService = inject(DetailProjectInfoService); + private readonly detailProgramInfoService = inject(DetailProgramInfoService); + + private readonly getMyProjectsUseCase = inject(GetMyProjectsUseCase); + + private unsubscribeUrlChange?: () => void; + + readonly info = signal(undefined); + readonly listType = signal<"project" | "program" | "profile">("project"); + readonly projectForm = this.projectFormService.getForm(); + readonly memberProjects = this.detailProfileInfoService.memberProjects; + readonly profile = this.detailProfileInfoService.profile; + // userType вытягивается реактивно из текущего профиля: подписка раньше делалась + // через authRepository.profile.pipe(...).subscribe(set), после миграции на сигнал + // это просто computed — обновляется автоматически когда profile() придёт. + readonly userType = computed(() => this.profile()?.personal.userType); + readonly isInProject = computed(() => { + if (this.listType() !== "project" || !this.info()) return undefined; + const myId = this.profile()?.id; + if (myId === undefined) return undefined; + return !!this.info() + ?.collaborators?.map((person: Collaborator) => person.userId) + .includes(myId); + }); + readonly queryCourseId = signal(null); + readonly isProfileFill = this.profileDetailUIInfoService.isProfileFill; + + // Сигналы для работы с модальными окнами с текстом + readonly errorMessageModal = signal(""); + + // Сторонние переменные для работы с роутингом или доп проверок + readonly backPath = signal(undefined); + + readonly isContactsModalOpen = signal(false); + readonly isMaterialsModalOpen = signal(false); + + readonly contactLinks = computed<{ label: string; url: string }[]>(() => + ((this.info()?.links as string[] | undefined) ?? []).map(link => ({ label: link, url: link })), + ); + + readonly materialLinks = computed<{ label: string; url: string }[]>(() => + ((this.info()?.materials as { title: string; url: string }[] | undefined) ?? []).map(m => ({ + label: m.title, + url: m.url, + })), + ); + + readonly isUserManager = computed(() => { + if (this.listType() === "program") { + return this.info()?.isUserManager; + } + return undefined; + }); + + readonly isUserMember = computed(() => { + if (this.listType() === "program") { + return this.info()?.isUserMember; + } + return undefined; + }); + + readonly isUserExpert = computed(() => { + const type = this.userType(); + return type !== undefined && type === 3; + }); + + readonly isProjectAssigned = computed(() => { + const programId = this.info()?.id; + if (!programId) return false; + + return this.memberProjects().some( + project => project.leader === this.profile()?.id && project.partnerProgram?.id === programId, + ); + }); + + initializationDetail(): void { + this.route.data.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(data => { + this.listType.set(data["listType"]); + this.initializeBackPath(); + this.initializeInfo(); + }); + + this.route.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(params => { + const courseId = params["courseId"]; + this.queryCourseId.set(courseId ? Number(courseId) : null); + }); + + this.updatePageStates(); + this.unsubscribeUrlChange = this.location.onUrlChange(url => { + this.updatePageStates(url); + }); + } + + /** + * Перенаправляет на страницу с информацией в завивисимости от listType + */ + redirectDetailInfo(): void { + switch (this.listType()) { + case "profile": + this.router.navigateByUrl(AppRoutes.profile.detail(this.info().id)); + break; + + case "project": + this.router.navigateByUrl(AppRoutes.projects.detail(this.info().id)); + break; + + case "program": + this.router.navigateByUrl(AppRoutes.program.detail(this.info().id)); + break; + } + } + + /** + * Инициализация строки для back компонента в зависимости от типа данных + */ + private initializeBackPath(): void { + if (this.listType() === "project") { + this.backPath.set(AppRoutes.projects.all()); + } else if (this.listType() === "program") { + this.backPath.set(AppRoutes.program.list()); + } + } + + /** + * Обновляет состояния страниц на основе URL + */ + private updatePageStates(url?: string): void { + const currentUrl = url || this.router.url; + + this.detailProgramInfoService.applyUpdateStage( + "projects", + currentUrl.includes("/projects") && !currentUrl.includes("/projects-rating"), + ); + + this.detailProgramInfoService.applyUpdateStage("members", currentUrl.includes("/members")); + + this.detailProgramInfoService.applyUpdateStage( + "projects-rating", + currentUrl.includes("/projects-rating"), + ); + + this.detailProjectInfoService.applyUpdateStage("team", currentUrl.includes("/team")); + this.detailProjectInfoService.applyUpdateStage("vacancies", currentUrl.includes("/vacancies")); + this.detailProjectInfoService.applyUpdateStage("chat", currentUrl.includes("/chat")); + this.detailProjectInfoService.applyUpdateStage( + "work-section", + currentUrl.includes("/work-section"), + ); + + this.detailProjectInfoService.applyUpdateStage( + "gant-diagram", + currentUrl.includes("/gant-diagram"), + ); + this.detailProjectInfoService.applyUpdateStage("kanban", currentUrl.includes("/kanban")); + } + + private initializeInfo() { + if (this.listType() === "project") { + const project = this.projectsDetailUIInfoService.project; + this.info.set(project()); + + this.detailProjectInfoService.applySetIsDisabled(this.info()?.partnerProgram?.isSubmitted); + + // isInProject теперь computed — пересчитается сам когда profile() и info() готовы. + } else if (this.listType() === "program") { + const program = this.programDetailMainUIInfoService.program; + this.info.set(program()); + + if (this.isUserMember() && (this.isUserExpert() || this.isUserManager())) return; + + if (this.isUserMember()) { + if (!this.isProjectAssigned()) { + this.getMyProjectsUseCase + .execute() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: result => { + if (!result.ok) { + this.memberProjects.set([]); + return; + } + + this.memberProjects.set(result.value.results.filter(project => !project.draft)); + }, + }); + } + } + } else { + this.detailProfileInfoService.initializationProfile(); + toObservable(this.profileDetailUIInfoService.user, { injector: this.injector }) + .pipe( + filter((user): user is User => !!user), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(user => this.info.set(user)); + + this.detailProfileInfoService.initializationLeaderProjects(); + } + } + + destroy(): void { + this.unsubscribeUrlChange?.(); + } +} diff --git a/projects/social_platform/src/app/ui/widgets/detail/services/profile/detail-profile-info.service.ts b/projects/social_platform/src/app/ui/widgets/detail/services/profile/detail-profile-info.service.ts new file mode 100644 index 000000000..50c880357 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/detail/services/profile/detail-profile-info.service.ts @@ -0,0 +1,190 @@ +/** @format */ + +import { DestroyRef, inject, Injectable, Injector, signal } from "@angular/core"; +import { ActivatedRoute } from "@angular/router"; +import { SnackbarService } from "@domain/shared/snackbar.service"; +import { saveFile } from "@utils/export-file"; +import { SendForUserUseCase } from "@api/invite/use-cases/send-for-user.use-case"; +import { ProfileDetailUIInfoService } from "@api/profile/facades/detail/ui/profile-detail-ui-info.service"; +import { ProjectTeamUIService } from "@api/project/facades/edit/ui/project-team-ui.service"; +import { User } from "@domain/auth/user.model"; +import { Project } from "@domain/project/project.model"; +import { filter, take } from "rxjs"; +import { DownloadCvUseCase } from "@api/auth/use-cases/download-cv.use-case"; +import { ProfileInfoService } from "@api/profile/facades/profile-info.service"; +import { takeUntilDestroyed, toObservable } from "@angular/core/rxjs-interop"; + +@Injectable() +export class DetailProfileInfoService { + private readonly route = inject(ActivatedRoute); + private readonly snackbarService = inject(SnackbarService); + private readonly injector = inject(Injector); + private readonly destroyRef = inject(DestroyRef); + + private readonly profileInfoService = inject(ProfileInfoService); + private readonly projectTeamUIService = inject(ProjectTeamUIService); + private readonly profileDetailUIInfoService = inject(ProfileDetailUIInfoService); + + private readonly downloadCvUseCase = inject(DownloadCvUseCase); + private readonly sendForUserUseCase = inject(SendForUserUseCase); + + readonly inviteForm = this.projectTeamUIService.inviteForm; + + readonly profile = this.profileInfoService.profile; + readonly profileProjects = signal([]); + readonly isSended = signal(false); + readonly isProfileFill = signal(false); + readonly showApproveSkillModal = signal(false); + readonly showSendInviteModal = signal(false); + readonly showNoProjectsModal = signal(false); + readonly showActiveInviteModal = signal(false); + readonly showNoInProgramModal = signal(false); + readonly showSuccessInviteModal = signal(false); + readonly isDelayModalOpen = signal(false); + + // Переменные для работы с модалкой подачи проекта + readonly selectedProjectId = signal(null); + readonly memberProjects = signal([]); + + initializationLeaderProjects(): void { + const viewedId = Number(this.route.snapshot.params["id"]); + + // Профиль текущего юзера грузится асинхронно (office init), поэтому ждём его + // реактивно, а не читаем сигнал синхронно — иначе гонка даёт null и запрос не идёт. + toObservable(this.profileInfoService.profile, { injector: this.injector }) + .pipe( + filter((user): user is User => !!user), + take(1), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(currentUser => { + // На своём профиле кнопка «пригласить» не показывается — грузить не нужно. + if (currentUser.id === viewedId) return; + this.profileInfoService.ensureLeaderProjectsLoaded(); + }); + + toObservable(this.profileInfoService.leaderProjects, { injector: this.injector }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(projects => this.profileProjects.set(projects)); + } + + initializationProfile(): void { + this.route.data.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ + next: r => { + this.profileDetailUIInfoService.applyInitProfile(r); + }, + }); + + const isProfileFill = this.profileDetailUIInfoService.isProfileFill(); + this.isProfileFill.set(isProfileFill); + } + + onCopyLink(profileId: number): void { + let fullUrl = ""; + + // Формирование URL в зависимости от типа ресурса + fullUrl = `${location.origin}/office/profile/${profileId}/`; + + // Копирование в буфер обмена + navigator.clipboard.writeText(fullUrl).then( + () => this.snackbarService.success("скопирован URL"), + () => this.snackbarService.error("не удалось скопировать ссылку"), + ); + } + + onProjectRadioChange(event: Event): void { + const target = event.target as HTMLInputElement; + this.selectedProjectId.set(+target.value); + + if (this.selectedProjectId) { + this.memberProjects().find(project => project.id === this.selectedProjectId()); + } + } + + inviteUser(): void { + if (!this.profileProjects().length) { + this.showNoProjectsModal.set(true); + } else { + this.showSendInviteModal.set(true); + } + } + + sendInvite(): void { + const roleControl = this.inviteForm.get("role"); + const role = roleControl?.value; + const userId = this.route.snapshot.params["id"]; + + roleControl?.markAsTouched({ onlySelf: true }); + + if (roleControl?.invalid || this.selectedProjectId() === null) { + return; + } + + this.sendForUserUseCase + .execute({ + userId, + projectId: this.selectedProjectId()!, + role: role!, + }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: result => { + if (!result.ok) { + const error = result.error.cause as { error?: { user?: string[] } } | undefined; + const userErrors = error?.error?.user ?? []; + + if (userErrors[0]?.includes("проект относится к программе")) { + this.showNoInProgramModal.set(true); + } else if (userErrors[0]?.includes("активное приглашение")) { + this.showActiveInviteModal.set(true); + } + return; + } + + this.showSendInviteModal.set(false); + this.showSuccessInviteModal.set(true); + + this.inviteForm.reset(); + this.selectedProjectId.set(null); + }, + }); + } + + openSkills: Record = {}; + + onOpenSkill(skillId: number) { + this.openSkills[skillId] = !this.openSkills[skillId]; + } + + onCloseModal(skillId: number) { + this.openSkills[skillId] = false; + } + + downloadCV() { + this.isSended.set(true); + this.downloadCvUseCase + .execute() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: result => { + this.isSended.set(false); + + if (!result.ok) { + const error = result.error.cause as { status?: number } | undefined; + if (error?.status === 400) { + this.isDelayModalOpen.set(true); + } + return; + } + + saveFile(result.value, "cv", this.profile()?.firstName + " " + this.profile()?.lastName); + }, + error: err => { + this.isSended.set(false); + if (err?.status === 400) { + this.isDelayModalOpen.set(true); + } + }, + }); + } +} diff --git a/projects/social_platform/src/app/ui/widgets/detail/services/program/detail-program-info.service.ts b/projects/social_platform/src/app/ui/widgets/detail/services/program/detail-program-info.service.ts new file mode 100644 index 000000000..c1bbc3bbf --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/detail/services/program/detail-program-info.service.ts @@ -0,0 +1,128 @@ +/** @format */ + +import { DestroyRef, inject, Injectable, signal } from "@angular/core"; +import { ProgramDetailMainUIInfoService } from "@api/program/facades/detail/ui/program-detail-main-ui-info.service"; +import { ApplyProjectToProgramUseCase } from "@api/program/use-cases/apply-project-to-program.use-case"; +import { GetProgramProjectAdditionalFieldsUseCase } from "@api/program/use-cases/get-program-project-additional-fields.use-case"; +import { + PartnerProgramFields, + ProjectNewAdditionalProgramFields, +} from "@domain/program/partner-program-fields.model"; + +import { Router } from "@angular/router"; +import { switchMap } from "rxjs"; +import { ProjectFormService } from "@api/project/project-form.service"; +import { Program } from "@domain/program/program.model"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; +import { AppRoutes } from "@api/paths/app-routes"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; + +@Injectable() +export class DetailProgramInfoService { + private readonly router = inject(Router); + private readonly logger = inject(LoggerService); + private readonly destroyRef = inject(DestroyRef); + + private readonly projectFormService = inject(ProjectFormService); + private readonly programDetailMainUIInfoService = inject(ProgramDetailMainUIInfoService); + + private readonly applyProjectToProgramUseCase = inject(ApplyProjectToProgramUseCase); + private readonly getProgramProjectAdditionalFieldsUseCase = inject( + GetProgramProjectAdditionalFieldsUseCase, + ); + + readonly isProjectsPage = signal(false); + readonly isMembersPage = signal(false); + readonly isProjectsRatingPage = signal(false); + readonly additionalFields = signal([]); + readonly isAssignProjectToProgramModalOpen = signal(false); + readonly isProgramEndedModalOpen = signal(false); + readonly isProgramSubmissionProjectsEndedModalOpen = signal(false); + readonly assignProjectToProgramModalMessage = signal(null); + readonly registerDateExpired = this.programDetailMainUIInfoService.registerDateExpired; + + private readonly projectForm = this.projectFormService.getForm(); + + addNewProject(programId: number): void { + this.getProgramProjectAdditionalFieldsUseCase + .execute(programId) + .pipe( + switchMap(filtersResult => { + const fields = filtersResult.ok ? filtersResult.value.programFields : []; + const newFieldsFormValues = fields.map(field => + ProjectNewAdditionalProgramFields.fromField(field, this.placeholderFor(field)), + ); + const body = { project: this.projectForm.value, programFieldValues: newFieldsFormValues }; + return this.applyProjectToProgramUseCase.execute(programId, body); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe({ + next: result => { + if (!result.ok) { + const error = result.error.cause as + | { status?: number; error?: { detail?: string } } + | undefined; + if (error?.status === 400) { + this.isAssignProjectToProgramModalOpen.set(true); + this.assignProjectToProgramModalMessage.set(error.error?.detail ?? null); + } + return; + } + + const response = result.value; + + this.router + .navigate([AppRoutes.projects.edit(response.projectId)], { + queryParams: { editingStep: "additional", fromProgram: true }, + }) + .then(() => this.logger.debug("Route change from ProjectsComponent")); + }, + }); + } + + private placeholderFor(field: PartnerProgramFields): string | boolean { + if (field.fieldType === "checkbox") return false; + if (field.options.length > 0) return field.options[0]; + return "-"; + } + + /** + * Проверка завершения программы перед регистрацией + */ + checkPrograRegistrationEnded(event: Event, program: Program): void { + if ( + program?.datetimeRegistrationEnds && + Date.now() > Date.parse(program.datetimeRegistrationEnds) + ) { + event.preventDefault(); + event.stopPropagation(); + this.isProgramEndedModalOpen.set(true); + } else if ( + program?.datetimeProjectSubmissionEnds && + Date.now() > Date.parse(program?.datetimeProjectSubmissionEnds) + ) { + event.preventDefault(); + event.stopPropagation(); + this.isProgramSubmissionProjectsEndedModalOpen.set(true); + } else { + this.router.navigateByUrl(AppRoutes.program.register(program.id)); + } + } + + applyUpdateStage(stage: "projects" | "projects-rating" | "members", isStage: boolean): void { + switch (stage) { + case "projects": + this.isProjectsPage.set(isStage); + break; + + case "members": + this.isMembersPage.set(isStage); + break; + + case "projects-rating": + this.isProjectsRatingPage.set(isStage); + break; + } + } +} diff --git a/projects/social_platform/src/app/ui/widgets/detail/services/project/detail-project-info.service.ts b/projects/social_platform/src/app/ui/widgets/detail/services/project/detail-project-info.service.ts new file mode 100644 index 000000000..9ed344f8a --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/detail/services/project/detail-project-info.service.ts @@ -0,0 +1,111 @@ +/** @format */ + +import { DestroyRef, inject, Injectable, signal } from "@angular/core"; +import { ActivatedRoute, Router } from "@angular/router"; +import { concatMap, map } from "rxjs"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; +import { LeaveProjectUseCase } from "@api/project/use-cases/leave-project.use-case"; +import { AppRoutes } from "@api/paths/app-routes"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; + +@Injectable() +export class DetailProjectInfoService { + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + private readonly logger = inject(LoggerService); + private readonly destroyRef = inject(DestroyRef); + + private readonly leaveProjectUseCase = inject(LeaveProjectUseCase); + + readonly isTeamPage = signal(false); + readonly isVacanciesPage = signal(false); + readonly isProjectChatPage = signal(false); + readonly submissionProjectDateExpired?: boolean; + readonly isProjectWorkSectionPage = signal(false); + readonly isKanbanBoardPage = signal(false); + readonly isGantDiagramPage = signal(false); + readonly isLeaveProjectModalOpen = signal(false); // Флаг модального окна выхода + readonly isEditDisable = signal(false); // Флаг недоступности редактирования + readonly isEditDisableModal = signal(false); // Флаг недоступности редактирования для модалки + readonly openSupport = signal(false); // Флаг модального окна поддержки + readonly leaderLeaveModal = signal(false); // Флаг модального окна предупреждения лидера + + applySetIsDisabled(isSubmitted: boolean): void { + this.isEditDisable.set(isSubmitted ?? false); + } + + /** + * Закрытие модального окна выхода из проекта + */ + onCloseLeaveProjectModal(): void { + this.isLeaveProjectModalOpen.set(false); + } + + /** + * Закрытие модального окна для невозможности редактировать проект + */ + onUnableEditingProject(): void { + if (this.isEditDisable()) { + this.isEditDisableModal.set(true); + } else { + this.isEditDisableModal.set(false); + } + } + + /** + * Выход из проекта + */ + onLeave() { + this.route.data + .pipe( + map(r => r["data"][0]), + concatMap(p => this.leaveProjectUseCase.execute(p.id)), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(result => { + if (!result.ok) { + this.leaderLeaveModal.set(true); + return; + } + + this.router + .navigateByUrl(AppRoutes.projects.my()) + .then(() => this.logger.debug("Route changed from ProjectInfoComponent")); + }); + } + + routingToMyProjects(): void { + this.router.navigateByUrl(AppRoutes.projects.my()); + } + + applyUpdateStage( + stage: "team" | "vacancies" | "chat" | "work-section" | "gant-diagram" | "kanban", + isStage: boolean, + ): void { + switch (stage) { + case "team": + this.isTeamPage.set(isStage); + break; + + case "chat": + this.isProjectChatPage.set(isStage); + break; + + case "gant-diagram": + this.isGantDiagramPage.set(isStage); + break; + + case "kanban": + this.isKanbanBoardPage.set(isStage); + break; + + case "vacancies": + this.isVacanciesPage.set(isStage); + break; + + case "work-section": + this.isProjectWorkSectionPage.set(isStage); + break; + } + } +} diff --git a/projects/social_platform/src/app/ui/widgets/feed-filter/feed-filter.component.html b/projects/social_platform/src/app/ui/widgets/feed-filter/feed-filter.component.html new file mode 100644 index 000000000..7774f3f65 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/feed-filter/feed-filter.component.html @@ -0,0 +1,48 @@ + + +
    + +
    + +
    +
    +
    + Фильтр +
    + + @if (filterOpen()) { + + } +
    +
    + + +
    + @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/ui/widgets/feed-filter/feed-filter.component.scss similarity index 100% rename from projects/social_platform/src/app/office/feed/filter/feed-filter.component.scss rename to projects/social_platform/src/app/ui/widgets/feed-filter/feed-filter.component.scss diff --git a/projects/social_platform/src/app/office/feed/filter/feed-filter.component.spec.ts b/projects/social_platform/src/app/ui/widgets/feed-filter/feed-filter.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/office/feed/filter/feed-filter.component.spec.ts rename to projects/social_platform/src/app/ui/widgets/feed-filter/feed-filter.component.spec.ts diff --git a/projects/social_platform/src/app/ui/widgets/feed-filter/feed-filter.component.ts b/projects/social_platform/src/app/ui/widgets/feed-filter/feed-filter.component.ts new file mode 100644 index 000000000..4294faca1 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/feed-filter/feed-filter.component.ts @@ -0,0 +1,57 @@ +/** @format */ + +import { animate, style, transition, trigger } from "@angular/animations"; +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, inject, OnInit } from "@angular/core"; +import { IconComponent } from "@ui/primitives"; +import { ClickOutsideModule } from "ng-click-outside"; +import { feedFilter } from "@core/consts/filters/feed-filter.const"; +import { FeedFilterInfoService } from "./service/feed-filter-info.service"; +import { DetailProfileInfoService } from "../detail/services/profile/detail-profile-info.service"; +import { ProfileDetailUIInfoService } from "@api/profile/facades/detail/ui/profile-detail-ui-info.service"; + +/** Компонент фильтрации ленты по типам контента с мгновенной синхронизацией через URL. */ +@Component({ + selector: "app-feed-filter", + imports: [CommonModule, ClickOutsideModule, IconComponent], + templateUrl: "./feed-filter.component.html", + styleUrl: "./feed-filter.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [ + trigger("dropdownAnimation", [ + transition(":enter", [ + style({ opacity: 0, transform: "scaleY(0.8)" }), + animate(".12s cubic-bezier(0, 0, 0.2, 1)"), + ]), + transition(":leave", [animate(".1s linear", style({ opacity: 0 }))]), + ]), + ], + providers: [FeedFilterInfoService, ProfileDetailUIInfoService, DetailProfileInfoService], +}) +export class FeedFilterComponent implements OnInit { + private readonly feedFilterInfoService = inject(FeedFilterInfoService); + + // Состояние выпадающего меню фильтров + protected readonly filterOpen = this.feedFilterInfoService.filterOpen; + + // Массив активных фильтров + protected readonly includedFilters = this.feedFilterInfoService.includedFilters; + + protected readonly feedFilterOptions = feedFilter; + + ngOnInit() { + this.feedFilterInfoService.initializationFeedFilter(); + } + + setFilter(keyword: string): void { + this.feedFilterInfoService.setFilter(keyword); + } + + resetFilter(): void { + this.feedFilterInfoService.resetFilter(); + } + + onClickOutside(): void { + this.feedFilterInfoService.onClickOutside(); + } +} diff --git a/projects/social_platform/src/app/ui/widgets/feed-filter/service/feed-filter-info.service.ts b/projects/social_platform/src/app/ui/widgets/feed-filter/service/feed-filter-info.service.ts new file mode 100644 index 000000000..1fe2d98f2 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/feed-filter/service/feed-filter-info.service.ts @@ -0,0 +1,89 @@ +/** @format */ + +import { DestroyRef, inject, Injectable, signal } from "@angular/core"; +import { ActivatedRoute, Router } from "@angular/router"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; + +@Injectable() +export class FeedFilterInfoService { + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + private readonly logger = inject(LoggerService); + private readonly destroyRef = inject(DestroyRef); + + // Состояние выпадающего меню фильтров + readonly filterOpen = signal(false); + + // Массив активных фильтров + readonly includedFilters = signal(""); + + initializationFeedFilter(): void { + this.route.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(queries => { + if (queries["includes"]) { + this.includedFilters.set(queries["includes"]); + } else { + this.includedFilters.set(""); + } + }); + } + + /** Переключает фильтр (добавляет/удаляет) с мгновенным обновлением URL. */ + setFilter(keyword: string): void { + this.includedFilters.update(included => { + if (keyword.startsWith("project/")) { + // Если уже активен этот же вложенный фильтр - сбрасываем к "projects" + if (included === keyword) { + return "project"; + } + return keyword; + } + + // Если кликнули на "projects" + if (keyword === "project") { + if (included.startsWith("project/")) { + return "project"; + } + + if (included === "project") { + return ""; + } + + return "project"; + } + + if (included === keyword) { + return ""; + } + return keyword; + }); + + // Мгновенно обновляем URL + this.updateUrl(); + } + + /** Сбрасывает все фильтры. */ + resetFilter(): void { + this.includedFilters.set(""); + this.updateUrl(); + } + + /** Закрывает выпадающее меню (ClickOutside). */ + onClickOutside(): void { + this.filterOpen.set(false); + } + + private updateUrl(): void { + const includesParam = this.includedFilters().length > 0 ? this.includedFilters() : null; + + this.router + .navigate([], { + queryParams: { + includes: includesParam, + }, + relativeTo: this.route, + queryParamsHandling: "merge", + }) + .then(() => this.logger.debug("Query change from FeedFilterComponent")); + } +} diff --git a/projects/social_platform/src/app/ui/widgets/header/header.component.html b/projects/social_platform/src/app/ui/widgets/header/header.component.html new file mode 100644 index 000000000..d40db51f1 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/header/header.component.html @@ -0,0 +1,41 @@ + + +
    +
    +
    +
    + @if ((showBall | async) || hasInvites) { +
    + } + + @if (showNotifications) { +
    +

    Уведомления

    +
      + @for (invite of invites(); track invite.id) { +
    • + +
    • + } +
    +
    + } +
    +
    + @if (authService.profile | async; as user) { + + } +
    +
    +
    diff --git a/projects/social_platform/src/app/office/shared/header/header.component.scss b/projects/social_platform/src/app/ui/widgets/header/header.component.scss similarity index 97% rename from projects/social_platform/src/app/office/shared/header/header.component.scss rename to projects/social_platform/src/app/ui/widgets/header/header.component.scss index f295db90c..7290ff8de 100644 --- a/projects/social_platform/src/app/office/shared/header/header.component.scss +++ b/projects/social_platform/src/app/ui/widgets/header/header.component.scss @@ -1,6 +1,6 @@ /** @format */ -@import "src/styles/responsive"; +@import "styles/responsive"; .header { border-bottom: 1px solid var(--gray); diff --git a/projects/social_platform/src/app/office/shared/header/header.component.spec.ts b/projects/social_platform/src/app/ui/widgets/header/header.component.spec.ts similarity index 80% rename from projects/social_platform/src/app/office/shared/header/header.component.spec.ts rename to projects/social_platform/src/app/ui/widgets/header/header.component.spec.ts index e4e612f56..babd19e95 100644 --- a/projects/social_platform/src/app/office/shared/header/header.component.spec.ts +++ b/projects/social_platform/src/app/ui/widgets/header/header.component.spec.ts @@ -4,20 +4,22 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { HeaderComponent } from "./header.component"; import { of } from "rxjs"; -import { AuthService } from "@auth/services"; import { HttpClientTestingModule } from "@angular/common/http/testing"; import { RouterTestingModule } from "@angular/router/testing"; +import { AuthInfoService } from "@api/auth/facades/auth-info.service"; describe("HeaderComponent", () => { let component: HeaderComponent; let fixture: ComponentFixture; beforeEach(async () => { - const authSpy = jasmine.createSpyObj([{ profile: of({}) }]); + const authInfoSpy = { + profile: of(null), + }; await TestBed.configureTestingModule({ imports: [HttpClientTestingModule, RouterTestingModule, HeaderComponent], - providers: [{ provide: AuthService, useValue: authSpy }], + providers: [{ provide: AuthInfoService, useValue: authInfoSpy }], }).compileComponents(); }); diff --git a/projects/social_platform/src/app/ui/widgets/header/header.component.ts b/projects/social_platform/src/app/ui/widgets/header/header.component.ts new file mode 100644 index 000000000..a09e740d8 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/header/header.component.ts @@ -0,0 +1,64 @@ +/** @format */ + +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + inject, + input, + Input, + output, + Output, +} from "@angular/core"; +import { Invite } from "@domain/invite/invite.model"; +import { IconComponent } from "@ui/primitives"; +import { AsyncPipe } from "@angular/common"; +import { ClickOutsideModule } from "ng-click-outside"; +import { InviteManageCardComponent, ProfileInfoComponent } from "@uilib"; +import { NotificationService } from "@ui/services/notification/notification.service"; +import { AuthInfoService } from "@api/auth/facades/auth-info.service"; + +/** Компонент заголовка приложения с панелью уведомлений и инвайтами. */ +@Component({ + selector: "app-header", + templateUrl: "./header.component.html", + styleUrl: "./header.component.scss", + imports: [ + ClickOutsideModule, + IconComponent, + InviteManageCardComponent, + ProfileInfoComponent, + AsyncPipe, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class HeaderComponent { + private readonly notificationService = inject(NotificationService); + public readonly authService = inject(AuthInfoService); + + readonly invites = input([]); + + readonly acceptInvite = output(); + readonly rejectInvite = output(); + + showBall = this.notificationService.hasNotifications; + showNotifications = false; + + get hasInvites(): boolean { + return !!this.invites().filter(invite => invite.isAccepted === null).length; + } + + onClickOutside() { + this.showNotifications = false; + } + + onRejectInvite(inviteId: number): void { + this.rejectInvite.emit(inviteId); + this.showNotifications = false; + } + + onAcceptInvite(inviteId: number): void { + this.acceptInvite.emit(inviteId); + this.showNotifications = false; + } +} diff --git a/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.html b/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.html new file mode 100644 index 000000000..4eb9a8284 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.html @@ -0,0 +1,303 @@ + + +
    +
    + @if (shouldShowProjectInfo()) { +
    +
    +

    12

    + +
    +
    +

    12

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

    {{ info()?.name }}

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

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

    + } @else { +

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

    + } +
    + + + @if (type() === "projects" || type() === "invite") { + @if (info().name) { +

    {{ info().name | truncate: 12 }}

    + } +

    + @if (industryRepository.getOne(info()?.industry!); as industry) { + {{ industry?.name }} + } +

    + } @else { +

    {{ info()?.firstName | truncate: 10 }}

    +

    {{ info()?.lastName | truncate: 10 }}

    + @if (info()?.speciality) { +

    + {{ info()?.speciality | truncate: 20 }} + @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 | truncate: 10 }} +
    • + } +
    + } + } +
    +
    + + +
    + @if (type() === "projects") { +
    + @if (info().partnerProgram || info().draft) { + @if (info().partnerProgram && info().draft) { +
    + +
    + + @if (programProjectHovered) { +
    +

    + проект привязан к программе {{ info().partnerProgram.name }} +

    +
    + } + } + + + проект + + +
    + +
    + + @if (iconHovered) { +
    +

    + @if (info().draft) { + проект пока еще не опубликовали. + } @else { + проект привязан к программе + {{ info().partnerProgram.name }} + } +

    +
    + } + } @else { + + проект + + } +
    + } @else if (type() === "members") { + @if (leaderId() === loggedUserId() && loggedUserId() !== info()?.userId) { +
    + выдать роль + удалить +
    + } @else { + + профиль + + } + } @else { +
    + + принять + + + отклонить + +
    + } +
    +
    + + + +
    +

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

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

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

    +

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

    + + Хорошо + +
    +
    +
    diff --git a/projects/social_platform/src/app/office/features/info-card/info-card.component.scss b/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.scss similarity index 100% rename from projects/social_platform/src/app/office/features/info-card/info-card.component.scss rename to projects/social_platform/src/app/ui/widgets/info-card/info-card.component.scss diff --git a/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.spec.ts b/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.spec.ts new file mode 100644 index 000000000..0fc33a848 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.spec.ts @@ -0,0 +1,50 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { of } from "rxjs"; +import { IndustryRepositoryPort } from "@domain/industry/ports/industry.repository.port"; +import { InfoCardComponent } from "./info-card.component"; +import { provideRouter } from "@angular/router"; +import { ProjectSubscriptionRepositoryPort } from "@domain/project/ports/project-subscription.repository.port"; +import { EventBus } from "@domain/shared/event-bus"; + +describe("ProjectCardComponent", () => { + let component: InfoCardComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const industrySpy = { industries: () => of([]), getOne: () => of({ name: "Test Industry" }) }; + + await TestBed.configureTestingModule({ + imports: [InfoCardComponent], + providers: [ + { provide: IndustryRepositoryPort, useValue: industrySpy }, + { + provide: ProjectSubscriptionRepositoryPort, + useValue: { addSubscription: of({}), deleteSubscription: of({}) }, + }, + { provide: EventBus, useValue: { emit: () => {} } }, + provideRouter([]), + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(InfoCardComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput("info", { + id: 1, + name: "Test Project", + description: "", + skills: [], + status: "active", + isCompetitive: false, + }); + fixture.componentRef.setInput("type", "projects"); + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.ts b/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.ts new file mode 100644 index 000000000..12a9fedf7 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.ts @@ -0,0 +1,258 @@ +/** @format */ + +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + inject, + Input, + input, + output, +} from "@angular/core"; +import { IconComponent, ButtonComponent } from "@ui/primitives"; +import { AvatarComponent } from "@ui/primitives/avatar/avatar.component"; +import { CommonModule } from "@angular/common"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { ClickOutsideModule } from "ng-click-outside"; +import { Router, RouterLink } from "@angular/router"; +import { TagComponent } from "@ui/primitives/tag/tag.component"; +import { YearsFromBirthdayPipe, TruncatePipe } from "@corelib"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { AddProjectSubscriptionUseCase } from "@api/project/use-cases/add-project-subscription.use-case"; +import { DeleteProjectSubscriptionUseCase } from "@api/project/use-cases/delete-project-subscription.use-case"; +import { AppRoutes } from "@api/paths/app-routes"; +import { IndustryRepositoryPort } from "@domain/industry/ports/industry.repository.port"; + +/** + * Компонент карточки информации с разным наполнением, в зависимости от контекста + */ +@Component({ + selector: "app-info-card", + templateUrl: "./info-card.component.html", + styleUrl: "./info-card.component.scss", + imports: [ + CommonModule, + AvatarComponent, + IconComponent, + ModalComponent, + ButtonComponent, + ClickOutsideModule, + TagComponent, + YearsFromBirthdayPipe, + TruncatePipe, + RouterLink, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class InfoCardComponent { + private readonly destroyRef = inject(DestroyRef); + private readonly addProjectSubscriptionUseCase = inject(AddProjectSubscriptionUseCase); + private readonly deleteProjectSubscriptionUseCase = inject(DeleteProjectSubscriptionUseCase); + public readonly industryRepository = inject(IndustryRepositoryPort); + private readonly router = inject(Router); + private readonly logger = inject(LoggerService); + + protected readonly AppRoutes = AppRoutes; + + readonly info = input(); + readonly type = input<"invite" | "projects" | "members" | "rating">("projects"); + readonly appereance = input<"my" | "subs" | "base" | "empty">("base"); + readonly section = input<"projects" | "subscriptions" | "other">("projects"); + readonly canDelete = input(false); + @Input() isSubscribed?: boolean | null = false; + readonly profileId = input(); + readonly leaderId = input(); + readonly loggedUserId = input(); + + readonly onAcceptingInvite = output(); + readonly onRejectingInvite = output(); + readonly onCreate = output(); + readonly onRemoveCollaborator = output(); + + // Состояние компонента + isUnsubscribeModalOpen = false; + inviteErrorModal = false; + haveBadge = this.calculateHaveBadge(); + + programProjectHovered = false; + iconHovered = false; + draftProjectHovered = false; + + removeCollaboratorFromProject(userId: number): void { + this.onRemoveCollaborator.emit(userId); + } + + /** + * Определяет, нужно ли показывать информацию о проекте + */ + shouldShowProjectInfo(): boolean { + return ( + this.type() === "projects" && this.appereance() !== "subs" && this.appereance() !== "empty" + ); + } + + /** + * Определяет, нужно ли показывать бейдж подписки + */ + shouldShowSubscriptionBadge(): boolean { + return ( + this.appereance() !== "empty" && + this.haveBadge && + this.appereance() === "base" && + this.type() !== "invite" && + this.type() !== "members" + ); + } + + /** + * Возвращает URL для аватара + */ + getAvatarUrl(): string { + const currentImageAddress = + this.appereance() === "empty" && this.section() === "projects" + ? "/assets/images/projects/shared/add-project.svg" + : this.appereance() === "empty" && this.section() === "subscriptions" + ? "/assets/images/projects/shared/empty-subscriptions.svg" + : ""; + return this.info()?.imageAddress || this.info()?.avatar || currentImageAddress; + } + + /** + * Переключение подписки (универсальный метод) + */ + toggleSubscription(event: Event): void { + if (this.isSubscribed) { + this.onSubscribe(event, this.profileId()!); + } else { + this.onSubscribe(event, this.profileId()!); + } + } + + /** + * Обработка отклонения приглашения + */ + onRejectInvite(event: Event, inviteId: number): void { + if (!this.info() || !inviteId) { + this.logger.warn("Cannot reject invite: missing project or inviteId"); + return; + } + + this.stopEventPropagation(event); + this.onRejectingInvite.emit(inviteId); + } + + /** + * Обработка принятия приглашения + */ + onAcceptInvite(event: Event, inviteId: number): void { + if (!this.info() || !inviteId) { + this.logger.warn("Cannot accept invite: missing project or inviteId"); + return; + } + + this.stopEventPropagation(event); + this.onAcceptingInvite.emit(inviteId); + } + + /** + * Подписка на проект или открытие модального окна отписки + */ + onSubscribe(event: Event, projectId: number): void { + if (!projectId) { + this.logger.warn("Cannot subscribe: missing projectId"); + return; + } + + this.stopEventPropagation(event); + + if (this.isSubscribed) { + this.isUnsubscribeModalOpen = true; + return; + } + + this.addProjectSubscriptionUseCase + .execute(projectId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: result => { + if (!result.ok) { + this.logger.error("Error subscribing to project:", result.error); + return; + } + + this.isSubscribed = true; + }, + }); + } + + /** + * Отписка от проекта + */ + onUnsubscribe(event: Event, projectId: number): void { + if (!projectId) { + this.logger.warn("Cannot unsubscribe: missing projectId"); + return; + } + + this.stopEventPropagation(event); + + this.deleteProjectSubscriptionUseCase + .execute(projectId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: result => { + if (!result.ok) { + this.logger.error("Error unsubscribing from project:", result.error); + return; + } + + this.isSubscribed = false; + this.isUnsubscribeModalOpen = false; + }, + }); + } + + /** + * Закрытие модального окна отписки + */ + 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(AppRoutes.projects.all()) + .then(() => this.logger.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/ui/widgets/message-input/message-input.component.html b/projects/social_platform/src/app/ui/widgets/message-input/message-input.component.html new file mode 100644 index 000000000..88915f0b9 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/message-input/message-input.component.html @@ -0,0 +1,125 @@ + + +
    + @if (attachFiles.length) { +
      + @for (file of attachFiles; let index = $index; track index) { +
    • + +
      +

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

      + @if (file.type) { +
      + {{ file.type.includes("/") ? (file.type | fileType) : (file.type | uppercase) }} • + {{ +file.size | formatedFileSize }} +
      + } +
      + @if (file.loading) { + + + + + + + } @else { + + } +
    • + } +
    + } + @if (editingMessage) { +
    + +
    +
    + {{ editingMessage.author.firstName }} {{ editingMessage.author.lastName }} +
    +
    {{ editingMessage.text }}
    +
    + +
    + } + @if (replyMessage()) { +
    + +
    +
    + {{ replyMessage()?.author?.firstName }} {{ replyMessage()?.author?.lastName }} +
    +
    {{ replyMessage()?.text }}
    +
    + +
    + } +
    + + + +
    + @if (showDropModal) { +
    +
    +
    + drop files +

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

    +

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

    +
    +
    + } +
    diff --git a/projects/social_platform/src/app/office/features/message-input/message-input.component.scss b/projects/social_platform/src/app/ui/widgets/message-input/message-input.component.scss similarity index 99% rename from projects/social_platform/src/app/office/features/message-input/message-input.component.scss rename to projects/social_platform/src/app/ui/widgets/message-input/message-input.component.scss index f9f689b14..ad62c820d 100644 --- a/projects/social_platform/src/app/office/features/message-input/message-input.component.scss +++ b/projects/social_platform/src/app/ui/widgets/message-input/message-input.component.scss @@ -216,7 +216,7 @@ $button-size: 40px; padding: 36px; background-color: var(--white); border: 2px dashed var(--accent); - border-radius: 5px; + border-radius: var(--rounded-md); transform: translate(-50%, -50%); } diff --git a/projects/social_platform/src/app/ui/widgets/message-input/message-input.component.spec.ts b/projects/social_platform/src/app/ui/widgets/message-input/message-input.component.spec.ts new file mode 100644 index 000000000..853129221 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/message-input/message-input.component.spec.ts @@ -0,0 +1,42 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { MessageInputComponent } from "./message-input.component"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { provideNgxMask } from "ngx-mask"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { API_URL } from "@corelib"; +import { of } from "rxjs"; + +describe("MessageInputComponent", () => { + let component: MessageInputComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const authSpy = { + fetchProfile: of({}), + fetchUserRoles: of([]), + fetchChangeableRoles: of([]), + }; + + await TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, MessageInputComponent], + providers: [ + { provide: AuthRepositoryPort, useValue: authSpy }, + { provide: API_URL, useValue: "" }, + provideNgxMask(), + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(MessageInputComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/widgets/message-input/message-input.component.ts b/projects/social_platform/src/app/ui/widgets/message-input/message-input.component.ts new file mode 100644 index 000000000..150aa7dd0 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/message-input/message-input.component.ts @@ -0,0 +1,291 @@ +/** @format */ + +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DestroyRef, + forwardRef, + inject, + Input, + input, + OnDestroy, + OnInit, + output, +} from "@angular/core"; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; +import { ChatMessage } from "@domain/chat/chat-message.model"; +import { fromEvent, map } from "rxjs"; +import { FileService } from "@core/lib/services/file/file.service"; +import { FileTypePipe } from "@ui/pipes/file-type.pipe"; +import { AutosizeModule } from "ngx-autosize"; +import { NgxMaskDirective } from "ngx-mask"; +import { IconComponent } from "@ui/primitives"; +import { UpperCasePipe } from "@angular/common"; +import { FormatedFileSizePipe } from "@corelib"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; + +/** Компонент ввода сообщений для чата с поддержкой файлов, редактирования и ControlValueAccessor. */ +@Component({ + selector: "app-message-input", + templateUrl: "./message-input.component.html", + styleUrl: "./message-input.component.scss", + providers: [ + { + // Регистрация как ControlValueAccessor для работы с формами + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MessageInputComponent), + multi: true, + }, + ], + imports: [ + IconComponent, + NgxMaskDirective, + AutosizeModule, + FileTypePipe, + FormatedFileSizePipe, + UpperCasePipe, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MessageInputComponent implements OnInit, OnDestroy, ControlValueAccessor { + private readonly destroyRef = inject(DestroyRef); + private readonly cdr = inject(ChangeDetectorRef); + + constructor(private readonly fileService: FileService) {} + + readonly placeholder = input(""); + readonly mask = input(""); + + /** Приватное поле для хранения редактируемого сообщения */ + private _editingMessage?: ChatMessage; + + @Input() + set editingMessage(message: ChatMessage | undefined) { + this._editingMessage = message; + + // Иммутабельно перезаписываем value и пропагируем в форму — иначе textarea (OnPush) не обновится, + // и при submit форма будет пустая (форма не знает что мы подставили текст). + const newText = message !== undefined ? message.text : ""; + this.value = { ...this.value, text: newText }; + this.onChange(this.value); + this.cdr.markForCheck(); + } + + get editingMessage(): ChatMessage | undefined { + return this._editingMessage; + } + + readonly replyMessage = input(); + + @Input() + set appValue(value: MessageInputComponent["value"]) { + this.value = value; + } + + get appValue(): MessageInputComponent["value"] { + return this.value; + } + + readonly appValueChange = output(); + readonly submit = output(); + readonly resize = output(); + readonly cancel = output(); + + ngOnInit(): void { + // Обработчик события dragover для всего документа + fromEvent(document, "dragover") + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: e => { + this.handleDragOver(e); + this.cdr.markForCheck(); + }, + }); + + // Обработчик события drop для всего документа + fromEvent(document, "drop") + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: e => { + this.handleDrop(e); + this.cdr.markForCheck(); + }, + }); + } + + ngOnDestroy(): void {} + + private handleDragOver(event: DragEvent): void { + event.stopPropagation(); + event.preventDefault(); + this.showDropModal = true; + } + + private handleDrop(event: DragEvent): void { + event.stopPropagation(); + event.preventDefault(); + + const files = event.dataTransfer?.files; + if (!files) return; + + this.addFiles(files); + this.showDropModal = false; + } + + /** Флаг отображения модального окна для drag&drop */ + showDropModal = false; + + /** Значение компонента: текст и массив URL файлов */ + value: { text: string; filesUrl: string[] } = { text: "", filesUrl: [] }; + + onInput(event: Event): void { + const value = (event.target as HTMLInputElement).value; + const newValue = { ...this.value, text: value }; + + this.onChange(newValue); + this.appValueChange.emit(newValue); + this.value = newValue; + } + + onBlur(): void { + this.onTouch(); + } + + // Методы ControlValueAccessor + writeValue(value: MessageInputComponent["value"]): void { + setTimeout(() => { + this.value = value; + this.cdr.markForCheck(); + + // Очистка списка файлов если нет URL файлов + if (!value.filesUrl.length) { + this.attachFiles = []; + } + + // OnPush не перерисует textarea без явного markForCheck — без этого поле не очищается после submit + this.cdr.markForCheck(); + }); + } + + /** Функция обратного вызова для уведомления об изменениях */ + // eslint-disable-next-line no-use-before-define + onChange: (value: MessageInputComponent["value"]) => void = () => {}; + + registerOnChange(fn: (v: MessageInputComponent["value"]) => void): void { + this.onChange = fn; + } + + /** Функция обратного вызова для уведомления о касании */ + onTouch: () => void = () => {}; + + registerOnTouched(fn: () => void): void { + this.onTouch = fn; + } + + /** Флаг отключения компонента */ + disabled = false; + + setDisabledState(isDisabled: boolean) { + this.disabled = isDisabled; + } + + onTextareaKeydown(event: any) { + if (event.key === "Tab") { + event.preventDefault(); + const textarea = event.target as HTMLTextAreaElement; + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + + // Вставка символа табуляции в позицию курсора + textarea.value = textarea.value.substring(0, start) + "\t" + textarea.value.substring(end); + textarea.selectionStart = textarea.selectionEnd = start + 1; + this.onInput(event); + } + } + + /** Массив прикрепленных файлов с метаданными */ + attachFiles: { + name: string; + size: string; + type: string; + link?: string; + loading: boolean; + }[] = []; + + onUpload(evt: Event) { + const files = (evt.currentTarget as HTMLInputElement).files; + + if (!files?.length) { + return; + } + + this.addFiles(files); + } + + private addFiles(files: FileList): void { + // Создание записей для каждого файла + for (let i = 0; i < files.length; i++) { + this.attachFiles.push({ + name: files[i].name, + size: files[i].size.toString(), + type: files[i].type, + loading: true, + }); + } + + // Загрузка каждого файла на сервер + for (let i = 0; i < files.length; i++) { + this.fileService + .uploadFile(files[i]) + .pipe( + map(r => r.url), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe({ + next: url => { + // Обновление значения компонента с новым URL файла + this.value = { + ...this.value, + filesUrl: [...this.value.filesUrl, url], + }; + this.cdr.markForCheck(); + + this.onChange(this.value); + this.cdr.markForCheck(); + + // Обновление метаданных файла + setTimeout(() => { + this.attachFiles[i].loading = false; + this.attachFiles[i].link = url; + this.cdr.markForCheck(); + }); + }, + complete: () => { + setTimeout(() => { + this.attachFiles[i].loading = false; + this.cdr.markForCheck(); + }); + }, + }); + } + } + + onDeleteFile(idx: number): void { + const file = this.attachFiles[idx]; + if (!file || !file.link) return; + + this.fileService + .deleteFile(file.link) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + // Удаление из массива прикрепленных файлов + this.attachFiles.splice(idx, 1); + this.cdr.markForCheck(); + // Удаление URL из значения компонента + this.value.filesUrl.splice(idx, 1); + this.onChange(this.value); + this.cdr.markForCheck(); + }); + } +} diff --git a/projects/social_platform/src/app/ui/widgets/news-card/carousel/carousel.component.html b/projects/social_platform/src/app/ui/widgets/news-card/carousel/carousel.component.html new file mode 100644 index 000000000..ceb82d7c5 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/news-card/carousel/carousel.component.html @@ -0,0 +1,29 @@ + + +@if (images().length) { + +} diff --git a/projects/social_platform/src/app/office/shared/carousel/carousel.component.scss b/projects/social_platform/src/app/ui/widgets/news-card/carousel/carousel.component.scss similarity index 100% rename from projects/social_platform/src/app/office/shared/carousel/carousel.component.scss rename to projects/social_platform/src/app/ui/widgets/news-card/carousel/carousel.component.scss diff --git a/projects/social_platform/src/app/office/shared/carousel/carousel.component.spec.ts b/projects/social_platform/src/app/ui/widgets/news-card/carousel/carousel.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/office/shared/carousel/carousel.component.spec.ts rename to projects/social_platform/src/app/ui/widgets/news-card/carousel/carousel.component.spec.ts diff --git a/projects/social_platform/src/app/ui/widgets/news-card/carousel/carousel.component.ts b/projects/social_platform/src/app/ui/widgets/news-card/carousel/carousel.component.ts new file mode 100644 index 000000000..568464bef --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/news-card/carousel/carousel.component.ts @@ -0,0 +1,69 @@ +/** @format */ + +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + EventEmitter, + inject, + input, + OnInit, + output, + Output, +} from "@angular/core"; +import { FileModel } from "@domain/file/file.model"; +import { IconComponent } from "@uilib"; + +/** Компонент карусели для просмотра изображений с навигацией и лайками. */ +@Component({ + selector: "app-carousel", + imports: [IconComponent], + templateUrl: "./carousel.component.html", + styleUrls: ["./carousel.component.scss"], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CarouselComponent implements OnInit { + readonly images = input>([]); + readonly like = output(); + + private readonly cdRef = inject(ChangeDetectorRef); + + currentIndex = 0; + lastTouch = 0; + showLike = false; + + ngOnInit(): void {} + + next(): void { + if (this.images.length) { + this.currentIndex = (this.currentIndex + 1) % this.images.length; + } + } + + prev(): void { + if (this.images.length) { + this.currentIndex = (this.currentIndex - 1 + this.images.length) % this.images.length; + } + } + + onTouchImg(_event: TouchEvent): void { + const now = Date.now(); + if (now - this.lastTouch < 300) { + this.like.emit(this.currentIndex); + this.showLike = true; + setTimeout(() => { + this.showLike = false; + this.cdRef.markForCheck(); + }, 1000); + } + this.lastTouch = now; + } + + getImageUrl(image: FileModel | string): string { + return typeof image === "string" ? image : image.link; + } + + getImageName(image: FileModel | string): string { + return typeof image === "string" ? "Image" : image.name || "Image"; + } +} diff --git a/projects/social_platform/src/app/ui/widgets/news-card/news-card.component.html b/projects/social_platform/src/app/ui/widgets/news-card/news-card.component.html new file mode 100644 index 000000000..1968d7cef --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/news-card/news-card.component.html @@ -0,0 +1,144 @@ + +
    +
    + + +
    +
    {{ feedItem().name | truncate: 30 }}
    +
    + {{ feedItem().datetimeCreated | dayjs: "format" : "DD.MM.YY" }} +
    +
    +
    + @if (isOwner()) { +
    +
    + +
    + @if (menuOpen) { +
      + @if (!editMode) { +
    • редактировать
    • + } +
    • удалить
    • +
    + } +
    + } +
    + @if (feedItem().text) { +
    + @if (!editMode) { +

    + } @else { + @if (editForm.get("text"); as text) { + + } + } +
    + } + @defer (when editMode) { +
      + @for (f of imagesEditList; track f.id) { + + } +
    +
      + @for (f of filesEditList; track f.id) { + + } +
    + } + @if (descriptionExpandable() && !editMode) { +
    + {{ readFullDescription() ? "скрыть" : "подробнее" }} +
    + } + @if (!editMode) { + + } + @if (!editMode && filesViewList.length) { +
    + @for (f of filesViewList; track $index) { + + } +
    + } + @if (!editMode) { + + } @else { + + } +
    diff --git a/projects/social_platform/src/app/ui/widgets/news-card/news-card.component.scss b/projects/social_platform/src/app/ui/widgets/news-card/news-card.component.scss new file mode 100644 index 000000000..d8a9b4077 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/news-card/news-card.component.scss @@ -0,0 +1,244 @@ +@use "styles/typography"; +@use "styles/responsive"; + +.card { + padding: 24px 12px; + background-color: var(--light-white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-lg); + + &__head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; + } + + &__menu { + position: relative; + } + + &__dots { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + color: var(--dark-grey); + cursor: pointer; + } + + &__options { + position: absolute; + top: 0%; + right: 0%; + z-index: 2; + padding: 20px 0; + background-color: var(--white); + border: 0.5px solid var(--medium-grey-for-outline); + border-radius: var(--rounded-xl); + } + + &__option { + width: 120px; + padding: 5px 20px; + color: var(--grey-for-text); + cursor: pointer; + transition: background-color 0.2s; + + &:hover { + color: var(--accent); + } + } + + &__avatar { + width: 40px; + height: 40px; + margin-right: 10px; + border-radius: 50%; + object-fit: cover; + } + + &__title { + display: flex; + align-items: center; + } + + &__top { + display: flex; + gap: 10px; + align-items: center; + } + + &__name { + color: var(--black); + } + + &__date { + color: var(--dark-grey); + } + + &__views { + display: flex; + gap: 3px; + align-items: center; + color: var(--dark-grey); + + i { + margin-bottom: 1px; + } + } + + &__right { + display: flex; + gap: 5px; + align-items: center; + } + + /* stylelint-disable value-no-vendor-prefix */ + &__text { + color: var(--grey-for-text); + white-space: break-spaces; + + p { + display: -webkit-box; + overflow: hidden; + color: var(--black); + text-overflow: ellipsis; + -webkit-box-orient: vertical; + -webkit-line-clamp: 4; + transition: all 0.7s ease-in-out; + + &.expanded { + -webkit-line-clamp: unset; + } + } + } + /* stylelint-enable value-no-vendor-prefix */ + + &__edit-files { + display: flex; + flex-direction: column; + gap: 10px; + + &:not(:empty) { + margin-top: 30px; + } + } + + &__gallery { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 10px; + margin-bottom: 10px; + } + + &__files { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 20px; + } + + &__img { + position: relative; + + img { + width: 100%; + object-fit: cover; + } + } + + &__img-like { + position: absolute; + top: 50%; + left: 50%; + display: flex; + align-items: center; + justify-content: center; + width: 75px; + height: 75px; + color: var(--accent); + background-color: var(--white); + border-radius: var(--rounded-xl); + transition: transform 0.1s ease-in-out; + transform: translate(-50%, -50%) scale(0); + + &--show { + transform: translate(-50%, -50%) scale(1); + } + } + + &__footer { + margin-top: 10px; + } +} + +.footer { + display: flex; + align-items: center; + justify-content: space-between; + + &__left { + display: flex; + gap: 10px; + align-items: center; + } + + &__item { + display: flex; + align-items: center; + color: var(--dark-grey); + } + + &__like { + cursor: pointer; + + &--active { + color: var(--accent); + } + } +} + +.share { + color: var(--dark-grey); + + &__icon { + cursor: pointer; + } +} + +.read-more { + margin-top: 12px; + color: var(--accent); + cursor: pointer; + transition: color 0.2s; + + &:hover { + color: var(--accent-dark); + } +} + +.editor-footer { + display: flex; + justify-content: space-between; + padding-top: 10px; + margin-top: 20px; + border-top: 1px solid var(--medium-grey-for-outline); + + &__actions { + display: flex; + gap: 10px; + align-items: center; + } + + &__attach { + color: var(--dark-grey); + cursor: pointer; + + input { + display: none; + } + } +} diff --git a/projects/social_platform/src/app/ui/widgets/news-card/news-card.component.spec.ts b/projects/social_platform/src/app/ui/widgets/news-card/news-card.component.spec.ts new file mode 100644 index 000000000..b6d579ad7 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/news-card/news-card.component.spec.ts @@ -0,0 +1,53 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { NewsCardComponent } from "./news-card.component"; +import { RouterTestingModule } from "@angular/router/testing"; +import { ReactiveFormsModule } from "@angular/forms"; +import { of } from "rxjs"; +import { ProjectNewsRepository as ProjectNewsService } from "@infrastructure/repository/project/project-news.repository"; +import { AuthRepository } from "@infrastructure/repository/auth/auth.repository"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { DayjsPipe } from "projects/core"; +import { FeedNews } from "@domain/news/project-news.model"; +import { API_URL } from "@corelib"; + +describe("NewsCardComponent", () => { + let component: NewsCardComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const projectNewsServiceSpy = { addNews: vi.fn() }; + const authSpy = { + profile: of({}), + }; + + await TestBed.configureTestingModule({ + imports: [ + RouterTestingModule, + ReactiveFormsModule, + HttpClientTestingModule, + NewsCardComponent, + DayjsPipe, + ], + providers: [ + { provide: ProjectNewsService, useValue: projectNewsServiceSpy }, + { provide: AuthRepository, useValue: authSpy }, + { provide: API_URL, useValue: "" }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(NewsCardComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput("feedItem", FeedNews.default()); + fixture.componentRef.setInput("resourceLink", ["office", "projects", 1, "news"]); + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/widgets/news-card/news-card.component.ts b/projects/social_platform/src/app/ui/widgets/news-card/news-card.component.ts new file mode 100644 index 000000000..8ffc33b4d --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/news-card/news-card.component.ts @@ -0,0 +1,444 @@ +/** @format */ + +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DestroyRef, + ElementRef, + EventEmitter, + inject, + input, + OnInit, + output, + Output, + signal, + viewChild, + ViewChild, +} from "@angular/core"; +import { SnackbarService } from "@domain/shared/snackbar.service"; +import { ActivatedRoute, RouterLink } from "@angular/router"; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; +import { + DayjsPipe, + FormControlPipe, + LoggerService, + ParseBreaksPipe, + ParseLinksPipe, + ValidationService, + TruncatePipe, +} from "@corelib"; +import { FileService } from "@core/lib/services/file/file.service"; +import { nanoid } from "nanoid"; +import { ClickOutsideModule } from "ng-click-outside"; +import { CarouselComponent } from "./carousel/carousel.component"; +import { ImgCardComponent } from "@ui/primitives/img-card/img-card.component"; +import { FeedNews } from "@domain/news/project-news.model"; +import { ButtonComponent, IconComponent } from "@ui/primitives"; +import { TextareaComponent } from "@ui/primitives/textarea/textarea.component"; +import { FileUploadItemComponent } from "@ui/primitives/file-upload-item/file-upload-item.component"; +import { FileItemComponent } from "@ui/primitives/file-item/file-item.component"; +import { FileModel } from "@domain/file/file.model"; +import { catchError, forkJoin, noop, Observable, of, take, tap } from "rxjs"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { ExpandService } from "@api/expand/expand.service"; + +/** Виджет карточки новости: отображение, лайк, режим редактирования. */ +@Component({ + selector: "app-news-card", + templateUrl: "./news-card.component.html", + styleUrl: "./news-card.component.scss", + imports: [ + ClickOutsideModule, + RouterLink, + IconComponent, + TextareaComponent, + ReactiveFormsModule, + FileUploadItemComponent, + FileItemComponent, + ButtonComponent, + DayjsPipe, + FormControlPipe, + TruncatePipe, + ParseLinksPipe, + ParseBreaksPipe, + CarouselComponent, + ImgCardComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ExpandService], +}) +export class NewsCardComponent implements OnInit { + private readonly destroyRef = inject(DestroyRef); + private readonly expandService = inject(ExpandService); + + constructor( + private readonly snackbarService: SnackbarService, + private readonly route: ActivatedRoute, + private readonly fb: FormBuilder, + private readonly validationService: ValidationService, + private readonly fileService: FileService, + private readonly cdRef: ChangeDetectorRef, + private readonly loggerService: LoggerService, + ) { + this.editForm = this.fb.group({ + text: ["", [Validators.required]], + }); + } + + readonly feedItem = input.required(); + readonly resourceLink = input.required<(string | number)[]>(); + readonly contentId = input(); + readonly isOwner = input(); + + readonly delete = output(); + readonly like = output(); + readonly edited = output(); + + placeholderUrl = "https://hwchamber.co.uk/wp-content/uploads/2022/04/avatar-placeholder.gif"; + + editMode = false; + editForm: FormGroup; + + private profileId = signal(0); + + // Оригинальные списки (не изменяются во время редактирования) + imagesViewList: FileModel[] = []; + filesViewList: FileModel[] = []; + + // Списки для редактирования + imagesEditList: { + id: string; + src: string; + loading: boolean; + error: boolean; + tempFile: File | null; + }[] = []; + + filesEditList: { + id: string; + src: string; + loading: boolean; + error: string; + name: string; + size: number; + type: string; + tempFile: File | null; + }[] = []; + + readonly newsTextEl = viewChild("newsTextEl"); + + ngOnInit(): void { + this.editForm.setValue({ + text: this.feedItem().text, + }); + + const processedFiles = this.feedItem().files.map(file => { + if (typeof file === "string") { + return { + link: file, + name: "Image", + mimeType: "image/jpeg", + size: 0, + datetimeUploaded: "", + extension: "", + user: 0, + } as FileModel; + } + return file; + }); + + this.showLikes = this.feedItem().files.map(() => false); + + this.imagesViewList = processedFiles.filter(f => { + const [type] = (f.mimeType || "").split("/"); + return type === "image" || f.mimeType === "x-empty"; + }); + + this.filesViewList = processedFiles.filter(f => { + const [type] = (f.mimeType || "").split("/"); + return type !== "image" && f.mimeType !== "x-empty"; + }); + + this.route.params.pipe(take(1), takeUntilDestroyed(this.destroyRef)).subscribe({ + next: q => { + this.profileId.set(q["id"]); + this.cdRef.markForCheck(); + }, + }); + + this.initEditLists(); + } + + ngOnDestroy(): void {} + + /** + * Инициализация списков редактирования из текущих данных + */ + private initEditLists(): void { + this.imagesEditList = this.imagesViewList.map(file => ({ + src: file.link, + id: nanoid(), + error: false, + loading: false, + tempFile: null, + })); + + this.filesEditList = this.filesViewList.map(file => ({ + src: file.link, + id: nanoid(), + error: "", + loading: false, + name: file.name, + size: file.size, + type: file.mimeType, + tempFile: null, + })); + } + + ngAfterViewInit(): void { + setTimeout(() => { + this.expandService.checkExpandable("description", true, this.newsTextEl()); + this.cdRef.markForCheck(); + }); + } + + onCopyLink(): void { + const isProject = this.resourceLink()[0].toString().includes("projects"); + const isProfile = this.resourceLink()[0].toString().includes("profile"); + let fullUrl = ""; + + if (isProject) { + fullUrl = `${location.origin}/office/projects/${this.contentId()}/news/${this.feedItem().id}`; + } else { + fullUrl = `${location.origin}/office/profile/${this.profileId() || this.contentId()}/news/${ + this.feedItem().id + }`; + } + + navigator.clipboard.writeText(fullUrl).then(() => { + this.snackbarService.success("Ссылка скопирована"); + }); + } + + menuOpen = false; + + onCloseMenu() { + this.menuOpen = false; + } + + onEditSubmit(): void { + if (!this.validationService.getFormValidation(this.editForm)) return; + + const uploadedImages = this.imagesEditList + .filter(f => f.src && !f.loading && !f.error) + .map(f => f.src); + + this.imagesViewList = this.imagesEditList + .filter(f => f.src && !f.loading && !f.error) + .map(f => ({ + link: f.src, + name: "Image", + mimeType: "image/jpeg", + size: 0, + datetimeUploaded: "", + extension: "", + user: 0, + })); + + this.filesViewList = this.filesEditList + .filter(f => f.src && !f.loading && !f.error) + .map(f => ({ + link: f.src, + name: f.name, + size: f.size, + mimeType: f.type, + datetimeUploaded: "", + extension: "", + user: 0, + })); + + this.feedItem().text = this.editForm.value.text; + + this.feedItem().files = [...this.imagesViewList, ...this.filesViewList]; + + this.edited.emit({ + ...this.editForm.value, + files: uploadedImages, + }); + + this.cdRef.detectChanges(); + this.onCloseEditMode(); + } + + onCloseEditMode() { + this.editMode = false; + + this.initEditLists(); + + this.editForm.setValue({ + text: this.feedItem().text, + }); + } + + onUploadFile(event: Event) { + const files = (event.currentTarget as HTMLInputElement).files; + if (!files) return; + + const observableArray: Observable[] = []; + + for (let i = 0; i < files.length; i++) { + const fileType = files[i].type.split("/")[0]; + + if (fileType === "image") { + const fileObj: NewsCardComponent["imagesEditList"][0] = { + id: nanoid(2), + src: "", + loading: true, + error: false, + tempFile: files[i], + }; + this.imagesEditList.push(fileObj); + + observableArray.push( + this.fileService.uploadFile(files[i]).pipe( + tap(file => { + fileObj.src = file.url; + fileObj.loading = false; + fileObj.tempFile = null; + }), + catchError(() => { + fileObj.loading = false; + fileObj.error = true; + return of(null); + }), + ), + ); + } else { + const fileObj: NewsCardComponent["filesEditList"][0] = { + id: nanoid(2), + loading: true, + error: "", + src: "", + tempFile: files[i], + name: files[i].name, + size: files[i].size, + type: files[i].type, + }; + this.filesEditList.push(fileObj); + + observableArray.push( + this.fileService.uploadFile(files[i]).pipe( + tap(file => { + fileObj.loading = false; + fileObj.src = file.url; + fileObj.tempFile = null; + }), + catchError(() => { + fileObj.loading = false; + fileObj.error = "Ошибка загрузки"; + return of(null); + }), + ), + ); + } + } + + forkJoin(observableArray).subscribe(noop); + + (event.currentTarget as HTMLInputElement).value = ""; + } + + onDeletePhoto(fId: string) { + const fileIdx = this.imagesEditList.findIndex(f => f.id === fId); + if (fileIdx === -1) return; + + if (this.imagesEditList[fileIdx].src) { + this.imagesEditList[fileIdx].loading = true; + this.fileService + .deleteFile(this.imagesEditList[fileIdx].src) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.imagesEditList.splice(fileIdx, 1); + this.cdRef.markForCheck(); + }); + } else { + this.imagesEditList.splice(fileIdx, 1); + } + } + + onDeleteFile(fId: string) { + const fileIdx = this.filesEditList.findIndex(f => f.id === fId); + if (fileIdx === -1) return; + + if (this.filesEditList[fileIdx].src) { + this.filesEditList[fileIdx].loading = true; + this.fileService + .deleteFile(this.filesEditList[fileIdx].src) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.filesEditList.splice(fileIdx, 1); + this.cdRef.markForCheck(); + }); + } else { + this.filesEditList.splice(fileIdx, 1); + } + } + + onRetryUpload(id: string) { + const fileObj = this.imagesEditList.find(f => f.id === id); + if (!fileObj || !fileObj.tempFile) return; + + fileObj.loading = true; + fileObj.error = false; + + this.fileService + .uploadFile(fileObj.tempFile) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: file => { + fileObj.src = file.url; + fileObj.loading = false; + fileObj.tempFile = null; + this.cdRef.markForCheck(); + }, + error: () => { + fileObj.error = true; + fileObj.loading = false; + }, + }); + } + + showLikes: boolean[] = []; + lastTouch = 0; + + onTouchImg(_event: TouchEvent, imgIdx: number) { + if (Date.now() - this.lastTouch < 300) { + this.like.emit(this.feedItem().id); + this.showLikes[imgIdx] = true; + + setTimeout(() => { + this.showLikes[imgIdx] = false; + this.cdRef.markForCheck(); + }, 1000); + } + + this.lastTouch = Date.now(); + } + + handleLike(index: number): void { + this.loggerService.info("Лайк на изображении с индексом: ", index); + } + + protected readonly descriptionExpandable = this.expandService.descriptionExpandable; + protected readonly readFullDescription = this.expandService.readFullDescription; + + protected onExpandNewsText(elem: HTMLElement): void { + this.expandService.onExpand( + "description", + elem, + "expanded", + this.expandService.readFullDescription(), + ); + } +} diff --git a/projects/social_platform/src/app/ui/widgets/news-form/news-form.component.html b/projects/social_platform/src/app/ui/widgets/news-form/news-form.component.html new file mode 100644 index 000000000..80931a0f6 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/news-form/news-form.component.html @@ -0,0 +1,60 @@ + + +
    +
    + + +
    +
    + @for (i of imagesList; track i.id) { + + } +
    +
    + @for (f of filesList; track f.id) { + + } +
    + +
    diff --git a/projects/social_platform/src/app/office/features/news-form/news-form.component.scss b/projects/social_platform/src/app/ui/widgets/news-form/news-form.component.scss similarity index 97% rename from projects/social_platform/src/app/office/features/news-form/news-form.component.scss rename to projects/social_platform/src/app/ui/widgets/news-form/news-form.component.scss index 668815fb7..2c5205a79 100644 --- a/projects/social_platform/src/app/office/features/news-form/news-form.component.scss +++ b/projects/social_platform/src/app/ui/widgets/news-form/news-form.component.scss @@ -6,7 +6,7 @@ &__row { display: flex; - align-items: center; + align-items: flex-start; ::ng-deep app-textarea { textarea { diff --git a/projects/social_platform/src/app/ui/widgets/news-form/news-form.component.spec.ts b/projects/social_platform/src/app/ui/widgets/news-form/news-form.component.spec.ts new file mode 100644 index 000000000..07bdcfcb3 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/news-form/news-form.component.spec.ts @@ -0,0 +1,48 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { NewsFormComponent } from "./news-form.component"; +import { RouterTestingModule } from "@angular/router/testing"; +import { ReactiveFormsModule } from "@angular/forms"; +import { ProjectNewsRepository as ProjectNewsService } from "@infrastructure/repository/project/project-news.repository"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { of } from "rxjs"; +import { AuthRepository } from "@infrastructure/repository/auth/auth.repository"; +import { API_URL } from "@corelib"; + +describe("NewsFormComponent", () => { + let component: NewsFormComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const projectNewsServiceSpy = { addNews: vi.fn() }; + const authSpy = { + profile: of({}), + }; + + await TestBed.configureTestingModule({ + imports: [ + RouterTestingModule, + ReactiveFormsModule, + HttpClientTestingModule, + NewsFormComponent, + ], + providers: [ + { provide: ProjectNewsService, useValue: projectNewsServiceSpy }, + { provide: AuthRepository, useValue: authSpy }, + { provide: API_URL, useValue: "" }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(NewsFormComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/widgets/news-form/news-form.component.ts b/projects/social_platform/src/app/ui/widgets/news-form/news-form.component.ts new file mode 100644 index 000000000..965c896e6 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/news-form/news-form.component.ts @@ -0,0 +1,284 @@ +/** @format */ + +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + effect, + inject, + input, + OnInit, + output, +} from "@angular/core"; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; +import { ValidationService } from "@corelib"; +import { nanoid } from "nanoid"; +import { AutosizeModule } from "ngx-autosize"; +import { TextareaComponent } from "@ui/primitives/textarea/textarea.component"; +import { ImgCardComponent } from "@ui/primitives/img-card/img-card.component"; +import { FileUploadItemComponent } from "@ui/primitives/file-upload-item/file-upload-item.component"; +import { IconComponent } from "@ui/primitives"; +import { FileService } from "@core/lib/services/file/file.service"; +import { catchError, forkJoin, noop, Observable, of, tap } from "rxjs"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; + +/** + * Компонент формы создания новости + * + * Функциональность: + * - Создание новой новости с текстом и прикрепленными файлами + * - Загрузка файлов через input или drag&drop, а также вставка из буфера обмена + * - Разделение файлов на изображения и документы + * - Предварительный просмотр загруженных файлов + * - Управление состояниями загрузки и ошибок для каждого файла + * - Возможность удаления и повторной загрузки файлов + * + * Выходные события: + * output addNews - событие добавления новости, передает объект с текстом и массивом URL файлов + * + * Внутренние свойства: + * - messageForm - форма с полем текста новости (обязательное) + * - imagesList - массив объектов изображений с состояниями загрузки + * - filesList - массив объектов файлов с состояниями загрузки + */ +@Component({ + selector: "app-news-form", + templateUrl: "./news-form.component.html", + styleUrl: "./news-form.component.scss", + imports: [ + ReactiveFormsModule, + AutosizeModule, + IconComponent, + FileUploadItemComponent, + ImgCardComponent, + TextareaComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NewsFormComponent implements OnInit { + private readonly destroyRef = inject(DestroyRef); + + constructor( + private readonly fb: FormBuilder, + private readonly validationService: ValidationService, + private readonly fileService: FileService, + ) { + this.messageForm = this.fb.group({ + text: ["", [Validators.required]], + }); + + effect(() => { + if (this.pending()) { + this.messageForm.disable({ emitEvent: false }); + } else { + this.messageForm.enable({ emitEvent: false }); + } + }); + } + + readonly addNews = output<{ text: string; files: string[] }>(); + + readonly pending = input(false); + + ngOnInit(): void {} + + messageForm: FormGroup; + + readonly maxTextLength = 15940; + + get isTextOverflow(): boolean { + return (this.messageForm.get("text")?.value?.length ?? 0) > this.maxTextLength; + } + + /** + * Обработчик отправки формы + * Валидирует форму и эмитит событие с данными новости + */ + onSubmit() { + if (this.pending()) return; + if (this.isTextOverflow) return; + if (!this.validationService.getFormValidation(this.messageForm)) { + return; + } + + this.addNews.emit({ + ...this.messageForm.value, + files: [...this.imagesList.map(f => f.src), ...this.filesList.map(f => f.src)], + }); + + this.onResetForm(); + } + + /** + * Сброс формы и очистка списков файлов + */ + onResetForm() { + this.imagesList = []; + this.filesList = []; + this.messageForm.reset(); + } + + // Массив изображений с метаданными + imagesList: { + id: string; + src: string; + loading: boolean; + error: boolean; + tempFile: File | null; + }[] = []; + + // Массив файлов с метаданными + filesList: { + id: string; + loading: boolean; + error: string; + src: string; + tempFile: File; + }[] = []; + + /** + * Загрузка файлов на сервер + * Разделяет файлы на изображения и документы, загружает параллельно + */ + uploadFiles(files: FileList) { + const observableArray: Observable[] = []; + for (let i = 0; i < files.length; i++) { + const fileType = files[i].type.split("/")[0]; + + if (fileType === "image") { + const fileObj: NewsFormComponent["imagesList"][0] = { + id: nanoid(2), + src: "", + loading: true, + error: false, + tempFile: files[0], + }; + this.imagesList.push(fileObj); + observableArray.push( + this.fileService.uploadFile(files[i]).pipe( + tap(file => { + fileObj.src = file.url; + fileObj.loading = false; + fileObj.tempFile = null; + }), + catchError(() => { + fileObj.loading = false; + fileObj.error = true; + return of(null); + }), + ), + ); + } else { + const fileObj: NewsFormComponent["filesList"][0] = { + id: nanoid(2), + loading: true, + error: "", + src: "", + tempFile: files[0], + }; + this.filesList.push(fileObj); + observableArray.push( + this.fileService.uploadFile(files[i]).pipe( + tap(file => { + fileObj.loading = false; + fileObj.src = file.url; + }), + catchError(() => { + fileObj.loading = false; + fileObj.error = "Ошибка загрузки"; + return of(null); + }), + ), + ); + } + } + + forkJoin(observableArray).subscribe(noop); + } + + /** + * Обработчик выбора файлов через input + */ + onInputFiles(event: Event) { + const files = (event.currentTarget as HTMLInputElement).files; + if (!files) return; + + this.uploadFiles(files); + } + + /** + * Обработчик вставки файлов из буфера обмена + */ + onPaste(event: ClipboardEvent) { + const files = event.clipboardData?.files; + if (!files) return; + + this.uploadFiles(files); + } + + /** + * Удаление изображения из списка + * Если файл уже загружен на сервер, удаляет его оттуда + */ + onDeletePhoto(fId: string) { + const fileIdx = this.imagesList.findIndex(f => f.id === fId); + + if (this.imagesList[fileIdx].src) { + this.imagesList[fileIdx].loading = true; + this.fileService + .deleteFile(this.imagesList[fileIdx].src) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.imagesList.splice(fileIdx, 1); + }); + } else { + this.imagesList.splice(fileIdx, 1); + } + } + + /** + * Удаление файла из списка + * Если файл уже загружен на сервер, удаляет его оттуда + */ + onDeleteFile(fId: string) { + const fileIdx = this.filesList.findIndex(f => f.id === fId); + + if (this.filesList[fileIdx].src) { + this.filesList[fileIdx].loading = true; + this.fileService + .deleteFile(this.imagesList[fileIdx].src) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.filesList.splice(fileIdx, 1); + }); + } else { + this.filesList.splice(fileIdx, 1); + } + } + + /** + * Повторная попытка загрузки изображения + * Используется при ошибке загрузки + */ + onRetryUpload(id: string) { + const fileObj = this.imagesList.find(f => f.id === id); + if (!fileObj || !fileObj.tempFile) return; + + fileObj.loading = true; + fileObj.error = false; + this.fileService + .uploadFile(fileObj.tempFile) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: file => { + fileObj.src = file.url; + fileObj.loading = false; + fileObj.tempFile = null; + }, + error: () => { + fileObj.error = true; + fileObj.loading = false; + }, + }); + } +} diff --git a/projects/social_platform/src/app/ui/widgets/program-links/program-links.component.html b/projects/social_platform/src/app/ui/widgets/program-links/program-links.component.html new file mode 100644 index 000000000..1b1ea9597 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/program-links/program-links.component.html @@ -0,0 +1,26 @@ + + + diff --git a/projects/social_platform/src/app/office/features/program-links/program-links.component.scss b/projects/social_platform/src/app/ui/widgets/program-links/program-links.component.scss similarity index 100% rename from projects/social_platform/src/app/office/features/program-links/program-links.component.scss rename to projects/social_platform/src/app/ui/widgets/program-links/program-links.component.scss diff --git a/projects/social_platform/src/app/ui/widgets/program-links/program-links.component.ts b/projects/social_platform/src/app/ui/widgets/program-links/program-links.component.ts new file mode 100644 index 000000000..5a486157f --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/program-links/program-links.component.ts @@ -0,0 +1,19 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, input } from "@angular/core"; +import { IconComponent } from "@uilib"; +import { UserLinksPipe, TruncatePipe } from "@corelib"; + +/** Виджет ссылок программы (контакты/материалы). */ +@Component({ + selector: "app-program-links", + templateUrl: "./program-links.component.html", + styleUrl: "./program-links.component.scss", + imports: [IconComponent, UserLinksPipe, TruncatePipe], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProgramLinksComponent { + readonly title = input.required(); + readonly icon = input.required(); + readonly links = input.required<{ label: string; url: string }[]>(); +} diff --git a/projects/social_platform/src/app/ui/widgets/project-direction-card/project-direction-card.component.html b/projects/social_platform/src/app/ui/widgets/project-direction-card/project-direction-card.component.html new file mode 100644 index 000000000..99d2d13a3 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/project-direction-card/project-direction-card.component.html @@ -0,0 +1,166 @@ + + +
    + +

    {{ direction() }}

    +
    + + +
    +
    +

    {{ direction() }}

    + +
    + + @if (type() === "string") { +

    {{ about() }}

    + } @else { + @if (listType === "profile") { + @if (profileInfoType() === "skills") { +
    +
      + @for (aboutItem of about(); track $index) { +
    • +

      {{ aboutItem?.name }}

      +
      + {{ + aboutItem?.category?.name?.includes("Soft skills") ? "soft" : "hard" + }} + {{ aboutItem?.category?.name }} +
      +
    • + } +
    +
    + } @else { +
      + @if (!isOpenInfo) { + @for (yearItem of years; track yearItem.id) { +
    • +

      {{ yearItem.year }}

      + +
    • + } + } @else { +
      +
      + +

      {{ currentYear }}

      +
      + + @if (achievementsInfo().length > 0) { + @for (achievementInfo of achievementsInfo(); track $index) { +
      +

      + {{ achievementInfo.title }} +

      +

      + {{ achievementInfo.status }} +

      + + @if (files.length > 0) { + + } +
      + } + } +
      + } +
    + } + } @else { + @if (projectInfoType() === "goals") { +
    + @if (!isShowsConfirmGoal) { + @if (about().length > 0) { + @for (goalItem of about(); track $index) { +
  • + +

    {{ goalItem.title }}

    +
    + @if (!goalItem.isDone) { +
    + @if (goalCompleteHoverId === goalItem.id) { + + } @else { +

    + {{ goalItem.completionDate | dayjs: "format" : "DD.MM.YY" }} +

    + } +
    + } @else { + + } +
    +
  • + } + } + } @else { + @if (selectedGoal) { +
    +

    подтвердить выполнение цели

    + +
    + + + подтверждаю + + } + } + + } @else { +
    + @if (this.about.length > 0) { + @for (partnerItem of about(); track $index) { +
  • +
    +

    + {{ partnerItem.company.name | truncate: 40 }} +

    +

    {{ partnerItem.company.inn }}

    +
    + +

    + {{ partnerItem.contribution | truncate: 400 }} +

    +
  • + } + } +
    + } + } + } + + diff --git a/projects/social_platform/src/app/office/projects/detail/shared/project-direction-card/project-direction-card.component.scss b/projects/social_platform/src/app/ui/widgets/project-direction-card/project-direction-card.component.scss similarity index 100% rename from projects/social_platform/src/app/office/projects/detail/shared/project-direction-card/project-direction-card.component.scss rename to projects/social_platform/src/app/ui/widgets/project-direction-card/project-direction-card.component.scss diff --git a/projects/social_platform/src/app/ui/widgets/project-direction-card/project-direction-card.component.ts b/projects/social_platform/src/app/ui/widgets/project-direction-card/project-direction-card.component.ts new file mode 100644 index 000000000..48fe5a407 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/project-direction-card/project-direction-card.component.ts @@ -0,0 +1,212 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + inject, + input, + OnDestroy, + OnInit, + signal, +} from "@angular/core"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { IconComponent } from "@uilib"; +import { TagComponent } from "@ui/primitives/tag/tag.component"; +import { ActivatedRoute, Router } from "@angular/router"; +import { map, Subscription } from "rxjs"; +import { Achievement } from "@domain/auth/user.model"; +import { FileItemComponent } from "@ui/primitives/file-item/file-item.component"; +import { FileModel } from "@domain/file/file.model"; +import { AvatarComponent } from "@ui/primitives/avatar/avatar.component"; +import { ButtonComponent } from "@ui/primitives"; +import { TruncatePipe, DayjsPipe } from "@corelib"; +import { Goal } from "@domain/project/goals.model"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; +import { UpdateGoalUseCase } from "@api/project/use-cases/update-goal.use-case"; + +/** Универсальная карточка направлений профиля/проекта: навыки, достижения, цели и партнёры. */ +@Component({ + selector: "app-project-direction-card", + templateUrl: "./project-direction-card.component.html", + styleUrl: "./project-direction-card.component.scss", + imports: [ + CommonModule, + IconComponent, + ModalComponent, + TagComponent, + FileItemComponent, + AvatarComponent, + DayjsPipe, + TruncatePipe, + ButtonComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProjectDirectionCard implements OnInit, OnDestroy { + readonly direction = input.required(); + readonly icon = input.required(); + readonly about = input.required(); + readonly type = input.required(); + readonly isOwner = input(); + + readonly profileInfoType = input<"skills" | "achievements" | undefined>(); + readonly projectInfoType = input<"goals" | "partners" | undefined>(); + + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly updateGoalUseCase = inject(UpdateGoalUseCase); + private readonly logger = inject(LoggerService); + private readonly cdr = inject(ChangeDetectorRef); + + private subscriptions: Subscription[] = []; + + isShowModal = false; + isShowsConfirmGoal = false; + + isOpenInfo = false; + + goalCompleteHoverId: null | number = null; + selectedGoal: Goal | null = null; + + listType?: "profile" | "project"; + + // Поля для работы с достижениями + private allAchievements: Achievement[] = []; + years: { id: number; year: number }[] = []; + files: FileModel[] = []; + achievementsInfo = signal([]); + currentYear = 0; + + mouseHover(goalId: number): void { + if (this.isOwner()) { + if (goalId) { + this.goalCompleteHoverId = goalId; + } + } + } + + mouseLeave(): void { + if (this.isOwner()) { + this.goalCompleteHoverId = null; + } + } + + ngOnInit(): void { + const listTypeSub$ = this.route.data.subscribe(data => { + this.listType = data["listType"]; + }); + + if (this.profileInfoType() === "achievements" && Array.isArray(this.about)) { + this.allAchievements = this.about as Achievement[]; + this.years = Array.from( + new Map(this.allAchievements.map(a => [a.year, { id: a.id, year: a.year }])).values(), + ); + this.subscribeYearQueryParam(); + } + + this.subscriptions.push(listTypeSub$); + } + + ngOnDestroy(): void { + this.subscriptions.forEach($ => $.unsubscribe()); + } + + openConfirmModal(goal: Goal): void { + this.selectedGoal = goal; + this.isShowsConfirmGoal = true; + this.router.navigate([], { + queryParams: { goalId: goal.id }, + relativeTo: this.route, + queryParamsHandling: "merge", + }); + } + + confirmCompleteGoal(): void { + const projectId = this.route.snapshot.params["projectId"]; + const goalId = +this.route.snapshot.queryParams["goalId"]; + + const goals = this.about() as Goal[]; + if (!goalId || !Array.isArray(goals)) return; + + const goal = goals.find((g: Goal) => g.id === goalId); + + if (!goal) return; + + const completedGoal = { + id: goal.id, + title: goal.title, + completionDate: goal.completionDate, + responsible: goal.responsible, + isDone: true, + }; + + this.updateGoalUseCase.execute(Number(projectId), goal.id, completedGoal).subscribe({ + next: result => { + if (!result.ok) { + this.logger.error("Ошибка при обновлении цели:", result.error.cause); + return; + } + + const response = result.value; + const goals = this.about() as Goal[]; + if (Array.isArray(goals)) { + const index = goals.findIndex((g: Goal) => g.id === goalId); + if (index !== -1) { + goals[index] = response; + } + } + + this.isShowsConfirmGoal = false; + this.goalCompleteHoverId = null; + this.selectedGoal = null; + this.cdr.markForCheck(); + + this.router.navigate([], { + queryParams: {}, + replaceUrl: true, + }); + }, + }); + } + + openInfo(achievementYear: string): void { + this.router.navigate([], { + queryParams: { + year: achievementYear, + }, + relativeTo: this.route, + queryParamsHandling: "merge", + }); + } + + backToYears(): void { + this.router.navigate([], { + queryParams: {}, + replaceUrl: true, + }); + + this.isOpenInfo = false; + } + + private subscribeYearQueryParam(): void { + const infoParamSub$ = this.route.queryParams.pipe(map(p => p["year"])).subscribe(year => { + if (!year) { + this.isOpenInfo = false; + this.achievementsInfo.set([]); + this.files = []; + return; + } + + this.isOpenInfo = true; + this.currentYear = +year; + + const filtered = this.allAchievements.filter(a => +a.year === +year); + this.achievementsInfo.set(filtered); + this.files = filtered.flatMap(a => (a.files ?? []) as FileModel[]); + }); + + this.subscriptions.push(infoParamSub$); + } +} diff --git a/projects/social_platform/src/app/ui/widgets/project-navigation/project-navigation.component.html b/projects/social_platform/src/app/ui/widgets/project-navigation/project-navigation.component.html new file mode 100644 index 000000000..de1f69197 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/project-navigation/project-navigation.component.html @@ -0,0 +1,28 @@ + + + diff --git a/projects/social_platform/src/app/ui/widgets/project-navigation/project-navigation.component.scss b/projects/social_platform/src/app/ui/widgets/project-navigation/project-navigation.component.scss new file mode 100644 index 000000000..abc841229 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/project-navigation/project-navigation.component.scss @@ -0,0 +1,81 @@ +/** @format */ + +@use "styles/responsive"; +@use "styles/typography"; + +.project { + &__navigation { + padding: 22px 0; + margin-bottom: 22px; + border-bottom: 1px solid var(--grey-button); + } + + &__nav { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + justify-content: center; + 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; + 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(--dark-grey); + + @include typography.body-12; + + &--active { + color: var(--black); + } + } + + &__icon { + opacity: 0.1; + + &--active { + color: var(--accent); + opacity: 1; + } + } + + &__nav-item { + color: var(--dark-grey); + cursor: pointer; + + @include typography.heading-4; + + @include responsive.apply-desktop { + @include typography.heading-3; + } + + &:not(:last-child) { + margin-right: 24px; + } + + &--active { + color: var(--black); + } + } +} diff --git a/projects/social_platform/src/app/ui/widgets/project-navigation/project-navigation.component.ts b/projects/social_platform/src/app/ui/widgets/project-navigation/project-navigation.component.ts new file mode 100644 index 000000000..84543563b --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/project-navigation/project-navigation.component.ts @@ -0,0 +1,39 @@ +/** @format */ + +import { + Component, + inject, + Output, + EventEmitter, + Input, + ChangeDetectionStrategy, + input, + output, +} from "@angular/core"; +import { IconComponent } from "@uilib"; +import { CommonModule } from "@angular/common"; +import { ProjectStepService } from "@api/project/project-step.service"; +import { Navigation } from "@core/lib/models/navigation.model"; +import { EditStep } from "@core/public-api"; + +/** Виджет навигации по разделам проекта. */ +@Component({ + selector: "app-project-navigation", + templateUrl: "./project-navigation.component.html", + styleUrl: "project-navigation.component.scss", + imports: [IconComponent, CommonModule], + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProjectNavigationComponent { + readonly navItems = input.required(); + readonly stepChange = output(); + + private readonly projectStepService = inject(ProjectStepService); + + protected readonly currentStep = this.projectStepService.currentStep; + + onStepClick(step: EditStep): void { + this.projectStepService.navigateToStep(step); + } +} diff --git a/projects/social_platform/src/app/ui/widgets/project-vacancy-card/project-vacancy-card.component.html b/projects/social_platform/src/app/ui/widgets/project-vacancy-card/project-vacancy-card.component.html new file mode 100644 index 000000000..be1d30ebd --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/project-vacancy-card/project-vacancy-card.component.html @@ -0,0 +1,83 @@ + + +
    +
    + @if (type() === "vacancies") { +
    + +
    +

    + {{ vacancy().project.name | truncate: 12 }} +

    +

    + {{ vacancy().datetimeCreated | dayjs: "format" : "DD.MM.YY • HH:MM" }} +

    +
    +
    + } @else { +

    + {{ vacancy().role | truncate: 12 }} +

    + } +
    + @if (type() === "vacancies") { +

    + {{ vacancy().role | truncate: 12 }} +

    + } +
    + +

    + {{ +vacancy().salary === 0 ? "по договоренности" : vacancy().salary }} +

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

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

    + } +
    + +
    + @if (vacancy().description) { +
    +

    + @if (descriptionExpandable()) { +
    + {{ readFullDescription() ? "cкрыть" : "подробнее" }} +
    + } +
    + } +
    + +
    + подробнее + откликнуться +
    +
    diff --git a/projects/social_platform/src/app/office/projects/detail/shared/project-vacancy-card/project-vacancy-card.component.scss b/projects/social_platform/src/app/ui/widgets/project-vacancy-card/project-vacancy-card.component.scss similarity index 100% rename from projects/social_platform/src/app/office/projects/detail/shared/project-vacancy-card/project-vacancy-card.component.scss rename to projects/social_platform/src/app/ui/widgets/project-vacancy-card/project-vacancy-card.component.scss diff --git a/projects/social_platform/src/app/ui/widgets/project-vacancy-card/project-vacancy-card.component.spec.ts b/projects/social_platform/src/app/ui/widgets/project-vacancy-card/project-vacancy-card.component.spec.ts new file mode 100644 index 000000000..9e953b246 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/project-vacancy-card/project-vacancy-card.component.spec.ts @@ -0,0 +1,32 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ProjectVacancyCardComponent } from "./project-vacancy-card.component"; +import { provideRouter } from "@angular/router"; + +describe("ProjectVacancyCardComponent", () => { + let component: ProjectVacancyCardComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ProjectVacancyCardComponent], + providers: [provideRouter([])], + }).compileComponents(); + + fixture = TestBed.createComponent(ProjectVacancyCardComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput("vacancy", { + id: 1, + name: "Test Vacancy", + description: "", + requiredSkills: [], + salary: "100000", + }); + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/widgets/project-vacancy-card/project-vacancy-card.component.ts b/projects/social_platform/src/app/ui/widgets/project-vacancy-card/project-vacancy-card.component.ts new file mode 100644 index 000000000..a18e58e83 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/project-vacancy-card/project-vacancy-card.component.ts @@ -0,0 +1,76 @@ +/** @format */ +import { CommonModule } from "@angular/common"; +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + ElementRef, + inject, + input, + Input, + OnInit, + viewChild, +} from "@angular/core"; +import { RouterLink } from "@angular/router"; +import { Vacancy } from "@domain/vacancy/vacancy.model"; +import { IconComponent } from "@uilib"; +import { ButtonComponent } from "@ui/primitives"; +import { DayjsPipe, ParseBreaksPipe, ParseLinksPipe, TruncatePipe } from "@corelib"; +import { TagComponent } from "@ui/primitives/tag/tag.component"; +import { AvatarComponent } from "@ui/primitives/avatar/avatar.component"; +import { AppRoutes } from "@api/paths/app-routes"; +import { ExpandService } from "@api/expand/expand.service"; + +/** Компонент карточки вакансии проекта с возможностью раскрытия описания. */ +@Component({ + selector: "app-project-vacancy-card", + imports: [ + CommonModule, + RouterLink, + IconComponent, + ButtonComponent, + ParseLinksPipe, + ParseBreaksPipe, + TruncatePipe, + TagComponent, + AvatarComponent, + DayjsPipe, + ], + templateUrl: "./project-vacancy-card.component.html", + styleUrl: "./project-vacancy-card.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ExpandService], +}) +export class ProjectVacancyCardComponent implements OnInit, AfterViewInit { + private readonly expandService = inject(ExpandService); + + protected readonly AppRoutes = AppRoutes; + readonly vacancy = input.required(); + readonly type = input<"vacancies" | "project">("project"); + + private readonly descEl = viewChild("descEl"); + + endSliceOfSkills = 0; + + protected readonly descriptionExpandable = this.expandService.descriptionExpandable; + protected readonly readFullDescription = this.expandService.readFullDescription; + + ngOnInit(): void { + this.endSliceOfSkills = this.type() === "project" ? 5 : 3; + } + + ngAfterViewInit(): void { + setTimeout(() => { + this.expandService.checkExpandable("description", true, this.descEl()); + }); + } + + protected onExpandDescription(elem: HTMLElement): void { + this.expandService.onExpand( + "description", + elem, + "expanded", + this.expandService.readFullDescription(), + ); + } +} diff --git a/projects/social_platform/src/app/ui/widgets/projects-filter/projects-filter.component.html b/projects/social_platform/src/app/ui/widgets/projects-filter/projects-filter.component.html new file mode 100644 index 000000000..ec8ab7791 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/projects-filter/projects-filter.component.html @@ -0,0 +1,66 @@ + + +
    +
    +
    +

    фильтры

    + сбросить +
    + + + + + + + +
    +
    + +
    +
    +
    +
    diff --git a/projects/social_platform/src/app/office/projects/projects-filter/projects-filter.component.scss b/projects/social_platform/src/app/ui/widgets/projects-filter/projects-filter.component.scss similarity index 96% rename from projects/social_platform/src/app/office/projects/projects-filter/projects-filter.component.scss rename to projects/social_platform/src/app/ui/widgets/projects-filter/projects-filter.component.scss index 87eec5d64..3f54ff98b 100644 --- a/projects/social_platform/src/app/office/projects/projects-filter/projects-filter.component.scss +++ b/projects/social_platform/src/app/ui/widgets/projects-filter/projects-filter.component.scss @@ -39,6 +39,10 @@ align-items: center; justify-content: space-between; margin-bottom: 14px; + + a { + color: var(--accent) !important; + } } &__clear { diff --git a/projects/social_platform/src/app/ui/widgets/projects-filter/projects-filter.component.spec.ts b/projects/social_platform/src/app/ui/widgets/projects-filter/projects-filter.component.spec.ts new file mode 100644 index 000000000..d62228af1 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/projects-filter/projects-filter.component.spec.ts @@ -0,0 +1,35 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { ProjectsFilterComponent } from "./projects-filter.component"; +import { provideRouter } from "@angular/router"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { IndustryRepositoryPort } from "@domain/industry/ports/industry.repository.port"; +import { signal } from "@angular/core"; + +describe("ProjectsFilterComponent", () => { + let component: ProjectsFilterComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const industrySpy = { + industries: signal([]), + }; + + await TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, ProjectsFilterComponent], + providers: [{ provide: IndustryRepositoryPort, useValue: industrySpy }, provideRouter([])], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ProjectsFilterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/widgets/projects-filter/projects-filter.component.ts b/projects/social_platform/src/app/ui/widgets/projects-filter/projects-filter.component.ts new file mode 100644 index 000000000..1339e11ad --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/projects-filter/projects-filter.component.ts @@ -0,0 +1,58 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, inject, OnInit } from "@angular/core"; +import { SelectComponent } from "@ui/primitives"; +import { ReactiveFormsModule } from "@angular/forms"; +import { tagsFilter } from "@core/consts/filters/tags-filter.const"; +import { ProjectsFilterInfoService } from "./service/projects-filter-info.service"; + +/** Компонент фильтрации проектов с синхронизацией фильтров через URL query-параметры. */ +@Component({ + selector: "app-projects-filter", + templateUrl: "./projects-filter.component.html", + styleUrl: "./projects-filter.component.scss", + imports: [SelectComponent, ReactiveFormsModule], + providers: [ProjectsFilterInfoService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProjectsFilterComponent implements OnInit { + private readonly projectsFilterInfoService = inject(ProjectsFilterInfoService); + + // Константы для фильтрации по типу проекта + readonly tagsFilter = tagsFilter; + + protected readonly industryControl = this.projectsFilterInfoService.industryControl; + + // Состояние фильтра по отрасли + protected readonly currentIndustry = this.projectsFilterInfoService.currentIndustry; + protected readonly industries = this.projectsFilterInfoService.industries; + + ngOnInit(): void { + // Подписка на данные об отраслях + this.projectsFilterInfoService.initializationProjectsFilter(); + } + + onFilterByIndustry(industryId?: number | null): void { + this.projectsFilterInfoService.onFilterByIndustry(industryId); + } + + onFilterByMembersCount(count?: number): void { + this.projectsFilterInfoService.onFilterByMembersCount(count); + } + + onFilterVacancies(has: boolean): void { + this.projectsFilterInfoService.onFilterVacancies(has); + } + + onFilterMospolytech(isMospolytech: boolean): void { + this.projectsFilterInfoService.onFilterMospolytech(isMospolytech); + } + + onFilterProjectType(event: Event, tagId?: number | null): void { + this.projectsFilterInfoService.onFilterProjectType(event, tagId); + } + + clearFilters(): void { + this.projectsFilterInfoService.clearFilters(); + } +} diff --git a/projects/social_platform/src/app/ui/widgets/projects-filter/service/projects-filter-info.service.ts b/projects/social_platform/src/app/ui/widgets/projects-filter/service/projects-filter-info.service.ts new file mode 100644 index 000000000..bb28901af --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/projects-filter/service/projects-filter-info.service.ts @@ -0,0 +1,167 @@ +/** @format */ + +import { DestroyRef, inject, Injectable, signal } from "@angular/core"; +import { FormControl } from "@angular/forms"; +import { ActivatedRoute, Router } from "@angular/router"; +import { optionsListElement } from "@utils/generate-options-list"; +import { takeUntilDestroyed, toObservable } from "@angular/core/rxjs-interop"; +import { map } from "rxjs"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; +import { IndustryRepositoryPort } from "@domain/industry/ports/industry.repository.port"; + +@Injectable() +export class ProjectsFilterInfoService { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly logger = inject(LoggerService); + private readonly destroyRef = inject(DestroyRef); + + private readonly industryRepository = inject(IndustryRepositoryPort); + + // Подписки для управления жизненным циклом + readonly industryControl = new FormControl(null); + + // Состояние фильтра по отрасли + readonly currentIndustry = signal(null); + readonly industries = signal([]); + + // Состояние остальных фильтров + readonly hasVacancies = signal(false); + readonly isMospolytech = signal(false); + + // Опции для фильтра по количеству участников + readonly currentMembersCount = signal(null); + + // Текущий тип проекта (по умолчанию - все проекты) + readonly currentFilterTag = signal(2); + + // Создаём observable в контексте инжекции (field initializer), чтобы toObservable не падал NG0203 при вызове из ngOnInit + private readonly industries$ = toObservable(this.industryRepository.industries); + + initializationProjectsFilter(): void { + this.initializationIndustries(); + + this.initializationIndustryControl(); + + // Восстановление состояния фильтров из query параметров + this.initializationCurrentParams(); + } + + private initializationIndustries(): void { + this.industries$ + .pipe( + map(industries => + industries.map(industry => ({ + id: industry.id, + label: industry.name, + value: industry.name, + })), + ), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(industries => { + this.industries.set(industries); + }); + } + + private initializationIndustryControl(): void { + this.industryControl.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => { + const industryId = this.industries().find(industry => industry.value === value); + this.onFilterByIndustry(industryId?.id); + }); + } + + private initializationCurrentParams(): void { + this.route.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(queries => { + this.currentIndustry.set(parseInt(queries["industry"])); + this.currentMembersCount.set(parseInt(queries["membersCount"])); + this.hasVacancies.set(queries["anyVacancies"] === "true"); + this.isMospolytech.set(queries["is_mospolytech"] === "true"); + + const tagParam = queries["is_rated_by_expert"]; + if (tagParam === undefined || isNaN(Number.parseInt(tagParam))) { + this.currentFilterTag.set(2); + } else { + this.currentFilterTag.set(Number.parseInt(tagParam)); + } + }); + } + + onFilterByIndustry(industryId?: number | null): void { + this.router + .navigate([], { + queryParams: { industry: industryId === this.currentIndustry() ? undefined : industryId }, + relativeTo: this.route, + queryParamsHandling: "merge", + }) + .then(() => this.logger.debug("Query change from ProjectsComponent")); + } + + onFilterByMembersCount(count?: number): void { + this.router + .navigate([], { + queryParams: { + membersCount: count === this.currentMembersCount() ? undefined : count, + }, + relativeTo: this.route, + queryParamsHandling: "merge", + }) + .then(() => this.logger.debug("Query change from ProjectsComponent")); + } + + onFilterVacancies(has: boolean): void { + this.router + .navigate([], { + queryParams: { + anyVacancies: has, + }, + relativeTo: this.route, + queryParamsHandling: "merge", + }) + .then(() => this.logger.debug("Query change from ProjectsComponent")); + } + + onFilterMospolytech(isMospolytech: boolean): void { + this.router + .navigate([], { + queryParams: { + is_mospolytech: isMospolytech, + partner_program: 3, // TODO: заменить когда появится итоговое id программы для политеха + }, + relativeTo: this.route, + queryParamsHandling: "merge", + }) + .then(() => this.logger.debug("Query change from ProjectsComponent")); + } + + onFilterProjectType(event: Event, tagId?: number | null): void { + event.stopPropagation(); + + this.router.navigate([], { + queryParams: { is_rated_by_expert: tagId === this.currentFilterTag() ? undefined : tagId }, + relativeTo: this.route, + queryParamsHandling: "merge", + }); + } + + clearFilters(): void { + this.currentFilterTag.set(2); + + this.router + .navigate([], { + queryParams: { + step: undefined, + anyVacancies: undefined, + membersCount: undefined, + industry: undefined, + is_rated_by_expert: undefined, + is_mospolytech: undefined, + partner_program: undefined, + name__contains: undefined, + }, + relativeTo: this.route, + queryParamsHandling: "merge", + }) + .then(() => this.logger.info("Query change from ProjectsComponent")); + } +} diff --git a/projects/social_platform/src/app/ui/widgets/skills-basket/skills-basket.component.html b/projects/social_platform/src/app/ui/widgets/skills-basket/skills-basket.component.html new file mode 100644 index 000000000..55afbd91e --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/skills-basket/skills-basket.component.html @@ -0,0 +1,31 @@ + + +
    +
    + @for (skill of value(); track skill.id) { +
    + {{ skill.name }} + +
    + } @empty { +
    + + Выберите навыки +
    + } +
    +
    diff --git a/projects/social_platform/src/app/office/shared/skills-basket/skills-basket.component.scss b/projects/social_platform/src/app/ui/widgets/skills-basket/skills-basket.component.scss similarity index 100% rename from projects/social_platform/src/app/office/shared/skills-basket/skills-basket.component.scss rename to projects/social_platform/src/app/ui/widgets/skills-basket/skills-basket.component.scss diff --git a/projects/social_platform/src/app/office/shared/skills-basket/skills-basket.component.spec.ts b/projects/social_platform/src/app/ui/widgets/skills-basket/skills-basket.component.spec.ts similarity index 100% rename from projects/social_platform/src/app/office/shared/skills-basket/skills-basket.component.spec.ts rename to projects/social_platform/src/app/ui/widgets/skills-basket/skills-basket.component.spec.ts diff --git a/projects/social_platform/src/app/ui/widgets/skills-basket/skills-basket.component.ts b/projects/social_platform/src/app/ui/widgets/skills-basket/skills-basket.component.ts new file mode 100644 index 000000000..2cad1672c --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/skills-basket/skills-basket.component.ts @@ -0,0 +1,62 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + forwardRef, + input, + Input, + signal, +} from "@angular/core"; +import { IconComponent } from "@ui/primitives"; +import { NG_VALUE_ACCESSOR } from "@angular/forms"; +import { noop } from "rxjs"; +import { Skill } from "@domain/skills/skill.model"; + +/** Компонент корзины навыков с ControlValueAccessor для форм. */ +@Component({ + selector: "app-skills-basket", + templateUrl: "./skills-basket.component.html", + styleUrl: "./skills-basket.component.scss", + imports: [CommonModule, IconComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + // Регистрация как ControlValueAccessor для работы с формами + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => SkillsBasketComponent), + multi: true, + }, + ], +}) +export class SkillsBasketComponent { + readonly error = input(false); + + value = signal([]); + + // Методы ControlValueAccessor + onChange: (val: Skill[]) => void = noop; + onTouched: () => void = noop; + + writeValue(val: Skill[]): void { + if (val) { + this.value.set(val); + } + } + + registerOnChange(fn: (v: unknown) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + deleteSkill(id: number): void { + const filtered = this.value().filter(skill => skill.id !== id); + + this.value.set(filtered); + this.onChange(filtered); + } +} diff --git a/projects/social_platform/src/app/ui/widgets/skills-group/skills-group.component.html b/projects/social_platform/src/app/ui/widgets/skills-group/skills-group.component.html new file mode 100644 index 000000000..64028bb76 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/skills-group/skills-group.component.html @@ -0,0 +1,41 @@ + + +
    +
    + {{ title() }} + @if (!disabled()) { + + } +
    + @if (contentVisible()) { +
    + @for (opt of options; track opt.id) { +
    +
    +
    + +
    +
    + +
    + } +
    + } +
    diff --git a/projects/social_platform/src/app/office/shared/skills-group/skills-group.component.scss b/projects/social_platform/src/app/ui/widgets/skills-group/skills-group.component.scss similarity index 93% rename from projects/social_platform/src/app/office/shared/skills-group/skills-group.component.scss rename to projects/social_platform/src/app/ui/widgets/skills-group/skills-group.component.scss index e7031401f..4fc68d9ef 100644 --- a/projects/social_platform/src/app/office/shared/skills-group/skills-group.component.scss +++ b/projects/social_platform/src/app/ui/widgets/skills-group/skills-group.component.scss @@ -6,6 +6,7 @@ min-height: 32px; .heading { + position: relative; display: flex; align-items: center; justify-content: space-between; @@ -56,6 +57,9 @@ gap: 14px; &--open { + position: absolute; + top: 0%; + right: 0%; width: 40%; } @@ -66,6 +70,10 @@ cursor: pointer; transition: opacity 0.2s ease; + label { + cursor: pointer; + } + &--disabled { pointer-events: none; cursor: not-allowed; diff --git a/projects/social_platform/src/app/office/shared/skills-group/skills-group.component.spec.ts b/projects/social_platform/src/app/ui/widgets/skills-group/skills-group.component.spec.ts similarity index 81% rename from projects/social_platform/src/app/office/shared/skills-group/skills-group.component.spec.ts rename to projects/social_platform/src/app/ui/widgets/skills-group/skills-group.component.spec.ts index 5b38910ed..05a5c8225 100644 --- a/projects/social_platform/src/app/office/shared/skills-group/skills-group.component.spec.ts +++ b/projects/social_platform/src/app/ui/widgets/skills-group/skills-group.component.spec.ts @@ -17,6 +17,9 @@ describe("SkillsGroupComponent", () => { beforeEach(() => { fixture = TestBed.createComponent(SkillsGroupComponent); component = fixture.componentInstance; + fixture.componentRef.setInput("options", []); + fixture.componentRef.setInput("selected", []); + fixture.componentRef.setInput("title", "Skills"); fixture.detectChanges(); }); diff --git a/projects/social_platform/src/app/ui/widgets/skills-group/skills-group.component.ts b/projects/social_platform/src/app/ui/widgets/skills-group/skills-group.component.ts new file mode 100644 index 000000000..a0982f55b --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/skills-group/skills-group.component.ts @@ -0,0 +1,74 @@ +/** @format */ +import { CommonModule } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + input, + Input, + output, + Output, + signal, +} from "@angular/core"; +import { IconComponent } from "@ui/primitives"; +import { Skill } from "@domain/skills/skill.model"; + +/** Компонент группы навыков с множественным выбором через чекбоксы и сворачиванием. */ +@Component({ + selector: "app-skills-group", + imports: [CommonModule, IconComponent], + templateUrl: "./skills-group.component.html", + styleUrl: "./skills-group.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SkillsGroupComponent { + @Input({ required: true }) set options(value: Skill[]) { + this._options.set(value); + } + + get options(): (Skill & { checked?: boolean })[] { + return this._options(); + } + + @Input({ required: true }) set selected(value: Skill[]) { + this._selected.set(value); + + const options = this.options.map(opt => { + return { ...opt, checked: value.some(skill => skill.id === opt.id) }; + }); + + this._options.set(options); + } + + get selected(): Skill[] { + return this._selected(); + } + + readonly title = input.required(); + readonly hasOpenGroups = input(false); + readonly disabled = input(false); + + readonly groupToggled = output(); + readonly optionToggled = output(); + + _options = signal<(Skill & { checked?: boolean })[]>([]); + _selected = signal([]); + contentVisible = signal(false); + + toggleContentVisible() { + if (this.disabled()) { + return; + } + + this.contentVisible.update(val => !val); + this.groupToggled.emit(this.contentVisible()); + } + + onOptionClick(opt: Skill) { + if (this.disabled()) { + return; + } + + this.optionToggled.emit(opt); + } +} diff --git a/projects/social_platform/src/app/ui/widgets/specializations-group/specializations-group.component.html b/projects/social_platform/src/app/ui/widgets/specializations-group/specializations-group.component.html new file mode 100644 index 000000000..15c338b33 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/specializations-group/specializations-group.component.html @@ -0,0 +1,28 @@ + + +
    +
    + {{ title() }} + @if (!disabled()) { + + } +
    + @if (contentVisible()) { +
    + @for (opt of options(); track opt.id) { +
    + +
    + } +
    + } +
    diff --git a/projects/social_platform/src/app/office/shared/specializations-group/specializations-group.component.scss b/projects/social_platform/src/app/ui/widgets/specializations-group/specializations-group.component.scss similarity index 92% rename from projects/social_platform/src/app/office/shared/specializations-group/specializations-group.component.scss rename to projects/social_platform/src/app/ui/widgets/specializations-group/specializations-group.component.scss index c0fada652..272283af4 100644 --- a/projects/social_platform/src/app/office/shared/specializations-group/specializations-group.component.scss +++ b/projects/social_platform/src/app/ui/widgets/specializations-group/specializations-group.component.scss @@ -58,6 +58,9 @@ gap: 5px; &--open { + position: absolute; + top: 2%; + right: 0%; z-index: 1000; width: 40%; } @@ -68,6 +71,10 @@ cursor: pointer; transition: opacity 0.2s ease; + label { + cursor: pointer; + } + &:hover { background-color: var(--light-gray); } diff --git a/projects/social_platform/src/app/office/shared/specializations-group/specializations-group.component.spec.ts b/projects/social_platform/src/app/ui/widgets/specializations-group/specializations-group.component.spec.ts similarity index 86% rename from projects/social_platform/src/app/office/shared/specializations-group/specializations-group.component.spec.ts rename to projects/social_platform/src/app/ui/widgets/specializations-group/specializations-group.component.spec.ts index 2697e27e7..9d88b7f3e 100644 --- a/projects/social_platform/src/app/office/shared/specializations-group/specializations-group.component.spec.ts +++ b/projects/social_platform/src/app/ui/widgets/specializations-group/specializations-group.component.spec.ts @@ -17,6 +17,8 @@ describe("SpecializationsGroupComponent", () => { beforeEach(() => { fixture = TestBed.createComponent(SpecializationsGroupComponent); component = fixture.componentInstance; + fixture.componentRef.setInput("title", "Specializations"); + fixture.componentRef.setInput("options", []); fixture.detectChanges(); }); diff --git a/projects/social_platform/src/app/ui/widgets/specializations-group/specializations-group.component.ts b/projects/social_platform/src/app/ui/widgets/specializations-group/specializations-group.component.ts new file mode 100644 index 000000000..f78ec3c4f --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/specializations-group/specializations-group.component.ts @@ -0,0 +1,43 @@ +/** @format */ + +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, input, output, signal } from "@angular/core"; +import { IconComponent } from "@ui/primitives"; +import { Specialization } from "@domain/specializations/specialization.model"; + +/** Компонент группы специализаций с возможностью сворачивания и выбора. */ +@Component({ + selector: "app-specializations-group", + imports: [CommonModule, IconComponent], + templateUrl: "./specializations-group.component.html", + styleUrl: "./specializations-group.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SpecializationsGroupComponent { + readonly title = input.required(); + readonly options = input.required(); + readonly hasOpenGroups = input(false); + readonly disabled = input(false); + + readonly selectOption = output(); + readonly groupToggled = output(); + + contentVisible = signal(false); + + toggleContentVisible() { + if (this.disabled()) { + return; + } + + this.contentVisible.update(val => !val); + this.groupToggled.emit(this.contentVisible()); + } + + onSelectOption(opt: Specialization) { + if (this.disabled()) { + return; + } + + this.selectOption.emit(opt); + } +} diff --git a/projects/social_platform/src/app/ui/widgets/vacancy-card/vacancy-card.component.html b/projects/social_platform/src/app/ui/widgets/vacancy-card/vacancy-card.component.html new file mode 100644 index 000000000..29637c768 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/vacancy-card/vacancy-card.component.html @@ -0,0 +1,41 @@ + + +@if (vacancy()) { +
    +
    +
    +

    {{ vacancy()!.role | truncate: 20 }}

    +
    +
    + @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/ui/widgets/vacancy-card/vacancy-card.component.scss similarity index 100% rename from projects/social_platform/src/app/office/features/vacancy-card/vacancy-card.component.scss rename to projects/social_platform/src/app/ui/widgets/vacancy-card/vacancy-card.component.scss diff --git a/projects/social_platform/src/app/ui/widgets/vacancy-card/vacancy-card.component.spec.ts b/projects/social_platform/src/app/ui/widgets/vacancy-card/vacancy-card.component.spec.ts new file mode 100644 index 000000000..1252c9976 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/vacancy-card/vacancy-card.component.spec.ts @@ -0,0 +1,44 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { VacancyCardComponent } from "./vacancy-card.component"; +import { provideNgxMask } from "ngx-mask"; +import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; +import { of } from "rxjs"; + +describe("VacancyCardComponent", () => { + let component: VacancyCardComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [VacancyCardComponent], + providers: [ + provideNgxMask(), + { + provide: AuthRepositoryPort, + useValue: { + fetchProfile: () => of({}), + fetchUserRoles: () => of([]), + fetchChangeableRoles: () => of([]), + fetchLeaderProjects: () => of({}), + }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(VacancyCardComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput("vacancy", { + id: 1, + name: "Test Vacancy", + description: "", + requiredSkills: [], + }); + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/widgets/vacancy-card/vacancy-card.component.ts b/projects/social_platform/src/app/ui/widgets/vacancy-card/vacancy-card.component.ts new file mode 100644 index 000000000..b66c423b9 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/vacancy-card/vacancy-card.component.ts @@ -0,0 +1,49 @@ +/** @format */ + +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + input, + Input, + OnInit, + output, + Output, +} from "@angular/core"; +import { Vacancy } from "@domain/vacancy/vacancy.model"; +import { IconComponent, ButtonComponent } from "@ui/primitives"; +import { TagComponent } from "@ui/primitives/tag/tag.component"; +import { TruncatePipe } from "@corelib"; + +/** Компонент карточки вакансии с кнопками редактирования и удаления. */ +@Component({ + selector: "app-vacancy-card", + templateUrl: "./vacancy-card.component.html", + styleUrl: "./vacancy-card.component.scss", + imports: [IconComponent, ButtonComponent, TagComponent, TruncatePipe], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class VacancyCardComponent implements OnInit { + readonly vacancy = input(); + + readonly remove = output(); + readonly edit = output(); + + skillString = ""; + + ngOnInit(): void {} + + onRemove(event: MouseEvent): void { + event.stopPropagation(); + event.preventDefault(); + + this.remove.emit(this.vacancy()!.id); + } + + onEdit(event: MouseEvent): void { + event.stopPropagation(); + event.preventDefault(); + + this.edit.emit(this.vacancy()!.id); + } +} diff --git a/projects/social_platform/src/app/ui/widgets/vacancy-filter/service/vacancy-filter-info.service.ts b/projects/social_platform/src/app/ui/widgets/vacancy-filter/service/vacancy-filter-info.service.ts new file mode 100644 index 000000000..a5c495e3e --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/vacancy-filter/service/vacancy-filter-info.service.ts @@ -0,0 +1,141 @@ +/** @format */ + +import { DestroyRef, inject, Injectable, signal } from "@angular/core"; +import { ActivatedRoute, Params, Router } from "@angular/router"; +import { map, tap } from "rxjs"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; +import { GetVacanciesUseCase } from "@api/vacancy/use-cases/get-vacancies.use-case"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; + +@Injectable() +export class VacancyFilterInfoService { + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + private readonly logger = inject(LoggerService); + private readonly destroyRef = inject(DestroyRef); + + private readonly getVacanciesUseCase = inject(GetVacanciesUseCase); + + readonly filterOpen = signal(false); + + readonly searchValue = signal(undefined); + + private readonly totalItemsCount = signal(0); + + readonly currentExperience = signal(undefined); + readonly currentWorkFormat = signal(undefined); + readonly currentWorkSchedule = signal(undefined); + readonly currentSalary = signal(undefined); + + initializationVacancyFilters(): void { + // Подписка на изменения параметров запроса + this.route.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(queries => { + // Синхронизация текущих значений фильтров с URL + this.currentExperience.set(queries["required_experience"]); + this.currentWorkFormat.set(queries["work_format"]); + this.currentWorkSchedule.set(queries["work_schedule"]); + this.currentSalary.set(queries["salary"]); + this.applyParamsSearchValue(queries); + }); + } + + applyInitSearchValue(value?: string): void { + this.searchValue.set(value); + } + + applyParamsSearchValue(queries: Params): void { + this.searchValue.set(queries["role_contains"]); + } + + setExperienceFilter(event: Event, experienceId: string): void { + event.stopPropagation(); + // Переключение фильтра (снятие если уже выбран) + this.currentExperience.set( + experienceId === this.currentExperience() ? undefined : experienceId, + ); + + // Обновление URL с новым параметром + this.router + .navigate([], { + queryParams: { required_experience: this.currentExperience() }, + relativeTo: this.route, + queryParamsHandling: "merge", + }) + .then(() => this.logger.debug("Query change from ProjectsComponent")); + } + + setWorkFormatFilter(event: Event, formatId: string): void { + event.stopPropagation(); + this.currentWorkFormat.set(formatId === this.currentWorkFormat() ? undefined : formatId); + + this.router + .navigate([], { + queryParams: { work_format: this.currentWorkFormat() }, + relativeTo: this.route, + queryParamsHandling: "merge", + }) + .then(() => this.logger.debug("Query change from ProjectsComponent")); + } + + setWorkScheduleFilter(event: Event, scheduleId: string): void { + event.stopPropagation(); + this.currentWorkSchedule.set( + scheduleId === this.currentWorkSchedule() ? undefined : scheduleId, + ); + + this.router + .navigate([], { + queryParams: { + work_schedule: this.currentWorkSchedule(), + }, + relativeTo: this.route, + queryParamsHandling: "merge", + }) + .then(() => this.logger.debug("Query change from ProjectsComponent")); + } + + resetFilter(): void { + this.router + .navigate([], { + queryParams: { + required_experience: null, + work_format: null, + work_schedule: null, + role_contains: null, + }, + relativeTo: this.route, + queryParamsHandling: "merge", + }) + .then(() => this.logger.debug("Filters reset from VacancyFilterComponent")); + } + + applyResetCurrentFilters(): void { + this.currentExperience.set(undefined); + this.currentWorkFormat.set(undefined); + this.currentWorkSchedule.set(undefined); + } + + onClickOutside(): void { + this.filterOpen.set(false); + } + + onFetch(offset: number, limit: number, projectId?: number) { + return this.getVacanciesUseCase + .execute({ + limit, + offset, + projectId, + requiredExperience: this.currentExperience(), + workFormat: this.currentWorkFormat(), + workSchedule: this.currentWorkSchedule(), + salary: this.currentSalary(), + searchValue: this.searchValue(), + }) + .pipe( + tap(result => { + this.totalItemsCount.set(result.ok ? result.value.length : 0); + }), + map(result => (result.ok ? result.value : [])), + ); + } +} diff --git a/projects/social_platform/src/app/ui/widgets/vacancy-filter/vacancy-filter.component.html b/projects/social_platform/src/app/ui/widgets/vacancy-filter/vacancy-filter.component.html new file mode 100644 index 000000000..fa70fe021 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/vacancy-filter/vacancy-filter.component.html @@ -0,0 +1,96 @@ + + +
    +
    +

    фильтры

    + сбросить +
    + + + мои отклики + + + +
    + +
    +
    +
    + фильтры +
    + + @if (filterOpen()) { + + } +
    +
    + + +
    +
    + опыт +
      + @for (option of workExperienceFilterOptions; track $index) { +
    • + + {{ option.label }} +
    • + } +
    +
    + +
    + график +
      + @for (option of workScheduleFilterOptions; track $index) { +
    • + + {{ option.label }} +
    • + } +
    +
    + +
    + формат работы +
      + @for (option of workFormatFilterOptions; track $index) { +
    • + + {{ option.label }} +
    • + } +
    +
    + +
    + заработная плата +
  • + + с зарплатой +
  • +
    +
    +
    diff --git a/projects/social_platform/src/app/office/vacancies/shared/filter/vacancy-filter.component.scss b/projects/social_platform/src/app/ui/widgets/vacancy-filter/vacancy-filter.component.scss similarity index 100% rename from projects/social_platform/src/app/office/vacancies/shared/filter/vacancy-filter.component.scss rename to projects/social_platform/src/app/ui/widgets/vacancy-filter/vacancy-filter.component.scss diff --git a/projects/social_platform/src/app/ui/widgets/vacancy-filter/vacancy-filter.component.spec.ts b/projects/social_platform/src/app/ui/widgets/vacancy-filter/vacancy-filter.component.spec.ts new file mode 100644 index 000000000..b15f61db8 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/vacancy-filter/vacancy-filter.component.spec.ts @@ -0,0 +1,32 @@ +/** @format */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { VacancyFilterComponent } from "./vacancy-filter.component"; +import { provideRouter } from "@angular/router"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { VacancyRepositoryPort } from "@domain/vacancy/ports/vacancy.repository.port"; +import { of } from "rxjs"; + +describe("FeedComponent", () => { + let component: VacancyFilterComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const vacancyRepoSpy = { + getForProject: of([]), + }; + + await TestBed.configureTestingModule({ + imports: [VacancyFilterComponent, HttpClientTestingModule], + providers: [{ provide: VacancyRepositoryPort, useValue: vacancyRepoSpy }, provideRouter([])], + }).compileComponents(); + + fixture = TestBed.createComponent(VacancyFilterComponent); + component = fixture.componentInstance; + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/widgets/vacancy-filter/vacancy-filter.component.ts b/projects/social_platform/src/app/ui/widgets/vacancy-filter/vacancy-filter.component.ts new file mode 100644 index 000000000..e6f8f0f65 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/vacancy-filter/vacancy-filter.component.ts @@ -0,0 +1,112 @@ +/** @format */ + +import { animate, style, transition, trigger } from "@angular/animations"; +import { CommonModule } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + inject, + Input, + OnInit, + output, + Output, +} from "@angular/core"; +import { RouterLink } from "@angular/router"; +import { ButtonComponent, CheckboxComponent, IconComponent } from "@ui/primitives"; +import { AppRoutes } from "@api/paths/app-routes"; +import { ClickOutsideModule } from "ng-click-outside"; +import { workFormatFilter } from "@core/consts/filters/work-format-filter.const"; +import { workScheduleFilter } from "@core/consts/filters/work-schedule-filter.const"; +import { workExperienceFilter } from "@core/consts/filters/work-experience-filter.const"; +import { VacancyFilterInfoService } from "./service/vacancy-filter-info.service"; + +/** + * Компонент фильтра вакансий без использования реактивных форм + * Использует сигналы для управления состоянием полей зарплаты + */ +@Component({ + selector: "app-vacancy-filter", + imports: [ + CommonModule, + CheckboxComponent, + ClickOutsideModule, + IconComponent, + ButtonComponent, + RouterLink, + ], + templateUrl: "./vacancy-filter.component.html", + styleUrl: "./vacancy-filter.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [ + trigger("dropdownAnimation", [ + transition(":enter", [ + style({ opacity: 0, transform: "scaleY(0.8)" }), + animate(".12s cubic-bezier(0, 0, 0.2, 1)"), + ]), + transition(":leave", [animate(".1s linear", style({ opacity: 0 }))]), + ]), + ], + providers: [VacancyFilterInfoService], +}) +export class VacancyFilterComponent implements OnInit { + private readonly vacancyFilterInfoService = inject(VacancyFilterInfoService); + + @Input() set searchValue(value: string | undefined) { + this.vacancyFilterInfoService.applyInitSearchValue(value); + } + + get searchValue() { + return this.vacancyFilterInfoService.searchValue(); + } + + readonly searchValueChange = output(); + + protected readonly filterOpen = this.vacancyFilterInfoService.filterOpen; + + protected readonly currentExperience = this.vacancyFilterInfoService.currentExperience; + protected readonly currentWorkFormat = this.vacancyFilterInfoService.currentWorkFormat; + protected readonly currentWorkSchedule = this.vacancyFilterInfoService.currentWorkSchedule; + protected readonly currentSalary = this.vacancyFilterInfoService.currentSalary; + + protected readonly workExperienceFilterOptions = workExperienceFilter; + + protected readonly workFormatFilterOptions = workFormatFilter; + + protected readonly workScheduleFilterOptions = workScheduleFilter; + + protected readonly AppRoutes = AppRoutes; + + ngOnInit() { + // Подписка на изменения параметров запроса + this.vacancyFilterInfoService.initializationVacancyFilters(); + } + + setExperienceFilter(event: Event, experienceId: string): void { + this.vacancyFilterInfoService.setExperienceFilter(event, experienceId); + } + + setWorkFormatFilter(event: Event, formatId: string): void { + this.vacancyFilterInfoService.setWorkFormatFilter(event, formatId); + } + + setWorkScheduleFilter(event: Event, scheduleId: string): void { + this.vacancyFilterInfoService.setWorkScheduleFilter(event, scheduleId); + } + + resetFilter(): void { + this.vacancyFilterInfoService.applyResetCurrentFilters(); + + this.onSearchValueChanged(""); + + this.vacancyFilterInfoService.resetFilter(); + } + + onSearchValueChanged(value: string) { + this.searchValueChange.emit(value); + } + + onClickOutside(): void { + this.vacancyFilterInfoService.onClickOutside(); + } +} diff --git a/projects/social_platform/src/app/utils/cache.ts b/projects/social_platform/src/app/utils/cache.ts new file mode 100644 index 000000000..2cf5fde1c --- /dev/null +++ b/projects/social_platform/src/app/utils/cache.ts @@ -0,0 +1,56 @@ +/** @format */ + +const CACHE_PREFIX = "procollab:cache"; + +function cacheKey(key: string, version: number) { + return `${CACHE_PREFIX}:${key}:v${version}`; +} + +type Persisted = { t: number; d: T }; + +// безопасно читаем (guard на отсутствие localStorage / парсинг / TTL) +export function readCache( + key: string, + version: number, + ttlMs: number, + revive?: (raw: any) => T, +): T | null { + try { + if (typeof localStorage === "undefined") return null; + const raw = localStorage.getItem(cacheKey(key, version)); + + if (!raw) return null; + + const parsed: Persisted = JSON.parse(raw); + + if (!parsed || typeof parsed.t !== "number") { + localStorage.removeItem(cacheKey(key, version)); + return null; + } + + if (Date.now() - parsed.t > ttlMs) { + localStorage.removeItem(cacheKey(key, version)); + return null; + } + + return revive ? revive(parsed.d) : (parsed.d as T); + } catch { + try { + localStorage.removeItem(cacheKey(key, version)); + } catch {} + return null; + } +} + +export function writeCache(key: string, version: number, data: T) { + try { + if (typeof localStorage === "undefined") return; + localStorage.setItem(cacheKey(key, version), JSON.stringify({ t: Date.now(), d: data })); + } catch {} +} + +export function clearCacheKey(key: string, version: number) { + try { + if (typeof localStorage !== "undefined") localStorage.removeItem(cacheKey(key, version)); + } catch {} +} diff --git a/projects/social_platform/src/app/utils/calculateProgress.ts b/projects/social_platform/src/app/utils/calculateProgress.ts index 830c8d260..000d6cefb 100644 --- a/projects/social_platform/src/app/utils/calculateProgress.ts +++ b/projects/social_platform/src/app/utils/calculateProgress.ts @@ -1,7 +1,8 @@ /** @format */ -import { User } from "@auth/models/user.model"; -import { profileFields } from "projects/core/src/consts/other/profile-fields.const"; +import { User } from "@domain/auth/user.model"; +import { profileFields } from "@core/consts/other/profile-fields.const"; +import { userToRaw } from "./userRaw"; /** * @fileoverview Функция для расчета прогресса заполнения профиля пользователя @@ -22,9 +23,10 @@ import { profileFields } from "projects/core/src/consts/other/profile-fields.con */ export const calculateProfileProgress = (user: User) => { let filledCount = 0; + const rawUser = userToRaw(user); profileFields.forEach(({ key, type }) => { - const value = user[key as keyof User]; + const value = rawUser[key as keyof typeof rawUser]; if (type === "array") { if (Array.isArray(value) && value.length > 0) filledCount++; diff --git a/projects/social_platform/src/app/utils/dashboardItemBuilder.ts b/projects/social_platform/src/app/utils/dashboardItemBuilder.ts new file mode 100644 index 000000000..d20820d52 --- /dev/null +++ b/projects/social_platform/src/app/utils/dashboardItemBuilder.ts @@ -0,0 +1,27 @@ +/** @format */ + +import { Project } from "@domain/project/project.model"; + +export interface DashboardItem { + sectionName: string; + title: string; + iconName: string; + arrayItems: TItem[]; +} + +export const dashboardItemBuilder = ( + amount: number, + sections: string[], + titles: string[], + icons: string[], + arrays: TItem[][], +): DashboardItem[] => { + if (amount <= 0) return []; + + return Array.from({ length: amount }, (_, i) => ({ + sectionName: sections[i], + title: titles[i], + iconName: icons[i], + arrayItems: arrays[i], + })); +}; diff --git a/projects/social_platform/src/app/utils/days-until.ts b/projects/social_platform/src/app/utils/days-until.ts new file mode 100644 index 000000000..2fbe21f03 --- /dev/null +++ b/projects/social_platform/src/app/utils/days-until.ts @@ -0,0 +1,13 @@ +/** @format */ + +export const daysUntil = (tommorowDate: Date) => { + const todayDate = new Date(); + + const difference = tommorowDate.getTime() - todayDate.getTime(); + + const dayInMs = 1000 * 60 * 60 * 24; + + if (difference <= 0) return 0; + + return Math.ceil(difference / dayInMs); +}; diff --git a/projects/social_platform/src/app/utils/directionItemBuilder.ts b/projects/social_platform/src/app/utils/directionItemBuilder.ts new file mode 100644 index 000000000..3b63d462b --- /dev/null +++ b/projects/social_platform/src/app/utils/directionItemBuilder.ts @@ -0,0 +1,25 @@ +/** @format */ + +export interface DirectionItem { + direction: string; + icon: string; + about: TAbout; + type: string; +} + +export const directionItemBuilder = ( + amount: number, + directions: string[], + icons: string[], + abouts: TAbout[], + types: string[], +): DirectionItem[] | undefined => { + if (amount <= 0) return; + + return Array.from({ length: amount }, (_, i) => ({ + direction: directions[i], + icon: icons[i], + about: abouts[i], + type: types[i], + })); +}; diff --git a/projects/social_platform/src/app/utils/exponentialBackoff.ts b/projects/social_platform/src/app/utils/exponentialBackoff.ts new file mode 100644 index 000000000..c45ce2130 --- /dev/null +++ b/projects/social_platform/src/app/utils/exponentialBackoff.ts @@ -0,0 +1,18 @@ +/** @format */ + +import { throwError, timer } from "rxjs"; + +export const exponentialBackoff = (maxAttempts: number) => { + const RETRY_DELAY = 1000; + + return { + count: maxAttempts, + delay: (error: any, retryCount: number) => { + if (error.status >= 400 && error.status < 500) { + return throwError(() => error); + } + const exponentialTimer = Math.pow(2, retryCount) * RETRY_DELAY; + return timer(exponentialTimer); + }, + }; +}; diff --git a/projects/social_platform/src/app/utils/helpers/export-file.ts b/projects/social_platform/src/app/utils/export-file.ts similarity index 97% rename from projects/social_platform/src/app/utils/helpers/export-file.ts rename to projects/social_platform/src/app/utils/export-file.ts index 40343b8f4..3ba333ffc 100644 --- a/projects/social_platform/src/app/utils/helpers/export-file.ts +++ b/projects/social_platform/src/app/utils/export-file.ts @@ -10,7 +10,7 @@ import { saveAs } from "file-saver"; export const saveFile = ( blob: Blob, type: "all" | "submitted" | "rates" | "cv", - name?: string + name?: string, ): void => { const prefixFileName = type === "all" ? "projects" : type === "rates" ? "scores" : "projects_review"; diff --git a/projects/social_platform/src/app/utils/generate-options-list.ts b/projects/social_platform/src/app/utils/generate-options-list.ts index f15bf93d6..6b03ef5e6 100644 --- a/projects/social_platform/src/app/utils/generate-options-list.ts +++ b/projects/social_platform/src/app/utils/generate-options-list.ts @@ -22,7 +22,8 @@ export interface optionsListElement { export const generateOptionsList = ( amount: number, type: "numbers" | "years" | "strings", - otherStrings: string[] = [] + otherStrings: string[] = [], + includePresent = true, ): optionsListElement[] => { if (amount <= 0) return []; @@ -57,7 +58,7 @@ export const generateOptionsList = ( }); } - if (type === "years") { + if (type === "years" && includePresent) { const currentId = amount - 1; list.push({ id: currentId, diff --git a/projects/social_platform/src/app/utils/getActionType.ts b/projects/social_platform/src/app/utils/getActionType.ts new file mode 100644 index 000000000..e098ade91 --- /dev/null +++ b/projects/social_platform/src/app/utils/getActionType.ts @@ -0,0 +1,11 @@ +/** @format */ + +import { actionTypeList } from "@core/consts/lists/action-type-list.const"; + +export const getActionType = (actionId: number) => { + const findedAction = actionTypeList.find(action => action.id === actionId); + + if (!findedAction) return "phone" as const; + + return findedAction.additionalInfo; +}; diff --git a/projects/social_platform/src/app/utils/getPriorityType.ts b/projects/social_platform/src/app/utils/getPriorityType.ts new file mode 100644 index 000000000..45b76bb51 --- /dev/null +++ b/projects/social_platform/src/app/utils/getPriorityType.ts @@ -0,0 +1,22 @@ +/** @format */ + +import { priorityInfoList } from "@core/consts/lists/priority-info-list.const"; +import { hexToRgba } from "./hexToRgba"; + +export const getPriorityType = ( + priorityId: number, + type: "background" | "color", + opacity = 0.25, +) => { + const findedPriority = priorityInfoList.find(priority => priority.priorityType === priorityId); + const baseColor = findedPriority?.color ?? "var(--light-white)"; + + if (!findedPriority) return; + + if (type === "color") { + return { "background-color": baseColor }; + } + + const rgbaColor = hexToRgba(baseColor, opacity); + return { "background-color": rgbaColor }; +}; diff --git a/projects/social_platform/src/app/utils/helpers/dashboardItemBuilder.ts b/projects/social_platform/src/app/utils/helpers/dashboardItemBuilder.ts deleted file mode 100644 index 45754f780..000000000 --- a/projects/social_platform/src/app/utils/helpers/dashboardItemBuilder.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** @format */ - -import { Project } from "@office/models/project.model"; - -export interface DashboardItem { - sectionName: string; - title: string; - iconName: string; - arrayItems: Project[]; -} - -export const dashboardItemBuilder = ( - amount: number, - sections: string[], - titles: string[], - icons: string[], - arrays: Project[][] -): DashboardItem[] => { - if (amount <= 0) return []; - - return Array.from({ length: amount }, (_, i) => ({ - sectionName: sections[i], - title: titles[i], - iconName: icons[i], - arrayItems: arrays[i], - })); -}; diff --git a/projects/social_platform/src/app/utils/helpers/directionItemBuilder.ts b/projects/social_platform/src/app/utils/helpers/directionItemBuilder.ts deleted file mode 100644 index e58dc86ae..000000000 --- a/projects/social_platform/src/app/utils/helpers/directionItemBuilder.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** @format */ - -export interface DirectionItem { - direction: string; - icon: string; - about: string; - type: string; -} - -export const directionItemBuilder = ( - amount: number, - directions: string[], - icons: string[], - abouts: string[] | any[], - types: string[] -) => { - if (amount <= 0) return; - - return Array.from({ length: amount }, (_, i) => ({ - direction: directions[i], - icon: icons[i], - about: abouts[i], - type: types[i], - })); -}; diff --git a/projects/social_platform/src/app/utils/hexToRgba.ts b/projects/social_platform/src/app/utils/hexToRgba.ts new file mode 100644 index 000000000..568c6c3a4 --- /dev/null +++ b/projects/social_platform/src/app/utils/hexToRgba.ts @@ -0,0 +1,10 @@ +/** @format */ + +export const hexToRgba = (hex: string, alpha: number): string => { + const [r, g, b] = hex + .replace(/^#/, "") + .match(/.{1,2}/g)! + .map(x => parseInt(x, 16)); + + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +}; diff --git a/projects/social_platform/src/app/utils/inviteToProjectMapper.ts b/projects/social_platform/src/app/utils/inviteToProjectMapper.ts index 94c55eb61..819f4a5ed 100644 --- a/projects/social_platform/src/app/utils/inviteToProjectMapper.ts +++ b/projects/social_platform/src/app/utils/inviteToProjectMapper.ts @@ -1,8 +1,9 @@ /** @format */ -import { Invite } from "@office/models/invite.model"; +import { Invite } from "@domain/invite/invite.model"; +import { InviteProjectSummary } from "@domain/project/invite-project-summary.model"; -export const inviteToProjectMapper = (invites: Invite[] = []): any[] => { +export const inviteToProjectMapper = (invites: Invite[] = []): InviteProjectSummary[] => { return (invites ?? []).map((invite: Invite) => ({ inviteId: invite.id, id: invite.project.id, diff --git a/projects/social_platform/src/app/utils/optionalUrl.validator.ts b/projects/social_platform/src/app/utils/optionalUrl.validator.ts index e5a117239..86dcb8180 100644 --- a/projects/social_platform/src/app/utils/optionalUrl.validator.ts +++ b/projects/social_platform/src/app/utils/optionalUrl.validator.ts @@ -3,7 +3,7 @@ import { AbstractControl, ValidationErrors } from "@angular/forms"; export const optionalUrlOrMentionValidator = ( - control: AbstractControl + control: AbstractControl, ): ValidationErrors | null => { const value: string = control.value; diff --git a/projects/social_platform/src/app/utils/responsive.ts b/projects/social_platform/src/app/utils/responsive.ts index 576cbefc9..4b1942d99 100644 --- a/projects/social_platform/src/app/utils/responsive.ts +++ b/projects/social_platform/src/app/utils/responsive.ts @@ -1,12 +1,9 @@ -/** - * Этот файл содержит константы, используемые для определения - * контрольных точек (breakpoints) в адаптивном дизайне. - * - * @format - * @fileoverview Константы для адаптивного дизайна - * @constant containerSm - Малый размер контейнера (680 пикселей) - * @constant containerMd - Средний размер контейнера (1280 пикселей) - */ +/** @format */ -export const containerSm = 680; -export const containerMd = 1280; +// Generated from styles/_responsive.scss — SCSS is single source of truth +export const containerMd = 1280; // $container-md: 1280px +export const desktop = 1000; // $desktop: 1000px +export const tablet = 750; // $tablet: 750px + +// Aliases used across the TS codebase +export const containerSm = tablet; diff --git a/projects/social_platform/src/app/utils/toCamelCase.ts b/projects/social_platform/src/app/utils/toCamelCase.ts new file mode 100644 index 000000000..1c63323cf --- /dev/null +++ b/projects/social_platform/src/app/utils/toCamelCase.ts @@ -0,0 +1,5 @@ +/** @format */ + +export const toCamelCase = (text: string) => { + return text.replace(/-([a-z])/g, g => g[1].toUpperCase()); +}; diff --git a/projects/social_platform/src/app/utils/userRaw.ts b/projects/social_platform/src/app/utils/userRaw.ts new file mode 100644 index 000000000..30fda8257 --- /dev/null +++ b/projects/social_platform/src/app/utils/userRaw.ts @@ -0,0 +1,132 @@ +/** @format */ + +import { User, UserInput, UserRaw } from "@domain/auth/user.model"; + +export function userFromRaw(raw: UserInput): User { + const user = Object.assign(new User(), raw); + + user.personal = { + onboardingStage: raw.personal?.onboardingStage ?? raw.onboardingStage!, + patronymic: raw.personal?.patronymic ?? raw.patronymic!, + aboutMe: raw.personal?.aboutMe ?? raw.aboutMe!, + birthday: raw.personal?.birthday ?? raw.birthday!, + avatar: raw.personal?.avatar ?? raw.avatar!, + links: raw.personal?.links ?? raw.links ?? [], + coverImageAddress: raw.personal?.coverImageAddress ?? raw.coverImageAddress, + speciality: raw.personal?.speciality ?? raw.speciality!, + userType: raw.personal?.userType ?? raw.userType!, + city: raw.personal?.city ?? raw.city!, + phoneNumber: raw.personal?.phoneNumber ?? raw.phoneNumber!, + region: raw.personal?.region ?? raw.region!, + isMospolytechStudent: raw.personal?.isMospolytechStudent ?? raw.isMospolytechStudent, + studyGroup: raw.personal?.studyGroup ?? raw.studyGroup, + v2Speciality: raw.personal?.v2Speciality ?? raw.v2Speciality!, + }; + + user.roles = { + member: raw.roles?.member ?? raw.member, + mentor: raw.roles?.mentor ?? raw.mentor, + expert: raw.roles?.expert ?? raw.expert, + investor: raw.roles?.investor ?? raw.investor, + }; + + user.relations = { + education: raw.relations?.education ?? raw.education ?? [], + userLanguages: raw.relations?.userLanguages ?? raw.userLanguages ?? [], + workExperience: raw.relations?.workExperience ?? raw.workExperience ?? [], + achievements: raw.relations?.achievements ?? raw.achievements ?? [], + programs: raw.relations?.programs ?? raw.programs ?? [], + projects: raw.relations?.projects ?? raw.projects ?? [], + subscribedProjects: raw.relations?.subscribedProjects ?? raw.subscribedProjects ?? [], + keySkills: raw.relations?.keySkills ?? raw.keySkills ?? [], + skills: raw.relations?.skills ?? raw.skills ?? [], + skillsIds: raw.relations?.skillsIds ?? raw.skillsIds ?? [], + progress: raw.relations?.progress ?? raw.progress, + isOnline: raw.relations?.isOnline ?? raw.isOnline!, + isActive: raw.relations?.isActive ?? raw.isActive!, + timeCreated: raw.relations?.timeCreated ?? raw.timeCreated!, + timeUpdated: raw.relations?.timeUpdated ?? raw.timeUpdated!, + verificationDate: raw.relations?.verificationDate ?? raw.verificationDate!, + }; + + user.subscription = { + isSubscribed: raw.subscription?.isSubscribed ?? raw.isSubscribed!, + lastSubscribeDate: raw.subscription?.lastSubscribeDate ?? raw.lastSubscribeDate!, + subscriptionDateOver: raw.subscription?.subscriptionDateOver ?? raw.subscriptionDateOver!, + lastSubscriptionType: raw.subscription?.lastSubscriptionType ?? raw.lastSubscriptionType!, + isAutopayAllowed: raw.subscription?.isAutopayAllowed ?? raw.isAutopayAllowed!, + }; + + Object.assign(user, userToRaw(user)); + + return user; +} + +export function userToRaw(user: UserInput): Partial { + const { personal, roles, relations, subscription, ...rest } = user; + + return omitUndefined({ + ...rest, + id: user.id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + + patronymic: user.patronymic, + aboutMe: user.aboutMe, + birthday: user.birthday, + avatar: user.avatar, + links: user.links, + coverImageAddress: user.coverImageAddress, + speciality: user.speciality, + userType: user.userType, + city: user.city, + phoneNumber: user.phoneNumber, + region: user.region, + isMospolytechStudent: user.isMospolytechStudent, + studyGroup: user.studyGroup, + v2Speciality: user.v2Speciality, + ...personal, + + member: user.member, + mentor: user.mentor, + expert: user.expert, + investor: user.investor, + ...roles, + + education: user.education, + userLanguages: user.userLanguages, + workExperience: user.workExperience, + achievements: user.achievements, + programs: user.programs, + projects: user.projects, + subscribedProjects: user.subscribedProjects, + keySkills: user.keySkills, + skills: user.skills, + skillsIds: user.skillsIds, + progress: user.progress, + isOnline: user.isOnline, + isActive: user.isActive, + timeCreated: user.timeCreated, + timeUpdated: user.timeUpdated, + verificationDate: user.verificationDate, + ...relations, + + isSubscribed: user.isSubscribed, + lastSubscribeDate: user.lastSubscribeDate, + subscriptionDateOver: user.subscriptionDateOver, + lastSubscriptionType: user.lastSubscriptionType, + isAutopayAllowed: user.isAutopayAllowed, + ...subscription, + }); +} + +function omitUndefined(obj: T): Partial { + return Object.entries(obj).reduce((acc, [key, value]) => { + if (value !== undefined) { + acc[key as keyof T] = value as T[keyof T]; + } + + return acc; + }, {} as Partial); +} diff --git a/projects/social_platform/src/app/utils/yearRangeValidators.ts b/projects/social_platform/src/app/utils/yearRangeValidators.ts index 3307f67a0..4a6675ffb 100644 --- a/projects/social_platform/src/app/utils/yearRangeValidators.ts +++ b/projects/social_platform/src/app/utils/yearRangeValidators.ts @@ -26,7 +26,7 @@ import { AbstractControl, ValidationErrors, ValidatorFn } from "@angular/forms"; */ export const yearRangeValidators = ( entryYearValue: string, - completionYearValue: string + completionYearValue: string, ): ValidatorFn => { return (formGroup: AbstractControl): ValidationErrors | null => { const entryYearControl = formGroup.get(entryYearValue); diff --git "a/projects/social_platform/src/assets/downloads/auth/shared/\320\237\320\276\320\273\320\270\321\202\320\270\320\272\320\260_\320\276\320\261\321\200\320\260\320\261\320\276\321\202\320\272\320\270_\320\277\320\265\321\200\321\201_\320\264\320\260\320\275\320\275\321\213\321\205_2022.docx" b/projects/social_platform/src/assets/downloads/auth/shared/privacy_policy_2022.docx similarity index 100% rename from "projects/social_platform/src/assets/downloads/auth/shared/\320\237\320\276\320\273\320\270\321\202\320\270\320\272\320\260_\320\276\320\261\321\200\320\260\320\261\320\276\321\202\320\272\320\270_\320\277\320\265\321\200\321\201_\320\264\320\260\320\275\320\275\321\213\321\205_2022.docx" rename to projects/social_platform/src/assets/downloads/auth/shared/privacy_policy_2022.docx diff --git a/projects/social_platform/src/assets/icons/svg/arrow-wide.svg b/projects/social_platform/src/assets/icons/svg/arrow-wide.svg new file mode 100644 index 000000000..d096f016f --- /dev/null +++ b/projects/social_platform/src/assets/icons/svg/arrow-wide.svg @@ -0,0 +1,3 @@ + + + diff --git a/projects/social_platform/src/assets/icons/svg/calendar.svg b/projects/social_platform/src/assets/icons/svg/calendar.svg new file mode 100644 index 000000000..4fe4af359 --- /dev/null +++ b/projects/social_platform/src/assets/icons/svg/calendar.svg @@ -0,0 +1,3 @@ + + + diff --git a/projects/social_platform/src/assets/icons/svg/command.svg b/projects/social_platform/src/assets/icons/svg/command.svg new file mode 100644 index 000000000..7b03c4bce --- /dev/null +++ b/projects/social_platform/src/assets/icons/svg/command.svg @@ -0,0 +1,3 @@ + + + diff --git a/projects/social_platform/src/assets/icons/svg/deadline.svg b/projects/social_platform/src/assets/icons/svg/deadline.svg index 3680150d0..68a63f405 100644 --- a/projects/social_platform/src/assets/icons/svg/deadline.svg +++ b/projects/social_platform/src/assets/icons/svg/deadline.svg @@ -1,3 +1,3 @@ - + diff --git a/projects/social_platform/src/assets/icons/svg/hashtag.svg b/projects/social_platform/src/assets/icons/svg/hashtag.svg new file mode 100644 index 000000000..75a1a822e --- /dev/null +++ b/projects/social_platform/src/assets/icons/svg/hashtag.svg @@ -0,0 +1,3 @@ + + + diff --git a/projects/social_platform/src/assets/icons/svg/person.svg b/projects/social_platform/src/assets/icons/svg/person.svg index 8746fa9c3..a273e14dc 100644 --- a/projects/social_platform/src/assets/icons/svg/person.svg +++ b/projects/social_platform/src/assets/icons/svg/person.svg @@ -1,3 +1,3 @@ - + diff --git a/projects/social_platform/src/assets/icons/symbol/svg/sprite.css.svg b/projects/social_platform/src/assets/icons/symbol/svg/sprite.css.svg index 27869683b..d3c7d6784 100644 --- a/projects/social_platform/src/assets/icons/symbol/svg/sprite.css.svg +++ b/projects/social_platform/src/assets/icons/symbol/svg/sprite.css.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/projects/social_platform/src/assets/images/projects/shared/divider.png b/projects/social_platform/src/assets/images/projects/shared/divider.png new file mode 100644 index 000000000..eaebf52a7 Binary files /dev/null and b/projects/social_platform/src/assets/images/projects/shared/divider.png differ diff --git a/projects/social_platform/src/environments/environment.prod.ts b/projects/social_platform/src/environments/environment.prod.ts index 1e018aa5e..747ef27f9 100644 --- a/projects/social_platform/src/environments/environment.prod.ts +++ b/projects/social_platform/src/environments/environment.prod.ts @@ -9,4 +9,10 @@ export const environment = { websocketUrl: "wss://api.procollab.ru/ws", websocketReconnectionInterval: 5000, websocketReconnectionMaxAttempts: 5, + // analytics + analyticsHost: "app.procollab.ru", + yandexMetrikaId: 91871365, + mailRuCounterId: "3622531", + mailRuRegisterId: "3543687", + registerPath: "/auth/register", }; diff --git a/projects/social_platform/src/environments/environment.ts b/projects/social_platform/src/environments/environment.ts index f48ef2d35..32e5cf59b 100644 --- a/projects/social_platform/src/environments/environment.ts +++ b/projects/social_platform/src/environments/environment.ts @@ -9,17 +9,14 @@ export const environment = { sentryDns: "https://fc61f416df6044bab8c7e1afd55f4355@o1186023.ingest.sentry.io/6577563", apiUrl: "https://dev.procollab.ru", // TODO: change it before merge skillsApiUrl: "https://skills.dev.procollab.ru", + // analytics + analyticsHost: "app.procollab.ru", + yandexMetrikaId: 91871365, + mailRuCounterId: "3622531", + mailRuRegisterId: "3543687", + registerPath: "/auth/register", // websockets websocketUrl: "wss://dev.procollab.ru/ws", // TODO: change it before merge websocketReconnectionInterval: 500, websocketReconnectionMaxAttempts: 5, }; - -/* - * For easier debugging in development mode, you can import the following file - * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. - * - * This import should be commented out in production mode because it will have a negative impact - * on performance if an error is thrown. - */ -// import 'zone.js/plugins/zone-error'; // Included with Angular CLI. diff --git a/projects/social_platform/src/index.html b/projects/social_platform/src/index.html index 23f5efbe5..cc879ac0f 100644 --- a/projects/social_platform/src/index.html +++ b/projects/social_platform/src/index.html @@ -1,13 +1,13 @@ - + Procollab - + - -# UI Library - Библиотека компонентов пользовательского интерфейса - -Эта библиотека содержит переиспользуемые Angular компоненты для построения пользовательского интерфейса приложения. Библиотека разделена на две основные категории: компоненты макета (layout) и примитивные компоненты (primitives). - -## Структура проекта - -\`\`\` -src/ -├── lib/ -│ ├── models/ # Модели данных -│ │ └── user.model.ts # Модель пользователя -│ └── components/ # Компоненты UI -│ ├── layout/ # Компоненты макета -│ │ ├── sidebar/ # Боковая панель навигации -│ │ ├── profile-control-panel/ # Панель управления профилем -│ │ ├── profile-info/ # Информация о профиле -│ │ ├── invite-manage-card/ # Карточка управления приглашениями -│ │ └── subscription-plans/ # Планы подписки -│ └── primitives/ # Базовые компоненты -│ ├── avatar/ # Аватар пользователя -│ ├── back/ # Кнопка "Назад" -│ └── icon/ # Иконки -└── public-api.ts # Публичный API библиотеки -\`\`\` - -## Установка и использование - -### Импорт компонентов - -\`\`\`typescript -import { -SidebarComponent, -ProfileControlPanelComponent, -AvatarComponent, -IconComponent -} from '@uilib'; -\`\`\` - -### Использование в шаблонах - -\`\`\`html - - - - - - - - - - -\`\`\` - -## Компоненты макета (Layout Components) - -### SidebarComponent - -Боковая панель навигации с логотипом и списком навигационных элементов. - -**Входные параметры:** - -- `navItems: NavItem[]` - массив элементов навигации -- `logoSrc: string` - путь к логотипу (обязательный) - -**Интерфейс NavItem:** -\`\`\`typescript -interface NavItem { -link: string; // Ссылка для роутинга -icon: string; // Название иконки -name: string; // Отображаемое имя -} -\`\`\` - -### ProfileControlPanelComponent - -Панель управления профилем с уведомлениями, чатами и кнопкой выхода. - -**Входные параметры:** - -- `user: User | null` - данные пользователя (обязательный) -- `invites: Invite[]` - массив приглашений (обязательный) -- `hasNotifications: boolean` - наличие уведомлений (обязательный) -- `hasUnreads: boolean` - наличие непрочитанных сообщений (обязательный) - -**Выходные события:** - -- `acceptInvite: EventEmitter` - принятие приглашения -- `rejectInvite: EventEmitter` - отклонение приглашения -- `logout: EventEmitter` - выход из системы - -### ProfileInfoComponent - -Отображение информации о профиле пользователя. - -**Входные параметры:** - -- `user: User` - данные пользователя (обязательный) - -**Выходные события:** - -- `logout: EventEmitter` - выход из системы - -### InviteManageCardComponent - -Карточка для управления приглашениями в проекты. - -**Входные параметры:** - -- `invite: Invite` - данные приглашения (обязательный) - -**Выходные события:** - -- `accept: EventEmitter` - принятие приглашения -- `reject: EventEmitter` - отклонение приглашения - -### SubscriptionPlansComponent - -Модальное окно с планами подписки. - -**Входные параметры:** - -- `open: boolean` - состояние открытия модального окна -- `subscriptionPlans: SubscriptionPlan[]` - массив планов подписки (обязательный) - -**Выходные события:** - -- `openChange: EventEmitter` - изменение состояния модального окна - -## Примитивные компоненты (Primitive Components) - -### AvatarComponent - -Компонент для отображения аватара пользователя с поддержкой индикатора онлайн-статуса. - -**Входные параметры:** - -- `url?: string` - URL изображения аватара -- `size: number = 50` - размер аватара в пикселях -- `hasBorder: boolean = false` - наличие рамки -- `isOnline: boolean = false` - онлайн статус -- `onlineBadgeSize: number = 16` - размер индикатора онлайн -- `onlineBadgeBorder: number = 3` - толщина рамки индикатора -- `onlineBadgeOffset: number = 0` - смещение индикатора - -### BackComponent - -Кнопка "Назад" для навигации. - -**Входные параметры:** - -- `path?: string` - путь для перехода (опциональный, по умолчанию используется history.back()) - -### IconComponent - -Компонент для отображения SVG иконок из спрайта. - -**Входные параметры:** - -- `icon: string` - название иконки (обязательный) -- `appSquare?: string` - размер квадратной иконки -- `appWidth?: string` - ширина иконки -- `appHeight?: string` - высота иконки -- `appViewBox?: string` - viewBox для SVG - -## Модели данных - -### User - -Модель пользователя системы. - -\`\`\`typescript -interface User { -id: number; // Уникальный идентификатор -email: string; // Email адрес -firstName: string; // Имя -lastName: string; // Фамилия -avatar: string; // URL аватара -isOnline: boolean; // Онлайн статус -isActive: boolean; // Активность аккаунта -onboardingStage: number | null; // Этап онбординга -speciality: string; // Специальность -userType: number; // Тип пользователя -timeCreated: string; // Время создания -timeUpdated: string; // Время обновления -verificationDate: string; // Дата верификации -} -\`\`\` - -## Стилизация - -Библиотека использует CSS переменные для темизации: - -\`\`\`css -:root { ---white: #ffffff; ---black: #000000; ---accent: #your-accent-color; ---accent-dark: #your-accent-dark-color; ---grey-for-text: #your-grey-color; ---light-gray: #your-light-gray; ---red: #your-red-color; ---gold-dark: #your-gold-color; -/_ и другие переменные _/ -} -\`\`\` - -## Зависимости - -Библиотека требует следующие зависимости: - -- `@angular/core` -- `@angular/common` -- `@angular/router` -- `ng-click-outside` - для обработки кликов вне элемента -- Различные внутренние модули проекта (`@auth/services`, `@office/models`, etc.) - -## Тестирование - -Каждый компонент имеет соответствующий `.spec.ts` файл с unit тестами. Тесты используют Angular Testing Utilities и Jasmine. - -Для запуска тестов: -\`\`\`bash -ng test ui -\`\`\` - -## Сборка - -Для сборки библиотеки: -\`\`\`bash -ng build ui -\`\`\` - -Результат сборки будет в папке `dist/ui/`. - -## Примеры использования - -### Базовая боковая панель - -\`\`\`typescript -// component.ts -export class AppComponent { -logoSrc = 'assets/logo.png'; -navItems: NavItem[] = [ -{ link: '/dashboard', icon: 'dashboard', name: 'Панель управления' }, -{ link: '/projects', icon: 'projects', name: 'Проекты' }, -{ link: '/profile', icon: 'user', name: 'Профиль' } -]; -} -\`\`\` - -\`\`\`html - - - - - - - - - - - - -\`\`\` - -### Использование аватара с онлайн статусом - -\`\`\`html - - -\`\`\` - -/\*\* - -- Модель пользователя системы -- Содержит всю необходимую информацию о пользователе для отображения в UI компонентах - _/ - export interface User { - /\*\* Уникальный идентификатор пользователя _/ - id: number; - -/\*_ Email адрес пользователя _/ -email: string; - -/\*_ Имя пользователя _/ -firstName: string; - -/\*_ Фамилия пользователя _/ -lastName: string; - -/\*_ URL изображения аватара пользователя _/ -avatar: string; - -/\*_ Статус онлайн (true - пользователь в сети, false - оффлайн) _/ -isOnline: boolean; - -/\*_ Статус активности аккаунта (true - активен, false - заблокирован/неактивен) _/ -isActive: boolean; - -/\*_ Текущий этап процесса онбординга (null если онбординг завершен) _/ -onboardingStage: number | null; - -/\*_ Специальность/профессия пользователя _/ -speciality: string; - -/\*_ Тип пользователя (числовой код, определяющий роль в системе) _/ -userType: number; - -/\*_ Дата и время создания аккаунта в ISO формате _/ -timeCreated: string; - -/\*_ Дата и время последнего обновления профиля в ISO формате _/ -timeUpdated: string; - -/\*_ Дата верификации аккаунта в ISO формате _/ -verificationDate: string; -} diff --git a/projects/ui/package.json b/projects/ui/package.json index 9d591ed07..cde77cc33 100644 --- a/projects/ui/package.json +++ b/projects/ui/package.json @@ -2,8 +2,8 @@ "name": "ui", "version": "0.0.1", "peerDependencies": { - "@angular/common": "^17.0.0", - "@angular/core": "^17.0.0" + "@angular/common": "^20.0.0", + "@angular/core": "^20.0.0" }, "dependencies": { "tslib": "^2.3.0" diff --git a/projects/ui/src/lib/components/layout/empty-manage-card/empty-manage-card.component.ts b/projects/ui/src/lib/components/layout/empty-manage-card/empty-manage-card.component.ts index 2e28f5c9b..883d92720 100644 --- a/projects/ui/src/lib/components/layout/empty-manage-card/empty-manage-card.component.ts +++ b/projects/ui/src/lib/components/layout/empty-manage-card/empty-manage-card.component.ts @@ -1,8 +1,9 @@ /** @format */ import { CommonModule } from "@angular/common"; -import { Component } from "@angular/core"; -import { ButtonComponent, IconComponent } from "@ui/components"; +import { ChangeDetectionStrategy, Component } from "@angular/core"; +import { ButtonComponent } from "../../primitives/button/button.component"; +import { IconComponent } from "../../primitives/icon/icon.component"; import { RouterLink } from "@angular/router"; @Component({ @@ -10,6 +11,6 @@ import { RouterLink } from "@angular/router"; templateUrl: "./empty-manage-card.component.html", styleUrl: "./empty-manage-card.component.scss", imports: [CommonModule, ButtonComponent, IconComponent, RouterLink], - standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, }) export class EmptyManageCardComponent {} diff --git a/projects/ui/src/lib/components/layout/invite-manage-card/invite-manage-card.component.html b/projects/ui/src/lib/components/layout/invite-manage-card/invite-manage-card.component.html index 5b455b07a..b59c32131 100644 --- a/projects/ui/src/lib/components/layout/invite-manage-card/invite-manage-card.component.html +++ b/projects/ui/src/lib/components/layout/invite-manage-card/invite-manage-card.component.html @@ -1,61 +1,64 @@ -@if (invite) { -
    -
    -
    - -
    -
    -
    - - {{ invite.sender.firstName }} {{ invite.sender.lastName }}
    приглашает вас в проект
    - "{{ invite.project.name | truncate: 20 }}" +@if (invite()) { +
    +
    +
    + +
    +
    +
    + + {{ invite().sender.firstName }} {{ invite().sender.lastName }}
    приглашает вас в проект
    + "{{ invite().project.name | truncate: 20 }}" - - + - - + + - - -
    + + +
    - +
    -
    -
    - - отклонить - принять +
    + + отклонить + принять - - + +
    -
    } diff --git a/projects/ui/src/lib/components/layout/invite-manage-card/invite-manage-card.component.spec.ts b/projects/ui/src/lib/components/layout/invite-manage-card/invite-manage-card.component.spec.ts index ed2c69405..b50b88185 100644 --- a/projects/ui/src/lib/components/layout/invite-manage-card/invite-manage-card.component.spec.ts +++ b/projects/ui/src/lib/components/layout/invite-manage-card/invite-manage-card.component.spec.ts @@ -1,6 +1,7 @@ /** @format */ import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { provideRouter } from "@angular/router"; import { InviteManageCardComponent } from "./invite-manage-card.component"; @@ -11,12 +12,19 @@ describe("InviteManageCardComponent", () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [InviteManageCardComponent], + providers: [provideRouter([])], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(InviteManageCardComponent); component = fixture.componentInstance; + // invite — required input; шаблон читает sender.{id,firstName,lastName,personal.avatar} и project.{id,name} + fixture.componentRef.setInput("invite", { + id: 1, + sender: { id: 2, firstName: "Иван", lastName: "Петров", personal: { avatar: "" } }, + project: { id: 3, name: "Проект" }, + }); fixture.detectChanges(); }); diff --git a/projects/ui/src/lib/components/layout/invite-manage-card/invite-manage-card.component.ts b/projects/ui/src/lib/components/layout/invite-manage-card/invite-manage-card.component.ts index c6487b6f5..8c4fad88e 100644 --- a/projects/ui/src/lib/components/layout/invite-manage-card/invite-manage-card.component.ts +++ b/projects/ui/src/lib/components/layout/invite-manage-card/invite-manage-card.component.ts @@ -1,12 +1,20 @@ /** @format */ -import { Component, EventEmitter, Input, type OnInit, Output } from "@angular/core"; -import type { Invite } from "@models/invite.model"; -import { DayjsPipe } from "projects/core"; -import { ButtonComponent } from "@ui/components"; +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + input, + Input, + type OnInit, + output, + Output, +} from "@angular/core"; +import type { Invite } from "projects/social_platform/src/app/domain/invite/invite.model"; +import { ButtonComponent } from "../../primitives/button/button.component"; import { RouterLink } from "@angular/router"; -import { AvatarComponent } from "@ui/components/avatar/avatar.component"; -import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; +import { AvatarComponent } from "../../primitives/avatar/avatar.component"; +import { TruncatePipe } from "@corelib"; /** * Компонент карточки управления приглашением @@ -30,20 +38,18 @@ import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; selector: "app-invite-manage-card", templateUrl: "./invite-manage-card.component.html", styleUrl: "./invite-manage-card.component.scss", - standalone: true, - imports: [AvatarComponent, RouterLink, ButtonComponent, DayjsPipe, TruncatePipe], + imports: [AvatarComponent, RouterLink, ButtonComponent, TruncatePipe], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class InviteManageCardComponent implements OnInit { - constructor() {} - /** Данные приглашения для отображения */ - @Input({ required: true }) invite!: Invite; + readonly invite = input.required(); /** Событие принятия приглашения (передает ID приглашения) */ - @Output() accept = new EventEmitter(); + readonly accept = output(); /** Событие отклонения приглашения (передает ID приглашения) */ - @Output() reject = new EventEmitter(); + readonly reject = output(); ngOnInit(): void {} } diff --git a/projects/ui/src/lib/components/layout/profile-control-panel/profile-control-panel.component.html b/projects/ui/src/lib/components/layout/profile-control-panel/profile-control-panel.component.html index 79fcab679..7727bd3eb 100644 --- a/projects/ui/src/lib/components/layout/profile-control-panel/profile-control-panel.component.html +++ b/projects/ui/src/lib/components/layout/profile-control-panel/profile-control-panel.component.html @@ -4,31 +4,31 @@
    - @if (hasNotifications || hasInvites) { -
    + @if (hasNotifications() || hasInvites) { +
    } - @if (invites.length) { -
    + @if (invites().length) { +
    }
    @if (showNotifications) { -
    -
      - @for (invite of invites; track invite.id) { -
    • - -
    • - } @empty { - - } -
    -
    +
    +
      + @for (invite of invites(); track invite.id) { +
    • + +
    • + } @empty { + + } +
    +
    }
    @@ -36,11 +36,11 @@
    - @if (user) { - + @if (user()) { + }
    diff --git a/projects/ui/src/lib/components/layout/profile-control-panel/profile-control-panel.component.ts b/projects/ui/src/lib/components/layout/profile-control-panel/profile-control-panel.component.ts index 9aaf0f41b..80ecd205d 100644 --- a/projects/ui/src/lib/components/layout/profile-control-panel/profile-control-panel.component.ts +++ b/projects/ui/src/lib/components/layout/profile-control-panel/profile-control-panel.component.ts @@ -1,14 +1,23 @@ /** @format */ import { CommonModule } from "@angular/common"; -import { ChangeDetectionStrategy, Component, Input, Output, EventEmitter } from "@angular/core"; -import { InviteManageCardComponent, ProfileInfoComponent, IconComponent } from "@uilib"; +import { + ChangeDetectionStrategy, + Component, + Input, + Output, + EventEmitter, + input, + output, +} from "@angular/core"; +import { InviteManageCardComponent } from "../invite-manage-card/invite-manage-card.component"; +import { ProfileInfoComponent } from "../profile-info/profile-info.component"; +import { IconComponent } from "../../primitives/icon/icon.component"; import { ClickOutsideModule } from "ng-click-outside"; -import type { Invite } from "@office/models/invite.model"; +import type { Invite } from "projects/social_platform/src/app/domain/invite/invite.model"; import { RouterLink } from "@angular/router"; -import type { User } from "../../../models/user.model"; import { EmptyManageCardComponent } from "../empty-manage-card/empty-manage-card.component"; -import { UserData } from "projects/skills/src/models/profile.model"; +import { User } from "@domain/auth/user.model"; /** * Компонент панели управления профилем @@ -32,14 +41,12 @@ import { UserData } from "projects/skills/src/models/profile.model"; */ @Component({ selector: "app-profile-control-panel", - standalone: true, imports: [ CommonModule, InviteManageCardComponent, ProfileInfoComponent, ClickOutsideModule, IconComponent, - RouterLink, EmptyManageCardComponent, ], templateUrl: "./profile-control-panel.component.html", @@ -48,32 +55,32 @@ import { UserData } from "projects/skills/src/models/profile.model"; }) export class ProfileControlPanelComponent { /** Данные текущего пользователя */ - @Input({ required: true }) user!: User | UserData | null; + readonly user = input.required(); /** Массив приглашений пользователя */ - @Input({ required: true }) invites!: Invite[]; + readonly invites = input.required(); /** Флаг наличия уведомлений */ - @Input({ required: true }) hasNotifications = false; + readonly hasNotifications = input(false); /** Флаг наличия непрочитанных сообщений */ - @Input({ required: true }) hasUnreads = false; + readonly hasUnreads = input(false); /** Событие принятия приглашения (передает ID приглашения) */ - @Output() acceptInvite = new EventEmitter(); + readonly acceptInvite = output(); /** Событие отклонения приглашения (передает ID приглашения) */ - @Output() rejectInvite = new EventEmitter(); + readonly rejectInvite = output(); /** Событие выхода из системы */ - @Output() logout = new EventEmitter(); + readonly logout = output(); /** * Проверяет наличие неотвеченных приглашений * @returns true если есть приглашения без ответа */ get hasInvites(): boolean { - return !!this.invites.filter(invite => invite.isAccepted === null).length; + return !!this.invites().filter(invite => invite.isAccepted === null).length; } /** Флаг отображения панели уведомлений */ diff --git a/projects/ui/src/lib/components/layout/profile-info/profile-info.component.html b/projects/ui/src/lib/components/layout/profile-info/profile-info.component.html index f9d0e8bc3..c9885743e 100644 --- a/projects/ui/src/lib/components/layout/profile-info/profile-info.component.html +++ b/projects/ui/src/lib/components/layout/profile-info/profile-info.component.html @@ -1,9 +1,13 @@ -@if (user) { - -
    - -
    -
    +@if (user()) { + +
    + +
    +
    } diff --git a/projects/ui/src/lib/components/layout/profile-info/profile-info.component.spec.ts b/projects/ui/src/lib/components/layout/profile-info/profile-info.component.spec.ts index ca2bbaa4b..628cde5c0 100644 --- a/projects/ui/src/lib/components/layout/profile-info/profile-info.component.spec.ts +++ b/projects/ui/src/lib/components/layout/profile-info/profile-info.component.spec.ts @@ -2,7 +2,6 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { RouterTestingModule } from "@angular/router/testing"; -import { AuthService } from "@auth/services"; import { ProfileInfoComponent } from "./profile-info.component"; import { HttpClientTestingModule } from "@angular/common/http/testing"; @@ -11,9 +10,9 @@ describe("ProfileInfoComponent", () => { let fixture: ComponentFixture; beforeEach(async () => { + // Компонент зависит только от Router. AuthService удалён. await TestBed.configureTestingModule({ imports: [RouterTestingModule, HttpClientTestingModule, ProfileInfoComponent], - providers: [AuthService], }).compileComponents(); }); diff --git a/projects/ui/src/lib/components/layout/profile-info/profile-info.component.ts b/projects/ui/src/lib/components/layout/profile-info/profile-info.component.ts index c41808474..9ec026ccb 100644 --- a/projects/ui/src/lib/components/layout/profile-info/profile-info.component.ts +++ b/projects/ui/src/lib/components/layout/profile-info/profile-info.component.ts @@ -1,11 +1,9 @@ /** @format */ -import { Component, EventEmitter, Input, type OnInit, Output } from "@angular/core"; +import { ChangeDetectionStrategy, Component, input, type OnInit, output } from "@angular/core"; import { Router, RouterLink } from "@angular/router"; -import { DayjsPipe } from "projects/core"; -import { AvatarComponent, IconComponent } from "@uilib"; -import type { User } from "../../../models/user.model"; -import { UserData } from "projects/skills/src/models/profile.model"; +import { User } from "@domain/auth/user.model"; +import { AvatarComponent } from "../../primitives/avatar/avatar.component"; /** * Компонент отображения информации о профиле пользователя @@ -25,8 +23,8 @@ import { UserData } from "projects/skills/src/models/profile.model"; selector: "app-profile-info", templateUrl: "./profile-info.component.html", styleUrl: "./profile-info.component.scss", - standalone: true, - imports: [RouterLink, AvatarComponent, IconComponent], + imports: [RouterLink, AvatarComponent], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class ProfileInfoComponent implements OnInit { constructor(readonly router: Router) {} @@ -36,8 +34,8 @@ export class ProfileInfoComponent implements OnInit { avatarSize = window.innerWidth < 920 ? 42 : 33; /** Данные пользователя для отображения */ - @Input({ required: true }) user!: User | UserData; + readonly user = input.required(); /** Событие выхода из системы */ - @Output() logout = new EventEmitter(); + readonly logout = output(); } diff --git a/projects/ui/src/lib/components/layout/sidebar/sidebar.component.html b/projects/ui/src/lib/components/layout/sidebar/sidebar.component.html index a1a1ff179..0657fbef5 100644 --- a/projects/ui/src/lib/components/layout/sidebar/sidebar.component.html +++ b/projects/ui/src/lib/components/layout/sidebar/sidebar.component.html @@ -2,7 +2,7 @@ diff --git a/projects/ui/src/lib/components/layout/sidebar/sidebar.component.spec.ts b/projects/ui/src/lib/components/layout/sidebar/sidebar.component.spec.ts index af2262a59..95fe6d577 100644 --- a/projects/ui/src/lib/components/layout/sidebar/sidebar.component.spec.ts +++ b/projects/ui/src/lib/components/layout/sidebar/sidebar.component.spec.ts @@ -3,29 +3,27 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { SidebarComponent } from "./sidebar.component"; -import { of } from "rxjs"; -import { AuthService } from "@auth/services"; import { HttpClientTestingModule } from "@angular/common/http/testing"; import { RouterTestingModule } from "@angular/router/testing"; +import { API_URL } from "@corelib"; describe("SidebarComponent", () => { let component: SidebarComponent; let fixture: ComponentFixture; beforeEach(async () => { - const authSpy = { - profile: of({}), - }; - + // SUT инжектит сервисы providedIn: "root" (часть из них через ApiService требует + // токен API_URL) и Router. AuthService удалён. logoSrc — required @Input. await TestBed.configureTestingModule({ imports: [HttpClientTestingModule, RouterTestingModule, SidebarComponent], - providers: [{ provide: AuthService, useValue: authSpy }], + providers: [{ provide: API_URL, useValue: "" }], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(SidebarComponent); component = fixture.componentInstance; + fixture.componentRef.setInput("logoSrc", ""); fixture.detectChanges(); }); diff --git a/projects/ui/src/lib/components/layout/sidebar/sidebar.component.ts b/projects/ui/src/lib/components/layout/sidebar/sidebar.component.ts index 726238b71..a36cfabfa 100644 --- a/projects/ui/src/lib/components/layout/sidebar/sidebar.component.ts +++ b/projects/ui/src/lib/components/layout/sidebar/sidebar.component.ts @@ -2,15 +2,15 @@ import { AfterViewInit, + ChangeDetectionStrategy, Component, ElementRef, - Input, - QueryList, - ViewChildren, + input, type OnInit, + viewChildren, } from "@angular/core"; import { RouterLink, RouterLinkActive } from "@angular/router"; -import { IconComponent } from "@uilib"; +import { IconComponent } from "../../primitives/icon/icon.component"; import { CommonModule } from "@angular/common"; import { ClickOutsideModule } from "ng-click-outside"; @@ -48,15 +48,15 @@ export interface NavItem { selector: "ui-sidebar", templateUrl: "./sidebar.component.html", styleUrl: "./sidebar.component.scss", - standalone: true, imports: [RouterLink, RouterLinkActive, IconComponent, ClickOutsideModule, CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class SidebarComponent implements OnInit, AfterViewInit { /** Массив элементов навигации */ - @Input() navItems: NavItem[] = []; + readonly navItems = input([]); /** Путь к изображению логотипа (обязательный параметр) */ - @Input({ required: true }) logoSrc!: string; + readonly logoSrc = input.required(); ngOnInit(): void { this.checkExternalActiveState(); @@ -68,7 +68,7 @@ export class SidebarComponent implements OnInit, AfterViewInit { }); } - @ViewChildren("navItem") navItemElements!: QueryList>; + readonly navItemElements = viewChildren>("navItem"); /** Позиция анимированной полосы (индекс элемента навигации) */ barPosition = 0; @@ -85,15 +85,15 @@ export class SidebarComponent implements OnInit, AfterViewInit { } initializeBarPosition(): void { - if (!this.navItemElements) return; + if (!this.navItemElements()) return; - const navElements = this.navItemElements.toArray(); + const navElements = this.navItemElements(); const activeRouterElement = navElements.find(elementRef => - elementRef.nativeElement.classList.contains("sidebar-nav__item--active") + elementRef.nativeElement.classList.contains("sidebar-nav__item--active"), ); - const activeExternalIndex = this.navItems.findIndex(item => item.isExternal && item.isActive); + const activeExternalIndex = this.navItems().findIndex(item => item.isExternal && item.isActive); if (activeRouterElement) { this.barPosition = activeRouterElement.nativeElement.offsetTop; @@ -107,7 +107,7 @@ export class SidebarComponent implements OnInit, AfterViewInit { checkExternalActiveState(): void { const currentUrl = window.location.href; - this.navItems.forEach(item => { + this.navItems().forEach(item => { if (item.isExternal) { if (item.link === "skills" && currentUrl.includes("skills.procollab.ru")) { item.isActive = true; @@ -126,7 +126,7 @@ export class SidebarComponent implements OnInit, AfterViewInit { */ handleItemClick(item: NavItem): void { if (item.isExternal) { - this.navItems.forEach(navItem => { + this.navItems().forEach(navItem => { if (navItem.isExternal) { navItem.isActive = false; } diff --git a/projects/ui/src/lib/components/primitives/avatar/avatar.component.html b/projects/ui/src/lib/components/primitives/avatar/avatar.component.html index e5c531c54..b27b40088 100644 --- a/projects/ui/src/lib/components/primitives/avatar/avatar.component.html +++ b/projects/ui/src/lib/components/primitives/avatar/avatar.component.html @@ -2,24 +2,24 @@
    avatar - @if (isOnline) { -
    + @if (isOnline()) { +
    }
    diff --git a/projects/ui/src/lib/components/primitives/avatar/avatar.component.spec.ts b/projects/ui/src/lib/components/primitives/avatar/avatar.component.spec.ts index fe26e0c3a..c42245e0e 100644 --- a/projects/ui/src/lib/components/primitives/avatar/avatar.component.spec.ts +++ b/projects/ui/src/lib/components/primitives/avatar/avatar.component.spec.ts @@ -13,48 +13,62 @@ describe("AvatarComponent", () => { }).compileComponents(); }); - beforeEach(() => { + it("should create", () => { fixture = TestBed.createComponent(AvatarComponent); component = fixture.componentInstance; + fixture.componentRef.setInput("url", undefined); fixture.detectChanges(); - }); - - it("should create", () => { expect(component).toBeTruthy(); }); it("should display placeholder image if no URL is provided", () => { + fixture = TestBed.createComponent(AvatarComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput("url", undefined); + fixture.detectChanges(); const img = fixture.nativeElement.querySelector("img"); expect(img.src).toContain(component.placeholderUrl); }); it("should display provided image if URL is provided", () => { - component.url = "https://example.com/avatar.png"; + fixture = TestBed.createComponent(AvatarComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput("url", "https://example.com/avatar.png"); fixture.detectChanges(); const img = fixture.nativeElement.querySelector("img"); - expect(img.src).toContain(component.url); + expect(img.src).toContain("https://example.com/avatar.png"); }); it("should have correct size", () => { + fixture = TestBed.createComponent(AvatarComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput("url", undefined); + fixture.detectChanges(); const div = fixture.nativeElement.querySelector(".avatar"); - expect(div.style.width).toBe(component.size + "px"); - expect(div.style.height).toBe(component.size + "px"); + expect(div.style.width).toBe(component.size() + "px"); + expect(div.style.height).toBe(component.size() + "px"); const img = fixture.nativeElement.querySelector("img"); - expect(img.style.width).toBe(component.size + "px"); - expect(img.style.height).toBe(component.size + "px"); + expect(img.style.width).toBe(component.size() + "px"); + expect(img.style.height).toBe(component.size() + "px"); }); it("should have border if hasBorder is true", () => { - component.hasBorder = true; + fixture = TestBed.createComponent(AvatarComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput("url", undefined); + fixture.componentRef.setInput("hasBorder", true); fixture.detectChanges(); const div = fixture.nativeElement.querySelector(".avatar"); - expect(div.classList.contains("avatar--border")).toBeTrue(); + expect(div.classList.contains("avatar--border")).toBe(true); }); it("should not have border if hasBorder is false", () => { - component.hasBorder = false; + fixture = TestBed.createComponent(AvatarComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput("url", undefined); + fixture.componentRef.setInput("hasBorder", false); fixture.detectChanges(); const div = fixture.nativeElement.querySelector(".avatar"); - expect(div.classList.contains("avatar--border")).toBeFalse(); + expect(div.classList.contains("avatar--border")).toBe(false); }); }); diff --git a/projects/ui/src/lib/components/primitives/avatar/avatar.component.ts b/projects/ui/src/lib/components/primitives/avatar/avatar.component.ts index df4aa4174..5504343ed 100644 --- a/projects/ui/src/lib/components/primitives/avatar/avatar.component.ts +++ b/projects/ui/src/lib/components/primitives/avatar/avatar.component.ts @@ -1,6 +1,6 @@ /** @format */ -import { Component, Input, type OnInit } from "@angular/core"; +import { ChangeDetectionStrategy, Component, input, Input, type OnInit } from "@angular/core"; /** * Компонент аватара пользователя @@ -30,28 +30,29 @@ import { Component, Input, type OnInit } from "@angular/core"; templateUrl: "./avatar.component.html", styleUrl: "./avatar.component.scss", standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, }) export class AvatarComponent implements OnInit { /** URL изображения аватара (опциональный, при отсутствии используется placeholder) */ - @Input({ required: true }) url?: string; + readonly url = input.required(); /** Размер аватара в пикселях (по умолчанию 50px) */ - @Input() size = 50; + readonly size = input(50); /** Флаг отображения рамки вокруг аватара */ - @Input() hasBorder = false; + readonly hasBorder = input(false); /** Флаг отображения индикатора онлайн статуса */ - @Input() isOnline = false; + readonly isOnline = input(false); /** Размер индикатора онлайн статуса в пикселях */ - @Input() onlineBadgeSize = 16; + readonly onlineBadgeSize = input(16); /** Толщина рамки индикатора онлайн статуса в пикселях */ - @Input() onlineBadgeBorder = 3; + readonly onlineBadgeBorder = input(3); /** Смещение индикатора онлайн статуса от края аватара */ - @Input() onlineBadgeOffset = 0; + readonly onlineBadgeOffset = input(0); /** URL placeholder изображения, используемого при отсутствии аватара */ placeholderUrl = diff --git a/projects/ui/src/lib/components/primitives/back/back.component.html b/projects/ui/src/lib/components/primitives/back/back.component.html index ffdfc17f1..2c266c470 100644 --- a/projects/ui/src/lib/components/primitives/back/back.component.html +++ b/projects/ui/src/lib/components/primitives/back/back.component.html @@ -2,5 +2,5 @@
    - {{ namespace }} + {{ namespace() }}
    diff --git a/projects/ui/src/lib/components/primitives/back/back.component.ts b/projects/ui/src/lib/components/primitives/back/back.component.ts index 6a284b98d..557c3b23d 100644 --- a/projects/ui/src/lib/components/primitives/back/back.component.ts +++ b/projects/ui/src/lib/components/primitives/back/back.component.ts @@ -1,9 +1,17 @@ /** @format */ -import { Component, inject, Input, type OnInit } from "@angular/core"; +import { + ChangeDetectionStrategy, + Component, + inject, + input, + Input, + type OnInit, +} from "@angular/core"; import { Router } from "@angular/router"; import { Location } from "@angular/common"; -import { IconComponent } from "@ui/components"; +import { IconComponent } from "../icon/icon.component"; +import { LoggerService } from "@corelib"; /** * Компонент кнопки "Назад" @@ -25,17 +33,17 @@ import { IconComponent } from "@ui/components"; selector: "app-back", templateUrl: "./back.component.html", styleUrl: "./back.component.scss", - standalone: true, imports: [IconComponent], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class BackComponent implements OnInit { private readonly router = inject(Router); private readonly location = inject(Location); + private readonly loggerService = inject(LoggerService); /** Путь для перехода (если не указан, используется history.back()) */ - @Input() path?: string; - - @Input() namespace?: string; + readonly path = input(); + readonly namespace = input(); ngOnInit(): void {} @@ -46,10 +54,10 @@ export class BackComponent implements OnInit { * иначе возвращается к предыдущей странице в истории браузера */ onClick(): void { - if (this.path) { + if (this.path()) { this.router - .navigateByUrl(this.path) - .then(() => console.debug("Route changed from BackComponent")); + .navigateByUrl(this.path()!) + .then(() => this.loggerService.debug("Route changed from BackComponent")); } else { this.location.back(); } diff --git a/projects/ui/src/lib/components/primitives/button/button.component.html b/projects/ui/src/lib/components/primitives/button/button.component.html new file mode 100644 index 000000000..ff459c0ff --- /dev/null +++ b/projects/ui/src/lib/components/primitives/button/button.component.html @@ -0,0 +1,33 @@ + + diff --git a/projects/ui/src/lib/components/primitives/button/button.component.scss b/projects/ui/src/lib/components/primitives/button/button.component.scss new file mode 100644 index 000000000..6c27a701c --- /dev/null +++ b/projects/ui/src/lib/components/primitives/button/button.component.scss @@ -0,0 +1,163 @@ +/** @format */ + +@use "styles/typography"; + +.button { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + max-height: 40px; + text-align: center; + cursor: pointer; + border-radius: var(--rounded-xxl); + transition: all 0.2s; + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } + + &.button--inline { + font-weight: 400; + color: var(--white); + background: var(--accent); + border: 0.5px solid transparent; + outline: none; + + &:hover { + background-color: var(--accent-medium); + box-shadow: -2px 3px 3px rgb(51 51 51 / 20%); + } + + ::ng-deep *:not(.dot-wave) { + display: block; + + &:not(:last-child) { + margin-right: 10px; + } + } + + &.button--red { + background-color: var(--red); + + &:hover { + background-color: var(--red-dark); + } + } + + &.button--gradient { + background: var(--gradient); + + &:hover { + background: var(--gradient-mild); + } + } + + &.button--grey { + color: var(--black); + background-color: var(--grey-button); + } + + &.button--green { + color: var(--white); + background-color: var(--green); + } + + &.button--gold { + color: var(--white); + background: var(--gold-dark); + } + + &.button--white { + color: var(--accent); + background: var(--white); + } + + &.button--no-border { + border: none; + } + } + + &.button--outline { + color: var(--accent); + background: transparent; + border: 0.5px solid var(--accent); + + &:hover { + color: var(--accent-medium); + border-color: var(--accent-medium); + box-shadow: -2px 3px 3px rgb(51 51 51 / 20%); + } + + &.button--red { + color: var(--red); + border-color: var(--red); + + &:hover { + color: var(--red-dark); + border-color: var(--red-dark); + } + } + + &.button--gold { + color: var(--gold); + border-color: var(--gold); + } + + &.button--white { + color: var(--white); + border: 0.5px solid var(--white); + } + + &.button--green { + color: var(--green); + border: 0.5px solid var(--green); + } + + &.button--no-border { + border: none; + } + + ::ng-deep *:not(.dot-wave) { + display: block; + + &:not(:last-child) { + margin-right: 10px; + } + } + } + + &--extra-small { + width: 70px; + padding: 2px 10px; + } + + &--small { + width: 100px; + padding: 4px 24px; + + &--icon { + padding: 12px 24px; + } + } + + &--medium { + width: 157px; + padding: 4px 0; + + &--icon { + padding: 12px 60px; + } + } + + &--big { + width: 100%; + padding: 4px 24px; + + &--icon { + width: 100%; + padding: 12px 24px; + } + } +} diff --git a/projects/ui/src/lib/components/primitives/button/button.component.ts b/projects/ui/src/lib/components/primitives/button/button.component.ts new file mode 100644 index 000000000..190f91ea1 --- /dev/null +++ b/projects/ui/src/lib/components/primitives/button/button.component.ts @@ -0,0 +1,24 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, input } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { LoaderComponent } from "../loader/loader.component"; + +@Component({ + selector: "app-button", + templateUrl: "./button.component.html", + styleUrl: "./button.component.scss", + imports: [CommonModule, LoaderComponent], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ButtonComponent { + color = input<"primary" | "red" | "grey" | "green" | "gold" | "gradient" | "white">("primary"); + loader = input(false); + size = input<"extra-small" | "small" | "medium" | "big">("small"); + hasBorder = input(true); + type = input<"submit" | "reset" | "button" | "icon">("button"); + appearance = input<"inline" | "outline">("inline"); + backgroundColor = input(); + disabled = input(false); + customTypographyClass = input(); +} diff --git a/projects/ui/src/lib/components/primitives/icon/icon.component.html b/projects/ui/src/lib/components/primitives/icon/icon.component.html index a9be57890..d2c1bc46f 100644 --- a/projects/ui/src/lib/components/primitives/icon/icon.component.html +++ b/projects/ui/src/lib/components/primitives/icon/icon.component.html @@ -6,5 +6,5 @@ [attr.height]="square || height" [attr.viewBox]="viewBox" > - + diff --git a/projects/ui/src/lib/components/primitives/icon/icon.component.spec.ts b/projects/ui/src/lib/components/primitives/icon/icon.component.spec.ts index b786851f6..05092d350 100644 --- a/projects/ui/src/lib/components/primitives/icon/icon.component.spec.ts +++ b/projects/ui/src/lib/components/primitives/icon/icon.component.spec.ts @@ -2,7 +2,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; -import { IconComponent } from "@ui/components"; +import { IconComponent } from "./icon.component"; describe("IconComponent", () => { let component: IconComponent; @@ -17,6 +17,7 @@ describe("IconComponent", () => { beforeEach(() => { fixture = TestBed.createComponent(IconComponent); component = fixture.componentInstance; + fixture.componentRef.setInput("icon", "check"); // icon — required input fixture.detectChanges(); }); @@ -25,17 +26,17 @@ describe("IconComponent", () => { }); it("should render the correct icon", () => { - component.icon = "check"; + fixture.componentRef.setInput("icon", "check"); fixture.detectChanges(); const useElement = fixture.debugElement.query(By.css("use")).nativeElement; expect(useElement.getAttribute("xlink:href")).toBe( - "assets/icons/symbol/svg/sprite.css.svg#check" + "assets/icons/symbol/svg/sprite.css.svg#check", ); }); it("should set the width and height attributes if square is not set", () => { - component.appWidth = "24"; - component.appHeight = "24"; + fixture.componentRef.setInput("appWidth", "24"); + fixture.componentRef.setInput("appHeight", "24"); fixture.detectChanges(); const svgElement = fixture.debugElement.query(By.css("svg")).nativeElement; expect(svgElement.getAttribute("width")).toBe("24"); @@ -43,16 +44,16 @@ describe("IconComponent", () => { }); it("should set the viewBox attribute if square is set", () => { - component.appSquare = "24"; + fixture.componentRef.setInput("appSquare", "24"); fixture.detectChanges(); const svgElement = fixture.debugElement.query(By.css("svg")).nativeElement; expect(svgElement.getAttribute("viewBox")).toBe("0 0 24 24"); }); it("should update the viewBox attribute when square, width or height is set", () => { - component.appSquare = "24"; - component.appWidth = "32"; - component.appHeight = "32"; + fixture.componentRef.setInput("appSquare", "24"); + fixture.componentRef.setInput("appWidth", "32"); + fixture.componentRef.setInput("appHeight", "32"); fixture.detectChanges(); const svgElement = fixture.debugElement.query(By.css("svg")).nativeElement; expect(svgElement.getAttribute("viewBox")).toBe("0 0 24 24"); diff --git a/projects/ui/src/lib/components/primitives/icon/icon.component.ts b/projects/ui/src/lib/components/primitives/icon/icon.component.ts index 15ae2c07e..44867fc38 100644 --- a/projects/ui/src/lib/components/primitives/icon/icon.component.ts +++ b/projects/ui/src/lib/components/primitives/icon/icon.component.ts @@ -1,6 +1,6 @@ /** @format */ -import { Component, Input, type OnInit } from "@angular/core"; +import { ChangeDetectionStrategy, Component, input, Input, type OnInit } from "@angular/core"; /** * Компонент для отображения SVG иконок из спрайта @@ -29,6 +29,7 @@ import { Component, Input, type OnInit } from "@angular/core"; templateUrl: "./icon.component.html", styleUrl: "./icon.component.scss", standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, }) export class IconComponent implements OnInit { /** @@ -97,7 +98,7 @@ export class IconComponent implements OnInit { } /** Название иконки из спрайта (обязательный параметр) */ - @Input({ required: true }) icon!: string; + readonly icon = input.required(); /** Внутреннее хранение размера квадратной иконки */ square?: string; diff --git a/projects/ui/src/lib/components/primitives/index.ts b/projects/ui/src/lib/components/primitives/index.ts index 6c767ebbb..077d123bf 100644 --- a/projects/ui/src/lib/components/primitives/index.ts +++ b/projects/ui/src/lib/components/primitives/index.ts @@ -14,4 +14,6 @@ export * from "./avatar/avatar.component"; export * from "./back/back.component"; +export * from "./button/button.component"; export * from "./icon/icon.component"; +export * from "./loader/loader.component"; diff --git a/projects/ui/src/lib/components/primitives/loader/loader.component.html b/projects/ui/src/lib/components/primitives/loader/loader.component.html new file mode 100644 index 000000000..efd04ffbc --- /dev/null +++ b/projects/ui/src/lib/components/primitives/loader/loader.component.html @@ -0,0 +1,35 @@ + +
    + @if (type() === "wave") { +
    +
    +
    +
    +
    +
    + } @else if (type() === "circle") { +
    +
    +
    +
    +
    +
    + } +
    diff --git a/projects/ui/src/lib/components/primitives/loader/loader.component.scss b/projects/ui/src/lib/components/primitives/loader/loader.component.scss new file mode 100644 index 000000000..291ab39ff --- /dev/null +++ b/projects/ui/src/lib/components/primitives/loader/loader.component.scss @@ -0,0 +1,91 @@ +.dot-wave { + --size: 47px; + --speed: 1s; + --color: black; + + display: flex; + flex-flow: row nowrap; + align-items: center; + justify-content: space-between; + width: var(--size); + height: calc(var(--size) * 0.17); + padding-top: calc(var(--size) * 0.34); +} + +.dot-wave__dot { + flex-shrink: 0; + width: calc(var(--size) * 0.17); + height: calc(var(--size) * 0.17); + background-color: var(--color); + border-radius: 50%; + will-change: transform; +} + +.dot-wave__dot:nth-child(1) { + animation: jump var(--speed) ease-in-out calc(var(--speed) * -0.45) infinite; +} + +.dot-wave__dot:nth-child(2) { + animation: jump var(--speed) ease-in-out calc(var(--speed) * -0.3) infinite; +} + +.dot-wave__dot:nth-child(3) { + animation: jump var(--speed) ease-in-out calc(var(--speed) * -0.15) infinite; +} + +.dot-wave__dot:nth-child(4) { + animation: jump var(--speed) ease-in-out infinite; +} + +@keyframes jump { + 0%, + 100% { + transform: translateY(0); + } + + 50% { + transform: translateY(-200%); + } +} + +.lds-ring { + position: relative; + display: inline-block; + width: var(--size); + height: var(--size); +} + +.lds-ring div { + position: absolute; + box-sizing: border-box; + display: block; + width: calc(var(--size) * 0.8); + height: calc(var(--size) * 0.8); + margin: 4px; + border: 4px solid var(--color); + border-color: var(--color) transparent transparent; + border-radius: 50%; + animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; +} + +.lds-ring div:nth-child(1) { + animation-delay: -0.45s; +} + +.lds-ring div:nth-child(2) { + animation-delay: -0.3s; +} + +.lds-ring div:nth-child(3) { + animation-delay: -0.15s; +} + +@keyframes lds-ring { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} diff --git a/projects/ui/src/lib/components/primitives/loader/loader.component.ts b/projects/ui/src/lib/components/primitives/loader/loader.component.ts new file mode 100644 index 000000000..fb7181de7 --- /dev/null +++ b/projects/ui/src/lib/components/primitives/loader/loader.component.ts @@ -0,0 +1,19 @@ +/** @format */ + +import { ChangeDetectionStrategy, Component, input, Input, OnInit } from "@angular/core"; + +@Component({ + selector: "app-loader", + templateUrl: "./loader.component.html", + styleUrl: "./loader.component.scss", + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class LoaderComponent implements OnInit { + readonly speed = input("1s"); + readonly size = input("15px"); + readonly color = input("white"); + readonly type = input<"wave" | "circle">("circle"); + + ngOnInit(): void {} +} diff --git a/projects/ui/src/lib/models/user.model.ts b/projects/ui/src/lib/models/user.model.ts deleted file mode 100644 index 2a2dc931c..000000000 --- a/projects/ui/src/lib/models/user.model.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** @format */ - -/** - * Модель пользователя системы - * Содержит всю необходимую информацию о пользователе для отображения в UI компонентах - */ -export interface User { - /** Уникальный идентификатор пользователя */ - id: number; - - /** Email адрес пользователя */ - email: string; - - /** Имя пользователя */ - firstName: string; - - /** Фамилия пользователя */ - lastName: string; - - /** URL изображения аватара пользователя */ - avatar: string; - - /** Статус онлайн (true - пользователь в сети, false - оффлайн) */ - isOnline: boolean; - - /** Статус активности аккаунта (true - активен, false - заблокирован/неактивен) */ - isActive: boolean; - - /** Текущий этап процесса онбординга (null если онбординг завершен) */ - onboardingStage: number | null; - - /** Специальность/профессия пользователя */ - speciality: string; - - /** Тип пользователя (числовой код, определяющий роль в системе) */ - userType: number; - - /** Дата и время создания аккаунта в ISO формате */ - timeCreated: string; - - /** Дата и время последнего обновления профиля в ISO формате */ - timeUpdated: string; - - /** Дата верификации аккаунта в ISO формате */ - verificationDate: string; -} diff --git a/projects/ui/tsconfig.spec.json b/projects/ui/tsconfig.spec.json index 4b02ff17e..03df2faf4 100644 --- a/projects/ui/tsconfig.spec.json +++ b/projects/ui/tsconfig.spec.json @@ -3,7 +3,7 @@ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "../../out-tsc/spec", - "types": ["jasmine"] + "types": ["vitest/globals", "node"] }, "include": ["**/*.spec.ts", "**/*.d.ts"] } diff --git a/test-setup.ts b/test-setup.ts new file mode 100644 index 000000000..39ecd42ef --- /dev/null +++ b/test-setup.ts @@ -0,0 +1,31 @@ +/** @format */ + +import { setupTestBed } from "@analogjs/vitest-angular/setup-testbed"; + +if (typeof IntersectionObserver === "undefined") { + (globalThis as any).IntersectionObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; +} + +if (!Element.prototype.scrollTo) { + (Element.prototype as any).scrollTo = function () {}; +} + +if (!URL.createObjectURL) { + let counter = 0; + const objectURLs = new Map(); + (URL as any).createObjectURL = function (blob: Blob): string { + const id = `blob:${++counter}`; + const url = `blob:${id}`; + objectURLs.set(url, blob as any); + return url; + }; + (URL as any).revokeObjectURL = function (url: string): void { + objectURLs.delete(url); + }; +} + +setupTestBed(); diff --git a/tsconfig.json b/tsconfig.json index 4369b89d0..137b590cc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,36 +6,36 @@ "outDir": "./dist/out-tsc", "forceConsistentCasingInFileNames": true, "strict": true, - "allowSyntheticDefaultImports": true, + "esModuleInterop": true, "noImplicitOverride": true, "noPropertyAccessFromIndexSignature": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "sourceMap": true, "declaration": false, - "downlevelIteration": true, "experimentalDecorators": true, - "moduleResolution": "node", + "moduleResolution": "bundler", "importHelpers": true, "target": "ES2022", "module": "es2020", "lib": ["es2020", "dom"], "paths": { - "@auth/*": ["projects/social_platform/src/app/auth/*"], + "@core/*": ["projects/core/src/*"], + "@domain/*": ["projects/social_platform/src/app/domain/*"], + "@infrastructure/*": ["projects/social_platform/src/app/infrastructure/*"], + "@api/*": ["projects/social_platform/src/app/api/*"], "@ui/*": ["projects/social_platform/src/app/ui/*"], - "@core/*": ["projects/social_platform/src/app/core/*"], - "@office/*": ["projects/social_platform/src/app/office/*"], + "@pages/*": ["projects/social_platform/src/app/ui/pages/*"], "@utils/*": ["projects/social_platform/src/app/utils/*"], + "@testing/*": ["projects/social_platform/src/app/testing/*"], "@environment": ["projects/social_platform/src/environments/environment.ts"], - "@error/*": ["projects/social_platform/src/app/error/*"], - "@services/*": ["projects/social_platform/src/app/office/services/*"], - "@models/*": ["projects/social_platform/src/app/office/models/*"], "@uilib": ["./projects/ui/src/public-api.ts"], "uilib/models": ["./projects/ui/src/models/*"], "@corelib": ["./projects/core/src/public-api.ts"], "core": ["./dist/core"], "ui": ["./dist/ui"] }, + "skipLibCheck": true, "useDefineForClassFields": false }, "angularCompilerOptions": { diff --git a/tsconfig.vitest.json b/tsconfig.vitest.json new file mode 100644 index 000000000..38f4e4814 --- /dev/null +++ b/tsconfig.vitest.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/vitest", + "types": ["node", "vitest/globals"] + }, + "include": ["projects/**/*.spec.ts", "projects/**/*.d.ts", "test-setup.ts"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 000000000..618c93b62 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,38 @@ +/** @format */ + +/// +import { fileURLToPath } from "node:url"; +import angular from "@analogjs/vite-plugin-angular"; +import { defineConfig } from "vitest/config"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + plugins: [tsconfigPaths(), angular({ tsconfig: "./tsconfig.vitest.json" })], + resolve: { + alias: [ + // ng-click-outside.main = битый CJS (import-синтаксис в .js). Резолвим на ESM-сборку. + { + find: /^ng-click-outside$/, + replacement: fileURLToPath( + new URL("node_modules/ng-click-outside/lib_esmodule/index.js", import.meta.url), + ), + }, + ], + }, + test: { + globals: true, + environment: "jsdom", + testTimeout: 10000, + setupFiles: ["./test-setup.ts"], + include: [ + "projects/core/**/*.spec.ts", + "projects/ui/**/*.spec.ts", + "projects/social_platform/**/*.spec.ts", + ], + exclude: [ + "projects/social_platform/src/app/ui/pages/projects/projects.component.spec.ts", + "projects/social_platform/src/app/ui/pages/program/program.component.spec.ts", + "projects/social_platform/src/app/ui/pages/profile/edit/editor-submit-button.directive.spec.ts", + ], + }, +});