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,374 @@
import {
CdkDrag,
CdkDragDrop,
CdkDropList,
CdkDropListGroup,
} from '@angular/cdk/drag-drop';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
input,
signal,
TemplateRef,
untracked,
viewChild,
} from '@angular/core';
import { form, FormField, maxLength, required } from '@angular/forms/signals';
import {
PiActionButtonComponent,
PiLabelDirective,
PiSegmentedControlComponent,
PiSelectFieldComponent,
PiSkeletonComponent,
PiTextareaComponent,
PiTextFieldComponent,
type PiSelectFieldOption,
type SegmentedControlOption,
} from '@pi/ui';
import { DrawerService } from '@task-stream/web/shared/ui';
import {
BoardFacade,
ProjectFacade,
provideBoardFacade,
} from '@task-stream/web/tracker/application';
import {
BoardColumn,
IssueArea,
IssuePriority,
IssueStatus,
IssueSummary,
IssueType,
REASON_REQUIRED,
ReleaseStatus,
} from '@task-stream/web/tracker/domain';
import { IssueCardComponent } from '../ui/issue-card';
import { AREA, options, PRIORITY, STATUS, TYPE } from '../ui/copy';
const ALL = '';
@Component({
selector: 'ts-board-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CdkDropListGroup,
CdkDropList,
CdkDrag,
FormField,
IssueCardComponent,
PiActionButtonComponent,
PiSelectFieldComponent,
PiSegmentedControlComponent,
PiSkeletonComponent,
PiTextFieldComponent,
PiTextareaComponent,
PiLabelDirective,
],
providers: [provideBoardFacade()],
styleUrl: './board.page.scss',
template: `
<div class="ts-board-toolbar">
<pi-select-field
class="ts-filter"
placeholder="Persona"
[options]="assigneeOptions()"
[(value)]="assignee"
/>
<pi-select-field
class="ts-filter"
placeholder="Release"
[options]="releaseOptions()"
[(value)]="releaseFilter"
/>
<pi-select-field
class="ts-filter"
placeholder="Área"
[options]="areaOptions"
[(value)]="area"
/>
<span class="ts-board-total"
><strong class="ts-mono">{{ board.total() }}</strong> tickets</span
>
<span class="ts-spacer"></span>
@if (project.canContribute()) {
<pi-action-button
color="primary"
text="Nuevo ticket"
(execute)="openCreate()"
/>
}
</div>
@if (board.loading() && !board.columns().length) {
<div class="ts-board">
@for (i of [1, 2, 3, 4]; track i) {
<pi-skeleton shape="block" height="20rem" />
}
</div>
} @else {
<div class="ts-board" cdkDropListGroup>
@for (column of board.columns(); track column.status) {
<section
class="ts-column"
[attr.data-status]="column.status"
[style.--tone]="statusTone(column.status)"
>
<header class="ts-column__header">
<span class="ts-column__name">{{
statusLabel(column.status)
}}</span>
<span class="ts-column__count">{{ column.total }}</span>
</header>
<div
class="ts-column__list"
cdkDropList
[id]="column.status"
[cdkDropListData]="column"
[cdkDropListDisabled]="!project.canContribute()"
(cdkDropListDropped)="drop($event)"
>
@for (issue of column.issues; track issue.id) {
<ts-issue-card cdkDrag [cdkDragData]="issue" [issue]="issue" />
} @empty {
<p class="ts-column__empty">Nada por aquí</p>
}
</div>
</section>
}
</div>
}
<ng-template #createForm>
<form
class="ts-form"
(submit)="$event.preventDefault(); create()"
novalidate
>
<pi-segmented-control
ariaLabel="Tipo"
[options]="typeOptions"
[(value)]="newType"
/>
<pi-text-field [formField]="f.title" [placeholder]="titlePlaceholder()">
<pi-label [isRequired]="true">Título</pi-label>
</pi-text-field>
<pi-textarea
[formField]="f.description"
placeholder="Pasos para reproducir, criterios de aceptación…"
>
<pi-label>Descripción</pi-label>
</pi-textarea>
@if (newType() === 'UI_CHANGE') {
<pi-text-field
[formField]="f.figmaUrl"
placeholder="https://www.figma.com/design/…"
>
<pi-label>Frame de Figma</pi-label>
</pi-text-field>
}
<div class="ts-form__row">
<pi-select-field [formField]="f.priority" [options]="priorityOptions">
<pi-label>Prioridad</pi-label>
</pi-select-field>
<pi-select-field [formField]="f.area" [options]="areaOptions">
<pi-label>Área</pi-label>
</pi-select-field>
</div>
<div class="ts-form__row">
<pi-select-field
[formField]="f.assigneeId"
[options]="assigneeOptions()"
>
<pi-label>Asignar a</pi-label>
</pi-select-field>
<pi-select-field
[formField]="f.releaseId"
[options]="releaseOptions()"
>
<pi-label>Release</pi-label>
</pi-select-field>
</div>
<pi-action-button
size="w100"
color="primary"
[text]="board.creating() ? 'Creando…' : 'Crear ticket'"
[disabled]="f().invalid() || board.creating()"
(execute)="create()"
/>
</form>
</ng-template>
<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="Sigue fallando en Safari 17…"
>
<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>
`,
})
export class BoardPage {
/** `?release=` (from the releases page), bound by the router. */
readonly release = input<string | undefined>();
protected readonly board = inject(BoardFacade);
protected readonly project = inject(ProjectFacade);
private readonly drawer = inject(DrawerService);
private readonly createForm =
viewChild.required<TemplateRef<unknown>>('createForm');
private readonly reasonForm =
viewChild.required<TemplateRef<unknown>>('reasonForm');
/* Filters */
protected readonly assignee = signal<string | null>(ALL);
protected readonly releaseFilter = signal<string | null>(ALL);
protected readonly area = signal<string | null>(ALL);
protected readonly assigneeOptions = computed<PiSelectFieldOption<string>[]>(
() => [
{ value: ALL, label: 'Todas las personas' },
...this.project
.members()
.map((m) => ({ value: m.user.id, label: m.user.name })),
],
);
protected readonly releaseOptions = computed<PiSelectFieldOption<string>[]>(
() => [
{ value: ALL, label: 'Todas las releases' },
...this.project
.releases()
.filter((r) => r.status === ReleaseStatus.PLANNED)
.map((r) => ({ value: r.id, label: r.name })),
],
);
protected readonly areaOptions: PiSelectFieldOption<string>[] = [
{ value: ALL, label: 'Todas las áreas' },
...options(AREA),
];
protected readonly priorityOptions = options(PRIORITY);
protected readonly typeOptions: SegmentedControlOption<IssueType>[] =
options(TYPE);
/* New ticket */
protected readonly newType = signal<IssueType>(IssueType.BUG);
private readonly model = signal({
title: '',
description: '',
figmaUrl: '',
priority: IssuePriority.MEDIUM as string,
area: ALL as string,
assigneeId: ALL as string,
releaseId: ALL as string,
});
protected readonly f = form(this.model, (p) => {
required(p.title);
maxLength(p.title, 255);
});
protected readonly titlePlaceholder = computed(() =>
this.newType() === IssueType.BUG
? 'El botón de pagar no responde en Safari'
: 'Qué hay que hacer',
);
/* Move needing a reason */
private pendingMove: { issueId: string; to: IssueStatus } | null = null;
private readonly reasonModel = signal({ text: '' });
protected readonly reason = form(this.reasonModel, (p) => required(p.text));
constructor() {
effect(() => {
const release = this.release();
if (release) untracked(() => this.releaseFilter.set(release));
});
// (Re)load whenever the project or a filter changes.
effect(() => {
const projectId = this.project.project()?.id;
const filter = {
assigneeId: this.assignee() || undefined,
releaseId: this.releaseFilter() || undefined,
area: (this.area() || undefined) as IssueArea | undefined,
};
if (!projectId) return;
untracked(() => this.board.show({ projectId, ...filter }));
});
}
protected statusLabel = (status: IssueStatus) => STATUS[status].label;
protected statusTone = (status: IssueStatus) => STATUS[status].tone;
protected drop(
event: CdkDragDrop<BoardColumn, BoardColumn, IssueSummary>,
): void {
if (event.previousContainer === event.container) return;
const to = event.container.data.status;
const issueId = event.item.data.id;
if (REASON_REQUIRED.includes(to)) {
this.pendingMove = { issueId, to };
this.reasonModel.set({ text: '' });
this.drawer.open(`Mover a «${STATUS[to].label}»`, this.reasonForm());
return;
}
this.board.moveIssue(issueId, to);
}
protected confirmMove(): void {
if (!this.pendingMove || this.reason().invalid()) return;
const { issueId, to } = this.pendingMove;
this.board.moveIssue(issueId, to, this.reasonModel().text);
this.pendingMove = null;
this.drawer.closeAll();
}
protected openCreate(): void {
this.model.set({
title: '',
description: '',
figmaUrl: '',
priority: IssuePriority.MEDIUM,
area: ALL,
assigneeId: ALL,
releaseId: this.releaseFilter() || ALL,
});
this.drawer.open('Nuevo ticket', this.createForm(), '36rem');
}
protected create(): void {
const projectId = this.project.project()?.id;
if (!projectId || this.f().invalid()) return;
const v = this.model();
this.board.createIssue({
projectId,
type: this.newType(),
title: v.title,
description: v.description || null,
figmaUrl:
this.newType() === IssueType.UI_CHANGE && v.figmaUrl
? v.figmaUrl
: null,
priority: v.priority as IssuePriority,
area: (v.area || null) as IssueArea | null,
assigneeId: v.assigneeId || null,
releaseId: v.releaseId || null,
});
this.drawer.closeAll();
}
}