import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal, TemplateRef, viewChild, } from '@angular/core'; import { form, FormField, required } from '@angular/forms/signals'; import { RouterLink } from '@angular/router'; import { PiActionButtonComponent, PiIconButtonComponent, PiIconComponent, PiLabelDirective, PiNoteComponent, PiSelectFieldComponent, PiSkeletonComponent, PiTextareaComponent, PiTextFieldComponent, type PiSelectFieldOption, } from '@pi/ui'; import { DrawerService } from '@task-stream/web/shared/ui'; import { IssueFacade, ProjectFacade, provideIssueFacade, } from '@task-stream/web/tracker/application'; import { Comment, IssueLink, IssueLinkType, IssuePriority, ReleaseStatus, } from '@task-stream/web/tracker/domain'; import { clock, initials, LINK, options, PRIORITY, statusLook, timeAgo, typeLook, } from '../ui/copy'; const NONE = ''; @Component({ selector: 'ts-issue-page', changeDetection: ChangeDetectionStrategy.OnPush, imports: [ RouterLink, FormField, PiActionButtonComponent, PiIconComponent, PiIconButtonComponent, PiLabelDirective, PiNoteComponent, PiSelectFieldComponent, PiSkeletonComponent, PiTextFieldComponent, PiTextareaComponent, ], providers: [provideIssueFacade()], styleUrl: './issue.page.scss', template: ` ← Tablero @if (facade.issue(); as issue) {
{{ issue.key }} {{ type().label }} {{ status().label }}

{{ issue.title }}

@if (project.canContribute()) { }
@if (project.canContribute() && issue.allowedTransitions.length) {
Mover a @for (to of issue.allowedTransitions; track to) { }
}

Descripción

@if (issue.description) {

{{ issue.description }}

} @else {

Sin descripción.

} @if (issue.figmaUrl) { Ver el diseño en Figma }

Relaciones

@for ( link of facade.outwardLinks(); track link.issueId + link.type ) { } @for ( link of facade.inwardLinks(); track link.issueId + link.type ) { } @if (!issue.links.length) {

Sin relaciones. Vincula FE ↔ BE ↔ QA para seguir el trabajo de punta a punta.

} @if (project.canContribute()) {
}

Comentarios {{ facade.comments().length }}

@for (comment of facade.comments(); track comment.id) {
{{ comment.author.name }} {{ ago(comment.createdAt) }}{{ comment.editedAt ? ' · editado' : '' }} @if (comment.author.id === facade.me()) { } @if ( comment.author.id === facade.me() || project.canManage() ) { }

{{ comment.body }}

} @if (project.canContribute()) {
}

Historial

    @for (entry of facade.history(); track entry.id) {
  1. {{ entry.summary }} {{ ago(entry.occurredAt) }}
  2. }
} @else if (facade.error(); as error) { } @else { }

Explica el motivo: quedará en el historial del ticket.

Motivo
Título Descripción Etiquetas (separadas por comas) Frame de Figma
`, }) export class IssuePage { /** Route param (`withComponentInputBinding`). */ readonly issueKey = input.required(); protected readonly facade = inject(IssueFacade); protected readonly project = inject(ProjectFacade); private readonly drawer = inject(DrawerService); private readonly reasonForm = viewChild.required>('reasonForm'); private readonly editForm = viewChild.required>('editForm'); private readonly commentEditForm = viewChild.required>('commentEditForm'); protected readonly type = computed(() => typeLook(this.project.workflow(), this.facade.issue()?.type ?? ''), ); protected readonly status = computed(() => statusLook(this.project.workflow(), this.facade.issue()?.status ?? ''), ); protected readonly ago = timeAgo; protected readonly assigneeOptions = computed[]>( () => [ { value: NONE, label: 'Sin asignar' }, ...this.project .members() .map((m) => ({ value: m.user.id, label: m.user.name })), ], ); protected readonly releaseOptions = computed[]>( () => [ { value: NONE, label: 'Sin release' }, ...this.project .releases() .filter( (r) => r.status === ReleaseStatus.PLANNED || r.id === this.facade.issue()?.release?.id, ) .map((r) => ({ value: r.id, label: r.name })), ], ); protected readonly priorityOptions = options(PRIORITY); /** Active values, plus the ticket's own even if archived since. */ protected readonly typeOptions = computed[]>( () => { const workflow = this.project.workflow(); const current = this.facade.issue()?.type; return workflow.settings.issueTypes .filter((t) => !t.archived || t.id === current) .map((t) => ({ value: t.id, label: t.name })); }, ); protected readonly areaOptions = computed[]>( () => { const workflow = this.project.workflow(); const current = this.facade.issue()?.area; return [ { value: NONE, label: 'Sin área' }, ...workflow.settings.areas .filter((a) => !a.archived || a.id === current) .map((a) => ({ value: a.id, label: a.name })), ]; }, ); protected readonly linkTypeOptions = ( Object.keys(LINK) as IssueLinkType[] ).map((value) => ({ value, label: LINK[value].outward, })); // Inline forms: plain signals (a required validator would flag them red right after submitting). protected readonly linkType = signal(IssueLinkType.RELATES_TO); protected readonly linkTarget = signal(''); protected readonly commentBody = signal(''); private pendingStatus: string | null = null; private readonly reasonModel = signal({ text: '' }); protected readonly reason = form(this.reasonModel, (p) => required(p.text)); private readonly editModel = signal({ title: '', description: '', labels: '', figmaUrl: '', }); protected readonly edit = form(this.editModel, (p) => required(p.title)); private editingComment: string | null = null; private readonly commentEditModel = signal({ body: '' }); protected readonly commentEdit = form(this.commentEditModel, (p) => required(p.body), ); constructor() { effect(() => this.facade.open(this.issueKey())); } protected statusLabel = (status: string) => statusLook(this.project.workflow(), status).label; protected statusTone = (status: string) => statusLook(this.project.workflow(), status).tone; protected readonly initialsOf = initials; protected readonly clock = clock; protected linkLabel = (link: IssueLink) => link.direction === 'OUTWARD' ? LINK[link.type].outward : LINK[link.type].inward; protected move(to: string): void { if (this.project.workflow().requiresReason(to)) { this.pendingStatus = to; this.reasonModel.set({ text: '' }); this.drawer.open(`Mover a «${this.statusLabel(to)}»`, this.reasonForm()); return; } this.facade.changeStatus(to); } protected confirmMove(): void { if (!this.pendingStatus || this.reason().invalid()) return; this.facade.changeStatus(this.pendingStatus, this.reasonModel().text); this.pendingStatus = null; this.drawer.closeAll(); } protected assign(assigneeId: string | null): void { this.facade.assign(assigneeId || null); } protected setRelease(releaseId: string | null): void { this.facade.setRelease(releaseId || null); } protected update(changes: { type?: string; priority?: IssuePriority; area?: string | null; }): void { this.facade.update(changes); } protected link(): void { const targetKey = this.linkTarget().trim(); if (!targetKey) return; this.facade.link( (this.linkType() ?? IssueLinkType.RELATES_TO) as IssueLinkType, targetKey, ); this.linkTarget.set(''); } protected unlink(link: IssueLink): void { this.facade.unlink(link); } protected addComment(): void { const body = this.commentBody().trim(); if (!body) return; this.facade.addComment(body); this.commentBody.set(''); } protected openEditComment(comment: Comment): void { this.editingComment = comment.id; this.commentEditModel.set({ body: comment.body }); this.drawer.open('Editar comentario', this.commentEditForm()); } protected saveComment(): void { if (!this.editingComment || this.commentEdit().invalid()) return; this.facade.editComment(this.editingComment, this.commentEditModel().body); this.drawer.closeAll(); } protected deleteComment(comment: Comment): void { if (confirm('¿Eliminar este comentario?')) this.facade.deleteComment(comment.id); } protected openEdit(): void { const issue = this.facade.issue(); if (!issue) return; this.editModel.set({ title: issue.title, description: issue.description ?? '', labels: issue.labels.join(', '), figmaUrl: issue.figmaUrl ?? '', }); this.drawer.open(`Editar ${issue.key}`, this.editForm(), '36rem'); } protected saveEdit(): void { if (this.edit().invalid()) return; const v = this.editModel(); this.facade.update({ title: v.title, description: v.description || null, labels: v.labels .split(',') .map((l) => l.trim()) .filter(Boolean), figmaUrl: v.figmaUrl || null, }); this.drawer.closeAll(); } }