@elasticias/core 0.0.16 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,17 @@
1
1
  import * as i0 from '@angular/core';
2
- import { Injectable, inject, signal, PLATFORM_ID, computed, effect } from '@angular/core';
2
+ import { Injectable, inject, Injector, signal, PLATFORM_ID, computed, effect, InjectionToken, DestroyRef } from '@angular/core';
3
3
  import { BehaviorSubject } from 'rxjs';
4
4
  import { StorageUtils } from '@elasticias/utils';
5
+ import { TranslateService } from '@ngx-translate/core';
5
6
  import { MessageService, ConfirmationService } from 'primeng/api';
6
- import { Router } from '@angular/router';
7
- import { PermissionsEnum } from '@elasticias/types';
7
+ import { Router, NavigationEnd } from '@angular/router';
8
+ import { Permissions } from '@elasticias/types';
8
9
  import { DOCUMENT, isPlatformBrowser } from '@angular/common';
9
- import { definePreset } from '@primeng/themes';
10
+ import { palette, updatePrimaryPalette, definePreset } from '@primeng/themes';
10
11
  import Aura from '@primeng/themes/aura';
12
+ import Lara from '@primeng/themes/lara';
13
+ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
14
+ import { filter } from 'rxjs/operators';
11
15
 
12
16
  class LoaderService {
13
17
  loadingSubject = new BehaviorSubject(false);
@@ -90,26 +94,148 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImpo
90
94
  args: [{ providedIn: 'root' }]
91
95
  }] });
92
96
 
93
- class ToastService {
94
- static DEFAULT_DELAY = 3000;
95
- static DEFAULT_DELAY_INFO = 5000;
96
- messageService = inject(MessageService);
97
- showInfo(message, title = 'Information', delay = ToastService.DEFAULT_DELAY_INFO) {
98
- this.messageService.add({ severity: 'info', summary: title, detail: message, life: delay });
97
+ /**
98
+ * Comptoir toast service — V2 successor to the legacy `ToastService`.
99
+ *
100
+ * Owns a signal-based queue (`toasts`) consumed by
101
+ * `<ef-toast-region>`, AND forwards every toast to PrimeNG's
102
+ * `MessageService` for back-compat with `<p-toast>` (still used by
103
+ * ClientApp v1). Either renderer picks the toasts up; both work.
104
+ *
105
+ * Default i18n keys (override per-call via `titleKey` / `textKey`):
106
+ * - `ef_toast_info_title`, `ef_toast_info_default`
107
+ * - `ef_toast_success_title`, `ef_toast_success_default`
108
+ * - `ef_toast_warn_title`, `ef_toast_warn_default`
109
+ * - `ef_toast_error_title`, `ef_toast_error_default`
110
+ *
111
+ * Default lifespans: info 5s, success 4s, warn 6s, error 8s.
112
+ */
113
+ class EfToastService {
114
+ static LIFE_INFO = 5000;
115
+ static LIFE_SUCCESS = 4000;
116
+ static LIFE_WARN = 6000;
117
+ static LIFE_ERROR = 8000;
118
+ /**
119
+ * Lazy holders. Resolving TranslateService eagerly at construction
120
+ * time pulls in HttpClient → HTTP_INTERCEPTORS → AuthorizeInterceptor
121
+ * → AuthorizeService → ToastService → cycle. We defer to the first
122
+ * actual translate / message-publish call.
123
+ */
124
+ injector = inject(Injector);
125
+ _translate;
126
+ _messageService;
127
+ _messageServiceResolved = false;
128
+ nextId = 1;
129
+ /** Live queue — `<ef-toast-region>` renders this. */
130
+ toasts = signal([], ...(ngDevMode ? [{ debugName: "toasts" }] : /* istanbul ignore next */ []));
131
+ /* ── Convenience methods (back-compat with the legacy
132
+ ToastService signature: `(message?, title?, life?)`). ── */
133
+ showInfo(message, title, life = EfToastService.LIFE_INFO) {
134
+ this.show({
135
+ severity: 'info',
136
+ title: title ?? this.t('ef_toast_info_title'),
137
+ text: message ?? this.t('ef_toast_info_default'),
138
+ life,
139
+ });
140
+ }
141
+ showSuccess(message, title, life = EfToastService.LIFE_SUCCESS) {
142
+ this.show({
143
+ severity: 'success',
144
+ title: title ?? this.t('ef_toast_success_title'),
145
+ text: message ?? this.t('ef_toast_success_default'),
146
+ life,
147
+ });
148
+ }
149
+ showWarn(message, title, life = EfToastService.LIFE_WARN) {
150
+ this.show({
151
+ severity: 'warn',
152
+ title: title ?? this.t('ef_toast_warn_title'),
153
+ text: message ?? this.t('ef_toast_warn_default'),
154
+ life,
155
+ });
99
156
  }
100
- showSuccess(message = 'Opération effectuée avec succès !', title = 'Succès', delay = ToastService.DEFAULT_DELAY) {
101
- this.messageService.add({ severity: 'success', summary: title, detail: message, life: delay });
157
+ showError(message, title, life = EfToastService.LIFE_ERROR) {
158
+ this.show({
159
+ severity: 'error',
160
+ title: title ?? this.t('ef_toast_error_title'),
161
+ text: message ?? this.t('ef_toast_error_default'),
162
+ life,
163
+ });
164
+ }
165
+ /** Generic show — opts can mix `title`/`titleKey`, `text`/`textKey`. */
166
+ show(opts) {
167
+ const severity = opts.severity ?? 'info';
168
+ const toast = {
169
+ id: this.nextId++,
170
+ severity,
171
+ title: this.resolve(opts.title, opts.titleKey, this.defaultTitleKey(severity)),
172
+ text: this.resolve(opts.text, opts.textKey, undefined),
173
+ life: opts.life ?? this.defaultLife(severity),
174
+ actions: opts.actions,
175
+ };
176
+ this.toasts.update(list => [...list, toast]);
177
+ // Forward to PrimeNG MessageService so v1's <p-toast> still
178
+ // catches the toast. Sticky in PrimeNG is `life: 0`.
179
+ this.messageService?.add({
180
+ severity: toast.severity,
181
+ summary: toast.title,
182
+ detail: toast.text,
183
+ life: toast.life || undefined,
184
+ sticky: toast.life === 0,
185
+ });
186
+ return toast;
187
+ }
188
+ get messageService() {
189
+ if (!this._messageServiceResolved) {
190
+ this._messageServiceResolved = true;
191
+ this._messageService = this.injector.get(MessageService, null, { optional: true });
192
+ }
193
+ return this._messageService ?? null;
102
194
  }
103
- showWarn(message, title = 'Avertissement', delay = ToastService.DEFAULT_DELAY) {
104
- this.messageService.add({ severity: 'warn', summary: title, detail: message, life: delay });
195
+ dismiss(id) {
196
+ this.toasts.update(list => list.filter(t => t.id !== id));
197
+ }
198
+ clear() {
199
+ this.toasts.set([]);
200
+ this.messageService?.clear();
201
+ }
202
+ /* (messageService getter is defined just below `show` to keep it
203
+ close to where it's consumed.) */
204
+ /* ── Internals ─────────────────────────────────────────────── */
205
+ resolve(literal, key, fallbackKey) {
206
+ if (literal != null)
207
+ return literal;
208
+ if (key)
209
+ return this.t(key);
210
+ if (fallbackKey)
211
+ return this.t(fallbackKey);
212
+ return '';
213
+ }
214
+ t(key) {
215
+ // Lazy-resolve TranslateService — see the field comment above
216
+ // for the AuthorizeService cycle this avoids.
217
+ this._translate ??= this.injector.get(TranslateService);
218
+ const value = this._translate.instant(key);
219
+ // `instant()` returns the key when no translation is loaded;
220
+ // fall through to empty string so untranslated toasts don't
221
+ // surface internal keys to end users.
222
+ return value === key ? '' : value;
223
+ }
224
+ defaultLife(severity) {
225
+ switch (severity) {
226
+ case 'info': return EfToastService.LIFE_INFO;
227
+ case 'success': return EfToastService.LIFE_SUCCESS;
228
+ case 'warn': return EfToastService.LIFE_WARN;
229
+ case 'error': return EfToastService.LIFE_ERROR;
230
+ }
105
231
  }
106
- showError(message = "Une erreur s'est produite lors du traitement de votre opération.", title = 'Erreur', delay = ToastService.DEFAULT_DELAY) {
107
- this.messageService.add({ severity: 'error', summary: title, detail: message, life: delay });
232
+ defaultTitleKey(severity) {
233
+ return `ef_toast_${severity}_title`;
108
234
  }
109
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: ToastService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
110
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: ToastService, providedIn: 'root' });
235
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: EfToastService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
236
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: EfToastService, providedIn: 'root' });
111
237
  }
112
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: ToastService, decorators: [{
238
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: EfToastService, decorators: [{
113
239
  type: Injectable,
114
240
  args: [{ providedIn: 'root' }]
115
241
  }] });
@@ -168,7 +294,7 @@ function screenGuard(config) {
168
294
  const router = inject(Router);
169
295
  const grantsKey = config?.grantsStorageKey ?? 'CURRENT_USER_GRANTS';
170
296
  const deniedRedirect = config?.deniedRedirect ?? '/';
171
- const requiredPermission = config?.requiredPermission ?? PermissionsEnum.Read;
297
+ const requiredPermission = config?.requiredPermission ?? Permissions.Read;
172
298
  const storageType = config?.storageType ?? 'local';
173
299
  const screenCode = getScreenCode(route);
174
300
  // If no screenCode found on this route or any parent, allow access
@@ -243,35 +369,35 @@ class EfThemeConfigService {
243
369
  platformId = inject(PLATFORM_ID);
244
370
  theme = computed(() => (this.appState()?.darkTheme ? 'dark' : 'light'), ...(ngDevMode ? [{ debugName: "theme" }] : /* istanbul ignore next */ []));
245
371
  transitionComplete = signal(false, ...(ngDevMode ? [{ debugName: "transitionComplete" }] : /* istanbul ignore next */ []));
246
- initialized = false;
247
372
  constructor() {
248
373
  const initialState = this.loadAppState();
249
374
  this.appState.set({ ...initialState });
375
+ // Apply preset class + RTL synchronously so the first paint has
376
+ // the right theme — the effect below picks up subsequent changes
377
+ // (including any subclass `appState.update()` issued before its
378
+ // first run).
250
379
  this.updatePresetClass(initialState);
380
+ if (isPlatformBrowser(this.platformId) && initialState?.RTL) {
381
+ this.document.documentElement.setAttribute('dir', 'rtl');
382
+ }
251
383
  effect(() => {
252
384
  const state = this.appState();
253
- if (!this.initialized || !state) {
254
- this.initialized = true;
385
+ if (!state)
255
386
  return;
256
- }
257
387
  this.saveAppState(state);
258
388
  this.updatePresetClass(state);
259
389
  this.handleDarkModeTransition(state);
260
390
  this.applyRTL(state);
261
391
  });
262
- // Apply RTL on initial load
263
- if (isPlatformBrowser(this.platformId)) {
264
- if (initialState?.RTL) {
265
- this.document.documentElement.setAttribute('dir', 'rtl');
266
- }
267
- }
268
392
  }
269
393
  static PRESET_CLASS_MAP = {
270
394
  Aura: 'theme-compact',
271
395
  Lara: 'theme-modern',
272
396
  Material: 'theme-material',
273
397
  Nora: 'theme-classic',
398
+ Comptoir: 'theme-comptoir',
274
399
  };
400
+ static TENANT_RAMP_STOPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900];
275
401
  static ALL_THEME_CLASSES = Object.values(EfThemeConfigService.PRESET_CLASS_MAP);
276
402
  updatePresetClass(state) {
277
403
  if (isPlatformBrowser(this.platformId)) {
@@ -387,6 +513,35 @@ class EfThemeConfigService {
387
513
  StorageUtils.setLocal(this.STORAGE_KEY, state);
388
514
  }
389
515
  }
516
+ /**
517
+ * Applies a tenant's brand color across both PrimeNG's primary palette
518
+ * and the Comptoir `--tenant-*` CSS variables.
519
+ *
520
+ * Generates a 50–950 ramp from the input hex via PrimeNG's `palette()`
521
+ * helper, hands the full ramp to `updatePrimaryPalette()`, and writes
522
+ * stops 50–900 onto `documentElement.style` so the SCSS layer's
523
+ * `var(--tenant-*)` references resolve to the tenant's color.
524
+ *
525
+ * Call this whenever the active tenant changes (e.g., from an effect
526
+ * watching `tenantService.storeConfig().primaryColor`).
527
+ */
528
+ setTenantAccent(hex) {
529
+ if (!hex)
530
+ return;
531
+ const ramp = palette(hex);
532
+ if (!ramp)
533
+ return;
534
+ updatePrimaryPalette(ramp);
535
+ if (isPlatformBrowser(this.platformId)) {
536
+ const root = this.document.documentElement;
537
+ for (const stop of EfThemeConfigService.TENANT_RAMP_STOPS) {
538
+ const value = ramp[String(stop)];
539
+ if (value) {
540
+ root.style.setProperty(`--tenant-${stop}`, value);
541
+ }
542
+ }
543
+ }
544
+ }
390
545
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: EfThemeConfigService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
391
546
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: EfThemeConfigService, providedIn: 'root' });
392
547
  }
@@ -397,6 +552,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImpo
397
552
  }]
398
553
  }], ctorParameters: () => [] });
399
554
 
555
+ /* ──────────────────────────────────────────────────────────────────
556
+ * LEGACY: Noir preset (Aura base, monochrome surface palette).
557
+ * Kept for backward-compat with apps that haven't migrated to
558
+ * Comptoir. Phase 5 of the design-system plan retrofits consumers
559
+ * to EfComptoirTheme; once that lands, Noir + EfTheme can be
560
+ * deleted.
561
+ * ────────────────────────────────────────────────────────────── */
400
562
  const Noir = definePreset(Aura, {
401
563
  semantic: {
402
564
  primary: {
@@ -448,13 +610,140 @@ const Noir = definePreset(Aura, {
448
610
  * Default Elasticias theme configuration for PrimeNG.
449
611
  * Uses the Noir preset (surface-based primary colors) with dark mode support.
450
612
  *
613
+ * @deprecated Migrate to {@link EfComptoirTheme} as part of Phase 5 of the
614
+ * design-system plan. Will be removed once all consumers have moved.
615
+ */
616
+ const EfTheme = {
617
+ preset: Noir,
618
+ options: {
619
+ darkModeSelector: '.p-dark',
620
+ }
621
+ };
622
+ /* ──────────────────────────────────────────────────────────────────
623
+ * COMPTOIR: ink surface + tenant primary (Lara base).
624
+ * Surface palette is the ink ramp from libs/tokens/colors.json.
625
+ * Primary palette defaults to the parfumerie sample tenant; it is
626
+ * runtime-replaced by EfThemeConfigService.setTenantAccent(hex)
627
+ * via PrimeNG's updatePrimaryPalette() API.
628
+ * ────────────────────────────────────────────────────────────── */
629
+ const ComptoirPreset = definePreset(Lara, {
630
+ semantic: {
631
+ primary: {
632
+ 50: '#f7f0f4',
633
+ 100: '#ecdce5',
634
+ 200: '#d8b4c5',
635
+ 300: '#b87a99',
636
+ 400: '#934e74',
637
+ 500: '#6f3257',
638
+ 600: '#54243f',
639
+ 700: '#401a30',
640
+ 800: '#2c1221',
641
+ 900: '#1a0913',
642
+ 950: '#0d040a'
643
+ },
644
+ /* ──────────────────────────────────────────────────────────────
645
+ * Control sizing (ADR-009). The native "comptoir" variant is the
646
+ * default render path and is pinned to --hit-base (40px) in CSS;
647
+ * these tokens align the OPT-IN PrimeNG variant (p-select filter,
648
+ * p-multiselect, p-inputnumber stepper, etc.) to the same canonical
649
+ * heights so the two paths agree:
650
+ * base → 40px (--hit-base) sm → 32px (--hit) lg → 48px (--hit-touch, POS/mobile)
651
+ * Lara form-field height ≈ paddingY*2 + lineHeight(1.5)*fontSize(14px) + 2px border.
652
+ * base: 8px*2 + 21 + 2 ≈ 40px · sm: 5px*2 + ~18 + 2 ≈ 32px · lg: 12px*2 + 21 + 2 ≈ 48px
653
+ * NOTE: exact pixel height depends on the app's root font-size; the
654
+ * native default path is the verified one — confirm the PrimeNG
655
+ * opt-in controls visually in the running app and nudge paddingY if
656
+ * they read 1-2px off. Border radius matches --r-md (12px). */
657
+ formField: {
658
+ paddingX: '0.75rem',
659
+ paddingY: '0.5rem',
660
+ borderRadius: '12px',
661
+ sm: {
662
+ fontSize: '0.78rem',
663
+ paddingX: '0.625rem',
664
+ paddingY: '0.3125rem'
665
+ },
666
+ lg: {
667
+ fontSize: '0.9375rem',
668
+ paddingX: '0.875rem',
669
+ paddingY: '0.75rem'
670
+ }
671
+ },
672
+ colorScheme: {
673
+ light: {
674
+ primary: {
675
+ color: '{primary.500}',
676
+ contrastColor: '#ffffff',
677
+ hoverColor: '{primary.600}',
678
+ activeColor: '{primary.700}'
679
+ },
680
+ surface: {
681
+ 0: '#ffffff',
682
+ 50: '#f8f9fb',
683
+ 100: '#f1f3f6',
684
+ 200: '#e4e8ee',
685
+ 300: '#cdd3dd',
686
+ 400: '#9ca5b3',
687
+ 500: '#6c7280',
688
+ 600: '#4a4f5a',
689
+ 700: '#2f3239',
690
+ 800: '#1d1f24',
691
+ 900: '#0f1115',
692
+ 950: '#06070a'
693
+ },
694
+ highlight: {
695
+ background: '{primary.500}',
696
+ focusBackground: '{primary.600}',
697
+ color: '#ffffff',
698
+ focusColor: '#ffffff'
699
+ }
700
+ },
701
+ dark: {
702
+ primary: {
703
+ color: '{primary.400}',
704
+ contrastColor: '{primary.950}',
705
+ hoverColor: '{primary.300}',
706
+ activeColor: '{primary.200}'
707
+ },
708
+ surface: {
709
+ 0: '#000000',
710
+ 50: '#06070a',
711
+ 100: '#0f1115',
712
+ 200: '#1d1f24',
713
+ 300: '#2f3239',
714
+ 400: '#4a4f5a',
715
+ 500: '#6c7280',
716
+ 600: '#9ca5b3',
717
+ 700: '#cdd3dd',
718
+ 800: '#e4e8ee',
719
+ 900: '#f1f3f6',
720
+ 950: '#f8f9fb'
721
+ },
722
+ highlight: {
723
+ background: '{primary.400}',
724
+ focusBackground: '{primary.300}',
725
+ color: '{primary.950}',
726
+ focusColor: '{primary.950}'
727
+ }
728
+ }
729
+ }
730
+ }
731
+ });
732
+ /**
733
+ * Comptoir theme configuration for PrimeNG (Phase 1 / design-system v0.2).
734
+ * Surface palette = ink ramp; primary palette = tenant accent
735
+ * (runtime-driven via `EfThemeConfigService.setTenantAccent(hex)`).
736
+ *
451
737
  * Usage with providePrimeNG:
452
738
  * ```ts
453
- * providePrimeNG({ theme: EfTheme, ripple: true })
739
+ * providePrimeNG({ theme: EfComptoirTheme, ripple: true })
454
740
  * ```
741
+ *
742
+ * Pair with `state.preset = 'Comptoir'` so the body class becomes
743
+ * `theme-comptoir` (avoids legacy `theme-modern` radius overrides).
455
744
  */
456
- const EfTheme = {
457
- preset: Noir,
745
+ const EfComptoirTheme = {
746
+ preset: ComptoirPreset,
458
747
  options: {
459
748
  darkModeSelector: '.p-dark',
460
749
  }
@@ -847,9 +1136,336 @@ const PRIMENG_AR_LOCALE = {
847
1136
  },
848
1137
  };
849
1138
 
1139
+ /**
1140
+ * Default skeleton for the eight ERP modules. Apps either consume this
1141
+ * directly via `EF_MODULES_TOKEN` or extend it with their own `navSections`.
1142
+ *
1143
+ * Phase 1 ships the metadata only — `ef-module-rail` (Phase 2) uses
1144
+ * `id` / `labelKey` / `icon` / `accent` / `defaultRoute`. `navSections`
1145
+ * are populated per-app as each module's screens land in Phase 4-5.
1146
+ */
1147
+ const EF_MODULES = [
1148
+ {
1149
+ id: 'sales',
1150
+ labelKey: 'modules.sales',
1151
+ icon: 'pi pi-shopping-bag',
1152
+ accent: 'm-sales',
1153
+ defaultRoute: '/operations/sales',
1154
+ navSections: [],
1155
+ group: 'operations',
1156
+ },
1157
+ {
1158
+ id: 'purchase',
1159
+ labelKey: 'modules.purchase',
1160
+ icon: 'pi pi-truck',
1161
+ accent: 'm-purchase',
1162
+ defaultRoute: '/operations/purchase',
1163
+ navSections: [],
1164
+ group: 'operations',
1165
+ },
1166
+ {
1167
+ id: 'stock',
1168
+ labelKey: 'modules.stock',
1169
+ icon: 'pi pi-warehouse',
1170
+ accent: 'm-stock',
1171
+ defaultRoute: '/operations/stock',
1172
+ navSections: [],
1173
+ group: 'operations',
1174
+ },
1175
+ {
1176
+ id: 'pos',
1177
+ labelKey: 'modules.pos',
1178
+ icon: 'pi pi-shop',
1179
+ accent: 'm-pos',
1180
+ defaultRoute: '/pos',
1181
+ navSections: [],
1182
+ group: 'operations',
1183
+ },
1184
+ {
1185
+ id: 'marketing',
1186
+ labelKey: 'modules.marketing',
1187
+ icon: 'pi pi-megaphone',
1188
+ accent: 'm-marketing',
1189
+ defaultRoute: '/marketing',
1190
+ navSections: [],
1191
+ group: 'commerce',
1192
+ },
1193
+ {
1194
+ id: 'store',
1195
+ labelKey: 'modules.store',
1196
+ icon: 'pi pi-globe',
1197
+ accent: 'm-store',
1198
+ defaultRoute: '/store',
1199
+ navSections: [],
1200
+ group: 'commerce',
1201
+ },
1202
+ {
1203
+ id: 'finance',
1204
+ labelKey: 'modules.finance',
1205
+ icon: 'pi pi-chart-line',
1206
+ accent: 'm-finance',
1207
+ defaultRoute: '/finance',
1208
+ navSections: [],
1209
+ group: 'commerce',
1210
+ },
1211
+ {
1212
+ id: 'admin',
1213
+ labelKey: 'modules.admin',
1214
+ icon: 'pi pi-shield',
1215
+ accent: 'm-admin',
1216
+ defaultRoute: '/admin',
1217
+ navSections: [],
1218
+ group: 'admin',
1219
+ },
1220
+ ];
1221
+ /**
1222
+ * DI token that the shell components (`ef-module-rail`, `ef-module-side`)
1223
+ * read from. Apps provide their own definition (typically extending
1224
+ * `EF_MODULES` with populated `navSections`):
1225
+ *
1226
+ * ```ts
1227
+ * providers: [
1228
+ * { provide: EF_MODULES_TOKEN, useValue: APP_MODULES }
1229
+ * ]
1230
+ * ```
1231
+ */
1232
+ const EF_MODULES_TOKEN = new InjectionToken('EF_MODULES', { providedIn: 'root', factory: () => EF_MODULES });
1233
+
1234
+ /**
1235
+ * Single source of truth for "which ERP module is active right now."
1236
+ *
1237
+ * Watches the Router and matches the current URL against each module's
1238
+ * `defaultRoute`. The longest matching prefix wins, so `/operations/sales`
1239
+ * resolves to `sales` even though `/operations` could in theory match
1240
+ * something shorter.
1241
+ *
1242
+ * Consumers — `ef-module-rail`, `ef-module-side`, `ef-app-main`,
1243
+ * `ef-page-head` — read `activeModule()` and derive their state from it.
1244
+ *
1245
+ * Apps that navigate programmatically without a URL change (rare) can
1246
+ * call `setActiveModule(id)` to override.
1247
+ */
1248
+ class EfActiveModuleService {
1249
+ router = inject(Router);
1250
+ modules = inject(EF_MODULES_TOKEN);
1251
+ destroyRef = inject(DestroyRef);
1252
+ _activeModule = signal(null, ...(ngDevMode ? [{ debugName: "_activeModule" }] : /* istanbul ignore next */ []));
1253
+ _activeNavItem = signal(null, ...(ngDevMode ? [{ debugName: "_activeNavItem" }] : /* istanbul ignore next */ []));
1254
+ activeModule = this._activeModule.asReadonly();
1255
+ activeModuleId = computed(() => this._activeModule()?.id ?? null, ...(ngDevMode ? [{ debugName: "activeModuleId" }] : /* istanbul ignore next */ []));
1256
+ activeNavItem = this._activeNavItem.asReadonly();
1257
+ constructor() {
1258
+ this.resolveFromUrl(this.router.url);
1259
+ this.router.events
1260
+ .pipe(filter((e) => e instanceof NavigationEnd), takeUntilDestroyed(this.destroyRef))
1261
+ .subscribe(e => this.resolveFromUrl(e.urlAfterRedirects));
1262
+ }
1263
+ /**
1264
+ * Force the active module. Most apps don't need this — the router
1265
+ * subscription keeps `activeModule()` in sync automatically.
1266
+ */
1267
+ setActiveModule(id) {
1268
+ if (id === null) {
1269
+ this._activeModule.set(null);
1270
+ return;
1271
+ }
1272
+ const match = this.modules.find(m => m.id === id);
1273
+ if (match)
1274
+ this._activeModule.set(match);
1275
+ }
1276
+ resolveFromUrl(url) {
1277
+ const path = url.split('?')[0].split('#')[0];
1278
+ let bestModule = null;
1279
+ let bestNavItem = null;
1280
+ let bestLen = 0;
1281
+ for (const m of this.modules) {
1282
+ for (const route of this.routesFor(m)) {
1283
+ if (path === route || path.startsWith(route + '/')) {
1284
+ if (route.length > bestLen) {
1285
+ bestModule = m;
1286
+ bestNavItem = this.findNavItem(m, route);
1287
+ bestLen = route.length;
1288
+ }
1289
+ }
1290
+ }
1291
+ }
1292
+ this._activeModule.set(bestModule);
1293
+ this._activeNavItem.set(bestNavItem);
1294
+ }
1295
+ findNavItem(m, route) {
1296
+ for (const section of m.navSections) {
1297
+ for (const item of section.items) {
1298
+ if (item.route === route)
1299
+ return item;
1300
+ }
1301
+ }
1302
+ return null;
1303
+ }
1304
+ /**
1305
+ * Every URL prefix that should resolve back to this module: the
1306
+ * `defaultRoute` plus every nav item route. Modules whose nav items
1307
+ * span multiple URL prefixes (e.g. sales spread across `/operations/sales`
1308
+ * and `/parameters/sales`) need this to stay active across all of them.
1309
+ */
1310
+ routesFor(m) {
1311
+ const routes = [m.defaultRoute];
1312
+ for (const section of m.navSections) {
1313
+ for (const item of section.items) {
1314
+ routes.push(item.route);
1315
+ }
1316
+ }
1317
+ return routes;
1318
+ }
1319
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: EfActiveModuleService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1320
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: EfActiveModuleService, providedIn: 'root' });
1321
+ }
1322
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: EfActiveModuleService, decorators: [{
1323
+ type: Injectable,
1324
+ args: [{ providedIn: 'root' }]
1325
+ }], ctorParameters: () => [] });
1326
+
1327
+ const QUERIES = [
1328
+ { viewport: 'mobile', query: '(max-width: 767px)' },
1329
+ { viewport: 'tablet', query: '(min-width: 768px) and (max-width: 1279px)' },
1330
+ { viewport: 'desktop', query: '(min-width: 1280px)' },
1331
+ ];
1332
+ /**
1333
+ * Emits the current viewport tier based on `window.matchMedia` breakpoints.
1334
+ *
1335
+ * Breakpoints come from `libs/tokens/targets.json`:
1336
+ * - mobile: ≤ 767px
1337
+ * - tablet: 768 – 1279px
1338
+ * - desktop: ≥ 1280px
1339
+ *
1340
+ * SSR-safe: returns `'desktop'` when `window` is unavailable.
1341
+ *
1342
+ * Usage:
1343
+ * ```ts
1344
+ * private viewport = inject(EfViewportService);
1345
+ *
1346
+ * isMobile = computed(() => this.viewport.current() === 'mobile');
1347
+ * ```
1348
+ */
1349
+ class EfViewportService {
1350
+ document = inject(DOCUMENT);
1351
+ platformId = inject(PLATFORM_ID);
1352
+ destroyRef = inject(DestroyRef);
1353
+ _current = signal('desktop', ...(ngDevMode ? [{ debugName: "_current" }] : /* istanbul ignore next */ []));
1354
+ current = this._current.asReadonly();
1355
+ isMobile = computed(() => this._current() === 'mobile', ...(ngDevMode ? [{ debugName: "isMobile" }] : /* istanbul ignore next */ []));
1356
+ isTablet = computed(() => this._current() === 'tablet', ...(ngDevMode ? [{ debugName: "isTablet" }] : /* istanbul ignore next */ []));
1357
+ isDesktop = computed(() => this._current() === 'desktop', ...(ngDevMode ? [{ debugName: "isDesktop" }] : /* istanbul ignore next */ []));
1358
+ constructor() {
1359
+ if (!isPlatformBrowser(this.platformId))
1360
+ return;
1361
+ const win = this.document.defaultView;
1362
+ if (!win || typeof win.matchMedia !== 'function')
1363
+ return;
1364
+ const lists = QUERIES.map(({ viewport, query }) => {
1365
+ const mql = win.matchMedia(query);
1366
+ const handler = (e) => {
1367
+ if (e.matches)
1368
+ this._current.set(viewport);
1369
+ };
1370
+ handler(mql);
1371
+ mql.addEventListener('change', handler);
1372
+ return { mql, handler };
1373
+ });
1374
+ this.destroyRef.onDestroy(() => {
1375
+ for (const { mql, handler } of lists) {
1376
+ mql.removeEventListener('change', handler);
1377
+ }
1378
+ });
1379
+ }
1380
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: EfViewportService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1381
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: EfViewportService, providedIn: 'root' });
1382
+ }
1383
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: EfViewportService, decorators: [{
1384
+ type: Injectable,
1385
+ args: [{ providedIn: 'root' }]
1386
+ }], ctorParameters: () => [] });
1387
+
1388
+ const LEVEL_ORDER = {
1389
+ none: 0,
1390
+ read: 1,
1391
+ write: 2,
1392
+ admin: 3,
1393
+ };
1394
+ const ACTION_REQUIRED = {
1395
+ read: 'read',
1396
+ write: 'write',
1397
+ admin: 'admin',
1398
+ };
1399
+ /**
1400
+ * Module-scoped permission service.
1401
+ *
1402
+ * Apps populate it from their auth bootstrap once the user's profile is
1403
+ * loaded — typically via `setPermissions()` or by providing a custom
1404
+ * source signal:
1405
+ *
1406
+ * ```ts
1407
+ * // bootstrap.ts
1408
+ * const perms = inject(EfPermissionService);
1409
+ * perms.setPermissions(profile.permissions);
1410
+ * ```
1411
+ *
1412
+ * The service is the single source of truth for shell components
1413
+ * (`ef-module-rail`, `ef-module-side`, `*efCan` directive) and routing
1414
+ * defaults. Once the screens/menus → Mongo migration lands, the
1415
+ * permissions list will be served denormalized on the user/profile
1416
+ * document and consumed here without a join.
1417
+ */
1418
+ class EfPermissionService {
1419
+ modules = inject(EF_MODULES_TOKEN);
1420
+ _permissions = signal([], ...(ngDevMode ? [{ debugName: "_permissions" }] : /* istanbul ignore next */ []));
1421
+ permissions = this._permissions.asReadonly();
1422
+ /**
1423
+ * Replace the current permission set. Pass `[]` to clear (e.g., on logout).
1424
+ */
1425
+ setPermissions(perms) {
1426
+ this._permissions.set(perms);
1427
+ }
1428
+ /**
1429
+ * The level granted to the current user for a given module.
1430
+ * Returns `'none'` if the module is not in the permission set.
1431
+ */
1432
+ level(module) {
1433
+ return this._permissions().find(p => p.module === module)?.level ?? 'none';
1434
+ }
1435
+ /**
1436
+ * Whether the current user can perform `action` on `module`.
1437
+ * Levels are hierarchical: `admin` > `write` > `read` > `none`.
1438
+ */
1439
+ can(module, action = 'read') {
1440
+ return LEVEL_ORDER[this.level(module)] >= LEVEL_ORDER[ACTION_REQUIRED[action]];
1441
+ }
1442
+ /**
1443
+ * The list of modules the user can read, in registry order.
1444
+ * Used by `ef-module-rail` to decide which icons render.
1445
+ */
1446
+ visibleModules = computed(() => {
1447
+ const perms = this._permissions();
1448
+ const granted = new Set(perms.filter(p => p.level !== 'none').map(p => p.module));
1449
+ return this.modules.filter(m => granted.has(m.id));
1450
+ }, ...(ngDevMode ? [{ debugName: "visibleModules" }] : /* istanbul ignore next */ []));
1451
+ /**
1452
+ * The first visible module — the default landing module after login.
1453
+ * Returns `null` when the user has no modules.
1454
+ */
1455
+ defaultModule = computed(() => {
1456
+ return this.visibleModules()[0] ?? null;
1457
+ }, ...(ngDevMode ? [{ debugName: "defaultModule" }] : /* istanbul ignore next */ []));
1458
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: EfPermissionService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1459
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: EfPermissionService, providedIn: 'root' });
1460
+ }
1461
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: EfPermissionService, decorators: [{
1462
+ type: Injectable,
1463
+ args: [{ providedIn: 'root' }]
1464
+ }] });
1465
+
850
1466
  /**
851
1467
  * Generated bundle index. Do not edit.
852
1468
  */
853
1469
 
854
- export { CacheService, ConfirmDialogService, DEFAULT_APP_STATE, EfTheme, EfThemeConfigService, LoaderService, PRIMENG_AR_LOCALE, PRIMENG_EN_LOCALE, PRIMENG_FR_LOCALE, ToastService, hasScreenPermission, screenGuard };
1470
+ export { CacheService, ConfirmDialogService, DEFAULT_APP_STATE, EF_MODULES, EF_MODULES_TOKEN, EfActiveModuleService, EfComptoirTheme, EfPermissionService, EfTheme, EfThemeConfigService, EfToastService, EfViewportService, LoaderService, PRIMENG_AR_LOCALE, PRIMENG_EN_LOCALE, PRIMENG_FR_LOCALE, EfToastService as ToastService, hasScreenPermission, screenGuard };
855
1471
  //# sourceMappingURL=elasticias-core.mjs.map