import { CdkDrag, CdkDragDrop, CdkDropList } from '@angular/cdk/drag-drop'; import { ChangeDetectionStrategy, Component, computed, effect, HostListener, inject, input, signal, TemplateRef, untracked, viewChild, } from '@angular/core'; import { NgTemplateOutlet } from '@angular/common'; import { form, FormField, maxLength, required } from '@angular/forms/signals'; import { PiActionButtonComponent, PiLabelDirective, PiSegmentedControlComponent, PiSelectFieldComponent, PiSkeletonComponent, PiSlideToggleComponent, PiTextareaComponent, PiTextFieldComponent, type PiSelectFieldOption, type SegmentedControlOption, } from '@pi/ui'; import { AuthFacade } from '@task-stream/web/identity/application'; import { DrawerService, ToastService } from '@task-stream/web/shared/ui'; import { BoardFacade, ProjectFacade, provideBoardFacade, } from '@task-stream/web/tracker/application'; import { BoardColumn, BoardFilter, boardFilterOf, IssuePriority, IssueSummary, isTyping, overWip, POINT_SCALE, ReleaseStatus, SavedFilter, sumPoints, SwimlaneMode, swimlanes, Workflow, } from '@task-stream/web/tracker/domain'; import { IssueCardComponent } from '../ui/issue-card'; import { options, pointOptions, PRIORITY, PRIORITY_LEVEL, statusLook, typeLook, } from '../ui/copy'; const ALL = ''; const LANE_KEY = 'ts.board.lanes'; /** The grouping last chosen on this browser (a convenience, not a setting). */ function storedLaneMode(): SwimlaneMode { try { const stored = localStorage.getItem(LANE_KEY); return (['assignee', 'epic', 'priority', 'type'] as const).includes( stored as never, ) ? (stored as SwimlaneMode) : 'none'; } catch { return 'none'; } } const statusLookType = (workflow: Workflow, id: string) => typeLook(workflow, id).label; /** "Sin asignar" in the person filter. */ const NOBODY = '__nobody__'; @Component({ selector: 'ts-board-page', changeDetection: ChangeDetectionStrategy.OnPush, imports: [ CdkDropList, CdkDrag, FormField, IssueCardComponent, NgTemplateOutlet, PiActionButtonComponent, PiSelectFieldComponent, PiSegmentedControlComponent, PiSkeletonComponent, PiSlideToggleComponent, PiTextFieldComponent, PiTextareaComponent, PiLabelDirective, ], providers: [provideBoardFacade()], styleUrl: './board.page.scss', template: ` @if (project.myRole()) {
@if (!board.activeView() && isFiltered()) { } @if (board.activeView(); as view) { @if (canDeleteView(view)) { } } @if (isFiltered()) { }
}
{{ board.total() }} tickets @if (project.canContribute()) { }
@if (board.loading() && !board.columns().length) {
@for (i of [1, 2, 3, 4]; track i) { }
} @else { @if (laneMode() === 'none') {
@for (column of board.columns(); track column.status) {
}
} @else {
@for (column of board.columns(); track column.status) {
}
@for (lane of lanes(); track lane.key) {

{{ laneLabel(lane.key) }} {{ lane.count }}

@for (column of lane.columns; track column.status) {
}
}
} }
{{ statusLabel(column.status) }} {{ column.total }}{{ wipOf(column) ? ' / ' + wipOf(column) : '' }} @if (pointsOf(column); as points) { · {{ points }} pts }
@for (issue of column.issues; track issue.id) { } @empty {

Nada por aquí

}
@if (typeOptions().length <= 5) { } @else { Tipo } Título Descripción @if (template(); as t) {

Plantilla de {{ typeLabel() }}: @if (t.description) { si dejas la descripción vacía se usará la de la plantilla } @if (t.description && t.checklist.length) { · } @if (t.checklist.length) { se añadirá su checklist ({{ t.checklist.length }} pasos) }

} @if (project.workflow().isDesignType(newType())) { Frame de Figma }
Prioridad Área Estimación
Asignar a Release

Guarda los filtros de ahora para volver a ellos con un clic.

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

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

Motivo
`, }) export class BoardPage { /** `?release=` (from the releases page), bound by the router. */ readonly release = input(); /** `?assignee=` (from the tracking page's team list), bound by the router. */ readonly assignee = input(); /** `?sprint=` (from the sprints page), bound by the router. */ readonly sprint = input(); protected readonly board = inject(BoardFacade); protected readonly project = inject(ProjectFacade); private readonly drawer = inject(DrawerService); private readonly toast = inject(ToastService); private readonly auth = inject(AuthFacade); private readonly me = computed(() => this.auth.user()?.id ?? null); private readonly createForm = viewChild.required>('createForm'); private readonly reasonForm = viewChild.required>('reasonForm'); private readonly viewForm = viewChild.required>('viewForm'); /* Filters */ protected readonly assigneeFilter = signal(ALL); protected readonly releaseFilter = signal(ALL); protected readonly area = signal(ALL); protected readonly typeFilter = signal(ALL); protected readonly sprintFilter = signal(ALL); protected readonly sprintFilterOptions = computed< PiSelectFieldOption[] >(() => [ { value: ALL, label: 'Todos los sprints' }, ...this.project.openSprints().map((s) => ({ value: s.id, label: s.status === 'ACTIVE' ? `${s.name} · en curso` : s.name, })), ]); protected readonly priorityFilter = signal(ALL); /** What is typed; `search` follows it once typing pauses. */ protected readonly searchInput = signal(''); private readonly search = signal(''); /** Labels only come from a saved view (no picker on the toolbar). */ private readonly labels = signal([]); protected readonly isFiltered = computed( () => !!( this.assigneeFilter() || this.releaseFilter() || this.sprintFilter() || this.area() || this.typeFilter() || this.priorityFilter() || this.search() || this.labels().length ), ); /* Swimlanes and WIP */ protected readonly laneOptions: PiSelectFieldOption[] = [ { value: 'none', label: 'Sin agrupar' }, { value: 'assignee', label: 'Agrupar por persona' }, { value: 'epic', label: 'Agrupar por épica' }, { value: 'priority', label: 'Agrupar por prioridad' }, { value: 'type', label: 'Agrupar por tipo' }, ]; protected readonly laneMode = signal(storedLaneMode()); protected readonly lanes = computed(() => swimlanes(this.board.columns(), this.laneMode(), (key) => this.laneMode() === 'priority' ? -PRIORITY_LEVEL[key as IssuePriority] : 0, ), ); protected laneLabel(key: string): string { switch (this.laneMode()) { case 'assignee': return key ? (this.project.members().find((m) => m.user.id === key)?.user.name ?? 'Antiguo miembro') : 'Sin asignar'; case 'epic': { if (!key) return 'Sin épica'; const card = this.board .columns() .flatMap((c) => c.issues) .find((i) => i.parent?.id === key); return card?.parent ? `${card.parent.key} · ${card.parent.title}` : key; } case 'priority': return PRIORITY[key as IssuePriority]?.label ?? key; case 'type': return statusLookType(this.project.workflow(), key); default: return ''; } } protected setLaneMode(mode: string | null): void { const next = (mode || 'none') as SwimlaneMode; this.laneMode.set(next); try { localStorage.setItem(LANE_KEY, next); } catch { // Private mode: the choice lasts until the page closes. } } protected wipOf = (column: BoardColumn) => this.project.workflow().wipLimit(column.status); protected isOver = (column: BoardColumn) => overWip(column.total, this.wipOf(column)); /* Saved views */ protected readonly viewName = signal(''); protected readonly viewShared = signal(false); protected readonly viewOptions = computed[]>( () => [ { value: ALL, label: 'Sin vista guardada' }, ...this.board.views().map((v) => ({ value: v.id, label: v.shared ? `${v.name} · compartida` : v.name, })), ], ); protected readonly assigneeFilterOptions = computed< PiSelectFieldOption[] >(() => [ { value: ALL, label: 'Todas las personas' }, { value: NOBODY, label: 'Sin asignar' }, ...this.project .members() .map((m) => ({ value: m.user.id, label: m.user.name })), ]); protected readonly typeFilterOptions = computed< PiSelectFieldOption[] >(() => [ { value: ALL, label: 'Todos los tipos' }, ...this.project .workflow() .settings.issueTypes.map((t) => ({ value: t.id, label: t.name })), ]); protected readonly priorityFilterOptions: PiSelectFieldOption[] = [ { value: ALL, label: 'Todas las prioridades' }, ...options(PRIORITY), ]; protected readonly assigneeOptions = computed[]>( () => [ { value: ALL, label: 'Todas las personas' }, ...this.project .members() .map((m) => ({ value: m.user.id, label: m.user.name })), ], ); protected readonly releaseOptions = computed[]>( () => [ { value: ALL, label: 'Todas las releases' }, ...this.project .releases() .filter((r) => r.status === ReleaseStatus.PLANNED) .map((r) => ({ value: r.id, label: r.name })), ], ); /** Archived areas still filter (old tickets keep them). */ protected readonly areaFilterOptions = computed< PiSelectFieldOption[] >(() => [ { value: ALL, label: 'Todas las áreas' }, ...this.project .workflow() .settings.areas.map((a) => ({ value: a.id, label: a.name })), ]); protected readonly areaOptions = computed[]>( () => [ { value: ALL, label: 'Sin área' }, ...this.project .workflow() .activeAreas() .map((a) => ({ value: a.id, label: a.name })), ], ); protected readonly priorityOptions = options(PRIORITY); protected readonly pointsOptions = pointOptions(POINT_SCALE); /** Estimated points of the cards loaded in a column. */ protected pointsOf = (column: BoardColumn) => sumPoints(column.issues); protected readonly typeOptions = computed[]>( () => this.project .workflow() .activeTypes() .map((t) => ({ value: t.id, label: t.name })), ); /* New ticket */ protected readonly newType = signal(''); /** The template the new ticket will get (the API applies it). */ protected readonly template = computed( () => this.project .workflow() .settings.issueTypes.find((t) => t.id === this.newType())?.template ?? null, ); protected readonly typeLabel = computed( () => typeLook(this.project.workflow(), this.newType()).label, ); private readonly model = signal({ title: '', description: '', figmaUrl: '', priority: IssuePriority.MEDIUM as string, area: ALL as string, storyPoints: '' 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.project.workflow().type(this.newType()).icon === 'bug' ? 'El botón de pagar no responde en Safari' : 'Qué hay que hacer', ); /* Move needing a reason */ private pendingMove: { issueId: string; to: string } | 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)); }); effect(() => { const assignee = this.assignee(); if (assignee) untracked(() => this.assigneeFilter.set(assignee)); }); effect(() => { const sprint = this.sprint(); if (sprint) untracked(() => this.sprintFilter.set(sprint)); }); effect((onCleanup) => { const text = this.searchInput().trim(); const timer = setTimeout(() => this.search.set(text), 300); onCleanup(() => clearTimeout(timer)); }); // (Re)load whenever the project or a filter changes. effect(() => { const projectId = this.project.project()?.id; const assignee = this.assigneeFilter(); const type = this.typeFilter(); const priority = this.priorityFilter() as IssuePriority | null; const filter: Omit = { unassigned: assignee === NOBODY || undefined, assigneeId: (assignee !== NOBODY && assignee) || undefined, releaseId: this.releaseFilter() || undefined, area: this.area() || undefined, types: type ? [type] : undefined, priorities: priority ? [priority] : undefined, labels: this.labels().length ? this.labels() : undefined, search: this.search() || undefined, sprintId: this.sprintFilter() || undefined, }; if (!projectId) return; untracked(() => this.board.show({ projectId, ...(Object.fromEntries( Object.entries(filter).filter(([, v]) => v !== undefined), ) as Omit), }), ); }); } protected statusLabel = (status: string) => statusLook(this.project.workflow(), status).label; protected statusTone = (status: string) => statusLook(this.project.workflow(), status).tone; /** * The ids of each row's column lists, by row ('' without swimlanes): a card * moves between the columns of its own row. Connected by id because the * lists are stamped from a template, and a template only sees the drop * groups around where it is declared, not where it is placed. */ protected readonly rowLists = computed(() => { const statuses = this.board.columns().map((c) => c.status); const rows = this.laneMode() === 'none' ? [''] : this.lanes().map((l) => l.key); return Object.fromEntries( rows.map((row) => [row, statuses.map((status) => `${row}|${status}`)]), ); }); /** Columns a dragged ticket can enter: its allowed transitions. */ protected readonly canEnter = ( drag: CdkDrag, drop: CdkDropList, ) => this.project .workflow() .allowedFrom(drag.data.status) .includes(drop.data.status); protected drop( event: CdkDragDrop, ): void { if (event.previousContainer === event.container) return; const to = event.container.data.status; const issueId = event.item.data.id; const target = this.board.columns().find((c) => c.status === to); const limit = this.project.workflow().wipLimit(to); if (target && overWip(target.total + 1, limit)) this.toast.info( `«${this.statusLabel(to)}» pasa de su límite WIP (${target.total + 1}/${limit}): mejor terminar algo antes de empezar más`, ); if (this.project.workflow().requiresReason(to)) { this.pendingMove = { issueId, to }; this.reasonModel.set({ text: '' }); this.drawer.open(`Mover a «${this.statusLabel(to)}»`, 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(); } /** Shows what a saved view shows (the toolbar picks the first of each list). */ protected applyView(id: string | null): void { const view = this.board.views().find((v) => v.id === id); if (!view) { if (!id) this.clearFilters(); return; } const projectId = this.project.project()?.id ?? ''; const filter = boardFilterOf(projectId, view.criteria); this.assigneeFilter.set( filter.unassigned ? NOBODY : (filter.assigneeId ?? ALL), ); this.releaseFilter.set(filter.releaseId ?? ALL); this.sprintFilter.set(ALL); this.area.set(filter.area ?? ALL); this.typeFilter.set(filter.types?.[0] ?? ALL); this.priorityFilter.set(filter.priorities?.[0] ?? ALL); this.labels.set(filter.labels ?? []); this.searchInput.set(filter.search ?? ''); this.search.set(filter.search ?? ''); } protected clearFilters(): void { for (const filter of [ this.assigneeFilter, this.releaseFilter, this.sprintFilter, this.area, this.typeFilter, this.priorityFilter, ]) filter.set(ALL); this.labels.set([]); this.searchInput.set(''); this.search.set(''); } protected openSaveView(): void { this.viewName.set(''); this.viewShared.set(false); this.drawer.open('Guardar vista', this.viewForm()); } protected saveView(): void { if (!this.viewName().trim()) return; this.board.saveView( this.viewName(), this.viewShared() && this.project.canContribute(), ); this.drawer.closeAll(); } protected canDeleteView(view: SavedFilter): boolean { return ( view.owner.id === this.me() || (view.shared && this.project.canManage()) ); } /** `c`: new ticket (not while typing, nor with a drawer open). */ @HostListener('document:keydown', ['$event']) protected onKey(event: KeyboardEvent): void { if ( event.key !== 'c' || event.ctrlKey || event.metaKey || event.altKey || isTyping(event.target) || !this.project.canContribute() || document.querySelector('[role="dialog"]') ) return; event.preventDefault(); this.openCreate(); } protected openCreate(): void { const types = this.typeOptions(); if (!types.some((t) => t.value === this.newType())) this.newType.set(types[0]?.value ?? ''); this.model.set({ title: '', description: '', figmaUrl: '', priority: IssuePriority.MEDIUM, area: ALL, storyPoints: '', 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.project.workflow().isDesignType(this.newType()) && v.figmaUrl ? v.figmaUrl : null, priority: v.priority as IssuePriority, area: v.area || null, storyPoints: v.storyPoints === '' ? null : Number(v.storyPoints), assigneeId: v.assigneeId || null, releaseId: v.releaseId || null, }); this.drawer.closeAll(); } }