commons-shared-web-ui 0.0.60 → 0.0.62

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