import { CdkDrag, CdkDragDrop, CdkDropList } from '@angular/cdk/drag-drop'; import { ChangeDetectionStrategy, Component, computed, effect, signal, inject, untracked, } from '@angular/core'; import { RouterLink } from '@angular/router'; import { PiActionButtonComponent, PiEmptyStateComponent, PiIconButtonComponent, PiIconComponent, PiNoteComponent, PiSelectFieldComponent, PiSkeletonComponent, PiTextFieldComponent, type PiSelectFieldOption, } from '@pi/ui'; import { BacklogFacade, ProjectFacade, provideBacklogFacade, } from '@task-stream/web/tracker/application'; import { BulkChange, IssuePriority, IssueSummary, sumPoints, } from '@task-stream/web/tracker/domain'; import { initials, options, PRIORITY, statusLook, typeLook } from '../ui/copy'; /** "Nobody" / "no sprint" in the bulk selects. */ const NOBODY = '__none__'; /** /p/:key/backlog — what is still to start, in the order the team takes it. */ @Component({ selector: 'ts-backlog-page', changeDetection: ChangeDetectionStrategy.OnPush, imports: [ RouterLink, CdkDrag, CdkDropList, PiActionButtonComponent, PiEmptyStateComponent, PiIconButtonComponent, PiIconComponent, PiNoteComponent, PiSelectFieldComponent, PiSkeletonComponent, PiTextFieldComponent, ], providers: [provideBacklogFacade()], template: `

Lo que aún no ha empezado, en el orden en que se va a coger. Arrastra para reordenar; las columnas del tablero siguen este mismo orden.

@if (facade.loaded() && facade.issues().length) { {{ points() }} pts estimados · {{ unestimated() }} sin estimar } @if (facade.loaded() && facade.total() > facade.issues().length) { {{ facade.issues().length }} de {{ facade.total() }} }
@if (facade.error(); as error) { } @else if (!facade.loaded()) { } @else if (facade.issues().length) { @if (project.canContribute()) { }
    @for (issue of facade.issues(); track issue.id; let i = $index) {
  1. {{ i + 1 }} {{ issue.key }} {{ issue.title }} {{ statusLabel(issue) }} {{ priorityLabel(issue) }} {{ issue.storyPoints ?? '—' }} {{ issue.assignee ? initialsOf(issue.assignee.name) : '—' }} @if (project.canContribute()) { }
  2. }
} @else { }

Papelera

`, styles: ` .ts-backlog { margin: 0; padding: 0.25rem 0; list-style: none; } .ts-backlog__row { display: grid; grid-template-columns: 1rem 2rem auto auto minmax(0, 1fr) auto auto 2rem auto auto; gap: 0.625rem; align-items: center; padding: 0.375rem 0.875rem; border-top: 1px solid var(--ts-line); background: var(--ts-surface); cursor: grab; &:first-child { border-top: 0; } @media (max-width: 720px) { grid-template-columns: 1rem 2rem auto minmax(0, 1fr) auto; .ts-backlog__type, .ts-backlog__points, .ts-pill, .ts-backlog__priority, .ts-backlog__who { display: none; } } } .ts-backlog__row.is-selected { background: color-mix(in srgb, var(--ts-brand) 8%, var(--ts-surface)); } .ts-bulk { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 0.75rem; pi-select-field, pi-text-field { width: 13rem; } } .ts-bulk__all { display: inline-flex; gap: 0.375rem; align-items: center; font-size: 0.875rem; } .ts-backlog__trash { margin: 1rem 0 0; font-size: 0.875rem; text-align: right; } .ts-backlog__pos { color: var(--ts-muted); font-size: 0.75rem; text-align: right; } .ts-backlog__type { display: inline-flex; color: var(--tone); } .ts-backlog__title { overflow: hidden; color: inherit; text-decoration: none; text-overflow: ellipsis; white-space: nowrap; &:hover { color: var(--ts-brand); } } .ts-backlog__priority { font-size: 0.8125rem; } .ts-backlog__points { font-size: 0.8125rem; text-align: right; } .ts-backlog__who { display: inline-grid; place-items: center; width: 1.5rem; height: 1.5rem; border-radius: 50%; background: var(--ts-line); font-size: 0.625rem; font-weight: 600; } .ts-backlog__moves { display: inline-flex; } .cdk-drag-preview { box-shadow: 0 8px 24px rgb(0 0 0 / 18%); } .cdk-drag-placeholder { opacity: 0.35; } `, }) export class BacklogPage { protected readonly facade = inject(BacklogFacade); protected readonly project = inject(ProjectFacade); protected readonly initialsOf = initials; /* Bulk edit */ protected readonly selected = signal(new Set()); protected readonly allSelected = computed( () => this.facade.issues().length > 0 && this.facade.issues().every((i) => this.selected().has(i.id)), ); protected readonly bulkFields: PiSelectFieldOption[] = [ { value: 'status', label: 'Mover a…' }, { value: 'assignee', label: 'Asignar a…' }, { value: 'sprint', label: 'Sprint…' }, { value: 'priority', label: 'Prioridad…' }, { value: 'label', label: 'Añadir etiqueta…' }, ]; protected readonly bulkField = signal('status'); protected readonly bulkValue = signal(null); protected readonly bulkLabel = signal(''); protected readonly statusOptions = computed[]>( () => this.project .workflow() .settings.statuses.filter((s) => !s.archived) .map((s) => ({ value: s.id, label: s.name })), ); protected readonly assigneeOptions = computed[]>( () => [ { value: NOBODY, label: 'Nadie (sin asignar)' }, ...this.project .members() .map((m) => ({ value: m.user.id, label: m.user.name })), ], ); protected readonly sprintOptions = computed[]>( () => [ { value: NOBODY, label: 'Backlog (sin sprint)' }, ...this.project .openSprints() .map((s) => ({ value: s.id, label: s.name })), ], ); protected readonly priorityOptions = options(PRIORITY); protected readonly canApply = computed(() => this.bulkField() === 'label' ? !!this.bulkLabel().trim() : !!this.bulkValue(), ); protected toggle(issueId: string, on: boolean): void { this.selected.update((set) => { const next = new Set(set); if (on) next.add(issueId); else next.delete(issueId); return next; }); } protected selectAll(on: boolean): void { this.selected.set(new Set(on ? this.facade.issues().map((i) => i.id) : [])); } protected applyBulk(): void { const value = this.bulkValue(); const change: BulkChange = {}; switch (this.bulkField()) { case 'status': if (value) change.status = value; break; case 'assignee': change.assigneeId = value === NOBODY ? null : value; break; case 'sprint': change.sprintId = value === NOBODY ? null : value; break; case 'priority': if (value) change.priority = value as IssuePriority; break; case 'label': change.addLabels = [this.bulkLabel().trim().toLowerCase()]; break; } this.facade.bulkUpdate([...this.selected()], change); this.selected.set(new Set()); this.bulkLabel.set(''); } protected readonly points = computed(() => sumPoints(this.facade.issues())); protected readonly unestimated = computed( () => this.facade.issues().filter((i) => i.storyPoints === null).length, ); constructor() { effect(() => { const projectId = this.project.project()?.id; if (projectId) untracked(() => this.facade.open(projectId)); }); } protected drop(event: CdkDragDrop): void { this.facade.move(event.item.data.id, event.currentIndex); } protected typeIcon = (issue: IssueSummary) => typeLook(this.project.workflow(), issue.type).icon; protected typeTone = (issue: IssueSummary) => typeLook(this.project.workflow(), issue.type).tone; protected typeLabel = (issue: IssueSummary) => typeLook(this.project.workflow(), issue.type).label; protected statusTone = (issue: IssueSummary) => statusLook(this.project.workflow(), issue.status).tone; protected statusLabel = (issue: IssueSummary) => statusLook(this.project.workflow(), issue.status).label; protected priorityLabel = (issue: IssueSummary) => PRIORITY[issue.priority].label; }