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

@@ -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)' };
}