import { ChangeDetectionStrategy, Component, computed, effect, HostListener, inject, input, signal, TemplateRef, viewChild, } from '@angular/core'; import { form, FormField, required } from '@angular/forms/signals'; import { RouterLink } from '@angular/router'; import { PiActionButtonComponent, PiCheckboxComponent, PiFileUploadComponent, PiIconButtonComponent, PiIconComponent, PiLabelDirective, PiNoteComponent, PiProgressBarComponent, PiSelectFieldComponent, PiSkeletonComponent, PiTextareaComponent, PiTextFieldComponent, type PiFileRejection, type PiSelectFieldOption, } from '@pi/ui'; import { DrawerService, ToastService } from '@task-stream/web/shared/ui'; import { IssueFacade, ProjectFacade, provideIssueFacade, } from '@task-stream/web/tracker/application'; import { Attachment, Comment, insertMention, IssueLink, IssueLinkType, IssuePriority, matches, mentionQuery, mentionSegments, ReleaseStatus, } from '@task-stream/web/tracker/domain'; import { clock, initials, LINK, options, PRIORITY, statusLook, timeAgo, typeLook, } from '../ui/copy'; import { AttachmentItem } from '../ui/attachments'; const NONE = ''; /** Same ceiling as the API's ATTACHMENTS_MAX_MB default. */ const MAX_FILE_BYTES = 25 * 1024 * 1024; @Component({ selector: 'ts-issue-page', changeDetection: ChangeDetectionStrategy.OnPush, imports: [ RouterLink, FormField, AttachmentItem, PiActionButtonComponent, PiCheckboxComponent, PiFileUploadComponent, PiIconComponent, PiIconButtonComponent, PiLabelDirective, PiNoteComponent, PiProgressBarComponent, PiSelectFieldComponent, PiSkeletonComponent, PiTextFieldComponent, PiTextareaComponent, ], providers: [provideIssueFacade()], styleUrl: './issue.page.scss', template: ` ← Tablero @if (facade.issue(); as issue) {
{{ issue.key }} {{ type().label }} {{ status().label }} @if (issue.parent; as parent) { {{ parent.key }} {{ parent.title }} }

{{ issue.title }}

@if (project.myRole()) { } @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 }

Checklist @if (issue.checklist.length) { {{ facade.checklistProgress().done }}/{{ facade.checklistProgress().total }} }

@if (issue.checklist.length) {
    @for (item of issue.checklist; track item.id) {
  • @if (project.canContribute()) { }
  • }
} @else if (!project.canContribute()) {

Sin pasos.

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

Subtareas @if (facade.children().length) { {{ facade.childProgress().finished }}/{{ facade.childProgress().total }} }

@if (facade.children().length) {
    @for (child of facade.children(); track child.id) {
  • {{ child.key }} {{ child.title }} {{ statusLabel(child.status) }} @if (child.assignee) { {{ initialsOf(child.assignee.name) }} }
  • }
} @else if (!project.canContribute()) {

Sin subtareas.

} @if (project.canContribute()) {
@if (issue.parent; as parent) { Cuelga de {{ parent.key }} } @else {
}
}

Adjuntos {{ ticketFiles().length }}

@if (ticketFiles().length) {
@for (file of ticketFiles(); track file.id) { }
} @else if (!project.canContribute()) {

Sin adjuntos.

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

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() ) { }

@for (part of segments(comment.body); track $index) { @if ('mention' in part) { @{{ part.mention }} } @else { {{ part.text }} } }

@if (filesOf(comment).length) {
@for (file of filesOf(comment); track file.id) { }
}
} @if (project.canContribute()) {
@if (mentionOptions().length) {
@for (member of mentionOptions(); track member.id) { }
}
}

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 toast = inject(ToastService); 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 })), ], ); /** Open sprints, plus the ticket's own even if it closed since. */ protected readonly sprintOptions = computed[]>( () => { const current = this.facade.issue()?.sprint; const open = this.project.openSprints(); return [ { value: NONE, label: 'Backlog (sin sprint)' }, ...open.map((s) => ({ value: s.id, label: s.status === 'ACTIVE' ? `${s.name} · en curso` : s.name, })), ...(current && !open.some((s) => s.id === current.id) ? [{ value: current.id, label: `${current.name} · cerrado` }] : []), ]; }, ); /** A closed sprint keeps its tickets. */ protected readonly sprintLocked = computed(() => { const current = this.facade.issue()?.sprint; return ( !!current && !this.project.openSprints().some((s) => s.id === current.id) ); }); 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(''); protected readonly commentFiles = signal([]); protected readonly segments = mentionSegments; /** Members matching what is typed after an `@` at the end of the comment. */ protected readonly mentionOptions = computed(() => { const query = mentionQuery(this.commentBody()); if (query === null) return []; return this.project .members() .map((m) => m.user) .filter( (user) => user.id !== this.facade.me() && matches(user.name, query), ) .slice(0, 6); }); protected readonly checklistText = signal(''); protected readonly subtaskTitle = signal(''); protected readonly subtaskType = signal(null); protected readonly parentKey = signal(''); /** Active types; subtasks start as tasks when the project has them. */ protected readonly subtaskTypeOptions = computed< PiSelectFieldOption[] >(() => this.project .workflow() .settings.issueTypes.filter((t) => !t.archived) .map((t) => ({ value: t.id, label: t.name })), ); protected readonly defaultSubtaskType = computed(() => { const ids = this.subtaskTypeOptions().map((o) => o.value); return ids.includes('TASK') ? 'TASK' : (ids[0] ?? ''); }); protected readonly pendingFiles = signal([]); protected readonly maxFileBytes = MAX_FILE_BYTES; /** Files on the ticket itself; those under a comment are shown there. */ protected readonly ticketFiles = computed( () => this.facade.issue()?.attachments.filter((a) => !a.commentId) ?? [], ); protected filesOf = (comment: Comment): Attachment[] => this.facade .issue() ?.attachments.filter((a) => a.commentId === comment.id) ?? []; protected canRemove = (file: Attachment): boolean => this.project.canContribute() && (file.uploadedBy.id === this.facade.me() || this.project.canManage()); 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 addChecklistItem(): void { const text = this.checklistText().trim(); if (!text) return; this.facade.addChecklistItem(text); this.checklistText.set(''); } protected addSubtask(): void { const title = this.subtaskTitle().trim(); const type = this.subtaskType() ?? this.defaultSubtaskType(); if (!title || !type) return; this.facade.addSubtask(title, type); this.subtaskTitle.set(''); } protected setParent(): void { const key = this.parentKey().trim(); if (!key) return; this.facade.setParent(key); this.parentKey.set(''); } 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.commentFiles()); this.commentBody.set(''); this.commentFiles.set([]); } protected mention(user: { id: string; name: string }): void { this.commentBody.update((text) => insertMention(text, user)); } protected attach(): void { this.facade.attach(this.pendingFiles()); this.pendingFiles.set([]); } protected rejected(rejections: PiFileRejection[]): void { for (const { file, reason } of rejections) { this.toast.error( reason === 'size' ? `${file.name} pasa de 25 MB` : reason === 'max-files' ? 'Como mucho 10 archivos a la vez' : `${file.name} no es un tipo admitido`, ); } } /** A screenshot pasted anywhere on the ticket is attached to it. */ @HostListener('document:paste', ['$event']) protected pasted(event: ClipboardEvent): void { const target = event.target as HTMLElement | null; // Pasting text into a field is just typing. if (target?.closest('input, textarea, [contenteditable]')) return; if (!this.project.canContribute() || !this.facade.issue()) return; const files = [...(event.clipboardData?.files ?? [])].filter( (file) => file.size <= MAX_FILE_BYTES, ); if (!files.length) return; event.preventDefault(); this.facade.attach( files.map((file) => file.name && file.name !== 'image.png' ? file : new File( [file], `captura-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.png`, { type: file.type, }, ), ), ); } 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(); } }