valtech-components 2.0.1001 → 2.0.1002

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.
@@ -1,6 +1,6 @@
1
1
  import * as i0 from '@angular/core';
2
2
  import { inject, InjectionToken, makeEnvironmentProviders, APP_INITIALIZER, ErrorHandler, Injectable, DestroyRef, Inject, signal, computed, PLATFORM_ID, isDevMode, Optional, runInInjectionContext, effect, Pipe, TemplateRef, ViewContainerRef, input, Directive, EventEmitter, Component, Input, Output, ChangeDetectionStrategy, HostBinding, HostListener, NgZone, ViewChild, ChangeDetectorRef, output, ContentChild, ElementRef, viewChild, untracked, Injector, isSignal, ViewEncapsulation } from '@angular/core';
3
- import { BehaviorSubject, throwError, Subject, map, distinctUntilChanged, filter as filter$1, take as take$1, firstValueFrom, of, from, EMPTY, Observable, interval, debounceTime, switchMap as switchMap$1, catchError as catchError$1, takeUntil, isObservable, forkJoin, race, timer, shareReplay } from 'rxjs';
3
+ import { BehaviorSubject, throwError, Subject, map, distinctUntilChanged, filter as filter$1, take as take$1, firstValueFrom, of, from, EMPTY, Observable, interval, debounceTime, switchMap as switchMap$1, catchError as catchError$1, takeUntil, isObservable, race, timer, forkJoin, shareReplay } from 'rxjs';
4
4
  import { catchError, switchMap, finalize, filter, take, map as map$1, tap, distinctUntilChanged as distinctUntilChanged$1, retry, timeout, debounceTime as debounceTime$1, takeUntil as takeUntil$1 } from 'rxjs/operators';
5
5
  import * as i1$3 from '@angular/common/http';
6
6
  import { provideHttpClient, withInterceptors, HttpClient, HttpErrorResponse } from '@angular/common/http';
@@ -25,7 +25,7 @@ import { getApps, getApp, initializeApp as initializeApp$1 } from 'firebase/app'
25
25
  import * as i2$1 from '@ionic/angular/standalone';
26
26
  import { AlertController, IonAvatar, IonCard, IonIcon, IonButton, IonSpinner, IonText, IonModal, IonHeader, IonToolbar, IonContent, IonButtons, IonTitle, IonProgressBar, IonSkeletonText, IonFab, IonFabButton, IonFabList, IonLabel, IonCardContent, IonCardHeader, IonCardTitle, IonCardSubtitle, IonCheckbox, IonTextarea, IonDatetime, IonInput, IonSelect, IonSelectOption, IonPopover, IonList, IonItem, IonRadioGroup, IonRadio, IonRange, IonSearchbar, IonSegment, IonSegmentButton, IonToggle, IonRippleEffect, IonAccordion, IonAccordionGroup, IonTabBar, IonTabButton, IonBadge, IonBreadcrumb, IonBreadcrumbs, IonChip, IonNote, IonDatetimeButton, ToastController as ToastController$1, IonCol, IonRow, IonRefresher, IonRefresherContent, IonMenuButton, IonFooter, IonListHeader, IonInfiniteScroll, IonInfiniteScrollContent, IonGrid, MenuController, IonMenu, IonMenuToggle, IonSplitPane } from '@ionic/angular/standalone';
27
27
  import { addIcons } from 'ionicons';
28
- import { addOutline, addCircleOutline, alertOutline, alertCircleOutline, arrowBackOutline, arrowForwardOutline, arrowDownOutline, settings, settingsOutline, checkmarkCircleOutline, ellipsisHorizontalOutline, notifications, notificationsOutline, openOutline, closeOutline, chatbubblesOutline, shareOutline, heart, heartOutline, home, homeOutline, eyeOffOutline, eyeOutline, scanOutline, chevronDownOutline, chevronForwardOutline, checkmarkOutline, clipboardOutline, copyOutline, filterOutline, locationOutline, calendarOutline, businessOutline, logoTwitter, logoInstagram, logoLinkedin, logoYoutube, logoTiktok, logoFacebook, logoGoogle, createOutline, trashOutline, playOutline, peopleOutline, phonePortraitOutline, refreshOutline, documentTextOutline, lockClosedOutline, informationCircleOutline, logoNpm, removeOutline, optionsOutline, personOutline, shieldCheckmarkOutline, keyOutline, desktopOutline, logOutOutline, cloudDownloadOutline, cloudOutline, gitBranchOutline, layersOutline, serverOutline, warningOutline, bulbOutline, lockOpenOutline, diamondOutline, add, close, share, create, trash, star, camera, mic, send, downloadOutline, chevronDown, language, globeOutline, checkmark, list, grid, apps, menu, search, person, helpCircle, informationCircle, documentText, mail, calendar, folder, chevronForward, ellipsisHorizontal, chevronBack, playBack, playForward, ellipse, starOutline, starHalf, heartHalf, checkmarkCircle, timeOutline, flag, trendingUp, trendingDown, remove, analytics, people, cash, cart, eye, chatbubbleOutline, thumbsUpOutline, thumbsUp, happyOutline, happy, sadOutline, sad, chevronUp, pin, pencil, callOutline, logoWhatsapp, paperPlaneOutline, mailOutline, chevronDownCircleOutline, closeCircle, alertCircle, logoApple, logoMicrosoft, linkOutline, unlinkOutline, cloudOfflineOutline, documentOutline, attachOutline, cameraOutline, closeCircleOutline, imageOutline, chevronBackOutline, sendOutline, chatbubbleEllipsesOutline, swapVerticalOutline, chevronUpOutline, searchOutline, cartOutline, chatbubble, compass, compassOutline, gridOutline, listOutline, folderOutline, documents, documentsOutline, statsChart, statsChartOutline, laptopOutline, swapHorizontalOutline, notificationsOffOutline, alarmOutline, chatboxEllipsesOutline, checkmarkDoneOutline, cogOutline, menuOutline } from 'ionicons/icons';
28
+ import { addOutline, addCircleOutline, alertOutline, alertCircleOutline, arrowBackOutline, arrowForwardOutline, arrowDownOutline, settings, settingsOutline, checkmarkCircleOutline, ellipsisHorizontalOutline, notifications, notificationsOutline, openOutline, closeOutline, chatbubblesOutline, shareOutline, heart, heartOutline, home, homeOutline, eyeOffOutline, eyeOutline, scanOutline, chevronDownOutline, chevronForwardOutline, checkmarkOutline, clipboardOutline, copyOutline, filterOutline, locationOutline, calendarOutline, businessOutline, logoTwitter, logoInstagram, logoLinkedin, logoYoutube, logoTiktok, logoFacebook, logoGoogle, createOutline, trashOutline, playOutline, peopleOutline, phonePortraitOutline, refreshOutline, documentTextOutline, lockClosedOutline, informationCircleOutline, logoNpm, removeOutline, optionsOutline, personOutline, shieldCheckmarkOutline, keyOutline, desktopOutline, logOutOutline, cloudDownloadOutline, cloudOutline, gitBranchOutline, layersOutline, serverOutline, warningOutline, bulbOutline, lockOpenOutline, diamondOutline, add, close, share, create, trash, star, camera, mic, send, downloadOutline, chevronDown, language, globeOutline, checkmark, list, grid, apps, menu, search, person, helpCircle, informationCircle, documentText, mail, calendar, folder, chevronForward, ellipsisHorizontal, chevronBack, playBack, playForward, ellipse, starOutline, starHalf, heartHalf, checkmarkCircle, timeOutline, flag, trendingUp, trendingDown, remove, analytics, people, cash, cart, eye, chatbubbleOutline, thumbsUpOutline, thumbsUp, happyOutline, happy, sadOutline, sad, chevronUp, pin, pencil, callOutline, logoWhatsapp, paperPlaneOutline, mailOutline, chevronDownCircleOutline, closeCircle, alertCircle, logoApple, logoMicrosoft, linkOutline, unlinkOutline, cloudOfflineOutline, documentOutline, attachOutline, cameraOutline, closeCircleOutline, imageOutline, chevronBackOutline, sendOutline, chatbubbleEllipsesOutline, swapVerticalOutline, chevronUpOutline, searchOutline, cartOutline, chatbubble, compass, compassOutline, gridOutline, listOutline, folderOutline, documents, documentsOutline, statsChart, statsChartOutline, laptopOutline, swapHorizontalOutline, alarmOutline, chatboxEllipsesOutline, checkmarkDoneOutline, cogOutline, notificationsOffOutline, menuOutline } from 'ionicons/icons';
29
29
  import { Browser } from '@capacitor/browser';
30
30
  import * as i1$6 from '@angular/platform-browser';
31
31
  import { DomSanitizer, Meta, Title } from '@angular/platform-browser';
@@ -54,7 +54,7 @@ import 'prismjs/components/prism-json';
54
54
  * Current version of valtech-components.
55
55
  * This is automatically updated during the publish process.
56
56
  */
57
- const VERSION = '2.0.1001';
57
+ const VERSION = '2.0.1002';
58
58
 
59
59
  // Control de estado de refresco (singleton a nivel de módulo)
60
60
  let isRefreshing = false;
@@ -50044,488 +50044,702 @@ function provideValtechOrganizationRoutes(opts) {
50044
50044
  }
50045
50045
 
50046
50046
  /**
50047
- * Defaults i18n (es/en) embebidos en `val-notification-preferences-view`.
50048
- * Auto-registrados en el constructor del componente si el consumer no proveyó
50049
- * el namespace (`Settings.Notifications` por default). SOLO incluye las keys que
50050
- * la vista usa.
50047
+ * Helper para montar el bloque completo de Settings en una app del factory: el
50048
+ * HUB de navegación (`val-settings-hub`) en el index + las vistas promovidas
50049
+ * (Perfil, Preferencias) + las rutas propias de la app (`extraRoutes`).
50051
50050
  *
50052
- * Portado del override `Settings.Notifications` (showcase) bajo el proceso de
50053
- * ADR-021.
50051
+ * Pensado para apps nuevas (greenfield) que adoptan el set entero. showcase NO
50052
+ * lo usa hoy (preserva su `settings.routes.ts` por la semántica de sus muchas
50053
+ * secciones no promovidas — ver guía de promoción), pero el provider debe
50054
+ * existir y compilar para el resto de apps.
50055
+ *
50056
+ * @example
50057
+ * ```ts
50058
+ * // app.routes.ts
50059
+ * import { provideValtechSettingsRoutes } from 'valtech-components';
50060
+ *
50061
+ * export const routes: Routes = [
50062
+ * {
50063
+ * path: 'app',
50064
+ * children: [
50065
+ * ...provideValtechSettingsRoutes({
50066
+ * hubConfig: { onLogout: () => auth.logoutWithConfirmation() },
50067
+ * extraRoutes: [
50068
+ * { path: 'security', loadComponent: () => ... },
50069
+ * ],
50070
+ * }),
50071
+ * ],
50072
+ * },
50073
+ * ];
50074
+ * ```
50054
50075
  */
50055
- const NOTIFICATION_PREFERENCES_VIEW_I18N = {
50076
+ function provideValtechSettingsRoutes(opts) {
50077
+ const basePath = opts?.basePath ?? 'settings';
50078
+ const includeProfile = opts?.includeProfile ?? true;
50079
+ const includePreferences = opts?.includePreferences ?? true;
50080
+ const includeSecurity = opts?.includeSecurity ?? true;
50081
+ const includeAccount = opts?.includeAccount ?? true;
50082
+ const includeOrganization = opts?.includeOrganization ?? true;
50083
+ return [
50084
+ {
50085
+ path: basePath,
50086
+ children: [
50087
+ {
50088
+ path: '',
50089
+ component: SettingsHubComponent,
50090
+ data: { settingsHubConfig: opts?.hubConfig },
50091
+ },
50092
+ ...(includeProfile ? provideValtechProfileRoutes({ config: opts?.profileConfig }) : []),
50093
+ ...(includePreferences
50094
+ ? provideValtechPreferencesRoutes({
50095
+ path: opts?.preferencesPath ?? 'preferences',
50096
+ config: opts?.preferencesConfig,
50097
+ })
50098
+ : []),
50099
+ ...(includeSecurity ? provideValtechSecurityRoutes({ config: opts?.securityConfig }) : []),
50100
+ ...(includeAccount ? provideValtechAccountRoutes({ config: opts?.accountConfig }) : []),
50101
+ ...(includeOrganization
50102
+ ? provideValtechOrganizationRoutes({ config: opts?.organizationConfig })
50103
+ : []),
50104
+ ...(opts?.extraRoutes ?? []),
50105
+ ],
50106
+ },
50107
+ ];
50108
+ }
50109
+
50110
+ /**
50111
+ * Refreshable Stream
50112
+ *
50113
+ * Helper estándar para vistas que consumen datos de Firestore. Soporta DOS
50114
+ * modos — el caller decide cuál vía la `factory`:
50115
+ *
50116
+ * - **one-shot** (recomendado por defecto): `() => from(svc.getAllOnce())`.
50117
+ * Carga una vez y completa. Más barato — sin listener vivo.
50118
+ * - **real-time**: `() => svc.getAll()`. Listener Firestore que auto-actualiza.
50119
+ * Solo donde importa (inbox de notificaciones, badges).
50120
+ *
50121
+ * En ambos modos la primera suscripción se gatea por `firebaseAuthReady`
50122
+ * (sesión de Firebase Auth lista) — así el listener/lectura nunca se adjunta
50123
+ * antes de que `request.auth` esté disponible en las reglas de Firestore, lo
50124
+ * que cierra la ventana de `permission-denied` en cold start de PWA.
50125
+ *
50126
+ * El `retry` con backoff corto es solo red de seguridad para reconexiones
50127
+ * transitorias (no para cubrir cold start — eso lo cubre el gate de auth).
50128
+ */
50129
+ /**
50130
+ * Crea un stream refrescable a partir de una factory de Observable.
50131
+ *
50132
+ * DEBE llamarse en un injection context (field initializer o constructor) —
50133
+ * usa `toSignal`/`toObservable`/`inject` internamente.
50134
+ *
50135
+ * @param factory - Función que produce el Observable a consumir. El caller
50136
+ * decide el modo: `() => from(svc.getAllOnce())` (one-shot) o
50137
+ * `() => svc.getAll()` (real-time).
50138
+ * @param options - Configuración opcional (fallback, retry, gate).
50139
+ *
50140
+ * @example
50141
+ * ```typescript
50142
+ * // one-shot (recomendado por defecto)
50143
+ * private readonly stream = createRefreshableStream(
50144
+ * () => from(this.svc.getAllOnce()),
50145
+ * );
50146
+ *
50147
+ * // real-time (inbox)
50148
+ * private readonly stream = createRefreshableStream(
50149
+ * () => this.notifs.getAll(50),
50150
+ * );
50151
+ *
50152
+ * readonly items = this.stream.data;
50153
+ * readonly loading = this.stream.loading;
50154
+ *
50155
+ * ionViewWillEnter() {
50156
+ * this.pageRefresh.register(() => this.stream.reload());
50157
+ * }
50158
+ * ```
50159
+ */
50160
+ function createRefreshableStream(factory, options) {
50161
+ const injector = options?.injector ?? inject(Injector);
50162
+ const fallback = (options?.fallback ?? null);
50163
+ const retryCount = options?.retryCount ?? 4;
50164
+ const retryBaseDelayMs = options?.retryBaseDelayMs ?? 500;
50165
+ const gateOnFirebaseAuth = options?.gateOnFirebaseAuth ?? true;
50166
+ const authGateTimeoutMs = options?.authGateTimeoutMs ?? 20000;
50167
+ const firstEmitTimeoutMs = options?.firstEmitTimeoutMs ?? 20000;
50168
+ // FirebaseService es opcional — sin él (apps sin Firebase) no se gatea.
50169
+ const firebase = gateOnFirebaseAuth ? injector.get(FirebaseService, null) : null;
50170
+ const _reload = signal(0);
50171
+ const _loading = signal(true);
50172
+ const _error = signal(false);
50173
+ // Gate de Firebase Auth: el stream no abre la primera suscripción hasta que
50174
+ // la sesión esté confirmada. `race` con un `timer` de seguridad — si la
50175
+ // sesión NUNCA se establece (token inválido, signInWithFirebase falló), el
50176
+ // gate igual se libera tras `authGateTimeoutMs` y la factory procede: si no
50177
+ // hay permiso terminará en estado `error`, jamás en skeleton infinito.
50178
+ // Tag los dos inputs del `race` para distinguir en logs cuál ganó: la sesión
50179
+ // de Firebase Auth (`auth-ready`) o el timeout de seguridad (`timeout`).
50180
+ const authGate$ = firebase
50181
+ ? race(firebase.firebaseAuthReady$.pipe(map$1(() => 'auth-ready')), timer(authGateTimeoutMs).pipe(map$1(() => 'timeout'))).pipe(take(1), tap(winner => {
50182
+ if (winner === 'timeout') {
50183
+ console.warn(`[RefreshStream] auth gate resolved by TIMEOUT after ${authGateTimeoutMs}ms ` +
50184
+ '(Firebase Auth session never confirmed)');
50185
+ }
50186
+ else {
50187
+ console.log('[RefreshStream] auth gate resolved by firebaseAuthReady$ (session ready)');
50188
+ }
50189
+ }))
50190
+ : of(true);
50191
+ const data = toSignal(toObservable(_reload, { injector }).pipe(tap(() => {
50192
+ _loading.set(true);
50193
+ _error.set(false);
50194
+ }),
50195
+ // Esperar a Firebase Auth listo antes de cada (re)suscripción. Como
50196
+ // `firebaseAuthReady$` completa tras la primera emisión, las reloads
50197
+ // posteriores resuelven el gate de inmediato (auth ya está listo).
50198
+ switchMap(() => authGate$.pipe(switchMap(() => factory().pipe(tap(() => {
50199
+ _loading.set(false);
50200
+ _error.set(false);
50201
+ console.log('[RefreshStream] factory emitted first value — loading=false');
50202
+ }),
50203
+ // Backoff corto exponencial acotado — solo red de seguridad para
50204
+ // reconexiones transitorias, NO para cubrir cold start.
50205
+ retry({
50206
+ count: retryCount,
50207
+ delay: (_err, attempt) => timer(Math.min(retryBaseDelayMs * 2 ** (attempt - 1), 4000)),
50208
+ }),
50209
+ // Techo absoluto para la PRIMERA emisión. Si la factory no emite
50210
+ // ni valor ni error (servicio nunca inicializado → `EMPTY` →
50211
+ // silencio), `timeout` dispara un error → `catchError` → estado
50212
+ // `error`. Nunca skeleton perpetuo. Va DESPUÉS de `retry` para
50213
+ // que cuente el tiempo total, no por intento.
50214
+ timeout({ first: firstEmitTimeoutMs }), catchError(err => {
50215
+ _loading.set(false);
50216
+ _error.set(true);
50217
+ console.error(`[RefreshStream] factory failed/timed out → error state: ${err instanceof Error ? err.message : String(err)}`);
50218
+ return of(fallback);
50219
+ })))))), { initialValue: null, injector });
50220
+ return {
50221
+ data,
50222
+ loading: _loading.asReadonly(),
50223
+ error: _error.asReadonly(),
50224
+ reload: () => _reload.update(n => n + 1),
50225
+ };
50226
+ }
50227
+
50228
+ /**
50229
+ * Defaults i18n (es/en) embebidos en `val-notifications-view`. Auto-registrados
50230
+ * en el constructor del componente si el consumer no proveyó el namespace
50231
+ * (`Notifications` por default). SOLO incluye las keys que la vista usa.
50232
+ *
50233
+ * Portado del override `Notifications` (showcase) bajo el proceso de ADR-021.
50234
+ */
50235
+ const NOTIFICATIONS_VIEW_I18N = {
50056
50236
  es: {
50057
50237
  pageTitle: 'Notificaciones',
50058
- pageDescription: 'Configura como quieres recibir notificaciones',
50059
- // Control principal (UX simplificado)
50060
- simpleTitle: 'Notificaciones push en este dispositivo',
50061
- simpleDescription: 'Recibe alertas en tiempo real incluso con la app cerrada.',
50062
- enabling: 'Activando…',
50063
- disabling: 'Desactivando…',
50064
- enabled: 'Activadas',
50065
- errorGeneric: 'No pudimos completar la configuración. Verifica los permisos del navegador y reintenta.',
50238
+ pageDescription: 'Tu bandeja de mensajes y actualizaciones',
50239
+ groupToday: 'Hoy',
50240
+ groupThisMonth: 'Este mes',
50241
+ groupEarlier: 'Anteriores',
50242
+ empty: 'Aún no tienes notificaciones',
50243
+ emptyHint: 'Activa las notificaciones para recibir actualizaciones, alertas y novedades importantes.',
50244
+ emptyCta: 'Ir a configuración',
50245
+ loadErrorTitle: 'No pudimos cargar tus notificaciones',
50246
+ loadErrorHint: 'Revisa tu conexión e inténtalo de nuevo. Tus notificaciones siguen guardadas.',
50247
+ offlineTitle: 'Sin conexión',
50248
+ offlineHint: 'No hay acceso a la red. Reconéctate e intenta de nuevo.',
50066
50249
  retryCta: 'Reintentar',
50067
- // Mensajes contextuales
50068
- permissionUnsupported: 'Tu navegador no soporta notificaciones push.',
50069
- permissionDeniedInfo: 'Tu navegador esta bloqueando las notificaciones. Para habilitarlas, ve a la configuracion del sitio y permitelas manualmente.',
50070
- // iOS PWA install
50071
- iosInstallTitle: 'En iOS, instala la app para recibir notificaciones',
50072
- iosInstallHint: 'Las notificaciones push en iOS (iPhone/iPad) solo funcionan cuando la app está instalada en la pantalla de inicio. Edge, Chrome y Firefox en iOS comparten esta limitación.',
50073
- iosInstallStep1: 'Abre este sitio en Safari (no en Chrome/Edge).',
50074
- iosInstallStep2: 'Toca el botón Compartir (□ con flecha hacia arriba).',
50075
- iosInstallStep3: "Elige 'Añadir a pantalla de inicio'.",
50076
- iosInstallStep4: 'Abre la app desde el ícono recién creado y vuelve aquí.',
50077
- // Detalles técnicos (expandible)
50078
- detailsShow: 'Ver detalles técnicos',
50079
- detailsHide: 'Ocultar detalles',
50080
- detailsToken: 'Token',
50081
- detailsNone: 'Sin registrar',
50082
- diagnosticsPermission: 'Permiso del navegador',
50083
- diagnosticsSupported: 'Soporte del navegador',
50084
- // Toasts
50085
- enableSuccess: 'Notificaciones habilitadas',
50086
- disableSuccess: 'Notificaciones deshabilitadas',
50250
+ pushOffTitle: 'No recibirás avisos',
50251
+ pushOffHint: 'Puedes leer tus mensajes aquí, pero las notificaciones push están desactivadas. Actívalas para recibir avisos en tu dispositivo.',
50252
+ pushOffCta: 'Activar notificaciones',
50253
+ loading: 'Cargando…',
50254
+ loadMore: 'Cargar más',
50255
+ seeMore: 'Ver más',
50256
+ seeLess: 'Ver menos',
50257
+ open: 'Ver',
50258
+ markAll: 'Marcar todas como leídas',
50259
+ unread: 'No leída',
50260
+ crossApp: 'Desde {app}',
50261
+ timeNow: 'ahora',
50262
+ timeMinutes: 'hace {n} min',
50263
+ timeHours: 'hace {n} h',
50264
+ timeDays: 'hace {n} d',
50265
+ markedAllSuccess: 'Notificaciones marcadas como leídas.',
50266
+ markedAllError: 'No se pudo marcar todas como leídas.',
50267
+ openError: 'No se pudo abrir la notificación.',
50087
50268
  },
50088
50269
  en: {
50089
50270
  pageTitle: 'Notifications',
50090
- pageDescription: 'Configure how you want to receive notifications',
50091
- simpleTitle: 'Push notifications on this device',
50092
- simpleDescription: 'Get real-time alerts even when the app is closed.',
50093
- enabling: 'Enabling…',
50094
- disabling: 'Disabling…',
50095
- enabled: 'Enabled',
50096
- errorGeneric: "We couldn't complete the setup. Check your browser permissions and retry.",
50271
+ pageDescription: 'Your inbox of messages and updates',
50272
+ groupToday: 'Today',
50273
+ groupThisMonth: 'This month',
50274
+ groupEarlier: 'Earlier',
50275
+ empty: 'No notifications yet',
50276
+ emptyHint: 'Enable notifications to receive updates, alerts and important news.',
50277
+ emptyCta: 'Go to settings',
50278
+ loadErrorTitle: "We couldn't load your notifications",
50279
+ loadErrorHint: 'Check your connection and try again. Your notifications are still saved.',
50280
+ offlineTitle: 'No connection',
50281
+ offlineHint: 'No network access. Reconnect and try again.',
50097
50282
  retryCta: 'Retry',
50098
- permissionUnsupported: 'Your browser does not support push notifications.',
50099
- permissionDeniedInfo: 'Your browser is blocking notifications. To enable them, go to site settings and allow them manually.',
50100
- iosInstallTitle: 'On iOS, install the app to receive notifications',
50101
- iosInstallHint: 'Push notifications on iOS (iPhone/iPad) only work when the app is installed on the Home Screen. Edge, Chrome and Firefox on iOS share this limitation.',
50102
- iosInstallStep1: 'Open this site in Safari (not Chrome/Edge).',
50103
- iosInstallStep2: 'Tap the Share button (□ with an up arrow).',
50104
- iosInstallStep3: "Choose 'Add to Home Screen'.",
50105
- iosInstallStep4: 'Open the app from the new icon and come back here.',
50106
- detailsShow: 'View technical details',
50107
- detailsHide: 'Hide details',
50108
- detailsToken: 'Token',
50109
- detailsNone: 'Not registered',
50110
- diagnosticsPermission: 'Browser permission',
50111
- diagnosticsSupported: 'Browser support',
50112
- enableSuccess: 'Notifications enabled',
50113
- disableSuccess: 'Notifications disabled',
50283
+ pushOffTitle: "You won't get alerts",
50284
+ pushOffHint: 'You can read your messages here, but push notifications are off. Turn them on to get alerts on your device.',
50285
+ pushOffCta: 'Enable notifications',
50286
+ loading: 'Loading…',
50287
+ loadMore: 'Load more',
50288
+ seeMore: 'See more',
50289
+ seeLess: 'See less',
50290
+ open: 'Open',
50291
+ markAll: 'Mark all as read',
50292
+ unread: 'Unread',
50293
+ crossApp: 'From {app}',
50294
+ timeNow: 'now',
50295
+ timeMinutes: '{n} min ago',
50296
+ timeHours: '{n} h ago',
50297
+ timeDays: '{n} d ago',
50298
+ markedAllSuccess: 'Notifications marked as read.',
50299
+ markedAllError: 'Could not mark all as read.',
50300
+ openError: 'Could not open notification.',
50114
50301
  },
50115
50302
  };
50116
50303
 
50117
50304
  addIcons({
50118
- alertCircleOutline,
50119
- chevronDownOutline,
50120
- chevronUpOutline,
50121
- closeCircleOutline,
50305
+ alarmOutline,
50306
+ chatboxEllipsesOutline,
50307
+ chatbubbleOutline,
50308
+ checkmarkCircleOutline,
50309
+ checkmarkDoneOutline,
50310
+ cloudOfflineOutline,
50311
+ cogOutline,
50312
+ documentTextOutline,
50122
50313
  notificationsOffOutline,
50123
50314
  notificationsOutline,
50124
- refreshOutline,
50315
+ openOutline,
50316
+ personOutline,
50125
50317
  });
50126
- const DEFAULT_NAMESPACE$2 = 'Settings.Notifications';
50318
+ const DEFAULT_NAMESPACE$2 = 'Notifications';
50319
+ const DEFAULT_SETTINGS_PATH = '/app/settings/notifications';
50320
+ const DEFAULT_PAGE_SIZE = 10;
50321
+ const DEFAULT_MAX_LOAD = 50;
50127
50322
  /**
50128
- * `val-notification-preferences-view` — **preferencias de notificaciones push**
50129
- * (FCM) full-feature autocontenida (organism). Promovida desde `showcase` bajo el
50130
- * proceso de ADR-021.
50323
+ * `val-notifications-view` — inbox de notificaciones full-feature autocontenido
50324
+ * (organism). Promovido desde `showcase` bajo el proceso de ADR-021.
50131
50325
  *
50132
- * Separada de `val-notifications-view` (el inbox / feed). Aquí el user **activa o
50133
- * desactiva** las notificaciones push en este dispositivo.
50326
+ * Separado de `/app/settings/notifications` (que controla permiso FCM + master
50327
+ * toggle). Aquí el user lee/abre/marca-leídas sus mensajes.
50134
50328
  *
50135
- * UX simplificado: UN solo toggle que ejecuta toda la secuencia (pedir permiso
50136
- * SW ready getToken → registrar en backend → set master pref) vía
50137
- * `MessagingService.enable()`. El registro del device en el backend va como
50138
- * callback (la lib no depende de `AuthService` para evitar ciclos). Mensajes
50139
- * contextuales y botón "Reintentar" aparecen solo cuando aplican.
50329
+ * - **Stream real-time** vía `NotificationsService.getAll(maxLoad)` envuelto en
50330
+ * `createRefreshableStream` (gateado por `firebaseAuthReady`, retry corto,
50331
+ * `firstEmitTimeoutMs: 15000`). Estados: loading (skeleton), error
50332
+ * (`val-empty-state` + reintentar), empty (`val-empty-state` + CTA), data
50333
+ * (lista agrupada Hoy / Este mes / Anteriores).
50334
+ * - **Abrir** una notif → `NotificationActionService.open()` (markRead +
50335
+ * switch-org + navegar/handoff cross-app). Hook `onNotificationOpen` opcional.
50336
+ * - **Marcar todas como leídas** (gateado por `enableMarkAll`).
50337
+ * - **CTA push-off** cuando el permiso del navegador no está concedido (gateado
50338
+ * por `showPushOffCta`); navega a `settingsNotificationsPath`.
50339
+ * - **Paginación soft** — slice local (`pageSize`, +`pageSize` por click) sobre el
50340
+ * cache del stream; el tope de fetch es `maxLoad`.
50341
+ * - **i18n auto-registrado** es/en bajo `i18nNamespace` (default `Notifications`).
50140
50342
  *
50141
- * NO renderiza ion-content — vive dentro de val-page-wrapper. `ActivatedRoute` se
50142
- * inyecta `{ optional: true }` → funciona sin router (embebida / Storybook).
50343
+ * NO renderiza ion-content — vive dentro de val-page-wrapper. `Router` y
50344
+ * `ActivatedRoute` se inyectan `{ optional: true }` → funciona sin router
50345
+ * (embebida / Storybook). El forward-compat icon resolver lee `notif.payload.icon`
50346
+ * si existe (`{ type, value, color? }`), cae a un mapa por `notif.type`, luego a
50347
+ * `notifications-outline`.
50143
50348
  */
50144
- class NotificationPreferencesViewComponent {
50349
+ class NotificationsViewComponent {
50145
50350
  /** Namespace i18n resuelto (capturado para llamadas no-reactivas). */
50146
50351
  get ns() {
50147
50352
  return this.resolvedConfig().i18nNamespace;
50148
50353
  }
50149
- // ---------------------------------------------------------------------------
50150
- // Lifecycle
50151
- // ---------------------------------------------------------------------------
50354
+ /** Paso de paginación soft (revela +N por "cargar más"). */
50355
+ get pageStep() {
50356
+ return this.resolvedConfig().pageSize;
50357
+ }
50152
50358
  constructor() {
50153
50359
  this.nav = inject(NavigationService);
50154
50360
  this.i18n = inject(I18nService);
50361
+ this.notifs = inject(NotificationsService);
50362
+ this.action = inject(NotificationActionService);
50155
50363
  this.toast = inject(ToastService);
50364
+ this.errors = inject(ValtechErrorService);
50156
50365
  this.auth = inject(AuthService);
50157
- this.messaging = inject(MessagingService);
50158
50366
  this.pageRefresh = inject(PageRefreshService);
50159
- this.prefs = inject(PreferencesService);
50160
50367
  // `ActivatedRoute` opcional → la vista funciona embebida / en Storybook sin
50161
- // router (solo lo usa para leer el route data de config como fallback).
50368
+ // router (solo lo usa para leer el route data de config como fallback). La
50369
+ // navegación va por `NavigationService` (wrapper opcional sobre `Router`).
50162
50370
  this.route = inject(ActivatedRoute, { optional: true });
50163
50371
  this.resolvedConfig = computed(() => {
50164
- const fromRoute = (this.route?.snapshot.data['notificationPreferencesConfig'] ??
50165
- {});
50372
+ const fromRoute = (this.route?.snapshot.data['notificationsConfig'] ?? {});
50166
50373
  const merged = { ...fromRoute, ...(this.config ?? {}) };
50167
50374
  return {
50168
- showDetails: merged.showDetails ?? true,
50375
+ showPushOffCta: merged.showPushOffCta ?? true,
50376
+ enableMarkAll: merged.enableMarkAll ?? true,
50377
+ settingsNotificationsPath: merged.settingsNotificationsPath ?? DEFAULT_SETTINGS_PATH,
50169
50378
  i18nNamespace: merged.i18nNamespace ?? DEFAULT_NAMESPACE$2,
50170
- onPushEnabled: merged.onPushEnabled,
50171
- onPushDisabled: merged.onPushDisabled,
50379
+ pageSize: merged.pageSize ?? DEFAULT_PAGE_SIZE,
50380
+ maxLoad: merged.maxLoad ?? DEFAULT_MAX_LOAD,
50381
+ onNotificationOpen: merged.onNotificationOpen,
50172
50382
  };
50173
50383
  });
50174
- /** Operación en curso (gobierna spinner del toggle + label "Activando…"). */
50175
- this._busy = signal(null);
50176
- /** True si el último intento falló — gobierna estado `error` + retry button. */
50177
- this._lastFailed = signal(false);
50178
- /** True mientras se re-obtiene el token FCM en background (no afecta la UI). */
50179
- this._repairingToken = false;
50180
- /** Toggle del expand de detalles técnicos. */
50181
- this.showDetails = signal(false);
50182
- this.permission = signal('default');
50183
- this.isSupported = signal(false);
50184
- this.currentToken = signal(null);
50185
- this.needsIOSInstall = signal(this.detectNeedsIOSInstall());
50186
- /** FormControl del toggle (sync-eado con `isEnabled` via effect). */
50187
- this.toggleControl = new FormControl(false, {
50188
- nonNullable: true,
50189
- });
50190
- /** Handler del listener `focus` — referencia guardada para poder removerlo. */
50191
- this.onWindowFocus = () => this.scheduleBrowserRefresh();
50192
- /** Timer del debounce del refresh disparado por `focus`. */
50193
- this.focusDebounceTimer = null;
50194
- /** Ventana de debounce (ms) para el refresh disparado por `focus`. */
50195
- this.FOCUS_DEBOUNCE_MS = 500;
50196
- // ---------------------------------------------------------------------------
50197
- // Estado derivado
50198
- // ---------------------------------------------------------------------------
50199
- this.pageTitle = computed(() => this.tt('pageTitle'));
50200
- this.pageDescription = computed(() => this.tt('pageDescription'));
50384
+ this._expanded = signal(new Set());
50385
+ this._pageSize = signal(this.resolvedConfig().pageSize);
50386
+ this._bulkBusy = signal(false);
50387
+ this._loadingMore = signal(false);
50201
50388
  /**
50202
- * Estado efectivo del toggle: "el usuario tiene push activo".
50389
+ * Push activado = permiso del navegador en `granted`. Si NO está activado,
50390
+ * el user puede leer el inbox (Firestore) pero NO recibirá push en su
50391
+ * dispositivo → mostramos una card que lo invita a ir a configuración.
50392
+ * Se evalúa en cada montaje de la vista.
50393
+ */
50394
+ this.pushEnabled = signal(this.auth.getNotificationPermissionState() === 'granted');
50395
+ /**
50396
+ * Stream firestore real-time vía el helper estándar `createRefreshableStream`
50397
+ * (modo real-time: factory devuelve un listener vivo `notifs.getAll(maxLoad)`).
50203
50398
  *
50204
- * Se deriva SOLO de la decisión del usuario (`master`) y del permiso del SO
50205
- * (`permission`). El token FCM NO forma parte del estado de UI: vive en
50206
- * memoria, se pierde en cada recarga/suspensión de la PWA y produciría flicker.
50207
- * Si falta token con `master && granted`, es un detalle reparable en
50208
- * background (ver `repairTokenIfNeeded`), no un cambio del toggle.
50399
+ * El helper gatea la primera suscripción por `firebaseAuthReady`, aplica un
50400
+ * `retry` corto con backoff y, tras agotar reintentos, emite el fallback (`[]`)
50401
+ * y marca `error` estado de error en UI, no skeleton infinito. `reload()`
50402
+ * re-suscribe el stream completo (lo usa el pull-to-refresh).
50209
50403
  */
50210
- this.isEnabled = computed(() => this.prefs.notificationsMaster() && this.permission() === 'granted');
50211
- this.state = computed(() => {
50212
- if (this.needsIOSInstall())
50213
- return 'needs-install';
50214
- if (this.permission() === 'unsupported' || !this.isSupported()) {
50215
- // iOS standalone aún puede dar isSupported false temprano antes de init
50216
- // solo bloqueamos si el browser explícitamente no soporta APIs.
50217
- if (this.permission() === 'unsupported')
50218
- return 'unsupported';
50219
- }
50220
- const busy = this._busy();
50221
- if (busy === 'enabling')
50222
- return 'enabling';
50223
- if (busy === 'disabling')
50224
- return 'disabling';
50225
- if (this.isEnabled())
50226
- return 'enabled';
50227
- if (this.permission() === 'denied')
50228
- return 'blocked';
50229
- if (this._lastFailed())
50230
- return 'error';
50231
- return 'idle';
50232
- });
50233
- this.busyLabel = computed(() => {
50234
- const s = this.state();
50235
- if (s === 'enabling')
50236
- return this.tt('enabling');
50237
- if (s === 'disabling')
50238
- return this.tt('disabling');
50239
- return null;
50404
+ this._stream = createRefreshableStream(() => this.notifs.getAll(this.resolvedConfig().maxLoad), {
50405
+ fallback: [],
50406
+ // `notifs.getAll()` YA gatea internamente por `firebaseAuthReady`. Si el
50407
+ // stream también gateara, serían DOS esperas de auth apiladas.
50408
+ gateOnFirebaseAuth: false,
50409
+ // Cold start de PWA en iOS puede pasar de 8s en redes lentas; con 15s
50410
+ // evitamos el falso estado de error.
50411
+ firstEmitTimeoutMs: 15000,
50240
50412
  });
50241
- this.contextMessage = computed(() => {
50242
- switch (this.state()) {
50243
- case 'unsupported':
50244
- return this.tt('permissionUnsupported');
50245
- case 'blocked':
50246
- return this.tt('permissionDeniedInfo');
50247
- case 'error':
50248
- return this.tt('errorGeneric');
50249
- default:
50250
- return null;
50413
+ /** True si el stream falló tras agotar reintentos → muestra estado de error. */
50414
+ this.streamError = this._stream.error;
50415
+ this.loading = computed(() => this._stream.data() === null);
50416
+ /**
50417
+ * Notifs reales mostradas. Filtramos docs sin título Y sin body — son ruido
50418
+ * (típicamente sentinel/system writes pre-schema con title/body opcionales).
50419
+ */
50420
+ this.allNotifs = computed(() => (this._stream.data() ?? []).filter(n => !!(n.title && n.title.trim()) || !!(n.body && n.body.trim())));
50421
+ this.hasMore = computed(() => this.allNotifs().length > this._pageSize());
50422
+ this.showMarkAll = computed(() => this.resolvedConfig().enableMarkAll && !this.loading() && this.allNotifs().some(n => !n.isRead));
50423
+ /** Sub-conjunto paginado, agrupado por período. */
50424
+ this.groups = computed(() => {
50425
+ const items = this.allNotifs().slice(0, this._pageSize());
50426
+ const now = new Date();
50427
+ const buckets = {
50428
+ today: [],
50429
+ thisMonth: [],
50430
+ earlier: [],
50431
+ };
50432
+ for (const n of items) {
50433
+ buckets[this.bucketOf(n, now)].push(n);
50251
50434
  }
50435
+ const out = [];
50436
+ if (buckets.today.length) {
50437
+ out.push({ key: 'today', label: this.tt('groupToday'), items: buckets.today });
50438
+ }
50439
+ if (buckets.thisMonth.length) {
50440
+ out.push({
50441
+ key: 'thisMonth',
50442
+ label: this.tt('groupThisMonth'),
50443
+ items: buckets.thisMonth,
50444
+ });
50445
+ }
50446
+ if (buckets.earlier.length) {
50447
+ out.push({ key: 'earlier', label: this.tt('groupEarlier'), items: buckets.earlier });
50448
+ }
50449
+ return out;
50252
50450
  });
50253
- this.alertVariant = computed(() => {
50254
- const s = this.state();
50255
- if (s === 'error' || s === 'blocked')
50256
- return 'warning';
50257
- if (s === 'unsupported')
50258
- return 'danger';
50259
- return 'info';
50451
+ this.pageTitle = computed(() => this.tt('pageTitle'));
50452
+ this.pageDescription = computed(() => this.tt('pageDescription'));
50453
+ // CTA "activa las notificaciones push" visible cuando pushEnabled() es false.
50454
+ this.ctaTitle = computed(() => this.tt('pushOffTitle'));
50455
+ this.ctaHint = computed(() => this.tt('pushOffHint'));
50456
+ this.ctaButtonProps = computed(() => ({
50457
+ text: this.tt('pushOffCta'),
50458
+ color: 'dark',
50459
+ fill: 'outline',
50460
+ size: 'small',
50461
+ shape: 'round',
50462
+ type: 'button',
50463
+ }));
50464
+ /**
50465
+ * Estado de error de carga: convierte el error del stream en
50466
+ * `EmptyStateMetadata` via el helper canónico `createErrorStateProps`.
50467
+ * El helper decide variante (`offline` vs `error`) por `isNetwork`.
50468
+ * Null cuando el stream no ha fallado.
50469
+ */
50470
+ this.errorState = computed(() => {
50471
+ this.i18n.lang();
50472
+ const err = this.streamError();
50473
+ if (!err)
50474
+ return null;
50475
+ return createErrorStateProps(err, {
50476
+ title: {
50477
+ offline: this.tt('offlineTitle'),
50478
+ error: this.tt('loadErrorTitle'),
50479
+ },
50480
+ description: {
50481
+ offline: this.tt('offlineHint'),
50482
+ error: interpretError(err).message || this.tt('loadErrorHint'),
50483
+ },
50484
+ retryLabel: this.tt('retryCta'),
50485
+ onRetry: () => this._stream.reload(),
50486
+ retrying: this.loading(),
50487
+ });
50260
50488
  });
50261
- this.alertIcon = computed(() => {
50262
- const s = this.state();
50263
- if (s === 'unsupported')
50264
- return 'close-circle-outline';
50265
- return 'alert-circle-outline';
50489
+ /**
50490
+ * Estado vacío (lista real = 0 notificaciones, sin error de carga).
50491
+ * Se construye manualmente con `variant: 'empty'` — sin helper de error.
50492
+ */
50493
+ this.emptyStateProps = computed(() => {
50494
+ this.i18n.lang();
50495
+ return {
50496
+ variant: 'empty',
50497
+ icon: 'notifications-off-outline',
50498
+ title: this.tt('empty'),
50499
+ description: this.tt('emptyHint'),
50500
+ action: {
50501
+ label: this.tt('emptyCta'),
50502
+ handler: () => this.onGoToSettings(),
50503
+ },
50504
+ };
50266
50505
  });
50267
- this.toggleProps = computed(() => ({
50268
- control: this.toggleControl,
50269
- color: 'primary',
50270
- disabled: this._busy() !== null ||
50271
- this.state() === 'unsupported' ||
50272
- this.state() === 'needs-install' ||
50273
- this.state() === 'blocked',
50274
- }));
50275
- this.retryButtonProps = computed(() => ({
50276
- text: this.tt('retryCta'),
50506
+ this.seeMore = computed(() => this.tt('seeMore'));
50507
+ this.seeLess = computed(() => this.tt('seeLess'));
50508
+ this.markAllButtonProps = computed(() => ({
50509
+ text: this.tt('markAll'),
50277
50510
  color: 'dark',
50278
50511
  fill: 'outline',
50512
+ size: 'small',
50513
+ shape: 'round',
50514
+ type: 'button',
50515
+ icon: { name: 'checkmark-done-outline', slot: 'start' },
50516
+ state: this._bulkBusy() ? ComponentStates.WORKING : ComponentStates.ENABLED,
50517
+ }));
50518
+ this.loadMoreButtonProps = computed(() => ({
50519
+ text: this.tt('loadMore'),
50520
+ color: 'dark',
50521
+ fill: 'clear',
50279
50522
  size: 'default',
50280
50523
  shape: 'round',
50281
50524
  type: 'button',
50282
- icon: { name: 'refresh-outline', slot: 'start' },
50283
- state: this._busy() !== null ? ComponentStates.WORKING : ComponentStates.ENABLED,
50525
+ state: this._loadingMore() ? ComponentStates.WORKING : ComponentStates.ENABLED,
50284
50526
  }));
50285
- this.tokenPreview = computed(() => {
50286
- const t = this.currentToken();
50287
- if (!t)
50288
- return null;
50289
- return `${t.slice(0, 22)}…${t.slice(-6)}`;
50290
- });
50291
50527
  const ns = this.ns;
50292
50528
  if (!this.i18n.hasNamespace(ns)) {
50293
- this.i18n.registerContent(ns, NOTIFICATION_PREFERENCES_VIEW_I18N);
50529
+ this.i18n.registerContent(ns, NOTIFICATIONS_VIEW_I18N);
50294
50530
  }
50295
50531
  this.nav.setBackHeader('pageTitle', ns, { withMenu: true });
50296
- void this.refreshBrowserState();
50297
50532
  // Re-aplicar header en cambio de idioma.
50298
50533
  effect(() => {
50299
50534
  this.i18n.lang();
50300
50535
  this.nav.setBackHeader('pageTitle', this.ns, { withMenu: true });
50301
50536
  }, { allowSignalWrites: true });
50302
- // Sync FormControl ←→ estado efectivo. Cuando el estado cambia (cambio de
50303
- // permission por el browser, o snapshot Firestore de `master`), el toggle se
50304
- // re-alinea con la realidad.
50305
- //
50306
- // IMPORTANTE: se ignoran los cambios mientras hay una operación en curso
50307
- // (`_busy() !== null`). Durante `onEnable`/`onDisable` las señales fuente se
50308
- // actualizan en momentos distintos y producirían estados intermedios →
50309
- // flicker visible. El toggle se re-alinea UNA sola vez en el `finally`.
50310
- effect(() => {
50311
- const eff = this.isEnabled();
50312
- if (this._busy() !== null)
50313
- return;
50314
- if (this.toggleControl.value !== eff) {
50315
- this.toggleControl.setValue(eff, { emitEvent: false });
50316
- }
50317
- }, { allowSignalWrites: true });
50318
- // Interceptar click del user: el toggle ya cambió visualmente, ejecutar
50319
- // la acción correspondiente.
50320
- this.toggleControl.valueChanges.subscribe(v => {
50321
- if (this._busy() !== null)
50322
- return;
50323
- if (v && !this.isEnabled()) {
50324
- void this.onEnable();
50325
- }
50326
- else if (!v && this.isEnabled()) {
50327
- void this.onDisable();
50328
- }
50329
- });
50330
- // Detectar regreso a la pestaña — el user pudo haber cambiado permisos
50331
- // desde la config del browser. El refresh va con debounce para no
50332
- // dispararse en ráfagas (en PWA mobile `focus` salta con cada cambio de
50333
- // app / aparición del teclado). El listener se remueve en ngOnDestroy.
50334
- if (typeof window !== 'undefined') {
50335
- window.addEventListener('focus', this.onWindowFocus);
50336
- }
50337
50537
  }
50338
50538
  /**
50339
- * Registra el pull-to-refresh estándar. Aunque esta vista no muestra una
50340
- * lista remota, refleja estado mutable del browser (permiso, token,
50341
- * soporte FCM) que puede cambiar fuera de la app — el refresh re-evalúa todo.
50342
- *
50343
- * Se usa `ngOnInit`/`ngOnDestroy` (NO `ionViewWillEnter`) porque
50344
- * `val-page-wrapper` usa `<router-outlet>` plano — los hooks `ionView*` solo
50345
- * disparan dentro de `<ion-router-outlet>`.
50539
+ * Registra el pull-to-refresh estándar. Usamos `ngOnInit`/`ngOnDestroy`
50540
+ * (NO `ionViewWillEnter`) porque `val-page-wrapper` usa `<router-outlet>`
50541
+ * plano los hooks `ionView*` solo disparan dentro de `<ion-router-outlet>`.
50346
50542
  */
50347
50543
  ngOnInit() {
50348
- this.pageRefresh.register(() => this.refreshBrowserState());
50544
+ this.pageRefresh.register(() => this.reload());
50349
50545
  }
50546
+ /** Quita el handler al destruir la vista — el refresher desaparece. */
50350
50547
  ngOnDestroy() {
50351
50548
  this.pageRefresh.unregister();
50352
- if (typeof window !== 'undefined') {
50353
- window.removeEventListener('focus', this.onWindowFocus);
50354
- }
50355
- if (this.focusDebounceTimer !== null) {
50356
- clearTimeout(this.focusDebounceTimer);
50357
- this.focusDebounceTimer = null;
50358
- }
50359
50549
  }
50360
- /** Refresca el estado del browser con debounce — evita ráfagas de `focus`. */
50361
- scheduleBrowserRefresh() {
50362
- if (this.focusDebounceTimer !== null) {
50363
- clearTimeout(this.focusDebounceTimer);
50364
- }
50365
- this.focusDebounceTimer = setTimeout(() => {
50366
- this.focusDebounceTimer = null;
50367
- void this.refreshBrowserState();
50368
- }, this.FOCUS_DEBOUNCE_MS);
50550
+ /**
50551
+ * Refresh manual: re-suscribe el stream Firestore (listener nuevo) vía el
50552
+ * helper. Resuelve tras un margen breve — el stream es real-time y re-emite
50553
+ * en milisegundos; el delay sólo mantiene el spinner visible lo justo.
50554
+ */
50555
+ async reload() {
50556
+ this._stream.reload();
50557
+ await new Promise(r => setTimeout(r, 700));
50369
50558
  }
50370
50559
  // ---------------------------------------------------------------------------
50371
- // Actions
50560
+ // Per-item helpers (puros — usables desde template).
50372
50561
  // ---------------------------------------------------------------------------
50562
+ openButtonProps() {
50563
+ return {
50564
+ text: this.tt('open'),
50565
+ color: 'dark',
50566
+ fill: 'clear',
50567
+ size: 'small',
50568
+ shape: 'round',
50569
+ type: 'button',
50570
+ icon: { name: 'open-outline', slot: 'end' },
50571
+ };
50572
+ }
50573
+ isExpanded(id) {
50574
+ return this._expanded().has(id);
50575
+ }
50576
+ toggleExpand(id) {
50577
+ const next = new Set(this._expanded());
50578
+ if (next.has(id))
50579
+ next.delete(id);
50580
+ else
50581
+ next.add(id);
50582
+ this._expanded.set(next);
50583
+ }
50584
+ isLong(body) {
50585
+ if (!body)
50586
+ return false;
50587
+ return body.length > 110 || body.includes('\n');
50588
+ }
50373
50589
  /**
50374
- * Secuencia completa de activación.
50375
- *
50376
- * Toda la mecánica (permission → SW ready → getToken con retry → watchdog de
50377
- * auto-reload) vive en `MessagingService.enable()` de la lib. El paso de
50378
- * registro del device en el backend se pasa como callback (`registerDevice`)
50379
- * porque la lib no puede depender de `AuthService` sin crear un ciclo.
50380
- *
50381
- * `enable()` no hace throw: resuelve con un `EnablePushResult` descriptivo.
50590
+ * Icon resolver con orden de fallback:
50591
+ * 1. notif.payload.icon (forward-compat, sin breaking change en schema)
50592
+ * 2. mapa por notif.type
50593
+ * 3. default notifications-outline
50382
50594
  */
50383
- async onEnable() {
50384
- this._busy.set('enabling');
50385
- this._lastFailed.set(false);
50386
- try {
50387
- const result = await this.messaging.enable({
50388
- registerDevice: token => this.auth.registerDevice(token).then(r => r.registered),
50389
- });
50390
- if (result.status === 'enabled') {
50391
- // Persistir flag master si aún no estaba activo.
50392
- if (!this.prefs.notificationsMaster()) {
50393
- await this.prefs.setNotificationsMaster(true);
50394
- }
50395
- this.toast.show({
50396
- message: this.tt('enableSuccess'),
50397
- duration: 2000,
50398
- color: 'dark',
50399
- position: 'top',
50400
- });
50401
- this.resolvedConfig().onPushEnabled?.();
50402
- }
50403
- else if (result.status === 'timeout' && result.reloaded) {
50404
- // La lib disparó el auto-reload — la página está por recargarse.
50405
- return;
50595
+ resolveIcon(n) {
50596
+ const hint = n.payload?.['icon'];
50597
+ if (hint?.value) {
50598
+ if (hint.type === 'image' || hint.type === 'avatar') {
50599
+ return { kind: hint.type, src: hint.value };
50406
50600
  }
50407
- else {
50408
- // denied / unsupported / error / timeout sin reload → fallo recuperable.
50409
- this._lastFailed.set(true);
50410
- this.toast.show({
50411
- message: this.tt('errorGeneric'),
50412
- duration: 3000,
50413
- color: 'dark',
50414
- position: 'top',
50415
- });
50601
+ // Ignoramos hint.color — todos los iconos van en dark por convención UX.
50602
+ return { kind: 'ionicon', name: hint.value, color: 'dark' };
50603
+ }
50604
+ const t = n.type;
50605
+ const name = (() => {
50606
+ switch (t) {
50607
+ case 'approval':
50608
+ return 'checkmark-circle-outline';
50609
+ case 'chat':
50610
+ case 'chatbot':
50611
+ return 'chatbubble-outline';
50612
+ case 'comment':
50613
+ return 'chatbox-ellipses-outline';
50614
+ case 'reminder':
50615
+ return 'alarm-outline';
50616
+ case 'system':
50617
+ return 'cog-outline';
50618
+ case 'user':
50619
+ case 'mention':
50620
+ return 'person-outline';
50621
+ case 'document':
50622
+ return 'document-text-outline';
50623
+ default:
50624
+ return 'notifications-outline';
50416
50625
  }
50626
+ })();
50627
+ return { kind: 'ionicon', name, color: 'dark' };
50628
+ }
50629
+ ioniconName(n) {
50630
+ const r = this.resolveIcon(n);
50631
+ return r.kind === 'ionicon' ? r.name : '';
50632
+ }
50633
+ ioniconColor(n) {
50634
+ const r = this.resolveIcon(n);
50635
+ return r.kind === 'ionicon' ? r.color : undefined;
50636
+ }
50637
+ imageSrc(n) {
50638
+ const r = this.resolveIcon(n);
50639
+ return r.kind === 'image' || r.kind === 'avatar' ? r.src : '';
50640
+ }
50641
+ ionColor(token) {
50642
+ if (!token)
50643
+ return 'var(--ion-color-primary)';
50644
+ return `var(--ion-color-${token})`;
50645
+ }
50646
+ relativeTime(n) {
50647
+ const d = n.createdAt instanceof Date ? n.createdAt : null;
50648
+ if (!d)
50649
+ return '';
50650
+ const diffMs = Date.now() - d.getTime();
50651
+ const min = Math.floor(diffMs / 60000);
50652
+ if (min < 1)
50653
+ return this.tt('timeNow');
50654
+ if (min < 60)
50655
+ return this.tt('timeMinutes').replace('{n}', String(min));
50656
+ const h = Math.floor(min / 60);
50657
+ if (h < 24)
50658
+ return this.tt('timeHours').replace('{n}', String(h));
50659
+ const days = Math.floor(h / 24);
50660
+ return this.tt('timeDays').replace('{n}', String(days));
50661
+ }
50662
+ /** Devuelve label "Desde {appName}" si la notif viene de otra app, sino null. */
50663
+ showCrossAppBadge(n) {
50664
+ const currentApp = 'showcase';
50665
+ if (!n.appId || n.appId === currentApp)
50666
+ return null;
50667
+ return this.tt('crossApp').replace('{app}', n.appId);
50668
+ }
50669
+ // ---------------------------------------------------------------------------
50670
+ // Actions
50671
+ // ---------------------------------------------------------------------------
50672
+ async onOpen(n) {
50673
+ try {
50674
+ const result = await this.action.open(n);
50675
+ this.resolvedConfig().onNotificationOpen?.(n, result);
50417
50676
  }
50418
- catch {
50419
- // `enable()` no hace throw, pero el callback de registro sí puede.
50420
- this._lastFailed.set(true);
50421
- this.toast.show({
50422
- message: this.tt('errorGeneric'),
50423
- duration: 3000,
50424
- color: 'dark',
50425
- position: 'top',
50677
+ catch (err) {
50678
+ this.errors.handle(err, {
50679
+ context: 'notifications.open',
50680
+ fallbackKey: 'openError',
50681
+ i18nNamespace: this.ns,
50426
50682
  });
50427
50683
  }
50428
- finally {
50429
- // Único refresh final: re-alinea el toggle UNA sola vez con el estado real.
50430
- await this.refreshBrowserState();
50431
- this._busy.set(null);
50432
- }
50433
50684
  }
50434
- async onDisable() {
50435
- this._busy.set('disabling');
50436
- this._lastFailed.set(false);
50685
+ async onMarkAll() {
50686
+ if (this._bulkBusy())
50687
+ return;
50688
+ this._bulkBusy.set(true);
50437
50689
  try {
50438
- await this.auth.disableNotifications();
50439
- if (this.prefs.notificationsMaster()) {
50440
- await this.prefs.setNotificationsMaster(false);
50441
- }
50690
+ await this.notifs.markAllAsRead();
50442
50691
  this.toast.show({
50443
- message: this.tt('disableSuccess'),
50444
- duration: 2000,
50692
+ message: this.tt('markedAllSuccess'),
50445
50693
  color: 'dark',
50694
+ duration: 2500,
50446
50695
  position: 'top',
50447
50696
  });
50448
- this.resolvedConfig().onPushDisabled?.();
50449
50697
  }
50450
- catch {
50451
- this._lastFailed.set(true);
50698
+ catch (err) {
50699
+ this.errors.handle(err, {
50700
+ context: 'notifications.markAll',
50701
+ fallbackKey: 'markedAllError',
50702
+ i18nNamespace: this.ns,
50703
+ });
50452
50704
  }
50453
50705
  finally {
50454
- await this.refreshBrowserState();
50455
- this._busy.set(null);
50706
+ this._bulkBusy.set(false);
50456
50707
  }
50457
50708
  }
50458
- /** Reintentar la secuencia completa de activación, sin importar el estado actual. */
50459
- async onRetry() {
50460
- this._lastFailed.set(false);
50461
- await this.onEnable();
50709
+ onLoadMore() {
50710
+ if (this._loadingMore())
50711
+ return;
50712
+ this._loadingMore.set(true);
50713
+ // Slice viene del cache local — no hay fetch real, pero un breve spinner
50714
+ // confirma al user que el click registró y suaviza la aparición de items.
50715
+ setTimeout(() => {
50716
+ this._pageSize.update(n => n + this.pageStep);
50717
+ this._loadingMore.set(false);
50718
+ }, 250);
50462
50719
  }
50463
- toggleDetails() {
50464
- this.showDetails.update(v => !v);
50720
+ onGoToSettings() {
50721
+ this.nav.navigateByUrl(this.resolvedConfig().settingsNotificationsPath);
50465
50722
  }
50466
50723
  // ---------------------------------------------------------------------------
50467
- // Privados
50724
+ // Grouping helpers
50468
50725
  // ---------------------------------------------------------------------------
50469
- async refreshBrowserState() {
50470
- this.isSupported.set(await this.messaging.isSupported());
50471
- this.permission.set(this.auth.getNotificationPermissionState());
50472
- this.currentToken.set(this.messaging.currentToken ?? null);
50473
- void this.repairTokenIfNeeded();
50474
- }
50475
- /**
50476
- * Repara el token FCM en background.
50477
- *
50478
- * Si el usuario tiene push activo (`master && granted`) pero no hay token en
50479
- * memoria — caso típico tras un cold start de la PWA — re-obtenemos el token
50480
- * y re-registramos el device sin tocar el toggle ni mostrar un error
50481
- * alarmista. El token es un detalle de implementación reparable, no parte del
50482
- * estado de UI (ver `isEnabled`).
50483
- */
50484
- async repairTokenIfNeeded() {
50485
- if (this._busy() !== null)
50486
- return;
50487
- if (this._repairingToken)
50488
- return;
50489
- if (!this.isEnabled())
50490
- return;
50491
- if (this.currentToken())
50492
- return;
50493
- this._repairingToken = true;
50494
- try {
50495
- const token = await this.messaging.getToken();
50496
- if (token) {
50497
- this.currentToken.set(token);
50498
- await this.auth.registerDevice(token);
50499
- }
50500
- }
50501
- catch {
50502
- // Reparación best-effort: no degradamos el toggle ni mostramos error.
50503
- }
50504
- finally {
50505
- this._repairingToken = false;
50726
+ bucketOf(n, now) {
50727
+ const d = n.createdAt instanceof Date ? n.createdAt : null;
50728
+ if (!d)
50729
+ return 'earlier';
50730
+ if (d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate()) {
50731
+ return 'today';
50506
50732
  }
50507
- }
50508
- /**
50509
- * iOS browser sin estar instalado como PWA — push web no disponible.
50510
- * Aplica a Safari y a Edge/Chrome/Firefox en iOS (todos WebKit por mandato Apple).
50511
- */
50512
- detectNeedsIOSInstall() {
50513
- if (typeof window === 'undefined' || typeof navigator === 'undefined') {
50514
- return false;
50733
+ if (d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth()) {
50734
+ return 'thisMonth';
50515
50735
  }
50516
- const ua = navigator.userAgent || '';
50517
- const isIOS = /iPad|iPhone|iPod/.test(ua);
50518
- if (!isIOS)
50519
- return false;
50520
- const isStandalone = window.matchMedia?.('(display-mode: standalone)').matches ||
50521
- navigator.standalone === true;
50522
- return !isStandalone;
50736
+ return 'earlier';
50523
50737
  }
50524
50738
  tt(key) {
50525
50739
  return this.i18n.t(key, this.ns);
50526
50740
  }
50527
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: NotificationPreferencesViewComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
50528
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: NotificationPreferencesViewComponent, isStandalone: true, selector: "val-notification-preferences-view", inputs: { config: "config" }, ngImport: i0, template: `
50741
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: NotificationsViewComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
50742
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: NotificationsViewComponent, isStandalone: true, selector: "val-notifications-view", inputs: { config: "config" }, ngImport: i0, template: `
50529
50743
  <div class="page">
50530
50744
  <header class="page-header">
50531
50745
  <val-display [props]="{ size: 'small', color: 'dark', content: pageTitle() }" />
@@ -50537,112 +50751,135 @@ class NotificationPreferencesViewComponent {
50537
50751
  content: pageDescription(),
50538
50752
  }"
50539
50753
  />
50754
+ @if (showMarkAll()) {
50755
+ <div class="row-actions">
50756
+ <val-button [props]="markAllButtonProps()" (click)="onMarkAll()" />
50757
+ </div>
50758
+ }
50540
50759
  </header>
50541
50760
 
50542
- @if (state() === 'needs-install') {
50543
- <section class="settings-section">
50544
- <val-title
50545
- [props]="{
50546
- size: 'medium',
50547
- color: 'dark',
50548
- bold: true,
50549
- content: tt('iosInstallTitle'),
50550
- }"
50551
- />
50552
- <div class="section-body">
50761
+ @if (resolvedConfig().showPushOffCta && !pushEnabled()) {
50762
+ <section class="notif-cta">
50763
+ <ion-icon class="notif-cta__icon" name="notifications-off-outline" aria-hidden="true" />
50764
+ <div class="notif-cta__body">
50765
+ <val-text
50766
+ [props]="{
50767
+ content: ctaTitle(),
50768
+ size: 'medium',
50769
+ color: 'dark',
50770
+ bold: true,
50771
+ }"
50772
+ />
50553
50773
  <val-text
50554
50774
  [props]="{
50775
+ content: ctaHint(),
50555
50776
  size: 'small',
50556
50777
  color: 'medium',
50557
50778
  bold: false,
50558
- content: tt('iosInstallHint'),
50559
50779
  }"
50560
50780
  />
50561
- <ol class="install-steps">
50562
- <li>{{ tt('iosInstallStep1') }}</li>
50563
- <li>{{ tt('iosInstallStep2') }}</li>
50564
- <li>{{ tt('iosInstallStep3') }}</li>
50565
- <li>{{ tt('iosInstallStep4') }}</li>
50566
- </ol>
50781
+ <div class="row-actions">
50782
+ <val-button [props]="ctaButtonProps()" (click)="onGoToSettings()" />
50783
+ </div>
50567
50784
  </div>
50568
50785
  </section>
50569
50786
  }
50570
50787
 
50571
- <section class="settings-section">
50572
- <div class="notif-row">
50573
- <div class="notif-row__icon">
50574
- <ion-icon name="notifications-outline" aria-hidden="true" />
50575
- </div>
50576
- <div class="notif-row__body">
50788
+ @if (loading()) {
50789
+ <val-skeleton-layout [props]="{ preset: 'list', rows: 6 }" />
50790
+ } @else if (errorState()) {
50791
+ <val-empty-state [props]="errorState()!" />
50792
+ } @else if (allNotifs().length === 0) {
50793
+ <val-empty-state [props]="emptyStateProps()" />
50794
+ } @else {
50795
+ @for (group of groups(); track group.key) {
50796
+ <section class="group">
50577
50797
  <val-title
50578
50798
  [props]="{
50579
50799
  size: 'medium',
50580
50800
  color: 'dark',
50581
50801
  bold: true,
50582
- content: tt('simpleTitle'),
50802
+ content: group.label,
50583
50803
  }"
50584
50804
  />
50585
- <val-text
50586
- [props]="{
50587
- size: 'small',
50588
- color: 'medium',
50589
- bold: false,
50590
- content: tt('simpleDescription'),
50591
- }"
50592
- />
50593
- @if (busyLabel(); as label) {
50594
- <span class="notif-row__busy">{{ label }}</span>
50595
- }
50596
- </div>
50597
- <div class="notif-row__control">
50598
- <val-toggle-input [props]="toggleProps()" />
50599
- </div>
50600
- </div>
50805
+ <ul class="notif-list">
50806
+ @for (n of group.items; track n.id) {
50807
+ <li class="notif" [class.notif--unread]="!n.isRead">
50808
+ <div class="notif__icon">
50809
+ @switch (resolveIcon(n).kind) {
50810
+ @case ('ionicon') {
50811
+ <ion-icon
50812
+ [name]="ioniconName(n)"
50813
+ [style.color]="ionColor(ioniconColor(n))"
50814
+ aria-hidden="true"
50815
+ />
50816
+ }
50817
+ @case ('image') {
50818
+ <img [src]="imageSrc(n)" alt="" loading="lazy" />
50819
+ }
50820
+ @case ('avatar') {
50821
+ <img class="notif__avatar" [src]="imageSrc(n)" alt="" loading="lazy" />
50822
+ }
50823
+ }
50824
+ </div>
50601
50825
 
50602
- @if (contextMessage(); as msg) {
50603
- <div class="alert" [class]="'alert--' + alertVariant()">
50604
- <ion-icon [name]="alertIcon()" aria-hidden="true" />
50605
- <span>{{ msg }}</span>
50606
- </div>
50826
+ <div class="notif__body">
50827
+ <div class="notif__head">
50828
+ <val-text
50829
+ [props]="{
50830
+ content: n.title || '—',
50831
+ size: 'medium',
50832
+ color: 'dark',
50833
+ bold: true,
50834
+ }"
50835
+ />
50836
+ <span class="notif__time">{{ relativeTime(n) }}</span>
50837
+ </div>
50838
+ @if (n.body) {
50839
+ <p class="notif__text" [class.notif__text--clamped]="!isExpanded(n.id) && isLong(n.body)">
50840
+ {{ n.body }}
50841
+ </p>
50842
+ @if (isLong(n.body)) {
50843
+ <button class="notif__expand" type="button" (click)="toggleExpand(n.id)">
50844
+ {{ isExpanded(n.id) ? seeLess() : seeMore() }}
50845
+ </button>
50846
+ }
50847
+ }
50848
+ @if (showCrossAppBadge(n); as appName) {
50849
+ <span class="notif__app">{{ appName }}</span>
50850
+ }
50851
+ @if (n.actionRoute) {
50852
+ <div class="row-actions row-actions--end">
50853
+ <val-button [props]="openButtonProps()" (click)="onOpen(n)" />
50854
+ </div>
50855
+ }
50856
+ </div>
50857
+ </li>
50858
+ }
50859
+ </ul>
50860
+ </section>
50607
50861
  }
50608
50862
 
50609
- @if (state() === 'error') {
50610
- <div class="row-actions">
50611
- <val-button [props]="retryButtonProps()" (click)="onRetry()" />
50863
+ @if (hasMore()) {
50864
+ <div class="row-actions row-actions--center">
50865
+ <val-button [props]="loadMoreButtonProps()" (click)="onLoadMore()" />
50612
50866
  </div>
50613
50867
  }
50614
- </section>
50615
-
50616
- @if (resolvedConfig().showDetails && state() !== 'needs-install' && state() !== 'unsupported') {
50617
- <section class="settings-section">
50618
- <button class="details-toggle" type="button" (click)="toggleDetails()">
50619
- <span>{{ showDetails() ? tt('detailsHide') : tt('detailsShow') }}</span>
50620
- <ion-icon [name]="showDetails() ? 'chevron-up-outline' : 'chevron-down-outline'" aria-hidden="true" />
50621
- </button>
50622
- @if (showDetails()) {
50623
- <dl class="details">
50624
- <div class="details__row">
50625
- <dt>{{ tt('detailsToken') }}</dt>
50626
- <dd>{{ tokenPreview() ?? tt('detailsNone') }}</dd>
50627
- </div>
50628
- <div class="details__row">
50629
- <dt>{{ tt('diagnosticsPermission') }}</dt>
50630
- <dd>{{ permission() }}</dd>
50631
- </div>
50632
- <div class="details__row">
50633
- <dt>{{ tt('diagnosticsSupported') }}</dt>
50634
- <dd>{{ isSupported() ? '✓' : '✗' }}</dd>
50635
- </div>
50636
- </dl>
50637
- }
50638
- </section>
50639
50868
  }
50640
50869
  </div>
50641
- `, isInline: true, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:8px}.settings-section{padding:16px 0}.settings-section+.settings-section{border-top:1px solid var(--val-border-color, rgba(0, 0, 0, .08))}.section-body{margin-top:16px;display:flex;flex-direction:column;gap:10px}.row-actions{margin-top:12px}.notif-row{display:grid;grid-template-columns:44px 1fr auto;gap:16px;align-items:center}.notif-row__icon{width:44px;height:44px;border-radius:50%;background:var(--ion-color-light, rgba(0, 0, 0, .06));display:flex;align-items:center;justify-content:center}.notif-row__icon ion-icon{font-size:22px;color:var(--ion-color-dark, #1a1a1a)}.notif-row__body{display:flex;flex-direction:column;gap:4px;min-width:0}.notif-row__busy{font-size:12px;color:var(--ion-color-medium, #92949c);font-style:italic;margin-top:4px}.notif-row__control{flex-shrink:0}.alert{display:flex;gap:10px;align-items:flex-start;margin-top:12px;padding:12px 14px;border-radius:10px;font-size:13px;line-height:1.45;background:var(--ion-color-light, rgba(0, 0, 0, .04));color:var(--ion-color-dark, #1a1a1a)}.alert ion-icon{font-size:18px;flex-shrink:0;margin-top:1px}.alert--warning ion-icon{color:var(--ion-color-warning-shade, #c79e08)}.alert--danger ion-icon{color:var(--ion-color-danger-shade, #cf3c4f)}.alert--info ion-icon{color:var(--ion-color-medium, #6b6b6b)}.install-steps{margin:0;padding-left:20px;font-size:13px;line-height:1.6;color:var(--ion-color-dark, #1a1a1a)}.install-steps li+li{margin-top:4px}.details-toggle{display:flex;align-items:center;gap:6px;background:none;border:0;padding:0;font-size:13px;color:var(--ion-color-medium, #6b6b6b);cursor:pointer}.details-toggle ion-icon{font-size:14px}.details{margin:12px 0 0;display:flex;flex-direction:column;gap:6px}.details__row{display:grid;grid-template-columns:140px 1fr;gap:12px;font-size:12px;align-items:baseline}.details__row dt{margin:0;color:var(--ion-color-medium, #6b6b6b);text-transform:uppercase;letter-spacing:.4px;font-weight:500}.details__row dd{margin:0;color:var(--ion-color-dark, #1a1a1a);font-family:ui-monospace,SFMono-Regular,monospace;font-size:11px;word-break:break-all}\n"], dependencies: [{ kind: "component", type: IonIcon, selector: "ion-icon", inputs: ["color", "flipRtl", "icon", "ios", "lazy", "md", "mode", "name", "sanitize", "size", "src"] }, { kind: "component", type: ToggleInputComponent, selector: "val-toggle-input", inputs: ["preset", "props"] }, { kind: "component", type: ButtonComponent, selector: "val-button", inputs: ["preset", "props"], outputs: ["onClick"] }, { kind: "component", type: DisplayComponent, selector: "val-display", inputs: ["props"] }, { kind: "component", type: TitleComponent, selector: "val-title", inputs: ["props"] }, { kind: "component", type: TextComponent, selector: "val-text", inputs: ["props"] }] }); }
50870
+ `, isInline: true, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:16px}.row-actions{margin-top:12px}.row-actions--center{display:flex;justify-content:center;margin-top:20px}.row-actions--end{display:flex;justify-content:flex-end;margin-top:8px}.notif-cta{display:flex;gap:12px;align-items:flex-start;padding:14px 16px;margin-bottom:16px;border:1px solid var(--ion-color-light-shade, rgba(0, 0, 0, .08));border-radius:14px;background:var(--ion-color-light)}.notif-cta__icon{font-size:24px;color:var(--ion-color-dark, #1a1a1a);flex-shrink:0;margin-top:2px}.notif-cta__body{display:flex;flex-direction:column;gap:2px}.group{padding:12px 0}.group+.group{border-top:1px solid var(--val-border-color, rgba(0, 0, 0, .08))}.notif-list{list-style:none;padding:0;margin:12px 0 0;display:flex;flex-direction:column;gap:10px}.notif{display:grid;grid-template-columns:44px 1fr;gap:12px;padding:12px;border-radius:12px;background:transparent;transition:background .12s ease}.notif--unread{background:var(--ion-color-light, rgba(0, 0, 0, .04))}.notif__icon{width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center;background:var(--ion-color-light, rgba(0, 0, 0, .06));overflow:hidden}.notif__icon ion-icon{font-size:22px}.notif__icon img{width:100%;height:100%;object-fit:cover}.notif__avatar{border-radius:50%}.notif__body{display:flex;flex-direction:column;gap:4px;min-width:0}.notif__head{display:flex;justify-content:space-between;align-items:baseline;gap:8px}.notif__time{font-size:12px;color:var(--ion-color-medium, #92949c);flex-shrink:0}.notif__text{margin:0;font-size:14px;line-height:1.4;color:var(--ion-color-dark, #1a1a1a);white-space:pre-wrap}.notif__text--clamped{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.notif__expand{background:none;border:0;padding:0;font-size:13px;color:var(--ion-color-dark, #1a1a1a);cursor:pointer;align-self:flex-start;text-decoration:underline}.notif__app{font-size:11px;text-transform:uppercase;letter-spacing:.4px;color:var(--ion-color-medium, #92949c);margin-top:2px}\n"], dependencies: [{ kind: "component", type: IonIcon, selector: "ion-icon", inputs: ["color", "flipRtl", "icon", "ios", "lazy", "md", "mode", "name", "sanitize", "size", "src"] }, { kind: "component", type: DisplayComponent, selector: "val-display", inputs: ["props"] }, { kind: "component", type: TitleComponent, selector: "val-title", inputs: ["props"] }, { kind: "component", type: TextComponent, selector: "val-text", inputs: ["props"] }, { kind: "component", type: ButtonComponent, selector: "val-button", inputs: ["preset", "props"], outputs: ["onClick"] }, { kind: "component", type: SkeletonLayoutComponent, selector: "val-skeleton-layout", inputs: ["props"] }, { kind: "component", type: EmptyStateComponent, selector: "val-empty-state", inputs: ["props"] }] }); }
50642
50871
  }
50643
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: NotificationPreferencesViewComponent, decorators: [{
50872
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: NotificationsViewComponent, decorators: [{
50644
50873
  type: Component,
50645
- args: [{ selector: 'val-notification-preferences-view', standalone: true, imports: [IonIcon, ToggleInputComponent, ButtonComponent, DisplayComponent, TitleComponent, TextComponent], template: `
50874
+ args: [{ selector: 'val-notifications-view', standalone: true, imports: [
50875
+ IonIcon,
50876
+ DisplayComponent,
50877
+ TitleComponent,
50878
+ TextComponent,
50879
+ ButtonComponent,
50880
+ SkeletonLayoutComponent,
50881
+ EmptyStateComponent,
50882
+ ], template: `
50646
50883
  <div class="page">
50647
50884
  <header class="page-header">
50648
50885
  <val-display [props]="{ size: 'small', color: 'dark', content: pageTitle() }" />
@@ -50654,1827 +50891,1577 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
50654
50891
  content: pageDescription(),
50655
50892
  }"
50656
50893
  />
50894
+ @if (showMarkAll()) {
50895
+ <div class="row-actions">
50896
+ <val-button [props]="markAllButtonProps()" (click)="onMarkAll()" />
50897
+ </div>
50898
+ }
50657
50899
  </header>
50658
50900
 
50659
- @if (state() === 'needs-install') {
50660
- <section class="settings-section">
50661
- <val-title
50662
- [props]="{
50663
- size: 'medium',
50664
- color: 'dark',
50665
- bold: true,
50666
- content: tt('iosInstallTitle'),
50667
- }"
50668
- />
50669
- <div class="section-body">
50901
+ @if (resolvedConfig().showPushOffCta && !pushEnabled()) {
50902
+ <section class="notif-cta">
50903
+ <ion-icon class="notif-cta__icon" name="notifications-off-outline" aria-hidden="true" />
50904
+ <div class="notif-cta__body">
50905
+ <val-text
50906
+ [props]="{
50907
+ content: ctaTitle(),
50908
+ size: 'medium',
50909
+ color: 'dark',
50910
+ bold: true,
50911
+ }"
50912
+ />
50670
50913
  <val-text
50671
50914
  [props]="{
50915
+ content: ctaHint(),
50672
50916
  size: 'small',
50673
50917
  color: 'medium',
50674
50918
  bold: false,
50675
- content: tt('iosInstallHint'),
50676
50919
  }"
50677
50920
  />
50678
- <ol class="install-steps">
50679
- <li>{{ tt('iosInstallStep1') }}</li>
50680
- <li>{{ tt('iosInstallStep2') }}</li>
50681
- <li>{{ tt('iosInstallStep3') }}</li>
50682
- <li>{{ tt('iosInstallStep4') }}</li>
50683
- </ol>
50921
+ <div class="row-actions">
50922
+ <val-button [props]="ctaButtonProps()" (click)="onGoToSettings()" />
50923
+ </div>
50684
50924
  </div>
50685
50925
  </section>
50686
50926
  }
50687
50927
 
50688
- <section class="settings-section">
50689
- <div class="notif-row">
50690
- <div class="notif-row__icon">
50691
- <ion-icon name="notifications-outline" aria-hidden="true" />
50692
- </div>
50693
- <div class="notif-row__body">
50928
+ @if (loading()) {
50929
+ <val-skeleton-layout [props]="{ preset: 'list', rows: 6 }" />
50930
+ } @else if (errorState()) {
50931
+ <val-empty-state [props]="errorState()!" />
50932
+ } @else if (allNotifs().length === 0) {
50933
+ <val-empty-state [props]="emptyStateProps()" />
50934
+ } @else {
50935
+ @for (group of groups(); track group.key) {
50936
+ <section class="group">
50694
50937
  <val-title
50695
50938
  [props]="{
50696
50939
  size: 'medium',
50697
50940
  color: 'dark',
50698
50941
  bold: true,
50699
- content: tt('simpleTitle'),
50700
- }"
50701
- />
50702
- <val-text
50703
- [props]="{
50704
- size: 'small',
50705
- color: 'medium',
50706
- bold: false,
50707
- content: tt('simpleDescription'),
50942
+ content: group.label,
50708
50943
  }"
50709
50944
  />
50710
- @if (busyLabel(); as label) {
50711
- <span class="notif-row__busy">{{ label }}</span>
50712
- }
50713
- </div>
50714
- <div class="notif-row__control">
50715
- <val-toggle-input [props]="toggleProps()" />
50716
- </div>
50717
- </div>
50945
+ <ul class="notif-list">
50946
+ @for (n of group.items; track n.id) {
50947
+ <li class="notif" [class.notif--unread]="!n.isRead">
50948
+ <div class="notif__icon">
50949
+ @switch (resolveIcon(n).kind) {
50950
+ @case ('ionicon') {
50951
+ <ion-icon
50952
+ [name]="ioniconName(n)"
50953
+ [style.color]="ionColor(ioniconColor(n))"
50954
+ aria-hidden="true"
50955
+ />
50956
+ }
50957
+ @case ('image') {
50958
+ <img [src]="imageSrc(n)" alt="" loading="lazy" />
50959
+ }
50960
+ @case ('avatar') {
50961
+ <img class="notif__avatar" [src]="imageSrc(n)" alt="" loading="lazy" />
50962
+ }
50963
+ }
50964
+ </div>
50718
50965
 
50719
- @if (contextMessage(); as msg) {
50720
- <div class="alert" [class]="'alert--' + alertVariant()">
50721
- <ion-icon [name]="alertIcon()" aria-hidden="true" />
50722
- <span>{{ msg }}</span>
50723
- </div>
50966
+ <div class="notif__body">
50967
+ <div class="notif__head">
50968
+ <val-text
50969
+ [props]="{
50970
+ content: n.title || '—',
50971
+ size: 'medium',
50972
+ color: 'dark',
50973
+ bold: true,
50974
+ }"
50975
+ />
50976
+ <span class="notif__time">{{ relativeTime(n) }}</span>
50977
+ </div>
50978
+ @if (n.body) {
50979
+ <p class="notif__text" [class.notif__text--clamped]="!isExpanded(n.id) && isLong(n.body)">
50980
+ {{ n.body }}
50981
+ </p>
50982
+ @if (isLong(n.body)) {
50983
+ <button class="notif__expand" type="button" (click)="toggleExpand(n.id)">
50984
+ {{ isExpanded(n.id) ? seeLess() : seeMore() }}
50985
+ </button>
50986
+ }
50987
+ }
50988
+ @if (showCrossAppBadge(n); as appName) {
50989
+ <span class="notif__app">{{ appName }}</span>
50990
+ }
50991
+ @if (n.actionRoute) {
50992
+ <div class="row-actions row-actions--end">
50993
+ <val-button [props]="openButtonProps()" (click)="onOpen(n)" />
50994
+ </div>
50995
+ }
50996
+ </div>
50997
+ </li>
50998
+ }
50999
+ </ul>
51000
+ </section>
50724
51001
  }
50725
51002
 
50726
- @if (state() === 'error') {
50727
- <div class="row-actions">
50728
- <val-button [props]="retryButtonProps()" (click)="onRetry()" />
51003
+ @if (hasMore()) {
51004
+ <div class="row-actions row-actions--center">
51005
+ <val-button [props]="loadMoreButtonProps()" (click)="onLoadMore()" />
50729
51006
  </div>
50730
51007
  }
50731
- </section>
50732
-
50733
- @if (resolvedConfig().showDetails && state() !== 'needs-install' && state() !== 'unsupported') {
50734
- <section class="settings-section">
50735
- <button class="details-toggle" type="button" (click)="toggleDetails()">
50736
- <span>{{ showDetails() ? tt('detailsHide') : tt('detailsShow') }}</span>
50737
- <ion-icon [name]="showDetails() ? 'chevron-up-outline' : 'chevron-down-outline'" aria-hidden="true" />
50738
- </button>
50739
- @if (showDetails()) {
50740
- <dl class="details">
50741
- <div class="details__row">
50742
- <dt>{{ tt('detailsToken') }}</dt>
50743
- <dd>{{ tokenPreview() ?? tt('detailsNone') }}</dd>
50744
- </div>
50745
- <div class="details__row">
50746
- <dt>{{ tt('diagnosticsPermission') }}</dt>
50747
- <dd>{{ permission() }}</dd>
50748
- </div>
50749
- <div class="details__row">
50750
- <dt>{{ tt('diagnosticsSupported') }}</dt>
50751
- <dd>{{ isSupported() ? '✓' : '✗' }}</dd>
50752
- </div>
50753
- </dl>
50754
- }
50755
- </section>
50756
51008
  }
50757
51009
  </div>
50758
- `, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:8px}.settings-section{padding:16px 0}.settings-section+.settings-section{border-top:1px solid var(--val-border-color, rgba(0, 0, 0, .08))}.section-body{margin-top:16px;display:flex;flex-direction:column;gap:10px}.row-actions{margin-top:12px}.notif-row{display:grid;grid-template-columns:44px 1fr auto;gap:16px;align-items:center}.notif-row__icon{width:44px;height:44px;border-radius:50%;background:var(--ion-color-light, rgba(0, 0, 0, .06));display:flex;align-items:center;justify-content:center}.notif-row__icon ion-icon{font-size:22px;color:var(--ion-color-dark, #1a1a1a)}.notif-row__body{display:flex;flex-direction:column;gap:4px;min-width:0}.notif-row__busy{font-size:12px;color:var(--ion-color-medium, #92949c);font-style:italic;margin-top:4px}.notif-row__control{flex-shrink:0}.alert{display:flex;gap:10px;align-items:flex-start;margin-top:12px;padding:12px 14px;border-radius:10px;font-size:13px;line-height:1.45;background:var(--ion-color-light, rgba(0, 0, 0, .04));color:var(--ion-color-dark, #1a1a1a)}.alert ion-icon{font-size:18px;flex-shrink:0;margin-top:1px}.alert--warning ion-icon{color:var(--ion-color-warning-shade, #c79e08)}.alert--danger ion-icon{color:var(--ion-color-danger-shade, #cf3c4f)}.alert--info ion-icon{color:var(--ion-color-medium, #6b6b6b)}.install-steps{margin:0;padding-left:20px;font-size:13px;line-height:1.6;color:var(--ion-color-dark, #1a1a1a)}.install-steps li+li{margin-top:4px}.details-toggle{display:flex;align-items:center;gap:6px;background:none;border:0;padding:0;font-size:13px;color:var(--ion-color-medium, #6b6b6b);cursor:pointer}.details-toggle ion-icon{font-size:14px}.details{margin:12px 0 0;display:flex;flex-direction:column;gap:6px}.details__row{display:grid;grid-template-columns:140px 1fr;gap:12px;font-size:12px;align-items:baseline}.details__row dt{margin:0;color:var(--ion-color-medium, #6b6b6b);text-transform:uppercase;letter-spacing:.4px;font-weight:500}.details__row dd{margin:0;color:var(--ion-color-dark, #1a1a1a);font-family:ui-monospace,SFMono-Regular,monospace;font-size:11px;word-break:break-all}\n"] }]
51010
+ `, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:16px}.row-actions{margin-top:12px}.row-actions--center{display:flex;justify-content:center;margin-top:20px}.row-actions--end{display:flex;justify-content:flex-end;margin-top:8px}.notif-cta{display:flex;gap:12px;align-items:flex-start;padding:14px 16px;margin-bottom:16px;border:1px solid var(--ion-color-light-shade, rgba(0, 0, 0, .08));border-radius:14px;background:var(--ion-color-light)}.notif-cta__icon{font-size:24px;color:var(--ion-color-dark, #1a1a1a);flex-shrink:0;margin-top:2px}.notif-cta__body{display:flex;flex-direction:column;gap:2px}.group{padding:12px 0}.group+.group{border-top:1px solid var(--val-border-color, rgba(0, 0, 0, .08))}.notif-list{list-style:none;padding:0;margin:12px 0 0;display:flex;flex-direction:column;gap:10px}.notif{display:grid;grid-template-columns:44px 1fr;gap:12px;padding:12px;border-radius:12px;background:transparent;transition:background .12s ease}.notif--unread{background:var(--ion-color-light, rgba(0, 0, 0, .04))}.notif__icon{width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center;background:var(--ion-color-light, rgba(0, 0, 0, .06));overflow:hidden}.notif__icon ion-icon{font-size:22px}.notif__icon img{width:100%;height:100%;object-fit:cover}.notif__avatar{border-radius:50%}.notif__body{display:flex;flex-direction:column;gap:4px;min-width:0}.notif__head{display:flex;justify-content:space-between;align-items:baseline;gap:8px}.notif__time{font-size:12px;color:var(--ion-color-medium, #92949c);flex-shrink:0}.notif__text{margin:0;font-size:14px;line-height:1.4;color:var(--ion-color-dark, #1a1a1a);white-space:pre-wrap}.notif__text--clamped{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.notif__expand{background:none;border:0;padding:0;font-size:13px;color:var(--ion-color-dark, #1a1a1a);cursor:pointer;align-self:flex-start;text-decoration:underline}.notif__app{font-size:11px;text-transform:uppercase;letter-spacing:.4px;color:var(--ion-color-medium, #92949c);margin-top:2px}\n"] }]
50759
51011
  }], ctorParameters: () => [], propDecorators: { config: [{
50760
51012
  type: Input
50761
51013
  }] } });
50762
51014
 
50763
51015
  /**
50764
- * Helper para montar las **preferencias de notificaciones push**
50765
- * (`val-notification-preferences-view`) como ruta en una app del factory. El
50766
- * `config` se pasa por route `data` (`notificationPreferencesConfig`) y el
50767
- * componente lo lee como fallback de su `@Input() config`.
50768
- *
50769
- * No confundir con `provideValtechNotificationsRoutes` (el inbox / feed).
51016
+ * Helper para montar el inbox de notificaciones (`val-notifications-view`) como
51017
+ * ruta en una app del factory. El `config` se pasa por route `data`
51018
+ * (`notificationsConfig`) y el componente lo lee como fallback de su
51019
+ * `@Input() config`.
50770
51020
  *
50771
51021
  * @example
50772
51022
  * ```ts
50773
- * // app.routes.ts (típicamente bajo settings)
50774
- * export const settingsRoutes: Routes = [
50775
- * ...provideValtechNotificationPreferencesRoutes(), // → notifications
51023
+ * // app.routes.ts
51024
+ * export const appRoutes: Routes = [
51025
+ * ...provideValtechNotificationsRoutes(), // → /app/notifications
51026
+ * { path: 'settings', loadChildren: () => ... },
50776
51027
  * ];
50777
51028
  *
50778
51029
  * // con config acotada:
50779
- * ...provideValtechNotificationPreferencesRoutes({ config: { showDetails: false } }),
50780
- * ...provideValtechNotificationPreferencesRoutes({ path: 'push' }),
51030
+ * ...provideValtechNotificationsRoutes({ config: { enableMarkAll: false } }),
51031
+ * ...provideValtechNotificationsRoutes({
51032
+ * config: { settingsNotificationsPath: '/app/preferences/push' },
51033
+ * }),
50781
51034
  * ```
50782
51035
  */
50783
- function provideValtechNotificationPreferencesRoutes(opts) {
51036
+ function provideValtechNotificationsRoutes(opts) {
50784
51037
  return [
50785
51038
  {
50786
51039
  path: opts?.path ?? 'notifications',
50787
- component: NotificationPreferencesViewComponent,
50788
- data: { notificationPreferencesConfig: opts?.config },
51040
+ component: NotificationsViewComponent,
51041
+ data: { notificationsConfig: opts?.config },
50789
51042
  },
50790
51043
  ];
50791
51044
  }
50792
51045
 
50793
51046
  /**
50794
- * Consume el catálogo de permisos del backend (`GET /org/{orgId}/permissions-catalog`).
50795
- * El backend es la fuente de verdad de qué permisos existen y de su alcance
50796
- * (globales de plataforma vs. propios de una app). La respuesta incluye el
50797
- * `appId` del request para que la vista distinga la app actual.
51047
+ * Defaults i18n (es/en) embebidos en `val-notification-preferences-view`.
51048
+ * Auto-registrados en el constructor del componente si el consumer no proveyó
51049
+ * el namespace (`Settings.Notifications` por default). SOLO incluye las keys que
51050
+ * la vista usa.
50798
51051
  *
50799
- * Promovido desde `showcase` a la lib bajo el proceso de ADR-021. Usa
50800
- * `VALTECH_AUTH_CONFIG.apiUrl` (como `OrgService`) en vez del `environment` de la
50801
- * app — la lib no importa el environment del consumer.
51052
+ * Portado del override `Settings.Notifications` (showcase) bajo el proceso de
51053
+ * ADR-021.
50802
51054
  */
50803
- class PermissionCatalogService {
50804
- constructor(config, http) {
50805
- this.config = config;
50806
- this.http = http;
50807
- }
50808
- getCatalog(orgId) {
50809
- return this.http.get(`${this.config.apiUrl}/org/${orgId}/permissions-catalog`);
50810
- }
50811
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: PermissionCatalogService, deps: [{ token: VALTECH_AUTH_CONFIG }, { token: i1$3.HttpClient }], target: i0.ɵɵFactoryTarget.Injectable }); }
50812
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: PermissionCatalogService, providedIn: 'root' }); }
50813
- }
50814
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: PermissionCatalogService, decorators: [{
50815
- type: Injectable,
50816
- args: [{ providedIn: 'root' }]
50817
- }], ctorParameters: () => [{ type: undefined, decorators: [{
50818
- type: Inject,
50819
- args: [VALTECH_AUTH_CONFIG]
50820
- }] }, { type: i1$3.HttpClient }] });
51055
+ const NOTIFICATION_PREFERENCES_VIEW_I18N = {
51056
+ es: {
51057
+ pageTitle: 'Notificaciones',
51058
+ pageDescription: 'Configura como quieres recibir notificaciones',
51059
+ // Control principal (UX simplificado)
51060
+ simpleTitle: 'Notificaciones push en este dispositivo',
51061
+ simpleDescription: 'Recibe alertas en tiempo real incluso con la app cerrada.',
51062
+ enabling: 'Activando…',
51063
+ disabling: 'Desactivando…',
51064
+ enabled: 'Activadas',
51065
+ errorGeneric: 'No pudimos completar la configuración. Verifica los permisos del navegador y reintenta.',
51066
+ retryCta: 'Reintentar',
51067
+ // Mensajes contextuales
51068
+ permissionUnsupported: 'Tu navegador no soporta notificaciones push.',
51069
+ permissionDeniedInfo: 'Tu navegador esta bloqueando las notificaciones. Para habilitarlas, ve a la configuracion del sitio y permitelas manualmente.',
51070
+ // iOS PWA install
51071
+ iosInstallTitle: 'En iOS, instala la app para recibir notificaciones',
51072
+ iosInstallHint: 'Las notificaciones push en iOS (iPhone/iPad) solo funcionan cuando la app está instalada en la pantalla de inicio. Edge, Chrome y Firefox en iOS comparten esta limitación.',
51073
+ iosInstallStep1: 'Abre este sitio en Safari (no en Chrome/Edge).',
51074
+ iosInstallStep2: 'Toca el botón Compartir (□ con flecha hacia arriba).',
51075
+ iosInstallStep3: "Elige 'Añadir a pantalla de inicio'.",
51076
+ iosInstallStep4: 'Abre la app desde el ícono recién creado y vuelve aquí.',
51077
+ // Detalles técnicos (expandible)
51078
+ detailsShow: 'Ver detalles técnicos',
51079
+ detailsHide: 'Ocultar detalles',
51080
+ detailsToken: 'Token',
51081
+ detailsNone: 'Sin registrar',
51082
+ diagnosticsPermission: 'Permiso del navegador',
51083
+ diagnosticsSupported: 'Soporte del navegador',
51084
+ // Toasts
51085
+ enableSuccess: 'Notificaciones habilitadas',
51086
+ disableSuccess: 'Notificaciones deshabilitadas',
51087
+ },
51088
+ en: {
51089
+ pageTitle: 'Notifications',
51090
+ pageDescription: 'Configure how you want to receive notifications',
51091
+ simpleTitle: 'Push notifications on this device',
51092
+ simpleDescription: 'Get real-time alerts even when the app is closed.',
51093
+ enabling: 'Enabling…',
51094
+ disabling: 'Disabling…',
51095
+ enabled: 'Enabled',
51096
+ errorGeneric: "We couldn't complete the setup. Check your browser permissions and retry.",
51097
+ retryCta: 'Retry',
51098
+ permissionUnsupported: 'Your browser does not support push notifications.',
51099
+ permissionDeniedInfo: 'Your browser is blocking notifications. To enable them, go to site settings and allow them manually.',
51100
+ iosInstallTitle: 'On iOS, install the app to receive notifications',
51101
+ iosInstallHint: 'Push notifications on iOS (iPhone/iPad) only work when the app is installed on the Home Screen. Edge, Chrome and Firefox on iOS share this limitation.',
51102
+ iosInstallStep1: 'Open this site in Safari (not Chrome/Edge).',
51103
+ iosInstallStep2: 'Tap the Share button (□ with an up arrow).',
51104
+ iosInstallStep3: "Choose 'Add to Home Screen'.",
51105
+ iosInstallStep4: 'Open the app from the new icon and come back here.',
51106
+ detailsShow: 'View technical details',
51107
+ detailsHide: 'Hide details',
51108
+ detailsToken: 'Token',
51109
+ detailsNone: 'Not registered',
51110
+ diagnosticsPermission: 'Browser permission',
51111
+ diagnosticsSupported: 'Browser support',
51112
+ enableSuccess: 'Notifications enabled',
51113
+ disableSuccess: 'Notifications disabled',
51114
+ },
51115
+ };
50821
51116
 
51117
+ addIcons({
51118
+ alertCircleOutline,
51119
+ chevronDownOutline,
51120
+ chevronUpOutline,
51121
+ closeCircleOutline,
51122
+ notificationsOffOutline,
51123
+ notificationsOutline,
51124
+ refreshOutline,
51125
+ });
51126
+ const DEFAULT_NAMESPACE$1 = 'Settings.Notifications';
50822
51127
  /**
50823
- * Defaults i18n (es/en) embebidos en `val-permissions-view`. Auto-registrados en
50824
- * el constructor del componente si el consumer no proveyó el namespace
50825
- * (`Settings.Permissions` por default). SOLO incluye las keys que la vista usa.
51128
+ * `val-notification-preferences-view` **preferencias de notificaciones push**
51129
+ * (FCM) full-feature autocontenida (organism). Promovida desde `showcase` bajo el
51130
+ * proceso de ADR-021.
50826
51131
  *
50827
- * Portado EXACTO de `PERMISSIONS_I18N` (showcase) bajo el proceso de ADR-021.
51132
+ * Separada de `val-notifications-view` (el inbox / feed). Aquí el user **activa o
51133
+ * desactiva** las notificaciones push en este dispositivo.
51134
+ *
51135
+ * UX simplificado: UN solo toggle que ejecuta toda la secuencia (pedir permiso →
51136
+ * SW ready → getToken → registrar en backend → set master pref) vía
51137
+ * `MessagingService.enable()`. El registro del device en el backend va como
51138
+ * callback (la lib no depende de `AuthService` para evitar ciclos). Mensajes
51139
+ * contextuales y botón "Reintentar" aparecen solo cuando aplican.
51140
+ *
51141
+ * NO renderiza ion-content — vive dentro de val-page-wrapper. `ActivatedRoute` se
51142
+ * inyecta `{ optional: true }` → funciona sin router (embebida / Storybook).
50828
51143
  */
50829
- const PERMISSIONS_VIEW_I18N = {
50830
- es: {
50831
- pageTitle: 'Permisos',
50832
- pageDescription: 'Roles y permisos de esta app en la organización activa',
50833
- systemRolesTitle: 'Roles del sistema',
50834
- systemRolesHint: 'Predefinidos por la plataforma. No editables.',
50835
- customRolesTitle: 'Roles personalizados',
50836
- customRolesHint: 'Definidos por tu organización.',
50837
- systemBadge: 'Sistema',
50838
- noCustomRoles: 'Esta organización no tiene roles personalizados.',
50839
- noPermissions: 'Sin permisos asignados.',
50840
- // Grupos de alcance
50841
- groupApp: 'Permisos de la app',
50842
- groupOrg: 'Permisos de la organización',
50843
- groupPlatform: 'Permisos de plataforma',
50844
- groupOther: 'Otras apps',
50845
- // Roles del sistema
50846
- roleViewer: 'Visor',
50847
- roleEditor: 'Editor',
50848
- roleAdmin: 'Administrador',
50849
- roleSuperAdmin: 'Super Administrador',
50850
- roleBingoAdmin: 'Bingo · Administrador',
50851
- roleBingoTesorero: 'Bingo · Tesorero',
50852
- roleBingoTaquilla: 'Bingo · Taquilla',
50853
- roleBingoOperator: 'Bingo · Operador',
50854
- // Apps
50855
- app_showcase: 'Showcase',
50856
- app_bingo: 'Bingo',
50857
- // Recursos
50858
- permUsers: 'Usuarios',
50859
- permRoles: 'Roles',
50860
- permRbac: 'Control de acceso',
50861
- permApps: 'Apps',
50862
- 'permApi-keys': 'API keys',
50863
- 'permEmail-logs': 'Logs de correo',
50864
- 'permSms-logs': 'Logs de SMS',
50865
- permDocuments: 'Documentos',
50866
- permMedia: 'Media',
50867
- permTemplates: 'Plantillas',
50868
- permRequests: 'Solicitudes',
50869
- permBingo: 'Bingo',
50870
- // Acciones
50871
- permRead: 'Leer',
50872
- permWrite: 'Escribir',
50873
- permCreate: 'Crear',
50874
- permDelete: 'Eliminar',
50875
- permManage: 'Gestionar',
50876
- permEdit: 'Editar',
50877
- permSell: 'Vender',
50878
- permCheckin: 'Canjear',
50879
- permSettle: 'Rendir',
50880
- permExport: 'Exportar',
50881
- permOperate: 'Operar',
50882
- permAll: 'Todo',
50883
- // Loading / error
50884
- errorTitle: 'Error al cargar permisos',
50885
- retry: 'Reintentar',
50886
- },
50887
- en: {
50888
- pageTitle: 'Permissions',
50889
- pageDescription: 'Roles and permissions of this app in the active organization',
50890
- systemRolesTitle: 'System roles',
50891
- systemRolesHint: 'Defined by the platform. Not editable.',
50892
- customRolesTitle: 'Custom roles',
50893
- customRolesHint: 'Defined by your organization.',
50894
- systemBadge: 'System',
50895
- noCustomRoles: 'This organization has no custom roles.',
50896
- noPermissions: 'No permissions assigned.',
50897
- groupApp: 'App permissions',
50898
- groupOrg: 'Organization permissions',
50899
- groupPlatform: 'Platform permissions',
50900
- groupOther: 'Other apps',
50901
- roleViewer: 'Viewer',
50902
- roleEditor: 'Editor',
50903
- roleAdmin: 'Administrator',
50904
- roleSuperAdmin: 'Super Administrator',
50905
- roleBingoAdmin: 'Bingo · Administrator',
50906
- roleBingoTesorero: 'Bingo · Treasurer',
50907
- roleBingoTaquilla: 'Bingo · Box office',
50908
- roleBingoOperator: 'Bingo · Operator',
50909
- app_showcase: 'Showcase',
50910
- app_bingo: 'Bingo',
50911
- permUsers: 'Users',
50912
- permRoles: 'Roles',
50913
- permRbac: 'Access control',
50914
- permApps: 'Apps',
50915
- 'permApi-keys': 'API keys',
50916
- 'permEmail-logs': 'Email logs',
50917
- 'permSms-logs': 'SMS logs',
50918
- permDocuments: 'Documents',
50919
- permMedia: 'Media',
50920
- permTemplates: 'Templates',
50921
- permRequests: 'Requests',
50922
- permBingo: 'Bingo',
50923
- permRead: 'Read',
50924
- permWrite: 'Write',
50925
- permCreate: 'Create',
50926
- permDelete: 'Delete',
50927
- permManage: 'Manage',
50928
- permEdit: 'Edit',
50929
- permSell: 'Sell',
50930
- permCheckin: 'Check-in',
50931
- permSettle: 'Settle',
50932
- permExport: 'Export',
50933
- permOperate: 'Operate',
50934
- permAll: 'All',
50935
- errorTitle: 'Error loading permissions',
50936
- retry: 'Retry',
50937
- },
50938
- };
50939
-
50940
- const DEFAULT_NAMESPACE$1 = 'Settings.Permissions';
50941
- /**
50942
- * `val-permissions-view` — vista Permisos full-feature autocontenida (organism).
50943
- * Visor RBAC de solo lectura: muestra los roles del sistema (Visor/Editor/Admin +
50944
- * roles de plataforma) y los roles personalizados de la org activa, con el
50945
- * catálogo de permisos agrupado por alcance (app actual / plataforma / otras apps).
50946
- * Promovida desde `showcase` bajo el proceso de ADR-021.
50947
- *
50948
- * **RBAC interno** (como `organization-view`): lee la org activa vía
50949
- * `AuthService.user().activeOrgId`. La vista NO muta nada — el control de quién
50950
- * puede verla es responsabilidad del guard de ruta del consumer; no hay gating
50951
- * fino interno ni se expone como config.
50952
- *
50953
- * Datos: `forkJoin` de `OrgService.listOrgRoles(orgId)` (roles de la org) +
50954
- * `PermissionCatalogService.getCatalog(orgId)` (catálogo + appId actual). El
50955
- * catálogo define el alcance (`scope`/`appId`) de cada recurso para agrupar.
50956
- *
50957
- * NO renderiza ion-content — vive dentro de val-page-wrapper. `ActivatedRoute` se
50958
- * inyecta `{ optional: true }` solo para leer el route data de config. Auto-registra
50959
- * sus defaults i18n (es/en) si el consumer no proveyó el namespace (default
50960
- * `Settings.Permissions`).
50961
- */
50962
- class PermissionsViewComponent {
51144
+ class NotificationPreferencesViewComponent {
50963
51145
  /** Namespace i18n resuelto (capturado para llamadas no-reactivas). */
50964
51146
  get ns() {
50965
51147
  return this.resolvedConfig().i18nNamespace;
50966
51148
  }
51149
+ // ---------------------------------------------------------------------------
51150
+ // Lifecycle
51151
+ // ---------------------------------------------------------------------------
50967
51152
  constructor() {
51153
+ this.nav = inject(NavigationService);
50968
51154
  this.i18n = inject(I18nService);
50969
- this.orgService = inject(OrgService);
50970
- this.catalog = inject(PermissionCatalogService);
51155
+ this.toast = inject(ToastService);
50971
51156
  this.auth = inject(AuthService);
50972
- this.nav = inject(NavigationService);
51157
+ this.messaging = inject(MessagingService);
51158
+ this.pageRefresh = inject(PageRefreshService);
51159
+ this.prefs = inject(PreferencesService);
51160
+ // `ActivatedRoute` opcional → la vista funciona embebida / en Storybook sin
51161
+ // router (solo lo usa para leer el route data de config como fallback).
50973
51162
  this.route = inject(ActivatedRoute, { optional: true });
50974
51163
  this.resolvedConfig = computed(() => {
50975
- const fromRoute = (this.route?.snapshot.data['permissionsConfig'] ?? {});
51164
+ const fromRoute = (this.route?.snapshot.data['notificationPreferencesConfig'] ??
51165
+ {});
50976
51166
  const merged = { ...fromRoute, ...(this.config ?? {}) };
50977
51167
  return {
50978
- showSystemRoles: merged.showSystemRoles ?? true,
50979
- showCustomRoles: merged.showCustomRoles ?? true,
51168
+ showDetails: merged.showDetails ?? true,
50980
51169
  i18nNamespace: merged.i18nNamespace ?? DEFAULT_NAMESPACE$1,
51170
+ onPushEnabled: merged.onPushEnabled,
51171
+ onPushDisabled: merged.onPushDisabled,
50981
51172
  };
50982
51173
  });
50983
- this.loading = signal(false);
50984
- this.loadError = signal(null);
50985
- this.roles = signal([]);
50986
- this.systemRoles = computed(() => this.roles().filter(r => r.isSystem));
50987
- this.customRoles = computed(() => this.roles().filter(r => !r.isSystem));
50988
- this.activeOrgId = computed(() => {
50989
- const u = this.auth.user();
50990
- return u?.activeOrgId ?? u?.activeOrg ?? '';
51174
+ /** Operación en curso (gobierna spinner del toggle + label "Activando…"). */
51175
+ this._busy = signal(null);
51176
+ /** True si el último intento falló — gobierna estado `error` + retry button. */
51177
+ this._lastFailed = signal(false);
51178
+ /** True mientras se re-obtiene el token FCM en background (no afecta la UI). */
51179
+ this._repairingToken = false;
51180
+ /** Toggle del expand de detalles técnicos. */
51181
+ this.showDetails = signal(false);
51182
+ this.permission = signal('default');
51183
+ this.isSupported = signal(false);
51184
+ this.currentToken = signal(null);
51185
+ this.needsIOSInstall = signal(this.detectNeedsIOSInstall());
51186
+ /** FormControl del toggle (sync-eado con `isEnabled` via effect). */
51187
+ this.toggleControl = new FormControl(false, {
51188
+ nonNullable: true,
51189
+ });
51190
+ /** Handler del listener `focus` — referencia guardada para poder removerlo. */
51191
+ this.onWindowFocus = () => this.scheduleBrowserRefresh();
51192
+ /** Timer del debounce del refresh disparado por `focus`. */
51193
+ this.focusDebounceTimer = null;
51194
+ /** Ventana de debounce (ms) para el refresh disparado por `focus`. */
51195
+ this.FOCUS_DEBOUNCE_MS = 500;
51196
+ // ---------------------------------------------------------------------------
51197
+ // Estado derivado
51198
+ // ---------------------------------------------------------------------------
51199
+ this.pageTitle = computed(() => this.tt('pageTitle'));
51200
+ this.pageDescription = computed(() => this.tt('pageDescription'));
51201
+ /**
51202
+ * Estado efectivo del toggle: "el usuario tiene push activo".
51203
+ *
51204
+ * Se deriva SOLO de la decisión del usuario (`master`) y del permiso del SO
51205
+ * (`permission`). El token FCM NO forma parte del estado de UI: vive en
51206
+ * memoria, se pierde en cada recarga/suspensión de la PWA y produciría flicker.
51207
+ * Si falta token con `master && granted`, es un detalle reparable en
51208
+ * background (ver `repairTokenIfNeeded`), no un cambio del toggle.
51209
+ */
51210
+ this.isEnabled = computed(() => this.prefs.notificationsMaster() && this.permission() === 'granted');
51211
+ this.state = computed(() => {
51212
+ if (this.needsIOSInstall())
51213
+ return 'needs-install';
51214
+ if (this.permission() === 'unsupported' || !this.isSupported()) {
51215
+ // iOS standalone aún puede dar isSupported false temprano antes de init —
51216
+ // solo bloqueamos si el browser explícitamente no soporta APIs.
51217
+ if (this.permission() === 'unsupported')
51218
+ return 'unsupported';
51219
+ }
51220
+ const busy = this._busy();
51221
+ if (busy === 'enabling')
51222
+ return 'enabling';
51223
+ if (busy === 'disabling')
51224
+ return 'disabling';
51225
+ if (this.isEnabled())
51226
+ return 'enabled';
51227
+ if (this.permission() === 'denied')
51228
+ return 'blocked';
51229
+ if (this._lastFailed())
51230
+ return 'error';
51231
+ return 'idle';
51232
+ });
51233
+ this.busyLabel = computed(() => {
51234
+ const s = this.state();
51235
+ if (s === 'enabling')
51236
+ return this.tt('enabling');
51237
+ if (s === 'disabling')
51238
+ return this.tt('disabling');
51239
+ return null;
51240
+ });
51241
+ this.contextMessage = computed(() => {
51242
+ switch (this.state()) {
51243
+ case 'unsupported':
51244
+ return this.tt('permissionUnsupported');
51245
+ case 'blocked':
51246
+ return this.tt('permissionDeniedInfo');
51247
+ case 'error':
51248
+ return this.tt('errorGeneric');
51249
+ default:
51250
+ return null;
51251
+ }
51252
+ });
51253
+ this.alertVariant = computed(() => {
51254
+ const s = this.state();
51255
+ if (s === 'error' || s === 'blocked')
51256
+ return 'warning';
51257
+ if (s === 'unsupported')
51258
+ return 'danger';
51259
+ return 'info';
51260
+ });
51261
+ this.alertIcon = computed(() => {
51262
+ const s = this.state();
51263
+ if (s === 'unsupported')
51264
+ return 'close-circle-outline';
51265
+ return 'alert-circle-outline';
50991
51266
  });
51267
+ this.toggleProps = computed(() => ({
51268
+ control: this.toggleControl,
51269
+ color: 'primary',
51270
+ disabled: this._busy() !== null ||
51271
+ this.state() === 'unsupported' ||
51272
+ this.state() === 'needs-install' ||
51273
+ this.state() === 'blocked',
51274
+ }));
50992
51275
  this.retryButtonProps = computed(() => ({
50993
- text: this.tt('retry'),
51276
+ text: this.tt('retryCta'),
50994
51277
  color: 'dark',
50995
51278
  fill: 'outline',
50996
51279
  size: 'default',
51280
+ shape: 'round',
50997
51281
  type: 'button',
50998
- state: this.loading() ? ComponentStates.DISABLED : ComponentStates.ENABLED,
51282
+ icon: { name: 'refresh-outline', slot: 'start' },
51283
+ state: this._busy() !== null ? ComponentStates.WORKING : ComponentStates.ENABLED,
50999
51284
  }));
51285
+ this.tokenPreview = computed(() => {
51286
+ const t = this.currentToken();
51287
+ if (!t)
51288
+ return null;
51289
+ return `${t.slice(0, 22)}…${t.slice(-6)}`;
51290
+ });
51000
51291
  const ns = this.ns;
51001
51292
  if (!this.i18n.hasNamespace(ns)) {
51002
- this.i18n.registerContent(ns, PERMISSIONS_VIEW_I18N);
51293
+ this.i18n.registerContent(ns, NOTIFICATION_PREFERENCES_VIEW_I18N);
51003
51294
  }
51004
51295
  this.nav.setBackHeader('pageTitle', ns, { withMenu: true });
51005
- this.load();
51006
- connectPageRefresh(() => this.load());
51007
- }
51008
- load() {
51009
- const orgId = this.activeOrgId();
51010
- if (!orgId)
51011
- return;
51012
- this.loading.set(true);
51013
- this.loadError.set(null);
51014
- forkJoin({
51015
- roles: this.orgService.listOrgRoles(orgId),
51016
- catalog: this.catalog.getCatalog(orgId),
51017
- }).subscribe({
51018
- next: ({ roles: rawRoles, catalog }) => {
51019
- const byResource = new Map();
51020
- for (const r of catalog.permissions)
51021
- byResource.set(r.resource, r);
51022
- const currentAppId = catalog.appId;
51023
- this.roles.set((rawRoles ?? []).map((r) => this.buildRoleView({
51024
- id: r.id,
51025
- name: r.name,
51026
- description: r.description,
51027
- permissions: r.permissions ?? [],
51028
- isSystem: r.isSystem ?? false,
51029
- }, byResource, currentAppId)));
51030
- this.loading.set(false);
51031
- },
51032
- error: err => {
51033
- this.loadError.set(err);
51034
- this.loading.set(false);
51035
- },
51296
+ void this.refreshBrowserState();
51297
+ // Re-aplicar header en cambio de idioma.
51298
+ effect(() => {
51299
+ this.i18n.lang();
51300
+ this.nav.setBackHeader('pageTitle', this.ns, { withMenu: true });
51301
+ }, { allowSignalWrites: true });
51302
+ // Sync FormControl ←→ estado efectivo. Cuando el estado cambia (cambio de
51303
+ // permission por el browser, o snapshot Firestore de `master`), el toggle se
51304
+ // re-alinea con la realidad.
51305
+ //
51306
+ // IMPORTANTE: se ignoran los cambios mientras hay una operación en curso
51307
+ // (`_busy() !== null`). Durante `onEnable`/`onDisable` las señales fuente se
51308
+ // actualizan en momentos distintos y producirían estados intermedios →
51309
+ // flicker visible. El toggle se re-alinea UNA sola vez en el `finally`.
51310
+ effect(() => {
51311
+ const eff = this.isEnabled();
51312
+ if (this._busy() !== null)
51313
+ return;
51314
+ if (this.toggleControl.value !== eff) {
51315
+ this.toggleControl.setValue(eff, { emitEvent: false });
51316
+ }
51317
+ }, { allowSignalWrites: true });
51318
+ // Interceptar click del user: el toggle ya cambió visualmente, ejecutar
51319
+ // la acción correspondiente.
51320
+ this.toggleControl.valueChanges.subscribe(v => {
51321
+ if (this._busy() !== null)
51322
+ return;
51323
+ if (v && !this.isEnabled()) {
51324
+ void this.onEnable();
51325
+ }
51326
+ else if (!v && this.isEnabled()) {
51327
+ void this.onDisable();
51328
+ }
51036
51329
  });
51330
+ // Detectar regreso a la pestaña — el user pudo haber cambiado permisos
51331
+ // desde la config del browser. El refresh va con debounce para no
51332
+ // dispararse en ráfagas (en PWA mobile `focus` salta con cada cambio de
51333
+ // app / aparición del teclado). El listener se remueve en ngOnDestroy.
51334
+ if (typeof window !== 'undefined') {
51335
+ window.addEventListener('focus', this.onWindowFocus);
51336
+ }
51037
51337
  }
51038
- buildRoleView(role, byResource, currentAppId) {
51039
- const appPerms = [];
51040
- const orgPerms = [];
51041
- const otherByApp = new Map();
51042
- for (const perm of role.permissions) {
51043
- const [resource, action] = perm.split(':');
51044
- const entry = byResource.get(resource);
51045
- // internal (operación de plataforma) NUNCA se muestra a nivel de producto
51046
- // — defensa en profundidad junto al filtro del backend (ADR-024).
51047
- if (entry?.scope === 'internal') {
51048
- continue;
51049
- }
51050
- const label = this.permLabel(resource, action);
51051
- if (entry?.scope === 'app') {
51052
- if (entry.appId === currentAppId) {
51053
- appPerms.push(label);
51054
- }
51055
- else {
51056
- const appId = entry.appId ?? '?';
51057
- const arr = otherByApp.get(appId) ?? [];
51058
- arr.push(label);
51059
- otherByApp.set(appId, arr);
51338
+ /**
51339
+ * Registra el pull-to-refresh estándar. Aunque esta vista no muestra una
51340
+ * lista remota, sí refleja estado mutable del browser (permiso, token,
51341
+ * soporte FCM) que puede cambiar fuera de la app — el refresh re-evalúa todo.
51342
+ *
51343
+ * Se usa `ngOnInit`/`ngOnDestroy` (NO `ionViewWillEnter`) porque
51344
+ * `val-page-wrapper` usa `<router-outlet>` plano — los hooks `ionView*` solo
51345
+ * disparan dentro de `<ion-router-outlet>`.
51346
+ */
51347
+ ngOnInit() {
51348
+ this.pageRefresh.register(() => this.refreshBrowserState());
51349
+ }
51350
+ ngOnDestroy() {
51351
+ this.pageRefresh.unregister();
51352
+ if (typeof window !== 'undefined') {
51353
+ window.removeEventListener('focus', this.onWindowFocus);
51354
+ }
51355
+ if (this.focusDebounceTimer !== null) {
51356
+ clearTimeout(this.focusDebounceTimer);
51357
+ this.focusDebounceTimer = null;
51358
+ }
51359
+ }
51360
+ /** Refresca el estado del browser con debounce — evita ráfagas de `focus`. */
51361
+ scheduleBrowserRefresh() {
51362
+ if (this.focusDebounceTimer !== null) {
51363
+ clearTimeout(this.focusDebounceTimer);
51364
+ }
51365
+ this.focusDebounceTimer = setTimeout(() => {
51366
+ this.focusDebounceTimer = null;
51367
+ void this.refreshBrowserState();
51368
+ }, this.FOCUS_DEBOUNCE_MS);
51369
+ }
51370
+ // ---------------------------------------------------------------------------
51371
+ // Actions
51372
+ // ---------------------------------------------------------------------------
51373
+ /**
51374
+ * Secuencia completa de activación.
51375
+ *
51376
+ * Toda la mecánica (permission → SW ready → getToken con retry → watchdog de
51377
+ * auto-reload) vive en `MessagingService.enable()` de la lib. El paso de
51378
+ * registro del device en el backend se pasa como callback (`registerDevice`)
51379
+ * porque la lib no puede depender de `AuthService` sin crear un ciclo.
51380
+ *
51381
+ * `enable()` no hace throw: resuelve con un `EnablePushResult` descriptivo.
51382
+ */
51383
+ async onEnable() {
51384
+ this._busy.set('enabling');
51385
+ this._lastFailed.set(false);
51386
+ try {
51387
+ const result = await this.messaging.enable({
51388
+ registerDevice: token => this.auth.registerDevice(token).then(r => r.registered),
51389
+ });
51390
+ if (result.status === 'enabled') {
51391
+ // Persistir flag master si aún no estaba activo.
51392
+ if (!this.prefs.notificationsMaster()) {
51393
+ await this.prefs.setNotificationsMaster(true);
51060
51394
  }
51395
+ this.toast.show({
51396
+ message: this.tt('enableSuccess'),
51397
+ duration: 2000,
51398
+ color: 'dark',
51399
+ position: 'top',
51400
+ });
51401
+ this.resolvedConfig().onPushEnabled?.();
51402
+ }
51403
+ else if (result.status === 'timeout' && result.reloaded) {
51404
+ // La lib disparó el auto-reload — la página está por recargarse.
51405
+ return;
51061
51406
  }
51062
51407
  else {
51063
- // org o desconocido gestión de la organización del cliente
51064
- orgPerms.push(label);
51408
+ // denied / unsupported / error / timeout sin reload → fallo recuperable.
51409
+ this._lastFailed.set(true);
51410
+ this.toast.show({
51411
+ message: this.tt('errorGeneric'),
51412
+ duration: 3000,
51413
+ color: 'dark',
51414
+ position: 'top',
51415
+ });
51065
51416
  }
51066
51417
  }
51067
- const groups = [];
51068
- if (appPerms.length) {
51069
- groups.push({ key: 'app', label: this.tt('groupApp'), perms: appPerms });
51070
- }
51071
- if (orgPerms.length) {
51072
- groups.push({
51073
- key: 'org',
51074
- label: this.tt('groupOrg'),
51075
- perms: orgPerms,
51418
+ catch {
51419
+ // `enable()` no hace throw, pero el callback de registro sí puede.
51420
+ this._lastFailed.set(true);
51421
+ this.toast.show({
51422
+ message: this.tt('errorGeneric'),
51423
+ duration: 3000,
51424
+ color: 'dark',
51425
+ position: 'top',
51076
51426
  });
51077
51427
  }
51078
- for (const [appId, perms] of otherByApp) {
51079
- groups.push({
51080
- key: `other:${appId}`,
51081
- label: `${this.tt('groupOther')}: ${this.appLabel(appId)}`,
51082
- perms,
51428
+ finally {
51429
+ // Único refresh final: re-alinea el toggle UNA sola vez con el estado real.
51430
+ await this.refreshBrowserState();
51431
+ this._busy.set(null);
51432
+ }
51433
+ }
51434
+ async onDisable() {
51435
+ this._busy.set('disabling');
51436
+ this._lastFailed.set(false);
51437
+ try {
51438
+ await this.auth.disableNotifications();
51439
+ if (this.prefs.notificationsMaster()) {
51440
+ await this.prefs.setNotificationsMaster(false);
51441
+ }
51442
+ this.toast.show({
51443
+ message: this.tt('disableSuccess'),
51444
+ duration: 2000,
51445
+ color: 'dark',
51446
+ position: 'top',
51083
51447
  });
51448
+ this.resolvedConfig().onPushDisabled?.();
51449
+ }
51450
+ catch {
51451
+ this._lastFailed.set(true);
51452
+ }
51453
+ finally {
51454
+ await this.refreshBrowserState();
51455
+ this._busy.set(null);
51084
51456
  }
51085
- return {
51086
- id: role.id,
51087
- name: role.name,
51088
- description: role.description,
51089
- isSystem: role.isSystem,
51090
- groups,
51091
- hasPerms: groups.length > 0,
51092
- };
51093
51457
  }
51094
- tt(key) {
51095
- return this.i18n.t(key, this.ns);
51458
+ /** Reintentar la secuencia completa de activación, sin importar el estado actual. */
51459
+ async onRetry() {
51460
+ this._lastFailed.set(false);
51461
+ await this.onEnable();
51096
51462
  }
51097
- appLabel(appId) {
51098
- const key = `app_${appId}`;
51099
- const label = this.tt(key);
51100
- return label === key ? appId : label;
51463
+ toggleDetails() {
51464
+ this.showDetails.update(v => !v);
51101
51465
  }
51102
- roleLabel(name) {
51103
- const map = {
51104
- viewer: 'roleViewer',
51105
- editor: 'roleEditor',
51106
- admin: 'roleAdmin',
51107
- super_admin: 'roleSuperAdmin',
51108
- bingo_admin: 'roleBingoAdmin',
51109
- bingo_tesorero: 'roleBingoTesorero',
51110
- bingo_taquilla: 'roleBingoTaquilla',
51111
- bingo_operator: 'roleBingoOperator',
51112
- };
51113
- return this.tt(map[name] ?? name);
51466
+ // ---------------------------------------------------------------------------
51467
+ // Privados
51468
+ // ---------------------------------------------------------------------------
51469
+ async refreshBrowserState() {
51470
+ this.isSupported.set(await this.messaging.isSupported());
51471
+ this.permission.set(this.auth.getNotificationPermissionState());
51472
+ this.currentToken.set(this.messaging.currentToken ?? null);
51473
+ void this.repairTokenIfNeeded();
51114
51474
  }
51115
- permLabel(resource, action) {
51116
- if (resource === '*' && action === '*')
51117
- return this.tt('permAll');
51118
- const resKey = resource === '*' ? 'permAll' : `perm${resource.charAt(0).toUpperCase()}${resource.slice(1)}`;
51119
- const actKey = action === '*' ? 'permAll' : `perm${action.charAt(0).toUpperCase()}${action.slice(1)}`;
51120
- const res = this.tt(resKey);
51121
- const act = this.tt(actKey);
51122
- return `${res}: ${act}`;
51475
+ /**
51476
+ * Repara el token FCM en background.
51477
+ *
51478
+ * Si el usuario tiene push activo (`master && granted`) pero no hay token en
51479
+ * memoria caso típico tras un cold start de la PWA — re-obtenemos el token
51480
+ * y re-registramos el device sin tocar el toggle ni mostrar un error
51481
+ * alarmista. El token es un detalle de implementación reparable, no parte del
51482
+ * estado de UI (ver `isEnabled`).
51483
+ */
51484
+ async repairTokenIfNeeded() {
51485
+ if (this._busy() !== null)
51486
+ return;
51487
+ if (this._repairingToken)
51488
+ return;
51489
+ if (!this.isEnabled())
51490
+ return;
51491
+ if (this.currentToken())
51492
+ return;
51493
+ this._repairingToken = true;
51494
+ try {
51495
+ const token = await this.messaging.getToken();
51496
+ if (token) {
51497
+ this.currentToken.set(token);
51498
+ await this.auth.registerDevice(token);
51499
+ }
51500
+ }
51501
+ catch {
51502
+ // Reparación best-effort: no degradamos el toggle ni mostramos error.
51503
+ }
51504
+ finally {
51505
+ this._repairingToken = false;
51506
+ }
51123
51507
  }
51124
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: PermissionsViewComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
51125
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: PermissionsViewComponent, isStandalone: true, selector: "val-permissions-view", inputs: { config: "config" }, ngImport: i0, template: `
51126
- <div class="page">
51127
- <header class="page-header">
51128
- <val-display [props]="{ size: 'small', color: 'dark', content: tt('pageTitle') }" />
51129
- <val-title
51130
- [props]="{
51131
- size: 'large',
51132
- color: 'dark',
51133
- bold: false,
51134
- content: tt('pageDescription'),
51135
- }"
51136
- />
51137
- </header>
51138
-
51139
- @if (loading()) {
51140
- <div class="spinner-row">
51141
- <ion-spinner name="crescent" />
51142
- </div>
51143
- } @else if (loadError()) {
51144
- <div class="error-row">
51145
- <val-text
51508
+ /**
51509
+ * iOS browser sin estar instalado como PWA push web no disponible.
51510
+ * Aplica a Safari y a Edge/Chrome/Firefox en iOS (todos WebKit por mandato Apple).
51511
+ */
51512
+ detectNeedsIOSInstall() {
51513
+ if (typeof window === 'undefined' || typeof navigator === 'undefined') {
51514
+ return false;
51515
+ }
51516
+ const ua = navigator.userAgent || '';
51517
+ const isIOS = /iPad|iPhone|iPod/.test(ua);
51518
+ if (!isIOS)
51519
+ return false;
51520
+ const isStandalone = window.matchMedia?.('(display-mode: standalone)').matches ||
51521
+ navigator.standalone === true;
51522
+ return !isStandalone;
51523
+ }
51524
+ tt(key) {
51525
+ return this.i18n.t(key, this.ns);
51526
+ }
51527
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: NotificationPreferencesViewComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
51528
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: NotificationPreferencesViewComponent, isStandalone: true, selector: "val-notification-preferences-view", inputs: { config: "config" }, ngImport: i0, template: `
51529
+ <div class="page">
51530
+ <header class="page-header">
51531
+ <val-display [props]="{ size: 'small', color: 'dark', content: pageTitle() }" />
51532
+ <val-title
51533
+ [props]="{
51534
+ size: 'large',
51535
+ color: 'dark',
51536
+ bold: false,
51537
+ content: pageDescription(),
51538
+ }"
51539
+ />
51540
+ </header>
51541
+
51542
+ @if (state() === 'needs-install') {
51543
+ <section class="settings-section">
51544
+ <val-title
51146
51545
  [props]="{
51147
- content: tt('errorTitle'),
51148
51546
  size: 'medium',
51149
- color: 'medium',
51150
- bold: false,
51547
+ color: 'dark',
51548
+ bold: true,
51549
+ content: tt('iosInstallTitle'),
51151
51550
  }"
51152
51551
  />
51153
- <val-button [props]="retryButtonProps()" (click)="load()" />
51552
+ <div class="section-body">
51553
+ <val-text
51554
+ [props]="{
51555
+ size: 'small',
51556
+ color: 'medium',
51557
+ bold: false,
51558
+ content: tt('iosInstallHint'),
51559
+ }"
51560
+ />
51561
+ <ol class="install-steps">
51562
+ <li>{{ tt('iosInstallStep1') }}</li>
51563
+ <li>{{ tt('iosInstallStep2') }}</li>
51564
+ <li>{{ tt('iosInstallStep3') }}</li>
51565
+ <li>{{ tt('iosInstallStep4') }}</li>
51566
+ </ol>
51567
+ </div>
51568
+ </section>
51569
+ }
51570
+
51571
+ <section class="settings-section">
51572
+ <div class="notif-row">
51573
+ <div class="notif-row__icon">
51574
+ <ion-icon name="notifications-outline" aria-hidden="true" />
51575
+ </div>
51576
+ <div class="notif-row__body">
51577
+ <val-title
51578
+ [props]="{
51579
+ size: 'medium',
51580
+ color: 'dark',
51581
+ bold: true,
51582
+ content: tt('simpleTitle'),
51583
+ }"
51584
+ />
51585
+ <val-text
51586
+ [props]="{
51587
+ size: 'small',
51588
+ color: 'medium',
51589
+ bold: false,
51590
+ content: tt('simpleDescription'),
51591
+ }"
51592
+ />
51593
+ @if (busyLabel(); as label) {
51594
+ <span class="notif-row__busy">{{ label }}</span>
51595
+ }
51596
+ </div>
51597
+ <div class="notif-row__control">
51598
+ <val-toggle-input [props]="toggleProps()" />
51599
+ </div>
51154
51600
  </div>
51155
- } @else {
51156
- @if (resolvedConfig().showSystemRoles) {
51157
- <section class="perm-section">
51158
- <div class="perm-section__header">
51159
- <val-title
51160
- [props]="{
51161
- size: 'medium',
51162
- color: 'dark',
51163
- bold: true,
51164
- content: tt('systemRolesTitle'),
51165
- }"
51166
- />
51167
- <val-text
51168
- [props]="{
51169
- content: tt('systemRolesHint'),
51170
- size: 'medium',
51171
- color: 'medium',
51172
- bold: false,
51173
- }"
51174
- />
51175
- </div>
51176
- <div class="role-list">
51177
- @for (role of systemRoles(); track role.id) {
51178
- <div class="role-card">
51179
- <div class="role-card__header">
51180
- <span class="role-card__name">{{ roleLabel(role.name) }}</span>
51181
- <val-pill
51182
- [props]="{
51183
- label: tt('systemBadge'),
51184
- color: 'primary',
51185
- weight: 'bold',
51186
- }"
51187
- />
51188
- </div>
51189
- @if (role.description) {
51190
- <span class="role-card__desc">{{ role.description }}</span>
51191
- }
51192
- @if (!role.hasPerms) {
51193
- <div class="perm-chips">
51194
- <span class="perm-chip perm-chip--empty">{{ tt('noPermissions') }}</span>
51195
- </div>
51196
- } @else {
51197
- @for (g of role.groups; track g.key) {
51198
- <div class="perm-group">
51199
- <span class="perm-group__label">{{ g.label }}</span>
51200
- <div class="perm-chips">
51201
- @for (p of g.perms; track p) {
51202
- <span class="perm-chip">{{ p }}</span>
51203
- }
51204
- </div>
51205
- </div>
51206
- }
51207
- }
51208
- </div>
51209
- }
51210
- </div>
51211
- </section>
51601
+
51602
+ @if (contextMessage(); as msg) {
51603
+ <div class="alert" [class]="'alert--' + alertVariant()">
51604
+ <ion-icon [name]="alertIcon()" aria-hidden="true" />
51605
+ <span>{{ msg }}</span>
51606
+ </div>
51212
51607
  }
51213
51608
 
51214
- @if (resolvedConfig().showCustomRoles) {
51215
- <section class="perm-section">
51216
- <div class="perm-section__header">
51217
- <val-title
51218
- [props]="{
51219
- size: 'medium',
51220
- color: 'dark',
51221
- bold: true,
51222
- content: tt('customRolesTitle'),
51223
- }"
51224
- />
51225
- <val-text
51226
- [props]="{
51227
- content: tt('customRolesHint'),
51228
- size: 'medium',
51229
- color: 'medium',
51230
- bold: false,
51231
- }"
51232
- />
51233
- </div>
51234
- @if (customRoles().length === 0) {
51235
- <val-text
51236
- [props]="{
51237
- content: tt('noCustomRoles'),
51238
- size: 'medium',
51239
- color: 'medium',
51240
- bold: false,
51241
- }"
51242
- />
51243
- } @else {
51244
- <div class="role-list">
51245
- @for (role of customRoles(); track role.id) {
51246
- <div class="role-card">
51247
- <div class="role-card__header">
51248
- <span class="role-card__name">{{ role.name }}</span>
51249
- </div>
51250
- @if (role.description) {
51251
- <span class="role-card__desc">{{ role.description }}</span>
51252
- }
51253
- @if (!role.hasPerms) {
51254
- <div class="perm-chips">
51255
- <span class="perm-chip perm-chip--empty">{{ tt('noPermissions') }}</span>
51256
- </div>
51257
- } @else {
51258
- @for (g of role.groups; track g.key) {
51259
- <div class="perm-group">
51260
- <span class="perm-group__label">{{ g.label }}</span>
51261
- <div class="perm-chips">
51262
- @for (p of g.perms; track p) {
51263
- <span class="perm-chip">{{ p }}</span>
51264
- }
51265
- </div>
51266
- </div>
51267
- }
51268
- }
51269
- </div>
51270
- }
51271
- </div>
51272
- }
51273
- </section>
51609
+ @if (state() === 'error') {
51610
+ <div class="row-actions">
51611
+ <val-button [props]="retryButtonProps()" (click)="onRetry()" />
51612
+ </div>
51274
51613
  }
51614
+ </section>
51615
+
51616
+ @if (resolvedConfig().showDetails && state() !== 'needs-install' && state() !== 'unsupported') {
51617
+ <section class="settings-section">
51618
+ <button class="details-toggle" type="button" (click)="toggleDetails()">
51619
+ <span>{{ showDetails() ? tt('detailsHide') : tt('detailsShow') }}</span>
51620
+ <ion-icon [name]="showDetails() ? 'chevron-up-outline' : 'chevron-down-outline'" aria-hidden="true" />
51621
+ </button>
51622
+ @if (showDetails()) {
51623
+ <dl class="details">
51624
+ <div class="details__row">
51625
+ <dt>{{ tt('detailsToken') }}</dt>
51626
+ <dd>{{ tokenPreview() ?? tt('detailsNone') }}</dd>
51627
+ </div>
51628
+ <div class="details__row">
51629
+ <dt>{{ tt('diagnosticsPermission') }}</dt>
51630
+ <dd>{{ permission() }}</dd>
51631
+ </div>
51632
+ <div class="details__row">
51633
+ <dt>{{ tt('diagnosticsSupported') }}</dt>
51634
+ <dd>{{ isSupported() ? '✓' : '✗' }}</dd>
51635
+ </div>
51636
+ </dl>
51637
+ }
51638
+ </section>
51275
51639
  }
51276
51640
  </div>
51277
- `, isInline: true, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:24px}.spinner-row,.error-row{display:flex;flex-direction:column;align-items:center;gap:16px;padding:32px 0}.perm-section{margin-bottom:32px}.perm-section__header{margin-bottom:12px}.role-list{display:flex;flex-direction:column;gap:10px}.role-card{border:1.5px solid var(--ion-color-light);border-radius:12px;padding:14px 16px;display:flex;flex-direction:column;gap:10px}.role-card__header{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.role-card__name{font-size:15px;font-weight:600;color:var(--ion-color-dark)}.role-card__desc{font-size:13px;color:var(--ion-color-medium);line-height:1.4}.perm-group{display:flex;flex-direction:column;gap:6px}.perm-group__label{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--ion-color-medium)}.perm-chips{display:flex;flex-wrap:wrap;gap:6px}.perm-chip{font-size:12px;padding:3px 10px;border-radius:20px;background:var(--ion-color-light);color:var(--ion-color-dark)}.perm-chip--empty{color:var(--ion-color-medium);background:transparent;padding-left:0}\n"], dependencies: [{ kind: "component", type: IonSpinner, selector: "ion-spinner", inputs: ["color", "duration", "name", "paused"] }, { kind: "component", type: DisplayComponent, selector: "val-display", inputs: ["props"] }, { kind: "component", type: PillComponent, selector: "val-pill", inputs: ["preset", "props"], outputs: ["pillClick", "pillAction"] }, { kind: "component", type: TitleComponent, selector: "val-title", inputs: ["props"] }, { kind: "component", type: TextComponent, selector: "val-text", inputs: ["props"] }, { kind: "component", type: ButtonComponent, selector: "val-button", inputs: ["preset", "props"], outputs: ["onClick"] }] }); }
51641
+ `, isInline: true, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:8px}.settings-section{padding:16px 0}.settings-section+.settings-section{border-top:1px solid var(--val-border-color, rgba(0, 0, 0, .08))}.section-body{margin-top:16px;display:flex;flex-direction:column;gap:10px}.row-actions{margin-top:12px}.notif-row{display:grid;grid-template-columns:44px 1fr auto;gap:16px;align-items:center}.notif-row__icon{width:44px;height:44px;border-radius:50%;background:var(--ion-color-light, rgba(0, 0, 0, .06));display:flex;align-items:center;justify-content:center}.notif-row__icon ion-icon{font-size:22px;color:var(--ion-color-dark, #1a1a1a)}.notif-row__body{display:flex;flex-direction:column;gap:4px;min-width:0}.notif-row__busy{font-size:12px;color:var(--ion-color-medium, #92949c);font-style:italic;margin-top:4px}.notif-row__control{flex-shrink:0}.alert{display:flex;gap:10px;align-items:flex-start;margin-top:12px;padding:12px 14px;border-radius:10px;font-size:13px;line-height:1.45;background:var(--ion-color-light, rgba(0, 0, 0, .04));color:var(--ion-color-dark, #1a1a1a)}.alert ion-icon{font-size:18px;flex-shrink:0;margin-top:1px}.alert--warning ion-icon{color:var(--ion-color-warning-shade, #c79e08)}.alert--danger ion-icon{color:var(--ion-color-danger-shade, #cf3c4f)}.alert--info ion-icon{color:var(--ion-color-medium, #6b6b6b)}.install-steps{margin:0;padding-left:20px;font-size:13px;line-height:1.6;color:var(--ion-color-dark, #1a1a1a)}.install-steps li+li{margin-top:4px}.details-toggle{display:flex;align-items:center;gap:6px;background:none;border:0;padding:0;font-size:13px;color:var(--ion-color-medium, #6b6b6b);cursor:pointer}.details-toggle ion-icon{font-size:14px}.details{margin:12px 0 0;display:flex;flex-direction:column;gap:6px}.details__row{display:grid;grid-template-columns:140px 1fr;gap:12px;font-size:12px;align-items:baseline}.details__row dt{margin:0;color:var(--ion-color-medium, #6b6b6b);text-transform:uppercase;letter-spacing:.4px;font-weight:500}.details__row dd{margin:0;color:var(--ion-color-dark, #1a1a1a);font-family:ui-monospace,SFMono-Regular,monospace;font-size:11px;word-break:break-all}\n"], dependencies: [{ kind: "component", type: IonIcon, selector: "ion-icon", inputs: ["color", "flipRtl", "icon", "ios", "lazy", "md", "mode", "name", "sanitize", "size", "src"] }, { kind: "component", type: ToggleInputComponent, selector: "val-toggle-input", inputs: ["preset", "props"] }, { kind: "component", type: ButtonComponent, selector: "val-button", inputs: ["preset", "props"], outputs: ["onClick"] }, { kind: "component", type: DisplayComponent, selector: "val-display", inputs: ["props"] }, { kind: "component", type: TitleComponent, selector: "val-title", inputs: ["props"] }, { kind: "component", type: TextComponent, selector: "val-text", inputs: ["props"] }] }); }
51278
51642
  }
51279
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: PermissionsViewComponent, decorators: [{
51643
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: NotificationPreferencesViewComponent, decorators: [{
51280
51644
  type: Component,
51281
- args: [{ selector: 'val-permissions-view', standalone: true, imports: [IonSpinner, DisplayComponent, PillComponent, TitleComponent, TextComponent, ButtonComponent], template: `
51645
+ args: [{ selector: 'val-notification-preferences-view', standalone: true, imports: [IonIcon, ToggleInputComponent, ButtonComponent, DisplayComponent, TitleComponent, TextComponent], template: `
51282
51646
  <div class="page">
51283
51647
  <header class="page-header">
51284
- <val-display [props]="{ size: 'small', color: 'dark', content: tt('pageTitle') }" />
51648
+ <val-display [props]="{ size: 'small', color: 'dark', content: pageTitle() }" />
51285
51649
  <val-title
51286
51650
  [props]="{
51287
51651
  size: 'large',
51288
51652
  color: 'dark',
51289
51653
  bold: false,
51290
- content: tt('pageDescription'),
51654
+ content: pageDescription(),
51291
51655
  }"
51292
51656
  />
51293
51657
  </header>
51294
51658
 
51295
- @if (loading()) {
51296
- <div class="spinner-row">
51297
- <ion-spinner name="crescent" />
51298
- </div>
51299
- } @else if (loadError()) {
51300
- <div class="error-row">
51301
- <val-text
51659
+ @if (state() === 'needs-install') {
51660
+ <section class="settings-section">
51661
+ <val-title
51302
51662
  [props]="{
51303
- content: tt('errorTitle'),
51304
51663
  size: 'medium',
51305
- color: 'medium',
51306
- bold: false,
51664
+ color: 'dark',
51665
+ bold: true,
51666
+ content: tt('iosInstallTitle'),
51307
51667
  }"
51308
51668
  />
51309
- <val-button [props]="retryButtonProps()" (click)="load()" />
51669
+ <div class="section-body">
51670
+ <val-text
51671
+ [props]="{
51672
+ size: 'small',
51673
+ color: 'medium',
51674
+ bold: false,
51675
+ content: tt('iosInstallHint'),
51676
+ }"
51677
+ />
51678
+ <ol class="install-steps">
51679
+ <li>{{ tt('iosInstallStep1') }}</li>
51680
+ <li>{{ tt('iosInstallStep2') }}</li>
51681
+ <li>{{ tt('iosInstallStep3') }}</li>
51682
+ <li>{{ tt('iosInstallStep4') }}</li>
51683
+ </ol>
51684
+ </div>
51685
+ </section>
51686
+ }
51687
+
51688
+ <section class="settings-section">
51689
+ <div class="notif-row">
51690
+ <div class="notif-row__icon">
51691
+ <ion-icon name="notifications-outline" aria-hidden="true" />
51692
+ </div>
51693
+ <div class="notif-row__body">
51694
+ <val-title
51695
+ [props]="{
51696
+ size: 'medium',
51697
+ color: 'dark',
51698
+ bold: true,
51699
+ content: tt('simpleTitle'),
51700
+ }"
51701
+ />
51702
+ <val-text
51703
+ [props]="{
51704
+ size: 'small',
51705
+ color: 'medium',
51706
+ bold: false,
51707
+ content: tt('simpleDescription'),
51708
+ }"
51709
+ />
51710
+ @if (busyLabel(); as label) {
51711
+ <span class="notif-row__busy">{{ label }}</span>
51712
+ }
51713
+ </div>
51714
+ <div class="notif-row__control">
51715
+ <val-toggle-input [props]="toggleProps()" />
51716
+ </div>
51310
51717
  </div>
51311
- } @else {
51312
- @if (resolvedConfig().showSystemRoles) {
51313
- <section class="perm-section">
51314
- <div class="perm-section__header">
51315
- <val-title
51316
- [props]="{
51317
- size: 'medium',
51318
- color: 'dark',
51319
- bold: true,
51320
- content: tt('systemRolesTitle'),
51321
- }"
51322
- />
51323
- <val-text
51324
- [props]="{
51325
- content: tt('systemRolesHint'),
51326
- size: 'medium',
51327
- color: 'medium',
51328
- bold: false,
51329
- }"
51330
- />
51331
- </div>
51332
- <div class="role-list">
51333
- @for (role of systemRoles(); track role.id) {
51334
- <div class="role-card">
51335
- <div class="role-card__header">
51336
- <span class="role-card__name">{{ roleLabel(role.name) }}</span>
51337
- <val-pill
51338
- [props]="{
51339
- label: tt('systemBadge'),
51340
- color: 'primary',
51341
- weight: 'bold',
51342
- }"
51343
- />
51344
- </div>
51345
- @if (role.description) {
51346
- <span class="role-card__desc">{{ role.description }}</span>
51347
- }
51348
- @if (!role.hasPerms) {
51349
- <div class="perm-chips">
51350
- <span class="perm-chip perm-chip--empty">{{ tt('noPermissions') }}</span>
51351
- </div>
51352
- } @else {
51353
- @for (g of role.groups; track g.key) {
51354
- <div class="perm-group">
51355
- <span class="perm-group__label">{{ g.label }}</span>
51356
- <div class="perm-chips">
51357
- @for (p of g.perms; track p) {
51358
- <span class="perm-chip">{{ p }}</span>
51359
- }
51360
- </div>
51361
- </div>
51362
- }
51363
- }
51364
- </div>
51365
- }
51366
- </div>
51367
- </section>
51718
+
51719
+ @if (contextMessage(); as msg) {
51720
+ <div class="alert" [class]="'alert--' + alertVariant()">
51721
+ <ion-icon [name]="alertIcon()" aria-hidden="true" />
51722
+ <span>{{ msg }}</span>
51723
+ </div>
51368
51724
  }
51369
51725
 
51370
- @if (resolvedConfig().showCustomRoles) {
51371
- <section class="perm-section">
51372
- <div class="perm-section__header">
51373
- <val-title
51374
- [props]="{
51375
- size: 'medium',
51376
- color: 'dark',
51377
- bold: true,
51378
- content: tt('customRolesTitle'),
51379
- }"
51380
- />
51381
- <val-text
51382
- [props]="{
51383
- content: tt('customRolesHint'),
51384
- size: 'medium',
51385
- color: 'medium',
51386
- bold: false,
51387
- }"
51388
- />
51389
- </div>
51390
- @if (customRoles().length === 0) {
51391
- <val-text
51392
- [props]="{
51393
- content: tt('noCustomRoles'),
51394
- size: 'medium',
51395
- color: 'medium',
51396
- bold: false,
51397
- }"
51398
- />
51399
- } @else {
51400
- <div class="role-list">
51401
- @for (role of customRoles(); track role.id) {
51402
- <div class="role-card">
51403
- <div class="role-card__header">
51404
- <span class="role-card__name">{{ role.name }}</span>
51405
- </div>
51406
- @if (role.description) {
51407
- <span class="role-card__desc">{{ role.description }}</span>
51408
- }
51409
- @if (!role.hasPerms) {
51410
- <div class="perm-chips">
51411
- <span class="perm-chip perm-chip--empty">{{ tt('noPermissions') }}</span>
51412
- </div>
51413
- } @else {
51414
- @for (g of role.groups; track g.key) {
51415
- <div class="perm-group">
51416
- <span class="perm-group__label">{{ g.label }}</span>
51417
- <div class="perm-chips">
51418
- @for (p of g.perms; track p) {
51419
- <span class="perm-chip">{{ p }}</span>
51420
- }
51421
- </div>
51422
- </div>
51423
- }
51424
- }
51425
- </div>
51426
- }
51427
- </div>
51428
- }
51429
- </section>
51726
+ @if (state() === 'error') {
51727
+ <div class="row-actions">
51728
+ <val-button [props]="retryButtonProps()" (click)="onRetry()" />
51729
+ </div>
51430
51730
  }
51731
+ </section>
51732
+
51733
+ @if (resolvedConfig().showDetails && state() !== 'needs-install' && state() !== 'unsupported') {
51734
+ <section class="settings-section">
51735
+ <button class="details-toggle" type="button" (click)="toggleDetails()">
51736
+ <span>{{ showDetails() ? tt('detailsHide') : tt('detailsShow') }}</span>
51737
+ <ion-icon [name]="showDetails() ? 'chevron-up-outline' : 'chevron-down-outline'" aria-hidden="true" />
51738
+ </button>
51739
+ @if (showDetails()) {
51740
+ <dl class="details">
51741
+ <div class="details__row">
51742
+ <dt>{{ tt('detailsToken') }}</dt>
51743
+ <dd>{{ tokenPreview() ?? tt('detailsNone') }}</dd>
51744
+ </div>
51745
+ <div class="details__row">
51746
+ <dt>{{ tt('diagnosticsPermission') }}</dt>
51747
+ <dd>{{ permission() }}</dd>
51748
+ </div>
51749
+ <div class="details__row">
51750
+ <dt>{{ tt('diagnosticsSupported') }}</dt>
51751
+ <dd>{{ isSupported() ? '✓' : '✗' }}</dd>
51752
+ </div>
51753
+ </dl>
51754
+ }
51755
+ </section>
51431
51756
  }
51432
51757
  </div>
51433
- `, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:24px}.spinner-row,.error-row{display:flex;flex-direction:column;align-items:center;gap:16px;padding:32px 0}.perm-section{margin-bottom:32px}.perm-section__header{margin-bottom:12px}.role-list{display:flex;flex-direction:column;gap:10px}.role-card{border:1.5px solid var(--ion-color-light);border-radius:12px;padding:14px 16px;display:flex;flex-direction:column;gap:10px}.role-card__header{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.role-card__name{font-size:15px;font-weight:600;color:var(--ion-color-dark)}.role-card__desc{font-size:13px;color:var(--ion-color-medium);line-height:1.4}.perm-group{display:flex;flex-direction:column;gap:6px}.perm-group__label{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--ion-color-medium)}.perm-chips{display:flex;flex-wrap:wrap;gap:6px}.perm-chip{font-size:12px;padding:3px 10px;border-radius:20px;background:var(--ion-color-light);color:var(--ion-color-dark)}.perm-chip--empty{color:var(--ion-color-medium);background:transparent;padding-left:0}\n"] }]
51758
+ `, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:8px}.settings-section{padding:16px 0}.settings-section+.settings-section{border-top:1px solid var(--val-border-color, rgba(0, 0, 0, .08))}.section-body{margin-top:16px;display:flex;flex-direction:column;gap:10px}.row-actions{margin-top:12px}.notif-row{display:grid;grid-template-columns:44px 1fr auto;gap:16px;align-items:center}.notif-row__icon{width:44px;height:44px;border-radius:50%;background:var(--ion-color-light, rgba(0, 0, 0, .06));display:flex;align-items:center;justify-content:center}.notif-row__icon ion-icon{font-size:22px;color:var(--ion-color-dark, #1a1a1a)}.notif-row__body{display:flex;flex-direction:column;gap:4px;min-width:0}.notif-row__busy{font-size:12px;color:var(--ion-color-medium, #92949c);font-style:italic;margin-top:4px}.notif-row__control{flex-shrink:0}.alert{display:flex;gap:10px;align-items:flex-start;margin-top:12px;padding:12px 14px;border-radius:10px;font-size:13px;line-height:1.45;background:var(--ion-color-light, rgba(0, 0, 0, .04));color:var(--ion-color-dark, #1a1a1a)}.alert ion-icon{font-size:18px;flex-shrink:0;margin-top:1px}.alert--warning ion-icon{color:var(--ion-color-warning-shade, #c79e08)}.alert--danger ion-icon{color:var(--ion-color-danger-shade, #cf3c4f)}.alert--info ion-icon{color:var(--ion-color-medium, #6b6b6b)}.install-steps{margin:0;padding-left:20px;font-size:13px;line-height:1.6;color:var(--ion-color-dark, #1a1a1a)}.install-steps li+li{margin-top:4px}.details-toggle{display:flex;align-items:center;gap:6px;background:none;border:0;padding:0;font-size:13px;color:var(--ion-color-medium, #6b6b6b);cursor:pointer}.details-toggle ion-icon{font-size:14px}.details{margin:12px 0 0;display:flex;flex-direction:column;gap:6px}.details__row{display:grid;grid-template-columns:140px 1fr;gap:12px;font-size:12px;align-items:baseline}.details__row dt{margin:0;color:var(--ion-color-medium, #6b6b6b);text-transform:uppercase;letter-spacing:.4px;font-weight:500}.details__row dd{margin:0;color:var(--ion-color-dark, #1a1a1a);font-family:ui-monospace,SFMono-Regular,monospace;font-size:11px;word-break:break-all}\n"] }]
51434
51759
  }], ctorParameters: () => [], propDecorators: { config: [{
51435
51760
  type: Input
51436
51761
  }] } });
51437
51762
 
51438
51763
  /**
51439
- * Helper para montar la vista Permisos (`val-permissions-view`) como ruta en una
51440
- * app del factory. El `config` se pasa por route `data` (`permissionsConfig`) y el
51764
+ * Helper para montar las **preferencias de notificaciones push**
51765
+ * (`val-notification-preferences-view`) como ruta en una app del factory. El
51766
+ * `config` se pasa por route `data` (`notificationPreferencesConfig`) y el
51441
51767
  * componente lo lee como fallback de su `@Input() config`.
51442
51768
  *
51769
+ * No confundir con `provideValtechNotificationsRoutes` (el inbox / feed).
51770
+ *
51443
51771
  * @example
51444
51772
  * ```ts
51445
- * // settings.routes.ts
51773
+ * // app.routes.ts (típicamente bajo settings)
51446
51774
  * export const settingsRoutes: Routes = [
51447
- * ...provideValtechPermissionsRoutes(), // → /app/settings/permissions
51448
- * { path: 'preferences', loadComponent: () => ... },
51775
+ * ...provideValtechNotificationPreferencesRoutes(), // → notifications
51449
51776
  * ];
51450
51777
  *
51451
51778
  * // con config acotada:
51452
- * ...provideValtechPermissionsRoutes({ config: { showCustomRoles: false } }),
51453
- * ...provideValtechPermissionsRoutes({ path: 'roles' }),
51454
- * ```
51455
- */
51456
- function provideValtechPermissionsRoutes(opts) {
51457
- return [
51458
- {
51459
- path: opts?.path ?? 'permissions',
51460
- component: PermissionsViewComponent,
51461
- data: { permissionsConfig: opts?.config },
51462
- },
51463
- ];
51464
- }
51465
-
51466
- /**
51467
- * Helper para montar el bloque completo de Settings en una app del factory: el
51468
- * HUB de navegación (`val-settings-hub`) + TODAS las vistas promovidas (Perfil,
51469
- * Preferencias, Seguridad, Cuenta, Gestión org, Preferencias de notificaciones,
51470
- * Permisos) + las rutas propias de la app (`extraRoutes`). Cada `include*` permite
51471
- * apagar una sección; `hubPath`/`indexRedirectTo`/`*Path` permiten reubicar el hub
51472
- * y las vistas para preservar URLs existentes.
51473
- *
51474
- * showcase lo consume (ver `settings.routes.ts` de showcase): hub en `preferences`,
51475
- * preferences-view en `general`, index→redirect, `organization/new` por `extraRoutes`.
51476
- *
51477
- * @example
51478
- * ```ts
51479
- * // app.routes.ts
51480
- * import { provideValtechSettingsRoutes } from 'valtech-components';
51481
- *
51482
- * export const routes: Routes = [
51483
- * {
51484
- * path: 'app',
51485
- * children: [
51486
- * ...provideValtechSettingsRoutes({
51487
- * hubConfig: { onLogout: () => auth.logoutWithConfirmation() },
51488
- * extraRoutes: [
51489
- * { path: 'security', loadComponent: () => ... },
51490
- * ],
51491
- * }),
51492
- * ],
51493
- * },
51494
- * ];
51779
+ * ...provideValtechNotificationPreferencesRoutes({ config: { showDetails: false } }),
51780
+ * ...provideValtechNotificationPreferencesRoutes({ path: 'push' }),
51495
51781
  * ```
51496
51782
  */
51497
- function provideValtechSettingsRoutes(opts) {
51498
- const basePath = opts?.basePath ?? 'settings';
51499
- const hubPath = opts?.hubPath ?? '';
51500
- const includeProfile = opts?.includeProfile ?? true;
51501
- const includePreferences = opts?.includePreferences ?? true;
51502
- const includeSecurity = opts?.includeSecurity ?? true;
51503
- const includeAccount = opts?.includeAccount ?? true;
51504
- const includeOrganization = opts?.includeOrganization ?? true;
51505
- const includeNotificationPreferences = opts?.includeNotificationPreferences ?? true;
51506
- const includePermissions = opts?.includePermissions ?? true;
51783
+ function provideValtechNotificationPreferencesRoutes(opts) {
51507
51784
  return [
51508
51785
  {
51509
- path: basePath,
51510
- children: [
51511
- ...(opts?.indexRedirectTo
51512
- ? [{ path: '', redirectTo: opts.indexRedirectTo, pathMatch: 'full' }]
51513
- : []),
51514
- {
51515
- path: hubPath,
51516
- component: SettingsHubComponent,
51517
- data: { settingsHubConfig: opts?.hubConfig },
51518
- },
51519
- ...(includeProfile ? provideValtechProfileRoutes({ config: opts?.profileConfig }) : []),
51520
- ...(includePreferences
51521
- ? provideValtechPreferencesRoutes({
51522
- path: opts?.preferencesPath ?? 'preferences',
51523
- config: opts?.preferencesConfig,
51524
- })
51525
- : []),
51526
- ...(includeSecurity ? provideValtechSecurityRoutes({ config: opts?.securityConfig }) : []),
51527
- ...(includeAccount ? provideValtechAccountRoutes({ config: opts?.accountConfig }) : []),
51528
- ...(includeOrganization
51529
- ? provideValtechOrganizationRoutes({ config: opts?.organizationConfig })
51530
- : []),
51531
- ...(includeNotificationPreferences
51532
- ? provideValtechNotificationPreferencesRoutes({
51533
- path: opts?.notificationPreferencesPath ?? 'notifications',
51534
- config: opts?.notificationPreferencesConfig,
51535
- })
51536
- : []),
51537
- ...(includePermissions
51538
- ? provideValtechPermissionsRoutes({ config: opts?.permissionsConfig })
51539
- : []),
51540
- ...(opts?.extraRoutes ?? []),
51541
- ],
51786
+ path: opts?.path ?? 'notifications',
51787
+ component: NotificationPreferencesViewComponent,
51788
+ data: { notificationPreferencesConfig: opts?.config },
51542
51789
  },
51543
51790
  ];
51544
51791
  }
51545
51792
 
51546
51793
  /**
51547
- * Refreshable Stream
51548
- *
51549
- * Helper estándar para vistas que consumen datos de Firestore. Soporta DOS
51550
- * modos el caller decide cuál vía la `factory`:
51551
- *
51552
- * - **one-shot** (recomendado por defecto): `() => from(svc.getAllOnce())`.
51553
- * Carga una vez y completa. Más barato — sin listener vivo.
51554
- * - **real-time**: `() => svc.getAll()`. Listener Firestore que auto-actualiza.
51555
- * Solo donde importa (inbox de notificaciones, badges).
51556
- *
51557
- * En ambos modos la primera suscripción se gatea por `firebaseAuthReady`
51558
- * (sesión de Firebase Auth lista) — así el listener/lectura nunca se adjunta
51559
- * antes de que `request.auth` esté disponible en las reglas de Firestore, lo
51560
- * que cierra la ventana de `permission-denied` en cold start de PWA.
51561
- *
51562
- * El `retry` con backoff corto es solo red de seguridad para reconexiones
51563
- * transitorias (no para cubrir cold start — eso lo cubre el gate de auth).
51564
- */
51565
- /**
51566
- * Crea un stream refrescable a partir de una factory de Observable.
51567
- *
51568
- * DEBE llamarse en un injection context (field initializer o constructor) —
51569
- * usa `toSignal`/`toObservable`/`inject` internamente.
51570
- *
51571
- * @param factory - Función que produce el Observable a consumir. El caller
51572
- * decide el modo: `() => from(svc.getAllOnce())` (one-shot) o
51573
- * `() => svc.getAll()` (real-time).
51574
- * @param options - Configuración opcional (fallback, retry, gate).
51575
- *
51576
- * @example
51577
- * ```typescript
51578
- * // one-shot (recomendado por defecto)
51579
- * private readonly stream = createRefreshableStream(
51580
- * () => from(this.svc.getAllOnce()),
51581
- * );
51582
- *
51583
- * // real-time (inbox)
51584
- * private readonly stream = createRefreshableStream(
51585
- * () => this.notifs.getAll(50),
51586
- * );
51587
- *
51588
- * readonly items = this.stream.data;
51589
- * readonly loading = this.stream.loading;
51794
+ * Consume el catálogo de permisos del backend (`GET /org/{orgId}/permissions-catalog`).
51795
+ * El backend es la fuente de verdad de qué permisos existen y de su alcance
51796
+ * (globales de plataforma vs. propios de una app). La respuesta incluye el
51797
+ * `appId` del request para que la vista distinga la app actual.
51590
51798
  *
51591
- * ionViewWillEnter() {
51592
- * this.pageRefresh.register(() => this.stream.reload());
51593
- * }
51594
- * ```
51799
+ * Promovido desde `showcase` a la lib bajo el proceso de ADR-021. Usa
51800
+ * `VALTECH_AUTH_CONFIG.apiUrl` (como `OrgService`) en vez del `environment` de la
51801
+ * app — la lib no importa el environment del consumer.
51595
51802
  */
51596
- function createRefreshableStream(factory, options) {
51597
- const injector = options?.injector ?? inject(Injector);
51598
- const fallback = (options?.fallback ?? null);
51599
- const retryCount = options?.retryCount ?? 4;
51600
- const retryBaseDelayMs = options?.retryBaseDelayMs ?? 500;
51601
- const gateOnFirebaseAuth = options?.gateOnFirebaseAuth ?? true;
51602
- const authGateTimeoutMs = options?.authGateTimeoutMs ?? 20000;
51603
- const firstEmitTimeoutMs = options?.firstEmitTimeoutMs ?? 20000;
51604
- // FirebaseService es opcional sin él (apps sin Firebase) no se gatea.
51605
- const firebase = gateOnFirebaseAuth ? injector.get(FirebaseService, null) : null;
51606
- const _reload = signal(0);
51607
- const _loading = signal(true);
51608
- const _error = signal(false);
51609
- // Gate de Firebase Auth: el stream no abre la primera suscripción hasta que
51610
- // la sesión esté confirmada. `race` con un `timer` de seguridad — si la
51611
- // sesión NUNCA se establece (token inválido, signInWithFirebase falló), el
51612
- // gate igual se libera tras `authGateTimeoutMs` y la factory procede: si no
51613
- // hay permiso terminará en estado `error`, jamás en skeleton infinito.
51614
- // Tag los dos inputs del `race` para distinguir en logs cuál ganó: la sesión
51615
- // de Firebase Auth (`auth-ready`) o el timeout de seguridad (`timeout`).
51616
- const authGate$ = firebase
51617
- ? race(firebase.firebaseAuthReady$.pipe(map$1(() => 'auth-ready')), timer(authGateTimeoutMs).pipe(map$1(() => 'timeout'))).pipe(take(1), tap(winner => {
51618
- if (winner === 'timeout') {
51619
- console.warn(`[RefreshStream] auth gate resolved by TIMEOUT after ${authGateTimeoutMs}ms ` +
51620
- '(Firebase Auth session never confirmed)');
51621
- }
51622
- else {
51623
- console.log('[RefreshStream] auth gate resolved by firebaseAuthReady$ (session ready)');
51624
- }
51625
- }))
51626
- : of(true);
51627
- const data = toSignal(toObservable(_reload, { injector }).pipe(tap(() => {
51628
- _loading.set(true);
51629
- _error.set(false);
51630
- }),
51631
- // Esperar a Firebase Auth listo antes de cada (re)suscripción. Como
51632
- // `firebaseAuthReady$` completa tras la primera emisión, las reloads
51633
- // posteriores resuelven el gate de inmediato (auth ya está listo).
51634
- switchMap(() => authGate$.pipe(switchMap(() => factory().pipe(tap(() => {
51635
- _loading.set(false);
51636
- _error.set(false);
51637
- console.log('[RefreshStream] factory emitted first value — loading=false');
51638
- }),
51639
- // Backoff corto exponencial acotado — solo red de seguridad para
51640
- // reconexiones transitorias, NO para cubrir cold start.
51641
- retry({
51642
- count: retryCount,
51643
- delay: (_err, attempt) => timer(Math.min(retryBaseDelayMs * 2 ** (attempt - 1), 4000)),
51644
- }),
51645
- // Techo absoluto para la PRIMERA emisión. Si la factory no emite
51646
- // ni valor ni error (servicio nunca inicializado → `EMPTY` →
51647
- // silencio), `timeout` dispara un error → `catchError` → estado
51648
- // `error`. Nunca skeleton perpetuo. Va DESPUÉS de `retry` para
51649
- // que cuente el tiempo total, no por intento.
51650
- timeout({ first: firstEmitTimeoutMs }), catchError(err => {
51651
- _loading.set(false);
51652
- _error.set(true);
51653
- console.error(`[RefreshStream] factory failed/timed out → error state: ${err instanceof Error ? err.message : String(err)}`);
51654
- return of(fallback);
51655
- })))))), { initialValue: null, injector });
51656
- return {
51657
- data,
51658
- loading: _loading.asReadonly(),
51659
- error: _error.asReadonly(),
51660
- reload: () => _reload.update(n => n + 1),
51661
- };
51803
+ class PermissionCatalogService {
51804
+ constructor(config, http) {
51805
+ this.config = config;
51806
+ this.http = http;
51807
+ }
51808
+ getCatalog(orgId) {
51809
+ return this.http.get(`${this.config.apiUrl}/org/${orgId}/permissions-catalog`);
51810
+ }
51811
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: PermissionCatalogService, deps: [{ token: VALTECH_AUTH_CONFIG }, { token: i1$3.HttpClient }], target: i0.ɵɵFactoryTarget.Injectable }); }
51812
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: PermissionCatalogService, providedIn: 'root' }); }
51662
51813
  }
51814
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: PermissionCatalogService, decorators: [{
51815
+ type: Injectable,
51816
+ args: [{ providedIn: 'root' }]
51817
+ }], ctorParameters: () => [{ type: undefined, decorators: [{
51818
+ type: Inject,
51819
+ args: [VALTECH_AUTH_CONFIG]
51820
+ }] }, { type: i1$3.HttpClient }] });
51663
51821
 
51664
51822
  /**
51665
- * Defaults i18n (es/en) embebidos en `val-notifications-view`. Auto-registrados
51666
- * en el constructor del componente si el consumer no proveyó el namespace
51667
- * (`Notifications` por default). SOLO incluye las keys que la vista usa.
51823
+ * Defaults i18n (es/en) embebidos en `val-permissions-view`. Auto-registrados en
51824
+ * el constructor del componente si el consumer no proveyó el namespace
51825
+ * (`Settings.Permissions` por default). SOLO incluye las keys que la vista usa.
51668
51826
  *
51669
- * Portado del override `Notifications` (showcase) bajo el proceso de ADR-021.
51827
+ * Portado EXACTO de `PERMISSIONS_I18N` (showcase) bajo el proceso de ADR-021.
51670
51828
  */
51671
- const NOTIFICATIONS_VIEW_I18N = {
51829
+ const PERMISSIONS_VIEW_I18N = {
51672
51830
  es: {
51673
- pageTitle: 'Notificaciones',
51674
- pageDescription: 'Tu bandeja de mensajes y actualizaciones',
51675
- groupToday: 'Hoy',
51676
- groupThisMonth: 'Este mes',
51677
- groupEarlier: 'Anteriores',
51678
- empty: 'Aún no tienes notificaciones',
51679
- emptyHint: 'Activa las notificaciones para recibir actualizaciones, alertas y novedades importantes.',
51680
- emptyCta: 'Ir a configuración',
51681
- loadErrorTitle: 'No pudimos cargar tus notificaciones',
51682
- loadErrorHint: 'Revisa tu conexión e inténtalo de nuevo. Tus notificaciones siguen guardadas.',
51683
- offlineTitle: 'Sin conexión',
51684
- offlineHint: 'No hay acceso a la red. Reconéctate e intenta de nuevo.',
51685
- retryCta: 'Reintentar',
51686
- pushOffTitle: 'No recibirás avisos',
51687
- pushOffHint: 'Puedes leer tus mensajes aquí, pero las notificaciones push están desactivadas. Actívalas para recibir avisos en tu dispositivo.',
51688
- pushOffCta: 'Activar notificaciones',
51689
- loading: 'Cargando…',
51690
- loadMore: 'Cargar más',
51691
- seeMore: 'Ver más',
51692
- seeLess: 'Ver menos',
51693
- open: 'Ver',
51694
- markAll: 'Marcar todas como leídas',
51695
- unread: 'No leída',
51696
- crossApp: 'Desde {app}',
51697
- timeNow: 'ahora',
51698
- timeMinutes: 'hace {n} min',
51699
- timeHours: 'hace {n} h',
51700
- timeDays: 'hace {n} d',
51701
- markedAllSuccess: 'Notificaciones marcadas como leídas.',
51702
- markedAllError: 'No se pudo marcar todas como leídas.',
51703
- openError: 'No se pudo abrir la notificación.',
51831
+ pageTitle: 'Permisos',
51832
+ pageDescription: 'Roles y permisos de esta app en la organización activa',
51833
+ systemRolesTitle: 'Roles del sistema',
51834
+ systemRolesHint: 'Predefinidos por la plataforma. No editables.',
51835
+ customRolesTitle: 'Roles personalizados',
51836
+ customRolesHint: 'Definidos por tu organización.',
51837
+ systemBadge: 'Sistema',
51838
+ noCustomRoles: 'Esta organización no tiene roles personalizados.',
51839
+ noPermissions: 'Sin permisos asignados.',
51840
+ // Grupos de alcance
51841
+ groupApp: 'Permisos de la app',
51842
+ groupOrg: 'Permisos de la organización',
51843
+ groupPlatform: 'Permisos de plataforma',
51844
+ groupOther: 'Otras apps',
51845
+ // Roles del sistema
51846
+ roleViewer: 'Visor',
51847
+ roleEditor: 'Editor',
51848
+ roleAdmin: 'Administrador',
51849
+ roleSuperAdmin: 'Super Administrador',
51850
+ roleBingoAdmin: 'Bingo · Administrador',
51851
+ roleBingoTesorero: 'Bingo · Tesorero',
51852
+ roleBingoTaquilla: 'Bingo · Taquilla',
51853
+ roleBingoOperator: 'Bingo · Operador',
51854
+ // Apps
51855
+ app_showcase: 'Showcase',
51856
+ app_bingo: 'Bingo',
51857
+ // Recursos
51858
+ permUsers: 'Usuarios',
51859
+ permRoles: 'Roles',
51860
+ permRbac: 'Control de acceso',
51861
+ permApps: 'Apps',
51862
+ 'permApi-keys': 'API keys',
51863
+ 'permEmail-logs': 'Logs de correo',
51864
+ 'permSms-logs': 'Logs de SMS',
51865
+ permDocuments: 'Documentos',
51866
+ permMedia: 'Media',
51867
+ permTemplates: 'Plantillas',
51868
+ permRequests: 'Solicitudes',
51869
+ permBingo: 'Bingo',
51870
+ // Acciones
51871
+ permRead: 'Leer',
51872
+ permWrite: 'Escribir',
51873
+ permCreate: 'Crear',
51874
+ permDelete: 'Eliminar',
51875
+ permManage: 'Gestionar',
51876
+ permEdit: 'Editar',
51877
+ permSell: 'Vender',
51878
+ permCheckin: 'Canjear',
51879
+ permSettle: 'Rendir',
51880
+ permExport: 'Exportar',
51881
+ permOperate: 'Operar',
51882
+ permAll: 'Todo',
51883
+ // Loading / error
51884
+ errorTitle: 'Error al cargar permisos',
51885
+ retry: 'Reintentar',
51704
51886
  },
51705
51887
  en: {
51706
- pageTitle: 'Notifications',
51707
- pageDescription: 'Your inbox of messages and updates',
51708
- groupToday: 'Today',
51709
- groupThisMonth: 'This month',
51710
- groupEarlier: 'Earlier',
51711
- empty: 'No notifications yet',
51712
- emptyHint: 'Enable notifications to receive updates, alerts and important news.',
51713
- emptyCta: 'Go to settings',
51714
- loadErrorTitle: "We couldn't load your notifications",
51715
- loadErrorHint: 'Check your connection and try again. Your notifications are still saved.',
51716
- offlineTitle: 'No connection',
51717
- offlineHint: 'No network access. Reconnect and try again.',
51718
- retryCta: 'Retry',
51719
- pushOffTitle: "You won't get alerts",
51720
- pushOffHint: 'You can read your messages here, but push notifications are off. Turn them on to get alerts on your device.',
51721
- pushOffCta: 'Enable notifications',
51722
- loading: 'Loading…',
51723
- loadMore: 'Load more',
51724
- seeMore: 'See more',
51725
- seeLess: 'See less',
51726
- open: 'Open',
51727
- markAll: 'Mark all as read',
51728
- unread: 'Unread',
51729
- crossApp: 'From {app}',
51730
- timeNow: 'now',
51731
- timeMinutes: '{n} min ago',
51732
- timeHours: '{n} h ago',
51733
- timeDays: '{n} d ago',
51734
- markedAllSuccess: 'Notifications marked as read.',
51735
- markedAllError: 'Could not mark all as read.',
51736
- openError: 'Could not open notification.',
51737
- },
51738
- };
51739
-
51740
- addIcons({
51741
- alarmOutline,
51742
- chatboxEllipsesOutline,
51743
- chatbubbleOutline,
51744
- checkmarkCircleOutline,
51745
- checkmarkDoneOutline,
51746
- cloudOfflineOutline,
51747
- cogOutline,
51748
- documentTextOutline,
51749
- notificationsOffOutline,
51750
- notificationsOutline,
51751
- openOutline,
51752
- personOutline,
51753
- });
51754
- const DEFAULT_NAMESPACE = 'Notifications';
51755
- const DEFAULT_SETTINGS_PATH = '/app/settings/notifications';
51756
- const DEFAULT_PAGE_SIZE = 10;
51757
- const DEFAULT_MAX_LOAD = 50;
51888
+ pageTitle: 'Permissions',
51889
+ pageDescription: 'Roles and permissions of this app in the active organization',
51890
+ systemRolesTitle: 'System roles',
51891
+ systemRolesHint: 'Defined by the platform. Not editable.',
51892
+ customRolesTitle: 'Custom roles',
51893
+ customRolesHint: 'Defined by your organization.',
51894
+ systemBadge: 'System',
51895
+ noCustomRoles: 'This organization has no custom roles.',
51896
+ noPermissions: 'No permissions assigned.',
51897
+ groupApp: 'App permissions',
51898
+ groupOrg: 'Organization permissions',
51899
+ groupPlatform: 'Platform permissions',
51900
+ groupOther: 'Other apps',
51901
+ roleViewer: 'Viewer',
51902
+ roleEditor: 'Editor',
51903
+ roleAdmin: 'Administrator',
51904
+ roleSuperAdmin: 'Super Administrator',
51905
+ roleBingoAdmin: 'Bingo · Administrator',
51906
+ roleBingoTesorero: 'Bingo · Treasurer',
51907
+ roleBingoTaquilla: 'Bingo · Box office',
51908
+ roleBingoOperator: 'Bingo · Operator',
51909
+ app_showcase: 'Showcase',
51910
+ app_bingo: 'Bingo',
51911
+ permUsers: 'Users',
51912
+ permRoles: 'Roles',
51913
+ permRbac: 'Access control',
51914
+ permApps: 'Apps',
51915
+ 'permApi-keys': 'API keys',
51916
+ 'permEmail-logs': 'Email logs',
51917
+ 'permSms-logs': 'SMS logs',
51918
+ permDocuments: 'Documents',
51919
+ permMedia: 'Media',
51920
+ permTemplates: 'Templates',
51921
+ permRequests: 'Requests',
51922
+ permBingo: 'Bingo',
51923
+ permRead: 'Read',
51924
+ permWrite: 'Write',
51925
+ permCreate: 'Create',
51926
+ permDelete: 'Delete',
51927
+ permManage: 'Manage',
51928
+ permEdit: 'Edit',
51929
+ permSell: 'Sell',
51930
+ permCheckin: 'Check-in',
51931
+ permSettle: 'Settle',
51932
+ permExport: 'Export',
51933
+ permOperate: 'Operate',
51934
+ permAll: 'All',
51935
+ errorTitle: 'Error loading permissions',
51936
+ retry: 'Retry',
51937
+ },
51938
+ };
51939
+
51940
+ const DEFAULT_NAMESPACE = 'Settings.Permissions';
51758
51941
  /**
51759
- * `val-notifications-view` — inbox de notificaciones full-feature autocontenido
51760
- * (organism). Promovido desde `showcase` bajo el proceso de ADR-021.
51942
+ * `val-permissions-view` — vista Permisos full-feature autocontenida (organism).
51943
+ * Visor RBAC de solo lectura: muestra los roles del sistema (Visor/Editor/Admin +
51944
+ * roles de plataforma) y los roles personalizados de la org activa, con el
51945
+ * catálogo de permisos agrupado por alcance (app actual / plataforma / otras apps).
51946
+ * Promovida desde `showcase` bajo el proceso de ADR-021.
51761
51947
  *
51762
- * Separado de `/app/settings/notifications` (que controla permiso FCM + master
51763
- * toggle). Aquí el user lee/abre/marca-leídas sus mensajes.
51948
+ * **RBAC interno** (como `organization-view`): lee la org activa vía
51949
+ * `AuthService.user().activeOrgId`. La vista NO muta nada — el control de quién
51950
+ * puede verla es responsabilidad del guard de ruta del consumer; no hay gating
51951
+ * fino interno ni se expone como config.
51764
51952
  *
51765
- * - **Stream real-time** vía `NotificationsService.getAll(maxLoad)` envuelto en
51766
- * `createRefreshableStream` (gateado por `firebaseAuthReady`, retry corto,
51767
- * `firstEmitTimeoutMs: 15000`). Estados: loading (skeleton), error
51768
- * (`val-empty-state` + reintentar), empty (`val-empty-state` + CTA), data
51769
- * (lista agrupada Hoy / Este mes / Anteriores).
51770
- * - **Abrir** una notif → `NotificationActionService.open()` (markRead +
51771
- * switch-org + navegar/handoff cross-app). Hook `onNotificationOpen` opcional.
51772
- * - **Marcar todas como leídas** (gateado por `enableMarkAll`).
51773
- * - **CTA push-off** cuando el permiso del navegador no está concedido (gateado
51774
- * por `showPushOffCta`); navega a `settingsNotificationsPath`.
51775
- * - **Paginación soft** — slice local (`pageSize`, +`pageSize` por click) sobre el
51776
- * cache del stream; el tope de fetch es `maxLoad`.
51777
- * - **i18n auto-registrado** es/en bajo `i18nNamespace` (default `Notifications`).
51953
+ * Datos: `forkJoin` de `OrgService.listOrgRoles(orgId)` (roles de la org) +
51954
+ * `PermissionCatalogService.getCatalog(orgId)` (catálogo + appId actual). El
51955
+ * catálogo define el alcance (`scope`/`appId`) de cada recurso para agrupar.
51778
51956
  *
51779
- * NO renderiza ion-content — vive dentro de val-page-wrapper. `Router` y
51780
- * `ActivatedRoute` se inyectan `{ optional: true }` funciona sin router
51781
- * (embebida / Storybook). El forward-compat icon resolver lee `notif.payload.icon`
51782
- * si existe (`{ type, value, color? }`), cae a un mapa por `notif.type`, luego a
51783
- * `notifications-outline`.
51957
+ * NO renderiza ion-content — vive dentro de val-page-wrapper. `ActivatedRoute` se
51958
+ * inyecta `{ optional: true }` solo para leer el route data de config. Auto-registra
51959
+ * sus defaults i18n (es/en) si el consumer no proveyó el namespace (default
51960
+ * `Settings.Permissions`).
51784
51961
  */
51785
- class NotificationsViewComponent {
51962
+ class PermissionsViewComponent {
51786
51963
  /** Namespace i18n resuelto (capturado para llamadas no-reactivas). */
51787
51964
  get ns() {
51788
51965
  return this.resolvedConfig().i18nNamespace;
51789
51966
  }
51790
- /** Paso de paginación soft (revela +N por "cargar más"). */
51791
- get pageStep() {
51792
- return this.resolvedConfig().pageSize;
51793
- }
51794
51967
  constructor() {
51795
- this.nav = inject(NavigationService);
51796
51968
  this.i18n = inject(I18nService);
51797
- this.notifs = inject(NotificationsService);
51798
- this.action = inject(NotificationActionService);
51799
- this.toast = inject(ToastService);
51800
- this.errors = inject(ValtechErrorService);
51969
+ this.orgService = inject(OrgService);
51970
+ this.catalog = inject(PermissionCatalogService);
51801
51971
  this.auth = inject(AuthService);
51802
- this.pageRefresh = inject(PageRefreshService);
51803
- // `ActivatedRoute` opcional → la vista funciona embebida / en Storybook sin
51804
- // router (solo lo usa para leer el route data de config como fallback). La
51805
- // navegación va por `NavigationService` (wrapper opcional sobre `Router`).
51972
+ this.nav = inject(NavigationService);
51806
51973
  this.route = inject(ActivatedRoute, { optional: true });
51807
51974
  this.resolvedConfig = computed(() => {
51808
- const fromRoute = (this.route?.snapshot.data['notificationsConfig'] ?? {});
51975
+ const fromRoute = (this.route?.snapshot.data['permissionsConfig'] ?? {});
51809
51976
  const merged = { ...fromRoute, ...(this.config ?? {}) };
51810
51977
  return {
51811
- showPushOffCta: merged.showPushOffCta ?? true,
51812
- enableMarkAll: merged.enableMarkAll ?? true,
51813
- settingsNotificationsPath: merged.settingsNotificationsPath ?? DEFAULT_SETTINGS_PATH,
51978
+ showSystemRoles: merged.showSystemRoles ?? true,
51979
+ showCustomRoles: merged.showCustomRoles ?? true,
51814
51980
  i18nNamespace: merged.i18nNamespace ?? DEFAULT_NAMESPACE,
51815
- pageSize: merged.pageSize ?? DEFAULT_PAGE_SIZE,
51816
- maxLoad: merged.maxLoad ?? DEFAULT_MAX_LOAD,
51817
- onNotificationOpen: merged.onNotificationOpen,
51818
- };
51819
- });
51820
- this._expanded = signal(new Set());
51821
- this._pageSize = signal(this.resolvedConfig().pageSize);
51822
- this._bulkBusy = signal(false);
51823
- this._loadingMore = signal(false);
51824
- /**
51825
- * Push activado = permiso del navegador en `granted`. Si NO está activado,
51826
- * el user puede leer el inbox (Firestore) pero NO recibirá push en su
51827
- * dispositivo → mostramos una card que lo invita a ir a configuración.
51828
- * Se evalúa en cada montaje de la vista.
51829
- */
51830
- this.pushEnabled = signal(this.auth.getNotificationPermissionState() === 'granted');
51831
- /**
51832
- * Stream firestore real-time vía el helper estándar `createRefreshableStream`
51833
- * (modo real-time: factory devuelve un listener vivo `notifs.getAll(maxLoad)`).
51834
- *
51835
- * El helper gatea la primera suscripción por `firebaseAuthReady`, aplica un
51836
- * `retry` corto con backoff y, tras agotar reintentos, emite el fallback (`[]`)
51837
- * y marca `error` — estado de error en UI, no skeleton infinito. `reload()`
51838
- * re-suscribe el stream completo (lo usa el pull-to-refresh).
51839
- */
51840
- this._stream = createRefreshableStream(() => this.notifs.getAll(this.resolvedConfig().maxLoad), {
51841
- fallback: [],
51842
- // `notifs.getAll()` YA gatea internamente por `firebaseAuthReady`. Si el
51843
- // stream también gateara, serían DOS esperas de auth apiladas.
51844
- gateOnFirebaseAuth: false,
51845
- // Cold start de PWA en iOS puede pasar de 8s en redes lentas; con 15s
51846
- // evitamos el falso estado de error.
51847
- firstEmitTimeoutMs: 15000,
51848
- });
51849
- /** True si el stream falló tras agotar reintentos → muestra estado de error. */
51850
- this.streamError = this._stream.error;
51851
- this.loading = computed(() => this._stream.data() === null);
51852
- /**
51853
- * Notifs reales mostradas. Filtramos docs sin título Y sin body — son ruido
51854
- * (típicamente sentinel/system writes pre-schema con title/body opcionales).
51855
- */
51856
- this.allNotifs = computed(() => (this._stream.data() ?? []).filter(n => !!(n.title && n.title.trim()) || !!(n.body && n.body.trim())));
51857
- this.hasMore = computed(() => this.allNotifs().length > this._pageSize());
51858
- this.showMarkAll = computed(() => this.resolvedConfig().enableMarkAll && !this.loading() && this.allNotifs().some(n => !n.isRead));
51859
- /** Sub-conjunto paginado, agrupado por período. */
51860
- this.groups = computed(() => {
51861
- const items = this.allNotifs().slice(0, this._pageSize());
51862
- const now = new Date();
51863
- const buckets = {
51864
- today: [],
51865
- thisMonth: [],
51866
- earlier: [],
51867
51981
  };
51868
- for (const n of items) {
51869
- buckets[this.bucketOf(n, now)].push(n);
51870
- }
51871
- const out = [];
51872
- if (buckets.today.length) {
51873
- out.push({ key: 'today', label: this.tt('groupToday'), items: buckets.today });
51874
- }
51875
- if (buckets.thisMonth.length) {
51876
- out.push({
51877
- key: 'thisMonth',
51878
- label: this.tt('groupThisMonth'),
51879
- items: buckets.thisMonth,
51880
- });
51881
- }
51882
- if (buckets.earlier.length) {
51883
- out.push({ key: 'earlier', label: this.tt('groupEarlier'), items: buckets.earlier });
51884
- }
51885
- return out;
51886
- });
51887
- this.pageTitle = computed(() => this.tt('pageTitle'));
51888
- this.pageDescription = computed(() => this.tt('pageDescription'));
51889
- // CTA "activa las notificaciones push" — visible cuando pushEnabled() es false.
51890
- this.ctaTitle = computed(() => this.tt('pushOffTitle'));
51891
- this.ctaHint = computed(() => this.tt('pushOffHint'));
51892
- this.ctaButtonProps = computed(() => ({
51893
- text: this.tt('pushOffCta'),
51894
- color: 'dark',
51895
- fill: 'outline',
51896
- size: 'small',
51897
- shape: 'round',
51898
- type: 'button',
51899
- }));
51900
- /**
51901
- * Estado de error de carga: convierte el error del stream en
51902
- * `EmptyStateMetadata` via el helper canónico `createErrorStateProps`.
51903
- * El helper decide variante (`offline` vs `error`) por `isNetwork`.
51904
- * Null cuando el stream no ha fallado.
51905
- */
51906
- this.errorState = computed(() => {
51907
- this.i18n.lang();
51908
- const err = this.streamError();
51909
- if (!err)
51910
- return null;
51911
- return createErrorStateProps(err, {
51912
- title: {
51913
- offline: this.tt('offlineTitle'),
51914
- error: this.tt('loadErrorTitle'),
51915
- },
51916
- description: {
51917
- offline: this.tt('offlineHint'),
51918
- error: interpretError(err).message || this.tt('loadErrorHint'),
51919
- },
51920
- retryLabel: this.tt('retryCta'),
51921
- onRetry: () => this._stream.reload(),
51922
- retrying: this.loading(),
51923
- });
51924
51982
  });
51925
- /**
51926
- * Estado vacío (lista real = 0 notificaciones, sin error de carga).
51927
- * Se construye manualmente con `variant: 'empty'` — sin helper de error.
51928
- */
51929
- this.emptyStateProps = computed(() => {
51930
- this.i18n.lang();
51931
- return {
51932
- variant: 'empty',
51933
- icon: 'notifications-off-outline',
51934
- title: this.tt('empty'),
51935
- description: this.tt('emptyHint'),
51936
- action: {
51937
- label: this.tt('emptyCta'),
51938
- handler: () => this.onGoToSettings(),
51939
- },
51940
- };
51983
+ this.loading = signal(false);
51984
+ this.loadError = signal(null);
51985
+ this.roles = signal([]);
51986
+ this.systemRoles = computed(() => this.roles().filter(r => r.isSystem));
51987
+ this.customRoles = computed(() => this.roles().filter(r => !r.isSystem));
51988
+ this.activeOrgId = computed(() => {
51989
+ const u = this.auth.user();
51990
+ return u?.activeOrgId ?? u?.activeOrg ?? '';
51941
51991
  });
51942
- this.seeMore = computed(() => this.tt('seeMore'));
51943
- this.seeLess = computed(() => this.tt('seeLess'));
51944
- this.markAllButtonProps = computed(() => ({
51945
- text: this.tt('markAll'),
51992
+ this.retryButtonProps = computed(() => ({
51993
+ text: this.tt('retry'),
51946
51994
  color: 'dark',
51947
51995
  fill: 'outline',
51948
- size: 'small',
51949
- shape: 'round',
51950
- type: 'button',
51951
- icon: { name: 'checkmark-done-outline', slot: 'start' },
51952
- state: this._bulkBusy() ? ComponentStates.WORKING : ComponentStates.ENABLED,
51953
- }));
51954
- this.loadMoreButtonProps = computed(() => ({
51955
- text: this.tt('loadMore'),
51956
- color: 'dark',
51957
- fill: 'clear',
51958
51996
  size: 'default',
51959
- shape: 'round',
51960
51997
  type: 'button',
51961
- state: this._loadingMore() ? ComponentStates.WORKING : ComponentStates.ENABLED,
51998
+ state: this.loading() ? ComponentStates.DISABLED : ComponentStates.ENABLED,
51962
51999
  }));
51963
52000
  const ns = this.ns;
51964
52001
  if (!this.i18n.hasNamespace(ns)) {
51965
- this.i18n.registerContent(ns, NOTIFICATIONS_VIEW_I18N);
52002
+ this.i18n.registerContent(ns, PERMISSIONS_VIEW_I18N);
51966
52003
  }
51967
52004
  this.nav.setBackHeader('pageTitle', ns, { withMenu: true });
51968
- // Re-aplicar header en cambio de idioma.
51969
- effect(() => {
51970
- this.i18n.lang();
51971
- this.nav.setBackHeader('pageTitle', this.ns, { withMenu: true });
51972
- }, { allowSignalWrites: true });
51973
- }
51974
- /**
51975
- * Registra el pull-to-refresh estándar. Usamos `ngOnInit`/`ngOnDestroy`
51976
- * (NO `ionViewWillEnter`) porque `val-page-wrapper` usa `<router-outlet>`
51977
- * plano — los hooks `ionView*` solo disparan dentro de `<ion-router-outlet>`.
51978
- */
51979
- ngOnInit() {
51980
- this.pageRefresh.register(() => this.reload());
51981
- }
51982
- /** Quita el handler al destruir la vista — el refresher desaparece. */
51983
- ngOnDestroy() {
51984
- this.pageRefresh.unregister();
51985
- }
51986
- /**
51987
- * Refresh manual: re-suscribe el stream Firestore (listener nuevo) vía el
51988
- * helper. Resuelve tras un margen breve — el stream es real-time y re-emite
51989
- * en milisegundos; el delay sólo mantiene el spinner visible lo justo.
51990
- */
51991
- async reload() {
51992
- this._stream.reload();
51993
- await new Promise(r => setTimeout(r, 700));
51994
- }
51995
- // ---------------------------------------------------------------------------
51996
- // Per-item helpers (puros — usables desde template).
51997
- // ---------------------------------------------------------------------------
51998
- openButtonProps() {
51999
- return {
52000
- text: this.tt('open'),
52001
- color: 'dark',
52002
- fill: 'clear',
52003
- size: 'small',
52004
- shape: 'round',
52005
- type: 'button',
52006
- icon: { name: 'open-outline', slot: 'end' },
52007
- };
52008
- }
52009
- isExpanded(id) {
52010
- return this._expanded().has(id);
52011
- }
52012
- toggleExpand(id) {
52013
- const next = new Set(this._expanded());
52014
- if (next.has(id))
52015
- next.delete(id);
52016
- else
52017
- next.add(id);
52018
- this._expanded.set(next);
52005
+ this.load();
52006
+ connectPageRefresh(() => this.load());
52019
52007
  }
52020
- isLong(body) {
52021
- if (!body)
52022
- return false;
52023
- return body.length > 110 || body.includes('\n');
52008
+ load() {
52009
+ const orgId = this.activeOrgId();
52010
+ if (!orgId)
52011
+ return;
52012
+ this.loading.set(true);
52013
+ this.loadError.set(null);
52014
+ forkJoin({
52015
+ roles: this.orgService.listOrgRoles(orgId),
52016
+ catalog: this.catalog.getCatalog(orgId),
52017
+ }).subscribe({
52018
+ next: ({ roles: rawRoles, catalog }) => {
52019
+ const byResource = new Map();
52020
+ for (const r of catalog.permissions)
52021
+ byResource.set(r.resource, r);
52022
+ const currentAppId = catalog.appId;
52023
+ this.roles.set((rawRoles ?? []).map((r) => this.buildRoleView({
52024
+ id: r.id,
52025
+ name: r.name,
52026
+ description: r.description,
52027
+ permissions: r.permissions ?? [],
52028
+ isSystem: r.isSystem ?? false,
52029
+ }, byResource, currentAppId)));
52030
+ this.loading.set(false);
52031
+ },
52032
+ error: err => {
52033
+ this.loadError.set(err);
52034
+ this.loading.set(false);
52035
+ },
52036
+ });
52024
52037
  }
52025
- /**
52026
- * Icon resolver con orden de fallback:
52027
- * 1. notif.payload.icon (forward-compat, sin breaking change en schema)
52028
- * 2. mapa por notif.type
52029
- * 3. default notifications-outline
52030
- */
52031
- resolveIcon(n) {
52032
- const hint = n.payload?.['icon'];
52033
- if (hint?.value) {
52034
- if (hint.type === 'image' || hint.type === 'avatar') {
52035
- return { kind: hint.type, src: hint.value };
52038
+ buildRoleView(role, byResource, currentAppId) {
52039
+ const appPerms = [];
52040
+ const orgPerms = [];
52041
+ const otherByApp = new Map();
52042
+ for (const perm of role.permissions) {
52043
+ const [resource, action] = perm.split(':');
52044
+ const entry = byResource.get(resource);
52045
+ // Sin entrada en el catálogo (el backend omite los recursos internal para
52046
+ // no-platform-admins) o entrada internal → NUNCA se muestra a nivel de
52047
+ // producto. Defensa en profundidad junto al filtro del backend (ADR-024).
52048
+ // Nota: el rol puede traer el permiso aunque el recurso esté filtrado del
52049
+ // catálogo (ej. admin tiene templates:*); por eso se descarta lo no resuelto.
52050
+ if (!entry || entry.scope === 'internal') {
52051
+ continue;
52036
52052
  }
52037
- // Ignoramos hint.color — todos los iconos van en dark por convención UX.
52038
- return { kind: 'ionicon', name: hint.value, color: 'dark' };
52039
- }
52040
- const t = n.type;
52041
- const name = (() => {
52042
- switch (t) {
52043
- case 'approval':
52044
- return 'checkmark-circle-outline';
52045
- case 'chat':
52046
- case 'chatbot':
52047
- return 'chatbubble-outline';
52048
- case 'comment':
52049
- return 'chatbox-ellipses-outline';
52050
- case 'reminder':
52051
- return 'alarm-outline';
52052
- case 'system':
52053
- return 'cog-outline';
52054
- case 'user':
52055
- case 'mention':
52056
- return 'person-outline';
52057
- case 'document':
52058
- return 'document-text-outline';
52059
- default:
52060
- return 'notifications-outline';
52053
+ const label = this.permLabel(resource, action);
52054
+ if (entry.scope === 'app') {
52055
+ if (entry.appId === currentAppId) {
52056
+ appPerms.push(label);
52057
+ }
52058
+ else {
52059
+ const appId = entry.appId ?? '?';
52060
+ const arr = otherByApp.get(appId) ?? [];
52061
+ arr.push(label);
52062
+ otherByApp.set(appId, arr);
52063
+ }
52064
+ }
52065
+ else {
52066
+ // scope === 'org' → gestión de la organización del cliente
52067
+ orgPerms.push(label);
52061
52068
  }
52062
- })();
52063
- return { kind: 'ionicon', name, color: 'dark' };
52064
- }
52065
- ioniconName(n) {
52066
- const r = this.resolveIcon(n);
52067
- return r.kind === 'ionicon' ? r.name : '';
52068
- }
52069
- ioniconColor(n) {
52070
- const r = this.resolveIcon(n);
52071
- return r.kind === 'ionicon' ? r.color : undefined;
52072
- }
52073
- imageSrc(n) {
52074
- const r = this.resolveIcon(n);
52075
- return r.kind === 'image' || r.kind === 'avatar' ? r.src : '';
52076
- }
52077
- ionColor(token) {
52078
- if (!token)
52079
- return 'var(--ion-color-primary)';
52080
- return `var(--ion-color-${token})`;
52081
- }
52082
- relativeTime(n) {
52083
- const d = n.createdAt instanceof Date ? n.createdAt : null;
52084
- if (!d)
52085
- return '';
52086
- const diffMs = Date.now() - d.getTime();
52087
- const min = Math.floor(diffMs / 60000);
52088
- if (min < 1)
52089
- return this.tt('timeNow');
52090
- if (min < 60)
52091
- return this.tt('timeMinutes').replace('{n}', String(min));
52092
- const h = Math.floor(min / 60);
52093
- if (h < 24)
52094
- return this.tt('timeHours').replace('{n}', String(h));
52095
- const days = Math.floor(h / 24);
52096
- return this.tt('timeDays').replace('{n}', String(days));
52097
- }
52098
- /** Devuelve label "Desde {appName}" si la notif viene de otra app, sino null. */
52099
- showCrossAppBadge(n) {
52100
- const currentApp = 'showcase';
52101
- if (!n.appId || n.appId === currentApp)
52102
- return null;
52103
- return this.tt('crossApp').replace('{app}', n.appId);
52104
- }
52105
- // ---------------------------------------------------------------------------
52106
- // Actions
52107
- // ---------------------------------------------------------------------------
52108
- async onOpen(n) {
52109
- try {
52110
- const result = await this.action.open(n);
52111
- this.resolvedConfig().onNotificationOpen?.(n, result);
52112
52069
  }
52113
- catch (err) {
52114
- this.errors.handle(err, {
52115
- context: 'notifications.open',
52116
- fallbackKey: 'openError',
52117
- i18nNamespace: this.ns,
52118
- });
52070
+ const groups = [];
52071
+ if (appPerms.length) {
52072
+ groups.push({ key: 'app', label: this.tt('groupApp'), perms: appPerms });
52119
52073
  }
52120
- }
52121
- async onMarkAll() {
52122
- if (this._bulkBusy())
52123
- return;
52124
- this._bulkBusy.set(true);
52125
- try {
52126
- await this.notifs.markAllAsRead();
52127
- this.toast.show({
52128
- message: this.tt('markedAllSuccess'),
52129
- color: 'dark',
52130
- duration: 2500,
52131
- position: 'top',
52074
+ if (orgPerms.length) {
52075
+ groups.push({
52076
+ key: 'org',
52077
+ label: this.tt('groupOrg'),
52078
+ perms: orgPerms,
52132
52079
  });
52133
52080
  }
52134
- catch (err) {
52135
- this.errors.handle(err, {
52136
- context: 'notifications.markAll',
52137
- fallbackKey: 'markedAllError',
52138
- i18nNamespace: this.ns,
52081
+ for (const [appId, perms] of otherByApp) {
52082
+ groups.push({
52083
+ key: `other:${appId}`,
52084
+ label: `${this.tt('groupOther')}: ${this.appLabel(appId)}`,
52085
+ perms,
52139
52086
  });
52140
52087
  }
52141
- finally {
52142
- this._bulkBusy.set(false);
52143
- }
52088
+ return {
52089
+ id: role.id,
52090
+ name: role.name,
52091
+ description: role.description,
52092
+ isSystem: role.isSystem,
52093
+ groups,
52094
+ hasPerms: groups.length > 0,
52095
+ };
52144
52096
  }
52145
- onLoadMore() {
52146
- if (this._loadingMore())
52147
- return;
52148
- this._loadingMore.set(true);
52149
- // Slice viene del cache local — no hay fetch real, pero un breve spinner
52150
- // confirma al user que el click registró y suaviza la aparición de items.
52151
- setTimeout(() => {
52152
- this._pageSize.update(n => n + this.pageStep);
52153
- this._loadingMore.set(false);
52154
- }, 250);
52097
+ tt(key) {
52098
+ return this.i18n.t(key, this.ns);
52155
52099
  }
52156
- onGoToSettings() {
52157
- this.nav.navigateByUrl(this.resolvedConfig().settingsNotificationsPath);
52100
+ appLabel(appId) {
52101
+ const key = `app_${appId}`;
52102
+ const label = this.tt(key);
52103
+ return label === key ? appId : label;
52158
52104
  }
52159
- // ---------------------------------------------------------------------------
52160
- // Grouping helpers
52161
- // ---------------------------------------------------------------------------
52162
- bucketOf(n, now) {
52163
- const d = n.createdAt instanceof Date ? n.createdAt : null;
52164
- if (!d)
52165
- return 'earlier';
52166
- if (d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate()) {
52167
- return 'today';
52168
- }
52169
- if (d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth()) {
52170
- return 'thisMonth';
52171
- }
52172
- return 'earlier';
52105
+ roleLabel(name) {
52106
+ const map = {
52107
+ viewer: 'roleViewer',
52108
+ editor: 'roleEditor',
52109
+ admin: 'roleAdmin',
52110
+ super_admin: 'roleSuperAdmin',
52111
+ bingo_admin: 'roleBingoAdmin',
52112
+ bingo_tesorero: 'roleBingoTesorero',
52113
+ bingo_taquilla: 'roleBingoTaquilla',
52114
+ bingo_operator: 'roleBingoOperator',
52115
+ };
52116
+ return this.tt(map[name] ?? name);
52173
52117
  }
52174
- tt(key) {
52175
- return this.i18n.t(key, this.ns);
52118
+ permLabel(resource, action) {
52119
+ if (resource === '*' && action === '*')
52120
+ return this.tt('permAll');
52121
+ const resKey = resource === '*' ? 'permAll' : `perm${resource.charAt(0).toUpperCase()}${resource.slice(1)}`;
52122
+ const actKey = action === '*' ? 'permAll' : `perm${action.charAt(0).toUpperCase()}${action.slice(1)}`;
52123
+ const res = this.tt(resKey);
52124
+ const act = this.tt(actKey);
52125
+ return `${res}: ${act}`;
52176
52126
  }
52177
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: NotificationsViewComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
52178
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: NotificationsViewComponent, isStandalone: true, selector: "val-notifications-view", inputs: { config: "config" }, ngImport: i0, template: `
52127
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: PermissionsViewComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
52128
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: PermissionsViewComponent, isStandalone: true, selector: "val-permissions-view", inputs: { config: "config" }, ngImport: i0, template: `
52179
52129
  <div class="page">
52180
52130
  <header class="page-header">
52181
- <val-display [props]="{ size: 'small', color: 'dark', content: pageTitle() }" />
52131
+ <val-display [props]="{ size: 'small', color: 'dark', content: tt('pageTitle') }" />
52182
52132
  <val-title
52183
52133
  [props]="{
52184
52134
  size: 'large',
52185
52135
  color: 'dark',
52186
52136
  bold: false,
52187
- content: pageDescription(),
52137
+ content: tt('pageDescription'),
52188
52138
  }"
52189
52139
  />
52190
- @if (showMarkAll()) {
52191
- <div class="row-actions">
52192
- <val-button [props]="markAllButtonProps()" (click)="onMarkAll()" />
52193
- </div>
52194
- }
52195
52140
  </header>
52196
52141
 
52197
- @if (resolvedConfig().showPushOffCta && !pushEnabled()) {
52198
- <section class="notif-cta">
52199
- <ion-icon class="notif-cta__icon" name="notifications-off-outline" aria-hidden="true" />
52200
- <div class="notif-cta__body">
52201
- <val-text
52202
- [props]="{
52203
- content: ctaTitle(),
52204
- size: 'medium',
52205
- color: 'dark',
52206
- bold: true,
52207
- }"
52208
- />
52209
- <val-text
52210
- [props]="{
52211
- content: ctaHint(),
52212
- size: 'small',
52213
- color: 'medium',
52214
- bold: false,
52215
- }"
52216
- />
52217
- <div class="row-actions">
52218
- <val-button [props]="ctaButtonProps()" (click)="onGoToSettings()" />
52219
- </div>
52220
- </div>
52221
- </section>
52222
- }
52223
-
52224
52142
  @if (loading()) {
52225
- <val-skeleton-layout [props]="{ preset: 'list', rows: 6 }" />
52226
- } @else if (errorState()) {
52227
- <val-empty-state [props]="errorState()!" />
52228
- } @else if (allNotifs().length === 0) {
52229
- <val-empty-state [props]="emptyStateProps()" />
52143
+ <div class="spinner-row">
52144
+ <ion-spinner name="crescent" />
52145
+ </div>
52146
+ } @else if (loadError()) {
52147
+ <div class="error-row">
52148
+ <val-text
52149
+ [props]="{
52150
+ content: tt('errorTitle'),
52151
+ size: 'medium',
52152
+ color: 'medium',
52153
+ bold: false,
52154
+ }"
52155
+ />
52156
+ <val-button [props]="retryButtonProps()" (click)="load()" />
52157
+ </div>
52230
52158
  } @else {
52231
- @for (group of groups(); track group.key) {
52232
- <section class="group">
52233
- <val-title
52234
- [props]="{
52235
- size: 'medium',
52236
- color: 'dark',
52237
- bold: true,
52238
- content: group.label,
52239
- }"
52240
- />
52241
- <ul class="notif-list">
52242
- @for (n of group.items; track n.id) {
52243
- <li class="notif" [class.notif--unread]="!n.isRead">
52244
- <div class="notif__icon">
52245
- @switch (resolveIcon(n).kind) {
52246
- @case ('ionicon') {
52247
- <ion-icon
52248
- [name]="ioniconName(n)"
52249
- [style.color]="ionColor(ioniconColor(n))"
52250
- aria-hidden="true"
52251
- />
52252
- }
52253
- @case ('image') {
52254
- <img [src]="imageSrc(n)" alt="" loading="lazy" />
52255
- }
52256
- @case ('avatar') {
52257
- <img class="notif__avatar" [src]="imageSrc(n)" alt="" loading="lazy" />
52258
- }
52259
- }
52159
+ @if (resolvedConfig().showSystemRoles) {
52160
+ <section class="perm-section">
52161
+ <div class="perm-section__header">
52162
+ <val-title
52163
+ [props]="{
52164
+ size: 'medium',
52165
+ color: 'dark',
52166
+ bold: true,
52167
+ content: tt('systemRolesTitle'),
52168
+ }"
52169
+ />
52170
+ <val-text
52171
+ [props]="{
52172
+ content: tt('systemRolesHint'),
52173
+ size: 'medium',
52174
+ color: 'medium',
52175
+ bold: false,
52176
+ }"
52177
+ />
52178
+ </div>
52179
+ <div class="role-list">
52180
+ @for (role of systemRoles(); track role.id) {
52181
+ <div class="role-card">
52182
+ <div class="role-card__header">
52183
+ <span class="role-card__name">{{ roleLabel(role.name) }}</span>
52184
+ <val-pill
52185
+ [props]="{
52186
+ label: tt('systemBadge'),
52187
+ color: 'primary',
52188
+ weight: 'bold',
52189
+ }"
52190
+ />
52260
52191
  </div>
52261
-
52262
- <div class="notif__body">
52263
- <div class="notif__head">
52264
- <val-text
52265
- [props]="{
52266
- content: n.title || '',
52267
- size: 'medium',
52268
- color: 'dark',
52269
- bold: true,
52270
- }"
52271
- />
52272
- <span class="notif__time">{{ relativeTime(n) }}</span>
52192
+ @if (role.description) {
52193
+ <span class="role-card__desc">{{ role.description }}</span>
52194
+ }
52195
+ @if (!role.hasPerms) {
52196
+ <div class="perm-chips">
52197
+ <span class="perm-chip perm-chip--empty">{{ tt('noPermissions') }}</span>
52273
52198
  </div>
52274
- @if (n.body) {
52275
- <p class="notif__text" [class.notif__text--clamped]="!isExpanded(n.id) && isLong(n.body)">
52276
- {{ n.body }}
52277
- </p>
52278
- @if (isLong(n.body)) {
52279
- <button class="notif__expand" type="button" (click)="toggleExpand(n.id)">
52280
- {{ isExpanded(n.id) ? seeLess() : seeMore() }}
52281
- </button>
52282
- }
52283
- }
52284
- @if (showCrossAppBadge(n); as appName) {
52285
- <span class="notif__app">{{ appName }}</span>
52286
- }
52287
- @if (n.actionRoute) {
52288
- <div class="row-actions row-actions--end">
52289
- <val-button [props]="openButtonProps()" (click)="onOpen(n)" />
52199
+ } @else {
52200
+ @for (g of role.groups; track g.key) {
52201
+ <div class="perm-group">
52202
+ <span class="perm-group__label">{{ g.label }}</span>
52203
+ <div class="perm-chips">
52204
+ @for (p of g.perms; track p) {
52205
+ <span class="perm-chip">{{ p }}</span>
52206
+ }
52207
+ </div>
52290
52208
  </div>
52291
52209
  }
52292
- </div>
52293
- </li>
52210
+ }
52211
+ </div>
52294
52212
  }
52295
- </ul>
52213
+ </div>
52296
52214
  </section>
52297
52215
  }
52298
52216
 
52299
- @if (hasMore()) {
52300
- <div class="row-actions row-actions--center">
52301
- <val-button [props]="loadMoreButtonProps()" (click)="onLoadMore()" />
52302
- </div>
52217
+ @if (resolvedConfig().showCustomRoles) {
52218
+ <section class="perm-section">
52219
+ <div class="perm-section__header">
52220
+ <val-title
52221
+ [props]="{
52222
+ size: 'medium',
52223
+ color: 'dark',
52224
+ bold: true,
52225
+ content: tt('customRolesTitle'),
52226
+ }"
52227
+ />
52228
+ <val-text
52229
+ [props]="{
52230
+ content: tt('customRolesHint'),
52231
+ size: 'medium',
52232
+ color: 'medium',
52233
+ bold: false,
52234
+ }"
52235
+ />
52236
+ </div>
52237
+ @if (customRoles().length === 0) {
52238
+ <val-text
52239
+ [props]="{
52240
+ content: tt('noCustomRoles'),
52241
+ size: 'medium',
52242
+ color: 'medium',
52243
+ bold: false,
52244
+ }"
52245
+ />
52246
+ } @else {
52247
+ <div class="role-list">
52248
+ @for (role of customRoles(); track role.id) {
52249
+ <div class="role-card">
52250
+ <div class="role-card__header">
52251
+ <span class="role-card__name">{{ role.name }}</span>
52252
+ </div>
52253
+ @if (role.description) {
52254
+ <span class="role-card__desc">{{ role.description }}</span>
52255
+ }
52256
+ @if (!role.hasPerms) {
52257
+ <div class="perm-chips">
52258
+ <span class="perm-chip perm-chip--empty">{{ tt('noPermissions') }}</span>
52259
+ </div>
52260
+ } @else {
52261
+ @for (g of role.groups; track g.key) {
52262
+ <div class="perm-group">
52263
+ <span class="perm-group__label">{{ g.label }}</span>
52264
+ <div class="perm-chips">
52265
+ @for (p of g.perms; track p) {
52266
+ <span class="perm-chip">{{ p }}</span>
52267
+ }
52268
+ </div>
52269
+ </div>
52270
+ }
52271
+ }
52272
+ </div>
52273
+ }
52274
+ </div>
52275
+ }
52276
+ </section>
52303
52277
  }
52304
52278
  }
52305
52279
  </div>
52306
- `, isInline: true, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:16px}.row-actions{margin-top:12px}.row-actions--center{display:flex;justify-content:center;margin-top:20px}.row-actions--end{display:flex;justify-content:flex-end;margin-top:8px}.notif-cta{display:flex;gap:12px;align-items:flex-start;padding:14px 16px;margin-bottom:16px;border:1px solid var(--ion-color-light-shade, rgba(0, 0, 0, .08));border-radius:14px;background:var(--ion-color-light)}.notif-cta__icon{font-size:24px;color:var(--ion-color-dark, #1a1a1a);flex-shrink:0;margin-top:2px}.notif-cta__body{display:flex;flex-direction:column;gap:2px}.group{padding:12px 0}.group+.group{border-top:1px solid var(--val-border-color, rgba(0, 0, 0, .08))}.notif-list{list-style:none;padding:0;margin:12px 0 0;display:flex;flex-direction:column;gap:10px}.notif{display:grid;grid-template-columns:44px 1fr;gap:12px;padding:12px;border-radius:12px;background:transparent;transition:background .12s ease}.notif--unread{background:var(--ion-color-light, rgba(0, 0, 0, .04))}.notif__icon{width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center;background:var(--ion-color-light, rgba(0, 0, 0, .06));overflow:hidden}.notif__icon ion-icon{font-size:22px}.notif__icon img{width:100%;height:100%;object-fit:cover}.notif__avatar{border-radius:50%}.notif__body{display:flex;flex-direction:column;gap:4px;min-width:0}.notif__head{display:flex;justify-content:space-between;align-items:baseline;gap:8px}.notif__time{font-size:12px;color:var(--ion-color-medium, #92949c);flex-shrink:0}.notif__text{margin:0;font-size:14px;line-height:1.4;color:var(--ion-color-dark, #1a1a1a);white-space:pre-wrap}.notif__text--clamped{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.notif__expand{background:none;border:0;padding:0;font-size:13px;color:var(--ion-color-dark, #1a1a1a);cursor:pointer;align-self:flex-start;text-decoration:underline}.notif__app{font-size:11px;text-transform:uppercase;letter-spacing:.4px;color:var(--ion-color-medium, #92949c);margin-top:2px}\n"], dependencies: [{ kind: "component", type: IonIcon, selector: "ion-icon", inputs: ["color", "flipRtl", "icon", "ios", "lazy", "md", "mode", "name", "sanitize", "size", "src"] }, { kind: "component", type: DisplayComponent, selector: "val-display", inputs: ["props"] }, { kind: "component", type: TitleComponent, selector: "val-title", inputs: ["props"] }, { kind: "component", type: TextComponent, selector: "val-text", inputs: ["props"] }, { kind: "component", type: ButtonComponent, selector: "val-button", inputs: ["preset", "props"], outputs: ["onClick"] }, { kind: "component", type: SkeletonLayoutComponent, selector: "val-skeleton-layout", inputs: ["props"] }, { kind: "component", type: EmptyStateComponent, selector: "val-empty-state", inputs: ["props"] }] }); }
52280
+ `, isInline: true, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:24px}.spinner-row,.error-row{display:flex;flex-direction:column;align-items:center;gap:16px;padding:32px 0}.perm-section{margin-bottom:32px}.perm-section__header{margin-bottom:12px}.role-list{display:flex;flex-direction:column;gap:10px}.role-card{border:1.5px solid var(--ion-color-light);border-radius:12px;padding:14px 16px;display:flex;flex-direction:column;gap:10px}.role-card__header{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.role-card__name{font-size:15px;font-weight:600;color:var(--ion-color-dark)}.role-card__desc{font-size:13px;color:var(--ion-color-medium);line-height:1.4}.perm-group{display:flex;flex-direction:column;gap:6px}.perm-group__label{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--ion-color-medium)}.perm-chips{display:flex;flex-wrap:wrap;gap:6px}.perm-chip{font-size:12px;padding:3px 10px;border-radius:20px;background:var(--ion-color-light);color:var(--ion-color-dark)}.perm-chip--empty{color:var(--ion-color-medium);background:transparent;padding-left:0}\n"], dependencies: [{ kind: "component", type: IonSpinner, selector: "ion-spinner", inputs: ["color", "duration", "name", "paused"] }, { kind: "component", type: DisplayComponent, selector: "val-display", inputs: ["props"] }, { kind: "component", type: PillComponent, selector: "val-pill", inputs: ["preset", "props"], outputs: ["pillClick", "pillAction"] }, { kind: "component", type: TitleComponent, selector: "val-title", inputs: ["props"] }, { kind: "component", type: TextComponent, selector: "val-text", inputs: ["props"] }, { kind: "component", type: ButtonComponent, selector: "val-button", inputs: ["preset", "props"], outputs: ["onClick"] }] }); }
52307
52281
  }
52308
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: NotificationsViewComponent, decorators: [{
52282
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: PermissionsViewComponent, decorators: [{
52309
52283
  type: Component,
52310
- args: [{ selector: 'val-notifications-view', standalone: true, imports: [
52311
- IonIcon,
52312
- DisplayComponent,
52313
- TitleComponent,
52314
- TextComponent,
52315
- ButtonComponent,
52316
- SkeletonLayoutComponent,
52317
- EmptyStateComponent,
52318
- ], template: `
52284
+ args: [{ selector: 'val-permissions-view', standalone: true, imports: [IonSpinner, DisplayComponent, PillComponent, TitleComponent, TextComponent, ButtonComponent], template: `
52319
52285
  <div class="page">
52320
52286
  <header class="page-header">
52321
- <val-display [props]="{ size: 'small', color: 'dark', content: pageTitle() }" />
52287
+ <val-display [props]="{ size: 'small', color: 'dark', content: tt('pageTitle') }" />
52322
52288
  <val-title
52323
52289
  [props]="{
52324
52290
  size: 'large',
52325
52291
  color: 'dark',
52326
52292
  bold: false,
52327
- content: pageDescription(),
52293
+ content: tt('pageDescription'),
52328
52294
  }"
52329
52295
  />
52330
- @if (showMarkAll()) {
52331
- <div class="row-actions">
52332
- <val-button [props]="markAllButtonProps()" (click)="onMarkAll()" />
52333
- </div>
52334
- }
52335
52296
  </header>
52336
52297
 
52337
- @if (resolvedConfig().showPushOffCta && !pushEnabled()) {
52338
- <section class="notif-cta">
52339
- <ion-icon class="notif-cta__icon" name="notifications-off-outline" aria-hidden="true" />
52340
- <div class="notif-cta__body">
52341
- <val-text
52342
- [props]="{
52343
- content: ctaTitle(),
52344
- size: 'medium',
52345
- color: 'dark',
52346
- bold: true,
52347
- }"
52348
- />
52349
- <val-text
52350
- [props]="{
52351
- content: ctaHint(),
52352
- size: 'small',
52353
- color: 'medium',
52354
- bold: false,
52355
- }"
52356
- />
52357
- <div class="row-actions">
52358
- <val-button [props]="ctaButtonProps()" (click)="onGoToSettings()" />
52359
- </div>
52360
- </div>
52361
- </section>
52362
- }
52363
-
52364
52298
  @if (loading()) {
52365
- <val-skeleton-layout [props]="{ preset: 'list', rows: 6 }" />
52366
- } @else if (errorState()) {
52367
- <val-empty-state [props]="errorState()!" />
52368
- } @else if (allNotifs().length === 0) {
52369
- <val-empty-state [props]="emptyStateProps()" />
52299
+ <div class="spinner-row">
52300
+ <ion-spinner name="crescent" />
52301
+ </div>
52302
+ } @else if (loadError()) {
52303
+ <div class="error-row">
52304
+ <val-text
52305
+ [props]="{
52306
+ content: tt('errorTitle'),
52307
+ size: 'medium',
52308
+ color: 'medium',
52309
+ bold: false,
52310
+ }"
52311
+ />
52312
+ <val-button [props]="retryButtonProps()" (click)="load()" />
52313
+ </div>
52370
52314
  } @else {
52371
- @for (group of groups(); track group.key) {
52372
- <section class="group">
52373
- <val-title
52374
- [props]="{
52375
- size: 'medium',
52376
- color: 'dark',
52377
- bold: true,
52378
- content: group.label,
52379
- }"
52380
- />
52381
- <ul class="notif-list">
52382
- @for (n of group.items; track n.id) {
52383
- <li class="notif" [class.notif--unread]="!n.isRead">
52384
- <div class="notif__icon">
52385
- @switch (resolveIcon(n).kind) {
52386
- @case ('ionicon') {
52387
- <ion-icon
52388
- [name]="ioniconName(n)"
52389
- [style.color]="ionColor(ioniconColor(n))"
52390
- aria-hidden="true"
52391
- />
52392
- }
52393
- @case ('image') {
52394
- <img [src]="imageSrc(n)" alt="" loading="lazy" />
52395
- }
52396
- @case ('avatar') {
52397
- <img class="notif__avatar" [src]="imageSrc(n)" alt="" loading="lazy" />
52398
- }
52399
- }
52315
+ @if (resolvedConfig().showSystemRoles) {
52316
+ <section class="perm-section">
52317
+ <div class="perm-section__header">
52318
+ <val-title
52319
+ [props]="{
52320
+ size: 'medium',
52321
+ color: 'dark',
52322
+ bold: true,
52323
+ content: tt('systemRolesTitle'),
52324
+ }"
52325
+ />
52326
+ <val-text
52327
+ [props]="{
52328
+ content: tt('systemRolesHint'),
52329
+ size: 'medium',
52330
+ color: 'medium',
52331
+ bold: false,
52332
+ }"
52333
+ />
52334
+ </div>
52335
+ <div class="role-list">
52336
+ @for (role of systemRoles(); track role.id) {
52337
+ <div class="role-card">
52338
+ <div class="role-card__header">
52339
+ <span class="role-card__name">{{ roleLabel(role.name) }}</span>
52340
+ <val-pill
52341
+ [props]="{
52342
+ label: tt('systemBadge'),
52343
+ color: 'primary',
52344
+ weight: 'bold',
52345
+ }"
52346
+ />
52400
52347
  </div>
52401
-
52402
- <div class="notif__body">
52403
- <div class="notif__head">
52404
- <val-text
52405
- [props]="{
52406
- content: n.title || '',
52407
- size: 'medium',
52408
- color: 'dark',
52409
- bold: true,
52410
- }"
52411
- />
52412
- <span class="notif__time">{{ relativeTime(n) }}</span>
52348
+ @if (role.description) {
52349
+ <span class="role-card__desc">{{ role.description }}</span>
52350
+ }
52351
+ @if (!role.hasPerms) {
52352
+ <div class="perm-chips">
52353
+ <span class="perm-chip perm-chip--empty">{{ tt('noPermissions') }}</span>
52413
52354
  </div>
52414
- @if (n.body) {
52415
- <p class="notif__text" [class.notif__text--clamped]="!isExpanded(n.id) && isLong(n.body)">
52416
- {{ n.body }}
52417
- </p>
52418
- @if (isLong(n.body)) {
52419
- <button class="notif__expand" type="button" (click)="toggleExpand(n.id)">
52420
- {{ isExpanded(n.id) ? seeLess() : seeMore() }}
52421
- </button>
52422
- }
52423
- }
52424
- @if (showCrossAppBadge(n); as appName) {
52425
- <span class="notif__app">{{ appName }}</span>
52426
- }
52427
- @if (n.actionRoute) {
52428
- <div class="row-actions row-actions--end">
52429
- <val-button [props]="openButtonProps()" (click)="onOpen(n)" />
52355
+ } @else {
52356
+ @for (g of role.groups; track g.key) {
52357
+ <div class="perm-group">
52358
+ <span class="perm-group__label">{{ g.label }}</span>
52359
+ <div class="perm-chips">
52360
+ @for (p of g.perms; track p) {
52361
+ <span class="perm-chip">{{ p }}</span>
52362
+ }
52363
+ </div>
52430
52364
  </div>
52431
52365
  }
52432
- </div>
52433
- </li>
52366
+ }
52367
+ </div>
52434
52368
  }
52435
- </ul>
52369
+ </div>
52436
52370
  </section>
52437
52371
  }
52438
52372
 
52439
- @if (hasMore()) {
52440
- <div class="row-actions row-actions--center">
52441
- <val-button [props]="loadMoreButtonProps()" (click)="onLoadMore()" />
52442
- </div>
52373
+ @if (resolvedConfig().showCustomRoles) {
52374
+ <section class="perm-section">
52375
+ <div class="perm-section__header">
52376
+ <val-title
52377
+ [props]="{
52378
+ size: 'medium',
52379
+ color: 'dark',
52380
+ bold: true,
52381
+ content: tt('customRolesTitle'),
52382
+ }"
52383
+ />
52384
+ <val-text
52385
+ [props]="{
52386
+ content: tt('customRolesHint'),
52387
+ size: 'medium',
52388
+ color: 'medium',
52389
+ bold: false,
52390
+ }"
52391
+ />
52392
+ </div>
52393
+ @if (customRoles().length === 0) {
52394
+ <val-text
52395
+ [props]="{
52396
+ content: tt('noCustomRoles'),
52397
+ size: 'medium',
52398
+ color: 'medium',
52399
+ bold: false,
52400
+ }"
52401
+ />
52402
+ } @else {
52403
+ <div class="role-list">
52404
+ @for (role of customRoles(); track role.id) {
52405
+ <div class="role-card">
52406
+ <div class="role-card__header">
52407
+ <span class="role-card__name">{{ role.name }}</span>
52408
+ </div>
52409
+ @if (role.description) {
52410
+ <span class="role-card__desc">{{ role.description }}</span>
52411
+ }
52412
+ @if (!role.hasPerms) {
52413
+ <div class="perm-chips">
52414
+ <span class="perm-chip perm-chip--empty">{{ tt('noPermissions') }}</span>
52415
+ </div>
52416
+ } @else {
52417
+ @for (g of role.groups; track g.key) {
52418
+ <div class="perm-group">
52419
+ <span class="perm-group__label">{{ g.label }}</span>
52420
+ <div class="perm-chips">
52421
+ @for (p of g.perms; track p) {
52422
+ <span class="perm-chip">{{ p }}</span>
52423
+ }
52424
+ </div>
52425
+ </div>
52426
+ }
52427
+ }
52428
+ </div>
52429
+ }
52430
+ </div>
52431
+ }
52432
+ </section>
52443
52433
  }
52444
52434
  }
52445
52435
  </div>
52446
- `, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:16px}.row-actions{margin-top:12px}.row-actions--center{display:flex;justify-content:center;margin-top:20px}.row-actions--end{display:flex;justify-content:flex-end;margin-top:8px}.notif-cta{display:flex;gap:12px;align-items:flex-start;padding:14px 16px;margin-bottom:16px;border:1px solid var(--ion-color-light-shade, rgba(0, 0, 0, .08));border-radius:14px;background:var(--ion-color-light)}.notif-cta__icon{font-size:24px;color:var(--ion-color-dark, #1a1a1a);flex-shrink:0;margin-top:2px}.notif-cta__body{display:flex;flex-direction:column;gap:2px}.group{padding:12px 0}.group+.group{border-top:1px solid var(--val-border-color, rgba(0, 0, 0, .08))}.notif-list{list-style:none;padding:0;margin:12px 0 0;display:flex;flex-direction:column;gap:10px}.notif{display:grid;grid-template-columns:44px 1fr;gap:12px;padding:12px;border-radius:12px;background:transparent;transition:background .12s ease}.notif--unread{background:var(--ion-color-light, rgba(0, 0, 0, .04))}.notif__icon{width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center;background:var(--ion-color-light, rgba(0, 0, 0, .06));overflow:hidden}.notif__icon ion-icon{font-size:22px}.notif__icon img{width:100%;height:100%;object-fit:cover}.notif__avatar{border-radius:50%}.notif__body{display:flex;flex-direction:column;gap:4px;min-width:0}.notif__head{display:flex;justify-content:space-between;align-items:baseline;gap:8px}.notif__time{font-size:12px;color:var(--ion-color-medium, #92949c);flex-shrink:0}.notif__text{margin:0;font-size:14px;line-height:1.4;color:var(--ion-color-dark, #1a1a1a);white-space:pre-wrap}.notif__text--clamped{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.notif__expand{background:none;border:0;padding:0;font-size:13px;color:var(--ion-color-dark, #1a1a1a);cursor:pointer;align-self:flex-start;text-decoration:underline}.notif__app{font-size:11px;text-transform:uppercase;letter-spacing:.4px;color:var(--ion-color-medium, #92949c);margin-top:2px}\n"] }]
52436
+ `, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:24px}.spinner-row,.error-row{display:flex;flex-direction:column;align-items:center;gap:16px;padding:32px 0}.perm-section{margin-bottom:32px}.perm-section__header{margin-bottom:12px}.role-list{display:flex;flex-direction:column;gap:10px}.role-card{border:1.5px solid var(--ion-color-light);border-radius:12px;padding:14px 16px;display:flex;flex-direction:column;gap:10px}.role-card__header{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.role-card__name{font-size:15px;font-weight:600;color:var(--ion-color-dark)}.role-card__desc{font-size:13px;color:var(--ion-color-medium);line-height:1.4}.perm-group{display:flex;flex-direction:column;gap:6px}.perm-group__label{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--ion-color-medium)}.perm-chips{display:flex;flex-wrap:wrap;gap:6px}.perm-chip{font-size:12px;padding:3px 10px;border-radius:20px;background:var(--ion-color-light);color:var(--ion-color-dark)}.perm-chip--empty{color:var(--ion-color-medium);background:transparent;padding-left:0}\n"] }]
52447
52437
  }], ctorParameters: () => [], propDecorators: { config: [{
52448
52438
  type: Input
52449
52439
  }] } });
52450
52440
 
52451
52441
  /**
52452
- * Helper para montar el inbox de notificaciones (`val-notifications-view`) como
52453
- * ruta en una app del factory. El `config` se pasa por route `data`
52454
- * (`notificationsConfig`) y el componente lo lee como fallback de su
52455
- * `@Input() config`.
52442
+ * Helper para montar la vista Permisos (`val-permissions-view`) como ruta en una
52443
+ * app del factory. El `config` se pasa por route `data` (`permissionsConfig`) y el
52444
+ * componente lo lee como fallback de su `@Input() config`.
52456
52445
  *
52457
52446
  * @example
52458
52447
  * ```ts
52459
- * // app.routes.ts
52460
- * export const appRoutes: Routes = [
52461
- * ...provideValtechNotificationsRoutes(), // → /app/notifications
52462
- * { path: 'settings', loadChildren: () => ... },
52448
+ * // settings.routes.ts
52449
+ * export const settingsRoutes: Routes = [
52450
+ * ...provideValtechPermissionsRoutes(), // → /app/settings/permissions
52451
+ * { path: 'preferences', loadComponent: () => ... },
52463
52452
  * ];
52464
52453
  *
52465
52454
  * // con config acotada:
52466
- * ...provideValtechNotificationsRoutes({ config: { enableMarkAll: false } }),
52467
- * ...provideValtechNotificationsRoutes({
52468
- * config: { settingsNotificationsPath: '/app/preferences/push' },
52469
- * }),
52455
+ * ...provideValtechPermissionsRoutes({ config: { showCustomRoles: false } }),
52456
+ * ...provideValtechPermissionsRoutes({ path: 'roles' }),
52470
52457
  * ```
52471
52458
  */
52472
- function provideValtechNotificationsRoutes(opts) {
52459
+ function provideValtechPermissionsRoutes(opts) {
52473
52460
  return [
52474
52461
  {
52475
- path: opts?.path ?? 'notifications',
52476
- component: NotificationsViewComponent,
52477
- data: { notificationsConfig: opts?.config },
52462
+ path: opts?.path ?? 'permissions',
52463
+ component: PermissionsViewComponent,
52464
+ data: { permissionsConfig: opts?.config },
52478
52465
  },
52479
52466
  ];
52480
52467
  }