import { ChangeDetectionStrategy, Component, computed, DestroyRef, DOCUMENT, inject, OnInit, signal, TemplateRef, viewChild, } from '@angular/core'; import { form, FormField, maxLength, pattern, required, } from '@angular/forms/signals'; import { PiActionButtonComponent, PiCheckboxComponent, PiEmptyStateComponent, PiLabelDirective, PiSelectFieldComponent, PiSkeletonComponent, PiTextareaComponent, PiTextFieldComponent, type PiSelectFieldOption, } from '@pi/ui'; import { DrawerService } from '@task-stream/web/shared/ui'; import { HomeFacade, InboxFacade, MyHomeFacade, ProjectsFacade, } from '@task-stream/web/tracker/application'; import { NotificationKind, ProjectRole, StatusCategory, } from '@task-stream/web/tracker/domain'; import { AuthFacade } from '@task-stream/web/identity/application'; import { GoToComponent } from '../ui/go-to'; import { ProjectCardComponent } from '../ui/project-card'; import { MyWorkComponent } from './my-work'; import { ActivityFeedComponent } from '../ui/home/activity-feed'; import { DaySummaryComponent } from '../ui/home/day-summary'; import { FirstStepsComponent } from '../ui/home/first-steps'; import { HomeSprintsComponent } from '../ui/home/home-sprints'; import { OnlineNowComponent } from '../ui/home/online-now'; import { ResumeComponent } from '../ui/home/resume'; import { WeekAgendaComponent } from '../ui/home/week-agenda'; import { HomeKpisComponent } from '../ui/home/kpis'; import { MyViewsComponent } from '../ui/my-views'; import { PinnedComponent } from '../ui/home/pinned'; /** How often the home page refreshes its reading while open. */ const REFRESH_MS = 60_000; @Component({ selector: 'ts-projects-page', changeDetection: ChangeDetectionStrategy.OnPush, imports: [ PiCheckboxComponent, FormField, GoToComponent, ProjectCardComponent, MyWorkComponent, ActivityFeedComponent, DaySummaryComponent, FirstStepsComponent, HomeSprintsComponent, OnlineNowComponent, ResumeComponent, WeekAgendaComponent, HomeKpisComponent, MyViewsComponent, PinnedComponent, PiActionButtonComponent, PiEmptyStateComponent, PiSkeletonComponent, PiTextFieldComponent, PiTextareaComponent, PiLabelDirective, PiSelectFieldComponent, ], template: `
@if (myHome.home(); as h) { }
@if (facade.projects().length) {

Proyectos {{ pad(facade.projects().length) }}

} @else {

Proyectos

} @if (facade.loading() && !facade.projects().length) {
@for (i of [1, 2, 3]; track i) { }
} @else if (!facade.projects().length) { } @else {
@for (project of facade.sorted(); track project.id) { }
}
@if (facade.projects().length) { @if (myHome.home(); as h) { }
@if (myHome.home(); as h) { }
@if (myHome.home()?.sprints?.length) { } @if (myHome.home(); as h) { }
}
Clave (prefijo de los tickets) Nombre Descripción @if (templateOptions().length > 1) { Empezar con la configuración de

Copia sus estados, transiciones, tipos, áreas, campos y automatizaciones. Después, cada proyecto cambia por su cuenta.

}

Cualquiera, también sin iniciar sesión, podrá verlo (solo lectura). Se puede cambiar después en Configuración.

`, 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-hint { margin: -0.5rem 0 0; color: var(--ts-muted); font-size: 0.8125rem; } .ts-home-projects { margin-bottom: 2rem; } ts-first-steps, ts-pinned, ts-home-kpis, ts-week-agenda, ts-my-views { margin-bottom: 1.25rem; } .ts-home { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); gap: 1.25rem; align-items: start; margin-bottom: 2.5rem; @media (max-width: 960px) { grid-template-columns: minmax(0, 1fr); } } .ts-home__column { display: grid; gap: 1.25rem; min-width: 0; } .ts-public-check { p { /* Under the checkbox's text, past the box. */ margin: 0 0 0 2.5rem; color: var(--ts-muted); font-size: 0.8125rem; } } .ts-projects { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 20rem), 1fr)); gap: 1.25rem; } `, }) 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>('createForm'); private readonly model = signal({ key: '', name: '', description: '', isPublic: false, template: '', }); /** Projects I own or administer: the ones whose configuration I can copy. */ protected readonly templateOptions = computed[]>( () => { const me = this.auth.user()?.id; return [ { value: '', label: 'La de por defecto' }, ...this.facade .sorted() .filter((p) => p.members.some( (m) => m.user.id === me && (m.role === ProjectRole.OWNER || m.role === ProjectRole.ADMIN), ), ) .map((p) => ({ value: p.id, label: `${p.key} · ${p.name}` })), ]; }, ); protected readonly f = form(this.model, (p) => { required(p.key); pattern(p.key, /^[A-Za-z][A-Za-z0-9]{1,9}$/, { message: '2-10 letras/números, empezando por letra', }); required(p.name); maxLength(p.name, 120); }); protected readonly pad = (n: number) => String(n).padStart(2, '0'); /* The home reading: agenda, activity, the week… refreshed while open. */ protected readonly myHome = inject(MyHomeFacade); protected readonly home = inject(HomeFacade); private readonly inbox = inject(InboxFacade); private readonly document = inject(DOCUMENT); private readonly destroyRef = inject(DestroyRef); private readonly todayStr = new Date().toISOString().slice(0, 10); private readonly openMine = computed(() => this.home .mine() .filter( (i) => i.statusCategory !== StatusCategory.DONE && i.statusCategory !== StatusCategory.CANCELLED, ), ); protected readonly dueToday = computed( () => this.openMine().filter((i) => i.dueDate?.slice(0, 10) === this.todayStr) .length, ); protected readonly overdue = computed( () => this.openMine().filter( (i) => !!i.dueDate && i.dueDate.slice(0, 10) < this.todayStr, ).length, ); protected readonly unreadMentions = computed( () => this.inbox .items() .filter((n) => n.kind === NotificationKind.MENTIONED && !n.readAt) .length, ); protected readonly projectKeys = computed( () => new Map(this.facade.projects().map((p) => [p.id, p.key])), ); protected readonly statsOf = computed( () => new Map( (this.myHome.home()?.projects ?? []).map((p) => [p.projectId, p]), ), ); ngOnInit(): void { this.facade.load(); this.myHome.refresh(); const timer = setInterval(() => this.myHome.refresh(), REFRESH_MS); const onVisible = () => { if (this.document.visibilityState === 'visible') this.myHome.refresh(); }; this.document.addEventListener('visibilitychange', onVisible); this.destroyRef.onDestroy(() => { clearInterval(timer); this.document.removeEventListener('visibilitychange', onVisible); }); } protected openCreate(): void { this.f().reset({ key: '', name: '', description: '', isPublic: false, template: '', }); this.drawer.open('Nuevo proyecto', this.createForm()); } protected create(): void { if (this.f().invalid()) return; const { key, name, description, isPublic, template } = this.model(); this.facade.create({ key: key.toUpperCase(), name, description: description || null, isPublic, templateProjectId: template || null, }); this.drawer.closeAll(); } }