valtech-components 4.0.141 → 4.0.143

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -30,6 +30,8 @@ export declare class EmptyStateComponent {
30
30
  protected variantClass: import("@angular/core").Signal<EmptyStateVariant>;
31
31
  /** Icono final — custom si se pasó, default de la variante si no. */
32
32
  protected iconName: import("@angular/core").Signal<string>;
33
+ /** Emoji opcional — si está, se renderiza en lugar del icono Ionicons. */
34
+ protected emoji: import("@angular/core").Signal<string>;
33
35
  /** Tamaño en px. Default 64. */
34
36
  protected iconSize: import("@angular/core").Signal<number>;
35
37
  /**
@@ -28,6 +28,14 @@ export interface CreateErrorStateOpts {
28
28
  onRetry?: () => void | Promise<void>;
29
29
  /** `true` mientras el retry está corriendo — el botón pasa a WORKING. */
30
30
  retrying?: boolean;
31
+ /**
32
+ * Emoji a mostrar en lugar del icono (ej. `'🥹'`). Un solo string aplica a
33
+ * ambas variantes; el objeto elige según `isNetwork` (offline vs error).
34
+ */
35
+ emoji?: string | {
36
+ offline: string;
37
+ error: string;
38
+ };
31
39
  }
32
40
  /**
33
41
  * Convierte un error capturado en `EmptyStateMetadata` listo para
@@ -51,6 +51,13 @@ export interface EmptyStateMetadata {
51
51
  * en el componente consumidor (Ionic standalone no auto-registra).
52
52
  */
53
53
  icon?: string;
54
+ /**
55
+ * Emoji a mostrar en lugar del icono Ionicons (ej. `'🥹'`). Si se especifica,
56
+ * tiene prioridad sobre `icon`/el default de la variante — se renderiza como
57
+ * texto (no requiere registrar nada en Ionic). Útil para dar un tono más
58
+ * humano a un estado de error/vacío.
59
+ */
60
+ emoji?: string;
54
61
  /** Tamaño del icono en píxeles. Default `64`. */
55
62
  iconSize?: number;
56
63
  /** Acción CTA opcional. Si se omite, no se muestra botón. */
@@ -1,6 +1,7 @@
1
1
  import { OnDestroy, OnInit } from '@angular/core';
2
2
  import { FormControl } from '@angular/forms';
3
3
  import { ButtonMetadata, InputMetadata } from '../../types';
4
+ import { PermissionScopeGroup } from '../../../services/org/permission-catalog.service';
4
5
  import { MemberDetail, OrgRoleWithPermissions } from './types';
5
6
  import * as i0 from "@angular/core";
6
7
  /**
@@ -28,6 +29,16 @@ export declare class MemberDetailModalComponent implements OnInit, OnDestroy {
28
29
  private catalog;
29
30
  /** Labeler de permisos con la SSOT del backend (catálogo). Se llena al cargar. */
30
31
  private readonly permLabeler;
32
+ /** Catálogo del backend (define el alcance de cada recurso para agrupar). */
33
+ private readonly catalogResp;
34
+ /**
35
+ * `true` mientras el catálogo aún no llegó (y hay `orgId` para pedirlo, sin
36
+ * error). Mientras tanto, la sección de permisos muestra skeleton — evita el
37
+ * parpadeo inglés→español del fallback humanizado antes de tener los labels es.
38
+ * Sin `orgId` el catálogo nunca se pide → `false` desde el arranque (se renderiza
39
+ * con el fallback, sin skeleton infinito).
40
+ */
41
+ readonly permsLoading: import("@angular/core").WritableSignal<boolean>;
31
42
  /** Inyectado por `ModalService.open` — referencia para cerrar desde dentro. */
32
43
  _modalRef?: {
33
44
  dismiss: (data?: unknown, role?: string) => void;
@@ -54,10 +65,20 @@ export declare class MemberDetailModalComponent implements OnInit, OnDestroy {
54
65
  readonly subtitle: import("@angular/core").Signal<string>;
55
66
  readonly currentRoleLabel: import("@angular/core").Signal<string>;
56
67
  readonly permissionsForCurrentRole: import("@angular/core").Signal<string[]>;
68
+ /**
69
+ * Permisos del rol actual agrupados por alcance (app actual / organización /
70
+ * otras apps) con sus labels ya resueltos — mismo patrón que `val-permissions-view`.
71
+ * Si el catálogo no cargó (sin `orgId` o falló), agrupa con un catálogo vacío:
72
+ * sin scope no se pueden clasificar los recursos → cae a un único grupo `org`
73
+ * con el labeler humanizado, manteniendo los chips visibles.
74
+ */
75
+ readonly permissionGroups: import("@angular/core").Signal<PermissionScopeGroup[]>;
57
76
  readonly roleSelectProps: import("@angular/core").Signal<Partial<InputMetadata>>;
58
77
  readonly removeButtonProps: import("@angular/core").Signal<Partial<ButtonMetadata>>;
59
78
  constructor();
60
79
  ngOnInit(): void;
80
+ /** Resuelve el nombre legible de una app por su appId (key `app_{id}`). */
81
+ appLabel(appId: string): string;
61
82
  permissionLabel(perm: string): string;
62
83
  private onChangeRole;
63
84
  onRemove(): Promise<void>;
@@ -1,22 +1,14 @@
1
1
  import { OnInit } from '@angular/core';
2
2
  import { ButtonMetadata } from '../../types';
3
+ import { PermissionScopeGroup } from '../../../services/org/permission-catalog.service';
3
4
  import { PermissionsViewConfig } from './types';
4
5
  import * as i0 from "@angular/core";
5
- interface PermGroupView {
6
- key: string;
7
- label: string;
8
- /** Alcance del grupo: 'app' (app actual) · 'org' (general) · 'other' (otra app). */
9
- scope: 'app' | 'org' | 'other';
10
- /** AppId de la otra app (solo cuando scope === 'other'). */
11
- appId?: string;
12
- perms: string[];
13
- }
14
6
  interface RoleView {
15
7
  id: string;
16
8
  name: string;
17
9
  description?: string;
18
10
  isSystem: boolean;
19
- groups: PermGroupView[];
11
+ groups: PermissionScopeGroup[];
20
12
  hasPerms: boolean;
21
13
  }
22
14
  /**
@@ -55,3 +55,46 @@ export type PermissionLabeler = (perm: string) => string;
55
55
  export declare function createPermissionLabeler(catalog: Pick<PermissionCatalogResponse, 'permissions' | 'actionLabels'>, locale: string, opts?: {
56
56
  allLabel?: string;
57
57
  }): PermissionLabeler;
58
+ /** Alcance de un grupo de permisos: app actual · organización · otra app. */
59
+ export type PermissionGroupScope = 'app' | 'org' | 'other';
60
+ /** Grupo de permisos agrupados por alcance, con sus labels ya resueltos. */
61
+ export interface PermissionScopeGroup {
62
+ /** Clave estable de track (`app` · `org` · `other:{appId}`). */
63
+ key: string;
64
+ /** Label del badge de alcance (ya traducido). */
65
+ label: string;
66
+ scope: PermissionGroupScope;
67
+ /** AppId de la otra app (solo cuando `scope === 'other'`). */
68
+ appId?: string;
69
+ /** Labels de cada permiso del grupo (ya resueltos por el labeler). */
70
+ perms: string[];
71
+ }
72
+ /**
73
+ * Etiquetas i18n de los grupos de alcance + resolvedor de nombre de app, que el
74
+ * consumer pasa ya traducidos (la lib no resuelve i18n por su cuenta acá).
75
+ */
76
+ export interface PermissionGroupLabels {
77
+ /** Label del grupo "Permisos de la app". */
78
+ app: string;
79
+ /** Label del grupo "Permisos de la organización". */
80
+ org: string;
81
+ /** Prefijo del grupo de otra app (se concatena con el nombre de la app). */
82
+ other: string;
83
+ /** Resuelve el nombre legible de una app por su appId. */
84
+ appLabel: (appId: string) => string;
85
+ }
86
+ /**
87
+ * Agrupa una lista de permisos (`resource:action`) por alcance — app actual /
88
+ * organización / otras apps — usando el catálogo del backend como SSOT del scope
89
+ * (`PermissionResource.scope` + `appId`). Cada permiso se etiqueta con `labeler`.
90
+ *
91
+ * Reutilizado por `val-permissions-view` y `val-member-detail-modal` para que
92
+ * ambos muestren los chips agrupados y tintados de forma idéntica. Función pura
93
+ * (sin Angular DI) — el consumer pasa los labels i18n ya resueltos.
94
+ *
95
+ * - Recursos ausentes del catálogo o con `scope === 'internal'` se descartan
96
+ * (defensa en profundidad junto al filtro del backend, ADR-024).
97
+ * - `scope === 'app'` con `appId === currentAppId` → grupo `app`; otro appId →
98
+ * grupo `other:{appId}`. `scope === 'org'` → grupo `org`.
99
+ */
100
+ export declare function groupPermissionsByScope(permissions: string[], catalog: Pick<PermissionCatalogResponse, 'permissions'>, currentAppId: string, labeler: PermissionLabeler, labels: PermissionGroupLabels): PermissionScopeGroup[];
package/lib/version.d.ts CHANGED
@@ -2,4 +2,4 @@
2
2
  * Current version of valtech-components.
3
3
  * This is automatically updated during the publish process.
4
4
  */
5
- export declare const VERSION = "4.0.141";
5
+ export declare const VERSION = "4.0.143";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "valtech-components",
3
- "version": "4.0.141",
3
+ "version": "4.0.143",
4
4
  "private": false,
5
5
  "bin": {
6
6
  "valtech-firebase-config": "./src/lib/services/firebase/scripts/generate-sw-config.js"
@@ -1,145 +0,0 @@
1
- /**
2
- * Firebase Messaging Service Worker
3
- *
4
- * Service Worker estático para Firebase Cloud Messaging.
5
- * Carga la configuración dinámicamente desde /firebase-config.js.
6
- *
7
- * CONFIGURACIÓN:
8
- * 1. Crea firebase.config.json con tu configuración de Firebase
9
- * 2. Ejecuta: npm run generate:firebase-config
10
- * Esto genera /firebase-config.js con: self.FIREBASE_CONFIG = {...}
11
- * 3. Agrega este SW y firebase-config.js a los assets de angular.json
12
- *
13
- * Ver README.md para documentación completa.
14
- */
15
-
16
- // Importar Firebase scripts
17
- importScripts('https://www.gstatic.com/firebasejs/10.7.0/firebase-app-compat.js');
18
- importScripts('https://www.gstatic.com/firebasejs/10.7.0/firebase-messaging-compat.js');
19
-
20
- // Importar configuración desde archivo externo (generado en build)
21
- // Este archivo define: self.FIREBASE_CONFIG = { ... }
22
- try {
23
- importScripts('/firebase-config.js');
24
- } catch (e) {
25
- console.error('[SW] No se pudo cargar firebase-config.js:', e);
26
- }
27
-
28
- // Verificar que la configuración existe
29
- if (!self.FIREBASE_CONFIG) {
30
- console.error('[SW] FIREBASE_CONFIG no está definido.');
31
- console.error('[SW] Ejecuta: npm run generate:firebase-config');
32
- } else {
33
- // Inicializar Firebase
34
- firebase.initializeApp(self.FIREBASE_CONFIG);
35
-
36
- // Obtener instancia de messaging
37
- const messaging = firebase.messaging();
38
-
39
- /**
40
- * Handler para mensajes en background.
41
- */
42
- messaging.onBackgroundMessage((payload) => {
43
- console.log('[SW] Mensaje recibido en background:', payload);
44
-
45
- // Web push usa mensajes data-only (sin bloque `notification`) para que el
46
- // navegador NO auto-muestre una notificación duplicada — el SW es el único
47
- // que la pinta. Title/body/icon llegan dentro de `data`. Se mantiene el
48
- // fallback a `payload.notification` por compatibilidad con mensajes
49
- // legacy o nativos que sí traen el bloque.
50
- const data = payload.data || {};
51
- const notificationTitle =
52
- payload.notification?.title || data.title || 'Nueva notificación';
53
- const notificationBody = payload.notification?.body || data.body || '';
54
- const notificationIcon =
55
- payload.notification?.icon || data.icon || '/assets/icon/favicon.ico';
56
-
57
- const notificationOptions = {
58
- body: notificationBody,
59
- icon: notificationIcon,
60
- image: payload.notification?.image,
61
- badge: '/assets/icon/badge.png',
62
- tag: payload.messageId || data.messageId || 'default',
63
- data: {
64
- ...data,
65
- messageId: payload.messageId || data.messageId,
66
- title: notificationTitle,
67
- body: notificationBody,
68
- },
69
- vibrate: [200, 100, 200],
70
- requireInteraction: data.require_interaction === 'true',
71
- };
72
-
73
- return self.registration.showNotification(notificationTitle, notificationOptions);
74
- });
75
-
76
- /**
77
- * Handler para clicks en notificaciones.
78
- */
79
- self.addEventListener('notificationclick', (event) => {
80
- console.log('[SW] Click en notificación:', event);
81
- event.notification.close();
82
-
83
- const data = event.notification.data || {};
84
- let targetUrl = '/';
85
-
86
- if (data.route) {
87
- targetUrl = data.route;
88
- } else if (data.url) {
89
- targetUrl = data.url;
90
- }
91
-
92
- if (data.query_params) {
93
- const separator = targetUrl.includes('?') ? '&' : '?';
94
- targetUrl += separator + data.query_params;
95
- }
96
-
97
- const notificationPayload = {
98
- type: 'NOTIFICATION_CLICK',
99
- notification: {
100
- title: data.title,
101
- body: data.body,
102
- data: data,
103
- messageId: data.messageId,
104
- },
105
- };
106
-
107
- event.waitUntil(
108
- clients
109
- .matchAll({ type: 'window', includeUncontrolled: true })
110
- .then((clientList) => {
111
- // Siempre enviar postMessage para que la app pueda reaccionar
112
- for (const client of clientList) {
113
- client.postMessage(notificationPayload);
114
- }
115
-
116
- // Navegar si hay route o url
117
- if (targetUrl !== '/') {
118
- for (const client of clientList) {
119
- if ('navigate' in client) {
120
- return client.navigate(targetUrl).then((c) => c?.focus());
121
- }
122
- }
123
- // Si no hay cliente abierto, abrir nueva ventana
124
- if (clients.openWindow) {
125
- return clients.openWindow(targetUrl);
126
- }
127
- }
128
-
129
- // Solo hacer focus si no hay navegación
130
- for (const client of clientList) {
131
- if ('focus' in client) {
132
- return client.focus();
133
- }
134
- }
135
- if (clients.openWindow) {
136
- return clients.openWindow('/');
137
- }
138
- })
139
- );
140
- });
141
-
142
- self.addEventListener('notificationclose', (event) => {
143
- console.log('[SW] Notificación cerrada:', event.notification.tag);
144
- });
145
- }