From c0492b32595e20efa5dc5b5fd8ff9165bd029889 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Sun, 25 Oct 2026 19:08:18 +0300 Subject: [PATCH 1/3] feat(certificates): simplify editor and recipient delivery actions --- docs/feature-specs/program-certificates.md | 27 ++- docs/qa/certificates-ux-20261025.md | 62 +++++ .../program-certificates.use-case.ts | 3 + .../program-certificates.repository.port.ts | 15 +- .../program/program-certificates.model.ts | 19 +- .../program-certificates-http.adapter.ts | 23 +- .../certificate-canvas.component.html | 32 +-- .../certificate-canvas.component.scss | 25 +- .../certificate-canvas.component.ts | 66 ++++- .../certificates/certificates.component.html | 227 ++++++++++++------ .../certificates/certificates.component.scss | 105 +++++++- .../certificates.component.spec.ts | 129 +++++++++- .../certificates/certificates.component.ts | 151 ++++++++++-- .../src/app/utils/certificate-layout.spec.ts | 22 +- .../src/app/utils/certificate-layout.ts | 17 ++ 15 files changed, 761 insertions(+), 162 deletions(-) create mode 100644 docs/qa/certificates-ux-20261025.md diff --git a/docs/feature-specs/program-certificates.md b/docs/feature-specs/program-certificates.md index f91759e20..fd83a1b66 100644 --- a/docs/feature-specs/program-certificates.md +++ b/docs/feature-specs/program-certificates.md @@ -13,7 +13,7 @@ Фон PNG/JPG, прозрачная подпись PNG, директор и должность. Элементы перемещаются мышью/касанием или стрелками, размер меняется за угол. Подпись сохраняет пропорции. Нет формы координат. Доступны центрирование по обеим осям, -отмена/повтор действий, увеличенный редактор с масштабом, проверка длинного +отмена/повтор действий, увеличенный редактор с целым листом, проверка длинного имени и пробный PDF. Размер текста задается в панели выбранного элемента. Обязательно только имя участника. Все восемь других элементов можно независимо @@ -58,7 +58,7 @@ drag/resize/границы/клавиатура; длинная кириллиц выбор. Ориентацию можно изменить вручную, отменить и повторить вместе с другими изменениями шаблона. Фон целиком вписывается в лист без обрезки и растяжения. Положение элементов задано относительно выбранного листа. Подгонка текста, -размер подписи, масштаб и PDF используют одинаковые размеры листа. Старые +размер подписи и PDF используют одинаковые размеры листа. Старые шаблоны без `orientation` считаются альбомными. Изменение шаблона не меняет ранее выданные PDF. Для выкладки сначала требуется API с поддержкой ориентации. @@ -71,3 +71,26 @@ Mont, PT Serif и Lobster. У каждого элемента свой выбо Файлы хранятся локально, PDF встраивает выбранный шрифт, подгонка длинного ФИО использует его метрики. Семейство не меняет элементы управления и подписи редактора. Для выкладки требуется сначала парный API с `layout.*.font_family`. + +## Уточнение редактора и отправления + +Размер в пикселях применяется при вводе. Рамка растет вместе с текстом, чтобы +ее прежняя высота не отменяла изменение размера. Длинные персональные имена +по-прежнему вписываются в рамку. Процентный масштаб убран: лист помещается +целиком, для большего просмотра есть развернутый редактор. Пробный PDF использует +выбранный пример имени, включая переключатель длинного имени. + +Способ доставки выбирается явно: «На платформе» или «На платформе и по почте». +Третий шаг называется «Отправление». В колонке «Письмо» готовый PDF без письма +показывается как «Не отправлено». Есть фильтр неотправленных сертификатов. + +В таблице можно выбрать отдельные строки или всех по текущему фильтру на всех +страницах, затем исключить отдельные строки. Выбор очищается при смене поиска +или фильтра. Доступны отправление выбранным, обновление PDF без письма и +скачивание выбранного архива. Перед отправлением отдельное подтверждение; +уже отправленные письма пропускаются. Повтор ошибок сохраняет способ доставки. + +При отличии от текущего шаблона строка показывает «Шаблон изменен», действие +называется «Обновить и скачать». Сначала сохраняются изменения, затем API +обновляет персональный PDF и сохраняет прежнюю ссылку участника. Архив также +использует текущий шаблон. Сохранение шаблона само по себе не меняет выданные PDF. diff --git a/docs/qa/certificates-ux-20261025.md b/docs/qa/certificates-ux-20261025.md new file mode 100644 index 000000000..a9242f349 --- /dev/null +++ b/docs/qa/certificates-ux-20261025.md @@ -0,0 +1,62 @@ + + +# Certificate editor and recipient actions — 2026-10-25 + +Scope: the existing program certificate management UI, preserving Mont and the +product controls. The goal includes clear delivery modes, current template PDFs, +recipient selection and responsive editor access. + +## Implemented behavior + +- A typed text size applies immediately; the text box grows with it instead of + silently limiting it to the old height. Size and geometry undo together. +- The full sheet fits the workspace. Percentage zoom is removed; the expanded + editor keeps the sheet and the preview PDF action together. +- The long-name switch is sent to the native preview endpoint. +- Delivery is explicitly “На платформе” or “На платформе и по почте”. +- Step 3 is “Отправление”. The mail column uses “Не отправлено” for a platform PDF + without email, with an additional unsent filter. +- Checkboxes select individual rows or all matching the current filter on every + page; individual exclusions are supported. Filter/search changes clear selection. +- Selected send skips sent messages. Refresh updates PDF without email. Retry + keeps each row's prior delivery mode, including platform-only failures. +- Changed templates have an explicit update-and-download action. Personal files + and selected archives use the current saved template and keep participant links. + +## Verified locally + +- 44 Angular component/layout/participant tests, build and targeted ESLint/Stylelint/Prettier. +- Paired API: 44 certificate tests, 41 passed and 3 PostgreSQL concurrency tests + skipped on SQLite. The full PostgreSQL CI, including the concurrent manager/ + worker test, passed for API commit `04008df927b75c1fa0b4de6a1c29c09f54db251c`. +- Actual Angular build and Django API on disposable local program 1: size 16 to + 48 applies immediately; no zoom control; long-name PDF contains the exact name. +- Updated an old personal PDF from the journal: current portrait background and + selected Lobster face, one A4 page, correct participant name, same secret link. +- Selected all four local rows, excluded one, submitted three: two were queued + and the already sent row was skipped. Running the real service worker against + the local locmem email backend produced exactly two emails and generated PDFs; + repeated processing produced zero duplicate emails. No external mail was sent. +- A selected ZIP contains four one-page portrait PDFs with embedded Lobster, + including the previously outdated document. +- A platform-only generation failure was retried through the actual browser/API, + then processed by the service: a PDF was created, the status became `ready`, + and no mail was produced. The UI distinguishes “PDF не создан” from a mail error. +- Native browser widths 390 and 320: the expanded editor computes the remaining + height below the tools and above the sticky footer; the whole page and PDF + button are visible. The mobile journal uses stacked rows with mail/PDF labels, + checkboxes and actions together; no horizontal document or table overflow. + +Evidence lives outside Git in `design-artifacts/program-certificates-ux-20261025/`. +Local services use synthetic accounts and do not connect to the real dev database. + +## Release gates still to verify + +- Paired API/frontend deployment and acceptance on dev program 6. +- Actual external email delivery has not been invoked. Local email checks use + a capture backend, with the application generation and delivery code. + +The earlier font release (API #825 and frontend #508) is deployed and visible on +dev. Its frontend deployment initially failed because the dev disk was full; +cache-only recovery preserved container/image/volume inventories and a rerun +succeeded. This UX change is a separate release and must not be mistaken for it. 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 index 9b9eaba31..cb0f5f61c 100644 --- 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 @@ -15,6 +15,9 @@ export class ProgramCertificatesUseCase { 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 currentPdf = this.repository.currentPdf.bind(this.repository); + readonly selection = this.repository.selection.bind(this.repository); readonly archive = this.repository.archive.bind(this.repository); + readonly currentArchive = this.repository.currentArchive.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 index adfb65702..b3cd06d84 100644 --- 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 @@ -6,6 +6,7 @@ import { CertificateFilter, CertificateJournal, CertificateSettings, + CertificateSelection, } from "../program-certificates.model"; export abstract class ProgramCertificatesRepositoryPort { abstract settings(id: number): Observable; @@ -20,7 +21,7 @@ export abstract class ProgramCertificatesRepositoryPort { file: File, ): Observable; abstract asset(id: number, assetId: number): Observable; - abstract preview(id: number, revision: number): Observable; + abstract preview(id: number, revision: number, longName?: boolean): 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; @@ -32,6 +33,18 @@ export abstract class ProgramCertificatesRepositoryPort { ): Observable; abstract retry(id: number, ids?: string[]): Observable<{ count: number }>; abstract pdf(id: number, certificateId: string): Observable; + abstract currentPdf(id: number, certificateId: string, revision: number): Observable; + abstract selection( + id: number, + revision: number, + action: "send" | "refresh" | "retry", + selection: CertificateSelection, + ): Observable<{ count: number; skipped: number }>; abstract archive(id: number, status: CertificateFilter, search: string): Observable; + abstract currentArchive( + id: number, + revision: number, + selection: CertificateSelection, + ): 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 index ce67730b2..29c0a9e30 100644 --- a/projects/social_platform/src/app/domain/program/program-certificates.model.ts +++ b/projects/social_platform/src/app/domain/program/program-certificates.model.ts @@ -76,7 +76,14 @@ export interface CertificateSettings { eligibleCounts: { all: number; submitted: number }; batch: { id: number; active: boolean; issueAt: string; initializedAt: string | null } | null; } -export type CertificateFilter = "all" | "sent" | "failed"; +export type CertificateFilter = "all" | "sent" | "failed" | "unsent"; +export interface CertificateSelection { + ids?: string[]; + allMatching?: boolean; + excludedIds?: string[]; + status?: CertificateFilter; + search?: string; +} export interface CertificateDelivery { id: string; name: string; @@ -85,9 +92,17 @@ export interface CertificateDelivery { pdfReady: boolean; error: string; sentAt: string | null; + outdated?: boolean; } export interface CertificateJournal { count: number; results: CertificateDelivery[]; - summary: { total: number; ready: number; sent: number; failed: number }; + summary: { + total: number; + ready: number; + sent: number; + failed: number; + unsent?: number; + outdated?: number; + }; } 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 index 3297f451e..bdd8658f7 100644 --- 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 @@ -9,6 +9,7 @@ import { CertificateFilter, CertificateJournal, CertificateSettings, + CertificateSelection, } from "@domain/program/program-certificates.model"; @Injectable() export class ProgramCertificatesHttpAdapter implements ProgramCertificatesRepositoryPort { @@ -31,8 +32,8 @@ export class ProgramCertificatesHttpAdapter implements ProgramCertificatesReposi 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 }); + preview(id: number, revision: number, longName = false) { + return this.api.postFile(this.root(id) + "preview/", { revision, longName }); } test(id: number, revision: number) { return this.api.post<{ detail: string }>(this.root(id) + "test/", { revision }); @@ -58,9 +59,27 @@ export class ProgramCertificatesHttpAdapter implements ProgramCertificatesReposi pdf(id: number, certificateId: string) { return this.api.getFile(this.root(id) + `deliveries/${certificateId}/pdf/`); } + currentPdf(id: number, certificateId: string, revision: number) { + return this.api.postFile(this.root(id) + `deliveries/${certificateId}/pdf/`, { revision }); + } + selection( + id: number, + revision: number, + action: "send" | "refresh" | "retry", + selection: CertificateSelection, + ) { + return this.api.post<{ count: number; skipped: number }>(this.root(id) + "selection/", { + revision, + action, + ...selection, + }); + } archive(id: number, status: CertificateFilter, search: string) { return this.api.getFile(this.root(id) + "archive/", this.query(status, search)); } + currentArchive(id: number, revision: number, selection: CertificateSelection) { + return this.api.postFile(this.root(id) + "archive/", { revision, ...selection }); + } 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/management/certificates/certificate-canvas.component.html b/projects/social_platform/src/app/ui/pages/program/management/certificates/certificate-canvas.component.html index 4feebad35..c55b192aa 100644 --- 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 @@ -21,7 +21,8 @@ min="8" max="72" [value]="box().fontSize" - [disabled]="selected() === 'signature' || preview()" + [disabled]="selected() === 'signature' || preview() || disabled()" + (input)="fontChanged($event)" (change)="fontChanged($event)" /> px @@ -122,30 +123,21 @@ } -
+
Проверить длинное имя - +
-

- Перетащите элемент · тяните за угол для размера · стрелки для точного перемещения -

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 index 6cbe2ad0a..1081f113e 100644 --- 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 @@ -261,32 +261,12 @@ margin-top: 12px; } - &__check, - &__zoom { + &__check { display: flex; gap: 8px; align-items: center; min-height: 32px; } - - &__zoom { - max-width: 100%; - color: var(--grey-for-text); - - select { - width: 164px; - padding-right: 28px; - font-size: 12px; - background-position: right 8px center; - background-size: 12px; - } - } - - &__instruction { - margin: 8px 0 0; - font-size: 11px; - color: var(--grey-for-text); - } } @media (width <= 749px) { @@ -304,8 +284,7 @@ .canvas__font, .canvas__family, - .canvas__check, - .canvas__zoom { + .canvas__check { min-height: 44px; } 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 index 2fa0ef758..4cb374f95 100644 --- 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 @@ -29,6 +29,7 @@ import { hiddenCertificateElements, moveCertificateBox, resizeCertificateBox, + sizeCertificateText, } from "@utils/certificate-layout"; @Component({ @@ -54,15 +55,17 @@ export class CertificateCanvasComponent { readonly layoutChange = output(); readonly interactionStart = output(); readonly removeElement = output(); + readonly downloadPreview = output(); readonly keys = CERTIFICATE_ELEMENTS; readonly labels = CERTIFICATE_LABELS; readonly fonts = CERTIFICATE_FONTS; readonly fontLoadError = signal(false); - readonly zoom = signal(1); readonly dragging = signal(false); readonly viewport = viewChild>("viewport"); + readonly bottom = viewChild>("bottom"); private readonly availableWidth = signal(0); private readonly viewportHeight = signal(900); + private readonly expandedPaperHeight = signal(360); private readonly fontsReady = signal(false); private context: CanvasRenderingContext2D | null = null; private readonly destroy = inject(DestroyRef); @@ -71,15 +74,14 @@ export class CertificateCanvasComponent { const width = this.availableWidth(); const page = this.page(); // Leave room for tools, the PDF action and the sticky save footer in the dialog. - const height = Math.max( - this.draft().orientation === "portrait" ? 360 : 200, - this.viewportHeight() - (this.expanded() ? 480 : 320), - ); + const height = this.expanded() + ? this.expandedPaperHeight() + : Math.max(this.draft().orientation === "portrait" ? 360 : 200, this.viewportHeight() - 320); const fit = this.expanded() || this.draft().orientation === "portrait" ? Math.min(width, (height * page.widthMm) / page.heightMm) : width; - return fit * this.zoom(); + return fit; }); readonly fits = computed(() => { this.fontsReady(); @@ -131,12 +133,37 @@ export class CertificateCanvasComponent { if (!element) return; const style = getComputedStyle(element); this.availableWidth.set( - element.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight), + element.clientWidth - + (parseFloat(style.paddingLeft) || 0) - + (parseFloat(style.paddingRight) || 0), ); this.viewportHeight.set(window.innerHeight); + if (this.expanded()) { + const footer = element + .closest(".certificates__editor") + ?.querySelector(".certificates__save"); + this.expandedPaperHeight.set( + Math.max( + 120, + window.innerHeight - + 24 - + (footer?.getBoundingClientRect().height ?? 80) - + Math.max(0, element.getBoundingClientRect().top) - + (parseFloat(style.paddingTop) || 0) - + (parseFloat(style.paddingBottom) || 0) - + (this.bottom()?.nativeElement.getBoundingClientRect().height ?? 44) - + 12, + ), + ); + } }; const observer = typeof ResizeObserver !== "undefined" ? new ResizeObserver(measure) : null; if (this.viewport()) observer?.observe(this.viewport()!.nativeElement); + if (this.bottom()) observer?.observe(this.bottom()!.nativeElement); + const footer = this.viewport() + ?.nativeElement.closest(".certificates__editor") + ?.querySelector(".certificates__save"); + if (footer) observer?.observe(footer); window.addEventListener("resize", measure); measure(); if (document.fonts) { @@ -296,18 +323,31 @@ export class CertificateCanvasComponent { ); } fontChanged(event: Event): void { - if (this.disabled()) return; + if (this.disabled() || this.preview() || this.selected() === "signature") return; const target = event.target as HTMLInputElement, value = Number(target.value); if (!Number.isFinite(value) || value < 8 || value > 72) { - target.value = String(this.box().fontSize); + if (event.type === "change") target.value = String(this.box().fontSize); return; } + if (value === this.box().fontSize) return; + const font = this.font(this.selected()); + const measure = (text: string) => { + if (this.context) + this.context.font = `${this.fontWeight(this.selected())} 1000px "${font.family}"`; + return this.context?.measureText(text).width ?? Array.from(text).length * 550; + }; this.interactionStart.emit(); - this.update(this.selected(), { ...this.box(), fontSize: value }); - } - setZoom(value: number): void { - this.zoom.set(Math.max(1, Math.min(2, value))); + this.update( + this.selected(), + sizeCertificateText( + this.text(this.selected()), + this.box(), + value, + measure, + this.draft().orientation, + ), + ); } 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 index e474f1c61..d378f824d 100644 --- 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 @@ -102,19 +102,8 @@

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

(interactionStart)="remember()" (layoutChange)="patch({ layout: $event }, false)" (removeElement)="hideElement($event)" + (downloadPreview)="preview()" /> -
- -

- {{ - dirty() - ? "Перед скачиванием сохраним изменения шаблона." - : "Проверьте расположение элементов в готовом PDF." - }} -

-