tin-spa 20.14.113 → 20.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/core/index.d.ts +80 -10
  2. package/fesm2022/{tin-spa-accounting.module-Cg4vWaJE.mjs → tin-spa-accounting.module-CamqAeW_.mjs} +71 -83
  3. package/fesm2022/tin-spa-accounting.module-CamqAeW_.mjs.map +1 -0
  4. package/fesm2022/{tin-spa-agent-page.component-DJvMPjCQ.mjs → tin-spa-agent-page.component-DTYn54p0.mjs} +2 -2
  5. package/fesm2022/{tin-spa-agent-page.component-DJvMPjCQ.mjs.map → tin-spa-agent-page.component-DTYn54p0.mjs.map} +1 -1
  6. package/fesm2022/{tin-spa-agent.component-D0CGGnGZ.mjs → tin-spa-agent.component-CZKMWl3r.mjs} +2 -2
  7. package/fesm2022/{tin-spa-agent.component-D0CGGnGZ.mjs.map → tin-spa-agent.component-CZKMWl3r.mjs.map} +1 -1
  8. package/fesm2022/{tin-spa-assets.module-qIWNwJ2E.mjs → tin-spa-assets.module-BcOjlhfm.mjs} +16 -11
  9. package/fesm2022/tin-spa-assets.module-BcOjlhfm.mjs.map +1 -0
  10. package/fesm2022/tin-spa-core.mjs +426 -112
  11. package/fesm2022/tin-spa-core.mjs.map +1 -1
  12. package/fesm2022/{tin-spa-general-erp.module-CoYxceLj.mjs → tin-spa-general-erp.module-b6SjnWUM.mjs} +2 -2
  13. package/fesm2022/{tin-spa-general-erp.module-CoYxceLj.mjs.map → tin-spa-general-erp.module-b6SjnWUM.mjs.map} +1 -1
  14. package/fesm2022/{tin-spa-hr.module-CKPZ9ZOj.mjs → tin-spa-hr.module-DkGVK2lX.mjs} +2 -2
  15. package/fesm2022/{tin-spa-hr.module-CKPZ9ZOj.mjs.map → tin-spa-hr.module-DkGVK2lX.mjs.map} +1 -1
  16. package/fesm2022/tin-spa-http.mjs +32 -1
  17. package/fesm2022/tin-spa-http.mjs.map +1 -1
  18. package/fesm2022/{tin-spa-inventory.module-DRKAcNau.mjs → tin-spa-inventory.module-CYWBqSCd.mjs} +16 -11
  19. package/fesm2022/tin-spa-inventory.module-CYWBqSCd.mjs.map +1 -0
  20. package/fesm2022/{tin-spa-loans.module-BTDmgrWo.mjs → tin-spa-loans.module-BDh-T-Ng.mjs} +2 -2
  21. package/fesm2022/{tin-spa-loans.module-BTDmgrWo.mjs.map → tin-spa-loans.module-BDh-T-Ng.mjs.map} +1 -1
  22. package/fesm2022/{tin-spa-manufacturing.module-CHta6oV_.mjs → tin-spa-manufacturing.module-9Ti-BFb2.mjs} +2 -2
  23. package/fesm2022/{tin-spa-manufacturing.module-CHta6oV_.mjs.map → tin-spa-manufacturing.module-9Ti-BFb2.mjs.map} +1 -1
  24. package/fesm2022/{tin-spa-payroll.module-CZ_GO1jF.mjs → tin-spa-payroll.module-54Efumv_.mjs} +9 -6
  25. package/fesm2022/{tin-spa-payroll.module-CZ_GO1jF.mjs.map → tin-spa-payroll.module-54Efumv_.mjs.map} +1 -1
  26. package/fesm2022/{tin-spa-purchasing.module-NCdwquaF.mjs → tin-spa-purchasing.module-Bq8JnsyM.mjs} +16 -11
  27. package/fesm2022/tin-spa-purchasing.module-Bq8JnsyM.mjs.map +1 -0
  28. package/fesm2022/{tin-spa-sales.module-D0uEGJW7.mjs → tin-spa-sales.module-nyrJulzD.mjs} +40 -17
  29. package/fesm2022/tin-spa-sales.module-nyrJulzD.mjs.map +1 -0
  30. package/fesm2022/{tin-spa-tenancy-erp.module-GwIv8Wbc.mjs → tin-spa-tenancy-erp.module-DGATA-p_.mjs} +2 -2
  31. package/fesm2022/{tin-spa-tenancy-erp.module-GwIv8Wbc.mjs.map → tin-spa-tenancy-erp.module-DGATA-p_.mjs.map} +1 -1
  32. package/fesm2022/{tin-spa-tin-spa-Cqe5hYTr.mjs → tin-spa-tin-spa-BMy59BVn.mjs} +1428 -219
  33. package/fesm2022/tin-spa-tin-spa-BMy59BVn.mjs.map +1 -0
  34. package/fesm2022/tin-spa.mjs +1 -1
  35. package/http/index.d.ts +3 -1
  36. package/index.d.ts +144 -24
  37. package/package.json +1 -1
  38. package/src/tin-styles.css +27 -0
  39. package/fesm2022/tin-spa-accounting.module-Cg4vWaJE.mjs.map +0 -1
  40. package/fesm2022/tin-spa-assets.module-qIWNwJ2E.mjs.map +0 -1
  41. package/fesm2022/tin-spa-inventory.module-DRKAcNau.mjs.map +0 -1
  42. package/fesm2022/tin-spa-purchasing.module-NCdwquaF.mjs.map +0 -1
  43. package/fesm2022/tin-spa-sales.module-D0uEGJW7.mjs.map +0 -1
  44. package/fesm2022/tin-spa-tin-spa-Cqe5hYTr.mjs.map +0 -1
@@ -1,6 +1,6 @@
1
1
  import { AUTH_TOKEN_KEY, AUTH_REFRESH_TOKEN_KEY } from 'tin-spa/keys';
2
2
  import * as i0 from '@angular/core';
3
- import { Injectable, NgModule, inject, Component, InjectionToken, makeEnvironmentProviders, Optional, Inject, Injector, EventEmitter, Output, Input, ENVIRONMENT_INITIALIZER } from '@angular/core';
3
+ import { Injectable, NgModule, inject, Component, InjectionToken, makeEnvironmentProviders, Optional, Inject, Injector, EventEmitter, Output, Input, ViewChild, ENVIRONMENT_INITIALIZER } from '@angular/core';
4
4
  import * as i1$2 from '@angular/material/dialog';
5
5
  import { MatDialogModule, MAT_DIALOG_DEFAULT_OPTIONS, MatDialogRef, MAT_DIALOG_DATA, MatDialog } from '@angular/material/dialog';
6
6
  import * as i1 from '@angular/common/http';
@@ -48,10 +48,8 @@ import { MatTooltipModule } from '@angular/material/tooltip';
48
48
  import { MatSidenavModule } from '@angular/material/sidenav';
49
49
  import { MatToolbarModule } from '@angular/material/toolbar';
50
50
  import { MatExpansionModule } from '@angular/material/expansion';
51
- import { MSAL_INSTANCE, MsalService } from '@azure/msal-angular';
52
- import { PublicClientApplication, BrowserCacheLocation } from '@azure/msal-browser';
51
+ import { setTinFallbackTokenAttacher, createTinTokenAttacher } from 'tin-spa/http';
53
52
  import * as i2$2 from '@abacritt/angularx-social-login';
54
- import { setTinFallbackTokenAttacher } from 'tin-spa/http';
55
53
 
56
54
  // Added (NFD-1): resolves a Field.min / Field.max bound that is expressed as a DYNAMIC TOKEN rather than a
57
55
  // literal date, so a config can say `max: 'today'` and mean "today at the moment the control is rendered",
@@ -237,6 +235,13 @@ class Core {
237
235
  if (!security) {
238
236
  return true;
239
237
  }
238
+ // Added (A-0902-71): fail CLOSED on an absent role. Both branches below dereference `currentRole`, so a
239
+ // null/undefined role THREW out of an authorization check instead of denying — the worst failure mode there
240
+ // is. The `!security` early return above stays first and is untouched: a surface that declares no security
241
+ // config is not gated at all and must keep rendering while the role is still in flight.
242
+ if (!currentRole) {
243
+ return false;
244
+ }
240
245
  if (security.deny?.some(role => currentRole[role.name])) {
241
246
  return false;
242
247
  }
@@ -324,6 +329,96 @@ class Core {
324
329
  return false;
325
330
  return this.resolveVisibility(item.visible, data) !== false;
326
331
  }
332
+ // ---------------------------------------------------------------------------
333
+ // Added (spa-charts-empty-state): the ONE emptiness rule for charts, kept here rather than in
334
+ // charts.component so spa-dashboard can ask the same question without reaching into a child component.
335
+ //
336
+ // A chart is EMPTY when nothing would draw: no datasets, or no dataset carries a finite non-zero value.
337
+ // All-zero counts as empty deliberately — a bar grid of six zero months and a line at -1..1 are the same
338
+ // blank slab to a reader as a chart with no series at all, and both are what the Piglet Health dashboard
339
+ // showed on a farm with no deaths.
340
+ // ---------------------------------------------------------------------------
341
+ static isChartDataEmpty(chartData) {
342
+ const sets = chartData?.datasets;
343
+ if (!Array.isArray(sets) || sets.length === 0)
344
+ return true;
345
+ return !sets.some((ds) => Array.isArray(ds?.data) && ds.data.some((v) => this.chartPointValue(v) !== 0));
346
+ }
347
+ // Chart.js accepts a bare number or a {x,y}/{y} point; anything else (null, undefined, NaN) is "no value"
348
+ static chartPointValue(v) {
349
+ const n = typeof v === 'number' ? v : (v && typeof v === 'object' ? Number(v.y) : NaN);
350
+ return Number.isFinite(n) ? n : 0;
351
+ }
352
+ // A GAUGE is never empty — gaugeValue 0 is a real, deliberate "0%" reading, not missing data
353
+ static isChartEmpty(chart, data) {
354
+ if (!chart)
355
+ return true;
356
+ if (chart.gaugeValue != null)
357
+ return false;
358
+ return this.isChartDataEmpty(data?.[chart.name] ?? chart.data); // same resolution order charts.component.buildChartData uses
359
+ }
360
+ // Does this chart put a CARD on the page at all? Visibility first, then the opt-in empty-hide.
361
+ // hideWhenEmpty is read chart-first, then grid; absent on both it is FALSE, so the default is the
362
+ // in-card empty state and no untracked consumer loses a card it renders today.
363
+ static isChartRendered(chart, config, data) {
364
+ if (!this.isItemVisible(chart, data))
365
+ return false;
366
+ const hide = chart?.hideWhenEmpty ?? config?.hideWhenEmpty ?? false;
367
+ return !(hide && this.isChartEmpty(chart, data));
368
+ }
369
+ // ---------------------------------------------------------------------------
370
+ // Added (spa-charts-empty-state, tiles twin): the SAME emptiness rule applied to a chart TILE.
371
+ //
372
+ // A chart tile is the small sibling of a chart card, and before this it had the identical defect:
373
+ // tiles.component.html guarded only the CANVAS, so a tile with no series drew its label and then a
374
+ // labelled empty box. There is deliberately NO second rule here — the resolution differs (a tile names
375
+ // its series with `dataField` and may carry a PLAIN ARRAY, which the tile builder wraps into one
376
+ // dataset) but the verdict is always isChartDataEmpty's.
377
+ //
378
+ // Two exemptions, both matching the chart's: a GAUGE is never empty (a 0% reading is a reading, whether
379
+ // it comes from a static gaugeValue or from a numeric dataField feeding one), and a NON-chart tile is
380
+ // never empty at all — no ordinary figure tile can be removed by this rule.
381
+ // ---------------------------------------------------------------------------
382
+ static isTileChartEmpty(tile, data) {
383
+ const chart = tile?.chart;
384
+ if (!chart)
385
+ return false; // not a chart tile — this rule has nothing to say about it
386
+ if (chart.gaugeValue != null)
387
+ return false;
388
+ const series = (chart.dataField ? data?.[chart.dataField] : null) ?? chart.data; // same order buildTileMiniChartData resolves in
389
+ if (typeof series === 'number')
390
+ return false; // a doughnut gauge fed by dataField: 0 is a real reading
391
+ return this.isChartDataEmpty(Array.isArray(series) ? { datasets: [{ data: series }] } : series); // a plain array is wrapped exactly as the tile builder wraps it
392
+ }
393
+ // Does this tile put a CARD in the row at all? Visibility first, then the opt-in empty-hide — the same
394
+ // shape, the same precedence and the same flag NAMES as isChartRendered, so a consumer configuring a
395
+ // tile and a chart side by side writes the same thing twice.
396
+ static isTileRendered(tile, config, data) {
397
+ if (!this.isItemVisible(tile, data))
398
+ return false;
399
+ const hide = tile?.hideWhenEmpty ?? config?.hideWhenEmpty ?? false;
400
+ return !(hide && this.isTileChartEmpty(tile, data));
401
+ }
402
+ // ---------------------------------------------------------------------------
403
+ // Added (spa-charts-empty-state, dangling heading): the capability gate for a LIST of config items,
404
+ // lifted verbatim out of tiles.component.applySecurity and charts.component.applySecurity so that
405
+ // spa-dashboard can ask "would this widget draw anything?" without a second copy of the rule drifting
406
+ // from the two it must agree with. Both components now call this; their behaviour is unchanged.
407
+ //
408
+ // STRICTLY OPT-IN, and the opt-in test stays explicit: unless the container declares `security` or some
409
+ // item declares `securityConfig`, the list is returned VERBATIM (same array reference) and denied is
410
+ // false — so an unresolved role can never blank a page that declared nothing, which is the guarantee
411
+ // owed to the untracked external consumers of this library.
412
+ // ---------------------------------------------------------------------------
413
+ static gatedItems(items, security, role) {
414
+ const declares = !!security || (items ?? []).some(i => !!i.securityConfig);
415
+ if (!declares)
416
+ return { items: items, denied: false };
417
+ if (!this.checkSecurity(security, role))
418
+ return { items: [], denied: true }; // the whole container refused
419
+ const kept = (items ?? []).filter(i => this.checkSecurity(i.securityConfig, role));
420
+ return { items: kept, denied: kept.length === 0 };
421
+ }
327
422
  static testVisible(config, data, field, currentRole) {
328
423
  // First check form level security if exists
329
424
  if (config.security && !this.hasFormAccess(config, currentRole)) {
@@ -442,6 +537,32 @@ class Core {
442
537
  });
443
538
  return payload;
444
539
  }
540
+ // Added (CFW-1 6b): the DISPLAY half of serverOwned, and the counterpart to buildSubmitPayload above.
541
+ // buildSubmitPayload omits a server-owned key from the WIRE but deliberately leaves the bound model intact, so
542
+ // that model keeps the value it was loaded with. Hand it back to the grid after a save and the operator is
543
+ // shown the stale figure the server has just recomputed and discarded. Measured as the phase-6c kill-sheet
544
+ // symptom: a saved carcass line still read `0.00` for lineAmount — and 0.00 is exactly how a CONDEMNED carcass
545
+ // reads, so the one wrong value on offer was the one that means "this pig paid nothing".
546
+ //
547
+ // A COPY with the key OMITTED, never null and never zero, for the same reason buildSubmitPayload omits: an
548
+ // absent key leaves the grid's existing cell alone, while a null would paint an empty cell over a correct one.
549
+ // Call this ONLY when there is no server body to adopt — when the response carried one, the model IS the
550
+ // server's truth and stripping would throw away the very value that makes the screen right.
551
+ static stripServerOwned(config, data) {
552
+ if (!data || !config?.fields)
553
+ return data;
554
+ const stripped = { ...data }; // shallow, matching buildSubmitPayload — the model is plain JSON
555
+ const omit = (field) => {
556
+ if (!field?.name || !field.serverOwned)
557
+ return;
558
+ delete stripped[field.name];
559
+ };
560
+ config.fields.forEach(field => {
561
+ omit(field);
562
+ field.subfields?.forEach(subfield => omit(subfield)); // a composite's subfields carry their own keys
563
+ });
564
+ return stripped;
565
+ }
445
566
  static testReadOnly(config, data, field) {
446
567
  // Added (CFW-1): serverOwned outranks EVERY mode, create included. `readonly` cannot express this — the
447
568
  // create branch below returns false for every field, so a readonly server-owned figure was fully editable
@@ -511,6 +632,12 @@ class Core {
511
632
  ;
512
633
  return '';
513
634
  }
635
+ // Added (PRF-1): a resolved date bound, written the way the user would have to pick it. Same format and same
636
+ // vocabulary as date.component.describeBound, so the submit gate and the field's own error cannot describe the
637
+ // same rule two different ways.
638
+ static describeDateBound(d) {
639
+ return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
640
+ }
514
641
  // Changed: the per-field checks, lifted out of validateObject unchanged so the loop can report the failing field
515
642
  static validateField(field, data) {
516
643
  if (field.type == 'file' || field.type == 'file-view' || field.type == 'blank' || field.type == 'section')
@@ -546,10 +673,24 @@ class Core {
546
673
  // to them would change nothing but is not this chunk's to prove.
547
674
  if ((field.type == 'date') && d === '')
548
675
  return `Please select ${name}`;
549
- if (field.min && d < field.min)
550
- return `Minimum ${name} is ${field.min}`;
551
- if (field.max && d > field.max)
552
- return `Maximum ${name} is ${field.max}`;
676
+ // Changed (PRF-1): a DATE bound is resolved through DateBounds before it is compared, exactly as the
677
+ // date control itself resolves it (date.component.refreshBounds). Field.min/max on a date may be a
678
+ // token ('today', 'today-28') or a FUNCTION evaluated against the live row — a documented part of the
679
+ // Field API since SBD-1 — and this gate compared the raw value against the raw bound. A function bound
680
+ // was therefore coerced to its own SOURCE TEXT, and '2026-09-03' > '(x) => {' is true, so EVERY submit
681
+ // was refused with the function body printed back at the operator while the picker itself showed no
682
+ // error. Measured on piglet's Record Farrowing dialog, whose farrowingDate carries function bounds:
683
+ // 0 mat-error, button enabled, no POST, toast reading "Maximum Farrowing Date is (x) => {...".
684
+ // A resolved bound of null means "no bound", so a literal date, an absent bound and an unparseable one
685
+ // all behave as before. number and money keep the literal comparison — correct for them, and untouched.
686
+ // Changed (TDB-1): the date branch moved OUT of `if (isRequired)` — see the checkDateBounds call below.
687
+ // number and money keep the literal comparison here, unchanged and still required-only.
688
+ if (field.type != 'date') {
689
+ if (field.min && d < field.min)
690
+ return `Minimum ${name} is ${field.min}`;
691
+ if (field.max && d > field.max)
692
+ return `Maximum ${name} is ${field.max}`;
693
+ }
553
694
  }
554
695
  else if (field.type == 'datetime') {
555
696
  if (d === '')
@@ -580,6 +721,21 @@ class Core {
580
721
  return `Please enter ${name}`;
581
722
  }
582
723
  }
724
+ // Added (TDB-1): a date's min/max is now enforced whenever the field HOLDS A VALUE, required or not. PRF-1
725
+ // fixed how a bound is resolved but left the check inside `if (isRequired)`, so an OPTIONAL date could carry
726
+ // any value at all and the submit gate never looked — while the picker beside it greyed the same days out.
727
+ // Two live examples: piglet's dateOfBirth (piggery.service.ts:1047 and :1511), both non-required and both
728
+ // carrying function bounds. An empty optional date still passes: DateBounds.resolve returns null for
729
+ // null/undefined/'' so `value` is falsy and both comparisons are skipped, which is what "optional" means.
730
+ if (field.type == 'date') {
731
+ const value = DateBounds.resolve(d, 'start');
732
+ const floor = DateBounds.resolve(field.min, 'start', data);
733
+ const ceiling = DateBounds.resolve(field.max, 'end', data);
734
+ if (value && floor && value < floor)
735
+ return `Earliest ${name} is ${this.describeDateBound(floor)}`;
736
+ if (value && ceiling && value > ceiling)
737
+ return `Latest ${name} is ${this.describeDateBound(ceiling)}`;
738
+ }
583
739
  if (field.type == 'text' && field.regex && d && d.toString().trim() !== '') {
584
740
  const regexPattern = new RegExp(field.regex.pattern);
585
741
  if (!regexPattern.test(d)) {
@@ -1536,6 +1692,8 @@ class TileConfig {
1536
1692
  // Changed: Added chart config container class
1537
1693
  class ChartConfig {
1538
1694
  }
1695
+ class DashboardConfig {
1696
+ }
1539
1697
  class Step {
1540
1698
  }
1541
1699
  class StepConfig {
@@ -1827,7 +1985,10 @@ class MessageService {
1827
1985
  }
1828
1986
  // Added: warning — for something the user should look at but which is not a failure. Distinct from error so
1829
1987
  // a recoverable situation is not dressed up in the same red as a broken one.
1830
- warning(details, subject) {
1988
+ // Changed: argument order is now (subject, details), matching errorWithSubject/infoWithSubject instead of
1989
+ // contradicting them. Safe to reorder rather than deprecate because the only caller in the estate is the
1990
+ // message gallery — swept across ng-space and all five consumer apps (src + e2e) before the change.
1991
+ warning(subject, details) {
1831
1992
  let type = "warning";
1832
1993
  const dialogRef = this.dialog.open(messageDialog, {
1833
1994
  width: "600px",
@@ -1835,7 +1996,9 @@ class MessageService {
1835
1996
  });
1836
1997
  }
1837
1998
  // Added: success — completes the set, so a confirmation does not have to borrow the 'info' styling.
1838
- success(details, subject) {
1999
+ // Changed: argument order is now (subject, details), matching errorWithSubject/infoWithSubject. Same
2000
+ // zero-external-caller evidence as warning above.
2001
+ success(subject, details) {
1839
2002
  let type = "success";
1840
2003
  const dialogRef = this.dialog.open(messageDialog, {
1841
2004
  width: "600px",
@@ -2252,7 +2415,8 @@ async function loadTinSpaAppSettings() {
2252
2415
  // tin-spa runtime configuration
2253
2416
  //
2254
2417
  // Centralises the "which runtime behaviours does this app want" decisions that used to be
2255
- // scattered across each consuming app (routing strategy, real-time/SignalR, Microsoft SSO).
2418
+ // scattered across each consuming app (routing strategy, real-time/SignalR).
2419
+ // Changed (20.14.114): Microsoft SSO is NO LONGER wired here - see the L3b note at the imports.
2256
2420
  //
2257
2421
  // A consuming app declares intent in ONE place:
2258
2422
  //
@@ -2350,56 +2514,15 @@ function tinSpaRuntimeConfigFactory(codeConfig) {
2350
2514
  }
2351
2515
  return merged;
2352
2516
  }
2353
- function tinSpaMsalInstanceFactory(config) {
2354
- const wantsMsal = !!config.microsoftAuth;
2355
- const secure = typeof window === 'undefined' || window.isSecureContext;
2356
- // A credential is "provided" only if non-empty — appsettings.json ships empty strings as
2357
- // placeholders, and those must trigger the same diagnostics as a missing section.
2358
- const missing = [];
2359
- if (!config.msal?.clientId?.trim())
2360
- missing.push('ClientId');
2361
- if (!config.msal?.authority?.trim())
2362
- missing.push('Authority');
2363
- if (wantsMsal && missing.length) {
2364
- console.error(`[tin-spa] Microsoft SSO is enabled but its credentials are incomplete — missing: ${missing.join(', ')}. ` +
2365
- `Provide them in assets/appsettings.json under "Msal" (or in provideTinSpaRuntime({ msal })). Microsoft SSO is INACTIVE.`);
2366
- }
2367
- if (wantsMsal && !secure) {
2368
- console.warn('[tin-spa] microsoftAuth is enabled but the app is NOT running over HTTPS (secure context). Microsoft SSO is INACTIVE until served over HTTPS.');
2369
- }
2370
- const active = wantsMsal && !missing.length && secure;
2371
- if (!active) {
2372
- // Stub: MsalService's constructor calls instance.initializeWrapperLibrary(), so it must exist
2373
- // or the app crashes at bootstrap. loginPopup rejects so callers' error handlers report it.
2374
- return {
2375
- initializeWrapperLibrary: () => { },
2376
- initialize: () => Promise.resolve(),
2377
- getAllAccounts: () => [],
2378
- getActiveAccount: () => null,
2379
- loginPopup: () => Promise.reject(new Error(!wantsMsal ? 'Microsoft login is disabled in app config.'
2380
- : missing.length ? `Microsoft login is not configured (missing: ${missing.join(', ')}).`
2381
- : 'Microsoft login requires HTTPS (a secure context).')),
2382
- };
2383
- }
2384
- // RedirectUri rule: use the configured value when provided; otherwise fall back to
2385
- // `${origin}/login` — correct whenever each environment's own /login URL is registered
2386
- // on the app registration.
2387
- const redirectUri = config.msal.redirectUri?.trim() || `${window.location.origin}/login`;
2388
- console.info(`[tin-spa] Microsoft SSO active — redirectUri: ${redirectUri}` +
2389
- (config.msal.redirectUri?.trim() ? ' (configured)' : ' (not configured — derived from window.location.origin)'));
2390
- const pca = new PublicClientApplication({
2391
- auth: { ...config.msal, redirectUri },
2392
- cache: { cacheLocation: BrowserCacheLocation.LocalStorage, storeAuthStateInCookie: false },
2393
- });
2394
- // MSAL v3 requires initialize() before any other API call. Kick it off now so it has
2395
- // normally completed by the time the user clicks a login button; callers still await
2396
- // initialize() themselves (it is idempotent) to close the race.
2397
- pca.initialize().catch(e => console.error('[tin-spa] MSAL initialize failed:', e));
2398
- return pca;
2399
- }
2517
+ // Changed (20.14.114, L3b): `tinSpaMsalInstanceFactory` was REMOVED with the vendor imports. It existed
2518
+ // only to build the MSAL_INSTANCE that provideTinSpaRuntime() used to provide, and it could not be kept
2519
+ // without importing PublicClientApplication. The `microsoftAuth` / `msal` config fields are RETAINED -
2520
+ // they are still merged from assets/appsettings.json and remain the per-environment switch an app reads
2521
+ // when it registers MSAL itself.
2400
2522
  // ── Public API ───────────────────────────────────────────────────────────────
2401
2523
  /**
2402
- * Wires up tin-spa runtime behaviours (routing strategy, real-time, MSAL) from a single config.
2524
+ * Wires up tin-spa runtime behaviours (routing strategy, real-time) from a single config.
2525
+ * Changed (20.14.114): no longer provides MSAL - an app that wants Microsoft SSO registers it itself.
2403
2526
  * Add the result to your AppModule (or bootstrap) providers.
2404
2527
  */
2405
2528
  function provideTinSpaRuntime(config) {
@@ -2413,10 +2536,9 @@ function provideTinSpaRuntime(config) {
2413
2536
  useFactory: tinSpaLocationStrategyFactory,
2414
2537
  deps: [PlatformLocation, [new Optional(), new Inject(APP_BASE_HREF)], TIN_SPA_RUNTIME_CONFIG],
2415
2538
  },
2416
- // MSAL_INSTANCE is built lazily (when MsalService is first injected, i.e. on the login page),
2417
- // so it reads the config token rather than running at bootstrap.
2418
- { provide: MSAL_INSTANCE, useFactory: tinSpaMsalInstanceFactory, deps: [TIN_SPA_RUNTIME_CONFIG] },
2419
- MsalService,
2539
+ // Changed (20.14.114, L3b): the MSAL_INSTANCE and MsalService providers were REMOVED. Providing them
2540
+ // from core forced every consumer to carry the vendor bundle whether or not it uses Microsoft SSO.
2541
+ // An app that wants it now provides MsalModule.forRoot(...) itself, alongside provideTinSpaRuntime().
2420
2542
  ]);
2421
2543
  }
2422
2544
 
@@ -3089,6 +3211,10 @@ class SignalRService {
3089
3211
  this.entityCreated$ = this.entityCreated.asObservable();
3090
3212
  this.entityUpdated$ = this.entityUpdated.asObservable();
3091
3213
  this.entityDeleted$ = this.entityDeleted.asObservable();
3214
+ // Added (A-0902-40): the invalidation stream. A plain Subject, like the position stream above — a listener
3215
+ // that has not opted in pays nothing, and nothing in the library subscribes to it by default.
3216
+ this.entityInvalidated = new Subject();
3217
+ this.entityInvalidated$ = this.entityInvalidated.asObservable();
3092
3218
  // Added (GPS phase 4): the position stream. A plain Subject, so a page that is not showing a map pays
3093
3219
  // nothing for it, and a page that is subscribes for exactly as long as it is open.
3094
3220
  this.vehiclePosition = new Subject();
@@ -3197,6 +3323,12 @@ class SignalRService {
3197
3323
  this.hubConnection.on('EntityDeleted', (entityName, data) => {
3198
3324
  this.entityDeleted.next({ entityName, data });
3199
3325
  });
3326
+ // Added (A-0902-40): the payload-free channel — deliberately a SEPARATE hub method from the Entity* trio.
3327
+ // Routing it through EntityUpdated with an empty payload would reach the table's row-replace handler and
3328
+ // blank a row; the server sends a name and, at most, an id, and the listener re-reads instead.
3329
+ this.hubConnection.on('EntityInvalidated', (entityName, id) => {
3330
+ this.entityInvalidated.next({ entityName, id });
3331
+ });
3200
3332
  // Added (GPS phase 4): live vehicle position frames. Deliberately a SEPARATE hub method from the
3201
3333
  // Entity* trio and not routed through them — a position carries six scalars and must never trigger
3202
3334
  // the table-reload machinery those events drive. The frame is emitted verbatim; mapping it to the
@@ -3317,6 +3449,10 @@ class OfflineService {
3317
3449
  //---------------- Registry ----------------
3318
3450
  this.entries = new Map();
3319
3451
  this.readUrls = new Map();
3452
+ // Added: the LOOKUP urls specifically, so serveCachedRead can enforce `lookupMaxAgeHours` on option lists only.
3453
+ // A grid's cached rows have no declared age limit and must keep being served however old they are; an option
3454
+ // list does — and the limit was declared and defaulted but never read, so a dropdown was served indefinitely.
3455
+ this.lookupUrls = new Map();
3320
3456
  this.readPrefixes = [];
3321
3457
  this.registered$ = new BehaviorSubject(false);
3322
3458
  //---------------- Runtime flags ----------------
@@ -3489,6 +3625,7 @@ class OfflineService {
3489
3625
  if (url && (allow === true || (Array.isArray(allow) && allow.includes(url)))) {
3490
3626
  if (!this.readUrls.has(url))
3491
3627
  this.readUrls.set(url, entry);
3628
+ this.lookupUrls.set(url, entry); // Changed: remember this url is a LOOKUP so its age limit can be enforced
3492
3629
  }
3493
3630
  if (field.detailsConfig?.formConfig)
3494
3631
  this.registerLookups(field.detailsConfig.formConfig, entry, depth + 1);
@@ -3795,6 +3932,16 @@ class OfflineService {
3795
3932
  async serveCachedRead(url) {
3796
3933
  const saved = await this.getRead(url);
3797
3934
  let data = saved?.data ?? null;
3935
+ // Added: enforce `lookupMaxAgeHours` — declared on OfflineConfig and defaulted in register(), but never read
3936
+ // anywhere, so an option list cached months ago was still served as current. Only LOOKUP urls are aged.
3937
+ // Pending offline creates are still appended below, so an expired list degrades to the not-yet-synced
3938
+ // parents rather than to nothing at all.
3939
+ const lookup = this.lookupUrls.get(url);
3940
+ if (lookup && saved) {
3941
+ const maxAgeMs = (lookup.config.lookupMaxAgeHours ?? 72) * 3600000;
3942
+ if (maxAgeMs > 0 && Date.now() - (saved.savedAt ?? 0) > maxAgeMs)
3943
+ data = null;
3944
+ }
3798
3945
  const listMatch = url.match(/^([^?/]+)\/list\//);
3799
3946
  if (listMatch) {
3800
3947
  const creates = await this.pendingCreates(listMatch[1]);
@@ -4501,6 +4648,29 @@ class AuthService {
4501
4648
  this.authTypeObserv = this.authTypeSource.asObservable();
4502
4649
  this.initializeGoogleAuth();
4503
4650
  this.initVisibilityRefresh(); // Changed: Refresh token when tab becomes visible after being hidden
4651
+ // Added (cold-reload 401, option D): install the FALLBACK token attacher from here, so it is in place
4652
+ // for every tier of every app rather than only for whoever remembered to register
4653
+ // TIN_LOGIN_TIER_HTTP_PROVIDERS on one route.
4654
+ //
4655
+ // 🔴 THE DEFECT THIS FIXES: shift-spa registered the login tier's providers on the LOGIN route only
4656
+ // (login.routes.ts:67, INDEX_ROUTES' `path: ''`). A COLD RELOAD straight into #/home/... never matches
4657
+ // that path, so the slot stayed empty and TinRootInterceptor took its fail-open branch
4658
+ // (root-interceptor.ts, the third branch) — measured: `configuration/get auth=0 -> 401`,
4659
+ // `accounts/list/x auth=0 -> 401`. The obvious fix, registering those providers at the eager root, was
4660
+ // MEASURED on 2026-09-03 and REFUSED: +1.18 MB raw / +185 kB transfer, because that file imports this
4661
+ // service and this service's DI graph is most of the primary.
4662
+ //
4663
+ // WHY HERE IS BOTH EARLY ENOUGH AND FREE: this service is providedIn:'root', so there is exactly one
4664
+ // instance, and on a cold reload the shell's canMatch guard injects it and calls tryRestoreSession()
4665
+ // before the router will admit any authenticated page — so this runs ahead of the first authenticated
4666
+ // request. And `createTinTokenAttacher` comes from tin-spa/http, which imports only @angular/core and
4667
+ // @angular/common/http, so it adds nothing anywhere.
4668
+ //
4669
+ // SAFE IN EVERY OTHER APP TOO: the slot is write-once (setTinFallbackTokenAttacher returns early if it
4670
+ // is already filled) and the DELEGATE ALWAYS WINS — TinRootInterceptor consults the primary's delegate
4671
+ // first and never reaches the attacher once the primary has loaded. `getToken` is read per request, so
4672
+ // installing before a token exists is correct: an empty token returns the request unchanged.
4673
+ setTinFallbackTokenAttacher(createTinTokenAttacher(() => this.tokenSource.value));
4504
4674
  }
4505
4675
  initializeGoogleAuth() {
4506
4676
  if (this.googleAuthInitialized) {
@@ -4801,8 +4971,32 @@ class AuthService {
4801
4971
  // Shared entry point for any page that needs to auto-redirect authenticated users
4802
4972
  // Returns true if session is active or was successfully restored via silent refresh
4803
4973
  async tryRestoreSession() {
4974
+ // Changed (relogin-role-state RRS-1): the valid-session path now HYDRATES the session instead of merely
4975
+ // reporting that one exists on disk.
4976
+ //
4977
+ // hasValidSession() reads the STORED token expiry. It answers "there is a live session here" without
4978
+ // restoring a single thing: not loggedin, not the token, not the user, and — the one that bites — not the
4979
+ // ROLE. It returned true into a service still holding the empty `new Role()` it is constructed with, so a
4980
+ // shell admitted on this path rendered every capability gate against a role with no capabilities. Those
4981
+ // gates read the role ONCE at init and never again (table.component.ts:72-73 in ensureInitialized, which
4982
+ // "runs exactly once per instance"; form.component.ts:37; app-configuration.component.ts:183;
4983
+ // form-skeleton.component.ts:78; detailsDialog.component.ts:692), so the refusal was PERMANENT.
4984
+ //
4985
+ // MEASURED on shift-spa against a throwaway database: a hard reload onto #/home/vehicles as admin
4986
+ // (Roles.Cap105 = 4, login payload role.cap105 = 4) was refused 5 times out of 5 with
4987
+ // "You do not have access to Vehicle", and 7 seconds later currentRoleSource still read
4988
+ // { roleName: 'New Role' } with loggedin FALSE — UpdateRole had never been called at all. The same page
4989
+ // reached by in-app navigation rendered the grid every time.
4990
+ //
4991
+ // 🔑 Why it looked intermittent: the hasRefreshToken() line below ALREADY hydrates, because silentRefresh
4992
+ // ends in applyLoginResponse. So an EXPIRED stored token restored perfectly while a still-VALID one
4993
+ // refused — the healthier the session, the more broken the page.
4994
+ //
4995
+ // checkAuthentication() is the app's existing hydration routine (the one authGuard awaits) and is
4996
+ // idempotent: with a live in-memory session it returns Promise.resolve(true) without touching storage,
4997
+ // which is exactly the case every caller of tryRestoreSession() hits on a warm navigation.
4804
4998
  if (this.hasValidSession())
4805
- return true;
4999
+ return this.checkAuthentication();
4806
5000
  if (this.hasRefreshToken())
4807
5001
  return (await this.silentRefresh()) === 'success'; // Changed: silentRefresh now returns a tri-state result
4808
5002
  return false;
@@ -4882,10 +5076,28 @@ class AuthService {
4882
5076
  this.storage.get(Constants.AUTH_NAME).then((resp) => {
4883
5077
  this.updateLoggedUserFullName(resp);
4884
5078
  });
4885
- this.storage.get(Constants.AUTH_ROLES).then((resp) => {
4886
- let ret = JSON.parse(resp);
4887
- this.UpdateRole(ret);
4888
- });
5079
+ // Changed (relogin-role-state RRS-1): AWAITED, not fire-and-forget — the returned promise below now waits
5080
+ // for this one. authGuard awaits ONLY what checkAuthentication returns (auth.guard.ts:17), and that used to
5081
+ // be chained off the TOKEN read alone, so the router admitted the route — and Angular built the page's
5082
+ // components — while currentRoleSource still held the empty `new Role()`. Every capability gate in the
5083
+ // library reads that value ONCE at init and never again (table.component.ts:72-73 inside ensureInitialized,
5084
+ // which "runs exactly once per instance"; form.component.ts:37; form-skeleton.component.ts:78;
5085
+ // detailsDialog.component.ts:692; table-config.service.ts:108), so the role landing a microtask later
5086
+ // repainted nothing: the page kept a PERMANENT "You do not have access to <entity>" refusal.
5087
+ // MEASURED on shift-spa against a throwaway database: a hard reload onto #/home/vehicles as admin
5088
+ // (Roles.Cap105 = 4) was refused 5 times out of 5, while the same page reached by in-app navigation
5089
+ // rendered the grid every time — i.e. pressing F5 on any secured grid locked the user out of it.
5090
+ // Failure-safe: a missing or unparseable role leaves the current value alone and still resolves, so a
5091
+ // corrupt storage entry can never reject the guard or blank a live session (it previously threw inside the
5092
+ // callback, where the rejection was swallowed unhandled).
5093
+ const roleRestored = this.storage.get(Constants.AUTH_ROLES).then((resp) => {
5094
+ if (!resp)
5095
+ return;
5096
+ try {
5097
+ this.UpdateRole(JSON.parse(resp));
5098
+ }
5099
+ catch { /* corrupt entry — keep the role we already hold */ }
5100
+ }).catch(() => { });
4889
5101
  this.storage.get(Constants.AUTH_TENANT_NAME).then((resp) => {
4890
5102
  this.updateTenantName(resp);
4891
5103
  });
@@ -4897,7 +5109,10 @@ class AuthService {
4897
5109
  this.UpdateAuthType(resp);
4898
5110
  });
4899
5111
  // Changed: Load token and check validity — if expired, await silentRefresh before allowing navigation
4900
- return this.storage.get(Constants.AUTH_TOKEN).then((token) => {
5112
+ // Changed (relogin-role-state RRS-1): the chain now STARTS from roleRestored, so the guard cannot resolve
5113
+ // — and no component can be created — before the restored role is on currentRoleSource. The two reads run
5114
+ // concurrently (both were issued above); this only orders the RESOLUTION, so it costs nothing on the wire.
5115
+ return roleRestored.then(() => this.storage.get(Constants.AUTH_TOKEN)).then((token) => {
4901
5116
  this.UpdateToken(token);
4902
5117
  return this.storage.get(Constants.AUTH_TOKEN_EXPIRE);
4903
5118
  }).then((expire) => {
@@ -5166,9 +5381,10 @@ class InventoryService {
5166
5381
  this.productsTableConfig = {
5167
5382
  showFilter: true,
5168
5383
  flatButtons: true,
5384
+ identityColumn: 'name', // Added (clickable-identity Phase 9): `name` IS the product. Declared, not left to the convention, so the locked-row cue and the TinSync icons can never drift onto sku/description
5169
5385
  minColumns: ['name', 'sku', 'categoryName'],
5170
5386
  columns: [
5171
- { name: 'name', type: 'text', alias: 'Product Name' },
5387
+ { name: 'name', type: 'button', alias: 'Product Name' }, // Changed (clickable-identity Phase 9): text -> button. The product's name is the row's identity and now opens the product itself, like every app's identity column. detailsConfig is NOT set here — it is wired in the constructor, because productDetailsConfig does not exist yet when this field initializer runs
5172
5388
  { name: 'sku', type: 'text', alias: 'SKU' },
5173
5389
  { name: 'categoryName', type: 'text', alias: 'Category' },
5174
5390
  { name: 'brandName', type: 'text', alias: 'Brand' },
@@ -5213,6 +5429,7 @@ class InventoryService {
5213
5429
  // Product components table for bundle details
5214
5430
  this.productComponentsTableConfig = {
5215
5431
  tabTitle: 'Components',
5432
+ identityColumn: false, // Added (clickable-identity Phase 9b, A-0902-13/-18): a component LINE of a bundle, not a record of its own — the bundle is the thing identified
5216
5433
  showFilter: false,
5217
5434
  elevation: 'none',
5218
5435
  flatButtons: true,
@@ -5269,11 +5486,13 @@ class InventoryService {
5269
5486
  this.bundleProductCreateButton = { name: 'create', display: 'Create Bundle', dialog: true, action: { url: 'products?action=create', method: 'post' } };
5270
5487
  // Table configuration for bundle products
5271
5488
  this.bundleProductsTableConfig = {
5489
+ identityColumn: 'name', // Added (clickable-identity Phase 9b): the bundle`s own name identifies the row
5272
5490
  showFilter: true,
5273
5491
  flatButtons: true,
5274
5492
  minColumns: ['name', 'sku', 'bundleAvailableQuantity'],
5275
5493
  columns: [
5276
- { name: 'name', type: 'text', alias: 'Bundle Name' },
5494
+ { name: 'name', type: 'button', alias: 'Bundle Name', detailsConfig: this.bundleProductDetailsConfig },
5495
+ // Changed (clickable-identity Phase 9b): text -> button, the row identity. detailsConfig is safe INLINE here - bundleProductDetailsConfig is an inline field initializer declared ABOVE this config, unlike productDetailsConfig which is constructor-assigned
5277
5496
  { name: 'sku', type: 'text', alias: 'SKU' },
5278
5497
  { name: 'bundleAvailableQuantity', type: 'number', alias: 'Available', icon: { name: 'info', color: '#2196F3', condition: (x) => x.bundleAvailableQuantity > 0 } }, // Changed: Removed infoMessage as it's not valid for columns
5279
5498
  { name: 'bundleEstimatedCost', type: 'money', alias: 'Est. Cost' },
@@ -5317,11 +5536,13 @@ class InventoryService {
5317
5536
  buttons: [this.serviceItemCreateButton, this.serviceItemEditButton, this.serviceItemDeleteButton]
5318
5537
  };
5319
5538
  this.serviceItemsTableConfig = {
5539
+ identityColumn: 'name', // Added (clickable-identity Phase 9b): the service`s own name identifies the row
5320
5540
  showFilter: true,
5321
5541
  flatButtons: true,
5322
5542
  minColumns: ['name', 'unitPrice', 'statusName'],
5323
5543
  columns: [
5324
- { name: 'name', type: 'text', alias: 'Service Name' },
5544
+ { name: 'name', type: 'button', alias: 'Service Name', detailsConfig: this.serviceItemDetailsConfig },
5545
+ // Changed (clickable-identity Phase 9b): text -> button, the row identity. detailsConfig is safe INLINE here - serviceItemDetailsConfig is an inline field initializer declared ABOVE this config, unlike productDetailsConfig which is constructor-assigned
5325
5546
  { name: 'description', type: 'text', alias: 'Description' },
5326
5547
  { name: 'unitPrice', type: 'money', alias: 'Unit Price' },
5327
5548
  { name: 'taxRateName', type: 'text', alias: 'Tax Rate' }, // Changed: Tax rate display with N/A fallback
@@ -5355,6 +5576,7 @@ class InventoryService {
5355
5576
  };
5356
5577
  this.requisitionItemsTableConfig = {
5357
5578
  tabTitle: 'Items',
5579
+ identityColumn: false, // Added (clickable-identity Phase 9b, A-0902-13/-18): a requisition LINE, not a record — the requisition is the thing identified
5358
5580
  showFilter: false,
5359
5581
  elevation: 'none',
5360
5582
  flatButtons: true,
@@ -5411,11 +5633,13 @@ class InventoryService {
5411
5633
  };
5412
5634
  this.requisitionViewButton = { name: 'view', dialog: true, detailsConfig: this.requisitionDetailsConfig };
5413
5635
  this.requisitionsTableConfig = {
5636
+ identityColumn: 'requisitionNumber', // Added (clickable-identity Phase 9b): the requisition document number identifies the row
5414
5637
  showFilter: true,
5415
5638
  flatButtons: true,
5416
5639
  minColumns: ['requisitionNumber', 'requesterName', 'requestDate'],
5417
5640
  columns: [
5418
- { name: 'requisitionNumber', type: 'text', alias: 'Requisition #' },
5641
+ { name: 'requisitionNumber', type: 'button', alias: 'Requisition #', detailsConfig: this.requisitionDetailsConfig },
5642
+ // Changed (clickable-identity Phase 9b): text -> button, the row identity. detailsConfig is safe INLINE here - requisitionDetailsConfig is an inline field initializer declared ABOVE this config, unlike productDetailsConfig which is constructor-assigned
5419
5643
  { name: 'requesterName', type: 'monogram', alias: 'Requested By' }, // Changed: monogram — the row still carries the full name, so filter/sort are untouched
5420
5644
  { name: 'departmentName', type: 'text', alias: 'Department' },
5421
5645
  { name: 'requestDate', type: 'date', alias: 'Request Date' },
@@ -5463,6 +5687,7 @@ class InventoryService {
5463
5687
  buttons: []
5464
5688
  };
5465
5689
  // Added (WS-1 sweep): paginated but deliberately NOT offline — InventoryItem does not implement ISync, so a queued write has no RowVersion/ClientRef to sync or dedupe on. Adding it is a migration, not a config change. A read-only cache for stock lookups on the floor belongs to the CACHE phase.
5690
+ // FLAGGED, deliberately NOT given an identity (clickable-identity Phase 9b, Arbiter A-0902-24 Q3, MEDIUM): the only text candidate is `productName`, aliased 'Product', but the dialog opens the INVENTORY ITEM - the same label/target mismatch the grip maintenanceTableConfig fix removed. `serialNumber` is the item's true identity but renders 'N/A' for non-serialised stock (the objection that held piglet movementsTableConfig). The row `view` button remains the affordance. NOTE for any future retype: inventoryStockItemsTableConfig spreads this config and shares its columns array BY REFERENCE, so a retype here also lands in the Stock Items tab, which today has buttons: []
5466
5691
  this.inventoryItemsTableConfig = {
5467
5692
  showFilter: true,
5468
5693
  flatButtons: true,
@@ -5519,11 +5744,13 @@ class InventoryService {
5519
5744
  };
5520
5745
  // Updated: Added create/edit/delete buttons to table
5521
5746
  this.inventoryAdjustmentsTableConfig = {
5747
+ identityColumn: 'adjustmentNumber', // Added (clickable-identity Phase 9b): the adjustment document number identifies the row; productName names the product it is ABOUT (Rule 2)
5522
5748
  showFilter: true,
5523
5749
  flatButtons: true,
5524
5750
  minColumns: ['adjustmentNumber', 'productName', 'reason'],
5525
5751
  columns: [
5526
- { name: 'adjustmentNumber', type: 'text', alias: 'Adjustment #' },
5752
+ { name: 'adjustmentNumber', type: 'button', alias: 'Adjustment #', detailsConfig: this.inventoryAdjustmentDetailsConfig },
5753
+ // Changed (clickable-identity Phase 9b): text -> button, the row identity. detailsConfig is safe INLINE here - inventoryAdjustmentDetailsConfig is an inline field initializer declared ABOVE this config, unlike productDetailsConfig which is constructor-assigned
5527
5754
  { name: 'productName', type: 'text', alias: 'Product' },
5528
5755
  { name: 'reason', type: 'text', alias: 'Reason' },
5529
5756
  { name: 'adjustmentQuantity', type: 'number', alias: 'Quantity' },
@@ -5563,6 +5790,7 @@ class InventoryService {
5563
5790
  // Added: Return items table for details dialog
5564
5791
  this.inventoryReturnItemsTableConfig = {
5565
5792
  tabTitle: 'Items',
5793
+ identityColumn: false, // Added (clickable-identity Phase 9b, A-0902-13/-18): a return LINE, not a record — the return is the thing identified
5566
5794
  showFilter: false,
5567
5795
  elevation: 'none',
5568
5796
  flatButtons: true,
@@ -5626,11 +5854,13 @@ class InventoryService {
5626
5854
  this.inventoryReturnCreateButton = { name: 'create', display: 'Create Return', dialog: true, action: { url: 'inventoryreturns?action=create', method: 'post' }, onSuccessButton: this.inventoryReturnViewButton };
5627
5855
  // Added: Main returns table config
5628
5856
  this.inventoryReturnsTableConfig = {
5857
+ identityColumn: 'returnNumber', // Added (clickable-identity Phase 9b): the return document number identifies the row; entityName names the source it came FROM (Rule 2)
5629
5858
  showFilter: true,
5630
5859
  flatButtons: true,
5631
5860
  minColumns: ['returnNumber', 'entityName', 'returnDate'],
5632
5861
  columns: [
5633
- { name: 'returnNumber', type: 'text', alias: 'Return #' },
5862
+ { name: 'returnNumber', type: 'button', alias: 'Return #', detailsConfig: this.inventoryReturnDetailsConfig },
5863
+ // Changed (clickable-identity Phase 9b): text -> button, the row identity. detailsConfig is safe INLINE here - inventoryReturnDetailsConfig is an inline field initializer declared ABOVE this config, unlike productDetailsConfig which is constructor-assigned
5634
5864
  { name: 'returnType', type: 'text', alias: 'Type' },
5635
5865
  { name: 'entityName', type: 'text', alias: 'Source' },
5636
5866
  { name: 'returnDate', type: 'date', alias: 'Date' },
@@ -5673,6 +5903,7 @@ class InventoryService {
5673
5903
  heroField: 'inventoryTransactionID',
5674
5904
  buttons: []
5675
5905
  };
5906
+ // FLAGGED, deliberately NOT given an identity (clickable-identity Phase 9b, Arbiter A-0902-24 Q3, MEDIUM): columns[0] is a DATE, which is not an identity, and `productName` is aliased 'Product' while the dialog opens the TRANSACTION - the grip maintenanceTableConfig mismatch again. This is a read-only ledger (buttons: [] on its details config); the row `view` button remains the affordance
5676
5907
  this.inventoryTransactionsTableConfig = {
5677
5908
  showFilter: true,
5678
5909
  flatButtons: true,
@@ -5758,6 +5989,8 @@ class InventoryService {
5758
5989
  // Changed: dropdown quick-add — productDetailsConfig is built above (field initializers run before this constructor body), so wire the product selects here to avoid capturing undefined
5759
5990
  this.inventoryAdjustmentFormConfig.fields.find(f => f.name === 'productID').detailsConfig = this.productDetailsConfig;
5760
5991
  this.inventoryReturnItemFormConfig.fields.find(f => f.name === 'productID').detailsConfig = this.productDetailsConfig;
5992
+ // Added (clickable-identity Phase 9): wire the product identity column's dialog here for the same reason as the two lines above — productsTableConfig is a field initializer and runs before this body, so referencing productDetailsConfig inline would capture undefined and open a blank drill-in
5993
+ this.productsTableConfig.columns.find(c => c.name === 'name').detailsConfig = this.productDetailsConfig;
5761
5994
  }
5762
5995
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: InventoryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
5763
5996
  static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: InventoryService, providedIn: 'root' }); }
@@ -6707,7 +6940,12 @@ class AccountingService {
6707
6940
  this.accountTransactionsTableConfig = {
6708
6941
  ...this.transactionsTableConfig,
6709
6942
  causeFormRefresh: true,
6710
- minColumns: ['date', 'description', 'amountDisplay', 'runningBalance'], // Changed: Use amountDisplay instead of amount
6943
+ // Changed: trimmed 4 -> 3 for CLAUDE.md's phone maximum. Kept the two that make a row identifiable (date,
6944
+ // description) plus runningBalance, which is the only column unique to the ACCOUNT ledger and the question
6945
+ // this view exists to answer. amountDisplay is dropped from the phone set, not from the table — it is still
6946
+ // a full column on wider screens, and it is the one figure a reader can recover, as the step between two
6947
+ // consecutive balances.
6948
+ minColumns: ['date', 'description', 'runningBalance'],
6711
6949
  tabTitle: 'Account Transactions',
6712
6950
  loadAction: { url: 'transactions/account/x' }, loadCriteria: 'account', loadIDField: 'accountID',
6713
6951
  columns: [
@@ -7941,6 +8179,7 @@ class SalesService {
7941
8179
  };
7942
8180
  this.salesOrderItemsTableConfig = {
7943
8181
  tabTitle: 'Items',
8182
+ identityColumn: false, // Added (clickable-identity Phase 9b, A-0902-13/-18): a sales-order LINE, not a record — the order is the thing identified
7944
8183
  // Added (delivered-order-lines-ui-gate): the three line buttons below are HIDDEN on a Delivered(2)/Cancelled(3) order, which on its own leaves the user staring at an uneditable list with nothing saying why or what to do instead. This is the same predicate as those buttons, reading config.parentData (the parent order), and the wording is the server's own from SalesOrderItemsController.CheckParentOrderOpen (lines 128 and 131) minus its "record of account" clause — same remedies, named in the same words, so the caption and a refusal toast can never contradict each other. The lines themselves stay listed and readable; only the writes are gone.
7945
8184
  caption: (o) => o?.status === 2 ? 'Cannot change lines on a delivered order — void the sale, raise a credit note against its invoice, then capture a new order.' : o?.status === 3 ? 'Cannot change lines on a cancelled order. Capture a new order instead.' : '',
7946
8185
  showFilter: false,
@@ -8006,11 +8245,13 @@ class SalesService {
8006
8245
  };
8007
8246
  this.salesOrderViewButton = { name: 'view', dialog: true, detailsConfig: this.salesOrderDetailsConfig };
8008
8247
  this.salesOrdersTableConfig = {
8248
+ identityColumn: 'orderNumber', // Added (clickable-identity Phase 9b): the order number identifies the row; displayCustomerName names the customer it is FOR (Rule 2)
8009
8249
  showFilter: true,
8010
8250
  flatButtons: true,
8011
8251
  minColumns: ['orderNumber', 'displayCustomerName', 'orderDate'],
8012
8252
  columns: [
8013
- { name: 'orderNumber', type: 'text', alias: 'Order #' },
8253
+ { name: 'orderNumber', type: 'button', alias: 'Order #', detailsConfig: this.salesOrderDetailsConfig },
8254
+ // Changed (clickable-identity Phase 9b): text -> button, the row identity. detailsConfig is safe INLINE here - salesOrderDetailsConfig is an inline field initializer declared ABOVE this config, unlike productDetailsConfig which is constructor-assigned
8014
8255
  { name: 'displayCustomerName', type: 'text', alias: 'Customer' },
8015
8256
  { name: 'orderDate', type: 'date', alias: 'Order Date' },
8016
8257
  { name: 'requiredDate', type: 'date', alias: 'Required Date' },
@@ -8055,6 +8296,7 @@ class SalesService {
8055
8296
  };
8056
8297
  this.saleItemsTableConfig = {
8057
8298
  tabTitle: 'Items',
8299
+ identityColumn: false, // Added (clickable-identity Phase 9b, A-0902-13/-18): a sale LINE, not a record — the sale is the thing identified
8058
8300
  showFilter: false,
8059
8301
  elevation: 'none',
8060
8302
  flatButtons: true,
@@ -8163,6 +8405,7 @@ class SalesService {
8163
8405
  this.saleRecordPaymentButton = { name: 'record-payment', display: 'Record Payment', inDialog: true, dialog: true, icon: { name: 'payment', color: 'blue' }, detailsConfig: this.salePaymentDetailsConfig, visible: x => x.timing === 1 && x.paymentStatus !== 1 };
8164
8406
  this.salePaymentsTableConfig = {
8165
8407
  tabTitle: 'Payment History',
8408
+ identityColumn: false, // Added (clickable-identity Phase 9b, A-0902-13/-18): a payment against a sale, not a record with an identity of its own
8166
8409
  showFilter: false,
8167
8410
  minColumns: ['paymentDate', 'amount', 'methodName'],
8168
8411
  columns: [
@@ -8255,6 +8498,7 @@ class PurchasingService {
8255
8498
  };
8256
8499
  this.supplierPurchaseOrderItemsTableConfig = {
8257
8500
  tabTitle: 'Items',
8501
+ identityColumn: false, // Added (clickable-identity Phase 9b, A-0902-13/-18): a purchase-order LINE, not a record — the PO is the thing identified
8258
8502
  showFilter: false,
8259
8503
  elevation: 'none',
8260
8504
  flatButtons: true,
@@ -8339,11 +8583,13 @@ class PurchasingService {
8339
8583
  };
8340
8584
  this.supplierPurchaseOrderViewButton = { name: 'view', dialog: true, detailsConfig: this.supplierPurchaseOrderDetailsConfig };
8341
8585
  this.supplierPurchaseOrdersTableConfig = {
8586
+ identityColumn: 'poNumber', // Added (clickable-identity Phase 9b): the PO number identifies the row; supplierName names the supplier it is FOR (Rule 2)
8342
8587
  showFilter: true,
8343
8588
  flatButtons: true,
8344
8589
  minColumns: ['poNumber', 'supplierName', 'orderDate'],
8345
8590
  columns: [
8346
- { name: 'poNumber', type: 'text', alias: 'PO #' },
8591
+ { name: 'poNumber', type: 'button', alias: 'PO #', detailsConfig: this.supplierPurchaseOrderDetailsConfig },
8592
+ // Changed (clickable-identity Phase 9b): text -> button, the row identity. detailsConfig is safe INLINE here - supplierPurchaseOrderDetailsConfig is an inline field initializer declared ABOVE this config, unlike productDetailsConfig which is constructor-assigned
8347
8593
  { name: 'supplierName', type: 'text', alias: 'Supplier' },
8348
8594
  { name: 'orderDate', type: 'date', alias: 'Order Date' },
8349
8595
  { name: 'expectedDeliveryDate', type: 'date', alias: 'Expected Delivery' },
@@ -8385,6 +8631,7 @@ class PurchasingService {
8385
8631
  // Changed: Renamed from inventoryReceiptItemsTableConfig to purchaseItemsTableConfig
8386
8632
  this.purchaseItemsTableConfig = {
8387
8633
  tabTitle: 'Items',
8634
+ identityColumn: false, // Added (clickable-identity Phase 9b, A-0902-13/-18): a purchase LINE, not a record — the purchase is the thing identified
8388
8635
  showFilter: false,
8389
8636
  elevation: 'none',
8390
8637
  flatButtons: true,
@@ -8554,6 +8801,7 @@ class PurchasingService {
8554
8801
  // Added: Receipt items table — shows what was physically received
8555
8802
  this.receiptItemsTableConfig = {
8556
8803
  tabTitle: 'Items',
8804
+ identityColumn: false, // Added (clickable-identity Phase 9b, A-0902-13/-18): a receipt LINE, not a record — the receipt is the thing identified
8557
8805
  showFilter: false,
8558
8806
  elevation: 'none',
8559
8807
  flatButtons: true,
@@ -8608,12 +8856,14 @@ class PurchasingService {
8608
8856
  // Changed (WS-1 sweep 2): now OFFLINE. The earlier skip here blamed the read registry — one url per entity, so only the first purchase opened in a session could match. That gap is closed (read PREFIXes, ng-space 1e04b17): `loadCriteria: 'purchase'` registers the stem `receipts/purchase/`, so every parent matches and each keeps its own cache entry. Receipting is a receiving-bay act away from a desk and Receipt IS ISync, so it qualifies on merit now the mechanism exists.
8609
8857
  // Still NOT paginated, and that is separate from offline: one purchase carries a handful of receipts, so paging would cost a round trip and buy nothing.
8610
8858
  this.receiptsTableConfig = {
8859
+ identityColumn: 'receiptNumber', // Added (clickable-identity Phase 9b): the receipt number identifies the row
8611
8860
  tabTitle: 'Receipts',
8612
8861
  showFilter: false,
8613
8862
  elevation: 'none',
8614
8863
  flatButtons: true,
8615
8864
  columns: [
8616
- { name: 'receiptNumber', type: 'text', alias: 'Receipt #' },
8865
+ { name: 'receiptNumber', type: 'button', alias: 'Receipt #', detailsConfig: this.receiptDetailsConfig },
8866
+ // Changed (clickable-identity Phase 9b): text -> button, the row identity. detailsConfig is safe INLINE here - receiptDetailsConfig is an inline field initializer declared ABOVE this config, unlike productDetailsConfig which is constructor-assigned
8617
8867
  { name: 'receivedDate', type: 'date', alias: 'Received' },
8618
8868
  { name: 'statusName', type: 'chip', alias: 'Status', colors: [{ name: '#E0E0E0', condition: x => x.statusName === 'Draft' }, { name: '#C8E6C9', condition: x => x.statusName === 'Completed' }] },
8619
8869
  { name: 'detailsDisplay', type: 'text', alias: 'Details' } // Changed: Renamed from productsDisplay/Products
@@ -8657,6 +8907,7 @@ class PurchasingService {
8657
8907
  // Changed (WS-1 sweep 2): still NOT offline and still NOT paginated, but the read-registry gap is no longer the reason — that is closed. It is skipped now because this table has NOTHING TO QUEUE: its only button is a void, which is a server-side ledger reversal, and payments are recorded from the parent purchase row (supplierRecordPaymentButton), not from here. Paging is omitted separately: a purchase carries only a few payments.
8658
8908
  this.supplierPaymentsTableConfig = {
8659
8909
  tabTitle: 'Payments',
8910
+ identityColumn: false, // Added (clickable-identity Phase 9b, A-0902-13/-18): a payment against a purchase, not a record with an identity of its own
8660
8911
  hidden: (x) => x.timing !== 1, // Changed: Hide Payments tab for Immediate purchases (only show for Deferred/Credit)
8661
8912
  showFilter: false,
8662
8913
  elevation: 'none',
@@ -8685,11 +8936,13 @@ class PurchasingService {
8685
8936
  ],
8686
8937
  };
8687
8938
  this.apAgingBaseTableConfig = {
8939
+ identityColumn: 'purchaseNumber', // Added (clickable-identity Phase 9b): the purchase number identifies the row. RETYPED text->button (A-0902-24 revision): the detailsConfig was already declared here but type text has NO click handler, so it could never fire
8688
8940
  showFilter: true,
8689
8941
  flatButtons: true,
8690
8942
  minColumns: ['purchaseNumber', 'supplierName', 'outstandingAmount'], // Changed: receiptNumber → purchaseNumber
8691
8943
  columns: [
8692
- { name: 'purchaseNumber', type: 'text', alias: 'Purchase #', detailsConfig: this.purchaseDetailsConfig }, // Changed: receiptNumber → purchaseNumber, inventoryReceiptDetailsConfig → purchaseDetailsConfig
8944
+ { name: 'purchaseNumber', type: 'button', alias: 'Purchase #', detailsConfig: this.purchaseDetailsConfig }, // Changed: receiptNumber → purchaseNumber, inventoryReceiptDetailsConfig → purchaseDetailsConfig
8945
+ // Changed (clickable-identity Phase 9b): text -> button, the row identity. detailsConfig is safe INLINE here - purchaseDetailsConfig is an inline field initializer declared ABOVE this config, unlike productDetailsConfig which is constructor-assigned
8693
8946
  { name: 'supplierName', type: 'text', alias: 'Supplier' },
8694
8947
  { name: 'purchaseDate', type: 'date', alias: 'Purchase Date' }, // Changed: receiptDate → purchaseDate
8695
8948
  { name: 'dueDate', type: 'date', alias: 'Due Date' },
@@ -11156,6 +11409,11 @@ class DataServiceLib {
11156
11409
  this.capTasks.name = "cap16";
11157
11410
  this.capTasks.display = "Tasks";
11158
11411
  this.capTasks.moduleKey = "tasks"; // Added (Setup v3)
11412
+ // Changed (board-file-locks-and-queue-order P0-4, 2026-09-04): restored to "home/general/tasks", the original
11413
+ // value (verified against c2c8f5ec^:211). BPI-3 had repointed this at the Work board because the Now page it
11414
+ // used to open had been deleted; the Work module has now left tin-spa altogether and become Pulse's own
11415
+ // "Board" (cap130, home/board in pulse-spa), so the Tasks nav item goes back to opening the Tasks page.
11416
+ // Per fsd A17 this library restore replaces pulse-spa's local override, which is deleted in the same change.
11159
11417
  this.capTasks.link = "home/general/tasks";
11160
11418
  this.capTasks.icon = "check_box";
11161
11419
  this.capHR.name = "cap17";
@@ -11722,13 +11980,13 @@ class DataServiceLib {
11722
11980
  { key: 'outOfStock', lane: 'stores', listField: 'outOfStock', tableConfig: inventory.productsTableConfig,
11723
11981
  columns: ['name', { column: 'sku', alias: 'SKU' }, 'categoryName'],
11724
11982
  extraColumns: [{ name: 'minimumLevel', alias: 'Minimum' }], // book-only — the page's own column is minimumInventoryLevel and would read blank here
11725
- buttons: [{ button: 'view', display: 'Open the product', icon: { name: 'launch' }, tip: 'Opens this product without leaving the Day Book' }], // Changed (sweep-1, owner directive 2026-08-20): was inventory_2 + blue. A row VIEW is `launch` estate-wide (shift-spa, grip-spa, piglet's dayBookViewPig) and inventory_2 is ALSO this lane's own section glyph, so the action and the card it sat on wore the same icon for two different meanings. The blue is dropped with it — an action icon is not recoloured away from its siblings. test-spa was swept on 08-20; this is the library's own copy of the same demo config, missed then
11983
+ buttons: [], // Changed (clickable-identity Phase 9): the 'Open the product' launch button is REMOVED — productsTableConfig.name is now the row's identity button and opens the same productDetailsConfig in place, so this was a second affordance to the same dialog on a three-column card. The 2026-08-20 directive it carried settled WHICH icon a row view wears, not that a row view must sit beside an identity
11726
11984
  overrides: { ...this.libraryDayBookChrome, minColumns: ['name', 'categoryName', 'minimumLevel'], sectionConfig: { title: 'Out of Stock', icon: 'remove_shopping_cart', chips: [{ text: 'nothing left to sell', color: '#c62828' }] } }
11727
11985
  },
11728
11986
  { key: 'lowStock', lane: 'stores', listField: 'lowStock', tableConfig: inventory.productsTableConfig,
11729
11987
  columns: ['name', { column: 'sku', alias: 'SKU' }, 'categoryName'],
11730
11988
  extraColumns: [{ name: 'available', alias: 'In Stock' }, { name: 'minimumLevel', alias: 'Minimum' }, { name: 'shortfall', alias: 'Short By' }],
11731
- buttons: [{ button: 'view', display: 'Open the product', icon: { name: 'launch' }, tip: 'Opens this product without leaving the Day Book' }], // Changed (sweep-1, owner directive 2026-08-20): was inventory_2 + blue. A row VIEW is `launch` estate-wide (shift-spa, grip-spa, piglet's dayBookViewPig) and inventory_2 is ALSO this lane's own section glyph, so the action and the card it sat on wore the same icon for two different meanings. The blue is dropped with it — an action icon is not recoloured away from its siblings. test-spa was swept on 08-20; this is the library's own copy of the same demo config, missed then
11989
+ buttons: [], // Changed (clickable-identity Phase 9): the 'Open the product' launch button is REMOVED — productsTableConfig.name is now the row's identity button and opens the same productDetailsConfig in place, so this was a second affordance to the same dialog on a three-column card. The 2026-08-20 directive it carried settled WHICH icon a row view wears, not that a row view must sit beside an identity
11732
11990
  overrides: { ...this.libraryDayBookChrome, minColumns: ['name', 'available', 'shortfall'], sectionConfig: { title: 'Low Stock', icon: 'inventory_2', chips: [{ text: 'below the minimum you chose to hold', color: '#ef6c00' }] } }
11733
11991
  }
11734
11992
  ];
@@ -12221,6 +12479,14 @@ class SuffixComponent {
12221
12479
  // stops the click, this stops any other caller, and a write affordance that is wrong to offer is also
12222
12480
  // wrong to honour. Copy is untouched — it reads.
12223
12481
  this.readonly = false;
12482
+ // Added (text-field-clear-icon, owner 2026-09-02): the clear X was gated on `isHovered` alone, so on a phone —
12483
+ // where no pointer ever hovers — it could not be reached at all, and on desktop it appeared only once the mouse
12484
+ // was already over the field. The owner's reference is the FILTER box (filter.component.html:7), whose X is
12485
+ // gated on exactly one thing: *ngIf="_filterText" — it is there whenever there is text to clear, hover or not.
12486
+ // `alwaysShow` lets the TYPEABLE TEXT family (text / text-single / text-area / email) adopt that same rule while
12487
+ // number / money / date / datetime / select-multi / text-multi keep the hover behaviour they ship with today.
12488
+ // An opt-in rather than a flipped default, because the untracked external consumers render those types too.
12489
+ this.alwaysShow = false;
12224
12490
  this.infoClick = new EventEmitter();
12225
12491
  this.copyClick = new EventEmitter();
12226
12492
  this.clearClick = new EventEmitter();
@@ -12228,6 +12494,20 @@ class SuffixComponent {
12228
12494
  }
12229
12495
  ngOnInit() {
12230
12496
  }
12497
+ // Added: the second half of the filter box's rule — "if it's typeable and has text, the icon should appear".
12498
+ // A clear control on an already-empty field is an affordance that does nothing, so it is not offered at all.
12499
+ // Deliberately allocation-free (no String(), no trim()): this is template-bound and the house bans allocating
12500
+ // getters in templates — it returns a primitive and touches nothing.
12501
+ get hasValue() {
12502
+ const v = this.value;
12503
+ if (v === null || v === undefined || v === '') {
12504
+ return false;
12505
+ }
12506
+ if (Array.isArray(v)) {
12507
+ return v.length > 0;
12508
+ }
12509
+ return true;
12510
+ }
12231
12511
  onInfoClick(event) {
12232
12512
  event.stopPropagation();
12233
12513
  if (this.infoMessage) {
@@ -12259,11 +12539,11 @@ class SuffixComponent {
12259
12539
  this.clearClick.emit();
12260
12540
  }
12261
12541
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: SuffixComponent, deps: [{ token: MessageService }], target: i0.ɵɵFactoryTarget.Component }); }
12262
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: SuffixComponent, isStandalone: false, selector: "spa-suffix", inputs: { label: "label", infoMessage: "infoMessage", copyContent: "copyContent", isHovered: "isHovered", clearContent: "clearContent", readonly: "readonly", value: "value" }, outputs: { infoClick: "infoClick", copyClick: "copyClick", clearClick: "clearClick", valueChange: "valueChange" }, ngImport: i0, template: "<label *ngIf=\"label\">&nbsp; {{label}}</label>\n<button mat-icon-button *ngIf=\"copyContent && isHovered\" (click)=\"onCopyClick($event)\" matTooltip=\"Copy Content\" matTooltipPosition=\"above\" style=\"opacity: 1;\">\n <mat-icon class=\"tinyIcon\">content_copy</mat-icon>\n</button>\n<button mat-icon-button *ngIf=\"clearContent && isHovered && !readonly\" (click)=\"onClearClick($event)\" matTooltip=\"Clear Content\" matTooltipPosition=\"above\" style=\"opacity: 1;\"><!-- Changed: `&& !readonly` \u2014 the X is the one affordance here that WRITES, and it rendered and fired on a locked field, so a view-only dialog could still be emptied. Copy above is deliberately NOT gated: reading a locked value is exactly what a view dialog is for. -->\n <mat-icon class=\"tinyIcon\">close</mat-icon>\n</button>\n<button mat-icon-button *ngIf=\"infoMessage\" (click)=\"onInfoClick($event)\" [matTooltip]=\"infoMessage\" matTooltipPosition=\"above\" style=\"opacity: 1;margin-right: 2px;\">\n <mat-icon class=\"tinyIcon\" style=\"color: steelblue;\">info</mat-icon>\n</button>\n", styles: [".smallIcon{font-size:14px}\n"], dependencies: [{ kind: "directive", type: i1$3.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "component", type: i3.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i4.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "directive", type: i5$1.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }] }); }
12542
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: SuffixComponent, isStandalone: false, selector: "spa-suffix", inputs: { label: "label", infoMessage: "infoMessage", copyContent: "copyContent", isHovered: "isHovered", clearContent: "clearContent", readonly: "readonly", alwaysShow: "alwaysShow", value: "value" }, outputs: { infoClick: "infoClick", copyClick: "copyClick", clearClick: "clearClick", valueChange: "valueChange" }, ngImport: i0, template: "<label *ngIf=\"label\">&nbsp; {{label}}</label>\n<button mat-icon-button *ngIf=\"copyContent && isHovered\" (click)=\"onCopyClick($event)\" matTooltip=\"Copy Content\" matTooltipPosition=\"above\" style=\"opacity: 1;\">\n <mat-icon class=\"tinyIcon\">content_copy</mat-icon>\n</button>\n<button mat-icon-button class=\"spa-clear-btn\" *ngIf=\"clearContent && !readonly && hasValue && (isHovered || alwaysShow)\" (click)=\"onClearClick($event)\" matTooltip=\"Clear\" matTooltipPosition=\"above\" style=\"opacity: 1;\"><!-- Changed: `&& !readonly` \u2014 the X is the one affordance here that WRITES, and it rendered and fired on a locked field, so a view-only dialog could still be emptied. Copy above is deliberately NOT gated: reading a locked value is exactly what a view dialog is for. --><!-- Changed (text-field-clear-icon, owner 2026-09-02): `isHovered` became `isHovered || alwaysShow`, and `hasValue` was added. This is the FILTER box's rule (filter.component.html:7, *ngIf=\"_filterText\") brought to the typeable text family: show it when there is something to clear. Hover alone made it unreachable on a phone. Tooltip is now \"Clear\" to match the filter's word exactly \u2014 one action, one name. The .spa-clear-btn class carries the coarse-pointer touch target in tin-styles.css; the glyph, the 14px suffix sizing and the palette are unchanged. -->\n <mat-icon class=\"tinyIcon\">close</mat-icon>\n</button>\n<button mat-icon-button *ngIf=\"infoMessage\" (click)=\"onInfoClick($event)\" [matTooltip]=\"infoMessage\" matTooltipPosition=\"above\" style=\"opacity: 1;margin-right: 2px;\">\n <mat-icon class=\"tinyIcon\" style=\"color: steelblue;\">info</mat-icon>\n</button>\n", styles: [".smallIcon{font-size:14px}\n"], dependencies: [{ kind: "directive", type: i1$3.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "component", type: i3.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i4.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "directive", type: i5$1.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }] }); }
12263
12543
  }
12264
12544
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: SuffixComponent, decorators: [{
12265
12545
  type: Component,
12266
- args: [{ selector: 'spa-suffix', standalone: false, template: "<label *ngIf=\"label\">&nbsp; {{label}}</label>\n<button mat-icon-button *ngIf=\"copyContent && isHovered\" (click)=\"onCopyClick($event)\" matTooltip=\"Copy Content\" matTooltipPosition=\"above\" style=\"opacity: 1;\">\n <mat-icon class=\"tinyIcon\">content_copy</mat-icon>\n</button>\n<button mat-icon-button *ngIf=\"clearContent && isHovered && !readonly\" (click)=\"onClearClick($event)\" matTooltip=\"Clear Content\" matTooltipPosition=\"above\" style=\"opacity: 1;\"><!-- Changed: `&& !readonly` \u2014 the X is the one affordance here that WRITES, and it rendered and fired on a locked field, so a view-only dialog could still be emptied. Copy above is deliberately NOT gated: reading a locked value is exactly what a view dialog is for. -->\n <mat-icon class=\"tinyIcon\">close</mat-icon>\n</button>\n<button mat-icon-button *ngIf=\"infoMessage\" (click)=\"onInfoClick($event)\" [matTooltip]=\"infoMessage\" matTooltipPosition=\"above\" style=\"opacity: 1;margin-right: 2px;\">\n <mat-icon class=\"tinyIcon\" style=\"color: steelblue;\">info</mat-icon>\n</button>\n", styles: [".smallIcon{font-size:14px}\n"] }]
12546
+ args: [{ selector: 'spa-suffix', standalone: false, template: "<label *ngIf=\"label\">&nbsp; {{label}}</label>\n<button mat-icon-button *ngIf=\"copyContent && isHovered\" (click)=\"onCopyClick($event)\" matTooltip=\"Copy Content\" matTooltipPosition=\"above\" style=\"opacity: 1;\">\n <mat-icon class=\"tinyIcon\">content_copy</mat-icon>\n</button>\n<button mat-icon-button class=\"spa-clear-btn\" *ngIf=\"clearContent && !readonly && hasValue && (isHovered || alwaysShow)\" (click)=\"onClearClick($event)\" matTooltip=\"Clear\" matTooltipPosition=\"above\" style=\"opacity: 1;\"><!-- Changed: `&& !readonly` \u2014 the X is the one affordance here that WRITES, and it rendered and fired on a locked field, so a view-only dialog could still be emptied. Copy above is deliberately NOT gated: reading a locked value is exactly what a view dialog is for. --><!-- Changed (text-field-clear-icon, owner 2026-09-02): `isHovered` became `isHovered || alwaysShow`, and `hasValue` was added. This is the FILTER box's rule (filter.component.html:7, *ngIf=\"_filterText\") brought to the typeable text family: show it when there is something to clear. Hover alone made it unreachable on a phone. Tooltip is now \"Clear\" to match the filter's word exactly \u2014 one action, one name. The .spa-clear-btn class carries the coarse-pointer touch target in tin-styles.css; the glyph, the 14px suffix sizing and the palette are unchanged. -->\n <mat-icon class=\"tinyIcon\">close</mat-icon>\n</button>\n<button mat-icon-button *ngIf=\"infoMessage\" (click)=\"onInfoClick($event)\" [matTooltip]=\"infoMessage\" matTooltipPosition=\"above\" style=\"opacity: 1;margin-right: 2px;\">\n <mat-icon class=\"tinyIcon\" style=\"color: steelblue;\">info</mat-icon>\n</button>\n", styles: [".smallIcon{font-size:14px}\n"] }]
12267
12547
  }], ctorParameters: () => [{ type: MessageService }], propDecorators: { label: [{
12268
12548
  type: Input
12269
12549
  }], infoMessage: [{
@@ -12276,6 +12556,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
12276
12556
  type: Input
12277
12557
  }], readonly: [{
12278
12558
  type: Input
12559
+ }], alwaysShow: [{
12560
+ type: Input
12279
12561
  }], value: [{
12280
12562
  type: Input
12281
12563
  }], infoClick: [{
@@ -12490,7 +12772,13 @@ class TextComponent {
12490
12772
  this.enterPress = new EventEmitter();
12491
12773
  this.width = "100%";
12492
12774
  this.copyContent = false;
12493
- this.clearContent = false;
12775
+ // Changed (text-field-clear-icon, owner 2026-09-02): was `false`. This is a box you type into, and the owner's
12776
+ // rule is that a typeable box with text in it always offers a way to empty it — the same rule the filter box
12777
+ // has always had. Opting in per field declaration was the reason he never saw it: across the whole library
12778
+ // exactly FIVE Field declarations set clearContent, and none of them is a text field.
12779
+ // form.component.html passes `field.clearContent ?? true` so an undefined declaration lands here as true and a
12780
+ // field that explicitly says `clearContent: false` is still honoured.
12781
+ this.clearContent = true;
12494
12782
  this.required = true;
12495
12783
  this.min = 0;
12496
12784
  this.max = 9000000000000000;
@@ -12567,6 +12855,15 @@ class TextComponent {
12567
12855
  this.control.markAsDirty();
12568
12856
  this.control.markAsTouched();
12569
12857
  this.valueChange.emit(this.value);
12858
+ // Added: return focus to the input. Clearing is the first half of "remove and then type again what I need" —
12859
+ // without this the caret is left on the X button and the next keystroke goes nowhere. The emitted value is
12860
+ // untouched: this control's cleared value is "" and stays "" (A-prime, chunk 9 — text and text-area agree).
12861
+ this.focusInput();
12862
+ }
12863
+ // Added: guarded so it is a no-op before the view exists (a programmatic clear() during init) and on the
12864
+ // server. Nothing downstream depends on it, so a missing element is silently fine.
12865
+ focusInput() {
12866
+ this.inputEl?.nativeElement?.focus();
12570
12867
  }
12571
12868
  enterPressed() {
12572
12869
  this.enterPress.emit();
@@ -12599,11 +12896,11 @@ class TextComponent {
12599
12896
  return "";
12600
12897
  }
12601
12898
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: TextComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
12602
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: TextComponent, isStandalone: false, selector: "spa-text", inputs: { appearance: "appearance", readonly: "readonly", hint: "hint", display: "display", placeholder: "placeholder", value: "value", format: "format", type: "type", width: "width", copyContent: "copyContent", clearContent: "clearContent", required: "required", min: "min", max: "max", regex: "regex", suffix: "suffix", infoMessage: "infoMessage" }, outputs: { valueChange: "valueChange", leave: "leave", enterPress: "enterPress" }, usesOnChanges: true, ngImport: i0, template: "<!-- Simple text input - Password, textarea, and autocomplete moved to separate components -->\n<mat-form-field [class.spa-readonly]=\"readonly\" [hideRequiredMarker]=\"!required\" [hintLabel]=\"hint\" [appearance]=\"appearance ?? 'fill'\" [ngStyle]=\"{'width':width ?? '100%'}\" style=\"margin-right: 5px;\" subscriptSizing=\"dynamic\" (mouseenter)=\"onMouseEnter()\" (mouseleave)=\"onMouseLeave()\">\n <mat-label *ngIf=\"format=='text'\">{{display}}</mat-label>\n <mat-label *ngIf=\"format=='date'\">{{display | date:'dd/MM/yyyy'}}</mat-label>\n <input matInput autocomplete=\"off\" (blur)=\"leaved()\" (keyup.enter)=\"enterPressed()\" [type]=\"type\" [placeholder]=\"computedPlaceholder\" [formControl]=\"control\" [required]=\"required\" [readonly]=\"readonly\"/>\n <mat-error *ngIf=\"control.invalid && (control.dirty || control.touched)\">{{validate(control)}}</mat-error>\n <div matSuffix class=\"suffix-icons\">\n <spa-suffix [label]=\"suffix\" [infoMessage]=\"infoMessage\" [copyContent]=\"copyContent\" [clearContent]=\"clearContent\" [readonly]=\"readonly\" (clearClick)=\"clear()\" [isHovered]=\"isHovered\" [(value)]=\"value\"></spa-suffix>\n </div>\n</mat-form-field>\n", styles: [""], dependencies: [{ kind: "directive", type: i1$3.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i1$3.NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }, { kind: "directive", type: i2$1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i2$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i2$1.RequiredValidator, selector: ":not([type=checkbox])[required][formControlName],:not([type=checkbox])[required][formControl],:not([type=checkbox])[required][ngModel]", inputs: ["required"] }, { kind: "directive", type: i2$1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "component", type: i5.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i5.MatLabel, selector: "mat-label" }, { kind: "directive", type: i5.MatError, selector: "mat-error, [matError]", inputs: ["id"] }, { kind: "directive", type: i5.MatSuffix, selector: "[matSuffix], [matIconSuffix], [matTextSuffix]", inputs: ["matTextSuffix"] }, { kind: "directive", type: i6.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "component", type: SuffixComponent, selector: "spa-suffix", inputs: ["label", "infoMessage", "copyContent", "isHovered", "clearContent", "readonly", "value"], outputs: ["infoClick", "copyClick", "clearClick", "valueChange"] }, { kind: "pipe", type: i1$3.DatePipe, name: "date" }] }); }
12899
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: TextComponent, isStandalone: false, selector: "spa-text", inputs: { appearance: "appearance", readonly: "readonly", hint: "hint", display: "display", placeholder: "placeholder", value: "value", format: "format", type: "type", width: "width", copyContent: "copyContent", clearContent: "clearContent", required: "required", min: "min", max: "max", regex: "regex", suffix: "suffix", infoMessage: "infoMessage" }, outputs: { valueChange: "valueChange", leave: "leave", enterPress: "enterPress" }, viewQueries: [{ propertyName: "inputEl", first: true, predicate: ["inputEl"], descendants: true }], usesOnChanges: true, ngImport: i0, template: "<!-- Simple text input - Password, textarea, and autocomplete moved to separate components -->\n<mat-form-field [class.spa-readonly]=\"readonly\" [hideRequiredMarker]=\"!required\" [hintLabel]=\"hint\" [appearance]=\"appearance ?? 'fill'\" [ngStyle]=\"{'width':width ?? '100%'}\" style=\"margin-right: 5px;\" subscriptSizing=\"dynamic\" (mouseenter)=\"onMouseEnter()\" (mouseleave)=\"onMouseLeave()\">\n <mat-label *ngIf=\"format=='text'\">{{display}}</mat-label>\n <mat-label *ngIf=\"format=='date'\">{{display | date:'dd/MM/yyyy'}}</mat-label>\n <input #inputEl matInput autocomplete=\"off\" (blur)=\"leaved()\" (keyup.enter)=\"enterPressed()\" [type]=\"type\" [placeholder]=\"computedPlaceholder\" [formControl]=\"control\" [required]=\"required\" [readonly]=\"readonly\"/>\n <mat-error *ngIf=\"control.invalid && (control.dirty || control.touched)\">{{validate(control)}}</mat-error>\n <div matSuffix class=\"suffix-icons\">\n <spa-suffix [label]=\"suffix\" [infoMessage]=\"infoMessage\" [copyContent]=\"copyContent\" [clearContent]=\"clearContent\" [readonly]=\"readonly\" (clearClick)=\"clear()\" [isHovered]=\"isHovered\" [alwaysShow]=\"true\" [(value)]=\"value\"></spa-suffix><!-- Changed (text-field-clear-icon): [alwaysShow]=\"true\" \u2014 a typeable box shows its clear X whenever it holds text, hover or not, exactly like the filter box. spa-suffix still gates on !readonly and hasValue. -->\n </div>\n</mat-form-field>\n", styles: [""], dependencies: [{ kind: "directive", type: i1$3.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i1$3.NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }, { kind: "directive", type: i2$1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i2$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i2$1.RequiredValidator, selector: ":not([type=checkbox])[required][formControlName],:not([type=checkbox])[required][formControl],:not([type=checkbox])[required][ngModel]", inputs: ["required"] }, { kind: "directive", type: i2$1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "component", type: i5.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i5.MatLabel, selector: "mat-label" }, { kind: "directive", type: i5.MatError, selector: "mat-error, [matError]", inputs: ["id"] }, { kind: "directive", type: i5.MatSuffix, selector: "[matSuffix], [matIconSuffix], [matTextSuffix]", inputs: ["matTextSuffix"] }, { kind: "directive", type: i6.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "component", type: SuffixComponent, selector: "spa-suffix", inputs: ["label", "infoMessage", "copyContent", "isHovered", "clearContent", "readonly", "alwaysShow", "value"], outputs: ["infoClick", "copyClick", "clearClick", "valueChange"] }, { kind: "pipe", type: i1$3.DatePipe, name: "date" }] }); }
12603
12900
  }
12604
12901
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: TextComponent, decorators: [{
12605
12902
  type: Component,
12606
- args: [{ selector: 'spa-text', standalone: false, template: "<!-- Simple text input - Password, textarea, and autocomplete moved to separate components -->\n<mat-form-field [class.spa-readonly]=\"readonly\" [hideRequiredMarker]=\"!required\" [hintLabel]=\"hint\" [appearance]=\"appearance ?? 'fill'\" [ngStyle]=\"{'width':width ?? '100%'}\" style=\"margin-right: 5px;\" subscriptSizing=\"dynamic\" (mouseenter)=\"onMouseEnter()\" (mouseleave)=\"onMouseLeave()\">\n <mat-label *ngIf=\"format=='text'\">{{display}}</mat-label>\n <mat-label *ngIf=\"format=='date'\">{{display | date:'dd/MM/yyyy'}}</mat-label>\n <input matInput autocomplete=\"off\" (blur)=\"leaved()\" (keyup.enter)=\"enterPressed()\" [type]=\"type\" [placeholder]=\"computedPlaceholder\" [formControl]=\"control\" [required]=\"required\" [readonly]=\"readonly\"/>\n <mat-error *ngIf=\"control.invalid && (control.dirty || control.touched)\">{{validate(control)}}</mat-error>\n <div matSuffix class=\"suffix-icons\">\n <spa-suffix [label]=\"suffix\" [infoMessage]=\"infoMessage\" [copyContent]=\"copyContent\" [clearContent]=\"clearContent\" [readonly]=\"readonly\" (clearClick)=\"clear()\" [isHovered]=\"isHovered\" [(value)]=\"value\"></spa-suffix>\n </div>\n</mat-form-field>\n" }]
12903
+ args: [{ selector: 'spa-text', standalone: false, template: "<!-- Simple text input - Password, textarea, and autocomplete moved to separate components -->\n<mat-form-field [class.spa-readonly]=\"readonly\" [hideRequiredMarker]=\"!required\" [hintLabel]=\"hint\" [appearance]=\"appearance ?? 'fill'\" [ngStyle]=\"{'width':width ?? '100%'}\" style=\"margin-right: 5px;\" subscriptSizing=\"dynamic\" (mouseenter)=\"onMouseEnter()\" (mouseleave)=\"onMouseLeave()\">\n <mat-label *ngIf=\"format=='text'\">{{display}}</mat-label>\n <mat-label *ngIf=\"format=='date'\">{{display | date:'dd/MM/yyyy'}}</mat-label>\n <input #inputEl matInput autocomplete=\"off\" (blur)=\"leaved()\" (keyup.enter)=\"enterPressed()\" [type]=\"type\" [placeholder]=\"computedPlaceholder\" [formControl]=\"control\" [required]=\"required\" [readonly]=\"readonly\"/>\n <mat-error *ngIf=\"control.invalid && (control.dirty || control.touched)\">{{validate(control)}}</mat-error>\n <div matSuffix class=\"suffix-icons\">\n <spa-suffix [label]=\"suffix\" [infoMessage]=\"infoMessage\" [copyContent]=\"copyContent\" [clearContent]=\"clearContent\" [readonly]=\"readonly\" (clearClick)=\"clear()\" [isHovered]=\"isHovered\" [alwaysShow]=\"true\" [(value)]=\"value\"></spa-suffix><!-- Changed (text-field-clear-icon): [alwaysShow]=\"true\" \u2014 a typeable box shows its clear X whenever it holds text, hover or not, exactly like the filter box. spa-suffix still gates on !readonly and hasValue. -->\n </div>\n</mat-form-field>\n" }]
12607
12904
  }], ctorParameters: () => [], propDecorators: { appearance: [{
12608
12905
  type: Input
12609
12906
  }], readonly: [{
@@ -12644,6 +12941,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
12644
12941
  type: Input
12645
12942
  }], infoMessage: [{
12646
12943
  type: Input
12944
+ }], inputEl: [{
12945
+ type: ViewChild,
12946
+ args: ['inputEl']
12647
12947
  }] } });
12648
12948
 
12649
12949
  // Added (token-slot, arbiter ruling A-0901-17): the login tier's minimal bearer-token attacher.
@@ -12667,6 +12967,18 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
12667
12967
  // the login response and the shell route's injector coming up. Measured on shift-spa: 6 untokenised
12668
12968
  // requests and 5 x 401 on EVERY login. The app recovered and looked perfect, which is what made it
12669
12969
  // dangerous enough to need a ruling.
12970
+ // Changed (cold-reload 401, option D): the attacher BODY moved VERBATIM to `tin-spa/http`
12971
+ // (http/src/lib/token-attacher.ts) as `createTinTokenAttacher(getToken)`, parameterised on its only
12972
+ // dependency. Both deliberate deviations and all of their reasoning travelled with it — read that file
12973
+ // before changing the attach rules. This initializer now supplies the LOGIN tier's token source, and
12974
+ // nothing more.
12975
+ //
12976
+ // 🔴 THIS FILE IS STILL LOAD-BEARING AND MUST NOT BE REMOVED AS "UNUSED" — the note below predates
12977
+ // option D and still stands. AuthService now installs the same attacher from its own constructor, which
12978
+ // makes this registration redundant on every path where AuthService is constructed first; but the slot is
12979
+ // write-once, so a second install is a silent no-op, and the login tier keeps its own explicit,
12980
+ // route-scoped guarantee that the attacher is in place during route RECOGNITION. ONE body, TWO token
12981
+ // sources: that is the law of the pair (A-0901-17) surviving the move, not being retired by it.
12670
12982
  // Added: add this to the LOGIN route's providers array (alongside the rest of LOGIN_PROVIDERS). It runs
12671
12983
  // during route RECOGNITION — before canMatch, before any component — so it is in place well ahead of the
12672
12984
  // first authenticated request the login flow can produce.
@@ -12676,30 +12988,7 @@ const TIN_LOGIN_TIER_HTTP_PROVIDERS = makeEnvironmentProviders([
12676
12988
  multi: true,
12677
12989
  useValue: () => {
12678
12990
  const auth = inject(AuthService);
12679
- setTinFallbackTokenAttacher((req) => {
12680
- const token = auth.tokenSource.value;
12681
- const url = req.url || '';
12682
- // DELIBERATE DEVIATION 1 from LoaderInterceptor.addToken — this is CONDITIONAL on a non-empty
12683
- // token, where addToken is not. addToken clones unconditionally and therefore sends a bare
12684
- // "Bearer " when tokenSource is still empty. That is not hypothetical: it is the measured
12685
- // accounts-prefetch-401 defect written up at length in accounting.service.ts:2308-2326, where a
12686
- // prefetch fired off `loggedinObserv` beat the token into tokenSource and answered 401 every
12687
- // time. This attacher lives ENTIRELY inside that same pre-token window, so it would hit the bug
12688
- // constantly rather than occasionally. Returning the request unchanged is also what preserves
12689
- // the fail-open guarantee for the LOGIN POST itself, which must go out anonymously.
12690
- //
12691
- // DELIBERATE DEVIATION 2 — the refresh/revoke exclusion is INLINE here rather than at the call
12692
- // site. In LoaderInterceptor the same test sits in intercept() around the addToken() call
12693
- // (loader-interceptor.service.ts, the `url.includes('User/refresh') || url.includes('User/revoke')`
12694
- // line). There is no equivalent call site here — TinRootInterceptor's fallback branch is
12695
- // deliberately dumb — so the rule has to travel with the attacher. Those endpoints are
12696
- // AllowAnonymous and authenticate from the request BODY; a stale bearer on them is at best noise
12697
- // and at worst a rejected refresh.
12698
- if (!token || url.includes('User/refresh') || url.includes('User/revoke')) {
12699
- return req;
12700
- }
12701
- return req.clone({ setHeaders: { Authorization: `Bearer ${token}` } });
12702
- });
12991
+ setTinFallbackTokenAttacher(createTinTokenAttacher(() => auth.tokenSource.value));
12703
12992
  }
12704
12993
  }
12705
12994
  ]);
@@ -13248,6 +13537,31 @@ class MenuSourceService {
13248
13537
  return false;
13249
13538
  return !!this.role[cap.name] && !!cap.showMenu && this.isFeatureAllowed(cap);
13250
13539
  }
13540
+ // Added (A-0902-64): does this group still have anything under it the user may open? The side nav renders
13541
+ // a GROUP on the parent's own capability and its ENTRIES on each entry's capability, and those are not the
13542
+ // same capability — the Inventory group keys on cap34 while its dashboard section keys on cap35, and
13543
+ // test/pulse's `HR Officer` holds the dashboard cap without the entity cap. So a group could render with
13544
+ // every row inside it denied: an empty expander saying the area exists while the dashboard says it does not.
13545
+ //
13546
+ // The rule (Arbiter A-0902-64): hide a group only when EVERY entry under it is denied. One permitted entry
13547
+ // keeps the group AND its dead siblings — those land on the no-access page, which exists for exactly that.
13548
+ //
13549
+ // No second mechanism: the entry test is isVisible(), the exact conjunction the nav template applies per
13550
+ // row (myRole[sub.name] && sub.showMenu && isFeatureAllowed(sub)), so showMenu, the tenant module and the
13551
+ // subscription feature all count as denial here identically to how they count there.
13552
+ //
13553
+ // Returns TRUE — "keep rendering" — for anything that is not a group with enumerated entries: a null cap, a
13554
+ // standalone destination, or a group carrying no entries at all. Those keep today's behaviour exactly.
13555
+ // Deliberately NOT cached: subItemsOf's WeakMap caches static config, but the role map is live and a silent
13556
+ // refresh or a re-login inside the same shell must repaint the menu.
13557
+ hasVisibleEntry(capItem) {
13558
+ if (!capItem || !capItem.capSubItems || capItem.ignoreSubsDisplay)
13559
+ return true;
13560
+ const entries = this.subItemsOf(capItem);
13561
+ if (!entries.length)
13562
+ return true;
13563
+ return entries.some(sub => this.isVisible(sub));
13564
+ }
13251
13565
  // The whole visible menu, in the SAME ORDER the side nav renders it (appConfig.capItems, unreversed —
13252
13566
  // 'side-modern' does not reverse; only the legacy top nav does). Computed on demand and returned fresh,
13253
13567
  // so callers must store the result rather than bind this method in a template.
@@ -13624,5 +13938,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
13624
13938
  * Generated bundle index. Do not edit.
13625
13939
  */
13626
13940
 
13627
- export { Account, AccountingService, Action, AlertConfig, AlertMessage, ApiErrorService, ApiResponse, AppConfig, AssetStatus, AuthService, BottomTab, CacheConfig, CapItem, ChangeUserPassword, ChartConfig, ConfigService, Constants, Core, CreditNoteStatus, DataServiceLib, DateBounds, DetailsDialogConfig, DetailsDialogProcessor, DetailsSource, FilterChipConfig, FiscalPeriodStatus, FormConfig, GRID_MENU_ROUTE, GRID_MENU_TAB, GeneralService, HttpService, InventoryService, InvoiceItemType, InvoiceStatus, LastRouteService, ListDialogConfig, LoansService, LogLevel, LogService, MenuSourceService, MessageService, MovementType, NotesConfig, NotificationsService, OfflineService, PageConfig, PaginationConfig, PayrollService, PreferencesService, PrivacyDialogComponent, ProductionService, Profile, PurchaseStatus, PurchasingService, QuoteStatus, REDACTED, ReceiptDialogComponent, ReceiptStatus, RedactingSignalRLogger, Register, Role, RoleAccess, SalesService, SearchConfig, SecurityConfig, SetupService, SignalRService, SignupData, SpaCoreModule, SpaMatModule, Step, StepConfig, StorageService, SubscriptionService, SuffixComponent, TIN_INLINE_ERRORS, TIN_LOGIN_TIER_HTTP_PROVIDERS, TIN_SILENT_REQUEST, TIN_SPA_RUNTIME_CONFIG, TableConfig, TermsDialogComponent, TextComponent, TextMaskComponent, TileConfig, TransactionTiming, UnitOfMeasure, User, derivePlaceholder, describeError, describeShape, dialogOptions, getTinSpaAppSettings, isInlineErrorRequest, isSilentRequest, loadTinSpaAppSettings, loginConfig, messageDialog, phraseSatisfied, provideTinSpaRuntime, redactSecrets, silentContext, tinRequestContext, tinSpaLocationStrategyFactory, tinSpaMsalInstanceFactory, tinSpaRuntimeConfigFactory };
13941
+ export { Account, AccountingService, Action, AlertConfig, AlertMessage, ApiErrorService, ApiResponse, AppConfig, AssetStatus, AuthService, BottomTab, CacheConfig, CapItem, ChangeUserPassword, ChartConfig, ConfigService, Constants, Core, CreditNoteStatus, DashboardConfig, DataServiceLib, DateBounds, DetailsDialogConfig, DetailsDialogProcessor, DetailsSource, FilterChipConfig, FiscalPeriodStatus, FormConfig, GRID_MENU_ROUTE, GRID_MENU_TAB, GeneralService, HttpService, InventoryService, InvoiceItemType, InvoiceStatus, LastRouteService, ListDialogConfig, LoansService, LogLevel, LogService, MenuSourceService, MessageService, MovementType, NotesConfig, NotificationsService, OfflineService, PageConfig, PaginationConfig, PayrollService, PreferencesService, PrivacyDialogComponent, ProductionService, Profile, PurchaseStatus, PurchasingService, QuoteStatus, REDACTED, ReceiptDialogComponent, ReceiptStatus, RedactingSignalRLogger, Register, Role, RoleAccess, SalesService, SearchConfig, SecurityConfig, SetupService, SignalRService, SignupData, SpaCoreModule, SpaMatModule, Step, StepConfig, StorageService, SubscriptionService, SuffixComponent, TIN_INLINE_ERRORS, TIN_LOGIN_TIER_HTTP_PROVIDERS, TIN_SILENT_REQUEST, TIN_SPA_RUNTIME_CONFIG, TableConfig, TermsDialogComponent, TextComponent, TextMaskComponent, TileConfig, TransactionTiming, UnitOfMeasure, User, derivePlaceholder, describeError, describeShape, dialogOptions, getTinSpaAppSettings, isInlineErrorRequest, isSilentRequest, loadTinSpaAppSettings, loginConfig, messageDialog, phraseSatisfied, provideTinSpaRuntime, redactSecrets, silentContext, tinRequestContext, tinSpaLocationStrategyFactory, tinSpaRuntimeConfigFactory };
13628
13942
  //# sourceMappingURL=tin-spa-core.mjs.map