feat(web): team list on the tracking dashboard

"¿Quién está con qué?" only listed people with open tickets. The tracking
page now shows the whole team: every member with their role and open
tickets (0 included), busiest first, plus former members who still have
tickets and the unassigned count. Each person links to the board filtered
by them: the board accepts ?assignee= like ?release=.

Role colours move to ui/copy.ts (shared with the members page). Docs and
Word/PDF updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 16:14:46 +02:00
parent 6c3550ae9d
commit 240ea9a539
8 changed files with 201 additions and 54 deletions

View File

@@ -74,7 +74,7 @@ const ALL = '';
class="ts-filter"
placeholder="Persona"
[options]="assigneeOptions()"
[(value)]="assignee"
[(value)]="assigneeFilter"
/>
<pi-select-field
class="ts-filter"
@@ -229,6 +229,8 @@ const ALL = '';
export class BoardPage {
/** `?release=` (from the releases page), bound by the router. */
readonly release = input<string | undefined>();
/** `?assignee=` (from the tracking page's team list), bound by the router. */
readonly assignee = input<string | undefined>();
protected readonly board = inject(BoardFacade);
protected readonly project = inject(ProjectFacade);
@@ -239,7 +241,7 @@ export class BoardPage {
viewChild.required<TemplateRef<unknown>>('reasonForm');
/* Filters */
protected readonly assignee = signal<string | null>(ALL);
protected readonly assigneeFilter = signal<string | null>(ALL);
protected readonly releaseFilter = signal<string | null>(ALL);
protected readonly area = signal<string | null>(ALL);
@@ -299,11 +301,15 @@ export class BoardPage {
const release = this.release();
if (release) untracked(() => this.releaseFilter.set(release));
});
effect(() => {
const assignee = this.assignee();
if (assignee) untracked(() => this.assigneeFilter.set(assignee));
});
// (Re)load whenever the project or a filter changes.
effect(() => {
const projectId = this.project.project()?.id;
const filter = {
assigneeId: this.assignee() || undefined,
assigneeId: this.assigneeFilter() || undefined,
releaseId: this.releaseFilter() || undefined,
area: (this.area() || undefined) as IssueArea | undefined,
};

View File

@@ -4,20 +4,22 @@ import {
computed,
inject,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { PiSkeletonComponent, PiStatCardComponent } from '@pi/ui';
import { ProjectFacade } from '@task-stream/web/tracker/application';
import {
BOARD_COLUMNS,
IssueStatus,
IssueType,
ProjectRole,
} from '@task-stream/web/tracker/domain';
import { initials, STATUS, TYPE } from '../ui/copy';
import { initials, ROLE, ROLE_TONE, STATUS, TYPE } from '../ui/copy';
/** "Seguimiento": the state of the whole project at a glance (live). */
@Component({
selector: 'ts-dashboard-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiSkeletonComponent, PiStatCardComponent],
imports: [RouterLink, PiSkeletonComponent, PiStatCardComponent],
template: `
@if (facade.stats(); as stats) {
<section class="ts-kpis" aria-label="Resumen">
@@ -66,28 +68,70 @@ import { initials, STATUS, TYPE } from '../ui/copy';
</section>
<div class="ts-dashboard">
<section class="ts-panel" aria-labelledby="load-title">
<section class="ts-panel" aria-labelledby="team-title">
<header class="ts-panel__head">
<h2 id="load-title">¿Quién está con qué?</h2>
<span class="ts-muted">Tickets abiertos</span>
<h2 id="team-title">Equipo</h2>
<span class="ts-muted"
>{{ team().length }}
{{ team().length === 1 ? 'persona' : 'personas' }} · tickets
abiertos</span
>
</header>
<ul class="ts-rows">
@for (row of stats.byAssignee; track row.user?.id ?? 'none') {
<ul class="ts-team">
@for (person of team(); track person.id) {
<li>
@if (row.user; as user) {
<span class="ts-who">{{ initialsOf(user.name) }}</span>
<span class="ts-rows__name">{{ user.name }}</span>
} @else {
<span class="ts-who ts-who--none">—</span>
<span class="ts-rows__name ts-muted">Sin asignar</span>
}
<span class="ts-bar"
><span [style.width.%]="(row.count / maxLoad()) * 100"></span
></span>
<span class="ts-rows__count">{{ row.count }}</span>
<a
class="ts-team__row"
[routerLink]="['../board']"
[queryParams]="{ assignee: person.id }"
[attr.aria-label]="
person.name +
': ' +
person.open +
' tickets abiertos. Ver su tablero'
"
>
<span class="ts-who" aria-hidden="true">{{
initialsOf(person.name)
}}</span>
<span class="ts-team__who">
<span class="ts-rows__name">{{ person.name }}</span>
@if (person.role; as role) {
<span class="ts-pill" [style.--tone]="roleTone(role)">{{
roleLabel(role)
}}</span>
} @else {
<span class="ts-pill">Ya no es miembro</span>
}
</span>
<span class="ts-bar" aria-hidden="true"
><span
[style.width.%]="(person.open / maxLoad()) * 100"
></span
></span>
<span class="ts-rows__count" [class.is-zero]="!person.open">{{
person.open
}}</span>
<span class="ts-team__go" aria-hidden="true">→</span>
</a>
</li>
}
@if (unassigned()) {
<li>
<div class="ts-team__row ts-team__row--static">
<span class="ts-who ts-who--none" aria-hidden="true">—</span>
<span class="ts-team__who">
<span class="ts-rows__name ts-muted">Sin asignar</span>
</span>
<span class="ts-bar" aria-hidden="true"
><span
[style.width.%]="(unassigned() / maxLoad()) * 100"
></span
></span>
<span class="ts-rows__count">{{ unassigned() }}</span>
<span class="ts-team__go"></span>
</div>
</li>
} @empty {
<li class="ts-muted">Nada abierto.</li>
}
</ul>
</section>
@@ -239,6 +283,68 @@ import { initials, STATUS, TYPE } from '../ui/copy';
border: 1px dashed var(--ts-line-strong);
color: var(--ts-muted);
}
/* Team: everyone in the project, busiest first; each row opens their board. */
.ts-team {
display: grid;
margin: 0 -0.5rem;
padding: 0;
list-style: none;
li {
margin: 0;
}
}
.ts-team__row {
display: grid;
grid-template-columns: 1.75rem minmax(0, 1fr) minmax(4rem, 8rem) 2rem 1rem;
align-items: center;
gap: 0.75rem;
min-height: 2.75rem;
padding: 0.375rem 0.5rem;
border-radius: 0.75rem;
color: inherit;
font-size: 0.875rem;
text-decoration: none;
transition: background-color 0.15s ease;
}
a.ts-team__row:hover {
background: var(--pi-color-primary-25);
.ts-team__go {
opacity: 1;
transform: none;
}
}
a.ts-team__row:focus-visible {
outline: 2px solid var(--ts-brand);
outline-offset: 1px;
}
.ts-team__who {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.25rem 0.5rem;
min-width: 0;
}
.ts-team__go {
color: var(--ts-brand);
opacity: 0;
transform: translateX(-3px);
transition:
opacity 0.15s ease,
transform 0.15s ease;
}
@media (max-width: 520px) {
/* On a phone the number says enough: give the room to name and role. */
.ts-team__row {
grid-template-columns: 1.75rem minmax(0, 1fr) 2rem 1rem;
}
.ts-team__row .ts-bar {
display: none;
}
}
.ts-rows__count.is-zero {
color: var(--ts-muted);
font-weight: 400;
}
.ts-bar {
height: 0.625rem;
border-radius: 0.25rem;
@@ -330,6 +436,40 @@ export class DashboardPage {
...(this.facade.stats()?.byAssignee.map((r) => r.count) ?? [1]),
),
);
/**
* Every member with their open tickets (0 included), busiest first. People
* who still have tickets but left the project are listed too.
*/
protected readonly team = computed(() => {
const load = new Map<string, { name: string; count: number }>();
for (const row of this.facade.stats()?.byAssignee ?? []) {
if (row.user)
load.set(row.user.id, { name: row.user.name, count: row.count });
}
const members = this.facade.members().map((member) => ({
id: member.user.id,
name: member.user.name,
role: member.role as ProjectRole | null,
open: load.get(member.user.id)?.count ?? 0,
}));
const former = [...load.entries()]
.filter(([id]) => !members.some((m) => m.id === id))
.map(([id, { name, count }]) => ({
id,
name,
role: null,
open: count,
}));
return [...members, ...former].sort(
(a, b) => b.open - a.open || a.name.localeCompare(b.name, 'es'),
);
});
protected readonly unassigned = computed(
() => this.facade.stats()?.byAssignee.find((row) => !row.user)?.count ?? 0,
);
protected readonly roleLabel = (role: ProjectRole) => ROLE[role];
protected readonly roleTone = (role: ProjectRole) => ROLE_TONE[role];
protected readonly typeOf = (key: string) =>
TYPE[key as IssueType] ?? { label: key, tone: 'var(--ts-muted)' };
}

View File

@@ -16,14 +16,7 @@ import {
import { AuthFacade } from '@task-stream/web/identity/application';
import { ProjectFacade } from '@task-stream/web/tracker/application';
import { ProjectMember, ProjectRole } from '@task-stream/web/tracker/domain';
import { initials, ROLE } from '../ui/copy';
const ROLE_TONE: Record<ProjectRole, string> = {
[ProjectRole.OWNER]: 'var(--ts-brand)',
[ProjectRole.ADMIN]: 'var(--ts-s-review)',
[ProjectRole.MEMBER]: 'var(--ts-s-todo)',
[ProjectRole.VIEWER]: 'var(--ts-s-cancelled)',
};
import { initials, ROLE, ROLE_TONE } from '../ui/copy';
/** Team of the project. Users come from Pireon. */
@Component({