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

@@ -2,18 +2,18 @@
## Qué resuelve ## Qué resuelve
| Necesidad | Cómo se resuelve | | Necesidad | Cómo se resuelve |
| ---------------- | ----------------------------------------------------------------------------------------------------- | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas | | 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas |
| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) | | 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) |
| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo | | ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo |
| 🧪 QA | Estados `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo | | 🧪 QA | Estados `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo |
| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) | | 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona | | 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket; el autor edita, el admin modera | | 💬 Comentarios | Decisiones y explicaciones dentro del ticket; el autor edita, el admin modera |
| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) | | 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) |
| 📊 Seguimiento | Panel con contadores, gráfico por estado, carga por persona, feed de actividad e historial por ticket | | 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket |
| ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) | | ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) |
Los **usuarios no se gestionan aquí**: vienen del servidor de identidad **Pireon** Los **usuarios no se gestionan aquí**: vienen del servidor de identidad **Pireon**
([Swagger](https://oauth.pireon.dev/api/documentation)). TaskStream gestiona proyectos, membresías y roles por ([Swagger](https://oauth.pireon.dev/api/documentation)). TaskStream gestiona proyectos, membresías y roles por

View File

@@ -195,17 +195,17 @@ globales como `.ts-pill` o `.ts-key` leen esa variable. Las animaciones se desac
## 4.6 Rutas ## 4.6 Rutas
| Ruta | Página | | Ruta | Página |
| -------------------------- | ----------------------------------------------- | | -------------------------- | ---------------------------------------------------------------------------------------- |
| `/login` | Inicio de sesión (solo sin sesión) | | `/login` | Inicio de sesión (solo sin sesión) |
| `/auth/callback` | Retorno de Pireon (PKCE) | | `/auth/callback` | Retorno de Pireon (PKCE) |
| `/` | Mis proyectos | | `/` | Mis proyectos |
| `/p/:key/board` | Tablero kanban (`?release=` filtra por release) | | `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) |
| `/p/:key/issues/:issueKey` | Ticket | | `/p/:key/issues/:issueKey` | Ticket |
| `/p/:key/releases` | Releases | | `/p/:key/releases` | Releases |
| `/p/:key/dashboard` | Seguimiento | | `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, equipo (cada persona enlaza a su tablero) y tipos |
| `/p/:key/activity` | Actividad en vivo | | `/p/:key/activity` | Actividad en vivo |
| `/p/:key/members` | Miembros | | `/p/:key/members` | Miembros |
Todo salvo `/login` y `/auth/callback` está protegido por `authGuard`. Los parámetros de ruta llegan como inputs de Todo salvo `/login` y `/auth/callback` está protegido por `authGuard`. Los parámetros de ruta llegan como inputs de
los componentes (`withComponentInputBinding`). los componentes (`withComponentInputBinding`).

Binary file not shown.

Binary file not shown.

View File

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

View File

@@ -4,20 +4,22 @@ import {
computed, computed,
inject, inject,
} from '@angular/core'; } from '@angular/core';
import { RouterLink } from '@angular/router';
import { PiSkeletonComponent, PiStatCardComponent } from '@pi/ui'; import { PiSkeletonComponent, PiStatCardComponent } from '@pi/ui';
import { ProjectFacade } from '@task-stream/web/tracker/application'; import { ProjectFacade } from '@task-stream/web/tracker/application';
import { import {
BOARD_COLUMNS, BOARD_COLUMNS,
IssueStatus, IssueStatus,
IssueType, IssueType,
ProjectRole,
} from '@task-stream/web/tracker/domain'; } 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). */ /** "Seguimiento": the state of the whole project at a glance (live). */
@Component({ @Component({
selector: 'ts-dashboard-page', selector: 'ts-dashboard-page',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiSkeletonComponent, PiStatCardComponent], imports: [RouterLink, PiSkeletonComponent, PiStatCardComponent],
template: ` template: `
@if (facade.stats(); as stats) { @if (facade.stats(); as stats) {
<section class="ts-kpis" aria-label="Resumen"> <section class="ts-kpis" aria-label="Resumen">
@@ -66,28 +68,70 @@ import { initials, STATUS, TYPE } from '../ui/copy';
</section> </section>
<div class="ts-dashboard"> <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"> <header class="ts-panel__head">
<h2 id="load-title">¿Quién está con qué?</h2> <h2 id="team-title">Equipo</h2>
<span class="ts-muted">Tickets abiertos</span> <span class="ts-muted"
>{{ team().length }}
{{ team().length === 1 ? 'persona' : 'personas' }} · tickets
abiertos</span
>
</header> </header>
<ul class="ts-rows"> <ul class="ts-team">
@for (row of stats.byAssignee; track row.user?.id ?? 'none') { @for (person of team(); track person.id) {
<li> <li>
@if (row.user; as user) { <a
<span class="ts-who">{{ initialsOf(user.name) }}</span> class="ts-team__row"
<span class="ts-rows__name">{{ user.name }}</span> [routerLink]="['../board']"
} @else { [queryParams]="{ assignee: person.id }"
<span class="ts-who ts-who--none">—</span> [attr.aria-label]="
<span class="ts-rows__name ts-muted">Sin asignar</span> person.name +
} ': ' +
<span class="ts-bar" person.open +
><span [style.width.%]="(row.count / maxLoad()) * 100"></span ' tickets abiertos. Ver su tablero'
></span> "
<span class="ts-rows__count">{{ row.count }}</span> >
<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> </li>
} @empty {
<li class="ts-muted">Nada abierto.</li>
} }
</ul> </ul>
</section> </section>
@@ -239,6 +283,68 @@ import { initials, STATUS, TYPE } from '../ui/copy';
border: 1px dashed var(--ts-line-strong); border: 1px dashed var(--ts-line-strong);
color: var(--ts-muted); 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 { .ts-bar {
height: 0.625rem; height: 0.625rem;
border-radius: 0.25rem; border-radius: 0.25rem;
@@ -330,6 +436,40 @@ export class DashboardPage {
...(this.facade.stats()?.byAssignee.map((r) => r.count) ?? [1]), ...(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) => protected readonly typeOf = (key: string) =>
TYPE[key as IssueType] ?? { label: key, tone: 'var(--ts-muted)' }; 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 { AuthFacade } from '@task-stream/web/identity/application';
import { ProjectFacade } from '@task-stream/web/tracker/application'; import { ProjectFacade } from '@task-stream/web/tracker/application';
import { ProjectMember, ProjectRole } from '@task-stream/web/tracker/domain'; import { ProjectMember, ProjectRole } from '@task-stream/web/tracker/domain';
import { initials, ROLE } from '../ui/copy'; import { initials, ROLE, ROLE_TONE } 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)',
};
/** Team of the project. Users come from Pireon. */ /** Team of the project. Users come from Pireon. */
@Component({ @Component({

View File

@@ -173,6 +173,14 @@ export const LINK: Record<IssueLinkType, { outward: string; inward: string }> =
}, },
}; };
/** Colour of each project role (pills in members and tracking). */
export 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)',
};
export const ROLE: Record<ProjectRole, string> = { export const ROLE: Record<ProjectRole, string> = {
[ProjectRole.OWNER]: 'Propietario', [ProjectRole.OWNER]: 'Propietario',
[ProjectRole.ADMIN]: 'Admin', [ProjectRole.ADMIN]: 'Admin',