@nauticana/sail 1.1.9 → 1.1.11

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 (79) hide show
  1. package/README.md +4 -2
  2. package/package.json +5 -1
  3. package/src/component/abstract/base_async.ts +9 -11
  4. package/src/component/abstract/base_form.ts +6 -5
  5. package/src/component/abstract/base_table.ts +131 -58
  6. package/src/component/abstract/base_view.ts +43 -27
  7. package/src/component/account/profile_editor.ts +2 -2
  8. package/src/component/billing/payment_methods.ts +1 -2
  9. package/src/component/billing/price_selector.html +2 -1
  10. package/src/component/billing/price_selector.ts +28 -13
  11. package/src/component/billing/trial_banner.ts +7 -1
  12. package/src/component/billing/usage_meter.ts +2 -2
  13. package/src/component/dashboard/data_card.html +1 -1
  14. package/src/component/dashboard/entity_selector.ts +21 -23
  15. package/src/component/form/form_field.html +6 -5
  16. package/src/component/form/form_field.ts +39 -21
  17. package/src/component/form/form_record.html +3 -3
  18. package/src/component/form/form_record.ts +23 -8
  19. package/src/component/form/form_table.html +2 -2
  20. package/src/component/form/form_table.ts +6 -5
  21. package/src/component/login/account_deletion.ts +6 -6
  22. package/src/component/login/chpass_component.html +4 -4
  23. package/src/component/login/chpass_component.ts +0 -1
  24. package/src/component/login/confirm_chpass_component.html +4 -4
  25. package/src/component/login/confirm_chpass_component.ts +1 -4
  26. package/src/component/login/consent_gate.html +6 -6
  27. package/src/component/login/consent_gate.ts +17 -24
  28. package/src/component/login/login_component.html +7 -4
  29. package/src/component/login/login_component.ts +9 -5
  30. package/src/component/login/otp_input.html +13 -5
  31. package/src/component/login/otp_input.ts +34 -27
  32. package/src/component/login/register_component.html +31 -14
  33. package/src/component/login/register_component.ts +80 -89
  34. package/src/component/login/sms_consent.html +11 -0
  35. package/src/component/login/sms_consent.ts +38 -0
  36. package/src/component/login/social_login.html +2 -2
  37. package/src/component/login/social_login.ts +20 -13
  38. package/src/component/navigation/navigation.html +2 -2
  39. package/src/component/navigation/navigation.ts +2 -0
  40. package/src/component/oauth_connection/oauth_connection_panel.ts +24 -3
  41. package/src/component/payment/user_payment_methods.html +2 -2
  42. package/src/component/payment/user_payment_methods.ts +12 -23
  43. package/src/component/payout/bank_info_form.html +4 -4
  44. package/src/component/payout/bank_info_form.ts +22 -26
  45. package/src/component/payout/provider_onboarding.html +5 -5
  46. package/src/component/payout/provider_onboarding.ts +28 -48
  47. package/src/component/security/trusted_devices.html +1 -1
  48. package/src/component/security/trusted_devices.ts +0 -1
  49. package/src/component/security/twofactor_setup.html +8 -4
  50. package/src/component/security/twofactor_setup.ts +21 -3
  51. package/src/component/security/twofactor_verify.html +2 -1
  52. package/src/component/security/twofactor_verify.ts +2 -8
  53. package/src/component/table/table_detail.html +15 -14
  54. package/src/component/table/table_detail.ts +62 -41
  55. package/src/component/table/table_edit.html +15 -11
  56. package/src/component/table/table_edit.ts +94 -67
  57. package/src/component/table/table_list.html +45 -12
  58. package/src/component/table/table_list.ts +98 -26
  59. package/src/component/table/table_lookup.html +11 -8
  60. package/src/component/table/table_lookup.ts +45 -37
  61. package/src/component/table/table_report.html +2 -2
  62. package/src/component/table/table_report.ts +37 -31
  63. package/src/component/table/table_search.html +4 -4
  64. package/src/component/table/table_search.ts +36 -45
  65. package/src/config.ts +5 -1
  66. package/src/index.ts +2 -0
  67. package/src/model/appdata.ts +5 -9
  68. package/src/model/common.ts +27 -27
  69. package/src/model/decorator.ts +25 -21
  70. package/src/service/api-response.interceptor.ts +4 -1
  71. package/src/service/auth.interceptor.ts +27 -7
  72. package/src/service/auth.service.ts +188 -134
  73. package/src/service/label.service.ts +6 -5
  74. package/src/service/oauth_connection.service.ts +2 -2
  75. package/src/service/rest_service.ts +57 -13
  76. package/src/service/rest_url.ts +23 -7
  77. package/src/service/script_loader.ts +12 -2
  78. package/src/util/errors.ts +20 -0
  79. package/src/util/money.ts +19 -7
package/README.md CHANGED
@@ -6,6 +6,8 @@ A shared Angular component library for building CRUD-based admin frontends. Prov
6
6
 
7
7
  **Recent additions:** `BaseAuthService.acceptToken(jwt)` — adopt an externally-minted JWT (registration / SSO-handoff flows) and run the full post-login sequence (store under the canonical `jwt` key, load appdata, init routes); apps must use this instead of writing `localStorage` directly. `BaseRestService.analytic<T>(endpoint, params?)` — GET a keel `analytic/<endpoint>` report and return its rows.
8
8
 
9
+ > **v1.1.10 is a breaking release.** The base classes migrated to signals (`records()`, `editableRecord()`, `isNew()` …), `login()`/`loginWithGoogle()` now return Observables, CRUD errors are typed `SailApiError`, `BaseView` split into read-only `BaseView` + `AbstractEditableView`, the decorators are renamed `IsSailString`/`IsSailNumeric`, and `configureRestUrls()` is finally safe to call from a subclass constructor. The complete old→new mapping is machine-readable in [`migration_guide.json`](migration_guide.json). Consumers must fully migrate before pushing to test or prod.
10
+
9
11
  ## What it provides
10
12
 
11
13
  | Category | Exports |
@@ -22,10 +24,10 @@ A shared Angular component library for building CRUD-based admin frontends. Prov
22
24
  | **Payout** | `PayoutProviderOnboardingComponent`, `PayoutBankInfoFormComponent` |
23
25
  | **Payments / SCA** | `UserPaymentMethodsComponent`, `ScaConfirmComponent`, `ScaConfirmer` (port), `SCA_CONFIRMER` (token) |
24
26
  | **Services** | `BaseAuthService` (OTP / social / push / deleteAccount / logoutEverywhere / profile: `getProfile`, `updateProfile`, `request`+`confirmEmailChange`, `request`+`confirmPhoneChange`), `BillingService`, `BackendService`, `PayoutService`, `UserPaymentMethodService`, `loadScript()`, `authInterceptor`, `apiResponseInterceptor` |
25
- | **Abstracts** | `BaseTable`, `BaseForm`, `BaseView`, `BaseAsync`, `BaseRestService` |
27
+ | **Abstracts** | `BaseTable`, `BaseForm`, `BaseView`, `AbstractEditableView`, `BaseAsync`, `BaseRestService` |
26
28
  | **Config** | `SAIL_GUI_CONFIG`, `SailGuiConfig`, `configureRestUrls()` |
27
29
  | **Models** | `ApplicationData`, `TableDefinition`, `SiudAction`, `ApplicationMenu`, `ConstantValue`, `UserAccount`, `RestReport`, `ReportParam`, `TrustedDevice`, `PublicPlan`, `PlanPrice`, `PaymentMethod`, `Subscription`, `Invoice`, `CheckoutRequest`/`CheckoutResponse`, `PortalResponse`, `UsageMeter`, `ChargeResult`, `UserPaymentMethod`, `TableAction`, `ReusableAccount`, `PayoutOnboardingSession`, `BankInfoFormValue`, `CountryProfile`, `OtpRequest`/`OtpResponse`, `SignupConsent`, `ConsentState`, `ConsentOption`, `SocialProvider`, `PushPlatform`, 2FA types, etc. |
28
- | **Decorators** | `@IsString()`, `@IsNumeric()` (class-validator based) |
30
+ | **Decorators** | `@IsSailString()`, `@IsSailNumeric()` (class-validator based) |
29
31
  | **Utils** | `titleCase()`, `fromPriceLabel()` |
30
32
 
31
33
  ## Quick start
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nauticana/sail",
3
- "version": "1.1.9",
3
+ "version": "1.1.11",
4
4
  "description": "Metadata-driven Angular component library for CRUD admin frontends backed by the keel Go backend. Tables, forms, navigation, OTP/2FA/social auth, billing, payout onboarding.",
5
5
  "main": "src/index.ts",
6
6
  "scripts": {
@@ -61,5 +61,9 @@
61
61
  "class-validator": "^0.15.1",
62
62
  "rxjs": "~7.8.0",
63
63
  "typescript": "~5.9.3"
64
+ },
65
+ "dependencies": {
66
+ "@types/qrcode": "^1.5.6",
67
+ "qrcode": "^1.5.4"
64
68
  }
65
69
  }
@@ -1,5 +1,7 @@
1
- import { signal } from '@angular/core';
1
+ import { DestroyRef, inject, signal } from '@angular/core';
2
+ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
2
3
  import { Observable } from 'rxjs';
4
+ import { errorDetail } from '../../util/errors';
3
5
 
4
6
  /**
5
7
  * Base class for components that perform async operations and need to show
@@ -10,6 +12,7 @@ import { Observable } from 'rxjs';
10
12
  * into the shared state automatically.
11
13
  */
12
14
  export abstract class BaseAsync {
15
+ private readonly destroyRef = inject(DestroyRef);
13
16
  readonly loading = signal(false);
14
17
  readonly errorMessage = signal('');
15
18
  readonly successMessage = signal('');
@@ -20,15 +23,9 @@ export abstract class BaseAsync {
20
23
  this.successMessage.set('');
21
24
  }
22
25
 
23
- /**
24
- * Set errorMessage from an HTTP error object; falls back to `fallback`.
25
- *
26
- * Reads `err.error.detail` first (keel emits RFC 7807 ProblemDetail with
27
- * `detail` as the human-readable field), then `err.error.message` for
28
- * legacy / non-RFC-7807 endpoints, then the supplied fallback.
29
- */
30
- protected setError(err: any, fallback: string): void {
31
- this.errorMessage.set(err?.error?.detail ?? err?.error?.message ?? fallback);
26
+ /** Set errorMessage from any sail error shape (SailApiError / HttpErrorResponse / Error). */
27
+ protected setError(err: unknown, fallback: string): void {
28
+ this.errorMessage.set(errorDetail(err, fallback));
32
29
  this.loading.set(false);
33
30
  }
34
31
 
@@ -37,6 +34,7 @@ export abstract class BaseAsync {
37
34
  * Clears messages, sets loading, invokes onSuccess on next, and routes
38
35
  * errors through setError with the given fallback message. Pass `onError`
39
36
  * when the component must also react to failures (e.g. emit an output).
37
+ * The subscription is torn down with the component.
40
38
  */
41
39
  protected run<T>(
42
40
  obs: Observable<T>,
@@ -46,7 +44,7 @@ export abstract class BaseAsync {
46
44
  ): void {
47
45
  this.clearMessages();
48
46
  this.loading.set(true);
49
- obs.subscribe({
47
+ obs.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
50
48
  next: (value) => {
51
49
  this.loading.set(false);
52
50
  onSuccess(value);
@@ -1,16 +1,17 @@
1
+ import { signal } from "@angular/core";
1
2
  import { BaseTable } from "./base_table";
2
3
 
3
4
  export abstract class BaseForm extends BaseTable {
4
- editableRecord: {[key: string]: any} = {};
5
- isNew = false;
6
- title = '';
5
+ readonly editableRecord = signal<{[key: string]: unknown}>({});
6
+ readonly isNew = signal(false);
7
+ readonly title = signal('');
7
8
 
8
9
  override isReadOnly(fieldName: string): boolean {
9
10
  // keel column_display_attribute: 'R' is always display-only; 'I'
10
11
  // (insert-only) is editable while creating, locked once it exists.
11
12
  const mode = this.getColumn(fieldName)?.DisplayMode;
12
13
  if (mode === 'R' || mode === 'U') return true; // 'U' = keel-set audit stamp
13
- if (mode === 'I') return !this.isNew;
14
- return !this.isNew && this.isKey(fieldName);
14
+ if (mode === 'I') return !this.isNew();
15
+ return !this.isNew() && this.isKey(fieldName);
15
16
  }
16
17
  }
@@ -1,10 +1,11 @@
1
- import { inject, signal, WritableSignal, Injector } from "@angular/core";
1
+ import { computed, inject, Injector, WritableSignal } from "@angular/core";
2
2
  import { ForeignKey, TableAction, TableColumn, TableDefinition } from "../../model/appdata";
3
3
  import { ConstantValue, LookupStyle, OpCode } from "../../model/common";
4
4
  import { BaseAuthService } from "../../service/auth.service";
5
5
  import { BackendService } from "../../service/rest_service";
6
6
  import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from "../../config";
7
7
  import { titleCase } from "../../util/text";
8
+ import { errorDetail } from "../../util/errors";
8
9
  import { MatDialog } from "@angular/material/dialog";
9
10
  import { RevealDialog } from "../table/reveal_dialog";
10
11
 
@@ -12,6 +13,9 @@ import { RevealDialog } from "../table/reveal_dialog";
12
13
  /** CRUD verb used by `requireAuth()` to compose the "Missing authorization" alert. */
13
14
  export type AuthVerb = 'create' | 'read' | 'update' | 'delete' | 'view';
14
15
 
16
+ /** ISO-like timestamp prefix — gate for comparing two strings as instants. */
17
+ const ISO_PREFIX = /^\d{4}-\d{2}-\d{2}T/;
18
+
15
19
 
16
20
  export abstract class BaseTable {
17
21
  protected readonly cacheService = inject(BaseAuthService);
@@ -20,17 +24,21 @@ export abstract class BaseTable {
20
24
  readonly #injector = inject(Injector);
21
25
  protected readonly config: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
22
26
  protected readonly dialogWidth: string = '400px';
23
- readonly tableName: WritableSignal<string> = signal('');
27
+ /** Subclasses supply the source — a plain signal or a linkedSignal over an input. */
28
+ abstract readonly tableName: WritableSignal<string>;
24
29
  /** Optional consumer-set override; when empty the title-cased table name is used. */
25
30
  caption = '';
26
31
  /** Consumer-set per-field option overrides (per fieldName). When unset, options come from the column's LookupDomain / LookupTable. */
27
32
  fieldOptions: {[key: string]: ConstantValue[]} = {};
28
- isReadOnlyBound = this.isReadOnly.bind(this);
29
33
 
30
34
  isReadOnly(_fieldName: string): boolean {
31
35
  return false;
32
36
  }
33
37
 
38
+ /** Stable function-input for `[isReadOnly]`. Signal reads inside isReadOnly()
39
+ * overrides are tracked by the invoking template, so mode flips re-render. */
40
+ readonly isReadOnlyFn = (fieldName: string): boolean => this.isReadOnly(fieldName);
41
+
34
42
  /** Re-exported on the class so templates can call `titleCase(x)` without an extra import in the consumer. */
35
43
  titleCase(str: string): string { return titleCase(str); }
36
44
 
@@ -71,9 +79,8 @@ export abstract class BaseTable {
71
79
  }
72
80
 
73
81
  /**
74
- * Returns the table's registered TableAction list — populated by
75
- * keel's REST engine from the basis `table_action` table. Empty
76
- * when the table has no custom actions.
82
+ * The table's registered TableAction list (basis `table_action` rows).
83
+ * Templates use the memoized `tableActions` / `recordActions` instead.
77
84
  */
78
85
  getActions(recordSpecific?: boolean): TableAction[] {
79
86
  const tableDef = this.cacheService.getTableDefinition(this.tableName());
@@ -82,6 +89,24 @@ export abstract class BaseTable {
82
89
  return actions.filter((a) => a.recordSpecific === recordSpecific).sort(this.sortActions);
83
90
  }
84
91
 
92
+ // Memoized for templates; the reactive appdata cache retriggers these.
93
+ readonly tableActions = computed(() => this.getActions(false));
94
+ readonly recordActions = computed(() => this.getActions(true));
95
+
96
+ /** Metadata-driven visible columns, memoized. */
97
+ readonly metadataColumns = computed(() => this.getDisplayedColumns());
98
+
99
+ private readonly columnMap = computed(() => {
100
+ const map = new Map<string, TableColumn>();
101
+ for (const col of this.cacheService.getTableDefinition(this.tableName())?.Columns ?? []) {
102
+ map.set(col.PascalName, col);
103
+ }
104
+ return map;
105
+ });
106
+
107
+ private readonly keyColumns = computed(() =>
108
+ this.cacheService.getTableDefinition(this.tableName())?.Keys ?? []);
109
+
85
110
  private sortActions = (a: TableAction, b: TableAction) =>
86
111
  (a.displayOrder ?? 0) - (b.displayOrder ?? 0);
87
112
 
@@ -134,7 +159,7 @@ export abstract class BaseTable {
134
159
  },
135
160
  error: (err) => {
136
161
  console.error(`Action ${action.action} failed`, err);
137
- alert(err?.error?.detail ?? `Failed to ${action.caption}`);
162
+ alert(errorDetail(err, `Failed to ${action.caption}`));
138
163
  },
139
164
  });
140
165
  }
@@ -173,8 +198,29 @@ export abstract class BaseTable {
173
198
  return pk;
174
199
  }
175
200
 
176
- emptyRecord(): any {
177
- const record : any = {[this.config.opField]: OpCode.Insert}
201
+ /** @for track key: joined PK values; index when keys are missing/unset. */
202
+ trackByRecord(index: number, record: Record<string, unknown>): string | number {
203
+ const keys = this.keyColumns();
204
+ if (!keys.length) return index;
205
+ const values = keys.map((key) => record[key.PascalName]);
206
+ if (values.some((v) => v === undefined || v === null || v === '')) return index;
207
+ return values.join('_');
208
+ }
209
+
210
+ /** DefaultValue as a typed literal; undefined for expression defaults (`now()`, `nextval(...)`). */
211
+ private literalDefault(field: TableColumn): unknown {
212
+ const dv = field.DefaultValue;
213
+ if (field.DataType === 'boolean') return dv === 'true';
214
+ if (dv == null || dv === '' || dv.includes('(')) return undefined;
215
+ if (field.DataType === 'integer' || field.DataType === 'float') {
216
+ const n = Number(dv);
217
+ return isNaN(n) ? undefined : n;
218
+ }
219
+ return dv;
220
+ }
221
+
222
+ emptyRecord(): Record<string, unknown> {
223
+ const record: Record<string, unknown> = {[this.config.opField]: OpCode.Insert};
178
224
  const tableDef = this.cacheService.getTableDefinition(this.tableName());
179
225
  const tableOverride = this.config.tableOverrides?.[this.tableName()];
180
226
  const editableTimestamps = new Set(tableOverride?.editableTimestamps ?? []);
@@ -186,16 +232,15 @@ export abstract class BaseTable {
186
232
  return; // auto-generated by database sequence
187
233
  }
188
234
  // column_display_attribute modes (explicit, win over the
189
- // timestamp heuristic). H/R/U are server-managed — omit so the
235
+ // timestamp heuristic). H/S/R/U are server-managed — omit so the
190
236
  // DB default/keel stamp fills them. D = seed the column default.
191
- if (field.DisplayMode === 'H' || field.DisplayMode === 'R'
192
- || field.DisplayMode === 'U') {
237
+ if (field.DisplayMode === 'H' || field.DisplayMode === 'S'
238
+ || field.DisplayMode === 'R' || field.DisplayMode === 'U') {
193
239
  return;
194
240
  }
195
241
  if (field.DisplayMode === 'D') {
196
- record[field.PascalName] = field.DataType === 'boolean'
197
- ? field.DefaultValue === 'true'
198
- : (field.DefaultValue ?? '');
242
+ record[field.PascalName] = this.literalDefault(field)
243
+ ?? (field.DataType === 'boolean' ? false : '');
199
244
  return;
200
245
  }
201
246
  if (!field.DisplayMode && field.HasDefault && field.DataType === 'timestamp'
@@ -203,9 +248,9 @@ export abstract class BaseTable {
203
248
  return; // audit column — server-managed, don't include
204
249
  }
205
250
  if (field.HasDefault && field.DataType !== 'timestamp') {
206
- record[field.PascalName] = field.DataType === 'boolean'
207
- ? field.DefaultValue === 'true'
208
- : field.DefaultValue;
251
+ const dv = this.literalDefault(field);
252
+ record[field.PascalName] = dv !== undefined ? dv
253
+ : (field.DataType === 'boolean' ? false : '');
209
254
  } else {
210
255
  record[field.PascalName] = field.DataType === 'boolean' ? false : '';
211
256
  }
@@ -228,7 +273,7 @@ export abstract class BaseTable {
228
273
  return record;
229
274
  }
230
275
 
231
- getDisplayedColumns(records: any[] = []): string[] {
276
+ getDisplayedColumns(records: Record<string, unknown>[] = []): string[] {
232
277
  const tableDef = this.cacheService.getTableDefinition(this.tableName());
233
278
  const tableOverride = this.config.tableOverrides?.[this.tableName()];
234
279
  const hidden = new Set([
@@ -238,6 +283,8 @@ export abstract class BaseTable {
238
283
  const editableTimestamps = new Set(tableOverride?.editableTimestamps ?? []);
239
284
  if (tableDef && tableDef.Columns && tableDef.Columns.length > 0) {
240
285
  return tableDef.Columns
286
+ // column_display_attribute: 'H' hidden and 'S' secret never render.
287
+ .filter(col => col.DisplayMode !== 'H' && col.DisplayMode !== 'S')
241
288
  .filter(col => !(col.HasDefault && col.DataType === 'timestamp'
242
289
  && !editableTimestamps.has(col.PascalName)))
243
290
  .sort((a, b) => a.Order - b.Order)
@@ -249,15 +296,12 @@ export abstract class BaseTable {
249
296
  return [];
250
297
  }
251
298
 
299
+ /** O(1) via a map memoized per table/appdata — called several times per cell per CD. */
252
300
  getColumn(fieldName: string): TableColumn | undefined {
253
- const tableDef = this.cacheService.getTableDefinition(this.tableName());
254
- if (tableDef && tableDef.Columns) {
255
- return tableDef.Columns.find((field) => field.PascalName === fieldName);
256
- }
257
- return undefined;
301
+ return this.columnMap().get(fieldName);
258
302
  }
259
303
 
260
- displayValue(fieldName: string, rawValue: any): string {
304
+ displayValue(fieldName: string, rawValue: unknown): string {
261
305
  if (rawValue === null || rawValue === undefined || rawValue === '') return '';
262
306
  const col = this.getColumn(fieldName);
263
307
  if (!col) return String(rawValue);
@@ -275,20 +319,17 @@ export abstract class BaseTable {
275
319
  }
276
320
 
277
321
  /**
278
- * Temporal input kind for a field, or null if not temporal. Drives both the
279
- * load-format and save-restore so TIME/DATE columns aren't given the
280
- * datetime-local (seconds + 'Z') treatment a Postgres TIME/DATE rejects.
322
+ * Temporal input kind for a field, or null if not temporal (metadata-driven
323
+ * only — name-based guessing would corrupt text fields ending in "Time").
281
324
  */
282
325
  temporalKind(fieldName: string): 'time' | 'date' | 'datetime-local' | null {
283
326
  const col = this.getColumn(fieldName)
284
- if (col?.InputType === 'time') return 'time'
285
- if (col?.InputType === 'date') return 'date'
286
- if (col?.InputType === 'datetime-local') return 'datetime-local'
327
+ if (!col) return null
328
+ if (col.InputType === 'time') return 'time'
329
+ if (col.InputType === 'date') return 'date'
330
+ if (col.InputType === 'datetime-local') return 'datetime-local'
287
331
  // Fallback for column metadata predating keel's time/date split.
288
- if (col ? col.DataType === 'timestamp'
289
- : (fieldName.endsWith('Time') || fieldName.endsWith('time'))) {
290
- return 'datetime-local'
291
- }
332
+ if (col.DataType === 'timestamp') return 'datetime-local'
292
333
  return null
293
334
  }
294
335
 
@@ -320,43 +361,52 @@ export abstract class BaseTable {
320
361
  return false;
321
362
  }
322
363
 
323
- isArray(value: any): boolean {
364
+ isArray(value: unknown): boolean {
324
365
  return Array.isArray(value);
325
366
  }
326
367
 
368
+ /** Backend value → form-input value. datetime-local inputs hold LOCAL wall-clock
369
+ * time while keel timestamps are UTC ISO, so convert (don't slice). */
327
370
  formatTimeStamp(val: string, kind: 'time' | 'date' | 'datetime-local' = 'datetime-local'): string {
328
371
  if (val && typeof val === 'string') {
329
372
  if (kind === 'time') return val.slice(0, 5); // HH:MM
330
373
  if (kind === 'date') return val.slice(0, 10); // YYYY-MM-DD
331
- return val.slice(0, 16); // YYYY-MM-DDTHH:MM
374
+ // Bare timestamps (no zone) from pre-ISO backends are treated as UTC.
375
+ const iso = (val.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(val)) ? val : val + 'Z';
376
+ const d = new Date(iso);
377
+ if (isNaN(d.getTime())) return val.slice(0, 16);
378
+ const pad = (n: number) => String(n).padStart(2, '0');
379
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
332
380
  }
333
381
  return val;
334
382
  }
335
383
 
336
- formatRecordTimeStamp(record: any) {
384
+ formatRecordTimeStamp(record: Record<string, unknown>) {
337
385
  if (record) {
338
386
  for (const key of Object.keys(record)) {
339
387
  const kind = this.temporalKind(key);
340
388
  if (kind) {
341
- record[key] = this.formatTimeStamp(record[key], kind);
389
+ record[key] = this.formatTimeStamp(record[key] as string, kind);
342
390
  }
343
391
  }
344
392
  }
345
393
  }
346
394
 
347
- restoreTimeStamp(val: any, kind: 'time' | 'date' | 'datetime-local' = 'datetime-local'): any {
395
+ /** Form-input value → backend wire value (local wall-clock → UTC ISO for datetime-local). */
396
+ restoreTimeStamp(val: unknown, kind: 'time' | 'date' | 'datetime-local' = 'datetime-local'): unknown {
348
397
  // PostgreSQL can't cast '' to a temporal type — normalize empty to null.
349
398
  if (val === '' || val === undefined || val === null) return null;
350
- // TIME/DATE values are sent verbatim ('08:45', '2026-06-15'); only
351
- // datetime-local needs the seconds + UTC marker the picker omits.
399
+ // TIME/DATE values are sent verbatim ('08:45', '2026-06-15').
352
400
  if (kind === 'time' || kind === 'date') return val;
353
401
  if (typeof val === 'string' && !val.endsWith('Z')) {
354
- return val + ':00Z';
402
+ const d = new Date(val); // no zone designator → parsed as LOCAL time
403
+ if (isNaN(d.getTime())) return val;
404
+ return d.toISOString();
355
405
  }
356
406
  return val;
357
407
  }
358
408
 
359
- restoreRecordTimeStamp(record: any) {
409
+ restoreRecordTimeStamp(record: Record<string, unknown>) {
360
410
  if (record) {
361
411
  for (const key of Object.keys(record)) {
362
412
  const kind = this.temporalKind(key);
@@ -367,7 +417,7 @@ export abstract class BaseTable {
367
417
  }
368
418
  }
369
419
 
370
- readyToSave(record: any, original?: any) {
420
+ readyToSave(record: Record<string, unknown>, original?: Record<string, unknown> | null) {
371
421
  this.restoreRecordTimeStamp(record);
372
422
  const op = record[this.config.opField];
373
423
  if (op === OpCode.Insert || op === OpCode.Delete) return; // already marked
@@ -385,20 +435,33 @@ export abstract class BaseTable {
385
435
  record[this.config.opField] = OpCode.Update;
386
436
  }
387
437
 
388
- /** Shallow compare of own scalar fields, ignoring child arrays and op_code. */
389
- isRecordDirty(current: any, original: any): boolean {
438
+ /** Equality tolerant of form coercion: number-vs-string by text, ISO timestamps by instant. */
439
+ private valuesEqual(a: unknown, b: unknown): boolean {
440
+ if (a === b) return true;
441
+ if (a == null || b == null) return false; // one side set, the other not
442
+ if (typeof a === 'number' || typeof b === 'number') return String(a) === String(b);
443
+ if (typeof a === 'string' && typeof b === 'string'
444
+ && ISO_PREFIX.test(a) && ISO_PREFIX.test(b)) {
445
+ const ta = Date.parse(a), tb = Date.parse(b);
446
+ if (!isNaN(ta) && !isNaN(tb)) return ta === tb;
447
+ }
448
+ return false;
449
+ }
450
+
451
+ /** Shallow compare of scalar fields over the union of keys; child arrays and op_code ignored. */
452
+ isRecordDirty(current: Record<string, unknown>, original: Record<string, unknown>): boolean {
390
453
  const opField = this.config.opField;
391
- for (const key of Object.keys(current)) {
454
+ const keys = new Set([...Object.keys(current), ...Object.keys(original)]);
455
+ for (const key of keys) {
392
456
  if (key === opField) continue;
393
- const curVal = current[key];
394
457
  // Ignore child-relation arrays; those have their own op_code per row.
395
- if (Array.isArray(curVal)) continue;
396
- if (curVal !== original[key]) return true;
458
+ if (Array.isArray(current[key]) || Array.isArray(original[key])) continue;
459
+ if (!this.valuesEqual(current[key], original[key])) return true;
397
460
  }
398
461
  return false;
399
462
  }
400
463
 
401
- getDialogData(record: any, isNew: boolean, readOnlyColumns: string[] = []): any {
464
+ getDialogData(record: Record<string, unknown>, isNew: boolean, readOnlyColumns: string[] = []): Record<string, unknown> {
402
465
  return {record, tableName: this.tableName(), isNew, readOnlyColumns};
403
466
  }
404
467
 
@@ -423,14 +486,14 @@ export abstract class BaseTable {
423
486
  return undefined;
424
487
  }
425
488
 
426
- getKeyFilters(record: any): {[key: string]: string} | undefined {
489
+ getKeyFilters(record: Record<string, unknown>): {[key: string]: string} | undefined {
427
490
  const tableDef = this.cacheService.getTableDefinition(this.tableName());
428
491
  if (!tableDef || !tableDef.Keys || tableDef.Keys.length === 0) {
429
492
  return undefined;
430
493
  }
431
494
  const filters: {[key: string]: string} = {};
432
495
  for (const key of tableDef.Keys) {
433
- filters[key.PascalName] = record[key.PascalName];
496
+ filters[key.PascalName] = String(record[key.PascalName] ?? '');
434
497
  }
435
498
  return filters;
436
499
  }
@@ -445,25 +508,35 @@ export abstract class BaseTable {
445
508
  return {'fk': fk, 'parent': parent};
446
509
  }
447
510
 
448
- initializeForeignKeys(record: any, parentTable: string, parentRecord: any) {
511
+ /** Seed FK columns from the parent's keys; returns the seeded (read-only) column names. */
512
+ initializeForeignKeys(record: Record<string, unknown>, parentTable: string, parentRecord: Record<string, unknown>): string[] {
449
513
  const readOnlyColumns: string[] = [];
450
514
  const fkData = this.getForeignKeyConfig(parentTable);
451
515
  if (fkData && fkData.fk && fkData.parent) {
516
+ if (fkData.fk.Columns.length !== fkData.parent.Keys.length) {
517
+ console.error(`sail: FK ${fkData.fk.ConstraintName} has ${fkData.fk.Columns.length} columns but parent ${parentTable} has ${fkData.parent.Keys.length} keys; skipping FK seed`);
518
+ return readOnlyColumns;
519
+ }
452
520
  fkData.fk.Columns.forEach((column, index) => {
453
521
  record[column.PascalName] = parentRecord[fkData.parent.Keys[index].PascalName];
454
522
  readOnlyColumns.push(column.PascalName);
455
523
  });
456
524
  }
525
+ return readOnlyColumns;
457
526
  }
458
527
 
459
- buildSearchTerms(record: {[key: string]: any}): {[key: string]: string} {
460
- const hiddenSet = new Set(this.config.hiddenFields);
528
+ buildSearchTerms(record: {[key: string]: unknown}): {[key: string]: string} {
529
+ const tableOverride = this.config.tableOverrides?.[this.tableName()];
530
+ const hiddenSet = new Set([
531
+ ...this.config.hiddenFields,
532
+ ...(tableOverride?.hiddenFields ?? []),
533
+ ]);
461
534
  const searchTerms: {[key: string]: string} = {};
462
535
  for (const key of Object.keys(record)) {
463
536
  if (hiddenSet.has(key)) continue;
464
537
  const val = record[key];
465
538
  if (val !== '' && val !== null && val !== undefined && !Array.isArray(val)) {
466
- searchTerms[key] = val;
539
+ searchTerms[key] = String(val);
467
540
  }
468
541
  }
469
542
  return searchTerms;
@@ -1,39 +1,49 @@
1
- import { Directive, inject, signal, WritableSignal } from "@angular/core";
1
+ import { computed, Directive, inject, signal, Type, WritableSignal } from "@angular/core";
2
2
  import { BaseTable } from "./base_table";
3
3
  import { MatDialog } from "@angular/material/dialog";
4
4
 
5
+ /** Read-only listing surface; complete as-is (TableLookup). Editing views extend AbstractEditableView. */
5
6
  @Directive()
6
7
  export abstract class BaseView extends BaseTable {
7
- protected readonly dialog = inject(MatDialog);
8
8
  readonly apiName: WritableSignal<string> = signal('');
9
- readonly dialogComponent: WritableSignal<any> = signal(undefined);
10
- records: Array<{[key: string]: any}> = [];
11
- displayedColumns: string[] = [];
12
- searchTerms: {[key: string]: string} = {};
13
- protected hasLinkPermission = false;
9
+ readonly records = signal<Record<string, unknown>[]>([]);
10
+ readonly displayedColumns = signal<string[]>([]);
11
+ readonly searchTerms = signal<{[key: string]: string}>({});
12
+ /** Last load/save failure shown by the view's template ('' when none). */
13
+ readonly viewError = signal('');
14
14
 
15
- protected abstract handleDelete(record: {[key: string]: any}): void;
15
+ protected readonly hasLinkPermission = computed(() => this.canUpdate() || this.canRead());
16
16
 
17
- protected abstract handleUpdate(result: {[key: string]: any}, isNew: boolean, original: {[key: string]: any}): void;
17
+ /** Whether an "actions" column is appended for privileged users; lookup views return false. */
18
+ protected includeActionsColumn(): boolean {
19
+ return true;
20
+ }
18
21
 
19
- updateDisplayedColumns(record: Array<{[key: string]: any}>) {
20
- this.displayedColumns = this.getDisplayedColumns(record);
21
- if (this.displayedColumns.length > 0 &&
22
- !this.displayedColumns.includes('actions') &&
22
+ updateDisplayedColumns(records: Record<string, unknown>[]) {
23
+ const columns = this.getDisplayedColumns(records);
24
+ if (columns.length > 0 &&
25
+ this.includeActionsColumn() &&
26
+ !columns.includes('actions') &&
23
27
  (this.canUpdate() || this.canDelete())
24
- ) this.displayedColumns.push('actions');
25
- this.hasLinkPermission = this.canUpdate() || this.canRead();
28
+ ) columns.push('actions');
29
+ this.displayedColumns.set(columns);
26
30
  }
27
31
 
28
32
  isLinkColumn(column: string): boolean {
29
- return (this.displayedColumns.length > 0 && this.displayedColumns[0] === column && this.hasLinkPermission);
33
+ const columns = this.displayedColumns();
34
+ return (columns.length > 0 && columns[0] === column && this.hasLinkPermission());
30
35
  }
36
+ }
31
37
 
32
- trackByRecord(index: number, record: {[key: string]: any}): any {
33
- const tableDef = this.cacheService.getTableDefinition(this.tableName());
34
- if (!tableDef || !tableDef.Keys || tableDef.Keys.length === 0) return index;
35
- return tableDef.Keys.map((key) => record[key.PascalName]).join('_');
36
- }
38
+ /** Listing surface with dialog-based create/edit/delete; subclasses implement persistence. */
39
+ @Directive()
40
+ export abstract class AbstractEditableView extends BaseView {
41
+ protected readonly dialog = inject(MatDialog);
42
+ readonly dialogComponent: WritableSignal<Type<unknown> | undefined> = signal(undefined);
43
+
44
+ protected abstract handleDelete(record: Record<string, unknown>): void;
45
+
46
+ protected abstract handleUpdate(result: Record<string, unknown>, isNew: boolean, original: Record<string, unknown>): void;
37
47
 
38
48
  /**
39
49
  * Shared row-dialog opener. Builds the dialog data and wires the result
@@ -41,12 +51,17 @@ export abstract class BaseView extends BaseTable {
41
51
  * `addRecord()` (new row + empty `original`) and `editRecord()` (existing
42
52
  * row passed through verbatim).
43
53
  */
44
- private openRecordDialog(record: {[key: string]: any}, isNew: boolean): void {
54
+ private openRecordDialog(record: Record<string, unknown>, isNew: boolean): void {
55
+ const component = this.dialogComponent();
56
+ if (!component) {
57
+ console.error('sail: no dialogComponent configured for', this.tableName());
58
+ return;
59
+ }
45
60
  const dialogData = this.getDialogData(record, isNew);
46
61
  const apiName = this.apiName();
47
62
  if (apiName) dialogData['apiName'] = apiName;
48
63
 
49
- this.dialog.open(this.dialogComponent(), {
64
+ this.dialog.open(component, {
50
65
  width: '90vw',
51
66
  maxWidth: '1200px',
52
67
  maxHeight: '90vh',
@@ -62,8 +77,9 @@ export abstract class BaseView extends BaseTable {
62
77
  const record = this.emptyRecord();
63
78
  // Seed columns from an existing row when emptyRecord() returned just the
64
79
  // op-code field (table metadata wasn't loaded yet).
65
- if (Object.keys(record).length === 1 && this.records.length > 0) {
66
- Object.keys(this.records[0]).forEach((key) => { record[key] = ''; });
80
+ const rows = this.records();
81
+ if (Object.keys(record).length === 1 && rows.length > 0) {
82
+ Object.keys(rows[0]).forEach((key) => { record[key] = ''; });
67
83
  }
68
84
  this.openRecordDialog(record, true);
69
85
  }
@@ -75,12 +91,12 @@ export abstract class BaseView extends BaseTable {
75
91
  * buttons are gated by the same permissions.
76
92
  * Block only when neither read nor update is granted.
77
93
  */
78
- editRecord(record: {[key: string]: any}) {
94
+ editRecord(record: Record<string, unknown>) {
79
95
  if (!this.canUpdate() && !this.requireAuth(() => this.canRead(), 'view')) return;
80
96
  this.openRecordDialog(record, false);
81
97
  }
82
98
 
83
- deleteRecord(record: {[key: string]: any}) {
99
+ deleteRecord(record: Record<string, unknown>) {
84
100
  if (!this.requireAuth(() => this.canDelete(), 'delete')) return;
85
101
  this.handleDelete(record);
86
102
  }
@@ -96,7 +96,7 @@ export class ProfileEditorComponent implements OnInit {
96
96
  this.loading.set(true);
97
97
  this.emailErr.set('');
98
98
  this.auth.confirmEmailChange(this.newEmail.value!, Number(this.emailCode.value)).subscribe({
99
- next: () => { this.loading.set(false); this.email.set(this.newEmail.value!); this.cancelEmailChange(); this.profileMsg.set('Email updated.'); },
99
+ next: () => { this.loading.set(false); this.email.set(this.newEmail.value!); this.cancelEmailChange(); this.emailMsg.set('Email updated.'); },
100
100
  error: (e) => { this.loading.set(false); this.emailErr.set(this.msg(e, 'Invalid or expired code.')); },
101
101
  });
102
102
  }
@@ -128,7 +128,7 @@ export class ProfileEditorComponent implements OnInit {
128
128
  this.loading.set(true);
129
129
  this.phoneErr.set('');
130
130
  this.auth.confirmPhoneChange(this.newPhone.value!, Number(this.phoneCode.value)).subscribe({
131
- next: () => { this.loading.set(false); this.phone.set(this.newPhone.value!); this.cancelPhoneChange(); this.profileMsg.set('Phone updated.'); },
131
+ next: () => { this.loading.set(false); this.phone.set(this.newPhone.value!); this.cancelPhoneChange(); this.phoneMsg.set('Phone updated.'); },
132
132
  error: (e) => { this.loading.set(false); this.phoneErr.set(this.msg(e, 'Invalid or expired code.')); },
133
133
  });
134
134
  }