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:
@@ -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`).
|
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
|
- 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.
|
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
|
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.
|
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/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/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/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/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 |
|
| `/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.
@@ -12,6 +12,7 @@ import {
|
|||||||
computed,
|
computed,
|
||||||
effect,
|
effect,
|
||||||
inject,
|
inject,
|
||||||
|
input,
|
||||||
signal,
|
signal,
|
||||||
untracked,
|
untracked,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
@@ -43,6 +44,77 @@ const FIELD_TYPES: { id: CustomFieldType; label: string }[] = [
|
|||||||
|
|
||||||
const CATEGORIES = Object.values(StatusCategory);
|
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). */
|
/** "Datos y análisis" → "DATOS_Y_ANALISIS": ids of new values (they never change after saving). */
|
||||||
function idFrom(name: string): string {
|
function idFrom(name: string): string {
|
||||||
const slug = name
|
const slug = name
|
||||||
@@ -79,16 +151,32 @@ function idFrom(name: string): string {
|
|||||||
PiNoteComponent,
|
PiNoteComponent,
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
<div class="ts-section-header">
|
<div class="ts-settings-layout">
|
||||||
<div>
|
<nav class="ts-settings-nav" aria-label="Secciones de la configuración">
|
||||||
<h2>Configuración</h2>
|
<p class="ts-settings-nav__title">Configuración</p>
|
||||||
<p>
|
@for (item of sections(); track item.id) {
|
||||||
Estados, transiciones, tipos de ticket, áreas y campos de este
|
<a
|
||||||
proyecto. Lo que ya se usa no se borra: se archiva y deja de
|
[routerLink]="[]"
|
||||||
ofrecerse.
|
[queryParams]="{ seccion: item.id }"
|
||||||
</p>
|
[class.is-active]="item.id === current().id"
|
||||||
</div>
|
[class.is-danger]="item.id === 'peligro'"
|
||||||
</div>
|
[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()) {
|
@if (!facade.canManage()) {
|
||||||
<pi-note
|
<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
|
<section
|
||||||
class="ts-panel ts-block ts-visibility"
|
class="ts-panel ts-block ts-visibility"
|
||||||
aria-labelledby="vis-title"
|
aria-labelledby="vis-title"
|
||||||
@@ -172,12 +260,15 @@ function idFrom(name: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@if (draft(); as d) {
|
@if (draft(); as d) {
|
||||||
|
@if (current().draft) {
|
||||||
<form
|
<form
|
||||||
class="ts-settings"
|
class="ts-settings"
|
||||||
(submit)="$event.preventDefault(); save()"
|
(submit)="$event.preventDefault(); save()"
|
||||||
novalidate
|
novalidate
|
||||||
>
|
>
|
||||||
<fieldset [disabled]="!facade.canManage()">
|
<fieldset [disabled]="!facade.canManage()">
|
||||||
|
@switch (current().id) {
|
||||||
|
@case ('flujo') {
|
||||||
<!-- Statuses -->
|
<!-- Statuses -->
|
||||||
<section class="ts-panel ts-block" aria-labelledby="st-title">
|
<section class="ts-panel ts-block" aria-labelledby="st-title">
|
||||||
<header class="ts-block__head">
|
<header class="ts-block__head">
|
||||||
@@ -357,7 +448,35 @@ function idFrom(name: string): string {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 -->
|
<!-- Ticket types -->
|
||||||
<section class="ts-panel ts-block" aria-labelledby="ty-title">
|
<section class="ts-panel ts-block" aria-labelledby="ty-title">
|
||||||
<header class="ts-block__head">
|
<header class="ts-block__head">
|
||||||
@@ -530,90 +649,6 @@ function idFrom(name: string): string {
|
|||||||
</ol>
|
</ol>
|
||||||
</section>
|
</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 -->
|
<!-- Custom fields -->
|
||||||
<section class="ts-panel ts-block" aria-labelledby="cf-title">
|
<section class="ts-panel ts-block" aria-labelledby="cf-title">
|
||||||
<header class="ts-block__head">
|
<header class="ts-block__head">
|
||||||
@@ -699,57 +734,94 @@ function idFrom(name: string): string {
|
|||||||
}
|
}
|
||||||
</ol>
|
</ol>
|
||||||
</section>
|
</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>
|
</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>
|
</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ol>
|
||||||
} @else {
|
</section>
|
||||||
<span>Cambios sin guardar</span>
|
|
||||||
}
|
}
|
||||||
</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>
|
</form>
|
||||||
|
}
|
||||||
|
|
||||||
@if (facade.canManage() && facade.project(); as project) {
|
@if (facade.canManage() && facade.project(); as project) {
|
||||||
|
@switch (current().id) {
|
||||||
|
@case ('automatizaciones') {
|
||||||
|
<ts-automations [projectId]="project.id" />
|
||||||
|
}
|
||||||
|
@case ('integraciones') {
|
||||||
<ts-git-integration
|
<ts-git-integration
|
||||||
[projectId]="project.id"
|
[projectId]="project.id"
|
||||||
[projectKey]="project.key"
|
[projectKey]="project.key"
|
||||||
/>
|
/>
|
||||||
<ts-automations [projectId]="project.id" />
|
|
||||||
<ts-webhooks [projectId]="project.id" />
|
<ts-webhooks [projectId]="project.id" />
|
||||||
|
}
|
||||||
|
@case ('datos') {
|
||||||
<ts-transfer [projectId]="project.id" [projectKey]="project.key" />
|
<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
|
<section
|
||||||
class="ts-panel ts-block ts-visibility"
|
class="ts-panel ts-block ts-visibility"
|
||||||
aria-labelledby="archive-title"
|
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
|
<ng-template
|
||||||
#archive
|
#archive
|
||||||
let-section="section"
|
let-section="section"
|
||||||
@@ -856,8 +968,123 @@ function idFrom(name: string): string {
|
|||||||
`,
|
`,
|
||||||
styles: `
|
styles: `
|
||||||
:host {
|
: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;
|
display: grid;
|
||||||
gap: 1rem;
|
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-visibility,
|
||||||
.ts-block.ts-danger {
|
.ts-block.ts-danger {
|
||||||
@@ -978,13 +1205,8 @@ function idFrom(name: string): string {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
border: 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 {
|
.ts-block {
|
||||||
|
container-type: inline-size;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -1053,8 +1275,8 @@ function idFrom(name: string): string {
|
|||||||
color: var(--ts-ink) !important;
|
color: var(--ts-ink) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* Two lines, to fit a panel of the pair: name and archive on top;
|
/* Two lines in a narrow panel: name and archive on top; icon, color
|
||||||
icon, color and template below. */
|
and template below. One line when it fits (below). */
|
||||||
.ts-type-grid {
|
.ts-type-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 2rem minmax(0, 1fr) minmax(0, 1fr) 6.5rem;
|
grid-template-columns: 2rem minmax(0, 1fr) minmax(0, 1fr) 6.5rem;
|
||||||
@@ -1083,6 +1305,30 @@ function idFrom(name: string): string {
|
|||||||
grid-row: 3;
|
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 {
|
.ts-area-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(8rem, 1fr) 6.5rem;
|
grid-template-columns: minmax(8rem, 1fr) 6.5rem;
|
||||||
@@ -1364,6 +1610,37 @@ export class SettingsPage {
|
|||||||
protected readonly icons = TYPE_ICONS;
|
protected readonly icons = TYPE_ICONS;
|
||||||
protected readonly fieldTypes = FIELD_TYPES;
|
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. */
|
/** What the API has; the draft is compared against it. */
|
||||||
private readonly saved = computed(
|
private readonly saved = computed(
|
||||||
() => this.facade.project()?.settings ?? null,
|
() => this.facade.project()?.settings ?? null,
|
||||||
|
|||||||
Reference in New Issue
Block a user