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:
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
OnInit,
|
||||
signal,
|
||||
@@ -25,7 +26,10 @@ import {
|
||||
} from '@pi/ui';
|
||||
import { DrawerService } from '@task-stream/web/shared/ui';
|
||||
import { ProjectsFacade } from '@task-stream/web/tracker/application';
|
||||
import { AuthFacade } from '@task-stream/web/identity/application';
|
||||
import { AvatarsComponent } from '../ui/avatars';
|
||||
import { GoToComponent } from '../ui/go-to';
|
||||
import { MyWorkComponent } from './my-work';
|
||||
import { projectTone, timeAgo } from '../ui/copy';
|
||||
|
||||
@Component({
|
||||
@@ -35,6 +39,8 @@ import { projectTone, timeAgo } from '../ui/copy';
|
||||
RouterLink,
|
||||
FormField,
|
||||
AvatarsComponent,
|
||||
GoToComponent,
|
||||
MyWorkComponent,
|
||||
PiActionButtonComponent,
|
||||
PiEmptyStateComponent,
|
||||
PiSkeletonComponent,
|
||||
@@ -45,20 +51,35 @@ import { projectTone, timeAgo } from '../ui/copy';
|
||||
template: `
|
||||
<header class="ts-page-header">
|
||||
<div>
|
||||
<p class="ts-eyebrow">
|
||||
Tus proyectos ·
|
||||
<span class="ts-mono">{{ pad(facade.projects().length) }}</span>
|
||||
</p>
|
||||
<h1>Proyectos</h1>
|
||||
<p>Los proyectos de los que formas parte, al día en tiempo real.</p>
|
||||
<p class="ts-eyebrow">Pireon · TaskStream</p>
|
||||
<h1>
|
||||
Hola,
|
||||
<span class="ts-gradient-text">{{ firstName() }}</span>
|
||||
</h1>
|
||||
<p>Lo tuyo en todos tus proyectos, al día en tiempo real.</p>
|
||||
</div>
|
||||
<div class="ts-home-actions">
|
||||
<ts-go-to />
|
||||
<pi-action-button
|
||||
color="primary"
|
||||
text="Nuevo proyecto"
|
||||
(execute)="openCreate()"
|
||||
/>
|
||||
</div>
|
||||
<pi-action-button
|
||||
color="primary"
|
||||
text="Nuevo proyecto"
|
||||
(execute)="openCreate()"
|
||||
/>
|
||||
</header>
|
||||
|
||||
@if (facade.projects().length) {
|
||||
<ts-my-work />
|
||||
<div class="ts-section-header">
|
||||
<h2>
|
||||
Proyectos
|
||||
<span class="ts-mono ts-muted">{{
|
||||
pad(facade.projects().length)
|
||||
}}</span>
|
||||
</h2>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (facade.loading() && !facade.projects().length) {
|
||||
<div class="ts-projects">
|
||||
@for (i of [1, 2, 3]; track i) {
|
||||
@@ -141,6 +162,27 @@ import { projectTone, timeAgo } from '../ui/copy';
|
||||
</ng-template>
|
||||
`,
|
||||
styles: `
|
||||
.ts-home-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
@media (max-width: 640px) {
|
||||
width: 100%;
|
||||
ts-go-to {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
.ts-section-header h2 {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.625rem;
|
||||
.ts-mono {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
.ts-projects {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 20rem), 1fr));
|
||||
@@ -216,6 +258,10 @@ import { projectTone, timeAgo } from '../ui/copy';
|
||||
})
|
||||
export class ProjectsPage implements OnInit {
|
||||
protected readonly facade = inject(ProjectsFacade);
|
||||
private readonly auth = inject(AuthFacade);
|
||||
protected readonly firstName = computed(
|
||||
() => this.auth.user()?.name.split(/\s+/)[0] ?? '',
|
||||
);
|
||||
private readonly drawer = inject(DrawerService);
|
||||
private readonly createForm =
|
||||
viewChild.required<TemplateRef<unknown>>('createForm');
|
||||
|
||||
Reference in New Issue
Block a user