@elasticias/screens 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,11 +1,12 @@
1
1
  import * as i0 from '@angular/core';
2
- import { Input, Component, signal, InjectionToken, inject, ChangeDetectorRef, ViewChild, Injector } from '@angular/core';
2
+ import { Input, Component, signal, InjectionToken, inject, ChangeDetectorRef, ViewChild, Injector, computed, DestroyRef } from '@angular/core';
3
3
  import { Subject, combineLatest, take } from 'rxjs';
4
- import { PermissionsEnum } from '@elasticias/types';
4
+ import { Permissions } from '@elasticias/types';
5
5
  import { Router, ActivatedRoute } from '@angular/router';
6
- import { StorageUtils, AppUtils } from '@elasticias/utils';
6
+ import { StorageUtils, AppUtils, CsvUtils } from '@elasticias/utils';
7
7
  import { CacheService, ToastService, ConfirmDialogService } from '@elasticias/core';
8
8
  import { Location } from '@angular/common';
9
+ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
9
10
 
10
11
  class AbstractEntity {
11
12
  clone(entity) {
@@ -105,11 +106,40 @@ class ScreenContext extends AbstractEntity {
105
106
  isGranted(permission) {
106
107
  return this.grants?.some((value) => value === permission) ?? false;
107
108
  }
109
+ /* ── Permission convenience getters ───────────────────────────────
110
+ Shared components (ef-row-actions, ef-data-card auto actions cell,
111
+ toolbars) read these to default-show / default-hide CRUD buttons
112
+ against the current user's grants without re-importing
113
+ Permissions at every call site. */
114
+ get hasReadPermission() {
115
+ return this.isGranted(Permissions.Read);
116
+ }
117
+ get hasCreatePermission() {
118
+ return this.isGranted(Permissions.Create);
119
+ }
120
+ get hasEditPermission() {
121
+ return this.isGranted(Permissions.Edit);
122
+ }
123
+ get hasDeletePermission() {
124
+ return this.isGranted(Permissions.Delete);
125
+ }
126
+ get hasDuplicatePermission() {
127
+ return this.isGranted(Permissions.Duplicate);
128
+ }
129
+ get hasPrintPermission() {
130
+ return this.isGranted(Permissions.Print);
131
+ }
132
+ get hasExportPermission() {
133
+ return this.isGranted(Permissions.Export);
134
+ }
135
+ get hasImportPermission() {
136
+ return this.isGranted(Permissions.Import);
137
+ }
108
138
  isReadOnly() {
109
- return (this.isGranted(PermissionsEnum.Read) &&
110
- !this.isGranted(PermissionsEnum.Write) &&
111
- !this.isGranted(PermissionsEnum.Edit) &&
112
- !this.isGranted(PermissionsEnum.Delete));
139
+ return (this.isGranted(Permissions.Read) &&
140
+ !this.isGranted(Permissions.Create) &&
141
+ !this.isGranted(Permissions.Edit) &&
142
+ !this.isGranted(Permissions.Delete));
113
143
  }
114
144
  }
115
145
 
@@ -175,6 +205,25 @@ class AbstractScreenComponent extends AbstractComponent {
175
205
  getConfig() {
176
206
  return null;
177
207
  }
208
+ /**
209
+ * Read a query param from the activated route's snapshot. Shared
210
+ * accessor for deep-links on both search and detail screens (e.g.
211
+ * `?status=PendingApproval`, `?mode=duplicate`) so screens don't
212
+ * reach into `route.snapshot.queryParamMap` themselves.
213
+ */
214
+ queryParam(name) {
215
+ return this.route.snapshot.queryParamMap.get(name);
216
+ }
217
+ /**
218
+ * Grant check for ANY screen (not just this one's `SCREEN` code) —
219
+ * e.g. an operational strip or report widget calling a
220
+ * differently-gated endpoint. For this screen's own grants prefer
221
+ * `context.isGranted(...)`.
222
+ */
223
+ hasGrant(screen, permission) {
224
+ const grants = StorageUtils.getLocal('CURRENT_USER_GRANTS');
225
+ return !!grants?.[screen]?.permissions?.includes(permission);
226
+ }
178
227
  getBundleName() {
179
228
  const config = this.getConfig();
180
229
  if (config) {
@@ -288,7 +337,17 @@ class AbstractScreenComponent extends AbstractComponent {
288
337
  }
289
338
  }
290
339
  setServerErrors(errors) {
291
- this.serverErrors.set(errors || {});
340
+ // Backend (FluentValidation) keys are PascalCase (e.g. `ClientType`);
341
+ // template field bindings and the camelCased `setFormErrors` path use
342
+ // camelCase. Normalize here so screens can read `serverErrors()['clientType']`
343
+ // and pass it straight to an `ef-*` control's `[errors]` input.
344
+ const normalized = {};
345
+ if (errors) {
346
+ Object.keys(errors).forEach((key) => {
347
+ normalized[AppUtils.toCamelCase(key)] = errors[key];
348
+ });
349
+ }
350
+ this.serverErrors.set(normalized);
292
351
  }
293
352
  clearServerErrors() {
294
353
  this.serverErrors.set({});
@@ -327,6 +386,7 @@ var ScreenStateEnum;
327
386
  ScreenStateEnum["DETAIL"] = "DETAIL";
328
387
  ScreenStateEnum["SEARCH"] = "SEARCH";
329
388
  ScreenStateEnum["CUSTOM"] = "CUSTOM";
389
+ ScreenStateEnum["REPORT"] = "REPORT";
330
390
  })(ScreenStateEnum || (ScreenStateEnum = {}));
331
391
  var StateUtilsEnum;
332
392
  (function (StateUtilsEnum) {
@@ -803,6 +863,1209 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImpo
803
863
  args: ['pTable']
804
864
  }] } });
805
865
 
866
+ /**
867
+ * Signal-first counterpart to {@link AbstractSearchScreenComponent}.
868
+ *
869
+ * Built for V2 + zoneless apps: state is exposed as signals
870
+ * (`items`, `loading`, `errorMsg`, `totalCount`, `criteria`) and the
871
+ * class is intentionally **decoupled from PrimeNG `<p-table>`** —
872
+ * subclasses render however they want (Comptoir `.tbl` patterns,
873
+ * cards, virtual lists, …) and call the helpers below to mutate the
874
+ * search criteria + re-execute.
875
+ *
876
+ * Same `ScreenConfig` surface as the legacy abstract:
877
+ * - `SCREEN` (string code matching backend grants)
878
+ * - `SERVICE` (NSwag client class with `search(criteria)` method)
879
+ * - `SEARCH_REFERENTIALS_KEYS` / `SEARCH_STATIC_LISTS` for ref-data
880
+ * - `DEFAULT_SORT` for first load
881
+ *
882
+ * Subclasses typically:
883
+ *
884
+ * ```ts
885
+ * @Component({ ... })
886
+ * export class FooComponent extends AbstractSearchScreenV2<FooDto> {
887
+ * protected override getConfig() { return FooConfig; }
888
+ * readonly rows = computed(() => this.items().map(toRow));
889
+ * }
890
+ * ```
891
+ */
892
+ class AbstractSearchScreenV2 extends AbstractScreenComponent {
893
+ screenState = ScreenStateEnum.SEARCH;
894
+ injector = inject(Injector);
895
+ serviceInstance;
896
+ /** Monotonic guard: bumped on every `search()` call so an
897
+ * out-of-order (stale) response from an earlier search can be
898
+ * dropped instead of overwriting the latest results. */
899
+ searchSeq = 0;
900
+ /** Current search results — populated after each `search()`. */
901
+ items = signal([], ...(ngDevMode ? [{ debugName: "items" }] : /* istanbul ignore next */ []));
902
+ totalCount = signal(0, ...(ngDevMode ? [{ debugName: "totalCount" }] : /* istanbul ignore next */ []));
903
+ loading = signal(false, ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
904
+ errorMsg = signal(null, ...(ngDevMode ? [{ debugName: "errorMsg" }] : /* istanbul ignore next */ []));
905
+ /** Live search criteria (paging, sort, text, dates, custom). */
906
+ criteria = signal(new SearchEntity({}), ...(ngDevMode ? [{ debugName: "criteria" }] : /* istanbul ignore next */ []));
907
+ /**
908
+ * Active date-range filter shown in `ef-datepicker-advanced`'s
909
+ * trigger. Display-only at construction time — actually filters
910
+ * the search once `onDateRangeChange()` fires (or a screen wires
911
+ * one in `ngOnInit`).
912
+ *
913
+ * Override `buildDefaultDateRange()` per screen to ship a
914
+ * different starting preset.
915
+ */
916
+ dateRange = signal(this.buildDefaultDateRange(), ...(ngDevMode ? [{ debugName: "dateRange" }] : /* istanbul ignore next */ []));
917
+ /* ── Filter-bar UI state (DRY) ──────────────────────────────────
918
+ Identical across every list screen — lifted up so subclasses
919
+ don't re-declare. `clear()` resets all four signals. */
920
+ /** Free-text search input value — bound `[(searchText)]` on
921
+ * ef-smart-bar; drives `runSearch()`. */
922
+ searchQuery = signal('', ...(ngDevMode ? [{ debugName: "searchQuery" }] : /* istanbul ignore next */ []));
923
+ /** Active status pill-group selection (defaults to `'all'`). */
924
+ statusFilter = signal('all', ...(ngDevMode ? [{ debugName: "statusFilter" }] : /* istanbul ignore next */ []));
925
+ /** Whether the advanced-filter drawer is open. */
926
+ drawerOpen = signal(false, ...(ngDevMode ? [{ debugName: "drawerOpen" }] : /* istanbul ignore next */ []));
927
+ /** Active named filter chips shown in the smart-bar. */
928
+ activeFilters = signal([], ...(ngDevMode ? [{ debugName: "activeFilters" }] : /* istanbul ignore next */ []));
929
+ /* ── Advanced-filter selects (DRY) ──────────────────────────────
930
+ Declarative `<ef-select>` filters rendered in the filter drawer.
931
+ Override `advancedFilters` per screen; the base owns the value
932
+ state, the apply→criteria push, and the reset. Type-agnostic:
933
+ each `key` maps to a typed backend query prop of any shape. */
934
+ /** Advanced-filter select definitions. Empty = no advanced filters. */
935
+ advancedFilters = [];
936
+ /** Criteria key of the screen's activation tri-state filter
937
+ * (`ef-activation-filter`), e.g. `'isActive'`. When set, the base
938
+ * applies, baseline-clears, and cache-restores the boolean like any
939
+ * declared advanced filter — the screen only binds the component to
940
+ * `advancedValues()` / `setAdvancedValue()`. `null` = no activation
941
+ * filter. */
942
+ activationFilterKey = null;
943
+ /** Live values per advanced filter, keyed by `AdvancedSelectFilter.key`. */
944
+ advancedValues = signal({}, ...(ngDevMode ? [{ debugName: "advancedValues" }] : /* istanbul ignore next */ []));
945
+ /** Store the picked value(s) for one advanced filter (no search yet —
946
+ * the drawer's `(apply)` runs it). */
947
+ setAdvancedValue(key, value) {
948
+ this.advancedValues.update((v) => ({ ...v, [key]: value }));
949
+ }
950
+ /** Push every advanced-filter value into the criteria as typed
951
+ * top-level props and re-run the search. Wired to the drawer's
952
+ * `(apply)`. Every DECLARED key is written on apply — a cleared
953
+ * control must actively remove its (possibly cache-restored)
954
+ * criteria value, not silently leave it behind. */
955
+ applyAdvancedFilters() {
956
+ const patch = {};
957
+ for (const f of this.advancedFilters)
958
+ patch[f.key] = undefined;
959
+ if (this.activationFilterKey)
960
+ patch[this.activationFilterKey] = undefined;
961
+ Object.assign(patch, this.advancedValues());
962
+ this.patchCriteria(patch);
963
+ }
964
+ /* ── Selection (DRY) ────────────────────────────────────────────
965
+ Per-row checkbox state, used by `ef-bulk-bar` and the auto
966
+ row-actions cell. */
967
+ /** Selected row ids — keyed by `String(rowId(row))`. */
968
+ selected = signal(new Set(), ...(ngDevMode ? [{ debugName: "selected" }] : /* istanbul ignore next */ []));
969
+ /** Live count derived from `selected`. */
970
+ selectionCount = computed(() => this.selected().size, ...(ngDevMode ? [{ debugName: "selectionCount" }] : /* istanbul ignore next */ []));
971
+ /* ── Sort indicator (DRY) ───────────────────────────────────────
972
+ ef-data-card consumes `[sort]` as `{ field, direction: 'asc' |
973
+ 'desc' }`. The base criteria stores the legacy
974
+ 'Ascending' / 'Descending' strings — convert lazily here. */
975
+ currentSort = computed(() => {
976
+ const s = this.criteria().sort?.[0];
977
+ if (!s?.field)
978
+ return null;
979
+ return {
980
+ field: s.field,
981
+ direction: (s.sortDirection ?? '').toLowerCase().startsWith('asc')
982
+ ? 'asc'
983
+ : 'desc',
984
+ };
985
+ }, ...(ngDevMode ? [{ debugName: "currentSort" }] : /* istanbul ignore next */ []));
986
+ /* ── Row-actions standard surface ───────────────────────────────
987
+ Subclasses can flip these off when a particular CRUD action
988
+ isn't applicable. Defaults are all-on; permission filtering
989
+ still happens at render time via ScreenContext, so an action
990
+ that the user can't perform is hidden regardless of these
991
+ flags. */
992
+ showViewAction = signal(true, ...(ngDevMode ? [{ debugName: "showViewAction" }] : /* istanbul ignore next */ []));
993
+ showEditAction = signal(true, ...(ngDevMode ? [{ debugName: "showEditAction" }] : /* istanbul ignore next */ []));
994
+ showDuplicateAction = signal(true, ...(ngDevMode ? [{ debugName: "showDuplicateAction" }] : /* istanbul ignore next */ []));
995
+ showDeleteAction = signal(true, ...(ngDevMode ? [{ debugName: "showDeleteAction" }] : /* istanbul ignore next */ []));
996
+ /**
997
+ * PK accessor for a row. Default returns `row?.id` — override when
998
+ * the backend names its primary key something else (e.g. sales-orders'
999
+ * `orderId`). Used by the row-actions dispatcher and as the implicit
1000
+ * navigateToDetails / edit / delete argument.
1001
+ */
1002
+ rowId(row) {
1003
+ return row?.id;
1004
+ }
1005
+ /**
1006
+ * Dispatch helper wired to ef-data-card's `(rowAction)` output and
1007
+ * the auto-rendered row-actions cell. Routes the standard four
1008
+ * actions to the inherited methods so subclasses don't have to
1009
+ * declare per-screen rowActions arrays.
1010
+ */
1011
+ onRowAction(action, row) {
1012
+ const id = this.rowId(row);
1013
+ switch (action) {
1014
+ case 'view':
1015
+ this.navigateToDetails(id);
1016
+ break;
1017
+ case 'edit':
1018
+ this.edit(id);
1019
+ break;
1020
+ case 'duplicate':
1021
+ this.duplicate(id);
1022
+ break;
1023
+ case 'delete':
1024
+ this.delete(id);
1025
+ break;
1026
+ }
1027
+ }
1028
+ /* ── Date-range filter (DRY) ────────────────────────────────────
1029
+ Wired to ef-datepicker-advanced. The default range is
1030
+ `last_30_days` — override `buildDefaultDateRange()` per screen
1031
+ if a different starting preset is needed. */
1032
+ /**
1033
+ * Wired to `<ef-datepicker-advanced (rangeChange)>` — stores the
1034
+ * range for trigger display and pushes it into the search criteria
1035
+ * so the next `search()` filters by it.
1036
+ */
1037
+ onDateRangeChange(range) {
1038
+ this.dateRange.set(range);
1039
+ this.setDateRange(range.start, range.end);
1040
+ }
1041
+ /**
1042
+ * Reset the date range to the default. Called automatically by
1043
+ * `clear()` so screens don't have to remember to invoke it from
1044
+ * their own `clearAll()` orchestration.
1045
+ */
1046
+ resetDateRange() {
1047
+ this.dateRange.set(this.buildDefaultDateRange());
1048
+ }
1049
+ /**
1050
+ * Build the default `EfDateRange`. Override per screen to ship a
1051
+ * different default — e.g., a 90-day window for low-velocity
1052
+ * catalogues. Default: last 30 days.
1053
+ */
1054
+ buildDefaultDateRange() {
1055
+ return {
1056
+ start: this.daysAgo(29),
1057
+ end: this.startOfToday(),
1058
+ presetKey: 'last_30_days',
1059
+ labelKey: 'date_preset_last_30_days',
1060
+ label: '30 derniers jours',
1061
+ };
1062
+ }
1063
+ /** Today at 00:00 local time. */
1064
+ startOfToday() {
1065
+ const d = new Date();
1066
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate());
1067
+ }
1068
+ /** N days before today, at 00:00 local time. */
1069
+ daysAgo(n) {
1070
+ const t = this.startOfToday();
1071
+ t.setDate(t.getDate() - n);
1072
+ return t;
1073
+ }
1074
+ /* ── Filter-bar handlers (DRY) ──────────────────────────────────
1075
+ Wired to ef-smart-bar / pill-group / filter-drawer / chip
1076
+ removal. None of these need overriding — subclasses use them
1077
+ through inherited template bindings. */
1078
+ /** Wired to filter-drawer's `(apply)` — pushes the current
1079
+ * searchQuery into the criteria and re-runs the search. */
1080
+ runSearch() {
1081
+ this.setSearchText(this.searchQuery());
1082
+ }
1083
+ /** Pill-group click handler. Stores the selected status code; the
1084
+ * search itself only re-runs once the consumer pushes the value
1085
+ * into the criteria (or wires it via beforeSearch). */
1086
+ setStatus(key) {
1087
+ this.statusFilter.set(key);
1088
+ }
1089
+ toggleDrawer() {
1090
+ this.drawerOpen.update((o) => !o);
1091
+ }
1092
+ /** Drop a chip from the active-filters list. */
1093
+ removeFilter(key) {
1094
+ this.activeFilters.update((filters) => filters.filter((f) => f.key !== key));
1095
+ }
1096
+ /* ── Selection helpers (DRY) ────────────────────────────────────
1097
+ Mutate `selected` (Set<string>); template binds via
1098
+ `[checked]="isSelected(rowId(row))"` and
1099
+ `(change)="toggleSelection(rowId(row))"`. */
1100
+ toggleSelection(id) {
1101
+ this.selected.update((set) => {
1102
+ const next = new Set(set);
1103
+ if (next.has(id))
1104
+ next.delete(id);
1105
+ else
1106
+ next.add(id);
1107
+ return next;
1108
+ });
1109
+ }
1110
+ /** Toggle every visible row in or out of the selection in one shot. */
1111
+ toggleAllSelection() {
1112
+ const all = this.items().map((item) => String(this.rowId(item)));
1113
+ this.selected.update((set) => set.size === all.length ? new Set() : new Set(all));
1114
+ }
1115
+ isSelected(id) {
1116
+ return this.selected().has(id);
1117
+ }
1118
+ /* ── Column builders (DRY) ──────────────────────────────────────
1119
+ Convenience helpers that return a fully-formed EfDataCardColumn
1120
+ with sensible per-type defaults. Pass `opts` to override any
1121
+ property — `opts` always wins over the builder's defaults. */
1122
+ /** Selection checkbox column — paired with `efColumnTemplate="select"`
1123
+ * for the row's checkbox. 40px wide, no other props. */
1124
+ addSelectColumn(width = '40px') {
1125
+ return { id: 'select', width };
1126
+ }
1127
+ /** Plain text column. */
1128
+ addTextColumn(field, headerKey, opts = {}) {
1129
+ return { id: field, ...opts, field, headerKey, type: 'text' };
1130
+ }
1131
+ /** Monospace column — JetBrains Mono with tabular-nums; for codes,
1132
+ * IDs, refs, anything where character alignment matters. */
1133
+ addMonoColumn(field, headerKey, opts = {}) {
1134
+ return { id: field, ...opts, field, headerKey, type: 'mono' };
1135
+ }
1136
+ /** Number column — end-aligned, integer by default. Override
1137
+ * `minFractionDigits` / `maxFractionDigits` via opts for decimals. */
1138
+ addNumberColumn(field, headerKey, opts = {}) {
1139
+ return {
1140
+ id: field,
1141
+ minFractionDigits: 0,
1142
+ maxFractionDigits: 0,
1143
+ ...opts,
1144
+ field,
1145
+ headerKey,
1146
+ type: 'number',
1147
+ align: opts.align ?? 'end',
1148
+ };
1149
+ }
1150
+ /** Money column — end-aligned. ISO currency code defaults to `'MAD'`
1151
+ * (Elasticias' primary tenant locale); pass `opts.currencyCode` for
1152
+ * euros / USD / etc. */
1153
+ addMoneyColumn(field, headerKey, opts = {}) {
1154
+ return {
1155
+ id: field,
1156
+ currencyCode: 'MAD',
1157
+ ...opts,
1158
+ field,
1159
+ headerKey,
1160
+ type: 'money',
1161
+ align: opts.align ?? 'end',
1162
+ };
1163
+ }
1164
+ /** Date column — formats as `dd/MM/yyyy` by default. */
1165
+ addDateColumn(field, headerKey, opts = {}) {
1166
+ return { id: field, ...opts, field, headerKey, type: 'date' };
1167
+ }
1168
+ /** Datetime column — formats as `dd/MM/yyyy HH:mm` by default. */
1169
+ addDatetimeColumn(field, headerKey, opts = {}) {
1170
+ return { id: field, ...opts, field, headerKey, type: 'datetime' };
1171
+ }
1172
+ /** Boolean column — renders the `bool yes / bool no` indicator. */
1173
+ addBooleanColumn(field, headerKey, opts = {}) {
1174
+ return { id: field, ...opts, field, headerKey, type: 'boolean' };
1175
+ }
1176
+ /** Static-class chip column — renders `chip <chipPrefix><value>`.
1177
+ * Use `addStatusColumn` for reference_data-driven palettes. */
1178
+ addChipColumn(field, headerKey, opts = {}) {
1179
+ return {
1180
+ id: field,
1181
+ chipPrefix: 'chip-',
1182
+ ...opts,
1183
+ field,
1184
+ headerKey,
1185
+ type: 'chip',
1186
+ };
1187
+ }
1188
+ /** Status chip column — palette + label resolved from
1189
+ * `reference_data` via `referenceKey`. Sortable by default. */
1190
+ addStatusColumn(field, headerKey, referenceKey, opts = {}) {
1191
+ return {
1192
+ id: field,
1193
+ sortable: true,
1194
+ ...opts,
1195
+ field,
1196
+ headerKey,
1197
+ type: 'status',
1198
+ referenceKey,
1199
+ };
1200
+ }
1201
+ /** Reference column — looks up `field`'s value in the reference
1202
+ * list keyed by `referenceKey`, renders the matching item's label.
1203
+ * Defaults: `valueField: 'id'`, `labelField: 'label'`. */
1204
+ addReferenceColumn(field, headerKey, referenceKey, opts = {}) {
1205
+ const { valueField, labelField, ...rest } = opts;
1206
+ return {
1207
+ id: field,
1208
+ ...rest,
1209
+ field,
1210
+ headerKey,
1211
+ type: 'reference',
1212
+ referenceKey,
1213
+ referenceValueField: valueField ?? rest.referenceValueField ?? 'code',
1214
+ referenceLabelField: labelField ?? rest.referenceLabelField ?? 'label',
1215
+ };
1216
+ }
1217
+ ngOnInit() {
1218
+ super.ngOnInit();
1219
+ this.serviceInstance = this.injector.get(this.getConfig().SERVICE);
1220
+ const cfg = this.getConfig();
1221
+ const hasDynamicRefs = (cfg?.SEARCH_REFERENTIALS_KEYS?.length ?? 0) > 0;
1222
+ const hasStaticLists = (cfg?.SEARCH_STATIC_LISTS?.length ?? 0) > 0;
1223
+ if (hasDynamicRefs || hasStaticLists) {
1224
+ if (hasDynamicRefs)
1225
+ this.initializeReferenceKeys(cfg.SEARCH_REFERENTIALS_KEYS);
1226
+ if (hasStaticLists)
1227
+ this.initializeStaticLists(cfg.SEARCH_STATIC_LISTS);
1228
+ this.loadReferenceData(cfg.REF_DATA_OPTIONS);
1229
+ this.refDataLoaded$.pipe(take(1)).subscribe(() => {
1230
+ this.bootstrapInitialSearch();
1231
+ this.applyDeepLinkStatus();
1232
+ });
1233
+ }
1234
+ else {
1235
+ this.bootstrapInitialSearch();
1236
+ this.applyDeepLinkStatus();
1237
+ }
1238
+ }
1239
+ /**
1240
+ * Deep-link support: `/<list>?status=<code>` pre-selects the status
1241
+ * pill after the initial criteria bootstrap (so the reset doesn't
1242
+ * clobber it). Runs through `setStatus()`, so a subclass override
1243
+ * that pushes the status into the criteria (the usual pattern) gets
1244
+ * the deep-linked value too. No-op without the query param.
1245
+ */
1246
+ applyDeepLinkStatus() {
1247
+ const status = this.queryParam('status');
1248
+ if (status)
1249
+ this.setStatus(status);
1250
+ }
1251
+ /** Restore criteria from cache (returning to a screen) or seed defaults. */
1252
+ bootstrapInitialSearch() {
1253
+ const cached = this.cacheService.getCache(this.screenStateKey);
1254
+ if (cached) {
1255
+ this.criteria.set(new SearchEntity(cached));
1256
+ this.restoreFilterUiFromCriteria(cached);
1257
+ }
1258
+ else {
1259
+ const cfg = this.getConfig();
1260
+ const fresh = new SearchEntity({});
1261
+ if (cfg?.DEFAULT_SORT) {
1262
+ fresh.sort = [
1263
+ {
1264
+ field: cfg.DEFAULT_SORT.field,
1265
+ sortDirection: cfg.DEFAULT_SORT.direction,
1266
+ },
1267
+ ];
1268
+ }
1269
+ fresh.pagination = {
1270
+ pageNumber: PaginationEnum.DEFAULT_PAGE,
1271
+ pageSize: PaginationEnum.DEFAULT_PAGE_SIZE,
1272
+ };
1273
+ this.criteria.set(fresh);
1274
+ }
1275
+ this.search();
1276
+ }
1277
+ /**
1278
+ * Cache-restore sync: when a revisit restores cached criteria,
1279
+ * reflect the restored filters back into the filter-bar UI state so
1280
+ * what the drawer / search box displays matches what the search will
1281
+ * actually send (otherwise a stale criteria filter keeps applying
1282
+ * while every control reads "Tous"). Base handles the free-text
1283
+ * input and the declared advanced filters; override (calling super)
1284
+ * to sync screen-specific state — status pill, custom switches, ….
1285
+ */
1286
+ restoreFilterUiFromCriteria(cached) {
1287
+ this.searchQuery.set(cached['searchText'] ?? '');
1288
+ const next = {};
1289
+ for (const f of this.advancedFilters) {
1290
+ const value = cached[f.key];
1291
+ if (value !== undefined && value !== null)
1292
+ next[f.key] = value;
1293
+ }
1294
+ const activationKey = this.activationFilterKey;
1295
+ if (activationKey && typeof cached[activationKey] === 'boolean') {
1296
+ next[activationKey] = cached[activationKey];
1297
+ }
1298
+ if (Object.keys(next).length) {
1299
+ this.advancedValues.update((cur) => ({ ...cur, ...next }));
1300
+ }
1301
+ }
1302
+ /**
1303
+ * Run a search with the current `criteria()`. Always populates
1304
+ * `items` / `totalCount` / `loading` / `errorMsg` and persists the
1305
+ * criteria to cache on success so revisits can restore.
1306
+ */
1307
+ search() {
1308
+ this.loading.set(true);
1309
+ this.errorMsg.set(null);
1310
+ // Stale responses are dropped so the latest issued search always
1311
+ // wins, even if an earlier in-flight search resolves later.
1312
+ const seq = ++this.searchSeq;
1313
+ this.serviceInstance.search(this.criteria()).subscribe({
1314
+ next: (result) => {
1315
+ if (seq !== this.searchSeq)
1316
+ return;
1317
+ this.items.set((result?.items ?? []));
1318
+ this.totalCount.set(result?.totalCount ?? 0);
1319
+ this.cacheService.setCache(this.screenStateKey, this.criteria());
1320
+ this.loading.set(false);
1321
+ },
1322
+ error: (err) => {
1323
+ if (seq !== this.searchSeq)
1324
+ return;
1325
+ console.error('Search failed', err);
1326
+ this.errorMsg.set(err?.message ?? 'Erreur de chargement');
1327
+ this.loading.set(false);
1328
+ },
1329
+ });
1330
+ }
1331
+ /* ── Convenience criteria mutators ───────────────────────────── */
1332
+ setSearchText(text) {
1333
+ this.criteria.update((c) => this.cloneCriteria(c, { searchText: text || undefined, page: 1 }));
1334
+ this.search();
1335
+ }
1336
+ setPage(pageNumber, pageSize) {
1337
+ this.criteria.update((c) => this.cloneCriteria(c, {
1338
+ page: pageNumber,
1339
+ pageSize: pageSize ?? c.pagination.pageSize,
1340
+ }));
1341
+ this.search();
1342
+ }
1343
+ setSort(field, direction = SortDirectionEnum.DESC) {
1344
+ // Normalize `'asc' | 'desc'` (ef-data-card emits these) to the
1345
+ // backend's legacy `'Ascending' | 'Descending'` strings, so
1346
+ // subclasses don't need to override `setSort` just for that.
1347
+ const normalized = (direction || '').toLowerCase().startsWith('asc')
1348
+ ? 'Ascending'
1349
+ : 'Descending';
1350
+ this.criteria.update((c) => this.cloneCriteria(c, { sort: [{ field, sortDirection: normalized }] }));
1351
+ this.search();
1352
+ }
1353
+ setDateRange(start, end) {
1354
+ this.criteria.update((c) => this.cloneCriteria(c, { start, end, page: 1 }));
1355
+ this.search();
1356
+ }
1357
+ /** Patch arbitrary extra fields onto the criteria (for module-specific filters). */
1358
+ patchCriteria(patch) {
1359
+ this.criteria.update((c) => this.cloneCriteria(c, { extra: patch, page: 1 }));
1360
+ this.search();
1361
+ }
1362
+ /** Reset everything to defaults and re-fetch. Subclasses bind this
1363
+ * to ef-smart-bar's `(clear)` output directly — no per-screen
1364
+ * `clearAll()` orchestration needed. */
1365
+ clear() {
1366
+ const cfg = this.getConfig();
1367
+ const fresh = new SearchEntity({});
1368
+ if (cfg?.DEFAULT_SORT) {
1369
+ fresh.sort = [
1370
+ {
1371
+ field: cfg.DEFAULT_SORT.field,
1372
+ sortDirection: cfg.DEFAULT_SORT.direction,
1373
+ },
1374
+ ];
1375
+ }
1376
+ this.criteria.set(fresh);
1377
+ // Reset all the shared filter-bar / selection signals so
1378
+ // subclasses don't have to track each one individually.
1379
+ this.searchQuery.set('');
1380
+ this.statusFilter.set('all');
1381
+ this.drawerOpen.set(false);
1382
+ this.activeFilters.set([]);
1383
+ this.advancedValues.set({});
1384
+ this.selected.set(new Set());
1385
+ this.resetDateRange();
1386
+ this.cacheService.setCache(this.screenStateKey, fresh);
1387
+ this.search();
1388
+ }
1389
+ cloneCriteria(c, patch) {
1390
+ const next = new SearchEntity({});
1391
+ Object.assign(next, c);
1392
+ if ('searchText' in patch)
1393
+ next.searchText = patch.searchText;
1394
+ if (patch.page !== undefined || patch.pageSize !== undefined) {
1395
+ next.pagination = {
1396
+ pageNumber: patch.page ?? c.pagination.pageNumber,
1397
+ pageSize: patch.pageSize ?? c.pagination.pageSize,
1398
+ };
1399
+ }
1400
+ if (patch.sort !== undefined)
1401
+ next.sort = patch.sort;
1402
+ if (patch.start !== undefined)
1403
+ next.start = patch.start;
1404
+ if (patch.end !== undefined)
1405
+ next.end = patch.end;
1406
+ if (patch.extra)
1407
+ Object.assign(next, patch.extra);
1408
+ return next;
1409
+ }
1410
+ /* ── Navigation helpers ─────────────────────────────────────────
1411
+ V2 routing convention (matches v1):
1412
+ /<list>/details → AbstractDetailScreenV2 in create mode
1413
+ /<list>/details/:id → AbstractDetailScreenV2 in edit mode
1414
+ /<list>/details/:id?mode=duplicate → duplicate-as-template
1415
+
1416
+ `currentUrl` cached on `AbstractScreenComponent.ngOnInit` is
1417
+ not reliable (Router.url isn't committed yet during route
1418
+ activation, so it holds the previous URL). Read `router.url`
1419
+ at call time via `resolveListUrl()` instead. */
1420
+ /** Resolve the list-screen URL at call time. Drops query / fragment
1421
+ * and strips a trailing slash. */
1422
+ resolveListUrl() {
1423
+ let url = this.router.url || this.currentUrl || '';
1424
+ const q = url.indexOf('?');
1425
+ if (q >= 0)
1426
+ url = url.slice(0, q);
1427
+ const h = url.indexOf('#');
1428
+ if (h >= 0)
1429
+ url = url.slice(0, h);
1430
+ return url.replace(/\/$/, '');
1431
+ }
1432
+ navigateToDetails(id) {
1433
+ const base = `${this.resolveListUrl()}/details`;
1434
+ if (id == null && id !== 0) {
1435
+ this.router.navigate([base]);
1436
+ return;
1437
+ }
1438
+ this.router.navigate([base, id]);
1439
+ }
1440
+ /** Open the create form. */
1441
+ add() {
1442
+ this.router.navigate([`${this.resolveListUrl()}/details`]);
1443
+ }
1444
+ edit(id) {
1445
+ if (!id) {
1446
+ this.toastService.showError('Item [id] is undefined !');
1447
+ return;
1448
+ }
1449
+ this.router.navigate([`${this.resolveListUrl()}/details`, id]);
1450
+ }
1451
+ delete(id) {
1452
+ if (!id) {
1453
+ this.toastService.showError('Item [id] is undefined!');
1454
+ return;
1455
+ }
1456
+ this.confirmDialogService.confirm('Êtes-vous sûr de vouloir supprimer ?', () => this.serviceInstance.delete(id).subscribe({
1457
+ next: (result) => {
1458
+ if (result?.errors?.length) {
1459
+ this.handleErrors(result.errors);
1460
+ }
1461
+ else {
1462
+ this.search();
1463
+ this.toastService.showSuccess();
1464
+ }
1465
+ },
1466
+ // The HTTP error interceptor surfaces the message (toast). Swallow
1467
+ // here so a rejected delete (e.g. a 422 business-rule violation)
1468
+ // doesn't bubble up as an unhandled error.
1469
+ error: () => undefined,
1470
+ }), () => undefined);
1471
+ }
1472
+ duplicate(id) {
1473
+ if (!id) {
1474
+ this.toastService.showError('Item [id] is undefined !');
1475
+ return;
1476
+ }
1477
+ this.router.navigate([`${this.resolveListUrl()}/details`, id], {
1478
+ queryParams: { mode: 'duplicate' },
1479
+ });
1480
+ }
1481
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: AbstractSearchScreenV2, deps: null, target: i0.ɵɵFactoryTarget.Component });
1482
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.11", type: AbstractSearchScreenV2, isStandalone: true, selector: "ng-component", usesInheritance: true, ngImport: i0, template: '', isInline: true });
1483
+ }
1484
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: AbstractSearchScreenV2, decorators: [{
1485
+ type: Component,
1486
+ args: [{ template: '', standalone: true }]
1487
+ }] });
1488
+
1489
+ /**
1490
+ * Injection token for the change-history client. Provide it once at app root:
1491
+ *
1492
+ * @example
1493
+ * { provide: AUDIT_HISTORY_SERVICE, useExisting: AuditClient }
1494
+ *
1495
+ * Detail screens then only declare `static AUDIT_ENTITY_TYPE = 'Client'` on
1496
+ * their config — {@link AbstractDetailScreenV2} loads the history automatically
1497
+ * into its `auditEntries` signal.
1498
+ */
1499
+ const AUDIT_HISTORY_SERVICE = new InjectionToken('AUDIT_HISTORY_SERVICE');
1500
+
1501
+ /**
1502
+ * Signal-first counterpart to {@link AbstractDetailScreenComponent}.
1503
+ *
1504
+ * Same business mechanism as v1 but signal-based and zoneless-friendly:
1505
+ * `entity` / `entityId` / `editionState` / `duplicateMode` / `loading`
1506
+ * / `errorMsg` are all writable signals.
1507
+ *
1508
+ * Routing convention (V2):
1509
+ * - `/<resource>/details` → new (create mode, `editionState=false`)
1510
+ * - `/<resource>/details/:id` → edit mode (loads via `service.get(id)`)
1511
+ * - `/<resource>/details/:id?mode=duplicate` → loads then strips id on save
1512
+ *
1513
+ * The same component handles all three paths — the route param +
1514
+ * `mode` query string drives the state. After a successful create
1515
+ * (or duplicate-save), the screen navigates to
1516
+ * `/<resource>/details/<new-id>` so the user lands in edit mode.
1517
+ *
1518
+ * Subclasses typically override:
1519
+ * - `getConfig()` — returns ScreenConfig with SERVICE
1520
+ * - `beforeSave(): boolean` — return false to cancel save
1521
+ * (default: true)
1522
+ * - `afterLoad()` — post-process loaded entity
1523
+ * - `afterSave(result)` — default invalidates refs + navigates
1524
+ * to the new edit URL after create
1525
+ * - `onSaveError(errors)` — surface form-level validation errors
1526
+ * - `customizeDuplicatedEntity` — clean fields before duplicating
1527
+ *
1528
+ * ```ts
1529
+ * @Component({ ... })
1530
+ * export class FooDetailComponent extends AbstractDetailScreenV2<FooDto> {
1531
+ * override getConfig() { return FooConfig; }
1532
+ *
1533
+ * protected override beforeSave(): boolean {
1534
+ * // validate this.entity() shape; return false to cancel
1535
+ * return true;
1536
+ * }
1537
+ * }
1538
+ * ```
1539
+ */
1540
+ class AbstractDetailScreenV2 extends AbstractScreenComponent {
1541
+ screenState = ScreenStateEnum.DETAIL;
1542
+ injector = inject(Injector);
1543
+ location = inject(Location);
1544
+ destroyRef = inject(DestroyRef);
1545
+ auditHistoryService = inject(AUDIT_HISTORY_SERVICE, { optional: true });
1546
+ serviceInstance;
1547
+ /**
1548
+ * Change-history entries for the loaded record, newest first. Populated
1549
+ * automatically when the config sets `AUDIT_ENTITY_TYPE` and an
1550
+ * `AUDIT_HISTORY_SERVICE` is provided. Bind it directly:
1551
+ * `<ef-change-history [entries]="auditEntries()" />`.
1552
+ */
1553
+ auditEntries = signal([], ...(ngDevMode ? [{ debugName: "auditEntries" }] : /* istanbul ignore next */ []));
1554
+ /** Loaded entity. Empty object when on `/details` (create mode). */
1555
+ entity = signal({}, ...(ngDevMode ? [{ debugName: "entity" }] : /* istanbul ignore next */ []));
1556
+ /** PK from the route param, or `null` on `/details` (new). */
1557
+ entityId = signal(null, ...(ngDevMode ? [{ debugName: "entityId" }] : /* istanbul ignore next */ []));
1558
+ /** Edit-mode flag — true when an id is present and we're not duplicating. */
1559
+ editionState = signal(false, ...(ngDevMode ? [{ debugName: "editionState" }] : /* istanbul ignore next */ []));
1560
+ /**
1561
+ * When true, the loaded entity is treated as a template — saving
1562
+ * runs `service.create()` instead of `service.update()`. Set by
1563
+ * the `?mode=duplicate` query param.
1564
+ */
1565
+ duplicateMode = signal(false, ...(ngDevMode ? [{ debugName: "duplicateMode" }] : /* istanbul ignore next */ []));
1566
+ /** True while service.get / create / update / delete is in flight. */
1567
+ loading = signal(false, ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
1568
+ /** Last load / save error message — empty string when none. */
1569
+ errorMsg = signal(null, ...(ngDevMode ? [{ debugName: "errorMsg" }] : /* istanbul ignore next */ []));
1570
+ /**
1571
+ * Reactive list of custom actions for `<ef-detail-toolbar>`'s
1572
+ * `[customActions]` input — recomputes whenever editionState /
1573
+ * duplicateMode flip. Default contents:
1574
+ * - Edit mode → empty (the standard `print | duplicate |
1575
+ * delete | save` row covers it)
1576
+ * - Create mode → `[Cancel]` — navigates back to the list
1577
+ * - Duplicate mode → `[Cancel]` — drops `?mode=duplicate` and
1578
+ * returns to edit view of the source entity
1579
+ *
1580
+ * Subclasses override `getCustomActions()` to add screen-specific
1581
+ * actions (Approve / Print PDF / Mark as paid / etc.). Call
1582
+ * `super.getCustomActions()` to keep the Cancel default.
1583
+ */
1584
+ customActions = computed(() => this.getCustomActions(), ...(ngDevMode ? [{ debugName: "customActions" }] : /* istanbul ignore next */ []));
1585
+ /**
1586
+ * Builder for `customActions`. Override per screen to add or
1587
+ * replace the defaults. Pure function — reads other signals
1588
+ * freely, returns a fresh array each call.
1589
+ */
1590
+ getCustomActions() {
1591
+ // Edit mode: standard right-side group is enough; let subclass
1592
+ // append Preview / Print PDF / etc. by overriding this method.
1593
+ if (this.editionState())
1594
+ return [];
1595
+ // Create / duplicate mode: a Cancel button. See cancel().
1596
+ return [
1597
+ {
1598
+ id: 'cancel',
1599
+ labelKey: 'common_cancel',
1600
+ icon: 'pi pi-times',
1601
+ severity: 'ghost',
1602
+ command: () => this.cancel(),
1603
+ },
1604
+ ];
1605
+ }
1606
+ ngOnInit() {
1607
+ super.ngOnInit();
1608
+ this.serviceInstance = this.injector.get(this.getConfig().SERVICE);
1609
+ // Reference / static lists for the detail context (different
1610
+ // set than the search screen's — `DETAILS_*` not `SEARCH_*`).
1611
+ const cfg = this.getConfig();
1612
+ if (cfg) {
1613
+ const hasDynamicRefs = (cfg.DETAILS_REFERENTIALS_KEYS?.length ?? 0) > 0;
1614
+ const hasStaticLists = (cfg.DETAILS_STATIC_LISTS?.length ?? 0) > 0;
1615
+ if (hasDynamicRefs)
1616
+ this.initializeReferenceKeys(cfg.DETAILS_REFERENTIALS_KEYS);
1617
+ if (hasStaticLists)
1618
+ this.initializeStaticLists(cfg.DETAILS_STATIC_LISTS);
1619
+ if (hasDynamicRefs || hasStaticLists)
1620
+ this.loadReferenceData(cfg.REF_DATA_OPTIONS);
1621
+ }
1622
+ // Wire route params → load (or reset to empty on /details).
1623
+ combineLatest([this.route.paramMap, this.route.queryParamMap])
1624
+ .pipe(takeUntilDestroyed(this.destroyRef))
1625
+ .subscribe(([params, queryParams]) => {
1626
+ const id = params.get('id');
1627
+ const isDuplicate = queryParams.get('mode') === 'duplicate';
1628
+ this.entityId.set(id);
1629
+ this.duplicateMode.set(isDuplicate);
1630
+ this.context.duplicateMode = isDuplicate;
1631
+ this.editionState.set(!!id && !isDuplicate);
1632
+ if (id) {
1633
+ this.loadData();
1634
+ }
1635
+ else {
1636
+ // New mode — reset to a fresh empty entity, no history yet.
1637
+ this.entity.set({});
1638
+ this.auditEntries.set([]);
1639
+ this.afterLoad();
1640
+ }
1641
+ });
1642
+ }
1643
+ /**
1644
+ * Merge a partial patch into the `entity` signal — the canonical
1645
+ * way for form inputs to write back. Spread-immutably so OnPush
1646
+ * change detection picks it up.
1647
+ *
1648
+ * ```html
1649
+ * <ef-input-text
1650
+ * variant="comptoir"
1651
+ * [value]="title()"
1652
+ * (valueChangeEvent)="patchEntity({ title: $event })"
1653
+ * />
1654
+ * ```
1655
+ */
1656
+ patchEntity(patch) {
1657
+ this.entity.update(current => ({ ...current, ...patch }));
1658
+ }
1659
+ /** Fetch the entity from the backend and populate `entity`. */
1660
+ loadData() {
1661
+ const id = this.entityId();
1662
+ if (!id)
1663
+ return;
1664
+ this.loading.set(true);
1665
+ this.errorMsg.set(null);
1666
+ this.serviceInstance.get(id).subscribe({
1667
+ next: (result) => {
1668
+ const vm = new ViewModelEntity(result);
1669
+ vm.id = id;
1670
+ this.entity.set(vm);
1671
+ this.loading.set(false);
1672
+ this.afterLoad();
1673
+ this.loadAuditHistory();
1674
+ },
1675
+ error: (err) => {
1676
+ console.error('AbstractDetailScreenV2.loadData failed', err);
1677
+ this.errorMsg.set(err?.message ?? 'Erreur de chargement');
1678
+ this.loading.set(false);
1679
+ },
1680
+ });
1681
+ }
1682
+ /**
1683
+ * Persist the entity. Routes to `service.create()` when in new
1684
+ * or duplicate mode, `service.update(id, entity)` when editing.
1685
+ * Subclass `beforeSave()` can return `false` to cancel.
1686
+ */
1687
+ save() {
1688
+ const proceed = this.beforeSave();
1689
+ if (proceed === false)
1690
+ return;
1691
+ this.clearServerErrors();
1692
+ const isCreate = !this.editionState() || this.duplicateMode();
1693
+ const payload = this.duplicateMode()
1694
+ ? this.prepareEntityForDuplication()
1695
+ : this.entity();
1696
+ this.loading.set(true);
1697
+ const op = isCreate
1698
+ ? this.serviceInstance.create(payload)
1699
+ : this.serviceInstance.update(this.entityId(), payload);
1700
+ op.subscribe({
1701
+ next: (response) => {
1702
+ this.loading.set(false);
1703
+ if (response?.errors?.length) {
1704
+ this.handleErrors(response.errors);
1705
+ return;
1706
+ }
1707
+ this.toastService.showSuccess();
1708
+ this.afterSave(response);
1709
+ },
1710
+ error: (response) => {
1711
+ this.loading.set(false);
1712
+ if (response?.errors) {
1713
+ this.setServerErrors(response.errors);
1714
+ this.setFormErrors(response.errors);
1715
+ this.onSaveError(response.errors);
1716
+ }
1717
+ },
1718
+ });
1719
+ }
1720
+ /**
1721
+ * Soft-delete-then-navigate-back. Confirms with the user first.
1722
+ */
1723
+ delete() {
1724
+ const id = this.entityId();
1725
+ if (!id) {
1726
+ this.toastService.showError('Item [id] is undefined!');
1727
+ return;
1728
+ }
1729
+ this.confirmDialogService.confirm('Êtes-vous sûr de vouloir supprimer ?', () => this.serviceInstance.delete(id).subscribe({
1730
+ next: (result) => {
1731
+ if (result?.errors?.length) {
1732
+ this.handleErrors(result.errors);
1733
+ }
1734
+ else {
1735
+ this.toastService.showSuccess();
1736
+ this.navigateBack();
1737
+ }
1738
+ },
1739
+ // The HTTP error interceptor surfaces the message (toast).
1740
+ // Swallow here so a rejected delete (e.g. a 422 business-rule
1741
+ // violation) doesn't bubble up as an unhandled error.
1742
+ error: () => undefined,
1743
+ }), () => undefined);
1744
+ }
1745
+ /** Navigate to `/details/:id?mode=duplicate` so the abstract can
1746
+ * reload the source entity, treat it as a template, and persist
1747
+ * via `service.create()` after the user hits Save.
1748
+ * Without the id, the duplicate route would have nothing to
1749
+ * fetch — `entity` would be empty and the clone-as-template
1750
+ * flow would fall back to creating a blank record. */
1751
+ duplicate() {
1752
+ const id = this.entityId();
1753
+ const base = this.detailsBaseUrl();
1754
+ if (id == null) {
1755
+ // No source record (already on /details with no id) —
1756
+ // just open the create form.
1757
+ this.router.navigate([base]);
1758
+ return;
1759
+ }
1760
+ this.router.navigate([base, id], {
1761
+ queryParams: { mode: 'duplicate' },
1762
+ });
1763
+ }
1764
+ /**
1765
+ * Cancel handler for the default toolbar action:
1766
+ * - Duplicate mode → drop `?mode=duplicate` and return to
1767
+ * `/details/:id`. The route subscription re-fires and reloads
1768
+ * the source entity, discarding any in-memory edits.
1769
+ * - Create mode (no id) → navigate back to the list.
1770
+ *
1771
+ * Subclasses may override to add a confirm dialog when the form
1772
+ * is dirty.
1773
+ */
1774
+ cancel() {
1775
+ if (this.duplicateMode() && this.entityId() != null) {
1776
+ this.router.navigate([this.detailsBaseUrl(), this.entityId()]);
1777
+ return;
1778
+ }
1779
+ this.navigateBack();
1780
+ }
1781
+ /** Navigate back to the list (strip `/details` and any id). */
1782
+ navigateBack() {
1783
+ const url = (this.router.url || '').split('?')[0];
1784
+ const idx = url.indexOf('/details');
1785
+ if (idx !== -1) {
1786
+ this.router.navigate([url.substring(0, idx)]);
1787
+ }
1788
+ else {
1789
+ this.location.back();
1790
+ }
1791
+ }
1792
+ /** Subclass print hook — no-op default. */
1793
+ print() { }
1794
+ /**
1795
+ * Load the standardized change-history into `auditEntries` when the config
1796
+ * opts in via `AUDIT_ENTITY_TYPE` and an `AUDIT_HISTORY_SERVICE` is provided.
1797
+ * Called automatically after a successful `loadData()`. Failures degrade to
1798
+ * an empty history rather than blocking the screen.
1799
+ */
1800
+ loadAuditHistory() {
1801
+ this.auditEntries.set([]);
1802
+ const entityType = this.getConfig()?.AUDIT_ENTITY_TYPE;
1803
+ const id = this.entityId();
1804
+ // No service provided, screen not opted in, or no id yet → empty box.
1805
+ if (!this.auditHistoryService || !entityType || !id) {
1806
+ return;
1807
+ }
1808
+ // Fully defensive: a missing/misconfigured audit client (no provider,
1809
+ // wrong shape, get() throwing, or an HTTP failure) must never break the
1810
+ // detail screen — it just leaves the history empty.
1811
+ try {
1812
+ const result$ = this.auditHistoryService.get(entityType, id);
1813
+ if (!result$ || typeof result$.subscribe !== 'function') {
1814
+ return;
1815
+ }
1816
+ result$.subscribe({
1817
+ next: (entries) => this.auditEntries.set(entries ?? []),
1818
+ error: () => this.auditEntries.set([]),
1819
+ });
1820
+ }
1821
+ catch {
1822
+ this.auditEntries.set([]);
1823
+ }
1824
+ }
1825
+ /* ── Override hooks ─────────────────────────────────────────── */
1826
+ /**
1827
+ * Called right before `save()` dispatches to the backend. Return
1828
+ * `false` to cancel (e.g., form validation failed). Default: no-op.
1829
+ */
1830
+ beforeSave() {
1831
+ return true;
1832
+ }
1833
+ /** Override to post-process the loaded `entity` (or to refresh
1834
+ * derived signals). Default: no-op. */
1835
+ afterLoad() { }
1836
+ /**
1837
+ * Default post-save behaviour:
1838
+ * - invalidate / refresh reference data per ScreenConfig
1839
+ * - after a CREATE (or duplicate-save), navigate to
1840
+ * `/details/<new-id>` so the user lands in edit mode
1841
+ */
1842
+ afterSave(result) {
1843
+ const cfg = this.getConfig();
1844
+ if (cfg?.INVALIDATE_KEYS_ON_SAVE) {
1845
+ this.invalidateReferences(cfg.INVALIDATE_KEYS_ON_SAVE);
1846
+ if (cfg.REFRESH_ON_SAVE) {
1847
+ this.refreshReferenceData(cfg.INVALIDATE_KEYS_ON_SAVE);
1848
+ }
1849
+ }
1850
+ const isCreate = !this.editionState() || this.duplicateMode();
1851
+ if (isCreate) {
1852
+ const newId = typeof result === 'object' ? (result?.id ?? result) : result;
1853
+ this.router.navigate([this.detailsBaseUrl(), newId]);
1854
+ }
1855
+ else {
1856
+ // Update succeeded in place — refresh the change-history so the new
1857
+ // entry shows without a manual reload.
1858
+ this.loadAuditHistory();
1859
+ }
1860
+ }
1861
+ /** Override to surface form-level validation errors after a 4xx
1862
+ * response. Default: no-op (errors are already on `serverErrors`). */
1863
+ onSaveError(_errors) { }
1864
+ /** Strip id (if present) from the entity before a duplicate save. */
1865
+ prepareEntityForDuplication() {
1866
+ const dup = { ...this.entity() };
1867
+ delete dup.id;
1868
+ delete dup.Id;
1869
+ return this.customizeDuplicatedEntity(dup);
1870
+ }
1871
+ /** Override to clear additional fields (codes, slugs, refs)
1872
+ * before saving a duplicated entity. */
1873
+ customizeDuplicatedEntity(entity) {
1874
+ return entity;
1875
+ }
1876
+ /* ── Internals ──────────────────────────────────────────────── */
1877
+ /** Resolve the bare `/<resource>/details` base URL — strips a
1878
+ * trailing `:id`, query string, fragment, trailing slash. */
1879
+ detailsBaseUrl() {
1880
+ let url = this.router.url || '';
1881
+ const q = url.indexOf('?');
1882
+ if (q >= 0)
1883
+ url = url.slice(0, q);
1884
+ const h = url.indexOf('#');
1885
+ if (h >= 0)
1886
+ url = url.slice(0, h);
1887
+ // If we're on /…/details/<id>, drop <id>; if on /…/details, leave it.
1888
+ const m = url.match(/^(.*?\/details)(?:\/[^/]+)?\/?$/);
1889
+ return m ? m[1] : url;
1890
+ }
1891
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: AbstractDetailScreenV2, deps: null, target: i0.ɵɵFactoryTarget.Component });
1892
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.11", type: AbstractDetailScreenV2, isStandalone: true, selector: "ng-component", usesInheritance: true, ngImport: i0, template: '', isInline: true });
1893
+ }
1894
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: AbstractDetailScreenV2, decorators: [{
1895
+ type: Component,
1896
+ args: [{ template: '', standalone: true }]
1897
+ }] });
1898
+
1899
+ /**
1900
+ * Signal-first base for report/dashboard screens — the third V2 abstract,
1901
+ * alongside {@link AbstractSearchScreenV2} / AbstractDetailScreenV2.
1902
+ *
1903
+ * Owns the report CHASSIS only: period state (`dateRange`, seeded from the
1904
+ * config's `DEFAULT_PERIOD` preset), per-widget lifecycle (`widget()` +
1905
+ * `loadAll()`), `REPORT_STATIC_LISTS` preload, and Export-gated CSV
1906
+ * (Task 2). Widget COMPOSITION stays in each screen's template — a
1907
+ * metadata-driven report renderer was rejected in ADR-015 and this class
1908
+ * must not become its frontend half.
1909
+ *
1910
+ * `ScreenConfig` surface: `SCREEN` (backend screen code), `SERVICE` (NSwag
1911
+ * reports client), `DEFAULT_PERIOD`, `REPORT_STATIC_LISTS`.
1912
+ *
1913
+ * Subclasses typically:
1914
+ *
1915
+ * ```ts
1916
+ * export class FooDashboardComponent extends AbstractReportScreenV2 {
1917
+ * protected override getConfig() { return FooDashboardConfig; }
1918
+ * readonly kpisW = this.widget((start, end) =>
1919
+ * this.client.kpis(new GetFooKpisQuery({ start, end })));
1920
+ * }
1921
+ * ```
1922
+ *
1923
+ * Loaders close over the screen's own filter signals; the screen calls
1924
+ * `loadAll()` (period-wide) or `someW.reload()` (single widget) when its
1925
+ * filters change. Period-independent blocks (operational strips) load
1926
+ * outside the registry on purpose.
1927
+ */
1928
+ class AbstractReportScreenV2 extends AbstractScreenComponent {
1929
+ screenState = ScreenStateEnum.REPORT;
1930
+ injector = inject(Injector);
1931
+ /** NSwag reports client resolved from `ScreenConfig.SERVICE` — expose a
1932
+ * typed getter in the screen: `get client() { return this.reportsService as XClient; }` */
1933
+ reportsService;
1934
+ registeredWidgets = [];
1935
+ /** Active period. Bound to `ef-datepicker-advanced`; every widget loader
1936
+ * receives its UTC-normalized start/end. */
1937
+ dateRange = signal(this.buildDefaultDateRange(), ...(ngDevMode ? [{ debugName: "dateRange" }] : /* istanbul ignore next */ []));
1938
+ ngOnInit() {
1939
+ super.ngOnInit();
1940
+ this.reportsService = this.injector.get(this.getConfig().SERVICE);
1941
+ const lists = this.getConfig()?.REPORT_STATIC_LISTS ?? [];
1942
+ if (lists.length > 0) {
1943
+ this.initializeStaticLists(lists);
1944
+ this.loadReferenceData(this.getConfig()?.REF_DATA_OPTIONS);
1945
+ this.refDataLoaded$.pipe(take(1)).subscribe(() => this.loadAll());
1946
+ }
1947
+ else {
1948
+ this.loadAll();
1949
+ }
1950
+ }
1951
+ /* ── Period ─────────────────────────────────────────────────── */
1952
+ /** Wired to `<ef-datepicker-advanced (rangeChange)>`. */
1953
+ onDateRangeChange(range) {
1954
+ this.dateRange.set(range);
1955
+ this.loadAll();
1956
+ }
1957
+ /** Default period from the config's `DEFAULT_PERIOD` preset. */
1958
+ buildDefaultDateRange() {
1959
+ return this.rangeFromPreset(this.getConfig()?.DEFAULT_PERIOD ?? 'last_30_days');
1960
+ }
1961
+ /**
1962
+ * Resolve a built-in {@link EfDatePresetKey} to an inclusive day range
1963
+ * (both boundaries at 00:00 local). Unknown keys normalize to
1964
+ * `last_30_days`. Weeks start Monday (matches `$dateTrunc`, ADR-015).
1965
+ */
1966
+ rangeFromPreset(key) {
1967
+ const KNOWN = [
1968
+ 'today', 'this_week', 'this_month', 'last_30_days',
1969
+ 'last_90_days', 'this_quarter', 'this_year',
1970
+ ];
1971
+ const k = KNOWN.includes(key) ? key : 'last_30_days';
1972
+ const today = this.startOfToday();
1973
+ const end = new Date(today);
1974
+ let start = new Date(today);
1975
+ switch (k) {
1976
+ case 'today':
1977
+ break;
1978
+ case 'this_week': {
1979
+ const dow = (today.getDay() + 6) % 7;
1980
+ start.setDate(today.getDate() - dow);
1981
+ break;
1982
+ }
1983
+ case 'this_month':
1984
+ start = new Date(today.getFullYear(), today.getMonth(), 1);
1985
+ break;
1986
+ case 'last_90_days':
1987
+ start.setDate(today.getDate() - 89);
1988
+ break;
1989
+ case 'this_quarter':
1990
+ start = new Date(today.getFullYear(), Math.floor(today.getMonth() / 3) * 3, 1);
1991
+ break;
1992
+ case 'this_year':
1993
+ start = new Date(today.getFullYear(), 0, 1);
1994
+ break;
1995
+ case 'last_30_days':
1996
+ default:
1997
+ start.setDate(today.getDate() - 29);
1998
+ break;
1999
+ }
2000
+ return { start, end, presetKey: k, labelKey: `date_preset_${k}`, label: '' };
2001
+ }
2002
+ startOfToday() {
2003
+ const d = new Date();
2004
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate());
2005
+ }
2006
+ /**
2007
+ * Re-anchor a local-midnight Date to UTC midnight of the same calendar
2008
+ * date. NSwag serializes via toISOString(); for UTC+ users (Morocco)
2009
+ * local midnight would otherwise shift into the previous UTC day.
2010
+ */
2011
+ toUtcDate(d) {
2012
+ return new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
2013
+ }
2014
+ /* ── Widgets ────────────────────────────────────────────────── */
2015
+ /**
2016
+ * Register a report widget. The loader receives the current period's
2017
+ * UTC-normalized start/end and returns the NSwag observable; extra
2018
+ * filters are simply closed over from the screen's own signals.
2019
+ */
2020
+ widget(loader) {
2021
+ const data = signal(null, ...(ngDevMode ? [{ debugName: "data" }] : /* istanbul ignore next */ []));
2022
+ const status = signal('loading', ...(ngDevMode ? [{ debugName: "status" }] : /* istanbul ignore next */ []));
2023
+ const handle = {
2024
+ data: data.asReadonly(),
2025
+ status: status.asReadonly(),
2026
+ reload: () => {
2027
+ const { start, end } = this.dateRange();
2028
+ status.set('loading');
2029
+ loader(this.toUtcDate(start), this.toUtcDate(end)).subscribe({
2030
+ next: (value) => {
2031
+ data.set(value);
2032
+ status.set('ready');
2033
+ },
2034
+ error: () => status.set('error'),
2035
+ });
2036
+ },
2037
+ };
2038
+ this.registeredWidgets.push(handle);
2039
+ return handle;
2040
+ }
2041
+ /** Reload every registered widget against the current period. */
2042
+ loadAll() {
2043
+ for (const w of this.registeredWidgets)
2044
+ w.reload();
2045
+ }
2046
+ /* ── Grants + export ────────────────────────────────────────── */
2047
+ /** Export grant (ADR-011) on this screen's own code. Grants are loaded
2048
+ * once by `processGrants()` in ngOnInit — safe to call from templates. */
2049
+ canExport() {
2050
+ return this.context?.isGranted(Permissions.Export) ?? false;
2051
+ }
2052
+ /** CSV download gated by the Export grant — silently no-ops without it. */
2053
+ exportCsv(filename, rows, columns) {
2054
+ if (!this.canExport())
2055
+ return;
2056
+ // `CsvColumn<T>['key']` is `keyof T & string`, which is `never` for the
2057
+ // bare `object` type — widen to `Record<string, unknown>` so the
2058
+ // generic infers a `string`-keyed column, matching `columns` as declared.
2059
+ CsvUtils.download(filename, CsvUtils.toCsv(rows, columns));
2060
+ }
2061
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: AbstractReportScreenV2, deps: null, target: i0.ɵɵFactoryTarget.Component });
2062
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.11", type: AbstractReportScreenV2, isStandalone: true, selector: "ng-component", usesInheritance: true, ngImport: i0, template: '', isInline: true });
2063
+ }
2064
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: AbstractReportScreenV2, decorators: [{
2065
+ type: Component,
2066
+ args: [{ template: '', standalone: true }]
2067
+ }] });
2068
+
806
2069
  class AbstractSubScreenComponent extends AbstractScreenComponent {
807
2070
  screenState = null;
808
2071
  ngOnInit() {
@@ -821,6 +2084,76 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImpo
821
2084
  }]
822
2085
  }] });
823
2086
 
2087
+ /**
2088
+ * Signal-first base for SUB-screens — self-contained fragments embedded
2089
+ * inside a host screen (dashboard strips, side panels, …) rather than
2090
+ * routed on their own. The V2 counterpart to the legacy
2091
+ * {@link AbstractSubScreenComponent}.
2092
+ *
2093
+ * A sub-screen typically borrows ANOTHER screen's identity: its config
2094
+ * sets `SCREEN` to the code whose grants gate the data it shows (e.g. a
2095
+ * recent-orders strip on the sales dashboard uses `'SalesOrders'`), so
2096
+ * `canRead()` and `ScreenContext` permissions line up with the backend
2097
+ * seed without any cross-screen plumbing in the component.
2098
+ *
2099
+ * `ScreenConfig` surface (same fields as list screens, reused here):
2100
+ * - `SCREEN` — screen code whose grants apply to this fragment
2101
+ * - `SERVICE` — NSwag client resolved into `serviceInstance`
2102
+ * - `SEARCH_REFERENTIALS_KEYS` / `SEARCH_STATIC_LISTS` — ref-data the
2103
+ * fragment's columns/labels need; loaded on init, `refDataLoaded$`
2104
+ * fires when ready (reference columns resolve reactively, so data
2105
+ * fetches don't have to wait for it)
2106
+ *
2107
+ * No criteria caching, no routing, no toolbar — a sub-screen renders one
2108
+ * `ef-card` (or similar) and owns only its data + collapse state.
2109
+ */
2110
+ class AbstractSubScreenV2 extends AbstractScreenComponent {
2111
+ screenState = null;
2112
+ injector = inject(Injector);
2113
+ /** NSwag client resolved from `ScreenConfig.SERVICE` (null-safe: a
2114
+ * sub-screen fed entirely by inputs may omit SERVICE). */
2115
+ serviceInstance;
2116
+ /**
2117
+ * Collapse state, bound `[(collapsed)]` on the sub-screen's `ef-card`.
2118
+ * Starts collapsed: sub-screens are secondary content on their host
2119
+ * screen, so they open on demand. Subclasses that must start open set
2120
+ * `this.collapsed.set(false)` in their constructor.
2121
+ */
2122
+ collapsed = signal(true, ...(ngDevMode ? [{ debugName: "collapsed" }] : /* istanbul ignore next */ []));
2123
+ toggleCollapsed() {
2124
+ this.collapsed.update((c) => !c);
2125
+ }
2126
+ /** Read grant on the config's `SCREEN` — gate the whole fragment on
2127
+ * this so an unauthorized user gets nothing (not an erroring card). */
2128
+ canRead() {
2129
+ return this.context?.isGranted(Permissions.Read) ?? false;
2130
+ }
2131
+ ngOnInit() {
2132
+ super.ngOnInit();
2133
+ const cfg = this.getConfig();
2134
+ if (cfg?.SERVICE) {
2135
+ this.serviceInstance = this.injector.get(cfg.SERVICE);
2136
+ }
2137
+ const hasDynamicRefs = (cfg?.SEARCH_REFERENTIALS_KEYS?.length ?? 0) > 0;
2138
+ const hasStaticLists = (cfg?.SEARCH_STATIC_LISTS?.length ?? 0) > 0;
2139
+ if (hasDynamicRefs)
2140
+ this.initializeReferenceKeys(cfg.SEARCH_REFERENTIALS_KEYS);
2141
+ if (hasStaticLists)
2142
+ this.initializeStaticLists(cfg.SEARCH_STATIC_LISTS);
2143
+ // No refs declared → no load, and (matching AbstractSearchScreenV2)
2144
+ // no refDataLoaded$ emission — don't wait on it in that case.
2145
+ if (hasDynamicRefs || hasStaticLists) {
2146
+ this.loadReferenceData(cfg.REF_DATA_OPTIONS);
2147
+ }
2148
+ }
2149
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: AbstractSubScreenV2, deps: null, target: i0.ɵɵFactoryTarget.Component });
2150
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.11", type: AbstractSubScreenV2, isStandalone: true, selector: "ng-component", usesInheritance: true, ngImport: i0, template: '', isInline: true });
2151
+ }
2152
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: AbstractSubScreenV2, decorators: [{
2153
+ type: Component,
2154
+ args: [{ template: '', standalone: true }]
2155
+ }] });
2156
+
824
2157
  class ScreenConfig {
825
2158
  SCREEN;
826
2159
  SERVICE;
@@ -832,6 +2165,24 @@ class ScreenConfig {
832
2165
  REFRESH_ON_SAVE;
833
2166
  REF_DATA_OPTIONS;
834
2167
  DEFAULT_SORT;
2168
+ /**
2169
+ * Backend entity type for the standardized change-history box. When set,
2170
+ * AbstractDetailScreenV2 auto-loads the record's audit trail (via the
2171
+ * AUDIT_HISTORY_SERVICE token) into its `auditEntries` signal — the screen
2172
+ * only needs `<ef-change-history [entries]="auditEntries()" />`. Leave unset
2173
+ * to opt out. Must match the backend IAuditable.AuditEntityType (e.g. 'Client').
2174
+ */
2175
+ AUDIT_ENTITY_TYPE;
2176
+ /**
2177
+ * Report screens (AbstractReportScreenV2): starting period preset for the
2178
+ * screen's ef-datepicker-advanced. Unset → 'last_30_days'.
2179
+ */
2180
+ DEFAULT_PERIOD;
2181
+ /**
2182
+ * Report screens: translatable static lists preloaded before the first
2183
+ * loadAll() — same mechanism as SEARCH_STATIC_LISTS on list screens.
2184
+ */
2185
+ REPORT_STATIC_LISTS;
835
2186
  }
836
2187
 
837
2188
  // Abstract base classes
@@ -840,5 +2191,5 @@ class ScreenConfig {
840
2191
  * Generated bundle index. Do not edit.
841
2192
  */
842
2193
 
843
- export { AbstractComponent, AbstractDetailScreenComponent, AbstractEntity, AbstractScreenComponent, AbstractSearchScreenComponent, AbstractSubScreenComponent, PaginationEnum, SCREEN_REF_DATA_SERVICE, ScreenConfig, ScreenContext, ScreenStateEnum, SearchEntity, SortDirectionEnum, StateUtilsEnum, ViewModelEntity };
2194
+ export { AUDIT_HISTORY_SERVICE, AbstractComponent, AbstractDetailScreenComponent, AbstractDetailScreenV2, AbstractEntity, AbstractReportScreenV2, AbstractScreenComponent, AbstractSearchScreenComponent, AbstractSearchScreenV2, AbstractSubScreenComponent, AbstractSubScreenV2, PaginationEnum, SCREEN_REF_DATA_SERVICE, ScreenConfig, ScreenContext, ScreenStateEnum, SearchEntity, SortDirectionEnum, StateUtilsEnum, ViewModelEntity };
844
2195
  //# sourceMappingURL=elasticias-screens.mjs.map