feat(web): printable report of a sprint or a release
/p/:key/report/(sprint|release)/🆔 what it is, its totals, the release notes and its tickets by stage. «Imprimir o guardar en PDF» opens the browser's dialog; on paper the app's header, tabs, tinted panels and lights are left out. Linked from Sprints and Releases. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,7 @@ describe('appRoutes', () => {
|
|||||||
'epics',
|
'epics',
|
||||||
'trash',
|
'trash',
|
||||||
'releases',
|
'releases',
|
||||||
|
'report/:kind/:id',
|
||||||
'dashboard',
|
'dashboard',
|
||||||
'analytics',
|
'analytics',
|
||||||
'settings',
|
'settings',
|
||||||
|
|||||||
@@ -618,3 +618,34 @@ html.dark-mode .pi-shell {
|
|||||||
background-color: var(--ts-surface-raised) !important;
|
background-color: var(--ts-surface-raised) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Printing a report (/p/:key/report/…): the report alone, on white paper.
|
||||||
|
@media print {
|
||||||
|
body:has(ts-report-page) {
|
||||||
|
background: #fff !important;
|
||||||
|
color: #000;
|
||||||
|
|
||||||
|
.ts-header,
|
||||||
|
.ts-project-header,
|
||||||
|
.ts-tabs,
|
||||||
|
.ts-main::before,
|
||||||
|
.ts-main::after {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-main {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No tinted panels or soft lights on paper.
|
||||||
|
.ts-panel {
|
||||||
|
background: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -281,7 +281,7 @@ visualización de la plataforma:
|
|||||||
## 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) |
|
||||||
| `/` | Inicio: «Ir a…» (`WEB-12`), mi trabajo (`ts-my-work`) y mis proyectos |
|
| `/` | Inicio: «Ir a…» (`WEB-12`), mi trabajo (`ts-my-work`) y mis proyectos |
|
||||||
@@ -289,6 +289,7 @@ visualización de la plataforma:
|
|||||||
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) |
|
| `/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/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) |
|
||||||
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea |
|
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea |
|
||||||
| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, equipo (cada persona enlaza a su tablero) y tipos |
|
| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, equipo (cada persona enlaza a su tablero) y tipos |
|
||||||
| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) |
|
| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) |
|
||||||
|
|||||||
@@ -30,26 +30,23 @@ cuando hay pistas, por dónde empezar.
|
|||||||
|
|
||||||
### En curso, en este orden
|
### En curso, en este orden
|
||||||
|
|
||||||
1. **Exportar e imprimir un sprint o una release**: un PDF con los tickets, su estado y las notas, para un cliente o
|
En el panel del proyecto:
|
||||||
para archivar.
|
|
||||||
|
|
||||||
Y en el panel del proyecto:
|
1. **Sprint en curso**: días que quedan, puntos hechos y pendientes, y aviso si al ritmo actual no llega.
|
||||||
|
2. **Tickets en riesgo**: vencidos, que vencen esta semana, bloqueados y atascados (días sin moverse en un estado en
|
||||||
2. **Sprint en curso**: días que quedan, puntos hechos y pendientes, y aviso si al ritmo actual no llega.
|
|
||||||
3. **Tickets en riesgo**: vencidos, que vencen esta semana, bloqueados y atascados (días sin moverse en un estado en
|
|
||||||
curso), cada grupo con enlace a la lista filtrada.
|
curso), cada grupo con enlace a la lista filtrada.
|
||||||
4. **Antigüedad de lo abierto**: cada ticket en curso por los días que lleva en su estado, con el umbral habitual del
|
3. **Antigüedad de lo abierto**: cada ticket en curso por los días que lleva en su estado, con el umbral habitual del
|
||||||
proyecto (p85 del tiempo de ciclo).
|
proyecto (p85 del tiempo de ciclo).
|
||||||
5. **Cuellos de botella**: días medios en cada estado y columnas por encima de su límite WIP.
|
4. **Cuellos de botella**: días medios en cada estado y columnas por encima de su límite WIP.
|
||||||
6. **Próximas releases con previsión**: progreso, fecha objetivo y fecha probable según el ritmo real (Monte Carlo),
|
5. **Próximas releases con previsión**: progreso, fecha objetivo y fecha probable según el ritmo real (Monte Carlo),
|
||||||
con semáforo.
|
con semáforo.
|
||||||
7. **Qué ha cambiado desde tu última visita**: nuevos, cerrados, devueltos por QA y menciones.
|
6. **Qué ha cambiado desde tu última visita**: nuevos, cerrados, devueltos por QA y menciones.
|
||||||
8. **Carga del equipo en puntos**: asignado frente a lo que cada persona suele cerrar por sprint, con aviso de
|
7. **Carga del equipo en puntos**: asignado frente a lo que cada persona suele cerrar por sprint, con aviso de
|
||||||
sobrecarga y sus bloqueados.
|
sobrecarga y sus bloqueados.
|
||||||
9. **Salud del backlog**: sin estimar, sin área, sin asignar y sin tocar en 90 días, con enlace a la lista filtrada.
|
8. **Salud del backlog**: sin estimar, sin área, sin asignar y sin tocar en 90 días, con enlace a la lista filtrada.
|
||||||
10. **Calidad**: bugs abiertos por prioridad y su tendencia, devoluciones de QA por área y bugs abiertos en los 14 días
|
9. **Calidad**: bugs abiertos por prioridad y su tendencia, devoluciones de QA por área y bugs abiertos en los 14 días
|
||||||
siguientes a cada release.
|
siguientes a cada release.
|
||||||
11. **Desglose por campo personalizado**: tickets abiertos según un campo de opciones a elegir («bugs por
|
10. **Desglose por campo personalizado**: tickets abiertos según un campo de opciones a elegir («bugs por
|
||||||
navegador»).
|
navegador»).
|
||||||
|
|
||||||
### Aceptadas, para después de esta tanda
|
### Aceptadas, para después de esta tanda
|
||||||
@@ -129,3 +126,4 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi
|
|||||||
- Plantillas de proyecto: al crear uno, «Empezar con la configuración de» otro que administras copia sus estados,
|
- Plantillas de proyecto: al crear uno, «Empezar con la configuración de» otro que administras copia sus estados,
|
||||||
transiciones, tipos, áreas, campos personalizados y automatizaciones. (El burndown y la velocidad de sprint ya
|
transiciones, tipos, áreas, campos personalizados y automatizaciones. (El burndown y la velocidad de sprint ya
|
||||||
existían, en Sprints.)
|
existían, en Sprints.)
|
||||||
|
- Informe para imprimir (o guardar en PDF) de un sprint o una release, desde Sprints y Releases.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -102,6 +102,7 @@ export interface IssueListFilter {
|
|||||||
assigneeId?: string;
|
assigneeId?: string;
|
||||||
unassigned?: boolean;
|
unassigned?: boolean;
|
||||||
releaseId?: string;
|
releaseId?: string;
|
||||||
|
sprintId?: string;
|
||||||
/** Only tickets holding these custom values. */
|
/** Only tickets holding these custom values. */
|
||||||
customFields?: CustomFieldValue[];
|
customFields?: CustomFieldValue[];
|
||||||
limit: number;
|
limit: number;
|
||||||
|
|||||||
@@ -115,6 +115,11 @@ const NO_RELEASE = '';
|
|||||||
[queryParams]="{ release: release.id }"
|
[queryParams]="{ release: release.id }"
|
||||||
>Ver en la lista →</a
|
>Ver en la lista →</a
|
||||||
>
|
>
|
||||||
|
<a
|
||||||
|
class="ts-release__link"
|
||||||
|
[routerLink]="['../report', 'release', release.id]"
|
||||||
|
>Informe para imprimir →</a
|
||||||
|
>
|
||||||
<span class="ts-spacer"></span>
|
<span class="ts-spacer"></span>
|
||||||
@if (release.notes) {
|
@if (release.notes) {
|
||||||
<pi-action-button
|
<pi-action-button
|
||||||
|
|||||||
414
libs/web/tracker/presenters/src/lib/pages/report.page.ts
Normal file
414
libs/web/tracker/presenters/src/lib/pages/report.page.ts
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
effect,
|
||||||
|
inject,
|
||||||
|
input,
|
||||||
|
untracked,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { PiNoteComponent, PiSkeletonComponent } from '@pi/ui';
|
||||||
|
import {
|
||||||
|
ListFacade,
|
||||||
|
ProjectFacade,
|
||||||
|
provideListFacade,
|
||||||
|
} from '@task-stream/web/tracker/application';
|
||||||
|
import {
|
||||||
|
IssueSummary,
|
||||||
|
ReleaseStatus,
|
||||||
|
SprintStatus,
|
||||||
|
StatusCategory,
|
||||||
|
} from '@task-stream/web/tracker/domain';
|
||||||
|
import { MarkdownComponent } from '../ui/markdown';
|
||||||
|
import { CATEGORY, statusLook, typeLook } from '../ui/copy';
|
||||||
|
|
||||||
|
/** Groups in the order work goes through them. */
|
||||||
|
const ORDER: StatusCategory[] = [
|
||||||
|
StatusCategory.DONE,
|
||||||
|
StatusCategory.QA,
|
||||||
|
StatusCategory.IN_PROGRESS,
|
||||||
|
StatusCategory.TODO,
|
||||||
|
StatusCategory.CANCELLED,
|
||||||
|
];
|
||||||
|
|
||||||
|
const day = (iso: string | null) =>
|
||||||
|
iso
|
||||||
|
? new Date(`${iso.slice(0, 10)}T00:00:00Z`).toLocaleDateString('es', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
timeZone: 'UTC',
|
||||||
|
})
|
||||||
|
: '—';
|
||||||
|
|
||||||
|
const SPRINT_STATUS: Record<SprintStatus, string> = {
|
||||||
|
[SprintStatus.PLANNED]: 'Planificado',
|
||||||
|
[SprintStatus.ACTIVE]: 'En curso',
|
||||||
|
[SprintStatus.CLOSED]: 'Cerrado',
|
||||||
|
};
|
||||||
|
const RELEASE_STATUS: Record<ReleaseStatus, string> = {
|
||||||
|
[ReleaseStatus.PLANNED]: 'Planificada',
|
||||||
|
[ReleaseStatus.RELEASED]: 'Publicada',
|
||||||
|
[ReleaseStatus.ARCHIVED]: 'Archivada',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /p/:key/report/(sprint|release)/:id — a sprint or a release on paper:
|
||||||
|
* what it is, how far it got and its tickets by stage (and, for a release,
|
||||||
|
* its notes). «Imprimir» uses the browser's dialog, which also saves a PDF;
|
||||||
|
* the app's header and tabs stay off the page (styles.scss).
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'ts-report-page',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [
|
||||||
|
RouterLink,
|
||||||
|
PiNoteComponent,
|
||||||
|
PiSkeletonComponent,
|
||||||
|
MarkdownComponent,
|
||||||
|
],
|
||||||
|
providers: [provideListFacade()],
|
||||||
|
template: `
|
||||||
|
<div class="ts-report__toolbar">
|
||||||
|
<a class="ts-plain-link" [routerLink]="['../../..', back()]">← Volver</a>
|
||||||
|
<button type="button" class="ts-primary" (click)="print()">
|
||||||
|
Imprimir o guardar en PDF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!subject()) {
|
||||||
|
@if (project.project()) {
|
||||||
|
<pi-note
|
||||||
|
gap="xs"
|
||||||
|
status="error"
|
||||||
|
appearance="fill"
|
||||||
|
type="status"
|
||||||
|
title="No existe"
|
||||||
|
[text]="
|
||||||
|
kind() === 'sprint'
|
||||||
|
? 'Ese sprint no está en este proyecto.'
|
||||||
|
: 'Esa release no está en este proyecto.'
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
} @else {
|
||||||
|
<pi-skeleton shape="block" height="16rem" />
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
@let s = subject()!;
|
||||||
|
<article class="ts-report ts-panel">
|
||||||
|
<header class="ts-report__head">
|
||||||
|
<p class="ts-report__eyebrow">
|
||||||
|
{{ project.project()?.key }} · {{ project.project()?.name }} ·
|
||||||
|
{{ kind() === 'sprint' ? 'Sprint' : 'Release' }}
|
||||||
|
</p>
|
||||||
|
<h1>{{ s.name }}</h1>
|
||||||
|
<p class="ts-report__meta">{{ s.status }} · {{ s.dates }}</p>
|
||||||
|
@if (s.about) {
|
||||||
|
<p class="ts-report__about">{{ s.about }}</p>
|
||||||
|
}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if (!list.loaded()) {
|
||||||
|
<pi-skeleton shape="block" height="8rem" />
|
||||||
|
} @else {
|
||||||
|
<dl class="ts-report__totals">
|
||||||
|
<div>
|
||||||
|
<dt>Tickets</dt>
|
||||||
|
<dd>{{ totals().tickets }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Terminados</dt>
|
||||||
|
<dd>{{ totals().done }} · {{ totals().percent }}%</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Puntos</dt>
|
||||||
|
<dd>{{ totals().donePoints }} de {{ totals().points }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Sin terminar</dt>
|
||||||
|
<dd>{{ totals().open }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
@if (s.notes) {
|
||||||
|
<section class="ts-report__notes">
|
||||||
|
<h2>Notas de la versión</h2>
|
||||||
|
<ts-markdown [text]="s.notes" />
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
@for (group of groups(); track group.category) {
|
||||||
|
<section class="ts-report__group">
|
||||||
|
<h2>
|
||||||
|
{{ group.label }}
|
||||||
|
<span class="ts-report__count">{{ group.issues.length }}</span>
|
||||||
|
</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Clave</th>
|
||||||
|
<th>Título</th>
|
||||||
|
<th>Tipo</th>
|
||||||
|
<th>Estado</th>
|
||||||
|
<th>Asignado</th>
|
||||||
|
<th class="ts-report__num">Pts</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (issue of group.issues; track issue.id) {
|
||||||
|
<tr>
|
||||||
|
<td class="ts-report__key">{{ issue.key }}</td>
|
||||||
|
<td>{{ issue.title }}</td>
|
||||||
|
<td>{{ typeOf(issue) }}</td>
|
||||||
|
<td>{{ statusOf(issue) }}</td>
|
||||||
|
<td>{{ issue.assignee?.name ?? '—' }}</td>
|
||||||
|
<td class="ts-report__num">
|
||||||
|
{{ issue.storyPoints ?? '—' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
} @empty {
|
||||||
|
<p class="ts-muted">Sin tickets.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (list.total() > list.issues().length) {
|
||||||
|
<p class="ts-muted">
|
||||||
|
Se muestran {{ list.issues().length }} de {{ list.total() }}.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
<footer class="ts-report__foot">
|
||||||
|
Generado el {{ today }} · TaskStream
|
||||||
|
</footer>
|
||||||
|
}
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
:host {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.ts-report__toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.ts-report {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.25rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
.ts-report__eyebrow {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ts-muted);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
.ts-report__meta,
|
||||||
|
.ts-report__about {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ts-ink-soft);
|
||||||
|
}
|
||||||
|
.ts-report__about {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
.ts-report__totals {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 0;
|
||||||
|
div {
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
border: 1px solid var(--ts-line);
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
}
|
||||||
|
dt {
|
||||||
|
color: var(--ts-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
dd {
|
||||||
|
margin: 0.125rem 0 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.ts-report__count {
|
||||||
|
color: var(--ts-muted);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.ts-report__group {
|
||||||
|
break-inside: auto;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
color: var(--ts-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--ts-line);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
tr {
|
||||||
|
break-inside: avoid;
|
||||||
|
}
|
||||||
|
.ts-report__key {
|
||||||
|
font-family: var(--ts-font-mono, monospace);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ts-report__num {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.ts-report__foot {
|
||||||
|
color: var(--ts-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.ts-report__totals {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
.ts-report {
|
||||||
|
padding: 1rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media print {
|
||||||
|
.ts-report__toolbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.ts-report {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
background: none;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class ReportPage {
|
||||||
|
readonly kind = input<'sprint' | 'release'>('sprint');
|
||||||
|
readonly id = input.required<string>();
|
||||||
|
protected readonly project = inject(ProjectFacade);
|
||||||
|
protected readonly list = inject(ListFacade);
|
||||||
|
protected readonly today = day(new Date().toISOString());
|
||||||
|
|
||||||
|
/** The page it came from, to go back to. */
|
||||||
|
protected readonly back = computed(() =>
|
||||||
|
this.kind() === 'sprint' ? 'sprints' : 'releases',
|
||||||
|
);
|
||||||
|
|
||||||
|
/** What the report is about, told the same way for both. */
|
||||||
|
protected readonly subject = computed(() => {
|
||||||
|
if (this.kind() === 'sprint') {
|
||||||
|
const sprint = this.project.sprints().find((s) => s.id === this.id());
|
||||||
|
return sprint
|
||||||
|
? {
|
||||||
|
name: sprint.name,
|
||||||
|
status: SPRINT_STATUS[sprint.status],
|
||||||
|
dates: `del ${day(sprint.startDate)} al ${day(sprint.endDate)}`,
|
||||||
|
about: sprint.goal,
|
||||||
|
notes: null,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
const release = this.project.releases().find((r) => r.id === this.id());
|
||||||
|
return release
|
||||||
|
? {
|
||||||
|
name: release.name,
|
||||||
|
status: RELEASE_STATUS[release.status],
|
||||||
|
dates: release.releasedAt
|
||||||
|
? `publicada el ${day(release.releasedAt)}`
|
||||||
|
: release.targetDate
|
||||||
|
? `prevista para el ${day(release.targetDate)}`
|
||||||
|
: 'sin fecha',
|
||||||
|
about: release.description,
|
||||||
|
notes: release.notes,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
});
|
||||||
|
|
||||||
|
protected readonly totals = computed(() => {
|
||||||
|
const issues = this.list.issues();
|
||||||
|
const done = issues.filter((i) => i.statusCategory === StatusCategory.DONE);
|
||||||
|
const open = issues.filter(
|
||||||
|
(i) =>
|
||||||
|
i.statusCategory !== StatusCategory.DONE &&
|
||||||
|
i.statusCategory !== StatusCategory.CANCELLED,
|
||||||
|
);
|
||||||
|
const points = (list: IssueSummary[]) =>
|
||||||
|
list.reduce((sum, i) => sum + (i.storyPoints ?? 0), 0);
|
||||||
|
const counted = issues.filter(
|
||||||
|
(i) => i.statusCategory !== StatusCategory.CANCELLED,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
tickets: issues.length,
|
||||||
|
done: done.length,
|
||||||
|
open: open.length,
|
||||||
|
percent: counted.length
|
||||||
|
? Math.round((done.length / counted.length) * 100)
|
||||||
|
: 0,
|
||||||
|
points: points(counted),
|
||||||
|
donePoints: points(done),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
protected readonly groups = computed(() =>
|
||||||
|
ORDER.map((category) => ({
|
||||||
|
category,
|
||||||
|
label: CATEGORY[category].label,
|
||||||
|
issues: this.list
|
||||||
|
.issues()
|
||||||
|
.filter((i) => i.statusCategory === category)
|
||||||
|
.sort((a, b) => a.rank.localeCompare(b.rank)),
|
||||||
|
})).filter((g) => g.issues.length),
|
||||||
|
);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
const projectId = this.project.project()?.id;
|
||||||
|
if (projectId) untracked(() => this.list.open(projectId));
|
||||||
|
});
|
||||||
|
effect(() => {
|
||||||
|
const id = this.id();
|
||||||
|
const criteria =
|
||||||
|
this.kind() === 'sprint' ? { sprintId: id } : { releaseId: id };
|
||||||
|
untracked(() => this.list.filter(criteria));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected typeOf(issue: IssueSummary): string {
|
||||||
|
return typeLook(this.project.workflow(), issue.type).label;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected statusOf(issue: IssueSummary): string {
|
||||||
|
return statusLook(this.project.workflow(), issue.status).label;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected print(): void {
|
||||||
|
window.print();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -123,6 +123,11 @@ const STATUS: Record<SprintStatus, string> = {
|
|||||||
[queryParams]="{ sprint: sprint.id }"
|
[queryParams]="{ sprint: sprint.id }"
|
||||||
>Ver en el tablero →</a
|
>Ver en el tablero →</a
|
||||||
>
|
>
|
||||||
|
<a
|
||||||
|
class="ts-plain-link"
|
||||||
|
[routerLink]="['../report', 'sprint', sprint.id]"
|
||||||
|
>Informe para imprimir →</a
|
||||||
|
>
|
||||||
<span class="ts-spacer"></span>
|
<span class="ts-spacer"></span>
|
||||||
@if (project.canManage()) {
|
@if (project.canManage()) {
|
||||||
@switch (sprint.status) {
|
@switch (sprint.status) {
|
||||||
|
|||||||
@@ -92,6 +92,12 @@ export const trackerRoutes: Routes = [
|
|||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./pages/releases.page').then((m) => m.ReleasesPage),
|
import('./pages/releases.page').then((m) => m.ReleasesPage),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'report/:kind/:id',
|
||||||
|
title: 'Informe · TaskStream',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./pages/report.page').then((m) => m.ReportPage),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'dashboard',
|
path: 'dashboard',
|
||||||
title: 'Seguimiento · TaskStream',
|
title: 'Seguimiento · TaskStream',
|
||||||
|
|||||||
Reference in New Issue
Block a user