@honuware/ui 0.1.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.
@@ -0,0 +1,1101 @@
1
+ import * as i0 from '@angular/core';
2
+ import { inject, DestroyRef, Inject, Injectable, InjectionToken, Input, Component, ViewChild, ViewChildren } from '@angular/core';
3
+ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
4
+ import { BehaviorSubject, timer, of, from, Subject, combineLatest, takeUntil } from 'rxjs';
5
+ import { map, distinctUntilChanged, tap, catchError, filter, take, concatMap } from 'rxjs/operators';
6
+ import * as i2 from '@honuware/ui/access';
7
+ import { HONUWARE_CRUD_ACCESS } from '@honuware/ui/access';
8
+ import * as i1 from '@honuware/ui/auth';
9
+ import * as i5 from '@angular/material/table';
10
+ import { MatTableModule } from '@angular/material/table';
11
+ import * as i6 from '@angular/material/button';
12
+ import { MatButtonModule } from '@angular/material/button';
13
+ import * as i5$1 from '@angular/material/icon';
14
+ import { MatIconModule } from '@angular/material/icon';
15
+ import * as i8 from '@angular/material/progress-spinner';
16
+ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
17
+ import * as i4 from '@angular/material/dialog';
18
+ import { MatDialogModule } from '@angular/material/dialog';
19
+ import * as i9 from '@angular/material/select';
20
+ import { MatSelectModule } from '@angular/material/select';
21
+ import { MatFormFieldModule } from '@angular/material/form-field';
22
+ import { isMicrosecondTimestamp, formatMicroseconds, ConfirmDialogComponent } from '@honuware/ui/foundation';
23
+ import * as i1$1 from '@angular/router';
24
+ import { RouterModule } from '@angular/router';
25
+ import * as i4$1 from '@angular/material/checkbox';
26
+ import { MatCheckboxModule } from '@angular/material/checkbox';
27
+ import { CompositeControlComponent } from '@honuware/ui/controls';
28
+ import { PhotoUploadComponent } from '@honuware/ui/photos';
29
+ import * as i3 from '@angular/material/card';
30
+ import { MatCardModule } from '@angular/material/card';
31
+
32
+ class DatabaseSchemaService {
33
+ crudAccess;
34
+ authService;
35
+ _schema;
36
+ _schemaSubject = new BehaviorSubject(undefined);
37
+ _refreshSub;
38
+ _fetchInProgress = false;
39
+ _retryTimeoutMs = 6000; // configurable timeout
40
+ destroyRef = inject(DestroyRef);
41
+ schema$ = this._schemaSubject.asObservable();
42
+ constructor(crudAccess, authService) {
43
+ this.crudAccess = crudAccess;
44
+ this.authService = authService;
45
+ this.startSchemaPolling();
46
+ this.authService.authData$.pipe(map(authData => authData.isAuth), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)).subscribe(() => {
47
+ this.refreshSchema();
48
+ });
49
+ }
50
+ startSchemaPolling() {
51
+ this.tryFetchSchema();
52
+ }
53
+ refreshSchema() {
54
+ this._schema = undefined;
55
+ this._schemaSubject.next(undefined);
56
+ this.tryFetchSchema();
57
+ }
58
+ tryFetchSchema() {
59
+ if (this._fetchInProgress)
60
+ return;
61
+ this._refreshSub?.unsubscribe();
62
+ this._fetchInProgress = true;
63
+ this._refreshSub = this.crudAccess.getDbSchema().pipe(tap(schema => {
64
+ this._schema = schema;
65
+ this._schemaSubject.next(schema);
66
+ this._fetchInProgress = false;
67
+ this._refreshSub?.unsubscribe(); // Stop further polling
68
+ this._refreshSub = undefined;
69
+ }), catchError(() => {
70
+ this._fetchInProgress = false;
71
+ // Schedule retry after timeout
72
+ this._refreshSub = timer(this._retryTimeoutMs).subscribe(() => this.tryFetchSchema());
73
+ return of(undefined);
74
+ })).subscribe();
75
+ }
76
+ /**
77
+ * Returns the cached DatabaseSchema if present, otherwise waits for a successful fetch.
78
+ */
79
+ GetDBSchema() {
80
+ if (this._schema) {
81
+ return of(this._schema);
82
+ }
83
+ // Wait for the schema to be available
84
+ return this.schema$.pipe(filter((schema) => !!schema), take(1));
85
+ }
86
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: DatabaseSchemaService, deps: [{ token: HONUWARE_CRUD_ACCESS }, { token: i1.AuthService }], target: i0.ɵɵFactoryTarget.Injectable });
87
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: DatabaseSchemaService, providedIn: 'root' });
88
+ }
89
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: DatabaseSchemaService, decorators: [{
90
+ type: Injectable,
91
+ args: [{
92
+ providedIn: 'root'
93
+ }]
94
+ }], ctorParameters: () => [{ type: undefined, decorators: [{
95
+ type: Inject,
96
+ args: [HONUWARE_CRUD_ACCESS]
97
+ }] }, { type: i1.AuthService }] });
98
+
99
+ /**
100
+ * Serialize a binding stack to a ctx query param string.
101
+ * Format: "table:pkColumn:pkValue,table:pkColumn:pkValue"
102
+ */
103
+ function serializeBindingStack(stack) {
104
+ return stack.map(b => `${b.tableName}:${b.primaryKeyName}:${b.primaryKeyValue}`).join(',');
105
+ }
106
+ /**
107
+ * Deserialize a ctx query param string into a TableBinding array.
108
+ * Returns empty array for null/empty input.
109
+ */
110
+ function parseBindingStack(ctx) {
111
+ if (!ctx)
112
+ return [];
113
+ return ctx.split(',').map(triple => {
114
+ const [tableName, primaryKeyName, primaryKeyValue] = triple.split(':');
115
+ return { tableName, primaryKeyName, primaryKeyValue };
116
+ });
117
+ }
118
+ /**
119
+ * Derive filter pairs from the binding stack for the current table.
120
+ * Looks at the current table's foreign keys and matches them against
121
+ * the binding stack to build WHERE clause filters.
122
+ *
123
+ * For example, if viewing purchases with ctx=people:person_id:42,
124
+ * and purchases has FK column payer_person_id -> people.person_id,
125
+ * this returns [{ column_name: 'payer_person_id', column_value: '42' }].
126
+ */
127
+ function deriveFilterPairs(currentTableSchema, bindingStack) {
128
+ if (bindingStack.length === 0)
129
+ return [];
130
+ const filterPairs = [];
131
+ for (const fk of currentTableSchema.foreign_keys) {
132
+ const binding = bindingStack.find(b => b.tableName === fk.parent_table_name && b.primaryKeyName === fk.parent_column_name);
133
+ if (binding) {
134
+ filterPairs.push({
135
+ column_name: fk.column_name,
136
+ column_value: binding.primaryKeyValue,
137
+ });
138
+ }
139
+ }
140
+ return filterPairs;
141
+ }
142
+
143
+ const DEFAULT_CRUD_EDITOR_ROUTES = {
144
+ basePath: '/admin/tables',
145
+ adminHome: '/admin',
146
+ };
147
+ // Root-provided with the knottyyoga defaults, so nothing needs wiring today.
148
+ const CRUD_EDITOR_ROUTES = new InjectionToken('CRUD_EDITOR_ROUTES', {
149
+ providedIn: 'root',
150
+ factory: () => DEFAULT_CRUD_EDITOR_ROUTES,
151
+ });
152
+
153
+ class TableViewControlComponent {
154
+ dbSchemaService;
155
+ crudAccess;
156
+ photoUrlBuilder;
157
+ editorRoutes;
158
+ router;
159
+ dialog;
160
+ tableName;
161
+ pageSize = 10;
162
+ pageOffset = 0;
163
+ sortColumn;
164
+ sortAscending = true;
165
+ bindingStack = [];
166
+ databaseSchema;
167
+ tableSchema;
168
+ columns = [];
169
+ displayedColumns = [];
170
+ dataSource = [];
171
+ totalCount = 0;
172
+ loading = true;
173
+ error;
174
+ childTableReferences = [];
175
+ /** Maps column_name → pk_value → display_text for FK columns. */
176
+ fkDisplayMap = {};
177
+ pageSizeOptions = [5, 10, 20, 30, 50, 100];
178
+ constructor(dbSchemaService, crudAccess, photoUrlBuilder, editorRoutes, router, dialog) {
179
+ this.dbSchemaService = dbSchemaService;
180
+ this.crudAccess = crudAccess;
181
+ this.photoUrlBuilder = photoUrlBuilder;
182
+ this.editorRoutes = editorRoutes;
183
+ this.router = router;
184
+ this.dialog = dialog;
185
+ }
186
+ ngOnInit() {
187
+ this.loadData();
188
+ }
189
+ ngOnChanges(changes) {
190
+ if (changes['tableName'] || changes['pageSize'] || changes['pageOffset'] ||
191
+ changes['sortColumn'] || changes['sortAscending'] || changes['bindingStack']) {
192
+ if (!changes['tableName']?.firstChange) {
193
+ this.loadData();
194
+ }
195
+ }
196
+ }
197
+ loadData() {
198
+ this.loading = true;
199
+ this.error = undefined;
200
+ this.dbSchemaService.GetDBSchema().subscribe({
201
+ next: (schema) => {
202
+ this.databaseSchema = schema;
203
+ this.tableSchema = schema.tables.find(t => t.table_name === this.tableName);
204
+ if (!this.tableSchema) {
205
+ this.error = `Table "${this.tableName}" not found in schema`;
206
+ this.loading = false;
207
+ return;
208
+ }
209
+ this.columns = this.tableSchema.columns.filter(c => !this.isTrueValue(c.hidden));
210
+ // Display actions column first, then photo column if supported, then data columns
211
+ const photoColumn = this.hasPhotoSupport ? ['photo'] : [];
212
+ this.displayedColumns = ['actions'].concat(photoColumn, this.columns.map(c => c.column_name));
213
+ // Compute child table references
214
+ this.childTableReferences = this.getChildTableReferences(schema);
215
+ // Derive filter pairs from binding stack + FK info
216
+ const filterPairs = deriveFilterPairs(this.tableSchema, this.bindingStack);
217
+ // Use primary key as default sort column if not specified
218
+ const sortCol = this.sortColumn || this.tableSchema.primary_key;
219
+ this.crudAccess.getFilteredTableRows(this.tableName, sortCol, this.sortAscending, this.pageSize, this.pageOffset, filterPairs).subscribe({
220
+ next: (result) => {
221
+ this.totalCount = result.totalCount;
222
+ // Convert dataTable array to array of objects
223
+ this.dataSource = result.dataTable.map(row => {
224
+ const obj = {};
225
+ result.sortedColumnNames.forEach((colName, idx) => {
226
+ obj[colName] = row[idx];
227
+ });
228
+ return obj;
229
+ });
230
+ this.resolveFkDisplayValues();
231
+ },
232
+ error: (err) => {
233
+ this.error = 'Failed to load data';
234
+ this.loading = false;
235
+ console.error('Error loading table data:', err);
236
+ }
237
+ });
238
+ },
239
+ error: (err) => {
240
+ this.error = 'Failed to load schema';
241
+ this.loading = false;
242
+ console.error('Error loading schema:', err);
243
+ }
244
+ });
245
+ }
246
+ getChildTableReferences(schema) {
247
+ const refs = [];
248
+ for (const nestedTableName of schema.nested_tables) {
249
+ const childSchema = schema.tables.find(t => t.table_name === nestedTableName);
250
+ if (!childSchema)
251
+ continue;
252
+ for (const fk of childSchema.foreign_keys) {
253
+ if (fk.parent_table_name === this.tableName) {
254
+ refs.push({
255
+ childTableName: nestedTableName,
256
+ childTableFriendlyName: childSchema.table_friendly_name || nestedTableName,
257
+ foreignKey: fk,
258
+ });
259
+ }
260
+ }
261
+ }
262
+ return refs;
263
+ }
264
+ resolveFkDisplayValues() {
265
+ if (!this.tableSchema || this.tableSchema.foreign_keys.length === 0) {
266
+ this.loading = false;
267
+ return;
268
+ }
269
+ const fkEntries = [];
270
+ for (const fk of this.tableSchema.foreign_keys) {
271
+ const uniqueValues = [...new Set(this.dataSource
272
+ .map(row => row[fk.column_name])
273
+ .filter(v => !!v))];
274
+ if (uniqueValues.length === 0)
275
+ continue;
276
+ fkEntries.push({
277
+ colName: fk.column_name,
278
+ parentTable: fk.parent_table_name,
279
+ values: uniqueValues,
280
+ });
281
+ }
282
+ if (fkEntries.length === 0) {
283
+ this.loading = false;
284
+ return;
285
+ }
286
+ // Process sequentially to avoid concurrent transaction errors on the server
287
+ from(fkEntries).pipe(concatMap(entry => this.crudAccess.resolveFkDisplay(entry.parentTable, entry.values).pipe(tap(response => {
288
+ this.fkDisplayMap[entry.colName] = response.resolved;
289
+ })))).subscribe({
290
+ complete: () => {
291
+ this.loading = false;
292
+ },
293
+ error: (err) => {
294
+ console.error('FK display resolution failed:', err);
295
+ this.loading = false;
296
+ }
297
+ });
298
+ }
299
+ onNavigateToChild(ref, row) {
300
+ const primaryKeyValue = this.getPrimaryKeyValue(row);
301
+ if (!primaryKeyValue || !this.tableSchema)
302
+ return;
303
+ const newStack = [
304
+ ...this.bindingStack,
305
+ {
306
+ tableName: this.tableName,
307
+ primaryKeyName: this.tableSchema.primary_key,
308
+ primaryKeyValue,
309
+ }
310
+ ];
311
+ this.router.navigate([this.editorRoutes.basePath, ref.childTableName, 'view', this.pageSize, 0], { queryParams: { ctx: serializeBindingStack(newStack) } });
312
+ }
313
+ isTrueValue(value) {
314
+ return value === true || value === 't';
315
+ }
316
+ formatCellValue(column, value) {
317
+ if (!value)
318
+ return value;
319
+ // Check FK display map first
320
+ const fkResolved = this.fkDisplayMap[column.column_name];
321
+ if (fkResolved && fkResolved[value]) {
322
+ return fkResolved[value];
323
+ }
324
+ if (column.html_input_type === 'date' && isMicrosecondTimestamp(value)) {
325
+ return formatMicroseconds(value);
326
+ }
327
+ return value;
328
+ }
329
+ isBoolColumn(column) {
330
+ return column.type === 'bool' ||
331
+ column.html_input_type === 'bool' ||
332
+ column.html_input_type === 'checkbox';
333
+ }
334
+ getBoolValue(value) {
335
+ return value === 't' || value === 'true' || value === '1';
336
+ }
337
+ getColumnHeader(column) {
338
+ return column.column_friendly_name || column.column_name;
339
+ }
340
+ get hasPhotoSupport() {
341
+ return this.isTrueValue(this.tableSchema?.has_photo_support);
342
+ }
343
+ getPhotoUrl(row) {
344
+ const id = this.getPrimaryKeyValue(row);
345
+ return this.photoUrlBuilder.scaledPhotoUrl(this.tableName, id, 50, 50);
346
+ }
347
+ getPrimaryKeyValue(row) {
348
+ return this.tableSchema ? row[this.tableSchema.primary_key] : '';
349
+ }
350
+ // Pagination helpers
351
+ get currentPage() {
352
+ return this.pageOffset;
353
+ }
354
+ get totalPages() {
355
+ return Math.ceil(this.totalCount / this.pageSize);
356
+ }
357
+ get startRow() {
358
+ return this.pageOffset * this.pageSize + 1;
359
+ }
360
+ get endRow() {
361
+ return Math.min((this.pageOffset + 1) * this.pageSize, this.totalCount);
362
+ }
363
+ get canGoPrevious() {
364
+ return this.pageOffset > 0;
365
+ }
366
+ get canGoNext() {
367
+ return this.pageOffset < this.totalPages - 1;
368
+ }
369
+ // Navigation methods
370
+ goToFirstPage() {
371
+ this.navigateToPage(0);
372
+ }
373
+ goToPreviousPage() {
374
+ if (this.canGoPrevious) {
375
+ this.navigateToPage(this.pageOffset - 1);
376
+ }
377
+ }
378
+ goToNextPage() {
379
+ if (this.canGoNext) {
380
+ this.navigateToPage(this.pageOffset + 1);
381
+ }
382
+ }
383
+ goToLastPage() {
384
+ this.navigateToPage(this.totalPages - 1);
385
+ }
386
+ /** Build query params preserving ctx for nested navigation. */
387
+ buildCtxQueryParams(extra) {
388
+ const params = { ...extra };
389
+ if (this.bindingStack.length > 0) {
390
+ params['ctx'] = serializeBindingStack(this.bindingStack);
391
+ }
392
+ return params;
393
+ }
394
+ navigateToPage(page) {
395
+ this.router.navigate([this.editorRoutes.basePath, this.tableName, 'view', this.pageSize, page], { queryParams: this.buildCtxQueryParams() });
396
+ }
397
+ onPageSizeChange(newPageSize) {
398
+ // Reset to first page when changing page size
399
+ this.router.navigate([this.editorRoutes.basePath, this.tableName, 'view', newPageSize, 0], { queryParams: this.buildCtxQueryParams() });
400
+ }
401
+ // Action methods
402
+ onNewItem() {
403
+ const returnUrl = this.router.url;
404
+ this.router.navigate([this.editorRoutes.basePath, this.tableName, 'new'], {
405
+ queryParams: this.buildCtxQueryParams({ returnUrl })
406
+ });
407
+ }
408
+ onEditRow(row) {
409
+ const primaryKeyValue = this.getPrimaryKeyValue(row);
410
+ if (!primaryKeyValue) {
411
+ console.error('Cannot edit row: no primary key value found', { row, tableSchema: this.tableSchema });
412
+ return;
413
+ }
414
+ const returnUrl = this.router.url;
415
+ this.router.navigate([this.editorRoutes.basePath, this.tableName, 'edit', primaryKeyValue], {
416
+ queryParams: this.buildCtxQueryParams({ returnUrl })
417
+ });
418
+ }
419
+ /** Navigate back to the parent table's edit page by popping the binding stack. */
420
+ onNavigateToParent() {
421
+ if (this.bindingStack.length === 0)
422
+ return;
423
+ const parentBinding = this.bindingStack[this.bindingStack.length - 1];
424
+ const parentStack = this.bindingStack.slice(0, -1);
425
+ const queryParams = {};
426
+ if (parentStack.length > 0) {
427
+ queryParams['ctx'] = serializeBindingStack(parentStack);
428
+ }
429
+ this.router.navigate([this.editorRoutes.basePath, parentBinding.tableName, 'edit', parentBinding.primaryKeyValue], { queryParams });
430
+ }
431
+ get hasParent() {
432
+ return this.bindingStack.length > 0;
433
+ }
434
+ get parentFriendlyName() {
435
+ if (this.bindingStack.length === 0)
436
+ return '';
437
+ const parentBinding = this.bindingStack[this.bindingStack.length - 1];
438
+ const parentSchema = this.databaseSchema?.tables.find(t => t.table_name === parentBinding.tableName);
439
+ return parentSchema?.table_friendly_name || parentBinding.tableName;
440
+ }
441
+ onDeleteRow(row) {
442
+ const primaryKeyValue = this.getPrimaryKeyValue(row);
443
+ const primaryKeyName = this.tableSchema?.primary_key || 'id';
444
+ const friendlyName = this.tableSchema?.table_friendly_name || this.tableName;
445
+ const dialogRef = this.dialog.open(ConfirmDialogComponent, {
446
+ width: '400px',
447
+ data: {
448
+ title: 'Confirm Delete',
449
+ description: `Are you sure you want to delete this ${friendlyName} record (ID: ${primaryKeyValue})?`,
450
+ buttonText: 'Delete',
451
+ }
452
+ });
453
+ dialogRef.afterClosed().subscribe(confirmed => {
454
+ if (confirmed) {
455
+ this.crudAccess.deleteItem(this.tableName, primaryKeyName, primaryKeyValue).subscribe({
456
+ next: () => {
457
+ // Refresh the data after deletion
458
+ this.loadData();
459
+ },
460
+ error: (err) => {
461
+ console.error('Error deleting row:', err);
462
+ this.error = 'Failed to delete row';
463
+ }
464
+ });
465
+ }
466
+ });
467
+ }
468
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: TableViewControlComponent, deps: [{ token: DatabaseSchemaService }, { token: HONUWARE_CRUD_ACCESS }, { token: i2.PhotoUrlBuilder }, { token: CRUD_EDITOR_ROUTES }, { token: i1$1.Router }, { token: i4.MatDialog }], target: i0.ɵɵFactoryTarget.Component });
469
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.18", type: TableViewControlComponent, isStandalone: true, selector: "hw-table-view-control", inputs: { tableName: "tableName", pageSize: "pageSize", pageOffset: "pageOffset", sortColumn: "sortColumn", sortAscending: "sortAscending", bindingStack: "bindingStack" }, usesOnChanges: true, ngImport: i0, template: "<div class=\"table-view-container\">\n <!-- Back to Parent button -->\n @if (hasParent) {\n <button mat-stroked-button class=\"parent-button\" (click)=\"onNavigateToParent()\">\n <mat-icon>arrow_back</mat-icon>\n Back to {{ parentFriendlyName }}\n </button>\n }\n\n <!-- Header with New Item button -->\n <div class=\"table-header\">\n <h2>{{ tableSchema?.table_friendly_name || tableName }}</h2>\n <button mat-raised-button color=\"primary\" (click)=\"onNewItem()\">\n <mat-icon>add</mat-icon>\n New Item\n </button>\n </div>\n\n <!-- Loading state -->\n @if (loading) {\n <div class=\"loading-container\">\n <mat-spinner diameter=\"40\"></mat-spinner>\n <p>Loading...</p>\n </div>\n }\n\n <!-- Error state -->\n @if (error && !loading) {\n <div class=\"error-container\">\n <mat-icon>error</mat-icon>\n <p>{{ error }}</p>\n </div>\n }\n\n <!-- Empty state -->\n @if (!loading && !error && dataSource.length === 0) {\n <div class=\"empty-container\">\n <mat-icon>inbox</mat-icon>\n <p>No records found</p>\n <button mat-stroked-button color=\"primary\" (click)=\"onNewItem()\">\n Create First Record\n </button>\n </div>\n }\n\n <!-- Data table -->\n @if (!loading && !error && dataSource.length > 0) {\n <div class=\"table-wrapper\">\n <table mat-table [dataSource]=\"dataSource\" multiTemplateDataRows>\n <!-- Dynamic columns -->\n @for (column of columns; track column.column_name) {\n <ng-container [matColumnDef]=\"column.column_name\">\n <th mat-header-cell *matHeaderCellDef>{{ getColumnHeader(column) }}</th>\n <td mat-cell *matCellDef=\"let row\">\n @if (isBoolColumn(column)) {\n <mat-icon [class]=\"getBoolValue(row[column.column_name]) ? 'bool-true' : 'bool-false'\">\n {{ getBoolValue(row[column.column_name]) ? 'check_circle' : 'cancel' }}\n </mat-icon>\n } @else {\n {{ formatCellValue(column, row[column.column_name]) }}\n }\n </td>\n </ng-container>\n }\n\n <!-- Photo column (always defined, only included in displayedColumns when supported) -->\n <ng-container matColumnDef=\"photo\">\n <th mat-header-cell *matHeaderCellDef>Photo</th>\n <td mat-cell *matCellDef=\"let row\">\n <img [src]=\"getPhotoUrl(row)\"\n class=\"photo-thumbnail\"\n (error)=\"$any($event.target).style.display='none'; $any($event.target).nextElementSibling.style.display='inline-flex'\"\n alt=\"Photo\">\n <mat-icon class=\"photo-placeholder\" style=\"display: none\">image</mat-icon>\n </td>\n </ng-container>\n\n <!-- Actions column -->\n <ng-container matColumnDef=\"actions\">\n <th mat-header-cell *matHeaderCellDef>Actions</th>\n <td mat-cell *matCellDef=\"let row\">\n <div class=\"actions-row\">\n <button mat-icon-button color=\"primary\" (click)=\"onEditRow(row); $event.stopPropagation()\" title=\"Edit\">\n <mat-icon>edit</mat-icon>\n </button>\n <button mat-icon-button color=\"warn\" (click)=\"onDeleteRow(row); $event.stopPropagation()\" title=\"Delete\">\n <mat-icon>delete</mat-icon>\n </button>\n </div>\n </td>\n </ng-container>\n\n <!-- Child tables detail row (spans all columns) -->\n <ng-container matColumnDef=\"childTables\">\n <td mat-cell *matCellDef=\"let row\" [attr.colspan]=\"displayedColumns.length\">\n <div class=\"child-table-pills\">\n @for (ref of childTableReferences; track ref.childTableName + ref.foreignKey.column_name) {\n <button class=\"child-pill\" (click)=\"onNavigateToChild(ref, row); $event.stopPropagation()\">\n <mat-icon>subdirectory_arrow_right</mat-icon>\n {{ ref.childTableFriendlyName }}\n </button>\n }\n </div>\n </td>\n </ng-container>\n\n <tr mat-header-row *matHeaderRowDef=\"displayedColumns\"></tr>\n <tr mat-row *matRowDef=\"let row; columns: displayedColumns;\"></tr>\n <tr mat-row *matRowDef=\"let row; columns: ['childTables'];\"\n class=\"child-tables-row\"\n [class.hidden-row]=\"childTableReferences.length === 0\"></tr>\n </table>\n </div>\n\n <!-- Pagination controls -->\n <div class=\"pagination-container\">\n <div class=\"pagination-left\">\n <mat-form-field appearance=\"outline\" class=\"page-size-select\">\n <mat-label>Page size</mat-label>\n <mat-select [value]=\"pageSize\" (selectionChange)=\"onPageSizeChange($event.value)\">\n @for (size of pageSizeOptions; track size) {\n <mat-option [value]=\"size\">{{ size }}</mat-option>\n }\n </mat-select>\n </mat-form-field>\n <span class=\"pagination-info\">\n Showing {{ startRow }} - {{ endRow }} of {{ totalCount }}\n </span>\n </div>\n <div class=\"pagination-buttons\">\n <button mat-icon-button\n [disabled]=\"!canGoPrevious\"\n (click)=\"goToFirstPage()\"\n title=\"First page\">\n <mat-icon>first_page</mat-icon>\n </button>\n <button mat-icon-button\n [disabled]=\"!canGoPrevious\"\n (click)=\"goToPreviousPage()\"\n title=\"Previous page\">\n <mat-icon>chevron_left</mat-icon>\n </button>\n <span class=\"page-info\">Page {{ currentPage + 1 }} of {{ totalPages }}</span>\n <button mat-icon-button\n [disabled]=\"!canGoNext\"\n (click)=\"goToNextPage()\"\n title=\"Next page\">\n <mat-icon>chevron_right</mat-icon>\n </button>\n <button mat-icon-button\n [disabled]=\"!canGoNext\"\n (click)=\"goToLastPage()\"\n title=\"Last page\">\n <mat-icon>last_page</mat-icon>\n </button>\n </div>\n </div>\n }\n</div>\n", styles: [".table-view-container{padding:16px}.parent-button{margin-bottom:12px}.table-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.table-header h2{margin:0}.loading-container,.error-container,.empty-container{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:48px;text-align:center}.loading-container mat-icon,.error-container mat-icon,.empty-container mat-icon{font-size:48px;width:48px;height:48px;margin-bottom:16px}.loading-container p,.error-container p,.empty-container p{margin:0 0 16px;color:#666}.error-container mat-icon{color:#f44336}.empty-container mat-icon{color:#9e9e9e}.table-wrapper{overflow-x:auto;margin-bottom:16px}.table-wrapper table{width:100%}.table-wrapper tr.mat-mdc-row td{border-bottom:none}.table-wrapper tr.child-tables-row td{border-bottom:1px solid #e0e0e0}.mat-column-actions{width:80px}.actions-row{display:flex;align-items:center}.actions-row .mat-mdc-icon-button{--mat-icon-button-state-layer-size: 36px;padding:0}.pagination-container{display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-top:1px solid #e0e0e0}.pagination-left{display:flex;align-items:center;gap:16px}.page-size-select{width:100px}.page-size-select ::ng-deep .mat-mdc-form-field-subscript-wrapper{display:none}.pagination-info{color:#666;font-size:14px}.pagination-buttons{display:flex;align-items:center;gap:4px}.page-info{padding:0 12px;font-size:14px}.bool-true{color:#16a34a}.bool-false{color:#dc2626}.mat-column-photo{width:60px;text-align:center}.photo-thumbnail{max-width:50px;max-height:50px;border-radius:4px;object-fit:cover}.photo-placeholder{color:#9e9e9e;font-size:24px;width:24px;height:24px}.child-tables-row{height:auto!important}.child-tables-row td{padding:0!important;border-bottom:1px solid #e0e0e0}.hidden-row{display:none}.child-table-pills{display:flex;flex-wrap:wrap;gap:6px;padding:4px 16px 6px 48px}.child-pill{display:inline-flex;align-items:center;gap:4px;padding:2px 10px 2px 6px;border:1px solid #90caf9;border-radius:16px;background:#e3f2fd;color:#1565c0;font-size:12px;cursor:pointer;white-space:nowrap}.child-pill mat-icon{font-size:14px;width:14px;height:14px}.child-pill:hover{background:#bbdefb;border-color:#42a5f5}\n"], dependencies: [{ kind: "ngmodule", type: MatTableModule }, { kind: "component", type: i5.MatTable, selector: "mat-table, table[mat-table]", exportAs: ["matTable"] }, { kind: "directive", type: i5.MatHeaderCellDef, selector: "[matHeaderCellDef]" }, { kind: "directive", type: i5.MatHeaderRowDef, selector: "[matHeaderRowDef]", inputs: ["matHeaderRowDef", "matHeaderRowDefSticky"] }, { kind: "directive", type: i5.MatColumnDef, selector: "[matColumnDef]", inputs: ["matColumnDef"] }, { kind: "directive", type: i5.MatCellDef, selector: "[matCellDef]" }, { kind: "directive", type: i5.MatRowDef, selector: "[matRowDef]", inputs: ["matRowDefColumns", "matRowDefWhen"] }, { kind: "directive", type: i5.MatHeaderCell, selector: "mat-header-cell, th[mat-header-cell]" }, { kind: "directive", type: i5.MatCell, selector: "mat-cell, td[mat-cell]" }, { kind: "component", type: i5.MatHeaderRow, selector: "mat-header-row, tr[mat-header-row]", exportAs: ["matHeaderRow"] }, { kind: "component", type: i5.MatRow, selector: "mat-row, tr[mat-row]", exportAs: ["matRow"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i6.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i6.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i5$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatProgressSpinnerModule }, { kind: "component", type: i8.MatProgressSpinner, selector: "mat-progress-spinner, mat-spinner", inputs: ["color", "mode", "value", "diameter", "strokeWidth"], exportAs: ["matProgressSpinner"] }, { kind: "ngmodule", type: MatDialogModule }, { kind: "ngmodule", type: MatSelectModule }, { kind: "component", type: i9.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i9.MatLabel, selector: "mat-label" }, { kind: "component", type: i9.MatSelect, selector: "mat-select", inputs: ["aria-describedby", "panelClass", "disabled", "disableRipple", "tabIndex", "hideSingleSelectionIndicator", "placeholder", "required", "multiple", "disableOptionCentering", "compareWith", "value", "aria-label", "aria-labelledby", "errorStateMatcher", "typeaheadDebounceInterval", "sortComparator", "id", "panelWidth", "canSelectNullableOptions"], outputs: ["openedChange", "opened", "closed", "selectionChange", "valueChange"], exportAs: ["matSelect"] }, { kind: "component", type: i9.MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "ngmodule", type: MatFormFieldModule }] });
470
+ }
471
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: TableViewControlComponent, decorators: [{
472
+ type: Component,
473
+ args: [{ selector: 'hw-table-view-control', standalone: true, imports: [
474
+ MatTableModule,
475
+ MatButtonModule,
476
+ MatIconModule,
477
+ MatProgressSpinnerModule,
478
+ MatDialogModule,
479
+ MatSelectModule,
480
+ MatFormFieldModule
481
+ ], template: "<div class=\"table-view-container\">\n <!-- Back to Parent button -->\n @if (hasParent) {\n <button mat-stroked-button class=\"parent-button\" (click)=\"onNavigateToParent()\">\n <mat-icon>arrow_back</mat-icon>\n Back to {{ parentFriendlyName }}\n </button>\n }\n\n <!-- Header with New Item button -->\n <div class=\"table-header\">\n <h2>{{ tableSchema?.table_friendly_name || tableName }}</h2>\n <button mat-raised-button color=\"primary\" (click)=\"onNewItem()\">\n <mat-icon>add</mat-icon>\n New Item\n </button>\n </div>\n\n <!-- Loading state -->\n @if (loading) {\n <div class=\"loading-container\">\n <mat-spinner diameter=\"40\"></mat-spinner>\n <p>Loading...</p>\n </div>\n }\n\n <!-- Error state -->\n @if (error && !loading) {\n <div class=\"error-container\">\n <mat-icon>error</mat-icon>\n <p>{{ error }}</p>\n </div>\n }\n\n <!-- Empty state -->\n @if (!loading && !error && dataSource.length === 0) {\n <div class=\"empty-container\">\n <mat-icon>inbox</mat-icon>\n <p>No records found</p>\n <button mat-stroked-button color=\"primary\" (click)=\"onNewItem()\">\n Create First Record\n </button>\n </div>\n }\n\n <!-- Data table -->\n @if (!loading && !error && dataSource.length > 0) {\n <div class=\"table-wrapper\">\n <table mat-table [dataSource]=\"dataSource\" multiTemplateDataRows>\n <!-- Dynamic columns -->\n @for (column of columns; track column.column_name) {\n <ng-container [matColumnDef]=\"column.column_name\">\n <th mat-header-cell *matHeaderCellDef>{{ getColumnHeader(column) }}</th>\n <td mat-cell *matCellDef=\"let row\">\n @if (isBoolColumn(column)) {\n <mat-icon [class]=\"getBoolValue(row[column.column_name]) ? 'bool-true' : 'bool-false'\">\n {{ getBoolValue(row[column.column_name]) ? 'check_circle' : 'cancel' }}\n </mat-icon>\n } @else {\n {{ formatCellValue(column, row[column.column_name]) }}\n }\n </td>\n </ng-container>\n }\n\n <!-- Photo column (always defined, only included in displayedColumns when supported) -->\n <ng-container matColumnDef=\"photo\">\n <th mat-header-cell *matHeaderCellDef>Photo</th>\n <td mat-cell *matCellDef=\"let row\">\n <img [src]=\"getPhotoUrl(row)\"\n class=\"photo-thumbnail\"\n (error)=\"$any($event.target).style.display='none'; $any($event.target).nextElementSibling.style.display='inline-flex'\"\n alt=\"Photo\">\n <mat-icon class=\"photo-placeholder\" style=\"display: none\">image</mat-icon>\n </td>\n </ng-container>\n\n <!-- Actions column -->\n <ng-container matColumnDef=\"actions\">\n <th mat-header-cell *matHeaderCellDef>Actions</th>\n <td mat-cell *matCellDef=\"let row\">\n <div class=\"actions-row\">\n <button mat-icon-button color=\"primary\" (click)=\"onEditRow(row); $event.stopPropagation()\" title=\"Edit\">\n <mat-icon>edit</mat-icon>\n </button>\n <button mat-icon-button color=\"warn\" (click)=\"onDeleteRow(row); $event.stopPropagation()\" title=\"Delete\">\n <mat-icon>delete</mat-icon>\n </button>\n </div>\n </td>\n </ng-container>\n\n <!-- Child tables detail row (spans all columns) -->\n <ng-container matColumnDef=\"childTables\">\n <td mat-cell *matCellDef=\"let row\" [attr.colspan]=\"displayedColumns.length\">\n <div class=\"child-table-pills\">\n @for (ref of childTableReferences; track ref.childTableName + ref.foreignKey.column_name) {\n <button class=\"child-pill\" (click)=\"onNavigateToChild(ref, row); $event.stopPropagation()\">\n <mat-icon>subdirectory_arrow_right</mat-icon>\n {{ ref.childTableFriendlyName }}\n </button>\n }\n </div>\n </td>\n </ng-container>\n\n <tr mat-header-row *matHeaderRowDef=\"displayedColumns\"></tr>\n <tr mat-row *matRowDef=\"let row; columns: displayedColumns;\"></tr>\n <tr mat-row *matRowDef=\"let row; columns: ['childTables'];\"\n class=\"child-tables-row\"\n [class.hidden-row]=\"childTableReferences.length === 0\"></tr>\n </table>\n </div>\n\n <!-- Pagination controls -->\n <div class=\"pagination-container\">\n <div class=\"pagination-left\">\n <mat-form-field appearance=\"outline\" class=\"page-size-select\">\n <mat-label>Page size</mat-label>\n <mat-select [value]=\"pageSize\" (selectionChange)=\"onPageSizeChange($event.value)\">\n @for (size of pageSizeOptions; track size) {\n <mat-option [value]=\"size\">{{ size }}</mat-option>\n }\n </mat-select>\n </mat-form-field>\n <span class=\"pagination-info\">\n Showing {{ startRow }} - {{ endRow }} of {{ totalCount }}\n </span>\n </div>\n <div class=\"pagination-buttons\">\n <button mat-icon-button\n [disabled]=\"!canGoPrevious\"\n (click)=\"goToFirstPage()\"\n title=\"First page\">\n <mat-icon>first_page</mat-icon>\n </button>\n <button mat-icon-button\n [disabled]=\"!canGoPrevious\"\n (click)=\"goToPreviousPage()\"\n title=\"Previous page\">\n <mat-icon>chevron_left</mat-icon>\n </button>\n <span class=\"page-info\">Page {{ currentPage + 1 }} of {{ totalPages }}</span>\n <button mat-icon-button\n [disabled]=\"!canGoNext\"\n (click)=\"goToNextPage()\"\n title=\"Next page\">\n <mat-icon>chevron_right</mat-icon>\n </button>\n <button mat-icon-button\n [disabled]=\"!canGoNext\"\n (click)=\"goToLastPage()\"\n title=\"Last page\">\n <mat-icon>last_page</mat-icon>\n </button>\n </div>\n </div>\n }\n</div>\n", styles: [".table-view-container{padding:16px}.parent-button{margin-bottom:12px}.table-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.table-header h2{margin:0}.loading-container,.error-container,.empty-container{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:48px;text-align:center}.loading-container mat-icon,.error-container mat-icon,.empty-container mat-icon{font-size:48px;width:48px;height:48px;margin-bottom:16px}.loading-container p,.error-container p,.empty-container p{margin:0 0 16px;color:#666}.error-container mat-icon{color:#f44336}.empty-container mat-icon{color:#9e9e9e}.table-wrapper{overflow-x:auto;margin-bottom:16px}.table-wrapper table{width:100%}.table-wrapper tr.mat-mdc-row td{border-bottom:none}.table-wrapper tr.child-tables-row td{border-bottom:1px solid #e0e0e0}.mat-column-actions{width:80px}.actions-row{display:flex;align-items:center}.actions-row .mat-mdc-icon-button{--mat-icon-button-state-layer-size: 36px;padding:0}.pagination-container{display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-top:1px solid #e0e0e0}.pagination-left{display:flex;align-items:center;gap:16px}.page-size-select{width:100px}.page-size-select ::ng-deep .mat-mdc-form-field-subscript-wrapper{display:none}.pagination-info{color:#666;font-size:14px}.pagination-buttons{display:flex;align-items:center;gap:4px}.page-info{padding:0 12px;font-size:14px}.bool-true{color:#16a34a}.bool-false{color:#dc2626}.mat-column-photo{width:60px;text-align:center}.photo-thumbnail{max-width:50px;max-height:50px;border-radius:4px;object-fit:cover}.photo-placeholder{color:#9e9e9e;font-size:24px;width:24px;height:24px}.child-tables-row{height:auto!important}.child-tables-row td{padding:0!important;border-bottom:1px solid #e0e0e0}.hidden-row{display:none}.child-table-pills{display:flex;flex-wrap:wrap;gap:6px;padding:4px 16px 6px 48px}.child-pill{display:inline-flex;align-items:center;gap:4px;padding:2px 10px 2px 6px;border:1px solid #90caf9;border-radius:16px;background:#e3f2fd;color:#1565c0;font-size:12px;cursor:pointer;white-space:nowrap}.child-pill mat-icon{font-size:14px;width:14px;height:14px}.child-pill:hover{background:#bbdefb;border-color:#42a5f5}\n"] }]
482
+ }], ctorParameters: () => [{ type: DatabaseSchemaService }, { type: undefined, decorators: [{
483
+ type: Inject,
484
+ args: [HONUWARE_CRUD_ACCESS]
485
+ }] }, { type: i2.PhotoUrlBuilder }, { type: undefined, decorators: [{
486
+ type: Inject,
487
+ args: [CRUD_EDITOR_ROUTES]
488
+ }] }, { type: i1$1.Router }, { type: i4.MatDialog }], propDecorators: { tableName: [{
489
+ type: Input
490
+ }], pageSize: [{
491
+ type: Input
492
+ }], pageOffset: [{
493
+ type: Input
494
+ }], sortColumn: [{
495
+ type: Input
496
+ }], sortAscending: [{
497
+ type: Input
498
+ }], bindingStack: [{
499
+ type: Input
500
+ }] } });
501
+
502
+ class CompositeRowControlComponent {
503
+ dbSchemaService;
504
+ crudAccess;
505
+ editorRoutes;
506
+ router;
507
+ tableName;
508
+ columnNames;
509
+ primaryKeyName;
510
+ primaryKeyValue;
511
+ isCreateMode;
512
+ returnUrl;
513
+ bindingStack = [];
514
+ formAssist;
515
+ controls;
516
+ photoUpload;
517
+ columnInfos = [];
518
+ loading = true;
519
+ columnValues = []; // values for each column
520
+ hasPhotoSupport = false;
521
+ childTableReferences = [];
522
+ /** Set of FK column names from navigation context (readonly + pre-filled in create mode). */
523
+ contextFkColumnNames = new Set();
524
+ databaseSchema;
525
+ tableSchema;
526
+ /** Tracks whether each computed date dest column is in auto-compute mode. */
527
+ computedDateAutoState = {};
528
+ constructor(dbSchemaService, crudAccess, editorRoutes, router) {
529
+ this.dbSchemaService = dbSchemaService;
530
+ this.crudAccess = crudAccess;
531
+ this.editorRoutes = editorRoutes;
532
+ this.router = router;
533
+ }
534
+ ngOnInit() {
535
+ this.loadColumnInfos();
536
+ }
537
+ loadColumnInfos() {
538
+ this.dbSchemaService.GetDBSchema().subscribe(schema => {
539
+ this.databaseSchema = schema;
540
+ const table = schema.tables.find(t => t.table_name === this.tableName);
541
+ if (!table) {
542
+ this.columnInfos = [];
543
+ this.loading = false;
544
+ return;
545
+ }
546
+ this.tableSchema = table;
547
+ this.hasPhotoSupport = this.isTrueValue(table.has_photo_support);
548
+ this.columnInfos = this.columnNames.map(colName => table.columns.find(col => col.column_name === colName));
549
+ // Compute context FK columns from binding stack
550
+ this.contextFkColumnNames = this.computeContextFkColumns(table);
551
+ // Compute child table references (for nested table navigation buttons)
552
+ this.childTableReferences = this.isCreateMode ? [] : this.getChildTableReferences(schema);
553
+ if (!this.isCreateMode && this.primaryKeyName && this.primaryKeyValue) {
554
+ // Fetch row data using getRowByValues (unified for root and nested)
555
+ const filterPairs = [
556
+ { column_name: this.primaryKeyName, column_value: this.primaryKeyValue }
557
+ ];
558
+ this.crudAccess.getRowByValues(this.tableName, filterPairs).subscribe(result => {
559
+ // Map each columnName to its value in the returned row
560
+ this.columnValues = this.columnNames.map(colName => {
561
+ const sortedIdx = result.sortedColumnNames.indexOf(colName);
562
+ return result.dataTable.length > 0 && sortedIdx !== -1 ? result.dataTable[0][sortedIdx] : undefined;
563
+ });
564
+ this.loading = false;
565
+ });
566
+ }
567
+ else {
568
+ // Create mode: pre-fill FK columns from binding stack context, then apply formAssist defaults
569
+ this.columnValues = this.columnNames.map(colName => {
570
+ const fkPrefill = this.getContextFkValue(table, colName);
571
+ if (fkPrefill)
572
+ return fkPrefill;
573
+ // Apply formAssist defaults
574
+ if (this.formAssist?.defaults && colName in this.formAssist.defaults) {
575
+ return this.formAssist.defaults[colName];
576
+ }
577
+ return undefined;
578
+ });
579
+ // Initialize computed date auto state
580
+ this.initComputedDateState();
581
+ this.loading = false;
582
+ }
583
+ });
584
+ }
585
+ /**
586
+ * Compute the set of FK column names that come from navigation context.
587
+ * These should be readonly in both edit and create mode.
588
+ */
589
+ computeContextFkColumns(table) {
590
+ const fkColumns = new Set();
591
+ for (const fk of table.foreign_keys) {
592
+ const binding = this.bindingStack.find(b => b.tableName === fk.parent_table_name && b.primaryKeyName === fk.parent_column_name);
593
+ if (binding) {
594
+ fkColumns.add(fk.column_name);
595
+ }
596
+ }
597
+ return fkColumns;
598
+ }
599
+ /**
600
+ * Get the pre-fill value for an FK column from the binding stack.
601
+ * Returns null if the column is not an FK from context.
602
+ */
603
+ getContextFkValue(table, columnName) {
604
+ for (const fk of table.foreign_keys) {
605
+ if (fk.column_name !== columnName)
606
+ continue;
607
+ const binding = this.bindingStack.find(b => b.tableName === fk.parent_table_name && b.primaryKeyName === fk.parent_column_name);
608
+ if (binding) {
609
+ return binding.primaryKeyValue;
610
+ }
611
+ }
612
+ return null;
613
+ }
614
+ /**
615
+ * Find nested tables that have FK references to the current table.
616
+ * Only includes tables listed in schema.nested_tables.
617
+ */
618
+ getChildTableReferences(schema) {
619
+ const refs = [];
620
+ for (const nestedTableName of schema.nested_tables) {
621
+ const childSchema = schema.tables.find(t => t.table_name === nestedTableName);
622
+ if (!childSchema)
623
+ continue;
624
+ for (const fk of childSchema.foreign_keys) {
625
+ if (fk.parent_table_name === this.tableName) {
626
+ refs.push({
627
+ childTableName: nestedTableName,
628
+ childTableFriendlyName: childSchema.table_friendly_name || nestedTableName,
629
+ foreignKey: fk,
630
+ });
631
+ }
632
+ }
633
+ }
634
+ return refs;
635
+ }
636
+ get visibleColumns() {
637
+ return this.columnInfos
638
+ .map((col, index) => ({ col, index }))
639
+ .filter(({ col }) => {
640
+ if (this.isTrueValue(col.hidden))
641
+ return false;
642
+ if (this.isTrueValue(col.readonly) && this.isCreateMode)
643
+ return false;
644
+ return true;
645
+ });
646
+ }
647
+ /** Check if a column should be readonly. Includes context FK columns. */
648
+ isReadonlyColumn(col) {
649
+ return this.isPrimaryKeyColumn(col) || this.isTrueValue(col.readonly)
650
+ || this.contextFkColumnNames.has(col.column_name);
651
+ }
652
+ /** Whether the column is an FK from navigation context. */
653
+ isContextFkColumn(col) {
654
+ return this.contextFkColumnNames.has(col.column_name);
655
+ }
656
+ onValueChanged(index, value) {
657
+ const visible = this.visibleColumns;
658
+ const entry = visible[index];
659
+ if (!entry)
660
+ return;
661
+ const control = this.controls.get(index);
662
+ if (control) {
663
+ control.value = value;
664
+ }
665
+ // Update the backing columnValues so computed dates can read it
666
+ this.columnValues[entry.index] = value;
667
+ // Propagate to computed date destinations
668
+ this.applyComputedDates(entry.col.column_name);
669
+ }
670
+ getControlValues() {
671
+ const values = {};
672
+ const visible = this.visibleColumns;
673
+ this.controls.forEach((control, idx) => {
674
+ const entry = visible[idx];
675
+ if (!entry)
676
+ return;
677
+ const col = entry.col;
678
+ if (this.isTrueValue(col.readonly))
679
+ return;
680
+ // Skip empty strings for non-text columns — PostgreSQL can't cast ""
681
+ // to numeric/date/bool types, and empty means "no change" for those.
682
+ if (control.value === '' && !this.isTextColumnType(col.type))
683
+ return;
684
+ values[this.columnNames[entry.index]] = control.value;
685
+ });
686
+ return values;
687
+ }
688
+ isTextColumnType(type) {
689
+ const upper = type.toUpperCase();
690
+ return upper === 'VARCHAR' || upper === 'TEXT' || upper === 'CHAR';
691
+ }
692
+ onSubmit() {
693
+ const values = this.getControlValues();
694
+ if (this.isCreateMode) {
695
+ const body = {
696
+ table_name: this.tableName,
697
+ value: values
698
+ };
699
+ if (this.hasPhotoSupport) {
700
+ this.crudAccess.addItemFetchPrimaryKey(body).subscribe({
701
+ next: (primaryKey) => {
702
+ if (this.photoUpload?.hasPendingFile) {
703
+ this.photoUpload.uploadPendingPhoto(this.tableName, Number(primaryKey)).subscribe({
704
+ next: () => this.navigateBack(),
705
+ error: (err) => {
706
+ console.error('Error uploading photo:', err);
707
+ this.navigateBack();
708
+ }
709
+ });
710
+ }
711
+ else {
712
+ this.navigateBack();
713
+ }
714
+ },
715
+ error: (err) => console.error('Error creating item:', err)
716
+ });
717
+ }
718
+ else {
719
+ this.crudAccess.addItem(body).subscribe({
720
+ next: () => this.navigateBack(),
721
+ error: (err) => console.error('Error creating item:', err)
722
+ });
723
+ }
724
+ }
725
+ else {
726
+ if (!this.primaryKeyName || !this.primaryKeyValue)
727
+ return;
728
+ const body = {
729
+ table_name: this.tableName,
730
+ value: values,
731
+ column_name: this.primaryKeyName,
732
+ column_value: this.primaryKeyValue
733
+ };
734
+ this.crudAccess.updateItem(body).subscribe({
735
+ next: () => this.navigateBack(),
736
+ error: (err) => console.error('Error updating item:', err)
737
+ });
738
+ }
739
+ }
740
+ onCancel() {
741
+ this.navigateBack();
742
+ }
743
+ isTrueValue(value) {
744
+ return value === true || value === 't';
745
+ }
746
+ get showPhotoUpload() {
747
+ if (!this.hasPhotoSupport)
748
+ return false;
749
+ if (this.isCreateMode)
750
+ return true;
751
+ return !!this.primaryKeyValue;
752
+ }
753
+ get photoItemId() {
754
+ if (this.isCreateMode)
755
+ return 0;
756
+ return Number(this.primaryKeyValue);
757
+ }
758
+ getForeignKeyInfo(col) {
759
+ return this.tableSchema?.foreign_keys.find(fk => fk.column_name === col.column_name);
760
+ }
761
+ isPrimaryKeyColumn(col) {
762
+ if (this.isCreateMode)
763
+ return false;
764
+ // Handle both boolean true and string "t" from server
765
+ return col.primary_key === true || col.primary_key === 't';
766
+ }
767
+ // --- Nested navigation ---
768
+ get hasParent() {
769
+ return this.bindingStack.length > 0;
770
+ }
771
+ get parentFriendlyName() {
772
+ if (this.bindingStack.length === 0)
773
+ return '';
774
+ const parentBinding = this.bindingStack[this.bindingStack.length - 1];
775
+ const parentSchema = this.databaseSchema?.tables.find(t => t.table_name === parentBinding.tableName);
776
+ return parentSchema?.table_friendly_name || parentBinding.tableName;
777
+ }
778
+ /** Navigate back to the parent table's edit page by popping the binding stack. */
779
+ onNavigateToParent() {
780
+ if (this.bindingStack.length === 0)
781
+ return;
782
+ const parentBinding = this.bindingStack[this.bindingStack.length - 1];
783
+ const parentStack = this.bindingStack.slice(0, -1);
784
+ const queryParams = {};
785
+ if (parentStack.length > 0) {
786
+ queryParams['ctx'] = serializeBindingStack(parentStack);
787
+ }
788
+ this.router.navigate([this.editorRoutes.basePath, parentBinding.tableName, 'edit', parentBinding.primaryKeyValue], { queryParams });
789
+ }
790
+ /** Navigate to a child (nested) table filtered by the current row. */
791
+ onNavigateToChild(ref) {
792
+ if (!this.primaryKeyValue || !this.tableSchema)
793
+ return;
794
+ // Push current table onto the binding stack
795
+ const newStack = [
796
+ ...this.bindingStack,
797
+ {
798
+ tableName: this.tableName,
799
+ primaryKeyName: this.tableSchema.primary_key,
800
+ primaryKeyValue: this.primaryKeyValue,
801
+ }
802
+ ];
803
+ const queryParams = {
804
+ ctx: serializeBindingStack(newStack),
805
+ };
806
+ this.router.navigate([this.editorRoutes.basePath, ref.childTableName, 'view', 10, 0], { queryParams });
807
+ }
808
+ // --- Computed date support ---
809
+ initComputedDateState() {
810
+ if (!this.formAssist?.computedDates)
811
+ return;
812
+ for (const rule of this.formAssist.computedDates) {
813
+ this.computedDateAutoState[rule.dest] = rule.autoByDefault;
814
+ }
815
+ }
816
+ /** When a source field changes, update any auto-computed dest fields. */
817
+ applyComputedDates(changedColumn) {
818
+ if (!this.formAssist?.computedDates)
819
+ return;
820
+ for (const rule of this.formAssist.computedDates) {
821
+ if (rule.source !== changedColumn)
822
+ continue;
823
+ if (!this.computedDateAutoState[rule.dest])
824
+ continue;
825
+ this.computeAndSetDate(rule);
826
+ }
827
+ }
828
+ computeAndSetDate(rule) {
829
+ const sourceIndex = this.columnNames.indexOf(rule.source);
830
+ const destIndex = this.columnNames.indexOf(rule.dest);
831
+ if (sourceIndex === -1 || destIndex === -1)
832
+ return;
833
+ const sourceValue = this.columnValues[sourceIndex];
834
+ if (!sourceValue)
835
+ return;
836
+ const sourceUs = parseInt(sourceValue, 10);
837
+ if (isNaN(sourceUs))
838
+ return;
839
+ const destUs = sourceUs + (rule.offsetMinutes * 60 * 1_000_000);
840
+ const destValue = String(destUs);
841
+ this.columnValues[destIndex] = destValue;
842
+ // Update the visible control if it exists
843
+ const visible = this.visibleColumns;
844
+ const visibleIdx = visible.findIndex(v => v.index === destIndex);
845
+ if (visibleIdx !== -1) {
846
+ const control = this.controls?.get(visibleIdx);
847
+ if (control) {
848
+ control.value = destValue;
849
+ }
850
+ }
851
+ }
852
+ /** Toggle auto-compute for a computed date dest column. */
853
+ onToggleComputedDate(destColumn) {
854
+ this.computedDateAutoState[destColumn] = !this.computedDateAutoState[destColumn];
855
+ if (this.computedDateAutoState[destColumn]) {
856
+ // Re-compute from source
857
+ const rule = this.formAssist?.computedDates?.find(r => r.dest === destColumn);
858
+ if (rule) {
859
+ this.computeAndSetDate(rule);
860
+ }
861
+ }
862
+ }
863
+ /** Check if a column is a computed date dest in auto mode. */
864
+ isComputedDateAuto(columnName) {
865
+ return this.computedDateAutoState[columnName] === true;
866
+ }
867
+ /** Get the computed date rule for a column, if any. */
868
+ getComputedDateRule(columnName) {
869
+ return this.formAssist?.computedDates?.find(r => r.dest === columnName);
870
+ }
871
+ navigateBack() {
872
+ if (this.returnUrl) {
873
+ this.router.navigateByUrl(this.returnUrl);
874
+ }
875
+ }
876
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: CompositeRowControlComponent, deps: [{ token: DatabaseSchemaService }, { token: HONUWARE_CRUD_ACCESS }, { token: CRUD_EDITOR_ROUTES }, { token: i1$1.Router }], target: i0.ɵɵFactoryTarget.Component });
877
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.18", type: CompositeRowControlComponent, isStandalone: true, selector: "hw-composite-row-control", inputs: { tableName: "tableName", columnNames: "columnNames", primaryKeyName: "primaryKeyName", primaryKeyValue: "primaryKeyValue", isCreateMode: "isCreateMode", returnUrl: "returnUrl", bindingStack: "bindingStack", formAssist: "formAssist" }, viewQueries: [{ propertyName: "photoUpload", first: true, predicate: PhotoUploadComponent, descendants: true }, { propertyName: "controls", predicate: CompositeControlComponent, descendants: true }], ngImport: i0, template: "@if (loading) {\n<div>Loading...</div>\n}\n@if (!loading) {\n<!-- Back to Parent button -->\n@if (hasParent) {\n <button mat-stroked-button class=\"parent-button\" (click)=\"onNavigateToParent()\">\n <mat-icon>arrow_back</mat-icon>\n Back to {{ parentFriendlyName }}\n </button>\n}\n@if (showPhotoUpload) {\n <hw-photo-upload [tableName]=\"tableName\" [tableItemId]=\"photoItemId\" [deferUpload]=\"isCreateMode\"></hw-photo-upload>\n}\n<form>\n @for (entry of visibleColumns; track entry.col.column_name; let i = $index) {\n <hw-composite-control [dataInfo]=\"entry.col\"\n [value]=\"columnValues[entry.index]\"\n [readOnly]=\"isReadonlyColumn(entry.col) || isComputedDateAuto(entry.col.column_name)\"\n [foreignKeyInfo]=\"getForeignKeyInfo(entry.col)\"\n (valueChanged)=\"onValueChanged(i, $event)\">\n </hw-composite-control>\n @if (getComputedDateRule(entry.col.column_name); as rule) {\n <div class=\"computed-date-toggle\">\n <mat-checkbox [checked]=\"isComputedDateAuto(entry.col.column_name)\"\n (change)=\"onToggleComputedDate(entry.col.column_name)\">\n Auto-compute from {{ rule.source }} + {{ rule.offsetMinutes }} min\n </mat-checkbox>\n </div>\n }\n }\n <div class=\"form-actions\">\n <button type=\"button\" mat-raised-button color=\"primary\" class=\"submit-button\" (click)=\"onSubmit()\">\n {{ isCreateMode ? 'Create' : 'Update' }}\n </button>\n @if (returnUrl) {\n <button type=\"button\" mat-stroked-button class=\"cancel-button\" (click)=\"onCancel()\">\n Cancel\n </button>\n }\n </div>\n</form>\n<!-- Nested table cards (only in edit mode) -->\n@if (!isCreateMode && childTableReferences.length > 0) {\n <div class=\"nested-tables-section\">\n <h3>Related Tables</h3>\n <div class=\"nested-table-cards\">\n @for (ref of childTableReferences; track ref.childTableName + ref.foreignKey.column_name) {\n <button mat-stroked-button class=\"nested-table-card\" (click)=\"onNavigateToChild(ref)\">\n <mat-icon>subdirectory_arrow_right</mat-icon>\n {{ ref.childTableFriendlyName }}\n </button>\n }\n </div>\n </div>\n}\n}\n", styles: [":host{display:block}.parent-button{margin-bottom:12px}hw-photo-upload{display:block;margin-bottom:1rem}form{display:flex;flex-direction:column;gap:1rem;max-width:600px}.form-actions{display:flex;justify-content:flex-start;gap:.5rem;margin-top:1rem}.nested-tables-section{margin-top:2rem;max-width:600px}.nested-tables-section h3{margin:0 0 12px;font-size:16px;color:#333}.nested-table-cards{display:flex;flex-wrap:wrap;gap:8px}.nested-table-card{display:inline-flex;align-items:center;gap:6px}.computed-date-toggle{margin-top:-.5rem;margin-bottom:.25rem;font-size:.85rem;color:#666}\n"], dependencies: [{ kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i6.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatCheckboxModule }, { kind: "component", type: i4$1.MatCheckbox, selector: "mat-checkbox", inputs: ["aria-label", "aria-labelledby", "aria-describedby", "aria-expanded", "aria-controls", "aria-owns", "id", "required", "labelPosition", "name", "value", "disableRipple", "tabIndex", "color", "disabledInteractive", "checked", "disabled", "indeterminate"], outputs: ["change", "indeterminateChange"], exportAs: ["matCheckbox"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i5$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "component", type: CompositeControlComponent, selector: "hw-composite-control", inputs: ["dataInfo", "value", "readOnly", "foreignKeyInfo"], outputs: ["valueChanged"] }, { kind: "component", type: PhotoUploadComponent, selector: "hw-photo-upload", inputs: ["tableName", "tableItemId", "userMode", "deferUpload"] }] });
878
+ }
879
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: CompositeRowControlComponent, decorators: [{
880
+ type: Component,
881
+ args: [{ selector: 'hw-composite-row-control', standalone: true, imports: [MatButtonModule, MatCheckboxModule, MatIconModule, CompositeControlComponent, PhotoUploadComponent], template: "@if (loading) {\n<div>Loading...</div>\n}\n@if (!loading) {\n<!-- Back to Parent button -->\n@if (hasParent) {\n <button mat-stroked-button class=\"parent-button\" (click)=\"onNavigateToParent()\">\n <mat-icon>arrow_back</mat-icon>\n Back to {{ parentFriendlyName }}\n </button>\n}\n@if (showPhotoUpload) {\n <hw-photo-upload [tableName]=\"tableName\" [tableItemId]=\"photoItemId\" [deferUpload]=\"isCreateMode\"></hw-photo-upload>\n}\n<form>\n @for (entry of visibleColumns; track entry.col.column_name; let i = $index) {\n <hw-composite-control [dataInfo]=\"entry.col\"\n [value]=\"columnValues[entry.index]\"\n [readOnly]=\"isReadonlyColumn(entry.col) || isComputedDateAuto(entry.col.column_name)\"\n [foreignKeyInfo]=\"getForeignKeyInfo(entry.col)\"\n (valueChanged)=\"onValueChanged(i, $event)\">\n </hw-composite-control>\n @if (getComputedDateRule(entry.col.column_name); as rule) {\n <div class=\"computed-date-toggle\">\n <mat-checkbox [checked]=\"isComputedDateAuto(entry.col.column_name)\"\n (change)=\"onToggleComputedDate(entry.col.column_name)\">\n Auto-compute from {{ rule.source }} + {{ rule.offsetMinutes }} min\n </mat-checkbox>\n </div>\n }\n }\n <div class=\"form-actions\">\n <button type=\"button\" mat-raised-button color=\"primary\" class=\"submit-button\" (click)=\"onSubmit()\">\n {{ isCreateMode ? 'Create' : 'Update' }}\n </button>\n @if (returnUrl) {\n <button type=\"button\" mat-stroked-button class=\"cancel-button\" (click)=\"onCancel()\">\n Cancel\n </button>\n }\n </div>\n</form>\n<!-- Nested table cards (only in edit mode) -->\n@if (!isCreateMode && childTableReferences.length > 0) {\n <div class=\"nested-tables-section\">\n <h3>Related Tables</h3>\n <div class=\"nested-table-cards\">\n @for (ref of childTableReferences; track ref.childTableName + ref.foreignKey.column_name) {\n <button mat-stroked-button class=\"nested-table-card\" (click)=\"onNavigateToChild(ref)\">\n <mat-icon>subdirectory_arrow_right</mat-icon>\n {{ ref.childTableFriendlyName }}\n </button>\n }\n </div>\n </div>\n}\n}\n", styles: [":host{display:block}.parent-button{margin-bottom:12px}hw-photo-upload{display:block;margin-bottom:1rem}form{display:flex;flex-direction:column;gap:1rem;max-width:600px}.form-actions{display:flex;justify-content:flex-start;gap:.5rem;margin-top:1rem}.nested-tables-section{margin-top:2rem;max-width:600px}.nested-tables-section h3{margin:0 0 12px;font-size:16px;color:#333}.nested-table-cards{display:flex;flex-wrap:wrap;gap:8px}.nested-table-card{display:inline-flex;align-items:center;gap:6px}.computed-date-toggle{margin-top:-.5rem;margin-bottom:.25rem;font-size:.85rem;color:#666}\n"] }]
882
+ }], ctorParameters: () => [{ type: DatabaseSchemaService }, { type: undefined, decorators: [{
883
+ type: Inject,
884
+ args: [HONUWARE_CRUD_ACCESS]
885
+ }] }, { type: undefined, decorators: [{
886
+ type: Inject,
887
+ args: [CRUD_EDITOR_ROUTES]
888
+ }] }, { type: i1$1.Router }], propDecorators: { tableName: [{
889
+ type: Input
890
+ }], columnNames: [{
891
+ type: Input
892
+ }], primaryKeyName: [{
893
+ type: Input
894
+ }], primaryKeyValue: [{
895
+ type: Input
896
+ }], isCreateMode: [{
897
+ type: Input
898
+ }], returnUrl: [{
899
+ type: Input
900
+ }], bindingStack: [{
901
+ type: Input
902
+ }], formAssist: [{
903
+ type: Input
904
+ }], controls: [{
905
+ type: ViewChildren,
906
+ args: [CompositeControlComponent]
907
+ }], photoUpload: [{
908
+ type: ViewChild,
909
+ args: [PhotoUploadComponent]
910
+ }] } });
911
+
912
+ class TableViewPageComponent {
913
+ route;
914
+ tableName = '';
915
+ pageSize = 10;
916
+ pageOffset = 0;
917
+ bindingStack = [];
918
+ adminHome;
919
+ destroy$ = new Subject();
920
+ constructor(route, editorRoutes) {
921
+ this.route = route;
922
+ this.adminHome = editorRoutes.adminHome;
923
+ }
924
+ ngOnInit() {
925
+ combineLatest([
926
+ this.route.params,
927
+ this.route.queryParams
928
+ ]).pipe(takeUntil(this.destroy$)).subscribe(([params, queryParams]) => {
929
+ this.tableName = params['tableName'] || '';
930
+ this.pageSize = parseInt(params['pageSize'], 10) || 10;
931
+ this.pageOffset = parseInt(params['pageOffset'], 10) || 0;
932
+ this.bindingStack = parseBindingStack(queryParams['ctx']);
933
+ });
934
+ }
935
+ ngOnDestroy() {
936
+ this.destroy$.next();
937
+ this.destroy$.complete();
938
+ }
939
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: TableViewPageComponent, deps: [{ token: i1$1.ActivatedRoute }, { token: CRUD_EDITOR_ROUTES }], target: i0.ɵɵFactoryTarget.Component });
940
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.18", type: TableViewPageComponent, isStandalone: true, selector: "hw-table-view-page", ngImport: i0, template: "<a [routerLink]=\"adminHome\" class=\"din text-sm text-blue-600 hover:underline mb-4 inline-block\">&larr; Back to admin portal</a>\n<hw-table-view-control\n [tableName]=\"tableName\"\n [pageSize]=\"pageSize\"\n [pageOffset]=\"pageOffset\"\n [bindingStack]=\"bindingStack\">\n</hw-table-view-control>\n", dependencies: [{ kind: "ngmodule", type: RouterModule }, { kind: "directive", type: i1$1.RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "routerLink"] }, { kind: "component", type: TableViewControlComponent, selector: "hw-table-view-control", inputs: ["tableName", "pageSize", "pageOffset", "sortColumn", "sortAscending", "bindingStack"] }] });
941
+ }
942
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: TableViewPageComponent, decorators: [{
943
+ type: Component,
944
+ args: [{ selector: 'hw-table-view-page', standalone: true, imports: [RouterModule, TableViewControlComponent], template: "<a [routerLink]=\"adminHome\" class=\"din text-sm text-blue-600 hover:underline mb-4 inline-block\">&larr; Back to admin portal</a>\n<hw-table-view-control\n [tableName]=\"tableName\"\n [pageSize]=\"pageSize\"\n [pageOffset]=\"pageOffset\"\n [bindingStack]=\"bindingStack\">\n</hw-table-view-control>\n" }]
945
+ }], ctorParameters: () => [{ type: i1$1.ActivatedRoute }, { type: undefined, decorators: [{
946
+ type: Inject,
947
+ args: [CRUD_EDITOR_ROUTES]
948
+ }] }] });
949
+
950
+ class TableEditPageComponent {
951
+ route;
952
+ dbSchemaService;
953
+ tableName = '';
954
+ primaryKeyName = 'id';
955
+ primaryKeyValue = '';
956
+ columnNames = [];
957
+ tableFriendlyName = '';
958
+ returnUrl = '';
959
+ bindingStack = [];
960
+ loading = true;
961
+ error;
962
+ adminHome;
963
+ destroy$ = new Subject();
964
+ constructor(route, dbSchemaService, editorRoutes) {
965
+ this.route = route;
966
+ this.dbSchemaService = dbSchemaService;
967
+ this.adminHome = editorRoutes.adminHome;
968
+ }
969
+ ngOnInit() {
970
+ combineLatest([
971
+ this.route.params,
972
+ this.route.queryParams,
973
+ this.dbSchemaService.GetDBSchema()
974
+ ]).pipe(takeUntil(this.destroy$)).subscribe({
975
+ next: ([params, queryParams, schema]) => {
976
+ this.tableName = params['tableName'] || '';
977
+ this.primaryKeyValue = params['id'] || '';
978
+ this.returnUrl = queryParams['returnUrl'] || '';
979
+ this.bindingStack = parseBindingStack(queryParams['ctx']);
980
+ const tableSchema = schema.tables.find(t => t.table_name === this.tableName);
981
+ if (!tableSchema) {
982
+ this.error = `Table "${this.tableName}" not found`;
983
+ this.loading = false;
984
+ return;
985
+ }
986
+ this.tableFriendlyName = tableSchema.table_friendly_name || this.tableName;
987
+ this.primaryKeyName = tableSchema.primary_key;
988
+ this.columnNames = tableSchema.columns.map(c => c.column_name);
989
+ this.loading = false;
990
+ },
991
+ error: (err) => {
992
+ this.error = 'Failed to load schema';
993
+ this.loading = false;
994
+ console.error('Error loading schema:', err);
995
+ }
996
+ });
997
+ }
998
+ ngOnDestroy() {
999
+ this.destroy$.next();
1000
+ this.destroy$.complete();
1001
+ }
1002
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: TableEditPageComponent, deps: [{ token: i1$1.ActivatedRoute }, { token: DatabaseSchemaService }, { token: CRUD_EDITOR_ROUTES }], target: i0.ɵɵFactoryTarget.Component });
1003
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.18", type: TableEditPageComponent, isStandalone: true, selector: "hw-table-edit-page", ngImport: i0, template: "@if (loading) {\n <div class=\"loading-container\">\n <mat-spinner diameter=\"40\"></mat-spinner>\n <p>Loading...</p>\n </div>\n}\n@if (!loading && error) {\n <div class=\"error-container\">\n <mat-icon>error</mat-icon>\n <p>{{ error }}</p>\n </div>\n}\n@if (!loading && !error) {\n <div class=\"edit-page-container\">\n <a [routerLink]=\"adminHome\" class=\"din text-sm text-blue-600 hover:underline mb-4 inline-block\">&larr; Back to admin portal</a>\n <h1 class=\"din-condensed-bold text-2xl mb-6\">Edit {{ tableFriendlyName }}</h1>\n <mat-card class=\"edit-card\">\n <mat-card-content>\n <hw-composite-row-control\n [tableName]=\"tableName\"\n [columnNames]=\"columnNames\"\n [primaryKeyName]=\"primaryKeyName\"\n [primaryKeyValue]=\"primaryKeyValue\"\n [isCreateMode]=\"false\"\n [returnUrl]=\"returnUrl\"\n [bindingStack]=\"bindingStack\">\n </hw-composite-row-control>\n </mat-card-content>\n </mat-card>\n </div>\n}\n", styles: [".edit-page-container{padding:24px;max-width:800px}.edit-card{padding:16px;border:1px solid rgba(0,0,0,.12);box-shadow:0 2px 4px #0000001a}.loading-container{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:48px;gap:16px}.loading-container p{color:#0009}.error-container{display:flex;align-items:center;gap:8px;padding:24px;color:#f44336}.error-container mat-icon{color:#f44336}\n"], dependencies: [{ kind: "ngmodule", type: RouterModule }, { kind: "directive", type: i1$1.RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "routerLink"] }, { kind: "ngmodule", type: MatCardModule }, { kind: "component", type: i3.MatCard, selector: "mat-card", inputs: ["appearance"], exportAs: ["matCard"] }, { kind: "directive", type: i3.MatCardContent, selector: "mat-card-content" }, { kind: "ngmodule", type: MatProgressSpinnerModule }, { kind: "component", type: i8.MatProgressSpinner, selector: "mat-progress-spinner, mat-spinner", inputs: ["color", "mode", "value", "diameter", "strokeWidth"], exportAs: ["matProgressSpinner"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i5$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "component", type: CompositeRowControlComponent, selector: "hw-composite-row-control", inputs: ["tableName", "columnNames", "primaryKeyName", "primaryKeyValue", "isCreateMode", "returnUrl", "bindingStack", "formAssist"] }] });
1004
+ }
1005
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: TableEditPageComponent, decorators: [{
1006
+ type: Component,
1007
+ args: [{ selector: 'hw-table-edit-page', standalone: true, imports: [
1008
+ RouterModule,
1009
+ MatCardModule,
1010
+ MatProgressSpinnerModule,
1011
+ MatIconModule,
1012
+ CompositeRowControlComponent
1013
+ ], template: "@if (loading) {\n <div class=\"loading-container\">\n <mat-spinner diameter=\"40\"></mat-spinner>\n <p>Loading...</p>\n </div>\n}\n@if (!loading && error) {\n <div class=\"error-container\">\n <mat-icon>error</mat-icon>\n <p>{{ error }}</p>\n </div>\n}\n@if (!loading && !error) {\n <div class=\"edit-page-container\">\n <a [routerLink]=\"adminHome\" class=\"din text-sm text-blue-600 hover:underline mb-4 inline-block\">&larr; Back to admin portal</a>\n <h1 class=\"din-condensed-bold text-2xl mb-6\">Edit {{ tableFriendlyName }}</h1>\n <mat-card class=\"edit-card\">\n <mat-card-content>\n <hw-composite-row-control\n [tableName]=\"tableName\"\n [columnNames]=\"columnNames\"\n [primaryKeyName]=\"primaryKeyName\"\n [primaryKeyValue]=\"primaryKeyValue\"\n [isCreateMode]=\"false\"\n [returnUrl]=\"returnUrl\"\n [bindingStack]=\"bindingStack\">\n </hw-composite-row-control>\n </mat-card-content>\n </mat-card>\n </div>\n}\n", styles: [".edit-page-container{padding:24px;max-width:800px}.edit-card{padding:16px;border:1px solid rgba(0,0,0,.12);box-shadow:0 2px 4px #0000001a}.loading-container{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:48px;gap:16px}.loading-container p{color:#0009}.error-container{display:flex;align-items:center;gap:8px;padding:24px;color:#f44336}.error-container mat-icon{color:#f44336}\n"] }]
1014
+ }], ctorParameters: () => [{ type: i1$1.ActivatedRoute }, { type: DatabaseSchemaService }, { type: undefined, decorators: [{
1015
+ type: Inject,
1016
+ args: [CRUD_EDITOR_ROUTES]
1017
+ }] }] });
1018
+
1019
+ class TableNewPageComponent {
1020
+ route;
1021
+ router;
1022
+ dbSchemaService;
1023
+ tableName = '';
1024
+ columnNames = [];
1025
+ tableFriendlyName = '';
1026
+ returnUrl = '';
1027
+ bindingStack = [];
1028
+ formAssist;
1029
+ loading = true;
1030
+ error;
1031
+ adminHome;
1032
+ destroy$ = new Subject();
1033
+ constructor(route, router, dbSchemaService, editorRoutes) {
1034
+ this.route = route;
1035
+ this.router = router;
1036
+ this.dbSchemaService = dbSchemaService;
1037
+ this.adminHome = editorRoutes.adminHome;
1038
+ // Read formAssist from browser history state. Angular's router.navigate({ state })
1039
+ // stores data in the History API. getCurrentNavigation() is unreliable (returns null
1040
+ // for lazy-loaded routes), so we read directly from history.state instead.
1041
+ this.formAssist = history.state?.formAssist;
1042
+ }
1043
+ ngOnInit() {
1044
+ combineLatest([
1045
+ this.route.params,
1046
+ this.route.queryParams,
1047
+ this.dbSchemaService.GetDBSchema()
1048
+ ]).pipe(takeUntil(this.destroy$)).subscribe({
1049
+ next: ([params, queryParams, schema]) => {
1050
+ this.tableName = params['tableName'] || '';
1051
+ this.returnUrl = queryParams['returnUrl'] || '';
1052
+ this.bindingStack = parseBindingStack(queryParams['ctx']);
1053
+ const tableSchema = schema.tables.find(t => t.table_name === this.tableName);
1054
+ if (!tableSchema) {
1055
+ this.error = `Table "${this.tableName}" not found`;
1056
+ this.loading = false;
1057
+ return;
1058
+ }
1059
+ this.tableFriendlyName = tableSchema.table_friendly_name || this.tableName;
1060
+ // Filter out primary key column for create mode (auto-generated)
1061
+ // Handle both boolean true and string "t" from server
1062
+ this.columnNames = tableSchema.columns
1063
+ .filter(c => c.primary_key !== true && c.primary_key !== 't')
1064
+ .map(c => c.column_name);
1065
+ this.loading = false;
1066
+ },
1067
+ error: (err) => {
1068
+ this.error = 'Failed to load schema';
1069
+ this.loading = false;
1070
+ console.error('Error loading schema:', err);
1071
+ }
1072
+ });
1073
+ }
1074
+ ngOnDestroy() {
1075
+ this.destroy$.next();
1076
+ this.destroy$.complete();
1077
+ }
1078
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: TableNewPageComponent, deps: [{ token: i1$1.ActivatedRoute }, { token: i1$1.Router }, { token: DatabaseSchemaService }, { token: CRUD_EDITOR_ROUTES }], target: i0.ɵɵFactoryTarget.Component });
1079
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.18", type: TableNewPageComponent, isStandalone: true, selector: "hw-table-new-page", ngImport: i0, template: "@if (loading) {\n <div class=\"loading\">Loading...</div>\n}\n@if (!loading && error) {\n <div class=\"error\">{{ error }}</div>\n}\n@if (!loading && !error) {\n <div class=\"new-page-container\">\n <a [routerLink]=\"adminHome\" class=\"din text-sm text-blue-600 hover:underline mb-4 inline-block\">&larr; Back to admin portal</a>\n <h1 class=\"din-condensed-bold text-2xl mb-6\">New {{ tableFriendlyName }}</h1>\n <hw-composite-row-control\n [tableName]=\"tableName\"\n [columnNames]=\"columnNames\"\n [isCreateMode]=\"true\"\n [returnUrl]=\"returnUrl\"\n [bindingStack]=\"bindingStack\"\n [formAssist]=\"formAssist\">\n </hw-composite-row-control>\n </div>\n}\n", styles: [".new-page-container{padding:16px}h2{margin-bottom:16px}.loading,.error{padding:16px}.error{color:#f44336}\n"], dependencies: [{ kind: "ngmodule", type: RouterModule }, { kind: "directive", type: i1$1.RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "routerLink"] }, { kind: "component", type: CompositeRowControlComponent, selector: "hw-composite-row-control", inputs: ["tableName", "columnNames", "primaryKeyName", "primaryKeyValue", "isCreateMode", "returnUrl", "bindingStack", "formAssist"] }] });
1080
+ }
1081
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: TableNewPageComponent, decorators: [{
1082
+ type: Component,
1083
+ args: [{ selector: 'hw-table-new-page', standalone: true, imports: [RouterModule, CompositeRowControlComponent], template: "@if (loading) {\n <div class=\"loading\">Loading...</div>\n}\n@if (!loading && error) {\n <div class=\"error\">{{ error }}</div>\n}\n@if (!loading && !error) {\n <div class=\"new-page-container\">\n <a [routerLink]=\"adminHome\" class=\"din text-sm text-blue-600 hover:underline mb-4 inline-block\">&larr; Back to admin portal</a>\n <h1 class=\"din-condensed-bold text-2xl mb-6\">New {{ tableFriendlyName }}</h1>\n <hw-composite-row-control\n [tableName]=\"tableName\"\n [columnNames]=\"columnNames\"\n [isCreateMode]=\"true\"\n [returnUrl]=\"returnUrl\"\n [bindingStack]=\"bindingStack\"\n [formAssist]=\"formAssist\">\n </hw-composite-row-control>\n </div>\n}\n", styles: [".new-page-container{padding:16px}h2{margin-bottom:16px}.loading,.error{padding:16px}.error{color:#f44336}\n"] }]
1084
+ }], ctorParameters: () => [{ type: i1$1.ActivatedRoute }, { type: i1$1.Router }, { type: DatabaseSchemaService }, { type: undefined, decorators: [{
1085
+ type: Inject,
1086
+ args: [CRUD_EDITOR_ROUTES]
1087
+ }] }] });
1088
+
1089
+ /*
1090
+ * @honuware/ui/crud — the generic table editor: DatabaseSchemaService, the
1091
+ * table-binding utils, the table-view / composite-row containers, the
1092
+ * TableView/Edit/New pages, and the CRUD_EDITOR_ROUTES config token. Sits on
1093
+ * top of foundation + access + auth + controls + photos. Selectors use `hw-`.
1094
+ */
1095
+
1096
+ /**
1097
+ * Generated bundle index. Do not edit.
1098
+ */
1099
+
1100
+ export { CRUD_EDITOR_ROUTES, CompositeRowControlComponent, DEFAULT_CRUD_EDITOR_ROUTES, DatabaseSchemaService, TableEditPageComponent, TableNewPageComponent, TableViewControlComponent, TableViewPageComponent, deriveFilterPairs, parseBindingStack, serializeBindingStack };
1101
+ //# sourceMappingURL=honuware-ui-crud.mjs.map