diff --git a/projects/social_platform/src/app/domain/program/program-launch.model.ts b/projects/social_platform/src/app/domain/program/program-launch.model.ts index b42a99a9f..babbcf7c0 100644 --- a/projects/social_platform/src/app/domain/program/program-launch.model.ts +++ b/projects/social_platform/src/app/domain/program/program-launch.model.ts @@ -1,6 +1,7 @@ /** @format */ import { Program } from "./program.model"; import { ManagementPage } from "./program-management.model"; +import { ProgramImageFraming } from "./program-image-framing.model"; export type ProgramFormat = "case_championship" | "hackathon" | "noncompetitive"; export const PROGRAM_FORMATS: { id: ProgramFormat; label: string; description: string }[] = [ @@ -24,6 +25,8 @@ export interface ManagedProgram { id: number; name: string; draft: boolean; + imageAddress?: string | null; + imageFraming?: ProgramImageFraming | null; programFormat: ProgramFormat | null; solutionStorageMode?: "legacy_project" | "solution"; updatedAt: string; diff --git a/projects/social_platform/src/app/ui/pages/program/main/main.component.scss b/projects/social_platform/src/app/ui/pages/program/main/main.component.scss index 86cd8625a..f45591400 100644 --- a/projects/social_platform/src/app/ui/pages/program/main/main.component.scss +++ b/projects/social_platform/src/app/ui/pages/program/main/main.component.scss @@ -1,5 +1,11 @@ @use "styles/responsive"; +:host { + display: block; + width: 100%; + min-width: 0; +} + .programs { display: grid; grid-template-columns: 1fr; diff --git a/projects/social_platform/src/app/ui/pages/program/main/program-card/program-card.component.html b/projects/social_platform/src/app/ui/pages/program/main/program-card/program-card.component.html index 491f35d5c..1e8a68a86 100644 --- a/projects/social_platform/src/app/ui/pages/program/main/program-card/program-card.component.html +++ b/projects/social_platform/src/app/ui/pages/program/main/program-card/program-card.component.html @@ -1,94 +1,120 @@ - -@if (desktopLayout()) { - @if (program()) { +
+ @if (desktopLayout()) {
+ />
-
-

{{ program().name }}

- +

+ {{ program().name }} +

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

-
-

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

-

-

-

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

- -

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

+ @if (program().datetimeStarted && program().datetimeFinished) { +

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

+

-

+

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

+

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

+ } @else { +

+ {{ + program().datetimeStarted || program().datetimeFinished + ? "Сроки заполнены не полностью" + : "Сроки не заданы" + }} +

+ }
- } -} @else { - @if (program()) { -
+ } @else { +
+ />
-

{{ program().name }}

-

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

-
-

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

- -

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

+ @if (program().datetimeStarted && program().datetimeFinished) { +

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

+

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

+ } @else { +

+ {{ + program().datetimeStarted || program().datetimeFinished + ? "Сроки заполнены не полностью" + : "Сроки не заданы" + }} +

+ }
} -} + @if (management()) { +
+

+ {{ program().draft ? "Не виден участникам" : "Опубликована" }} +

+ {{ program().draft ? "Продолжить настройку" : "Управлять" }} +
+ } +
+ + @switch (registrationState()) { + @case ("draft") { + Черновик + } + @case ("ended") { + регистрация завершена + } + @case ("participating") { + ты уже участвуешь! + } + @case ("actual") { + Регистрация до {{ program().datetimeRegistrationEnds | date: "dd MMMM" }} + } + @default { + Срок регистрации не задан + } + } + diff --git a/projects/social_platform/src/app/ui/pages/program/main/program-card/program-card.component.scss b/projects/social_platform/src/app/ui/pages/program/main/program-card/program-card.component.scss index e66ff0477..811b5a279 100644 --- a/projects/social_platform/src/app/ui/pages/program/main/program-card/program-card.component.scss +++ b/projects/social_platform/src/app/ui/pages/program/main/program-card/program-card.component.scss @@ -2,8 +2,77 @@ @use "styles/ui-foundation"; @use "styles/responsive"; +@use "styles/program-management-controls"; + +:host { + display: block; + min-width: 0; +} + +.program-card { + &__actions { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; + margin-top: 8px; + } + + &__publication { + min-width: 0; + margin: 0; + font-size: 10px; + line-height: 13px; + color: var(--dark-grey); + } + + .ui-button.ui-button { + @include ui-foundation.button; + @include program-management-controls.compact-action; + + flex-shrink: 0; + min-width: 110px; + } + + .program-card__continue { + width: 188px; + } +} + +.card__registration--draft, +.card__registration--not-set { + background: var(--dark-grey); +} @media screen and (max-width: 999px) { + .program-card--managed { + @include ui-foundation.card; + + .card { + padding: 0; + background: transparent; + border: 0; + box-shadow: none; + } + } + + .program-card__actions { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 8px; + + .ui-button.ui-button { + justify-self: stretch; + width: 100%; + min-height: 44px; + } + } + + .program-card__publication { + font-size: 12px; + line-height: 18px; + } + .card { @include ui-foundation.card; @@ -72,6 +141,21 @@ } @media screen and (min-width: 1000px) { + .program-card { + max-width: 333px; + } + + .program-card--managed { + .card__info { + flex: 1; + } + + .ui-button.ui-button.program-card__continue { + padding-inline: 12px; + white-space: nowrap; + } + } + .card { position: relative; display: flex; diff --git a/projects/social_platform/src/app/ui/pages/program/main/program-card/program-card.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/main/program-card/program-card.component.spec.ts index 9d4d92341..d4b839adb 100644 --- a/projects/social_platform/src/app/ui/pages/program/main/program-card/program-card.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/program/main/program-card/program-card.component.spec.ts @@ -1,28 +1,88 @@ /** @format */ - +import { signal } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { ProgramCardComponent } from "./program-card.component"; +import { provideRouter } from "@angular/router"; +import { DesktopLayoutService } from "@uilib"; +import { ProgramCardComponent, ProgramCardData } from "./program-card.component"; describe("ProgramCardComponent", () => { - let component: ProgramCardComponent; let fixture: ComponentFixture; - + const desktop = signal(true); + const program: ProgramCardData = { + id: 7, + name: "Программа", + datetimeStarted: "2099-11-10T00:00:00Z", + datetimeFinished: "2099-11-30T00:00:00Z", + datetimeRegistrationEnds: "2099-11-15T00:00:00Z", + }; + const setProgram = (patch: Partial) => { + fixture.componentRef.setInput("program", { ...program, ...patch }); + fixture.detectChanges(); + }; + const element = () => fixture.nativeElement as HTMLElement; beforeEach(async () => { + desktop.set(true); await TestBed.configureTestingModule({ imports: [ProgramCardComponent], + providers: [provideRouter([]), { provide: DesktopLayoutService, useValue: { desktop } }], }).compileComponents(); - fixture = TestBed.createComponent(ProgramCardComponent); - component = fixture.componentInstance; - fixture.componentRef.setInput("program", { - id: 1, - name: "Test Program", - datetimeRegistrationEnds: new Date(Date.now() + 86400000).toISOString(), + setProgram({}); + }); + it("keeps the public card free of added actions", () => { + expect(element().querySelector(".card__name")?.textContent).toContain(program.name); + expect(element().querySelector(".program-card__actions")).toBeNull(); + expect(element().querySelector("a")).toBeNull(); + }); + it("keeps expiry ahead of membership and updates reused inputs", () => { + setProgram({ isUserMember: true }); + expect(element().querySelector(".card__registration--participating")).not.toBeNull(); + setProgram({ isUserMember: true, datetimeRegistrationEnds: "2000-01-01T00:00:00Z" }); + expect(element().querySelector(".card__registration--ended")?.textContent).toContain( + "регистрация завершена", + ); + expect(element().querySelector(".card__registration--participating")).toBeNull(); + }); + it("continues a draft without inventing dates or an open registration", () => { + fixture.componentRef.setInput("management", true); + setProgram({ + draft: true, + datetimeStarted: null, + datetimeFinished: null, + datetimeRegistrationEnds: null, }); - fixture.detectChanges(); + expect(element().querySelector(".card__registration")?.textContent).toContain("Черновик"); + expect(element().querySelector(".card__dates")?.textContent).toContain("Сроки не заданы"); + expect(element().textContent).toContain("Не виден участникам"); + expect(element().querySelector("a")?.getAttribute("href")).toBe("/office/program/7/launch"); }); - - it("should create", () => { - expect(component).toBeTruthy(); + it("routes a published program to management with its own publication status", () => { + fixture.componentRef.setInput("management", true); + setProgram({ draft: false }); + expect(element().querySelector(".card__registration")?.textContent).toContain("Регистрация до"); + expect(element().textContent).toContain("Опубликована"); + expect(element().querySelector("a")?.getAttribute("href")).toBe( + "/office/program/7/management/participants", + ); + }); + it("uses the saved image and keeps the native mobile avatar", () => { + desktop.set(false); + setProgram({ + imageAddress: "https://example.com/logo.png", + imageFraming: { logo: { x: 0.2, y: 0.7, zoom: 1.5 } }, + }); + expect(element().querySelector(".card__photo img")?.getAttribute("src")).toBe( + "https://example.com/logo.png", + ); + expect(element().querySelector(".card__photo .avatar")?.getAttribute("style")).toContain( + "64px", + ); + expect(element().querySelector("h3")?.getAttribute("title")).toBe(program.name); + }); + it("does not imply registration is open without a deadline", () => { + setProgram({ datetimeRegistrationEnds: null }); + expect(element().querySelector(".card__registration--not-set")?.textContent).toContain( + "Срок регистрации не задан", + ); }); }); diff --git a/projects/social_platform/src/app/ui/pages/program/main/program-card/program-card.component.ts b/projects/social_platform/src/app/ui/pages/program/main/program-card/program-card.component.ts index de20162b1..a89f26ba8 100644 --- a/projects/social_platform/src/app/ui/pages/program/main/program-card/program-card.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/main/program-card/program-card.component.ts @@ -1,33 +1,55 @@ /** @format */ -import { DesktopLayoutService } from "@uilib"; -/** @format */ - -import { CardDirective } from "@uilib"; -/** @format */ - -import { ChangeDetectionStrategy, Component, inject, input, Input, OnInit } from "@angular/core"; +import { ButtonDirective, CardDirective, DesktopLayoutService } from "@uilib"; +import { ChangeDetectionStrategy, Component, computed, inject, input } from "@angular/core"; import { AvatarComponent } from "@ui/primitives/avatar/avatar.component"; -import { DatePipe, NgClass } from "@angular/common"; -import { Program } from "@domain/program/program.model"; +import { DatePipe, NgClass, NgTemplateOutlet } from "@angular/common"; +import { RouterLink } from "@angular/router"; +import { ProgramImageFraming } from "@domain/program/program-image-framing.model"; +import { AppRoutes } from "@api/paths/app-routes"; + +/** Shared saved identity for the public catalog and organizer cards. */ +export interface ProgramCardData { + id: number; + name: string; + imageAddress?: string | null; + imageFraming?: ProgramImageFraming | null; + datetimeRegistrationEnds: string | null; + datetimeStarted: string | null; + datetimeFinished: string | null; + isUserMember?: boolean; + draft?: boolean; +} /** Карточка программы с краткой информацией для списков. */ @Component({ selector: "app-program-card", templateUrl: "./program-card.component.html", styleUrl: "./program-card.component.scss", - imports: [CardDirective, AvatarComponent, DatePipe, NgClass], + imports: [ + CardDirective, + AvatarComponent, + DatePipe, + NgClass, + NgTemplateOutlet, + ButtonDirective, + RouterLink, + ], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class ProgramCardComponent implements OnInit { +export class ProgramCardComponent { protected readonly desktopLayout = inject(DesktopLayoutService).desktop; - - readonly program = input.required(); - - ngOnInit(): void { + protected readonly paths = AppRoutes; + readonly program = input.required(); + readonly management = input(false); + readonly registerDateExpired = computed(() => { const deadline = this.program().datetimeRegistrationEnds; - this.registerDateExpired = Boolean(deadline && Date.now() > Date.parse(deadline)); - } - - registerDateExpired?: boolean; + return Boolean(deadline && Date.now() > Date.parse(deadline)); + }); + readonly registrationState = computed(() => { + if (this.management() && this.program().draft) return "draft"; + if (this.registerDateExpired()) return "ended"; + if (!this.management() && this.program().isUserMember) return "participating"; + return this.program().datetimeRegistrationEnds ? "actual" : "not-set"; + }); } diff --git a/projects/social_platform/src/app/ui/pages/program/managed/managed.component.html b/projects/social_platform/src/app/ui/pages/program/managed/managed.component.html index f23f17142..e50253e59 100644 --- a/projects/social_platform/src/app/ui/pages/program/managed/managed.component.html +++ b/projects/social_platform/src/app/ui/pages/program/managed/managed.component.html @@ -1,8 +1,14 @@
- + @if (loading()) {

Загружаем программы…

} @else if (error()) { @@ -12,36 +18,26 @@ } @else if (data(); as data) {
@for (program of data.results; track program.id) { - + } @empty { - @if (search.value) { -

Программы не найдены

-

Попробуйте изменить название в поиске.

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

На этой странице программ нет.

- - } @else { -

Программы пока не созданы

-

Здесь появятся программы, которыми вы управляете, включая незавершенные черновики.

- @if (canCreate()) { - Создать программу +
+ @if (search.value) { +

Программы не найдены

+

Попробуйте изменить название в поиске.

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

На этой странице программ нет.

+ + } @else { +

Программы пока не созданы

+

+ Здесь появятся программы, которыми вы управляете, включая незавершенные черновики. +

+ @if (canCreate()) { + Создать программу + } } - } +
}
@if (data.count > 10 || offset()) { diff --git a/projects/social_platform/src/app/ui/pages/program/managed/managed.component.scss b/projects/social_platform/src/app/ui/pages/program/managed/managed.component.scss index 7342229d8..0fae55272 100644 --- a/projects/social_platform/src/app/ui/pages/program/managed/managed.component.scss +++ b/projects/social_platform/src/app/ui/pages/program/managed/managed.component.scss @@ -4,6 +4,7 @@ :host { display: block; + width: 100%; min-width: 0; } @@ -31,50 +32,23 @@ } &__search { - display: grid; - gap: 8px; - max-width: 480px; + width: 100%; margin-bottom: 24px; } &__list { display: grid; gap: 24px; - padding: 20px; - background: var(--light-white); - border-radius: 15px; + align-items: start; } - &__row { - display: flex; - gap: 24px; - align-items: center; - justify-content: space-between; - - > div { - min-width: 0; - } - - > a { - flex-shrink: 0; - } + &__empty { + grid-column: 1 / -1; } } -@media (max-width: 999px) { - .managed { - &__list { - padding: 16px; - } - - &__row { - display: grid; - gap: 12px; - - > a { - justify-self: start; - margin-block: 6px; - } - } +@media (min-width: 1000px) { + .managed__list { + grid-template-columns: repeat(2, minmax(0, 333px)); } } diff --git a/projects/social_platform/src/app/ui/pages/program/managed/managed.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/managed/managed.component.spec.ts new file mode 100644 index 000000000..164a4b897 --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/program/managed/managed.component.spec.ts @@ -0,0 +1,49 @@ +/** @format */ +import { TestBed } from "@angular/core/testing"; +import { provideRouter, Router } from "@angular/router"; +import { RouterTestingHarness } from "@angular/router/testing"; +import { of } from "rxjs"; +import { ProgramLaunchUseCase } from "@api/program/use-cases/program-launch.use-case"; +import { ManagedProgramsComponent } from "./managed.component"; + +describe("ManagedProgramsComponent", () => { + const api = { + capabilities: vi.fn(() => of({ canCreateProgram: false })), + managed: vi.fn(() => of({ count: 0, next: null, previous: null, results: [] })), + }; + beforeEach(async () => { + vi.clearAllMocks(); + await TestBed.configureTestingModule({ + providers: [ + provideRouter([{ path: "managed", component: ManagedProgramsComponent }]), + { provide: ProgramLaunchUseCase, useValue: api }, + ], + }).compileComponents(); + }); + it("keeps search in the URL, resets offset and preserves it on pagination", async () => { + const harness = await RouterTestingHarness.create(); + const component = await harness.navigateByUrl( + "/managed?search=исходный&offset=10", + ManagedProgramsComponent, + ); + expect(api.managed).toHaveBeenLastCalledWith({ search: "исходный", offset: 10, limit: 10 }); + component.search.setValue("новый"); + const router = TestBed.inject(Router); + await vi.waitFor(() => { + expect(router.parseUrl(router.url).queryParams).toEqual({ search: "новый" }); + expect(api.managed).toHaveBeenLastCalledWith({ search: "новый", offset: 0, limit: 10 }); + }); + component.page(1); + await vi.waitFor(() => + expect(router.parseUrl(router.url).queryParams).toEqual({ search: "новый", offset: "10" }), + ); + }); + it("does not offer creation when the separate permission is absent", async () => { + const harness = await RouterTestingHarness.create(); + await harness.navigateByUrl("/managed", ManagedProgramsComponent); + expect(harness.routeNativeElement?.textContent).toContain("Программы пока не созданы"); + expect( + harness.routeNativeElement?.querySelector('a[href="/office/program/create"]'), + ).toBeNull(); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/program/managed/managed.component.ts b/projects/social_platform/src/app/ui/pages/program/managed/managed.component.ts index 0aea84bd4..c9b4bf8d7 100644 --- a/projects/social_platform/src/app/ui/pages/program/managed/managed.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/managed/managed.component.ts @@ -27,6 +27,7 @@ import { ProgramLaunchUseCase } from "@api/program/use-cases/program-launch.use- import { ManagedProgramsPage } from "@domain/program/program-launch.model"; import { AppRoutes } from "@api/paths/app-routes"; import { evaluationError } from "@utils/evaluation-workspace"; +import { ProgramCardComponent } from "../main/program-card/program-card.component"; @Component({ selector: "app-managed-programs", @@ -37,6 +38,7 @@ import { evaluationError } from "@utils/evaluation-workspace"; AlertDirective, ButtonDirective, PaginationComponent, + ProgramCardComponent, ], templateUrl: "./managed.component.html", styleUrl: "./managed.component.scss", diff --git a/projects/social_platform/src/app/ui/pages/program/program.component.html b/projects/social_platform/src/app/ui/pages/program/program.component.html index 720815a54..97c82e6f2 100644 --- a/projects/social_platform/src/app/ui/pages/program/program.component.html +++ b/projects/social_platform/src/app/ui/pages/program/program.component.html @@ -10,26 +10,24 @@ > }
- - @if (!isManaged()) { - - } - + + @if (!isManaged()) { +