From cab0a41e60be44d9aabd4eb5ebc7934280ce266e Mon Sep 17 00:00:00 2001 From: "maxim.gorbatyuk" Date: Fri, 26 Dec 2025 21:23:24 +0500 Subject: [PATCH 1/4] Added component --- .../admin-navbar/admin-navbar.component.ts | 5 + src/app/modules/admin/admin-routing.module.ts | 5 + src/app/modules/admin/admin.module.ts | 6 + .../historical-data-template-edit-form.ts | 43 ++++ .../historical-data-templates.component.html | 137 ++++++++++++ .../historical-data-templates.component.scss | 25 +++ .../historical-data-templates.component.ts | 166 ++++++++++++++ .../historical-charts-page.component.html | 80 ++----- .../historical-charts-page.component.ts | 131 +---------- ...storical-salaries-by-grade-chart-object.ts | 207 ------------------ ...cal-salaries-by-grade-chart.component.html | 25 --- ...cal-salaries-by-grade-chart.component.scss | 10 - ...-salaries-by-grade-chart.component.spec.ts | 32 --- ...rical-salaries-by-grade-chart.component.ts | 56 ----- .../historical-charts-page.component.ts | 31 ++- .../historical-salaries-chart-object.ts | 72 +++--- .../historical-salaries-chart.component.html | 8 +- .../historical-salaries-chart.component.scss | 10 +- src/app/modules/salaries/salaries.module.ts | 2 - src/app/services/historical-charts.service.ts | 72 +++--- ...aries-historical-data-templates.service.ts | 65 ++++++ 21 files changed, 587 insertions(+), 601 deletions(-) create mode 100644 src/app/modules/admin/components/salaries/historical-data-templates/historical-data-template-edit-form.ts create mode 100644 src/app/modules/admin/components/salaries/historical-data-templates/historical-data-templates.component.html create mode 100644 src/app/modules/admin/components/salaries/historical-data-templates/historical-data-templates.component.scss create mode 100644 src/app/modules/admin/components/salaries/historical-data-templates/historical-data-templates.component.ts delete mode 100644 src/app/modules/salaries/components/historical-charts-page/historical-salaries-by-grade-chart/historical-salaries-by-grade-chart-object.ts delete mode 100644 src/app/modules/salaries/components/historical-charts-page/historical-salaries-by-grade-chart/historical-salaries-by-grade-chart.component.html delete mode 100644 src/app/modules/salaries/components/historical-charts-page/historical-salaries-by-grade-chart/historical-salaries-by-grade-chart.component.scss delete mode 100644 src/app/modules/salaries/components/historical-charts-page/historical-salaries-by-grade-chart/historical-salaries-by-grade-chart.component.spec.ts delete mode 100644 src/app/modules/salaries/components/historical-charts-page/historical-salaries-by-grade-chart/historical-salaries-by-grade-chart.component.ts create mode 100644 src/app/services/salaries-historical-data-templates.service.ts diff --git a/src/app/components/admin-navbar/admin-navbar.component.ts b/src/app/components/admin-navbar/admin-navbar.component.ts index 1187c3bd..7f637a48 100644 --- a/src/app/components/admin-navbar/admin-navbar.component.ts +++ b/src/app/components/admin-navbar/admin-navbar.component.ts @@ -71,6 +71,11 @@ export class AdminNavbarComponent { url: "/admin/salaries/imported-salaries", isExternal: false, }, + { + title: "График исторических данных", + url: "/admin/salaries/historical-data-templates", + isExternal: false, + }, ], }, { diff --git a/src/app/modules/admin/admin-routing.module.ts b/src/app/modules/admin/admin-routing.module.ts index d1e81cb4..475b44be 100644 --- a/src/app/modules/admin/admin-routing.module.ts +++ b/src/app/modules/admin/admin-routing.module.ts @@ -23,6 +23,7 @@ import { GitHubJobsPageComponent } from "./components/github/github-jobs-page/gi import { OpenAiPromptsAdminPageComponent } from "./components/openai-prompts/openai-prompts-admin-page.component"; import { CompanyReviewsStatSubscriptionsComponent } from "./components/telegram/reviews-stat-subscriptions/reviews-stat-subscriptions.component"; import { JobPostingMessageSubscriptionsComponent } from "./components/telegram/job-posting-message-subscriptions/job-posting-message-subscriptions.component"; +import { HistoricalDataTemplatesComponent } from "./components/salaries/historical-data-templates/historical-data-templates.component"; const routes: Routes = [ { path: "", component: AdminStartPageComponent }, @@ -73,6 +74,10 @@ const routes: Routes = [ { path: "github/processing-jobs", component: GitHubJobsPageComponent }, { path: "openai-prompts", component: OpenAiPromptsAdminPageComponent }, + { + path: "salaries/historical-data-templates", + component: HistoricalDataTemplatesComponent, + }, ]; @NgModule({ diff --git a/src/app/modules/admin/admin.module.ts b/src/app/modules/admin/admin.module.ts index 783cbb4b..623b773f 100644 --- a/src/app/modules/admin/admin.module.ts +++ b/src/app/modules/admin/admin.module.ts @@ -4,6 +4,7 @@ import { CommonModule } from "@angular/common"; import { AdminRoutingModule } from "./admin-routing.module"; import { SharedModule } from "@shared/shared.module"; import { FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { NgSelectModule } from "@ng-select/ng-select"; import { BackgroundJobsComponent } from "./components/background-jobs/background-jobs.component"; import { HealthCheckTableComponent } from "./components/health-check-table/health-check-table.component"; import { JobsTableComponent } from "./components/jobs-table/jobs-table.component"; @@ -36,6 +37,8 @@ import { CompanyReviewsStatSubscriptionsComponent } from "./components/telegram/ import { JobPostingMessageSubscriptionsComponent } from "./components/telegram/job-posting-message-subscriptions/job-posting-message-subscriptions.component"; import { JobPostingMessageSubscriptionsService } from "@services/job-posting-message-subscriptions.service"; import { TelegramSubscriptionsService } from "@services/telegram-subscriptions.service"; +import { HistoricalDataTemplatesComponent } from "./components/salaries/historical-data-templates/historical-data-templates.component"; +import { SalariesHistoricalDataTemplatesService } from "@services/salaries-historical-data-templates.service"; const adminServices = [ AdminDashboardService, @@ -44,6 +47,7 @@ const adminServices = [ CompanyReviewsTelegramSubscriptionsService, JobPostingMessageSubscriptionsService, TelegramSubscriptionsService, + SalariesHistoricalDataTemplatesService, ]; @NgModule({ @@ -74,6 +78,7 @@ const adminServices = [ OpenAiPromptsAdminPageComponent, CompanyReviewsStatSubscriptionsComponent, JobPostingMessageSubscriptionsComponent, + HistoricalDataTemplatesComponent, ], imports: [ CommonModule, @@ -81,6 +86,7 @@ const adminServices = [ AdminRoutingModule, FormsModule, ReactiveFormsModule, + NgSelectModule, ], providers: adminServices, exports: [], diff --git a/src/app/modules/admin/components/salaries/historical-data-templates/historical-data-template-edit-form.ts b/src/app/modules/admin/components/salaries/historical-data-templates/historical-data-template-edit-form.ts new file mode 100644 index 00000000..dfad7656 --- /dev/null +++ b/src/app/modules/admin/components/salaries/historical-data-templates/historical-data-template-edit-form.ts @@ -0,0 +1,43 @@ +import { FormControl, FormGroup, Validators } from "@angular/forms"; +import { + CreateSalariesHistoricalDataRecordTemplateRequest, + SalariesHistoricalDataRecordTemplateDto, +} from "@services/salaries-historical-data-templates.service"; + +export class HistoricalDataTemplateEditForm extends FormGroup { + constructor( + private readonly item: SalariesHistoricalDataRecordTemplateDto | null, + ) { + super({ + name: new FormControl(item?.name ?? null, [ + Validators.required, + Validators.maxLength(100), + ]), + professionIds: new FormControl(item?.professionIds ?? [], [ + Validators.required, + ]), + }); + } + + hasItemToEdit(): boolean { + return this.item != null; + } + + getItemId(): string | null { + return this.item?.id ?? null; + } + + createRequestOrNull(): CreateSalariesHistoricalDataRecordTemplateRequest | null { + if (!this.valid) { + this.markAllAsTouched(); + return null; + } + + const professionIds = this.value.professionIds as Array; + + return { + name: this.value.name, + professionIds: professionIds ?? [], + }; + } +} diff --git a/src/app/modules/admin/components/salaries/historical-data-templates/historical-data-templates.component.html b/src/app/modules/admin/components/salaries/historical-data-templates/historical-data-templates.component.html new file mode 100644 index 00000000..d55ff26e --- /dev/null +++ b/src/app/modules/admin/components/salaries/historical-data-templates/historical-data-templates.component.html @@ -0,0 +1,137 @@ +Шаблоны исторических данных + +
+
+
+
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
IDНазваниеСпециальностиСозданоОбновленоДействия
+ {{ item.id }} + {{ item.name }}{{ getProfessionNames(item.professionIds) }}{{ item.createdAt | date: "yyyy-MM-dd HH:mm" }}{{ item.updatedAt | date: "yyyy-MM-dd HH:mm" }} +
+ + +
+
+
+ +
+ +
+
+
+
+ + +
+
+
Нет данных
+
+
+
+ + +
+ +
+
+ + +
+
+ + + +
+ +
+ + + + +
+ +
+ +
+
+
+ + diff --git a/src/app/modules/admin/components/salaries/historical-data-templates/historical-data-templates.component.scss b/src/app/modules/admin/components/salaries/historical-data-templates/historical-data-templates.component.scss new file mode 100644 index 00000000..2896edbd --- /dev/null +++ b/src/app/modules/admin/components/salaries/historical-data-templates/historical-data-templates.component.scss @@ -0,0 +1,25 @@ +// Component styles + +// Ensure ng-select dropdown is visible inside modal +:host ::ng-deep { + .modal-body { + overflow: visible; + } + + .modal-content { + overflow: visible; + } +} + +// Ensure action dropdown is visible outside table container +// Using overflow: clip on x-axis preserves horizontal scroll behavior if needed +// while allowing dropdowns to overflow vertically +.table-responsive { + overflow: visible; + + .dropdown-menu { + position: absolute; + z-index: 1050; + } +} + diff --git a/src/app/modules/admin/components/salaries/historical-data-templates/historical-data-templates.component.ts b/src/app/modules/admin/components/salaries/historical-data-templates/historical-data-templates.component.ts new file mode 100644 index 00000000..b5b616bf --- /dev/null +++ b/src/app/modules/admin/components/salaries/historical-data-templates/historical-data-templates.component.ts @@ -0,0 +1,166 @@ +import { Component, OnDestroy, OnInit } from "@angular/core"; +import { defaultPageParams } from "@models/page-params"; +import { PaginatedList } from "@models/paginated-list"; +import { TitleService } from "@services/title.service"; +import { untilDestroyed } from "@shared/subscriptions/until-destroyed"; +import { + SalariesHistoricalDataRecordTemplateDto, + SalariesHistoricalDataTemplatesService, +} from "@services/salaries-historical-data-templates.service"; +import { AlertService } from "@shared/components/alert/services/alert.service"; +import { ConfirmMsg } from "@shared/components/dialogs/models/confirm-msg"; +import { DialogMessage } from "@shared/components/dialogs/models/dialog-message"; +import { HistoricalDataTemplateEditForm } from "./historical-data-template-edit-form"; +import { ProfessionsService } from "@services/professions.service"; +import { LabelEntityDto } from "@services/label-entity.model"; +import { SelectItem } from "@shared/select-boxes/select-item"; + +@Component({ + templateUrl: "./historical-data-templates.component.html", + styleUrls: ["./historical-data-templates.component.scss"], + standalone: false, +}) +export class HistoricalDataTemplatesComponent implements OnInit, OnDestroy { + items: Array | null = null; + source: PaginatedList | null = null; + currentPage: number = 1; + + editForm: HistoricalDataTemplateEditForm | null = null; + confirmDeletionMessage: DialogMessage | null = null; + + professions: Array = []; + professionsAsOptions: Array> = []; + + constructor( + private readonly service: SalariesHistoricalDataTemplatesService, + private readonly professionsService: ProfessionsService, + titleService: TitleService, + private readonly alert: AlertService, + ) { + titleService.setTitle("Шаблоны исторических данных"); + } + + ngOnInit(): void { + this.items = null; + this.source = null; + + this.loadProfessions(); + this.loadData(this.currentPage); + } + + loadProfessions(): void { + if (this.professions.length > 0) { + return; + } + + this.professionsService + .allForSelectBoxes() + .pipe(untilDestroyed(this)) + .subscribe((x) => { + this.professions = x; + this.professionsAsOptions = x.map((p) => ({ + value: p.id.toString(), + item: p.id, + label: p.title, + })); + }); + } + + loadData(pageToRequest: number): void { + this.items = null; + this.source = null; + this.currentPage = pageToRequest; + + this.service + .search({ + page: this.currentPage, + pageSize: defaultPageParams.pageSize, + }) + .pipe(untilDestroyed(this)) + .subscribe((x) => { + this.items = x.results; + this.source = x; + }); + } + + delete(item: SalariesHistoricalDataRecordTemplateDto): void { + this.confirmDeletionMessage = new DialogMessage( + new ConfirmMsg( + "Удалить шаблон", + `Вы уверены, что хотите удалить шаблон "${item.name}"?`, + () => { + this.service + .delete(item.id) + .pipe(untilDestroyed(this)) + .subscribe(() => { + this.alert.success("Шаблон удален"); + this.loadData(this.currentPage); + }); + }, + ), + ); + } + + create(): void { + this.editForm = new HistoricalDataTemplateEditForm(null); + } + + onEditModalDlgClose(): void { + this.editForm = null; + } + + onEditFormSubmit(): void { + if (this.editForm == null) { + return; + } + + const createRequest = this.editForm.createRequestOrNull(); + if (createRequest == null) { + return; + } + + const itemId = this.editForm.getItemId(); + if (this.editForm.hasItemToEdit() && itemId != null) { + this.service + .update(itemId, createRequest) + .pipe(untilDestroyed(this)) + .subscribe(() => { + this.alert.success("Шаблон обновлен"); + this.editForm = null; + this.loadData(this.currentPage); + }); + + return; + } + + this.service + .create(createRequest) + .pipe(untilDestroyed(this)) + .subscribe(() => { + this.alert.success("Шаблон создан"); + this.editForm = null; + this.loadData(this.currentPage); + }); + } + + openEditDlg(item: SalariesHistoricalDataRecordTemplateDto): void { + this.editForm = new HistoricalDataTemplateEditForm(item); + } + + getProfessionNames(professionIds: number[] | null): string { + if (!professionIds || professionIds.length === 0) { + return "-"; + } + + return professionIds + .map((id) => { + const profession = this.professions.find((p) => p.id === id); + return profession?.title ?? id.toString(); + }) + .join(", "); + } + + ngOnDestroy(): void { + // ignored + } +} diff --git a/src/app/modules/salaries/components/historical-charts-page/historical-charts-page.component.html b/src/app/modules/salaries/components/historical-charts-page/historical-charts-page.component.html index 1a23e588..6c5085b5 100644 --- a/src/app/modules/salaries/components/historical-charts-page/historical-charts-page.component.html +++ b/src/app/modules/salaries/components/historical-charts-page/historical-charts-page.component.html @@ -1,62 +1,29 @@ Исторические данные
-
-
-
Фильтр
- -
- -
-
- Тенденция изменения медианы и средней зарплаты -
+
+ + Период: {{ data.from | date:'dd.MM.yyyy' }} - {{ data.to | date:'dd.MM.yyyy' }} + +
-
-

- Графики отображают тенденцию измнения зарплат за определенный отрезок - времени. График покажет 4 линии по оси Y слева на зарплатные значения: - медианная и средняя для компаний в Казахстане и на удаленке. Еще одна - линия по оси Y справа покажет, сколько было релевантных анкет на - определенный момент времени. -

-

Фильтр позволит ограничить выборку анкет по желаемым критериям.

+
+
+
+ {{ template.name || 'Общие данные' }}
- -
- -
- +
+
-
-
Оценка статистики респондентами
-
- -
+ +
+

Нет данных для отображения

-
+
@@ -64,20 +31,3 @@
- - -
-
-
- Доступ к статистике открыватся только после того, как пользователь - добавляет актуальную зарплату. Пожалуйста, перейдите по ссылке ниже, - чтобы перейти к форме заполнния анкеты. -
- -
-
-
diff --git a/src/app/modules/salaries/components/historical-charts-page/historical-charts-page.component.ts b/src/app/modules/salaries/components/historical-charts-page/historical-charts-page.component.ts index bd85036b..d897b2b1 100644 --- a/src/app/modules/salaries/components/historical-charts-page/historical-charts-page.component.ts +++ b/src/app/modules/salaries/components/historical-charts-page/historical-charts-page.component.ts @@ -1,5 +1,5 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; -import { ActivatedRoute, Router } from "@angular/router"; +import { Router } from "@angular/router"; import { TitleService } from "@services/title.service"; import { AuthService } from "@shared/services/auth/auth.service"; import { CookieService } from "ngx-cookie-service"; @@ -9,17 +9,6 @@ import { GetSalariesHistoricalChartResponse, HistoricalChartsService, } from "@services/historical-charts.service"; -import { SalariesChartActivatedRoute } from "../shared/salaries-activated-route"; -import { SalaryChartGlobalFiltersData } from "../shared/global-filters-form-group"; -import { UserSalariesService } from "@services/user-salaries.service"; -import { LabelEntityDto } from "@services/label-entity.model"; -import { DeveloperGrade } from "@models/enums"; -import { - ApiBackendAbsoluteUrl, - ClipboardCopier, -} from "@shared/value-objects/clipboard-copier"; -import { ConvertObjectToHttpParams } from "@shared/value-objects/convert-object-to-http"; -import { HistoricalSurveyChartResponse } from "@services/historical-charts.models"; @Component({ templateUrl: "./historical-charts-page.component.html", @@ -27,44 +16,22 @@ import { HistoricalSurveyChartResponse } from "@services/historical-charts.model standalone: false, }) export class HistoricalChartsPageComponent implements OnInit, OnDestroy { - readonly activatedRoute: SalariesChartActivatedRoute; - data: GetSalariesHistoricalChartResponse | null = null; - surveyChart: HistoricalSurveyChartResponse | null = null; - - filterData = new SalaryChartGlobalFiltersData(); - isAuthenticated = false; - shouldAddOwnSalary = false; - - skills: Array = []; - industries: Array = []; - professions: Array = []; - private initialLoading = true; constructor( private readonly service: HistoricalChartsService, - private readonly salariesService: UserSalariesService, private readonly router: Router, private readonly authService: AuthService, private readonly cookieService: CookieService, private readonly titleService: TitleService, private readonly gtag: GoogleAnalyticsService, - activatedRouteSource: ActivatedRoute, ) { titleService.setTitle("Исторические данные"); - this.activatedRoute = new SalariesChartActivatedRoute(activatedRouteSource); } ngOnInit(): void { if (this.authService.isAuthenticated()) { - this.activatedRoute - .getQueryParams() - .pipe(untilDestroyed(this)) - .subscribe((x) => { - this.filterData = x; - this.load(this.filterData); - }); - + this.load(); return; } @@ -76,103 +43,15 @@ export class HistoricalChartsPageComponent implements OnInit, OnDestroy { this.titleService.resetTitle(); } - applyGlobalFilters(data: SalaryChartGlobalFiltersData): void { - if (this.filterData.equals(data)) { - return; - } - - this.filterData = data; - const selectedGrade = data.grade ? DeveloperGrade[data.grade] : "empty"; - this.gtag.event( - "salaries_filters_applied", - "historical_data", - selectedGrade, - ); - this.load(data); - } - - resetGlobalFilters(): void { - const newFilterData = new SalaryChartGlobalFiltersData(); - if (this.filterData.equals(newFilterData)) { - return; - } - - this.filterData = newFilterData; - this.gtag.event("salaries_filters_reset", "historical_data"); - - if (this.router.url.indexOf("?") > 0) { - this.router.navigateByUrl( - this.router.url.substring(0, this.router.url.indexOf("?")), - ); - return; - } - - this.load(); - } - - share(data: SalaryChartGlobalFiltersData): void { - this.filterData = data; - this.gtag.event("salaries_share_clicked", "historical_data"); - - const currentUrl = new ApiBackendAbsoluteUrl("/chart-share").asString(); - const shareUrl = `${currentUrl}?${new ConvertObjectToHttpParams( - this.filterData, - ).get()}`; - new ClipboardCopier(shareUrl).execute(); - } - - load(data: SalaryChartGlobalFiltersData | null = null): void { + load(): void { this.data = null; - - const shouldLoadSelectBoxItems = - this.skills.length === 0 || - this.industries.length === 0 || - this.professions.length === 0 || - this.initialLoading; - - if (shouldLoadSelectBoxItems) { - this.salariesService - .selectBoxItems() - .pipe(untilDestroyed(this)) - .subscribe((x) => { - this.skills = x.skills; - this.industries = x.industries; - this.professions = x.professions; - - this.loadChartWithFilter(data); - this.initialLoading = false; - }); - - return; - } - - this.loadChartWithFilter(data); - } - - loadChartWithFilter(data: SalaryChartGlobalFiltersData | null = null): void { - const filterToApply = { - grade: data?.grade ?? null, - profsInclude: data?.profsInclude ?? null, - cities: data?.cities ?? null, - skills: data?.skills ?? null, - }; + this.gtag.event("historical_charts_load", "historical_data"); this.service - .salariesChart(filterToApply) + .salariesChart() .pipe(untilDestroyed(this)) .subscribe((x) => { - this.isAuthenticated = x.hasAuthentication; this.data = x; - this.shouldAddOwnSalary = x.shouldAddOwnSalary; - }); - - this.service - .surveyChart(filterToApply) - .pipe(untilDestroyed(this)) - .subscribe((x) => { - this.isAuthenticated = x.hasAuthentication; - this.surveyChart = x; - this.shouldAddOwnSalary = x.shouldAddOwnSalary; }); } } diff --git a/src/app/modules/salaries/components/historical-charts-page/historical-salaries-by-grade-chart/historical-salaries-by-grade-chart-object.ts b/src/app/modules/salaries/components/historical-charts-page/historical-salaries-by-grade-chart/historical-salaries-by-grade-chart-object.ts deleted file mode 100644 index 5bbdf167..00000000 --- a/src/app/modules/salaries/components/historical-charts-page/historical-salaries-by-grade-chart/historical-salaries-by-grade-chart-object.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { - SalariesCountWeekByWeekChart, - SalariesCountWeekByWeekChartGradeItem, -} from "@services/historical-charts.service"; -import { Chart, ChartType, PointStyle } from "chart.js/auto"; -import { RandomRgbColor } from "../../random-rgb-color"; -import { DeveloperGrade, DeveloperGradeEnum } from "@models/enums"; - -interface ChartDatasetType { - label: string; - data: Array; - borderWidth: number; - borderColor: string; - backgroundColor: string; - pointStyle: PointStyle; - grade: DeveloperGrade | null; - yAxisID: "y" | "y1"; - hidden: boolean; - - toggle(): void; -} - -export class HistoricalSalariesByGradeChartObject extends Chart { - private readonly datasets: Array = []; - - constructor(canvasId: string, chartData: SalariesCountWeekByWeekChart) { - const datasets: Array = []; - - const juniorSalaries = []; - const middleSalaries = []; - const seniorSalaries = []; - const leadSalaries = []; - - for (let index = 0; index < chartData.gradeItems.length; index++) { - const element = chartData.gradeItems[index]; - - switch (element.grade) { - case DeveloperGrade.Junior: - juniorSalaries.push(element); - break; - - case DeveloperGrade.Middle: - middleSalaries.push(element); - break; - - case DeveloperGrade.Senior: - seniorSalaries.push(element); - break; - - case DeveloperGrade.Lead: - leadSalaries.push(element); - break; - } - } - - datasets.push( - ...HistoricalSalariesByGradeChartObject.getDatasetForGrade( - DeveloperGrade.Junior, - juniorSalaries, - false, - ), - ...HistoricalSalariesByGradeChartObject.getDatasetForGrade( - DeveloperGrade.Middle, - middleSalaries, - false, - ), - ...HistoricalSalariesByGradeChartObject.getDatasetForGrade( - DeveloperGrade.Senior, - seniorSalaries, - false, - ), - ...HistoricalSalariesByGradeChartObject.getDatasetForGrade( - DeveloperGrade.Lead, - leadSalaries, - false, - ), - new DatasetItem( - "Количество анкет", - chartData.totalCountItems.map((x) => x.totalCount), - 4, - new RandomRgbColor(), - "circle", - null, - "y1", - false, - ), - ); - - super(canvasId, { - type: "line" as ChartType, - data: { - labels: chartData.weekEnds.map((x) => x.toISOString().slice(0, 10)), - datasets: datasets, - }, - options: { - responsive: true, - maintainAspectRatio: false, - scales: { - y: { - beginAtZero: true, - position: "left", - }, - y1: { - beginAtZero: true, - position: "right", - }, - }, - elements: { - line: { - tension: 0.4, - }, - }, - plugins: { - legend: { - position: "bottom", - title: { - position: "start", - }, - }, - }, - }, - }); - - this.datasets = datasets; - } - - toggle(grade: DeveloperGrade): void { - this.datasets.filter((x) => x.grade === grade).forEach((x) => x.toggle()); - - this.update(); - } - - private static getDatasetForGrade( - grade: DeveloperGrade, - items: Array, - hidden: boolean, - ): Array { - const prefix = DeveloperGrade[grade]; - return [ - new DatasetItem( - prefix + ", медиана, КЗ", - items.map((x) => x.localMedian), - 2, - new RandomRgbColor(), - true as PointStyle, - grade, - "y", - hidden, - ), - new DatasetItem( - prefix + ", средняя, КЗ", - items.map((x) => x.localAverage), - 2, - new RandomRgbColor(), - true as PointStyle, - grade, - "y", - hidden, - ), - new DatasetItem( - prefix + ", медиана, удаленка", - items.map((x) => x.remoteMedian), - 2, - new RandomRgbColor(), - true as PointStyle, - grade, - "y", - hidden, - ), - new DatasetItem( - prefix + ", средняя, удаленка", - items.map((x) => x.remoteAverage), - 2, - new RandomRgbColor(), - true as PointStyle, - grade, - "y", - hidden, - ), - ]; - } -} - -class DatasetItem implements ChartDatasetType { - constructor( - readonly label: string, - readonly data: number[], - readonly borderWidth: number, - readonly color: RandomRgbColor, - readonly pointStyle: PointStyle, - readonly grade: DeveloperGrade | null, - readonly yAxisID: "y1" | "y", - hidden: boolean, - ) { - this.borderColor = color.toString(1); - this.backgroundColor = color.toString(0.5); - this.hidden = hidden; - } - - hidden: boolean; - readonly borderColor: string; - readonly backgroundColor: string; - - toggle(): void { - this.hidden = !this.hidden; - } -} diff --git a/src/app/modules/salaries/components/historical-charts-page/historical-salaries-by-grade-chart/historical-salaries-by-grade-chart.component.html b/src/app/modules/salaries/components/historical-charts-page/historical-salaries-by-grade-chart/historical-salaries-by-grade-chart.component.html deleted file mode 100644 index 40bbf066..00000000 --- a/src/app/modules/salaries/components/historical-charts-page/historical-salaries-by-grade-chart/historical-salaries-by-grade-chart.component.html +++ /dev/null @@ -1,25 +0,0 @@ -
- Тенденция изменений, разделение по грейдам -
- -
-
- Ниже вы можете включить группы графиков для соответствующего грейда. Будет - показано по 4 линии на графике: медиана и средняя в Казахстане и медиана и - средняя на удаленке. -
-
- -
-
- -
- {{ chart }} -
diff --git a/src/app/modules/salaries/components/historical-charts-page/historical-salaries-by-grade-chart/historical-salaries-by-grade-chart.component.scss b/src/app/modules/salaries/components/historical-charts-page/historical-salaries-by-grade-chart/historical-salaries-by-grade-chart.component.scss deleted file mode 100644 index a43d2bc3..00000000 --- a/src/app/modules/salaries/components/historical-charts-page/historical-salaries-by-grade-chart/historical-salaries-by-grade-chart.component.scss +++ /dev/null @@ -1,10 +0,0 @@ -#canvas-historical-chart-by-grade { - min-height: 600px; -} - -#canvas-historical-chart-by-grade-container { - position: relative; - width: 100%; - height: 100%; - min-height: 600px; -} diff --git a/src/app/modules/salaries/components/historical-charts-page/historical-salaries-by-grade-chart/historical-salaries-by-grade-chart.component.spec.ts b/src/app/modules/salaries/components/historical-charts-page/historical-salaries-by-grade-chart/historical-salaries-by-grade-chart.component.spec.ts deleted file mode 100644 index 57dc319a..00000000 --- a/src/app/modules/salaries/components/historical-charts-page/historical-salaries-by-grade-chart/historical-salaries-by-grade-chart.component.spec.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { - mostUsedImports, - mostUsedServices, - testUtilStubs, -} from "@shared/test-utils"; -import { HistoricalSalariesByGradeChartComponent } from "./historical-salaries-by-grade-chart.component"; - -describe("HistoricalSalariesByGradeChartComponent", () => { - let component: HistoricalSalariesByGradeChartComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - declarations: [HistoricalSalariesByGradeChartComponent], - imports: [...mostUsedImports], - providers: [...testUtilStubs, ...mostUsedServices], - schemas: [CUSTOM_ELEMENTS_SCHEMA], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(HistoricalSalariesByGradeChartComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/app/modules/salaries/components/historical-charts-page/historical-salaries-by-grade-chart/historical-salaries-by-grade-chart.component.ts b/src/app/modules/salaries/components/historical-charts-page/historical-salaries-by-grade-chart/historical-salaries-by-grade-chart.component.ts deleted file mode 100644 index 69d35e2c..00000000 --- a/src/app/modules/salaries/components/historical-charts-page/historical-salaries-by-grade-chart/historical-salaries-by-grade-chart.component.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Component, Input, OnInit } from "@angular/core"; -import { SalariesCountWeekByWeekChart } from "@services/historical-charts.service"; -import { HistoricalSalariesByGradeChartObject } from "./historical-salaries-by-grade-chart-object"; -import { DeveloperGrade, DeveloperGradeEnum } from "@models/enums"; - -interface GradeToggleButton { - label: string; - bgCss: string; - textCss: string; - toggle(): void; -} - -@Component({ - selector: "app-historical-salaries-by-grade-chart", - templateUrl: "./historical-salaries-by-grade-chart.component.html", - styleUrls: ["./historical-salaries-by-grade-chart.component.scss"], - standalone: false, -}) -export class HistoricalSalariesByGradeChartComponent implements OnInit { - gradesButtons: Array = []; - - @Input() - data: SalariesCountWeekByWeekChart | null = null; - - chart: HistoricalSalariesByGradeChartObject | null = null; - - ngOnInit(): void { - if (this.data == null || !this.data.hasGradeItems) { - return; - } - - this.gradesButtons = [ - this.createGradeToggleButton(DeveloperGrade.Junior), - this.createGradeToggleButton(DeveloperGrade.Middle), - this.createGradeToggleButton(DeveloperGrade.Senior), - this.createGradeToggleButton(DeveloperGrade.Lead), - ]; - - this.chart = new HistoricalSalariesByGradeChartObject( - "canvas-historical-chart-by-grade", - this.data, - ); - } - - private createGradeToggleButton(grade: DeveloperGrade): GradeToggleButton { - const color = DeveloperGradeEnum.getColorData(grade); - return { - label: color.label, - bgCss: color.cssBackground, - textCss: color.cssText, - toggle: () => { - this.chart?.toggle(grade); - }, - }; - } -} diff --git a/src/app/modules/salaries/components/historical-charts-page/historical-salaries-chart/historical-charts-page.component.ts b/src/app/modules/salaries/components/historical-charts-page/historical-salaries-chart/historical-charts-page.component.ts index e5e02cf1..269e7375 100644 --- a/src/app/modules/salaries/components/historical-charts-page/historical-salaries-chart/historical-charts-page.component.ts +++ b/src/app/modules/salaries/components/historical-charts-page/historical-salaries-chart/historical-charts-page.component.ts @@ -1,5 +1,5 @@ -import { Component, Input, OnInit } from "@angular/core"; -import { SalariesCountWeekByWeekChart } from "@services/historical-charts.service"; +import { Component, Input, AfterViewInit, OnDestroy } from "@angular/core"; +import { HistoricalDataByTemplate } from "@services/historical-charts.service"; import { HistoricalSalariesChartObject } from "./historical-salaries-chart-object"; @Component({ @@ -8,20 +8,31 @@ import { HistoricalSalariesChartObject } from "./historical-salaries-chart-objec styleUrls: ["./historical-salaries-chart.component.scss"], standalone: false, }) -export class HistoricalSalariesChartComponent implements OnInit { +export class HistoricalSalariesChartComponent implements AfterViewInit, OnDestroy { @Input() - data: SalariesCountWeekByWeekChart | null = null; + template: HistoricalDataByTemplate | null = null; chart: HistoricalSalariesChartObject | null = null; + readonly canvasId = "canvas_" + Math.random().toString(36); - ngOnInit(): void { - if (this.data == null) { + ngAfterViewInit(): void { + this.initChart(); + } + + ngOnDestroy(): void { + this.chart?.destroy(); + } + + initChart(): void { + if (this.template == null) { return; } - this.chart = new HistoricalSalariesChartObject( - "canvas-historical-chart", - this.data, - ); + this.chart = new HistoricalSalariesChartObject(this.canvasId, this.template); + + const chartEl = document.getElementById(this.canvasId); + if (chartEl != null && chartEl.parentElement != null) { + chartEl.style.height = chartEl?.parentElement.style.height ?? "100%"; + } } } diff --git a/src/app/modules/salaries/components/historical-charts-page/historical-salaries-chart/historical-salaries-chart-object.ts b/src/app/modules/salaries/components/historical-charts-page/historical-salaries-chart/historical-salaries-chart-object.ts index 67671798..00dad637 100644 --- a/src/app/modules/salaries/components/historical-charts-page/historical-salaries-chart/historical-salaries-chart-object.ts +++ b/src/app/modules/salaries/components/historical-charts-page/historical-salaries-chart/historical-salaries-chart-object.ts @@ -1,4 +1,4 @@ -import { SalariesCountWeekByWeekChart } from "@services/historical-charts.service"; +import { HistoricalDataByTemplate } from "@services/historical-charts.service"; import { Chart, ChartType, PointStyle } from "chart.js/auto"; import { RandomRgbColor } from "../../random-rgb-color"; @@ -15,36 +15,21 @@ interface ChartDatasetType { export class HistoricalSalariesChartObject extends Chart { private readonly datasets: Array = []; - constructor(canvasId: string, chartData: SalariesCountWeekByWeekChart) { - // По датасету на медиану и среднюю + constructor(canvasId: string, template: HistoricalDataByTemplate) { + const dataPoints = template.dataPoints ?? []; + const datasets: Array = [ new DatasetItem( - "Медиана, КЗ", - chartData.totalCountItems.map((x) => x.localMedian), - 2, - new RandomRgbColor(), - true as PointStyle, - "y", - ), - new DatasetItem( - "Средняя, КЗ", - chartData.totalCountItems.map((x) => x.localAverage), + "Медиана", + dataPoints.map((x) => x.medianLocalSalary), 2, new RandomRgbColor(), true as PointStyle, "y", ), new DatasetItem( - "Медиана, удаленка", - chartData.totalCountItems.map((x) => x.remoteMedian), - 2, - new RandomRgbColor(), - true as PointStyle, - "y", - ), - new DatasetItem( - "Средняя, удаленка", - chartData.totalCountItems.map((x) => x.remoteAverage), + "Средняя", + dataPoints.map((x) => x.averageLocalSalary), 2, new RandomRgbColor(), true as PointStyle, @@ -52,7 +37,7 @@ export class HistoricalSalariesChartObject extends Chart { ), new DatasetItem( "Количество анкет", - chartData.totalCountItems.map((x) => x.totalCount), + dataPoints.map((x) => x.totalSalaryCount), 4, new RandomRgbColor(), "circle", @@ -60,10 +45,47 @@ export class HistoricalSalariesChartObject extends Chart { ), ]; + // Add grade-based median datasets if available + const firstPointWithGrades = dataPoints.find( + (x) => x.medianLocalSalaryByGrade != null, + ); + if (firstPointWithGrades?.medianLocalSalaryByGrade) { + const grades = Object.keys( + firstPointWithGrades.medianLocalSalaryByGrade, + ) as Array; + + for (const grade of grades) { + if (grade === "Undefined") continue; + + const gradeData = dataPoints.map( + (x) => x.medianLocalSalaryByGrade?.[grade] ?? 0, + ); + + // Only add if there's actual data + if (gradeData.some((v) => v > 0)) { + datasets.push( + new DatasetItem( + `Медиана ${grade}`, + gradeData, + 1, + new RandomRgbColor(), + "rect" as PointStyle, + "y", + ), + ); + } + } + } + + const labels = dataPoints.map((x) => { + const date = new Date(x.date); + return date.toISOString().slice(0, 10); + }); + super(canvasId, { type: "line" as ChartType, data: { - labels: chartData.weekEnds.map((x) => x.toISOString().slice(0, 10)), + labels: labels, datasets: datasets, }, options: { diff --git a/src/app/modules/salaries/components/historical-charts-page/historical-salaries-chart/historical-salaries-chart.component.html b/src/app/modules/salaries/components/historical-charts-page/historical-salaries-chart/historical-salaries-chart.component.html index 05082786..664f59d2 100644 --- a/src/app/modules/salaries/components/historical-charts-page/historical-salaries-chart/historical-salaries-chart.component.html +++ b/src/app/modules/salaries/components/historical-charts-page/historical-salaries-chart/historical-salaries-chart.component.html @@ -1,7 +1,7 @@ -
- Общие данные +
+ {{ template?.name }}
-
- {{ chart }} +
+ {{ chart }}
diff --git a/src/app/modules/salaries/components/historical-charts-page/historical-salaries-chart/historical-salaries-chart.component.scss b/src/app/modules/salaries/components/historical-charts-page/historical-salaries-chart/historical-salaries-chart.component.scss index d7f25b1d..5932c76c 100644 --- a/src/app/modules/salaries/components/historical-charts-page/historical-salaries-chart/historical-salaries-chart.component.scss +++ b/src/app/modules/salaries/components/historical-charts-page/historical-salaries-chart/historical-salaries-chart.component.scss @@ -1,10 +1,10 @@ -#canvas-historical-chart { - min-height: 600px; -} - -#canvas-historical-chart-container { +.canvas-container { position: relative; width: 100%; height: 100%; min-height: 600px; + + canvas { + min-height: 600px; + } } diff --git a/src/app/modules/salaries/salaries.module.ts b/src/app/modules/salaries/salaries.module.ts index 2878d726..9079051c 100644 --- a/src/app/modules/salaries/salaries.module.ts +++ b/src/app/modules/salaries/salaries.module.ts @@ -27,7 +27,6 @@ import { SalariesAddingChartComponent } from "./components/salaries-chart/salari import { CurrencySelectBoxComponent } from "./components/salaries-chart/currency-select-box/currency-select-box.component"; import { HistoricalChartsPageComponent } from "./components/historical-charts-page/historical-charts-page.component"; import { HistoricalSalariesChartComponent } from "./components/historical-charts-page/historical-salaries-chart/historical-charts-page.component"; -import { HistoricalSalariesByGradeChartComponent } from "./components/historical-charts-page/historical-salaries-by-grade-chart/historical-salaries-by-grade-chart.component"; import { SalariesByAgeChartComponent } from "./components/salaries-by-age-chart/salaries-by-age-chart.component"; import { SalariesByExperienceChartComponent } from "./components/salaries-by-experience-chart/salaries-by-experience-chart.component"; import { SalariesByCityChartComponent } from "./components/salaries-by-cities-chart/salaries-by-city-chart.component"; @@ -62,7 +61,6 @@ import { SurveyHistoricalChartComponent } from "./components/historical-charts-p CurrencySelectBoxComponent, HistoricalChartsPageComponent, HistoricalSalariesChartComponent, - HistoricalSalariesByGradeChartComponent, SalariesByAgeChartComponent, SalariesByExperienceChartComponent, SalariesByCityChartComponent, diff --git a/src/app/services/historical-charts.service.ts b/src/app/services/historical-charts.service.ts index 3678d9b4..7c9143a2 100644 --- a/src/app/services/historical-charts.service.ts +++ b/src/app/services/historical-charts.service.ts @@ -2,47 +2,43 @@ import { Injectable } from "@angular/core"; import { Observable } from "rxjs"; import { ApiService } from "./api.service"; import { ConvertObjectToHttpParams } from "@shared/value-objects/convert-object-to-http"; -import { DeveloperGrade } from "@models/enums"; -import { KazakhstanCity } from "@models/salaries/kazakhstan-city"; import { HistoricalSurveyChartResponse } from "./historical-charts.models"; -export interface SalariesCountWeekByWeekChartItem { - totalCount: number; - localMedian: number; - localAverage: number; - remoteMedian: number; - remoteAverage: number; +export interface MedianLocalSalaryByGrade { + Undefined?: number; + Trainee?: number; + Junior?: number; + Middle?: number; + Senior?: number; + Lead?: number; } -export interface SalariesCountWeekByWeekChartGradeItem - extends SalariesCountWeekByWeekChartItem { - grade: DeveloperGrade; +export interface HistoricalDataPoint { + date: Date; + medianLocalSalary: number; + averageLocalSalary: number; + minLocalSalary: number | null; + maxLocalSalary: number | null; + totalSalaryCount: number; + medianLocalSalaryByGrade: MedianLocalSalaryByGrade | null; } -export interface SalariesCountWeekByWeekChart { - weekEnds: Array; - totalCountItems: Array; - gradeItems: Array; - hasGradeItems: boolean; +export interface HistoricalDataByTemplate { + templateId: string; + name: string | null; + professionIds: number[] | null; + dataPoints: HistoricalDataPoint[] | null; } export interface GetSalariesHistoricalChartResponse { + templates: HistoricalDataByTemplate[] | null; from: Date; to: Date; - chartFrom: Date; - chartTo: Date; - - hasAuthentication: boolean; - shouldAddOwnSalary: boolean; - - salariesCountWeekByWeekChart: SalariesCountWeekByWeekChart | null; } export interface SalariesChartFilterData { - grade: DeveloperGrade | null; - profsInclude: Array | null; - cities: Array | null; - skills: Array | null; + from?: Date | null; + to?: Date | null; } @Injectable() @@ -54,18 +50,26 @@ export class HistoricalChartsService { } salariesChart( - params: SalariesChartFilterData, + params?: SalariesChartFilterData, ): Observable { - return this.api.get( - this.apiUrl + "salaries?" + new ConvertObjectToHttpParams(params).get(), - ); + const queryString = params + ? new ConvertObjectToHttpParams(params).get() + : ""; + const url = queryString + ? this.apiUrl + "salaries?" + queryString + : this.apiUrl + "salaries"; + return this.api.get(url); } surveyChart( params: SalariesChartFilterData, ): Observable { - return this.api.get( - this.apiUrl + "survey?" + new ConvertObjectToHttpParams(params).get(), - ); + const queryString = params + ? new ConvertObjectToHttpParams(params).get() + : ""; + const url = queryString + ? this.apiUrl + "survey?" + queryString + : this.apiUrl + "survey"; + return this.api.get(url); } } diff --git a/src/app/services/salaries-historical-data-templates.service.ts b/src/app/services/salaries-historical-data-templates.service.ts new file mode 100644 index 00000000..904f069e --- /dev/null +++ b/src/app/services/salaries-historical-data-templates.service.ts @@ -0,0 +1,65 @@ +import { Injectable } from "@angular/core"; +import { defaultPageParams, PageParams } from "@models/page-params"; +import { PaginatedList } from "@models/paginated-list"; +import { ConvertObjectToHttpParams } from "@shared/value-objects/convert-object-to-http"; +import { Observable } from "rxjs"; +import { ApiService } from "./api.service"; + +export interface SalariesHistoricalDataRecordTemplateDto { + id: string; + name: string | null; + professionIds: number[] | null; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateSalariesHistoricalDataRecordTemplateRequest { + name: string; + professionIds: number[]; +} + +export interface UpdateSalariesHistoricalDataRecordTemplateRequest { + name: string; + professionIds: number[]; +} + +@Injectable() +export class SalariesHistoricalDataTemplatesService { + private readonly apiUrl: string; + + constructor(private readonly api: ApiService) { + this.apiUrl = `/api/salaries-historical-data-templates`; + } + + search( + pageParams: PageParams = defaultPageParams, + ): Observable> { + return this.api.get>( + this.apiUrl + "?" + new ConvertObjectToHttpParams(pageParams).get(), + ); + } + + create( + body: CreateSalariesHistoricalDataRecordTemplateRequest, + ): Observable { + return this.api.post( + `${this.apiUrl}`, + body, + ); + } + + update( + id: string, + body: UpdateSalariesHistoricalDataRecordTemplateRequest, + ): Observable { + return this.api.post( + `${this.apiUrl}/${id}`, + body, + ); + } + + delete(id: string): Observable { + return this.api.delete(`${this.apiUrl}/${id}`, {}); + } +} + From e1156a96c1480516f85f1c5f39577c1a000f31fe Mon Sep 17 00:00:00 2001 From: "maxim.gorbatyuk" Date: Fri, 26 Dec 2025 21:37:54 +0500 Subject: [PATCH 2/4] + --- .../historical-data-templates.component.html | 30 +++++++++- .../historical-data-templates.component.ts | 16 +++++ ...sting-message-subscriptions.component.html | 46 +++++++++++++-- ...sting-message-subscriptions.component.scss | 21 +++++++ ...posting-message-subscriptions.component.ts | 57 +++++++++++++++++- .../subscription-edit-form.ts | 16 +---- .../stat-data-cache-records.component.html | 47 +++++++++++++-- .../stat-data-cache-records.component.scss | 21 +++++++ .../stat-data-cache-records.component.ts | 59 ++++++++++++++++++- .../subscription-edit-form.ts | 17 +----- 10 files changed, 287 insertions(+), 43 deletions(-) diff --git a/src/app/modules/admin/components/salaries/historical-data-templates/historical-data-templates.component.html b/src/app/modules/admin/components/salaries/historical-data-templates/historical-data-templates.component.html index d55ff26e..8ac293f8 100644 --- a/src/app/modules/admin/components/salaries/historical-data-templates/historical-data-templates.component.html +++ b/src/app/modules/admin/components/salaries/historical-data-templates/historical-data-templates.component.html @@ -27,10 +27,20 @@ - {{ item.id }} + ...{{ item.id.split('-').pop() }} {{ item.name }} - {{ getProfessionNames(item.professionIds) }} + + + {{ item.professionIds.length }} специальност{{ item.professionIds.length === 1 ? 'ь' : (item.professionIds.length < 5 ? 'и' : 'ей') }} + + - + {{ item.createdAt | date: "yyyy-MM-dd HH:mm" }} {{ item.updatedAt | date: "yyyy-MM-dd HH:mm" }} @@ -135,3 +145,19 @@ *ngIf="confirmDeletionMessage" [message]="confirmDeletionMessage" > + + +
    +
  • + {{ profession.title }} + ID: {{ profession.id }} +
  • +
+
diff --git a/src/app/modules/admin/components/salaries/historical-data-templates/historical-data-templates.component.ts b/src/app/modules/admin/components/salaries/historical-data-templates/historical-data-templates.component.ts index b5b616bf..3fb8dd12 100644 --- a/src/app/modules/admin/components/salaries/historical-data-templates/historical-data-templates.component.ts +++ b/src/app/modules/admin/components/salaries/historical-data-templates/historical-data-templates.component.ts @@ -31,6 +31,8 @@ export class HistoricalDataTemplatesComponent implements OnInit, OnDestroy { professions: Array = []; professionsAsOptions: Array> = []; + selectedProfessionsForModal: Array | null = null; + constructor( private readonly service: SalariesHistoricalDataTemplatesService, private readonly professionsService: ProfessionsService, @@ -160,6 +162,20 @@ export class HistoricalDataTemplatesComponent implements OnInit, OnDestroy { .join(", "); } + openProfessionsModal(professionIds: number[] | null): void { + if (!professionIds || professionIds.length === 0) { + return; + } + + this.selectedProfessionsForModal = professionIds + .map((id) => this.professions.find((p) => p.id === id)) + .filter((p): p is LabelEntityDto => p != null); + } + + closeProfessionsModal(): void { + this.selectedProfessionsForModal = null; + } + ngOnDestroy(): void { // ignored } diff --git a/src/app/modules/admin/components/telegram/job-posting-message-subscriptions/job-posting-message-subscriptions.component.html b/src/app/modules/admin/components/telegram/job-posting-message-subscriptions/job-posting-message-subscriptions.component.html index f2bb3d58..f794b667 100644 --- a/src/app/modules/admin/components/telegram/job-posting-message-subscriptions/job-posting-message-subscriptions.component.html +++ b/src/app/modules/admin/components/telegram/job-posting-message-subscriptions/job-posting-message-subscriptions.component.html @@ -29,9 +29,15 @@ {{ item.name }} - - {{ speciality }}, - + + {{ item.professionIds.length }} специальност{{ item.professionIds.length === 1 ? 'ь' : (item.professionIds.length < 5 ? 'и' : 'ей') }} + + - {{ item.telegramChatId }} {{ item.createdAt | date: "yyyy-MM-dd HH:mm" }} @@ -67,7 +73,8 @@
  • Удалить
  • @@ -133,8 +140,19 @@
    - - + + + @@ -150,3 +168,19 @@ *ngIf="confirmDeletionMessage" [message]="confirmDeletionMessage" > + + +
      +
    • + {{ profession.title }} + ID: {{ profession.id }} +
    • +
    +
    diff --git a/src/app/modules/admin/components/telegram/job-posting-message-subscriptions/job-posting-message-subscriptions.component.scss b/src/app/modules/admin/components/telegram/job-posting-message-subscriptions/job-posting-message-subscriptions.component.scss index e8302a90..19a4b3f7 100644 --- a/src/app/modules/admin/components/telegram/job-posting-message-subscriptions/job-posting-message-subscriptions.component.scss +++ b/src/app/modules/admin/components/telegram/job-posting-message-subscriptions/job-posting-message-subscriptions.component.scss @@ -3,3 +3,24 @@ max-height: 500px; overflow-y: auto; } + +// Ensure ng-select dropdown is visible inside modal +:host ::ng-deep { + .modal-body { + overflow: visible; + } + + .modal-content { + overflow: visible; + } +} + +// Ensure action dropdown is visible outside table container +.table-responsive { + overflow: visible; + + .dropdown-menu { + position: absolute; + z-index: 1050; + } +} diff --git a/src/app/modules/admin/components/telegram/job-posting-message-subscriptions/job-posting-message-subscriptions.component.ts b/src/app/modules/admin/components/telegram/job-posting-message-subscriptions/job-posting-message-subscriptions.component.ts index 1bb2eb38..bc1e78a8 100644 --- a/src/app/modules/admin/components/telegram/job-posting-message-subscriptions/job-posting-message-subscriptions.component.ts +++ b/src/app/modules/admin/components/telegram/job-posting-message-subscriptions/job-posting-message-subscriptions.component.ts @@ -14,6 +14,9 @@ import { ConfirmMsg } from "@shared/components/dialogs/models/confirm-msg"; import { DialogMessage } from "@shared/components/dialogs/models/dialog-message"; import { OpenAiDialogData } from "../open-ai-dialog-data"; import { JobPostingMessageSubscriptionEditForm } from "./subscription-edit-form"; +import { ProfessionsService } from "@services/professions.service"; +import { LabelEntityDto } from "@services/label-entity.model"; +import { SelectItem } from "@shared/select-boxes/select-item"; @Component({ templateUrl: "./job-posting-message-subscriptions.component.html", @@ -31,8 +34,14 @@ export class JobPostingMessageSubscriptionsComponent confirmDeletionMessage: DialogMessage | null = null; openAiDialogData: OpenAiDialogData | null = null; + professions: Array = []; + professionsAsOptions: Array> = []; + + selectedProfessionsForModal: Array | null = null; + constructor( private readonly service: JobPostingMessageSubscriptionsService, + private readonly professionsService: ProfessionsService, titleService: TitleService, private readonly alert: AlertService, ) { @@ -43,9 +52,28 @@ export class JobPostingMessageSubscriptionsComponent this.items = null; this.source = null; + this.loadProfessions(); this.loadData(this.currentPage); } + loadProfessions(): void { + if (this.professions.length > 0) { + return; + } + + this.professionsService + .allForSelectBoxes() + .pipe(untilDestroyed(this)) + .subscribe((x) => { + this.professions = x; + this.professionsAsOptions = x.map((p) => ({ + value: p.id.toString(), + item: p.id, + label: p.title, + })); + }); + } + loadData(pageToRequest: number): void { this.items = null; this.source = null; @@ -123,7 +151,7 @@ export class JobPostingMessageSubscriptionsComponent .subscribe(() => { this.alert.success("Подписка была создана"); this.editForm = null; - this.ngOnInit(); + this.loadData(this.currentPage); }); } @@ -131,6 +159,33 @@ export class JobPostingMessageSubscriptionsComponent return SubscriptionRegularityType[regularity]; } + getProfessionNames(professionIds: number[] | null): string { + if (!professionIds || professionIds.length === 0) { + return "-"; + } + + return professionIds + .map((id) => { + const profession = this.professions.find((p) => p.id === id); + return profession?.title ?? id.toString(); + }) + .join(", "); + } + + openProfessionsModal(professionIds: number[] | null): void { + if (!professionIds || professionIds.length === 0) { + return; + } + + this.selectedProfessionsForModal = professionIds + .map((id) => this.professions.find((p) => p.id === id)) + .filter((p): p is LabelEntityDto => p != null); + } + + closeProfessionsModal(): void { + this.selectedProfessionsForModal = null; + } + ngOnDestroy(): void { // ignored } diff --git a/src/app/modules/admin/components/telegram/job-posting-message-subscriptions/subscription-edit-form.ts b/src/app/modules/admin/components/telegram/job-posting-message-subscriptions/subscription-edit-form.ts index 626d62d2..d58ff781 100644 --- a/src/app/modules/admin/components/telegram/job-posting-message-subscriptions/subscription-edit-form.ts +++ b/src/app/modules/admin/components/telegram/job-posting-message-subscriptions/subscription-edit-form.ts @@ -16,9 +16,7 @@ export class JobPostingMessageSubscriptionEditForm extends FormGroup { Validators.pattern(JobPostingMessageSubscriptionEditForm.digitsPattern), Validators.maxLength(100), ]), - professionIds: new FormControl(item?.professionIds?.join(",") ?? null, [ - Validators.maxLength(500), - ]), + professionIds: new FormControl(item?.professionIds ?? [], []), }); } @@ -37,20 +35,12 @@ export class JobPostingMessageSubscriptionEditForm extends FormGroup { } const telegramChatId = parseInt(this.value.telegramChatId as string); - - const professionIdsAsString = this.value.professionIds as string; - let professionIds: Array = []; - if (professionIdsAsString != null) { - professionIds = professionIdsAsString - .split(",") - .map((x) => parseInt(x, 10)) - .filter((x) => !isNaN(x)); - } + const professionIds = this.value.professionIds as Array; return { name: this.value.name, telegramChatId: telegramChatId, - professionIds: professionIds, + professionIds: professionIds ?? [], }; } } diff --git a/src/app/modules/admin/components/telegram/salaries-stat-subscriptions/stat-data-cache-records.component.html b/src/app/modules/admin/components/telegram/salaries-stat-subscriptions/stat-data-cache-records.component.html index fdbd6c2c..5d739875 100644 --- a/src/app/modules/admin/components/telegram/salaries-stat-subscriptions/stat-data-cache-records.component.html +++ b/src/app/modules/admin/components/telegram/salaries-stat-subscriptions/stat-data-cache-records.component.html @@ -33,7 +33,17 @@ {{ item.name }} {{ item.telegramChatId }} - {{ item.professionIds }} + + + {{ item.professionIds.length }} специальност{{ item.professionIds.length === 1 ? 'ь' : (item.professionIds.length < 5 ? 'и' : 'ей') }} + + - + Удалить @@ -143,7 +153,7 @@
    - - + + + @@ -239,3 +260,19 @@ openAiDialogData.json }} + + +
      +
    • + {{ profession.title }} + ID: {{ profession.id }} +
    • +
    +
    diff --git a/src/app/modules/admin/components/telegram/salaries-stat-subscriptions/stat-data-cache-records.component.scss b/src/app/modules/admin/components/telegram/salaries-stat-subscriptions/stat-data-cache-records.component.scss index b470acdf..f175f284 100644 --- a/src/app/modules/admin/components/telegram/salaries-stat-subscriptions/stat-data-cache-records.component.scss +++ b/src/app/modules/admin/components/telegram/salaries-stat-subscriptions/stat-data-cache-records.component.scss @@ -11,3 +11,24 @@ cursor: pointer; font-weight: 500; } + +// Ensure ng-select dropdown is visible inside modal +:host ::ng-deep { + .modal-body { + overflow: visible; + } + + .modal-content { + overflow: visible; + } +} + +// Ensure action dropdown is visible outside table container +.table-responsive { + overflow: visible; + + .dropdown-menu { + position: absolute; + z-index: 1050; + } +} diff --git a/src/app/modules/admin/components/telegram/salaries-stat-subscriptions/stat-data-cache-records.component.ts b/src/app/modules/admin/components/telegram/salaries-stat-subscriptions/stat-data-cache-records.component.ts index 21a6d68e..1b83b709 100644 --- a/src/app/modules/admin/components/telegram/salaries-stat-subscriptions/stat-data-cache-records.component.ts +++ b/src/app/modules/admin/components/telegram/salaries-stat-subscriptions/stat-data-cache-records.component.ts @@ -14,6 +14,9 @@ import { ConfirmMsg } from "@shared/components/dialogs/models/confirm-msg"; import { DialogMessage } from "@shared/components/dialogs/models/dialog-message"; import { OpenAiDialogData } from "../open-ai-dialog-data"; import { TelegramSubscriptionEditForm } from "./subscription-edit-form"; +import { ProfessionsService } from "@services/professions.service"; +import { LabelEntityDto } from "@services/label-entity.model"; +import { SelectItem } from "@shared/select-boxes/select-item"; @Component({ templateUrl: "./stat-data-cache-records.component.html", @@ -29,8 +32,14 @@ export class StatDataCacheRecordsComponent implements OnInit, OnDestroy { confirmDeletionMessage: DialogMessage | null = null; openAiDialogData: OpenAiDialogData | null = null; + professions: Array = []; + professionsAsOptions: Array> = []; + + selectedProfessionsForModal: Array | null = null; + constructor( private readonly service: TelegramSubscriptionsService, + private readonly professionsService: ProfessionsService, titleService: TitleService, private readonly alert: AlertService, ) { @@ -41,9 +50,28 @@ export class StatDataCacheRecordsComponent implements OnInit, OnDestroy { this.items = null; this.source = null; + this.loadProfessions(); this.loadData(this.currentPage); } + loadProfessions(): void { + if (this.professions.length > 0) { + return; + } + + this.professionsService + .allForSelectBoxes() + .pipe(untilDestroyed(this)) + .subscribe((x) => { + this.professions = x; + this.professionsAsOptions = x.map((p) => ({ + value: p.id.toString(), + item: p.id, + label: p.title, + })); + }); + } + loadData(pageToRequest: number): void { this.items = null; this.source = null; @@ -123,7 +151,7 @@ export class StatDataCacheRecordsComponent implements OnInit, OnDestroy { .subscribe(() => { this.alert.success("Подписка была обновлена"); this.editForm = null; - this.ngOnInit(); + this.loadData(this.currentPage); }); return; @@ -135,7 +163,7 @@ export class StatDataCacheRecordsComponent implements OnInit, OnDestroy { .subscribe(() => { this.alert.success("Подписка была создана"); this.editForm = null; - this.ngOnInit(); + this.loadData(this.currentPage); }); } @@ -184,6 +212,33 @@ export class StatDataCacheRecordsComponent implements OnInit, OnDestroy { return SubscriptionRegularityType[regularity]; } + getProfessionNames(professionIds: number[] | null): string { + if (!professionIds || professionIds.length === 0) { + return "-"; + } + + return professionIds + .map((id) => { + const profession = this.professions.find((p) => p.id === id); + return profession?.title ?? id.toString(); + }) + .join(", "); + } + + openProfessionsModal(professionIds: number[] | null): void { + if (!professionIds || professionIds.length === 0) { + return; + } + + this.selectedProfessionsForModal = professionIds + .map((id) => this.professions.find((p) => p.id === id)) + .filter((p): p is LabelEntityDto => p != null); + } + + closeProfessionsModal(): void { + this.selectedProfessionsForModal = null; + } + ngOnDestroy(): void { // ignored } diff --git a/src/app/modules/admin/components/telegram/salaries-stat-subscriptions/subscription-edit-form.ts b/src/app/modules/admin/components/telegram/salaries-stat-subscriptions/subscription-edit-form.ts index f5b599e3..e966e933 100644 --- a/src/app/modules/admin/components/telegram/salaries-stat-subscriptions/subscription-edit-form.ts +++ b/src/app/modules/admin/components/telegram/salaries-stat-subscriptions/subscription-edit-form.ts @@ -16,9 +16,7 @@ export class TelegramSubscriptionEditForm extends FormGroup { Validators.pattern(TelegramSubscriptionEditForm.digitsPattern), Validators.maxLength(100), ]), - professionIds: new FormControl(item?.professionIds?.join(",") ?? null, [ - Validators.maxLength(500), - ]), + professionIds: new FormControl(item?.professionIds ?? [], []), preventNotificationIfNoDifference: new FormControl( item?.preventNotificationIfNoDifference ?? false, [], @@ -44,23 +42,14 @@ export class TelegramSubscriptionEditForm extends FormGroup { return null; } - const professionIdsAsString = this.value.professionIds as string; - - let professionIds: Array = []; - if (professionIdsAsString != null) { - professionIds = professionIdsAsString - .split(",") - .map((x) => parseInt(x, 10)) - .filter((x) => !isNaN(x)); - } - + const professionIds = this.value.professionIds as Array; const telegramChatId = parseInt(this.value.telegramChatId as string, 10); const regularity = parseInt(this.value.regularity as string, 10); return { name: this.value.name, telegramChatId: telegramChatId, - professionIds: professionIds, + professionIds: professionIds ?? [], regularity: regularity, useAiAnalysis: this.value.useAiAnalysis, preventNotificationIfNoDifference: From aadba63fab7edc738cb0a97f9c52919d762340ce Mon Sep 17 00:00:00 2001 From: "maxim.gorbatyuk" Date: Fri, 26 Dec 2025 21:42:55 +0500 Subject: [PATCH 3/4] Fixed tables --- .../companies-admin-page/companies-admin-page.component.html | 2 +- .../company-admin-page/company-admin-page.component.html | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/modules/admin/components/companies/companies-admin-page/companies-admin-page.component.html b/src/app/modules/admin/components/companies/companies-admin-page/companies-admin-page.component.html index 9ed0f273..c3ee3443 100644 --- a/src/app/modules/admin/components/companies/companies-admin-page/companies-admin-page.component.html +++ b/src/app/modules/admin/components/companies/companies-admin-page/companies-admin-page.component.html @@ -74,7 +74,7 @@ > {{ item.rating | number: "1.2-2" }} - {{ item.reviews.length || item.reviewsCount }} + {{ item.reviews?.length || item.reviewsCount }} {{ item.viewsCount }} {{ item.slug }} {{ item.createdAt | date: "yyyy-MM-dd HH:mm" }} diff --git a/src/app/modules/admin/components/companies/company-admin-page/company-admin-page.component.html b/src/app/modules/admin/components/companies/company-admin-page/company-admin-page.component.html index dcff0ad0..a12ba196 100644 --- a/src/app/modules/admin/components/companies/company-admin-page/company-admin-page.component.html +++ b/src/app/modules/admin/components/companies/company-admin-page/company-admin-page.component.html @@ -87,7 +87,7 @@
    Кол-во отзывов
    - {{ company.reviews.length || company.reviewsCount }} + {{ company.reviews?.length || company.reviewsCount }}
    @@ -143,7 +143,7 @@
    From bcb56ca66a4b48f7796d08c77f96a17e2a63b905 Mon Sep 17 00:00:00 2001 From: "maxim.gorbatyuk" Date: Fri, 26 Dec 2025 21:54:07 +0500 Subject: [PATCH 4/4] Fix --- src/app/app.component.spec.ts | 23 ++++++++++------------- src/app/app.component.ts | 4 ---- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index cd844d86..e448e27f 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -1,37 +1,34 @@ import { CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; -import { TestBed } from "@angular/core/testing"; +import { TestBed, waitForAsync } from "@angular/core/testing"; import { RouterTestingModule } from "@angular/router/testing"; import { AuthService } from "@shared/services/auth/auth.service"; +import { SharedModule } from "@shared/shared.module"; +import { SpinnerService } from "@shared/services/spinners/spinner-service"; import { MockAuthService, - mostUsedImports, mostUsedServices, testUtilStubs, } from "@shared/test-utils"; import { AppComponent } from "./app.component"; describe("AppComponent", () => { - beforeEach(async () => { - await TestBed.configureTestingModule({ + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ providers: [ - ...mostUsedServices, { provide: AuthService, useClass: MockAuthService }, + ...testUtilStubs, + ...mostUsedServices, + SpinnerService, ], - imports: [RouterTestingModule, ...mostUsedImports], declarations: [AppComponent], + imports: [RouterTestingModule, SharedModule], schemas: [CUSTOM_ELEMENTS_SCHEMA], }).compileComponents(); - }); + })); it("should create the app", () => { const fixture = TestBed.createComponent(AppComponent); const app = fixture.componentInstance; expect(app).toBeTruthy(); }); - - it(`should have as title 'Techinterview.space'`, () => { - const fixture = TestBed.createComponent(AppComponent); - const app = fixture.componentInstance; - expect(app.title).toEqual("Techinterview.space"); - }); }); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index d15887db..378254e3 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -10,15 +10,11 @@ import { untilDestroyed } from "./shared/subscriptions/until-destroyed"; standalone: false, }) export class AppComponent implements OnInit, OnDestroy { - isAuthenticated = false; - title = "Techinterview.space"; - get showAdminNavbar(): boolean { return this.router.url.startsWith("/admin"); } constructor( - private readonly authService: AuthService, private readonly router: Router, ) {}