first commit

This commit is contained in:
2026-10-03 13:40:26 +02:00
parent 732a5b5a16
commit 566f415fa7
555 changed files with 62704 additions and 0 deletions

View File

@@ -0,0 +1,615 @@
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,
IssueArea,
IssueLink,
IssueLinkType,
IssuePriority,
IssueStatus,
REASON_REQUIRED,
ReleaseStatus,
} from '@task-stream/web/tracker/domain';
import {
AREA,
clock,
initials,
LINK,
options,
PRIORITY,
STATUS,
TYPE,
timeAgo,
} 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: `
<a class="ts-back" routerLink="../../board">← Tablero</a>
@if (facade.issue(); as issue) {
<div class="ts-issue">
<section class="ts-issue__main">
<header class="ts-issue__header">
<div class="ts-issue__meta">
<span class="ts-key">{{ issue.key }}</span>
<span class="ts-type" [style.--tone]="type().tone">
<pi-icon [icon]="type().icon" size="x-small" />
{{ type().label }}
</span>
<span class="ts-pill" [style.--tone]="status().tone">{{
status().label
}}</span>
</div>
<div class="ts-issue__title-row">
<h1>{{ issue.title }}</h1>
@if (project.canContribute()) {
<pi-icon-button
icon="edit"
color="grey"
appearance="ghost"
aria-label="Editar"
(execute)="openEdit()"
/>
}
</div>
</header>
@if (project.canContribute() && issue.allowedTransitions.length) {
<div class="ts-transitions" role="group" aria-label="Mover a">
<span class="ts-eyebrow">Mover a</span>
@for (to of issue.allowedTransitions; track to) {
<button
type="button"
class="ts-move"
[style.--tone]="statusTone(to)"
(click)="move(to)"
>
{{ statusLabel(to) }}
<span aria-hidden="true">→</span>
</button>
}
</div>
}
<article class="ts-block">
<h2 class="ts-section">Descripción</h2>
@if (issue.description) {
<p class="ts-prewrap">{{ issue.description }}</p>
} @else {
<p class="ts-muted">Sin descripción.</p>
}
@if (issue.figmaUrl) {
<a
class="ts-figma"
[href]="issue.figmaUrl"
target="_blank"
rel="noopener"
>
<pi-icon icon="figma" size="small" /> Ver el diseño en Figma
</a>
}
</article>
<article class="ts-block">
<h2 class="ts-section">Relaciones</h2>
@for (
link of facade.outwardLinks();
track link.issueId + link.type
) {
<div class="ts-link">
<span class="ts-link__kind">{{ linkLabel(link) }}</span>
<a class="ts-key" [routerLink]="['..', link.issueKey]">{{
link.issueKey
}}</a>
@if (project.canContribute()) {
<pi-icon-button
icon="close"
color="grey"
appearance="ghost"
aria-label="Quitar"
(execute)="unlink(link)"
/>
}
</div>
}
@for (
link of facade.inwardLinks();
track link.issueId + link.type
) {
<div class="ts-link">
<span class="ts-link__kind">{{ linkLabel(link) }}</span>
<a class="ts-key" [routerLink]="['..', link.issueKey]">{{
link.issueKey
}}</a>
</div>
}
@if (!issue.links.length) {
<p class="ts-muted">
Sin relaciones. Vincula FE ↔ BE ↔ QA para seguir el trabajo de
punta a punta.
</p>
}
@if (project.canContribute()) {
<form
class="ts-inline-form"
(submit)="$event.preventDefault(); link()"
novalidate
>
<pi-select-field
[options]="linkTypeOptions"
[(value)]="linkType"
/>
<pi-text-field
placeholder="Clave, p. ej. BE-3"
[(value)]="linkTarget"
/>
<pi-action-button
size="small"
color="primary"
text="Relacionar"
[disabled]="!linkTarget().trim()"
(execute)="link()"
/>
</form>
}
</article>
<article class="ts-block">
<h2 class="ts-section">
Comentarios
<span class="ts-mono">{{ facade.comments().length }}</span>
</h2>
@for (comment of facade.comments(); track comment.id) {
<div class="ts-comment">
<span class="ts-comment__avatar" aria-hidden="true">{{
initialsOf(comment.author.name)
}}</span>
<div class="ts-comment__body">
<div class="ts-comment__head">
<strong>{{ comment.author.name }}</strong>
<span class="ts-comment__when"
>{{ ago(comment.createdAt)
}}{{ comment.editedAt ? ' · editado' : '' }}</span
>
<span class="ts-spacer"></span>
@if (comment.author.id === facade.me()) {
<pi-icon-button
icon="edit"
color="grey"
appearance="ghost"
aria-label="Editar"
(execute)="openEditComment(comment)"
/>
}
@if (
comment.author.id === facade.me() || project.canManage()
) {
<pi-icon-button
icon="trash"
color="grey"
appearance="ghost"
aria-label="Eliminar"
(execute)="deleteComment(comment)"
/>
}
</div>
<p class="ts-prewrap">{{ comment.body }}</p>
</div>
</div>
}
@if (project.canContribute()) {
<form
class="ts-form"
(submit)="$event.preventDefault(); addComment()"
novalidate
>
<pi-textarea
placeholder="Deja una decisión, una explicación, un paso para reproducir…"
[(value)]="commentBody"
/>
<div>
<pi-action-button
color="primary"
text="Comentar"
[disabled]="!commentBody().trim()"
(execute)="addComment()"
/>
</div>
</form>
}
</article>
<article class="ts-block">
<h2 class="ts-section">Historial</h2>
<ol class="ts-log">
@for (entry of facade.history(); track entry.id) {
<li>
<time
class="ts-log__when"
[attr.datetime]="entry.occurredAt"
>{{ clock(entry.occurredAt) }}</time
>
<span class="ts-log__what">{{ entry.summary }}</span>
<span class="ts-log__ago">{{ ago(entry.occurredAt) }}</span>
</li>
}
</ol>
</article>
</section>
<aside class="ts-issue__side ts-panel">
<p class="ts-eyebrow">Ficha</p>
<pi-select-field
[options]="assigneeOptions()"
[disabled]="!project.canContribute()"
[value]="issue.assignee?.id ?? ''"
(valueChange)="assign($event)"
>
<pi-label>Asignado</pi-label>
</pi-select-field>
<pi-select-field
[options]="releaseOptions()"
[disabled]="!project.canContribute()"
[value]="issue.release?.id ?? ''"
(valueChange)="setRelease($event)"
>
<pi-label>Release</pi-label>
</pi-select-field>
<pi-select-field
[options]="priorityOptions"
[disabled]="!project.canContribute()"
[value]="issue.priority"
(valueChange)="update({ priority: $any($event) })"
>
<pi-label>Prioridad</pi-label>
</pi-select-field>
<pi-select-field
[options]="areaOptions"
[disabled]="!project.canContribute()"
[value]="issue.area ?? ''"
(valueChange)="update({ area: $any($event || null) })"
>
<pi-label>Área</pi-label>
</pi-select-field>
<dl class="ts-facts">
<dt>Reportado por</dt>
<dd>{{ issue.reporter.name }}</dd>
<dt>Creado</dt>
<dd class="ts-mono">{{ ago(issue.createdAt) }}</dd>
<dt>Actualizado</dt>
<dd class="ts-mono">{{ ago(issue.updatedAt) }}</dd>
@if (issue.labels.length) {
<dt>Etiquetas</dt>
<dd class="ts-labels">
@for (label of issue.labels; track label) {
<span class="ts-label">#{{ label }}</span>
}
</dd>
}
</dl>
</aside>
</div>
} @else if (facade.error(); as error) {
<pi-note
gap="xs"
status="error"
appearance="fill"
type="status"
title="No se pudo abrir el ticket"
[text]="error"
/>
} @else {
<pi-skeleton shape="text" [lines]="8" />
}
<ng-template #reasonForm>
<form
class="ts-form"
(submit)="$event.preventDefault(); confirmMove()"
novalidate
>
<p class="ts-muted">
Explica el motivo: quedará en el historial del ticket.
</p>
<pi-textarea
[formField]="reason.text"
placeholder="Qué ha fallado y cómo reproducirlo"
>
<pi-label [isRequired]="true">Motivo</pi-label>
</pi-textarea>
<pi-action-button
size="w100"
color="primary"
text="Mover"
[disabled]="reason().invalid()"
(execute)="confirmMove()"
/>
</form>
</ng-template>
<ng-template #editForm>
<form
class="ts-form"
(submit)="$event.preventDefault(); saveEdit()"
novalidate
>
<pi-text-field [formField]="edit.title">
<pi-label [isRequired]="true">Título</pi-label>
</pi-text-field>
<pi-textarea [formField]="edit.description">
<pi-label>Descripción</pi-label>
</pi-textarea>
<pi-text-field [formField]="edit.labels" placeholder="safari, checkout">
<pi-label>Etiquetas (separadas por comas)</pi-label>
</pi-text-field>
<pi-text-field
[formField]="edit.figmaUrl"
placeholder="https://www.figma.com/design/…"
>
<pi-label>Frame de Figma</pi-label>
</pi-text-field>
<pi-action-button
size="w100"
color="primary"
text="Guardar"
[disabled]="edit().invalid()"
(execute)="saveEdit()"
/>
</form>
</ng-template>
<ng-template #commentEditForm>
<form
class="ts-form"
(submit)="$event.preventDefault(); saveComment()"
novalidate
>
<pi-textarea [formField]="commentEdit.body" />
<pi-action-button
size="w100"
color="primary"
text="Guardar"
[disabled]="commentEdit().invalid()"
(execute)="saveComment()"
/>
</form>
</ng-template>
`,
})
export class IssuePage {
/** Route param (`withComponentInputBinding`). */
readonly issueKey = input.required<string>();
protected readonly facade = inject(IssueFacade);
protected readonly project = inject(ProjectFacade);
private readonly drawer = inject(DrawerService);
private readonly reasonForm =
viewChild.required<TemplateRef<unknown>>('reasonForm');
private readonly editForm =
viewChild.required<TemplateRef<unknown>>('editForm');
private readonly commentEditForm =
viewChild.required<TemplateRef<unknown>>('commentEditForm');
protected readonly type = computed(
() => TYPE[this.facade.issue()?.type ?? 'TASK'],
);
protected readonly status = computed(
() => STATUS[this.facade.issue()?.status ?? IssueStatus.BACKLOG],
);
protected readonly ago = timeAgo;
protected readonly assigneeOptions = computed<PiSelectFieldOption<string>[]>(
() => [
{ value: NONE, label: 'Sin asignar' },
...this.project
.members()
.map((m) => ({ value: m.user.id, label: m.user.name })),
],
);
protected readonly releaseOptions = computed<PiSelectFieldOption<string>[]>(
() => [
{ 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);
protected readonly areaOptions: PiSelectFieldOption<string>[] = [
{ value: NONE, label: 'Sin área' },
...options(AREA),
];
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<string | null>(IssueLinkType.RELATES_TO);
protected readonly linkTarget = signal('');
protected readonly commentBody = signal('');
private pendingStatus: IssueStatus | 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: IssueStatus) => STATUS[status].label;
protected statusTone = (status: IssueStatus) => STATUS[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: IssueStatus): void {
if (REASON_REQUIRED.includes(to)) {
this.pendingStatus = to;
this.reasonModel.set({ text: '' });
this.drawer.open(`Mover a «${STATUS[to].label}»`, 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: {
priority?: IssuePriority;
area?: IssueArea | 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();
}
}