import { RouterLink } from '@angular/router'; import { CdkDrag, CdkDragDrop, CdkDragHandle, CdkDropList, moveItemInArray, } from '@angular/cdk/drag-drop'; import { ChangeDetectionStrategy, Component, computed, effect, inject, signal, untracked, } from '@angular/core'; import { NgTemplateOutlet } from '@angular/common'; import { PiCheckboxComponent, PiIconComponent, PiNoteComponent } from '@pi/ui'; import { GitIntegrationComponent } from '../ui/git-integration'; import { WebhooksComponent } from '../ui/webhooks'; import { TransferComponent } from '../ui/transfer'; import { AutomationsComponent } from '../ui/automations'; import { ProjectFacade } from '@task-stream/web/tracker/application'; import { CustomFieldType, IssueTypeOption, ProjectSettings, SETTING_COLORS, StatusCategory, TYPE_ICONS, } from '@task-stream/web/tracker/domain'; import { CATEGORY, COLOR_LABEL, ICON_LABEL, tone } from '../ui/copy'; type Section = 'statuses' | 'issueTypes' | 'areas' | 'customFields'; const FIELD_TYPES: { id: CustomFieldType; label: string }[] = [ { id: CustomFieldType.TEXT, label: 'Texto' }, { id: CustomFieldType.NUMBER, label: 'Número' }, { id: CustomFieldType.DATE, label: 'Fecha' }, { id: CustomFieldType.SELECT, label: 'Lista de opciones' }, ]; const CATEGORIES = Object.values(StatusCategory); /** "Datos y análisis" → "DATOS_Y_ANALISIS": ids of new values (they never change after saving). */ function idFrom(name: string): string { const slug = name .normalize('NFD') .replace(/[̀-ͯ]/g, '') .toUpperCase() .replace(/[^A-Z0-9]+/g, '_') .replace(/^_+|_+$/g, '') .replace(/^[^A-Z]+/, '') .slice(0, 30); return slug || 'NUEVO'; } /** * "Configuración" (owners and admins): the project's statuses, how tickets * move between them, ticket types and areas. Edits a local draft, saved in * one go; values can be archived but not removed once saved. */ @Component({ selector: 'ts-settings-page', changeDetection: ChangeDetectionStrategy.OnPush, imports: [ PiCheckboxComponent, RouterLink, GitIntegrationComponent, WebhooksComponent, TransferComponent, AutomationsComponent, CdkDropList, CdkDrag, CdkDragHandle, NgTemplateOutlet, PiIconComponent, PiNoteComponent, ], template: `

Configuración

Estados, transiciones, tipos de ticket, áreas y campos de este proyecto. Lo que ya se usa no se borra: se archiva y deja de ofrecerse.

@if (!facade.canManage()) { } @if (facade.project(); as project) {

Visibilidad

@if (project.isPublic) { Público: cualquiera, también sin iniciar sesión, ve el tablero, los tickets, los comentarios, las releases, la actividad y las estadísticas (en la web, por GraphQL y por REST). Solo los miembros pueden cambiar algo. } @else { Privado: solo los miembros del proyecto lo ven. }

Propuestas del público

@if (!project.isPublic) { Solo para proyectos públicos: hazlo público para abrir un formulario donde cualquiera, sin cuenta, reporte un error o proponga una idea. } @else if (project.acceptsProposals) { Abierto: cualquiera puede enviar un error o una idea desde el formulario. Llegan como tickets con la etiqueta «propuesta», y se pueden votar. } @else { Cerrado. Ábrelo para que cualquiera, sin cuenta, pueda reportar un error o proponer una idea; llegarán como tickets con la etiqueta «propuesta». }

} @if (draft(); as d) {

Estados

En el orden de las columnas del tablero. La categoría dice qué significa cada uno para releases, «Mi trabajo» y estadísticas.

    @for (status of d.statuses; track $index; let i = $index) {
  1. {{ status.id }} Pide motivo
  2. }

Transiciones

A qué estados puede pasar un ticket desde cada uno (fila → columna). Los archivados no reciben tickets.

@for (to of activeStatuses(); track to.id) { } @for (from of d.statuses; track from.id) { @for (to of activeStatuses(); track to.id) { } }
Desde {{ to.name }}
{{ from.name }} @if (from.id !== to.id) { {{ from.name }} → {{ to.name }} }

Tipos de ticket

Los de icono de paleta piden un frame de Figma.

    @for (type of d.issueTypes; track $index; let i = $index) {
  1. {{ type.id }} @if (openTemplate() === i) {
    }
  2. }

Áreas

Equipo o capa a la que va el ticket.

    @for (area of d.areas; track $index; let i = $index) {
  1. {{ area.id }}
  2. }

Campos personalizados

Datos propios de este proyecto en cada ticket (navegador, cliente, coste…). El tipo no cambia una vez guardado.

@if (!d.customFields.length) {

Ningún campo todavía.

}
    @for (field of d.customFields; track $index; let i = $index) {
  1. {{ field.id }} @if (field.type === 'SELECT') { }
  2. }
@if (facade.canManage() && (dirty() || facade.savingSettings())) {
@if (problems().length) {
    @for (problem of problems(); track problem) {
  • {{ problem }}
  • }
} @else { Cambios sin guardar }
}
@if (facade.canManage() && facade.project(); as project) { } @if (facade.isOwner() && facade.project(); as project) {

Eliminar el proyecto

Se borran sus tickets, comentarios y releases, y los enlaces que tickets de otros proyectos tengan hacia los suyos. La clave {{ project.key }} queda libre. No se puede deshacer.

} } @if (isNew(section, item.id)) { } @else if (item.archived) { } @else { } `, styles: ` :host { display: grid; gap: 1rem; } .ts-block.ts-visibility, .ts-block.ts-danger { display: flex; align-items: center; justify-content: space-between; gap: 1.5rem; flex-wrap: wrap; > div { flex: 1 1 26rem; } p { max-width: 46rem; margin: 0.25rem 0 0; font-size: 0.8125rem; } } .ts-switch { display: inline-flex; align-items: center; gap: 0.625rem; font-size: 0.875rem; font-weight: 600; cursor: pointer; input { position: absolute; opacity: 0; width: 1px; height: 1px; } input:focus-visible + .ts-switch__track { outline: 2px solid var(--ts-brand); outline-offset: 2px; } input:disabled ~ * { opacity: 0.6; cursor: default; } } .ts-switch__track { position: relative; width: 2.5rem; height: 1.375rem; border-radius: 999px; background: var(--ts-line-strong); transition: background-color 0.15s ease; &::after { content: ''; position: absolute; top: 3px; left: 3px; width: 1rem; height: 1rem; border-radius: 50%; background: var(--ts-surface); box-shadow: var(--ts-shadow); transition: transform 0.15s ease; } } .ts-switch input:checked + .ts-switch__track { background: var(--ts-brand); &::after { transform: translateX(1.125rem); } } .ts-danger { border: 1px solid color-mix(in srgb, var(--ts-c-red) 45%, transparent); h3 { color: var(--ts-c-red); } } .ts-danger__confirm { display: flex; align-items: flex-end; gap: 0.75rem; flex-wrap: wrap; } .ts-danger__label { display: grid; gap: 0.375rem; font-size: 0.8125rem; color: var(--ts-ink-soft); code { font-family: var(--ts-font-mono); font-weight: 600; } .ts-input { width: 12rem; } } .ts-delete { min-width: 0 !important; height: 2.125rem; padding: 0 1rem !important; border: 0 !important; border-radius: 999px !important; background: var(--ts-c-red) !important; color: #fff !important; font: inherit; font-size: 0.8125rem; font-weight: 600; cursor: pointer; &:disabled { opacity: 0.45; cursor: default; } } .ts-settings { display: grid; gap: 1rem; padding-bottom: 0; } fieldset { display: grid; gap: 1rem; min-width: 0; margin: 0; padding: 0; border: 0; } .ts-settings__pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 26rem), 1fr)); gap: 1rem; align-items: start; } .ts-block { display: grid; gap: 0.75rem; min-width: 0; padding: 1.25rem; h3 { margin: 0; font-size: 1rem; font-weight: 700; } } .ts-block__head { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; flex-wrap: wrap; p { margin: 0.25rem 0 0; font-size: 0.8125rem; } } .ts-rows { display: grid; gap: 0.375rem; margin: 0; padding: 0; list-style: none; } .ts-rows-head { padding: 0 0.5rem; font-size: 0.6875rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ts-muted); } .ts-status-grid { display: grid; grid-template-columns: 1.25rem minmax(10rem, 1fr) 9rem 8rem 5.5rem 5.5rem 6.5rem; align-items: center; gap: 0.5rem; } .ts-template { display: grid; grid-column: 1 / -1; gap: 0.5rem; padding: 0.5rem 0 0.25rem; } .ts-template__field { display: grid; gap: 0.25rem; font-size: 0.8125rem; color: var(--ts-muted); textarea { font: inherit; color: var(--ts-ink); resize: vertical; } } .ts-template-toggle { white-space: nowrap; &.has-template { border-color: var(--ts-brand) !important; color: var(--ts-ink) !important; } } .ts-type-grid { display: grid; grid-template-columns: 2rem minmax(8rem, 1fr) 7rem 7rem auto 6.5rem; align-items: center; gap: 0.5rem; } .ts-area-grid { display: grid; grid-template-columns: minmax(8rem, 1fr) 6.5rem; align-items: center; gap: 0.5rem; } /* Name and archive on top; the type (and options) below. */ .ts-field-grid { display: grid; grid-template-columns: minmax(8rem, 1fr) 6.5rem; align-items: center; gap: 0.5rem; .ts-field-type { grid-row: 2; grid-column: 1 / -1; } } .ts-field-options { grid-column: 1 / -1; display: grid; gap: 0.25rem; font-size: 0.8125rem; color: var(--ts-muted); textarea { font: inherit; color: var(--ts-ink); background: var(--ts-surface); border: 1px solid var(--ts-line); border-radius: 0.5rem; padding: 0.375rem 0.5rem; resize: vertical; } } .ts-empty-fields { margin: 0; } .ts-row { --tone: var(--ts-muted); padding: 0.375rem 0.5rem; border-radius: 0.625rem; background: var(--ts-surface); border: 1px solid var(--ts-line); &.is-archived { background: var(--ts-sunken); .ts-input, .ts-type-icon { opacity: 0.6; } } } .cdk-drag-preview { box-shadow: var(--ts-lift); } .cdk-drag-placeholder { opacity: 0.35; } .ts-grip { cursor: grab; color: var(--ts-muted); text-align: center; user-select: none; } .ts-name { position: relative; display: flex; align-items: center; gap: 0.5rem; min-width: 0; .ts-input { flex: 1; min-width: 0; } } .ts-swatch { flex: none; width: 0.625rem; height: 0.625rem; border-radius: 50%; background: var(--tone); } .ts-id { flex: 0 0 5.5rem; text-align: right; overflow: hidden; text-overflow: ellipsis; font-family: var(--ts-font-mono); font-size: 0.625rem; color: var(--ts-muted); } .ts-input { box-sizing: border-box; height: 2.125rem; width: 100%; padding: 0 0.5rem; border: 1px solid var(--ts-line); border-radius: 0.5rem; background: var(--ts-surface); color: var(--ts-ink); font: inherit; font-size: 0.875rem; &:focus-visible { outline: 2px solid var(--ts-brand); outline-offset: 1px; } &:disabled { color: var(--ts-ink-soft); background: transparent; } } .ts-check { display: grid; place-items: center; } input[type='checkbox'] { width: 1rem; height: 1rem; accent-color: var(--ts-brand); cursor: pointer; } .ts-type-icon { display: inline-grid; place-items: center; width: 2rem; height: 2rem; border-radius: 0.5rem; color: var(--tone); background: color-mix(in srgb, var(--tone) 12%, var(--ts-surface)); } .ts-matrix-wrap { overflow-x: auto; } .ts-matrix { border-collapse: collapse; font-size: 0.8125rem; th, td { padding: 0.375rem 0.5rem; border-bottom: 1px solid var(--ts-line); text-align: center; } th[scope='row'] { text-align: left; white-space: nowrap; } thead th { vertical-align: bottom; } tr.is-archived th[scope='row'] { opacity: 0.6; } td:hover { background: var(--pi-color-primary-25); } } /* @pi/ui styles every