diff --git a/package-lock.json b/package-lock.json index 5844bd070..75e3c1a6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "class-transformer": "^0.5.1", "core-js": "^3.23.4", "dayjs": "^1.11.3", + "file-saver": "^2.0.5", "fuse.js": "^6.6.2", "js-base64": "^3.7.7", "js-cookie": "^3.0.5", @@ -11150,6 +11151,12 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -28302,6 +28309,11 @@ "flat-cache": "^3.0.4" } }, + "file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==" + }, "fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", diff --git a/package.json b/package.json index 4dae92da2..a843f40d5 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "class-transformer": "^0.5.1", "core-js": "^3.23.4", "dayjs": "^1.11.3", + "file-saver": "^2.0.5", "fuse.js": "^6.6.2", "js-base64": "^3.7.7", "js-cookie": "^3.0.5", diff --git a/projects/core/src/lib/interceptors/bearer-token.interceptor.ts b/projects/core/src/lib/interceptors/bearer-token.interceptor.ts index 032f1d701..22d47dac4 100644 --- a/projects/core/src/lib/interceptors/bearer-token.interceptor.ts +++ b/projects/core/src/lib/interceptors/bearer-token.interceptor.ts @@ -46,9 +46,7 @@ export class BearerTokenInterceptor implements HttpInterceptor { */ intercept(request: HttpRequest, next: HttpHandler): Observable> { // Базовые заголовки для всех запросов - const headers: Record = { - Accept: "application/json", - }; + const headers: Record = {}; const tokens = this.tokenService.getTokens(); @@ -57,14 +55,24 @@ export class BearerTokenInterceptor implements HttpInterceptor { headers["Authorization"] = `Bearer ${tokens.access}`; } + // Для blob запросов (файлы) не устанавливаем Accept, чтобы не парсить blob как JSON + const isBlobRequest = + request.url.includes("/export") || + request.url.includes("/download") || + (request.headers.has("X-Request-Type") && request.headers.get("X-Request-Type") === "blob"); + + const hasAcceptHeader = request.headers.has("Accept"); + + if (!isBlobRequest && !hasAcceptHeader) { + headers["Accept"] = "application/json"; + } + const req = request.clone({ setHeaders: headers }); - // Если токены есть, обрабатываем запрос с возможностью обновления токенов if (tokens !== null) { return this.handleRequestWithTokens(req, next); } else { - // Если токенов нет, просто выполняем запрос - return next.handle(request); + return next.handle(req); } } @@ -107,10 +115,9 @@ export class BearerTokenInterceptor implements HttpInterceptor { request: HttpRequest, next: HttpHandler ): Observable> { - // Если токен еще не обновляется, начинаем процесс обновления if (!this.isRefreshing) { this.isRefreshing = true; - this.refreshTokenSubject.next(null); // Сбрасываем subject + this.refreshTokenSubject.next(null); return this.tokenService.refreshTokens().pipe( catchError(err => { @@ -119,22 +126,29 @@ export class BearerTokenInterceptor implements HttpInterceptor { }), switchMap(res => { this.isRefreshing = false; - this.refreshTokenSubject.next(res.access); // Уведомляем о новом токене + this.refreshTokenSubject.next(res.access); - // Сохраняем новые токены в хранилище this.tokenService.memTokens(res); - // Подготавливаем заголовки с новым токеном - const headers: Record = { - Accept: "application/json", - }; + const headers: Record = {}; const tokens = this.tokenService.getTokens(); if (tokens) { headers["Authorization"] = `Bearer ${tokens.access}`; } - // Повторяем исходный запрос с новым токеном + const isBlobRequest = + request.url.includes("/export") || + request.url.includes("/download") || + (request.headers.has("X-Request-Type") && + request.headers.get("X-Request-Type") === "blob"); + + const hasAcceptHeader = request.headers.has("Accept"); + + if (!isBlobRequest && !hasAcceptHeader) { + headers["Accept"] = "application/json"; + } + return next.handle( request.clone({ setHeaders: headers, @@ -144,17 +158,32 @@ export class BearerTokenInterceptor implements HttpInterceptor { ); } - // Если токен уже обновляется, ждем завершения процесса return this.refreshTokenSubject.pipe( - filter(token => token !== null), // Ждем получения нового токена - take(1), // Берем только первое значение - switchMap(token => - next.handle( + filter(token => token !== null), + take(1), + switchMap(token => { + const headers: Record = { + Authorization: `Bearer ${token}`, + }; + + const isBlobRequest = + request.url.includes("/export") || + request.url.includes("/download") || + (request.headers.has("X-Request-Type") && + request.headers.get("X-Request-Type") === "blob"); + + const hasAcceptHeader = request.headers.has("Accept"); + + if (!isBlobRequest && !hasAcceptHeader) { + headers["Accept"] = "application/json"; + } + + return next.handle( request.clone({ - setHeaders: { Authorization: `Bearer ${token}` }, + setHeaders: headers, }) - ) - ) + ); + }) ); } } diff --git a/projects/core/src/lib/interceptors/camelcase.interceptor.ts b/projects/core/src/lib/interceptors/camelcase.interceptor.ts index cc1e0b672..44daedd8b 100644 --- a/projects/core/src/lib/interceptors/camelcase.interceptor.ts +++ b/projects/core/src/lib/interceptors/camelcase.interceptor.ts @@ -54,24 +54,34 @@ export class CamelcaseInterceptor implements HttpInterceptor { }), }); } else { - // Если тела нет, просто клонируем запрос req = request.clone(); } // Выполняем запрос и обрабатываем ответ return next.handle(req).pipe( map((event: HttpEvent) => { - // Обрабатываем только HTTP ответы (не события загрузки и т.д.) if (event instanceof HttpResponse) { + if (event.body instanceof Blob) { + return event; + } + + if (typeof event.body !== "object" || event.body === null) { + return event; + } + // Клонируем ответ с преобразованным телом (snake_case → camelCase) - return event.clone({ - body: camelcaseKeys(event.body, { - deep: true, // Рекурсивное преобразование вложенных объектов - }), - }); + try { + return event.clone({ + body: camelcaseKeys(event.body, { + deep: true, // Рекурсивное преобразование вложенных объектов + }), + }); + } catch (error) { + console.warn("CamelcaseInterceptor: Failed to transform response body", error); + return event; + } } - // Для других типов событий возвращаем как есть return event; }) ); diff --git a/projects/core/src/lib/services/api.service.ts b/projects/core/src/lib/services/api.service.ts index 20b7b93fe..02df1d320 100644 --- a/projects/core/src/lib/services/api.service.ts +++ b/projects/core/src/lib/services/api.service.ts @@ -44,6 +44,15 @@ export class ApiService { return this.http.get(this.apiUrl + path, { params, ...options }).pipe(first()) as Observable; } + getFile(path: string, params?: HttpParams): Observable { + return this.http + .get(this.apiUrl + path, { + params, + responseType: "blob", + }) + .pipe(first()) as Observable; + } + /** * Выполняет PUT запрос к API (полное обновление ресурса) * @param path - Относительный путь к ресурсу diff --git a/projects/social_platform/src/app/auth/services/auth.service.ts b/projects/social_platform/src/app/auth/services/auth.service.ts index 97056c75b..9c544e025 100644 --- a/projects/social_platform/src/app/auth/services/auth.service.ts +++ b/projects/social_platform/src/app/auth/services/auth.service.ts @@ -76,12 +76,8 @@ export class AuthService { .pipe(map(json => plainToInstance(RegisterResponse, json))); } - /** - * Отправить резюме по email - * @returns Observable завершения операции - */ - sendCV(): Observable { - return this.apiService.get(`${this.AUTH_USERS_URL}/send_mail_cv/`); + downloadCV() { + return this.apiService.getFile(`${this.AUTH_USERS_URL}/download_cv/`); } /** Поток данных профиля пользователя */ 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 index 5fd27e0c4..1d17051f8 100644 --- a/projects/social_platform/src/app/office/features/detail/detail.component.html +++ b/projects/social_platform/src/app/office/features/detail/detail.component.html @@ -545,10 +545,9 @@

@if (+profile.id === +info().id) { cкачать CVподтвердить владение н

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

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

- + 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 index c24d0ee40..48788f030 100644 --- a/projects/social_platform/src/app/office/features/detail/detail.component.ts +++ b/projects/social_platform/src/app/office/features/detail/detail.component.ts @@ -2,8 +2,8 @@ import { CommonModule, Location } from "@angular/common"; import { ChangeDetectorRef, Component, inject, OnDestroy, OnInit, signal } from "@angular/core"; -import { ButtonComponent, InputComponent } from "@ui/components"; -import { BackComponent, IconComponent } from "@uilib"; +import { ButtonComponent } 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"; @@ -23,15 +23,13 @@ import { ProfileDataService } from "@office/profile/detail/services/profile-date import { ProfileService } from "projects/skills/src/app/profile/services/profile.service"; import { SnackbarService } from "@ui/services/snackbar.service"; import { ApproveSkillComponent } from "../approve-skill/approve-skill.component"; -import { ProjectsService } from "@office/projects/services/projects.service"; -import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe"; 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 { HttpRequest, HttpResponse } from "@angular/common/http"; +import { saveFile } from "@utils/helpers/export-file"; @Component({ selector: "app-detail", @@ -42,13 +40,10 @@ import { HttpRequest, HttpResponse } from "@angular/common/http"; RouterModule, IconComponent, ButtonComponent, - BackComponent, ModalComponent, AvatarComponent, TooltipComponent, - InputComponent, ApproveSkillComponent, - TruncatePipe, ], standalone: true, }) @@ -64,7 +59,6 @@ export class DeatilComponent implements OnInit, OnDestroy { private readonly location = inject(Location); private readonly profileDataService = inject(ProfileDataService); public readonly skillsProfileService = inject(ProfileService); - private readonly projectsService = inject(ProjectsService); public readonly chatService = inject(ChatService); private readonly cdRef = inject(ChangeDetectorRef); private readonly programService = inject(ProgramService); @@ -388,15 +382,17 @@ export class DeatilComponent implements OnInit, OnDestroy { * Отправка CV пользователя на email * Проверяет ограничения по времени и отправляет CV на почту пользователя */ - sendCVEmail() { - this.authService.sendCV().subscribe({ - next: () => { - this.isSended = true; + 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; - this.errorMessageModal.set(err.error.seconds_after_retry); } }, }); 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 index 31d170cef..9134795e1 100644 --- 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 @@ -55,6 +55,54 @@ (closeFilter)="closeFilter()" (clear)="onClearFilters()" > + + @if (listType === 'projects') { +
+ + выгрузка проектов + + + + + сданные решения + + +
+ } @else { +
+ + выгрузка оценок + + + + + итоговые расчеты + + +
+ } } 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 index 3401734d3..5302e26c8 100644 --- 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 @@ -4,7 +4,7 @@ .page { display: grid; grid-template-columns: 8fr 2fr; - gap: 20px; + gap: 10px; padding-bottom: 100px; &__outlet { @@ -59,6 +59,22 @@ &__left { 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; + } + } + } + } } .filter { 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 index 986dbb9e1..99d94ae11 100644 --- 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 @@ -25,12 +25,11 @@ import { Subscription, switchMap, tap, - throttleTime, } 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, Validators } from "@angular/forms"; +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"; @@ -42,9 +41,12 @@ import { SubscriptionService } from "@office/services/subscription.service"; import { ApiPagination } from "@models/api-pagination.model"; import { HttpParams } from "@angular/common/http"; import { PartnerProgramFields } from "@office/models/partner-program-fields.model"; -import { CheckboxComponent } from "@ui/components"; +import { 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"; @Component({ selector: "app-list", @@ -58,6 +60,8 @@ import { tagsFilter } from "projects/core/src/consts/filters/tags-filter.const"; SearchComponent, RatingCardComponent, InfoCardComponent, + ButtonComponent, + IconComponent, ], standalone: true, }) @@ -82,12 +86,13 @@ export class ProgramListComponent implements OnInit, OnDestroy, AfterViewInit { 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); - private previousReqQuery: Record = {}; - private availableFilters: PartnerProgramFields[] = []; + protected availableFilters: PartnerProgramFields[] = []; searchForm: FormGroup; @@ -109,6 +114,11 @@ export class ProgramListComponent implements OnInit, OnDestroy, AfterViewInit { readonly ratingOptionsList = tagsFilter; isFilterOpen = 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 { @@ -490,6 +500,54 @@ export class ProgramListComponent implements OnInit, OnDestroy, AfterViewInit { 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; @@ -562,19 +620,4 @@ export class ProgramListComponent implements OnInit, OnDestroy, AfterViewInit { private get searchParamName(): string { return this.listType === "rating" ? "name__contains" : "search"; } - - private flattenFilters(filters: Record): Record { - const flattened: Record = {}; - - Object.keys(filters).forEach(key => { - const value = filters[key]; - if (Array.isArray(value) && value.length > 0) { - flattened[key] = Array.isArray(value[0]) ? value.join(",") : value.toString(); - } else if (value !== undefined && value !== null) { - flattened[key] = value.toString(); - } - }); - - return flattened; - } } 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 index 0ba988f01..c0c1ef698 100644 --- 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 @@ -14,4 +14,10 @@ export class ProgramDataService { 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/shared/program-card/program-card.component.html b/projects/social_platform/src/app/office/program/shared/program-card/program-card.component.html index 4c6a029f2..567644a12 100644 --- 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 @@ -12,18 +12,19 @@

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

diff --git a/projects/social_platform/src/app/office/program/shared/program-card/program-card.component.scss b/projects/social_platform/src/app/office/program/shared/program-card/program-card.component.scss index c8162485f..443705742 100644 --- a/projects/social_platform/src/app/office/program/shared/program-card/program-card.component.scss +++ b/projects/social_platform/src/app/office/program/shared/program-card/program-card.component.scss @@ -55,7 +55,7 @@ } &--ended { - background-color: var(--red); + background-color: var(--red) !important; } &--participating { diff --git a/projects/social_platform/src/app/office/services/export-file.service.ts b/projects/social_platform/src/app/office/services/export-file.service.ts new file mode 100644 index 000000000..40f361d60 --- /dev/null +++ b/projects/social_platform/src/app/office/services/export-file.service.ts @@ -0,0 +1,32 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { ApiService } from "@corelib"; + +@Injectable({ providedIn: "root" }) +export class ExportFileService { + private readonly EXPORT_PROGRAM_FILE_URL = "/programs/"; + private readonly EXPORT_PROGRAM_RATES_URL = "/export-rates/"; + private readonly EXPORT_PROGRAM_ALL_PROJECTS_URL = "/export-projects/"; + private readonly EXPORT_PROGRAM_SUBMITTED_PROJECTS_URL = "/export-projects/?only_submitted=1"; + + private readonly apiService = inject(ApiService); + + exportProgramRates(programId: number) { + return this.apiService.getFile( + `${this.EXPORT_PROGRAM_FILE_URL}${programId}${this.EXPORT_PROGRAM_RATES_URL}` + ); + } + + exportAllProjects(programId: number) { + return this.apiService.getFile( + `${this.EXPORT_PROGRAM_FILE_URL}${programId}${this.EXPORT_PROGRAM_ALL_PROJECTS_URL}` + ); + } + + exportSubmittedProjects(programId: number) { + return this.apiService.getFile( + `${this.EXPORT_PROGRAM_FILE_URL}${programId}${this.EXPORT_PROGRAM_SUBMITTED_PROJECTS_URL}` + ); + } +} diff --git a/projects/social_platform/src/app/utils/helpers/export-file.ts b/projects/social_platform/src/app/utils/helpers/export-file.ts new file mode 100644 index 000000000..40343b8f4 --- /dev/null +++ b/projects/social_platform/src/app/utils/helpers/export-file.ts @@ -0,0 +1,23 @@ +/** @format */ + +/** + * Сохраняет blob файл на диск пользователя + * @param blob - Blob объект с данными файла + * @param fileName - Имя файла для сохранения (по умолчанию 'export.xlsx') + */ +import { saveAs } from "file-saver"; + +export const saveFile = ( + blob: Blob, + type: "all" | "submitted" | "rates" | "cv", + name?: string +): void => { + const prefixFileName = + type === "all" ? "projects" : type === "rates" ? "scores" : "projects_review"; + const todayDate = new Date().toLocaleDateString("ru-RU"); + const fullName = + (type !== "cv" ? prefixFileName + "-" : "") + + name + + (type !== "cv" ? "-" + todayDate + ".xlsx" : ".pdf"); + saveAs(blob, fullName); +};