@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 {
|
|
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(
|
|
110
|
-
!this.isGranted(
|
|
111
|
-
!this.isGranted(
|
|
112
|
-
!this.isGranted(
|
|
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
|
-
|
|
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
|