diff --git a/projects/social_platform/src/app/api/program/facades/detail/program-analytics-attention.service.spec.ts b/projects/social_platform/src/app/api/program/facades/detail/program-analytics-attention.service.spec.ts index 269407ae0..5e2076f1e 100644 --- a/projects/social_platform/src/app/api/program/facades/detail/program-analytics-attention.service.spec.ts +++ b/projects/social_platform/src/app/api/program/facades/detail/program-analytics-attention.service.spec.ts @@ -1,4 +1,7 @@ /** @format */ + +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; + import { TestBed } from "@angular/core/testing"; import { of, Subject } from "rxjs"; import { GetProgramManagerAssignmentsUseCase } from "@api/program/use-cases/get-program-manager-assignments.use-case"; @@ -46,6 +49,10 @@ describe.each([ assignments.execute.mockClear(); TestBed.configureTestingModule({ providers: [ + { + provide: ProgramRepositoryPort, + useValue: { getCaseProjects: () => of({}), exportCaseProjects: vi.fn() }, + }, ProgramAnalyticsDrilldownService, { provide: GetProgramManagerProjectsNotSubmittedUseCase, useValue: notSubmitted }, { provide: GetProgramManagerParticipantsWithoutTeamUseCase, useValue: participants }, diff --git a/projects/social_platform/src/app/api/program/facades/detail/program-analytics-drilldown.service.spec.ts b/projects/social_platform/src/app/api/program/facades/detail/program-analytics-drilldown.service.spec.ts index 1dc00e783..11fdbcc3b 100644 --- a/projects/social_platform/src/app/api/program/facades/detail/program-analytics-drilldown.service.spec.ts +++ b/projects/social_platform/src/app/api/program/facades/detail/program-analytics-drilldown.service.spec.ts @@ -1,4 +1,7 @@ /** @format */ + +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; + import { TestBed } from "@angular/core/testing"; import { GetProgramManagerAssignmentsUseCase } from "@api/program/use-cases/get-program-manager-assignments.use-case"; import { GetProgramManagerAssignmentScoresUseCase } from "@api/program/use-cases/get-program-manager-assignment-scores.use-case"; @@ -29,6 +32,10 @@ describe("ProgramAnalyticsDrilldownService", () => { scores.execute.mockReset().mockReturnValue(of(ok(scoreDetail()))); TestBed.configureTestingModule({ providers: [ + { + provide: ProgramRepositoryPort, + useValue: { getCaseProjects: () => of({}), exportCaseProjects: vi.fn() }, + }, provideRouter([]), { provide: GetProgramManagerProjectsNotSubmittedUseCase, useValue: { execute: vi.fn() } }, { diff --git a/projects/social_platform/src/app/api/program/facades/detail/program-analytics-drilldown.service.ts b/projects/social_platform/src/app/api/program/facades/detail/program-analytics-drilldown.service.ts index 6aa602192..034299301 100644 --- a/projects/social_platform/src/app/api/program/facades/detail/program-analytics-drilldown.service.ts +++ b/projects/social_platform/src/app/api/program/facades/detail/program-analytics-drilldown.service.ts @@ -2,6 +2,9 @@ import { computed, DestroyRef, inject, Injectable, signal } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { Subject, takeUntil } from "rxjs"; +import { ProgramCaseProjectsService } from "./program-case-projects.service"; +import { ProgramCaseSelection } from "@domain/program/program-case-analytics.model"; +import { ProgramAnalyticsCaseMetrics } from "@domain/program/program-analytics.model"; import { GetProgramManagerAssignmentsUseCase } from "@api/program/use-cases/get-program-manager-assignments.use-case"; import { GetProgramManagerAssignmentScoresUseCase } from "@api/program/use-cases/get-program-manager-assignment-scores.use-case"; import { GetProgramManagerParticipantsWithoutTeamUseCase } from "@api/program/use-cases/get-program-manager-participants-without-team.use-case"; @@ -28,6 +31,7 @@ export type AnalyticsAttentionView = | "projects-awaiting-evaluation" | "projects-not-submitted"; export type AnalyticsDrilldownView = + | "case" | "assignments" | "scores" | "delayed" @@ -37,6 +41,20 @@ export type AnalyticsDrilldownView = /** Живёт вместе с одной analytics-модалкой, не сохраняет manager-данные между открытиями. */ @Injectable() export class ProgramAnalyticsDrilldownService { + readonly cases = inject(ProgramCaseProjectsService); + + openCase( + programId: number, + selection: ProgramCaseSelection, + metrics: ProgramAnalyticsCaseMetrics, + applicable: boolean, + programName = "program", + ): void { + if (!this.start(programId)) return; + this.view.set("case"); + this.cases.open(programId, selection, metrics, applicable, programName); + } + private readonly getAssignments = inject(GetProgramManagerAssignmentsUseCase); private readonly getScores = inject(GetProgramManagerAssignmentScoresUseCase); private readonly getParticipants = inject(GetProgramManagerParticipantsWithoutTeamUseCase); @@ -272,6 +290,7 @@ export class ProgramAnalyticsDrilldownService { /** Закрытие и смена программы уничтожают весь локальный контекст и активные запросы. */ close(): void { + this.cases.reset(); this.cancelAssignments.next(); this.cancelScores.next(); this.cancelAttention.next(); diff --git a/projects/social_platform/src/app/api/program/facades/detail/program-analytics-info.service.ts b/projects/social_platform/src/app/api/program/facades/detail/program-analytics-info.service.ts index 7c0c04d13..0f1058cfd 100644 --- a/projects/social_platform/src/app/api/program/facades/detail/program-analytics-info.service.ts +++ b/projects/social_platform/src/app/api/program/facades/detail/program-analytics-info.service.ts @@ -34,6 +34,7 @@ export class ProgramAnalyticsInfoService { private routeProgramId: number | null = null; private loadedProgram: Program | undefined; readonly programId = signal(null); + readonly programName = computed(() => this.programUI.program()?.name ?? "program"); constructor() { this.route.parent?.paramMap diff --git a/projects/social_platform/src/app/api/program/facades/detail/program-case-projects.service.spec.ts b/projects/social_platform/src/app/api/program/facades/detail/program-case-projects.service.spec.ts new file mode 100644 index 000000000..125eb9d70 --- /dev/null +++ b/projects/social_platform/src/app/api/program/facades/detail/program-case-projects.service.spec.ts @@ -0,0 +1,187 @@ +/** @format */ +import { TestBed } from "@angular/core/testing"; +import { of, Subject } from "rxjs"; +import { GetProgramCaseProjectsUseCase } from "@api/program/use-cases/get-program-case-projects.use-case"; +import { ExportProgramCaseProjectsUseCase } from "@api/program/use-cases/export-program-case-projects.use-case"; +import { + casePage, + caseMetrics, + selectedCase, + caseProject, +} from "@domain/program/program-case-analytics.fixture"; +import { fail, ok } from "@domain/shared/result.type"; +import { saveCaseProjects } from "@utils/program-case-analytics"; +import { ProgramCaseProjectsService } from "./program-case-projects.service"; + +vi.mock("@utils/program-case-analytics", () => ({ saveCaseProjects: vi.fn() })); + +describe("Case drilldown: контекст, поиск, страницы и независимый export", () => { + const get = { execute: vi.fn() }; + const download = { execute: vi.fn() }; + let service: ProgramCaseProjectsService; + beforeEach(() => { + get.execute.mockReset().mockReturnValue(of(ok(casePage()))); + download.execute.mockReset().mockReturnValue(of(ok(new Blob(["xlsx"])))); + vi.mocked(saveCaseProjects).mockReset(); + TestBed.configureTestingModule({ + providers: [ + ProgramCaseProjectsService, + { provide: GetProgramCaseProjectsUseCase, useValue: get }, + { provide: ExportProgramCaseProjectsUseCase, useValue: download }, + ], + }); + service = TestBed.inject(ProgramCaseProjectsService); + }); + it("до подтверждения contract export заблокирован; overview-метрики видны при loading", () => { + const pending = new Subject(); + get.execute.mockReturnValue(pending); + service.open(12, selectedCase, caseMetrics, true); + expect(service.pending()).toBe(true); + expect(service.metrics()).toEqual(caseMetrics); + service.download(); + expect(download.execute).not.toHaveBeenCalled(); + pending.next(ok(casePage())); + expect(service.canExport()).toBe(true); + }); + it("search сбрасывает страницу; метрики/export сохраняют полный bucket при no results", () => { + service.open(12, selectedCase); + service.offset.set(25); + service.searchDraft.set(" неизвестный проект "); + get.execute.mockReturnValue(of(ok(casePage({ count: 0, results: [] })))); + service.applySearch(); + expect(get.execute).toHaveBeenLastCalledWith(12, { + selection: selectedCase, + search: "неизвестный проект", + limit: 25, + offset: 0, + }); + expect(service.page()?.count).toBe(0); + expect(service.metrics()).toEqual(caseMetrics); + expect(service.canExport()).toBe(true); + service.download(); + expect(download.execute).toHaveBeenCalledExactlyOnceWith(12, selectedCase); + service.clearSearch(); + expect(get.execute.mock.lastCall?.[1].search).toBe(""); + }); + it("pagination не следует URL next и использует стабильный offset", () => { + get.execute.mockReturnValue( + of( + ok( + casePage({ + count: 26, + next: "https://foreign.invalid", + results: Array.from({ length: 25 }, (_, i) => caseProject({ programProjectId: i + 1 })), + }), + ), + ), + ); + service.open(12, selectedCase); + expect(service.range()).toBe("1–25 из 26"); + get.execute.mockReturnValue(of(ok(casePage({ count: 26, results: [caseProject()] })))); + service.changePage(1); + expect(get.execute.mock.lastCall?.[1].offset).toBe(25); + expect(service.range()).toBe("26–26 из 26"); + expect(service.hasNext()).toBe(false); + service.changePage(1); + expect(get.execute).toHaveBeenCalledTimes(2); + service.changePage(-1); + expect(get.execute.mock.lastCall?.[1].offset).toBe(0); + }); + it("zero configured case не включает export", () => { + get.execute.mockReturnValue( + of( + ok( + casePage({ + count: 0, + results: [], + caseMetrics: { projectsTotal: 0, participantsTotal: 0, submitted: 0, notSubmitted: 0 }, + }), + ), + ), + ); + service.open(12, selectedCase); + expect(service.canExport()).toBe(false); + expect(service.page()?.results).toEqual([]); + }); + it.each(["case", "program", "close", "destroy"] as const)( + "отменяет list и XLSX при %s", + change => { + service.open(12, selectedCase); + const staleList = new Subject(); + const freshList = new Subject(); + const staleFile = new Subject(); + get.execute.mockReturnValue(staleList); + download.execute.mockReturnValue(staleFile); + service.download(); + service.load(); + get.execute.mockReturnValue(freshList); + if (change === "case") service.open(12, { scope: "without_case" }); + if (change === "program") service.open(99, selectedCase); + if (change === "close") service.reset(); + if (change === "destroy") TestBed.resetTestingModule(); + expect(staleList.observed).toBe(false); + staleList.next(ok(casePage())); + expect(service.page()).toBeNull(); + expect(staleFile.observed).toBe(false); + staleFile.next(ok(new Blob(["stale"]))); + expect(saveCaseProjects).not.toHaveBeenCalled(); + if (change === "close" || change === "destroy") { + expect(staleList.observed).toBe(false); + staleList.next(ok(casePage())); + expect(service.page()).toBeNull(); + } + if (change === "case" || change === "program") { + const fresh = casePage({ count: 0, results: [] }); + freshList.next(ok(fresh)); + expect(service.page()).toEqual(fresh); + } + }, + ); + it("поиск отменяет предыдущий list, но не прерывает full-case export", () => { + const first = new Subject(), + next = new Subject(), + file = new Subject(); + service.open(12, selectedCase); + download.execute.mockReturnValue(file); + service.download(); + service.download(); + expect(download.execute).toHaveBeenCalledTimes(1); + get.execute.mockReturnValueOnce(first).mockReturnValueOnce(next); + service.load(); + service.searchDraft.set("Новый"); + service.applySearch(); + expect(first.observed).toBe(false); + expect(file.observed).toBe(true); + file.next(ok(new Blob(["xlsx"]))); + expect(saveCaseProjects).toHaveBeenCalledTimes(1); + next.next(ok(casePage())); + }); + it("ошибка/retry сохраняет контекст, а отказ прав выключает export", () => { + get.execute.mockReturnValue(of(fail({ kind: "network" }))); + service.open(12, selectedCase, caseMetrics); + expect(service.error()?.kind).toBe("network"); + expect(service.pending()).toBe(false); + expect(service.canExport()).toBe(false); + get.execute.mockReturnValue(of(ok(casePage()))); + service.load(); + expect(service.error()).toBeNull(); + download.execute.mockReturnValue(of(fail({ kind: "forbidden" }))); + service.download(); + expect(service.exportError()?.kind).toBe("forbidden"); + expect(service.canExport()).toBe(false); + }); + it("all проверяет одну строку, но выгружает весь scope", () => { + get.execute.mockReturnValue( + of(ok(casePage({ selection: { scope: "all", caseName: null }, caseMetrics: null }))), + ); + service.open(12, { scope: "all" }); + expect(get.execute).toHaveBeenCalledExactlyOnceWith(12, { + selection: { scope: "all" }, + search: "", + offset: 0, + limit: 1, + }); + service.download(); + expect(download.execute).toHaveBeenCalledExactlyOnceWith(12, { scope: "all" }); + }); +}); diff --git a/projects/social_platform/src/app/api/program/facades/detail/program-case-projects.service.ts b/projects/social_platform/src/app/api/program/facades/detail/program-case-projects.service.ts new file mode 100644 index 000000000..d1b0dbd71 --- /dev/null +++ b/projects/social_platform/src/app/api/program/facades/detail/program-case-projects.service.ts @@ -0,0 +1,159 @@ +/** @format */ +import { computed, DestroyRef, inject, Injectable, signal } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { Subject, takeUntil } from "rxjs"; +import { GetProgramCaseProjectsUseCase } from "@api/program/use-cases/get-program-case-projects.use-case"; +import { ExportProgramCaseProjectsUseCase } from "@api/program/use-cases/export-program-case-projects.use-case"; +import { + ProgramCaseError, + ProgramCasePage, + ProgramCaseSelection, +} from "@domain/program/program-case-analytics.model"; +import { ProgramAnalyticsCaseMetrics } from "@domain/program/program-analytics.model"; +import { saveCaseProjects } from "@utils/program-case-analytics"; + +/** Отдельный экземпляр для блока Кейсы и для существующей AnalyticsDrilldown. */ +@Injectable({ providedIn: "root" }) +export class ProgramCaseProjectsService { + private readonly getProjects = inject(GetProgramCaseProjectsUseCase); + private readonly exportProjects = inject(ExportProgramCaseProjectsUseCase); + private readonly destroyRef = inject(DestroyRef); + private readonly cancelList = new Subject(); + private readonly cancelExport = new Subject(); + private programId: number | null = null; + private programName = "program"; + readonly selection = signal(null); + readonly page = signal(null); + readonly metrics = signal(null); + readonly submissionApplicable = signal(false); + readonly pending = signal(false); + readonly error = signal(null); + readonly searchDraft = signal(""); + readonly search = signal(""); + readonly offset = signal(0); + readonly limit = 25; + readonly exportPending = signal(false); + readonly exportError = signal(null); + private readonly confirmed = signal(false); + private readonly total = signal(0); + readonly canExport = computed( + () => this.confirmed() && this.total() > 0 && !this.exportPending(), + ); + readonly hasNext = computed(() => { + const page = this.page(); + return !!page && this.offset() + page.results.length < page.count; + }); + readonly range = computed(() => { + const page = this.page(); + return page?.results.length + ? `${this.offset() + 1}–${this.offset() + page.results.length} из ${page.count}` + : `0 из ${page?.count ?? 0}`; + }); + + constructor() { + this.destroyRef.onDestroy(() => this.reset()); + } + + open( + programId: number, + selection: ProgramCaseSelection, + metrics: ProgramAnalyticsCaseMetrics | null = null, + applicable = false, + programName = "program", + ): void { + this.reset(); + if (!Number.isInteger(programId) || programId <= 0) return; + this.programId = programId; + this.programName = programName; + this.selection.set(selection); + this.metrics.set(metrics); + this.submissionApplicable.set(applicable); + this.load(); + } + + load(): void { + const selection = this.selection(); + if (this.programId === null || !selection) return; + this.cancelList.next(); + this.page.set(null); + this.pending.set(true); + this.error.set(null); + this.getProjects + .execute(this.programId, { + selection, + search: this.search(), + limit: selection.scope === "all" ? 1 : this.limit, + offset: this.offset(), + }) + .pipe(takeUntil(this.cancelList), takeUntilDestroyed(this.destroyRef)) + .subscribe(result => { + this.pending.set(false); + if (result.ok) { + this.page.set(result.value); + this.metrics.set(result.value.caseMetrics); + this.submissionApplicable.set(result.value.submissionApplicable); + this.total.set(result.value.caseMetrics?.projectsTotal ?? result.value.count); + this.confirmed.set(true); + } else { + this.error.set(result.error); + this.confirmed.set(false); + this.cancelExport.next(); + this.exportPending.set(false); + } + }); + } + + applySearch(): void { + this.search.set(this.searchDraft().trim()); + this.offset.set(0); + this.load(); + } + clearSearch(): void { + this.searchDraft.set(""); + this.applySearch(); + } + changePage(direction: -1 | 1): void { + if (this.pending() || (direction === 1 ? !this.hasNext() : this.offset() === 0)) return; + this.offset.update(offset => Math.max(0, offset + direction * this.limit)); + this.load(); + } + + download(): void { + const selection = this.selection(); + const programId = this.programId; + if (programId === null || !selection || !this.canExport()) return; + this.exportPending.set(true); + this.exportError.set(null); + this.exportProjects + .execute(programId, selection) + .pipe(takeUntil(this.cancelExport), takeUntilDestroyed(this.destroyRef)) + .subscribe(result => { + this.exportPending.set(false); + if (result.ok) saveCaseProjects(result.value, this.programName, selection); + else { + this.exportError.set(result.error); + if (result.error.kind !== "network") this.confirmed.set(false); + } + }); + } + + /** Поиск отменяет только list; смена кейса/программы и закрытие отменяют также файл. */ + reset(): void { + this.cancelList.next(); + this.cancelExport.next(); + this.programId = null; + this.selection.set(null); + this.page.set(null); + this.metrics.set(null); + this.submissionApplicable.set(false); + this.pending.set(false); + this.error.set(null); + this.searchDraft.set(""); + this.search.set(""); + this.offset.set(0); + this.exportPending.set(false); + this.exportError.set(null); + this.confirmed.set(false); + this.total.set(0); + } +} diff --git a/projects/social_platform/src/app/api/program/use-cases/export-program-case-projects.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/export-program-case-projects.use-case.ts new file mode 100644 index 000000000..778a1af34 --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/export-program-case-projects.use-case.ts @@ -0,0 +1,34 @@ +/** @format */ +import { inject, Injectable } from "@angular/core"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { + ProgramCaseError, + ProgramCaseSelection, +} from "@domain/program/program-case-analytics.model"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { catchError, map, Observable, of, switchMap } from "rxjs"; +import { + caseRequestError, + GetProgramCaseProjectsUseCase, +} from "./get-program-case-projects.use-case"; + +@Injectable({ providedIn: "root" }) +export class ExportProgramCaseProjectsUseCase { + private readonly repository = inject(ProgramRepositoryPort); + private readonly getProjects = inject(GetProgramCaseProjectsUseCase); + + /** Повторная проверка контракта перед файлом; search/offset/limit не попадают в export. */ + execute( + programId: number, + selection: ProgramCaseSelection, + ): Observable> { + return this.getProjects.execute(programId, { selection, limit: 1, offset: 0 }).pipe( + switchMap(result => + result.ok + ? this.repository.exportCaseProjects(programId, selection).pipe(map(blob => ok(blob))) + : of(fail(result.error)), + ), + catchError((error: unknown) => of(fail(caseRequestError(error)))), + ); + } +} diff --git a/projects/social_platform/src/app/api/program/use-cases/get-program-case-projects.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/get-program-case-projects.use-case.ts new file mode 100644 index 000000000..93bf1b6c3 --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/get-program-case-projects.use-case.ts @@ -0,0 +1,47 @@ +/** @format */ +import { inject, Injectable } from "@angular/core"; +import { HttpErrorResponse } from "@angular/common/http"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { + isProgramCasePage, + ProgramCaseError, + ProgramCasePage, + ProgramCaseQuery, +} from "@domain/program/program-case-analytics.model"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { catchError, map, Observable, of } from "rxjs"; + +export function caseRequestError(error: unknown): ProgramCaseError { + const status = error instanceof HttpErrorResponse ? error.status : 0; + return { + kind: + status === 401 + ? "unauthorized" + : status === 403 + ? "forbidden" + : status === 404 + ? "not_found" + : status === 400 + ? "invalid" + : "network", + }; +} + +@Injectable({ providedIn: "root" }) +export class GetProgramCaseProjectsUseCase { + private readonly repository = inject(ProgramRepositoryPort); + + execute( + programId: number, + query: ProgramCaseQuery, + ): Observable> { + return this.repository.getCaseProjects(programId, query).pipe( + map(value => + isProgramCasePage(value, query.selection) + ? ok(value) + : fail({ kind: "unsupported" }), + ), + catchError((error: unknown) => of(fail(caseRequestError(error)))), + ); + } +} diff --git a/projects/social_platform/src/app/api/program/use-cases/program-case-projects.use-case.spec.ts b/projects/social_platform/src/app/api/program/use-cases/program-case-projects.use-case.spec.ts new file mode 100644 index 000000000..f521f1954 --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/program-case-projects.use-case.spec.ts @@ -0,0 +1,101 @@ +/** @format */ +import { TestBed } from "@angular/core/testing"; +import { HttpErrorResponse } from "@angular/common/http"; +import { firstValueFrom, of, throwError } from "rxjs"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { casePage, selectedCase } from "@domain/program/program-case-analytics.fixture"; +import { ProgramCaseSelection } from "@domain/program/program-case-analytics.model"; +import { GetProgramCaseProjectsUseCase } from "./get-program-case-projects.use-case"; +import { ExportProgramCaseProjectsUseCase } from "./export-program-case-projects.use-case"; + +describe("Case analytics: проверка контракта перед list/export", () => { + const repository = { getCaseProjects: vi.fn(), exportCaseProjects: vi.fn() }; + let get: GetProgramCaseProjectsUseCase; + let download: ExportProgramCaseProjectsUseCase; + beforeEach(() => { + repository.getCaseProjects.mockReset().mockReturnValue(of(casePage())); + repository.exportCaseProjects.mockReset().mockReturnValue(of(new Blob(["xlsx"]))); + TestBed.configureTestingModule({ + providers: [{ provide: ProgramRepositoryPort, useValue: repository }], + }); + get = TestBed.inject(GetProgramCaseProjectsUseCase); + download = TestBed.inject(ExportProgramCaseProjectsUseCase); + }); + it("возвращает проверенный DTO и nullable презентацию без detail fetch", async () => { + const result = await firstValueFrom(get.execute(12, { selection: selectedCase })); + expect(result).toEqual({ ok: true, value: casePage() }); + expect(repository.getCaseProjects).toHaveBeenCalledExactlyOnceWith(12, { + selection: selectedCase, + }); + }); + it.each([ + { count: 1, next: null, previous: null, results: [{ id: 1, name: "Legacy" }] }, + casePage({ selection: { scope: "without_case", caseName: null } }), + casePage({ caseMetrics: null }), + casePage({ results: [{ id: 1 } as any] }), + ])("не подтверждает legacy/чужой/неполный контракт и не запрашивает файл", async value => { + repository.getCaseProjects.mockReturnValue(of(value)); + expect(await firstValueFrom(download.execute(12, selectedCase))).toEqual({ + ok: false, + error: { kind: "unsupported" }, + }); + expect(repository.exportCaseProjects).not.toHaveBeenCalled(); + }); + it.each([ + [401, "unauthorized"], + [403, "forbidden"], + [404, "not_found"], + [400, "invalid"], + [500, "network"], + [0, "network"], + ])("контролируемая ошибка %s → %s", async (status, kind) => { + repository.getCaseProjects.mockReturnValue( + throwError(() => new HttpErrorResponse({ status: Number(status) })), + ); + expect(await firstValueFrom(get.execute(12, { selection: selectedCase }))).toEqual({ + ok: false, + error: { kind }, + }); + expect(await firstValueFrom(download.execute(12, selectedCase))).toEqual({ + ok: false, + error: { kind }, + }); + expect(repository.exportCaseProjects).not.toHaveBeenCalled(); + }); + it.each([ + { scope: "all" }, + { scope: "without_case" }, + { scope: "selected", caseName: "Без выбранного кейса" }, + ] as ProgramCaseSelection[])("различает typed scope и настоящий option: %j", async selection => { + const page = casePage({ + selection: { + scope: selection.scope, + caseName: selection.scope === "selected" ? selection.caseName : null, + }, + caseMetrics: + selection.scope === "all" + ? null + : { projectsTotal: 0, participantsTotal: 0, submitted: 0, notSubmitted: 0 }, + casesConfigured: selection.scope === "selected", + count: 0, + results: [], + }); + repository.getCaseProjects.mockReturnValue(of(page)); + expect((await firstValueFrom(download.execute(12, selection))).ok).toBe(true); + expect(repository.getCaseProjects).toHaveBeenCalledExactlyOnceWith(12, { + selection, + limit: 1, + offset: 0, + }); + expect(repository.exportCaseProjects).toHaveBeenCalledExactlyOnceWith(12, selection); + }); + it("ошибка самого XLSX остаётся контролируемой", async () => { + repository.exportCaseProjects.mockReturnValue( + throwError(() => new HttpErrorResponse({ status: 403 })), + ); + expect(await firstValueFrom(download.execute(12, selectedCase))).toEqual({ + ok: false, + error: { kind: "forbidden" }, + }); + }); +}); diff --git a/projects/social_platform/src/app/api/project/facades/edit/ui/project-team-ui.service.spec.ts b/projects/social_platform/src/app/api/project/facades/edit/ui/project-team-ui.service.spec.ts index d57a3b5a1..cb869018f 100644 --- a/projects/social_platform/src/app/api/project/facades/edit/ui/project-team-ui.service.spec.ts +++ b/projects/social_platform/src/app/api/project/facades/edit/ui/project-team-ui.service.spec.ts @@ -4,6 +4,17 @@ import { ProjectTeamUIService } from "./project-team-ui.service"; import { User } from "@domain/auth/user.model"; import { Collaborator } from "@domain/project/collaborator.model"; describe("ProjectTeamUIService", () => { + it("отличает начальную загрузку от пустого результата resolver", () => { + TestBed.configureTestingModule({ providers: [ProjectTeamUIService] }); + const ui = TestBed.inject(ProjectTeamUIService); + expect(ui.teamLoading()).toBe(true); + ui.applySetCollaborators([]); + expect(ui.teamLoading()).toBe(false); + expect(ui.collaborators()).toEqual([]); + ui.applyOpenInviteModal(); + ui.applyCloseInviteModal(); + expect(ui.teamLoading()).toBe(false); + }); it("reset очищает только создание приглашения, сохраняет участников и валидаторы", () => { TestBed.configureTestingModule({ providers: [ProjectTeamUIService] }); const ui = TestBed.inject(ProjectTeamUIService); diff --git a/projects/social_platform/src/app/api/project/facades/edit/ui/project-team-ui.service.ts b/projects/social_platform/src/app/api/project/facades/edit/ui/project-team-ui.service.ts index 0de6ed895..57c71e8be 100644 --- a/projects/social_platform/src/app/api/project/facades/edit/ui/project-team-ui.service.ts +++ b/projects/social_platform/src/app/api/project/facades/edit/ui/project-team-ui.service.ts @@ -17,6 +17,8 @@ export class ProjectTeamUIService { readonly invites = signal([]); readonly collaborators = signal([]); + // До инициализации из resolver пустой массив ещё не означает пустую команду. + readonly teamLoading = signal(true); readonly isInviteModalOpen = signal(false); readonly inviteSubmitError = signal(null); @@ -51,6 +53,7 @@ export class ProjectTeamUIService { applySetCollaborators(collaborators: Collaborator[]): void { this.collaborators.set(collaborators); + this.teamLoading.set(false); } applyOpenInviteModal(): void { diff --git a/projects/social_platform/src/app/domain/program/ports/program.repository.port.ts b/projects/social_platform/src/app/domain/program/ports/program.repository.port.ts index 1fb175580..8f52a372a 100644 --- a/projects/social_platform/src/app/domain/program/ports/program.repository.port.ts +++ b/projects/social_platform/src/app/domain/program/ports/program.repository.port.ts @@ -1,6 +1,10 @@ /** @format */ import { HttpParams } from "@angular/common/http"; +import { + ProgramCaseQuery, + ProgramCaseSelection, +} from "@domain/program/program-case-analytics.model"; import { ProgramRoleWidget } from "../program-role-widget.model"; import { Observable } from "rxjs"; import { ApiPagination } from "../../other/api-pagination.model"; @@ -28,6 +32,9 @@ import { /** Порт репозитория программ: список/детали/создание/регистрация, проекты/участники/фильтры. */ export abstract class ProgramRepositoryPort { + abstract getCaseProjects(programId: number, query: ProgramCaseQuery): Observable; + abstract exportCaseProjects(programId: number, selection: ProgramCaseSelection): Observable; + /** Сводка текущего пользователя без обращения к manager-only API. */ abstract getRoleWidget(programId: number): Observable; abstract getAll( diff --git a/projects/social_platform/src/app/domain/program/program-case-analytics.fixture.ts b/projects/social_platform/src/app/domain/program/program-case-analytics.fixture.ts new file mode 100644 index 000000000..616194e47 --- /dev/null +++ b/projects/social_platform/src/app/domain/program/program-case-analytics.fixture.ts @@ -0,0 +1,58 @@ +/** @format */ +import { + ProgramCasePage, + ProgramCaseProject, + ProgramCaseSelection, +} from "./program-case-analytics.model"; + +export const selectedCase: ProgramCaseSelection = { + scope: "selected", + caseName: "Цифровая трансформация", +}; +export const caseMetrics = { + projectsTotal: 2, + participantsTotal: 5, + submitted: 1, + notSubmitted: 1, +}; +export function caseProject(overrides: Partial = {}): ProgramCaseProject { + return { + programProjectId: 901, + project: { + id: 73, + name: "Умный город", + region: "Москва", + presentationAddress: "https://example.org/deck.pdf", + }, + case: { kind: "selected", name: "Цифровая трансформация" }, + leader: { userId: 14, fullName: "Анна Иванова" }, + teamSize: 3, + linkedAt: "2026-09-01T10:00:00Z", + submitted: true, + submittedAt: "2026-09-20T11:00:00Z", + ...overrides, + }; +} +export function casePage(overrides: Partial = {}): ProgramCasePage { + return { + count: 2, + next: null, + previous: null, + results: [ + caseProject(), + caseProject({ + programProjectId: 902, + project: { id: 74, name: "Второй проект", region: null, presentationAddress: null }, + leader: null, + teamSize: 1, + submitted: false, + submittedAt: null, + }), + ], + selection: { scope: "selected", caseName: "Цифровая трансформация" }, + casesConfigured: true, + submissionApplicable: true, + caseMetrics: { ...caseMetrics }, + ...overrides, + }; +} diff --git a/projects/social_platform/src/app/domain/program/program-case-analytics.model.ts b/projects/social_platform/src/app/domain/program/program-case-analytics.model.ts new file mode 100644 index 000000000..8df122723 --- /dev/null +++ b/projects/social_platform/src/app/domain/program/program-case-analytics.model.ts @@ -0,0 +1,99 @@ +/** @format */ +import { ProgramAnalyticsCaseMetrics, ProgramAnalyticsError } from "./program-analytics.model"; + +export type ProgramCaseSelection = + | { scope: "selected"; caseName: string } + | { scope: "all"; caseName?: never } + | { scope: "without_case"; caseName?: never }; +export interface ProgramCaseQuery { + selection: ProgramCaseSelection; + search?: string; + limit?: number; + offset?: number; +} +export interface ProgramCaseProject { + programProjectId: number; + project: { id: number; name: string; region: string | null; presentationAddress: string | null }; + case: { kind: "selected" | "without_case"; name: string | null }; + leader: { userId: number; fullName: string } | null; + teamSize: number; + linkedAt: string; + submitted: boolean; + submittedAt: string | null; +} +export interface ProgramCasePage { + count: number; + next: string | null; + previous: string | null; + results: ProgramCaseProject[]; + selection: { scope: ProgramCaseSelection["scope"]; caseName: string | null }; + casesConfigured: boolean; + submissionApplicable: boolean; + caseMetrics: ProgramAnalyticsCaseMetrics | null; +} +export interface ProgramCaseError { + kind: ProgramAnalyticsError["kind"] | "unsupported" | "invalid"; +} + +const record = (value: unknown): value is Record => + !!value && typeof value === "object" && !Array.isArray(value); +const count = (value: unknown): value is number => + typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +const textOrNull = (value: unknown): boolean => value === null || typeof value === "string"; + +/** Старый backend игнорирует view: проверяем структуру и идентичность bucket, а не только HTTP 200. */ +export function isProgramCasePage( + value: unknown, + selection: ProgramCaseSelection, +): value is ProgramCasePage { + if ( + !record(value) || + !record(value["selection"]) || + value["selection"]["scope"] !== selection["scope"] || + value["selection"]["caseName"] !== + (selection["scope"] === "selected" ? selection["caseName"] : null) || + typeof value["casesConfigured"] !== "boolean" || + typeof value["submissionApplicable"] !== "boolean" || + !count(value["count"]) || + !textOrNull(value["next"]) || + !textOrNull(value["previous"]) || + !Array.isArray(value["results"]) + ) + return false; + const metrics = value["caseMetrics"]; + if ( + selection["scope"] === "all" + ? metrics !== null + : !record(metrics) || + !["projectsTotal", "participantsTotal", "submitted", "notSubmitted"].every(key => + count(metrics[key]), + ) + ) + return false; + return value["results"].every(row => { + if (!record(row) || !record(row["project"]) || !record(row["case"])) return false; + const bucket = row["case"]; + return ( + count(row["programProjectId"]) && + row["programProjectId"] > 0 && + count(row["project"]["id"]) && + row["project"]["id"] > 0 && + typeof row["project"]["name"] === "string" && + textOrNull(row["project"]["presentationAddress"]) && + textOrNull(row["project"]["region"]) && + ((bucket["kind"] === "without_case" && bucket["name"] === null) || + (bucket["kind"] === "selected" && typeof bucket["name"] === "string")) && + (selection["scope"] === "all" || + (bucket["kind"] === selection["scope"] && + bucket["name"] === (selection["scope"] === "selected" ? selection["caseName"] : null))) && + (row["leader"] === null || + (record(row["leader"]) && + count(row["leader"]["userId"]) && + typeof row["leader"]["fullName"] === "string")) && + count(row["teamSize"]) && + typeof row["linkedAt"] === "string" && + typeof row["submitted"] === "boolean" && + textOrNull(row["submittedAt"]) + ); + }); +} diff --git a/projects/social_platform/src/app/infrastructure/adapters/program/program-case-http.integration.spec.ts b/projects/social_platform/src/app/infrastructure/adapters/program/program-case-http.integration.spec.ts new file mode 100644 index 000000000..57ba13ec8 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/program/program-case-http.integration.spec.ts @@ -0,0 +1,82 @@ +/** @format */ +import { TestBed } from "@angular/core/testing"; +import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from "@angular/common/http"; +import { HttpTestingController, provideHttpClientTesting } from "@angular/common/http/testing"; +import { API_URL, CamelcaseInterceptor } from "@corelib"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { ProgramRepository } from "@infrastructure/repository/program/program.repository"; +import { ExportProgramCaseProjectsUseCase } from "@api/program/use-cases/export-program-case-projects.use-case"; +import { firstValueFrom } from "rxjs"; + +describe("Case export: HTTP → camelCase → repository → contract guard → XLSX", () => { + let http: HttpTestingController; + const selection = { scope: "selected", caseName: "Кейс + &=100%" } as const; + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + { provide: API_URL, useValue: "/api" }, + { provide: HTTP_INTERCEPTORS, useClass: CamelcaseInterceptor, multi: true }, + { provide: ProgramRepositoryPort, useClass: ProgramRepository }, + ], + }); + http = TestBed.inject(HttpTestingController); + }); + afterEach(() => http.verify()); + it("принимает настоящий snake_case DTO и затем запрашивает только полный scope", async () => { + const promise = firstValueFrom( + TestBed.inject(ExportProgramCaseProjectsUseCase).execute(12, selection), + ); + const list = http.expectOne(req => req.url === "/api/programs/12/projects/"); + expect(list.request.params.get("case_name")).toBe("Кейс + &=100%"); + expect(list.request.params.get("view")).toBe("case_analytics"); + list.flush({ + count: 1, + next: null, + previous: null, + cases_configured: true, + submission_applicable: true, + selection: { scope: "selected", case_name: selection.caseName }, + case_metrics: { projects_total: 1, participants_total: 1, submitted: 1, not_submitted: 0 }, + results: [ + { + program_project_id: 901, + project: { + id: 73, + name: "Проект", + presentation_address: "https://example.org/p.pdf", + region: null, + }, + case: { kind: "selected", name: selection.caseName }, + leader: { user_id: 7, full_name: "Анна Иванова" }, + team_size: 2, + linked_at: "2026-09-01T10:00:00Z", + submitted: true, + submitted_at: null, + }, + ], + }); + const file = http.expectOne(req => req.url === "/api/programs/12/export-projects/"); + expect(file.request.responseType).toBe("blob"); + expect(file.request.params.keys().sort()).toEqual(["case_name", "case_scope", "view"]); + const blob = new Blob(["xlsx fixture"]); + file.flush(blob); + expect(await promise).toEqual({ ok: true, value: blob }); + }); + it("legacy HTTP 200 не вызывает export endpoint", async () => { + const promise = firstValueFrom( + TestBed.inject(ExportProgramCaseProjectsUseCase).execute(12, selection), + ); + http + .expectOne(req => req.url === "/api/programs/12/projects/") + .flush({ + count: 1, + next: null, + previous: null, + results: [{ id: 73, name: "Обычный ProjectList" }], + }); + expect(await promise).toEqual({ ok: false, error: { kind: "unsupported" } }); + http.expectNone(req => req.url.includes("export-projects")); + }); +}); diff --git a/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.spec.ts b/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.spec.ts index a069b028c..8f8c9e8ff 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.spec.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.spec.ts @@ -17,7 +17,7 @@ describe("ProgramHttpAdapter", () => { let api: any; function setup(): void { - api = { get: vi.fn(), post: vi.fn() }; + api = { get: vi.fn(), post: vi.fn(), getFile: vi.fn() }; TestBed.configureTestingModule({ providers: [ProgramHttpAdapter, { provide: ApiService, useValue: api }], }); @@ -31,6 +31,48 @@ describe("ProgramHttpAdapter", () => { previous: "", }); + it("case list передаёт exact option, opt-in, поиск и страницу", () => { + setup(); + api.get.mockReturnValue(of({})); + adapter + .getCaseProjects(12, { + selection: { scope: "selected", caseName: " Кейс & A " }, + search: " Проект ", + limit: 25, + offset: 50, + }) + .subscribe(); + const [url, params] = api.get.mock.lastCall; + expect(url).toBe("/programs/12/projects/"); + expect(Object.fromEntries(params.keys().map((key: string) => [key, params.get(key)]))).toEqual({ + view: "case_analytics", + case_scope: "selected", + case_name: " Кейс & A ", + search: "Проект", + limit: "25", + offset: "50", + }); + }); + + it.each(["all", "without_case", "selected"] as const)( + "case XLSX %s не принимает search/pagination", + scope => { + setup(); + api.getFile.mockReturnValue(of(new Blob())); + const selection = + scope === "selected" ? { scope, caseName: "Без выбранного кейса" } : { scope }; + adapter.exportCaseProjects(12, selection).subscribe(); + const [url, params] = api.getFile.mock.lastCall; + expect(url).toBe("/programs/12/export-projects/"); + expect(params.keys().sort()).toEqual( + scope === "selected" ? ["case_name", "case_scope", "view"] : ["case_scope", "view"], + ); + expect(params.get("view")).toBe("case_analytics"); + expect(params.get("case_scope")).toBe(scope); + expect(params.get("case_name")).toBe(scope === "selected" ? "Без выбранного кейса" : null); + }, + ); + it("getAll идёт в GET /programs/ c limit/offset и дополнительными фильтрами", () => { setup(); api.get.mockReturnValue(of(pagination())); diff --git a/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.ts index 320e46ac6..3c29a247f 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.ts @@ -5,6 +5,10 @@ import { ProgramRoleWidget } from "@domain/program/program-role-widget.model"; import { ApiService } from "@corelib"; import { Observable } from "rxjs"; import { HttpParams } from "@angular/common/http"; +import { + ProgramCaseQuery, + ProgramCaseSelection, +} from "@domain/program/program-case-analytics.model"; import { ApiPagination } from "@domain/other/api-pagination.model"; import { User } from "@domain/auth/user.model"; import { PartnerProgramFields } from "@domain/program/partner-program-fields.model"; @@ -31,6 +35,28 @@ import { /** HTTP-адаптер программ: `/programs`, `/auth/public-users` (детали, проекты, участники, фильтры, регистрация). */ @Injectable({ providedIn: "root" }) export class ProgramHttpAdapter { + getCaseProjects(programId: number, query: ProgramCaseQuery): Observable { + let params = this.caseParams(query.selection) + .set("limit", query.limit ?? 25) + .set("offset", query.offset ?? 0); + if (query.search?.trim()) params = params.set("search", query.search.trim()); + return this.apiService.get(`${this.PROGRAMS_URL}/${programId}/projects/`, params); + } + + exportCaseProjects(programId: number, selection: ProgramCaseSelection): Observable { + return this.apiService.getFile( + `${this.PROGRAMS_URL}/${programId}/export-projects/`, + this.caseParams(selection), + ); + } + + private caseParams(selection: ProgramCaseSelection): HttpParams { + const params = new HttpParams() + .set("view", "case_analytics") + .set("case_scope", selection.scope); + return selection.scope === "selected" ? params.set("case_name", selection.caseName) : params; + } + /** Отдельный ролевой endpoint: доступ и агрегации проверяет сервер. */ getRoleWidget(programId: number): Observable { return this.apiService.get(`${this.PROGRAMS_URL}/${programId}/analytics-widget/`); diff --git a/projects/social_platform/src/app/infrastructure/repository/program/program.repository.ts b/projects/social_platform/src/app/infrastructure/repository/program/program.repository.ts index c7efb7d83..1e0b5c2d6 100644 --- a/projects/social_platform/src/app/infrastructure/repository/program/program.repository.ts +++ b/projects/social_platform/src/app/infrastructure/repository/program/program.repository.ts @@ -1,6 +1,10 @@ /** @format */ import { HttpParams } from "@angular/common/http"; +import { + ProgramCaseQuery, + ProgramCaseSelection, +} from "@domain/program/program-case-analytics.model"; import { ProgramRoleWidget } from "@domain/program/program-role-widget.model"; import { inject, Injectable } from "@angular/core"; import { map, Observable, tap, throwError } from "rxjs"; @@ -36,6 +40,14 @@ import { /** Репозиторий программ: `EntityCache` для `getOne`, остальное — passthrough. */ @Injectable({ providedIn: "root" }) export class ProgramRepository implements ProgramRepositoryPort { + getCaseProjects(programId: number, query: ProgramCaseQuery): Observable { + return this.programAdapter.getCaseProjects(programId, query); + } + + exportCaseProjects(programId: number, selection: ProgramCaseSelection): Observable { + return this.programAdapter.exportCaseProjects(programId, selection); + } + /** Не кеширует ролевую сводку: прежние данные не должны переживать смену контекста. */ getRoleWidget(programId: number): Observable { return this.programAdapter.getRoleWidget(programId); diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.html b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.html index 81be4423b..ba18da65e 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.html +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.html @@ -261,39 +261,53 @@

Кейсы

@if (caseEmptyMessage(); as message) {

{{ message }}

- } @else { + } + @if (caseRows().length) {
    @for (row of caseRows(); track row.key) { -
  • -
    - {{ row.name }} - - {{ row.projectsTotal }} - {{ row.totalLabel }} - -
    - -
    - @if (overview.cases.submissionApplicable) { - Сдано {{ row.submitted }} - +
  • +
  • }
@@ -608,6 +622,7 @@

Динамика активности

diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.scss b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.scss index 128ebbcc0..9ba6d26d6 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.scss +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.scss @@ -308,12 +308,27 @@ } .case-row { + display: block; flex-shrink: 0; + width: 100%; min-width: 0; padding: 10px; + font: inherit; + text-align: left; + cursor: pointer; background: var(--light-gray); + border: 1px solid transparent; border-radius: 8px; + &:hover { + border-color: var(--accent); + } + + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + &--without-case { border: 1px dashed var(--medium-grey-for-outline); } @@ -499,7 +514,9 @@ background: var(--light-gray); border: 1px solid var(--medium-grey-for-outline); border-radius: 8px; - transition: background-color 0.15s, border-color 0.15s; + transition: + background-color 0.15s, + border-color 0.15s; &:hover:not(:disabled), &:active:not(:disabled) { diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.spec.ts index 29b499cbb..6d4808c55 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.spec.ts @@ -1,5 +1,7 @@ /** @format */ +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; + import { signal } from "@angular/core"; import { TestBed } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; @@ -99,6 +101,7 @@ describe("ProgramAnalyticsComponent", () => { const error = signal(null); const analytics = { programId: signal(12), + programName: signal("Тестовая программа"), data, pending, failed, @@ -128,6 +131,10 @@ describe("ProgramAnalyticsComponent", () => { TestBed.configureTestingModule({ imports: [ProgramAnalyticsComponent], providers: [ + { + provide: ProgramRepositoryPort, + useValue: { getCaseProjects: () => of({}), exportCaseProjects: vi.fn() }, + }, provideRouter([]), { provide: GetProgramManagerProjectsNotSubmittedUseCase, useValue: { execute: vi.fn() } }, { @@ -151,6 +158,10 @@ describe("ProgramAnalyticsComponent", () => { .overrideComponent(ProgramAnalyticsComponent, { set: { providers: [ + { + provide: ProgramRepositoryPort, + useValue: { getCaseProjects: () => of({}), exportCaseProjects: vi.fn() }, + }, { provide: ProgramAnalyticsInfoService, useValue: analytics }, { provide: ExportFileInfoService, useValue: exports }, ], @@ -160,6 +171,36 @@ describe("ProgramAnalyticsComponent", () => { }); describe("cases from manager overview", () => { + it("не показывает общую выгрузку и не запрашивает проекты до открытия кейса", () => { + const fixture = TestBed.createComponent(ProgramAnalyticsComponent); + const repo = fixture.debugElement.injector.get(ProgramRepositoryPort); + const list = vi.spyOn(repo, "getCaseProjects"); + fixture.detectChanges(); + + const card = (fixture.nativeElement as HTMLElement).querySelector( + '[data-testid="cases-card"]', + )!; + expect(card.textContent).not.toContain("Выгрузить все проекты"); + expect(card.textContent).not.toContain("Проверяем доступность выгрузки"); + expect(list).not.toHaveBeenCalled(); + }); + + it.each([0, 2, 3])("клик строки %s передаёт точный scope, включая нулевой кейс", index => { + const { fixture, rows } = renderCases(); + const drilldown = fixture.debugElement.query( + By.directive(AnalyticsDrilldownComponent), + ).componentInstance; + const open = vi.spyOn(drilldown, "openCase").mockImplementation(() => {}); + rows[index].click(); + expect(open).toHaveBeenCalledWith( + index === 3 + ? { scope: "without_case" } + : { scope: "selected", caseName: index === 0 ? "Case A" : "Case C" }, + expect.objectContaining({ projectsTotal: index === 0 ? 3 : index === 2 ? 0 : 2 }), + true, + rows[index], + ); + }); function renderCases() { const fixture = TestBed.createComponent(ProgramAnalyticsComponent); fixture.detectChanges(); @@ -207,7 +248,11 @@ describe("ProgramAnalyticsComponent", () => { "Case A: 3 проекта, сдано 2, не сдано 1, участников 7", ); expect(rows[3].classList.contains("case-row--without-case")).toBe(true); - expect(card.querySelector("button, a")).toBeNull(); + expect( + rows.every( + row => row.tagName === "BUTTON" && row.getAttribute("aria-haspopup") === "dialog", + ), + ).toBe(true); expect(JSON.stringify(data())).toBe(before); expect(analytics.initialize).toHaveBeenCalledOnce(); const tooltip = fixture.debugElement.query(By.css(".cases-card app-tooltip")) @@ -252,12 +297,13 @@ describe("ProgramAnalyticsComponent", () => { ])("renders controlled empty state when configured=%s", (configured, message) => { const model = overview(); model.cases.configured = configured; - if (configured) model.cases.items = []; + model.cases.items = []; data.set(model); const { card, rows } = renderCases(); - expect(rows).toHaveLength(0); + expect(rows).toHaveLength(1); expect(card.querySelector(".analytics-empty-state.text-body-10")?.textContent).toBe(message); - expect(card.textContent).not.toContain("Без выбранного кейса"); + expect(card.textContent).toContain("Без выбранного кейса"); + expect(rows[0].tagName).toBe("BUTTON"); }); it("keeps all configured zero options, without an empty state or fake bar values", () => { @@ -286,7 +332,9 @@ describe("ProgramAnalyticsComponent", () => { expect(card.textContent).toContain("Участников 7"); expect(card.textContent).not.toMatch(/Сдано|Не сдано/); expect(rows[0].querySelector(".case-row__volume")!.style.width).toBe("100%"); - expect(rows[0].getAttribute("aria-label")).toBe("Case A: 3 проекта, участников 7"); + expect(rows[0].getAttribute("aria-label")).toBe( + "Case A: 3 проекта, участников 7. Открыть проекты кейса", + ); expect(model.cases.items[0].submitted).toBe(2); expect( fixture.nativeElement.querySelector('[data-testid="solution-funnel"]').textContent, diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.ts b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.ts index ae402f7c0..97d1bd988 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/analytics.component.ts @@ -10,6 +10,7 @@ import { } from "@angular/core"; import { MatProgressBarModule } from "@angular/material/progress-bar"; import { ExportFileInfoService } from "@api/export-file/facades/export-file-info.service"; +import { ProgramCaseSelection } from "@domain/program/program-case-analytics.model"; import { ProgramAnalyticsInfoService } from "@api/program/facades/detail/program-analytics-info.service"; import { ProgramAnalyticsActivityPoint, @@ -71,10 +72,17 @@ interface AnalyticsCaseRow extends ProgramAnalyticsCaseMetrics { export class ProgramAnalyticsComponent implements OnInit { private readonly analytics = inject(ProgramAnalyticsInfoService); private readonly exports = inject(ExportFileInfoService); + + protected caseSelection(row: AnalyticsCaseRow): ProgramCaseSelection { + return row.isWithoutCase + ? { scope: "without_case" } + : { scope: "selected", caseName: row.name }; + } private readonly pluralize = new PluralizePipe(); protected readonly data = this.analytics.data; protected readonly programId = this.analytics.programId; + protected readonly programName = this.analytics.programName; protected readonly pending = this.analytics.pending; protected readonly failed = this.analytics.failed; protected readonly error = this.analytics.error; @@ -98,7 +106,7 @@ export class ProgramAnalyticsComponent implements OnInit { /** Preserve backend order/counts; only bar percentages and presentation are derived. */ protected readonly caseRows = computed(() => { const cases = this.data()?.cases; - if (!cases?.configured || !cases.items.length) return []; + if (!cases) return []; const rows = cases.items.map((item, index) => ({ ...item, key: `case:${index}`, diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-attention-route.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-attention-route.spec.ts index 82fd0dea9..ce465103a 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-attention-route.spec.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-attention-route.spec.ts @@ -1,4 +1,7 @@ /** @format */ + +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; + import { ChangeDetectionStrategy, Component, inject } from "@angular/core"; import { TestBed } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; @@ -25,7 +28,13 @@ import { AnalyticsDrilldownComponent } from "./analytics-drilldown.component"; @Component({ imports: [AnalyticsDrilldownComponent], - providers: [ProgramAnalyticsInfoService], + providers: [ + { + provide: ProgramRepositoryPort, + useValue: { getCaseProjects: () => of({}), exportCaseProjects: vi.fn() }, + }, + ProgramAnalyticsInfoService, + ], changeDetection: ChangeDetectionStrategy.OnPush, template: ` }

{{ title() }}

+ @if (state.view() === "case" && cases.selection()?.scope === "without_case") { +

+ Проекты, для которых кейс не выбран или больше недоступен. +

+ } @if (state.view() === "backlog") {

Ожидающие назначения

} @@ -67,6 +74,197 @@

{{ title() + @if (state.view() === "case") { +
+ @if (cases.metrics(); as metrics) { +
+
+
Проекты
+
{{ metrics.projectsTotal }}
+
+
+
Участники
+
{{ metrics.participantsTotal }}
+
+ @if (cases.submissionApplicable()) { +
+
Сдано
+
{{ metrics.submitted }}
+
+
+
Не сдано
+
{{ metrics.notSubmitted }}
+
+ } +
+ } +
+ Выгрузить проекты кейса +

+ {{ + cases.metrics()?.projectsTotal === 0 + ? "В кейсе пока нет проектов" + : "Все проекты кейса, без учёта поиска" + }} +

+ @if (cases.exportError(); as error) { +

{{ caseErrorMessage(error) }}

+ } +
+ +
+ @if (cases.pending()) { +

+ Загружаем список… +

+ } @else if (cases.error(); as error) { + + @if (error.kind === "network") { + + } + } @else if (cases.page(); as page) { +

Найдено: {{ page.count }}

+ @if (!page.results.length) { +
+ {{ + cases.search() + ? "По запросу ничего не найдено" + : "В этом кейсе пока нет проектов" + }} +

+ {{ + cases.search() + ? "Измените запрос или очистите поиск." + : "Проекты появятся здесь после выбора этого кейса." + }} +

+
+ } @else { + + + + + + + + + + + @for (item of page.results; track item.programProjectId) { + + + + + + + } + +
ПроектЛидер / командаСдача решенияПрезентация
+ {{ item.project.name }} + + {{ item.leader?.fullName || "Лидер не указан" }} + Размер команды: {{ item.teamSize }} + + @if (!page.submissionApplicable) { + Не требуется + } @else { + {{ item.submitted ? "Сдано" : "Не сдано" }} + {{ + item.submittedAt ? (item.submittedAt | date: "dd.MM.yyyy") : "—" + }} + } + + @if (presentationHref(item.project.presentationAddress); as href) { + Открыть презентацию + } @else { + {{ + item.project.presentationAddress + ? "Ссылка недоступна" + : "Не добавлена" + }} + } +
+ } + @if (page.count > cases.limit || cases.offset() > 0) { + + } + } +
+
+ } @if (state.isAttentionView()) {

} } - } @else { + } @else if (state.view() !== "case") { @if (state.view() === "delayed") {
    @for (expert of state.delayedExperts(); track expert.expertId) { diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.scss b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.scss index cf0cf17d8..cc1372080 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.scss +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.scss @@ -1,6 +1,133 @@ /** @format */ @use "styles/responsive"; +// Только case-вариант: shared modal задаёт width своим stylesheet после подключения overlay. +.modal__body.analytics-drilldown-body.analytics-drilldown-body--case { + box-sizing: border-box; + width: min(880px, calc(100% - 24px)); + max-height: calc(100dvh - 32px); + padding: 20px; + overflow-y: auto; +} + +.analytics-case { + &__metrics { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + padding: 10px 12px; + margin: 0 0 16px; + background: var(--light-gray); + border-radius: 8px; + + div { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: baseline; + } + + dt { + font-size: 12px; + color: var(--dark-grey); + } + + dd { + margin: 0; + font-weight: 600; + } + + @include responsive.apply-desktop { + display: flex; + + div + div { + padding-left: 12px; + border-left: 1px solid var(--medium-grey-for-outline); + } + } + } + + &__export { + display: grid; + gap: 6px; + margin-bottom: 16px; + font-size: 12px; + + app-button { + width: 100%; + } + + @include responsive.apply-desktop { + justify-items: start; + + app-button { + width: 220px; + } + } + } + + &__loading { + display: flex; + gap: 8px; + align-items: center; + justify-content: center; + padding: 48px 12px; + color: var(--dark-grey); + } + + .analytics-drilldown__search-controls { + max-width: 520px; + + input { + flex-basis: 100%; + border-radius: var(--rounded-xxl); + + @include responsive.apply-desktop { + flex-basis: 220px; + } + } + } + + .analytics-case__table { + small { + display: block; + margin-top: 4px; + font-size: 12px; + } + + @include responsive.apply-desktop { + th, + td { + padding: 10px 8px; + } + + th { + font-size: 13px; + } + + th:first-child, + td:first-child { + width: 32%; + } + + th:nth-child(2), + td:nth-child(2) { + width: 28%; + } + + th:nth-child(3), + td:nth-child(3) { + width: 20%; + } + + th:last-child, + td:last-child { + width: 20%; + } + } + } +} + // Overlay лежит вне host: единственное глобальное правило ограничено собственным bodyClass. .modal__body.analytics-drilldown-body { box-sizing: border-box; diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.spec.ts index cb7f2b951..1fe36c32b 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.spec.ts @@ -1,4 +1,7 @@ /** @format */ + +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; + import { CdkTrapFocus } from "@angular/cdk/a11y"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; @@ -44,6 +47,10 @@ describe("AnalyticsDrilldownComponent: real overlay lifecycle", () => { await TestBed.configureTestingModule({ imports: [AnalyticsDrilldownComponent], providers: [ + { + provide: ProgramRepositoryPort, + useValue: { getCaseProjects: () => of({}), exportCaseProjects: vi.fn() }, + }, provideRouter([]), { provide: GetProgramManagerProjectsNotSubmittedUseCase, diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.ts b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.ts index 9bb14783e..34613035f 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.ts @@ -1,5 +1,10 @@ /** @format */ import { A11yModule, CdkTrapFocus } from "@angular/cdk/a11y"; +import { ProgramCaseProjectsService } from "@api/program/facades/detail/program-case-projects.service"; +import { ProgramCaseSelection } from "@domain/program/program-case-analytics.model"; +import { ProgramAnalyticsCaseMetrics } from "@domain/program/program-analytics.model"; +import { ButtonComponent, LoaderComponent } from "@ui/primitives"; +import { caseErrorMessage, presentationHref } from "@utils/program-case-analytics"; import { AfterViewInit, afterRenderEffect, @@ -43,16 +48,29 @@ import { selector: "app-analytics-drilldown", templateUrl: "./analytics-drilldown.component.html", styleUrl: "./analytics-drilldown.component.scss", - imports: [A11yModule, ModalComponent, AvatarComponent, NgTemplateOutlet, DatePipe, RouterLink], - providers: [ProgramAnalyticsDrilldownService], + imports: [ + A11yModule, + ModalComponent, + AvatarComponent, + NgTemplateOutlet, + DatePipe, + RouterLink, + ButtonComponent, + LoaderComponent, + ], + providers: [ProgramAnalyticsDrilldownService, ProgramCaseProjectsService], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, }) export class AnalyticsDrilldownComponent implements AfterViewInit { readonly programId = input(null); + readonly programName = input("program"); readonly notSubmittedApplicable = input(false); readonly delayedExperts = input({ total: 0, items: [] }); protected readonly state = inject(ProgramAnalyticsDrilldownService); + protected readonly cases = this.state.cases; + protected readonly caseErrorMessage = caseErrorMessage; + protected readonly presentationHref = presentationHref; private readonly destroyRef = inject(DestroyRef); private trigger: HTMLElement | null = null; private focusedView = this.state.view(); @@ -72,6 +90,12 @@ export class AnalyticsDrilldownComponent implements AfterViewInit { protected readonly requestError = analyticsRequestError; protected readonly title = computed(() => { switch (this.state.view()) { + case "case": { + const selection = this.cases.selection(); + return selection?.scope === "selected" + ? `Кейс: ${selection.caseName}` + : "Без выбранного кейса"; + } case "participants-without-team": return "Участники без команды"; case "projects-awaiting-evaluation": @@ -202,6 +226,18 @@ export class AnalyticsDrilldownComponent implements AfterViewInit { this.state.openDelayed(id, this.delayedExperts()); } + openCase( + selection: ProgramCaseSelection, + metrics: ProgramAnalyticsCaseMetrics, + applicable: boolean, + trigger: HTMLElement, + ): void { + const id = this.programId(); + if (id === null || this.state.open() || this.attached()) return; + this.trigger = trigger; + this.state.openCase(id, selection, metrics, applicable, this.programName()); + } + /** Attention root сохраняет конкретную строку-trigger для обычного закрытия. */ openAttention(view: AnalyticsAttentionView, trigger: HTMLElement): void { const id = this.programId(); diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/_team-member-row.scss b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/_team-member-row.scss new file mode 100644 index 000000000..487b562cf --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/_team-member-row.scss @@ -0,0 +1,172 @@ +/** @format */ + +:host { + display: block; + min-width: 0; +} + +.member-row { + box-sizing: border-box; + display: grid; + grid-template-areas: + "avatar name role status actions" + "avatar description role status actions"; + grid-template-columns: 40px minmax(0, 1.18fr) minmax(0, 1fr) 148px 68px; + gap: 4px 12px; + align-items: center; + min-height: 66px; + padding: 12px 0; + color: var(--black); + border-bottom: 1px solid var(--medium-grey-for-outline); + + app-avatar { + grid-area: avatar; + } + + &__identity { + display: contents; + } + + &__name { + grid-area: name; + min-width: 0; + margin: 0; + font-size: 14px; + font-weight: 400; + line-height: 21px; + overflow-wrap: anywhere; + } + + &__description { + grid-area: description; + min-width: 0; + margin: 0; + font-size: 12px; + line-height: 1.3; + color: var(--grey-for-text); + overflow-wrap: anywhere; + } + + &__role { + grid-area: role; + min-width: 0; + font-size: 12px; + line-height: 1.3; + overflow-wrap: anywhere; + } + + &__status { + grid-area: status; + font-size: 12px; + line-height: 1.3; + color: var(--grey-for-text); + } + + &__actions { + display: flex; + grid-area: actions; + gap: 4px; + align-items: center; + justify-content: flex-end; + } + + &__action { + display: inline-flex; + flex-shrink: 0; + gap: 6px; + align-items: center; + justify-content: center; + width: 32px; + min-height: 32px; + padding: 0; + font: inherit; + font-size: 12px; + line-height: 1.5; + color: var(--grey-for-text); + cursor: pointer; + background: transparent; + border: 0; + border-radius: 8px; + + &:hover { + color: var(--accent); + } + + &--danger:hover { + color: var(--red-dark); + } + + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + + &:disabled { + cursor: wait; + opacity: 0.5; + } + } + + &__action-label { + display: none; + } + + &__error { + grid-column: 2 / -1; + margin: 0; + font-size: 12px; + line-height: 1.5; + color: var(--error, #b42318); + } +} + +@media (width < 750px) { + .member-row { + grid-template-areas: + "avatar name" + "avatar role" + "avatar description" + "actions actions"; + grid-template-columns: 40px minmax(0, 1fr); + row-gap: 0; + + &__role, + &__description, + &__status { + margin-top: 4px; + } + + &--pending { + grid-template-areas: + "avatar name" + "avatar role" + "avatar description" + ". status" + "actions actions"; + } + + &:not(.member-row--pending) .member-row__status { + display: none; + } + + &__actions { + flex-wrap: wrap; + gap: 4px 16px; + margin-top: 0; + } + + &__action { + width: auto; + min-height: 44px; + padding: 0 4px; + } + + &__action-label { + display: inline; + } + + &__error { + grid-column: 1 / -1; + } + } +} diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.html b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.html index 6ed7a2af1..cb0b210d8 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.html +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.html @@ -1,26 +1,41 @@ - -@if (collaborator()) { -
    -
    - -
    -

    - {{ collaborator().firstName | truncate: 12 }} {{ collaborator().lastName | truncate: 12 }} -

    -

    {{ collaborator().role | truncate: 12 }}

    -
    - -
    - - -
    -
    +
    + +
    +

    {{ collaborator().firstName }} {{ collaborator().lastName }}

    + {{ collaborator().role || "Роль не указана" }} +

    + {{ isLeader() ? "Руководитель проекта" : "Участник проекта" }} + @if (isCurrentUser()) { + · Вы + } +

    -} + В команде +
    + +
    + @if (error()) { + + } +
    diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.scss b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.scss index 7b9f8aa19..0f49e4c51 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.scss +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.scss @@ -1,62 +1,2 @@ /** @format */ - -@use "styles/typography"; - -.collaborator { - min-height: 200px; - padding: 15px 24px; - background-color: white; - border: 1px solid var(--dark-grey); - border-radius: var(--rounded-md); - - &__role { - color: var(--black); - } - - &__requirements { - color: var(--dark-grey); - } - - &__info { - display: flex; - flex-direction: column; - gap: 20px; - align-items: center; - text-align: center; - - &--text { - display: flex; - flex-direction: column; - gap: 5px; - } - } - - &__skills { - display: flex; - flex-direction: column; - gap: 10px; - margin: 10px 0; - } - - &__icons { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - - i { - color: var(--accent); - cursor: pointer; - } - - &--delete { - color: var(--red) !important; - } - - &--edit { - &--disabled { - opacity: 0.3; - } - } - } -} +@use "../team-member-row"; diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.spec.ts b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.spec.ts index 30e418306..fb15f42ea 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.spec.ts @@ -2,7 +2,8 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { CollaboratorCardComponent } from "./collaborator-card.component"; -import { provideRouter } from "@angular/router"; +import { of, Subject } from "rxjs"; +import { ActivatedRoute, provideRouter } from "@angular/router"; import { RemoveProjectCollaboratorUseCase } from "@api/project/use-cases/remove-project-collaborator.use-case"; describe("CollaboratorCardComponent", () => { @@ -14,6 +15,7 @@ describe("CollaboratorCardComponent", () => { imports: [CollaboratorCardComponent], providers: [ provideRouter([]), + { provide: ActivatedRoute, useValue: { snapshot: { params: { projectId: "5" } } } }, { provide: RemoveProjectCollaboratorUseCase, useValue: { execute: vi.fn() }, @@ -39,4 +41,30 @@ describe("CollaboratorCardComponent", () => { it("should create", () => { expect(component).toBeTruthy(); }); + it("сохраняет подтверждение и не удаляет участника при ошибке, блокирует повтор", () => { + const confirm = vi.spyOn(window, "confirm").mockReturnValue(false); + const execute = vi.mocked(TestBed.inject(RemoveProjectCollaboratorUseCase).execute); + const removed = vi.fn(); + component.collaboratorRemoved.subscribe(removed); + component.onDeleteCollaborator(1); + expect(execute).not.toHaveBeenCalled(); + confirm.mockReturnValue(true); + const request = new Subject(); + execute.mockReturnValue(request); + component.onDeleteCollaborator(1); + component.onDeleteCollaborator(1); + expect(execute).toHaveBeenCalledExactlyOnceWith(5, 1); + request.next({ ok: false, error: { kind: "remove_project_collaborator_error" } }); + request.complete(); + fixture.detectChanges(); + expect(removed).not.toHaveBeenCalled(); + expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain( + "Не удалось", + ); + expect(component.removing()).toBe(false); + execute.mockReturnValue(of({ ok: true, value: 1 })); + component.onDeleteCollaborator(1); + expect(removed).toHaveBeenCalledExactlyOnceWith(1); + confirm.mockRestore(); + }); }); diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.ts b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.ts index 038e9a82a..7b479be68 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.ts +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.ts @@ -1,78 +1,57 @@ /** @format */ - -import { CommonModule } from "@angular/common"; import { ChangeDetectionStrategy, Component, DestroyRef, - EventEmitter, inject, input, - Input, - OnInit, output, - Output, + signal, } from "@angular/core"; -import { FormBuilder, FormGroup, ReactiveFormsModule } from "@angular/forms"; import { ActivatedRoute } from "@angular/router"; -import { ErrorMessage } from "@core/lib/models/error/error-message"; import { Collaborator } from "@domain/project/collaborator.model"; import { AvatarComponent } from "@ui/primitives/avatar/avatar.component"; import { IconComponent } from "@uilib"; -import { TruncatePipe } from "@corelib"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { finalize } from "rxjs"; import { RemoveProjectCollaboratorUseCase } from "@api/project/use-cases/remove-project-collaborator.use-case"; -/** Карточка участника команды проекта. */ @Component({ selector: "app-collaborator-card", templateUrl: "./collaborator-card.component.html", styleUrl: "./collaborator-card.component.scss", - imports: [CommonModule, ReactiveFormsModule, AvatarComponent, IconComponent, TruncatePipe], + imports: [AvatarComponent, IconComponent], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class CollaboratorCardComponent implements OnInit { +export class CollaboratorCardComponent { private readonly removeProjectCollaboratorUseCase = inject(RemoveProjectCollaboratorUseCase); private readonly route = inject(ActivatedRoute); - private readonly fb = inject(FormBuilder); private readonly destroyRef = inject(DestroyRef); - - constructor() { - this.inviteForm = this.fb.group({ - role: [""], - specializations: this.fb.array([]), - }); - } - - inviteForm: FormGroup; - errorMessage = ErrorMessage; - readonly collaborator = input.required(); + readonly isCurrentUser = input(false); + readonly isLeader = input(false); readonly collaboratorRemoved = output(); - - ngOnInit(): void { - if (this.collaborator()) { - this.inviteForm.patchValue({ - role: this.collaborator().role, - specialization: this.collaborator().skills, - }); - } - } + readonly removing = signal(false); + readonly error = signal(""); onDeleteCollaborator(collaboratorId: number): void { + if (this.removing()) return; const projectId = this.route.snapshot.params["projectId"]; - if (!confirm("Вы точно хотите удалить участника проекта?")) return; - + this.removing.set(true); + this.error.set(""); this.removeProjectCollaboratorUseCase .execute(+projectId, collaboratorId) - .pipe(takeUntilDestroyed(this.destroyRef)) + .pipe( + takeUntilDestroyed(this.destroyRef), + finalize(() => this.removing.set(false)), + ) .subscribe({ next: result => { if (!result.ok) { + this.error.set("Не удалось исключить участника. Попробуйте ещё раз."); return; } - this.collaboratorRemoved.emit(result.value); }, }); diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/invite-card/invite-card.component.html b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/invite-card/invite-card.component.html index 46673b918..893bba396 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/invite-card/invite-card.component.html +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/invite-card/invite-card.component.html @@ -1,97 +1,98 @@ - -@if (invite()) { -
    -
    -
    -

    {{ invite().role | truncate: 30 }}

    - @if (invite().isAccepted === null) { -

    • приглашение отправлено

    - } -
    - -
    - -

    - {{ invite().user.firstName | truncate: 15 }} {{ invite().user.lastName | truncate: 15 }} -

    -
    -
    - - - - - - - - +
    + +
    +

    {{ invite().user.firstName }} {{ invite().user.lastName }}

    + {{ invite().role || "Роль не указана" }} +

    + @if (sentDate(); as date) { + Отправлено {{ date }} + } @else { + Ожидает ответа участника + } +

    +
    + Приглашение отправлено +
    + +
    +
    +@if (isRemoveInviteModal()) { + +
    + + +
    +
    } - - -
    -

    Вы, действительно, хотите удалить приглашение в команду?

    -
    - Отмена - Удалить +
    +
    + +
    -
    - - - -
    -

    Редактирование участника

    -

    Роль в команде

    - - @if (inviteForm.get("role"); as role) { -
    - - @if (role | controlError: "required") { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    - } -
    - } - @if (inviteForm.get("specialization"); as specialization) { -
    - - @if (specialization | controlError: "required") { -
    - {{ errorMessage.VALIDATION_REQUIRED }} -
    - } -
    - } - Сохранить - -
    -
    + +} diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/invite-card/invite-card.component.scss b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/invite-card/invite-card.component.scss index 32e871467..0f49e4c51 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/invite-card/invite-card.component.scss +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/invite-card/invite-card.component.scss @@ -1,105 +1,2 @@ /** @format */ - -@use "styles/typography"; - -.invite { - display: flex; - flex-direction: column; - gap: 15px; - justify-content: space-between; - padding: 15px 10px; - background-color: var(--light-gray); - border-radius: var(--rounded-md); - - &__role { - color: var(--black); - } - - &__requirements { - color: var(--dark-grey); - } - - &__status { - color: var(--dark-grey); - } - - &__info { - display: flex; - flex-direction: column; - gap: 5px; - } - - &__user { - display: flex; - gap: 10px; - align-items: center; - } - - &__top { - display: flex; - gap: 15px; - align-items: center; - justify-content: space-between; - padding-bottom: 5px; - margin-bottom: 15px; - border-bottom: 0.5px solid var(--dark-grey); - } - - &__warning-modal { - display: flex; - flex-direction: column; - justify-content: center; - width: 400px; - text-align: center; - - p { - margin-bottom: 15px; - } - } - - &__form { - display: flex; - flex-direction: column; - gap: 10px; - - :nth-child(2) { - margin-bottom: 220px; - } - - ::ng-deep { - app-input { - input { - @include typography.body-12; - } - } - - app-select { - .field__input, - ul, - li { - @include typography.body-12; - } - } - } - } - - &__buttons { - display: flex; - gap: 10px; - align-items: center; - } - - &__icons { - display: flex; - gap: 5px; - align-items: center; - } - - &__icon { - cursor: pointer; - } - - &__edit { - color: var(--dark-grey); - } -} +@use "../team-member-row"; diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/invite-card/invite-card.component.spec.ts b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/invite-card/invite-card.component.spec.ts index aaa0527c2..80d776b46 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/invite-card/invite-card.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/invite-card/invite-card.component.spec.ts @@ -6,7 +6,7 @@ import { provideNgxMask } from "ngx-mask"; import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; import { of } from "rxjs"; -describe("VacancyCardComponent", () => { +describe("InviteCardComponent", () => { let component: InviteCardComponent; let fixture: ComponentFixture; @@ -39,4 +39,48 @@ describe("VacancyCardComponent", () => { it("should create", () => { expect(component).toBeTruthy(); }); + it("показывает настоящую дату отправки и не выдумывает отсутствующую", () => { + fixture.componentRef.setInput("invite", { + id: 1, + user: { firstName: "Анна", lastName: "Смирнова", personal: { avatar: "" } }, + datetimeCreated: "2024-03-12T12:00:00Z", + }); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain("Отправлено 12 мар."); + fixture.componentRef.setInput("invite", { ...component.invite(), datetimeCreated: "invalid" }); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain("Ожидает ответа участника"); + }); + it("редактирование нормализует роль, сохраняет specialization и не отправляет пустую роль", () => { + fixture.componentRef.setInput("invite", { + ...component.invite(), + role: "Аналитик", + specialization: "legacy-value", + }); + const edit = vi.fn(); + component.edit.subscribe(edit); + component.openEdit({ currentTarget: document.createElement("button") } as any); + expect(component.role.value).toBe("Аналитик"); + component.role.setValue(" "); + component.onEdit(); + expect(edit).not.toHaveBeenCalled(); + component.role.setValue(" Продуктовый аналитик "); + component.onEdit(); + expect(edit).toHaveBeenCalledExactlyOnceWith({ + inviteId: 1, + role: "Продуктовый аналитик", + specialization: "legacy-value", + }); + expect(component.isEditInviteModal()).toBe(false); + }); + it("отмена отзыва не удаляет, подтверждение передаёт ID приглашения", () => { + const remove = vi.fn(); + component.remove.subscribe(remove); + component.openRemove({ currentTarget: document.createElement("button") } as any); + expect(remove).not.toHaveBeenCalled(); + component.isRemoveInviteModal.set(false); + expect(remove).not.toHaveBeenCalled(); + component.onRemove(); + expect(remove).toHaveBeenCalledExactlyOnceWith(1); + }); }); diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/invite-card/invite-card.component.ts b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/invite-card/invite-card.component.ts index 6f0f011a2..4494a0159 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/invite-card/invite-card.component.ts +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/invite-card/invite-card.component.ts @@ -1,90 +1,70 @@ /** @format */ - -import { - ChangeDetectionStrategy, - Component, - EventEmitter, - input, - Input, - OnInit, - output, - Output, - signal, -} from "@angular/core"; -import { FormBuilder, FormGroup, ReactiveFormsModule } from "@angular/forms"; -import { ErrorMessage } from "@core/lib/models/error/error-message"; +import { ChangeDetectionStrategy, Component, computed, input, output, signal } from "@angular/core"; +import { FormControl, ReactiveFormsModule } from "@angular/forms"; import { Invite } from "@domain/invite/invite.model"; -import { IconComponent, ButtonComponent, SelectComponent, InputComponent } from "@ui/primitives"; -import { ModalComponent } from "@ui/primitives/modal/modal.component"; -import { rolesMembersList } from "@core/consts/lists/roles-members-list.const"; +import { normalizeInviteText } from "@domain/invite/project-role-suggestions"; +import { inviteRoleValidator } from "@api/invite/project-invite-form"; +import { IconComponent } from "@ui/primitives"; import { AvatarComponent } from "@ui/primitives/avatar/avatar.component"; -import { TruncatePipe, ControlErrorPipe } from "@corelib"; +import { ProjectInviteDialogComponent } from "@ui/widgets/project-invite/project-invite-dialog.component"; +import { ProjectInviteRoleInputComponent } from "@ui/widgets/project-invite/project-invite-role-input.component"; -/** Карточка приглашения в команду с редактированием и удалением. */ +/** Ожидающее приглашение: существующие PATCH роли и DELETE приглашения. */ @Component({ selector: "app-invite-card", templateUrl: "./invite-card.component.html", styleUrl: "./invite-card.component.scss", imports: [ IconComponent, - ButtonComponent, - ModalComponent, - SelectComponent, - ControlErrorPipe, - TruncatePipe, - ReactiveFormsModule, - InputComponent, AvatarComponent, + ReactiveFormsModule, + ProjectInviteDialogComponent, + ProjectInviteRoleInputComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class InviteCardComponent implements OnInit { - constructor(private readonly fb: FormBuilder) { - this.inviteForm = this.fb.group({ - role: [""], - specialization: [null], - }); - } - - readonly rolesMembersList = rolesMembersList; - - inviteForm: FormGroup; - errorMessage = ErrorMessage; - +export class InviteCardComponent { readonly invite = input.required(); - readonly remove = output(); readonly edit = output<{ inviteId: number; role: string; specialization: string }>(); - - ngOnInit(): void { - if (this.invite()) { - this.inviteForm.patchValue({ - role: this.invite().role, - specialization: this.invite().specialization, - }); - } + readonly role = new FormControl("", { nonNullable: true, validators: [inviteRoleValidator] }); + readonly isRemoveInviteModal = signal(false); + readonly isEditInviteModal = signal(false); + protected trigger: HTMLElement | null = null; + readonly sentDate = computed(() => { + const value = this.invite().datetimeCreated; + if (!value) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) + ? null + : new Intl.DateTimeFormat("ru-RU", { + day: "numeric", + month: "short", + year: "numeric", + }).format(date); + }); + openEdit(event: Event): void { + this.trigger = event.currentTarget as HTMLElement; + this.role.reset(this.invite().role ?? ""); + this.isEditInviteModal.set(true); } - - // Сигналы для управления состоянием модальных окон - isRemoveInviteModal = signal(false); - isEditInviteModal = signal(false); - - onRemove(event: MouseEvent): void { - event.stopPropagation(); - event.preventDefault(); - - this.remove.emit(this.invite()?.id); + openRemove(event: Event): void { + this.trigger = event.currentTarget as HTMLElement; + this.isRemoveInviteModal.set(true); } - - onEdit(event: MouseEvent): void { - event.stopPropagation(); - event.preventDefault(); - this.isEditInviteModal.set(false); - + onRemove(): void { + this.isRemoveInviteModal.set(false); + this.remove.emit(this.invite().id); + } + onEdit(): void { + this.role.setValue(normalizeInviteText(this.role.value)); + this.role.markAsTouched(); + if (this.role.invalid) return; this.edit.emit({ - inviteId: this.invite()?.id, - role: this.inviteForm.value.role, - specialization: this.inviteForm.value.specialization, + inviteId: this.invite().id, + role: this.role.value, + specialization: this.invite().specialization ?? "", }); + this.isEditInviteModal.set(false); } } diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/project-team-step.component.html b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/project-team-step.component.html index f7697aac2..f64551e0d 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/project-team-step.component.html +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/project-team-step.component.html @@ -1,126 +1,103 @@ - -
    -
    -
      - @for (collaborator of collaborators(); track collaborator.userId) { -
    • - -
    • - } -
    -
    - - @if (isInviteModalOpen()) { - - } - - - -
    -
    - - - @if (isHintTeamVisible("team")) { -
    -

    - Напишите зону ответственности участника, приглашаемого в команду + @if (teamLoading()) { +

    +

    Загрузка команды…

    + +
    + } @else { +
    +

    + Участники команды + {{ collaborators().length }} +

    +
      + @for (collaborator of collaborators(); track collaborator.userId) { +
    • + +
    • + } +
    + @if (!collaborators().length || (onlyCurrentUser() && !pendingInvites().length)) { +
    +

    + {{ + onlyCurrentUser() ? "В команде пока только вы" : "Здесь будут участники вашей команды" + }}

    -

    подробнее

    +

    Пригласите участника и укажите его роль в проекте.

    } -
    -
    - - -
    -
    -

    Уверены, большие дела не делаются в одиночку!

    -
    + -
    -

    - После создания проекта пригласите в команду тех, кто вместе с вами будет причастен к его - реализации + @if (pendingInvites().length) { +

    +

    + Ожидают подтверждения + {{ pendingInvites().length }} +

    +

    + Участники появятся в команде, когда примут приглашение.

    +
      + @for (invite of pendingInvites(); track invite.id) { +
    • + +
    • + } +
    +
    + } @else { +

    Нет ожидающих приглашений.

    + } + } + -

    - Найдите участника PROCOLLAB по имени или фамилии, выберите его и укажите роль или зону - ответственности в проекте. -

    - -

    - Участник получит приглашение стать частью команды – это приглашение необходимо принять. - Ваш со-командник получит уведомление или может найти приглашение во вкладке «проект» -

    -
    - - спасибо, понятно -
    -
    -
    +@if (isInviteModalOpen()) { + +} diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/project-team-step.component.scss b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/project-team-step.component.scss index 54a86df08..2c1a5fb0f 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/project-team-step.component.scss +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/project-team-step.component.scss @@ -1,228 +1,154 @@ /** @format */ -@use "styles/responsive"; -@use "styles/typography"; - -.project { - max-width: 1280px; - - &__inner { - width: 100%; - margin-bottom: 25px; - - @include responsive.apply-desktop { - display: flex; - gap: 90px; - justify-content: space-between; - margin-bottom: 0; - margin-bottom: 20px; - } - } - - &__inner > fieldset:not(:last-child) { - margin-bottom: 20px; - } - - &__left { - flex-basis: 50%; - margin-bottom: 20px; - - form { - width: 280px; - - @include responsive.apply-desktop { - width: 600px; - } - } - } - - &__right { - flex-basis: 50%; +:host { + display: block; + min-width: 0; +} - :first-child & :not(span, fieldset, label, h4, p, i) { - margin-top: 26px; - margin-bottom: 10px; - } +.team { + display: flex; + flex-direction: column; + gap: 24px; + color: var(--black); - :last-child & :not(i, span) { - margin-top: 10px; - } + h2, + h3, + p { + margin: 0; + font-weight: 400; } - &__invite { + &__header { display: flex; - flex-direction: column; - - app-button { - width: 100%; - - @include typography.body-12; - - @include responsive.apply-desktop { - width: 25%; - - @include typography.bold-body-16; - } - } + gap: 24px; + align-items: center; + justify-content: space-between; } - &__no-items { - position: absolute; - bottom: -50%; - left: 50%; - transform: translateY(-50%); + &__intro { + min-width: 0; } - &__tooltip { - position: fixed; - right: - calc( - (100vw - 1080px) / 2 - ); - bottom: 24px; - z-index: 30; + h2 { + margin-bottom: 4px; + font-size: 18px; + line-height: 27px; } -} -.invite { - &__title { - margin-bottom: 12px; - } + h3 { + font-size: 14px; + line-height: 21px; - &__role { - margin-bottom: 12px; + [aria-hidden] { + margin-inline: 4px; + } } - &__item { - flex-grow: 1; - margin-bottom: 12px; + p { + font-size: 12px; + line-height: 1.3; + color: var(--grey-for-text); } - &__list { - width: 100%; - margin-top: 10px; + .invite__submit { + flex: 0 0 208px; } - &__team { - grid-template-columns: repeat(1, 270px); - grid-gap: 20px; - margin: 15px 0; + ::ng-deep .invite__submit button { + min-height: 25px; + padding: 4px 12px; + line-height: 16px; - @include responsive.apply-desktop { - grid-template-columns: repeat(5, 150px); + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; } } +} - &__link { - display: flex; - gap: 20px; - margin-bottom: 12px; +.team-section { + min-width: 0; +} - app-button { - flex-basis: 59%; - } +.team .team-section__description { + margin-top: 4px; +} - &--wrapper { - flex-basis: 40%; - flex-direction: column-reverse; - } +.team-list { + padding: 0; + margin: 8px 0 0; + list-style: none; + + li { + min-width: 0; } +} - &__submit { - position: relative; +.team-empty { + margin-top: 24px; - app-tooltip { - position: absolute; - right: -1.5%; - bottom: 37%; - } + .team-empty__title { + margin-bottom: 4px; + font-size: 14px; + color: var(--black); } } -.vacancy { - &__submit { - display: block; - } +.team-loading > p { + margin-bottom: 8px; + font-size: 14px; } -.tooltip { - position: relative; +.team-skeleton { display: flex; + gap: 12px; align-items: center; + padding: 12px 0; + border-bottom: 1px solid var(--medium-grey-for-outline); - &__wrapper { - position: absolute; - right: 100%; - bottom: 22px; - left: auto; - width: 310px; - padding: 18px 10px 10px 16px; - background-color: var(--white); - border: 0.5px solid var(--grey-for-text); - border-radius: var(--rounded-lg) var(--rounded-lg) 0 var(--rounded-lg); - - :last-child { - color: var(--black); - cursor: pointer; - } + &__avatar { + flex: 0 0 40px; + height: 40px; + background: var(--medium-grey-for-outline); + border-radius: 50%; } - &__text { - color: var(--grey-for-text); + &__copy { + display: grid; + gap: 8px; + width: min(190px, 100% - 52px); - a { - color: var(--accent); - } - } -} - -.cancel { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - width: 80%; - max-height: calc(100vh - 40px); - padding: 0 24px; - overflow-y: auto; - - @include responsive.apply-desktop { - width: 605px; - max-width: 100%; - } + span { + height: 10px; + background: var(--medium-grey-for-outline); + border-radius: 4px; - &__cross { - position: absolute; - top: 0; - right: 0; - width: 32px; - height: 32px; - cursor: pointer; - - @include responsive.apply-desktop { - top: 8px; - right: 8px; + &:last-child { + width: 68%; + } } } +} - &__top { - display: flex; +@media (width < 750px) { + .team__header { flex-direction: column; - margin-bottom: 10px; + gap: 12px; + align-items: stretch; } - &__title { - text-align: center; + .team .invite__submit { + flex-basis: auto; + width: 100%; } - &__text { - text-align: center; + .team ::ng-deep .invite__submit button { + min-height: 44px; + max-height: none; - &-block { - display: flex; - flex-direction: column; - gap: 12px; - margin: 30px 0; + i { + position: absolute; + left: 12px; } } } diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/project-team-step.component.spec.ts b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/project-team-step.component.spec.ts index 97f00e821..3922aba57 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/project-team-step.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/project-team-step.component.spec.ts @@ -8,6 +8,7 @@ import { Subject, of } from "rxjs"; import { ProjectTeamService } from "@api/project/facades/edit/project-team.service"; import { ProjectTeamUIService } from "@api/project/facades/edit/ui/project-team-ui.service"; import { ProjectsEditInfoService } from "@api/project/facades/edit/projects-edit-info.service"; +import { ProfileInfoService } from "@api/profile/facades/profile-info.service"; import { TooltipInfoService } from "@api/tooltip/tooltip-info.service"; import { UpdateInviteUseCase } from "@api/invite/use-cases/update-invite.use-case"; import { RevokeInviteUseCase } from "@api/invite/use-cases/revoke-invite.use-case"; @@ -46,6 +47,7 @@ describe("Окно приглашения из редактора команды provideNgxMask(), ProjectTeamService, ProjectTeamUIService, + { provide: ProfileInfoService, useValue: { profile: signal({ id: 1 }) } }, { provide: RemoveProjectCollaboratorUseCase, useValue: {} }, { provide: InviteRepositoryPort, useValue: repo }, { @@ -72,6 +74,7 @@ describe("Окно приглашения из редактора команды ], }).compileComponents(); ui = TestBed.inject(ProjectTeamUIService); + ui.applySetCollaborators([]); fixture = TestBed.createComponent(ProjectTeamStepComponent); document.body.appendChild(fixture.nativeElement); await fixture.whenStable(); @@ -153,7 +156,9 @@ describe("Окно приглашения из редактора команды request = new Subject(); repo.sendForUser.mockReturnValue(request); send.click(); - request.next({ id: 90, isAccepted: false } as Invite); + await fixture.whenStable(); + expect(fixture.nativeElement.querySelector(".invite__submit button").disabled).toBe(true); + request.next({ id: 90, isAccepted: null, user: users[3], role: "Дизайнер" } as any); await fixture.whenStable(); expect(document.querySelector(".project-invite")).toBeNull(); expect(ui.invites()[0].id).toBe(90); @@ -162,4 +167,92 @@ describe("Окно приглашения из редактора команды fixture.nativeElement.querySelector(".invite__submit button"), ); }); + it("считает только участников и pending, показывает роли у людей", async () => { + ui.collaborators.set([ + { + userId: 1, + firstName: "Анна", + lastName: "Смирнова", + role: " Аналитик ", + skills: [], + avatar: "", + }, + { + userId: 2, + firstName: "Иван", + lastName: "Петров", + role: "аналитик", + skills: [], + avatar: "", + }, + { userId: 3, firstName: "Ольга", lastName: "Иванова", role: "", skills: [], avatar: "" }, + ]); + ui.invites.set( + [null, true, false].map((isAccepted, i) => ({ + id: i + 1, + user: users[i], + role: "Дизайнер", + isAccepted, + })) as any, + ); + await fixture.whenStable(); + const root = fixture.nativeElement as HTMLElement; + expect(root.querySelector('[data-team-count="members"]')?.textContent).toBe("3"); + expect(root.querySelector('[data-team-count="pending"]')?.textContent).toBe("1"); + expect(root.querySelectorAll("app-invite-card")).toHaveLength(1); + expect(root.querySelectorAll("app-collaborator-card .member-row__role")).toHaveLength(3); + expect(root.querySelector(".member-row__role")?.textContent).toContain("Аналитик"); + expect(root.textContent).toContain("Роль не указана"); + expect(root.querySelector(".member-row__you")?.textContent).toContain("Вы"); + expect(root.textContent).toContain("Руководитель проекта"); + ui.applyRemoveCollaborator(2); + ui.applyRemoveInvitation(1); + await fixture.whenStable(); + expect(root.querySelector('[data-team-count="members"]')?.textContent).toBe("2"); + expect(root.querySelector('[data-team-count="pending"]')).toBeNull(); + expect(root.querySelector("#team-pending-title")).toBeNull(); + }); + it("пустая команда показывает понятное состояние и доступный CTA", async () => { + const root = fixture.nativeElement as HTMLElement; + expect(root.textContent).toContain("Здесь будут участники вашей команды"); + expect(root.querySelector('[data-team-count="members"]')?.textContent).toBe("0"); + expect(root.querySelector("#team-pending-title")).toBeNull(); + await open(); + expect(ui.isInviteModalOpen()).toBe(true); + }); + it("до инициализации скрывает ложные счётчики и блокирует приглашение", async () => { + ui.teamLoading.set(true); + await fixture.whenStable(); + const root = fixture.nativeElement as HTMLElement; + expect(root.querySelector('[role="status"]')?.textContent).toContain("Загрузка команды"); + expect(root.querySelector("[data-team-count]")).toBeNull(); + const trigger = root.querySelector(".invite__submit button")!; + expect(trigger.disabled).toBe(true); + trigger.click(); + expect(ui.isInviteModalOpen()).toBe(false); + ui.applySetCollaborators([]); + await fixture.whenStable(); + expect(root.querySelector('[role="status"]')).toBeNull(); + expect(root.querySelector('[data-team-count="members"]')?.textContent).toBe("0"); + expect(trigger.disabled).toBe(false); + }); + it("сохраняет владельца в empty и не называет чужого участника текущим пользователем", async () => { + const owner = { + userId: 1, + firstName: "Анна", + lastName: "Смирнова", + role: "Основатель", + skills: [], + avatar: "", + }; + ui.applySetCollaborators([owner]); + await fixture.whenStable(); + const root = fixture.nativeElement as HTMLElement; + expect(root.querySelectorAll("app-collaborator-card")).toHaveLength(1); + expect(root.textContent).toContain("В команде пока только вы"); + expect(root.querySelector('[data-team-count="members"]')?.textContent).toBe("1"); + ui.applySetCollaborators([{ ...owner, userId: 2 }]); + await fixture.whenStable(); + expect(root.textContent).not.toContain("В команде пока только вы"); + }); }); diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/project-team-step.component.ts b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/project-team-step.component.ts index b9819429a..015a8fac9 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/project-team-step.component.ts +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/project-team-step.component.ts @@ -1,33 +1,32 @@ /** @format */ - -import { CommonModule } from "@angular/common"; -import { ChangeDetectionStrategy, Component, inject, OnInit, OnDestroy } from "@angular/core"; -import { ButtonComponent } from "@ui/primitives"; +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + OnInit, + OnDestroy, +} from "@angular/core"; +import { ButtonComponent, IconComponent } from "@ui/primitives"; import { InviteCardComponent } from "./invite-card/invite-card.component"; -import { IconComponent } from "@uilib"; import { CollaboratorCardComponent } from "./collaborator-card/collaborator-card.component"; -import { TooltipComponent } from "@ui/primitives/tooltip/tooltip.component"; import { isLoading } from "@domain/shared/async-state"; -import { TooltipInfoService } from "@api/tooltip/tooltip-info.service"; import { ProjectTeamService } from "@api/project/facades/edit/project-team.service"; import { ProjectTeamUIService } from "@api/project/facades/edit/ui/project-team-ui.service"; import { ProjectsEditInfoService } from "@api/project/facades/edit/projects-edit-info.service"; +import { ProfileInfoService } from "@api/profile/facades/profile-info.service"; import { ProjectMemberInviteModalComponent } from "@ui/widgets/project-invite/project-member-invite-modal.component"; -import { ModalComponent } from "@ui/primitives/modal/modal.component"; -/** Шаг редактирования проекта: команда. */ +/** Команда проекта: участники, ожидающие приглашения и фактические роли. */ @Component({ selector: "app-project-team-step", templateUrl: "./project-team-step.component.html", styleUrl: "./project-team-step.component.scss", imports: [ - CommonModule, ButtonComponent, IconComponent, InviteCardComponent, CollaboratorCardComponent, - TooltipComponent, - ModalComponent, ProjectMemberInviteModalComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, @@ -36,74 +35,46 @@ export class ProjectTeamStepComponent implements OnInit, OnDestroy { private readonly projectsEditInfoService = inject(ProjectsEditInfoService); private readonly projectTeamService = inject(ProjectTeamService); private readonly projectTeamUIService = inject(ProjectTeamUIService); - protected readonly tooltipInfoService = inject(TooltipInfoService); - + protected readonly currentUser = inject(ProfileInfoService).profile; protected inviteTrigger: HTMLElement | null = null; protected readonly invitationProject = this.projectsEditInfoService.invitationProject; - - // Геттеры для данных - protected readonly invites = this.projectTeamUIService.invites; protected readonly collaborators = this.projectTeamUIService.collaborators; - protected readonly invitesFill = this.projectTeamUIService.invitesFill; - + protected readonly pendingInvites = computed(() => + this.projectTeamUIService.invites().filter(invite => invite.isAccepted === null), + ); + protected readonly teamLoading = this.projectTeamUIService.teamLoading; + protected readonly onlyCurrentUser = computed( + () => + this.collaborators().length === 1 && + this.collaborators()[0].userId === this.currentUser()?.id, + ); protected readonly isInviteModalOpen = this.projectTeamUIService.isInviteModalOpen; protected readonly isLoading = isLoading; protected readonly inviteFormIsSubmitting = this.projectTeamUIService.inviteFormIsSubmitting; - protected readonly projectId = this.projectsEditInfoService.profileId; - /** Наличие подсказки */ - protected readonly haveHint = this.tooltipInfoService.haveHint; - - protected isHintTeamVisible = this.tooltipInfoService.isVisible; - protected readonly isHintTeamModal = this.projectTeamUIService.isHintTeamModal; - - /** Позиция подсказки */ - protected readonly tooltipPosition = this.tooltipInfoService.tooltipPosition; - - /** Состояние видимости подсказки */ - protected readonly isTooltipVisible = this.tooltipInfoService.isVisible; - ngOnInit(): void { - // Настраиваем динамическую валидацию this.projectTeamService.setupDynamicValidation(); } - - /** Показать подсказку */ - toggleTooltip(key: "base" | "team"): void { - this.tooltipInfoService.toggleTooltip(key); - } - openInviteModal(event: Event): void { this.inviteTrigger = (event.currentTarget as HTMLElement).querySelector("button") ?? (event.currentTarget as HTMLElement); this.projectTeamUIService.applyOpenInviteModal(); } - closeInviteModal(): void { this.projectTeamUIService.applyCloseInviteModal(); } - ngOnDestroy(): void { this.closeInviteModal(); } - editInvitation(params: { inviteId: number; role: string; specialization: string }): void { this.projectTeamService.editInvitation(params); } - removeInvitation(invitationId: number): void { this.projectTeamService.removeInvitation(invitationId); } - onCollaboratorRemove(collaboratorId: number): void { this.projectTeamUIService.applyRemoveCollaborator(collaboratorId); } - - openHintModal(event: Event): void { - event.preventDefault(); - this.tooltipInfoService.toggleTooltip("team"); - this.projectTeamUIService.applyOpenHintModal(); - } } diff --git a/projects/social_platform/src/app/ui/widgets/info-card/info-card-list-contract.spec.ts b/projects/social_platform/src/app/ui/widgets/info-card/info-card-list-contract.spec.ts index dfe6d84e8..f1541d255 100644 --- a/projects/social_platform/src/app/ui/widgets/info-card/info-card-list-contract.spec.ts +++ b/projects/social_platform/src/app/ui/widgets/info-card/info-card-list-contract.spec.ts @@ -52,7 +52,11 @@ describe("Мои проекты: HTTP list → преобразование ко * Передаёт настоящий сокращённый snake_case payload через HTTP/interceptor, * адаптер, репозиторий и use case. Готовая camelCase-фикстура скрыла бы дефект. */ - async function loadProject(submitted: boolean | undefined, draft = false): Promise { + async function loadProject( + submitted: boolean | undefined, + draft = false, + ids = { projectId: 101, programLinkId: 120, programId: 12 }, + ): Promise { const resultPromise = firstValueFrom(TestBed.inject(GetMyProjectsUseCase).execute()); const request = http.expectOne("/api/auth/users/projects/"); expect(request.request.method).toBe("GET"); @@ -62,7 +66,7 @@ describe("Мои проекты: HTTP list → преобразование ко previous: null, results: [ { - id: 101, + id: ids.projectId, name: "Проект программы", leader: 7, short_description: "Описание проекта", @@ -71,12 +75,17 @@ describe("Мои проекты: HTTP list → преобразование ко views_count: 0, draft, is_company: false, + partner_program_tags: [], partner_program: { - id: 12, + id: ids.programId, name: "Программа", ...(submitted === undefined ? {} - : { program_link_id: 120, program_id: 12, is_submitted: submitted }), + : { + program_link_id: ids.programLinkId, + program_id: ids.programId, + is_submitted: submitted, + }), }, }, ], @@ -89,11 +98,12 @@ describe("Мои проекты: HTTP list → преобразование ко expect(project.shortDescription).toBe("Описание проекта"); expect(project.partnerProgram?.isSubmitted).toBe(submitted); if (submitted !== undefined) { - expect(project.partnerProgram?.programLinkId).toBe(120); - expect(project.partnerProgram?.programId).toBe(12); + expect(project.partnerProgram?.programLinkId).toBe(ids.programLinkId); + expect(project.partnerProgram?.programId).toBe(ids.programId); } fixture.componentRef.setInput("info", project); fixture.detectChanges(); + return project; } const text = (selector: string): string => @@ -103,9 +113,9 @@ describe("Мои проекты: HTTP list → преобразование ко { submitted: true, draft: false, status: "Сдан в программу" }, { submitted: true, draft: true, status: "Сдан в программу" }, { submitted: false, draft: false, status: "В программе" }, - { submitted: false, draft: true, status: "Черновик" }, + { submitted: false, draft: true, status: "В программе" }, { submitted: undefined, draft: false, status: "В программе" }, - { submitted: undefined, draft: true, status: "Черновик" }, + { submitted: undefined, draft: true, status: "В программе" }, ])("is_submitted=$submitted, draft=$draft: lifecycle и доступ независимы", async item => { await loadProject(item.submitted, item.draft); for (const userId of [7, 99, undefined]) { @@ -125,6 +135,21 @@ describe("Мои проекты: HTTP list → преобразование ко } }); + it("DEV 286: связь 105 с программой 9 важнее draft и пустых тегов", async () => { + const project = await loadProject(false, true, { + projectId: 286, + programLinkId: 105, + programId: 9, + }); + expect(project.id).toBe(286); + expect(project.draft).toBe(true); + expect(text(".card__status")).toBe("В программе"); + expect(fixture.nativeElement.querySelectorAll(".card__status")).toHaveLength(1); + expect(text(".card__role")).toBe("Лидер"); + expect(text(".card__access-label")).toBe("можно редактировать"); + expect(fixture.componentInstance["myProjectPresentation"]()?.canEdit).toBe(true); + }); + it("обновляет доступ после получения состояния и не сохраняет его для неполного ответа", async () => { for (const submitted of [undefined, false, true, undefined]) { await loadProject(submitted); diff --git a/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.spec.ts b/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.spec.ts index c08c35bd1..d975100eb 100644 --- a/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.spec.ts +++ b/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.spec.ts @@ -185,19 +185,70 @@ describe("InfoCardComponent: статусы моих проектов", () => { ); }); - it("draft имеет приоритет над обычной связью с программой", () => { - fixture.componentRef.setInput( - "info", - projectCardFixture({ draft: true, partnerProgram: projectCardProgram(false) }), - ); - fixture.detectChanges(); - expect(card().querySelector(".card__status")?.textContent?.trim()).toBe("Черновик"); - expect(card().querySelector(".card__project-action")?.textContent?.trim()).toBe( - "Открыть", - ); - fixture.detectChanges(); - expect(card().querySelector(".card__info--project-partner")).toBeNull(); - expect(card().textContent).not.toContain("привязан к программе"); + it.each([ + { draft: true, submitted: null, lifecycle: "draft", label: "Черновик", leaderCanEdit: true }, + { + draft: false, + submitted: null, + lifecycle: "published", + label: "Опубликован", + leaderCanEdit: true, + }, + { + draft: true, + submitted: false, + lifecycle: "program", + label: "В программе", + leaderCanEdit: true, + }, + { + draft: false, + submitted: false, + lifecycle: "program", + label: "В программе", + leaderCanEdit: true, + }, + { + draft: true, + submitted: true, + lifecycle: "submitted", + label: "Сдан в программу", + leaderCanEdit: false, + }, + { + draft: false, + submitted: true, + lifecycle: "submitted", + label: "Сдан в программу", + leaderCanEdit: false, + }, + ])("draft=$draft, submitted=$submitted: $label независимо от роли", item => { + const project = projectCardFixture({ + draft: item.draft, + partnerProgram: item.submitted === null ? null : projectCardProgram(item.submitted), + partnerProgramsTags: [], + }); + fixture.componentRef.setInput("info", project); + for (const userId of [7, 99, undefined]) { + fixture.componentRef.setInput("loggedUserId", userId); + fixture.detectChanges(); + const canEdit = userId === 7 && item.leaderCanEdit; + expect(component["myProjectPresentation"]()).toMatchObject({ + lifecycle: item.lifecycle, + role: userId === 7 ? "leader" : "participant", + canEdit, + }); + expect(card().querySelectorAll(".card__status")).toHaveLength(1); + expect(card().querySelector(".card__status")?.textContent?.trim()).toBe(item.label); + expect(card().querySelector(".card__footer .card__role")?.textContent).toBe( + userId === 7 ? "Лидер" : "Участник", + ); + expect(card().querySelector(".card__access-label")?.textContent).toBe( + canEdit ? "можно редактировать" : "только просмотр", + ); + expect(card().querySelector(".card__project-action")?.textContent?.trim()).toBe("Открыть"); + expect(card().querySelector(".card__info--project-partner")).toBeNull(); + } }); it("отсутствующая связь не превращает опубликованный проект в программный", () => { diff --git a/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.ts b/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.ts index 1a4b946e3..e22775360 100644 --- a/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.ts +++ b/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.ts @@ -83,7 +83,7 @@ export class InfoCardComponent { ); /** - * Lifecycle зависит только от проекта: submitted > draft > program > published. + * Lifecycle зависит только от проекта: submitted > program > draft > published. * Роль определяется отдельно по текущему профилю. Отсутствующие ID не делают * пользователя лидером; после сдачи даже лидер видит «только просмотр». * Единственный признак сдачи — isSubmitted: canSubmit описывает возможность @@ -100,10 +100,10 @@ export class InfoCardComponent { const isSubmitted = project.partnerProgram?.isSubmitted === true; const lifecycle = isSubmitted ? "submitted" - : project.draft === true - ? "draft" - : project.partnerProgram != null - ? "program" + : project.partnerProgram != null + ? "program" + : project.draft === true + ? "draft" : "published"; const labels: Record = { diff --git a/projects/social_platform/src/app/ui/widgets/project-invite/project-invite-dialog.component.html b/projects/social_platform/src/app/ui/widgets/project-invite/project-invite-dialog.component.html index ec2609066..c3cd29449 100644 --- a/projects/social_platform/src/app/ui/widgets/project-invite/project-invite-dialog.component.html +++ b/projects/social_platform/src/app/ui/widgets/project-invite/project-invite-dialog.component.html @@ -3,6 +3,7 @@