valtech-components 2.0.999 → 2.0.1000
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.
- package/esm2022/lib/components/organisms/permissions-view/permissions-view.component.mjs +9 -14
- package/esm2022/lib/components/organisms/permissions-view/permissions-view.i18n.mjs +1 -3
- package/esm2022/lib/components/organisms/settings-hub/settings.routes.mjs +26 -8
- package/esm2022/lib/services/org/permission-catalog.service.mjs +1 -1
- package/esm2022/lib/version.mjs +2 -2
- package/fesm2022/valtech-components.mjs +1990 -1981
- package/fesm2022/valtech-components.mjs.map +1 -1
- package/lib/components/organisms/settings-hub/settings.routes.d.ts +31 -6
- package/lib/services/org/permission-catalog.service.d.ts +1 -1
- package/lib/version.d.ts +1 -1
- package/package.json +1 -1
|
@@ -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, race, timer,
|
|
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';
|
|
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, alarmOutline, chatboxEllipsesOutline, checkmarkDoneOutline, cogOutline,
|
|
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';
|
|
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.
|
|
57
|
+
const VERSION = '2.0.1000';
|
|
58
58
|
|
|
59
59
|
// Control de estado de refresco (singleton a nivel de módulo)
|
|
60
60
|
let isRefreshing = false;
|
|
@@ -50044,702 +50044,488 @@ function provideValtechOrganizationRoutes(opts) {
|
|
|
50044
50044
|
}
|
|
50045
50045
|
|
|
50046
50046
|
/**
|
|
50047
|
-
*
|
|
50048
|
-
*
|
|
50049
|
-
*
|
|
50050
|
-
*
|
|
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
|
-
* ```
|
|
50075
|
-
*/
|
|
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.
|
|
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.
|
|
50232
50051
|
*
|
|
50233
|
-
* Portado del override `Notifications` (showcase) bajo el proceso de
|
|
50052
|
+
* Portado del override `Settings.Notifications` (showcase) bajo el proceso de
|
|
50053
|
+
* ADR-021.
|
|
50234
50054
|
*/
|
|
50235
|
-
const
|
|
50055
|
+
const NOTIFICATION_PREFERENCES_VIEW_I18N = {
|
|
50236
50056
|
es: {
|
|
50237
50057
|
pageTitle: 'Notificaciones',
|
|
50238
|
-
pageDescription: '
|
|
50239
|
-
|
|
50240
|
-
|
|
50241
|
-
|
|
50242
|
-
|
|
50243
|
-
|
|
50244
|
-
|
|
50245
|
-
|
|
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.',
|
|
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.',
|
|
50249
50066
|
retryCta: 'Reintentar',
|
|
50250
|
-
|
|
50251
|
-
|
|
50252
|
-
|
|
50253
|
-
|
|
50254
|
-
|
|
50255
|
-
|
|
50256
|
-
|
|
50257
|
-
|
|
50258
|
-
|
|
50259
|
-
|
|
50260
|
-
|
|
50261
|
-
|
|
50262
|
-
|
|
50263
|
-
|
|
50264
|
-
|
|
50265
|
-
|
|
50266
|
-
|
|
50267
|
-
|
|
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',
|
|
50268
50087
|
},
|
|
50269
50088
|
en: {
|
|
50270
50089
|
pageTitle: 'Notifications',
|
|
50271
|
-
pageDescription: '
|
|
50272
|
-
|
|
50273
|
-
|
|
50274
|
-
|
|
50275
|
-
|
|
50276
|
-
|
|
50277
|
-
|
|
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.',
|
|
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.",
|
|
50282
50097
|
retryCta: 'Retry',
|
|
50283
|
-
|
|
50284
|
-
|
|
50285
|
-
|
|
50286
|
-
|
|
50287
|
-
|
|
50288
|
-
|
|
50289
|
-
|
|
50290
|
-
|
|
50291
|
-
|
|
50292
|
-
|
|
50293
|
-
|
|
50294
|
-
|
|
50295
|
-
|
|
50296
|
-
|
|
50297
|
-
|
|
50298
|
-
|
|
50299
|
-
markedAllError: 'Could not mark all as read.',
|
|
50300
|
-
openError: 'Could not open notification.',
|
|
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',
|
|
50301
50114
|
},
|
|
50302
50115
|
};
|
|
50303
50116
|
|
|
50304
50117
|
addIcons({
|
|
50305
|
-
|
|
50306
|
-
|
|
50307
|
-
|
|
50308
|
-
|
|
50309
|
-
checkmarkDoneOutline,
|
|
50310
|
-
cloudOfflineOutline,
|
|
50311
|
-
cogOutline,
|
|
50312
|
-
documentTextOutline,
|
|
50118
|
+
alertCircleOutline,
|
|
50119
|
+
chevronDownOutline,
|
|
50120
|
+
chevronUpOutline,
|
|
50121
|
+
closeCircleOutline,
|
|
50313
50122
|
notificationsOffOutline,
|
|
50314
50123
|
notificationsOutline,
|
|
50315
|
-
|
|
50316
|
-
personOutline,
|
|
50124
|
+
refreshOutline,
|
|
50317
50125
|
});
|
|
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;
|
|
50126
|
+
const DEFAULT_NAMESPACE$2 = 'Settings.Notifications';
|
|
50322
50127
|
/**
|
|
50323
|
-
* `val-
|
|
50324
|
-
* (organism).
|
|
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.
|
|
50325
50131
|
*
|
|
50326
|
-
*
|
|
50327
|
-
*
|
|
50132
|
+
* Separada de `val-notifications-view` (el inbox / feed). Aquí el user **activa o
|
|
50133
|
+
* desactiva** las notificaciones push en este dispositivo.
|
|
50328
50134
|
*
|
|
50329
|
-
*
|
|
50330
|
-
*
|
|
50331
|
-
*
|
|
50332
|
-
*
|
|
50333
|
-
*
|
|
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`).
|
|
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.
|
|
50342
50140
|
*
|
|
50343
|
-
* NO renderiza ion-content — vive dentro de val-page-wrapper. `
|
|
50344
|
-
*
|
|
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`.
|
|
50141
|
+
* NO renderiza ion-content — vive dentro de val-page-wrapper. `ActivatedRoute` se
|
|
50142
|
+
* inyecta `{ optional: true }` → funciona sin router (embebida / Storybook).
|
|
50348
50143
|
*/
|
|
50349
|
-
class
|
|
50144
|
+
class NotificationPreferencesViewComponent {
|
|
50350
50145
|
/** Namespace i18n resuelto (capturado para llamadas no-reactivas). */
|
|
50351
50146
|
get ns() {
|
|
50352
50147
|
return this.resolvedConfig().i18nNamespace;
|
|
50353
50148
|
}
|
|
50354
|
-
|
|
50355
|
-
|
|
50356
|
-
|
|
50357
|
-
}
|
|
50149
|
+
// ---------------------------------------------------------------------------
|
|
50150
|
+
// Lifecycle
|
|
50151
|
+
// ---------------------------------------------------------------------------
|
|
50358
50152
|
constructor() {
|
|
50359
50153
|
this.nav = inject(NavigationService);
|
|
50360
50154
|
this.i18n = inject(I18nService);
|
|
50361
|
-
this.notifs = inject(NotificationsService);
|
|
50362
|
-
this.action = inject(NotificationActionService);
|
|
50363
50155
|
this.toast = inject(ToastService);
|
|
50364
|
-
this.errors = inject(ValtechErrorService);
|
|
50365
50156
|
this.auth = inject(AuthService);
|
|
50157
|
+
this.messaging = inject(MessagingService);
|
|
50366
50158
|
this.pageRefresh = inject(PageRefreshService);
|
|
50159
|
+
this.prefs = inject(PreferencesService);
|
|
50367
50160
|
// `ActivatedRoute` opcional → la vista funciona embebida / en Storybook sin
|
|
50368
|
-
// router (solo lo usa para leer el route data de config como fallback).
|
|
50369
|
-
// navegación va por `NavigationService` (wrapper opcional sobre `Router`).
|
|
50161
|
+
// router (solo lo usa para leer el route data de config como fallback).
|
|
50370
50162
|
this.route = inject(ActivatedRoute, { optional: true });
|
|
50371
50163
|
this.resolvedConfig = computed(() => {
|
|
50372
|
-
const fromRoute = (this.route?.snapshot.data['
|
|
50164
|
+
const fromRoute = (this.route?.snapshot.data['notificationPreferencesConfig'] ??
|
|
50165
|
+
{});
|
|
50373
50166
|
const merged = { ...fromRoute, ...(this.config ?? {}) };
|
|
50374
50167
|
return {
|
|
50375
|
-
|
|
50376
|
-
enableMarkAll: merged.enableMarkAll ?? true,
|
|
50377
|
-
settingsNotificationsPath: merged.settingsNotificationsPath ?? DEFAULT_SETTINGS_PATH,
|
|
50168
|
+
showDetails: merged.showDetails ?? true,
|
|
50378
50169
|
i18nNamespace: merged.i18nNamespace ?? DEFAULT_NAMESPACE$2,
|
|
50379
|
-
|
|
50380
|
-
|
|
50381
|
-
onNotificationOpen: merged.onNotificationOpen,
|
|
50170
|
+
onPushEnabled: merged.onPushEnabled,
|
|
50171
|
+
onPushDisabled: merged.onPushDisabled,
|
|
50382
50172
|
};
|
|
50383
50173
|
});
|
|
50384
|
-
|
|
50385
|
-
this.
|
|
50386
|
-
|
|
50387
|
-
this.
|
|
50388
|
-
/**
|
|
50389
|
-
|
|
50390
|
-
|
|
50391
|
-
|
|
50392
|
-
|
|
50393
|
-
|
|
50394
|
-
this.
|
|
50395
|
-
|
|
50396
|
-
|
|
50397
|
-
|
|
50398
|
-
|
|
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).
|
|
50403
|
-
*/
|
|
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,
|
|
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,
|
|
50412
50189
|
});
|
|
50413
|
-
/**
|
|
50414
|
-
this.
|
|
50415
|
-
|
|
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'));
|
|
50416
50201
|
/**
|
|
50417
|
-
*
|
|
50418
|
-
*
|
|
50202
|
+
* Estado efectivo del toggle: "el usuario tiene push activo".
|
|
50203
|
+
*
|
|
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.
|
|
50419
50209
|
*/
|
|
50420
|
-
this.
|
|
50421
|
-
this.
|
|
50422
|
-
|
|
50423
|
-
|
|
50424
|
-
|
|
50425
|
-
|
|
50426
|
-
|
|
50427
|
-
|
|
50428
|
-
|
|
50429
|
-
thisMonth: [],
|
|
50430
|
-
earlier: [],
|
|
50431
|
-
};
|
|
50432
|
-
for (const n of items) {
|
|
50433
|
-
buckets[this.bucketOf(n, now)].push(n);
|
|
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
|
-
});
|
|
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';
|
|
50445
50219
|
}
|
|
50446
|
-
|
|
50447
|
-
|
|
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;
|
|
50240
|
+
});
|
|
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;
|
|
50448
50251
|
}
|
|
50449
|
-
return out;
|
|
50450
50252
|
});
|
|
50451
|
-
this.
|
|
50452
|
-
|
|
50453
|
-
|
|
50454
|
-
|
|
50455
|
-
|
|
50456
|
-
|
|
50457
|
-
|
|
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
|
-
});
|
|
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';
|
|
50488
50260
|
});
|
|
50489
|
-
|
|
50490
|
-
|
|
50491
|
-
|
|
50492
|
-
|
|
50493
|
-
|
|
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
|
-
};
|
|
50261
|
+
this.alertIcon = computed(() => {
|
|
50262
|
+
const s = this.state();
|
|
50263
|
+
if (s === 'unsupported')
|
|
50264
|
+
return 'close-circle-outline';
|
|
50265
|
+
return 'alert-circle-outline';
|
|
50505
50266
|
});
|
|
50506
|
-
this.
|
|
50507
|
-
|
|
50508
|
-
|
|
50509
|
-
|
|
50510
|
-
|
|
50511
|
-
|
|
50512
|
-
|
|
50513
|
-
shape: 'round',
|
|
50514
|
-
type: 'button',
|
|
50515
|
-
icon: { name: 'checkmark-done-outline', slot: 'start' },
|
|
50516
|
-
state: this._bulkBusy() ? ComponentStates.WORKING : ComponentStates.ENABLED,
|
|
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',
|
|
50517
50274
|
}));
|
|
50518
|
-
this.
|
|
50519
|
-
text: this.tt('
|
|
50275
|
+
this.retryButtonProps = computed(() => ({
|
|
50276
|
+
text: this.tt('retryCta'),
|
|
50520
50277
|
color: 'dark',
|
|
50521
|
-
fill: '
|
|
50278
|
+
fill: 'outline',
|
|
50522
50279
|
size: 'default',
|
|
50523
50280
|
shape: 'round',
|
|
50524
50281
|
type: 'button',
|
|
50525
|
-
|
|
50282
|
+
icon: { name: 'refresh-outline', slot: 'start' },
|
|
50283
|
+
state: this._busy() !== null ? ComponentStates.WORKING : ComponentStates.ENABLED,
|
|
50526
50284
|
}));
|
|
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
|
+
});
|
|
50527
50291
|
const ns = this.ns;
|
|
50528
50292
|
if (!this.i18n.hasNamespace(ns)) {
|
|
50529
|
-
this.i18n.registerContent(ns,
|
|
50293
|
+
this.i18n.registerContent(ns, NOTIFICATION_PREFERENCES_VIEW_I18N);
|
|
50530
50294
|
}
|
|
50531
50295
|
this.nav.setBackHeader('pageTitle', ns, { withMenu: true });
|
|
50296
|
+
void this.refreshBrowserState();
|
|
50532
50297
|
// Re-aplicar header en cambio de idioma.
|
|
50533
50298
|
effect(() => {
|
|
50534
50299
|
this.i18n.lang();
|
|
50535
50300
|
this.nav.setBackHeader('pageTitle', this.ns, { withMenu: true });
|
|
50536
50301
|
}, { 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
|
+
}
|
|
50537
50337
|
}
|
|
50538
50338
|
/**
|
|
50539
|
-
* Registra el pull-to-refresh estándar.
|
|
50540
|
-
*
|
|
50541
|
-
*
|
|
50339
|
+
* Registra el pull-to-refresh estándar. Aunque esta vista no muestra una
|
|
50340
|
+
* lista remota, sí 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>`.
|
|
50542
50346
|
*/
|
|
50543
50347
|
ngOnInit() {
|
|
50544
|
-
this.pageRefresh.register(() => this.
|
|
50348
|
+
this.pageRefresh.register(() => this.refreshBrowserState());
|
|
50545
50349
|
}
|
|
50546
|
-
/** Quita el handler al destruir la vista — el refresher desaparece. */
|
|
50547
50350
|
ngOnDestroy() {
|
|
50548
50351
|
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
|
+
}
|
|
50549
50359
|
}
|
|
50550
|
-
/**
|
|
50551
|
-
|
|
50552
|
-
|
|
50553
|
-
|
|
50554
|
-
|
|
50555
|
-
|
|
50556
|
-
|
|
50557
|
-
|
|
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);
|
|
50558
50369
|
}
|
|
50559
50370
|
// ---------------------------------------------------------------------------
|
|
50560
|
-
//
|
|
50371
|
+
// Actions
|
|
50561
50372
|
// ---------------------------------------------------------------------------
|
|
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
|
-
}
|
|
50589
50373
|
/**
|
|
50590
|
-
*
|
|
50591
|
-
*
|
|
50592
|
-
*
|
|
50593
|
-
*
|
|
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.
|
|
50594
50382
|
*/
|
|
50595
|
-
|
|
50596
|
-
|
|
50597
|
-
|
|
50598
|
-
|
|
50599
|
-
|
|
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?.();
|
|
50600
50402
|
}
|
|
50601
|
-
|
|
50602
|
-
|
|
50603
|
-
|
|
50604
|
-
|
|
50605
|
-
|
|
50606
|
-
|
|
50607
|
-
|
|
50608
|
-
|
|
50609
|
-
|
|
50610
|
-
|
|
50611
|
-
|
|
50612
|
-
|
|
50613
|
-
|
|
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';
|
|
50403
|
+
else if (result.status === 'timeout' && result.reloaded) {
|
|
50404
|
+
// La lib disparó el auto-reload — la página está por recargarse.
|
|
50405
|
+
return;
|
|
50406
|
+
}
|
|
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
|
+
});
|
|
50625
50416
|
}
|
|
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);
|
|
50676
50417
|
}
|
|
50677
|
-
catch
|
|
50678
|
-
|
|
50679
|
-
|
|
50680
|
-
|
|
50681
|
-
|
|
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',
|
|
50682
50426
|
});
|
|
50683
50427
|
}
|
|
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
|
+
}
|
|
50684
50433
|
}
|
|
50685
|
-
async
|
|
50686
|
-
|
|
50687
|
-
|
|
50688
|
-
this._bulkBusy.set(true);
|
|
50434
|
+
async onDisable() {
|
|
50435
|
+
this._busy.set('disabling');
|
|
50436
|
+
this._lastFailed.set(false);
|
|
50689
50437
|
try {
|
|
50690
|
-
await this.
|
|
50438
|
+
await this.auth.disableNotifications();
|
|
50439
|
+
if (this.prefs.notificationsMaster()) {
|
|
50440
|
+
await this.prefs.setNotificationsMaster(false);
|
|
50441
|
+
}
|
|
50691
50442
|
this.toast.show({
|
|
50692
|
-
message: this.tt('
|
|
50443
|
+
message: this.tt('disableSuccess'),
|
|
50444
|
+
duration: 2000,
|
|
50693
50445
|
color: 'dark',
|
|
50694
|
-
duration: 2500,
|
|
50695
50446
|
position: 'top',
|
|
50696
50447
|
});
|
|
50448
|
+
this.resolvedConfig().onPushDisabled?.();
|
|
50697
50449
|
}
|
|
50698
|
-
catch
|
|
50699
|
-
this.
|
|
50700
|
-
context: 'notifications.markAll',
|
|
50701
|
-
fallbackKey: 'markedAllError',
|
|
50702
|
-
i18nNamespace: this.ns,
|
|
50703
|
-
});
|
|
50450
|
+
catch {
|
|
50451
|
+
this._lastFailed.set(true);
|
|
50704
50452
|
}
|
|
50705
50453
|
finally {
|
|
50706
|
-
this.
|
|
50454
|
+
await this.refreshBrowserState();
|
|
50455
|
+
this._busy.set(null);
|
|
50707
50456
|
}
|
|
50708
50457
|
}
|
|
50709
|
-
|
|
50710
|
-
|
|
50711
|
-
|
|
50712
|
-
this.
|
|
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);
|
|
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();
|
|
50719
50462
|
}
|
|
50720
|
-
|
|
50721
|
-
this.
|
|
50463
|
+
toggleDetails() {
|
|
50464
|
+
this.showDetails.update(v => !v);
|
|
50722
50465
|
}
|
|
50723
50466
|
// ---------------------------------------------------------------------------
|
|
50724
|
-
//
|
|
50467
|
+
// Privados
|
|
50725
50468
|
// ---------------------------------------------------------------------------
|
|
50726
|
-
|
|
50727
|
-
|
|
50728
|
-
|
|
50729
|
-
|
|
50730
|
-
|
|
50731
|
-
|
|
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
|
+
}
|
|
50732
50500
|
}
|
|
50733
|
-
|
|
50734
|
-
|
|
50501
|
+
catch {
|
|
50502
|
+
// Reparación best-effort: no degradamos el toggle ni mostramos error.
|
|
50735
50503
|
}
|
|
50736
|
-
|
|
50504
|
+
finally {
|
|
50505
|
+
this._repairingToken = false;
|
|
50506
|
+
}
|
|
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;
|
|
50515
|
+
}
|
|
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;
|
|
50737
50523
|
}
|
|
50738
50524
|
tt(key) {
|
|
50739
50525
|
return this.i18n.t(key, this.ns);
|
|
50740
50526
|
}
|
|
50741
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type:
|
|
50742
|
-
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type:
|
|
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: `
|
|
50743
50529
|
<div class="page">
|
|
50744
50530
|
<header class="page-header">
|
|
50745
50531
|
<val-display [props]="{ size: 'small', color: 'dark', content: pageTitle() }" />
|
|
@@ -50751,135 +50537,112 @@ class NotificationsViewComponent {
|
|
|
50751
50537
|
content: pageDescription(),
|
|
50752
50538
|
}"
|
|
50753
50539
|
/>
|
|
50754
|
-
@if (showMarkAll()) {
|
|
50755
|
-
<div class="row-actions">
|
|
50756
|
-
<val-button [props]="markAllButtonProps()" (click)="onMarkAll()" />
|
|
50757
|
-
</div>
|
|
50758
|
-
}
|
|
50759
50540
|
</header>
|
|
50760
50541
|
|
|
50761
|
-
@if (
|
|
50762
|
-
<section class="
|
|
50763
|
-
<
|
|
50764
|
-
|
|
50765
|
-
|
|
50766
|
-
|
|
50767
|
-
|
|
50768
|
-
|
|
50769
|
-
|
|
50770
|
-
|
|
50771
|
-
|
|
50772
|
-
/>
|
|
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">
|
|
50773
50553
|
<val-text
|
|
50774
50554
|
[props]="{
|
|
50775
|
-
content: ctaHint(),
|
|
50776
50555
|
size: 'small',
|
|
50777
50556
|
color: 'medium',
|
|
50778
50557
|
bold: false,
|
|
50558
|
+
content: tt('iosInstallHint'),
|
|
50779
50559
|
}"
|
|
50780
50560
|
/>
|
|
50781
|
-
<
|
|
50782
|
-
<
|
|
50783
|
-
|
|
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>
|
|
50784
50567
|
</div>
|
|
50785
50568
|
</section>
|
|
50786
50569
|
}
|
|
50787
50570
|
|
|
50788
|
-
|
|
50789
|
-
<
|
|
50790
|
-
|
|
50791
|
-
|
|
50792
|
-
|
|
50793
|
-
|
|
50794
|
-
} @else {
|
|
50795
|
-
@for (group of groups(); track group.key) {
|
|
50796
|
-
<section class="group">
|
|
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">
|
|
50797
50577
|
<val-title
|
|
50798
50578
|
[props]="{
|
|
50799
50579
|
size: 'medium',
|
|
50800
50580
|
color: 'dark',
|
|
50801
50581
|
bold: true,
|
|
50802
|
-
content:
|
|
50582
|
+
content: tt('simpleTitle'),
|
|
50803
50583
|
}"
|
|
50804
50584
|
/>
|
|
50805
|
-
<
|
|
50806
|
-
|
|
50807
|
-
|
|
50808
|
-
|
|
50809
|
-
|
|
50810
|
-
|
|
50811
|
-
|
|
50812
|
-
|
|
50813
|
-
|
|
50814
|
-
|
|
50815
|
-
|
|
50816
|
-
|
|
50817
|
-
|
|
50818
|
-
|
|
50819
|
-
|
|
50820
|
-
|
|
50821
|
-
<img class="notif__avatar" [src]="imageSrc(n)" alt="" loading="lazy" />
|
|
50822
|
-
}
|
|
50823
|
-
}
|
|
50824
|
-
</div>
|
|
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>
|
|
50825
50601
|
|
|
50826
|
-
|
|
50827
|
-
|
|
50828
|
-
|
|
50829
|
-
|
|
50830
|
-
|
|
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>
|
|
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>
|
|
50861
50607
|
}
|
|
50862
50608
|
|
|
50863
|
-
@if (
|
|
50864
|
-
<div class="row-actions
|
|
50865
|
-
<val-button [props]="
|
|
50609
|
+
@if (state() === 'error') {
|
|
50610
|
+
<div class="row-actions">
|
|
50611
|
+
<val-button [props]="retryButtonProps()" (click)="onRetry()" />
|
|
50866
50612
|
</div>
|
|
50867
50613
|
}
|
|
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>
|
|
50868
50639
|
}
|
|
50869
50640
|
</div>
|
|
50870
|
-
`, isInline: true, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:16px}.
|
|
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"] }] }); }
|
|
50871
50642
|
}
|
|
50872
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type:
|
|
50643
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: NotificationPreferencesViewComponent, decorators: [{
|
|
50873
50644
|
type: Component,
|
|
50874
|
-
args: [{ selector: 'val-
|
|
50875
|
-
IonIcon,
|
|
50876
|
-
DisplayComponent,
|
|
50877
|
-
TitleComponent,
|
|
50878
|
-
TextComponent,
|
|
50879
|
-
ButtonComponent,
|
|
50880
|
-
SkeletonLayoutComponent,
|
|
50881
|
-
EmptyStateComponent,
|
|
50882
|
-
], template: `
|
|
50645
|
+
args: [{ selector: 'val-notification-preferences-view', standalone: true, imports: [IonIcon, ToggleInputComponent, ButtonComponent, DisplayComponent, TitleComponent, TextComponent], template: `
|
|
50883
50646
|
<div class="page">
|
|
50884
50647
|
<header class="page-header">
|
|
50885
50648
|
<val-display [props]="{ size: 'small', color: 'dark', content: pageTitle() }" />
|
|
@@ -50891,1574 +50654,1820 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
50891
50654
|
content: pageDescription(),
|
|
50892
50655
|
}"
|
|
50893
50656
|
/>
|
|
50894
|
-
@if (showMarkAll()) {
|
|
50895
|
-
<div class="row-actions">
|
|
50896
|
-
<val-button [props]="markAllButtonProps()" (click)="onMarkAll()" />
|
|
50897
|
-
</div>
|
|
50898
|
-
}
|
|
50899
50657
|
</header>
|
|
50900
50658
|
|
|
50901
|
-
@if (
|
|
50902
|
-
<section class="
|
|
50903
|
-
<
|
|
50904
|
-
|
|
50905
|
-
|
|
50906
|
-
|
|
50907
|
-
|
|
50908
|
-
|
|
50909
|
-
|
|
50910
|
-
|
|
50911
|
-
|
|
50912
|
-
/>
|
|
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">
|
|
50913
50670
|
<val-text
|
|
50914
50671
|
[props]="{
|
|
50915
|
-
content: ctaHint(),
|
|
50916
50672
|
size: 'small',
|
|
50917
50673
|
color: 'medium',
|
|
50918
50674
|
bold: false,
|
|
50675
|
+
content: tt('iosInstallHint'),
|
|
50919
50676
|
}"
|
|
50920
50677
|
/>
|
|
50921
|
-
<
|
|
50922
|
-
<
|
|
50923
|
-
|
|
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>
|
|
50924
50684
|
</div>
|
|
50925
50685
|
</section>
|
|
50926
50686
|
}
|
|
50927
50687
|
|
|
50928
|
-
|
|
50929
|
-
<
|
|
50930
|
-
|
|
50931
|
-
|
|
50932
|
-
|
|
50933
|
-
|
|
50934
|
-
} @else {
|
|
50935
|
-
@for (group of groups(); track group.key) {
|
|
50936
|
-
<section class="group">
|
|
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">
|
|
50937
50694
|
<val-title
|
|
50938
50695
|
[props]="{
|
|
50939
50696
|
size: 'medium',
|
|
50940
50697
|
color: 'dark',
|
|
50941
50698
|
bold: true,
|
|
50942
|
-
content:
|
|
50699
|
+
content: tt('simpleTitle'),
|
|
50943
50700
|
}"
|
|
50944
50701
|
/>
|
|
50945
|
-
<
|
|
50946
|
-
|
|
50947
|
-
|
|
50948
|
-
|
|
50949
|
-
|
|
50950
|
-
|
|
50951
|
-
|
|
50952
|
-
|
|
50953
|
-
|
|
50954
|
-
|
|
50955
|
-
|
|
50956
|
-
|
|
50957
|
-
|
|
50958
|
-
|
|
50959
|
-
|
|
50960
|
-
|
|
50961
|
-
<img class="notif__avatar" [src]="imageSrc(n)" alt="" loading="lazy" />
|
|
50962
|
-
}
|
|
50963
|
-
}
|
|
50964
|
-
</div>
|
|
50702
|
+
<val-text
|
|
50703
|
+
[props]="{
|
|
50704
|
+
size: 'small',
|
|
50705
|
+
color: 'medium',
|
|
50706
|
+
bold: false,
|
|
50707
|
+
content: tt('simpleDescription'),
|
|
50708
|
+
}"
|
|
50709
|
+
/>
|
|
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>
|
|
50965
50718
|
|
|
50966
|
-
|
|
50967
|
-
|
|
50968
|
-
|
|
50969
|
-
|
|
50970
|
-
|
|
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>
|
|
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>
|
|
51001
50724
|
}
|
|
51002
50725
|
|
|
51003
|
-
@if (
|
|
51004
|
-
<div class="row-actions
|
|
51005
|
-
<val-button [props]="
|
|
50726
|
+
@if (state() === 'error') {
|
|
50727
|
+
<div class="row-actions">
|
|
50728
|
+
<val-button [props]="retryButtonProps()" (click)="onRetry()" />
|
|
51006
50729
|
</div>
|
|
51007
50730
|
}
|
|
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>
|
|
51008
50756
|
}
|
|
51009
50757
|
</div>
|
|
51010
|
-
`, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:16px}.
|
|
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"] }]
|
|
51011
50759
|
}], ctorParameters: () => [], propDecorators: { config: [{
|
|
51012
50760
|
type: Input
|
|
51013
50761
|
}] } });
|
|
51014
50762
|
|
|
51015
50763
|
/**
|
|
51016
|
-
* Helper para montar
|
|
51017
|
-
* ruta en una app del factory. El
|
|
51018
|
-
*
|
|
51019
|
-
* `@Input() config`.
|
|
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).
|
|
51020
50770
|
*
|
|
51021
50771
|
* @example
|
|
51022
50772
|
* ```ts
|
|
51023
|
-
* // app.routes.ts
|
|
51024
|
-
* export const
|
|
51025
|
-
* ...
|
|
51026
|
-
* { path: 'settings', loadChildren: () => ... },
|
|
50773
|
+
* // app.routes.ts (típicamente bajo settings)
|
|
50774
|
+
* export const settingsRoutes: Routes = [
|
|
50775
|
+
* ...provideValtechNotificationPreferencesRoutes(), // → notifications
|
|
51027
50776
|
* ];
|
|
51028
50777
|
*
|
|
51029
50778
|
* // con config acotada:
|
|
51030
|
-
* ...
|
|
51031
|
-
* ...
|
|
51032
|
-
* config: { settingsNotificationsPath: '/app/preferences/push' },
|
|
51033
|
-
* }),
|
|
50779
|
+
* ...provideValtechNotificationPreferencesRoutes({ config: { showDetails: false } }),
|
|
50780
|
+
* ...provideValtechNotificationPreferencesRoutes({ path: 'push' }),
|
|
51034
50781
|
* ```
|
|
51035
50782
|
*/
|
|
51036
|
-
function
|
|
50783
|
+
function provideValtechNotificationPreferencesRoutes(opts) {
|
|
51037
50784
|
return [
|
|
51038
50785
|
{
|
|
51039
50786
|
path: opts?.path ?? 'notifications',
|
|
51040
|
-
component:
|
|
51041
|
-
data: {
|
|
50787
|
+
component: NotificationPreferencesViewComponent,
|
|
50788
|
+
data: { notificationPreferencesConfig: opts?.config },
|
|
51042
50789
|
},
|
|
51043
50790
|
];
|
|
51044
50791
|
}
|
|
51045
50792
|
|
|
51046
50793
|
/**
|
|
51047
|
-
*
|
|
51048
|
-
*
|
|
51049
|
-
*
|
|
51050
|
-
* la vista
|
|
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.
|
|
51051
50798
|
*
|
|
51052
|
-
*
|
|
51053
|
-
*
|
|
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.
|
|
51054
50802
|
*/
|
|
51055
|
-
|
|
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.
|
|
50826
|
+
*
|
|
50827
|
+
* Portado EXACTO de `PERMISSIONS_I18N` (showcase) bajo el proceso de ADR-021.
|
|
50828
|
+
*/
|
|
50829
|
+
const PERMISSIONS_VIEW_I18N = {
|
|
51056
50830
|
es: {
|
|
51057
|
-
pageTitle: '
|
|
51058
|
-
pageDescription: '
|
|
51059
|
-
|
|
51060
|
-
|
|
51061
|
-
|
|
51062
|
-
|
|
51063
|
-
|
|
51064
|
-
|
|
51065
|
-
|
|
51066
|
-
|
|
51067
|
-
|
|
51068
|
-
|
|
51069
|
-
|
|
51070
|
-
//
|
|
51071
|
-
|
|
51072
|
-
|
|
51073
|
-
|
|
51074
|
-
|
|
51075
|
-
|
|
51076
|
-
|
|
51077
|
-
|
|
51078
|
-
|
|
51079
|
-
|
|
51080
|
-
|
|
51081
|
-
|
|
51082
|
-
|
|
51083
|
-
|
|
51084
|
-
|
|
51085
|
-
|
|
51086
|
-
|
|
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',
|
|
51087
50885
|
},
|
|
51088
50886
|
en: {
|
|
51089
|
-
pageTitle: '
|
|
51090
|
-
pageDescription: '
|
|
51091
|
-
|
|
51092
|
-
|
|
51093
|
-
|
|
51094
|
-
|
|
51095
|
-
|
|
51096
|
-
|
|
51097
|
-
|
|
51098
|
-
|
|
51099
|
-
|
|
51100
|
-
|
|
51101
|
-
|
|
51102
|
-
|
|
51103
|
-
|
|
51104
|
-
|
|
51105
|
-
|
|
51106
|
-
|
|
51107
|
-
|
|
51108
|
-
|
|
51109
|
-
|
|
51110
|
-
|
|
51111
|
-
|
|
51112
|
-
|
|
51113
|
-
|
|
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',
|
|
51114
50935
|
},
|
|
51115
50936
|
};
|
|
51116
50937
|
|
|
51117
|
-
|
|
51118
|
-
alertCircleOutline,
|
|
51119
|
-
chevronDownOutline,
|
|
51120
|
-
chevronUpOutline,
|
|
51121
|
-
closeCircleOutline,
|
|
51122
|
-
notificationsOffOutline,
|
|
51123
|
-
notificationsOutline,
|
|
51124
|
-
refreshOutline,
|
|
51125
|
-
});
|
|
51126
|
-
const DEFAULT_NAMESPACE$1 = 'Settings.Notifications';
|
|
50938
|
+
const DEFAULT_NAMESPACE$1 = 'Settings.Permissions';
|
|
51127
50939
|
/**
|
|
51128
|
-
* `val-
|
|
51129
|
-
*
|
|
51130
|
-
*
|
|
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.
|
|
51131
50945
|
*
|
|
51132
|
-
*
|
|
51133
|
-
*
|
|
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.
|
|
51134
50950
|
*
|
|
51135
|
-
*
|
|
51136
|
-
*
|
|
51137
|
-
*
|
|
51138
|
-
* callback (la lib no depende de `AuthService` para evitar ciclos). Mensajes
|
|
51139
|
-
* contextuales y botón "Reintentar" aparecen solo cuando aplican.
|
|
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.
|
|
51140
50954
|
*
|
|
51141
50955
|
* NO renderiza ion-content — vive dentro de val-page-wrapper. `ActivatedRoute` se
|
|
51142
|
-
* inyecta `{ optional: true }`
|
|
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`).
|
|
51143
50959
|
*/
|
|
51144
|
-
class
|
|
50960
|
+
class PermissionsViewComponent {
|
|
51145
50961
|
/** Namespace i18n resuelto (capturado para llamadas no-reactivas). */
|
|
51146
50962
|
get ns() {
|
|
51147
50963
|
return this.resolvedConfig().i18nNamespace;
|
|
51148
50964
|
}
|
|
51149
|
-
// ---------------------------------------------------------------------------
|
|
51150
|
-
// Lifecycle
|
|
51151
|
-
// ---------------------------------------------------------------------------
|
|
51152
50965
|
constructor() {
|
|
51153
|
-
this.nav = inject(NavigationService);
|
|
51154
50966
|
this.i18n = inject(I18nService);
|
|
51155
|
-
this.
|
|
50967
|
+
this.orgService = inject(OrgService);
|
|
50968
|
+
this.catalog = inject(PermissionCatalogService);
|
|
51156
50969
|
this.auth = inject(AuthService);
|
|
51157
|
-
this.
|
|
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).
|
|
50970
|
+
this.nav = inject(NavigationService);
|
|
51162
50971
|
this.route = inject(ActivatedRoute, { optional: true });
|
|
51163
50972
|
this.resolvedConfig = computed(() => {
|
|
51164
|
-
const fromRoute = (this.route?.snapshot.data['
|
|
51165
|
-
{});
|
|
50973
|
+
const fromRoute = (this.route?.snapshot.data['permissionsConfig'] ?? {});
|
|
51166
50974
|
const merged = { ...fromRoute, ...(this.config ?? {}) };
|
|
51167
50975
|
return {
|
|
51168
|
-
|
|
50976
|
+
showSystemRoles: merged.showSystemRoles ?? true,
|
|
50977
|
+
showCustomRoles: merged.showCustomRoles ?? true,
|
|
51169
50978
|
i18nNamespace: merged.i18nNamespace ?? DEFAULT_NAMESPACE$1,
|
|
51170
|
-
onPushEnabled: merged.onPushEnabled,
|
|
51171
|
-
onPushDisabled: merged.onPushDisabled,
|
|
51172
50979
|
};
|
|
51173
50980
|
});
|
|
51174
|
-
|
|
51175
|
-
this.
|
|
51176
|
-
|
|
51177
|
-
this.
|
|
51178
|
-
|
|
51179
|
-
this.
|
|
51180
|
-
|
|
51181
|
-
|
|
51182
|
-
this.permission = signal('default');
|
|
51183
|
-
this.isSupported = signal(false);
|
|
51184
|
-
this.currentToken = signal(null);
|
|
51185
|
-
this.needsIOSInstall = signal(this.detectNeedsIOSInstall());
|
|
51186
|
-
/** FormControl del toggle (sync-eado con `isEnabled` via effect). */
|
|
51187
|
-
this.toggleControl = new FormControl(false, {
|
|
51188
|
-
nonNullable: true,
|
|
51189
|
-
});
|
|
51190
|
-
/** Handler del listener `focus` — referencia guardada para poder removerlo. */
|
|
51191
|
-
this.onWindowFocus = () => this.scheduleBrowserRefresh();
|
|
51192
|
-
/** Timer del debounce del refresh disparado por `focus`. */
|
|
51193
|
-
this.focusDebounceTimer = null;
|
|
51194
|
-
/** Ventana de debounce (ms) para el refresh disparado por `focus`. */
|
|
51195
|
-
this.FOCUS_DEBOUNCE_MS = 500;
|
|
51196
|
-
// ---------------------------------------------------------------------------
|
|
51197
|
-
// Estado derivado
|
|
51198
|
-
// ---------------------------------------------------------------------------
|
|
51199
|
-
this.pageTitle = computed(() => this.tt('pageTitle'));
|
|
51200
|
-
this.pageDescription = computed(() => this.tt('pageDescription'));
|
|
51201
|
-
/**
|
|
51202
|
-
* Estado efectivo del toggle: "el usuario tiene push activo".
|
|
51203
|
-
*
|
|
51204
|
-
* Se deriva SOLO de la decisión del usuario (`master`) y del permiso del SO
|
|
51205
|
-
* (`permission`). El token FCM NO forma parte del estado de UI: vive en
|
|
51206
|
-
* memoria, se pierde en cada recarga/suspensión de la PWA y produciría flicker.
|
|
51207
|
-
* Si falta token con `master && granted`, es un detalle reparable en
|
|
51208
|
-
* background (ver `repairTokenIfNeeded`), no un cambio del toggle.
|
|
51209
|
-
*/
|
|
51210
|
-
this.isEnabled = computed(() => this.prefs.notificationsMaster() && this.permission() === 'granted');
|
|
51211
|
-
this.state = computed(() => {
|
|
51212
|
-
if (this.needsIOSInstall())
|
|
51213
|
-
return 'needs-install';
|
|
51214
|
-
if (this.permission() === 'unsupported' || !this.isSupported()) {
|
|
51215
|
-
// iOS standalone aún puede dar isSupported false temprano antes de init —
|
|
51216
|
-
// solo bloqueamos si el browser explícitamente no soporta APIs.
|
|
51217
|
-
if (this.permission() === 'unsupported')
|
|
51218
|
-
return 'unsupported';
|
|
51219
|
-
}
|
|
51220
|
-
const busy = this._busy();
|
|
51221
|
-
if (busy === 'enabling')
|
|
51222
|
-
return 'enabling';
|
|
51223
|
-
if (busy === 'disabling')
|
|
51224
|
-
return 'disabling';
|
|
51225
|
-
if (this.isEnabled())
|
|
51226
|
-
return 'enabled';
|
|
51227
|
-
if (this.permission() === 'denied')
|
|
51228
|
-
return 'blocked';
|
|
51229
|
-
if (this._lastFailed())
|
|
51230
|
-
return 'error';
|
|
51231
|
-
return 'idle';
|
|
51232
|
-
});
|
|
51233
|
-
this.busyLabel = computed(() => {
|
|
51234
|
-
const s = this.state();
|
|
51235
|
-
if (s === 'enabling')
|
|
51236
|
-
return this.tt('enabling');
|
|
51237
|
-
if (s === 'disabling')
|
|
51238
|
-
return this.tt('disabling');
|
|
51239
|
-
return null;
|
|
51240
|
-
});
|
|
51241
|
-
this.contextMessage = computed(() => {
|
|
51242
|
-
switch (this.state()) {
|
|
51243
|
-
case 'unsupported':
|
|
51244
|
-
return this.tt('permissionUnsupported');
|
|
51245
|
-
case 'blocked':
|
|
51246
|
-
return this.tt('permissionDeniedInfo');
|
|
51247
|
-
case 'error':
|
|
51248
|
-
return this.tt('errorGeneric');
|
|
51249
|
-
default:
|
|
51250
|
-
return null;
|
|
51251
|
-
}
|
|
51252
|
-
});
|
|
51253
|
-
this.alertVariant = computed(() => {
|
|
51254
|
-
const s = this.state();
|
|
51255
|
-
if (s === 'error' || s === 'blocked')
|
|
51256
|
-
return 'warning';
|
|
51257
|
-
if (s === 'unsupported')
|
|
51258
|
-
return 'danger';
|
|
51259
|
-
return 'info';
|
|
51260
|
-
});
|
|
51261
|
-
this.alertIcon = computed(() => {
|
|
51262
|
-
const s = this.state();
|
|
51263
|
-
if (s === 'unsupported')
|
|
51264
|
-
return 'close-circle-outline';
|
|
51265
|
-
return 'alert-circle-outline';
|
|
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 ?? '';
|
|
51266
50989
|
});
|
|
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
|
-
}));
|
|
51275
50990
|
this.retryButtonProps = computed(() => ({
|
|
51276
|
-
text: this.tt('
|
|
50991
|
+
text: this.tt('retry'),
|
|
51277
50992
|
color: 'dark',
|
|
51278
50993
|
fill: 'outline',
|
|
51279
50994
|
size: 'default',
|
|
51280
|
-
shape: 'round',
|
|
51281
50995
|
type: 'button',
|
|
51282
|
-
|
|
51283
|
-
state: this._busy() !== null ? ComponentStates.WORKING : ComponentStates.ENABLED,
|
|
50996
|
+
state: this.loading() ? ComponentStates.DISABLED : ComponentStates.ENABLED,
|
|
51284
50997
|
}));
|
|
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
|
-
});
|
|
51291
50998
|
const ns = this.ns;
|
|
51292
50999
|
if (!this.i18n.hasNamespace(ns)) {
|
|
51293
|
-
this.i18n.registerContent(ns,
|
|
51000
|
+
this.i18n.registerContent(ns, PERMISSIONS_VIEW_I18N);
|
|
51294
51001
|
}
|
|
51295
51002
|
this.nav.setBackHeader('pageTitle', ns, { withMenu: true });
|
|
51296
|
-
|
|
51297
|
-
|
|
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
|
-
}
|
|
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
|
-
}
|
|
51337
|
-
}
|
|
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
|
-
}
|
|
51003
|
+
this.load();
|
|
51004
|
+
connectPageRefresh(() => this.load());
|
|
51359
51005
|
}
|
|
51360
|
-
|
|
51361
|
-
|
|
51362
|
-
if (
|
|
51363
|
-
|
|
51364
|
-
|
|
51365
|
-
this.
|
|
51366
|
-
|
|
51367
|
-
|
|
51368
|
-
|
|
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
|
+
},
|
|
51034
|
+
});
|
|
51369
51035
|
}
|
|
51370
|
-
|
|
51371
|
-
|
|
51372
|
-
|
|
51373
|
-
|
|
51374
|
-
|
|
51375
|
-
|
|
51376
|
-
|
|
51377
|
-
|
|
51378
|
-
|
|
51379
|
-
|
|
51380
|
-
|
|
51381
|
-
|
|
51382
|
-
|
|
51383
|
-
|
|
51384
|
-
|
|
51385
|
-
|
|
51386
|
-
|
|
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);
|
|
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);
|
|
51394
51053
|
}
|
|
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;
|
|
51406
51054
|
}
|
|
51407
51055
|
else {
|
|
51408
|
-
//
|
|
51409
|
-
|
|
51410
|
-
this.toast.show({
|
|
51411
|
-
message: this.tt('errorGeneric'),
|
|
51412
|
-
duration: 3000,
|
|
51413
|
-
color: 'dark',
|
|
51414
|
-
position: 'top',
|
|
51415
|
-
});
|
|
51056
|
+
// global o desconocido → plataforma
|
|
51057
|
+
platformPerms.push(label);
|
|
51416
51058
|
}
|
|
51417
51059
|
}
|
|
51418
|
-
|
|
51419
|
-
|
|
51420
|
-
this.
|
|
51421
|
-
this.toast.show({
|
|
51422
|
-
message: this.tt('errorGeneric'),
|
|
51423
|
-
duration: 3000,
|
|
51424
|
-
color: 'dark',
|
|
51425
|
-
position: 'top',
|
|
51426
|
-
});
|
|
51427
|
-
}
|
|
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);
|
|
51060
|
+
const groups = [];
|
|
51061
|
+
if (appPerms.length) {
|
|
51062
|
+
groups.push({ key: 'app', label: this.tt('groupApp'), perms: appPerms });
|
|
51432
51063
|
}
|
|
51433
|
-
|
|
51434
|
-
|
|
51435
|
-
|
|
51436
|
-
|
|
51437
|
-
|
|
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',
|
|
51064
|
+
if (platformPerms.length) {
|
|
51065
|
+
groups.push({
|
|
51066
|
+
key: 'platform',
|
|
51067
|
+
label: this.tt('groupPlatform'),
|
|
51068
|
+
perms: platformPerms,
|
|
51447
51069
|
});
|
|
51448
|
-
this.resolvedConfig().onPushDisabled?.();
|
|
51449
|
-
}
|
|
51450
|
-
catch {
|
|
51451
|
-
this._lastFailed.set(true);
|
|
51452
51070
|
}
|
|
51453
|
-
|
|
51454
|
-
|
|
51455
|
-
|
|
51071
|
+
for (const [appId, perms] of otherByApp) {
|
|
51072
|
+
groups.push({
|
|
51073
|
+
key: `other:${appId}`,
|
|
51074
|
+
label: `${this.tt('groupOther')}: ${this.appLabel(appId)}`,
|
|
51075
|
+
perms,
|
|
51076
|
+
});
|
|
51456
51077
|
}
|
|
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
|
+
};
|
|
51457
51086
|
}
|
|
51458
|
-
|
|
51459
|
-
|
|
51460
|
-
this._lastFailed.set(false);
|
|
51461
|
-
await this.onEnable();
|
|
51462
|
-
}
|
|
51463
|
-
toggleDetails() {
|
|
51464
|
-
this.showDetails.update(v => !v);
|
|
51465
|
-
}
|
|
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();
|
|
51087
|
+
tt(key) {
|
|
51088
|
+
return this.i18n.t(key, this.ns);
|
|
51474
51089
|
}
|
|
51475
|
-
|
|
51476
|
-
|
|
51477
|
-
|
|
51478
|
-
|
|
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
|
-
}
|
|
51090
|
+
appLabel(appId) {
|
|
51091
|
+
const key = `app_${appId}`;
|
|
51092
|
+
const label = this.tt(key);
|
|
51093
|
+
return label === key ? appId : label;
|
|
51507
51094
|
}
|
|
51508
|
-
|
|
51509
|
-
|
|
51510
|
-
|
|
51511
|
-
|
|
51512
|
-
|
|
51513
|
-
|
|
51514
|
-
|
|
51515
|
-
|
|
51516
|
-
|
|
51517
|
-
|
|
51518
|
-
|
|
51519
|
-
|
|
51520
|
-
const isStandalone = window.matchMedia?.('(display-mode: standalone)').matches ||
|
|
51521
|
-
navigator.standalone === true;
|
|
51522
|
-
return !isStandalone;
|
|
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);
|
|
51523
51107
|
}
|
|
51524
|
-
|
|
51525
|
-
|
|
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}`;
|
|
51526
51116
|
}
|
|
51527
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type:
|
|
51528
|
-
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type:
|
|
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: `
|
|
51529
51119
|
<div class="page">
|
|
51530
51120
|
<header class="page-header">
|
|
51531
|
-
<val-display [props]="{ size: 'small', color: 'dark', content: pageTitle
|
|
51121
|
+
<val-display [props]="{ size: 'small', color: 'dark', content: tt('pageTitle') }" />
|
|
51532
51122
|
<val-title
|
|
51533
51123
|
[props]="{
|
|
51534
51124
|
size: 'large',
|
|
51535
51125
|
color: 'dark',
|
|
51536
51126
|
bold: false,
|
|
51537
|
-
content: pageDescription
|
|
51127
|
+
content: tt('pageDescription'),
|
|
51538
51128
|
}"
|
|
51539
51129
|
/>
|
|
51540
51130
|
</header>
|
|
51541
51131
|
|
|
51542
|
-
@if (
|
|
51543
|
-
<
|
|
51544
|
-
<
|
|
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
|
|
51545
51139
|
[props]="{
|
|
51140
|
+
content: tt('errorTitle'),
|
|
51546
51141
|
size: 'medium',
|
|
51547
|
-
color: '
|
|
51548
|
-
bold:
|
|
51549
|
-
content: tt('iosInstallTitle'),
|
|
51142
|
+
color: 'medium',
|
|
51143
|
+
bold: false,
|
|
51550
51144
|
}"
|
|
51551
51145
|
/>
|
|
51552
|
-
<
|
|
51553
|
-
<val-text
|
|
51554
|
-
[props]="{
|
|
51555
|
-
size: 'small',
|
|
51556
|
-
color: 'medium',
|
|
51557
|
-
bold: false,
|
|
51558
|
-
content: tt('iosInstallHint'),
|
|
51559
|
-
}"
|
|
51560
|
-
/>
|
|
51561
|
-
<ol class="install-steps">
|
|
51562
|
-
<li>{{ tt('iosInstallStep1') }}</li>
|
|
51563
|
-
<li>{{ tt('iosInstallStep2') }}</li>
|
|
51564
|
-
<li>{{ tt('iosInstallStep3') }}</li>
|
|
51565
|
-
<li>{{ tt('iosInstallStep4') }}</li>
|
|
51566
|
-
</ol>
|
|
51567
|
-
</div>
|
|
51568
|
-
</section>
|
|
51569
|
-
}
|
|
51570
|
-
|
|
51571
|
-
<section class="settings-section">
|
|
51572
|
-
<div class="notif-row">
|
|
51573
|
-
<div class="notif-row__icon">
|
|
51574
|
-
<ion-icon name="notifications-outline" aria-hidden="true" />
|
|
51575
|
-
</div>
|
|
51576
|
-
<div class="notif-row__body">
|
|
51577
|
-
<val-title
|
|
51578
|
-
[props]="{
|
|
51579
|
-
size: 'medium',
|
|
51580
|
-
color: 'dark',
|
|
51581
|
-
bold: true,
|
|
51582
|
-
content: tt('simpleTitle'),
|
|
51583
|
-
}"
|
|
51584
|
-
/>
|
|
51585
|
-
<val-text
|
|
51586
|
-
[props]="{
|
|
51587
|
-
size: 'small',
|
|
51588
|
-
color: 'medium',
|
|
51589
|
-
bold: false,
|
|
51590
|
-
content: tt('simpleDescription'),
|
|
51591
|
-
}"
|
|
51592
|
-
/>
|
|
51593
|
-
@if (busyLabel(); as label) {
|
|
51594
|
-
<span class="notif-row__busy">{{ label }}</span>
|
|
51595
|
-
}
|
|
51596
|
-
</div>
|
|
51597
|
-
<div class="notif-row__control">
|
|
51598
|
-
<val-toggle-input [props]="toggleProps()" />
|
|
51599
|
-
</div>
|
|
51146
|
+
<val-button [props]="retryButtonProps()" (click)="load()" />
|
|
51600
51147
|
</div>
|
|
51601
|
-
|
|
51602
|
-
@if (
|
|
51603
|
-
<
|
|
51604
|
-
<
|
|
51605
|
-
|
|
51606
|
-
|
|
51607
|
-
|
|
51608
|
-
|
|
51609
|
-
|
|
51610
|
-
|
|
51611
|
-
|
|
51612
|
-
|
|
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>
|
|
51613
51205
|
}
|
|
51614
|
-
</section>
|
|
51615
51206
|
|
|
51616
|
-
|
|
51617
|
-
|
|
51618
|
-
|
|
51619
|
-
|
|
51620
|
-
|
|
51621
|
-
|
|
51622
|
-
|
|
51623
|
-
|
|
51624
|
-
|
|
51625
|
-
|
|
51626
|
-
|
|
51627
|
-
|
|
51628
|
-
|
|
51629
|
-
|
|
51630
|
-
|
|
51631
|
-
|
|
51632
|
-
|
|
51633
|
-
|
|
51634
|
-
|
|
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
|
+
}
|
|
51635
51264
|
</div>
|
|
51636
|
-
|
|
51637
|
-
|
|
51638
|
-
|
|
51265
|
+
}
|
|
51266
|
+
</section>
|
|
51267
|
+
}
|
|
51639
51268
|
}
|
|
51640
51269
|
</div>
|
|
51641
|
-
`, isInline: true, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:
|
|
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"] }] }); }
|
|
51642
51271
|
}
|
|
51643
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type:
|
|
51272
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: PermissionsViewComponent, decorators: [{
|
|
51644
51273
|
type: Component,
|
|
51645
|
-
args: [{ selector: 'val-
|
|
51274
|
+
args: [{ selector: 'val-permissions-view', standalone: true, imports: [IonSpinner, DisplayComponent, PillComponent, TitleComponent, TextComponent, ButtonComponent], template: `
|
|
51646
51275
|
<div class="page">
|
|
51647
51276
|
<header class="page-header">
|
|
51648
|
-
<val-display [props]="{ size: 'small', color: 'dark', content: pageTitle
|
|
51277
|
+
<val-display [props]="{ size: 'small', color: 'dark', content: tt('pageTitle') }" />
|
|
51649
51278
|
<val-title
|
|
51650
51279
|
[props]="{
|
|
51651
51280
|
size: 'large',
|
|
51652
51281
|
color: 'dark',
|
|
51653
51282
|
bold: false,
|
|
51654
|
-
content: pageDescription
|
|
51283
|
+
content: tt('pageDescription'),
|
|
51655
51284
|
}"
|
|
51656
51285
|
/>
|
|
51657
51286
|
</header>
|
|
51658
51287
|
|
|
51659
|
-
@if (
|
|
51660
|
-
<
|
|
51661
|
-
<
|
|
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
|
|
51662
51295
|
[props]="{
|
|
51296
|
+
content: tt('errorTitle'),
|
|
51663
51297
|
size: 'medium',
|
|
51664
|
-
color: '
|
|
51665
|
-
bold:
|
|
51666
|
-
content: tt('iosInstallTitle'),
|
|
51298
|
+
color: 'medium',
|
|
51299
|
+
bold: false,
|
|
51667
51300
|
}"
|
|
51668
51301
|
/>
|
|
51669
|
-
<
|
|
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>
|
|
51302
|
+
<val-button [props]="retryButtonProps()" (click)="load()" />
|
|
51717
51303
|
</div>
|
|
51718
|
-
|
|
51719
|
-
@if (
|
|
51720
|
-
<
|
|
51721
|
-
<
|
|
51722
|
-
|
|
51723
|
-
|
|
51724
|
-
|
|
51725
|
-
|
|
51726
|
-
|
|
51727
|
-
|
|
51728
|
-
|
|
51729
|
-
|
|
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>
|
|
51730
51361
|
}
|
|
51731
|
-
</section>
|
|
51732
51362
|
|
|
51733
|
-
|
|
51734
|
-
|
|
51735
|
-
|
|
51736
|
-
|
|
51737
|
-
|
|
51738
|
-
|
|
51739
|
-
|
|
51740
|
-
|
|
51741
|
-
|
|
51742
|
-
|
|
51743
|
-
|
|
51744
|
-
|
|
51745
|
-
|
|
51746
|
-
|
|
51747
|
-
|
|
51748
|
-
|
|
51749
|
-
|
|
51750
|
-
|
|
51751
|
-
|
|
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
|
+
}
|
|
51752
51420
|
</div>
|
|
51753
|
-
|
|
51754
|
-
|
|
51755
|
-
|
|
51421
|
+
}
|
|
51422
|
+
</section>
|
|
51423
|
+
}
|
|
51756
51424
|
}
|
|
51757
51425
|
</div>
|
|
51758
|
-
`, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:
|
|
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"] }]
|
|
51759
51427
|
}], ctorParameters: () => [], propDecorators: { config: [{
|
|
51760
51428
|
type: Input
|
|
51761
51429
|
}] } });
|
|
51762
51430
|
|
|
51763
51431
|
/**
|
|
51764
|
-
* Helper para montar
|
|
51765
|
-
*
|
|
51766
|
-
* `config` se pasa por route `data` (`notificationPreferencesConfig`) y el
|
|
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
|
|
51767
51434
|
* componente lo lee como fallback de su `@Input() config`.
|
|
51768
51435
|
*
|
|
51769
|
-
* No confundir con `provideValtechNotificationsRoutes` (el inbox / feed).
|
|
51770
|
-
*
|
|
51771
51436
|
* @example
|
|
51772
51437
|
* ```ts
|
|
51773
|
-
* //
|
|
51438
|
+
* // settings.routes.ts
|
|
51774
51439
|
* export const settingsRoutes: Routes = [
|
|
51775
|
-
* ...
|
|
51440
|
+
* ...provideValtechPermissionsRoutes(), // → /app/settings/permissions
|
|
51441
|
+
* { path: 'preferences', loadComponent: () => ... },
|
|
51776
51442
|
* ];
|
|
51777
51443
|
*
|
|
51778
51444
|
* // con config acotada:
|
|
51779
|
-
* ...
|
|
51780
|
-
* ...
|
|
51445
|
+
* ...provideValtechPermissionsRoutes({ config: { showCustomRoles: false } }),
|
|
51446
|
+
* ...provideValtechPermissionsRoutes({ path: 'roles' }),
|
|
51781
51447
|
* ```
|
|
51782
51448
|
*/
|
|
51783
|
-
function
|
|
51449
|
+
function provideValtechPermissionsRoutes(opts) {
|
|
51784
51450
|
return [
|
|
51785
51451
|
{
|
|
51786
|
-
path: opts?.path ?? '
|
|
51787
|
-
component:
|
|
51788
|
-
data: {
|
|
51452
|
+
path: opts?.path ?? 'permissions',
|
|
51453
|
+
component: PermissionsViewComponent,
|
|
51454
|
+
data: { permissionsConfig: opts?.config },
|
|
51789
51455
|
},
|
|
51790
51456
|
];
|
|
51791
51457
|
}
|
|
51792
51458
|
|
|
51793
51459
|
/**
|
|
51794
|
-
*
|
|
51795
|
-
*
|
|
51796
|
-
*
|
|
51797
|
-
*
|
|
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.
|
|
51798
51466
|
*
|
|
51799
|
-
*
|
|
51800
|
-
*
|
|
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.
|
|
51467
|
+
* showcase lo consume (ver `settings.routes.ts` de showcase): hub en `preferences`,
|
|
51468
|
+
* preferences-view en `general`, index→redirect, `organization/new` por `extraRoutes`.
|
|
51826
51469
|
*
|
|
51827
|
-
*
|
|
51470
|
+
* @example
|
|
51471
|
+
* ```ts
|
|
51472
|
+
* // app.routes.ts
|
|
51473
|
+
* import { provideValtechSettingsRoutes } from 'valtech-components';
|
|
51474
|
+
*
|
|
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
|
+
* ```
|
|
51828
51489
|
*/
|
|
51829
|
-
|
|
51830
|
-
|
|
51831
|
-
|
|
51832
|
-
|
|
51833
|
-
|
|
51834
|
-
|
|
51835
|
-
|
|
51836
|
-
|
|
51837
|
-
|
|
51838
|
-
|
|
51839
|
-
|
|
51840
|
-
|
|
51841
|
-
|
|
51842
|
-
|
|
51843
|
-
|
|
51844
|
-
|
|
51845
|
-
|
|
51846
|
-
|
|
51847
|
-
|
|
51848
|
-
|
|
51849
|
-
|
|
51850
|
-
|
|
51851
|
-
|
|
51852
|
-
|
|
51853
|
-
|
|
51854
|
-
|
|
51855
|
-
|
|
51856
|
-
|
|
51857
|
-
|
|
51858
|
-
|
|
51859
|
-
|
|
51860
|
-
|
|
51861
|
-
|
|
51862
|
-
|
|
51863
|
-
|
|
51864
|
-
|
|
51865
|
-
|
|
51866
|
-
|
|
51867
|
-
|
|
51868
|
-
|
|
51869
|
-
|
|
51870
|
-
|
|
51871
|
-
|
|
51872
|
-
|
|
51873
|
-
|
|
51874
|
-
|
|
51875
|
-
|
|
51876
|
-
|
|
51877
|
-
|
|
51878
|
-
|
|
51879
|
-
|
|
51880
|
-
|
|
51881
|
-
|
|
51882
|
-
|
|
51883
|
-
|
|
51884
|
-
|
|
51885
|
-
|
|
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 = {
|
|
51665
|
+
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.',
|
|
51886
51697
|
},
|
|
51887
51698
|
en: {
|
|
51888
|
-
pageTitle: '
|
|
51889
|
-
pageDescription: '
|
|
51890
|
-
|
|
51891
|
-
|
|
51892
|
-
|
|
51893
|
-
|
|
51894
|
-
|
|
51895
|
-
|
|
51896
|
-
|
|
51897
|
-
|
|
51898
|
-
|
|
51899
|
-
|
|
51900
|
-
|
|
51901
|
-
|
|
51902
|
-
|
|
51903
|
-
|
|
51904
|
-
|
|
51905
|
-
|
|
51906
|
-
|
|
51907
|
-
|
|
51908
|
-
|
|
51909
|
-
|
|
51910
|
-
|
|
51911
|
-
|
|
51912
|
-
|
|
51913
|
-
|
|
51914
|
-
|
|
51915
|
-
|
|
51916
|
-
|
|
51917
|
-
|
|
51918
|
-
|
|
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',
|
|
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.',
|
|
51937
51730
|
},
|
|
51938
51731
|
};
|
|
51939
51732
|
|
|
51940
|
-
|
|
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;
|
|
51941
51751
|
/**
|
|
51942
|
-
* `val-
|
|
51943
|
-
*
|
|
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.
|
|
51752
|
+
* `val-notifications-view` — inbox de notificaciones full-feature autocontenido
|
|
51753
|
+
* (organism). Promovido desde `showcase` bajo el proceso de ADR-021.
|
|
51947
51754
|
*
|
|
51948
|
-
*
|
|
51949
|
-
*
|
|
51950
|
-
* puede verla es responsabilidad del guard de ruta del consumer; no hay gating
|
|
51951
|
-
* fino interno ni se expone como config.
|
|
51755
|
+
* Separado de `/app/settings/notifications` (que controla permiso FCM + master
|
|
51756
|
+
* toggle). Aquí el user lee/abre/marca-leídas sus mensajes.
|
|
51952
51757
|
*
|
|
51953
|
-
*
|
|
51954
|
-
*
|
|
51955
|
-
*
|
|
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`).
|
|
51956
51771
|
*
|
|
51957
|
-
* NO renderiza ion-content — vive dentro de val-page-wrapper. `
|
|
51958
|
-
*
|
|
51959
|
-
*
|
|
51960
|
-
* `
|
|
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`.
|
|
51961
51777
|
*/
|
|
51962
|
-
class
|
|
51778
|
+
class NotificationsViewComponent {
|
|
51963
51779
|
/** Namespace i18n resuelto (capturado para llamadas no-reactivas). */
|
|
51964
51780
|
get ns() {
|
|
51965
51781
|
return this.resolvedConfig().i18nNamespace;
|
|
51966
51782
|
}
|
|
51783
|
+
/** Paso de paginación soft (revela +N por "cargar más"). */
|
|
51784
|
+
get pageStep() {
|
|
51785
|
+
return this.resolvedConfig().pageSize;
|
|
51786
|
+
}
|
|
51967
51787
|
constructor() {
|
|
51788
|
+
this.nav = inject(NavigationService);
|
|
51968
51789
|
this.i18n = inject(I18nService);
|
|
51969
|
-
this.
|
|
51970
|
-
this.
|
|
51790
|
+
this.notifs = inject(NotificationsService);
|
|
51791
|
+
this.action = inject(NotificationActionService);
|
|
51792
|
+
this.toast = inject(ToastService);
|
|
51793
|
+
this.errors = inject(ValtechErrorService);
|
|
51971
51794
|
this.auth = inject(AuthService);
|
|
51972
|
-
this.
|
|
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`).
|
|
51973
51799
|
this.route = inject(ActivatedRoute, { optional: true });
|
|
51974
51800
|
this.resolvedConfig = computed(() => {
|
|
51975
|
-
const fromRoute = (this.route?.snapshot.data['
|
|
51801
|
+
const fromRoute = (this.route?.snapshot.data['notificationsConfig'] ?? {});
|
|
51976
51802
|
const merged = { ...fromRoute, ...(this.config ?? {}) };
|
|
51977
51803
|
return {
|
|
51978
|
-
|
|
51979
|
-
|
|
51804
|
+
showPushOffCta: merged.showPushOffCta ?? true,
|
|
51805
|
+
enableMarkAll: merged.enableMarkAll ?? true,
|
|
51806
|
+
settingsNotificationsPath: merged.settingsNotificationsPath ?? DEFAULT_SETTINGS_PATH,
|
|
51980
51807
|
i18nNamespace: merged.i18nNamespace ?? DEFAULT_NAMESPACE,
|
|
51808
|
+
pageSize: merged.pageSize ?? DEFAULT_PAGE_SIZE,
|
|
51809
|
+
maxLoad: merged.maxLoad ?? DEFAULT_MAX_LOAD,
|
|
51810
|
+
onNotificationOpen: merged.onNotificationOpen,
|
|
51981
51811
|
};
|
|
51982
51812
|
});
|
|
51983
|
-
this.
|
|
51984
|
-
this.
|
|
51985
|
-
this.
|
|
51986
|
-
this.
|
|
51987
|
-
|
|
51988
|
-
|
|
51989
|
-
|
|
51990
|
-
|
|
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,
|
|
51991
51841
|
});
|
|
51992
|
-
|
|
51993
|
-
|
|
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
|
+
};
|
|
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
|
+
});
|
|
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'),
|
|
51994
51887
|
color: 'dark',
|
|
51995
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
|
+
};
|
|
51934
|
+
});
|
|
51935
|
+
this.seeMore = computed(() => this.tt('seeMore'));
|
|
51936
|
+
this.seeLess = computed(() => this.tt('seeLess'));
|
|
51937
|
+
this.markAllButtonProps = computed(() => ({
|
|
51938
|
+
text: this.tt('markAll'),
|
|
51939
|
+
color: 'dark',
|
|
51940
|
+
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',
|
|
51996
51951
|
size: 'default',
|
|
51952
|
+
shape: 'round',
|
|
51997
51953
|
type: 'button',
|
|
51998
|
-
state: this.
|
|
51954
|
+
state: this._loadingMore() ? ComponentStates.WORKING : ComponentStates.ENABLED,
|
|
51999
51955
|
}));
|
|
52000
51956
|
const ns = this.ns;
|
|
52001
51957
|
if (!this.i18n.hasNamespace(ns)) {
|
|
52002
|
-
this.i18n.registerContent(ns,
|
|
51958
|
+
this.i18n.registerContent(ns, NOTIFICATIONS_VIEW_I18N);
|
|
52003
51959
|
}
|
|
52004
51960
|
this.nav.setBackHeader('pageTitle', ns, { withMenu: true });
|
|
52005
|
-
|
|
52006
|
-
|
|
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 });
|
|
52007
51966
|
}
|
|
52008
|
-
|
|
52009
|
-
|
|
52010
|
-
|
|
52011
|
-
|
|
52012
|
-
|
|
52013
|
-
|
|
52014
|
-
|
|
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
|
-
});
|
|
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());
|
|
52037
51974
|
}
|
|
52038
|
-
|
|
52039
|
-
|
|
52040
|
-
|
|
52041
|
-
|
|
52042
|
-
|
|
52043
|
-
|
|
52044
|
-
|
|
52045
|
-
|
|
52046
|
-
|
|
52047
|
-
|
|
52048
|
-
|
|
52049
|
-
|
|
52050
|
-
|
|
52051
|
-
|
|
52052
|
-
|
|
52053
|
-
|
|
52054
|
-
|
|
52055
|
-
|
|
52056
|
-
|
|
52057
|
-
|
|
52058
|
-
|
|
52059
|
-
|
|
52060
|
-
|
|
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);
|
|
52012
|
+
}
|
|
52013
|
+
isLong(body) {
|
|
52014
|
+
if (!body)
|
|
52015
|
+
return false;
|
|
52016
|
+
return body.length > 110 || body.includes('\n');
|
|
52017
|
+
}
|
|
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 };
|
|
52061
52029
|
}
|
|
52062
|
-
|
|
52063
|
-
|
|
52064
|
-
|
|
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';
|
|
52065
52054
|
}
|
|
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);
|
|
52066
52105
|
}
|
|
52067
|
-
|
|
52068
|
-
|
|
52069
|
-
|
|
52106
|
+
catch (err) {
|
|
52107
|
+
this.errors.handle(err, {
|
|
52108
|
+
context: 'notifications.open',
|
|
52109
|
+
fallbackKey: 'openError',
|
|
52110
|
+
i18nNamespace: this.ns,
|
|
52111
|
+
});
|
|
52070
52112
|
}
|
|
52071
|
-
|
|
52072
|
-
|
|
52073
|
-
|
|
52074
|
-
|
|
52075
|
-
|
|
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',
|
|
52076
52125
|
});
|
|
52077
52126
|
}
|
|
52078
|
-
|
|
52079
|
-
|
|
52080
|
-
|
|
52081
|
-
|
|
52082
|
-
|
|
52127
|
+
catch (err) {
|
|
52128
|
+
this.errors.handle(err, {
|
|
52129
|
+
context: 'notifications.markAll',
|
|
52130
|
+
fallbackKey: 'markedAllError',
|
|
52131
|
+
i18nNamespace: this.ns,
|
|
52083
52132
|
});
|
|
52084
52133
|
}
|
|
52085
|
-
|
|
52086
|
-
|
|
52087
|
-
|
|
52088
|
-
description: role.description,
|
|
52089
|
-
isSystem: role.isSystem,
|
|
52090
|
-
groups,
|
|
52091
|
-
hasPerms: groups.length > 0,
|
|
52092
|
-
};
|
|
52093
|
-
}
|
|
52094
|
-
tt(key) {
|
|
52095
|
-
return this.i18n.t(key, this.ns);
|
|
52134
|
+
finally {
|
|
52135
|
+
this._bulkBusy.set(false);
|
|
52136
|
+
}
|
|
52096
52137
|
}
|
|
52097
|
-
|
|
52098
|
-
|
|
52099
|
-
|
|
52100
|
-
|
|
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);
|
|
52101
52148
|
}
|
|
52102
|
-
|
|
52103
|
-
|
|
52104
|
-
viewer: 'roleViewer',
|
|
52105
|
-
editor: 'roleEditor',
|
|
52106
|
-
admin: 'roleAdmin',
|
|
52107
|
-
super_admin: 'roleSuperAdmin',
|
|
52108
|
-
bingo_admin: 'roleBingoAdmin',
|
|
52109
|
-
bingo_tesorero: 'roleBingoTesorero',
|
|
52110
|
-
bingo_taquilla: 'roleBingoTaquilla',
|
|
52111
|
-
bingo_operator: 'roleBingoOperator',
|
|
52112
|
-
};
|
|
52113
|
-
return this.tt(map[name] ?? name);
|
|
52149
|
+
onGoToSettings() {
|
|
52150
|
+
this.nav.navigateByUrl(this.resolvedConfig().settingsNotificationsPath);
|
|
52114
52151
|
}
|
|
52115
|
-
|
|
52116
|
-
|
|
52117
|
-
|
|
52118
|
-
|
|
52119
|
-
const
|
|
52120
|
-
|
|
52121
|
-
|
|
52122
|
-
|
|
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';
|
|
52123
52166
|
}
|
|
52124
|
-
|
|
52125
|
-
|
|
52167
|
+
tt(key) {
|
|
52168
|
+
return this.i18n.t(key, this.ns);
|
|
52169
|
+
}
|
|
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: `
|
|
52126
52172
|
<div class="page">
|
|
52127
52173
|
<header class="page-header">
|
|
52128
|
-
<val-display [props]="{ size: 'small', color: 'dark', content:
|
|
52174
|
+
<val-display [props]="{ size: 'small', color: 'dark', content: pageTitle() }" />
|
|
52129
52175
|
<val-title
|
|
52130
52176
|
[props]="{
|
|
52131
52177
|
size: 'large',
|
|
52132
52178
|
color: 'dark',
|
|
52133
52179
|
bold: false,
|
|
52134
|
-
content:
|
|
52180
|
+
content: pageDescription(),
|
|
52135
52181
|
}"
|
|
52136
52182
|
/>
|
|
52183
|
+
@if (showMarkAll()) {
|
|
52184
|
+
<div class="row-actions">
|
|
52185
|
+
<val-button [props]="markAllButtonProps()" (click)="onMarkAll()" />
|
|
52186
|
+
</div>
|
|
52187
|
+
}
|
|
52137
52188
|
</header>
|
|
52138
52189
|
|
|
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
|
+
|
|
52139
52217
|
@if (loading()) {
|
|
52140
|
-
<
|
|
52141
|
-
|
|
52142
|
-
|
|
52143
|
-
} @else if (
|
|
52144
|
-
<
|
|
52145
|
-
<val-text
|
|
52146
|
-
[props]="{
|
|
52147
|
-
content: tt('errorTitle'),
|
|
52148
|
-
size: 'medium',
|
|
52149
|
-
color: 'medium',
|
|
52150
|
-
bold: false,
|
|
52151
|
-
}"
|
|
52152
|
-
/>
|
|
52153
|
-
<val-button [props]="retryButtonProps()" (click)="load()" />
|
|
52154
|
-
</div>
|
|
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()" />
|
|
52155
52223
|
} @else {
|
|
52156
|
-
@
|
|
52157
|
-
<section class="
|
|
52158
|
-
<
|
|
52159
|
-
|
|
52160
|
-
|
|
52161
|
-
|
|
52162
|
-
|
|
52163
|
-
|
|
52164
|
-
|
|
52165
|
-
|
|
52166
|
-
|
|
52167
|
-
|
|
52168
|
-
[
|
|
52169
|
-
|
|
52170
|
-
|
|
52171
|
-
|
|
52172
|
-
|
|
52173
|
-
|
|
52174
|
-
|
|
52175
|
-
|
|
52176
|
-
|
|
52177
|
-
|
|
52178
|
-
|
|
52179
|
-
|
|
52180
|
-
|
|
52181
|
-
|
|
52182
|
-
|
|
52183
|
-
|
|
52184
|
-
color: 'primary',
|
|
52185
|
-
weight: 'bold',
|
|
52186
|
-
}"
|
|
52187
|
-
/>
|
|
52188
|
-
</div>
|
|
52189
|
-
@if (role.description) {
|
|
52190
|
-
<span class="role-card__desc">{{ role.description }}</span>
|
|
52191
|
-
}
|
|
52192
|
-
@if (!role.hasPerms) {
|
|
52193
|
-
<div class="perm-chips">
|
|
52194
|
-
<span class="perm-chip perm-chip--empty">{{ tt('noPermissions') }}</span>
|
|
52195
|
-
</div>
|
|
52196
|
-
} @else {
|
|
52197
|
-
@for (g of role.groups; track g.key) {
|
|
52198
|
-
<div class="perm-group">
|
|
52199
|
-
<span class="perm-group__label">{{ g.label }}</span>
|
|
52200
|
-
<div class="perm-chips">
|
|
52201
|
-
@for (p of g.perms; track p) {
|
|
52202
|
-
<span class="perm-chip">{{ p }}</span>
|
|
52203
|
-
}
|
|
52204
|
-
</div>
|
|
52205
|
-
</div>
|
|
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
|
+
}
|
|
52206
52252
|
}
|
|
52207
|
-
|
|
52208
|
-
</div>
|
|
52209
|
-
}
|
|
52210
|
-
</div>
|
|
52211
|
-
</section>
|
|
52212
|
-
}
|
|
52253
|
+
</div>
|
|
52213
52254
|
|
|
52214
|
-
|
|
52215
|
-
|
|
52216
|
-
|
|
52217
|
-
|
|
52218
|
-
|
|
52219
|
-
|
|
52220
|
-
|
|
52221
|
-
|
|
52222
|
-
|
|
52223
|
-
|
|
52224
|
-
|
|
52225
|
-
<val-text
|
|
52226
|
-
[props]="{
|
|
52227
|
-
content: tt('customRolesHint'),
|
|
52228
|
-
size: 'medium',
|
|
52229
|
-
color: 'medium',
|
|
52230
|
-
bold: false,
|
|
52231
|
-
}"
|
|
52232
|
-
/>
|
|
52233
|
-
</div>
|
|
52234
|
-
@if (customRoles().length === 0) {
|
|
52235
|
-
<val-text
|
|
52236
|
-
[props]="{
|
|
52237
|
-
content: tt('noCustomRoles'),
|
|
52238
|
-
size: 'medium',
|
|
52239
|
-
color: 'medium',
|
|
52240
|
-
bold: false,
|
|
52241
|
-
}"
|
|
52242
|
-
/>
|
|
52243
|
-
} @else {
|
|
52244
|
-
<div class="role-list">
|
|
52245
|
-
@for (role of customRoles(); track role.id) {
|
|
52246
|
-
<div class="role-card">
|
|
52247
|
-
<div class="role-card__header">
|
|
52248
|
-
<span class="role-card__name">{{ role.name }}</span>
|
|
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>
|
|
52249
52266
|
</div>
|
|
52250
|
-
@if (
|
|
52251
|
-
<
|
|
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
|
+
}
|
|
52252
52276
|
}
|
|
52253
|
-
@if (
|
|
52254
|
-
<
|
|
52255
|
-
|
|
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)" />
|
|
52256
52283
|
</div>
|
|
52257
|
-
} @else {
|
|
52258
|
-
@for (g of role.groups; track g.key) {
|
|
52259
|
-
<div class="perm-group">
|
|
52260
|
-
<span class="perm-group__label">{{ g.label }}</span>
|
|
52261
|
-
<div class="perm-chips">
|
|
52262
|
-
@for (p of g.perms; track p) {
|
|
52263
|
-
<span class="perm-chip">{{ p }}</span>
|
|
52264
|
-
}
|
|
52265
|
-
</div>
|
|
52266
|
-
</div>
|
|
52267
|
-
}
|
|
52268
52284
|
}
|
|
52269
52285
|
</div>
|
|
52270
|
-
|
|
52271
|
-
|
|
52272
|
-
|
|
52286
|
+
</li>
|
|
52287
|
+
}
|
|
52288
|
+
</ul>
|
|
52273
52289
|
</section>
|
|
52274
52290
|
}
|
|
52291
|
+
|
|
52292
|
+
@if (hasMore()) {
|
|
52293
|
+
<div class="row-actions row-actions--center">
|
|
52294
|
+
<val-button [props]="loadMoreButtonProps()" (click)="onLoadMore()" />
|
|
52295
|
+
</div>
|
|
52296
|
+
}
|
|
52275
52297
|
}
|
|
52276
52298
|
</div>
|
|
52277
|
-
`, isInline: true, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:
|
|
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"] }] }); }
|
|
52278
52300
|
}
|
|
52279
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type:
|
|
52301
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: NotificationsViewComponent, decorators: [{
|
|
52280
52302
|
type: Component,
|
|
52281
|
-
args: [{ selector: 'val-
|
|
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: `
|
|
52282
52312
|
<div class="page">
|
|
52283
52313
|
<header class="page-header">
|
|
52284
|
-
<val-display [props]="{ size: 'small', color: 'dark', content:
|
|
52314
|
+
<val-display [props]="{ size: 'small', color: 'dark', content: pageTitle() }" />
|
|
52285
52315
|
<val-title
|
|
52286
52316
|
[props]="{
|
|
52287
52317
|
size: 'large',
|
|
52288
52318
|
color: 'dark',
|
|
52289
52319
|
bold: false,
|
|
52290
|
-
content:
|
|
52320
|
+
content: pageDescription(),
|
|
52291
52321
|
}"
|
|
52292
52322
|
/>
|
|
52323
|
+
@if (showMarkAll()) {
|
|
52324
|
+
<div class="row-actions">
|
|
52325
|
+
<val-button [props]="markAllButtonProps()" (click)="onMarkAll()" />
|
|
52326
|
+
</div>
|
|
52327
|
+
}
|
|
52293
52328
|
</header>
|
|
52294
52329
|
|
|
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
|
+
|
|
52295
52357
|
@if (loading()) {
|
|
52296
|
-
<
|
|
52297
|
-
|
|
52298
|
-
|
|
52299
|
-
} @else if (
|
|
52300
|
-
<
|
|
52301
|
-
<val-text
|
|
52302
|
-
[props]="{
|
|
52303
|
-
content: tt('errorTitle'),
|
|
52304
|
-
size: 'medium',
|
|
52305
|
-
color: 'medium',
|
|
52306
|
-
bold: false,
|
|
52307
|
-
}"
|
|
52308
|
-
/>
|
|
52309
|
-
<val-button [props]="retryButtonProps()" (click)="load()" />
|
|
52310
|
-
</div>
|
|
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()" />
|
|
52311
52363
|
} @else {
|
|
52312
|
-
@
|
|
52313
|
-
<section class="
|
|
52314
|
-
<
|
|
52315
|
-
|
|
52316
|
-
|
|
52317
|
-
|
|
52318
|
-
|
|
52319
|
-
|
|
52320
|
-
|
|
52321
|
-
|
|
52322
|
-
|
|
52323
|
-
|
|
52324
|
-
[
|
|
52325
|
-
|
|
52326
|
-
|
|
52327
|
-
|
|
52328
|
-
|
|
52329
|
-
|
|
52330
|
-
|
|
52331
|
-
|
|
52332
|
-
|
|
52333
|
-
|
|
52334
|
-
|
|
52335
|
-
|
|
52336
|
-
|
|
52337
|
-
|
|
52338
|
-
|
|
52339
|
-
|
|
52340
|
-
color: 'primary',
|
|
52341
|
-
weight: 'bold',
|
|
52342
|
-
}"
|
|
52343
|
-
/>
|
|
52344
|
-
</div>
|
|
52345
|
-
@if (role.description) {
|
|
52346
|
-
<span class="role-card__desc">{{ role.description }}</span>
|
|
52347
|
-
}
|
|
52348
|
-
@if (!role.hasPerms) {
|
|
52349
|
-
<div class="perm-chips">
|
|
52350
|
-
<span class="perm-chip perm-chip--empty">{{ tt('noPermissions') }}</span>
|
|
52351
|
-
</div>
|
|
52352
|
-
} @else {
|
|
52353
|
-
@for (g of role.groups; track g.key) {
|
|
52354
|
-
<div class="perm-group">
|
|
52355
|
-
<span class="perm-group__label">{{ g.label }}</span>
|
|
52356
|
-
<div class="perm-chips">
|
|
52357
|
-
@for (p of g.perms; track p) {
|
|
52358
|
-
<span class="perm-chip">{{ p }}</span>
|
|
52359
|
-
}
|
|
52360
|
-
</div>
|
|
52361
|
-
</div>
|
|
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
|
+
}
|
|
52362
52392
|
}
|
|
52363
|
-
|
|
52364
|
-
</div>
|
|
52365
|
-
}
|
|
52366
|
-
</div>
|
|
52367
|
-
</section>
|
|
52368
|
-
}
|
|
52393
|
+
</div>
|
|
52369
52394
|
|
|
52370
|
-
|
|
52371
|
-
|
|
52372
|
-
|
|
52373
|
-
|
|
52374
|
-
|
|
52375
|
-
|
|
52376
|
-
|
|
52377
|
-
|
|
52378
|
-
|
|
52379
|
-
|
|
52380
|
-
|
|
52381
|
-
<val-text
|
|
52382
|
-
[props]="{
|
|
52383
|
-
content: tt('customRolesHint'),
|
|
52384
|
-
size: 'medium',
|
|
52385
|
-
color: 'medium',
|
|
52386
|
-
bold: false,
|
|
52387
|
-
}"
|
|
52388
|
-
/>
|
|
52389
|
-
</div>
|
|
52390
|
-
@if (customRoles().length === 0) {
|
|
52391
|
-
<val-text
|
|
52392
|
-
[props]="{
|
|
52393
|
-
content: tt('noCustomRoles'),
|
|
52394
|
-
size: 'medium',
|
|
52395
|
-
color: 'medium',
|
|
52396
|
-
bold: false,
|
|
52397
|
-
}"
|
|
52398
|
-
/>
|
|
52399
|
-
} @else {
|
|
52400
|
-
<div class="role-list">
|
|
52401
|
-
@for (role of customRoles(); track role.id) {
|
|
52402
|
-
<div class="role-card">
|
|
52403
|
-
<div class="role-card__header">
|
|
52404
|
-
<span class="role-card__name">{{ role.name }}</span>
|
|
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>
|
|
52405
52406
|
</div>
|
|
52406
|
-
@if (
|
|
52407
|
-
<
|
|
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
|
+
}
|
|
52408
52416
|
}
|
|
52409
|
-
@if (
|
|
52410
|
-
<
|
|
52411
|
-
|
|
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)" />
|
|
52412
52423
|
</div>
|
|
52413
|
-
} @else {
|
|
52414
|
-
@for (g of role.groups; track g.key) {
|
|
52415
|
-
<div class="perm-group">
|
|
52416
|
-
<span class="perm-group__label">{{ g.label }}</span>
|
|
52417
|
-
<div class="perm-chips">
|
|
52418
|
-
@for (p of g.perms; track p) {
|
|
52419
|
-
<span class="perm-chip">{{ p }}</span>
|
|
52420
|
-
}
|
|
52421
|
-
</div>
|
|
52422
|
-
</div>
|
|
52423
|
-
}
|
|
52424
52424
|
}
|
|
52425
52425
|
</div>
|
|
52426
|
-
|
|
52427
|
-
|
|
52428
|
-
|
|
52426
|
+
</li>
|
|
52427
|
+
}
|
|
52428
|
+
</ul>
|
|
52429
52429
|
</section>
|
|
52430
52430
|
}
|
|
52431
|
+
|
|
52432
|
+
@if (hasMore()) {
|
|
52433
|
+
<div class="row-actions row-actions--center">
|
|
52434
|
+
<val-button [props]="loadMoreButtonProps()" (click)="onLoadMore()" />
|
|
52435
|
+
</div>
|
|
52436
|
+
}
|
|
52431
52437
|
}
|
|
52432
52438
|
</div>
|
|
52433
|
-
`, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:
|
|
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"] }]
|
|
52434
52440
|
}], ctorParameters: () => [], propDecorators: { config: [{
|
|
52435
52441
|
type: Input
|
|
52436
52442
|
}] } });
|
|
52437
52443
|
|
|
52438
52444
|
/**
|
|
52439
|
-
* Helper para montar
|
|
52440
|
-
* app del factory. El `config` se pasa por route `data`
|
|
52441
|
-
* componente lo lee como fallback de su
|
|
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
52449
|
*
|
|
52443
52450
|
* @example
|
|
52444
52451
|
* ```ts
|
|
52445
|
-
* //
|
|
52446
|
-
* export const
|
|
52447
|
-
* ...
|
|
52448
|
-
* { path: '
|
|
52452
|
+
* // app.routes.ts
|
|
52453
|
+
* export const appRoutes: Routes = [
|
|
52454
|
+
* ...provideValtechNotificationsRoutes(), // → /app/notifications
|
|
52455
|
+
* { path: 'settings', loadChildren: () => ... },
|
|
52449
52456
|
* ];
|
|
52450
52457
|
*
|
|
52451
52458
|
* // con config acotada:
|
|
52452
|
-
* ...
|
|
52453
|
-
* ...
|
|
52459
|
+
* ...provideValtechNotificationsRoutes({ config: { enableMarkAll: false } }),
|
|
52460
|
+
* ...provideValtechNotificationsRoutes({
|
|
52461
|
+
* config: { settingsNotificationsPath: '/app/preferences/push' },
|
|
52462
|
+
* }),
|
|
52454
52463
|
* ```
|
|
52455
52464
|
*/
|
|
52456
|
-
function
|
|
52465
|
+
function provideValtechNotificationsRoutes(opts) {
|
|
52457
52466
|
return [
|
|
52458
52467
|
{
|
|
52459
|
-
path: opts?.path ?? '
|
|
52460
|
-
component:
|
|
52461
|
-
data: {
|
|
52468
|
+
path: opts?.path ?? 'notifications',
|
|
52469
|
+
component: NotificationsViewComponent,
|
|
52470
|
+
data: { notificationsConfig: opts?.config },
|
|
52462
52471
|
},
|
|
52463
52472
|
];
|
|
52464
52473
|
}
|