feat(web): settings by sections, with a menu

The settings page was one long strip of panels of uneven widths. It has
a menu now (General, Flujo de trabajo, Tickets, Equipos,
Automatizaciones, Integraciones, Importar y exportar, Zona peligrosa),
one section at a time and in the URL (?seccion=), each panel full width.
A dot marks the sections with unsaved changes, and the save bar follows
whichever section is open. On a phone the menu is a row of chips.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 21:20:56 +02:00
parent 2cbe6af878
commit 0564810d36
4 changed files with 439 additions and 162 deletions

View File

@@ -276,7 +276,7 @@ visualización de la plataforma:
lectura, y en uno privado (o inexistente), el enlace para iniciar sesión volviendo a esa página (`returnUrl`).
- El tiempo real necesita sesión: `GraphqlRealtimeGateway` no abre subscriptions sin token (si lo hiciera, la API
cerraría el WebSocket y el cliente reintentaría sin fin). Los visitantes anónimos ven los datos al cargar la página.
- Configuración tiene el interruptor «Público» (propietario y admins) y, solo para el propietario, «Eliminar el
- Configuración tiene en General el interruptor «Público» (propietario y admins) y, en Zona peligrosa y solo para el propietario, «Eliminar el
proyecto», que exige escribir la clave. Al borrarlo se vuelve a la portada; si otro lo borra mientras lo tienes
abierto, la subscription avisa (`ProjectChanged.deleted`) y también se vuelve a la portada.
@@ -296,7 +296,7 @@ visualización de la plataforma:
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea |
| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), calidad (bugs por prioridad y por semana, QA por área, bugs tras cada release), abiertos por un campo de lista a elegir, equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio |
| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) |
| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto): estados (orden, categoría, color, motivo), transiciones, tipos, áreas, equipos (con sus miembros), definición de «Hecho» y campos personalizados; archivar en lugar de borrar |
| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto), con un menú de secciones (`?seccion=`): General (visibilidad, propuestas), Flujo de trabajo (estados, transiciones, definición de «Hecho»), Tickets (tipos, áreas, campos), Equipos, Automatizaciones, Integraciones (Git, webhooks), Importar y exportar y Zona peligrosa; archivar en lugar de borrar, y un punto en el menú donde hay cambios sin guardar |
| `/p/:key/activity` | Actividad en vivo |
| `/p/:key/members` | Miembros, con su rol (propietario, admin, miembro, lector o invitado). Un invitado de un proyecto privado solo tiene Tablero, Lista y Miembros, ve solo sus tickets y abre tickets sin asignarlos ni planificarlos |

Binary file not shown.

Binary file not shown.

View File

@@ -12,6 +12,7 @@ import {
computed,
effect,
inject,
input,
signal,
untracked,
} from '@angular/core';
@@ -43,6 +44,77 @@ const FIELD_TYPES: { id: CustomFieldType; label: string }[] = [
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 y si el público puede proponer cosas.',
},
{
id: 'flujo',
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: 'equipos',
draft: true,
label: 'Equipos',
hint: 'Grupos de miembros, para asignarles tickets y avisarles con «@equipo».',
},
{
id: 'automatizaciones',
label: 'Automatizaciones',
hint: 'Reglas «cuando… si… entonces…» que hacen solas el trabajo repetido.',
needs: 'manage',
},
{
id: 'integraciones',
label: 'Integraciones',
hint: 'Commits y pull requests de Git en cada ticket, y avisos a otros sistemas.',
needs: 'manage',
},
{
id: 'datos',
label: 'Importar y exportar',
hint: 'Los tickets en CSV: sacarlos, o traerlos de TaskStream o de Jira.',
needs: 'manage',
},
{
id: 'peligro',
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<Record<keyof ProjectSettings, string>> = {
statuses: 'flujo',
transitions: 'flujo',
checklistBeforeDone: 'flujo',
issueTypes: 'tickets',
areas: 'tickets',
customFields: 'tickets',
teams: 'equipos',
};
/** "Datos y análisis" → "DATOS_Y_ANALISIS": ids of new values (they never change after saving). */
function idFrom(name: string): string {
const slug = name
@@ -79,16 +151,32 @@ function idFrom(name: string): string {
PiNoteComponent,
],
template: `
<div class="ts-section-header">
<div>
<h2>Configuración</h2>
<p>
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.
</p>
</div>
</div>
<div class="ts-settings-layout">
<nav class="ts-settings-nav" aria-label="Secciones de la configuración">
<p class="ts-settings-nav__title">Configuración</p>
@for (item of sections(); track item.id) {
<a
[routerLink]="[]"
[queryParams]="{ seccion: item.id }"
[class.is-active]="item.id === current().id"
[class.is-danger]="item.id === 'peligro'"
[attr.aria-current]="item.id === current().id ? 'page' : null"
>
<span>{{ item.label }}</span>
@if (changed().has(item.id)) {
<span class="ts-settings-nav__dot" title="Cambios sin guardar"
><span class="visually-hidden">(cambios sin guardar)</span></span
>
}
</a>
}
</nav>
<div class="ts-settings-main">
<header class="ts-settings-head">
<h2>{{ current().label }}</h2>
<p>{{ current().hint }}</p>
</header>
@if (!facade.canManage()) {
<pi-note
@@ -101,7 +189,7 @@ function idFrom(name: string): string {
/>
}
@if (facade.project(); as project) {
@if (current().id === 'general' && facade.project(); as project) {
<section
class="ts-panel ts-block ts-visibility"
aria-labelledby="vis-title"
@@ -172,12 +260,15 @@ function idFrom(name: string): string {
}
@if (draft(); as d) {
@if (current().draft) {
<form
class="ts-settings"
(submit)="$event.preventDefault(); save()"
novalidate
>
<fieldset [disabled]="!facade.canManage()">
@switch (current().id) {
@case ('flujo') {
<!-- Statuses -->
<section class="ts-panel ts-block" aria-labelledby="st-title">
<header class="ts-block__head">
@@ -357,7 +448,35 @@ function idFrom(name: string): string {
</div>
</section>
<div class="ts-settings__pair">
<!-- Definition of done -->
<section class="ts-panel ts-block" aria-labelledby="dod-title">
<header class="ts-block__head">
<div>
<h3 id="dod-title">Definición de «Hecho»</h3>
<p class="ts-muted">
Un ticket no pasa a un estado «Hecho» con su checklist a
medias. El propietario y los admins sí pueden cerrarlo.
</p>
</div>
<label class="ts-switch">
<input
type="checkbox"
role="switch"
[checked]="!!d.checklistBeforeDone"
(change)="
setChecklistBeforeDone($any($event.target).checked)
"
/>
<span class="ts-switch__track" aria-hidden="true"></span>
<span>{{
d.checklistBeforeDone ? 'Activada' : 'Desactivada'
}}</span>
</label>
</header>
</section>
}
@case ('tickets') {
<!-- Ticket types -->
<section class="ts-panel ts-block" aria-labelledby="ty-title">
<header class="ts-block__head">
@@ -530,90 +649,6 @@ function idFrom(name: string): string {
</ol>
</section>
<!-- Teams -->
<section class="ts-panel ts-block" aria-labelledby="teams-title">
<header class="ts-block__head">
<div>
<h3 id="teams-title">Equipos</h3>
<p class="ts-muted">
Grupos de miembros: los tickets se pueden asignar a uno y
«@equipo» avisa a todos sus miembros.
</p>
</div>
<button type="button" class="ts-add" (click)="addTeam()">
+ Añadir equipo
</button>
</header>
@if (!d.teams.length) {
<p class="ts-muted ts-empty-fields">Ningún equipo todavía.</p>
}
<ol class="ts-rows">
@for (team of d.teams; track $index; let i = $index) {
<li
class="ts-row ts-field-grid"
[class.is-archived]="team.archived"
>
<span class="ts-name">
<input
class="ts-input"
maxlength="40"
[attr.aria-label]="'Nombre del equipo ' + (i + 1)"
[value]="team.name"
(input)="setName('teams', i, $any($event.target).value)"
/>
<code class="ts-id">{{ team.id }}</code>
</span>
<ng-container
[ngTemplateOutlet]="archive"
[ngTemplateOutletContext]="{
section: 'teams',
index: i,
item: team,
}"
/>
<div class="ts-team-members ts-field-type">
@for (member of facade.members(); track member.user.id) {
<pi-checkbox
[label]="member.user.name"
[checked]="team.memberIds.includes(member.user.id)"
(checkedChange)="
setTeamMember(i, member.user.id, $event)
"
/>
}
</div>
</li>
}
</ol>
</section>
<!-- Definition of done -->
<section class="ts-panel ts-block" aria-labelledby="dod-title">
<header class="ts-block__head">
<div>
<h3 id="dod-title">Definición de «Hecho»</h3>
<p class="ts-muted">
Un ticket no pasa a un estado «Hecho» con su checklist a
medias. El propietario y los admins sí pueden cerrarlo.
</p>
</div>
<label class="ts-switch">
<input
type="checkbox"
role="switch"
[checked]="!!d.checklistBeforeDone"
(change)="
setChecklistBeforeDone($any($event.target).checked)
"
/>
<span class="ts-switch__track" aria-hidden="true"></span>
<span>{{
d.checklistBeforeDone ? 'Activada' : 'Desactivada'
}}</span>
</label>
</header>
</section>
<!-- Custom fields -->
<section class="ts-panel ts-block" aria-labelledby="cf-title">
<header class="ts-block__head">
@@ -699,57 +734,94 @@ function idFrom(name: string): string {
}
</ol>
</section>
}
@case ('equipos') {
<!-- Teams -->
<section class="ts-panel ts-block" aria-labelledby="teams-title">
<header class="ts-block__head">
<div>
<h3 id="teams-title">Equipos</h3>
<p class="ts-muted">
Grupos de miembros: los tickets se pueden asignar a uno y
«@equipo» avisa a todos sus miembros.
</p>
</div>
<button type="button" class="ts-add" (click)="addTeam()">
+ Añadir equipo
</button>
</header>
@if (!d.teams.length) {
<p class="ts-muted ts-empty-fields">Ningún equipo todavía.</p>
}
<ol class="ts-rows">
@for (team of d.teams; track $index; let i = $index) {
<li
class="ts-row ts-field-grid"
[class.is-archived]="team.archived"
>
<span class="ts-name">
<input
class="ts-input"
maxlength="40"
[attr.aria-label]="'Nombre del equipo ' + (i + 1)"
[value]="team.name"
(input)="setName('teams', i, $any($event.target).value)"
/>
<code class="ts-id">{{ team.id }}</code>
</span>
<ng-container
[ngTemplateOutlet]="archive"
[ngTemplateOutletContext]="{
section: 'teams',
index: i,
item: team,
}"
/>
<div class="ts-team-members ts-field-type">
@for (member of facade.members(); track member.user.id) {
<pi-checkbox
[label]="member.user.name"
[checked]="team.memberIds.includes(member.user.id)"
(checkedChange)="
setTeamMember(i, member.user.id, $event)
"
/>
}
</div>
</fieldset>
@if (facade.canManage() && (dirty() || facade.savingSettings())) {
<footer class="ts-savebar" [class.is-dirty]="dirty()">
<div class="ts-savebar__status" aria-live="polite">
@if (problems().length) {
<ul class="ts-problems">
@for (problem of problems(); track problem) {
<li>
<pi-icon icon="exclamation" size="x-small" />
{{ problem }}
</li>
}
</ul>
} @else {
<span>Cambios sin guardar</span>
</ol>
</section>
}
</div>
<button
type="button"
class="ts-secondary"
[disabled]="!dirty() || facade.savingSettings()"
(click)="discard()"
>
Descartar
</button>
<button
type="submit"
class="ts-primary"
[disabled]="
!dirty() || !!problems().length || facade.savingSettings()
"
>
{{ facade.savingSettings() ? 'Guardando…' : 'Guardar cambios' }}
</button>
</footer>
}
</fieldset>
</form>
}
@if (facade.canManage() && facade.project(); as project) {
@switch (current().id) {
@case ('automatizaciones') {
<ts-automations [projectId]="project.id" />
}
@case ('integraciones') {
<ts-git-integration
[projectId]="project.id"
[projectKey]="project.key"
/>
<ts-automations [projectId]="project.id" />
<ts-webhooks [projectId]="project.id" />
}
@case ('datos') {
<ts-transfer [projectId]="project.id" [projectKey]="project.key" />
}
}
}
@if (facade.isOwner() && facade.project(); as project) {
@if (
current().id === 'peligro' && facade.isOwner() && facade.project();
as project
) {
<section
class="ts-panel ts-block ts-visibility"
aria-labelledby="archive-title"
@@ -817,6 +889,46 @@ function idFrom(name: string): string {
}
}
<!-- After whichever section is open: the draft spans several. -->
@if (facade.canManage() && (dirty() || facade.savingSettings())) {
<footer class="ts-savebar" [class.is-dirty]="dirty()">
<div class="ts-savebar__status" aria-live="polite">
@if (problems().length) {
<ul class="ts-problems">
@for (problem of problems(); track problem) {
<li>
<pi-icon icon="exclamation" size="x-small" />
{{ problem }}
</li>
}
</ul>
} @else {
<span>Cambios sin guardar</span>
}
</div>
<button
type="button"
class="ts-secondary"
[disabled]="!dirty() || facade.savingSettings()"
(click)="discard()"
>
Descartar
</button>
<button
type="button"
class="ts-primary"
(click)="save()"
[disabled]="
!dirty() || !!problems().length || facade.savingSettings()
"
>
{{ facade.savingSettings() ? 'Guardando…' : 'Guardar cambios' }}
</button>
</footer>
}
</div>
</div>
<ng-template
#archive
let-section="section"
@@ -856,8 +968,123 @@ function idFrom(name: string): string {
`,
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 {
@@ -978,13 +1205,8 @@ function idFrom(name: string): string {
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 {
container-type: inline-size;
display: grid;
gap: 0.75rem;
min-width: 0;
@@ -1053,8 +1275,8 @@ function idFrom(name: string): string {
color: var(--ts-ink) !important;
}
}
/* Two lines, to fit a panel of the pair: name and archive on top;
icon, color and template below. */
/* 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;
@@ -1083,6 +1305,30 @@ function idFrom(name: string): string {
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;
@@ -1364,6 +1610,37 @@ export class SettingsPage {
protected readonly icons = TYPE_ICONS;
protected readonly fieldTypes = FIELD_TYPES;
/** `?seccion=` (`withComponentInputBinding`). */
readonly seccion = input<string | undefined>();
protected readonly sections = computed(() =>
SECTIONS.filter(
(s) =>
!s.needs ||
(s.needs === 'manage' && this.facade.canManage()) ||
(s.needs === 'owner' && this.facade.isOwner()),
),
);
protected readonly current = computed(
() =>
this.sections().find((s) => s.id === this.seccion()) ??
this.sections()[0],
);
/** Sections with unsaved changes, marked in the menu. */
protected readonly changed = computed(() => {
const draft = this.draft();
const saved = this.saved();
const changed = new Set<string>();
if (!draft || !saved) return changed;
for (const [key, section] of Object.entries(DRAFT_SECTION)) {
const k = key as keyof ProjectSettings;
if (JSON.stringify(draft[k]) !== JSON.stringify(saved[k]))
changed.add(section);
}
return changed;
});
/** What the API has; the draft is compared against it. */
private readonly saved = computed(
() => this.facade.project()?.settings ?? null,