diff --git a/docs/feature-specs/program-certificates.md b/docs/feature-specs/program-certificates.md new file mode 100644 index 000000000..e16eb236b --- /dev/null +++ b/docs/feature-specs/program-certificates.md @@ -0,0 +1,35 @@ + + +# Сертификаты в управлении программой + +Источник: утвержденная пользователем страница Figma `904:3219` в +`tvog0fpEgWgEtr7KUTjbyE`. Шаблон `904:3220`, автовыдача `904:3221`, +журнал ошибок `940:4465`, редактор подписи `916:4155`. + +В существующее управление добавляется вкладка **Сертификаты** с тремя шагами: +шаблон, автовыдача, отправки. Angular 20, действующие Mont и UI foundation. +Состояние и приватные данные не попадают в глобальный EntityCache. + +Фон PNG/JPG, прозрачная подпись PNG, директор и должность. Элементы перемещаются +мышью/касанием или стрелками, размер меняется за угол. Подпись сохраняет +пропорции. Нет формы координат. Доступны центрирование, возврат к сохраненному +размещению, увеличенный редактор, проверка длинного имени и пробный PDF. + +Два режима: все зарегистрированные либо участники команд со сданным решением +в текущей программе. Нет дополнительной проверки почты. Запуск вручную или +по дате после завершения; подтверждение, пробное письмо только себе, пауза. +Все адресаты и документы поступают из API, без демонстрационных участников. + +Журнал: независимые готовность PDF и отправка, Все/Отправлено/Ошибки, +серверный поиск/пагинация, повтор одной или всех ошибок, архив по фильтру. +Повтор не создает новый PDF и не затрагивает отправленные письма. +Неясный бейдж «Включено» заменен явной строкой управления очередью. + +Контракт: парный backend PR, `docs/modules/program-certificates.md`. +Маршрут: `/office/program/:programId/management/certificates`. +На странице программы участнику показывается ссылка на свой PDF. + +Приемка: роли и чужая программа; оба режима получателей; PNG/JPEG и лимит; +drag/resize/границы/клавиатура; длинная кириллица в A4 PDF; сохранение и 409; +расписание/пауза/повтор; фильтры и пустые/ошибочные состояния; мобильная ширина; +защита несохраненных изменений; сборка Angular и проверки backend. diff --git a/projects/social_platform/src/app/api/program/use-cases/program-certificates.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/program-certificates.use-case.ts new file mode 100644 index 000000000..9b9eaba31 --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/program-certificates.use-case.ts @@ -0,0 +1,20 @@ +/** @format */ +import { inject, Injectable } from "@angular/core"; +import { ProgramCertificatesRepositoryPort } from "@domain/program/ports/program-certificates.repository.port"; +@Injectable() +export class ProgramCertificatesUseCase { + private readonly repository = inject(ProgramCertificatesRepositoryPort); + readonly settings = this.repository.settings.bind(this.repository); + readonly save = this.repository.save.bind(this.repository); + readonly upload = this.repository.upload.bind(this.repository); + readonly asset = this.repository.asset.bind(this.repository); + readonly preview = this.repository.preview.bind(this.repository); + readonly test = this.repository.test.bind(this.repository); + readonly activate = this.repository.activate.bind(this.repository); + readonly queue = this.repository.queue.bind(this.repository); + readonly journal = this.repository.journal.bind(this.repository); + readonly retry = this.repository.retry.bind(this.repository); + readonly pdf = this.repository.pdf.bind(this.repository); + readonly archive = this.repository.archive.bind(this.repository); + readonly mine = this.repository.mine.bind(this.repository); +} diff --git a/projects/social_platform/src/app/domain/program/ports/program-certificates.repository.port.ts b/projects/social_platform/src/app/domain/program/ports/program-certificates.repository.port.ts new file mode 100644 index 000000000..adfb65702 --- /dev/null +++ b/projects/social_platform/src/app/domain/program/ports/program-certificates.repository.port.ts @@ -0,0 +1,37 @@ +/** @format */ +import { Observable } from "rxjs"; +import { + CertificateAsset, + CertificateDraft, + CertificateFilter, + CertificateJournal, + CertificateSettings, +} from "../program-certificates.model"; +export abstract class ProgramCertificatesRepositoryPort { + abstract settings(id: number): Observable; + abstract save( + id: number, + revision: number, + draft: CertificateDraft, + ): Observable; + abstract upload( + id: number, + kind: "background" | "signature", + file: File, + ): Observable; + abstract asset(id: number, assetId: number): Observable; + abstract preview(id: number, revision: number): Observable; + abstract test(id: number, revision: number): Observable<{ detail: string }>; + abstract activate(id: number, revision: number): Observable; + abstract queue(id: number, action: "pause" | "resume"): Observable; + abstract journal( + id: number, + status: CertificateFilter, + search: string, + offset: number, + ): Observable; + abstract retry(id: number, ids?: string[]): Observable<{ count: number }>; + abstract pdf(id: number, certificateId: string): Observable; + abstract archive(id: number, status: CertificateFilter, search: string): Observable; + abstract mine(id: number): Observable<{ id: string; issuedAt: string; url: string }[]>; +} diff --git a/projects/social_platform/src/app/domain/program/program-certificates.model.ts b/projects/social_platform/src/app/domain/program/program-certificates.model.ts new file mode 100644 index 000000000..9e12cc23a --- /dev/null +++ b/projects/social_platform/src/app/domain/program/program-certificates.model.ts @@ -0,0 +1,60 @@ +/** @format */ +export type CertificateElement = + | "title" + | "subtitle" + | "name" + | "program" + | "date" + | "number" + | "director" + | "position" + | "signature"; +export interface CertificateBox { + x: number; + y: number; + width: number; + height: number; + fontSize: number; +} +export interface CertificateAsset { + id: number; + name: string; + width: number; + height: number; +} +export interface CertificateDraft { + backgroundId: number | null; + signatureId: number | null; + director: string; + position: string; + showText: boolean; + audience: "all" | "submitted"; + timing: "manual" | "scheduled"; + scheduledAt: string | null; + sendEmail: boolean; + subject: string; + message: string; + layout: Record; +} +export interface CertificateSettings { + revision: number; + draft: CertificateDraft; + assets: { background: CertificateAsset | null; signature: CertificateAsset | null }; + eligibleCounts: { all: number; submitted: number }; + batch: { id: number; active: boolean; issueAt: string; initializedAt: string | null } | null; +} +export type CertificateFilter = "all" | "sent" | "failed"; +export interface CertificateDelivery { + id: string; + name: string; + email: string; + status: "pending" | "generating" | "sending" | "sent" | "failed" | "ready"; + pdfReady: boolean; + error: string; + sentAt: string | null; +} +export interface CertificateJournal { + count: number; + results: CertificateDelivery[]; + summary: { total: number; ready: number; sent: number; failed: number }; +} diff --git a/projects/social_platform/src/app/domain/program/program-management.model.ts b/projects/social_platform/src/app/domain/program/program-management.model.ts index 4a1586e0f..bff4d240d 100644 --- a/projects/social_platform/src/app/domain/program/program-management.model.ts +++ b/projects/social_platform/src/app/domain/program/program-management.model.ts @@ -10,6 +10,7 @@ export type ManagementSection = | "experts" | "results" | "analytics" + | "certificates" | "settings" | "evaluation"; export type SettingsSection = diff --git a/projects/social_platform/src/app/infrastructure/adapters/program/program-certificates-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/program/program-certificates-http.adapter.ts new file mode 100644 index 000000000..3297f451e --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/program/program-certificates-http.adapter.ts @@ -0,0 +1,69 @@ +/** @format */ +import { inject, Injectable } from "@angular/core"; +import { HttpParams } from "@angular/common/http"; +import { ApiService } from "@corelib"; +import { ProgramCertificatesRepositoryPort } from "@domain/program/ports/program-certificates.repository.port"; +import { + CertificateAsset, + CertificateDraft, + CertificateFilter, + CertificateJournal, + CertificateSettings, +} from "@domain/program/program-certificates.model"; +@Injectable() +export class ProgramCertificatesHttpAdapter implements ProgramCertificatesRepositoryPort { + private readonly api = inject(ApiService); + private root(id: number) { + return `/programs/${id}/management/certificates/`; + } + settings(id: number) { + return this.api.get(this.root(id)); + } + save(id: number, revision: number, draft: CertificateDraft) { + return this.api.put(this.root(id), { revision, draft }); + } + upload(id: number, kind: "background" | "signature", file: File) { + const body = new FormData(); + body.append("file", file); + body.append("kind", kind); + return this.api.post(this.root(id) + "assets/", body); + } + asset(id: number, assetId: number) { + return this.api.getFile(this.root(id) + `assets/${assetId}/`); + } + preview(id: number, revision: number) { + return this.api.postFile(this.root(id) + "preview/", { revision }); + } + test(id: number, revision: number) { + return this.api.post<{ detail: string }>(this.root(id) + "test/", { revision }); + } + activate(id: number, revision: number) { + return this.api.post(this.root(id) + "activate/", { revision }); + } + queue(id: number, action: "pause" | "resume") { + return this.api.post(this.root(id) + `queue/${action}/`, {}); + } + private query(status: CertificateFilter, search: string) { + return new HttpParams().set("status", status).set("search", search); + } + journal(id: number, status: CertificateFilter, search: string, offset: number) { + return this.api.get( + this.root(id) + "deliveries/", + this.query(status, search).set("offset", offset).set("limit", 20), + ); + } + retry(id: number, ids?: string[]) { + return this.api.post<{ count: number }>(this.root(id) + "retry/", ids ? { ids } : {}); + } + pdf(id: number, certificateId: string) { + return this.api.getFile(this.root(id) + `deliveries/${certificateId}/pdf/`); + } + archive(id: number, status: CertificateFilter, search: string) { + return this.api.getFile(this.root(id) + "archive/", this.query(status, search)); + } + mine(id: number) { + return this.api.get<{ id: string; issuedAt: string; url: string }[]>( + `/programs/${id}/certificates/me/`, + ); + } +} 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 index 016ece6a9..3e69e79f3 100644 --- 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 @@ -24,6 +24,9 @@
+ @if (program()!.isUserMember) { + + }

о программе

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 index 285fe23e1..8d284287e 100644 --- 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 @@ -31,6 +31,7 @@ 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"; +import { MyCertificateComponent } from "./my-certificate.component"; /** Страница основной вкладки программы с описанием, ссылками и новостями. */ @Component({ @@ -51,6 +52,7 @@ import { ProgramLinksComponent } from "@ui/widgets/program-links/program-links.c RouterModule, NewsCardComponent, ProgramLinksComponent, + MyCertificateComponent, ], providers: [ProgramDetailMainService, ExpandService], changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/projects/social_platform/src/app/ui/pages/program/detail/main/main.roles.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/main/main.roles.spec.ts index 3f9e31b43..576ed43cf 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/main/main.roles.spec.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/main/main.roles.spec.ts @@ -7,6 +7,8 @@ import { By } from "@angular/platform-browser"; import { BehaviorSubject, of } from "rxjs"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ProgramDetailMainComponent } from "./main.component"; +import { MyCertificateComponent } from "./my-certificate.component"; +import { ProgramCertificatesUseCase } from "@api/program/use-cases/program-certificates.use-case"; import { ProgramRoleWidgetComponent } from "./role-widget/program-role-widget.component"; import { NewsFormComponent } from "@ui/widgets/news-form/news-form.component"; import { NewsCardComponent } from "@ui/widgets/news-card/news-card.component"; @@ -50,6 +52,7 @@ describe("Program page: program-scoped news and role visibility", () => { const added = { ...news, id: 2, text: "Созданная организатором новость" }; const fetch = vi.fn(), add = vi.fn(); + const mine = vi.fn(); let data: BehaviorSubject<{ data: Program }>; beforeEach(async () => { @@ -57,6 +60,7 @@ describe("Program page: program-scoped news and role visibility", () => { data = new BehaviorSubject({ data: Program.default() }); fetch.mockReset().mockReturnValue(of(ok({ count: 1, results: [news] }))); add.mockReset().mockReturnValue(of(ok(added))); + mine.mockReset().mockReturnValue(of([])); vi.spyOn(ProgramDetailMainService.prototype, "initScroll").mockImplementation(() => {}); await TestBed.configureTestingModule({ imports: [ProgramDetailMainComponent], @@ -92,6 +96,9 @@ describe("Program page: program-scoped news and role visibility", () => { ].map(provide => ({ provide, useValue: {} })), ], }) + .overrideComponent(MyCertificateComponent, { + set: { providers: [{ provide: ProgramCertificatesUseCase, useValue: { mine } }] }, + }) .overrideComponent(ProgramDetailMainComponent, { remove: { imports: [NewsFormComponent, NewsCardComponent, ProgramRoleWidgetComponent], @@ -153,6 +160,7 @@ describe("Program page: program-scoped news and role visibility", () => { expect(fetch).toHaveBeenCalledOnce(); expect(f.nativeElement.textContent).toContain(news.text); expect(f.nativeElement.querySelector("app-news-form")).toBeNull(); + expect(mine).toHaveBeenCalledExactlyOnceWith(12); }); it("shows expert widget without requesting or revealing member/manager news", () => { const f = render({ isUserExpert: true }); @@ -161,6 +169,7 @@ describe("Program page: program-scoped news and role visibility", () => { expect(f.nativeElement.querySelector("app-program-role-widget")).toBeTruthy(); expect(f.nativeElement.querySelector("app-news-card,app-news-form")).toBeNull(); expect(fetch).not.toHaveBeenCalled(); + expect(mine).not.toHaveBeenCalled(); }); it("preserves advertisement and no internal content for an unrelated user", () => { const f = render({}); diff --git a/projects/social_platform/src/app/ui/pages/program/detail/main/my-certificate.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/main/my-certificate.component.spec.ts new file mode 100644 index 000000000..2028dbab2 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/main/my-certificate.component.spec.ts @@ -0,0 +1,67 @@ +/** @format */ +import { TestBed } from "@angular/core/testing"; +import { of, Subject, throwError } from "rxjs"; +import { ProgramCertificatesUseCase } from "@api/program/use-cases/program-certificates.use-case"; +import { MyCertificateComponent } from "./my-certificate.component"; + +function setup(mine = vi.fn(() => of([] as { id: string; issuedAt: string; url: string }[]))) { + TestBed.configureTestingModule({ imports: [MyCertificateComponent] }); + TestBed.overrideComponent(MyCertificateComponent, { + set: { providers: [{ provide: ProgramCertificatesUseCase, useValue: { mine } }] }, + }); + const fixture = TestBed.createComponent(MyCertificateComponent); + fixture.componentRef.setInput("programId", 12); + fixture.detectChanges(); + return { fixture, component: fixture.componentInstance, mine }; +} +describe("Participant certificate", () => { + it("shows only the own download returned by API", () => { + const row = { + id: "own", + issuedAt: "2026-10-24", + url: "https://dev.procollab.ru/programs/12/certificates/download/own/", + }; + const { fixture, mine } = setup(vi.fn(() => of([row]))); + fixture.detectChanges(); + expect(mine).toHaveBeenCalledExactlyOnceWith(12); + expect(fixture.nativeElement.querySelector("a").href).toBe(row.url); + fixture.destroy(); + }); + it("hides an empty state when no certificate has been issued", () => { + const empty = setup(); + empty.fixture.detectChanges(); + expect(empty.fixture.nativeElement.querySelector("section")).toBeNull(); + empty.fixture.destroy(); + }); + it("never displays a late certificate from the previously opened program", () => { + const old = new Subject(), + current = new Subject(); + const mine = vi.fn().mockReturnValueOnce(old).mockReturnValueOnce(current); + const { fixture, component } = setup(mine); + fixture.componentRef.setInput("programId", 13); + fixture.detectChanges(); + old.next([{ id: "old", url: "https://example.com/old" }]); + old.complete(); + current.next([]); + current.complete(); + fixture.detectChanges(); + expect(component.url()).toBeNull(); + expect(fixture.nativeElement.querySelector("a")).toBeNull(); + fixture.destroy(); + }); + it("offers a retry after a failed request without displaying a stale link", () => { + const { fixture, component, mine } = setup( + vi + .fn() + .mockReturnValueOnce(throwError(() => new Error("offline"))) + .mockReturnValueOnce(of([])), + ); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain("Не удалось проверить сертификат"); + component.load(); + fixture.detectChanges(); + expect(mine).toHaveBeenCalledTimes(2); + expect(component.error()).toBe(false); + fixture.destroy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/program/detail/main/my-certificate.component.ts b/projects/social_platform/src/app/ui/pages/program/detail/main/my-certificate.component.ts new file mode 100644 index 000000000..5a9ae483f --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/detail/main/my-certificate.component.ts @@ -0,0 +1,105 @@ +/** @format */ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + effect, + inject, + input, + signal, + untracked, +} from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { ButtonDirective } from "@uilib"; +import { ProgramCertificatesUseCase } from "@api/program/use-cases/program-certificates.use-case"; +import { ProgramCertificatesRepositoryPort } from "@domain/program/ports/program-certificates.repository.port"; +import { ProgramCertificatesHttpAdapter } from "@infrastructure/adapters/program/program-certificates-http.adapter"; +@Component({ + selector: "app-my-certificate", + imports: [ButtonDirective], + providers: [ + ProgramCertificatesUseCase, + { provide: ProgramCertificatesRepositoryPort, useClass: ProgramCertificatesHttpAdapter }, + ], + template: ` + @if (url()) { +
+
+

Ваш сертификат готов

+

Спасибо за участие в программе!

+
+ Скачать PDF +
+ } @else if (error()) { +
+

Не удалось проверить сертификат.

+ +
+ } + `, + styles: ` + @use "styles/ui-foundation"; + @use "styles/program-management-controls"; + :host { + display: block; + } + .ui-button.ui-button { + @include ui-foundation.button; + @include program-management-controls.compact-action; + } + .my-certificate { + display: flex; + gap: 16px; + align-items: center; + justify-content: space-between; + padding: 20px; + background: var(--accent-light); + border-radius: 12px; + margin-bottom: 20px; + font-size: 12px; + } + h3 { + margin: 0; + font-size: 14px; + } + p { + margin: 6px 0 0; + } + @media (width <= 600px) { + .my-certificate { + flex-wrap: wrap; + } + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MyCertificateComponent { + readonly programId = input.required(); + private readonly api = inject(ProgramCertificatesUseCase); + private readonly destroy = inject(DestroyRef); + readonly url = signal(null); + readonly error = signal(false); + private sequence = 0; + constructor() { + effect(() => { + this.programId(); + untracked(() => this.load()); + }); + } + load(): void { + const sequence = ++this.sequence; + this.error.set(false); + this.url.set(null); + this.api + .mine(this.programId()) + .pipe(takeUntilDestroyed(this.destroy)) + .subscribe({ + next: rows => { + if (sequence === this.sequence) this.url.set(rows[0]?.url ?? null); + }, + error: () => { + if (sequence === this.sequence) this.error.set(true); + }, + }); + } +} diff --git a/projects/social_platform/src/app/ui/pages/program/management/certificates/certificate-canvas.component.html b/projects/social_platform/src/app/ui/pages/program/management/certificates/certificate-canvas.component.html new file mode 100644 index 000000000..68f01d151 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/management/certificates/certificate-canvas.component.html @@ -0,0 +1,60 @@ + +
+ @if (background()) { + Фон сертификата + } + + @for (key of keys; track key) { + @if (visible(key)) { +
+ @if (key === "signature") { + Подпись + } @else { + {{ text(key) }} + } + @if (selected() === key) { + {{ labels[key] }} + + } +
+ } + } +
+
+ Выбрано: {{ labels[selected()] }} + + + + Перетащите элемент · потяните за угол для размера +
diff --git a/projects/social_platform/src/app/ui/pages/program/management/certificates/certificate-canvas.component.scss b/projects/social_platform/src/app/ui/pages/program/management/certificates/certificate-canvas.component.scss new file mode 100644 index 000000000..3d7357953 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/management/certificates/certificate-canvas.component.scss @@ -0,0 +1,134 @@ +/** @format */ +:host { + display: block; + min-width: 0; +} + +.canvas { + position: relative; + width: 100%; + aspect-ratio: 297 / 210; + overflow: hidden; + background: white; + box-shadow: 0 2px 16px #0000000d; + container-type: inline-size; + + &__background { + position: absolute; + width: 100%; + height: 100%; + object-fit: cover; + pointer-events: none; + } + + &__element { + position: absolute; + line-height: 1.3; + color: var(--black); + text-align: center; + touch-action: none; + cursor: grab; + user-select: none; + outline: 1px solid transparent; + + span:not(.canvas__label) { + display: block; + height: 100%; + overflow: hidden; + overflow-wrap: anywhere; + } + + img { + width: 100%; + height: 100%; + object-fit: contain; + pointer-events: none; + } + + &:active { + cursor: grabbing; + } + + &:hover, + &:focus-visible, + &--selected { + outline-color: var(--accent); + } + + &--bold { + font-weight: 600; + } + } + + &__element--selected { + z-index: 2; + } + + &__label { + position: absolute; + top: -20px; + left: 0; + padding: 2px 5px; + font-size: 10px; + line-height: 16px; + color: white; + white-space: nowrap; + pointer-events: none; + background: var(--accent); + border-radius: 4px; + } + + &__resize { + position: absolute; + right: -7px; + bottom: -7px; + width: 16px; + height: 16px; + padding: 0; + touch-action: none; + cursor: nwse-resize; + background: white; + border: 2px solid var(--accent); + } + + &__guide { + position: absolute; + left: 50%; + height: 100%; + pointer-events: none; + border-left: 1px dashed var(--accent-light); + opacity: 0; + } + + &:focus-within &__guide { + opacity: 0.6; + } + + &__tools { + display: flex; + flex-wrap: wrap; + gap: 8px 16px; + align-items: center; + margin-top: 14px; + font-size: 10px; + color: var(--grey-for-text); + + button { + padding: 8px; + font: inherit; + color: var(--accent); + cursor: pointer; + background: none; + border: 0; + } + } +} + +@media (width <= 600px) { + .canvas__resize { + right: -10px; + bottom: -10px; + width: 22px; + height: 22px; + } +} diff --git a/projects/social_platform/src/app/ui/pages/program/management/certificates/certificate-canvas.component.ts b/projects/social_platform/src/app/ui/pages/program/management/certificates/certificate-canvas.component.ts new file mode 100644 index 000000000..d43560762 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/management/certificates/certificate-canvas.component.ts @@ -0,0 +1,174 @@ +/** @format */ +import { ChangeDetectionStrategy, Component, input, output, signal } from "@angular/core"; +import { + CertificateBox, + CertificateDraft, + CertificateElement, +} from "@domain/program/program-certificates.model"; +import { moveCertificateBox, resizeCertificateBox } from "@utils/certificate-layout"; + +@Component({ + selector: "app-certificate-canvas", + templateUrl: "./certificate-canvas.component.html", + styleUrl: "./certificate-canvas.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CertificateCanvasComponent { + readonly draft = input.required(); + readonly programName = input.required(); + readonly background = input(null); + readonly signature = input(null); + readonly signatureRatio = input(2); + readonly longName = input(false); + readonly layoutChange = output(); + readonly selected = signal("name"); + readonly keys: CertificateElement[] = [ + "title", + "subtitle", + "name", + "program", + "date", + "number", + "director", + "position", + "signature", + ]; + readonly labels: Record = { + title: "Заголовок", + subtitle: "Подзаголовок", + name: "Имя участника", + program: "Название программы", + date: "Дата выдачи", + number: "Номер", + director: "Имя директора", + position: "Должность", + signature: "Подпись", + }; + private drag: { + key: CertificateElement; + box: CertificateBox; + x: number; + y: number; + rect: DOMRect; + resize: boolean; + pointer: number; + } | null = null; + visible(key: CertificateElement): boolean { + return ( + !((key === "title" || key === "subtitle") && !this.draft().showText) && + (key !== "signature" || !!this.signature()) + ); + } + text(key: CertificateElement): string { + const draft = this.draft(); + switch (key) { + case "title": + return "СЕРТИФИКАТ"; + case "subtitle": + return "подтверждает участие"; + case "name": + return this.longName() ? "Александра Константиновна Добровольская" : "Имя участника"; + case "program": + return this.programName(); + case "date": + return new Intl.DateTimeFormat("ru-RU", { timeZone: "Europe/Moscow" }).format(new Date()); + case "number": + return "№ ПРОБНЫЙ"; + case "director": + return draft.director || "Имя директора"; + case "position": + return draft.position || "Должность"; + default: + return ""; + } + } + font(key: CertificateElement): string { + const box = this.draft().layout[key]; + const size = Math.max( + 8, + Math.min( + box.fontSize, + (box.height * 7.937) / 1.35, + (box.width * 11.22 * 2.5) / Math.max(this.text(key).length, 1), + ), + ); + return `${size / 11.22}cqw`; + } + start(event: PointerEvent, key: CertificateElement, resize = false): void { + if (event.button !== 0) return; + event.preventDefault(); + event.stopPropagation(); + this.selected.set(key); + const target = event.currentTarget as HTMLElement; + const canvas = target.closest(".canvas"); + if (!canvas) return; + target.setPointerCapture(event.pointerId); + this.drag = { + key, + box: { ...this.draft().layout[key] }, + x: event.clientX, + y: event.clientY, + rect: canvas.getBoundingClientRect(), + resize, + pointer: event.pointerId, + }; + } + move(event: PointerEvent): void { + const drag = this.drag; + if (!drag || drag.pointer !== event.pointerId) return; + const dx = ((event.clientX - drag.x) / drag.rect.width) * 100; + const dy = ((event.clientY - drag.y) / drag.rect.height) * 100; + this.update( + drag.key, + drag.resize + ? resizeCertificateBox( + drag.box, + dx, + dy, + drag.key === "signature" ? this.signatureRatio() : undefined, + ) + : moveCertificateBox(drag.box, dx, dy), + ); + } + end(): void { + this.drag = null; + } + keydown(event: KeyboardEvent, key: CertificateElement): void { + const directions: Record = { + ArrowLeft: [-1, 0], + ArrowRight: [1, 0], + ArrowUp: [0, -1], + ArrowDown: [0, 1], + }; + const vector = directions[event.key]; + if (!vector) return; + event.preventDefault(); + this.selected.set(key); + const step = event.shiftKey ? 2 : 0.25; + this.update( + key, + moveCertificateBox(this.draft().layout[key], vector[0] * step, vector[1] * step), + ); + } + center(): void { + const key = this.selected(), + box = this.draft().layout[key]; + this.update(key, { ...box, x: (100 - box.width) / 2 }); + } + resizeSelected(scale: number): void { + const key = this.selected(), + box = this.draft().layout[key]; + this.update( + key, + resizeCertificateBox( + box, + box.width * scale, + box.height * scale, + key === "signature" ? this.signatureRatio() : undefined, + ), + ); + } + private update(key: CertificateElement, box: CertificateBox): void { + this.layoutChange.emit({ ...this.draft().layout, [key]: box }); + } +} diff --git a/projects/social_platform/src/app/ui/pages/program/management/certificates/certificates.component.html b/projects/social_platform/src/app/ui/pages/program/management/certificates/certificates.component.html new file mode 100644 index 000000000..b89c314c0 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/management/certificates/certificates.component.html @@ -0,0 +1,568 @@ + +
+

Сертификаты

+

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

+
+ +@if (error()) { + +} +@if (message()) { + {{ + message() + }} +} +@if (loading()) { +
Загружаем настройки сертификатов…
+} @else if (draft(); as d) { + @if (step() === 1) { +
+
+

Шаблон сертификата

+

А4 · альбомный · 297 × 210 мм

+
+

Фон сертификата

+

{{ assets().background?.name || "Загрузите свой фон" }}

+

PNG или JPG до 10 МБ

+ + + @if (assets().background) { + + } +
+ +

Выключите, если текст уже есть на фоне.

+ + +
+

Подпись

+

{{ assets().signature?.name || "PNG с прозрачным фоном до 10 МБ" }}

+ + + @if (assets().signature) { + + } +
+
+
+
+

Предпросмотр

+ +
+
+ +
+ +

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

+
+ +
+ @if (dirty()) { +

Сохраните изменения, чтобы проверить их в PDF.

+ } +
+
+ } + @if (step() === 2) { +
+

Кому выдавать сертификаты

+
+ + +
+

+ Состав участников проверяется в момент запуска. Один участник получает один сертификат. +

+

Когда выдать

+ + + @if (d.timing === "scheduled") { + + } +
+ +

+ В письме будет персональная ссылка на PDF. Сертификат также доступен на странице программы. +

+ @if (d.sendEmail) { + + +

Подстановки: {{ placeholders }}

+ + } +
+
+ Подходят под условие: {{ recipients() }} +

+ Ранее выданные сертификаты и отправленные письма не дублируются. +

+
+ +
+ @if (snapshot()?.batch?.active) { +

+ Выдача уже работает. Управление очередью — на шаге «Отправки». +

+ } +
+ } + @if (step() === 3) { +
+
+
+ {{ journal()?.summary?.ready ?? 0 }}PDF готовы +
+
+ {{ journal()?.summary?.sent ?? 0 }}Отправлено +
+
+ {{ journal()?.summary?.failed ?? 0 }}Ошибки +
+
+ @if (snapshot()?.batch; as batch) { +
+

+ {{ batch.active ? "Автовыдача работает" : "Выдача приостановлена" }} · запуск + {{ batch.issueAt | date: "dd.MM.yyyy, HH:mm" : "+0300" }} (МСК) +

+ +
+ } + +
+ + + +
+ @if (journalError()) { +

+ {{ journalError() }} + +

+ } +
+ + + + + + + + + + + @for (row of journal()?.results; track row.id) { + + + + + + + } @empty { + + + + } + +
УчастникОтправкаСертификатДействие
+ {{ row.name }}{{ row.email }} + + {{ + labels[row.status] + }}{{ + row.error || (row.sentAt | date: "dd.MM.yyyy, HH:mm" : "+0300") + }} + {{ row.pdfReady ? "PDF готов" : "Готовится" }} + @if (row.status === "failed") { + + } + @if (row.pdfReady) { + + } +
+ {{ + journalLoading() + ? "Загружаем отправки…" + : filter() === "failed" + ? "Ошибок отправки не найдено." + : search() + ? "Никого не нашли. Измените запрос." + : "Отправок пока нет. Настройте шаблон и запустите выдачу." + }} +
+
+ +

+ Повтор использует готовый PDF. «Отправлено» означает, что письмо принято почтовым сервисом. +

+
+ } + @if (step() !== 3) { +
+ {{ dirty() ? "Есть несохраненные изменения" : "Все изменения сохранены" }} + @if (snapshot()?.batch) { + Изменения применятся к следующей выдаче. + } + +
+ + @if (step() === 1) { + + } +
+
+ } + +
+
+
+

Размещение элементов

+

Перетащите элемент. Потяните за угол, чтобы изменить размер.

+
+ +
+ +
+
+ +
+

Запустить выдачу сертификатов?

+

+ {{ + d.audience === "all" + ? "Всем зарегистрированным участникам" + : "Только участникам, сдавшим решение" + }} + · сейчас подходят {{ recipients() }}. +

+

+ {{ + d.sendEmail + ? "После создания PDF участники получат письмо со ссылкой на сертификат." + : "PDF будут доступны участникам на странице программы, без отправки письма." + }} +

+

+ {{ + d.timing === "manual" + ? "Запуск сразу после подтверждения." + : "Запуск по указанной дате после окончания программы." + }} +

+
+ +
+
+
+} diff --git a/projects/social_platform/src/app/ui/pages/program/management/certificates/certificates.component.scss b/projects/social_platform/src/app/ui/pages/program/management/certificates/certificates.component.scss new file mode 100644 index 000000000..79f454685 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/management/certificates/certificates.component.scss @@ -0,0 +1,519 @@ +/** @format */ +@use "styles/ui-foundation"; +@use "styles/program-management-controls"; + +:host { + display: block; + min-width: 0; + font-size: 12px; + line-height: 1.5; + color: var(--black); +} + +.ui-button.ui-button { + @include ui-foundation.button; + @include program-management-controls.compact-action; +} + +.ui-field { + @include ui-foundation.control; + + width: 100%; + font-size: 12px; +} + +.ui-choice { + @include ui-foundation.choice; +} + +h2, +h3, +h4, +p { + margin: 0; +} + +h2 { + font-size: 24px; + font-weight: 600; +} + +h3 { + font-size: 14px; + font-weight: 600; +} + +h4 { + font-size: 12px; + font-weight: 600; +} + +button { + font: inherit; + cursor: pointer; + + &:disabled { + cursor: default; + opacity: 0.5; + } +} + +.certificates { + &__heading { + margin-bottom: 20px; + + p { + margin-top: 6px; + color: var(--grey-for-text); + } + } + + &__steps, + &__filters { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 20px; + + button { + min-width: 150px; + min-height: 32px; + padding: 6px 20px; + color: var(--black); + background: var(--grey-button); + border: 0; + border-radius: 40px; + + &[aria-current="step"], + &[aria-pressed="true"] { + color: white; + background: var(--accent); + } + } + } + + &__card { + min-width: 0; + padding: 24px; + margin: 0; + background: white; + border: 0; + border-radius: 16px; + } + + &__notice { + margin: 12px 0; + } + + &__template { + display: grid; + grid-template-columns: 225px minmax(0, 1fr); + gap: 24px; + } + + &__settings { + min-width: 0; + padding-right: 20px; + border-right: 1px solid var(--medium-grey-for-outline); + } + + &__muted { + margin-top: 6px; + font-size: 11px; + color: var(--grey-for-text); + } + + &__upload { + padding: 16px 12px; + margin: 20px 0; + border: 1px dashed var(--gray); + border-radius: 10px; + + p { + margin: 6px 0; + font-size: 11px; + overflow-wrap: anywhere; + } + + .ui-button { + margin-top: 8px; + } + } + + &__field { + display: grid; + gap: 8px; + margin-top: 18px; + + small { + color: var(--grey-for-text); + } + + textarea { + min-height: 120px; + resize: vertical; + } + } + + &__check { + display: flex; + gap: 8px; + align-items: flex-start; + margin: 12px 0 4px; + + input { + flex-shrink: 0; + } + } + + &__row, + &__queue, + &__footer, + &__launch, + &__save { + display: flex; + gap: 16px; + align-items: center; + justify-content: space-between; + } + + &__preview { + min-width: 0; + } + + &__paper { + padding: 24px 16px 12px; + margin: 12px 0 16px; + background: var(--white); + border-radius: 12px; + } + + &__text-button { + padding: 8px 0; + font-size: 11px; + color: var(--accent); + background: none; + border: 0; + } + + &__actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-top: 12px; + } + + &__automation { + h3:not(:first-child) { + margin-top: 28px; + } + + .ui-button { + margin-top: 12px; + } + } + + &__audience { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-top: 16px; + } + + &__option { + display: flex; + gap: 10px; + align-items: flex-start; + padding: 18px 16px; + cursor: pointer; + border: 1px solid var(--gray); + border-radius: 12px; + + strong, + small { + display: block; + } + + small { + margin-top: 6px; + font-size: 11px; + color: var(--grey-for-text); + } + + &--selected { + background: var(--accent-light); + border-color: var(--accent); + } + } + + &__date { + max-width: 300px; + } + + &__divider { + margin: 24px 0; + border-top: 1px solid var(--medium-grey-for-outline); + } + + &__launch { + padding: 20px 0 0; + margin-top: 24px; + border-top: 1px solid var(--medium-grey-for-outline); + } + + &__metrics { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; + margin-bottom: 20px; + + strong { + display: block; + font-size: 26px; + line-height: 1.3; + } + + span { + display: block; + margin-top: 6px; + font-size: 11px; + color: var(--grey-for-text); + } + } + + &__queue { + margin: 20px 0; + + .ui-button { + flex-shrink: 0; + min-width: 160px; + } + } + + &__search { + display: block; + margin-bottom: 16px; + } + + &__filters { + margin-bottom: 8px; + } + + &__table { + overflow-x: auto; + + table { + width: 100%; + font-size: 12px; + text-align: left; + border-collapse: collapse; + } + + th { + font-size: 11px; + font-weight: 400; + color: var(--grey-for-text); + } + + th, + td { + padding: 16px 8px; + border-bottom: 1px solid var(--medium-grey-for-outline); + } + + td:first-child, + th:first-child { + padding-left: 0; + } + + td:first-child { + width: 34%; + } + + td { + min-width: 100px; + vertical-align: middle; + } + + strong { + font-weight: 400; + } + + small { + display: block; + margin-top: 4px; + font-size: 10px; + color: var(--grey-for-text); + overflow-wrap: anywhere; + } + + td:last-child .ui-button { + width: 100%; + margin: 4px 0; + } + } + + &__error { + color: var(--red-dark, #b22b38); + } + + &__empty { + padding: 36px 12px !important; + color: var(--grey-for-text); + text-align: center; + } + + &__footer { + flex-wrap: wrap; + margin-top: 16px; + } + + &__pages { + display: flex; + gap: 10px; + align-items: center; + font-size: 11px; + color: var(--grey-for-text); + + button { + padding: 8px; + color: var(--accent); + background: transparent; + border: 0; + } + } + + &__footnote { + margin-top: 16px; + } + + &__save { + flex-wrap: wrap; + margin: 20px 0; + font-size: 11px; + color: var(--grey-for-text); + + small { + display: block; + } + } + + &__editor { + min-width: 0; + padding: 24px; + + .certificates__row { + align-items: flex-start; + margin-bottom: 28px; + } + + .ui-button { + flex-shrink: 0; + min-width: 88px; + white-space: nowrap; + } + + p { + margin-top: 8px; + color: var(--grey-for-text); + } + } + + &__confirmation { + padding: 24px; + + p { + margin-top: 16px; + } + + h2 { + font-size: 20px; + } + + .certificates__actions { + justify-content: flex-end; + margin-top: 24px; + } + } +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); +} + +@media (width <= 1000px) { + .certificates__template { + grid-template-columns: 200px minmax(0, 1fr); + gap: 16px; + } +} + +@media (width <= 700px) { + .certificates { + &__card { + padding: 16px; + } + + &__template, + &__audience { + grid-template-columns: 1fr; + } + + &__settings { + padding-right: 0; + border-right: 0; + } + + &__steps, + &__filters { + gap: 6px; + + button { + flex: 1; + min-width: 0; + padding: 8px; + } + } + + &__queue, + &__launch { + flex-wrap: wrap; + } + + &__queue .ui-button, + &__launch .ui-button { + width: 100%; + } + + &__metrics strong { + font-size: 22px; + } + + &__editor { + padding: 12px; + + h2 { + font-size: 20px; + } + } + + &__filters button { + padding: 8px 4px; + font-size: 10px; + white-space: nowrap; + } + + &__table { + th, + td { + padding: 12px 6px; + font-size: 11px; + } + } + } +} diff --git a/projects/social_platform/src/app/ui/pages/program/management/certificates/certificates.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/management/certificates/certificates.component.spec.ts new file mode 100644 index 000000000..0fad956e5 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/management/certificates/certificates.component.spec.ts @@ -0,0 +1,135 @@ +/** @format */ +import { TestBed } from "@angular/core/testing"; +import { of, Subject } from "rxjs"; +import { describe, expect, it, vi } from "vitest"; +import { ProgramCertificatesRepositoryPort } from "@domain/program/ports/program-certificates.repository.port"; +import { ProgramCertificatesUseCase } from "@api/program/use-cases/program-certificates.use-case"; +import { CertificateDraft, CertificateSettings } from "@domain/program/program-certificates.model"; +import { ManagementCertificatesComponent } from "./certificates.component"; +const keys = [ + "title", + "subtitle", + "name", + "program", + "date", + "number", + "director", + "position", + "signature", +]; +function settings(): CertificateSettings { + return { + revision: 1, + draft: { + backgroundId: null, + signatureId: null, + director: "", + position: "", + showText: true, + audience: "submitted", + timing: "manual", + scheduledAt: null, + sendEmail: true, + subject: "Сертификат {program}", + message: "Здравствуйте, {name}!", + layout: Object.fromEntries( + keys.map(key => [key, { x: 10, y: 20, width: 60, height: 10, fontSize: 18 }]), + ) as CertificateDraft["layout"], + }, + assets: { background: null, signature: null }, + eligibleCounts: { all: 5, submitted: 2 }, + batch: null, + }; +} +async function setup() { + const data = settings(); + const repository = { + settings: vi.fn(() => of(data)), + save: vi.fn(() => of({ ...data, revision: 2 })), + journal: vi.fn(() => + of({ count: 0, results: [], summary: { total: 0, ready: 0, sent: 0, failed: 0 } }), + ), + retry: vi.fn(() => of({ count: 1 })), + preview: vi.fn(), + test: vi.fn(), + asset: vi.fn(), + upload: vi.fn(), + activate: vi.fn(), + queue: vi.fn(), + pdf: vi.fn(), + archive: vi.fn(), + mine: vi.fn(), + }; + TestBed.configureTestingModule({ imports: [ManagementCertificatesComponent] }); + TestBed.overrideComponent(ManagementCertificatesComponent, { + set: { + providers: [ + ProgramCertificatesUseCase, + { provide: ProgramCertificatesRepositoryPort, useValue: repository }, + ], + }, + }); + const fixture = TestBed.createComponent(ManagementCertificatesComponent); + fixture.componentRef.setInput("programId", 9); + fixture.componentRef.setInput("programName", "Моя программа"); + fixture.componentRef.setInput("confirmDiscard", () => Promise.resolve(true)); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + return { fixture, component: fixture.componentInstance, repository }; +} +describe("Program certificates", () => { + it("loads a real program and shows no invented participants or email prerequisite", async () => { + const { fixture, component, repository } = await setup(); + expect(repository.settings).toHaveBeenCalledWith(9); + expect(component.recipients()).toBe(2); + expect(fixture.nativeElement.textContent).not.toContain("подтверждения почты"); + expect(fixture.nativeElement.textContent).not.toContain("Включено"); + fixture.destroy(); + }); + it("tracks drag/form changes for the parent navigation guard and discards them", async () => { + const { fixture, component } = await setup(); + const emit = vi.spyOn(component.statusChange, "emit"); + component.patch({ director: "Новый директор" }); + fixture.detectChanges(); + expect(component.dirty()).toBe(true); + expect(emit).toHaveBeenCalledWith({ dirty: true, busy: false }); + component.discard(); + fixture.detectChanges(); + expect(component.dirty()).toBe(false); + fixture.destroy(); + }); + it("requests only failed deliveries and resets page when switching filters", async () => { + const { fixture, component, repository } = await setup(); + component.offset.set(40); + component.search.set("Крылова"); + component.setFilter("failed"); + await fixture.whenStable(); + expect(repository.journal).toHaveBeenLastCalledWith(9, "failed", "Крылова", 0); + fixture.destroy(); + }); + it("ignores a slower response from a previously selected filter", async () => { + const { fixture, component, repository } = await setup(); + const old = new Subject(), + current = new Subject(); + repository.journal.mockReturnValueOnce(old).mockReturnValueOnce(current); + component.setFilter("all"); + component.setFilter("failed"); + const data = { count: 1, results: [], summary: { total: 2, ready: 2, sent: 1, failed: 1 } }; + current.next(data); + current.complete(); + await fixture.whenStable(); + old.next({ ...data, count: 2 }); + old.complete(); + await fixture.whenStable(); + expect(component.journal()?.count).toBe(1); + fixture.destroy(); + }); + it("retries a single selected error without resubmitting the template", async () => { + const { fixture, component, repository } = await setup(); + await component.retry("certificate-id"); + expect(repository.retry).toHaveBeenCalledWith(9, ["certificate-id"]); + expect(repository.activate).not.toHaveBeenCalled(); + fixture.destroy(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/program/management/certificates/certificates.component.ts b/projects/social_platform/src/app/ui/pages/program/management/certificates/certificates.component.ts new file mode 100644 index 000000000..c569ec3be --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/management/certificates/certificates.component.ts @@ -0,0 +1,345 @@ +/** @format */ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + computed, + effect, + inject, + input, + output, + signal, + untracked, +} from "@angular/core"; +import { DatePipe } from "@angular/common"; +import { FormsModule } from "@angular/forms"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { Observable, Subject, debounceTime, firstValueFrom, interval } from "rxjs"; +import { saveAs } from "file-saver"; +import { AlertDirective, ButtonDirective, ChoiceDirective, FieldDirective } from "@uilib"; +import { ModalComponent } from "@ui/primitives/modal/modal.component"; +import { TransientNoticeComponent } from "@ui/primitives/transient-notice/transient-notice.component"; +import { ProgramCertificatesUseCase } from "@api/program/use-cases/program-certificates.use-case"; +import { ProgramCertificatesRepositoryPort } from "@domain/program/ports/program-certificates.repository.port"; +import { ProgramCertificatesHttpAdapter } from "@infrastructure/adapters/program/program-certificates-http.adapter"; +import { + CertificateAsset, + CertificateDraft, + CertificateFilter, + CertificateJournal, + CertificateSettings, +} from "@domain/program/program-certificates.model"; +import { evaluationError } from "@utils/evaluation-workspace"; +import { fromMoscowInput, toMoscowInput } from "@utils/program-management"; +import { CertificateCanvasComponent } from "./certificate-canvas.component"; + +@Component({ + selector: "app-management-certificates", + imports: [ + FormsModule, + DatePipe, + ButtonDirective, + FieldDirective, + ChoiceDirective, + AlertDirective, + ModalComponent, + TransientNoticeComponent, + CertificateCanvasComponent, + ], + providers: [ + ProgramCertificatesUseCase, + { provide: ProgramCertificatesRepositoryPort, useClass: ProgramCertificatesHttpAdapter }, + ], + templateUrl: "./certificates.component.html", + styleUrl: "./certificates.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ManagementCertificatesComponent { + readonly programId = input.required(); + readonly programName = input.required(); + readonly confirmDiscard = input.required<() => Promise>(); + readonly statusChange = output<{ dirty: boolean; busy: boolean }>(); + private readonly api = inject(ProgramCertificatesUseCase); + private readonly destroy = inject(DestroyRef); + readonly snapshot = signal(null); + readonly draft = signal(null); + readonly assets = signal({ background: null, signature: null }); + readonly backgroundUrl = signal(null); + readonly signatureUrl = signal(null); + readonly loading = signal(true); + readonly busy = signal(false); + readonly error = signal(null); + readonly message = signal(null, { equal: () => false }); + readonly step = signal(1); + readonly editorOpen = signal(false); + readonly confirmOpen = signal(false); + readonly longName = signal(false); + readonly journal = signal(null); + readonly journalLoading = signal(false); + readonly journalError = signal(null); + readonly filter = signal("all"); + readonly search = signal(""); + readonly offset = signal(0); + readonly dirty = computed( + () => JSON.stringify(this.draft()) !== JSON.stringify(this.snapshot()?.draft ?? null), + ); + readonly recipients = computed( + () => this.snapshot()?.eligibleCounts[this.draft()?.audience ?? "submitted"] ?? 0, + ); + readonly signatureRatio = computed(() => { + const a = this.assets().signature; + return a ? a.width / a.height : 2; + }); + readonly scheduleInput = computed(() => + this.draft()?.scheduledAt ? toMoscowInput(this.draft()!.scheduledAt) : "", + ); + readonly steps = ["1. Шаблон", "2. Автовыдача", "3. Отправки"]; + readonly placeholders = "{name}, {program}, {date}, {number}"; + readonly labels = { + pending: "В очереди", + generating: "Создаем PDF", + sending: "Отправляем", + sent: "Отправлено", + failed: "Ошибка", + ready: "Без отправки", + }; + private readonly searchChanges = new Subject(); + private sequence = 0; + private loadSequence = 0; + private objectUrls = new Set(); + constructor() { + effect(() => { + this.programId(); + untracked(() => void this.reload()); + }); + effect(() => + this.statusChange.emit({ dirty: this.dirty(), busy: this.busy() || this.loading() }), + ); + this.searchChanges.pipe(debounceTime(300), takeUntilDestroyed(this.destroy)).subscribe(() => { + this.offset.set(0); + void this.loadJournal(); + }); + interval(15000) + .pipe(takeUntilDestroyed(this.destroy)) + .subscribe(() => { + if ( + this.step() === 3 && + !this.journalLoading() && + !this.busy() && + this.snapshot()?.batch?.active + ) + void this.loadJournal(); + }); + this.destroy.onDestroy(() => this.objectUrls.forEach(url => URL.revokeObjectURL(url))); + } + private observe(request: Observable): Promise { + return firstValueFrom(request.pipe(takeUntilDestroyed(this.destroy))); + } + private accept(settings: CertificateSettings): void { + this.snapshot.set(settings); + this.draft.set(structuredClone(settings.draft)); + this.assets.set(settings.assets); + void this.loadAssets(); + } + async reload(): Promise { + if (this.dirty() && !(await this.confirmDiscard()())) return; + const sequence = ++this.loadSequence; + this.loading.set(true); + this.error.set(null); + try { + const data = await this.observe(this.api.settings(this.programId())); + if (sequence === this.loadSequence) this.accept(data); + } catch (error) { + if (sequence === this.loadSequence) this.error.set(evaluationError(error)); + } finally { + if (sequence === this.loadSequence) this.loading.set(false); + } + } + async loadAssets(): Promise { + const assets = this.assets(); + for (const kind of ["background", "signature"] as const) { + const target = kind === "background" ? this.backgroundUrl : this.signatureUrl; + const asset = assets[kind]; + if (!asset) { + this.release(target()); + target.set(null); + continue; + } + try { + const blob = await this.observe(this.api.asset(this.programId(), asset.id)); + if (this.assets()[kind]?.id !== asset.id) continue; + this.release(target()); + const url = URL.createObjectURL(blob); + this.objectUrls.add(url); + target.set(url); + } catch { + this.error.set("Не удалось загрузить изображение. Обновите шаблон."); + } + } + } + private release(url: string | null): void { + if (url) { + URL.revokeObjectURL(url); + this.objectUrls.delete(url); + } + } + patch(values: Partial): void { + this.draft.update(d => (d ? { ...d, ...values } : d)); + } + schedule(value: string): void { + this.patch({ scheduledAt: value ? fromMoscowInput(value) : null }); + } + discard(): void { + const snapshot = this.snapshot(); + if (!snapshot) return; + this.draft.set(structuredClone(snapshot.draft)); + this.assets.set(snapshot.assets); + void this.loadAssets(); + } + private async run(request: () => Observable, accept: (value: T) => void): Promise { + if (this.busy()) return; + this.busy.set(true); + this.error.set(null); + try { + accept(await this.observe(request())); + } catch (error) { + this.error.set(evaluationError(error)); + } finally { + this.busy.set(false); + } + } + save(): Promise { + return this.run( + () => this.api.save(this.programId(), this.snapshot()!.revision, this.draft()!), + data => { + this.accept(data); + this.message.set("Настройки сертификата сохранены."); + }, + ); + } + async upload(event: Event, kind: "background" | "signature"): Promise { + const input = event.target as HTMLInputElement, + file = input.files?.[0]; + input.value = ""; + if (!file) return; + if (file.size > 10 * 1024 * 1024) { + this.error.set("Размер файла не должен превышать 10 МБ."); + return; + } + await this.run( + () => this.api.upload(this.programId(), kind, file), + asset => this.setAsset(kind, asset), + ); + } + private setAsset(kind: "background" | "signature", asset: CertificateAsset): void { + this.assets.update(assets => ({ ...assets, [kind]: asset })); + this.patch(kind === "background" ? { backgroundId: asset.id } : { signatureId: asset.id }); + if (kind === "signature") { + const draft = this.draft()!; + const box = draft.layout.signature; + const ratio = asset.width / asset.height; + const height = Math.min((box.width * (297 / 210)) / ratio, 100 - box.y); + this.patch({ + layout: { + ...draft.layout, + signature: { ...box, height, width: (height * ratio) / (297 / 210) }, + }, + }); + } + void this.loadAssets(); + } + remove(kind: "background" | "signature"): void { + this.assets.update(assets => ({ ...assets, [kind]: null })); + this.patch(kind === "background" ? { backgroundId: null } : { signatureId: null }); + void this.loadAssets(); + } + resetLayout(): void { + if (this.snapshot()) this.patch({ layout: structuredClone(this.snapshot()!.draft.layout) }); + } + preview(): Promise { + return this.run( + () => this.api.preview(this.programId(), this.snapshot()!.revision), + blob => saveAs(blob, "certificate-preview.pdf"), + ); + } + test(): Promise { + return this.run( + () => this.api.test(this.programId(), this.snapshot()!.revision), + data => this.message.set(data.detail), + ); + } + activate(): Promise { + return this.run( + () => this.api.activate(this.programId(), this.snapshot()!.revision), + data => { + this.accept(data); + this.confirmOpen.set(false); + this.step.set(3); + this.message.set("Выдача запущена. Прогресс появится в журнале."); + void this.loadJournal(); + }, + ); + } + queue(): Promise { + return this.run( + () => this.api.queue(this.programId(), this.snapshot()?.batch?.active ? "pause" : "resume"), + data => { + this.snapshot.set(data); + void this.loadJournal(); + }, + ); + } + setStep(step: number): void { + this.step.set(step); + if (step === 3) void this.loadJournal(); + } + setFilter(filter: CertificateFilter): void { + this.filter.set(filter); + this.offset.set(0); + void this.loadJournal(); + } + searchChanged(value: string): void { + this.search.set(value); + this.searchChanges.next(); + } + async loadJournal(): Promise { + const sequence = ++this.sequence; + this.journalLoading.set(true); + this.journalError.set(null); + try { + const data = await this.observe( + this.api.journal(this.programId(), this.filter(), this.search(), this.offset()), + ); + if (sequence === this.sequence) this.journal.set(data); + } catch (error) { + if (sequence === this.sequence) this.journalError.set(evaluationError(error)); + } finally { + if (sequence === this.sequence) this.journalLoading.set(false); + } + } + page(delta: number): void { + this.offset.update(offset => Math.max(0, offset + delta * 20)); + void this.loadJournal(); + } + retry(id?: string): Promise { + return this.run( + () => this.api.retry(this.programId(), id ? [id] : undefined), + data => { + this.message.set(`Поставлено в очередь: ${data.count}.`); + void this.loadJournal(); + }, + ); + } + pdf(id: string): Promise { + return this.run( + () => this.api.pdf(this.programId(), id), + blob => saveAs(blob, `certificate-${id.slice(0, 8)}.pdf`), + ); + } + archive(): Promise { + return this.run( + () => this.api.archive(this.programId(), this.filter(), this.search()), + blob => saveAs(blob, "certificates.zip"), + ); + } +} diff --git a/projects/social_platform/src/app/ui/pages/program/management/management.component.html b/projects/social_platform/src/app/ui/pages/program/management/management.component.html index d2144cd8f..49f9a7497 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/management.component.html +++ b/projects/social_platform/src/app/ui/pages/program/management/management.component.html @@ -44,6 +44,14 @@

{{ requestError() }}

Загружаем данные программы… } @else if (state.program(); as program) { + @if (section() === "certificates") { + + } @if (section() === "results") { } diff --git a/projects/social_platform/src/app/ui/pages/program/management/management.component.ts b/projects/social_platform/src/app/ui/pages/program/management/management.component.ts index 17ec3f87a..90ef9c239 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/management.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/management/management.component.ts @@ -77,6 +77,7 @@ import { ResultProjectScoresComponent } from "./results/result-project-scores.co import { ManagementResultsComponent } from "./results/results.component"; import { ManagementOrganizersComponent } from "./organizers/organizers.component"; import { ManagementRegistrationComponent } from "./registration/registration.component"; +import { ManagementCertificatesComponent } from "./certificates/certificates.component"; import { presentationHref } from "@utils/program-case-analytics"; import { evaluationLabel, fromMoscowInput, toMoscowInput } from "@utils/program-management"; import { DEFAULT_IMAGE_FRAME, programImageFraming } from "@utils/program-branding"; @@ -115,6 +116,7 @@ import { TransientNoticeComponent } from "@ui/primitives/transient-notice/transi ResultProjectScoresComponent, ManagementOrganizersComponent, ManagementRegistrationComponent, + ManagementCertificatesComponent, ], providers: [ ProgramManagementService, @@ -232,6 +234,8 @@ export class ProgramManagementComponent implements OnInit { }); private searchNavigation: { value: string } | null = null; readonly saving = signal(false); + readonly certificatesForm = viewChild(ManagementCertificatesComponent); + readonly certificatesStatus = signal({ dirty: false, busy: false }); readonly uploading = signal(null); readonly formError = signal(null); readonly saved = signal(false, { equal: () => false }); @@ -283,6 +287,7 @@ export class ProgramManagementComponent implements OnInit { { id: "results", label: "Результаты" }, { id: "analytics", label: "Аналитика" }, { id: "settings", label: "Настройки" }, + { id: "certificates", label: "Сертификаты" }, ]; readonly settingsTabs = [ { id: "basic", label: "Основное" }, @@ -414,7 +419,8 @@ export class ProgramManagementComponent implements OnInit { this.criteriaStatus().busy || this.expertsStatus().busy || this.organizersStatus().busy || - this.registrationStatus().busy + this.registrationStatus().busy || + this.certificatesStatus().busy ) { event.preventDefault(); event.returnValue = ""; @@ -440,6 +446,7 @@ export class ProgramManagementComponent implements OnInit { "experts", "results", "analytics", + "certificates", "settings", "evaluation", ].includes(raw) @@ -1080,7 +1087,8 @@ export class ProgramManagementComponent implements OnInit { this.criteriaStatus().dirty || this.expertsStatus().dirty || this.organizersStatus().dirty || - this.registrationStatus().dirty + this.registrationStatus().dirty || + this.certificatesStatus().dirty ); } async canLeave(): Promise { @@ -1093,7 +1101,8 @@ export class ProgramManagementComponent implements OnInit { this.criteriaStatus().busy || this.expertsStatus().busy || this.organizersStatus().busy || - this.registrationStatus().busy + this.registrationStatus().busy || + this.certificatesStatus().busy ) return false; if (!this.dirty()) return true; @@ -1118,6 +1127,8 @@ export class ProgramManagementComponent implements OnInit { this.organizersStatus.set({ dirty: false, busy: false }); this.registrationForm()?.discard(); this.registrationStatus.set({ dirty: false, busy: false }); + this.certificatesForm()?.discard(); + this.certificatesStatus.set({ dirty: false, busy: false }); } return yes; } diff --git a/projects/social_platform/src/app/utils/certificate-layout.spec.ts b/projects/social_platform/src/app/utils/certificate-layout.spec.ts new file mode 100644 index 000000000..5f7c8499e --- /dev/null +++ b/projects/social_platform/src/app/utils/certificate-layout.spec.ts @@ -0,0 +1,27 @@ +/** @format */ +import { describe, expect, it } from "vitest"; +import { moveCertificateBox, resizeCertificateBox } from "./certificate-layout"; +const box = { x: 10, y: 20, width: 30, height: 15, fontSize: 18 }; +describe("Certificate layout geometry", () => { + it("keeps dragged elements inside the page at every edge", () => { + expect(moveCertificateBox(box, -100, -100)).toMatchObject({ x: 0, y: 0 }); + expect(moveCertificateBox(box, 100, 100)).toMatchObject({ x: 70, y: 85 }); + }); + it("snaps near the center without changing its size", () => { + expect(moveCertificateBox(box, 24.5, 0)).toEqual({ ...box, x: 35 }); + }); + it("preserves the actual signature ratio on landscape A4", () => { + const resized = resizeCertificateBox(box, 20, 80, 3); + expect((resized.width * (297 / 210)) / resized.height).toBeCloseTo(3); + expect(resized.height + resized.y).toBeLessThanOrEqual(100); + }); + it("limits a tall signature by remaining height without stretching", () => { + const resized = resizeCertificateBox({ ...box, y: 80 }, 100, 0, 0.5); + expect(resized.height).toBe(20); + expect((resized.width * (297 / 210)) / resized.height).toBeCloseTo(0.5); + }); + it("clamps text resizing and never mutates the saved layout", () => { + expect(resizeCertificateBox(box, -100, -100)).toMatchObject({ width: 2, height: 1 }); + expect(box).toEqual({ x: 10, y: 20, width: 30, height: 15, fontSize: 18 }); + }); +}); diff --git a/projects/social_platform/src/app/utils/certificate-layout.ts b/projects/social_platform/src/app/utils/certificate-layout.ts new file mode 100644 index 000000000..945aac246 --- /dev/null +++ b/projects/social_platform/src/app/utils/certificate-layout.ts @@ -0,0 +1,25 @@ +/** @format */ +import { CertificateBox } from "@domain/program/program-certificates.model"; +const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value)); +export function moveCertificateBox(box: CertificateBox, dx: number, dy: number): CertificateBox { + let x = clamp(box.x + dx, 0, 100 - box.width); + if (Math.abs(x + box.width / 2 - 50) < 1) x = 50 - box.width / 2; + return { ...box, x, y: clamp(box.y + dy, 0, 100 - box.height) }; +} +export function resizeCertificateBox( + box: CertificateBox, + dw: number, + dh: number, + imageRatio?: number, +): CertificateBox { + let width = clamp(box.width + dw, 2, 100 - box.x); + let height = clamp(box.height + dh, 1, 100 - box.y); + if (imageRatio) { + height = (width * (297 / 210)) / imageRatio; + if (height > 100 - box.y) { + height = 100 - box.y; + width = (height * imageRatio) / (297 / 210); + } + } + return { ...box, width, height }; +}