diff --git a/docs/04-cliente-web.md b/docs/04-cliente-web.md index 14000a9..c2a459e 100644 --- a/docs/04-cliente-web.md +++ b/docs/04-cliente-web.md @@ -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 | diff --git a/docs/TaskStream-Documentacion.docx b/docs/TaskStream-Documentacion.docx index 330db39..9a4a66c 100644 Binary files a/docs/TaskStream-Documentacion.docx and b/docs/TaskStream-Documentacion.docx differ diff --git a/docs/TaskStream-Documentacion.pdf b/docs/TaskStream-Documentacion.pdf index 4442c42..6392d70 100644 Binary files a/docs/TaskStream-Documentacion.pdf and b/docs/TaskStream-Documentacion.pdf differ diff --git a/libs/web/tracker/presenters/src/lib/pages/settings.page.ts b/libs/web/tracker/presenters/src/lib/pages/settings.page.ts index ac09637..3d96c64 100644 --- a/libs/web/tracker/presenters/src/lib/pages/settings.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/settings.page.ts @@ -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> = { + 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,29 +151,45 @@ function idFrom(name: string): string { 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()) { - - } +
+
+

{{ current().label }}

+

{{ current().hint }}

+
- @if (facade.project(); as project) { + @if (!facade.canManage()) { + + } + + @if (current().id === 'general' && facade.project(); as project) {
+ @switch (current().id) { + @case ('flujo') {
@@ -357,7 +448,35 @@ function idFrom(name: string): string {
-
+ +
+
+
+

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') {
@@ -530,90 +649,6 @@ function idFrom(name: string): string {
- -
-
-
-

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

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

-
- -
-
-
@@ -699,57 +734,94 @@ function idFrom(name: string): string { }
-
+ } + @case ('equipos') { + +
+
+
+

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() && (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) { + @if (facade.canManage() && facade.project(); as project) { + @switch (current().id) { + @case ('automatizaciones') { + + } + @case ('integraciones') { + + + } + @case ('datos') { + + } + } + } + + @if ( + current().id === 'peligro' && facade.isOwner() && facade.project(); + as project + ) {
+ @if (facade.canManage() && (dirty() || facade.savingSettings())) { +
+
+ @if (problems().length) { +
    + @for (problem of problems(); track problem) { +
  • + + {{ problem }} +
  • + } +
+ } @else { + Cambios sin guardar + } +
+ + +
+ } +
+ + .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(); + + 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(); + 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,