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.
- package/core/index.d.ts +80 -10
- package/fesm2022/{tin-spa-accounting.module-Cg4vWaJE.mjs → tin-spa-accounting.module-CamqAeW_.mjs} +71 -83
- package/fesm2022/tin-spa-accounting.module-CamqAeW_.mjs.map +1 -0
- package/fesm2022/{tin-spa-agent-page.component-DJvMPjCQ.mjs → tin-spa-agent-page.component-DTYn54p0.mjs} +2 -2
- package/fesm2022/{tin-spa-agent-page.component-DJvMPjCQ.mjs.map → tin-spa-agent-page.component-DTYn54p0.mjs.map} +1 -1
- package/fesm2022/{tin-spa-agent.component-D0CGGnGZ.mjs → tin-spa-agent.component-CZKMWl3r.mjs} +2 -2
- package/fesm2022/{tin-spa-agent.component-D0CGGnGZ.mjs.map → tin-spa-agent.component-CZKMWl3r.mjs.map} +1 -1
- package/fesm2022/{tin-spa-assets.module-qIWNwJ2E.mjs → tin-spa-assets.module-BcOjlhfm.mjs} +16 -11
- package/fesm2022/tin-spa-assets.module-BcOjlhfm.mjs.map +1 -0
- package/fesm2022/tin-spa-core.mjs +426 -112
- package/fesm2022/tin-spa-core.mjs.map +1 -1
- package/fesm2022/{tin-spa-general-erp.module-CoYxceLj.mjs → tin-spa-general-erp.module-b6SjnWUM.mjs} +2 -2
- package/fesm2022/{tin-spa-general-erp.module-CoYxceLj.mjs.map → tin-spa-general-erp.module-b6SjnWUM.mjs.map} +1 -1
- package/fesm2022/{tin-spa-hr.module-CKPZ9ZOj.mjs → tin-spa-hr.module-DkGVK2lX.mjs} +2 -2
- package/fesm2022/{tin-spa-hr.module-CKPZ9ZOj.mjs.map → tin-spa-hr.module-DkGVK2lX.mjs.map} +1 -1
- package/fesm2022/tin-spa-http.mjs +32 -1
- package/fesm2022/tin-spa-http.mjs.map +1 -1
- package/fesm2022/{tin-spa-inventory.module-DRKAcNau.mjs → tin-spa-inventory.module-CYWBqSCd.mjs} +16 -11
- package/fesm2022/tin-spa-inventory.module-CYWBqSCd.mjs.map +1 -0
- package/fesm2022/{tin-spa-loans.module-BTDmgrWo.mjs → tin-spa-loans.module-BDh-T-Ng.mjs} +2 -2
- package/fesm2022/{tin-spa-loans.module-BTDmgrWo.mjs.map → tin-spa-loans.module-BDh-T-Ng.mjs.map} +1 -1
- package/fesm2022/{tin-spa-manufacturing.module-CHta6oV_.mjs → tin-spa-manufacturing.module-9Ti-BFb2.mjs} +2 -2
- package/fesm2022/{tin-spa-manufacturing.module-CHta6oV_.mjs.map → tin-spa-manufacturing.module-9Ti-BFb2.mjs.map} +1 -1
- package/fesm2022/{tin-spa-payroll.module-CZ_GO1jF.mjs → tin-spa-payroll.module-54Efumv_.mjs} +9 -6
- package/fesm2022/{tin-spa-payroll.module-CZ_GO1jF.mjs.map → tin-spa-payroll.module-54Efumv_.mjs.map} +1 -1
- package/fesm2022/{tin-spa-purchasing.module-NCdwquaF.mjs → tin-spa-purchasing.module-Bq8JnsyM.mjs} +16 -11
- package/fesm2022/tin-spa-purchasing.module-Bq8JnsyM.mjs.map +1 -0
- package/fesm2022/{tin-spa-sales.module-D0uEGJW7.mjs → tin-spa-sales.module-nyrJulzD.mjs} +40 -17
- package/fesm2022/tin-spa-sales.module-nyrJulzD.mjs.map +1 -0
- package/fesm2022/{tin-spa-tenancy-erp.module-GwIv8Wbc.mjs → tin-spa-tenancy-erp.module-DGATA-p_.mjs} +2 -2
- package/fesm2022/{tin-spa-tenancy-erp.module-GwIv8Wbc.mjs.map → tin-spa-tenancy-erp.module-DGATA-p_.mjs.map} +1 -1
- package/fesm2022/{tin-spa-tin-spa-Cqe5hYTr.mjs → tin-spa-tin-spa-BMy59BVn.mjs} +1428 -219
- package/fesm2022/tin-spa-tin-spa-BMy59BVn.mjs.map +1 -0
- package/fesm2022/tin-spa.mjs +1 -1
- package/http/index.d.ts +3 -1
- package/index.d.ts +144 -24
- package/package.json +1 -1
- package/src/tin-styles.css +27 -0
- package/fesm2022/tin-spa-accounting.module-Cg4vWaJE.mjs.map +0 -1
- package/fesm2022/tin-spa-assets.module-qIWNwJ2E.mjs.map +0 -1
- package/fesm2022/tin-spa-inventory.module-DRKAcNau.mjs.map +0 -1
- package/fesm2022/tin-spa-purchasing.module-NCdwquaF.mjs.map +0 -1
- package/fesm2022/tin-spa-sales.module-D0uEGJW7.mjs.map +0 -1
- 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 {
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
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
|
|
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
|
-
//
|
|
2417
|
-
//
|
|
2418
|
-
|
|
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
|
|
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
|
-
|
|
4886
|
-
|
|
4887
|
-
|
|
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
|
-
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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
|
-
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: [
|
|
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: [
|
|
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\"> {{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 &&
|
|
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\"> {{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\"> {{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 &&
|
|
12546
|
+
args: [{ selector: 'spa-suffix', standalone: false, template: "<label *ngIf=\"label\"> {{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
|
-
|
|
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
|
|
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
|
|
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((
|
|
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,
|
|
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
|