valtech-components 2.0.1000 → 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.1000';
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,1820 +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.
50798
- *
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.
50802
- */
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 }] });
50821
-
50822
- /**
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.
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.
50826
51051
  *
50827
- * Portado EXACTO de `PERMISSIONS_I18N` (showcase) bajo el proceso de ADR-021.
51052
+ * Portado del override `Settings.Notifications` (showcase) bajo el proceso de
51053
+ * ADR-021.
50828
51054
  */
50829
- const PERMISSIONS_VIEW_I18N = {
51055
+ const NOTIFICATION_PREFERENCES_VIEW_I18N = {
50830
51056
  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
- groupPlatform: 'Permisos de plataforma',
50843
- groupOther: 'Otras apps',
50844
- // Roles del sistema
50845
- roleViewer: 'Visor',
50846
- roleEditor: 'Editor',
50847
- roleAdmin: 'Administrador',
50848
- roleSuperAdmin: 'Super Administrador',
50849
- roleBingoAdmin: 'Bingo · Administrador',
50850
- roleBingoTesorero: 'Bingo · Tesorero',
50851
- roleBingoTaquilla: 'Bingo · Taquilla',
50852
- roleBingoOperator: 'Bingo · Operador',
50853
- // Apps
50854
- app_showcase: 'Showcase',
50855
- app_bingo: 'Bingo',
50856
- // Recursos
50857
- permUsers: 'Usuarios',
50858
- permRoles: 'Roles',
50859
- permRbac: 'Control de acceso',
50860
- permApps: 'Apps',
50861
- 'permApi-keys': 'API keys',
50862
- 'permEmail-logs': 'Logs de correo',
50863
- 'permSms-logs': 'Logs de SMS',
50864
- permDocuments: 'Documentos',
50865
- permMedia: 'Media',
50866
- permTemplates: 'Plantillas',
50867
- permRequests: 'Solicitudes',
50868
- permBingo: 'Bingo',
50869
- // Acciones
50870
- permRead: 'Leer',
50871
- permWrite: 'Escribir',
50872
- permCreate: 'Crear',
50873
- permDelete: 'Eliminar',
50874
- permManage: 'Gestionar',
50875
- permEdit: 'Editar',
50876
- permSell: 'Vender',
50877
- permCheckin: 'Canjear',
50878
- permSettle: 'Rendir',
50879
- permExport: 'Exportar',
50880
- permOperate: 'Operar',
50881
- permAll: 'Todo',
50882
- // Loading / error
50883
- errorTitle: 'Error al cargar permisos',
50884
- retry: 'Reintentar',
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',
50885
51087
  },
50886
51088
  en: {
50887
- pageTitle: 'Permissions',
50888
- pageDescription: 'Roles and permissions of this app in the active organization',
50889
- systemRolesTitle: 'System roles',
50890
- systemRolesHint: 'Defined by the platform. Not editable.',
50891
- customRolesTitle: 'Custom roles',
50892
- customRolesHint: 'Defined by your organization.',
50893
- systemBadge: 'System',
50894
- noCustomRoles: 'This organization has no custom roles.',
50895
- noPermissions: 'No permissions assigned.',
50896
- groupApp: 'App permissions',
50897
- groupPlatform: 'Platform permissions',
50898
- groupOther: 'Other apps',
50899
- roleViewer: 'Viewer',
50900
- roleEditor: 'Editor',
50901
- roleAdmin: 'Administrator',
50902
- roleSuperAdmin: 'Super Administrator',
50903
- roleBingoAdmin: 'Bingo · Administrator',
50904
- roleBingoTesorero: 'Bingo · Treasurer',
50905
- roleBingoTaquilla: 'Bingo · Box office',
50906
- roleBingoOperator: 'Bingo · Operator',
50907
- app_showcase: 'Showcase',
50908
- app_bingo: 'Bingo',
50909
- permUsers: 'Users',
50910
- permRoles: 'Roles',
50911
- permRbac: 'Access control',
50912
- permApps: 'Apps',
50913
- 'permApi-keys': 'API keys',
50914
- 'permEmail-logs': 'Email logs',
50915
- 'permSms-logs': 'SMS logs',
50916
- permDocuments: 'Documents',
50917
- permMedia: 'Media',
50918
- permTemplates: 'Templates',
50919
- permRequests: 'Requests',
50920
- permBingo: 'Bingo',
50921
- permRead: 'Read',
50922
- permWrite: 'Write',
50923
- permCreate: 'Create',
50924
- permDelete: 'Delete',
50925
- permManage: 'Manage',
50926
- permEdit: 'Edit',
50927
- permSell: 'Sell',
50928
- permCheckin: 'Check-in',
50929
- permSettle: 'Settle',
50930
- permExport: 'Export',
50931
- permOperate: 'Operate',
50932
- permAll: 'All',
50933
- errorTitle: 'Error loading permissions',
50934
- retry: 'Retry',
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',
50935
51114
  },
50936
51115
  };
50937
51116
 
50938
- const DEFAULT_NAMESPACE$1 = 'Settings.Permissions';
51117
+ addIcons({
51118
+ alertCircleOutline,
51119
+ chevronDownOutline,
51120
+ chevronUpOutline,
51121
+ closeCircleOutline,
51122
+ notificationsOffOutline,
51123
+ notificationsOutline,
51124
+ refreshOutline,
51125
+ });
51126
+ const DEFAULT_NAMESPACE$1 = 'Settings.Notifications';
50939
51127
  /**
50940
- * `val-permissions-view` — vista Permisos full-feature autocontenida (organism).
50941
- * Visor RBAC de solo lectura: muestra los roles del sistema (Visor/Editor/Admin +
50942
- * roles de plataforma) y los roles personalizados de la org activa, con el
50943
- * catálogo de permisos agrupado por alcance (app actual / plataforma / otras apps).
50944
- * Promovida desde `showcase` bajo el proceso de ADR-021.
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.
50945
51131
  *
50946
- * **RBAC interno** (como `organization-view`): lee la org activa vía
50947
- * `AuthService.user().activeOrgId`. La vista NO muta nada — el control de quién
50948
- * puede verla es responsabilidad del guard de ruta del consumer; no hay gating
50949
- * fino interno ni se expone como config.
51132
+ * Separada de `val-notifications-view` (el inbox / feed). Aquí el user **activa o
51133
+ * desactiva** las notificaciones push en este dispositivo.
50950
51134
  *
50951
- * Datos: `forkJoin` de `OrgService.listOrgRoles(orgId)` (roles de la org) +
50952
- * `PermissionCatalogService.getCatalog(orgId)` (catálogo + appId actual). El
50953
- * catálogo define el alcance (`scope`/`appId`) de cada recurso para agrupar.
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.
50954
51140
  *
50955
51141
  * NO renderiza ion-content — vive dentro de val-page-wrapper. `ActivatedRoute` se
50956
- * inyecta `{ optional: true }` solo para leer el route data de config. Auto-registra
50957
- * sus defaults i18n (es/en) si el consumer no proveyó el namespace (default
50958
- * `Settings.Permissions`).
51142
+ * inyecta `{ optional: true }` funciona sin router (embebida / Storybook).
50959
51143
  */
50960
- class PermissionsViewComponent {
51144
+ class NotificationPreferencesViewComponent {
50961
51145
  /** Namespace i18n resuelto (capturado para llamadas no-reactivas). */
50962
51146
  get ns() {
50963
51147
  return this.resolvedConfig().i18nNamespace;
50964
51148
  }
51149
+ // ---------------------------------------------------------------------------
51150
+ // Lifecycle
51151
+ // ---------------------------------------------------------------------------
50965
51152
  constructor() {
51153
+ this.nav = inject(NavigationService);
50966
51154
  this.i18n = inject(I18nService);
50967
- this.orgService = inject(OrgService);
50968
- this.catalog = inject(PermissionCatalogService);
51155
+ this.toast = inject(ToastService);
50969
51156
  this.auth = inject(AuthService);
50970
- 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).
50971
51162
  this.route = inject(ActivatedRoute, { optional: true });
50972
51163
  this.resolvedConfig = computed(() => {
50973
- const fromRoute = (this.route?.snapshot.data['permissionsConfig'] ?? {});
51164
+ const fromRoute = (this.route?.snapshot.data['notificationPreferencesConfig'] ??
51165
+ {});
50974
51166
  const merged = { ...fromRoute, ...(this.config ?? {}) };
50975
51167
  return {
50976
- showSystemRoles: merged.showSystemRoles ?? true,
50977
- showCustomRoles: merged.showCustomRoles ?? true,
51168
+ showDetails: merged.showDetails ?? true,
50978
51169
  i18nNamespace: merged.i18nNamespace ?? DEFAULT_NAMESPACE$1,
51170
+ onPushEnabled: merged.onPushEnabled,
51171
+ onPushDisabled: merged.onPushDisabled,
50979
51172
  };
50980
51173
  });
50981
- this.loading = signal(false);
50982
- this.loadError = signal(null);
50983
- this.roles = signal([]);
50984
- this.systemRoles = computed(() => this.roles().filter(r => r.isSystem));
50985
- this.customRoles = computed(() => this.roles().filter(r => !r.isSystem));
50986
- this.activeOrgId = computed(() => {
50987
- const u = this.auth.user();
50988
- 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
+ }
50989
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';
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
+ }));
50990
51275
  this.retryButtonProps = computed(() => ({
50991
- text: this.tt('retry'),
51276
+ text: this.tt('retryCta'),
50992
51277
  color: 'dark',
50993
51278
  fill: 'outline',
50994
51279
  size: 'default',
51280
+ shape: 'round',
50995
51281
  type: 'button',
50996
- state: this.loading() ? ComponentStates.DISABLED : ComponentStates.ENABLED,
51282
+ icon: { name: 'refresh-outline', slot: 'start' },
51283
+ state: this._busy() !== null ? ComponentStates.WORKING : ComponentStates.ENABLED,
50997
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
+ });
50998
51291
  const ns = this.ns;
50999
51292
  if (!this.i18n.hasNamespace(ns)) {
51000
- this.i18n.registerContent(ns, PERMISSIONS_VIEW_I18N);
51293
+ this.i18n.registerContent(ns, NOTIFICATION_PREFERENCES_VIEW_I18N);
51001
51294
  }
51002
51295
  this.nav.setBackHeader('pageTitle', ns, { withMenu: true });
51003
- this.load();
51004
- connectPageRefresh(() => this.load());
51005
- }
51006
- load() {
51007
- const orgId = this.activeOrgId();
51008
- if (!orgId)
51009
- return;
51010
- this.loading.set(true);
51011
- this.loadError.set(null);
51012
- forkJoin({
51013
- roles: this.orgService.listOrgRoles(orgId),
51014
- catalog: this.catalog.getCatalog(orgId),
51015
- }).subscribe({
51016
- next: ({ roles: rawRoles, catalog }) => {
51017
- const byResource = new Map();
51018
- for (const r of catalog.permissions)
51019
- byResource.set(r.resource, r);
51020
- const currentAppId = catalog.appId;
51021
- this.roles.set((rawRoles ?? []).map((r) => this.buildRoleView({
51022
- id: r.id,
51023
- name: r.name,
51024
- description: r.description,
51025
- permissions: r.permissions ?? [],
51026
- isSystem: r.isSystem ?? false,
51027
- }, byResource, currentAppId)));
51028
- this.loading.set(false);
51029
- },
51030
- error: err => {
51031
- this.loadError.set(err);
51032
- this.loading.set(false);
51033
- },
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
+ }
51034
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
+ }
51035
51337
  }
51036
- buildRoleView(role, byResource, currentAppId) {
51037
- const appPerms = [];
51038
- const platformPerms = [];
51039
- const otherByApp = new Map();
51040
- for (const perm of role.permissions) {
51041
- const [resource, action] = perm.split(':');
51042
- const label = this.permLabel(resource, action);
51043
- const entry = byResource.get(resource);
51044
- if (entry?.scope === 'app') {
51045
- if (entry.appId === currentAppId) {
51046
- appPerms.push(label);
51047
- }
51048
- else {
51049
- const appId = entry.appId ?? '?';
51050
- const arr = otherByApp.get(appId) ?? [];
51051
- arr.push(label);
51052
- 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);
51053
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;
51054
51406
  }
51055
51407
  else {
51056
- // global o desconocidoplataforma
51057
- platformPerms.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
+ });
51058
51416
  }
51059
51417
  }
51060
- const groups = [];
51061
- if (appPerms.length) {
51062
- groups.push({ key: 'app', label: this.tt('groupApp'), perms: appPerms });
51063
- }
51064
- if (platformPerms.length) {
51065
- groups.push({
51066
- key: 'platform',
51067
- label: this.tt('groupPlatform'),
51068
- perms: platformPerms,
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',
51069
51426
  });
51070
51427
  }
51071
- for (const [appId, perms] of otherByApp) {
51072
- groups.push({
51073
- key: `other:${appId}`,
51074
- label: `${this.tt('groupOther')}: ${this.appLabel(appId)}`,
51075
- 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',
51076
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);
51077
51456
  }
51078
- return {
51079
- id: role.id,
51080
- name: role.name,
51081
- description: role.description,
51082
- isSystem: role.isSystem,
51083
- groups,
51084
- hasPerms: groups.length > 0,
51085
- };
51086
51457
  }
51087
- tt(key) {
51088
- 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();
51089
51462
  }
51090
- appLabel(appId) {
51091
- const key = `app_${appId}`;
51092
- const label = this.tt(key);
51093
- return label === key ? appId : label;
51463
+ toggleDetails() {
51464
+ this.showDetails.update(v => !v);
51094
51465
  }
51095
- roleLabel(name) {
51096
- const map = {
51097
- viewer: 'roleViewer',
51098
- editor: 'roleEditor',
51099
- admin: 'roleAdmin',
51100
- super_admin: 'roleSuperAdmin',
51101
- bingo_admin: 'roleBingoAdmin',
51102
- bingo_tesorero: 'roleBingoTesorero',
51103
- bingo_taquilla: 'roleBingoTaquilla',
51104
- bingo_operator: 'roleBingoOperator',
51105
- };
51106
- 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();
51107
51474
  }
51108
- permLabel(resource, action) {
51109
- if (resource === '*' && action === '*')
51110
- return this.tt('permAll');
51111
- const resKey = resource === '*' ? 'permAll' : `perm${resource.charAt(0).toUpperCase()}${resource.slice(1)}`;
51112
- const actKey = action === '*' ? 'permAll' : `perm${action.charAt(0).toUpperCase()}${action.slice(1)}`;
51113
- const res = this.tt(resKey);
51114
- const act = this.tt(actKey);
51115
- 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
+ }
51116
51507
  }
51117
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: PermissionsViewComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
51118
- 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: `
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: `
51119
51529
  <div class="page">
51120
51530
  <header class="page-header">
51121
- <val-display [props]="{ size: 'small', color: 'dark', content: tt('pageTitle') }" />
51531
+ <val-display [props]="{ size: 'small', color: 'dark', content: pageTitle() }" />
51122
51532
  <val-title
51123
51533
  [props]="{
51124
51534
  size: 'large',
51125
51535
  color: 'dark',
51126
51536
  bold: false,
51127
- content: tt('pageDescription'),
51537
+ content: pageDescription(),
51128
51538
  }"
51129
51539
  />
51130
51540
  </header>
51131
51541
 
51132
- @if (loading()) {
51133
- <div class="spinner-row">
51134
- <ion-spinner name="crescent" />
51135
- </div>
51136
- } @else if (loadError()) {
51137
- <div class="error-row">
51138
- <val-text
51542
+ @if (state() === 'needs-install') {
51543
+ <section class="settings-section">
51544
+ <val-title
51139
51545
  [props]="{
51140
- content: tt('errorTitle'),
51141
51546
  size: 'medium',
51142
- color: 'medium',
51143
- bold: false,
51547
+ color: 'dark',
51548
+ bold: true,
51549
+ content: tt('iosInstallTitle'),
51144
51550
  }"
51145
51551
  />
51146
- <val-button [props]="retryButtonProps()" (click)="load()" />
51147
- </div>
51148
- } @else {
51149
- @if (resolvedConfig().showSystemRoles) {
51150
- <section class="perm-section">
51151
- <div class="perm-section__header">
51152
- <val-title
51153
- [props]="{
51154
- size: 'medium',
51155
- color: 'dark',
51156
- bold: true,
51157
- content: tt('systemRolesTitle'),
51158
- }"
51159
- />
51160
- <val-text
51161
- [props]="{
51162
- content: tt('systemRolesHint'),
51163
- size: 'medium',
51164
- color: 'medium',
51165
- bold: false,
51166
- }"
51167
- />
51168
- </div>
51169
- <div class="role-list">
51170
- @for (role of systemRoles(); track role.id) {
51171
- <div class="role-card">
51172
- <div class="role-card__header">
51173
- <span class="role-card__name">{{ roleLabel(role.name) }}</span>
51174
- <val-pill
51175
- [props]="{
51176
- label: tt('systemBadge'),
51177
- color: 'primary',
51178
- weight: 'bold',
51179
- }"
51180
- />
51181
- </div>
51182
- @if (role.description) {
51183
- <span class="role-card__desc">{{ role.description }}</span>
51184
- }
51185
- @if (!role.hasPerms) {
51186
- <div class="perm-chips">
51187
- <span class="perm-chip perm-chip--empty">{{ tt('noPermissions') }}</span>
51188
- </div>
51189
- } @else {
51190
- @for (g of role.groups; track g.key) {
51191
- <div class="perm-group">
51192
- <span class="perm-group__label">{{ g.label }}</span>
51193
- <div class="perm-chips">
51194
- @for (p of g.perms; track p) {
51195
- <span class="perm-chip">{{ p }}</span>
51196
- }
51197
- </div>
51198
- </div>
51199
- }
51200
- }
51201
- </div>
51202
- }
51203
- </div>
51204
- </section>
51205
- }
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
+ }
51206
51570
 
51207
- @if (resolvedConfig().showCustomRoles) {
51208
- <section class="perm-section">
51209
- <div class="perm-section__header">
51210
- <val-title
51211
- [props]="{
51212
- size: 'medium',
51213
- color: 'dark',
51214
- bold: true,
51215
- content: tt('customRolesTitle'),
51216
- }"
51217
- />
51218
- <val-text
51219
- [props]="{
51220
- content: tt('customRolesHint'),
51221
- size: 'medium',
51222
- color: 'medium',
51223
- bold: false,
51224
- }"
51225
- />
51226
- </div>
51227
- @if (customRoles().length === 0) {
51228
- <val-text
51229
- [props]="{
51230
- content: tt('noCustomRoles'),
51231
- size: 'medium',
51232
- color: 'medium',
51233
- bold: false,
51234
- }"
51235
- />
51236
- } @else {
51237
- <div class="role-list">
51238
- @for (role of customRoles(); track role.id) {
51239
- <div class="role-card">
51240
- <div class="role-card__header">
51241
- <span class="role-card__name">{{ role.name }}</span>
51242
- </div>
51243
- @if (role.description) {
51244
- <span class="role-card__desc">{{ role.description }}</span>
51245
- }
51246
- @if (!role.hasPerms) {
51247
- <div class="perm-chips">
51248
- <span class="perm-chip perm-chip--empty">{{ tt('noPermissions') }}</span>
51249
- </div>
51250
- } @else {
51251
- @for (g of role.groups; track g.key) {
51252
- <div class="perm-group">
51253
- <span class="perm-group__label">{{ g.label }}</span>
51254
- <div class="perm-chips">
51255
- @for (p of g.perms; track p) {
51256
- <span class="perm-chip">{{ p }}</span>
51257
- }
51258
- </div>
51259
- </div>
51260
- }
51261
- }
51262
- </div>
51263
- }
51264
- </div>
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>
51265
51595
  }
51266
- </section>
51596
+ </div>
51597
+ <div class="notif-row__control">
51598
+ <val-toggle-input [props]="toggleProps()" />
51599
+ </div>
51600
+ </div>
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>
51607
+ }
51608
+
51609
+ @if (state() === 'error') {
51610
+ <div class="row-actions">
51611
+ <val-button [props]="retryButtonProps()" (click)="onRetry()" />
51612
+ </div>
51267
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>
51268
51639
  }
51269
51640
  </div>
51270
- `, 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"] }] }); }
51271
51642
  }
51272
- 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: [{
51273
51644
  type: Component,
51274
- 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: `
51275
51646
  <div class="page">
51276
51647
  <header class="page-header">
51277
- <val-display [props]="{ size: 'small', color: 'dark', content: tt('pageTitle') }" />
51648
+ <val-display [props]="{ size: 'small', color: 'dark', content: pageTitle() }" />
51278
51649
  <val-title
51279
51650
  [props]="{
51280
51651
  size: 'large',
51281
51652
  color: 'dark',
51282
51653
  bold: false,
51283
- content: tt('pageDescription'),
51654
+ content: pageDescription(),
51284
51655
  }"
51285
51656
  />
51286
51657
  </header>
51287
51658
 
51288
- @if (loading()) {
51289
- <div class="spinner-row">
51290
- <ion-spinner name="crescent" />
51291
- </div>
51292
- } @else if (loadError()) {
51293
- <div class="error-row">
51294
- <val-text
51659
+ @if (state() === 'needs-install') {
51660
+ <section class="settings-section">
51661
+ <val-title
51295
51662
  [props]="{
51296
- content: tt('errorTitle'),
51297
51663
  size: 'medium',
51298
- color: 'medium',
51299
- bold: false,
51664
+ color: 'dark',
51665
+ bold: true,
51666
+ content: tt('iosInstallTitle'),
51300
51667
  }"
51301
51668
  />
51302
- <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>
51303
51717
  </div>
51304
- } @else {
51305
- @if (resolvedConfig().showSystemRoles) {
51306
- <section class="perm-section">
51307
- <div class="perm-section__header">
51308
- <val-title
51309
- [props]="{
51310
- size: 'medium',
51311
- color: 'dark',
51312
- bold: true,
51313
- content: tt('systemRolesTitle'),
51314
- }"
51315
- />
51316
- <val-text
51317
- [props]="{
51318
- content: tt('systemRolesHint'),
51319
- size: 'medium',
51320
- color: 'medium',
51321
- bold: false,
51322
- }"
51323
- />
51324
- </div>
51325
- <div class="role-list">
51326
- @for (role of systemRoles(); track role.id) {
51327
- <div class="role-card">
51328
- <div class="role-card__header">
51329
- <span class="role-card__name">{{ roleLabel(role.name) }}</span>
51330
- <val-pill
51331
- [props]="{
51332
- label: tt('systemBadge'),
51333
- color: 'primary',
51334
- weight: 'bold',
51335
- }"
51336
- />
51337
- </div>
51338
- @if (role.description) {
51339
- <span class="role-card__desc">{{ role.description }}</span>
51340
- }
51341
- @if (!role.hasPerms) {
51342
- <div class="perm-chips">
51343
- <span class="perm-chip perm-chip--empty">{{ tt('noPermissions') }}</span>
51344
- </div>
51345
- } @else {
51346
- @for (g of role.groups; track g.key) {
51347
- <div class="perm-group">
51348
- <span class="perm-group__label">{{ g.label }}</span>
51349
- <div class="perm-chips">
51350
- @for (p of g.perms; track p) {
51351
- <span class="perm-chip">{{ p }}</span>
51352
- }
51353
- </div>
51354
- </div>
51355
- }
51356
- }
51357
- </div>
51358
- }
51359
- </div>
51360
- </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>
51361
51724
  }
51362
51725
 
51363
- @if (resolvedConfig().showCustomRoles) {
51364
- <section class="perm-section">
51365
- <div class="perm-section__header">
51366
- <val-title
51367
- [props]="{
51368
- size: 'medium',
51369
- color: 'dark',
51370
- bold: true,
51371
- content: tt('customRolesTitle'),
51372
- }"
51373
- />
51374
- <val-text
51375
- [props]="{
51376
- content: tt('customRolesHint'),
51377
- size: 'medium',
51378
- color: 'medium',
51379
- bold: false,
51380
- }"
51381
- />
51382
- </div>
51383
- @if (customRoles().length === 0) {
51384
- <val-text
51385
- [props]="{
51386
- content: tt('noCustomRoles'),
51387
- size: 'medium',
51388
- color: 'medium',
51389
- bold: false,
51390
- }"
51391
- />
51392
- } @else {
51393
- <div class="role-list">
51394
- @for (role of customRoles(); track role.id) {
51395
- <div class="role-card">
51396
- <div class="role-card__header">
51397
- <span class="role-card__name">{{ role.name }}</span>
51398
- </div>
51399
- @if (role.description) {
51400
- <span class="role-card__desc">{{ role.description }}</span>
51401
- }
51402
- @if (!role.hasPerms) {
51403
- <div class="perm-chips">
51404
- <span class="perm-chip perm-chip--empty">{{ tt('noPermissions') }}</span>
51405
- </div>
51406
- } @else {
51407
- @for (g of role.groups; track g.key) {
51408
- <div class="perm-group">
51409
- <span class="perm-group__label">{{ g.label }}</span>
51410
- <div class="perm-chips">
51411
- @for (p of g.perms; track p) {
51412
- <span class="perm-chip">{{ p }}</span>
51413
- }
51414
- </div>
51415
- </div>
51416
- }
51417
- }
51418
- </div>
51419
- }
51420
- </div>
51421
- }
51422
- </section>
51726
+ @if (state() === 'error') {
51727
+ <div class="row-actions">
51728
+ <val-button [props]="retryButtonProps()" (click)="onRetry()" />
51729
+ </div>
51423
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>
51424
51756
  }
51425
51757
  </div>
51426
- `, 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"] }]
51427
51759
  }], ctorParameters: () => [], propDecorators: { config: [{
51428
51760
  type: Input
51429
51761
  }] } });
51430
51762
 
51431
51763
  /**
51432
- * Helper para montar la vista Permisos (`val-permissions-view`) como ruta en una
51433
- * 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
51434
51767
  * componente lo lee como fallback de su `@Input() config`.
51435
51768
  *
51769
+ * No confundir con `provideValtechNotificationsRoutes` (el inbox / feed).
51770
+ *
51436
51771
  * @example
51437
51772
  * ```ts
51438
- * // settings.routes.ts
51773
+ * // app.routes.ts (típicamente bajo settings)
51439
51774
  * export const settingsRoutes: Routes = [
51440
- * ...provideValtechPermissionsRoutes(), // → /app/settings/permissions
51441
- * { path: 'preferences', loadComponent: () => ... },
51775
+ * ...provideValtechNotificationPreferencesRoutes(), // → notifications
51442
51776
  * ];
51443
51777
  *
51444
51778
  * // con config acotada:
51445
- * ...provideValtechPermissionsRoutes({ config: { showCustomRoles: false } }),
51446
- * ...provideValtechPermissionsRoutes({ path: 'roles' }),
51779
+ * ...provideValtechNotificationPreferencesRoutes({ config: { showDetails: false } }),
51780
+ * ...provideValtechNotificationPreferencesRoutes({ path: 'push' }),
51447
51781
  * ```
51448
51782
  */
51449
- function provideValtechPermissionsRoutes(opts) {
51783
+ function provideValtechNotificationPreferencesRoutes(opts) {
51450
51784
  return [
51451
51785
  {
51452
- path: opts?.path ?? 'permissions',
51453
- component: PermissionsViewComponent,
51454
- data: { permissionsConfig: opts?.config },
51786
+ path: opts?.path ?? 'notifications',
51787
+ component: NotificationPreferencesViewComponent,
51788
+ data: { notificationPreferencesConfig: opts?.config },
51455
51789
  },
51456
51790
  ];
51457
51791
  }
51458
51792
 
51459
51793
  /**
51460
- * Helper para montar el bloque completo de Settings en una app del factory: el
51461
- * HUB de navegación (`val-settings-hub`) + TODAS las vistas promovidas (Perfil,
51462
- * Preferencias, Seguridad, Cuenta, Gestión org, Preferencias de notificaciones,
51463
- * Permisos) + las rutas propias de la app (`extraRoutes`). Cada `include*` permite
51464
- * apagar una sección; `hubPath`/`indexRedirectTo`/`*Path` permiten reubicar el hub
51465
- * y las vistas para preservar URLs existentes.
51466
- *
51467
- * showcase lo consume (ver `settings.routes.ts` de showcase): hub en `preferences`,
51468
- * preferences-view en `general`, index→redirect, `organization/new` por `extraRoutes`.
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.
51469
51798
  *
51470
- * @example
51471
- * ```ts
51472
- * // app.routes.ts
51473
- * import { provideValtechSettingsRoutes } from 'valtech-components';
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.
51802
+ */
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' }); }
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 }] });
51821
+
51822
+ /**
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.
51474
51826
  *
51475
- * export const routes: Routes = [
51476
- * {
51477
- * path: 'app',
51478
- * children: [
51479
- * ...provideValtechSettingsRoutes({
51480
- * hubConfig: { onLogout: () => auth.logoutWithConfirmation() },
51481
- * extraRoutes: [
51482
- * { path: 'security', loadComponent: () => ... },
51483
- * ],
51484
- * }),
51485
- * ],
51486
- * },
51487
- * ];
51488
- * ```
51827
+ * Portado EXACTO de `PERMISSIONS_I18N` (showcase) bajo el proceso de ADR-021.
51489
51828
  */
51490
- function provideValtechSettingsRoutes(opts) {
51491
- const basePath = opts?.basePath ?? 'settings';
51492
- const hubPath = opts?.hubPath ?? '';
51493
- const includeProfile = opts?.includeProfile ?? true;
51494
- const includePreferences = opts?.includePreferences ?? true;
51495
- const includeSecurity = opts?.includeSecurity ?? true;
51496
- const includeAccount = opts?.includeAccount ?? true;
51497
- const includeOrganization = opts?.includeOrganization ?? true;
51498
- const includeNotificationPreferences = opts?.includeNotificationPreferences ?? true;
51499
- const includePermissions = opts?.includePermissions ?? true;
51500
- return [
51501
- {
51502
- path: basePath,
51503
- children: [
51504
- ...(opts?.indexRedirectTo
51505
- ? [{ path: '', redirectTo: opts.indexRedirectTo, pathMatch: 'full' }]
51506
- : []),
51507
- {
51508
- path: hubPath,
51509
- component: SettingsHubComponent,
51510
- data: { settingsHubConfig: opts?.hubConfig },
51511
- },
51512
- ...(includeProfile ? provideValtechProfileRoutes({ config: opts?.profileConfig }) : []),
51513
- ...(includePreferences
51514
- ? provideValtechPreferencesRoutes({
51515
- path: opts?.preferencesPath ?? 'preferences',
51516
- config: opts?.preferencesConfig,
51517
- })
51518
- : []),
51519
- ...(includeSecurity ? provideValtechSecurityRoutes({ config: opts?.securityConfig }) : []),
51520
- ...(includeAccount ? provideValtechAccountRoutes({ config: opts?.accountConfig }) : []),
51521
- ...(includeOrganization
51522
- ? provideValtechOrganizationRoutes({ config: opts?.organizationConfig })
51523
- : []),
51524
- ...(includeNotificationPreferences
51525
- ? provideValtechNotificationPreferencesRoutes({
51526
- path: opts?.notificationPreferencesPath ?? 'notifications',
51527
- config: opts?.notificationPreferencesConfig,
51528
- })
51529
- : []),
51530
- ...(includePermissions
51531
- ? provideValtechPermissionsRoutes({ config: opts?.permissionsConfig })
51532
- : []),
51533
- ...(opts?.extraRoutes ?? []),
51534
- ],
51535
- },
51536
- ];
51537
- }
51538
-
51539
- /**
51540
- * Refreshable Stream
51541
- *
51542
- * Helper estándar para vistas que consumen datos de Firestore. Soporta DOS
51543
- * modos — el caller decide cuál vía la `factory`:
51544
- *
51545
- * - **one-shot** (recomendado por defecto): `() => from(svc.getAllOnce())`.
51546
- * Carga una vez y completa. Más barato — sin listener vivo.
51547
- * - **real-time**: `() => svc.getAll()`. Listener Firestore que auto-actualiza.
51548
- * Solo donde importa (inbox de notificaciones, badges).
51549
- *
51550
- * En ambos modos la primera suscripción se gatea por `firebaseAuthReady`
51551
- * (sesión de Firebase Auth lista) — así el listener/lectura nunca se adjunta
51552
- * antes de que `request.auth` esté disponible en las reglas de Firestore, lo
51553
- * que cierra la ventana de `permission-denied` en cold start de PWA.
51554
- *
51555
- * El `retry` con backoff corto es solo red de seguridad para reconexiones
51556
- * transitorias (no para cubrir cold start — eso lo cubre el gate de auth).
51557
- */
51558
- /**
51559
- * Crea un stream refrescable a partir de una factory de Observable.
51560
- *
51561
- * DEBE llamarse en un injection context (field initializer o constructor) —
51562
- * usa `toSignal`/`toObservable`/`inject` internamente.
51563
- *
51564
- * @param factory - Función que produce el Observable a consumir. El caller
51565
- * decide el modo: `() => from(svc.getAllOnce())` (one-shot) o
51566
- * `() => svc.getAll()` (real-time).
51567
- * @param options - Configuración opcional (fallback, retry, gate).
51568
- *
51569
- * @example
51570
- * ```typescript
51571
- * // one-shot (recomendado por defecto)
51572
- * private readonly stream = createRefreshableStream(
51573
- * () => from(this.svc.getAllOnce()),
51574
- * );
51575
- *
51576
- * // real-time (inbox)
51577
- * private readonly stream = createRefreshableStream(
51578
- * () => this.notifs.getAll(50),
51579
- * );
51580
- *
51581
- * readonly items = this.stream.data;
51582
- * readonly loading = this.stream.loading;
51583
- *
51584
- * ionViewWillEnter() {
51585
- * this.pageRefresh.register(() => this.stream.reload());
51586
- * }
51587
- * ```
51588
- */
51589
- function createRefreshableStream(factory, options) {
51590
- const injector = options?.injector ?? inject(Injector);
51591
- const fallback = (options?.fallback ?? null);
51592
- const retryCount = options?.retryCount ?? 4;
51593
- const retryBaseDelayMs = options?.retryBaseDelayMs ?? 500;
51594
- const gateOnFirebaseAuth = options?.gateOnFirebaseAuth ?? true;
51595
- const authGateTimeoutMs = options?.authGateTimeoutMs ?? 20000;
51596
- const firstEmitTimeoutMs = options?.firstEmitTimeoutMs ?? 20000;
51597
- // FirebaseService es opcional — sin él (apps sin Firebase) no se gatea.
51598
- const firebase = gateOnFirebaseAuth ? injector.get(FirebaseService, null) : null;
51599
- const _reload = signal(0);
51600
- const _loading = signal(true);
51601
- const _error = signal(false);
51602
- // Gate de Firebase Auth: el stream no abre la primera suscripción hasta que
51603
- // la sesión esté confirmada. `race` con un `timer` de seguridad — si la
51604
- // sesión NUNCA se establece (token inválido, signInWithFirebase falló), el
51605
- // gate igual se libera tras `authGateTimeoutMs` y la factory procede: si no
51606
- // hay permiso terminará en estado `error`, jamás en skeleton infinito.
51607
- // Tag los dos inputs del `race` para distinguir en logs cuál ganó: la sesión
51608
- // de Firebase Auth (`auth-ready`) o el timeout de seguridad (`timeout`).
51609
- const authGate$ = firebase
51610
- ? race(firebase.firebaseAuthReady$.pipe(map$1(() => 'auth-ready')), timer(authGateTimeoutMs).pipe(map$1(() => 'timeout'))).pipe(take(1), tap(winner => {
51611
- if (winner === 'timeout') {
51612
- console.warn(`[RefreshStream] auth gate resolved by TIMEOUT after ${authGateTimeoutMs}ms ` +
51613
- '(Firebase Auth session never confirmed)');
51614
- }
51615
- else {
51616
- console.log('[RefreshStream] auth gate resolved by firebaseAuthReady$ (session ready)');
51617
- }
51618
- }))
51619
- : of(true);
51620
- const data = toSignal(toObservable(_reload, { injector }).pipe(tap(() => {
51621
- _loading.set(true);
51622
- _error.set(false);
51623
- }),
51624
- // Esperar a Firebase Auth listo antes de cada (re)suscripción. Como
51625
- // `firebaseAuthReady$` completa tras la primera emisión, las reloads
51626
- // posteriores resuelven el gate de inmediato (auth ya está listo).
51627
- switchMap(() => authGate$.pipe(switchMap(() => factory().pipe(tap(() => {
51628
- _loading.set(false);
51629
- _error.set(false);
51630
- console.log('[RefreshStream] factory emitted first value — loading=false');
51631
- }),
51632
- // Backoff corto exponencial acotado — solo red de seguridad para
51633
- // reconexiones transitorias, NO para cubrir cold start.
51634
- retry({
51635
- count: retryCount,
51636
- delay: (_err, attempt) => timer(Math.min(retryBaseDelayMs * 2 ** (attempt - 1), 4000)),
51637
- }),
51638
- // Techo absoluto para la PRIMERA emisión. Si la factory no emite
51639
- // ni valor ni error (servicio nunca inicializado → `EMPTY` →
51640
- // silencio), `timeout` dispara un error → `catchError` → estado
51641
- // `error`. Nunca skeleton perpetuo. Va DESPUÉS de `retry` para
51642
- // que cuente el tiempo total, no por intento.
51643
- timeout({ first: firstEmitTimeoutMs }), catchError(err => {
51644
- _loading.set(false);
51645
- _error.set(true);
51646
- console.error(`[RefreshStream] factory failed/timed out → error state: ${err instanceof Error ? err.message : String(err)}`);
51647
- return of(fallback);
51648
- })))))), { initialValue: null, injector });
51649
- return {
51650
- data,
51651
- loading: _loading.asReadonly(),
51652
- error: _error.asReadonly(),
51653
- reload: () => _reload.update(n => n + 1),
51654
- };
51655
- }
51656
-
51657
- /**
51658
- * Defaults i18n (es/en) embebidos en `val-notifications-view`. Auto-registrados
51659
- * en el constructor del componente si el consumer no proveyó el namespace
51660
- * (`Notifications` por default). SOLO incluye las keys que la vista usa.
51661
- *
51662
- * Portado del override `Notifications` (showcase) bajo el proceso de ADR-021.
51663
- */
51664
- const NOTIFICATIONS_VIEW_I18N = {
51829
+ const PERMISSIONS_VIEW_I18N = {
51665
51830
  es: {
51666
- pageTitle: 'Notificaciones',
51667
- pageDescription: 'Tu bandeja de mensajes y actualizaciones',
51668
- groupToday: 'Hoy',
51669
- groupThisMonth: 'Este mes',
51670
- groupEarlier: 'Anteriores',
51671
- empty: 'Aún no tienes notificaciones',
51672
- emptyHint: 'Activa las notificaciones para recibir actualizaciones, alertas y novedades importantes.',
51673
- emptyCta: 'Ir a configuración',
51674
- loadErrorTitle: 'No pudimos cargar tus notificaciones',
51675
- loadErrorHint: 'Revisa tu conexión e inténtalo de nuevo. Tus notificaciones siguen guardadas.',
51676
- offlineTitle: 'Sin conexión',
51677
- offlineHint: 'No hay acceso a la red. Reconéctate e intenta de nuevo.',
51678
- retryCta: 'Reintentar',
51679
- pushOffTitle: 'No recibirás avisos',
51680
- pushOffHint: 'Puedes leer tus mensajes aquí, pero las notificaciones push están desactivadas. Actívalas para recibir avisos en tu dispositivo.',
51681
- pushOffCta: 'Activar notificaciones',
51682
- loading: 'Cargando…',
51683
- loadMore: 'Cargar más',
51684
- seeMore: 'Ver más',
51685
- seeLess: 'Ver menos',
51686
- open: 'Ver',
51687
- markAll: 'Marcar todas como leídas',
51688
- unread: 'No leída',
51689
- crossApp: 'Desde {app}',
51690
- timeNow: 'ahora',
51691
- timeMinutes: 'hace {n} min',
51692
- timeHours: 'hace {n} h',
51693
- timeDays: 'hace {n} d',
51694
- markedAllSuccess: 'Notificaciones marcadas como leídas.',
51695
- markedAllError: 'No se pudo marcar todas como leídas.',
51696
- 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',
51697
51886
  },
51698
51887
  en: {
51699
- pageTitle: 'Notifications',
51700
- pageDescription: 'Your inbox of messages and updates',
51701
- groupToday: 'Today',
51702
- groupThisMonth: 'This month',
51703
- groupEarlier: 'Earlier',
51704
- empty: 'No notifications yet',
51705
- emptyHint: 'Enable notifications to receive updates, alerts and important news.',
51706
- emptyCta: 'Go to settings',
51707
- loadErrorTitle: "We couldn't load your notifications",
51708
- loadErrorHint: 'Check your connection and try again. Your notifications are still saved.',
51709
- offlineTitle: 'No connection',
51710
- offlineHint: 'No network access. Reconnect and try again.',
51711
- retryCta: 'Retry',
51712
- pushOffTitle: "You won't get alerts",
51713
- pushOffHint: 'You can read your messages here, but push notifications are off. Turn them on to get alerts on your device.',
51714
- pushOffCta: 'Enable notifications',
51715
- loading: 'Loading…',
51716
- loadMore: 'Load more',
51717
- seeMore: 'See more',
51718
- seeLess: 'See less',
51719
- open: 'Open',
51720
- markAll: 'Mark all as read',
51721
- unread: 'Unread',
51722
- crossApp: 'From {app}',
51723
- timeNow: 'now',
51724
- timeMinutes: '{n} min ago',
51725
- timeHours: '{n} h ago',
51726
- timeDays: '{n} d ago',
51727
- markedAllSuccess: 'Notifications marked as read.',
51728
- markedAllError: 'Could not mark all as read.',
51729
- openError: 'Could not open notification.',
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',
51730
51937
  },
51731
51938
  };
51732
51939
 
51733
- addIcons({
51734
- alarmOutline,
51735
- chatboxEllipsesOutline,
51736
- chatbubbleOutline,
51737
- checkmarkCircleOutline,
51738
- checkmarkDoneOutline,
51739
- cloudOfflineOutline,
51740
- cogOutline,
51741
- documentTextOutline,
51742
- notificationsOffOutline,
51743
- notificationsOutline,
51744
- openOutline,
51745
- personOutline,
51746
- });
51747
- const DEFAULT_NAMESPACE = 'Notifications';
51748
- const DEFAULT_SETTINGS_PATH = '/app/settings/notifications';
51749
- const DEFAULT_PAGE_SIZE = 10;
51750
- const DEFAULT_MAX_LOAD = 50;
51940
+ const DEFAULT_NAMESPACE = 'Settings.Permissions';
51751
51941
  /**
51752
- * `val-notifications-view` — inbox de notificaciones full-feature autocontenido
51753
- * (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.
51754
51947
  *
51755
- * Separado de `/app/settings/notifications` (que controla permiso FCM + master
51756
- * 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.
51757
51952
  *
51758
- * - **Stream real-time** vía `NotificationsService.getAll(maxLoad)` envuelto en
51759
- * `createRefreshableStream` (gateado por `firebaseAuthReady`, retry corto,
51760
- * `firstEmitTimeoutMs: 15000`). Estados: loading (skeleton), error
51761
- * (`val-empty-state` + reintentar), empty (`val-empty-state` + CTA), data
51762
- * (lista agrupada Hoy / Este mes / Anteriores).
51763
- * - **Abrir** una notif → `NotificationActionService.open()` (markRead +
51764
- * switch-org + navegar/handoff cross-app). Hook `onNotificationOpen` opcional.
51765
- * - **Marcar todas como leídas** (gateado por `enableMarkAll`).
51766
- * - **CTA push-off** cuando el permiso del navegador no está concedido (gateado
51767
- * por `showPushOffCta`); navega a `settingsNotificationsPath`.
51768
- * - **Paginación soft** — slice local (`pageSize`, +`pageSize` por click) sobre el
51769
- * cache del stream; el tope de fetch es `maxLoad`.
51770
- * - **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.
51771
51956
  *
51772
- * NO renderiza ion-content — vive dentro de val-page-wrapper. `Router` y
51773
- * `ActivatedRoute` se inyectan `{ optional: true }` funciona sin router
51774
- * (embebida / Storybook). El forward-compat icon resolver lee `notif.payload.icon`
51775
- * si existe (`{ type, value, color? }`), cae a un mapa por `notif.type`, luego a
51776
- * `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`).
51777
51961
  */
51778
- class NotificationsViewComponent {
51962
+ class PermissionsViewComponent {
51779
51963
  /** Namespace i18n resuelto (capturado para llamadas no-reactivas). */
51780
51964
  get ns() {
51781
51965
  return this.resolvedConfig().i18nNamespace;
51782
51966
  }
51783
- /** Paso de paginación soft (revela +N por "cargar más"). */
51784
- get pageStep() {
51785
- return this.resolvedConfig().pageSize;
51786
- }
51787
51967
  constructor() {
51788
- this.nav = inject(NavigationService);
51789
51968
  this.i18n = inject(I18nService);
51790
- this.notifs = inject(NotificationsService);
51791
- this.action = inject(NotificationActionService);
51792
- this.toast = inject(ToastService);
51793
- this.errors = inject(ValtechErrorService);
51969
+ this.orgService = inject(OrgService);
51970
+ this.catalog = inject(PermissionCatalogService);
51794
51971
  this.auth = inject(AuthService);
51795
- this.pageRefresh = inject(PageRefreshService);
51796
- // `ActivatedRoute` opcional → la vista funciona embebida / en Storybook sin
51797
- // router (solo lo usa para leer el route data de config como fallback). La
51798
- // navegación va por `NavigationService` (wrapper opcional sobre `Router`).
51972
+ this.nav = inject(NavigationService);
51799
51973
  this.route = inject(ActivatedRoute, { optional: true });
51800
51974
  this.resolvedConfig = computed(() => {
51801
- const fromRoute = (this.route?.snapshot.data['notificationsConfig'] ?? {});
51975
+ const fromRoute = (this.route?.snapshot.data['permissionsConfig'] ?? {});
51802
51976
  const merged = { ...fromRoute, ...(this.config ?? {}) };
51803
51977
  return {
51804
- showPushOffCta: merged.showPushOffCta ?? true,
51805
- enableMarkAll: merged.enableMarkAll ?? true,
51806
- settingsNotificationsPath: merged.settingsNotificationsPath ?? DEFAULT_SETTINGS_PATH,
51978
+ showSystemRoles: merged.showSystemRoles ?? true,
51979
+ showCustomRoles: merged.showCustomRoles ?? true,
51807
51980
  i18nNamespace: merged.i18nNamespace ?? DEFAULT_NAMESPACE,
51808
- pageSize: merged.pageSize ?? DEFAULT_PAGE_SIZE,
51809
- maxLoad: merged.maxLoad ?? DEFAULT_MAX_LOAD,
51810
- onNotificationOpen: merged.onNotificationOpen,
51811
- };
51812
- });
51813
- this._expanded = signal(new Set());
51814
- this._pageSize = signal(this.resolvedConfig().pageSize);
51815
- this._bulkBusy = signal(false);
51816
- this._loadingMore = signal(false);
51817
- /**
51818
- * Push activado = permiso del navegador en `granted`. Si NO está activado,
51819
- * el user puede leer el inbox (Firestore) pero NO recibirá push en su
51820
- * dispositivo → mostramos una card que lo invita a ir a configuración.
51821
- * Se evalúa en cada montaje de la vista.
51822
- */
51823
- this.pushEnabled = signal(this.auth.getNotificationPermissionState() === 'granted');
51824
- /**
51825
- * Stream firestore real-time vía el helper estándar `createRefreshableStream`
51826
- * (modo real-time: factory devuelve un listener vivo `notifs.getAll(maxLoad)`).
51827
- *
51828
- * El helper gatea la primera suscripción por `firebaseAuthReady`, aplica un
51829
- * `retry` corto con backoff y, tras agotar reintentos, emite el fallback (`[]`)
51830
- * y marca `error` — estado de error en UI, no skeleton infinito. `reload()`
51831
- * re-suscribe el stream completo (lo usa el pull-to-refresh).
51832
- */
51833
- this._stream = createRefreshableStream(() => this.notifs.getAll(this.resolvedConfig().maxLoad), {
51834
- fallback: [],
51835
- // `notifs.getAll()` YA gatea internamente por `firebaseAuthReady`. Si el
51836
- // stream también gateara, serían DOS esperas de auth apiladas.
51837
- gateOnFirebaseAuth: false,
51838
- // Cold start de PWA en iOS puede pasar de 8s en redes lentas; con 15s
51839
- // evitamos el falso estado de error.
51840
- firstEmitTimeoutMs: 15000,
51841
- });
51842
- /** True si el stream falló tras agotar reintentos → muestra estado de error. */
51843
- this.streamError = this._stream.error;
51844
- this.loading = computed(() => this._stream.data() === null);
51845
- /**
51846
- * Notifs reales mostradas. Filtramos docs sin título Y sin body — son ruido
51847
- * (típicamente sentinel/system writes pre-schema con title/body opcionales).
51848
- */
51849
- this.allNotifs = computed(() => (this._stream.data() ?? []).filter(n => !!(n.title && n.title.trim()) || !!(n.body && n.body.trim())));
51850
- this.hasMore = computed(() => this.allNotifs().length > this._pageSize());
51851
- this.showMarkAll = computed(() => this.resolvedConfig().enableMarkAll && !this.loading() && this.allNotifs().some(n => !n.isRead));
51852
- /** Sub-conjunto paginado, agrupado por período. */
51853
- this.groups = computed(() => {
51854
- const items = this.allNotifs().slice(0, this._pageSize());
51855
- const now = new Date();
51856
- const buckets = {
51857
- today: [],
51858
- thisMonth: [],
51859
- earlier: [],
51860
51981
  };
51861
- for (const n of items) {
51862
- buckets[this.bucketOf(n, now)].push(n);
51863
- }
51864
- const out = [];
51865
- if (buckets.today.length) {
51866
- out.push({ key: 'today', label: this.tt('groupToday'), items: buckets.today });
51867
- }
51868
- if (buckets.thisMonth.length) {
51869
- out.push({
51870
- key: 'thisMonth',
51871
- label: this.tt('groupThisMonth'),
51872
- items: buckets.thisMonth,
51873
- });
51874
- }
51875
- if (buckets.earlier.length) {
51876
- out.push({ key: 'earlier', label: this.tt('groupEarlier'), items: buckets.earlier });
51877
- }
51878
- return out;
51879
51982
  });
51880
- this.pageTitle = computed(() => this.tt('pageTitle'));
51881
- this.pageDescription = computed(() => this.tt('pageDescription'));
51882
- // CTA "activa las notificaciones push" — visible cuando pushEnabled() es false.
51883
- this.ctaTitle = computed(() => this.tt('pushOffTitle'));
51884
- this.ctaHint = computed(() => this.tt('pushOffHint'));
51885
- this.ctaButtonProps = computed(() => ({
51886
- text: this.tt('pushOffCta'),
51887
- color: 'dark',
51888
- fill: 'outline',
51889
- size: 'small',
51890
- shape: 'round',
51891
- type: 'button',
51892
- }));
51893
- /**
51894
- * Estado de error de carga: convierte el error del stream en
51895
- * `EmptyStateMetadata` via el helper canónico `createErrorStateProps`.
51896
- * El helper decide variante (`offline` vs `error`) por `isNetwork`.
51897
- * Null cuando el stream no ha fallado.
51898
- */
51899
- this.errorState = computed(() => {
51900
- this.i18n.lang();
51901
- const err = this.streamError();
51902
- if (!err)
51903
- return null;
51904
- return createErrorStateProps(err, {
51905
- title: {
51906
- offline: this.tt('offlineTitle'),
51907
- error: this.tt('loadErrorTitle'),
51908
- },
51909
- description: {
51910
- offline: this.tt('offlineHint'),
51911
- error: interpretError(err).message || this.tt('loadErrorHint'),
51912
- },
51913
- retryLabel: this.tt('retryCta'),
51914
- onRetry: () => this._stream.reload(),
51915
- retrying: this.loading(),
51916
- });
51917
- });
51918
- /**
51919
- * Estado vacío (lista real = 0 notificaciones, sin error de carga).
51920
- * Se construye manualmente con `variant: 'empty'` — sin helper de error.
51921
- */
51922
- this.emptyStateProps = computed(() => {
51923
- this.i18n.lang();
51924
- return {
51925
- variant: 'empty',
51926
- icon: 'notifications-off-outline',
51927
- title: this.tt('empty'),
51928
- description: this.tt('emptyHint'),
51929
- action: {
51930
- label: this.tt('emptyCta'),
51931
- handler: () => this.onGoToSettings(),
51932
- },
51933
- };
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 ?? '';
51934
51991
  });
51935
- this.seeMore = computed(() => this.tt('seeMore'));
51936
- this.seeLess = computed(() => this.tt('seeLess'));
51937
- this.markAllButtonProps = computed(() => ({
51938
- text: this.tt('markAll'),
51992
+ this.retryButtonProps = computed(() => ({
51993
+ text: this.tt('retry'),
51939
51994
  color: 'dark',
51940
51995
  fill: 'outline',
51941
- size: 'small',
51942
- shape: 'round',
51943
- type: 'button',
51944
- icon: { name: 'checkmark-done-outline', slot: 'start' },
51945
- state: this._bulkBusy() ? ComponentStates.WORKING : ComponentStates.ENABLED,
51946
- }));
51947
- this.loadMoreButtonProps = computed(() => ({
51948
- text: this.tt('loadMore'),
51949
- color: 'dark',
51950
- fill: 'clear',
51951
51996
  size: 'default',
51952
- shape: 'round',
51953
51997
  type: 'button',
51954
- state: this._loadingMore() ? ComponentStates.WORKING : ComponentStates.ENABLED,
51998
+ state: this.loading() ? ComponentStates.DISABLED : ComponentStates.ENABLED,
51955
51999
  }));
51956
52000
  const ns = this.ns;
51957
52001
  if (!this.i18n.hasNamespace(ns)) {
51958
- this.i18n.registerContent(ns, NOTIFICATIONS_VIEW_I18N);
52002
+ this.i18n.registerContent(ns, PERMISSIONS_VIEW_I18N);
51959
52003
  }
51960
52004
  this.nav.setBackHeader('pageTitle', ns, { withMenu: true });
51961
- // Re-aplicar header en cambio de idioma.
51962
- effect(() => {
51963
- this.i18n.lang();
51964
- this.nav.setBackHeader('pageTitle', this.ns, { withMenu: true });
51965
- }, { allowSignalWrites: true });
51966
- }
51967
- /**
51968
- * Registra el pull-to-refresh estándar. Usamos `ngOnInit`/`ngOnDestroy`
51969
- * (NO `ionViewWillEnter`) porque `val-page-wrapper` usa `<router-outlet>`
51970
- * plano — los hooks `ionView*` solo disparan dentro de `<ion-router-outlet>`.
51971
- */
51972
- ngOnInit() {
51973
- this.pageRefresh.register(() => this.reload());
51974
- }
51975
- /** Quita el handler al destruir la vista — el refresher desaparece. */
51976
- ngOnDestroy() {
51977
- this.pageRefresh.unregister();
51978
- }
51979
- /**
51980
- * Refresh manual: re-suscribe el stream Firestore (listener nuevo) vía el
51981
- * helper. Resuelve tras un margen breve — el stream es real-time y re-emite
51982
- * en milisegundos; el delay sólo mantiene el spinner visible lo justo.
51983
- */
51984
- async reload() {
51985
- this._stream.reload();
51986
- await new Promise(r => setTimeout(r, 700));
51987
- }
51988
- // ---------------------------------------------------------------------------
51989
- // Per-item helpers (puros — usables desde template).
51990
- // ---------------------------------------------------------------------------
51991
- openButtonProps() {
51992
- return {
51993
- text: this.tt('open'),
51994
- color: 'dark',
51995
- fill: 'clear',
51996
- size: 'small',
51997
- shape: 'round',
51998
- type: 'button',
51999
- icon: { name: 'open-outline', slot: 'end' },
52000
- };
52001
- }
52002
- isExpanded(id) {
52003
- return this._expanded().has(id);
52004
- }
52005
- toggleExpand(id) {
52006
- const next = new Set(this._expanded());
52007
- if (next.has(id))
52008
- next.delete(id);
52009
- else
52010
- next.add(id);
52011
- this._expanded.set(next);
52005
+ this.load();
52006
+ connectPageRefresh(() => this.load());
52012
52007
  }
52013
- isLong(body) {
52014
- if (!body)
52015
- return false;
52016
- 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
+ });
52017
52037
  }
52018
- /**
52019
- * Icon resolver con orden de fallback:
52020
- * 1. notif.payload.icon (forward-compat, sin breaking change en schema)
52021
- * 2. mapa por notif.type
52022
- * 3. default notifications-outline
52023
- */
52024
- resolveIcon(n) {
52025
- const hint = n.payload?.['icon'];
52026
- if (hint?.value) {
52027
- if (hint.type === 'image' || hint.type === 'avatar') {
52028
- 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;
52029
52052
  }
52030
- // Ignoramos hint.color — todos los iconos van en dark por convención UX.
52031
- return { kind: 'ionicon', name: hint.value, color: 'dark' };
52032
- }
52033
- const t = n.type;
52034
- const name = (() => {
52035
- switch (t) {
52036
- case 'approval':
52037
- return 'checkmark-circle-outline';
52038
- case 'chat':
52039
- case 'chatbot':
52040
- return 'chatbubble-outline';
52041
- case 'comment':
52042
- return 'chatbox-ellipses-outline';
52043
- case 'reminder':
52044
- return 'alarm-outline';
52045
- case 'system':
52046
- return 'cog-outline';
52047
- case 'user':
52048
- case 'mention':
52049
- return 'person-outline';
52050
- case 'document':
52051
- return 'document-text-outline';
52052
- default:
52053
- 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);
52054
52068
  }
52055
- })();
52056
- return { kind: 'ionicon', name, color: 'dark' };
52057
- }
52058
- ioniconName(n) {
52059
- const r = this.resolveIcon(n);
52060
- return r.kind === 'ionicon' ? r.name : '';
52061
- }
52062
- ioniconColor(n) {
52063
- const r = this.resolveIcon(n);
52064
- return r.kind === 'ionicon' ? r.color : undefined;
52065
- }
52066
- imageSrc(n) {
52067
- const r = this.resolveIcon(n);
52068
- return r.kind === 'image' || r.kind === 'avatar' ? r.src : '';
52069
- }
52070
- ionColor(token) {
52071
- if (!token)
52072
- return 'var(--ion-color-primary)';
52073
- return `var(--ion-color-${token})`;
52074
- }
52075
- relativeTime(n) {
52076
- const d = n.createdAt instanceof Date ? n.createdAt : null;
52077
- if (!d)
52078
- return '';
52079
- const diffMs = Date.now() - d.getTime();
52080
- const min = Math.floor(diffMs / 60000);
52081
- if (min < 1)
52082
- return this.tt('timeNow');
52083
- if (min < 60)
52084
- return this.tt('timeMinutes').replace('{n}', String(min));
52085
- const h = Math.floor(min / 60);
52086
- if (h < 24)
52087
- return this.tt('timeHours').replace('{n}', String(h));
52088
- const days = Math.floor(h / 24);
52089
- return this.tt('timeDays').replace('{n}', String(days));
52090
- }
52091
- /** Devuelve label "Desde {appName}" si la notif viene de otra app, sino null. */
52092
- showCrossAppBadge(n) {
52093
- const currentApp = 'showcase';
52094
- if (!n.appId || n.appId === currentApp)
52095
- return null;
52096
- return this.tt('crossApp').replace('{app}', n.appId);
52097
- }
52098
- // ---------------------------------------------------------------------------
52099
- // Actions
52100
- // ---------------------------------------------------------------------------
52101
- async onOpen(n) {
52102
- try {
52103
- const result = await this.action.open(n);
52104
- this.resolvedConfig().onNotificationOpen?.(n, result);
52105
52069
  }
52106
- catch (err) {
52107
- this.errors.handle(err, {
52108
- context: 'notifications.open',
52109
- fallbackKey: 'openError',
52110
- i18nNamespace: this.ns,
52111
- });
52070
+ const groups = [];
52071
+ if (appPerms.length) {
52072
+ groups.push({ key: 'app', label: this.tt('groupApp'), perms: appPerms });
52112
52073
  }
52113
- }
52114
- async onMarkAll() {
52115
- if (this._bulkBusy())
52116
- return;
52117
- this._bulkBusy.set(true);
52118
- try {
52119
- await this.notifs.markAllAsRead();
52120
- this.toast.show({
52121
- message: this.tt('markedAllSuccess'),
52122
- color: 'dark',
52123
- duration: 2500,
52124
- position: 'top',
52074
+ if (orgPerms.length) {
52075
+ groups.push({
52076
+ key: 'org',
52077
+ label: this.tt('groupOrg'),
52078
+ perms: orgPerms,
52125
52079
  });
52126
52080
  }
52127
- catch (err) {
52128
- this.errors.handle(err, {
52129
- context: 'notifications.markAll',
52130
- fallbackKey: 'markedAllError',
52131
- 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,
52132
52086
  });
52133
52087
  }
52134
- finally {
52135
- this._bulkBusy.set(false);
52136
- }
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
+ };
52137
52096
  }
52138
- onLoadMore() {
52139
- if (this._loadingMore())
52140
- return;
52141
- this._loadingMore.set(true);
52142
- // Slice viene del cache local — no hay fetch real, pero un breve spinner
52143
- // confirma al user que el click registró y suaviza la aparición de items.
52144
- setTimeout(() => {
52145
- this._pageSize.update(n => n + this.pageStep);
52146
- this._loadingMore.set(false);
52147
- }, 250);
52097
+ tt(key) {
52098
+ return this.i18n.t(key, this.ns);
52148
52099
  }
52149
- onGoToSettings() {
52150
- 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;
52151
52104
  }
52152
- // ---------------------------------------------------------------------------
52153
- // Grouping helpers
52154
- // ---------------------------------------------------------------------------
52155
- bucketOf(n, now) {
52156
- const d = n.createdAt instanceof Date ? n.createdAt : null;
52157
- if (!d)
52158
- return 'earlier';
52159
- if (d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate()) {
52160
- return 'today';
52161
- }
52162
- if (d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth()) {
52163
- return 'thisMonth';
52164
- }
52165
- 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);
52166
52117
  }
52167
- tt(key) {
52168
- 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}`;
52169
52126
  }
52170
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: NotificationsViewComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
52171
- 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: `
52172
52129
  <div class="page">
52173
52130
  <header class="page-header">
52174
- <val-display [props]="{ size: 'small', color: 'dark', content: pageTitle() }" />
52131
+ <val-display [props]="{ size: 'small', color: 'dark', content: tt('pageTitle') }" />
52175
52132
  <val-title
52176
52133
  [props]="{
52177
52134
  size: 'large',
52178
52135
  color: 'dark',
52179
52136
  bold: false,
52180
- content: pageDescription(),
52137
+ content: tt('pageDescription'),
52181
52138
  }"
52182
52139
  />
52183
- @if (showMarkAll()) {
52184
- <div class="row-actions">
52185
- <val-button [props]="markAllButtonProps()" (click)="onMarkAll()" />
52186
- </div>
52187
- }
52188
52140
  </header>
52189
52141
 
52190
- @if (resolvedConfig().showPushOffCta && !pushEnabled()) {
52191
- <section class="notif-cta">
52192
- <ion-icon class="notif-cta__icon" name="notifications-off-outline" aria-hidden="true" />
52193
- <div class="notif-cta__body">
52194
- <val-text
52195
- [props]="{
52196
- content: ctaTitle(),
52197
- size: 'medium',
52198
- color: 'dark',
52199
- bold: true,
52200
- }"
52201
- />
52202
- <val-text
52203
- [props]="{
52204
- content: ctaHint(),
52205
- size: 'small',
52206
- color: 'medium',
52207
- bold: false,
52208
- }"
52209
- />
52210
- <div class="row-actions">
52211
- <val-button [props]="ctaButtonProps()" (click)="onGoToSettings()" />
52212
- </div>
52213
- </div>
52214
- </section>
52215
- }
52216
-
52217
52142
  @if (loading()) {
52218
- <val-skeleton-layout [props]="{ preset: 'list', rows: 6 }" />
52219
- } @else if (errorState()) {
52220
- <val-empty-state [props]="errorState()!" />
52221
- } @else if (allNotifs().length === 0) {
52222
- <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>
52223
52158
  } @else {
52224
- @for (group of groups(); track group.key) {
52225
- <section class="group">
52226
- <val-title
52227
- [props]="{
52228
- size: 'medium',
52229
- color: 'dark',
52230
- bold: true,
52231
- content: group.label,
52232
- }"
52233
- />
52234
- <ul class="notif-list">
52235
- @for (n of group.items; track n.id) {
52236
- <li class="notif" [class.notif--unread]="!n.isRead">
52237
- <div class="notif__icon">
52238
- @switch (resolveIcon(n).kind) {
52239
- @case ('ionicon') {
52240
- <ion-icon
52241
- [name]="ioniconName(n)"
52242
- [style.color]="ionColor(ioniconColor(n))"
52243
- aria-hidden="true"
52244
- />
52245
- }
52246
- @case ('image') {
52247
- <img [src]="imageSrc(n)" alt="" loading="lazy" />
52248
- }
52249
- @case ('avatar') {
52250
- <img class="notif__avatar" [src]="imageSrc(n)" alt="" loading="lazy" />
52251
- }
52252
- }
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
+ />
52253
52191
  </div>
52254
-
52255
- <div class="notif__body">
52256
- <div class="notif__head">
52257
- <val-text
52258
- [props]="{
52259
- content: n.title || '',
52260
- size: 'medium',
52261
- color: 'dark',
52262
- bold: true,
52263
- }"
52264
- />
52265
- <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>
52266
52198
  </div>
52267
- @if (n.body) {
52268
- <p class="notif__text" [class.notif__text--clamped]="!isExpanded(n.id) && isLong(n.body)">
52269
- {{ n.body }}
52270
- </p>
52271
- @if (isLong(n.body)) {
52272
- <button class="notif__expand" type="button" (click)="toggleExpand(n.id)">
52273
- {{ isExpanded(n.id) ? seeLess() : seeMore() }}
52274
- </button>
52275
- }
52276
- }
52277
- @if (showCrossAppBadge(n); as appName) {
52278
- <span class="notif__app">{{ appName }}</span>
52279
- }
52280
- @if (n.actionRoute) {
52281
- <div class="row-actions row-actions--end">
52282
- <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>
52283
52208
  </div>
52284
52209
  }
52285
- </div>
52286
- </li>
52210
+ }
52211
+ </div>
52287
52212
  }
52288
- </ul>
52213
+ </div>
52289
52214
  </section>
52290
52215
  }
52291
52216
 
52292
- @if (hasMore()) {
52293
- <div class="row-actions row-actions--center">
52294
- <val-button [props]="loadMoreButtonProps()" (click)="onLoadMore()" />
52295
- </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>
52296
52277
  }
52297
52278
  }
52298
52279
  </div>
52299
- `, 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"] }] }); }
52300
52281
  }
52301
- 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: [{
52302
52283
  type: Component,
52303
- args: [{ selector: 'val-notifications-view', standalone: true, imports: [
52304
- IonIcon,
52305
- DisplayComponent,
52306
- TitleComponent,
52307
- TextComponent,
52308
- ButtonComponent,
52309
- SkeletonLayoutComponent,
52310
- EmptyStateComponent,
52311
- ], template: `
52284
+ args: [{ selector: 'val-permissions-view', standalone: true, imports: [IonSpinner, DisplayComponent, PillComponent, TitleComponent, TextComponent, ButtonComponent], template: `
52312
52285
  <div class="page">
52313
52286
  <header class="page-header">
52314
- <val-display [props]="{ size: 'small', color: 'dark', content: pageTitle() }" />
52287
+ <val-display [props]="{ size: 'small', color: 'dark', content: tt('pageTitle') }" />
52315
52288
  <val-title
52316
52289
  [props]="{
52317
52290
  size: 'large',
52318
52291
  color: 'dark',
52319
52292
  bold: false,
52320
- content: pageDescription(),
52293
+ content: tt('pageDescription'),
52321
52294
  }"
52322
52295
  />
52323
- @if (showMarkAll()) {
52324
- <div class="row-actions">
52325
- <val-button [props]="markAllButtonProps()" (click)="onMarkAll()" />
52326
- </div>
52327
- }
52328
52296
  </header>
52329
52297
 
52330
- @if (resolvedConfig().showPushOffCta && !pushEnabled()) {
52331
- <section class="notif-cta">
52332
- <ion-icon class="notif-cta__icon" name="notifications-off-outline" aria-hidden="true" />
52333
- <div class="notif-cta__body">
52334
- <val-text
52335
- [props]="{
52336
- content: ctaTitle(),
52337
- size: 'medium',
52338
- color: 'dark',
52339
- bold: true,
52340
- }"
52341
- />
52342
- <val-text
52343
- [props]="{
52344
- content: ctaHint(),
52345
- size: 'small',
52346
- color: 'medium',
52347
- bold: false,
52348
- }"
52349
- />
52350
- <div class="row-actions">
52351
- <val-button [props]="ctaButtonProps()" (click)="onGoToSettings()" />
52352
- </div>
52353
- </div>
52354
- </section>
52355
- }
52356
-
52357
52298
  @if (loading()) {
52358
- <val-skeleton-layout [props]="{ preset: 'list', rows: 6 }" />
52359
- } @else if (errorState()) {
52360
- <val-empty-state [props]="errorState()!" />
52361
- } @else if (allNotifs().length === 0) {
52362
- <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>
52363
52314
  } @else {
52364
- @for (group of groups(); track group.key) {
52365
- <section class="group">
52366
- <val-title
52367
- [props]="{
52368
- size: 'medium',
52369
- color: 'dark',
52370
- bold: true,
52371
- content: group.label,
52372
- }"
52373
- />
52374
- <ul class="notif-list">
52375
- @for (n of group.items; track n.id) {
52376
- <li class="notif" [class.notif--unread]="!n.isRead">
52377
- <div class="notif__icon">
52378
- @switch (resolveIcon(n).kind) {
52379
- @case ('ionicon') {
52380
- <ion-icon
52381
- [name]="ioniconName(n)"
52382
- [style.color]="ionColor(ioniconColor(n))"
52383
- aria-hidden="true"
52384
- />
52385
- }
52386
- @case ('image') {
52387
- <img [src]="imageSrc(n)" alt="" loading="lazy" />
52388
- }
52389
- @case ('avatar') {
52390
- <img class="notif__avatar" [src]="imageSrc(n)" alt="" loading="lazy" />
52391
- }
52392
- }
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
+ />
52393
52347
  </div>
52394
-
52395
- <div class="notif__body">
52396
- <div class="notif__head">
52397
- <val-text
52398
- [props]="{
52399
- content: n.title || '',
52400
- size: 'medium',
52401
- color: 'dark',
52402
- bold: true,
52403
- }"
52404
- />
52405
- <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>
52406
52354
  </div>
52407
- @if (n.body) {
52408
- <p class="notif__text" [class.notif__text--clamped]="!isExpanded(n.id) && isLong(n.body)">
52409
- {{ n.body }}
52410
- </p>
52411
- @if (isLong(n.body)) {
52412
- <button class="notif__expand" type="button" (click)="toggleExpand(n.id)">
52413
- {{ isExpanded(n.id) ? seeLess() : seeMore() }}
52414
- </button>
52415
- }
52416
- }
52417
- @if (showCrossAppBadge(n); as appName) {
52418
- <span class="notif__app">{{ appName }}</span>
52419
- }
52420
- @if (n.actionRoute) {
52421
- <div class="row-actions row-actions--end">
52422
- <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>
52423
52364
  </div>
52424
52365
  }
52425
- </div>
52426
- </li>
52366
+ }
52367
+ </div>
52427
52368
  }
52428
- </ul>
52369
+ </div>
52429
52370
  </section>
52430
52371
  }
52431
52372
 
52432
- @if (hasMore()) {
52433
- <div class="row-actions row-actions--center">
52434
- <val-button [props]="loadMoreButtonProps()" (click)="onLoadMore()" />
52435
- </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>
52436
52433
  }
52437
52434
  }
52438
52435
  </div>
52439
- `, 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"] }]
52440
52437
  }], ctorParameters: () => [], propDecorators: { config: [{
52441
52438
  type: Input
52442
52439
  }] } });
52443
52440
 
52444
52441
  /**
52445
- * Helper para montar el inbox de notificaciones (`val-notifications-view`) como
52446
- * ruta en una app del factory. El `config` se pasa por route `data`
52447
- * (`notificationsConfig`) y el componente lo lee como fallback de su
52448
- * `@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`.
52449
52445
  *
52450
52446
  * @example
52451
52447
  * ```ts
52452
- * // app.routes.ts
52453
- * export const appRoutes: Routes = [
52454
- * ...provideValtechNotificationsRoutes(), // → /app/notifications
52455
- * { path: 'settings', loadChildren: () => ... },
52448
+ * // settings.routes.ts
52449
+ * export const settingsRoutes: Routes = [
52450
+ * ...provideValtechPermissionsRoutes(), // → /app/settings/permissions
52451
+ * { path: 'preferences', loadComponent: () => ... },
52456
52452
  * ];
52457
52453
  *
52458
52454
  * // con config acotada:
52459
- * ...provideValtechNotificationsRoutes({ config: { enableMarkAll: false } }),
52460
- * ...provideValtechNotificationsRoutes({
52461
- * config: { settingsNotificationsPath: '/app/preferences/push' },
52462
- * }),
52455
+ * ...provideValtechPermissionsRoutes({ config: { showCustomRoles: false } }),
52456
+ * ...provideValtechPermissionsRoutes({ path: 'roles' }),
52463
52457
  * ```
52464
52458
  */
52465
- function provideValtechNotificationsRoutes(opts) {
52459
+ function provideValtechPermissionsRoutes(opts) {
52466
52460
  return [
52467
52461
  {
52468
- path: opts?.path ?? 'notifications',
52469
- component: NotificationsViewComponent,
52470
- data: { notificationsConfig: opts?.config },
52462
+ path: opts?.path ?? 'permissions',
52463
+ component: PermissionsViewComponent,
52464
+ data: { permissionsConfig: opts?.config },
52471
52465
  },
52472
52466
  ];
52473
52467
  }