platformcommons-web-lib 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.
Files changed (208) hide show
  1. package/commons-shared-web-ui-1.0.0.tgz +0 -0
  2. package/documentation/alert.md +123 -0
  3. package/documentation/button-dropdown.md +126 -0
  4. package/documentation/button.md +184 -0
  5. package/documentation/cards-usage-guidelines.md +131 -0
  6. package/documentation/configurable-form.md +605 -0
  7. package/documentation/confirmation-modal.md +250 -0
  8. package/documentation/filter-sidebar.md +178 -0
  9. package/documentation/filter-table-selector.md +228 -0
  10. package/documentation/form-builder.md +597 -0
  11. package/documentation/form-components.md +384 -0
  12. package/documentation/nav.md +427 -0
  13. package/documentation/pagination.md +181 -0
  14. package/documentation/side-nav-documentation.md +169 -0
  15. package/documentation/smart-form.md +2177 -0
  16. package/documentation/smart-table.md +1198 -0
  17. package/documentation/snackbar.md +118 -0
  18. package/documentation/style-externalization.md +88 -0
  19. package/documentation/summary-card.md +279 -0
  20. package/ng-package.json +28 -0
  21. package/package.json +54 -0
  22. package/src/lib/modules/alert/alert.models.ts +6 -0
  23. package/src/lib/modules/alert/alert.module.ts +16 -0
  24. package/src/lib/modules/alert/alert.theme.scss +85 -0
  25. package/src/lib/modules/alert/components/alert/alert.component.html +27 -0
  26. package/src/lib/modules/alert/components/alert/alert.component.scss +92 -0
  27. package/src/lib/modules/alert/components/alert/alert.component.ts +81 -0
  28. package/src/lib/modules/button/button.models.ts +13 -0
  29. package/src/lib/modules/button/button.module.ts +16 -0
  30. package/src/lib/modules/button/button.theme.scss +121 -0
  31. package/src/lib/modules/button/components/button/button.component.html +22 -0
  32. package/src/lib/modules/button/components/button/button.component.scss +88 -0
  33. package/src/lib/modules/button/components/button/button.component.ts +67 -0
  34. package/src/lib/modules/button-dropdown/button-dropdown.models.ts +26 -0
  35. package/src/lib/modules/button-dropdown/button-dropdown.module.ts +22 -0
  36. package/src/lib/modules/button-dropdown/button-dropdown.theme.scss +87 -0
  37. package/src/lib/modules/button-dropdown/components/button-dropdown/button-dropdown.component.html +41 -0
  38. package/src/lib/modules/button-dropdown/components/button-dropdown/button-dropdown.component.scss +135 -0
  39. package/src/lib/modules/button-dropdown/components/button-dropdown/button-dropdown.component.ts +160 -0
  40. package/src/lib/modules/configurable-form/component/configurable-form.component.html +294 -0
  41. package/src/lib/modules/configurable-form/component/configurable-form.component.scss +503 -0
  42. package/src/lib/modules/configurable-form/component/configurable-form.component.ts +628 -0
  43. package/src/lib/modules/configurable-form/configurable-form.examples.ts +154 -0
  44. package/src/lib/modules/configurable-form/configurable-form.model.ts +131 -0
  45. package/src/lib/modules/configurable-form/configurable-form.module.ts +19 -0
  46. package/src/lib/modules/configurable-form/configurable-form.theme.scss +78 -0
  47. package/src/lib/modules/confirmation-modal/components/confirmation-modal/confirmation-modal.component.html +77 -0
  48. package/src/lib/modules/confirmation-modal/components/confirmation-modal/confirmation-modal.component.scss +395 -0
  49. package/src/lib/modules/confirmation-modal/components/confirmation-modal/confirmation-modal.component.ts +266 -0
  50. package/src/lib/modules/confirmation-modal/confirmation-modal.models.ts +71 -0
  51. package/src/lib/modules/confirmation-modal/confirmation-modal.module.ts +20 -0
  52. package/src/lib/modules/confirmation-modal/confirmation-modal.theme.scss +87 -0
  53. package/src/lib/modules/filter/components/filter/filter.component.html +131 -0
  54. package/src/lib/modules/filter/components/filter/filter.component.scss +245 -0
  55. package/src/lib/modules/filter/components/filter/filter.component.ts +216 -0
  56. package/src/lib/modules/filter/filter.models.ts +88 -0
  57. package/src/lib/modules/filter/filter.module.ts +24 -0
  58. package/src/lib/modules/filter/filter.theme.scss +92 -0
  59. package/src/lib/modules/filter-sidebar/components/filter-sidebar/filter-sidebar.component.html +112 -0
  60. package/src/lib/modules/filter-sidebar/components/filter-sidebar/filter-sidebar.component.scss +186 -0
  61. package/src/lib/modules/filter-sidebar/components/filter-sidebar/filter-sidebar.component.ts +163 -0
  62. package/src/lib/modules/filter-sidebar/filter-sidebar.models.ts +95 -0
  63. package/src/lib/modules/filter-sidebar/filter-sidebar.module.ts +24 -0
  64. package/src/lib/modules/filter-sidebar/filter-sidebar.theme.scss +38 -0
  65. package/src/lib/modules/filter-table-selector/components/filter-table-selector/filter-table-selector.component.html +73 -0
  66. package/src/lib/modules/filter-table-selector/components/filter-table-selector/filter-table-selector.component.scss +321 -0
  67. package/src/lib/modules/filter-table-selector/components/filter-table-selector/filter-table-selector.component.ts +361 -0
  68. package/src/lib/modules/filter-table-selector/filter-table-selector.models.ts +91 -0
  69. package/src/lib/modules/filter-table-selector/filter-table-selector.module.ts +22 -0
  70. package/src/lib/modules/filter-table-selector/filter-table-selector.theme.scss +36 -0
  71. package/src/lib/modules/form-builder/components/field-configurator/configurator-config-panel/configurator-config-panel.component.html +63 -0
  72. package/src/lib/modules/form-builder/components/field-configurator/configurator-config-panel/configurator-config-panel.component.scss +496 -0
  73. package/src/lib/modules/form-builder/components/field-configurator/configurator-config-panel/configurator-config-panel.component.ts +445 -0
  74. package/src/lib/modules/form-builder/components/field-configurator/configurator-tree/configurator-tree.component.html +75 -0
  75. package/src/lib/modules/form-builder/components/field-configurator/configurator-tree/configurator-tree.component.scss +210 -0
  76. package/src/lib/modules/form-builder/components/field-configurator/configurator-tree/configurator-tree.component.ts +55 -0
  77. package/src/lib/modules/form-builder/components/field-configurator/field-configurator.component.html +25 -0
  78. package/src/lib/modules/form-builder/components/field-configurator/field-configurator.component.scss +82 -0
  79. package/src/lib/modules/form-builder/components/field-configurator/field-configurator.component.ts +95 -0
  80. package/src/lib/modules/form-builder/components/field-selection/field-selection.component.html +20 -0
  81. package/src/lib/modules/form-builder/components/field-selection/field-selection.component.scss +37 -0
  82. package/src/lib/modules/form-builder/components/field-selection/field-selection.component.ts +94 -0
  83. package/src/lib/modules/form-builder/components/field-selection/group-node/group-node.component.html +46 -0
  84. package/src/lib/modules/form-builder/components/field-selection/group-node/group-node.component.scss +102 -0
  85. package/src/lib/modules/form-builder/components/field-selection/group-node/group-node.component.ts +50 -0
  86. package/src/lib/modules/form-builder/components/field-selection/selection-field-node/selection-field-node.component.html +35 -0
  87. package/src/lib/modules/form-builder/components/field-selection/selection-field-node/selection-field-node.component.scss +67 -0
  88. package/src/lib/modules/form-builder/components/field-selection/selection-field-node/selection-field-node.component.ts +34 -0
  89. package/src/lib/modules/form-builder/components/field-selection/selection-section-node/selection-section-node.component.html +68 -0
  90. package/src/lib/modules/form-builder/components/field-selection/selection-section-node/selection-section-node.component.scss +113 -0
  91. package/src/lib/modules/form-builder/components/field-selection/selection-section-node/selection-section-node.component.ts +74 -0
  92. package/src/lib/modules/form-builder/configs/field-type-schema.map.ts +533 -0
  93. package/src/lib/modules/form-builder/form-builder.module.ts +36 -0
  94. package/src/lib/modules/form-builder/form-builder.theme.scss +212 -0
  95. package/src/lib/modules/form-builder/index.ts +9 -0
  96. package/src/lib/modules/form-builder/models/builder.models.ts +7 -0
  97. package/src/lib/modules/form-builder/models/field-configurator.models.ts +38 -0
  98. package/src/lib/modules/form-builder/models/field-selection.models.ts +51 -0
  99. package/src/lib/modules/form-builder/services/field-configurator.service.ts +258 -0
  100. package/src/lib/modules/form-builder/services/field-selection.service.ts +300 -0
  101. package/src/lib/modules/form-builder/services/form-schema-tree.service.ts +652 -0
  102. package/src/lib/modules/form-builder/tokens/builder.tokens.ts +10 -0
  103. package/src/lib/modules/form-builder/utils/constants.ts +43 -0
  104. package/src/lib/modules/form-components/components/checkbox/_theme.scss +63 -0
  105. package/src/lib/modules/form-components/components/checkbox/checkbox.component.html +29 -0
  106. package/src/lib/modules/form-components/components/checkbox/checkbox.component.scss +111 -0
  107. package/src/lib/modules/form-components/components/checkbox/checkbox.component.ts +207 -0
  108. package/src/lib/modules/form-components/components/checkbox/checkbox.models.ts +35 -0
  109. package/src/lib/modules/form-components/components/datepicker/_theme.scss +82 -0
  110. package/src/lib/modules/form-components/components/datepicker/datepicker.component.html +42 -0
  111. package/src/lib/modules/form-components/components/datepicker/datepicker.component.scss +115 -0
  112. package/src/lib/modules/form-components/components/datepicker/datepicker.component.ts +267 -0
  113. package/src/lib/modules/form-components/components/datepicker/datepicker.models.ts +45 -0
  114. package/src/lib/modules/form-components/components/dropdown/_theme.scss +91 -0
  115. package/src/lib/modules/form-components/components/dropdown/dropdown.component.html +74 -0
  116. package/src/lib/modules/form-components/components/dropdown/dropdown.component.scss +252 -0
  117. package/src/lib/modules/form-components/components/dropdown/dropdown.component.ts +377 -0
  118. package/src/lib/modules/form-components/components/dropdown/dropdown.models.ts +53 -0
  119. package/src/lib/modules/form-components/components/input/_theme.scss +77 -0
  120. package/src/lib/modules/form-components/components/input/input.component.html +51 -0
  121. package/src/lib/modules/form-components/components/input/input.component.scss +128 -0
  122. package/src/lib/modules/form-components/components/input/input.component.ts +250 -0
  123. package/src/lib/modules/form-components/components/input/input.models.ts +55 -0
  124. package/src/lib/modules/form-components/components/radio/_theme.scss +61 -0
  125. package/src/lib/modules/form-components/components/radio/radio.component.html +22 -0
  126. package/src/lib/modules/form-components/components/radio/radio.component.scss +107 -0
  127. package/src/lib/modules/form-components/components/radio/radio.component.ts +181 -0
  128. package/src/lib/modules/form-components/components/radio/radio.models.ts +39 -0
  129. package/src/lib/modules/form-components/components/search/_theme.scss +73 -0
  130. package/src/lib/modules/form-components/components/search/search.component.html +15 -0
  131. package/src/lib/modules/form-components/components/search/search.component.scss +87 -0
  132. package/src/lib/modules/form-components/components/search/search.component.ts +213 -0
  133. package/src/lib/modules/form-components/components/search/search.models.ts +40 -0
  134. package/src/lib/modules/form-components/components/toggle/_theme.scss +45 -0
  135. package/src/lib/modules/form-components/components/toggle/toggle.component.html +15 -0
  136. package/src/lib/modules/form-components/components/toggle/toggle.component.scss +81 -0
  137. package/src/lib/modules/form-components/components/toggle/toggle.component.ts +166 -0
  138. package/src/lib/modules/form-components/components/toggle/toggle.models.ts +27 -0
  139. package/src/lib/modules/form-components/directives/click-outside.directive.ts +22 -0
  140. package/src/lib/modules/form-components/form-components.module.ts +41 -0
  141. package/src/lib/modules/form-components/form-components.theme.scss +25 -0
  142. package/src/lib/modules/material/material.module.ts +94 -0
  143. package/src/lib/modules/nav/components/nav/nav.component.html +34 -0
  144. package/src/lib/modules/nav/components/nav/nav.component.scss +171 -0
  145. package/src/lib/modules/nav/components/nav/nav.component.ts +82 -0
  146. package/src/lib/modules/nav/nav.models.ts +31 -0
  147. package/src/lib/modules/nav/nav.module.ts +17 -0
  148. package/src/lib/modules/nav/nav.theme.scss +86 -0
  149. package/src/lib/modules/pagination/components/pagination/pagination.component.html +52 -0
  150. package/src/lib/modules/pagination/components/pagination/pagination.component.scss +155 -0
  151. package/src/lib/modules/pagination/components/pagination/pagination.component.ts +109 -0
  152. package/src/lib/modules/pagination/pagination.module.ts +17 -0
  153. package/src/lib/modules/pagination/pagination.theme.scss +66 -0
  154. package/src/lib/modules/side-nav/components/side-nav/side-nav.component.html +56 -0
  155. package/src/lib/modules/side-nav/components/side-nav/side-nav.component.scss +342 -0
  156. package/src/lib/modules/side-nav/components/side-nav/side-nav.component.ts +135 -0
  157. package/src/lib/modules/side-nav/side-nav.models.ts +38 -0
  158. package/src/lib/modules/side-nav/side-nav.module.ts +16 -0
  159. package/src/lib/modules/side-nav/side-nav.theme.scss +111 -0
  160. package/src/lib/modules/smart-form/components/form-field/form-field.component.html +1109 -0
  161. package/src/lib/modules/smart-form/components/form-field/form-field.component.scss +1860 -0
  162. package/src/lib/modules/smart-form/components/form-field/form-field.component.ts +2232 -0
  163. package/src/lib/modules/smart-form/components/form-section/form-section.component.html +64 -0
  164. package/src/lib/modules/smart-form/components/form-section/form-section.component.scss +209 -0
  165. package/src/lib/modules/smart-form/components/form-section/form-section.component.ts +119 -0
  166. package/src/lib/modules/smart-form/components/smart-form/smart-form.component.html +253 -0
  167. package/src/lib/modules/smart-form/components/smart-form/smart-form.component.scss +689 -0
  168. package/src/lib/modules/smart-form/components/smart-form/smart-form.component.ts +1087 -0
  169. package/src/lib/modules/smart-form/index.ts +10 -0
  170. package/src/lib/modules/smart-form/models/form-schema.model.ts +700 -0
  171. package/src/lib/modules/smart-form/models/hierarchy-config.model.ts +21 -0
  172. package/src/lib/modules/smart-form/services/expression.service.ts +75 -0
  173. package/src/lib/modules/smart-form/services/smart-form-controller.service.ts +65 -0
  174. package/src/lib/modules/smart-form/smart-form.examples.ts +1324 -0
  175. package/src/lib/modules/smart-form/smart-form.module.ts +36 -0
  176. package/src/lib/modules/smart-form/smart-form.theme.scss +890 -0
  177. package/src/lib/modules/smart-form/utils/translation.utils.ts +82 -0
  178. package/src/lib/modules/smart-form/utils/trusted-url.pipe.ts +25 -0
  179. package/src/lib/modules/smart-form/utils/validation.utils.ts +98 -0
  180. package/src/lib/modules/smart-table/components/smart-table/smart-table.component.html +283 -0
  181. package/src/lib/modules/smart-table/components/smart-table/smart-table.component.scss +685 -0
  182. package/src/lib/modules/smart-table/components/smart-table/smart-table.component.ts +1118 -0
  183. package/src/lib/modules/smart-table/models/table-config.model.ts +202 -0
  184. package/src/lib/modules/smart-table/smart-table.module.ts +30 -0
  185. package/src/lib/modules/smart-table/smart-table.theme.scss +335 -0
  186. package/src/lib/modules/smart-table/utils/safe-html.pipe.ts +22 -0
  187. package/src/lib/modules/smart-table/utils/smart-table.utils.ts +18 -0
  188. package/src/lib/modules/snackbar/components/snackbar.component.html +41 -0
  189. package/src/lib/modules/snackbar/components/snackbar.component.scss +99 -0
  190. package/src/lib/modules/snackbar/components/snackbar.component.ts +18 -0
  191. package/src/lib/modules/snackbar/models/snackbar.models.ts +10 -0
  192. package/src/lib/modules/snackbar/services/snackbar.service.ts +40 -0
  193. package/src/lib/modules/snackbar/snackbar.module.ts +11 -0
  194. package/src/lib/modules/snackbar/snackbar.theme.scss +93 -0
  195. package/src/lib/modules/summary-card/components/summary-card/summary-card.component.html +47 -0
  196. package/src/lib/modules/summary-card/components/summary-card/summary-card.component.scss +199 -0
  197. package/src/lib/modules/summary-card/components/summary-card/summary-card.component.ts +126 -0
  198. package/src/lib/modules/summary-card/summary-card.module.ts +18 -0
  199. package/src/lib/modules/summary-card/summary-card.theme.scss +176 -0
  200. package/src/lib/shared-ui.module.ts +44 -0
  201. package/src/lib/styles/global.scss +152 -0
  202. package/src/lib/styles/utilities.scss +250 -0
  203. package/src/lib/utils/constants.ts +11 -0
  204. package/src/lib/utils/storage.utils.ts +37 -0
  205. package/src/lib/utils/string.utils.ts +23 -0
  206. package/src/lib/utils/translation.utils.ts +87 -0
  207. package/src/public-api.ts +104 -0
  208. package/tsconfig.lib.json +15 -0
@@ -0,0 +1,1118 @@
1
+ import { Component, EventEmitter, Input, OnInit, Output, OnChanges, SimpleChanges, AfterViewInit, OnDestroy, ViewChildren, QueryList, ElementRef, ChangeDetectorRef, NgZone, inject, LOCALE_ID, HostListener } from '@angular/core';
2
+ import { TableConfig, TableColumn, TableAction, TableFilter, TableDataChangeEvent, TableRowSaveEvent } from '../../models/table-config.model';
3
+ import { HttpClient, HttpParams, HttpHeaders } from '@angular/common/http';
4
+ import { formatDate } from '@angular/common';
5
+ import { Router } from '@angular/router';
6
+ import { catchError, finalize, map, debounceTime, distinctUntilChanged } from 'rxjs/operators';
7
+ import { of, forkJoin, Subject } from 'rxjs';
8
+
9
+ @Component({
10
+ selector: 'lib-smart-table',
11
+ templateUrl: './smart-table.component.html',
12
+ styleUrls: ['./smart-table.component.scss'],
13
+ standalone: false
14
+ })
15
+ export class SmartTableComponent implements OnInit, OnChanges, AfterViewInit, OnDestroy {
16
+ @Input() config!: TableConfig;
17
+
18
+ /**
19
+ * External data mode: pass table rows directly from the parent.
20
+ * When this input is provided, the component will NOT make any internal API calls.
21
+ * Instead, it emits sortChange / pageChange / searchChange / filterChange events
22
+ * so the parent can fetch and supply updated data.
23
+ */
24
+ @Input() tableData?: any[];
25
+
26
+ /**
27
+ * Total number of items — used by the pagination component when operating in
28
+ * external-data mode. Must be kept in sync by the parent.
29
+ */
30
+ @Input() totalItemsCount?: number;
31
+
32
+ @Output() action = new EventEmitter<{ action: TableAction, row: any }>();
33
+ @Output() topAction = new EventEmitter<TableAction>(); // For top bar buttons
34
+ @Output() filterChange = new EventEmitter<{ key: string, value: any }>();
35
+ @Output() rowSelect = new EventEmitter<any[]>();
36
+ @Output() columnClick = new EventEmitter<{ row: any, column: string }>();
37
+
38
+ /**
39
+ * Pre-selected row objects. These are tracked across pages.
40
+ * Matching incoming data rows (via rowIdField) will be checked automatically.
41
+ */
42
+ @Input() selectedRows: any[] = [];
43
+
44
+ /** Emitted in external-data mode when the user changes the sort column/direction. */
45
+ @Output() sortChange = new EventEmitter<TableDataChangeEvent>();
46
+ /** Emitted in external-data mode when the user changes the page or page size. */
47
+ @Output() pageChange = new EventEmitter<TableDataChangeEvent>();
48
+ /** Emitted in external-data mode when the user types in the search box. */
49
+ @Output() searchChange = new EventEmitter<TableDataChangeEvent>();
50
+ /** Emitted when an inline-edited or inline-added row is saved. */
51
+ @Output() rowSave = new EventEmitter<TableRowSaveEvent>();
52
+
53
+ data: any[] = [];
54
+ totalItems: number = 0;
55
+ currentPage: number = 1;
56
+ loading: boolean = false;
57
+ originalRowsCache = new Map<any, any>();
58
+
59
+ // State
60
+ activeSort: { key: string, direction: 'ASC' | 'DESC' } = { key: '', direction: 'ASC' };
61
+ private isSortActive: boolean = false; // true only when orderBy is explicitly configured or user has sorted
62
+ activeFilters: { [key: string]: any } = {};
63
+ searchTerm: string = '';
64
+ stickyColumnStyles: { [key: string]: any } = {};
65
+ hasStickyColumns: boolean = false;
66
+ openDropdownId: string | null = null;
67
+ /** Viewport-relative position used to render the dropdown as position:fixed. */
68
+ dropdownPosition: { top: number; right: number } = { top: 0, right: 0 };
69
+ /** Items and row for the currently open dropdown — rendered in a portal outside the table. */
70
+ activeDropdownItems: any[] | null = null;
71
+ activeDropdownRow: any = null;
72
+
73
+ // --- Filter dropdown state ---
74
+ openFilterKey: string | null = null;
75
+ activeFilterLabels: { [key: string]: string } = {};
76
+ /** Viewport-relative position used to render the filter panel as position:fixed. */
77
+ filterPosition: { top: number; left: number } = { top: 0, left: 0 };
78
+ /** The filter config for the currently open filter panel. */
79
+ activeFilterData: any | null = null;
80
+
81
+ // --- Delete confirmation modal state ---
82
+ deleteModalOpen = false;
83
+ deleteModalConfig: any = {};
84
+ private pendingDeleteAction: { item: any; row: any } | null = null;
85
+
86
+ searchSubject = new Subject<string>();
87
+
88
+ @ViewChildren('stickyHeader') stickyHeaders!: QueryList<ElementRef>;
89
+ private resizeObserver: ResizeObserver | null = null;
90
+ private locale = inject(LOCALE_ID);
91
+
92
+ constructor(
93
+ private http: HttpClient,
94
+ private router: Router,
95
+ private cdr: ChangeDetectorRef,
96
+ private ngZone: NgZone
97
+ ) {
98
+ // Debounce search input
99
+ this.searchSubject.pipe(
100
+ debounceTime(this.config?.searchConfig?.debounceTime || 300),
101
+ distinctUntilChanged()
102
+ ).subscribe(term => {
103
+ this.searchTerm = term;
104
+ this.currentPage = 1;
105
+ if (this.tableData !== undefined) {
106
+ // External-data mode: delegate to parent via event
107
+ this.searchChange.emit(this.buildChangeEvent());
108
+ } else {
109
+ this.loadData();
110
+ }
111
+ });
112
+ }
113
+
114
+ ngOnInit(): void {
115
+ if (this.config) {
116
+ this.applyPaginationDefaults();
117
+ if (this.config.sortBy) {
118
+ this.activeSort.key = this.config.sortBy;
119
+ }
120
+ if (this.config.orderBy) {
121
+ this.activeSort.direction = this.config.orderBy;
122
+ this.isSortActive = true;
123
+ }
124
+ this.loadFilterOptions();
125
+
126
+ if (this.tableData !== undefined) {
127
+ // External-data mode: sync incoming data, skip internal API call
128
+ this.data = this.tableData;
129
+ if (this.totalItemsCount !== undefined) {
130
+ this.totalItems = this.totalItemsCount;
131
+ }
132
+ } else {
133
+ this.loadData();
134
+ }
135
+ }
136
+ }
137
+
138
+ ngOnChanges(changes: SimpleChanges): void {
139
+ // External data changed — sync directly without an API call
140
+ if (changes['tableData']) {
141
+ this.data = this.tableData || [];
142
+ }
143
+ // Total count updated from parent
144
+ if (changes['totalItemsCount'] && this.totalItemsCount !== undefined) {
145
+ this.totalItems = this.totalItemsCount;
146
+ }
147
+ // Config changed (non-first) — reload filter opts; reload data only if NOT in external mode
148
+ if (changes['config'] && !changes['config'].firstChange) {
149
+ this.applyPaginationDefaults();
150
+ this.loadFilterOptions();
151
+ if (this.tableData === undefined) {
152
+ this.loadData();
153
+ }
154
+ setTimeout(() => this.calculateStickyPositions());
155
+ }
156
+ if (changes['selectedRows']) {
157
+ this.syncSelection();
158
+ }
159
+ }
160
+
161
+ ngAfterViewInit(): void {
162
+ this.setupResizeObserver();
163
+ }
164
+
165
+ ngOnDestroy(): void {
166
+ if (this.resizeObserver) {
167
+ this.resizeObserver.disconnect();
168
+ }
169
+ }
170
+
171
+ private setupResizeObserver(): void {
172
+ if (this.resizeObserver) {
173
+ this.resizeObserver.disconnect();
174
+ }
175
+
176
+ this.resizeObserver = new ResizeObserver(() => {
177
+ this.ngZone.run(() => {
178
+ this.calculateStickyPositions();
179
+ });
180
+ });
181
+
182
+ if (this.stickyHeaders) {
183
+ this.stickyHeaders.changes.subscribe(() => {
184
+ this.observeHeaders();
185
+ // Also recalculate immediately
186
+ setTimeout(() => this.calculateStickyPositions());
187
+ });
188
+ this.observeHeaders();
189
+ }
190
+ }
191
+
192
+ private observeHeaders(): void {
193
+ if (!this.resizeObserver || !this.stickyHeaders) return;
194
+ this.stickyHeaders.forEach(header => {
195
+ this.resizeObserver!.observe(header.nativeElement);
196
+ });
197
+ }
198
+
199
+ loadData(): void {
200
+ if (!this.config?.apiUrl) return;
201
+
202
+ this.loading = true;
203
+ let params: HttpParams;
204
+
205
+ // --- Query Params Construction ---
206
+ if (this.config.queryParamsConfig) {
207
+ const qpConfig = this.config.queryParamsConfig;
208
+ const pageKey = qpConfig.pageKey || 'page';
209
+ const sizeKey = qpConfig.sizeKey || 'pageSize';
210
+ const pageIndex = this.currentPage + (qpConfig.pageIndexOffset || 0);
211
+
212
+ let paramsObj: any = {
213
+ [pageKey]: pageIndex.toString(),
214
+ [sizeKey]: (this.config.pagination?.pageSize || 10).toString()
215
+ };
216
+
217
+ if (this.activeSort.key) paramsObj['sortBy'] = this.activeSort.key;
218
+ // Only include orderBy if explicitly configured or user has sorted a column
219
+ if (this.isSortActive && this.activeSort.direction) paramsObj['orderBy'] = this.activeSort.direction;
220
+
221
+ // Search Handling
222
+ if (this.searchTerm) {
223
+ const searchConfig = this.config.searchConfig;
224
+ const searchKey = searchConfig?.searchKey || 'search';
225
+
226
+ if (searchConfig?.handling === 'nested_string' && qpConfig.nestedStringConfig) {
227
+ // Will be handled in nested string construction below
228
+ } else {
229
+ paramsObj[searchKey] = this.searchTerm;
230
+ }
231
+ }
232
+
233
+ // Filter Handling (and Search if nested)
234
+ if (qpConfig.filterHandling === 'nested_string' && qpConfig.nestedStringConfig) {
235
+ const { paramName, baseValue, separator, assignment } = qpConfig.nestedStringConfig;
236
+ let nestedString = baseValue || '';
237
+ const assign = assignment || '=';
238
+
239
+ // Add Filters
240
+ Object.keys(this.activeFilters).forEach(key => {
241
+ if (this.activeFilters[key]) {
242
+ const prefix = nestedString ? separator : '';
243
+ nestedString += `${prefix}${key}${assign}${this.activeFilters[key]}`;
244
+ }
245
+ });
246
+
247
+ // Add Search if nested
248
+ if (this.searchTerm && this.config.searchConfig?.handling === 'nested_string') {
249
+ const searchKey = this.config.searchConfig.searchKey || 'SEARCH_TERM';
250
+ const prefix = nestedString ? separator : '';
251
+ nestedString += `${prefix}${searchKey}${assign}${this.searchTerm}`;
252
+ }
253
+
254
+ paramsObj[paramName] = nestedString;
255
+ } else {
256
+ // Standard handling
257
+ Object.keys(this.activeFilters).forEach(key => {
258
+ if (this.activeFilters[key]) paramsObj[key] = this.activeFilters[key];
259
+ });
260
+ }
261
+ params = new HttpParams({ fromObject: paramsObj });
262
+
263
+ } else {
264
+ // --- Default Behavior (Backward Compatibility) ---
265
+ let paramsObj: any = {};
266
+
267
+ if (this.config.pagination?.enabled) {
268
+ paramsObj['page'] = this.currentPage;
269
+ paramsObj['pageSize'] = this.config.pagination.pageSize;
270
+ }
271
+
272
+ if (this.activeSort.key) {
273
+ paramsObj['sortBy'] = this.activeSort.key;
274
+ // Only include orderBy if explicitly configured or user has sorted a column
275
+ if (this.isSortActive) paramsObj['orderBy'] = this.activeSort.direction;
276
+ }
277
+
278
+ if (this.searchTerm) {
279
+ const searchKey = this.config.searchConfig?.searchKey || 'search';
280
+ paramsObj[searchKey] = this.searchTerm;
281
+ }
282
+
283
+ Object.keys(this.activeFilters).forEach(key => {
284
+ if (this.activeFilters[key]) {
285
+ paramsObj[key] = this.activeFilters[key];
286
+ }
287
+ });
288
+
289
+ // Custom Request Params Mapper (if provided)
290
+ if (this.config.requestParams) {
291
+ const customParams = this.config.requestParams(
292
+ this.currentPage,
293
+ this.config.pagination?.pageSize || 10,
294
+ this.activeSort.key,
295
+ this.activeSort.direction,
296
+ this.searchTerm,
297
+ this.activeFilters
298
+ );
299
+ if (customParams instanceof HttpParams) {
300
+ // If function returns HttpParams, use it directly (caveat: might lose previous params if not careful in mapper)
301
+ params = customParams;
302
+ } else {
303
+ paramsObj = { ...paramsObj, ...customParams };
304
+ params = new HttpParams({ fromObject: paramsObj });
305
+ }
306
+ } else {
307
+ params = new HttpParams({ fromObject: paramsObj });
308
+ }
309
+ }
310
+
311
+ // --- Data Fetching ---
312
+
313
+ // Check for separate count API
314
+ const totalCountConfig = this.config.pagination?.totalCountConfig;
315
+ let request$: any; // Observable
316
+
317
+ if (totalCountConfig?.source === 'separate' && totalCountConfig.apiUrl) {
318
+ const headers = this.getHeaders();
319
+ const method = this.config.apiMethod || 'GET';
320
+ const body = this.config.apiPayload || {};
321
+
322
+ const dataRequest$ = method === 'POST' ? this.http.post<any>(this.config.apiUrl, body, { params, headers }) : this.http.get<any>(this.config.apiUrl, { params, headers });
323
+ const countRequest$ = method === 'POST' ? this.http.post<any>(totalCountConfig.apiUrl, body, { params, headers }) : this.http.get<any>(totalCountConfig.apiUrl, { params, headers });
324
+
325
+ request$ = forkJoin({
326
+ data: dataRequest$,
327
+ count: countRequest$
328
+ }).pipe(
329
+ map(({ data, count }) => {
330
+ const dataPath = this.config.dataResponsePath !== undefined ? this.config.dataResponsePath : '';
331
+ return {
332
+ data: this.getValueByPath(data, dataPath),
333
+ total: this.getValueByPath(count, totalCountConfig.responsePath || '')
334
+ };
335
+ })
336
+ );
337
+ } else {
338
+
339
+ const headers = this.getHeaders();
340
+ const method = this.config.apiMethod || 'GET';
341
+ const body = this.config.apiPayload || {};
342
+
343
+ const baseRequest$ = method === 'POST' ? this.http.post<any>(this.config.apiUrl, body, { params, headers }) : this.http.get<any>(this.config.apiUrl, { params, headers });
344
+
345
+ request$ = baseRequest$.pipe(
346
+ map(response => {
347
+ const dataPath = this.config.dataResponsePath !== undefined ? this.config.dataResponsePath : '';
348
+ const totalPath = totalCountConfig?.responsePath || '';
349
+ return {
350
+ data: this.getValueByPath(response, dataPath),
351
+ // If source is 'same', try to get total from response, else default 0
352
+ total: totalCountConfig ? this.getValueByPath(response, totalPath) : 0
353
+ };
354
+ })
355
+ );
356
+ }
357
+
358
+ request$.pipe(
359
+ finalize(() => this.loading = false),
360
+ catchError(err => {
361
+ console.error('Table Data Fetch Error', err);
362
+ return of({ data: [], total: 0 });
363
+ })
364
+ )
365
+ .subscribe((result: any) => {
366
+ this.data = result.data || [];
367
+ if (this.config.pagination) {
368
+ this.totalItems = result.total || 0;
369
+ }
370
+ if (!this.config.columns || this.config.columns.length === 0) {
371
+ if (this.data && this.data.length > 0) {
372
+ this.config.columns = Object.keys(this.data[0]).map(key => ({
373
+ key: key,
374
+ label: this.toTitleCase(key),
375
+ type: 'text',
376
+ sortable: false,
377
+ editable: false
378
+ }));
379
+ }
380
+ }
381
+ this.syncSelection();
382
+ });
383
+ }
384
+
385
+ /**
386
+ * Syncs the locally loaded data set with the externally provided selectedRows.
387
+ * Marks 'selected' property on row objects to reflect checkbox state.
388
+ */
389
+ private syncSelection(): void {
390
+ if (!this.data || !this.data.length || !this.config) return;
391
+
392
+ const idField = (this.config as any).rowIdField || 'id';
393
+ const selectedIds = new Set((this.selectedRows || []).map(r => r[idField]));
394
+
395
+ this.data.forEach(row => {
396
+ row.selected = selectedIds.has(row[idField]);
397
+ });
398
+ }
399
+
400
+ // --- Actions ---
401
+
402
+ onPageChange(page: number): void {
403
+ this.currentPage = page;
404
+ if (this.tableData !== undefined) {
405
+ this.pageChange.emit(this.buildChangeEvent());
406
+ } else {
407
+ this.loadData();
408
+ }
409
+ }
410
+
411
+ onPageSizeChange(size: number): void {
412
+ if (this.config.pagination) {
413
+ this.config.pagination.pageSize = size;
414
+ this.currentPage = 1; // Reset to first page
415
+ if (this.tableData !== undefined) {
416
+ this.pageChange.emit(this.buildChangeEvent());
417
+ } else {
418
+ this.loadData();
419
+ }
420
+ }
421
+ }
422
+
423
+ onSort(col: TableColumn): void {
424
+ if (!col.sortable) return;
425
+
426
+ if (this.activeSort.key === col.key) {
427
+ this.activeSort.direction = this.activeSort.direction === 'ASC' ? 'DESC' : 'ASC';
428
+ } else {
429
+ this.activeSort.key = col.key;
430
+ this.activeSort.direction = 'ASC';
431
+ }
432
+ this.isSortActive = true; // User has actively sorted, include orderBy in API call
433
+
434
+ if (this.tableData !== undefined) {
435
+ // External-data mode: let the parent handle the API call
436
+ this.sortChange.emit(this.buildChangeEvent());
437
+ } else {
438
+ this.loadData();
439
+ }
440
+ }
441
+
442
+ onSearch(event: Event): void {
443
+ const value = (event.target as HTMLInputElement).value;
444
+ this.searchSubject.next(value);
445
+ }
446
+
447
+ onFilterChange(key: string, event: Event): void {
448
+ const value = (event.target as HTMLSelectElement).value;
449
+ this.applyFilter(key, value);
450
+ }
451
+
452
+ private applyFilter(key: string, value: any): void {
453
+ this.activeFilters[key] = value;
454
+ this.currentPage = 1;
455
+ this.filterChange.emit({ key, value });
456
+ if (this.tableData === undefined) {
457
+ this.loadData();
458
+ }
459
+ }
460
+
461
+ toggleFilter(key: string, event: Event): void {
462
+ event.stopPropagation();
463
+ if (this.openFilterKey === key) {
464
+ this.openFilterKey = null;
465
+ this.activeFilterData = null;
466
+ return;
467
+ }
468
+ const trigger = event.currentTarget as HTMLElement;
469
+ const rect = trigger.getBoundingClientRect();
470
+ this.filterPosition = { top: rect.bottom + 6, left: rect.left };
471
+ this.openFilterKey = key;
472
+ this.activeFilterData = this.config.filters?.find(f => f.key === key) || null;
473
+ }
474
+
475
+ selectFilterOption(filter: any, opt: { label: string; value: any } | null): void {
476
+ const value = opt ? opt.value : '';
477
+ this.activeFilterLabels[filter.key] = opt ? opt.label : '';
478
+ this.applyFilter(filter.key, value);
479
+ this.openFilterKey = null;
480
+ }
481
+
482
+ getFilterDisplay(filter: any): string {
483
+ return this.activeFilterLabels[filter.key] || filter.label;
484
+ }
485
+
486
+ getValidFilterOptions(filter: any): any[] {
487
+ return (filter?.options || []).filter((opt: any) => opt.label != null && opt.label !== '');
488
+ }
489
+
490
+ isFilterActive(filter: any): boolean {
491
+ return !!this.activeFilterLabels[filter.key];
492
+ }
493
+
494
+ // --- Private helpers ---
495
+
496
+ /**
497
+ * Assembles the current table state into a `TableDataChangeEvent` object.
498
+ * Emitted to the parent in external-data mode so it can fetch and supply new data.
499
+ */
500
+ private buildChangeEvent(): TableDataChangeEvent {
501
+ return {
502
+ page: this.currentPage,
503
+ pageSize: this.config.pagination?.pageSize || 10,
504
+ sortBy: this.activeSort.key || undefined,
505
+ orderBy: this.isSortActive ? this.activeSort.direction : undefined,
506
+ searchTerm: this.searchTerm || undefined,
507
+ filters: { ...this.activeFilters }
508
+ };
509
+ }
510
+
511
+ onAction(action: TableAction, row: any): void {
512
+ if (action.type === 'edit-row' as any) {
513
+ const idField = (this.config as any).rowIdField || 'id';
514
+ this.originalRowsCache.set(row[idField], JSON.parse(JSON.stringify(row)));
515
+ this.prepareDateFieldsForEdit(row);
516
+ row.isEditing = true;
517
+ this.action.emit({ action, row });
518
+ return;
519
+ }
520
+
521
+ if (action.type === 'callback' && action.callback) {
522
+ action.callback(row);
523
+ }
524
+
525
+ if (action.type === 'route' && action.route) {
526
+ const url = this.replaceParams(action.route, row);
527
+ this.router.navigateByUrl(url);
528
+ return;
529
+ }
530
+
531
+ if (action.type === 'api') {
532
+ if (action.confirmationNeeded) {
533
+ const message = action.confirmationMessage || this.config.labels?.defaultConfirmationMessage || 'Are you sure?';
534
+ if (!confirm(message)) return;
535
+ }
536
+ if (action.apiUrl) {
537
+ this.executeApiAction(action, row);
538
+ } else {
539
+ this.action.emit({ action, row });
540
+ }
541
+ } else {
542
+ this.action.emit({ action, row });
543
+ }
544
+ }
545
+
546
+ onActionItemClick(item: any, row: any, event: Event): void {
547
+ event.stopPropagation();
548
+
549
+ if (item.type === 'edit-row') {
550
+ const idField = (this.config as any).rowIdField || 'id';
551
+ this.originalRowsCache.set(row[idField], JSON.parse(JSON.stringify(row)));
552
+ this.prepareDateFieldsForEdit(row);
553
+ row.isEditing = true;
554
+ this.action.emit({ action: item as TableAction, row });
555
+ this.closeDropdown();
556
+ return;
557
+ }
558
+
559
+ if (item.type === 'delete') {
560
+ this.openDeleteConfirmModal(item, row);
561
+ return;
562
+ }
563
+
564
+ if (item.type === 'callback' && item.callback) {
565
+ item.callback(row);
566
+ }
567
+
568
+ if (item.type === 'route' && item.route) {
569
+ const url = this.replaceParams(item.route, row);
570
+ this.router.navigateByUrl(url);
571
+ return;
572
+ }
573
+
574
+ if (item.type === 'api') {
575
+ if (item.confirmationNeeded) {
576
+ const message = item.confirmationMessage || this.config.labels?.defaultConfirmationMessage || 'Are you sure?';
577
+ if (!confirm(message)) return;
578
+ }
579
+ if (item.apiUrl) {
580
+ this.executeApiAction(item as TableAction, row);
581
+ } else {
582
+ this.action.emit({ action: item as TableAction, row });
583
+ }
584
+ } else {
585
+ this.action.emit({ action: item as TableAction, row });
586
+ }
587
+ }
588
+
589
+ private openDeleteConfirmModal(item: any, row: any): void {
590
+ const cfg = item.deleteConfig || {};
591
+ this.deleteModalConfig = {
592
+ title: cfg.modalTitle || 'Confirm Delete',
593
+ size: 'sm' as const,
594
+ headerTheme: 'dark' as const,
595
+ confirmButton: {
596
+ label: cfg.confirmLabel || 'Delete',
597
+ type: 'danger' as const
598
+ },
599
+ cancelButton: {
600
+ label: cfg.cancelLabel || 'Cancel',
601
+ show: true
602
+ }
603
+ };
604
+ this.deleteModalMessage = cfg.modalMessage || 'Are you sure you want to delete this item?';
605
+ this.pendingDeleteAction = { item, row };
606
+ this.deleteModalOpen = true;
607
+ }
608
+
609
+ deleteModalMessage = '';
610
+
611
+ onDeleteConfirm(): void {
612
+ this.deleteModalOpen = false;
613
+ if (!this.pendingDeleteAction) return;
614
+ const { item, row } = this.pendingDeleteAction;
615
+ const cfg = item.deleteConfig || {};
616
+ const idField = cfg.idField || 'id';
617
+ const url = cfg.apiUrl.replace(`:${idField}`, row[idField]);
618
+ this.pendingDeleteAction = null;
619
+
620
+ this.loading = true;
621
+ this.http.delete(url, { headers: this.getHeaders() }).pipe(
622
+ finalize(() => this.loading = false)
623
+ ).subscribe({
624
+ next: () => {
625
+ if (this.data.length === 1 && this.currentPage > 1) {
626
+ this.currentPage--;
627
+ }
628
+ this.loadData();
629
+ this.action.emit({ action: item as TableAction, row });
630
+ },
631
+ error: (err) => console.error('[SmartTable] Delete API Error', err)
632
+ });
633
+ }
634
+
635
+ onDeleteCancel(): void {
636
+ this.deleteModalOpen = false;
637
+ this.pendingDeleteAction = null;
638
+ }
639
+
640
+ onTopAction(action: TableAction): void {
641
+ if (action.type === 'add-row' as any) {
642
+ const idField = (this.config as any).rowIdField || 'id';
643
+ const newRow: any = {
644
+ [idField]: 0,
645
+ isNew: true,
646
+ isEditing: true
647
+ };
648
+ if (this.config.columns) {
649
+ this.config.columns.forEach(col => {
650
+ if (col.editConfig && col.editConfig.defaultValue !== undefined) {
651
+ newRow[col.key] = col.dataType === 'date'
652
+ ? this.toDateObject(col.editConfig.defaultValue)
653
+ : col.editConfig.defaultValue;
654
+ } else {
655
+ newRow[col.key] = col.dataType === 'date' ? null : '';
656
+ }
657
+ if (col.subFields) {
658
+ col.subFields.forEach(sub => {
659
+ if (sub.editConfig && sub.editConfig.defaultValue !== undefined) {
660
+ newRow[sub.key] = sub.dataType === 'date'
661
+ ? this.toDateObject(sub.editConfig.defaultValue)
662
+ : sub.editConfig.defaultValue;
663
+ } else {
664
+ newRow[sub.key] = sub.dataType === 'date' ? null : '';
665
+ }
666
+ });
667
+ }
668
+ });
669
+ }
670
+ this.data = [newRow, ...this.data];
671
+ setTimeout(() => this.calculateStickyPositions());
672
+ this.topAction.emit(action);
673
+ return;
674
+ }
675
+
676
+ if (action.type === 'callback' && action.callback) {
677
+ action.callback(null); // No row for top action
678
+ }
679
+
680
+ if (action.type === 'route' && action.route) {
681
+ // Since it's a top action, replaceParams with an empty object or handle statically
682
+ const url = this.replaceParams(action.route, {});
683
+ this.router.navigateByUrl(url);
684
+ return;
685
+ }
686
+
687
+ if (action.type === 'api') {
688
+ if (action.confirmationNeeded) {
689
+ const message = action.confirmationMessage || this.config.labels?.defaultConfirmationMessage || 'Are you sure?';
690
+ if (!confirm(message)) return;
691
+ }
692
+ if (action.apiUrl) {
693
+ this.executeApiAction(action, null);
694
+ } else {
695
+ this.topAction.emit(action);
696
+ }
697
+ } else {
698
+ this.topAction.emit(action);
699
+ }
700
+ }
701
+
702
+ private executeApiAction(action: TableAction, row: any): void {
703
+ if (!action.apiUrl) return;
704
+
705
+ const url = row ? this.replaceParams(action.apiUrl, row) : action.apiUrl;
706
+ const method = action.apiMethod || 'POST';
707
+ const headers = this.getHeaders();
708
+ const body = row ? row : {};
709
+
710
+ this.loading = true;
711
+ this.http.request(method, url, { body, headers }).pipe(
712
+ finalize(() => this.loading = false)
713
+ ).subscribe({
714
+ next: () => {
715
+ this.loadData(); // reload on success
716
+ if (row) {
717
+ this.action.emit({ action, row });
718
+ } else {
719
+ this.topAction.emit(action);
720
+ }
721
+ },
722
+ error: (err) => {
723
+ console.error('API Action Error', err);
724
+ }
725
+ });
726
+ }
727
+
728
+ // --- Selection ---
729
+
730
+ onSelectAll(event: Event): void {
731
+ const checked = (event.target as HTMLInputElement).checked;
732
+ this.data.forEach(row => row.selected = checked);
733
+ this.updateSelectedRows();
734
+ }
735
+
736
+ onRowSelect(row: any): void {
737
+ this.updateSelectedRows();
738
+ }
739
+
740
+ updateSelectedRows(): void {
741
+ const idField = (this.config as any).rowIdField || 'id';
742
+
743
+ // 1. Identify rows from the global selection that are NOT on the current page
744
+ const currentPageIds = new Set(this.data.map(r => r[idField]));
745
+ const otherPagesSelected = (this.selectedRows || []).filter(r => !currentPageIds.has(r[idField]));
746
+
747
+ // 2. Identify currently selected rows on the current page
748
+ const currentPageSelected = this.data.filter(row => row.selected);
749
+
750
+ // 3. Merge them to form the new global selection
751
+ this.selectedRows = [...otherPagesSelected, ...currentPageSelected];
752
+
753
+ this.rowSelect.emit(this.selectedRows);
754
+ }
755
+
756
+ // --- Helpers ---
757
+
758
+ getCellValue(row: any, col: TableColumn): any {
759
+ // Support nested properties via labelPath or key
760
+ const path = col.labelPath || col.key;
761
+ let val = this.getValueByPath(row, path);
762
+
763
+ // Formatting (Date, etc.)
764
+ if (col.type === 'date' && val) {
765
+ if (col.dateFormat) {
766
+ try {
767
+ return formatDate(val, col.dateFormat, this.locale);
768
+ } catch (e) {
769
+ console.warn('Invalid date format or value', val, col.dateFormat);
770
+ return val;
771
+ }
772
+ }
773
+ return new Date(val).toLocaleDateString();
774
+ }
775
+
776
+ if (val === null || val === undefined || val === '') {
777
+ return col.emptyValue ?? this.config.emptyValue ?? '-';
778
+ }
779
+ return val;
780
+ }
781
+
782
+ getBadgeClass(row: any, col: TableColumn): string {
783
+ const val = this.getCellValue(row, col);
784
+ const strVal = String(val);
785
+
786
+ // Config approach
787
+ if (col.badgeConfig && col.badgeConfig[strVal]) {
788
+ return `badge-${col.badgeConfig[strVal]}`;
789
+ }
790
+
791
+ // Default Logic
792
+ const status = strVal.toLowerCase();
793
+
794
+ if (['active', 'completed', 'success', 'approved'].includes(status)) return 'badge-success';
795
+ if (['pending', 'in progress', 'waiting'].includes(status)) return 'badge-warning';
796
+ if (['rejected', 'failed', 'error', 'deleted'].includes(status)) return 'badge-danger';
797
+ if (['draft', 'inactive'].includes(status)) return 'badge-neutral';
798
+
799
+ return 'badge-info'; // default
800
+ }
801
+
802
+ getAscOpacity(key: string): number {
803
+ if (this.activeSort.key !== key) return 0.45;
804
+ return this.activeSort.direction === 'ASC' ? 1 : 0.2;
805
+ }
806
+
807
+ getDescOpacity(key: string): number {
808
+ if (this.activeSort.key !== key) return 0.45;
809
+ return this.activeSort.direction === 'DESC' ? 1 : 0.2;
810
+ }
811
+
812
+ private replaceParams(template: string, row: any): string {
813
+ return template.replace(/:([a-zA-Z0-9_]+)/g, (_, key) => row[key] || '');
814
+ }
815
+
816
+ private loadFilterOptions(): void {
817
+ if (!this.config.filters) return;
818
+
819
+ this.config.filters.forEach(filter => {
820
+ if (filter.apiUrl && !filter.options) {
821
+ const headers = this.getHeaders();
822
+ let params = new HttpParams();
823
+
824
+ if (filter.handling === 'nested_string' && filter.nestedStringConfig) {
825
+ const { paramName, baseValue } = filter.nestedStringConfig;
826
+ if (baseValue) {
827
+ params = params.set(paramName, baseValue);
828
+ }
829
+ }
830
+
831
+ const method = filter.apiMethod || 'GET';
832
+ const body = filter.apiPayload || {};
833
+
834
+ const request$ = method === 'POST'
835
+ ? this.http.post<any>(filter.apiUrl, body, { headers, params })
836
+ : this.http.get<any>(filter.apiUrl, { headers, params });
837
+
838
+ request$.subscribe({
839
+ next: (response) => {
840
+ const data = filter.dataPath ? this.getValueByPath(response, filter.dataPath) : response;
841
+ if (!Array.isArray(data)) {
842
+ console.error(`Filter data for ${filter.key} is not an array`, data);
843
+ return;
844
+ }
845
+ filter.options = data.map((item: any) => {
846
+ const label = filter.labelPath ? this.getValueByPath(item, filter.labelPath) :
847
+ (filter.labelKey ? item[filter.labelKey] : item.label || item.name);
848
+ const value = filter.valuePath ? this.getValueByPath(item, filter.valuePath) :
849
+ (filter.valueKey ? item[filter.valueKey] : item.value || item.code);
850
+ return { label, value };
851
+ });
852
+
853
+ // Auto-populate matching column options
854
+ if (this.config.columns) {
855
+ const matchingColumn = this.config.columns.find(col => col.key === filter.key);
856
+ if (matchingColumn && matchingColumn.dataType === 'select' && !matchingColumn.options) {
857
+ matchingColumn.options = filter.options;
858
+ }
859
+ }
860
+ },
861
+ error: (err) => console.error(`Failed to load filter options for ${filter.key}:`, err)
862
+ });
863
+ }
864
+ });
865
+ }
866
+
867
+ private getValueByPath(obj: any, path: string): any {
868
+ if (!path || path === '') return obj;
869
+ return path.split('.').reduce((acc, part) => {
870
+ const match = part.match(/(\w+)\[(\d+)\]/);
871
+ if (match) {
872
+ return acc?.[match[1]]?.[parseInt(match[2])];
873
+ }
874
+ return acc?.[part];
875
+ }, obj);
876
+ }
877
+
878
+ private calculateStickyPositions(): void {
879
+ // We calculate positions based on rendered widths
880
+ if (!this.stickyHeaders || this.stickyHeaders.length === 0) return;
881
+
882
+ this.stickyColumnStyles = {};
883
+ let leftOffset = 0;
884
+ this.hasStickyColumns = false;
885
+
886
+ // Default: only the first data column is sticky
887
+ const stickyCount = this.config.stickyColumnCount !== undefined ? this.config.stickyColumnCount : 1;
888
+
889
+ // Checkbox width handling
890
+ if (this.config.selectable) {
891
+ const firstSticky = this.config.columns.find((c, i) => i < stickyCount || c.sticky);
892
+ if (firstSticky) {
893
+ // We can try to measure checkbox col if needed, but usually fixed 40px
894
+ // Or better, if we have a checkbox col ref, assume 40px for now as it is fixed in CSS
895
+ leftOffset = 40;
896
+ }
897
+ }
898
+
899
+ const headerElements = this.stickyHeaders.toArray();
900
+
901
+ this.config.columns.forEach((col, index) => {
902
+ if (col.sticky || index < stickyCount) {
903
+ col.sticky = true;
904
+ this.hasStickyColumns = false; // Reset to true only after we set styles? No, wait.
905
+ // Actually property is used in template to add class 'sticky-col'
906
+ // but we need to update Styles based on previous cols widths
907
+
908
+ // Find corresponding header element
909
+ // Note: headerElements corresponds to columns indices
910
+ const headerEl = headerElements[index]?.nativeElement;
911
+
912
+ if (headerEl) {
913
+ // Set style for current column
914
+ this.stickyColumnStyles[col.key] = {
915
+ left: `${leftOffset}px`
916
+ // We DO NOT set width here, allowing it to be dynamic/auto
917
+ };
918
+
919
+ // Add THIS column's width to offset for the NEXT column
920
+ // use getBoundingClientRect or offsetWidth
921
+ leftOffset += headerEl.offsetWidth;
922
+ }
923
+ this.hasStickyColumns = true;
924
+ }
925
+ });
926
+
927
+ this.cdr.detectChanges();
928
+ }
929
+
930
+ private toTitleCase(str: string): string {
931
+ return str
932
+ .replace(/([A-Z])/g, ' $1') // insert space before capital letters
933
+ .replace(/^./, (str) => str.toUpperCase()) // capitalize the first letter
934
+ .trim(); // remove any leading/trailing whitespace
935
+ }
936
+
937
+ get columnCount(): number {
938
+ return this.config.columns.length;
939
+ }
940
+
941
+ get showPagination(): boolean {
942
+ if (!this.config?.pagination?.enabled) return false;
943
+ const pageSize = this.config.pagination.pageSize;
944
+ const effectiveTotal = this.totalItems > 0 ? this.totalItems : this.data.length;
945
+ return effectiveTotal > 10;
946
+ }
947
+
948
+ private applyPaginationDefaults(): void {
949
+ if (this.config?.pagination?.enabled) {
950
+ if (!this.config.pagination.pageSize) {
951
+ this.config.pagination.pageSize = 10;
952
+ }
953
+ if (!this.config.pagination.pageSizeOptions || this.config.pagination.pageSizeOptions.length === 0) {
954
+ this.config.pagination.pageSizeOptions = [5, 10, 20, 50, 100];
955
+ }
956
+ }
957
+ }
958
+
959
+ onColumnClick(row: any, col: TableColumn): void {
960
+ if (col.clickAction === 'callback') {
961
+ this.columnClick.emit({ row, column: col.key });
962
+ } else if (col.clickAction === 'route' && col.clickRoute) {
963
+ const url = this.replaceParams(col.clickRoute, row);
964
+ this.router.navigateByUrl(url);
965
+ }
966
+ }
967
+
968
+ private getHeaders(): HttpHeaders {
969
+ let headers = new HttpHeaders();
970
+ if (this.config.token) {
971
+ const headerName = this.config.tokenHeader || 'Authorization';
972
+ headers = headers.set(headerName, this.config.token);
973
+ }
974
+ return headers;
975
+ }
976
+
977
+ toggleDropdown(id: string, event: Event, items: any[], row: any): void {
978
+ event.stopPropagation();
979
+ if (this.openDropdownId === id) {
980
+ this.openDropdownId = null;
981
+ this.activeDropdownItems = null;
982
+ this.activeDropdownRow = null;
983
+ return;
984
+ }
985
+ const btn = event.currentTarget as HTMLElement;
986
+ const rect = btn.getBoundingClientRect();
987
+ const estimatedHeight = (items?.length || 1) * 44 + 8;
988
+ const spaceBelow = window.innerHeight - rect.bottom;
989
+ const top = spaceBelow < estimatedHeight + 4
990
+ ? Math.max(4, rect.top - estimatedHeight - 4)
991
+ : rect.bottom + 4;
992
+ this.dropdownPosition = {
993
+ top,
994
+ right: window.innerWidth - rect.right
995
+ };
996
+ this.openDropdownId = id;
997
+ this.activeDropdownItems = items || [];
998
+ this.activeDropdownRow = row;
999
+ }
1000
+
1001
+ @HostListener('document:click')
1002
+ closeDropdown(): void {
1003
+ this.openDropdownId = null;
1004
+ this.activeDropdownItems = null;
1005
+ this.activeDropdownRow = null;
1006
+ this.openFilterKey = null;
1007
+ this.activeFilterData = null;
1008
+ }
1009
+
1010
+ onCancelRow(row: any, index: number): void {
1011
+ if (row.isNew) {
1012
+ this.data.splice(index, 1);
1013
+ } else {
1014
+ const idField = (this.config as any).rowIdField || 'id';
1015
+ const original = this.originalRowsCache.get(row[idField]);
1016
+ if (original) {
1017
+ Object.assign(row, original);
1018
+ this.originalRowsCache.delete(row[idField]);
1019
+ }
1020
+ row.isEditing = false;
1021
+ }
1022
+ }
1023
+
1024
+ onSaveRow(row: any): void {
1025
+ const { isNew, isEditing, selected, ...payload } = row;
1026
+ this.normalizeDateFieldsForSave(payload);
1027
+ const idField = (this.config as any).rowIdField || 'id';
1028
+
1029
+ if (this.tableData !== undefined) {
1030
+ this.rowSave.emit({ row: payload, isNew: !!isNew });
1031
+ row.isEditing = false;
1032
+ row.isNew = false;
1033
+ } else {
1034
+ this.loading = true;
1035
+ const headers = this.getHeaders();
1036
+
1037
+ if (isNew) {
1038
+ const url = this.config.apiUrl || '';
1039
+ this.http.post(url, payload, { headers }).pipe(
1040
+ finalize(() => this.loading = false)
1041
+ ).subscribe({
1042
+ next: () => {
1043
+ row.isEditing = false;
1044
+ row.isNew = false;
1045
+ this.loadData();
1046
+ },
1047
+ error: (err) => {
1048
+ console.error('[SmartTable] Save Row Error', err);
1049
+ }
1050
+ });
1051
+ } else {
1052
+ let url = this.config.apiUrl || '';
1053
+ if (url) {
1054
+ if (url.includes(':id') || url.includes(':' + idField)) {
1055
+ url = this.replaceParams(url, row);
1056
+ } else {
1057
+ url = url.endsWith('/') ? `${url}${row[idField]}` : `${url}/${row[idField]}`;
1058
+ }
1059
+ }
1060
+ const method = this.config.apiMethod === 'POST' ? 'POST' : 'PUT';
1061
+ this.http.request(method, url, { body: payload, headers }).pipe(
1062
+ finalize(() => this.loading = false)
1063
+ ).subscribe({
1064
+ next: () => {
1065
+ row.isEditing = false;
1066
+ this.originalRowsCache.delete(row[idField]);
1067
+ this.loadData();
1068
+ },
1069
+ error: (err) => {
1070
+ console.error('[SmartTable] Update Row Error', err);
1071
+ }
1072
+ });
1073
+ }
1074
+ }
1075
+ }
1076
+
1077
+ // ── Date field helpers ─────────────────────────────────────────────────
1078
+
1079
+ /** Convert any parseable date value to a native Date object for mat-datepicker binding. */
1080
+ private toDateObject(val: any): Date | null {
1081
+ if (!val) return null;
1082
+ if (val instanceof Date) return val;
1083
+ const d = new Date(val);
1084
+ return isNaN(d.getTime()) ? null : d;
1085
+ }
1086
+
1087
+ /** Pre-process all date columns on a row before entering edit mode. */
1088
+ private prepareDateFieldsForEdit(row: any): void {
1089
+ this.config.columns.forEach(col => {
1090
+ if (col.dataType === 'date' && row[col.key]) {
1091
+ row[col.key] = this.toDateObject(row[col.key]);
1092
+ }
1093
+ if (col.subFields) {
1094
+ col.subFields.forEach(sub => {
1095
+ if (sub.dataType === 'date' && row[sub.key]) {
1096
+ row[sub.key] = this.toDateObject(row[sub.key]);
1097
+ }
1098
+ });
1099
+ }
1100
+ });
1101
+ }
1102
+
1103
+ /** Normalize Date objects back to ISO strings before sending payloads. */
1104
+ private normalizeDateFieldsForSave(payload: any): void {
1105
+ this.config.columns.forEach(col => {
1106
+ if (col.dataType === 'date' && payload[col.key] instanceof Date) {
1107
+ payload[col.key] = payload[col.key].toISOString();
1108
+ }
1109
+ if (col.subFields) {
1110
+ col.subFields.forEach(sub => {
1111
+ if (sub.dataType === 'date' && payload[sub.key] instanceof Date) {
1112
+ payload[sub.key] = payload[sub.key].toISOString();
1113
+ }
1114
+ });
1115
+ }
1116
+ });
1117
+ }
1118
+ }