import { RouterLink } from '@angular/router'; import { CdkDrag, CdkDragDrop, CdkDragHandle, CdkDropList, moveItemInArray, } from '@angular/cdk/drag-drop'; import { ChangeDetectionStrategy, Component, computed, effect, inject, input, 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 { ClientShareComponent } from '../ui/client-share'; 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' | 'teams'; 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); interface SettingsSection { id: string; label: string; hint: string; /** Edits the settings draft (saved with the bar below). */ draft?: boolean; /** Only for who can manage the project (or its owner). */ needs?: 'manage' | 'owner'; } const SECTIONS: SettingsSection[] = [ { id: 'general', label: 'General', hint: 'Quién ve el proyecto, si el público puede proponer cosas y el panel para clientes.', }, { id: 'workflow', draft: true, label: 'Flujo de trabajo', hint: 'Las columnas del tablero, a cuáles se puede pasar desde cada una y cuándo algo está hecho.', }, { id: 'tickets', draft: true, label: 'Tickets', hint: 'Tipos, áreas y campos propios. Lo que ya se usa no se borra: se archiva y deja de ofrecerse.', }, { id: 'teams', draft: true, label: 'Equipos', hint: 'Grupos de miembros, para asignarles tickets y avisarles con «@equipo».', }, { id: 'automations', label: 'Automatizaciones', hint: 'Reglas «cuando… si… entonces…» que hacen solas el trabajo repetido.', needs: 'manage', }, { id: 'integrations', label: 'Integraciones', hint: 'Commits y pull requests de Git en cada ticket, y avisos a otros sistemas.', needs: 'manage', }, { id: 'data', label: 'Importar y exportar', hint: 'Los tickets en CSV: sacarlos, o traerlos de TaskStream o de Jira.', needs: 'manage', }, { id: 'danger', label: 'Zona peligrosa', hint: 'Archivar el proyecto o eliminarlo del todo.', needs: 'owner', }, ]; /** Which section holds each part of the draft. */ const DRAFT_SECTION: Partial> = { statuses: 'workflow', transitions: 'workflow', checklistBeforeDone: 'workflow', issueTypes: 'tickets', areas: 'tickets', customFields: 'tickets', teams: 'teams', }; /** "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, ClientShareComponent, CdkDropList, CdkDrag, CdkDragHandle, NgTemplateOutlet, PiIconComponent, PiNoteComponent, ], template: `

{{ current().label }}

{{ current().hint }}

@if (!facade.canManage()) { } @if (current().id === 'general' && 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 (facade.canManage()) { } } @if (draft(); as d) { @if (current().draft) {
@switch (current().id) { @case ('workflow') {

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 }} }

Definición de «Hecho»

Un ticket no pasa a un estado «Hecho» con su checklist a medias. El propietario y los admins sí pueden cerrarlo.

} @case ('tickets') {

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. }
} @case ('teams') {

Equipos

Grupos de miembros: los tickets se pueden asignar a uno y «@equipo» avisa a todos sus miembros.

@if (!d.teams.length) {

Ningún equipo todavía.

}
    @for (team of d.teams; track $index; let i = $index) {
  1. {{ team.id }}
    @for (member of facade.members(); track member.user.id) { }
  2. }
} }
} @if (facade.canManage() && facade.project(); as project) { @switch (current().id) { @case ('automations') { } @case ('integrations') { } @case ('data') { } } } @if ( current().id === 'danger' && facade.isOwner() && facade.project(); as project ) {

Archivar el proyecto

@if (project.archivedAt) { Archivado: se puede ver todo, pero nadie puede cambiar nada hasta que lo desarchives. } @else { Para un proyecto terminado: queda todo a la vista (tablero, tickets, historia, estadísticas) y en solo lectura. Se puede desarchivar cuando quieras. }

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 (facade.canManage() && (dirty() || facade.savingSettings())) {
@if (problems().length) {
    @for (problem of problems(); track problem) {
  • {{ problem }}
  • }
} @else { Cambios sin guardar }
}
@if (isNew(section, item.id)) { } @else if (item.archived) { } @else { } `, styles: ` :host { display: block; } /* A menu of sections on the left, one section at a time on the right. */ .ts-settings-layout { display: grid; grid-template-columns: 13.5rem minmax(0, 1fr); gap: 2.5rem; align-items: start; margin-top: 0.5rem; } .ts-settings-nav { position: sticky; top: 5.5rem; display: grid; gap: 0.125rem; a { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; padding: 0.5rem 0.75rem; border-radius: 0.625rem; color: var(--ts-ink-soft); font-size: 0.875rem; font-weight: 500; text-decoration: none; transition: background-color 0.15s ease, color 0.15s ease; &:hover { background: color-mix(in srgb, var(--ts-ink) 6%, transparent); color: var(--ts-ink); } &.is-active { background: color-mix(in srgb, var(--ts-brand) 14%, transparent); box-shadow: inset 3px 0 0 var(--ts-brand); color: var(--ts-ink); font-weight: 600; } &.is-danger { margin-top: 0.75rem; color: var(--ts-c-red); } &:focus-visible { outline: 2px solid var(--ts-brand); outline-offset: 2px; } } } .ts-settings-nav__title { margin: 0 0 0.5rem; padding: 0 0.75rem; color: var(--ts-muted); font-size: 0.6875rem; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; } .ts-settings-nav__dot { flex: none; width: 0.5rem; height: 0.5rem; border-radius: 50%; background: var(--ts-c-amber); } .ts-settings-main { display: grid; gap: 1rem; min-width: 0; } .ts-settings-head { margin-bottom: 0.25rem; h2 { margin: 0; font-size: 1.375rem; font-weight: 700; letter-spacing: -0.01em; } p { max-width: 46rem; margin: 0.375rem 0 0; color: var(--ts-muted); font-size: 0.875rem; } } @media (max-width: 900px) { .ts-settings-layout { grid-template-columns: minmax(0, 1fr); gap: 1.25rem; } /* The menu becomes a row of chips that scrolls sideways. */ .ts-settings-nav { position: static; display: flex; gap: 0.375rem; margin: 0 -1rem; padding: 0 1rem 0.25rem; overflow-x: auto; scrollbar-width: none; a { flex: none; padding: 0.4375rem 0.875rem; border: 1px solid var(--ts-line); border-radius: 999px; white-space: nowrap; &.is-active { border-color: transparent; box-shadow: none; } &.is-danger { margin-top: 0; } } } .ts-settings-nav__title { display: none; } } .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-toggle { flex: none; font-weight: 600; } .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-block { container-type: inline-size; 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; } } /* Two lines in a narrow panel: name and archive on top; icon, color and template below. One line when it fits (below). */ .ts-type-grid { display: grid; grid-template-columns: 2rem minmax(0, 1fr) minmax(0, 1fr) 6.5rem; align-items: center; gap: 0.5rem; > .ts-type-icon { grid-area: 1 / 1; } > .ts-name { grid-area: 1 / 2 / 2 / 4; } > .ts-archive, > .ts-icon-button { grid-area: 1 / 4; } > select:nth-of-type(1) { grid-area: 2 / 2; } > select:nth-of-type(2) { grid-area: 2 / 3; } > .ts-template-toggle { grid-area: 2 / 4; } > .ts-template { grid-row: 3; } } @container (min-width: 46rem) { .ts-type-grid { grid-template-columns: 2rem minmax(8rem, 1fr) 8rem 8rem auto 6.5rem; > .ts-name { grid-area: 1 / 2; } > select:nth-of-type(1) { grid-area: 1 / 3; } > select:nth-of-type(2) { grid-area: 1 / 4; } > .ts-template-toggle { grid-area: 1 / 5; } > .ts-archive, > .ts-icon-button { grid-area: 1 / 6; } > .ts-template { grid-row: 2; } } } .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-team-members { display: flex; flex-wrap: wrap; gap: 0.25rem 1rem; } .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; } .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