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 3879019d6..e601905fa 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 @@ -59,6 +59,21 @@ export class ProgramAnalyticsDrilldownService { }); private readonly allSelectionCount = signal(0); readonly preview = signal(null); + readonly excludedRecipients = signal([]); + readonly remainingRecipients = computed(() => + Math.max(0, (this.preview()?.recipientCount ?? 0) - this.excludedRecipients().length), + ); + readonly canRemind = computed(() => this.view() !== "participants-without-team"); + readonly selectedWithoutExpert = computed( + () => + this.projectsPage()?.results.some( + row => this.isSelected(row.programProjectId) && row.reason === "no_assignments", + ) ?? false, + ); + excludeRecipient(id: number): void { + if (!this.actionPending()) + this.excludedRecipients.update(ids => (ids.includes(id) ? ids : [...ids, id])); + } readonly actionPending = signal<"preview" | "send" | "export" | null>(null); readonly actionError = signal(""); readonly actionSuccess = signal("", { equal: () => false }); @@ -128,6 +143,8 @@ export class ProgramAnalyticsDrilldownService { if ( this.programId === null || !category || + !this.canRemind() || + this.selectedWithoutExpert() || !this.selectedCount() || this.actionPending() || this.attentionPending() || @@ -145,19 +162,32 @@ export class ProgramAnalyticsDrilldownService { .pipe(takeUntil(this.cancelActions), takeUntilDestroyed(this.destroyRef)) .subscribe(result => { this.actionPending.set(null); - if (result.ok) this.preview.set(result.value); - else this.setActionError(result.error.kind); + if (result.ok) { + this.excludedRecipients.set([]); + this.preview.set(result.value); + } else this.setActionError(result.error.kind); }); } sendReminder(): void { const category = this.actionCategory(); const token = this.preview()?.token; - if (this.programId === null || !category || !token || this.actionPending()) return; + if ( + this.programId === null || + !category || + !token || + this.actionPending() || + !this.remainingRecipients() || + this.preview()?.withoutAssignmentsCount + ) + return; this.actionPending.set("send"); this.actionError.set(""); - this.actions - .send(this.programId, category, token) + const excluded = this.excludedRecipients(); + (excluded.length + ? this.actions.send(this.programId, category, token, excluded) + : this.actions.send(this.programId, category, token) + ) .pipe(takeUntil(this.cancelActions), takeUntilDestroyed(this.destroyRef)) .subscribe(result => { this.actionPending.set(null); @@ -173,7 +203,7 @@ export class ProgramAnalyticsDrilldownService { }); } - exportAttention(): void { + exportAttention(selected = this.selectedCount() > 0): void { const category = this.actionCategory(); if ( this.programId === null || @@ -189,9 +219,7 @@ export class ProgramAnalyticsDrilldownService { this.actions .export(this.programId, category, { search: this.appliedSearch(), - selection: this.selectedCount() - ? this.selection() - : { mode: "all", ids: [], excludedIds: [] }, + selection: selected ? this.selection() : { mode: "all", ids: [], excludedIds: [] }, }) .pipe(takeUntil(this.cancelActions), takeUntilDestroyed(this.destroyRef)) .subscribe(result => { diff --git a/projects/social_platform/src/app/api/program/use-cases/program-attention-actions.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/program-attention-actions.use-case.ts index 0cf0c176b..c7cd1f3c0 100644 --- a/projects/social_platform/src/app/api/program/use-cases/program-attention-actions.use-case.ts +++ b/projects/social_platform/src/app/api/program/use-cases/program-attention-actions.use-case.ts @@ -22,8 +22,16 @@ export class ProgramAttentionActionsUseCase { return this.safe(this.repository.previewAttentionReminder(programId, category, query)); } - send(programId: number, category: ProgramAttentionCategory, token: string) { - return this.safe(this.repository.sendAttentionReminder(programId, category, token)); + send( + programId: number, + category: ProgramAttentionCategory, + token: string, + excludedRecipientIds?: number[], + ) { + const request = excludedRecipientIds?.length + ? this.repository.sendAttentionReminder(programId, category, token, excludedRecipientIds) + : this.repository.sendAttentionReminder(programId, category, token); + return this.safe(request); } export( diff --git a/projects/social_platform/src/app/api/project/facades/edit/project-form.factory.spec.ts b/projects/social_platform/src/app/api/project/facades/edit/project-form.factory.spec.ts index 7557db54d..efa30f554 100644 --- a/projects/social_platform/src/app/api/project/facades/edit/project-form.factory.spec.ts +++ b/projects/social_platform/src/app/api/project/facades/edit/project-form.factory.spec.ts @@ -6,16 +6,12 @@ import { createProjectAchievementGroup, createProjectForm } from "./project-form describe("project-form.factory", () => { const fb = new FormBuilder(); - it("создаёт основную форму проекта с ожидаемыми обязательными полями", () => { + it("требует только название для основной формы проекта", () => { const form = createProjectForm(fb); expect(form.get("name")?.hasError("required")).toBe(true); - expect(form.get("region")?.hasError("required")).toBe(true); - expect(form.get("industryId")?.hasError("required")).toBe(true); - expect(form.get("description")?.hasError("required")).toBe(true); - expect(form.get("coverImageAddress")?.hasError("required")).toBe(true); - expect(form.get("targetAudience")?.hasError("required")).toBe(true); - expect(form.get("problem")?.hasError("required")).toBe(true); + form.get("name")?.setValue("Проект"); + expect(form.valid).toBe(true); expect(form.get("links")).toEqual(expect.any(FormArray)); expect(form.get("achievements")).toEqual(expect.any(FormArray)); }); diff --git a/projects/social_platform/src/app/api/project/facades/edit/project-form.factory.ts b/projects/social_platform/src/app/api/project/facades/edit/project-form.factory.ts index 8374ae843..a577a59ab 100644 --- a/projects/social_platform/src/app/api/project/facades/edit/project-form.factory.ts +++ b/projects/social_platform/src/app/api/project/facades/edit/project-form.factory.ts @@ -15,19 +15,19 @@ type ProjectAchievement = Project["achievements"][number]; export function createProjectForm(fb: FormBuilder): FormGroup { return fb.group({ imageAddress: [""], - name: ["", [Validators.required]], - region: ["", [Validators.required, projectRegionValidator()]], + name: ["", [Validators.required, Validators.pattern(/\S/), Validators.maxLength(256)]], + region: ["", [projectRegionValidator()]], implementationDeadline: [null], trl: [null], links: fb.array([]), link: ["", Validators.pattern(/^(https?:\/\/)/)], - industryId: [undefined, [Validators.required]], - description: ["", [Validators.required, Validators.minLength(0), Validators.maxLength(800)]], + industryId: [undefined], + description: ["", [Validators.maxLength(800)]], presentationAddress: [""], - coverImageAddress: ["", [Validators.required]], + coverImageAddress: [""], actuality: ["", [Validators.maxLength(400)]], - targetAudience: ["", [Validators.required, Validators.maxLength(400)]], - problem: ["", [Validators.required, Validators.maxLength(400)]], + targetAudience: ["", [Validators.maxLength(400)]], + problem: ["", [Validators.maxLength(400)]], partnerProgramId: [null], achievements: fb.array([]), title: [""], diff --git a/projects/social_platform/src/app/api/project/facades/edit/project-form.service.ts b/projects/social_platform/src/app/api/project/facades/edit/project-form.service.ts index 58d1abc8d..4dab3fc15 100644 --- a/projects/social_platform/src/app/api/project/facades/edit/project-form.service.ts +++ b/projects/social_platform/src/app/api/project/facades/edit/project-form.service.ts @@ -84,10 +84,7 @@ export class ProjectFormService { this.projectId.set(Number.isSafeInteger(project.id) && project.id > 0 ? project.id : null); const rawRegion = typeof project.region === "string" ? project.region.trim() : ""; const canonicalRegion = findCanonicalRussianRegion(rawRegion); - this.region?.setValidators([ - Validators.required, - projectRegionValidator(canonicalRegion ? "" : rawRegion), - ]); + this.region?.setValidators([projectRegionValidator(canonicalRegion ? "" : rawRegion)]); // Заполняем простые поля this.projectForm.patchValue({ diff --git a/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.spec.ts b/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.spec.ts index 4224e90e9..099d4ecf3 100644 --- a/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.spec.ts +++ b/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.spec.ts @@ -293,7 +293,7 @@ describe("ProjectsEditInfoService canonical relation integration", () => { expect(service.isCompetitive()).toBe(true); expect(update.execute).toHaveBeenCalledWith({ id: 55, - data: { name: "Project", draft: false }, + data: { name: "Project", draft: true }, }); }); @@ -399,7 +399,7 @@ describe("ProjectsEditInfoService canonical relation integration", () => { }); }); - it("single-link happy path: validation, confirmation, PUT, submit, main save and success exactly once", () => { + it("saves project content and program fields before freezing the copy", () => { const single = project(); single.partnerProgram.programId = 12; single.partnerProgram.programLinkId = 700; @@ -419,17 +419,20 @@ describe("ProjectsEditInfoService canonical relation integration", () => { expect(repo.updateProgramLinkFields).toHaveBeenCalledTimes(1); expect(repo.updateProgramLinkFields.mock.calls[0][0]).toBe(700); expect(programRepo.submitCompettetiveProject).not.toHaveBeenCalled(); - expect(update.execute).not.toHaveBeenCalled(); + expect(update.execute).toHaveBeenCalledExactlyOnceWith({ + id: 55, + data: { name: "Project", draft: true }, + }); + expect(update.execute.mock.invocationCallOrder[0]).toBeLessThan( + repo.updateProgramLinkFields.mock.invocationCallOrder[0], + ); put.next(); put.complete(); expect(programRepo.submitCompettetiveProject).toHaveBeenCalledExactlyOnceWith(700); - expect(update.execute).not.toHaveBeenCalled(); + expect(update.execute).toHaveBeenCalledOnce(); submission.next({}); submission.complete(); - expect(update.execute).toHaveBeenCalledExactlyOnceWith({ - id: 55, - data: { name: "Project", draft: false }, - }); + expect(update.execute).toHaveBeenCalledOnce(); expect(service.projectForm.get("partnerProgramId")?.value).toBe(12); expect(additional.submitted()).toBe(true); expect(additional.canSubmit()).toBe(false); @@ -608,7 +611,7 @@ describe("ProjectsEditInfoService canonical relation integration", () => { expect(repo.updateProgramLinkFields).toHaveBeenCalledOnce(); expect(programRepo.submitCompettetiveProject).toHaveBeenCalledExactlyOnceWith(700); expect(ui.applyOpenSendDescisionLateModal).toHaveBeenCalledOnce(); - expect(update.execute).not.toHaveBeenCalled(); + expect(update.execute).toHaveBeenCalledOnce(); expect(TestBed.inject(SnackbarService).success).not.toHaveBeenCalled(); expect(service.projFormIsSubmitting$().status).toBe("failure"); expect(repo.getProgramLinkFields.mock.calls).toEqual([[700], [700]]); @@ -625,7 +628,7 @@ describe("ProjectsEditInfoService canonical relation integration", () => { expect(ui.applySendDescision).toHaveBeenCalledOnce(); expect(repo.updateProgramLinkFields).toHaveBeenCalledOnce(); expect(programRepo.submitCompettetiveProject).toHaveBeenCalledExactlyOnceWith(700); - expect(update.execute).not.toHaveBeenCalled(); + expect(update.execute).toHaveBeenCalledOnce(); expect(TestBed.inject(SnackbarService).success).not.toHaveBeenCalled(); }); @@ -633,7 +636,7 @@ describe("ProjectsEditInfoService canonical relation integration", () => { ["submit", "Проект уже был сдан на проверку."], ["put", "Нельзя изменять значения полей программы после сдачи проекта на проверку."], ])( - "already-submitted %s race refreshes read-only state without main save or success", + "already-submitted %s race refreshes read-only state without further writes or success", (source, detail) => { service.loadProgramTagsAndProject(); service.additionalForm.get("case")?.setValue("B"); @@ -657,7 +660,7 @@ describe("ProjectsEditInfoService canonical relation integration", () => { expect(repo.getProgramLinkFields.mock.calls).toEqual([[700], [700]]); expect(service.additionalForm.disabled).toBe(true); expect(additional.submitted()).toBe(true); - expect(update.execute).not.toHaveBeenCalled(); + expect(update.execute).toHaveBeenCalledOnce(); expect(TestBed.inject(SnackbarService).success).not.toHaveBeenCalled(); expect(service.projFormIsSubmitting$().status).toBe("failure"); expect(repo.updateProgramLinkFields).toHaveBeenCalledOnce(); diff --git a/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.ts b/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.ts index 8770bed68..8a1a6007e 100644 --- a/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.ts +++ b/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.ts @@ -243,7 +243,7 @@ export class ProjectsEditInfoService { if (!this.isCompetitive() || this.projectAdditionalService.submitted()) { this.projFormIsSubmitting$.set(loading()); if (this.activeProgramLinkId() && !this.projectAdditionalService.submitted()) { - this.sendAdditionalFields(this.activeProgramLinkId()!, false); + this.sendAdditionalFields(this.activeProgramLinkId()!); } else this.submitProjectForm(); return; } @@ -261,14 +261,14 @@ export class ProjectsEditInfoService { this.projFormIsSubmitting$.set(loading()); if (this.activeProgramLinkId() && !this.projectAdditionalService.submitted()) { - this.sendAdditionalFields(this.activeProgramLinkId()!, false); + this.sendAdditionalFields(this.activeProgramLinkId()!); } else { this.submitProjectForm(); } } /** Сохраняет форму; перед публикацией подтверждает намеренную очистку файлов в режиме черновика. */ - submitProjectForm(): void { + submitProjectForm(submissionLinkId?: number): void { if (this.projectFormService.coverResetPending) return; if (!this.additionalContextReady()) return; const isDraft = this.projectForm.get("draft")?.value === true; @@ -277,16 +277,19 @@ export class ProjectsEditInfoService { const rawPayload = this.projectFormService.getFormValue(); const payload = { ...rawPayload }; + if (this.additionalForm.get("project_description")) delete payload.description; + if (this.additionalForm.get("project_presentation")) delete payload.presentationAddress; // Редактирование проверенной связи не является командой привязки/отвязки. // Сохраняем control и отдельный сценарий привязки, не изменяя исходные данные формы. if (this.activeProgramLinkId()) delete payload.partnerProgramId; + if (submissionLinkId) payload.draft = true; const projectId = Number(this.route.snapshot.paramMap.get("projectId")); if (this.projectVacancyUIService.isDirty()) { this.projectVacancyService.submitVacancy(projectId); } - this.saveProjectPayload(projectId, payload, isDraft) + this.saveProjectPayload(projectId, payload, isDraft || !!submissionLinkId) .pipe( switchMap(result => { if (!result.ok) { @@ -299,20 +302,16 @@ export class ProjectsEditInfoService { }), switchMap(() => this.savePartners(projectId)), switchMap(() => this.saveOrEditResources(projectId)), + switchMap(() => + submissionLinkId ? this.saveProgramFields(submissionLinkId, true) : of(null), + ), takeUntilDestroyed(this.destroyRef), ) .subscribe({ next: () => { this.completeSubmitedProjectForm(projectId); }, - error: err => { - this.submitMode.set(null); - this.projFormIsSubmitting$.set(failure("ошибка при сохранении данных")); - this.snackBarService.error("ошибка при сохранении данных"); - if (err.error["error"].includes("Срок подачи проектов в программу завершен.")) { - this.projectsEditUIInfoService.applyOpenSendDescisionLateModal(); - } - }, + error: error => this.handleProjectSubmitError(error), }); } @@ -351,9 +350,8 @@ export class ProjectsEditInfoService { if (!this.submissionAvailable()) return; if (!this.validateProjectForms(false)) return; this.projFormIsSubmitting$.set(loading()); - if (this.projectAdditionalService.submitted()) this.submitProjectForm(); - else if (this.activeProgramLinkId()) - this.sendAdditionalFields(this.activeProgramLinkId()!, this.isCompetitive()); + const linkId = this.activeProgramLinkId(); + if (!this.projectAdditionalService.submitted() && linkId) this.submitProjectForm(linkId); } loadProgramTagsAndProject(): void { @@ -399,6 +397,21 @@ export class ProjectsEditInfoService { const id = Number(params["projectId"]); const project: Project | undefined = data["data"]?.[0]; const matches = Number.isSafeInteger(id) && id > 0 && project?.id === id; + if (matches && project.teamPolicy?.isFrozen) { + void this.router.navigate([AppRoutes.projects.detail(id)], { replaceUrl: true }); + return; + } + const sections = matches ? project.partnerProgram?.sections : undefined; + const step = query["editingStep"]; + if ( + sections && + ((step === "contacts" && !sections.partners) || + (step === "achievements" && !sections.achievements) || + (step === "vacancies" && !sections.vacancies) || + (step === "additional" && !sections.programData)) + ) { + this.projectStepService.navigateToStep("main"); + } if (this.profileId() !== id) this.projectTeamUIService.applyCloseInviteModal(); this.profileId.set(id); this.invitationProject.set(matches ? project : null); @@ -471,7 +484,9 @@ export class ProjectsEditInfoService { this.snackBarService.error("Проверьте обязательные поля и исправьте ошибки перед сохранением."); this.projectStepService.setStepFromRoute( !additionalValid - ? "additional" + ? this.invitationProject()?.partnerProgram?.sections?.programData === false + ? "main" + : "additional" : !projectValid ? this.achievements.invalid ? "achievements" @@ -591,10 +606,24 @@ export class ProjectsEditInfoService { return false; } - private sendAdditionalFields(programLinkId: number, submit: boolean): void { + private saveProgramFields(programLinkId: number, submit: boolean) { + return this.projectAdditionalService.save(programLinkId, submit).pipe( + switchMap(result => { + if (result.ok) return of(null); + this.projFormIsSubmitting$.set(failure(result.error.kind)); + if (result.error.kind === "submission_closed") { + this.projectsEditUIInfoService.applyOpenSendDescisionLateModal(); + } + const hidden = this.invitationProject()?.partnerProgram?.sections?.programData === false; + this.projectStepService.setStepFromRoute(hidden ? "main" : "additional"); + return EMPTY; + }), + ); + } + + private sendAdditionalFields(programLinkId: number): void { let fieldsSaved = false; - this.projectAdditionalService - .save(programLinkId, submit) + this.saveProgramFields(programLinkId, false) .pipe( takeUntilDestroyed(this.destroyRef), finalize(() => { @@ -602,15 +631,7 @@ export class ProjectsEditInfoService { this.projFormIsSubmitting$.set(initial()); }), ) - .subscribe(result => { - if (!result.ok) { - this.projFormIsSubmitting$.set(failure(result.error.kind)); - if (result.error.kind === "submission_closed") { - this.projectsEditUIInfoService.applyOpenSendDescisionLateModal(); - } - this.projectStepService.setStepFromRoute("additional"); - return; - } + .subscribe(() => { fieldsSaved = true; this.submitProjectForm(); }); diff --git a/projects/social_platform/src/app/api/project/use-cases/create-project.use-case.ts b/projects/social_platform/src/app/api/project/use-cases/create-project.use-case.ts index 0ccfa76f6..fff04df95 100644 --- a/projects/social_platform/src/app/api/project/use-cases/create-project.use-case.ts +++ b/projects/social_platform/src/app/api/project/use-cases/create-project.use-case.ts @@ -14,8 +14,11 @@ export class CreateProjectUseCase { private readonly projectRepositoryPort = inject(ProjectRepositoryPort); private readonly eventBus = inject(EventBus); - execute(): Observable> { - return this.projectRepositoryPort.postOne().pipe( + execute(name?: string): Observable> { + const project = name + ? this.projectRepositoryPort.postOne(name) + : this.projectRepositoryPort.postOne(); + return project.pipe( tap(project => this.eventBus.emit(projectCreated(project))), map(project => ok(project)), catchError(() => of(fail({ kind: "unknown" as const }))), diff --git a/projects/social_platform/src/app/domain/program/dto/apply-to-program.model.ts b/projects/social_platform/src/app/domain/program/dto/apply-to-program.model.ts index 1e0b22128..7a5bb6e77 100644 --- a/projects/social_platform/src/app/domain/program/dto/apply-to-program.model.ts +++ b/projects/social_platform/src/app/domain/program/dto/apply-to-program.model.ts @@ -4,6 +4,6 @@ import { Project } from "@domain/project/project.model"; import { ProjectNewAdditionalProgramFields } from "../partner-program-fields.model"; export interface ApplyToProgramDTO { - project: Project; + project: Partial; programFieldValues: ProjectNewAdditionalProgramFields[]; } diff --git a/projects/social_platform/src/app/domain/program/partner-program-fields.model.ts b/projects/social_platform/src/app/domain/program/partner-program-fields.model.ts index 0d3aa1ada..e45336135 100644 --- a/projects/social_platform/src/app/domain/program/partner-program-fields.model.ts +++ b/projects/social_platform/src/app/domain/program/partner-program-fields.model.ts @@ -8,7 +8,7 @@ export class PartnerProgramFields { id!: number; name!: string; label!: string; - fieldType!: "text" | "textarea" | "checkbox" | "select" | "radio" | "file"; + fieldType!: "text" | "textarea" | "checkbox" | "select" | "radio" | "file" | "url"; isRequired!: boolean; helpText!: string; options!: string[]; diff --git a/projects/social_platform/src/app/domain/program/ports/program-certificates.repository.port.ts b/projects/social_platform/src/app/domain/program/ports/program-certificates.repository.port.ts index b3cd06d84..6f4cc7a4b 100644 --- a/projects/social_platform/src/app/domain/program/ports/program-certificates.repository.port.ts +++ b/projects/social_platform/src/app/domain/program/ports/program-certificates.repository.port.ts @@ -17,7 +17,7 @@ export abstract class ProgramCertificatesRepositoryPort { ): Observable; abstract upload( id: number, - kind: "background" | "signature", + kind: "background" | "signature" | "font", file: File, ): Observable; abstract asset(id: number, assetId: number): Observable; 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 7f01e3de0..2e467e32b 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 @@ -50,6 +50,7 @@ export abstract class ProgramRepositoryPort { programId: number, category: ProgramAttentionCategory, token: string, + excludedRecipientIds?: number[], ): Observable; abstract exportAttention( programId: number, diff --git a/projects/social_platform/src/app/domain/program/program-analytics-attention.model.ts b/projects/social_platform/src/app/domain/program/program-analytics-attention.model.ts index e9b875ef2..f50e71962 100644 --- a/projects/social_platform/src/app/domain/program/program-analytics-attention.model.ts +++ b/projects/social_platform/src/app/domain/program/program-analytics-attention.model.ts @@ -46,6 +46,7 @@ export interface ProgramAnalyticsAttentionProject { reasonLabel: string; assignmentsTotal: number | null; assignmentsCompleted: number | null; + experts?: { id: number; fullName: string; completed: boolean }[]; } /** Режим берём из ответа списка, не из потенциально устаревшей сводки. */ diff --git a/projects/social_platform/src/app/domain/program/program-attention-actions.model.ts b/projects/social_platform/src/app/domain/program/program-attention-actions.model.ts index 98128efe4..4a7091670 100644 --- a/projects/social_platform/src/app/domain/program/program-attention-actions.model.ts +++ b/projects/social_platform/src/app/domain/program/program-attention-actions.model.ts @@ -22,7 +22,7 @@ export interface ProgramAttentionPreview { withoutAssignmentsCount: number; unavailableCount: number; previewText: string; - recipients: { userId: number; fullName: string }[]; + recipients: { userId: number; fullName: string; email?: string; avatar?: string | null }[]; expiresIn: number; } diff --git a/projects/social_platform/src/app/domain/program/program-certificates.model.ts b/projects/social_platform/src/app/domain/program/program-certificates.model.ts index 845e2e92b..961eebb67 100644 --- a/projects/social_platform/src/app/domain/program/program-certificates.model.ts +++ b/projects/social_platform/src/app/domain/program/program-certificates.model.ts @@ -37,14 +37,30 @@ export interface CertificateBox { width: number; height: number; fontSize: number; - fontFamily?: "mont" | "pt-serif" | "lobster"; + fontFamily?: + | "mont" + | "mont-regular" + | "mont-light" + | "mont-bold" + | "pt-serif" + | "lobster" + | `custom-${number}`; } export const CERTIFICATE_FONTS = [ { key: "mont", label: "Mont", family: "Mont", boldWeight: 600 }, + { + key: "mont-regular", + label: "Mont Regular", + family: "Certificate Mont Regular", + boldWeight: 400, + }, + { key: "mont-light", label: "Mont Light", family: "Certificate Mont Light", boldWeight: 400 }, + { key: "mont-bold", label: "Mont Bold", family: "Certificate Mont Bold", boldWeight: 400 }, { key: "pt-serif", label: "PT Serif", family: "Certificate PT Serif", boldWeight: 700 }, { key: "lobster", label: "Lobster", family: "Certificate Lobster", boldWeight: 400 }, ] as const; export function certificateFont(key: CertificateBox["fontFamily"]) { + if (key?.startsWith("custom-")) return { key, label: key, family: key, boldWeight: 400 }; return CERTIFICATE_FONTS.find(font => font.key === key) ?? CERTIFICATE_FONTS[0]; } export interface CertificateAsset { @@ -73,6 +89,7 @@ export interface CertificateSettings { revision: number; draft: CertificateDraft; assets: { background: CertificateAsset | null; signature: CertificateAsset | null }; + fonts?: CertificateAsset[]; eligibleCounts: { all: number; submitted: number }; batch: { id: number; active: boolean; issueAt: string; initializedAt: string | null } | null; } diff --git a/projects/social_platform/src/app/domain/program/program-launch.model.ts b/projects/social_platform/src/app/domain/program/program-launch.model.ts index babbcf7c0..f9f183126 100644 --- a/projects/social_platform/src/app/domain/program/program-launch.model.ts +++ b/projects/social_platform/src/app/domain/program/program-launch.model.ts @@ -8,17 +8,17 @@ export const PROGRAM_FORMATS: { id: ProgramFormat; label: string; description: s { id: "case_championship", label: "Кейс-чемпионат", - description: "Команды решают кейс и сдают решение на оценку.", + description: "Кейс, описание решения и презентация", }, { id: "hackathon", label: "Хакатон", - description: "Команды создают продукт. В форме предусмотрены репозиторий и демо.", + description: "Задача, репозиторий и инструкция запуска", }, { id: "noncompetitive", - label: "Неконкурсная программа", - description: "Регистрация, материалы и работа с участниками без конкурсной сдачи.", + label: "Без конкурса", + description: "Регистрация и материалы без конкурсной сдачи", }, ]; export interface ManagedProgram { diff --git a/projects/social_platform/src/app/domain/program/program-project-form.model.ts b/projects/social_platform/src/app/domain/program/program-project-form.model.ts index 2fe111386..6804554cd 100644 --- a/projects/social_platform/src/app/domain/program/program-project-form.model.ts +++ b/projects/social_platform/src/app/domain/program/program-project-form.model.ts @@ -2,6 +2,13 @@ import { PartnerProgramFields } from "./partner-program-fields.model"; +export type ProjectSections = { + partners: boolean; + achievements: boolean; + vacancies: boolean; + programData: boolean; +}; + export interface ManagementCase { name: string; projectsCount: number; @@ -9,6 +16,8 @@ export interface ManagementCase { export interface ManagementFormField extends PartnerProgramFields { answersCount: number; editableType: boolean; + isVisible?: boolean; + standard?: boolean; } export interface ManagementProjectForm { updatedAt: string; @@ -17,16 +26,25 @@ export interface ManagementProjectForm { cases: ManagementCase[]; fields: ManagementFormField[]; supportedTypes: PartnerProgramFields["fieldType"][]; + sections?: ProjectSections; + teamMinSize?: number | null; + teamMaxSize?: number | null; + caseRequired?: boolean; + caseLabel?: string; } export interface ManagementProjectFormWrite { expectedUpdatedAt: string; expectedRevision: string; cases: string[]; + sections?: ProjectSections; + teamMinSize?: number | null; + teamMaxSize?: number | null; fields: { id?: number | null; label: string; fieldType: PartnerProgramFields["fieldType"]; isRequired: boolean; + isVisible?: boolean; helpText: string; options: string[]; }[]; diff --git a/projects/social_platform/src/app/domain/project/ports/project.repository.port.ts b/projects/social_platform/src/app/domain/project/ports/project.repository.port.ts index 1a61a0073..ed29fd99f 100644 --- a/projects/social_platform/src/app/domain/project/ports/project.repository.port.ts +++ b/projects/social_platform/src/app/domain/project/ports/project.repository.port.ts @@ -14,7 +14,7 @@ export abstract class ProjectRepositoryPort { abstract getAll(params?: HttpParams): Observable>; abstract getOne(id: number): Observable; abstract getMy(params?: HttpParams): Observable>; - abstract postOne(): Observable; + abstract postOne(name?: string): Observable; abstract update(id: number, data: Partial): Observable; abstract deleteOne(id: number): Observable; abstract refreshCount(): Observable; diff --git a/projects/social_platform/src/app/domain/project/project.model.ts b/projects/social_platform/src/app/domain/project/project.model.ts index 57e97253e..ef4158ea4 100644 --- a/projects/social_platform/src/app/domain/project/project.model.ts +++ b/projects/social_platform/src/app/domain/project/project.model.ts @@ -9,6 +9,7 @@ import { Collaborator } from "./collaborator.model"; import { Goal } from "./goals.model"; import { Partner } from "./partner.model"; import { Resource } from "./resource.model"; +import { ProjectSections } from "../program/program-project-form.model"; /** Основная модель проекта и связанные классы */ @@ -22,6 +23,16 @@ export class PartnerProgramInfo { canSubmit!: boolean; programFields!: PartnerProgramFields[]; programFieldValues!: PartnerProgramFieldsValues[]; + sourceProjectId?: number | null; + sections?: ProjectSections; +} + +export interface ProgramProjectCopy { + projectId: number; + programId: number; + programLinkId: number; + name: string; + submitted: boolean; } export interface TeamPolicy { @@ -64,6 +75,7 @@ export class Project { leaderInfo?: { firstName: string; lastName: string }; partnerProgramsTags?: string[]; partnerProgram!: PartnerProgramInfo | null; + programCopies?: ProgramProjectCopy[]; vacancies!: Vacancy[]; isCompany!: boolean; inviteId!: number; diff --git a/projects/social_platform/src/app/infrastructure/adapters/program/program-certificates-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/program/program-certificates-http.adapter.ts index bdd8658f7..58cac9907 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/program/program-certificates-http.adapter.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/program/program-certificates-http.adapter.ts @@ -23,7 +23,7 @@ export class ProgramCertificatesHttpAdapter implements ProgramCertificatesReposi save(id: number, revision: number, draft: CertificateDraft) { return this.api.put(this.root(id), { revision, draft }); } - upload(id: number, kind: "background" | "signature", file: File) { + upload(id: number, kind: "background" | "signature" | "font", file: File) { const body = new FormData(); body.append("file", file); body.append("kind", kind); @@ -78,7 +78,11 @@ export class ProgramCertificatesHttpAdapter implements ProgramCertificatesReposi return this.api.getFile(this.root(id) + "archive/", this.query(status, search)); } currentArchive(id: number, revision: number, selection: CertificateSelection) { - return this.api.postFile(this.root(id) + "archive/", { revision, ...selection }); + return this.api.postFile(this.root(id) + "archive/", { + revision, + ...selection, + publishedOnly: true, + }); } mine(id: number) { return this.api.get<{ id: string; issuedAt: string; url: string }[]>( 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 830a737d2..b112bfc25 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 @@ -58,10 +58,11 @@ export class ProgramHttpAdapter { programId: number, category: ProgramAttentionCategory, token: string, + excludedRecipientIds?: number[], ): Observable { return this.apiService.post( `/programs/${programId}/manager-overview/attention/${category}/reminders/`, - { token }, + { token, ...(excludedRecipientIds?.length ? { excludedRecipientIds } : {}) }, ); } exportAttention( diff --git a/projects/social_platform/src/app/infrastructure/adapters/project/project-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/project/project-http.adapter.ts index d6c8379a8..6fe8d9a89 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/project/project-http.adapter.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/project/project-http.adapter.ts @@ -27,8 +27,8 @@ export class ProjectHttpAdapter { return this.apiService.get(`${this.PROJECTS_URL}/count/`); } - postCreate(): Observable { - return this.apiService.post(`${this.PROJECTS_URL}/`, {}); + postCreate(name?: string): Observable { + return this.apiService.post(`${this.PROJECTS_URL}/`, name ? { name } : {}); } putUpdate(id: number, data: Partial): Observable { 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 643fec38d..caa5e05e7 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 @@ -61,8 +61,14 @@ export class ProgramRepository implements ProgramRepositoryPort { programId: number, category: ProgramAttentionCategory, token: string, + excludedRecipientIds?: number[], ): Observable { - return this.programAdapter.sendAttentionReminder(programId, category, token); + return this.programAdapter.sendAttentionReminder( + programId, + category, + token, + excludedRecipientIds, + ); } exportAttention( programId: number, diff --git a/projects/social_platform/src/app/infrastructure/repository/project/project.repository.ts b/projects/social_platform/src/app/infrastructure/repository/project/project.repository.ts index 00c6ae6fd..389e0cd6a 100644 --- a/projects/social_platform/src/app/infrastructure/repository/project/project.repository.ts +++ b/projects/social_platform/src/app/infrastructure/repository/project/project.repository.ts @@ -162,8 +162,10 @@ export class ProjectRepository implements ProjectRepositoryPort { .pipe(map(page => ({ ...page, results: plainToInstance(Project, page.results) }))); } - postOne(): Observable { - return this.projectAdapter.postCreate().pipe(map(dto => plainToInstance(Project, dto))); + postOne(name?: string): Observable { + return (name ? this.projectAdapter.postCreate(name) : this.projectAdapter.postCreate()).pipe( + map(dto => plainToInstance(Project, dto)), + ); } deleteOne(id: number): Observable { diff --git a/projects/social_platform/src/app/ui/pages/program/create/create.component.html b/projects/social_platform/src/app/ui/pages/program/create/create.component.html index 8f28591f0..476288a60 100644 --- a/projects/social_platform/src/app/ui/pages/program/create/create.component.html +++ b/projects/social_platform/src/app/ui/pages/program/create/create.component.html @@ -1,9 +1,8 @@
- К программам + Я организую
-

Создание программы

-

Начните с названия и формата. Остальное можно заполнить позже.

+

Создать программу

@if (error()) {
@@ -41,12 +40,10 @@

Создание программ недоступно

@if (form.controls.name.touched && form.controls.name.invalid) { Введите название программы до 255 символов. - } @else { - Название увидят участники после публикации. }

- Формат программы * + Формат @for (format of formats; track format.id) { }
-

- Черновик будет доступен вам и организаторам программы. В каталоге он появится после - публикации. -

- +
+

Черновик виден только организаторам.

} diff --git a/projects/social_platform/src/app/ui/pages/program/create/create.component.scss b/projects/social_platform/src/app/ui/pages/program/create/create.component.scss index 10ae3ca9c..485a7f8e1 100644 --- a/projects/social_platform/src/app/ui/pages/program/create/create.component.scss +++ b/projects/social_platform/src/app/ui/pages/program/create/create.component.scss @@ -67,19 +67,19 @@ } &__card { - padding: 20px; + padding: 24px; background: var(--light-white); border-radius: 15px; } &__formats { display: grid; - gap: 20px; - margin-block: 24px !important; + gap: 12px; + margin-block: 16px !important; legend { - padding-bottom: 20px; - font-size: 16px; + padding-bottom: 12px; + font-size: 14px; font-weight: 600; } } @@ -88,6 +88,7 @@ padding: 16px; cursor: pointer; background: var(--white); + border: 1px solid var(--medium-grey-for-outline); border-radius: 8px; span { @@ -103,13 +104,14 @@ &__format--active { background: var(--accent-light); + border-color: var(--accent); } &__actions { display: flex; gap: 12px; - justify-content: flex-end; - margin-top: 24px; + justify-content: flex-start; + margin-top: 16px; } } diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-attention.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-attention.component.spec.ts index a70de471e..325ca4d05 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-attention.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-attention.component.spec.ts @@ -63,7 +63,7 @@ describe("Attention views: real overlay", () => { unavailableCount: 0, previewText: "Анна, здравствуйте!", expiresIn: 900, - recipients: [{ userId: 123, fullName: "Анна Петрова" }], + recipients: [{ userId: 123, fullName: "Анна Петрова", email: "anna@example.com" }], }), ), ); @@ -512,44 +512,42 @@ describe("Attention views: real overlay", () => { }); it("checkbox selection opens an inline reviewed reminder and returns keyboard focus", async () => { - await open(); - expect(dialog().textContent).not.toContain("Зарегистрированные участники, которые"); - expect(button("Отправить напоминание").disabled).toBe(true); + await open("participants-without-visit"); + expect(button("Напомнить")).toBeUndefined(); dialog().querySelector('input[type="checkbox"]')!.click(); await fixture.whenStable(); expect(state.selectedCount()).toBe(1); - button("Отправить напоминание").click(); + button("Напомнить").click(); await fixture.whenStable(); expect(dialog().textContent).toContain("Получателей: 1"); - expect(dialog().textContent).toContain("Пример уведомления"); - expect(dialog().textContent).toContain("Пример письма на почту"); - expect(dialog().textContent).toContain("Открыть на платформе"); + expect(dialog().textContent).toContain("Отправить следующим людям"); + expect(dialog().textContent).toContain("Анна Петрова"); + expect(dialog().textContent).toContain("anna@example.com"); expect(dialog().textContent).not.toContain("Выбрано:"); expect(dialog().querySelector(".attention-list-tools__export")).toBeNull(); expect( Array.from(dialog().querySelectorAll("footer button"), item => item.textContent?.trim()), - ).toEqual(["Назад", "Отправить"]); - expect(dialog().textContent).toContain("Анна, здравствуйте!"); + ).toEqual(["Отмена", "Отправить"]); expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(1); expect(document.activeElement).toBe(dialog().querySelector("h2")); expect(dialog().querySelector("h2")?.textContent).toBe("Отправить напоминание"); expect(actions.send).not.toHaveBeenCalled(); - button("Назад").click(); + button("Отмена").click(); await fixture.whenStable(); expect(dialog().querySelector('input[type="checkbox"]')!.checked).toBe(true); expect(document.activeElement?.tagName).toBe("H2"); }); it("select all found selects beyond the visible page, send requires confirmation", async () => { - participants.execute.mockReturnValue(of(ok(participantsPage({ count: 75 })))); - await open(); + visit.execute.mockReturnValue(of(ok(participantsPage({ count: 75 })))); + await open("participants-without-visit"); const header = dialog().querySelector('thead input[type="checkbox"]')!; expect(header.getAttribute("aria-label")).toBe("Выбрать все строки таблицы"); expect(dialog().textContent).not.toContain("Выбрать все найденные"); header.click(); await fixture.whenStable(); expect(state.selectedCount()).toBe(75); - button("Отправить напоминание").click(); + button("Напомнить").click(); await fixture.whenStable(); expect(actions.preview.mock.lastCall[2].selection.mode).toBe("all"); expect(actions.send).not.toHaveBeenCalled(); @@ -557,10 +555,10 @@ describe("Attention views: real overlay", () => { await fixture.whenStable(); expect(actions.send).toHaveBeenCalledExactlyOnceWith( 12, - "participants-without-team", + "participants-without-visit", "preview", ); - expect(dialog().textContent).toContain("Напоминания созданы: 1"); + expect(dialog().textContent).toContain("Письма поставлены в очередь"); }); it.each([ diff --git a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.html b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.html index 0e9980769..5b6179757 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.html +++ b/projects/social_platform/src/app/ui/pages/program/detail/analytics/drilldown/analytics-drilldown.component.html @@ -294,24 +294,35 @@

{{ title() [attr.aria-labelledby]="'analytics-dialog-' + state.view()" aria-live="polite" > +

Отправить следующим людям

- Получателей: {{ preview.recipientCount }} + Получателей: {{ state.remainingRecipients() }}

- @if (preview.previewText) { -

Пример уведомления

-

{{ preview.previewText }}

-

Пример письма на почту

- - } +
    + @for (person of preview.recipients; track person.userId) { + @if (!state.excludedRecipients().includes(person.userId)) { +
  • + +
    + {{ person.fullName }}{{ person.email }} +
    + +
  • + } + } +
@if (preview.withoutAssignmentsCount) {

- Работ без незавершенных назначений: {{ preview.withoutAssignmentsCount }}. Они - доступны для выгрузки. + Снимите выбор с работ без незавершенных назначений, чтобы отправить напоминание.

} @if (preview.unavailableCount) { @@ -383,24 +394,45 @@

Пример письма на почту

Найдено: {{ state.attentionCount() }}

@if (state.supportsActions()) { - + @if (state.selectedCount()) { + + +
+ + +
+
+ } @else { + + } }
@if (!state.attentionPage()?.results?.length) { @@ -432,7 +464,6 @@

Пример письма на почту

Участник Город Дата регистрации - Действие @@ -452,11 +483,14 @@

Пример письма на почту

/> - {{ - item.fullName - }} + + {{ item.fullName }} + {{ item.city || "Не указано" }} @@ -466,14 +500,6 @@

Пример письма на почту

: "Нет данных" }} - - Открыть профиль - } @@ -507,7 +533,7 @@

Пример письма на почту

Дата сдачи Причина Прогресс - Действие + Эксперты @@ -526,7 +552,13 @@

Пример письма на почту

[attr.aria-label]="'Выбрать работу: ' + item.project.name" /> - {{ item.project.name }} + + {{ item.project.name }} + @if (item.leader; as leader) { Пример письма на почту Нет данных } - - Открыть проект + +
    + @for (expert of item.experts ?? []; track expert.id) { +
  • + {{ expert.fullName }} + + {{ expert.completed ? "Оценено" : "Нет оценки" }} + +
  • + } @empty { +
  • Эксперты не назначены
  • + } +
} @@ -648,7 +686,7 @@

Пример письма на почту

>{{ state.actionSuccess() }} } - @if (!state.preview()) { + @if (!state.preview() && state.selectedCount()) { Выбрано: {{ state.selectedCount() }} @@ -662,35 +700,59 @@

Пример письма на почту

(click)="state.backToAttentionList()" [disabled]="!!state.actionPending()" > - Назад + Отмена - } @else { + } @else if (state.canRemind() && state.selectedCount()) { +
+ + +
+ } + @if (!state.preview() && state.selectedCount()) { } 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 942eaa816..c09e10acb 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 @@ -442,6 +442,67 @@ } } +.attention-actions__reminder { + display: inline-flex; + align-items: center; + flex: 0 0 auto; + gap: 2px; + border: 1px solid var(--accent); + border-radius: 24px; + padding: 0 4px 0 8px; + .ui-button.ui-button { flex: none; } +} + +.attention-reminder__people { + display: grid; + gap: 4px; + max-height: 45vh; + overflow: auto; + margin: 0; + padding: 0; + list-style: none; + li { + display: grid; + grid-template-columns: 32px minmax(0, 1fr) 32px; + gap: 10px; + align-items: center; + padding-block: 8px; + border-bottom: 1px solid var(--medium-grey-for-outline); + } + small { display: block; color: var(--grey-for-text); overflow-wrap: anywhere; } +} + +.attention-project-experts { + display: grid; + gap: 6px; + padding: 0; + margin: 0; + list-style: none; + li { display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: baseline; } + small { color: var(--grey-for-text); } + small.attention-project-experts__done { color: var(--green-dark); } +} + +.attention-export-menu { + display: grid; + gap: 3px; + padding: 6px; + background: var(--light-white); + border: 1px solid var(--medium-grey-for-outline); + border-radius: 8px; + box-shadow: 0 6px 20px rgb(38 27 52 / 14%); + button { + padding: 8px 12px; + text-align: left; + color: var(--black); + border: 0; + border-radius: 4px; + background: transparent; + cursor: pointer; + &:hover, &:focus-visible { background: var(--white); } + } +} + @media (max-width: 599px) { .attention-actions__buttons { flex-basis: 100%; 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 3124f4964..6ee493502 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 @@ -10,6 +10,8 @@ import { } from "@uilib"; /** @format */ import { A11yModule, CdkTrapFocus } from "@angular/cdk/a11y"; +import { CdkMenuModule } from "@angular/cdk/menu"; +import { MatTooltipModule } from "@angular/material/tooltip"; 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"; @@ -73,6 +75,8 @@ import { TransientNoticeComponent } from "@ui/primitives/transient-notice/transi TableDirective, FormLayoutDirective, A11yModule, + CdkMenuModule, + MatTooltipModule, ModalComponent, AvatarComponent, NgTemplateOutlet, diff --git a/projects/social_platform/src/app/ui/pages/program/detail/list/program-projects-filter/service/program-projects-filter-info.service.ts b/projects/social_platform/src/app/ui/pages/program/detail/list/program-projects-filter/service/program-projects-filter-info.service.ts index 196e20dc2..3816dd77f 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/list/program-projects-filter/service/program-projects-filter-info.service.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/list/program-projects-filter/service/program-projects-filter-info.service.ts @@ -65,7 +65,7 @@ export class ProgramProjectsFilterInfoService { * @param fieldName - имя поля */ toggleAdditionalFormValues( - fieldType: "text" | "textarea" | "checkbox" | "select" | "radio" | "file", + fieldType: "text" | "textarea" | "checkbox" | "select" | "radio" | "file" | "url", fieldName: string, ): void { if (fieldType === "checkbox" || fieldType === "radio") { @@ -179,7 +179,7 @@ export class ProgramProjectsFilterInfoService { private shouldAddToQueryParams( value: any, - fieldType?: "text" | "textarea" | "checkbox" | "select" | "radio" | "file", + fieldType?: "text" | "textarea" | "checkbox" | "select" | "radio" | "file" | "url", ): boolean { if (fieldType === "checkbox" || fieldType === "radio") { return value === true; diff --git a/projects/social_platform/src/app/ui/pages/program/launch/launch.component.html b/projects/social_platform/src/app/ui/pages/program/launch/launch.component.html index e67615040..a7d664b25 100644 --- a/projects/social_platform/src/app/ui/pages/program/launch/launch.component.html +++ b/projects/social_platform/src/app/ui/pages/program/launch/launch.component.html @@ -1,7 +1,5 @@
- К моим программам -

Управление программой

@if (loading()) {

Проверяем настройки программы…

} @@ -22,28 +20,17 @@

Управление программой

}} } @if (state(); as state) { -

{{ state.program.name }}

+ @if (state.program.draft) { -

Черновик · Не виден участникам

-

Подготовка к запуску

-

Подготовка к запуску

-

- {{ - state.canPublish - ? "Все обязательные настройки готовы. Можно открыть регистрацию." - : "Завершите три настройки, чтобы открыть регистрацию." - }} -

+

{{ readyCount() }} из {{ state.checks.length }} шагов готово

@for (check of state.checks; track check.code) {
@@ -69,35 +56,19 @@

{{ check.title }}

} +
+ Предпросмотр +
-
-

Можно настроить после публикации

- @for (warning of state.warnings; track warning) { -

{{ warning }}

- } @empty { -

Материалы, организаторов и сертификаты можно добавить позже.

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

- {{ - state.canPublish - ? "Откроются страница программы и регистрация участников." - : "Публикация станет доступна после обязательных настроек." - }} -

} @else {
Программа опубликована diff --git a/projects/social_platform/src/app/ui/pages/program/launch/launch.component.ts b/projects/social_platform/src/app/ui/pages/program/launch/launch.component.ts index ddcdc988c..058e4a169 100644 --- a/projects/social_platform/src/app/ui/pages/program/launch/launch.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/launch/launch.component.ts @@ -17,11 +17,13 @@ import { TransientNoticeComponent } from "@ui/primitives/transient-notice/transi import { ProgramLaunchUseCase } from "@api/program/use-cases/program-launch.use-case"; import { ProgramLaunch } from "@domain/program/program-launch.model"; import { AppRoutes } from "@api/paths/app-routes"; +import { ProgramManagementHeaderComponent } from "../management/management-header.component"; import { evaluationError } from "@utils/evaluation-workspace"; @Component({ selector: "app-program-launch", imports: [ + ProgramManagementHeaderComponent, RouterLink, AlertDirective, ButtonDirective, @@ -49,15 +51,9 @@ export class ProgramLaunchComponent { readonly publishError = signal(null); readonly message = signal(null, { equal: () => false }); readonly confirmOpen = signal(false); - readonly tabs = [ - { id: "participants", label: "Участники" }, - { id: "projects", label: "Проекты" }, - { id: "experts", label: "Эксперты" }, - { id: "results", label: "Результаты" }, - { id: "analytics", label: "Аналитика" }, - { id: "settings", label: "Настройки" }, - { id: "certificates", label: "Сертификаты" }, - ]; + readyCount(): number { + return this.state()?.checks.filter(check => check.ready).length ?? 0; + } constructor() { combineLatest([this.route.paramMap, this.refresh]) .pipe( diff --git a/projects/social_platform/src/app/ui/pages/program/main/program-card/program-card.component.html b/projects/social_platform/src/app/ui/pages/program/main/program-card/program-card.component.html index 1e8a68a86..d1d03c39f 100644 --- a/projects/social_platform/src/app/ui/pages/program/main/program-card/program-card.component.html +++ b/projects/social_platform/src/app/ui/pages/program/main/program-card/program-card.component.html @@ -89,7 +89,7 @@

{{ program().name }}

[routerLink]=" program().draft ? paths.program.launch(program().id) - : paths.program.management(program().id) + : paths.program.management(program().id, 'analytics') " [attr.aria-label]=" (program().draft ? 'Продолжить настройку: ' : 'Управлять: ') + program().name diff --git a/projects/social_platform/src/app/ui/pages/program/management/certificates/certificate-canvas.component.html b/projects/social_platform/src/app/ui/pages/program/management/certificates/certificate-canvas.component.html index c55b192aa..d52660088 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/certificates/certificate-canvas.component.html +++ b/projects/social_platform/src/app/ui/pages/program/management/certificates/certificate-canvas.component.html @@ -1,18 +1,27 @@
+ +