feat: per-project workflow settings, statistics and "my work" home
Per-project settings (owners/admins, «Configuración» tab):
- Statuses with category (TODO, IN_PROGRESS, QA, DONE, CANCELLED), colour and
"requires reason"; configurable transitions; ticket types (icon, colour) and
areas. Defaults are the previous fixed values; projects created before keep
them without migration.
- Values are archived, never deleted: tickets, history and statistics keep
referring to them. Validated in the domain (ProjectSettingsChangedEvent).
- Releases, board, tracking and "my work" use categories, so custom statuses
keep working. Status changes carry name/category metadata for history.
- GraphQL: status/type/area are strings (breaking for clients sending enum
literals); Project.settings, updateProjectSettings, statusCategory,
myIssues(categories). REST: PUT /projects/{project}/settings, category filters.
- Read model: statusCategory on issues (backfilled at startup).
Statistics («Estadísticas» tab):
- New issue_timeline projection; pure functions compute created vs resolved,
cumulative flow, cycle/lead time (avg + p85), throughput per person, QA
pass/fail and release burnup. GraphQL Project.analytics(days),
Release.burnup; REST /projects/{project}/analytics, /releases/{id}/burnup.
- Web: SVG charts with validated light/dark palette, crosshair tooltip,
keyboard navigation, legend and data table.
Home "Mi trabajo": my open tickets across projects grouped by category (QA
failures first), tickets waiting for QA, upcoming releases and "Ir a…".
Tests: domain/application specs, e2e scenario for settings and analytics,
facade tests. Docs, READMEs, schema.gql/openapi.json and Word/PDF updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -36,16 +36,12 @@ import {
|
||||
} from '@task-stream/web/tracker/application';
|
||||
import {
|
||||
BoardColumn,
|
||||
IssueArea,
|
||||
IssuePriority,
|
||||
IssueStatus,
|
||||
IssueSummary,
|
||||
IssueType,
|
||||
REASON_REQUIRED,
|
||||
ReleaseStatus,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { IssueCardComponent } from '../ui/issue-card';
|
||||
import { AREA, options, PRIORITY, STATUS, TYPE } from '../ui/copy';
|
||||
import { options, PRIORITY, statusLook } from '../ui/copy';
|
||||
|
||||
const ALL = '';
|
||||
|
||||
@@ -85,7 +81,7 @@ const ALL = '';
|
||||
<pi-select-field
|
||||
class="ts-filter"
|
||||
placeholder="Área"
|
||||
[options]="areaOptions"
|
||||
[options]="areaFilterOptions()"
|
||||
[(value)]="area"
|
||||
/>
|
||||
<span class="ts-board-total"
|
||||
@@ -127,10 +123,16 @@ const ALL = '';
|
||||
[id]="column.status"
|
||||
[cdkDropListData]="column"
|
||||
[cdkDropListDisabled]="!project.canContribute()"
|
||||
[cdkDropListEnterPredicate]="canEnter"
|
||||
(cdkDropListDropped)="drop($event)"
|
||||
>
|
||||
@for (issue of column.issues; track issue.id) {
|
||||
<ts-issue-card cdkDrag [cdkDragData]="issue" [issue]="issue" />
|
||||
<ts-issue-card
|
||||
cdkDrag
|
||||
[cdkDragData]="issue"
|
||||
[issue]="issue"
|
||||
[workflow]="project.workflow()"
|
||||
/>
|
||||
} @empty {
|
||||
<p class="ts-column__empty">Nada por aquí</p>
|
||||
}
|
||||
@@ -146,11 +148,17 @@ const ALL = '';
|
||||
(submit)="$event.preventDefault(); create()"
|
||||
novalidate
|
||||
>
|
||||
<pi-segmented-control
|
||||
ariaLabel="Tipo"
|
||||
[options]="typeOptions"
|
||||
[(value)]="newType"
|
||||
/>
|
||||
@if (typeOptions().length <= 5) {
|
||||
<pi-segmented-control
|
||||
ariaLabel="Tipo"
|
||||
[options]="typeOptions()"
|
||||
[(value)]="newType"
|
||||
/>
|
||||
} @else {
|
||||
<pi-select-field [options]="typeOptions()" [(value)]="newType">
|
||||
<pi-label>Tipo</pi-label>
|
||||
</pi-select-field>
|
||||
}
|
||||
<pi-text-field [formField]="f.title" [placeholder]="titlePlaceholder()">
|
||||
<pi-label [isRequired]="true">Título</pi-label>
|
||||
</pi-text-field>
|
||||
@@ -160,7 +168,7 @@ const ALL = '';
|
||||
>
|
||||
<pi-label>Descripción</pi-label>
|
||||
</pi-textarea>
|
||||
@if (newType() === 'UI_CHANGE') {
|
||||
@if (project.workflow().isDesignType(newType())) {
|
||||
<pi-text-field
|
||||
[formField]="f.figmaUrl"
|
||||
placeholder="https://www.figma.com/design/…"
|
||||
@@ -172,7 +180,7 @@ const ALL = '';
|
||||
<pi-select-field [formField]="f.priority" [options]="priorityOptions">
|
||||
<pi-label>Prioridad</pi-label>
|
||||
</pi-select-field>
|
||||
<pi-select-field [formField]="f.area" [options]="areaOptions">
|
||||
<pi-select-field [formField]="f.area" [options]="areaOptions()">
|
||||
<pi-label>Área</pi-label>
|
||||
</pi-select-field>
|
||||
</div>
|
||||
@@ -262,16 +270,35 @@ export class BoardPage {
|
||||
.map((r) => ({ value: r.id, label: r.name })),
|
||||
],
|
||||
);
|
||||
protected readonly areaOptions: PiSelectFieldOption<string>[] = [
|
||||
/** Archived areas still filter (old tickets keep them). */
|
||||
protected readonly areaFilterOptions = computed<
|
||||
PiSelectFieldOption<string>[]
|
||||
>(() => [
|
||||
{ value: ALL, label: 'Todas las áreas' },
|
||||
...options(AREA),
|
||||
];
|
||||
...this.project
|
||||
.workflow()
|
||||
.settings.areas.map((a) => ({ value: a.id, label: a.name })),
|
||||
]);
|
||||
protected readonly areaOptions = computed<PiSelectFieldOption<string>[]>(
|
||||
() => [
|
||||
{ value: ALL, label: 'Sin área' },
|
||||
...this.project
|
||||
.workflow()
|
||||
.activeAreas()
|
||||
.map((a) => ({ value: a.id, label: a.name })),
|
||||
],
|
||||
);
|
||||
protected readonly priorityOptions = options(PRIORITY);
|
||||
protected readonly typeOptions: SegmentedControlOption<IssueType>[] =
|
||||
options(TYPE);
|
||||
protected readonly typeOptions = computed<SegmentedControlOption<string>[]>(
|
||||
() =>
|
||||
this.project
|
||||
.workflow()
|
||||
.activeTypes()
|
||||
.map((t) => ({ value: t.id, label: t.name })),
|
||||
);
|
||||
|
||||
/* New ticket */
|
||||
protected readonly newType = signal<IssueType>(IssueType.BUG);
|
||||
protected readonly newType = signal<string>('');
|
||||
private readonly model = signal({
|
||||
title: '',
|
||||
description: '',
|
||||
@@ -286,13 +313,13 @@ export class BoardPage {
|
||||
maxLength(p.title, 255);
|
||||
});
|
||||
protected readonly titlePlaceholder = computed(() =>
|
||||
this.newType() === IssueType.BUG
|
||||
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: IssueStatus } | null = null;
|
||||
private pendingMove: { issueId: string; to: string } | null = null;
|
||||
private readonly reasonModel = signal({ text: '' });
|
||||
protected readonly reason = form(this.reasonModel, (p) => required(p.text));
|
||||
|
||||
@@ -311,15 +338,27 @@ export class BoardPage {
|
||||
const filter = {
|
||||
assigneeId: this.assigneeFilter() || undefined,
|
||||
releaseId: this.releaseFilter() || undefined,
|
||||
area: (this.area() || undefined) as IssueArea | undefined,
|
||||
area: this.area() || undefined,
|
||||
};
|
||||
if (!projectId) return;
|
||||
untracked(() => this.board.show({ projectId, ...filter }));
|
||||
});
|
||||
}
|
||||
|
||||
protected statusLabel = (status: IssueStatus) => STATUS[status].label;
|
||||
protected statusTone = (status: IssueStatus) => STATUS[status].tone;
|
||||
protected statusLabel = (status: string) =>
|
||||
statusLook(this.project.workflow(), status).label;
|
||||
protected statusTone = (status: string) =>
|
||||
statusLook(this.project.workflow(), status).tone;
|
||||
|
||||
/** Columns a dragged ticket can enter: its allowed transitions. */
|
||||
protected readonly canEnter = (
|
||||
drag: CdkDrag<IssueSummary>,
|
||||
drop: CdkDropList<BoardColumn>,
|
||||
) =>
|
||||
this.project
|
||||
.workflow()
|
||||
.allowedFrom(drag.data.status)
|
||||
.includes(drop.data.status);
|
||||
|
||||
protected drop(
|
||||
event: CdkDragDrop<BoardColumn, BoardColumn, IssueSummary>,
|
||||
@@ -327,10 +366,10 @@ export class BoardPage {
|
||||
if (event.previousContainer === event.container) return;
|
||||
const to = event.container.data.status;
|
||||
const issueId = event.item.data.id;
|
||||
if (REASON_REQUIRED.includes(to)) {
|
||||
if (this.project.workflow().requiresReason(to)) {
|
||||
this.pendingMove = { issueId, to };
|
||||
this.reasonModel.set({ text: '' });
|
||||
this.drawer.open(`Mover a «${STATUS[to].label}»`, this.reasonForm());
|
||||
this.drawer.open(`Mover a «${this.statusLabel(to)}»`, this.reasonForm());
|
||||
return;
|
||||
}
|
||||
this.board.moveIssue(issueId, to);
|
||||
@@ -345,6 +384,9 @@ export class BoardPage {
|
||||
}
|
||||
|
||||
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: '',
|
||||
@@ -367,11 +409,11 @@ export class BoardPage {
|
||||
title: v.title,
|
||||
description: v.description || null,
|
||||
figmaUrl:
|
||||
this.newType() === IssueType.UI_CHANGE && v.figmaUrl
|
||||
this.project.workflow().isDesignType(this.newType()) && v.figmaUrl
|
||||
? v.figmaUrl
|
||||
: null,
|
||||
priority: v.priority as IssuePriority,
|
||||
area: (v.area || null) as IssueArea | null,
|
||||
area: v.area || null,
|
||||
assigneeId: v.assigneeId || null,
|
||||
releaseId: v.releaseId || null,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user