tin-spa 20.14.29 → 20.14.40

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.
@@ -8,7 +8,7 @@ import { HttpContextToken, HttpContext, HttpHeaders, HttpClientModule, HTTP_INTE
8
8
  import * as i1$1 from '@angular/router';
9
9
  import { NavigationEnd, Router, RouterModule } from '@angular/router';
10
10
  import Dexie, { liveQuery } from 'dexie';
11
- import { map, tap, catchError, mergeMap, filter, debounceTime, startWith, finalize as finalize$1, take, switchMap as switchMap$1 } from 'rxjs/operators';
11
+ import { map, tap, catchError, mergeMap, filter, shareReplay, debounceTime, startWith, finalize as finalize$1, take, timeout as timeout$1, distinctUntilChanged, switchMap as switchMap$1 } from 'rxjs/operators';
12
12
  import * as i1$2 from '@angular/common';
13
13
  import { PathLocationStrategy, HashLocationStrategy, PlatformLocation, LocationStrategy, APP_BASE_HREF, DecimalPipe, CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
14
14
  import * as i10$1 from '@azure/msal-angular';
@@ -375,7 +375,22 @@ class Core {
375
375
  return false;
376
376
  }
377
377
  static getInitialValue(field) {
378
- if (field.defaultValue) {
378
+ // Changed (E21): was `if (field.defaultValue)` — a TRUTHINESS test, so a deliberately falsy default
379
+ // (0, false, '') was silently ignored and the field fell through to the type default below. For most
380
+ // types that is harmless, because the fallthrough happens to land on the same value anyway (number -> 0,
381
+ // checkbox -> false, text -> ''). For a SELECT it is not: the fallthrough is `return null`, and a null
382
+ // posted into a non-nullable backend enum is rejected at model binding with an HTTP 400 that the user
383
+ // sees as the generic "Something went wrong ... a technical problem in the app" panel
384
+ // (api-error.service.ts:138-144). Nothing is logged server-side, because a binding failure is not an
385
+ // exception — which is exactly why this stayed hidden through a 124k-line API log.
386
+ //
387
+ // Measured before changing it: across ng-space and all four consumer apps exactly 11 fields declare a
388
+ // falsy defaultValue. Seven are number/money/checkbox and are unaffected, their fallthrough already
389
+ // producing the same value. The other four are selects that asked for 0 and were silently getting null:
390
+ // accounting.service.ts:152 itemType, :574 kind, assets.service.ts:25 defaultDepreciationMethod and
391
+ // :115 disposalType — three of them required, so users were being made to re-pick a value the config had
392
+ // already chosen for them. This is therefore behaviour-neutral everywhere except where it repairs a bug.
393
+ if (field.defaultValue !== undefined && field.defaultValue !== null) {
379
394
  if ((field.type == 'date' || field.type == 'datetime') && field.defaultValue == 'now')
380
395
  return this.nowDate(true);
381
396
  return field.defaultValue;
@@ -417,6 +432,30 @@ class Core {
417
432
  const onlyNumbers = value.replace(/[^\d.-]/g, '');
418
433
  return onlyNumbers;
419
434
  }
435
+ // Added: THE multi-value contract — the one place that knows how a multi-select value is shaped.
436
+ // spa-select-multi and spa-text-multi publish their selection as a ';'-delimited string (updateValue does
437
+ // selectedValues.join(';')), and every consumer that needs the parts back — a button's onClick converting to
438
+ // a List<int> DTO, a component re-reading its own bound value — used to re-implement the split inline. That
439
+ // drifted, expensively: piglet's two sale buttons split on ',' instead of ';', so "12;13" parsed to [NaN] →
440
+ // JSON [null] → a 400 from model binding, and NO sale row was ever written. A single selection contains no
441
+ // delimiter, so the one-pig case worked and hid the bug from every test anyone thought to run. Written once.
442
+ //
443
+ // Accepts all three shapes that legitimately reach a consumer:
444
+ // 'a;b' the documented wire shape
445
+ // 12 a bare scalar — one selection that was never stringified
446
+ // [1, 2] an array — any form whose DTO takes a List<int> rewrites the bound property in place, and that
447
+ // array flows straight back down [(value)] on the next change-detection pass
448
+ // Every part is trimmed and empties are dropped, so 'a; ;b' and ' a ;b ' both give ['a','b'].
449
+ // null/undefined/''/0 give [] — the established falsy-means-nothing-selected rule, preserved deliberately.
450
+ // ⚠️ A value CONTAINING ';' is not representable: join(';') is lossy and no escape is defined. The shape is
451
+ // for ids and codes, never free text the operator can type a semicolon into.
452
+ static parseMultiValue(raw) {
453
+ if (Array.isArray(raw))
454
+ return raw.filter(v => v !== null && v !== undefined && String(v).trim() !== '').map(v => String(v).trim());
455
+ if (!raw)
456
+ return [];
457
+ return String(raw).split(';').filter(v => v.trim() !== '').map(v => v.trim()); // String() first, so a bare scalar never throws "split is not a function"
458
+ }
420
459
  static getFirstDayOfMonth() {
421
460
  const date = new Date();
422
461
  return new Date(date.getFullYear(), date.getMonth(), 1);
@@ -439,20 +478,13 @@ class Core {
439
478
  }
440
479
  }
441
480
  static isValidEmailList(list) {
442
- if (list.includes(";")) {
443
- let emails = list.split(";");
444
- for (var email of emails) {
445
- if (this.emailIsValid(email) == false) {
446
- return false;
447
- }
448
- }
449
- }
450
- else {
451
- if (this.emailIsValid(list) == false) {
452
- return false;
453
- }
454
- }
455
- return true;
481
+ // Changed: was an inline split(";") behind an includes(";") branch — parseMultiValue is the one owner of the
482
+ // delimiter now, and because it TRIMS, "a@b.com; c@d.com" no longer fails on the space after the semicolon
483
+ // (the previous code validated " c@d.com" verbatim, which no email pattern accepts — a real false negative).
484
+ const emails = this.parseMultiValue(list);
485
+ if (!emails.length)
486
+ return this.emailIsValid(list); // preserves the old behaviour for ''/null — an empty list is not silently "valid"
487
+ return emails.every(email => this.emailIsValid(email));
456
488
  }
457
489
  static { this.nullDate = "01 Jan 1969"; }
458
490
  static isNumber(value) {
@@ -3715,6 +3747,7 @@ class DataServiceLib {
3715
3747
  this.capEmployees = new CapItem;
3716
3748
  this.capDepartments = new CapItem;
3717
3749
  this.capPositions = new CapItem;
3750
+ this.capUnlock = new CapItem; // Added: cap87 — the ILock unlock gate, checked by the BASE controller rather than by any one screen
3718
3751
  this.capGrades = new CapItem;
3719
3752
  this.capPayroll = new CapItem; // Changed: Added Payroll module sidebar items
3720
3753
  this.capSalaryStructures = new CapItem;
@@ -3801,6 +3834,11 @@ class DataServiceLib {
3801
3834
  includeAudit: true,
3802
3835
  };
3803
3836
  this.editDepartmentButton = { name: 'edit', dialog: true, action: { url: 'departments?action=edit', method: 'post', successMessage: 'Edited Successfully' } };
3837
+ // Added: releases the lock on a locked Department row. `visible` reads the `locked` field the controller
3838
+ // projects (DepartmentsController:41), so the button exists only where it can do something. The capability is
3839
+ // passed as the OBJECT, not `.name` — datalib assigns "cap87" in the constructor, after this initializer runs,
3840
+ // so an eagerly-read name would be "" and gate nothing. Full mirrors the server's own bar exactly.
3841
+ this.unlockDepartmentButton = { name: 'unlock', display: 'Unlock', tip: 'Unlock Record', icon: { name: 'lock_open' }, visible: x => x.locked, capability: this.capUnlock, requiredAccess: RoleAccess.Full, confirm: { message: 'Unlock this record? It will become editable and deletable.', confirmLabel: 'Unlock', cancelLabel: 'Keep locked' }, action: { url: 'departments?action=unlock', method: 'post' } };
3804
3842
  this.departmentDetailsConfig = {
3805
3843
  formConfig: this.departmentFormConfig,
3806
3844
  heroField: 'departmentID',
@@ -3820,6 +3858,8 @@ class DataServiceLib {
3820
3858
  includeAudit: true,
3821
3859
  };
3822
3860
  this.editPositionButton = { name: 'edit', dialog: true, action: { url: 'positions?action=edit', method: 'post', successMessage: 'Edited Successfully' } };
3861
+ // Added: the Position twin of unlockDepartmentButton — same gate, same shape, its own URL. `locked` is projected at PositionsController:42
3862
+ this.unlockPositionButton = { name: 'unlock', display: 'Unlock', tip: 'Unlock Record', icon: { name: 'lock_open' }, visible: x => x.locked, capability: this.capUnlock, requiredAccess: RoleAccess.Full, confirm: { message: 'Unlock this record? It will become editable and deletable.', confirmLabel: 'Unlock', cancelLabel: 'Keep locked' }, action: { url: 'positions?action=unlock', method: 'post' } };
3823
3863
  this.positionDetailsConfig = {
3824
3864
  formConfig: this.positionFormConfig,
3825
3865
  heroField: 'positionID',
@@ -4074,6 +4114,7 @@ class DataServiceLib {
4074
4114
  },
4075
4115
  this.editPositionButton,
4076
4116
  { name: 'delete', dialog: true, action: { url: 'positions?action=delete', method: 'post' } },
4117
+ this.unlockPositionButton, // Added: last, beside delete — hidden on every unlocked row, so the action column is unchanged for normal data
4077
4118
  ],
4078
4119
  loadAction: { url: 'positions/all/x' },
4079
4120
  formConfig: this.positionFormConfig,
@@ -4117,6 +4158,7 @@ class DataServiceLib {
4117
4158
  },
4118
4159
  this.editDepartmentButton,
4119
4160
  { name: 'delete', dialog: true, action: { url: 'departments?action=delete', method: 'post' } },
4161
+ this.unlockDepartmentButton, // Added: last, beside delete — hidden on every unlocked row, so the action column is unchanged for normal data
4120
4162
  ],
4121
4163
  loadAction: { url: 'departments/all/x' },
4122
4164
  formConfig: this.departmentFormConfig,
@@ -4720,7 +4762,17 @@ class DataServiceLib {
4720
4762
  this.capHR.moduleKey = "hr"; // Added (Setup v3)
4721
4763
  this.capHR.link = "home/hr/employees";
4722
4764
  this.capHR.icon = "diversity_3";
4723
- this.capHR.capSubItems = [this.capDepartments, this.capPositions, this.capEmployees,];
4765
+ this.capHR.capSubItems = [this.capDepartments, this.capPositions, this.capUnlock, this.capEmployees,]; // Changed: capUnlock sits beside the two ILock entities it releases, so the grant is found where it is used
4766
+ // Added: cap87 — releases the ILock protection (`POST /api/{entity}?action=unlock`, TinWeb 51f30e4). It is a
4767
+ // PERMISSION, not a page: no link, and showMenu = false keeps it out of every nav rendering while the Roles
4768
+ // screen (which does not filter on showMenu) still renders its access level. It lives under HR because the
4769
+ // only two ILock entities today are Department (cap19) and Position (cap20), but the server checks it on the
4770
+ // BASE controller, so a future ILock entity anywhere is covered by this one grant rather than a new column.
4771
+ // Deliberately in NO role template — Owner/Admin hold it because they hold every cap; anyone else is granted
4772
+ // it on purpose, which is the point of gating an irreversible action.
4773
+ this.capUnlock.name = "cap87";
4774
+ this.capUnlock.display = "Unlock Records";
4775
+ this.capUnlock.showMenu = false;
4724
4776
  this.capEmployees.name = "cap18";
4725
4777
  this.capEmployees.display = "Employees";
4726
4778
  this.capEmployees.link = "home/hr/employees";
@@ -5070,6 +5122,13 @@ class DataServiceLib {
5070
5122
  get modernDialogs() {
5071
5123
  return this.appConfig?.dialogStyle === 'modern';
5072
5124
  }
5125
+ // Added: the single place the library asks "may I name my own destructive prompts?". Read at prompt time
5126
+ // rather than cached, for the same reason as modernDialogs — an app assigns appConfig in its own service
5127
+ // constructor. Absent means false, so naming is a deliberate act and never an accident: an untracked consumer
5128
+ // that has never heard of this flag keeps getting "Yes" / "No" on every built-in delete confirmation.
5129
+ get namedDestructiveActions() {
5130
+ return this.appConfig?.namedDestructiveActions === true;
5131
+ }
5073
5132
  // Added: panel class for the overlay pane. Dialogs apply this so surface-level styling (radius, shadow,
5074
5133
  // header band) can live in tin-styles.css, which every consumer app already loads globally — component CSS
5075
5134
  // cannot reach the Material surface because it is an ancestor of the component's own view.
@@ -5355,6 +5414,16 @@ class messageDialog {
5355
5414
  this.data = data;
5356
5415
  this.dataService = dataService;
5357
5416
  this.modern = false; // Added: appConfig.dialogStyle === 'modern'; false keeps the long-standing presentation
5417
+ // Added: this confirmation destroys or undoes something. Drives COLOUR ONLY — never the labels, never the ids,
5418
+ // never whether the buttons exist. Default false, so a caller that says nothing renders exactly as before.
5419
+ //
5420
+ // Why colour has to key off this rather than off button position: the classic style hardcodes `color: green`
5421
+ // on the affirmative button and `color: red` on the dismissive one, an assumption that only holds while the
5422
+ // buttons read "Yes" and "No" and the affirmative answer is the harmless one. The moment the affirmative
5423
+ // button is NAMED — "Delete" — that mapping renders a green Delete beside a red Keep, i.e. it points the
5424
+ // user at the destructive action and warns them off the safe one. Destructiveness is a property of the
5425
+ // ACTION, so that is what the colour must follow.
5426
+ this.destructive = false;
5358
5427
  // Added: splits the body into paragraphs on blank lines. The messages themselves already separate "what
5359
5428
  // happened" from "what to do next" that way, but the old template rendered the whole thing as one run of
5360
5429
  // text — which is precisely why nobody read past the first line. The first paragraph is styled as a lead,
@@ -5372,12 +5441,16 @@ class messageDialog {
5372
5441
  this._confirmLabel = this.data.confirmLabel; // Added: optional caller-supplied action label for the affirmative button
5373
5442
  this._cancelLabel = this.data.cancelLabel; // Added: optional caller-supplied label for the dismissive button
5374
5443
  this._okLabel = this.data.okLabel; // Added: optional caller-supplied label for the acknowledge button
5444
+ this.destructive = this.data.destructive === true; // Added: strict true — an absent flag must never colour a prompt as dangerous
5375
5445
  // Added: resolve the style once, at open time. The panel class carries surface-level styling (radius,
5376
5446
  // shadow) that component CSS cannot reach, because the Material surface is an ancestor of this view.
5377
5447
  this.modern = !!this.dataService?.modernDialogs;
5378
5448
  if (this.modern)
5379
5449
  this.dialogRef.addPanelClass('tin-dialog-modern');
5380
5450
  }
5451
+ // Added: for a destructive confirm the icon becomes a warning rather than the neutral question mark. The
5452
+ // question mark is right for "Switch organisation?"; it is too quiet for "Delete this shelter?".
5453
+ get iconName() { return this.isConfirm && this.destructive ? 'warning_amber' : this.spec.icon; }
5381
5454
  // Added: per-type presentation. Kept as a lookup rather than scattered *ngIf branches so a new type is one
5382
5455
  // entry here instead of an edit in three places — the old template hardcoded its heading per type, which is
5383
5456
  // exactly how the caller-supplied subject came to be silently dropped for errors.
@@ -5389,7 +5462,6 @@ class messageDialog {
5389
5462
  confirm: { icon: 'help_outline', title: 'Just confirming' },
5390
5463
  }; }
5391
5464
  get spec() { return messageDialog.TYPES[this.messageType] ?? messageDialog.TYPES.info; }
5392
- get iconName() { return this.spec.icon; }
5393
5465
  // Changed: the caller's subject now wins for EVERY type. Previously the heading was hardcoded per type and
5394
5466
  // the subject was only rendered for 'info', so a friendly title like "No connection" never appeared on an
5395
5467
  // error — the user saw the bare word "Error" instead.
@@ -5435,11 +5507,11 @@ class messageDialog {
5435
5507
  this.dialogRef.close(resp);
5436
5508
  }
5437
5509
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: messageDialog, deps: [{ token: i4.MatDialogRef }, { token: MAT_DIALOG_DATA }, { token: DataServiceLib, optional: true }], target: i0.ɵɵFactoryTarget.Component }); }
5438
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: messageDialog, isStandalone: false, selector: "lib-app-message", ngImport: i0, template: "<!-- Changed: two presentations behind one component. `modern` (appConfig.dialogStyle === 'modern') renders the\n structured layout; anything else renders the long-standing look, so an app that never opts in still gets a\n familiar dialog. Changed: classic is a SUPPORTED escape hatch, not frozen legacy \u2014 it is expected to render\n the same information as modern, and its three rendering bugs were repaired on 2026-08-06. Button ids\n (btnYes / btnNo / btnOK) are identical in BOTH branches \u2014 existing E2E specs select on them and must not\n care which style is active. -->\n\n<ng-container *ngIf=\"modern; else classic\">\n\n <!-- Rebuilt from a hardcoded per-type heading plus one undifferentiated paragraph into a structured layout \u2014\n icon chip, real title, lead paragraph, supporting detail. The type drives colour via a single class on\n the root, so adding a type is one entry in the component rather than three template branches. -->\n <div class=\"tin-msg\" [ngClass]=\"'tin-msg--' + messageType\">\n\n <div class=\"tin-msg__head\">\n <!-- The icon carries the type at a glance. It sits in a soft tinted disc rather than on a saturated\n banner: the intent is to inform, not to alarm someone who has usually done nothing wrong. -->\n <div class=\"tin-msg__icon\">\n <mat-icon>{{ iconName }}</mat-icon>\n </div>\n <h2 class=\"tin-msg__title\">{{ title }}</h2>\n </div>\n\n <mat-dialog-content class=\"tin-msg__body\">\n <!-- First paragraph is the lead (what happened); the rest is supporting detail (what to do next).\n Splitting on blank lines is what gives the eye somewhere to land \u2014 the old single run of text is\n why nobody read past the first line. -->\n <p *ngFor=\"let p of paragraphs; let first = first\" class=\"tin-msg__p\" [class.tin-msg__p--lead]=\"first\">{{ p }}</p>\n\n <!-- The backend's correlation code. Deliberately the quietest thing in the dialog \u2014 the user does not\n need to act on it, they only need to be able to read it out if they call support, at which point it\n turns a \"something broke this morning\" report into one log row. Hidden entirely when absent. -->\n <p class=\"tin-msg__ref\" *ngIf=\"reference\">If you contact your administrator, quote reference <span class=\"tin-msg__ref-code\">{{ reference }}</span>.</p>\n </mat-dialog-content>\n\n <mat-dialog-actions class=\"tin-msg__actions\">\n <!-- Changed: labels reverted to \"No\" / \"Yes\" (owner's call, 2026-08-06). The redesign had shipped them as\n \"Cancel\" / \"Yes, continue\"; these are the most-clicked buttons in the app and the original wording is\n what everybody here reads. Text only \u2014 the ids btnNo / btnYes are untouched and identical to the\n classic branch, so every E2E selector (#btnYes) and every caller is unaffected.\n Changed: the literal text is now {{ confirmLabel }} / {{ cancelLabel }} / {{ okLabel }}, which STILL\n render \"Yes\" / \"No\" / \"OK\" unless the caller passes a label. A caller that knows it is confirming a\n destructive action can name it (\"Delete\") so it no longer reads identically to a harmless one. -->\n <ng-container *ngIf=\"isConfirm; else modernAcknowledge\">\n <button id=\"btnNo\" mat-button class=\"tin-msg__btn-quiet\" (click)=\"response('no')\">{{ cancelLabel }}</button>\n <button id=\"btnYes\" mat-flat-button class=\"tin-msg__btn-primary\" (click)=\"response('yes')\" cdkFocusInitial>{{ confirmLabel }}</button>\n </ng-container>\n <ng-template #modernAcknowledge>\n <button id=\"btnOK\" mat-flat-button class=\"tin-msg__btn-primary\" (click)=\"response('ok')\" cdkFocusInitial>{{ okLabel }}</button>\n </ng-template>\n </mat-dialog-actions>\n\n </div>\n\n</ng-container>\n\n\n<!-- Classic: the original LOOK, kept deliberately as the escape hatch (owner's call, 2026-08-06 \u2014 retire was\n recommended and declined). It is the original markup plus FIXES, never style changes, because reverting a\n bug alongside a look would be a regression. It renders the same INFORMATION as the modern branch, in its\n own visual language:\n 1. the caller's subject is used as the heading when one was supplied. The old template hardcoded the\n heading per type and rendered the subject for 'info' only, which is why a friendly title like\n \"No connection\" showed up as the bare word \"Error\".\n 2. 'warning' and 'success' are handled. They did not exist when this markup was written, so they would\n otherwise fall through every branch and render an empty dialog.\n 3. 'error' now has an icon; the reference line is no longer trapped in the error-only branch; and the\n body renders real paragraphs instead of one collapsed run of text.\n \u26A0\uFE0F Anything added to the modern branch above has to be considered here too \u2014 that omission is exactly how\n the three bugs in 3. got here. -->\n\n<ng-template #classic>\n <div class=\"tin-msg-classic\">\n\n <h2>\n <div class=\"tin-between\">\n <mat-label>{{ classicHeading }}</mat-label>\n <!-- Added: the missing 'error' icon. Every other type had one; error \u2014 the type that most needs a cue \u2014\n rendered its heading beside empty flex space. Colour matches the modern branch's error accent\n (#c62828) so the two presentations agree on what \"error\" looks like. -->\n <mat-icon *ngIf=\"messageType=='error'\" style=\"color: #c62828;\">error</mat-icon>\n <mat-icon *ngIf=\"messageType=='confirm'\">question_mark</mat-icon>\n <mat-icon *ngIf=\"messageType=='info'\" style=\"color: steelblue;\">info</mat-icon>\n <mat-icon *ngIf=\"messageType=='warning'\" style=\"color: #ef6c00;\">warning</mat-icon>\n <mat-icon *ngIf=\"messageType=='success'\" style=\"color: #2e7d32;\">check_circle</mat-icon>\n </div>\n </h2>\n\n <mat-dialog-content>\n\n <!-- Changed: was `{{ _messageDetails }}` rendered raw, so the blank-line-separated paragraphs the messages\n are actually written in (api-error.service.ts) collapsed into one undifferentiated run of text. That\n collapse is the original complaint that started WS-7 and it was still live here. Now renders the same\n `paragraphs` split the modern branch uses \u2014 same information, classic's own plain markup. -->\n <div *ngIf=\"messageType=='error'\">\n <p class=\"tin-msg-classic__p\" *ngFor=\"let p of paragraphs\">{{ p }}</p>\n </div>\n\n <div *ngIf=\"messageType!='error'\" class=\"alert alert-secondary\" role=\"alert\">\n <p class=\"tin-msg-classic__p\" *ngFor=\"let p of paragraphs\">{{ p }}</p>\n </div>\n\n <!-- Changed: lifted OUT of the error-only branch above. The reference is a correlation code, not an\n error-specific field \u2014 a warning or info that carried one used to drop it silently. -->\n <div class=\"tin-msg-classic__ref\" *ngIf=\"reference\">Reference: {{ reference }}</div>\n\n </mat-dialog-content>\n\n <mat-dialog-actions>\n\n <!-- Changed: same caller-supplied labels as the modern branch, defaulting identically to \"Yes\" / \"No\" /\n \"OK\". Classic is expected to render the same INFORMATION as modern in its own visual language, so a\n caller that names its action must be honoured here too \u2014 otherwise the next person to pass a label\n would silently get \"Yes\" on every untracked consumer still running classic. Colours and ids untouched. -->\n <button id=\"btnYes\" mat-stroked-button style=\"color: green;\" *ngIf=\"isConfirm\" (click)=\"response('yes')\" cdkFocusInitial>{{ confirmLabel }}</button>\n\n <button id=\"btnNo\" mat-stroked-button style=\"color: red;\" *ngIf=\"isConfirm\" (click)=\"response('no')\">{{ cancelLabel }}</button>\n\n <button id=\"btnOK\" mat-stroked-button *ngIf=\"!isConfirm\" color=\"primary\" (click)=\"response('ok')\" cdkFocusInitial>{{ okLabel }}</button>\n\n </mat-dialog-actions>\n\n </div>\n</ng-template>\n", styles: [":host{display:block}.tin-msg-classic h2{margin:0!important;padding:16px 16px 0!important;font-size:20px!important;font-weight:500!important;line-height:normal!important}.tin-msg-classic .tin-between{display:flex;justify-content:space-between;align-items:center;margin:0!important;padding:0!important}.tin-msg-classic__p{margin:0 0 10px}.tin-msg-classic__p:last-child{margin-bottom:0}.tin-msg-classic__ref{margin-top:10px;font-size:12px;color:#0000008c;letter-spacing:.04em}.tin-msg{--tin-accent: #1565c0;--tin-accent-tint: rgba(21, 101, 192, .1);--tin-title: rgba(0, 0, 0, .87);--tin-body: rgba(0, 0, 0, .6);position:relative;padding:4px 4px 0}.tin-msg:before{content:\"\";position:absolute;top:0;left:0;right:0;height:3px;background:var(--tin-accent);border-radius:3px 3px 0 0}.tin-msg--error{--tin-accent: #c62828;--tin-accent-tint: rgba(198, 40, 40, .1)}.tin-msg--warning{--tin-accent: #ef6c00;--tin-accent-tint: rgba(239, 108, 0, .12)}.tin-msg--info{--tin-accent: #1565c0;--tin-accent-tint: rgba(21, 101, 192, .1)}.tin-msg--success{--tin-accent: #2e7d32;--tin-accent-tint: rgba(46, 125, 50, .12)}.tin-msg--confirm{--tin-accent: #5e35b1;--tin-accent-tint: rgba(94, 53, 177, .1)}.tin-msg__head{display:flex;align-items:center;gap:14px;padding:20px 20px 8px}.tin-msg__icon{flex:0 0 auto;width:40px;height:40px;border-radius:50%;background:var(--tin-accent-tint);display:inline-flex;align-items:center;justify-content:center}.tin-msg__icon .mat-icon,.tin-msg__icon mat-icon{color:var(--tin-accent);font-size:22px;width:22px;height:22px;line-height:22px}.tin-msg__title{margin:0!important;padding:0!important;font-size:18px!important;font-weight:600!important;line-height:1.3!important;letter-spacing:-.1px;color:var(--tin-title)}.tin-msg__body{padding:0 20px 4px 74px!important;max-height:60vh}.tin-msg__p{margin:0 0 12px;font-size:13.5px;line-height:1.6;color:var(--tin-body)}.tin-msg__p--lead{font-size:14.5px;font-weight:500;color:var(--tin-title)}.tin-msg__p:last-child{margin-bottom:0}.tin-msg__ref{margin:14px 0 0;padding-top:10px;border-top:1px solid rgba(0,0,0,.08);font-size:12px;line-height:1.5;color:var(--tin-muted, rgba(0, 0, 0, .55))}.tin-msg__ref-code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:600;letter-spacing:.06em;color:var(--tin-title, rgba(0, 0, 0, .8));white-space:nowrap;-webkit-user-select:all;user-select:all}.tin-msg__actions{display:flex;justify-content:flex-end;gap:8px;padding:12px 20px 16px!important;margin:0!important;min-height:0!important}.tin-msg__btn-primary{background:var(--tin-accent)!important;color:#fff!important;min-width:88px;font-weight:500;letter-spacing:.2px}.tin-msg__btn-quiet{color:var(--tin-body)!important;font-weight:500}.tin-msg__icon{animation:tin-msg-pop .22s cubic-bezier(.2,.7,.3,1) both}@keyframes tin-msg-pop{0%{transform:scale(.82);opacity:0}to{transform:scale(1);opacity:1}}@media (prefers-reduced-motion: reduce){.tin-msg__icon{animation:none}}@media (max-width: 480px){.tin-msg__head{padding:16px 16px 8px;gap:12px}.tin-msg__body{padding:0 16px 4px!important}.tin-msg__actions{padding:12px 16px 14px!important}}\n"], dependencies: [{ kind: "directive", type: i1$2.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: i1$2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1$2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "component", type: i3.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i4$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "directive", type: i4.MatDialogActions, selector: "[mat-dialog-actions], mat-dialog-actions, [matDialogActions]", inputs: ["align"] }, { kind: "directive", type: i4.MatDialogContent, selector: "[mat-dialog-content], mat-dialog-content, [matDialogContent]" }, { kind: "directive", type: i3$1.MatLabel, selector: "mat-label" }] }); }
5510
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: messageDialog, isStandalone: false, selector: "lib-app-message", ngImport: i0, template: "<!-- Changed: two presentations behind one component. `modern` (appConfig.dialogStyle === 'modern') renders the\n structured layout; anything else renders the long-standing look, so an app that never opts in still gets a\n familiar dialog. Changed: classic is a SUPPORTED escape hatch, not frozen legacy \u2014 it is expected to render\n the same information as modern, and its three rendering bugs were repaired on 2026-08-06. Button ids\n (btnYes / btnNo / btnOK) are identical in BOTH branches \u2014 existing E2E specs select on them and must not\n care which style is active. -->\n\n<ng-container *ngIf=\"modern; else classic\">\n\n <!-- Rebuilt from a hardcoded per-type heading plus one undifferentiated paragraph into a structured layout \u2014\n icon chip, real title, lead paragraph, supporting detail. The type drives colour via a single class on\n the root, so adding a type is one entry in the component rather than three template branches. -->\n <!-- Changed: `tin-msg--destructive` re-points the single --tin-accent variable at the warn red. Because every\n accented part (the top rule, the icon disc, the primary button) already reads from that one variable, the\n whole dialog turns to a warning in one class instead of three overrides \u2014 and the affirmative button, which\n is the thing the user is about to press, can no longer come out purple or green on a \"Delete\". -->\n <div class=\"tin-msg\" [ngClass]=\"'tin-msg--' + messageType\" [class.tin-msg--destructive]=\"isConfirm && destructive\">\n\n <div class=\"tin-msg__head\">\n <!-- The icon carries the type at a glance. It sits in a soft tinted disc rather than on a saturated\n banner: the intent is to inform, not to alarm someone who has usually done nothing wrong. -->\n <div class=\"tin-msg__icon\">\n <mat-icon>{{ iconName }}</mat-icon>\n </div>\n <h2 class=\"tin-msg__title\">{{ title }}</h2>\n </div>\n\n <mat-dialog-content class=\"tin-msg__body\">\n <!-- First paragraph is the lead (what happened); the rest is supporting detail (what to do next).\n Splitting on blank lines is what gives the eye somewhere to land \u2014 the old single run of text is\n why nobody read past the first line. -->\n <p *ngFor=\"let p of paragraphs; let first = first\" class=\"tin-msg__p\" [class.tin-msg__p--lead]=\"first\">{{ p }}</p>\n\n <!-- The backend's correlation code. Deliberately the quietest thing in the dialog \u2014 the user does not\n need to act on it, they only need to be able to read it out if they call support, at which point it\n turns a \"something broke this morning\" report into one log row. Hidden entirely when absent. -->\n <p class=\"tin-msg__ref\" *ngIf=\"reference\">If you contact your administrator, quote reference <span class=\"tin-msg__ref-code\">{{ reference }}</span>.</p>\n </mat-dialog-content>\n\n <mat-dialog-actions class=\"tin-msg__actions\">\n <!-- Changed: labels reverted to \"No\" / \"Yes\" (owner's call, 2026-08-06). The redesign had shipped them as\n \"Cancel\" / \"Yes, continue\"; these are the most-clicked buttons in the app and the original wording is\n what everybody here reads. Text only \u2014 the ids btnNo / btnYes are untouched and identical to the\n classic branch, so every E2E selector (#btnYes) and every caller is unaffected.\n Changed: the literal text is now {{ confirmLabel }} / {{ cancelLabel }} / {{ okLabel }}, which STILL\n render \"Yes\" / \"No\" / \"OK\" unless the caller passes a label. A caller that knows it is confirming a\n destructive action can name it (\"Delete\") so it no longer reads identically to a harmless one. -->\n <!-- Changed: on a DESTRUCTIVE confirm the initial focus moves to the SAFE button. The affirmative button is\n still #btnYes in the same position with the same click handler \u2014 only the keyboard default changes, so\n hitting Enter on reflex now keeps the record instead of destroying it. Every E2E locator clicks #btnYes\n explicitly (helpers/form.helper.ts acceptConfirmIfPresent uses page.locator('#btnYes')), so none of them\n depend on which button holds focus. The colour comes from tin-msg--destructive on the root, not from\n anything here \u2014 the primary button reads --tin-accent either way. -->\n <ng-container *ngIf=\"isConfirm; else modernAcknowledge\">\n <button id=\"btnNo\" mat-button class=\"tin-msg__btn-quiet\" [class.tin-msg__btn-safe]=\"destructive\" (click)=\"response('no')\" [attr.cdkFocusInitial]=\"destructive ? '' : null\">{{ cancelLabel }}</button>\n <button id=\"btnYes\" mat-flat-button class=\"tin-msg__btn-primary\" (click)=\"response('yes')\" [attr.cdkFocusInitial]=\"destructive ? null : ''\">{{ confirmLabel }}</button>\n </ng-container>\n <ng-template #modernAcknowledge>\n <button id=\"btnOK\" mat-flat-button class=\"tin-msg__btn-primary\" (click)=\"response('ok')\" cdkFocusInitial>{{ okLabel }}</button>\n </ng-template>\n </mat-dialog-actions>\n\n </div>\n\n</ng-container>\n\n\n<!-- Classic: the original LOOK, kept deliberately as the escape hatch (owner's call, 2026-08-06 \u2014 retire was\n recommended and declined). It is the original markup plus FIXES, never style changes, because reverting a\n bug alongside a look would be a regression. It renders the same INFORMATION as the modern branch, in its\n own visual language:\n 1. the caller's subject is used as the heading when one was supplied. The old template hardcoded the\n heading per type and rendered the subject for 'info' only, which is why a friendly title like\n \"No connection\" showed up as the bare word \"Error\".\n 2. 'warning' and 'success' are handled. They did not exist when this markup was written, so they would\n otherwise fall through every branch and render an empty dialog.\n 3. 'error' now has an icon; the reference line is no longer trapped in the error-only branch; and the\n body renders real paragraphs instead of one collapsed run of text.\n \u26A0\uFE0F Anything added to the modern branch above has to be considered here too \u2014 that omission is exactly how\n the three bugs in 3. got here. -->\n\n<ng-template #classic>\n <div class=\"tin-msg-classic\">\n\n <h2>\n <div class=\"tin-between\">\n <mat-label>{{ classicHeading }}</mat-label>\n <!-- Added: the missing 'error' icon. Every other type had one; error \u2014 the type that most needs a cue \u2014\n rendered its heading beside empty flex space. Colour matches the modern branch's error accent\n (#c62828) so the two presentations agree on what \"error\" looks like. -->\n <mat-icon *ngIf=\"messageType=='error'\" style=\"color: #c62828;\">error</mat-icon>\n <!-- Changed: a destructive confirm shows a red warning instead of the neutral question mark, matching the\n modern branch. Same #c62828 as the error icon beside it, so the two styles agree on what danger looks\n like. A plain confirm (\"Switch organisation?\") is unchanged. -->\n <mat-icon *ngIf=\"messageType=='confirm' && destructive\" style=\"color: #c62828;\">warning</mat-icon>\n <mat-icon *ngIf=\"messageType=='confirm' && !destructive\">question_mark</mat-icon>\n <mat-icon *ngIf=\"messageType=='info'\" style=\"color: steelblue;\">info</mat-icon>\n <mat-icon *ngIf=\"messageType=='warning'\" style=\"color: #ef6c00;\">warning</mat-icon>\n <mat-icon *ngIf=\"messageType=='success'\" style=\"color: #2e7d32;\">check_circle</mat-icon>\n </div>\n </h2>\n\n <mat-dialog-content>\n\n <!-- Changed: was `{{ _messageDetails }}` rendered raw, so the blank-line-separated paragraphs the messages\n are actually written in (api-error.service.ts) collapsed into one undifferentiated run of text. That\n collapse is the original complaint that started WS-7 and it was still live here. Now renders the same\n `paragraphs` split the modern branch uses \u2014 same information, classic's own plain markup. -->\n <div *ngIf=\"messageType=='error'\">\n <p class=\"tin-msg-classic__p\" *ngFor=\"let p of paragraphs\">{{ p }}</p>\n </div>\n\n <div *ngIf=\"messageType!='error'\" class=\"alert alert-secondary\" role=\"alert\">\n <p class=\"tin-msg-classic__p\" *ngFor=\"let p of paragraphs\">{{ p }}</p>\n </div>\n\n <!-- Changed: lifted OUT of the error-only branch above. The reference is a correlation code, not an\n error-specific field \u2014 a warning or info that carried one used to drop it silently. -->\n <div class=\"tin-msg-classic__ref\" *ngIf=\"reference\">Reference: {{ reference }}</div>\n\n </mat-dialog-content>\n\n <mat-dialog-actions>\n\n <!-- Changed: same caller-supplied labels as the modern branch, defaulting identically to \"Yes\" / \"No\" /\n \"OK\". Classic is expected to render the same INFORMATION as modern in its own visual language, so a\n caller that names its action must be honoured here too \u2014 otherwise the next person to pass a label\n would silently get \"Yes\" on every untracked consumer still running classic. Colours and ids untouched. -->\n <!-- \u26A0\uFE0F Changed: THE COLOUR NOW FOLLOWS THE ACTION, NOT THE BUTTON'S POSITION. This markup hardcoded\n `color: green` on the affirmative button and `color: red` on the dismissive one. That mapping is only\n correct while the buttons read \"Yes\" and \"No\" and the affirmative answer is the harmless one \u2014 the\n green was never saying \"this is safe\", it was saying \"this is the Yes\". Name the action and the\n assumption inverts: a confirm offering \"Keep\" / \"Delete\" rendered a GREEN Delete next to a RED Keep,\n colouring the destructive action as the go-ahead and the safe one as the danger. That is the single\n reason the labels and the colours had to ship as one change rather than two.\n Destructive: affirmative red (#c62828, the same red the error icon uses), dismissive neutral \u2014 no\n colour at all, so the red is the only thing on the dialog asking for attention.\n Non-destructive: green/red exactly as before, byte for byte, for every caller that passes nothing.\n ids btnYes / btnNo and the click handlers are untouched in both cases. -->\n <button id=\"btnYes\" mat-stroked-button [style.color]=\"destructive ? '#c62828' : 'green'\" *ngIf=\"isConfirm\" (click)=\"response('yes')\" [attr.cdkFocusInitial]=\"destructive ? null : ''\">{{ confirmLabel }}</button>\n\n <button id=\"btnNo\" mat-stroked-button [style.color]=\"destructive ? null : 'red'\" *ngIf=\"isConfirm\" (click)=\"response('no')\" [attr.cdkFocusInitial]=\"destructive ? '' : null\">{{ cancelLabel }}</button>\n\n <button id=\"btnOK\" mat-stroked-button *ngIf=\"!isConfirm\" color=\"primary\" (click)=\"response('ok')\" cdkFocusInitial>{{ okLabel }}</button>\n\n </mat-dialog-actions>\n\n </div>\n</ng-template>\n", styles: [":host{display:block}.tin-msg-classic h2{margin:0!important;padding:16px 16px 0!important;font-size:20px!important;font-weight:500!important;line-height:normal!important}.tin-msg-classic .tin-between{display:flex;justify-content:space-between;align-items:center;margin:0!important;padding:0!important}.tin-msg-classic__p{margin:0 0 10px}.tin-msg-classic__p:last-child{margin-bottom:0}.tin-msg-classic__ref{margin-top:10px;font-size:12px;color:#0000008c;letter-spacing:.04em}.tin-msg{--tin-accent: #1565c0;--tin-accent-tint: rgba(21, 101, 192, .1);--tin-title: rgba(0, 0, 0, .87);--tin-body: rgba(0, 0, 0, .6);position:relative;padding:4px 4px 0}.tin-msg:before{content:\"\";position:absolute;top:0;left:0;right:0;height:3px;background:var(--tin-accent);border-radius:3px 3px 0 0}.tin-msg--error{--tin-accent: #c62828;--tin-accent-tint: rgba(198, 40, 40, .1)}.tin-msg--warning{--tin-accent: #ef6c00;--tin-accent-tint: rgba(239, 108, 0, .12)}.tin-msg--info{--tin-accent: #1565c0;--tin-accent-tint: rgba(21, 101, 192, .1)}.tin-msg--success{--tin-accent: #2e7d32;--tin-accent-tint: rgba(46, 125, 50, .12)}.tin-msg--confirm{--tin-accent: #5e35b1;--tin-accent-tint: rgba(94, 53, 177, .1)}.tin-msg--destructive{--tin-accent: #c62828;--tin-accent-tint: rgba(198, 40, 40, .1)}.tin-msg__head{display:flex;align-items:center;gap:14px;padding:20px 20px 8px}.tin-msg__icon{flex:0 0 auto;width:40px;height:40px;border-radius:50%;background:var(--tin-accent-tint);display:inline-flex;align-items:center;justify-content:center}.tin-msg__icon .mat-icon,.tin-msg__icon mat-icon{color:var(--tin-accent);font-size:22px;width:22px;height:22px;line-height:22px}.tin-msg__title{margin:0!important;padding:0!important;font-size:18px!important;font-weight:600!important;line-height:1.3!important;letter-spacing:-.1px;color:var(--tin-title)}.tin-msg__body{padding:0 20px 4px 74px!important;max-height:60vh}.tin-msg__p{margin:0 0 12px;font-size:13.5px;line-height:1.6;color:var(--tin-body)}.tin-msg__p--lead{font-size:14.5px;font-weight:500;color:var(--tin-title)}.tin-msg__p:last-child{margin-bottom:0}.tin-msg__ref{margin:14px 0 0;padding-top:10px;border-top:1px solid rgba(0,0,0,.08);font-size:12px;line-height:1.5;color:var(--tin-muted, rgba(0, 0, 0, .55))}.tin-msg__ref-code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:600;letter-spacing:.06em;color:var(--tin-title, rgba(0, 0, 0, .8));white-space:nowrap;-webkit-user-select:all;user-select:all}.tin-msg__actions{display:flex;justify-content:flex-end;gap:8px;padding:12px 20px 16px!important;margin:0!important;min-height:0!important}.tin-msg__btn-primary{background:var(--tin-accent)!important;color:#fff!important;min-width:88px;font-weight:500;letter-spacing:.2px}.tin-msg__btn-quiet{color:var(--tin-body)!important;font-weight:500}.tin-msg__btn-safe{color:var(--tin-title)!important;border:1px solid rgba(0,0,0,.23);font-weight:500}.tin-msg__icon{animation:tin-msg-pop .22s cubic-bezier(.2,.7,.3,1) both}@keyframes tin-msg-pop{0%{transform:scale(.82);opacity:0}to{transform:scale(1);opacity:1}}@media (prefers-reduced-motion: reduce){.tin-msg__icon{animation:none}}@media (max-width: 480px){.tin-msg__head{padding:16px 16px 8px;gap:12px}.tin-msg__body{padding:0 16px 4px!important}.tin-msg__actions{padding:12px 16px 14px!important}}\n"], dependencies: [{ kind: "directive", type: i1$2.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: i1$2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1$2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "component", type: i3.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i4$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "directive", type: i4.MatDialogActions, selector: "[mat-dialog-actions], mat-dialog-actions, [matDialogActions]", inputs: ["align"] }, { kind: "directive", type: i4.MatDialogContent, selector: "[mat-dialog-content], mat-dialog-content, [matDialogContent]" }, { kind: "directive", type: i3$1.MatLabel, selector: "mat-label" }] }); }
5439
5511
  }
5440
5512
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: messageDialog, decorators: [{
5441
5513
  type: Component,
5442
- args: [{ selector: 'lib-app-message', standalone: false, template: "<!-- Changed: two presentations behind one component. `modern` (appConfig.dialogStyle === 'modern') renders the\n structured layout; anything else renders the long-standing look, so an app that never opts in still gets a\n familiar dialog. Changed: classic is a SUPPORTED escape hatch, not frozen legacy \u2014 it is expected to render\n the same information as modern, and its three rendering bugs were repaired on 2026-08-06. Button ids\n (btnYes / btnNo / btnOK) are identical in BOTH branches \u2014 existing E2E specs select on them and must not\n care which style is active. -->\n\n<ng-container *ngIf=\"modern; else classic\">\n\n <!-- Rebuilt from a hardcoded per-type heading plus one undifferentiated paragraph into a structured layout \u2014\n icon chip, real title, lead paragraph, supporting detail. The type drives colour via a single class on\n the root, so adding a type is one entry in the component rather than three template branches. -->\n <div class=\"tin-msg\" [ngClass]=\"'tin-msg--' + messageType\">\n\n <div class=\"tin-msg__head\">\n <!-- The icon carries the type at a glance. It sits in a soft tinted disc rather than on a saturated\n banner: the intent is to inform, not to alarm someone who has usually done nothing wrong. -->\n <div class=\"tin-msg__icon\">\n <mat-icon>{{ iconName }}</mat-icon>\n </div>\n <h2 class=\"tin-msg__title\">{{ title }}</h2>\n </div>\n\n <mat-dialog-content class=\"tin-msg__body\">\n <!-- First paragraph is the lead (what happened); the rest is supporting detail (what to do next).\n Splitting on blank lines is what gives the eye somewhere to land \u2014 the old single run of text is\n why nobody read past the first line. -->\n <p *ngFor=\"let p of paragraphs; let first = first\" class=\"tin-msg__p\" [class.tin-msg__p--lead]=\"first\">{{ p }}</p>\n\n <!-- The backend's correlation code. Deliberately the quietest thing in the dialog \u2014 the user does not\n need to act on it, they only need to be able to read it out if they call support, at which point it\n turns a \"something broke this morning\" report into one log row. Hidden entirely when absent. -->\n <p class=\"tin-msg__ref\" *ngIf=\"reference\">If you contact your administrator, quote reference <span class=\"tin-msg__ref-code\">{{ reference }}</span>.</p>\n </mat-dialog-content>\n\n <mat-dialog-actions class=\"tin-msg__actions\">\n <!-- Changed: labels reverted to \"No\" / \"Yes\" (owner's call, 2026-08-06). The redesign had shipped them as\n \"Cancel\" / \"Yes, continue\"; these are the most-clicked buttons in the app and the original wording is\n what everybody here reads. Text only \u2014 the ids btnNo / btnYes are untouched and identical to the\n classic branch, so every E2E selector (#btnYes) and every caller is unaffected.\n Changed: the literal text is now {{ confirmLabel }} / {{ cancelLabel }} / {{ okLabel }}, which STILL\n render \"Yes\" / \"No\" / \"OK\" unless the caller passes a label. A caller that knows it is confirming a\n destructive action can name it (\"Delete\") so it no longer reads identically to a harmless one. -->\n <ng-container *ngIf=\"isConfirm; else modernAcknowledge\">\n <button id=\"btnNo\" mat-button class=\"tin-msg__btn-quiet\" (click)=\"response('no')\">{{ cancelLabel }}</button>\n <button id=\"btnYes\" mat-flat-button class=\"tin-msg__btn-primary\" (click)=\"response('yes')\" cdkFocusInitial>{{ confirmLabel }}</button>\n </ng-container>\n <ng-template #modernAcknowledge>\n <button id=\"btnOK\" mat-flat-button class=\"tin-msg__btn-primary\" (click)=\"response('ok')\" cdkFocusInitial>{{ okLabel }}</button>\n </ng-template>\n </mat-dialog-actions>\n\n </div>\n\n</ng-container>\n\n\n<!-- Classic: the original LOOK, kept deliberately as the escape hatch (owner's call, 2026-08-06 \u2014 retire was\n recommended and declined). It is the original markup plus FIXES, never style changes, because reverting a\n bug alongside a look would be a regression. It renders the same INFORMATION as the modern branch, in its\n own visual language:\n 1. the caller's subject is used as the heading when one was supplied. The old template hardcoded the\n heading per type and rendered the subject for 'info' only, which is why a friendly title like\n \"No connection\" showed up as the bare word \"Error\".\n 2. 'warning' and 'success' are handled. They did not exist when this markup was written, so they would\n otherwise fall through every branch and render an empty dialog.\n 3. 'error' now has an icon; the reference line is no longer trapped in the error-only branch; and the\n body renders real paragraphs instead of one collapsed run of text.\n \u26A0\uFE0F Anything added to the modern branch above has to be considered here too \u2014 that omission is exactly how\n the three bugs in 3. got here. -->\n\n<ng-template #classic>\n <div class=\"tin-msg-classic\">\n\n <h2>\n <div class=\"tin-between\">\n <mat-label>{{ classicHeading }}</mat-label>\n <!-- Added: the missing 'error' icon. Every other type had one; error \u2014 the type that most needs a cue \u2014\n rendered its heading beside empty flex space. Colour matches the modern branch's error accent\n (#c62828) so the two presentations agree on what \"error\" looks like. -->\n <mat-icon *ngIf=\"messageType=='error'\" style=\"color: #c62828;\">error</mat-icon>\n <mat-icon *ngIf=\"messageType=='confirm'\">question_mark</mat-icon>\n <mat-icon *ngIf=\"messageType=='info'\" style=\"color: steelblue;\">info</mat-icon>\n <mat-icon *ngIf=\"messageType=='warning'\" style=\"color: #ef6c00;\">warning</mat-icon>\n <mat-icon *ngIf=\"messageType=='success'\" style=\"color: #2e7d32;\">check_circle</mat-icon>\n </div>\n </h2>\n\n <mat-dialog-content>\n\n <!-- Changed: was `{{ _messageDetails }}` rendered raw, so the blank-line-separated paragraphs the messages\n are actually written in (api-error.service.ts) collapsed into one undifferentiated run of text. That\n collapse is the original complaint that started WS-7 and it was still live here. Now renders the same\n `paragraphs` split the modern branch uses \u2014 same information, classic's own plain markup. -->\n <div *ngIf=\"messageType=='error'\">\n <p class=\"tin-msg-classic__p\" *ngFor=\"let p of paragraphs\">{{ p }}</p>\n </div>\n\n <div *ngIf=\"messageType!='error'\" class=\"alert alert-secondary\" role=\"alert\">\n <p class=\"tin-msg-classic__p\" *ngFor=\"let p of paragraphs\">{{ p }}</p>\n </div>\n\n <!-- Changed: lifted OUT of the error-only branch above. The reference is a correlation code, not an\n error-specific field \u2014 a warning or info that carried one used to drop it silently. -->\n <div class=\"tin-msg-classic__ref\" *ngIf=\"reference\">Reference: {{ reference }}</div>\n\n </mat-dialog-content>\n\n <mat-dialog-actions>\n\n <!-- Changed: same caller-supplied labels as the modern branch, defaulting identically to \"Yes\" / \"No\" /\n \"OK\". Classic is expected to render the same INFORMATION as modern in its own visual language, so a\n caller that names its action must be honoured here too \u2014 otherwise the next person to pass a label\n would silently get \"Yes\" on every untracked consumer still running classic. Colours and ids untouched. -->\n <button id=\"btnYes\" mat-stroked-button style=\"color: green;\" *ngIf=\"isConfirm\" (click)=\"response('yes')\" cdkFocusInitial>{{ confirmLabel }}</button>\n\n <button id=\"btnNo\" mat-stroked-button style=\"color: red;\" *ngIf=\"isConfirm\" (click)=\"response('no')\">{{ cancelLabel }}</button>\n\n <button id=\"btnOK\" mat-stroked-button *ngIf=\"!isConfirm\" color=\"primary\" (click)=\"response('ok')\" cdkFocusInitial>{{ okLabel }}</button>\n\n </mat-dialog-actions>\n\n </div>\n</ng-template>\n", styles: [":host{display:block}.tin-msg-classic h2{margin:0!important;padding:16px 16px 0!important;font-size:20px!important;font-weight:500!important;line-height:normal!important}.tin-msg-classic .tin-between{display:flex;justify-content:space-between;align-items:center;margin:0!important;padding:0!important}.tin-msg-classic__p{margin:0 0 10px}.tin-msg-classic__p:last-child{margin-bottom:0}.tin-msg-classic__ref{margin-top:10px;font-size:12px;color:#0000008c;letter-spacing:.04em}.tin-msg{--tin-accent: #1565c0;--tin-accent-tint: rgba(21, 101, 192, .1);--tin-title: rgba(0, 0, 0, .87);--tin-body: rgba(0, 0, 0, .6);position:relative;padding:4px 4px 0}.tin-msg:before{content:\"\";position:absolute;top:0;left:0;right:0;height:3px;background:var(--tin-accent);border-radius:3px 3px 0 0}.tin-msg--error{--tin-accent: #c62828;--tin-accent-tint: rgba(198, 40, 40, .1)}.tin-msg--warning{--tin-accent: #ef6c00;--tin-accent-tint: rgba(239, 108, 0, .12)}.tin-msg--info{--tin-accent: #1565c0;--tin-accent-tint: rgba(21, 101, 192, .1)}.tin-msg--success{--tin-accent: #2e7d32;--tin-accent-tint: rgba(46, 125, 50, .12)}.tin-msg--confirm{--tin-accent: #5e35b1;--tin-accent-tint: rgba(94, 53, 177, .1)}.tin-msg__head{display:flex;align-items:center;gap:14px;padding:20px 20px 8px}.tin-msg__icon{flex:0 0 auto;width:40px;height:40px;border-radius:50%;background:var(--tin-accent-tint);display:inline-flex;align-items:center;justify-content:center}.tin-msg__icon .mat-icon,.tin-msg__icon mat-icon{color:var(--tin-accent);font-size:22px;width:22px;height:22px;line-height:22px}.tin-msg__title{margin:0!important;padding:0!important;font-size:18px!important;font-weight:600!important;line-height:1.3!important;letter-spacing:-.1px;color:var(--tin-title)}.tin-msg__body{padding:0 20px 4px 74px!important;max-height:60vh}.tin-msg__p{margin:0 0 12px;font-size:13.5px;line-height:1.6;color:var(--tin-body)}.tin-msg__p--lead{font-size:14.5px;font-weight:500;color:var(--tin-title)}.tin-msg__p:last-child{margin-bottom:0}.tin-msg__ref{margin:14px 0 0;padding-top:10px;border-top:1px solid rgba(0,0,0,.08);font-size:12px;line-height:1.5;color:var(--tin-muted, rgba(0, 0, 0, .55))}.tin-msg__ref-code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:600;letter-spacing:.06em;color:var(--tin-title, rgba(0, 0, 0, .8));white-space:nowrap;-webkit-user-select:all;user-select:all}.tin-msg__actions{display:flex;justify-content:flex-end;gap:8px;padding:12px 20px 16px!important;margin:0!important;min-height:0!important}.tin-msg__btn-primary{background:var(--tin-accent)!important;color:#fff!important;min-width:88px;font-weight:500;letter-spacing:.2px}.tin-msg__btn-quiet{color:var(--tin-body)!important;font-weight:500}.tin-msg__icon{animation:tin-msg-pop .22s cubic-bezier(.2,.7,.3,1) both}@keyframes tin-msg-pop{0%{transform:scale(.82);opacity:0}to{transform:scale(1);opacity:1}}@media (prefers-reduced-motion: reduce){.tin-msg__icon{animation:none}}@media (max-width: 480px){.tin-msg__head{padding:16px 16px 8px;gap:12px}.tin-msg__body{padding:0 16px 4px!important}.tin-msg__actions{padding:12px 16px 14px!important}}\n"] }]
5514
+ args: [{ selector: 'lib-app-message', standalone: false, template: "<!-- Changed: two presentations behind one component. `modern` (appConfig.dialogStyle === 'modern') renders the\n structured layout; anything else renders the long-standing look, so an app that never opts in still gets a\n familiar dialog. Changed: classic is a SUPPORTED escape hatch, not frozen legacy \u2014 it is expected to render\n the same information as modern, and its three rendering bugs were repaired on 2026-08-06. Button ids\n (btnYes / btnNo / btnOK) are identical in BOTH branches \u2014 existing E2E specs select on them and must not\n care which style is active. -->\n\n<ng-container *ngIf=\"modern; else classic\">\n\n <!-- Rebuilt from a hardcoded per-type heading plus one undifferentiated paragraph into a structured layout \u2014\n icon chip, real title, lead paragraph, supporting detail. The type drives colour via a single class on\n the root, so adding a type is one entry in the component rather than three template branches. -->\n <!-- Changed: `tin-msg--destructive` re-points the single --tin-accent variable at the warn red. Because every\n accented part (the top rule, the icon disc, the primary button) already reads from that one variable, the\n whole dialog turns to a warning in one class instead of three overrides \u2014 and the affirmative button, which\n is the thing the user is about to press, can no longer come out purple or green on a \"Delete\". -->\n <div class=\"tin-msg\" [ngClass]=\"'tin-msg--' + messageType\" [class.tin-msg--destructive]=\"isConfirm && destructive\">\n\n <div class=\"tin-msg__head\">\n <!-- The icon carries the type at a glance. It sits in a soft tinted disc rather than on a saturated\n banner: the intent is to inform, not to alarm someone who has usually done nothing wrong. -->\n <div class=\"tin-msg__icon\">\n <mat-icon>{{ iconName }}</mat-icon>\n </div>\n <h2 class=\"tin-msg__title\">{{ title }}</h2>\n </div>\n\n <mat-dialog-content class=\"tin-msg__body\">\n <!-- First paragraph is the lead (what happened); the rest is supporting detail (what to do next).\n Splitting on blank lines is what gives the eye somewhere to land \u2014 the old single run of text is\n why nobody read past the first line. -->\n <p *ngFor=\"let p of paragraphs; let first = first\" class=\"tin-msg__p\" [class.tin-msg__p--lead]=\"first\">{{ p }}</p>\n\n <!-- The backend's correlation code. Deliberately the quietest thing in the dialog \u2014 the user does not\n need to act on it, they only need to be able to read it out if they call support, at which point it\n turns a \"something broke this morning\" report into one log row. Hidden entirely when absent. -->\n <p class=\"tin-msg__ref\" *ngIf=\"reference\">If you contact your administrator, quote reference <span class=\"tin-msg__ref-code\">{{ reference }}</span>.</p>\n </mat-dialog-content>\n\n <mat-dialog-actions class=\"tin-msg__actions\">\n <!-- Changed: labels reverted to \"No\" / \"Yes\" (owner's call, 2026-08-06). The redesign had shipped them as\n \"Cancel\" / \"Yes, continue\"; these are the most-clicked buttons in the app and the original wording is\n what everybody here reads. Text only \u2014 the ids btnNo / btnYes are untouched and identical to the\n classic branch, so every E2E selector (#btnYes) and every caller is unaffected.\n Changed: the literal text is now {{ confirmLabel }} / {{ cancelLabel }} / {{ okLabel }}, which STILL\n render \"Yes\" / \"No\" / \"OK\" unless the caller passes a label. A caller that knows it is confirming a\n destructive action can name it (\"Delete\") so it no longer reads identically to a harmless one. -->\n <!-- Changed: on a DESTRUCTIVE confirm the initial focus moves to the SAFE button. The affirmative button is\n still #btnYes in the same position with the same click handler \u2014 only the keyboard default changes, so\n hitting Enter on reflex now keeps the record instead of destroying it. Every E2E locator clicks #btnYes\n explicitly (helpers/form.helper.ts acceptConfirmIfPresent uses page.locator('#btnYes')), so none of them\n depend on which button holds focus. The colour comes from tin-msg--destructive on the root, not from\n anything here \u2014 the primary button reads --tin-accent either way. -->\n <ng-container *ngIf=\"isConfirm; else modernAcknowledge\">\n <button id=\"btnNo\" mat-button class=\"tin-msg__btn-quiet\" [class.tin-msg__btn-safe]=\"destructive\" (click)=\"response('no')\" [attr.cdkFocusInitial]=\"destructive ? '' : null\">{{ cancelLabel }}</button>\n <button id=\"btnYes\" mat-flat-button class=\"tin-msg__btn-primary\" (click)=\"response('yes')\" [attr.cdkFocusInitial]=\"destructive ? null : ''\">{{ confirmLabel }}</button>\n </ng-container>\n <ng-template #modernAcknowledge>\n <button id=\"btnOK\" mat-flat-button class=\"tin-msg__btn-primary\" (click)=\"response('ok')\" cdkFocusInitial>{{ okLabel }}</button>\n </ng-template>\n </mat-dialog-actions>\n\n </div>\n\n</ng-container>\n\n\n<!-- Classic: the original LOOK, kept deliberately as the escape hatch (owner's call, 2026-08-06 \u2014 retire was\n recommended and declined). It is the original markup plus FIXES, never style changes, because reverting a\n bug alongside a look would be a regression. It renders the same INFORMATION as the modern branch, in its\n own visual language:\n 1. the caller's subject is used as the heading when one was supplied. The old template hardcoded the\n heading per type and rendered the subject for 'info' only, which is why a friendly title like\n \"No connection\" showed up as the bare word \"Error\".\n 2. 'warning' and 'success' are handled. They did not exist when this markup was written, so they would\n otherwise fall through every branch and render an empty dialog.\n 3. 'error' now has an icon; the reference line is no longer trapped in the error-only branch; and the\n body renders real paragraphs instead of one collapsed run of text.\n \u26A0\uFE0F Anything added to the modern branch above has to be considered here too \u2014 that omission is exactly how\n the three bugs in 3. got here. -->\n\n<ng-template #classic>\n <div class=\"tin-msg-classic\">\n\n <h2>\n <div class=\"tin-between\">\n <mat-label>{{ classicHeading }}</mat-label>\n <!-- Added: the missing 'error' icon. Every other type had one; error \u2014 the type that most needs a cue \u2014\n rendered its heading beside empty flex space. Colour matches the modern branch's error accent\n (#c62828) so the two presentations agree on what \"error\" looks like. -->\n <mat-icon *ngIf=\"messageType=='error'\" style=\"color: #c62828;\">error</mat-icon>\n <!-- Changed: a destructive confirm shows a red warning instead of the neutral question mark, matching the\n modern branch. Same #c62828 as the error icon beside it, so the two styles agree on what danger looks\n like. A plain confirm (\"Switch organisation?\") is unchanged. -->\n <mat-icon *ngIf=\"messageType=='confirm' && destructive\" style=\"color: #c62828;\">warning</mat-icon>\n <mat-icon *ngIf=\"messageType=='confirm' && !destructive\">question_mark</mat-icon>\n <mat-icon *ngIf=\"messageType=='info'\" style=\"color: steelblue;\">info</mat-icon>\n <mat-icon *ngIf=\"messageType=='warning'\" style=\"color: #ef6c00;\">warning</mat-icon>\n <mat-icon *ngIf=\"messageType=='success'\" style=\"color: #2e7d32;\">check_circle</mat-icon>\n </div>\n </h2>\n\n <mat-dialog-content>\n\n <!-- Changed: was `{{ _messageDetails }}` rendered raw, so the blank-line-separated paragraphs the messages\n are actually written in (api-error.service.ts) collapsed into one undifferentiated run of text. That\n collapse is the original complaint that started WS-7 and it was still live here. Now renders the same\n `paragraphs` split the modern branch uses \u2014 same information, classic's own plain markup. -->\n <div *ngIf=\"messageType=='error'\">\n <p class=\"tin-msg-classic__p\" *ngFor=\"let p of paragraphs\">{{ p }}</p>\n </div>\n\n <div *ngIf=\"messageType!='error'\" class=\"alert alert-secondary\" role=\"alert\">\n <p class=\"tin-msg-classic__p\" *ngFor=\"let p of paragraphs\">{{ p }}</p>\n </div>\n\n <!-- Changed: lifted OUT of the error-only branch above. The reference is a correlation code, not an\n error-specific field \u2014 a warning or info that carried one used to drop it silently. -->\n <div class=\"tin-msg-classic__ref\" *ngIf=\"reference\">Reference: {{ reference }}</div>\n\n </mat-dialog-content>\n\n <mat-dialog-actions>\n\n <!-- Changed: same caller-supplied labels as the modern branch, defaulting identically to \"Yes\" / \"No\" /\n \"OK\". Classic is expected to render the same INFORMATION as modern in its own visual language, so a\n caller that names its action must be honoured here too \u2014 otherwise the next person to pass a label\n would silently get \"Yes\" on every untracked consumer still running classic. Colours and ids untouched. -->\n <!-- \u26A0\uFE0F Changed: THE COLOUR NOW FOLLOWS THE ACTION, NOT THE BUTTON'S POSITION. This markup hardcoded\n `color: green` on the affirmative button and `color: red` on the dismissive one. That mapping is only\n correct while the buttons read \"Yes\" and \"No\" and the affirmative answer is the harmless one \u2014 the\n green was never saying \"this is safe\", it was saying \"this is the Yes\". Name the action and the\n assumption inverts: a confirm offering \"Keep\" / \"Delete\" rendered a GREEN Delete next to a RED Keep,\n colouring the destructive action as the go-ahead and the safe one as the danger. That is the single\n reason the labels and the colours had to ship as one change rather than two.\n Destructive: affirmative red (#c62828, the same red the error icon uses), dismissive neutral \u2014 no\n colour at all, so the red is the only thing on the dialog asking for attention.\n Non-destructive: green/red exactly as before, byte for byte, for every caller that passes nothing.\n ids btnYes / btnNo and the click handlers are untouched in both cases. -->\n <button id=\"btnYes\" mat-stroked-button [style.color]=\"destructive ? '#c62828' : 'green'\" *ngIf=\"isConfirm\" (click)=\"response('yes')\" [attr.cdkFocusInitial]=\"destructive ? null : ''\">{{ confirmLabel }}</button>\n\n <button id=\"btnNo\" mat-stroked-button [style.color]=\"destructive ? null : 'red'\" *ngIf=\"isConfirm\" (click)=\"response('no')\" [attr.cdkFocusInitial]=\"destructive ? '' : null\">{{ cancelLabel }}</button>\n\n <button id=\"btnOK\" mat-stroked-button *ngIf=\"!isConfirm\" color=\"primary\" (click)=\"response('ok')\" cdkFocusInitial>{{ okLabel }}</button>\n\n </mat-dialog-actions>\n\n </div>\n</ng-template>\n", styles: [":host{display:block}.tin-msg-classic h2{margin:0!important;padding:16px 16px 0!important;font-size:20px!important;font-weight:500!important;line-height:normal!important}.tin-msg-classic .tin-between{display:flex;justify-content:space-between;align-items:center;margin:0!important;padding:0!important}.tin-msg-classic__p{margin:0 0 10px}.tin-msg-classic__p:last-child{margin-bottom:0}.tin-msg-classic__ref{margin-top:10px;font-size:12px;color:#0000008c;letter-spacing:.04em}.tin-msg{--tin-accent: #1565c0;--tin-accent-tint: rgba(21, 101, 192, .1);--tin-title: rgba(0, 0, 0, .87);--tin-body: rgba(0, 0, 0, .6);position:relative;padding:4px 4px 0}.tin-msg:before{content:\"\";position:absolute;top:0;left:0;right:0;height:3px;background:var(--tin-accent);border-radius:3px 3px 0 0}.tin-msg--error{--tin-accent: #c62828;--tin-accent-tint: rgba(198, 40, 40, .1)}.tin-msg--warning{--tin-accent: #ef6c00;--tin-accent-tint: rgba(239, 108, 0, .12)}.tin-msg--info{--tin-accent: #1565c0;--tin-accent-tint: rgba(21, 101, 192, .1)}.tin-msg--success{--tin-accent: #2e7d32;--tin-accent-tint: rgba(46, 125, 50, .12)}.tin-msg--confirm{--tin-accent: #5e35b1;--tin-accent-tint: rgba(94, 53, 177, .1)}.tin-msg--destructive{--tin-accent: #c62828;--tin-accent-tint: rgba(198, 40, 40, .1)}.tin-msg__head{display:flex;align-items:center;gap:14px;padding:20px 20px 8px}.tin-msg__icon{flex:0 0 auto;width:40px;height:40px;border-radius:50%;background:var(--tin-accent-tint);display:inline-flex;align-items:center;justify-content:center}.tin-msg__icon .mat-icon,.tin-msg__icon mat-icon{color:var(--tin-accent);font-size:22px;width:22px;height:22px;line-height:22px}.tin-msg__title{margin:0!important;padding:0!important;font-size:18px!important;font-weight:600!important;line-height:1.3!important;letter-spacing:-.1px;color:var(--tin-title)}.tin-msg__body{padding:0 20px 4px 74px!important;max-height:60vh}.tin-msg__p{margin:0 0 12px;font-size:13.5px;line-height:1.6;color:var(--tin-body)}.tin-msg__p--lead{font-size:14.5px;font-weight:500;color:var(--tin-title)}.tin-msg__p:last-child{margin-bottom:0}.tin-msg__ref{margin:14px 0 0;padding-top:10px;border-top:1px solid rgba(0,0,0,.08);font-size:12px;line-height:1.5;color:var(--tin-muted, rgba(0, 0, 0, .55))}.tin-msg__ref-code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:600;letter-spacing:.06em;color:var(--tin-title, rgba(0, 0, 0, .8));white-space:nowrap;-webkit-user-select:all;user-select:all}.tin-msg__actions{display:flex;justify-content:flex-end;gap:8px;padding:12px 20px 16px!important;margin:0!important;min-height:0!important}.tin-msg__btn-primary{background:var(--tin-accent)!important;color:#fff!important;min-width:88px;font-weight:500;letter-spacing:.2px}.tin-msg__btn-quiet{color:var(--tin-body)!important;font-weight:500}.tin-msg__btn-safe{color:var(--tin-title)!important;border:1px solid rgba(0,0,0,.23);font-weight:500}.tin-msg__icon{animation:tin-msg-pop .22s cubic-bezier(.2,.7,.3,1) both}@keyframes tin-msg-pop{0%{transform:scale(.82);opacity:0}to{transform:scale(1);opacity:1}}@media (prefers-reduced-motion: reduce){.tin-msg__icon{animation:none}}@media (max-width: 480px){.tin-msg__head{padding:16px 16px 8px;gap:12px}.tin-msg__body{padding:0 16px 4px!important}.tin-msg__actions{padding:12px 16px 14px!important}}\n"] }]
5443
5515
  }], ctorParameters: () => [{ type: i4.MatDialogRef }, { type: undefined, decorators: [{
5444
5516
  type: Inject,
5445
5517
  args: [MAT_DIALOG_DATA]
@@ -5519,13 +5591,17 @@ class MessageService {
5519
5591
  // ⚠️ Do NOT change the defaults to make every confirm read better: piglet-spa (38) and shift-spa (13) select
5520
5592
  // these buttons by their literal text via getByRole('button', { name: 'Yes' }), and the owner reverted a
5521
5593
  // hardcoded relabel on 2026-08-06. Opt in per call site.
5522
- confirm(msg, confirmLabel, cancelLabel) {
5594
+ // Changed: optional `destructive`, appended last so every existing call — including the three-argument ones
5595
+ // added with the labels — is unaffected. It drives COLOUR only: the affirmative button turns warn-red and the
5596
+ // dismissive one goes neutral, because the classic style hardcodes green on the affirmative button and a
5597
+ // GREEN "Delete" beside a RED "Keep" tells the user precisely the opposite of the truth.
5598
+ confirm(msg, confirmLabel, cancelLabel, destructive) {
5523
5599
  let type = "confirm";
5524
5600
  let subject = "";
5525
5601
  let details = msg;
5526
5602
  const dialogRef = this.dialog.open(messageDialog, {
5527
5603
  width: "400px",
5528
- data: { type, subject, details, confirmLabel, cancelLabel },
5604
+ data: { type, subject, details, confirmLabel, cancelLabel, destructive },
5529
5605
  });
5530
5606
  return dialogRef.afterClosed().pipe(mergeMap((result) => {
5531
5607
  return of(result);
@@ -6766,7 +6842,13 @@ class AccountingService {
6766
6842
  { name: 'code', type: 'text', alias: 'Account Code', section: 'classification', infoMessage: 'Chart of accounts code (1xxx assets, 2xxx liabilities, 3xxx equity, 4xxx revenue, 5xxx expenses)' }, // Changed: COA code (B1); moved into the collapsed section
6767
6843
  { name: 'includeInCashTotal', type: 'checkbox', alias: 'Include in Cash Total', section: 'classification', hidden: (data) => data.type !== 0 }, // Changed: Only visible for Asset accounts (type 0)
6768
6844
  { name: 'includeInBankTotal', type: 'checkbox', alias: 'Include in Bank Total', section: 'classification', hidden: (data) => data.type !== 0 }, // Changed: Only visible for Asset accounts (type 0)
6769
- { name: 'cashFlow', type: 'select', alias: 'Cash Flow Section', section: 'classification', infoMessage: 'IAS 7 cash flow statement classification', // Changed: C5 cash flow category
6845
+ // Changed (E21): defaultValue added. An untouched select initialises to null (TinCore.getInitialValue
6846
+ // `case 'select': return null`), and Account.CashFlow is a NON-NULLABLE CashFlowCategory, so creating an
6847
+ // account without opening this collapsed section posted cashFlow:null and was rejected at model binding
6848
+ // with a 400 — surfaced to the user as "Something went wrong ... a technical problem in the app" and
6849
+ // logged nowhere. 0 is Operating, which is the model's own C# default (Account.cs:69), so this makes the
6850
+ // form agree with the entity rather than inventing a value.
6851
+ { name: 'cashFlow', type: 'select', alias: 'Cash Flow Section', section: 'classification', defaultValue: 0, infoMessage: 'IAS 7 cash flow statement classification', // Changed: C5 cash flow category
6770
6852
  options: [
6771
6853
  { name: 'Operating', value: 0 },
6772
6854
  { name: 'Investing', value: 1 },
@@ -10722,6 +10804,47 @@ class ButtonService {
10722
10804
  // Return static message
10723
10805
  return button.confirm.message;
10724
10806
  }
10807
+ // Added: the built-in action names the library is willing to recognise as destructive ON ITS OWN, and the
10808
+ // action word each one puts on the button. Deliberately only these two. They are the names the library
10809
+ // GENERATES a confirmation for without the config asking (table.deleteModel and detailsDialog.delete both
10810
+ // fall back to "Are you sure you want to delete ?"), so they are the only ones where naming the action cannot
10811
+ // be expressed at the call site. Everything else — void, cancel, discard, reverse — must pass its own label
10812
+ // through `confirm.confirmLabel`, because only the app knows whether its 'cancel' button cancels an order or
10813
+ // just closes something, and guessing wrong puts the wrong verb on the button that destroys data.
10814
+ static { this.DESTRUCTIVE_ACTIONS = { 'delete': 'Delete', 'remove': 'Remove' }; }
10815
+ // Added: resolves a confirmation's message, button labels and destructiveness in ONE place, so the four call
10816
+ // sites that raise a confirmation (table.deleteModel, table.doAction, detailsDialog.delete,
10817
+ // detailsDialog.executeAction) cannot drift apart on it — they already drifted once on the fallback message.
10818
+ //
10819
+ // `allowNamedDestructive` is the app-level opt-in (appConfig.namedDestructiveActions), passed in rather than
10820
+ // read here so this service keeps its single AuthService dependency and does not take one on DataServiceLib.
10821
+ //
10822
+ // Precedence, and why: an EXPLICIT label or flag in the config always wins, because it is a deliberate
10823
+ // statement of intent by the app. Only when the config is silent does the app-level flag allow the library to
10824
+ // infer destructiveness from the action name — and with the flag absent (every untracked consumer, and any app
10825
+ // that has not opted in) nothing is inferred at all, so the prompt renders "Yes" / "No" in green/red exactly
10826
+ // as it does today.
10827
+ getConfirmOptions(button, row, fallbackMessage, allowNamedDestructive) {
10828
+ const confirm = button?.confirm;
10829
+ const message = this.getConfirmMessage(button, row) || fallbackMessage || '';
10830
+ const actionWord = ButtonService.DESTRUCTIVE_ACTIONS[button?.name];
10831
+ // An explicit `destructive` wins either way — including an explicit false, which is how an app says "this
10832
+ // button is called delete but it is not destroying anything, leave it alone".
10833
+ const destructive = confirm?.destructive !== undefined
10834
+ ? confirm.destructive === true
10835
+ : (allowNamedDestructive === true && !!actionWord);
10836
+ if (!destructive) {
10837
+ return { message, confirmLabel: confirm?.confirmLabel, cancelLabel: confirm?.cancelLabel, destructive: false };
10838
+ }
10839
+ // Naming the SAFE button matters as much as naming the destructive one: "Keep" states what happens if you
10840
+ // press it, where "No" only answers a question the user has to re-read the sentence to reconstruct.
10841
+ return {
10842
+ message,
10843
+ confirmLabel: confirm?.confirmLabel || actionWord || 'Delete',
10844
+ cancelLabel: confirm?.cancelLabel || 'Keep',
10845
+ destructive: true
10846
+ };
10847
+ }
10725
10848
  getButtonProperties(button, row, config) {
10726
10849
  return {
10727
10850
  color: this.getButtonColor(button, row),
@@ -11700,6 +11823,8 @@ class SetupService {
11700
11823
  // Added (v2): module catalog — empty means the app has no module concept (nothing is ever hidden)
11701
11824
  this.modules = new BehaviorSubject([]);
11702
11825
  this.modules$ = this.modules.asObservable();
11826
+ this.modulesLoaded = false; // Added (F3): a guard must be able to tell "the catalog is genuinely empty" from "it has not been fetched yet"
11827
+ this.modulesInFlight = null; // Added (F3): de-dupes concurrent guard activations on a deep route
11703
11828
  } // Changed: injected for loadStatus only
11704
11829
  get enabled() {
11705
11830
  return !!this.dataService.appConfig?.setupConfig?.enabled;
@@ -11738,6 +11863,7 @@ class SetupService {
11738
11863
  this.status.next(apiResponse.data);
11739
11864
  this.pendingCount.next(apiResponse.data?.pending ?? 0);
11740
11865
  this.modules.next(apiResponse.data?.modules ?? []); // Added (v2): status carries the module catalog too
11866
+ this.modulesLoaded = true; // Added (F3): status carries the catalog, so a Getting Started visit satisfies ensureModules() too
11741
11867
  }
11742
11868
  },
11743
11869
  error: () => { }
@@ -11755,10 +11881,30 @@ class SetupService {
11755
11881
  // a dialog becomes a wall.
11756
11882
  if (apiResponse.success)
11757
11883
  this.modules.next(apiResponse.data ?? []);
11884
+ this.modulesLoaded = true; // Added (F3): answered either way — a failed load leaves the catalog empty, and empty means "hide nothing"
11758
11885
  },
11759
- error: () => { } // Backend without module support nothing gets hidden
11886
+ error: () => { this.modulesLoaded = true; } // Changed (F3): a backend without module support must not leave every later ensureModules() re-requesting
11760
11887
  });
11761
11888
  }
11889
+ // Added (F3): awaitable catalog for moduleGuard. Reading the BehaviorSubject at route-resolution time is not
11890
+ // enough — on a deep link or an F5 the catalog has not arrived yet, so a guard would read empty and fail open on
11891
+ // exactly the path it exists to protect. Fails open on every unhappy path (setup disabled, no SetupController,
11892
+ // 401, error) because hiding working functionality is the worse error, matching isModuleEnabled below and the
11893
+ // server's ModuleCatalog.IsEnabled. shareReplay collapses the several child activations of one deep route into
11894
+ // a single setup/modules request.
11895
+ ensureModules() {
11896
+ if (!this.enabled || this.modulesLoaded)
11897
+ return of(true);
11898
+ if (!this.modulesInFlight) {
11899
+ this.modulesInFlight = this.dataService.CallApi({ url: 'setup/modules', skipCache: true }).pipe(map((apiResponse) => (apiResponse.success ? apiResponse.data ?? [] : [])), tap((mods) => { this.modules.next(mods); this.modulesLoaded = true; this.modulesInFlight = null; }), catchError(() => { this.modulesLoaded = true; this.modulesInFlight = null; return of([]); }), shareReplay(1));
11900
+ }
11901
+ return this.modulesInFlight.pipe(map(() => true));
11902
+ }
11903
+ // Added (F3): the refusal toast names the module, not the key — falls back to the key for an app whose route
11904
+ // declares a key the server's catalog does not carry
11905
+ moduleTitle(key) {
11906
+ return this.modules.value?.find(m => m.key === key)?.title ?? key;
11907
+ }
11762
11908
  // Added (v2): nav-menu gate — unknown keys and apps without modules always pass (fail open)
11763
11909
  isModuleEnabled(key) {
11764
11910
  if (!key)
@@ -11804,6 +11950,26 @@ class SetupService {
11804
11950
  applyApprovalPresets(items) {
11805
11951
  return this.dataService.CallApi({ url: 'setup/approvals', method: 'post', skipCache: true }, { items });
11806
11952
  }
11953
+ // Added (seed seam): the example-data offers this tenant's steps can make. skipCache like every other setup
11954
+ // call, and here it is not optional — wouldCreate, existing and applied are stamped per REQUEST, so a cached
11955
+ // catalogue would keep offering rows the user has just created and keep the counts wrong.
11956
+ loadSeedOffers() {
11957
+ return this.dataService.CallApi({ url: 'setup/seedoffers', skipCache: true });
11958
+ }
11959
+ // Added (seed seam): apply ONE offer, plus whatever that offer declares it needs first — the server walks the
11960
+ // dependency chain, so the caller sends a single key. A POST, not a GET, and the server requires Edit: the two
11961
+ // seed buttons this replaces are both writes reachable by a View-only user, and that is not carried forward.
11962
+ applySeedOffer(key) {
11963
+ return this.dataService.CallApi({ url: 'setup/seed', method: 'post', skipCache: true }, { key });
11964
+ }
11965
+ // Added (seed-all): fill every area that is still empty, in ONE request. The client sends no list — the server
11966
+ // loops the SAME offer catalogue, applies only the offers whose own area still has something to add, and skips
11967
+ // anything the user lacks Edit on. Deliberately NOT a client-side loop over applySeedOffer(): that would put the
11968
+ // judgement about what counts as "empty" in the browser, which is exactly where the demo-data card's one global
11969
+ // sentinel already went wrong, and it would produce one audit entry and one toast per area.
11970
+ applySeedAll() {
11971
+ return this.dataService.CallApi({ url: 'setup/seed-all', method: 'post', skipCache: true }, {});
11972
+ }
11807
11973
  refresh() {
11808
11974
  this.loadStatus();
11809
11975
  }
@@ -12103,6 +12269,16 @@ class SetupGuideComponent {
12103
12269
  this.roleTemplates = [];
12104
12270
  this.selectedTemplates = {};
12105
12271
  this.creatingRoles = false;
12272
+ // Added (seed seam): example-data offers, keyed by the step they render inside. The MAP is what the template
12273
+ // indexes — indexing returns the same array reference every change-detection pass, where a filter() in the
12274
+ // template would allocate a new one per pass (the banned pattern that livelocked this library once already).
12275
+ this.seedRowsByStep = {};
12276
+ this.seedingKey = ''; // offer key with an in-flight apply — disables its button until the server answers
12277
+ // Added (seed-all): the SAME offers, reported area by area for the Demo data card at the foot of the page.
12278
+ // One list, two readers — a second fetch with its own idea of what is empty is how the two would drift.
12279
+ this.demoAreas = [];
12280
+ this.demoPending = 0; // areas with something left to add; materialized so the template binds a number, not a filter()
12281
+ this.seedingAll = false;
12106
12282
  // Added (presets): picker state. `groups` is materialized (same rule as moduleGroups above); selected/role are
12107
12283
  // plain maps keyed by preset key so a reload never clobbers a manual choice.
12108
12284
  // Changed (presets P4): one object per domain instead of a field per domain — see PresetPicker above.
@@ -12164,6 +12340,8 @@ class SetupGuideComponent {
12164
12340
  this.buildGroups();
12165
12341
  if (this.hasRoleTemplatesStep())
12166
12342
  this.loadRoleTemplates();
12343
+ if (this.hasSeedStep() || this.showDemoData)
12344
+ this.loadSeedOffers(); // Changed (seed-all): the Demo data card reports per area from the same offers, so an app with the card but no seed: true step still needs them
12167
12345
  // Added (presets): only load a picker when the backend actually serves its step. The approvals step is gated
12168
12346
  // on the approvals module, so turning that module off makes the step — and this call — disappear with it.
12169
12347
  this.pickers.forEach(picker => { if (this.hasPresetsStep(picker))
@@ -12352,6 +12530,122 @@ class SetupGuideComponent {
12352
12530
  return;
12353
12531
  this.selectedTemplates[tpl.key] = !this.selectedTemplates[tpl.key];
12354
12532
  }
12533
+ //---------- Example data (seed seam) ----------
12534
+ // Seeding lives HERE and not on a working page, because getting started is exactly when someone needs a few rows
12535
+ // to look at. Three things make this different from the demo-data card at the foot of the page:
12536
+ // 1. it is SCOPED — an offer seeds its own step's area and nothing else;
12537
+ // 2. it is DEPENDENCY-AWARE — the server applies whatever the offer declares it needs first, and the row says
12538
+ // so in plain words BEFORE the click;
12539
+ // 3. it is EXPLICITLY example data — the notice is on the row and repeated in the confirm, so nobody can add
12540
+ // it without knowing that is what they are doing.
12541
+ // ⚠️ Accounting seeding is deliberately NOT here and must never be moved here. It is not optional and it is not
12542
+ // a choice — the Transaction Types button and the automatic financial seeds stay exactly as they are.
12543
+ hasSeed(step) {
12544
+ return !!this.getAction(step).seed;
12545
+ }
12546
+ hasSeedStep() {
12547
+ return (this.status?.steps || []).some(s => this.getAction(s).seed);
12548
+ }
12549
+ loadSeedOffers() {
12550
+ this.setupService.loadSeedOffers().subscribe({
12551
+ next: resp => {
12552
+ // Same reasoning as loadRoleTemplates and loadPresets: a bare return renders no offers, which reads as
12553
+ // "this app has no example data to give you" — a completely different statement from "we could not fetch it".
12554
+ if (!resp.success) {
12555
+ this.apiErrorService.presentAppFailure(resp, 'load', 'setup/seedoffers');
12556
+ return;
12557
+ }
12558
+ this.buildSeedRows(resp.data || []);
12559
+ },
12560
+ error: () => { }
12561
+ });
12562
+ }
12563
+ // Materialize once per load. Offers with no stepKey are PREREQUISITES — they exist so another offer can declare
12564
+ // them, they render nowhere, and dropping them here is what keeps them invisible.
12565
+ buildSeedRows(offers) {
12566
+ const byStep = {};
12567
+ offers.filter(o => !!o.stepKey).forEach(offer => {
12568
+ const rows = byStep[offer.stepKey] || (byStep[offer.stepKey] = []);
12569
+ rows.push({ offer, alsoAdds: this.seedAlsoAdds(offer), hereAlready: this.seedHereAlready(offer), actionLabel: this.seedActionLabel(offer) });
12570
+ });
12571
+ this.seedRowsByStep = byStep;
12572
+ // Changed (seed-all): the Demo data card gets EVERY offer, prerequisites included. A step row hides a
12573
+ // prerequisite on purpose (nobody should have to think about product categories to get products), but the card
12574
+ // is a report on the whole system, and an area quietly filled by somebody else's dependency is still an area
12575
+ // that changed. Order is the server's catalogue order, untouched.
12576
+ this.demoAreas = offers.map(offer => ({ offer, label: this.demoAreaLabel(offer), state: this.demoAreaState(offer) }));
12577
+ this.demoPending = offers.filter(o => !o.applied).length;
12578
+ }
12579
+ // Added (seed-all): a PREREQUISITE offer's Name is deliberately a sentence FRAGMENT server-side ("product
12580
+ // categories"), because on a step row it is only ever read inside "…also adds product categories". This card is
12581
+ // the one place a prerequisite is listed as a row of its own, where a lowercase fragment beside three capitalised
12582
+ // sentences reads as a rendering fault. Only the first letter is raised — the wording stays the server's, so this
12583
+ // does not become a second place where copy is authored. Materialized at load, never computed in a binding.
12584
+ demoAreaLabel(offer) {
12585
+ const name = offer.name || '';
12586
+ return name.charAt(0).toUpperCase() + name.slice(1);
12587
+ }
12588
+ // What this ONE area says about itself: what is already in it, and what a fill would add. Numbers only — every
12589
+ // SENTENCE on this page is the server's (effect, sampleNotice), and this must not become a second place where
12590
+ // wording is authored. It always names what it counts; a bare number was removed from the Day Book on that ruling.
12591
+ demoAreaState(offer) {
12592
+ const here = offer.existing === 1 ? '1 record here' : `${offer.existing} records here`;
12593
+ if (offer.applied)
12594
+ return here;
12595
+ return offer.existing === 0 ? `empty — adds ${offer.wouldCreate}` : `${here} — adds ${offer.wouldCreate}`;
12596
+ }
12597
+ // The prerequisites this click would ALSO create. The server sends only the ones not already there, so the
12598
+ // sentence is true at the moment it is read — "it will also add X" about something already present is a lie.
12599
+ seedAlsoAdds(offer) {
12600
+ const includes = offer.includes || [];
12601
+ return includes.length === 0 ? '' : `Also adds ${includes.join(', ')} — these cannot exist without them.`;
12602
+ }
12603
+ // The live count of what is in this area already, real records or example ones. This is the decision-support
12604
+ // half: somebody with four customers already does not want three more invented ones. It always says WHAT it
12605
+ // counts — a bare number was removed from the Day Book on exactly that ruling.
12606
+ seedHereAlready(offer) {
12607
+ if (offer.applied || !offer.existing)
12608
+ return '';
12609
+ return offer.existing === 1 ? '1 record is already in this list' : `${offer.existing} records are already in this list`;
12610
+ }
12611
+ // The button says HOW MANY, because a button that says only "Seed" is asking for blind consent. wouldCreate is
12612
+ // the server's live count of rows that do not exist yet, so the number on the button is the number that lands.
12613
+ seedActionLabel(offer) {
12614
+ if (offer.wouldCreate === 1)
12615
+ return 'Add 1 example record';
12616
+ return offer.wouldCreate > 1 ? `Add ${offer.wouldCreate} example records` : 'Add example data';
12617
+ }
12618
+ // The confirm is where the example-data notice becomes unavoidable — "you don't want to just add without someone
12619
+ // knowing that this is what they are doing". Every sentence in it is the SERVER'S, none is composed here: the
12620
+ // effect, the prerequisite line and the standing notice are all authored in the catalogue, so the wording is
12621
+ // correctable in one place for all five apps. Blank lines are real paragraph breaks in the message dialog.
12622
+ applySeedOffer(offer) {
12623
+ if (offer.applied || this.seedingKey)
12624
+ return;
12625
+ const alsoAdds = (offer.includes || []).length > 0 ? `\n\nIt will also add: ${offer.includes.join(', ')}.` : '';
12626
+ this.messageService.confirm(`${offer.effect}${alsoAdds}\n\n${offer.sampleNotice}`, 'Add example data').subscribe(answer => {
12627
+ if (answer !== 'yes')
12628
+ return; // messageDialog answers with the literal 'yes'
12629
+ this.seedingKey = offer.key;
12630
+ this.setupService.applySeedOffer(offer.key).subscribe({
12631
+ next: resp => {
12632
+ this.seedingKey = '';
12633
+ if (!resp.success) {
12634
+ this.apiErrorService.presentAppFailure(resp, 'action', 'setup/seed');
12635
+ return;
12636
+ } // never the server's own words on a failure
12637
+ const created = resp.data || 0;
12638
+ // Deliberately NOT a triumphant message when nothing was written. An affordance that reports success and
12639
+ // creates nothing is this codebase's signature bug; the server reports that case as a failure, and this
12640
+ // line covers the honest "it was all already here" one.
12641
+ this.messageService.toast(created > 0 ? `${created} example record${created === 1 ? '' : 's'} added — look for “Example: ”` : 'Those example records are already here');
12642
+ this.loadSeedOffers(); // re-reads applied / wouldCreate / existing so the row comes back in its new state
12643
+ this.setupService.refresh(); // seeded rows move the step's own count
12644
+ },
12645
+ error: () => this.seedingKey = ''
12646
+ });
12647
+ });
12648
+ }
12355
12649
  //---------- Preset pickers (notifications, sign-off) ----------
12356
12650
  // ONE code path, driven by the PresetPicker objects above. Tick a row, pick who it goes to, apply. Both backends
12357
12651
  // are create-only and skip anything already configured, so neither can overwrite a rule the user has since
@@ -12470,6 +12764,48 @@ class SetupGuideComponent {
12470
12764
  removeDemoData() {
12471
12765
  this.runDemoAction('demo-data/remove', 'This permanently deletes every example record created by demo data. Continue?', 'Demo data removed successfully');
12472
12766
  }
12767
+ // Added (seed-all): "scroll right to the bottom where they seed demo data and it just populates everything for
12768
+ // them ON AREAS THAT DON'T HAVE DATA". That last clause is the whole feature and it did not exist — seedDemoData()
12769
+ // above asks demo-data/seed, which asks ONE global sentinel (a customer named "Example: …") and, once it is
12770
+ // there, seeds nothing further no matter how many other areas are still empty. This asks each area instead.
12771
+ //
12772
+ // 🔴 seedDemoData() is deliberately KEPT, not replaced. The offer catalogue covers the areas it declares; the
12773
+ // app-specific demo datasets (pigs, equipment, trips) still only come from demo-data/seed. Retiring that button
12774
+ // before an offer exists for every one of those areas would take away the only way to seed them — the same
12775
+ // mistake as removing a seed button before its replacement works.
12776
+ //
12777
+ // Every sentence in the confirm is the SERVER'S: each area's own effect line and the standing example-data
12778
+ // notice, both authored in the catalogue so all five apps read the same words and one edit corrects them all.
12779
+ seedAll() {
12780
+ if (this.seedingAll || this.demoBusy)
12781
+ return;
12782
+ const pending = this.demoAreas.filter(area => !area.offer.applied);
12783
+ if (pending.length === 0)
12784
+ return;
12785
+ const effects = pending.map(area => `• ${area.offer.effect}`).join('\n');
12786
+ this.messageService.confirm(`${effects}\n\n${pending[0].offer.sampleNotice}`, 'Fill the empty areas').subscribe(answer => {
12787
+ if (answer !== 'yes')
12788
+ return; // messageDialog answers with the literal 'yes'
12789
+ this.seedingAll = true;
12790
+ this.setupService.applySeedAll().subscribe({
12791
+ next: resp => {
12792
+ this.seedingAll = false;
12793
+ // The server reports "some areas advertised rows and wrote none" as a FAILURE on purpose, so this must
12794
+ // not toast a success over it. Never the server's own words on a failure — the shared classifier decides.
12795
+ if (!resp.success) {
12796
+ this.apiErrorService.presentAppFailure(resp, 'action', 'setup/seed-all');
12797
+ this.loadSeedOffers();
12798
+ return;
12799
+ }
12800
+ const created = resp.data || 0;
12801
+ this.messageService.toast(created > 0 ? `${created} example record${created === 1 ? '' : 's'} added — look for “Example: ”` : 'Every area already has data');
12802
+ this.loadSeedOffers(); // re-reads applied / wouldCreate / existing so every area line comes back in its new state
12803
+ this.setupService.refresh(); // seeded rows move the step counts
12804
+ },
12805
+ error: () => this.seedingAll = false
12806
+ });
12807
+ });
12808
+ }
12473
12809
  runDemoAction(url, question, success) {
12474
12810
  if (this.demoBusy)
12475
12811
  return;
@@ -12515,11 +12851,11 @@ class SetupGuideComponent {
12515
12851
  });
12516
12852
  }
12517
12853
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: SetupGuideComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
12518
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: SetupGuideComponent, isStandalone: false, selector: "spa-setup-guide", ngImport: i0, template: "<div class=\"setup-page\" *ngIf=\"status\">\n\n <!-- Hero: overall readiness + celebration state at 100% -->\n <mat-card class=\"setup-hero\" [class.celebrate]=\"status.percent === 100\">\n <div class=\"hero-content\" *ngIf=\"status.percent < 100\">\n <div class=\"hero-text\">\n <h1>{{ title }}</h1>\n <p>Complete these steps to get your system ready for day-to-day operation.</p>\n <span class=\"hero-counter\">{{ status.completed }} of {{ status.total }} steps completed</span>\n </div>\n <div class=\"hero-progress\">\n <span class=\"hero-percent\">{{ status.percent }}%</span>\n <mat-progress-bar mode=\"determinate\" [value]=\"status.percent\"></mat-progress-bar>\n </div>\n </div>\n <div class=\"hero-content celebration\" *ngIf=\"status.percent === 100\">\n <mat-icon class=\"celebrate-icon\">celebration</mat-icon>\n <div class=\"hero-text\">\n <h1>You're all set!</h1>\n <p>All setup steps are complete \u2014 your system is ready to operate.</p>\n </div>\n </div>\n </mat-card>\n\n <!-- Module picker (v2): choose what the business uses; optional modules toggle steps + menus -->\n <mat-card class=\"setup-modules\" *ngIf=\"modules.length > 0\">\n <div class=\"group-header\">\n <h2>Your modules</h2>\n <span class=\"group-counter\">Tap a module to turn it on or off \u2014 you can change this anytime.</span>\n </div>\n <!-- Changed (v3): grouped \u2014 the picker now carries the whole library catalog, so cards sit under their\n group heading (the app's own modules first). A backend without groups yields one unnamed group,\n which renders as the original flat grid. -->\n <div class=\"module-section\" *ngFor=\"let mgroup of moduleGroups\">\n <div class=\"module-section-head\">\n <span class=\"module-section-name\">{{ mgroup.name }}</span>\n <span class=\"module-section-count\">{{ mgroup.enabled }} of {{ mgroup.total }} on</span>\n </div>\n <div class=\"module-grid\">\n <div class=\"module-card\" *ngFor=\"let mod of mgroup.modules\"\n [class.enabled]=\"mod.enabled\" [class.core]=\"mod.core\" [class.busy]=\"togglingKey === mod.key\"\n (click)=\"toggleModule(mod)\">\n <div class=\"module-head\">\n <mat-icon class=\"module-icon\">{{ mod.icon || 'extension' }}</mat-icon>\n <mat-icon class=\"module-state\" [class.on]=\"mod.enabled\">{{ mod.enabled ? 'check_circle' : 'radio_button_unchecked' }}</mat-icon>\n </div>\n <div class=\"module-title\">{{ mod.title }}</div>\n <div class=\"module-description\">{{ mod.description }}</div>\n <span class=\"module-chip\" *ngIf=\"mod.core\">Always on</span>\n </div>\n </div>\n </div>\n <div class=\"module-actions\" *ngIf=\"!modulesConfirmed\">\n <button mat-flat-button color=\"primary\" [disabled]=\"togglingKey !== ''\" (click)=\"confirmModules()\">Confirm selection</button>\n <span class=\"module-hint\">Happy with this selection? Confirm it to complete the step below.</span>\n </div>\n </mat-card>\n\n <!-- Category groups, top-down \u2014 Changed: flat spa-checklist replaces the raised mat-accordion -->\n <mat-card class=\"setup-group\" *ngFor=\"let group of groups\">\n <div class=\"group-header\">\n <h2>{{ group.name }}</h2>\n <span class=\"group-counter\">{{ group.completed }} of {{ group.total }}</span>\n </div>\n <spa-checklist [config]=\"group.config\" [itemTemplate]=\"stepExtrasTpl\"></spa-checklist>\n </mat-card>\n\n <!-- Demo data (opt-in via setupConfig.demoData) \u2014 moved here off the per-app configuration pages -->\n <mat-card class=\"setup-group setup-demo\" *ngIf=\"showDemoData\">\n <div class=\"group-header\">\n <h2>Demo data</h2>\n <span class=\"group-counter\">Explore with example records, then clear them out</span>\n </div>\n <p class=\"demo-description\">Seeding fills the system with example records so you can try it out before capturing anything real. Removing deletes only those example records.</p>\n <div class=\"demo-actions\">\n <button mat-flat-button color=\"primary\" [disabled]=\"demoBusy\" (click)=\"seedDemoData()\"><mat-icon>add_circle</mat-icon> Seed demo data</button>\n <button mat-stroked-button color=\"warn\" [disabled]=\"demoBusy\" (click)=\"removeDemoData()\"><mat-icon>delete</mat-icon> Remove demo data</button>\n </div>\n </mat-card>\n\n</div>\n\n<!-- Projected into the expanded checklist body: predefined roles picker on the roles step (v2) -->\n<ng-template #stepExtrasTpl let-item>\n <div class=\"role-templates\" *ngIf=\"hasRoleTemplates(item.data) && roleTemplates.length > 0\">\n <div class=\"role-template\" *ngFor=\"let tpl of roleTemplates\" [class.created]=\"tpl.exists\" [class.selected]=\"!tpl.exists && selectedTemplates[tpl.key]\" (click)=\"toggleTemplate(tpl)\">\n <div class=\"role-head\">\n <mat-icon class=\"role-state\" [class.on]=\"tpl.exists || selectedTemplates[tpl.key]\">{{ (tpl.exists || selectedTemplates[tpl.key]) ? 'check_circle' : 'radio_button_unchecked' }}</mat-icon>\n <span class=\"role-name\">{{ tpl.name }}</span>\n <span class=\"role-created\" *ngIf=\"tpl.exists\"><mat-icon>check</mat-icon>Created</span>\n </div>\n <div class=\"role-description\">{{ tpl.description }}</div>\n </div>\n </div>\n\n <!-- Added (presets): the preset picker. Same row as the role picker above \u2014 same state icon, same 8px stack,\n same colours \u2014 plus ONE sentence and ONE control.\n Changed (presets P4): rendered through ONE shared template for both the notification step and the sign-off\n step, parameterised by the picker object. The two sit one above the other on the same page, so a second\n near-identical block would drift and every difference would read as a mistake. -->\n <ng-container *ngIf=\"pickerFor(item.data) as picker\">\n <ng-container *ngTemplateOutlet=\"presetPickerTpl; context: { $implicit: picker }\"></ng-container>\n </ng-container>\n</ng-template>\n\n<!-- The one picker body. `picker` carries the domain: its rows, its roles, its wording, its apply call. -->\n<ng-template #presetPickerTpl let-picker>\n <div class=\"preset-picker\" *ngIf=\"picker.loaded && picker.presets.length > 0\">\n\n <!-- No selectable role \u2192 we ask for one instead of offering ticks that would build an inert rule. The wording\n is the picker's own, because \"no roles\" and \"roles nobody is in\" are different situations. -->\n <div class=\"preset-empty\" *ngIf=\"picker.roles.length === 0\">{{ picker.emptyText }}</div>\n\n <ng-container *ngIf=\"picker.roles.length > 0\">\n <div class=\"preset-section\" *ngFor=\"let pgroup of picker.groups\">\n <div class=\"preset-section-head\">\n <span class=\"preset-section-name\">{{ pgroup.name }}</span>\n <span class=\"preset-section-count\">{{ pgroup.on }} of {{ pgroup.total }}</span>\n </div>\n <div class=\"preset-row\" *ngFor=\"let row of pgroup.rows\" role=\"checkbox\" tabindex=\"0\"\n [class.configured]=\"row.preset.exists\" [class.selected]=\"!row.preset.exists && picker.selected[row.preset.key]\"\n [attr.aria-checked]=\"row.preset.exists || !!picker.selected[row.preset.key]\" [attr.aria-disabled]=\"row.preset.exists\"\n (click)=\"togglePreset(picker, row.preset)\"\n (keydown.enter)=\"togglePreset(picker, row.preset)\"\n (keydown.space)=\"togglePreset(picker, row.preset); $event.preventDefault()\">\n <div class=\"preset-head\">\n <mat-icon class=\"preset-state\" [class.on]=\"row.preset.exists || picker.selected[row.preset.key]\">{{ (row.preset.exists || picker.selected[row.preset.key]) ? 'check_circle' : 'radio_button_unchecked' }}</mat-icon>\n <span class=\"preset-name\">{{ row.preset.name }}</span>\n <span class=\"preset-configured\" *ngIf=\"row.preset.exists\"><mat-icon>check</mat-icon>Configured</span>\n </div>\n <div class=\"preset-description\">{{ row.preset.description }}</div>\n <!-- The effect sentence comes from the server whole; `before` and `after` are its two halves either side\n of the {role} placeholder, so reading the line IS reading the configuration. The sign-off sentences\n carry their own deferred-notification caveat, which is why there is no extra note here. -->\n <div class=\"preset-effect\" *ngIf=\"!row.preset.exists\">{{ row.before }}<select class=\"preset-role\" [attr.aria-label]=\"picker.roleAria + row.preset.name\"\n (click)=\"$event.stopPropagation()\"\n (keydown)=\"$event.stopPropagation()\"\n (change)=\"setPresetRole(picker, row.preset, $any($event.target).value)\">\n <option *ngFor=\"let role of picker.roles\" [value]=\"role.value\" [selected]=\"role.value === picker.role[row.preset.key]\">{{ role.name }}</option>\n </select>{{ row.after }}</div>\n <div class=\"preset-effect preset-done\" *ngIf=\"row.preset.exists\">{{ picker.doneText }}</div>\n </div>\n </div>\n\n <!-- One page-level channel choice, notifications only. In-app is the only channel that always works; SMS is a\n stub and is never offered; an approval request is always in-app, so the sign-off picker has no such line. -->\n <div class=\"preset-email\" *ngIf=\"picker.showEmail\" role=\"checkbox\" tabindex=\"0\" [attr.aria-checked]=\"includeEmail\"\n (click)=\"toggleIncludeEmail()\"\n (keydown.enter)=\"toggleIncludeEmail()\"\n (keydown.space)=\"toggleIncludeEmail(); $event.preventDefault()\">\n <mat-icon class=\"preset-state\" [class.on]=\"includeEmail\">{{ includeEmail ? 'check_box' : 'check_box_outline_blank' }}</mat-icon>\n <span class=\"preset-email-label\">Also send these by email</span>\n <span class=\"preset-email-hint\">In-app always. Email needs your mail settings.</span>\n </div>\n </ng-container>\n </div>\n</ng-template>\n\n<!-- Graceful empty state (feature disabled or status unavailable) -->\n<div class=\"setup-empty\" *ngIf=\"!status\">\n <mat-icon>rocket_launch</mat-icon>\n <p>Setup status is not available yet.</p>\n</div>\n", styles: [".setup-page{max-width:860px;margin:0 auto;padding:16px;display:flex;flex-direction:column;gap:16px}.setup-hero{padding:24px}.hero-content{display:flex;align-items:center;justify-content:space-between;gap:24px;flex-wrap:wrap}.hero-text h1{margin:0 0 4px;font-size:24px}.hero-text p{margin:0 0 8px;color:#0009}.hero-counter{font-size:13px;color:#0009}.hero-progress{flex:1;min-width:220px;max-width:340px}.hero-percent{display:block;font-size:28px;font-weight:600;color:#2e7d32;margin-bottom:6px;text-align:right}.hero-progress mat-progress-bar{height:10px;border-radius:5px}.setup-hero.celebrate{background:linear-gradient(135deg,#e8f5e9,#f1f8e9)}.celebration{justify-content:flex-start}.celebrate-icon{font-size:48px;width:48px;height:48px;color:#2e7d32;animation:celebrate-pop .6s ease-out}@keyframes celebrate-pop{0%{transform:scale(.3);opacity:0}70%{transform:scale(1.15)}to{transform:scale(1);opacity:1}}.setup-group{padding:16px}.group-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}.group-header h2{margin:0;font-size:17px}.group-counter{font-size:12px;color:#0000008c}.setup-modules{padding:16px}.module-section+.module-section{margin-top:18px}.module-section-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;padding-bottom:6px;border-bottom:1px solid rgba(0,0,0,.08)}.module-section-name{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:#0009}.module-section-count{font-size:11px;color:#00000073;white-space:nowrap}.module-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px;margin-top:8px}.module-card{border:1px solid rgba(0,0,0,.12);border-radius:10px;padding:12px;cursor:pointer;transition:border-color .15s,background .15s,opacity .15s;opacity:.72;position:relative}.module-card:hover{border-color:#90a4ae}.module-card.enabled{border-color:#4caf50;background:#f6fbf6;opacity:1}.module-card.core{cursor:default}.module-card.busy{pointer-events:none;opacity:.5}.module-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}.module-icon{color:#546e7a}.module-card.enabled .module-icon{color:#2e7d32}.module-state{color:#b0bec5}.module-state.on{color:#4caf50}.module-title{font-weight:600;font-size:14px;margin-bottom:4px}.module-description{font-size:12px;color:#0009;min-height:30px}.module-chip{display:inline-block;margin-top:8px;background:#eceff1;color:#546e7a;border-radius:12px;padding:2px 10px;font-size:11px}.module-actions{display:flex;align-items:center;gap:8px 12px;margin-top:14px;flex-wrap:wrap}.module-actions button{flex-shrink:0}.module-hint{font-size:12px;color:#0000008c}.role-templates{display:flex;flex-direction:column;gap:8px;margin:0 0 12px}.role-template{border:1px solid rgba(0,0,0,.08);border-radius:8px;padding:10px 12px;cursor:pointer;transition:border-color .15s,background .15s}.role-template:hover{border-color:#90a4ae}.role-template.selected{border-color:#4caf50;background:#f6fbf6}.role-template.created{background:#f6fbf6;border-color:#c8e6c9;cursor:default}.role-head{display:flex;align-items:center;gap:8px}.role-state{color:#b0bec5;flex-shrink:0}.role-state.on{color:#4caf50}.role-name{font-weight:500;font-size:13px}.role-created{margin-left:auto;display:inline-flex;align-items:center;gap:4px;font-size:12px;color:#2e7d32}.role-created mat-icon{font-size:16px;width:16px;height:16px}.role-description{font-size:12px;color:#0009;margin:2px 0 0 32px}.preset-picker{display:flex;flex-direction:column;gap:14px;margin:0 0 12px}.preset-empty{font-size:12px;line-height:18px;color:#0009;max-width:62ch}.preset-section{display:flex;flex-direction:column;gap:8px}.preset-section-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;padding-bottom:6px;border-bottom:1px solid rgba(0,0,0,.08)}.preset-section-name{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:#0009}.preset-section-count{font-size:11px;color:#00000073;white-space:nowrap}.preset-row{border:1px solid rgba(0,0,0,.08);border-radius:8px;padding:10px 12px;cursor:pointer;transition:border-color .15s,background .15s}.preset-row:hover{border-color:#90a4ae}.preset-row:focus-visible{outline:2px solid #4caf50;outline-offset:2px}.preset-row.selected{border-color:#4caf50;background:#f6fbf6}.preset-row.configured{background:#f6fbf6;border-color:#c8e6c9;cursor:default}.preset-head{display:flex;align-items:center;gap:8px}.preset-state{color:#b0bec5;flex-shrink:0}.preset-state.on{color:#4caf50}.preset-name{font-weight:500;font-size:13px}.preset-configured{margin-left:auto;display:inline-flex;align-items:center;gap:4px;font-size:12px;color:#2e7d32;white-space:nowrap}.preset-configured mat-icon{font-size:16px;width:16px;height:16px}.preset-description{font-size:12px;color:#0009;margin:2px 0 0 32px}.preset-effect{font-size:12px;line-height:22px;color:#000000c7;margin:4px 0 0 32px}.preset-done{color:#2e7d32}.preset-role{font-family:inherit;font-size:12px;font-weight:500;color:#2e7d32;background:#fff;border:1px solid #c8e6c9;border-radius:6px;padding:2px 4px;margin:0 2px;max-width:100%;cursor:pointer}.preset-role:hover{border-color:#4caf50}.preset-role:focus-visible{outline:2px solid #4caf50;outline-offset:1px}.preset-email{display:flex;align-items:center;gap:8px;flex-wrap:wrap;cursor:pointer;padding:2px 0}.preset-email:focus-visible{outline:2px solid #4caf50;outline-offset:2px;border-radius:6px}.preset-email-label{font-size:13px;font-weight:500}.preset-email-hint{font-size:12px;color:#0000008c}.setup-empty{text-align:center;padding:48px 16px;color:#00000080}.setup-empty mat-icon{font-size:40px;width:40px;height:40px}.demo-description{margin:8px 0 14px;font-size:13px;color:#0009}.demo-actions{display:flex;gap:12px;flex-wrap:wrap}@media (max-width: 700px){.setup-page{padding:10px 0;gap:12px}.setup-hero{padding:12px}.setup-modules,.setup-group{padding:10px}.hero-content{gap:10px}.hero-text h1{font-size:20px}.setup-hero:not(.celebrate) .hero-text p{display:none}.hero-counter{font-size:12px}.hero-progress{min-width:0;max-width:none}.hero-percent{font-size:22px;margin-bottom:4px}.hero-progress mat-progress-bar{height:8px;border-radius:4px}.celebrate-icon{font-size:36px;width:36px;height:36px}.group-header{margin-bottom:6px;gap:2px 8px}.group-header h2{font-size:15px}.group-counter{font-size:11px}.setup-modules>.group-header,.setup-demo>.group-header{flex-wrap:wrap}.setup-modules>.group-header .group-counter,.setup-demo>.group-header .group-counter{flex:1 0 100%}.module-section+.module-section{margin-top:12px}.module-section-head{padding-bottom:4px}.module-grid{gap:8px;margin-top:6px}.module-card{padding:10px}.module-head{margin-bottom:4px}.module-title{font-size:13.5px;margin-bottom:2px}.module-description{min-height:0}.module-chip{margin-top:6px}.module-actions{margin-top:10px}.role-templates,.preset-picker{gap:6px;margin-bottom:8px}.role-template,.preset-row{padding:8px 10px}.role-description,.preset-description,.preset-effect{margin-left:0}.preset-section{gap:6px}.preset-section-head{padding-bottom:4px}.demo-description{margin:6px 0 10px;font-size:12.5px}.demo-actions{gap:8px}.setup-empty{padding:28px 12px}.module-hint{flex:0 0 100%}.preset-role{display:block;width:100%;margin:4px 0 0;padding:6px 8px}.preset-email-hint{flex:0 0 100%}}\n"], dependencies: [{ kind: "directive", type: i1$2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1$2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i1$2.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "directive", type: i2$2.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i2$2.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "component", type: i3.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i4$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "component", type: i19.MatCard, selector: "mat-card", inputs: ["appearance"], exportAs: ["matCard"] }, { kind: "component", type: i6$1.MatProgressBar, selector: "mat-progress-bar", inputs: ["color", "value", "bufferValue", "mode"], outputs: ["animationEnd"], exportAs: ["matProgressBar"] }, { kind: "component", type: ChecklistComponent, selector: "spa-checklist", inputs: ["config", "itemTemplate"] }] }); }
12854
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: SetupGuideComponent, isStandalone: false, selector: "spa-setup-guide", ngImport: i0, template: "<div class=\"setup-page\" *ngIf=\"status\">\n\n <!-- Hero: overall readiness + celebration state at 100% -->\n <mat-card class=\"setup-hero\" [class.celebrate]=\"status.percent === 100\">\n <div class=\"hero-content\" *ngIf=\"status.percent < 100\">\n <div class=\"hero-text\">\n <h1>{{ title }}</h1>\n <p>Complete these steps to get your system ready for day-to-day operation.</p>\n <span class=\"hero-counter\">{{ status.completed }} of {{ status.total }} steps completed</span>\n </div>\n <div class=\"hero-progress\">\n <span class=\"hero-percent\">{{ status.percent }}%</span>\n <mat-progress-bar mode=\"determinate\" [value]=\"status.percent\"></mat-progress-bar>\n </div>\n </div>\n <div class=\"hero-content celebration\" *ngIf=\"status.percent === 100\">\n <mat-icon class=\"celebrate-icon\">celebration</mat-icon>\n <div class=\"hero-text\">\n <h1>You're all set!</h1>\n <p>All setup steps are complete \u2014 your system is ready to operate.</p>\n </div>\n </div>\n </mat-card>\n\n <!-- Module picker (v2): choose what the business uses; optional modules toggle steps + menus -->\n <mat-card class=\"setup-modules\" *ngIf=\"modules.length > 0\">\n <div class=\"group-header\">\n <h2>Your modules</h2>\n <span class=\"group-counter\">Tap a module to turn it on or off \u2014 you can change this anytime.</span>\n </div>\n <!-- Changed (v3): grouped \u2014 the picker now carries the whole library catalog, so cards sit under their\n group heading (the app's own modules first). A backend without groups yields one unnamed group,\n which renders as the original flat grid. -->\n <div class=\"module-section\" *ngFor=\"let mgroup of moduleGroups\">\n <div class=\"module-section-head\">\n <span class=\"module-section-name\">{{ mgroup.name }}</span>\n <span class=\"module-section-count\">{{ mgroup.enabled }} of {{ mgroup.total }} on</span>\n </div>\n <div class=\"module-grid\">\n <div class=\"module-card\" *ngFor=\"let mod of mgroup.modules\"\n [class.enabled]=\"mod.enabled\" [class.core]=\"mod.core\" [class.busy]=\"togglingKey === mod.key\"\n (click)=\"toggleModule(mod)\">\n <div class=\"module-head\">\n <mat-icon class=\"module-icon\">{{ mod.icon || 'extension' }}</mat-icon>\n <mat-icon class=\"module-state\" [class.on]=\"mod.enabled\">{{ mod.enabled ? 'check_circle' : 'radio_button_unchecked' }}</mat-icon>\n </div>\n <div class=\"module-title\">{{ mod.title }}</div>\n <div class=\"module-description\">{{ mod.description }}</div>\n <span class=\"module-chip\" *ngIf=\"mod.core\">Always on</span>\n </div>\n </div>\n </div>\n <div class=\"module-actions\" *ngIf=\"!modulesConfirmed\">\n <button mat-flat-button color=\"primary\" [disabled]=\"togglingKey !== ''\" (click)=\"confirmModules()\">Confirm selection</button>\n <span class=\"module-hint\">Happy with this selection? Confirm it to complete the step below.</span>\n </div>\n </mat-card>\n\n <!-- Category groups, top-down \u2014 Changed: flat spa-checklist replaces the raised mat-accordion -->\n <mat-card class=\"setup-group\" *ngFor=\"let group of groups\">\n <div class=\"group-header\">\n <h2>{{ group.name }}</h2>\n <span class=\"group-counter\">{{ group.completed }} of {{ group.total }}</span>\n </div>\n <spa-checklist [config]=\"group.config\" [itemTemplate]=\"stepExtrasTpl\"></spa-checklist>\n </mat-card>\n\n <!-- Demo data (opt-in via setupConfig.demoData) \u2014 moved here off the per-app configuration pages -->\n <mat-card class=\"setup-group setup-demo\" *ngIf=\"showDemoData\">\n <div class=\"group-header\">\n <h2>Demo data</h2>\n <span class=\"group-counter\">Explore with example records, then clear them out</span>\n </div>\n <p class=\"demo-description\">Seeding fills the system with example records so you can try it out before capturing anything real. Removing deletes only those example records.</p>\n\n <!-- Added (seed-all): the per-area report. The card used to make ONE claim about the whole system, because the\n backend sentinel is a single customer named \"Example: \u2026\" \u2014 so one demo customer answered for products,\n suppliers and everything else, and none of it ever looked at REAL data. Each line here is one area\n answering for itself: what is in it now, and what a fill would add. Deliberately a compact one-line list\n and not the full seed-offer row used on the steps \u2014 a step is where you decide about ONE area, this is a\n report on all of them, and repeating the effect and notice paragraphs a dozen times would bury the button.\n The full wording is still shown, in the confirm, before anything is written. -->\n <div class=\"demo-areas\" *ngIf=\"demoAreas.length > 0\">\n <div class=\"demo-area\" *ngFor=\"let area of demoAreas\" [class.added]=\"area.offer.applied\">\n <mat-icon class=\"demo-area-state\" [class.on]=\"area.offer.applied\">{{ area.offer.applied ? 'check_circle' : (area.offer.icon || 'science') }}</mat-icon>\n <span class=\"demo-area-name\">{{ area.label }}</span>\n <span class=\"demo-area-count\">{{ area.state }}</span>\n </div>\n </div>\n\n <div class=\"demo-actions\">\n <!-- The owner's sentence, made literal: fill the areas that don't have data. It says HOW MANY areas, because\n a button that says only \"seed\" is asking for blind consent, and it disappears entirely when there is\n nothing left to fill rather than sitting there doing nothing when clicked. -->\n <button mat-flat-button color=\"primary\" *ngIf=\"demoPending > 0\" [disabled]=\"seedingAll || demoBusy\" (click)=\"seedAll()\"><mat-icon>auto_fix_high</mat-icon> Fill {{ demoPending === 1 ? '1 empty area' : demoPending + ' empty areas' }}</button>\n <span class=\"demo-full\" *ngIf=\"demoAreas.length > 0 && demoPending === 0\">Every area already has data.</span>\n <!-- KEPT, not replaced. The offer catalogue covers the areas it declares; each app's own demo dataset still\n comes only from here, so retiring this would take away the only way to seed those. -->\n <button mat-stroked-button color=\"primary\" [disabled]=\"demoBusy || seedingAll\" (click)=\"seedDemoData()\"><mat-icon>add_circle</mat-icon> Seed demo data</button>\n <button mat-stroked-button color=\"warn\" [disabled]=\"demoBusy || seedingAll\" (click)=\"removeDemoData()\"><mat-icon>delete</mat-icon> Remove demo data</button>\n </div>\n </mat-card>\n\n</div>\n\n<!-- Projected into the expanded checklist body: predefined roles picker on the roles step (v2) -->\n<ng-template #stepExtrasTpl let-item>\n <div class=\"role-templates\" *ngIf=\"hasRoleTemplates(item.data) && roleTemplates.length > 0\">\n <div class=\"role-template\" *ngFor=\"let tpl of roleTemplates\" [class.created]=\"tpl.exists\" [class.selected]=\"!tpl.exists && selectedTemplates[tpl.key]\" (click)=\"toggleTemplate(tpl)\">\n <div class=\"role-head\">\n <mat-icon class=\"role-state\" [class.on]=\"tpl.exists || selectedTemplates[tpl.key]\">{{ (tpl.exists || selectedTemplates[tpl.key]) ? 'check_circle' : 'radio_button_unchecked' }}</mat-icon>\n <span class=\"role-name\">{{ tpl.name }}</span>\n <span class=\"role-created\" *ngIf=\"tpl.exists\"><mat-icon>check</mat-icon>Created</span>\n </div>\n <div class=\"role-description\">{{ tpl.description }}</div>\n </div>\n </div>\n\n <!-- Added (seed seam): example-data offers for this step. Deliberately the SAME row the role picker and both\n preset pickers use \u2014 1px border, 8px radius, 10px/12px padding, the same green for \"already done\", the same\n 32px text indent \u2014 because this page is one the owner already likes and a fourth visual language inside it\n would be the change, not the feature. Three things are new, and each one is a stated requirement:\n \u2022 the effect line says exactly what lands, authored server-side;\n \u2022 \"Also adds \u2026\" names the prerequisites, because a vehicle cannot exist without its makes and models;\n \u2022 the notice band says in as many words that this is EXAMPLE data you can edit later. It is the only\n tinted band inside the row, so the eye finds it, and it is grey rather than amber, because this is a\n clarification and not a warning \u2014 nobody has done anything wrong by reading it.\n A row is a BUTTON, never a tick: unlike the presets there is no \"who\" to choose, so a checkbox idiom would\n promise a selection step that does not exist. -->\n <div class=\"seed-offers\" *ngIf=\"item.data && seedRowsByStep[item.data.key] as seedRows\">\n <div class=\"seed-offer\" *ngFor=\"let row of seedRows\" [class.added]=\"row.offer.applied\">\n <div class=\"seed-head\">\n <mat-icon class=\"seed-state\" [class.on]=\"row.offer.applied\">{{ row.offer.applied ? 'check_circle' : (row.offer.icon || 'science') }}</mat-icon>\n <span class=\"seed-name\">{{ row.offer.name }}</span>\n <span class=\"seed-added\" *ngIf=\"row.offer.applied\"><mat-icon>check</mat-icon>Added</span>\n </div>\n <div class=\"seed-description\">{{ row.offer.description }}</div>\n <div class=\"seed-effect\">{{ row.offer.effect }}</div>\n <div class=\"seed-also\" *ngIf=\"row.alsoAdds\">{{ row.alsoAdds }}</div>\n <div class=\"seed-notice\"><mat-icon>science</mat-icon><span>{{ row.offer.sampleNotice }}</span></div>\n <div class=\"seed-foot\">\n <span class=\"seed-already\" *ngIf=\"row.hereAlready\">{{ row.hereAlready }}</span>\n <button mat-stroked-button class=\"seed-action\" *ngIf=\"!row.offer.applied\" [disabled]=\"seedingKey !== ''\" (click)=\"applySeedOffer(row.offer)\"><mat-icon>add</mat-icon>{{ row.actionLabel }}</button>\n </div>\n </div>\n </div>\n\n <!-- Added (presets): the preset picker. Same row as the role picker above \u2014 same state icon, same 8px stack,\n same colours \u2014 plus ONE sentence and ONE control.\n Changed (presets P4): rendered through ONE shared template for both the notification step and the sign-off\n step, parameterised by the picker object. The two sit one above the other on the same page, so a second\n near-identical block would drift and every difference would read as a mistake. -->\n <ng-container *ngIf=\"pickerFor(item.data) as picker\">\n <ng-container *ngTemplateOutlet=\"presetPickerTpl; context: { $implicit: picker }\"></ng-container>\n </ng-container>\n</ng-template>\n\n<!-- The one picker body. `picker` carries the domain: its rows, its roles, its wording, its apply call. -->\n<ng-template #presetPickerTpl let-picker>\n <div class=\"preset-picker\" *ngIf=\"picker.loaded && picker.presets.length > 0\">\n\n <!-- No selectable role \u2192 we ask for one instead of offering ticks that would build an inert rule. The wording\n is the picker's own, because \"no roles\" and \"roles nobody is in\" are different situations. -->\n <div class=\"preset-empty\" *ngIf=\"picker.roles.length === 0\">{{ picker.emptyText }}</div>\n\n <ng-container *ngIf=\"picker.roles.length > 0\">\n <div class=\"preset-section\" *ngFor=\"let pgroup of picker.groups\">\n <div class=\"preset-section-head\">\n <span class=\"preset-section-name\">{{ pgroup.name }}</span>\n <span class=\"preset-section-count\">{{ pgroup.on }} of {{ pgroup.total }}</span>\n </div>\n <div class=\"preset-row\" *ngFor=\"let row of pgroup.rows\" role=\"checkbox\" tabindex=\"0\"\n [class.configured]=\"row.preset.exists\" [class.selected]=\"!row.preset.exists && picker.selected[row.preset.key]\"\n [attr.aria-checked]=\"row.preset.exists || !!picker.selected[row.preset.key]\" [attr.aria-disabled]=\"row.preset.exists\"\n (click)=\"togglePreset(picker, row.preset)\"\n (keydown.enter)=\"togglePreset(picker, row.preset)\"\n (keydown.space)=\"togglePreset(picker, row.preset); $event.preventDefault()\">\n <div class=\"preset-head\">\n <mat-icon class=\"preset-state\" [class.on]=\"row.preset.exists || picker.selected[row.preset.key]\">{{ (row.preset.exists || picker.selected[row.preset.key]) ? 'check_circle' : 'radio_button_unchecked' }}</mat-icon>\n <span class=\"preset-name\">{{ row.preset.name }}</span>\n <span class=\"preset-configured\" *ngIf=\"row.preset.exists\"><mat-icon>check</mat-icon>Configured</span>\n </div>\n <div class=\"preset-description\">{{ row.preset.description }}</div>\n <!-- The effect sentence comes from the server whole; `before` and `after` are its two halves either side\n of the {role} placeholder, so reading the line IS reading the configuration. The sign-off sentences\n carry their own deferred-notification caveat, which is why there is no extra note here. -->\n <div class=\"preset-effect\" *ngIf=\"!row.preset.exists\">{{ row.before }}<select class=\"preset-role\" [attr.aria-label]=\"picker.roleAria + row.preset.name\"\n (click)=\"$event.stopPropagation()\"\n (keydown)=\"$event.stopPropagation()\"\n (change)=\"setPresetRole(picker, row.preset, $any($event.target).value)\">\n <option *ngFor=\"let role of picker.roles\" [value]=\"role.value\" [selected]=\"role.value === picker.role[row.preset.key]\">{{ role.name }}</option>\n </select>{{ row.after }}</div>\n <div class=\"preset-effect preset-done\" *ngIf=\"row.preset.exists\">{{ picker.doneText }}</div>\n </div>\n </div>\n\n <!-- One page-level channel choice, notifications only. In-app is the only channel that always works; SMS is a\n stub and is never offered; an approval request is always in-app, so the sign-off picker has no such line. -->\n <div class=\"preset-email\" *ngIf=\"picker.showEmail\" role=\"checkbox\" tabindex=\"0\" [attr.aria-checked]=\"includeEmail\"\n (click)=\"toggleIncludeEmail()\"\n (keydown.enter)=\"toggleIncludeEmail()\"\n (keydown.space)=\"toggleIncludeEmail(); $event.preventDefault()\">\n <mat-icon class=\"preset-state\" [class.on]=\"includeEmail\">{{ includeEmail ? 'check_box' : 'check_box_outline_blank' }}</mat-icon>\n <span class=\"preset-email-label\">Also send these by email</span>\n <span class=\"preset-email-hint\">In-app always. Email needs your mail settings.</span>\n </div>\n </ng-container>\n </div>\n</ng-template>\n\n<!-- Graceful empty state (feature disabled or status unavailable) -->\n<div class=\"setup-empty\" *ngIf=\"!status\">\n <mat-icon>rocket_launch</mat-icon>\n <p>Setup status is not available yet.</p>\n</div>\n", styles: [".setup-page{max-width:860px;margin:0 auto;padding:16px;display:flex;flex-direction:column;gap:16px}.setup-hero{padding:24px}.hero-content{display:flex;align-items:center;justify-content:space-between;gap:24px;flex-wrap:wrap}.hero-text h1{margin:0 0 4px;font-size:24px}.hero-text p{margin:0 0 8px;color:#0009}.hero-counter{font-size:13px;color:#0009}.hero-progress{flex:1;min-width:220px;max-width:340px}.hero-percent{display:block;font-size:28px;font-weight:600;color:#2e7d32;margin-bottom:6px;text-align:right}.hero-progress mat-progress-bar{height:10px;border-radius:5px}.setup-hero.celebrate{background:linear-gradient(135deg,#e8f5e9,#f1f8e9)}.celebration{justify-content:flex-start}.celebrate-icon{font-size:48px;width:48px;height:48px;color:#2e7d32;animation:celebrate-pop .6s ease-out}@keyframes celebrate-pop{0%{transform:scale(.3);opacity:0}70%{transform:scale(1.15)}to{transform:scale(1);opacity:1}}.setup-group{padding:16px}.group-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}.group-header h2{margin:0;font-size:17px}.group-counter{font-size:12px;color:#0000008c}.setup-modules{padding:16px}.module-section+.module-section{margin-top:18px}.module-section-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;padding-bottom:6px;border-bottom:1px solid rgba(0,0,0,.08)}.module-section-name{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:#0009}.module-section-count{font-size:11px;color:#00000073;white-space:nowrap}.module-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px;margin-top:8px}.module-card{border:1px solid rgba(0,0,0,.12);border-radius:10px;padding:12px;cursor:pointer;transition:border-color .15s,background .15s,opacity .15s;opacity:.72;position:relative}.module-card:hover{border-color:#90a4ae}.module-card.enabled{border-color:#4caf50;background:#f6fbf6;opacity:1}.module-card.core{cursor:default}.module-card.busy{pointer-events:none;opacity:.5}.module-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}.module-icon{color:#546e7a}.module-card.enabled .module-icon{color:#2e7d32}.module-state{color:#b0bec5}.module-state.on{color:#4caf50}.module-title{font-weight:600;font-size:14px;margin-bottom:4px}.module-description{font-size:12px;color:#0009;min-height:30px}.module-chip{display:inline-block;margin-top:8px;background:#eceff1;color:#546e7a;border-radius:12px;padding:2px 10px;font-size:11px}.module-actions{display:flex;align-items:center;gap:8px 12px;margin-top:14px;flex-wrap:wrap}.module-actions button{flex-shrink:0}.module-hint{font-size:12px;color:#0000008c}.role-templates{display:flex;flex-direction:column;gap:8px;margin:0 0 12px}.role-template{border:1px solid rgba(0,0,0,.08);border-radius:8px;padding:10px 12px;cursor:pointer;transition:border-color .15s,background .15s}.role-template:hover{border-color:#90a4ae}.role-template.selected{border-color:#4caf50;background:#f6fbf6}.role-template.created{background:#f6fbf6;border-color:#c8e6c9;cursor:default}.role-head{display:flex;align-items:center;gap:8px}.role-state{color:#b0bec5;flex-shrink:0}.role-state.on{color:#4caf50}.role-name{font-weight:500;font-size:13px}.role-created{margin-left:auto;display:inline-flex;align-items:center;gap:4px;font-size:12px;color:#2e7d32}.role-created mat-icon{font-size:16px;width:16px;height:16px}.role-description{font-size:12px;color:#0009;margin:2px 0 0 32px}.seed-offers{display:flex;flex-direction:column;gap:8px;margin:0 0 12px}.seed-offer{border:1px solid rgba(0,0,0,.08);border-radius:8px;padding:10px 12px;transition:border-color .15s,background .15s}.seed-offer.added{background:#f6fbf6;border-color:#c8e6c9}.seed-head{display:flex;align-items:center;gap:8px}.seed-state{color:#b0bec5;flex-shrink:0}.seed-state.on{color:#4caf50}.seed-name{font-weight:500;font-size:13px}.seed-added{margin-left:auto;display:inline-flex;align-items:center;gap:4px;font-size:12px;color:#2e7d32;white-space:nowrap}.seed-added mat-icon{font-size:16px;width:16px;height:16px}.seed-description{font-size:12px;color:#0009;margin:2px 0 0 32px}.seed-effect{font-size:12px;line-height:18px;color:#000000c7;margin:4px 0 0 32px}.seed-also{font-size:12px;line-height:18px;color:#0000008c;margin:2px 0 0 32px}.seed-notice{display:flex;align-items:flex-start;gap:6px;margin:8px 0 0 32px;padding:6px 8px;border-radius:6px;background:#00000009;font-size:11.5px;line-height:17px;color:#0009}.seed-notice mat-icon{font-size:15px;width:15px;height:15px;line-height:15px;flex-shrink:0;margin-top:1px;color:#0000006b}.seed-foot{display:flex;align-items:center;justify-content:space-between;gap:8px 12px;flex-wrap:wrap;margin:10px 0 0 32px}.seed-already{font-size:11px;color:#00000073}.seed-action{margin-left:auto;flex-shrink:0}.seed-action mat-icon{font-size:18px;width:18px;height:18px;margin-right:4px}.preset-picker{display:flex;flex-direction:column;gap:14px;margin:0 0 12px}.preset-empty{font-size:12px;line-height:18px;color:#0009;max-width:62ch}.preset-section{display:flex;flex-direction:column;gap:8px}.preset-section-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;padding-bottom:6px;border-bottom:1px solid rgba(0,0,0,.08)}.preset-section-name{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:#0009}.preset-section-count{font-size:11px;color:#00000073;white-space:nowrap}.preset-row{border:1px solid rgba(0,0,0,.08);border-radius:8px;padding:10px 12px;cursor:pointer;transition:border-color .15s,background .15s}.preset-row:hover{border-color:#90a4ae}.preset-row:focus-visible{outline:2px solid #4caf50;outline-offset:2px}.preset-row.selected{border-color:#4caf50;background:#f6fbf6}.preset-row.configured{background:#f6fbf6;border-color:#c8e6c9;cursor:default}.preset-head{display:flex;align-items:center;gap:8px}.preset-state{color:#b0bec5;flex-shrink:0}.preset-state.on{color:#4caf50}.preset-name{font-weight:500;font-size:13px}.preset-configured{margin-left:auto;display:inline-flex;align-items:center;gap:4px;font-size:12px;color:#2e7d32;white-space:nowrap}.preset-configured mat-icon{font-size:16px;width:16px;height:16px}.preset-description{font-size:12px;color:#0009;margin:2px 0 0 32px}.preset-effect{font-size:12px;line-height:22px;color:#000000c7;margin:4px 0 0 32px}.preset-done{color:#2e7d32}.preset-role{font-family:inherit;font-size:12px;font-weight:500;color:#2e7d32;background:#fff;border:1px solid #c8e6c9;border-radius:6px;padding:2px 4px;margin:0 2px;max-width:100%;cursor:pointer}.preset-role:hover{border-color:#4caf50}.preset-role:focus-visible{outline:2px solid #4caf50;outline-offset:1px}.preset-email{display:flex;align-items:center;gap:8px;flex-wrap:wrap;cursor:pointer;padding:2px 0}.preset-email:focus-visible{outline:2px solid #4caf50;outline-offset:2px;border-radius:6px}.preset-email-label{font-size:13px;font-weight:500}.preset-email-hint{font-size:12px;color:#0000008c}.setup-empty{text-align:center;padding:48px 16px;color:#00000080}.setup-empty mat-icon{font-size:40px;width:40px;height:40px}.demo-description{margin:8px 0 14px;font-size:13px;color:#0009}.demo-actions{display:flex;gap:12px;flex-wrap:wrap;align-items:center}.demo-areas{display:flex;flex-direction:column;gap:6px;margin:0 0 14px}.demo-area{display:flex;align-items:center;gap:8px;border:1px solid rgba(0,0,0,.08);border-radius:8px;padding:7px 12px}.demo-area.added{background:#f6fbf6;border-color:#c8e6c9}.demo-area-state{color:#b0bec5;flex-shrink:0}.demo-area-state.on{color:#4caf50}.demo-area-name{font-size:12.5px;color:#000c}.demo-area-count{margin-left:auto;font-size:11.5px;color:#00000073;white-space:nowrap;padding-left:8px}.demo-area.added .demo-area-count{color:#2e7d32}.demo-full{font-size:12.5px;color:#00000080}@media (max-width: 700px){.setup-page{padding:10px 0;gap:12px}.setup-hero{padding:12px}.setup-modules,.setup-group{padding:10px}.hero-content{gap:10px}.hero-text h1{font-size:20px}.setup-hero:not(.celebrate) .hero-text p{display:none}.hero-counter{font-size:12px}.hero-progress{min-width:0;max-width:none}.hero-percent{font-size:22px;margin-bottom:4px}.hero-progress mat-progress-bar{height:8px;border-radius:4px}.celebrate-icon{font-size:36px;width:36px;height:36px}.group-header{margin-bottom:6px;gap:2px 8px}.group-header h2{font-size:15px}.group-counter{font-size:11px}.setup-modules>.group-header,.setup-demo>.group-header{flex-wrap:wrap}.setup-modules>.group-header .group-counter,.setup-demo>.group-header .group-counter{flex:1 0 100%}.module-section+.module-section{margin-top:12px}.module-section-head{padding-bottom:4px}.module-grid{gap:8px;margin-top:6px}.module-card{padding:10px}.module-head{margin-bottom:4px}.module-title{font-size:13.5px;margin-bottom:2px}.module-description{min-height:0}.module-chip{margin-top:6px}.module-actions{margin-top:10px}.role-templates,.preset-picker,.seed-offers{gap:6px;margin-bottom:8px}.role-template,.preset-row,.seed-offer{padding:8px 10px}.role-description,.preset-description,.preset-effect,.seed-description,.seed-effect,.seed-also,.seed-notice,.seed-foot{margin-left:0}.seed-foot{margin-top:8px}.seed-action{width:100%;margin-left:0}.seed-already{flex:1 0 100%;order:2}.preset-section{gap:6px}.preset-section-head{padding-bottom:4px}.demo-description{margin:6px 0 10px;font-size:12.5px}.demo-actions{gap:8px}.setup-empty{padding:28px 12px}.demo-areas{gap:5px;margin-bottom:10px}.demo-area{flex-wrap:wrap;padding:7px 10px}.demo-area-count{flex:1 0 100%;margin-left:32px;padding-left:0;white-space:normal}.demo-actions button{width:100%}.module-hint{flex:0 0 100%}.preset-role{display:block;width:100%;margin:4px 0 0;padding:6px 8px}.preset-email-hint{flex:0 0 100%}}\n"], dependencies: [{ kind: "directive", type: i1$2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1$2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i1$2.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "directive", type: i2$2.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i2$2.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "component", type: i3.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i4$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "component", type: i19.MatCard, selector: "mat-card", inputs: ["appearance"], exportAs: ["matCard"] }, { kind: "component", type: i6$1.MatProgressBar, selector: "mat-progress-bar", inputs: ["color", "value", "bufferValue", "mode"], outputs: ["animationEnd"], exportAs: ["matProgressBar"] }, { kind: "component", type: ChecklistComponent, selector: "spa-checklist", inputs: ["config", "itemTemplate"] }] }); }
12519
12855
  }
12520
12856
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: SetupGuideComponent, decorators: [{
12521
12857
  type: Component,
12522
- args: [{ selector: 'spa-setup-guide', standalone: false, template: "<div class=\"setup-page\" *ngIf=\"status\">\n\n <!-- Hero: overall readiness + celebration state at 100% -->\n <mat-card class=\"setup-hero\" [class.celebrate]=\"status.percent === 100\">\n <div class=\"hero-content\" *ngIf=\"status.percent < 100\">\n <div class=\"hero-text\">\n <h1>{{ title }}</h1>\n <p>Complete these steps to get your system ready for day-to-day operation.</p>\n <span class=\"hero-counter\">{{ status.completed }} of {{ status.total }} steps completed</span>\n </div>\n <div class=\"hero-progress\">\n <span class=\"hero-percent\">{{ status.percent }}%</span>\n <mat-progress-bar mode=\"determinate\" [value]=\"status.percent\"></mat-progress-bar>\n </div>\n </div>\n <div class=\"hero-content celebration\" *ngIf=\"status.percent === 100\">\n <mat-icon class=\"celebrate-icon\">celebration</mat-icon>\n <div class=\"hero-text\">\n <h1>You're all set!</h1>\n <p>All setup steps are complete \u2014 your system is ready to operate.</p>\n </div>\n </div>\n </mat-card>\n\n <!-- Module picker (v2): choose what the business uses; optional modules toggle steps + menus -->\n <mat-card class=\"setup-modules\" *ngIf=\"modules.length > 0\">\n <div class=\"group-header\">\n <h2>Your modules</h2>\n <span class=\"group-counter\">Tap a module to turn it on or off \u2014 you can change this anytime.</span>\n </div>\n <!-- Changed (v3): grouped \u2014 the picker now carries the whole library catalog, so cards sit under their\n group heading (the app's own modules first). A backend without groups yields one unnamed group,\n which renders as the original flat grid. -->\n <div class=\"module-section\" *ngFor=\"let mgroup of moduleGroups\">\n <div class=\"module-section-head\">\n <span class=\"module-section-name\">{{ mgroup.name }}</span>\n <span class=\"module-section-count\">{{ mgroup.enabled }} of {{ mgroup.total }} on</span>\n </div>\n <div class=\"module-grid\">\n <div class=\"module-card\" *ngFor=\"let mod of mgroup.modules\"\n [class.enabled]=\"mod.enabled\" [class.core]=\"mod.core\" [class.busy]=\"togglingKey === mod.key\"\n (click)=\"toggleModule(mod)\">\n <div class=\"module-head\">\n <mat-icon class=\"module-icon\">{{ mod.icon || 'extension' }}</mat-icon>\n <mat-icon class=\"module-state\" [class.on]=\"mod.enabled\">{{ mod.enabled ? 'check_circle' : 'radio_button_unchecked' }}</mat-icon>\n </div>\n <div class=\"module-title\">{{ mod.title }}</div>\n <div class=\"module-description\">{{ mod.description }}</div>\n <span class=\"module-chip\" *ngIf=\"mod.core\">Always on</span>\n </div>\n </div>\n </div>\n <div class=\"module-actions\" *ngIf=\"!modulesConfirmed\">\n <button mat-flat-button color=\"primary\" [disabled]=\"togglingKey !== ''\" (click)=\"confirmModules()\">Confirm selection</button>\n <span class=\"module-hint\">Happy with this selection? Confirm it to complete the step below.</span>\n </div>\n </mat-card>\n\n <!-- Category groups, top-down \u2014 Changed: flat spa-checklist replaces the raised mat-accordion -->\n <mat-card class=\"setup-group\" *ngFor=\"let group of groups\">\n <div class=\"group-header\">\n <h2>{{ group.name }}</h2>\n <span class=\"group-counter\">{{ group.completed }} of {{ group.total }}</span>\n </div>\n <spa-checklist [config]=\"group.config\" [itemTemplate]=\"stepExtrasTpl\"></spa-checklist>\n </mat-card>\n\n <!-- Demo data (opt-in via setupConfig.demoData) \u2014 moved here off the per-app configuration pages -->\n <mat-card class=\"setup-group setup-demo\" *ngIf=\"showDemoData\">\n <div class=\"group-header\">\n <h2>Demo data</h2>\n <span class=\"group-counter\">Explore with example records, then clear them out</span>\n </div>\n <p class=\"demo-description\">Seeding fills the system with example records so you can try it out before capturing anything real. Removing deletes only those example records.</p>\n <div class=\"demo-actions\">\n <button mat-flat-button color=\"primary\" [disabled]=\"demoBusy\" (click)=\"seedDemoData()\"><mat-icon>add_circle</mat-icon> Seed demo data</button>\n <button mat-stroked-button color=\"warn\" [disabled]=\"demoBusy\" (click)=\"removeDemoData()\"><mat-icon>delete</mat-icon> Remove demo data</button>\n </div>\n </mat-card>\n\n</div>\n\n<!-- Projected into the expanded checklist body: predefined roles picker on the roles step (v2) -->\n<ng-template #stepExtrasTpl let-item>\n <div class=\"role-templates\" *ngIf=\"hasRoleTemplates(item.data) && roleTemplates.length > 0\">\n <div class=\"role-template\" *ngFor=\"let tpl of roleTemplates\" [class.created]=\"tpl.exists\" [class.selected]=\"!tpl.exists && selectedTemplates[tpl.key]\" (click)=\"toggleTemplate(tpl)\">\n <div class=\"role-head\">\n <mat-icon class=\"role-state\" [class.on]=\"tpl.exists || selectedTemplates[tpl.key]\">{{ (tpl.exists || selectedTemplates[tpl.key]) ? 'check_circle' : 'radio_button_unchecked' }}</mat-icon>\n <span class=\"role-name\">{{ tpl.name }}</span>\n <span class=\"role-created\" *ngIf=\"tpl.exists\"><mat-icon>check</mat-icon>Created</span>\n </div>\n <div class=\"role-description\">{{ tpl.description }}</div>\n </div>\n </div>\n\n <!-- Added (presets): the preset picker. Same row as the role picker above \u2014 same state icon, same 8px stack,\n same colours \u2014 plus ONE sentence and ONE control.\n Changed (presets P4): rendered through ONE shared template for both the notification step and the sign-off\n step, parameterised by the picker object. The two sit one above the other on the same page, so a second\n near-identical block would drift and every difference would read as a mistake. -->\n <ng-container *ngIf=\"pickerFor(item.data) as picker\">\n <ng-container *ngTemplateOutlet=\"presetPickerTpl; context: { $implicit: picker }\"></ng-container>\n </ng-container>\n</ng-template>\n\n<!-- The one picker body. `picker` carries the domain: its rows, its roles, its wording, its apply call. -->\n<ng-template #presetPickerTpl let-picker>\n <div class=\"preset-picker\" *ngIf=\"picker.loaded && picker.presets.length > 0\">\n\n <!-- No selectable role \u2192 we ask for one instead of offering ticks that would build an inert rule. The wording\n is the picker's own, because \"no roles\" and \"roles nobody is in\" are different situations. -->\n <div class=\"preset-empty\" *ngIf=\"picker.roles.length === 0\">{{ picker.emptyText }}</div>\n\n <ng-container *ngIf=\"picker.roles.length > 0\">\n <div class=\"preset-section\" *ngFor=\"let pgroup of picker.groups\">\n <div class=\"preset-section-head\">\n <span class=\"preset-section-name\">{{ pgroup.name }}</span>\n <span class=\"preset-section-count\">{{ pgroup.on }} of {{ pgroup.total }}</span>\n </div>\n <div class=\"preset-row\" *ngFor=\"let row of pgroup.rows\" role=\"checkbox\" tabindex=\"0\"\n [class.configured]=\"row.preset.exists\" [class.selected]=\"!row.preset.exists && picker.selected[row.preset.key]\"\n [attr.aria-checked]=\"row.preset.exists || !!picker.selected[row.preset.key]\" [attr.aria-disabled]=\"row.preset.exists\"\n (click)=\"togglePreset(picker, row.preset)\"\n (keydown.enter)=\"togglePreset(picker, row.preset)\"\n (keydown.space)=\"togglePreset(picker, row.preset); $event.preventDefault()\">\n <div class=\"preset-head\">\n <mat-icon class=\"preset-state\" [class.on]=\"row.preset.exists || picker.selected[row.preset.key]\">{{ (row.preset.exists || picker.selected[row.preset.key]) ? 'check_circle' : 'radio_button_unchecked' }}</mat-icon>\n <span class=\"preset-name\">{{ row.preset.name }}</span>\n <span class=\"preset-configured\" *ngIf=\"row.preset.exists\"><mat-icon>check</mat-icon>Configured</span>\n </div>\n <div class=\"preset-description\">{{ row.preset.description }}</div>\n <!-- The effect sentence comes from the server whole; `before` and `after` are its two halves either side\n of the {role} placeholder, so reading the line IS reading the configuration. The sign-off sentences\n carry their own deferred-notification caveat, which is why there is no extra note here. -->\n <div class=\"preset-effect\" *ngIf=\"!row.preset.exists\">{{ row.before }}<select class=\"preset-role\" [attr.aria-label]=\"picker.roleAria + row.preset.name\"\n (click)=\"$event.stopPropagation()\"\n (keydown)=\"$event.stopPropagation()\"\n (change)=\"setPresetRole(picker, row.preset, $any($event.target).value)\">\n <option *ngFor=\"let role of picker.roles\" [value]=\"role.value\" [selected]=\"role.value === picker.role[row.preset.key]\">{{ role.name }}</option>\n </select>{{ row.after }}</div>\n <div class=\"preset-effect preset-done\" *ngIf=\"row.preset.exists\">{{ picker.doneText }}</div>\n </div>\n </div>\n\n <!-- One page-level channel choice, notifications only. In-app is the only channel that always works; SMS is a\n stub and is never offered; an approval request is always in-app, so the sign-off picker has no such line. -->\n <div class=\"preset-email\" *ngIf=\"picker.showEmail\" role=\"checkbox\" tabindex=\"0\" [attr.aria-checked]=\"includeEmail\"\n (click)=\"toggleIncludeEmail()\"\n (keydown.enter)=\"toggleIncludeEmail()\"\n (keydown.space)=\"toggleIncludeEmail(); $event.preventDefault()\">\n <mat-icon class=\"preset-state\" [class.on]=\"includeEmail\">{{ includeEmail ? 'check_box' : 'check_box_outline_blank' }}</mat-icon>\n <span class=\"preset-email-label\">Also send these by email</span>\n <span class=\"preset-email-hint\">In-app always. Email needs your mail settings.</span>\n </div>\n </ng-container>\n </div>\n</ng-template>\n\n<!-- Graceful empty state (feature disabled or status unavailable) -->\n<div class=\"setup-empty\" *ngIf=\"!status\">\n <mat-icon>rocket_launch</mat-icon>\n <p>Setup status is not available yet.</p>\n</div>\n", styles: [".setup-page{max-width:860px;margin:0 auto;padding:16px;display:flex;flex-direction:column;gap:16px}.setup-hero{padding:24px}.hero-content{display:flex;align-items:center;justify-content:space-between;gap:24px;flex-wrap:wrap}.hero-text h1{margin:0 0 4px;font-size:24px}.hero-text p{margin:0 0 8px;color:#0009}.hero-counter{font-size:13px;color:#0009}.hero-progress{flex:1;min-width:220px;max-width:340px}.hero-percent{display:block;font-size:28px;font-weight:600;color:#2e7d32;margin-bottom:6px;text-align:right}.hero-progress mat-progress-bar{height:10px;border-radius:5px}.setup-hero.celebrate{background:linear-gradient(135deg,#e8f5e9,#f1f8e9)}.celebration{justify-content:flex-start}.celebrate-icon{font-size:48px;width:48px;height:48px;color:#2e7d32;animation:celebrate-pop .6s ease-out}@keyframes celebrate-pop{0%{transform:scale(.3);opacity:0}70%{transform:scale(1.15)}to{transform:scale(1);opacity:1}}.setup-group{padding:16px}.group-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}.group-header h2{margin:0;font-size:17px}.group-counter{font-size:12px;color:#0000008c}.setup-modules{padding:16px}.module-section+.module-section{margin-top:18px}.module-section-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;padding-bottom:6px;border-bottom:1px solid rgba(0,0,0,.08)}.module-section-name{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:#0009}.module-section-count{font-size:11px;color:#00000073;white-space:nowrap}.module-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px;margin-top:8px}.module-card{border:1px solid rgba(0,0,0,.12);border-radius:10px;padding:12px;cursor:pointer;transition:border-color .15s,background .15s,opacity .15s;opacity:.72;position:relative}.module-card:hover{border-color:#90a4ae}.module-card.enabled{border-color:#4caf50;background:#f6fbf6;opacity:1}.module-card.core{cursor:default}.module-card.busy{pointer-events:none;opacity:.5}.module-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}.module-icon{color:#546e7a}.module-card.enabled .module-icon{color:#2e7d32}.module-state{color:#b0bec5}.module-state.on{color:#4caf50}.module-title{font-weight:600;font-size:14px;margin-bottom:4px}.module-description{font-size:12px;color:#0009;min-height:30px}.module-chip{display:inline-block;margin-top:8px;background:#eceff1;color:#546e7a;border-radius:12px;padding:2px 10px;font-size:11px}.module-actions{display:flex;align-items:center;gap:8px 12px;margin-top:14px;flex-wrap:wrap}.module-actions button{flex-shrink:0}.module-hint{font-size:12px;color:#0000008c}.role-templates{display:flex;flex-direction:column;gap:8px;margin:0 0 12px}.role-template{border:1px solid rgba(0,0,0,.08);border-radius:8px;padding:10px 12px;cursor:pointer;transition:border-color .15s,background .15s}.role-template:hover{border-color:#90a4ae}.role-template.selected{border-color:#4caf50;background:#f6fbf6}.role-template.created{background:#f6fbf6;border-color:#c8e6c9;cursor:default}.role-head{display:flex;align-items:center;gap:8px}.role-state{color:#b0bec5;flex-shrink:0}.role-state.on{color:#4caf50}.role-name{font-weight:500;font-size:13px}.role-created{margin-left:auto;display:inline-flex;align-items:center;gap:4px;font-size:12px;color:#2e7d32}.role-created mat-icon{font-size:16px;width:16px;height:16px}.role-description{font-size:12px;color:#0009;margin:2px 0 0 32px}.preset-picker{display:flex;flex-direction:column;gap:14px;margin:0 0 12px}.preset-empty{font-size:12px;line-height:18px;color:#0009;max-width:62ch}.preset-section{display:flex;flex-direction:column;gap:8px}.preset-section-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;padding-bottom:6px;border-bottom:1px solid rgba(0,0,0,.08)}.preset-section-name{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:#0009}.preset-section-count{font-size:11px;color:#00000073;white-space:nowrap}.preset-row{border:1px solid rgba(0,0,0,.08);border-radius:8px;padding:10px 12px;cursor:pointer;transition:border-color .15s,background .15s}.preset-row:hover{border-color:#90a4ae}.preset-row:focus-visible{outline:2px solid #4caf50;outline-offset:2px}.preset-row.selected{border-color:#4caf50;background:#f6fbf6}.preset-row.configured{background:#f6fbf6;border-color:#c8e6c9;cursor:default}.preset-head{display:flex;align-items:center;gap:8px}.preset-state{color:#b0bec5;flex-shrink:0}.preset-state.on{color:#4caf50}.preset-name{font-weight:500;font-size:13px}.preset-configured{margin-left:auto;display:inline-flex;align-items:center;gap:4px;font-size:12px;color:#2e7d32;white-space:nowrap}.preset-configured mat-icon{font-size:16px;width:16px;height:16px}.preset-description{font-size:12px;color:#0009;margin:2px 0 0 32px}.preset-effect{font-size:12px;line-height:22px;color:#000000c7;margin:4px 0 0 32px}.preset-done{color:#2e7d32}.preset-role{font-family:inherit;font-size:12px;font-weight:500;color:#2e7d32;background:#fff;border:1px solid #c8e6c9;border-radius:6px;padding:2px 4px;margin:0 2px;max-width:100%;cursor:pointer}.preset-role:hover{border-color:#4caf50}.preset-role:focus-visible{outline:2px solid #4caf50;outline-offset:1px}.preset-email{display:flex;align-items:center;gap:8px;flex-wrap:wrap;cursor:pointer;padding:2px 0}.preset-email:focus-visible{outline:2px solid #4caf50;outline-offset:2px;border-radius:6px}.preset-email-label{font-size:13px;font-weight:500}.preset-email-hint{font-size:12px;color:#0000008c}.setup-empty{text-align:center;padding:48px 16px;color:#00000080}.setup-empty mat-icon{font-size:40px;width:40px;height:40px}.demo-description{margin:8px 0 14px;font-size:13px;color:#0009}.demo-actions{display:flex;gap:12px;flex-wrap:wrap}@media (max-width: 700px){.setup-page{padding:10px 0;gap:12px}.setup-hero{padding:12px}.setup-modules,.setup-group{padding:10px}.hero-content{gap:10px}.hero-text h1{font-size:20px}.setup-hero:not(.celebrate) .hero-text p{display:none}.hero-counter{font-size:12px}.hero-progress{min-width:0;max-width:none}.hero-percent{font-size:22px;margin-bottom:4px}.hero-progress mat-progress-bar{height:8px;border-radius:4px}.celebrate-icon{font-size:36px;width:36px;height:36px}.group-header{margin-bottom:6px;gap:2px 8px}.group-header h2{font-size:15px}.group-counter{font-size:11px}.setup-modules>.group-header,.setup-demo>.group-header{flex-wrap:wrap}.setup-modules>.group-header .group-counter,.setup-demo>.group-header .group-counter{flex:1 0 100%}.module-section+.module-section{margin-top:12px}.module-section-head{padding-bottom:4px}.module-grid{gap:8px;margin-top:6px}.module-card{padding:10px}.module-head{margin-bottom:4px}.module-title{font-size:13.5px;margin-bottom:2px}.module-description{min-height:0}.module-chip{margin-top:6px}.module-actions{margin-top:10px}.role-templates,.preset-picker{gap:6px;margin-bottom:8px}.role-template,.preset-row{padding:8px 10px}.role-description,.preset-description,.preset-effect{margin-left:0}.preset-section{gap:6px}.preset-section-head{padding-bottom:4px}.demo-description{margin:6px 0 10px;font-size:12.5px}.demo-actions{gap:8px}.setup-empty{padding:28px 12px}.module-hint{flex:0 0 100%}.preset-role{display:block;width:100%;margin:4px 0 0;padding:6px 8px}.preset-email-hint{flex:0 0 100%}}\n"] }]
12858
+ args: [{ selector: 'spa-setup-guide', standalone: false, template: "<div class=\"setup-page\" *ngIf=\"status\">\n\n <!-- Hero: overall readiness + celebration state at 100% -->\n <mat-card class=\"setup-hero\" [class.celebrate]=\"status.percent === 100\">\n <div class=\"hero-content\" *ngIf=\"status.percent < 100\">\n <div class=\"hero-text\">\n <h1>{{ title }}</h1>\n <p>Complete these steps to get your system ready for day-to-day operation.</p>\n <span class=\"hero-counter\">{{ status.completed }} of {{ status.total }} steps completed</span>\n </div>\n <div class=\"hero-progress\">\n <span class=\"hero-percent\">{{ status.percent }}%</span>\n <mat-progress-bar mode=\"determinate\" [value]=\"status.percent\"></mat-progress-bar>\n </div>\n </div>\n <div class=\"hero-content celebration\" *ngIf=\"status.percent === 100\">\n <mat-icon class=\"celebrate-icon\">celebration</mat-icon>\n <div class=\"hero-text\">\n <h1>You're all set!</h1>\n <p>All setup steps are complete \u2014 your system is ready to operate.</p>\n </div>\n </div>\n </mat-card>\n\n <!-- Module picker (v2): choose what the business uses; optional modules toggle steps + menus -->\n <mat-card class=\"setup-modules\" *ngIf=\"modules.length > 0\">\n <div class=\"group-header\">\n <h2>Your modules</h2>\n <span class=\"group-counter\">Tap a module to turn it on or off \u2014 you can change this anytime.</span>\n </div>\n <!-- Changed (v3): grouped \u2014 the picker now carries the whole library catalog, so cards sit under their\n group heading (the app's own modules first). A backend without groups yields one unnamed group,\n which renders as the original flat grid. -->\n <div class=\"module-section\" *ngFor=\"let mgroup of moduleGroups\">\n <div class=\"module-section-head\">\n <span class=\"module-section-name\">{{ mgroup.name }}</span>\n <span class=\"module-section-count\">{{ mgroup.enabled }} of {{ mgroup.total }} on</span>\n </div>\n <div class=\"module-grid\">\n <div class=\"module-card\" *ngFor=\"let mod of mgroup.modules\"\n [class.enabled]=\"mod.enabled\" [class.core]=\"mod.core\" [class.busy]=\"togglingKey === mod.key\"\n (click)=\"toggleModule(mod)\">\n <div class=\"module-head\">\n <mat-icon class=\"module-icon\">{{ mod.icon || 'extension' }}</mat-icon>\n <mat-icon class=\"module-state\" [class.on]=\"mod.enabled\">{{ mod.enabled ? 'check_circle' : 'radio_button_unchecked' }}</mat-icon>\n </div>\n <div class=\"module-title\">{{ mod.title }}</div>\n <div class=\"module-description\">{{ mod.description }}</div>\n <span class=\"module-chip\" *ngIf=\"mod.core\">Always on</span>\n </div>\n </div>\n </div>\n <div class=\"module-actions\" *ngIf=\"!modulesConfirmed\">\n <button mat-flat-button color=\"primary\" [disabled]=\"togglingKey !== ''\" (click)=\"confirmModules()\">Confirm selection</button>\n <span class=\"module-hint\">Happy with this selection? Confirm it to complete the step below.</span>\n </div>\n </mat-card>\n\n <!-- Category groups, top-down \u2014 Changed: flat spa-checklist replaces the raised mat-accordion -->\n <mat-card class=\"setup-group\" *ngFor=\"let group of groups\">\n <div class=\"group-header\">\n <h2>{{ group.name }}</h2>\n <span class=\"group-counter\">{{ group.completed }} of {{ group.total }}</span>\n </div>\n <spa-checklist [config]=\"group.config\" [itemTemplate]=\"stepExtrasTpl\"></spa-checklist>\n </mat-card>\n\n <!-- Demo data (opt-in via setupConfig.demoData) \u2014 moved here off the per-app configuration pages -->\n <mat-card class=\"setup-group setup-demo\" *ngIf=\"showDemoData\">\n <div class=\"group-header\">\n <h2>Demo data</h2>\n <span class=\"group-counter\">Explore with example records, then clear them out</span>\n </div>\n <p class=\"demo-description\">Seeding fills the system with example records so you can try it out before capturing anything real. Removing deletes only those example records.</p>\n\n <!-- Added (seed-all): the per-area report. The card used to make ONE claim about the whole system, because the\n backend sentinel is a single customer named \"Example: \u2026\" \u2014 so one demo customer answered for products,\n suppliers and everything else, and none of it ever looked at REAL data. Each line here is one area\n answering for itself: what is in it now, and what a fill would add. Deliberately a compact one-line list\n and not the full seed-offer row used on the steps \u2014 a step is where you decide about ONE area, this is a\n report on all of them, and repeating the effect and notice paragraphs a dozen times would bury the button.\n The full wording is still shown, in the confirm, before anything is written. -->\n <div class=\"demo-areas\" *ngIf=\"demoAreas.length > 0\">\n <div class=\"demo-area\" *ngFor=\"let area of demoAreas\" [class.added]=\"area.offer.applied\">\n <mat-icon class=\"demo-area-state\" [class.on]=\"area.offer.applied\">{{ area.offer.applied ? 'check_circle' : (area.offer.icon || 'science') }}</mat-icon>\n <span class=\"demo-area-name\">{{ area.label }}</span>\n <span class=\"demo-area-count\">{{ area.state }}</span>\n </div>\n </div>\n\n <div class=\"demo-actions\">\n <!-- The owner's sentence, made literal: fill the areas that don't have data. It says HOW MANY areas, because\n a button that says only \"seed\" is asking for blind consent, and it disappears entirely when there is\n nothing left to fill rather than sitting there doing nothing when clicked. -->\n <button mat-flat-button color=\"primary\" *ngIf=\"demoPending > 0\" [disabled]=\"seedingAll || demoBusy\" (click)=\"seedAll()\"><mat-icon>auto_fix_high</mat-icon> Fill {{ demoPending === 1 ? '1 empty area' : demoPending + ' empty areas' }}</button>\n <span class=\"demo-full\" *ngIf=\"demoAreas.length > 0 && demoPending === 0\">Every area already has data.</span>\n <!-- KEPT, not replaced. The offer catalogue covers the areas it declares; each app's own demo dataset still\n comes only from here, so retiring this would take away the only way to seed those. -->\n <button mat-stroked-button color=\"primary\" [disabled]=\"demoBusy || seedingAll\" (click)=\"seedDemoData()\"><mat-icon>add_circle</mat-icon> Seed demo data</button>\n <button mat-stroked-button color=\"warn\" [disabled]=\"demoBusy || seedingAll\" (click)=\"removeDemoData()\"><mat-icon>delete</mat-icon> Remove demo data</button>\n </div>\n </mat-card>\n\n</div>\n\n<!-- Projected into the expanded checklist body: predefined roles picker on the roles step (v2) -->\n<ng-template #stepExtrasTpl let-item>\n <div class=\"role-templates\" *ngIf=\"hasRoleTemplates(item.data) && roleTemplates.length > 0\">\n <div class=\"role-template\" *ngFor=\"let tpl of roleTemplates\" [class.created]=\"tpl.exists\" [class.selected]=\"!tpl.exists && selectedTemplates[tpl.key]\" (click)=\"toggleTemplate(tpl)\">\n <div class=\"role-head\">\n <mat-icon class=\"role-state\" [class.on]=\"tpl.exists || selectedTemplates[tpl.key]\">{{ (tpl.exists || selectedTemplates[tpl.key]) ? 'check_circle' : 'radio_button_unchecked' }}</mat-icon>\n <span class=\"role-name\">{{ tpl.name }}</span>\n <span class=\"role-created\" *ngIf=\"tpl.exists\"><mat-icon>check</mat-icon>Created</span>\n </div>\n <div class=\"role-description\">{{ tpl.description }}</div>\n </div>\n </div>\n\n <!-- Added (seed seam): example-data offers for this step. Deliberately the SAME row the role picker and both\n preset pickers use \u2014 1px border, 8px radius, 10px/12px padding, the same green for \"already done\", the same\n 32px text indent \u2014 because this page is one the owner already likes and a fourth visual language inside it\n would be the change, not the feature. Three things are new, and each one is a stated requirement:\n \u2022 the effect line says exactly what lands, authored server-side;\n \u2022 \"Also adds \u2026\" names the prerequisites, because a vehicle cannot exist without its makes and models;\n \u2022 the notice band says in as many words that this is EXAMPLE data you can edit later. It is the only\n tinted band inside the row, so the eye finds it, and it is grey rather than amber, because this is a\n clarification and not a warning \u2014 nobody has done anything wrong by reading it.\n A row is a BUTTON, never a tick: unlike the presets there is no \"who\" to choose, so a checkbox idiom would\n promise a selection step that does not exist. -->\n <div class=\"seed-offers\" *ngIf=\"item.data && seedRowsByStep[item.data.key] as seedRows\">\n <div class=\"seed-offer\" *ngFor=\"let row of seedRows\" [class.added]=\"row.offer.applied\">\n <div class=\"seed-head\">\n <mat-icon class=\"seed-state\" [class.on]=\"row.offer.applied\">{{ row.offer.applied ? 'check_circle' : (row.offer.icon || 'science') }}</mat-icon>\n <span class=\"seed-name\">{{ row.offer.name }}</span>\n <span class=\"seed-added\" *ngIf=\"row.offer.applied\"><mat-icon>check</mat-icon>Added</span>\n </div>\n <div class=\"seed-description\">{{ row.offer.description }}</div>\n <div class=\"seed-effect\">{{ row.offer.effect }}</div>\n <div class=\"seed-also\" *ngIf=\"row.alsoAdds\">{{ row.alsoAdds }}</div>\n <div class=\"seed-notice\"><mat-icon>science</mat-icon><span>{{ row.offer.sampleNotice }}</span></div>\n <div class=\"seed-foot\">\n <span class=\"seed-already\" *ngIf=\"row.hereAlready\">{{ row.hereAlready }}</span>\n <button mat-stroked-button class=\"seed-action\" *ngIf=\"!row.offer.applied\" [disabled]=\"seedingKey !== ''\" (click)=\"applySeedOffer(row.offer)\"><mat-icon>add</mat-icon>{{ row.actionLabel }}</button>\n </div>\n </div>\n </div>\n\n <!-- Added (presets): the preset picker. Same row as the role picker above \u2014 same state icon, same 8px stack,\n same colours \u2014 plus ONE sentence and ONE control.\n Changed (presets P4): rendered through ONE shared template for both the notification step and the sign-off\n step, parameterised by the picker object. The two sit one above the other on the same page, so a second\n near-identical block would drift and every difference would read as a mistake. -->\n <ng-container *ngIf=\"pickerFor(item.data) as picker\">\n <ng-container *ngTemplateOutlet=\"presetPickerTpl; context: { $implicit: picker }\"></ng-container>\n </ng-container>\n</ng-template>\n\n<!-- The one picker body. `picker` carries the domain: its rows, its roles, its wording, its apply call. -->\n<ng-template #presetPickerTpl let-picker>\n <div class=\"preset-picker\" *ngIf=\"picker.loaded && picker.presets.length > 0\">\n\n <!-- No selectable role \u2192 we ask for one instead of offering ticks that would build an inert rule. The wording\n is the picker's own, because \"no roles\" and \"roles nobody is in\" are different situations. -->\n <div class=\"preset-empty\" *ngIf=\"picker.roles.length === 0\">{{ picker.emptyText }}</div>\n\n <ng-container *ngIf=\"picker.roles.length > 0\">\n <div class=\"preset-section\" *ngFor=\"let pgroup of picker.groups\">\n <div class=\"preset-section-head\">\n <span class=\"preset-section-name\">{{ pgroup.name }}</span>\n <span class=\"preset-section-count\">{{ pgroup.on }} of {{ pgroup.total }}</span>\n </div>\n <div class=\"preset-row\" *ngFor=\"let row of pgroup.rows\" role=\"checkbox\" tabindex=\"0\"\n [class.configured]=\"row.preset.exists\" [class.selected]=\"!row.preset.exists && picker.selected[row.preset.key]\"\n [attr.aria-checked]=\"row.preset.exists || !!picker.selected[row.preset.key]\" [attr.aria-disabled]=\"row.preset.exists\"\n (click)=\"togglePreset(picker, row.preset)\"\n (keydown.enter)=\"togglePreset(picker, row.preset)\"\n (keydown.space)=\"togglePreset(picker, row.preset); $event.preventDefault()\">\n <div class=\"preset-head\">\n <mat-icon class=\"preset-state\" [class.on]=\"row.preset.exists || picker.selected[row.preset.key]\">{{ (row.preset.exists || picker.selected[row.preset.key]) ? 'check_circle' : 'radio_button_unchecked' }}</mat-icon>\n <span class=\"preset-name\">{{ row.preset.name }}</span>\n <span class=\"preset-configured\" *ngIf=\"row.preset.exists\"><mat-icon>check</mat-icon>Configured</span>\n </div>\n <div class=\"preset-description\">{{ row.preset.description }}</div>\n <!-- The effect sentence comes from the server whole; `before` and `after` are its two halves either side\n of the {role} placeholder, so reading the line IS reading the configuration. The sign-off sentences\n carry their own deferred-notification caveat, which is why there is no extra note here. -->\n <div class=\"preset-effect\" *ngIf=\"!row.preset.exists\">{{ row.before }}<select class=\"preset-role\" [attr.aria-label]=\"picker.roleAria + row.preset.name\"\n (click)=\"$event.stopPropagation()\"\n (keydown)=\"$event.stopPropagation()\"\n (change)=\"setPresetRole(picker, row.preset, $any($event.target).value)\">\n <option *ngFor=\"let role of picker.roles\" [value]=\"role.value\" [selected]=\"role.value === picker.role[row.preset.key]\">{{ role.name }}</option>\n </select>{{ row.after }}</div>\n <div class=\"preset-effect preset-done\" *ngIf=\"row.preset.exists\">{{ picker.doneText }}</div>\n </div>\n </div>\n\n <!-- One page-level channel choice, notifications only. In-app is the only channel that always works; SMS is a\n stub and is never offered; an approval request is always in-app, so the sign-off picker has no such line. -->\n <div class=\"preset-email\" *ngIf=\"picker.showEmail\" role=\"checkbox\" tabindex=\"0\" [attr.aria-checked]=\"includeEmail\"\n (click)=\"toggleIncludeEmail()\"\n (keydown.enter)=\"toggleIncludeEmail()\"\n (keydown.space)=\"toggleIncludeEmail(); $event.preventDefault()\">\n <mat-icon class=\"preset-state\" [class.on]=\"includeEmail\">{{ includeEmail ? 'check_box' : 'check_box_outline_blank' }}</mat-icon>\n <span class=\"preset-email-label\">Also send these by email</span>\n <span class=\"preset-email-hint\">In-app always. Email needs your mail settings.</span>\n </div>\n </ng-container>\n </div>\n</ng-template>\n\n<!-- Graceful empty state (feature disabled or status unavailable) -->\n<div class=\"setup-empty\" *ngIf=\"!status\">\n <mat-icon>rocket_launch</mat-icon>\n <p>Setup status is not available yet.</p>\n</div>\n", styles: [".setup-page{max-width:860px;margin:0 auto;padding:16px;display:flex;flex-direction:column;gap:16px}.setup-hero{padding:24px}.hero-content{display:flex;align-items:center;justify-content:space-between;gap:24px;flex-wrap:wrap}.hero-text h1{margin:0 0 4px;font-size:24px}.hero-text p{margin:0 0 8px;color:#0009}.hero-counter{font-size:13px;color:#0009}.hero-progress{flex:1;min-width:220px;max-width:340px}.hero-percent{display:block;font-size:28px;font-weight:600;color:#2e7d32;margin-bottom:6px;text-align:right}.hero-progress mat-progress-bar{height:10px;border-radius:5px}.setup-hero.celebrate{background:linear-gradient(135deg,#e8f5e9,#f1f8e9)}.celebration{justify-content:flex-start}.celebrate-icon{font-size:48px;width:48px;height:48px;color:#2e7d32;animation:celebrate-pop .6s ease-out}@keyframes celebrate-pop{0%{transform:scale(.3);opacity:0}70%{transform:scale(1.15)}to{transform:scale(1);opacity:1}}.setup-group{padding:16px}.group-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}.group-header h2{margin:0;font-size:17px}.group-counter{font-size:12px;color:#0000008c}.setup-modules{padding:16px}.module-section+.module-section{margin-top:18px}.module-section-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;padding-bottom:6px;border-bottom:1px solid rgba(0,0,0,.08)}.module-section-name{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:#0009}.module-section-count{font-size:11px;color:#00000073;white-space:nowrap}.module-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px;margin-top:8px}.module-card{border:1px solid rgba(0,0,0,.12);border-radius:10px;padding:12px;cursor:pointer;transition:border-color .15s,background .15s,opacity .15s;opacity:.72;position:relative}.module-card:hover{border-color:#90a4ae}.module-card.enabled{border-color:#4caf50;background:#f6fbf6;opacity:1}.module-card.core{cursor:default}.module-card.busy{pointer-events:none;opacity:.5}.module-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}.module-icon{color:#546e7a}.module-card.enabled .module-icon{color:#2e7d32}.module-state{color:#b0bec5}.module-state.on{color:#4caf50}.module-title{font-weight:600;font-size:14px;margin-bottom:4px}.module-description{font-size:12px;color:#0009;min-height:30px}.module-chip{display:inline-block;margin-top:8px;background:#eceff1;color:#546e7a;border-radius:12px;padding:2px 10px;font-size:11px}.module-actions{display:flex;align-items:center;gap:8px 12px;margin-top:14px;flex-wrap:wrap}.module-actions button{flex-shrink:0}.module-hint{font-size:12px;color:#0000008c}.role-templates{display:flex;flex-direction:column;gap:8px;margin:0 0 12px}.role-template{border:1px solid rgba(0,0,0,.08);border-radius:8px;padding:10px 12px;cursor:pointer;transition:border-color .15s,background .15s}.role-template:hover{border-color:#90a4ae}.role-template.selected{border-color:#4caf50;background:#f6fbf6}.role-template.created{background:#f6fbf6;border-color:#c8e6c9;cursor:default}.role-head{display:flex;align-items:center;gap:8px}.role-state{color:#b0bec5;flex-shrink:0}.role-state.on{color:#4caf50}.role-name{font-weight:500;font-size:13px}.role-created{margin-left:auto;display:inline-flex;align-items:center;gap:4px;font-size:12px;color:#2e7d32}.role-created mat-icon{font-size:16px;width:16px;height:16px}.role-description{font-size:12px;color:#0009;margin:2px 0 0 32px}.seed-offers{display:flex;flex-direction:column;gap:8px;margin:0 0 12px}.seed-offer{border:1px solid rgba(0,0,0,.08);border-radius:8px;padding:10px 12px;transition:border-color .15s,background .15s}.seed-offer.added{background:#f6fbf6;border-color:#c8e6c9}.seed-head{display:flex;align-items:center;gap:8px}.seed-state{color:#b0bec5;flex-shrink:0}.seed-state.on{color:#4caf50}.seed-name{font-weight:500;font-size:13px}.seed-added{margin-left:auto;display:inline-flex;align-items:center;gap:4px;font-size:12px;color:#2e7d32;white-space:nowrap}.seed-added mat-icon{font-size:16px;width:16px;height:16px}.seed-description{font-size:12px;color:#0009;margin:2px 0 0 32px}.seed-effect{font-size:12px;line-height:18px;color:#000000c7;margin:4px 0 0 32px}.seed-also{font-size:12px;line-height:18px;color:#0000008c;margin:2px 0 0 32px}.seed-notice{display:flex;align-items:flex-start;gap:6px;margin:8px 0 0 32px;padding:6px 8px;border-radius:6px;background:#00000009;font-size:11.5px;line-height:17px;color:#0009}.seed-notice mat-icon{font-size:15px;width:15px;height:15px;line-height:15px;flex-shrink:0;margin-top:1px;color:#0000006b}.seed-foot{display:flex;align-items:center;justify-content:space-between;gap:8px 12px;flex-wrap:wrap;margin:10px 0 0 32px}.seed-already{font-size:11px;color:#00000073}.seed-action{margin-left:auto;flex-shrink:0}.seed-action mat-icon{font-size:18px;width:18px;height:18px;margin-right:4px}.preset-picker{display:flex;flex-direction:column;gap:14px;margin:0 0 12px}.preset-empty{font-size:12px;line-height:18px;color:#0009;max-width:62ch}.preset-section{display:flex;flex-direction:column;gap:8px}.preset-section-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;padding-bottom:6px;border-bottom:1px solid rgba(0,0,0,.08)}.preset-section-name{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:#0009}.preset-section-count{font-size:11px;color:#00000073;white-space:nowrap}.preset-row{border:1px solid rgba(0,0,0,.08);border-radius:8px;padding:10px 12px;cursor:pointer;transition:border-color .15s,background .15s}.preset-row:hover{border-color:#90a4ae}.preset-row:focus-visible{outline:2px solid #4caf50;outline-offset:2px}.preset-row.selected{border-color:#4caf50;background:#f6fbf6}.preset-row.configured{background:#f6fbf6;border-color:#c8e6c9;cursor:default}.preset-head{display:flex;align-items:center;gap:8px}.preset-state{color:#b0bec5;flex-shrink:0}.preset-state.on{color:#4caf50}.preset-name{font-weight:500;font-size:13px}.preset-configured{margin-left:auto;display:inline-flex;align-items:center;gap:4px;font-size:12px;color:#2e7d32;white-space:nowrap}.preset-configured mat-icon{font-size:16px;width:16px;height:16px}.preset-description{font-size:12px;color:#0009;margin:2px 0 0 32px}.preset-effect{font-size:12px;line-height:22px;color:#000000c7;margin:4px 0 0 32px}.preset-done{color:#2e7d32}.preset-role{font-family:inherit;font-size:12px;font-weight:500;color:#2e7d32;background:#fff;border:1px solid #c8e6c9;border-radius:6px;padding:2px 4px;margin:0 2px;max-width:100%;cursor:pointer}.preset-role:hover{border-color:#4caf50}.preset-role:focus-visible{outline:2px solid #4caf50;outline-offset:1px}.preset-email{display:flex;align-items:center;gap:8px;flex-wrap:wrap;cursor:pointer;padding:2px 0}.preset-email:focus-visible{outline:2px solid #4caf50;outline-offset:2px;border-radius:6px}.preset-email-label{font-size:13px;font-weight:500}.preset-email-hint{font-size:12px;color:#0000008c}.setup-empty{text-align:center;padding:48px 16px;color:#00000080}.setup-empty mat-icon{font-size:40px;width:40px;height:40px}.demo-description{margin:8px 0 14px;font-size:13px;color:#0009}.demo-actions{display:flex;gap:12px;flex-wrap:wrap;align-items:center}.demo-areas{display:flex;flex-direction:column;gap:6px;margin:0 0 14px}.demo-area{display:flex;align-items:center;gap:8px;border:1px solid rgba(0,0,0,.08);border-radius:8px;padding:7px 12px}.demo-area.added{background:#f6fbf6;border-color:#c8e6c9}.demo-area-state{color:#b0bec5;flex-shrink:0}.demo-area-state.on{color:#4caf50}.demo-area-name{font-size:12.5px;color:#000c}.demo-area-count{margin-left:auto;font-size:11.5px;color:#00000073;white-space:nowrap;padding-left:8px}.demo-area.added .demo-area-count{color:#2e7d32}.demo-full{font-size:12.5px;color:#00000080}@media (max-width: 700px){.setup-page{padding:10px 0;gap:12px}.setup-hero{padding:12px}.setup-modules,.setup-group{padding:10px}.hero-content{gap:10px}.hero-text h1{font-size:20px}.setup-hero:not(.celebrate) .hero-text p{display:none}.hero-counter{font-size:12px}.hero-progress{min-width:0;max-width:none}.hero-percent{font-size:22px;margin-bottom:4px}.hero-progress mat-progress-bar{height:8px;border-radius:4px}.celebrate-icon{font-size:36px;width:36px;height:36px}.group-header{margin-bottom:6px;gap:2px 8px}.group-header h2{font-size:15px}.group-counter{font-size:11px}.setup-modules>.group-header,.setup-demo>.group-header{flex-wrap:wrap}.setup-modules>.group-header .group-counter,.setup-demo>.group-header .group-counter{flex:1 0 100%}.module-section+.module-section{margin-top:12px}.module-section-head{padding-bottom:4px}.module-grid{gap:8px;margin-top:6px}.module-card{padding:10px}.module-head{margin-bottom:4px}.module-title{font-size:13.5px;margin-bottom:2px}.module-description{min-height:0}.module-chip{margin-top:6px}.module-actions{margin-top:10px}.role-templates,.preset-picker,.seed-offers{gap:6px;margin-bottom:8px}.role-template,.preset-row,.seed-offer{padding:8px 10px}.role-description,.preset-description,.preset-effect,.seed-description,.seed-effect,.seed-also,.seed-notice,.seed-foot{margin-left:0}.seed-foot{margin-top:8px}.seed-action{width:100%;margin-left:0}.seed-already{flex:1 0 100%;order:2}.preset-section{gap:6px}.preset-section-head{padding-bottom:4px}.demo-description{margin:6px 0 10px;font-size:12.5px}.demo-actions{gap:8px}.setup-empty{padding:28px 12px}.demo-areas{gap:5px;margin-bottom:10px}.demo-area{flex-wrap:wrap;padding:7px 10px}.demo-area-count{flex:1 0 100%;margin-left:32px;padding-left:0;white-space:normal}.demo-actions button{width:100%}.module-hint{flex:0 0 100%}.preset-role{display:block;width:100%;margin:4px 0 0;padding:6px 8px}.preset-email-hint{flex:0 0 100%}}\n"] }]
12523
12859
  }] });
12524
12860
 
12525
12861
  // Quiet Loading — perceived-progress engine (FSD D1).
@@ -13967,21 +14303,11 @@ class TextMultiComponent {
13967
14303
  this.loadIndicator = new FieldLoadIndicator();
13968
14304
  this.hoverChange = new EventEmitter();
13969
14305
  } // Changed: injected ApiErrorService so a failed option load is no longer silent
13970
- // Added: identical normalisation to SelectMultiComponent.toValueList, deliberately kept the same shape and
13971
- // name so the two multi-value components cannot drift. `value` is documented as a ';'-delimited string, but
13972
- // the truthiness guard below used to hand anything truthy straight to .split() — an array (any form whose
13973
- // backend DTO takes a List<int> rewrites the bound property in place before posting) or a bare scalar both
13974
- // threw "split is not a function". select-multi hit exactly that in piglet; nothing feeds text-multi an array
13975
- // today, so this is hardening ahead of the first caller that does, not a repair.
13976
- toValueList(raw) {
13977
- if (Array.isArray(raw))
13978
- return raw.filter(v => v !== null && v !== undefined && String(v).trim() !== '').map(v => String(v).trim()); // Added: the List<int> shape
13979
- if (!raw)
13980
- return []; // Added: preserves the previous falsy-means-nothing-entered rule for null/undefined/''
13981
- return String(raw).split(';').filter(v => v.trim() !== '').map(v => v.trim()); // Changed: String() first, so a bare scalar no longer throws either
13982
- }
13983
14306
  ngOnInit() {
13984
- this.values = this.toValueList(this.value); // Changed: was this.value.split(';') directly on an @Input of unknown shape
14307
+ // Changed: the private toValueList is gone it was a hand-kept COPY of SelectMultiComponent's, held in sync
14308
+ // by a comment asking the next reader not to let them drift. Core.parseMultiValue is that shared body, so
14309
+ // the two components cannot drift by construction rather than by request. Behaviour is unchanged.
14310
+ this.values = Core.parseMultiValue(this.value);
13985
14311
  this.setupAutoComplete();
13986
14312
  this.getData(this.loadAction);
13987
14313
  }
@@ -14209,21 +14535,11 @@ class SelectMultiComponent {
14209
14535
  }
14210
14536
  this.initializeValues();
14211
14537
  }
14212
- // Added: `value` is documented as a ';'-delimited string, but real callers legitimately hold an array —
14213
- // any form whose backend DTO takes a List<int> rewrites the bound property in place before posting
14214
- // (piglet has four: wean, sell, move, treat). That array flowed straight back down [(value)] and
14215
- // this.value.split(';') threw "split is not a function" on every subsequent change-detection pass.
14216
- // Normalise on the way in rather than type-guarding at one call site, and keep emitting the ';' string
14217
- // on the way out so no existing consumer's parsing changes.
14218
- toValueList(raw) {
14219
- if (Array.isArray(raw))
14220
- return raw.filter(v => v !== null && v !== undefined && String(v).trim() !== '').map(v => String(v).trim()); // Added: the List<int> shape
14221
- if (!raw)
14222
- return []; // Added: preserves the previous falsy-means-nothing-selected rule for null/undefined/''
14223
- return String(raw).split(';').filter(v => v.trim() !== '').map(v => v.trim()); // Changed: String() first, so a bare scalar (a single id) no longer throws either
14224
- }
14225
14538
  initializeValues() {
14226
- const requested = this.toValueList(this.value); // Changed: was this.value.split(';') directly on an @Input of unknown shape
14539
+ // Changed: the private toValueList is gone its body moved to Core.parseMultiValue, THE multi-value
14540
+ // contract. It was correct here and still re-implemented by every consumer downstream, because a private
14541
+ // component member is not reusable; publishing it is the whole point. Behaviour is byte-for-byte identical.
14542
+ const requested = Core.parseMultiValue(this.value);
14227
14543
  if (requested.length > 0) { // Changed: gate on the parsed request, not on truthiness of a raw value that may be an array
14228
14544
  const values = requested.filter(v => this.options?.some(opt => opt[this.optionValue]?.toString() === v)); // Changed: same "drop values not in options" rule, expressed without the map-to-null round trip
14229
14545
  this.selectedValues = values;
@@ -15643,6 +15959,33 @@ class TableRowComponent {
15643
15959
  showBanner(message) {
15644
15960
  this.showBannerEvent.emit(message);
15645
15961
  }
15962
+ // Added: a chip is a BADGE unless it declares a detailsConfig. 50 chip columns across the estate carried an
15963
+ // unconditional (click) with no detailsConfig — 39 status/enum and count badges plus 11 entity references that
15964
+ // were never wired — so every one of them rendered as a control, took focus, showed a pointer cursor, and did
15965
+ // nothing when clicked. columnClicked's no-detailsConfig branch emits actionClick under the COLUMN's name; no
15966
+ // handler anywhere in the six codebases listens for any of those 50 names, so the emit was a dead end, which is
15967
+ // why withdrawing it is safe for the untracked consumer apps too — their inert chips already did nothing.
15968
+ // Guarded here rather than in the template so the shape matches onMonogramTap above, which has resolved the same
15969
+ // question for the monogram cell since it gained a details path. The template also removes the AFFORDANCE
15970
+ // (.chip-inert + tabindex -1): swallowing a click a cell still advertises is the half fix that reads as a bug.
15971
+ onChipClick(column, row) {
15972
+ if (!column?.detailsConfig)
15973
+ return;
15974
+ this.onColumnClick(column, row);
15975
+ }
15976
+ // Added: a monogram cell can now DRILL IN. The circle replaces a name that may have been a click-through into
15977
+ // the record behind it (shift's Trips driver), and until now the monogram type had no details path at all — the
15978
+ // tap only ever surfaced the full name, so converting such a column silently deleted the navigation.
15979
+ // detailsConfig wins when the column declares one (the same field the chip/button types route through
15980
+ // columnClicked, so the dialog is opened by the table exactly as it always was); the banner stays the behaviour
15981
+ // for every monogram column that declares none, which is all of them today.
15982
+ onMonogramTap(label) {
15983
+ if (this.column?.detailsConfig) {
15984
+ this.onColumnClick(this.column, this.row);
15985
+ return;
15986
+ }
15987
+ this.showBanner(label);
15988
+ }
15646
15989
  textDisplayed(row, column) {
15647
15990
  let txt = row[column.name];
15648
15991
  let max = !this.smallScreen ? column?.maxLength : column?.maxLength / 2;
@@ -15660,11 +16003,11 @@ class TableRowComponent {
15660
16003
  return false;
15661
16004
  }
15662
16005
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: TableRowComponent, deps: [{ token: ButtonService }], target: i0.ɵɵFactoryTarget.Component }); }
15663
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: TableRowComponent, isStandalone: false, selector: "app-table-row", inputs: { column: "column", row: "row", config: "config", smallScreen: "smallScreen" }, outputs: { actionClick: "actionClick", columnClick: "columnClick", showBannerEvent: "showBannerEvent" }, usesOnChanges: true, ngImport: i0, template: "<ng-container [ngSwitch]=\"column.type\">\n <ng-container *ngSwitchCase=\"'checkbox'\">\n <spa-check [value]=\"row[column.name]\" [readonly]=\"true\"></spa-check>\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'select'\">\n <spa-select-lite [options]=\"column.options\" [optionDisplay]=\"column.optionDisplay\" [optionValue]=\"column.optionValue\" [(value)]=\"row[column.name]\" width=\"90%\"></spa-select-lite>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'chip'\">\n <button mat-stroked-button (click)=\"onColumnClick(column, row)\" [ngStyle]=\"{'background-color': vm.color || '#eceff1', 'color': 'rgba(0, 0, 0, 0.87)', 'border': 'none'}\">{{row[column.name]}}</button>\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'icon'\">\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'date'\">\n {{row[column.name] | date : 'dd/MM/yyyy'}}\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'date-short'\">\n {{row[column.name] | date : 'd MMM'}}\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'datetime'\">\n {{row[column.name] | date : 'dd/MM/yyyy HH:mm'}}\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'datetimesec'\">\n {{row[column.name] | date : 'dd/MM/yyyy HH:mm:ss'}}\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'money'\">\n <label [ngStyle]=\"{'color': vm.color }\">{{row[column.name] | currency:'':''}}</label>\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n\n <!-- Added: monogram \u2014 the row still holds the full name, so filter/sort keep working on the underlying value -->\n <ng-container *ngSwitchCase=\"'monogram'\">\n <spa-monogram [value]=\"row[column.name]\" [key]=\"column.keyField ? row[column.keyField] : null\" (tapped)=\"showBanner($event)\"></spa-monogram>\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'button'\">\n <button mat-stroked-button (click)=\"onColumnClick(column, row)\" [ngStyle]=\"{'color': vm.color}\" >{{row[column.name]}}</button>\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n\n <ng-container *ngSwitchDefault>\n <label [ngStyle]=\"{'color': vm.color }\">\n <ng-container *ngIf=\"column.type === 'number'\">\n {{row[column.name] | number:'1.0-2'}}\n </ng-container>\n <ng-container *ngIf=\"column.type !== 'number'\">\n {{vm.text}}\n </ng-container>\n </label>\n <mat-icon class=\"col-icon\" *ngIf=\"vm.truncated\" matTooltip='Show more' matTooltipPosition=\"above\" (click)=\"showBanner(row[column.name])\">more_horiz</mat-icon>\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n</ng-container>\n", styles: [".top{display:flex;flex-direction:row;flex-wrap:wrap;justify-content:space-between;align-items:center;margin-bottom:10px;margin-top:10px}.mat-mini-fab{width:32px;height:32px}.mat-mini-fab mat-icon{font-size:16px;margin-top:-3px}.mat-icon-button{width:32px;height:32px}.mat-icon-button mat-icon{font-size:20px;margin-top:-7px}.col-icon{margin-left:10px;vertical-align:middle}.title{margin-top:10px;font-size:larger;font-weight:300}.make-gray{background-color:#e5e5e5}.right-padding{padding-right:10px}.action-buttons-container{display:flex;justify-content:flex-end;align-items:center}.refreshIcon{font-size:22px!important;margin-top:-7px!important}.mat-mdc-cell .mat-mdc-outlined-button,.mat-mdc-cell .mat-mdc-button-base:not(.mat-mdc-icon-button){height:auto!important;min-height:36px!important;white-space:normal!important;word-break:break-word!important;overflow-wrap:break-word!important;line-height:1.4!important;text-align:left!important;padding:6px 16px!important}.mat-mdc-cell .mat-mdc-outlined-button .mdc-button__label,.mat-mdc-cell .mat-mdc-button-base:not(.mat-mdc-icon-button) .mdc-button__label{white-space:normal!important;word-break:break-word!important;overflow-wrap:break-word!important}\n"], dependencies: [{ kind: "directive", type: i1$2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1$2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i1$2.NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }, { kind: "directive", type: i1$2.NgSwitch, selector: "[ngSwitch]", inputs: ["ngSwitch"] }, { kind: "directive", type: i1$2.NgSwitchCase, selector: "[ngSwitchCase]", inputs: ["ngSwitchCase"] }, { kind: "directive", type: i1$2.NgSwitchDefault, selector: "[ngSwitchDefault]" }, { kind: "component", type: i3.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i4$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "directive", type: i7.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: CheckComponent, selector: "spa-check", inputs: ["readonly", "display", "value", "infoMessage", "hint"], outputs: ["valueChange", "click", "check", "uncheck", "infoClick"] }, { kind: "component", type: MonogramComponent, selector: "spa-monogram", inputs: ["value", "key"], outputs: ["tapped"] }, { kind: "component", type: SelectLiteComponent, selector: "spa-select-lite" }, { kind: "pipe", type: i1$2.DecimalPipe, name: "number" }, { kind: "pipe", type: i1$2.CurrencyPipe, name: "currency" }, { kind: "pipe", type: i1$2.DatePipe, name: "date" }], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None }); }
16006
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: TableRowComponent, isStandalone: false, selector: "app-table-row", inputs: { column: "column", row: "row", config: "config", smallScreen: "smallScreen" }, outputs: { actionClick: "actionClick", columnClick: "columnClick", showBannerEvent: "showBannerEvent" }, usesOnChanges: true, ngImport: i0, template: "<ng-container [ngSwitch]=\"column.type\">\n <ng-container *ngSwitchCase=\"'checkbox'\">\n <spa-check [value]=\"row[column.name]\" [readonly]=\"true\"></spa-check>\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'select'\">\n <spa-select-lite [options]=\"column.options\" [optionDisplay]=\"column.optionDisplay\" [optionValue]=\"column.optionValue\" [(value)]=\"row[column.name]\" width=\"90%\"></spa-select-lite>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'chip'\">\n <button mat-stroked-button [class.chip-inert]=\"!column.detailsConfig\" [tabIndex]=\"column.detailsConfig ? 0 : -1\" (click)=\"onChipClick(column, row)\" [ngStyle]=\"{'background-color': vm.color || '#eceff1', 'color': 'rgba(0, 0, 0, 0.87)', 'border': 'none'}\">{{row[column.name]}}</button> <!-- Changed: a chip only OFFERS a click when it can honour one \u2014 chip-inert and tabindex -1 withdraw the cursor/hover/focus affordance, onChipClick withdraws the behaviour -->\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'icon'\">\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'date'\">\n {{row[column.name] | date : 'dd/MM/yyyy'}}\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'date-short'\">\n {{row[column.name] | date : 'd MMM'}}\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'datetime'\">\n {{row[column.name] | date : 'dd/MM/yyyy HH:mm'}}\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'datetimesec'\">\n {{row[column.name] | date : 'dd/MM/yyyy HH:mm:ss'}}\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'money'\">\n <label [ngStyle]=\"{'color': vm.color }\">{{row[column.name] | currency:'':''}}</label>\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n\n <!-- Added: monogram \u2014 the row still holds the full name, so filter/sort keep working on the underlying value -->\n <ng-container *ngSwitchCase=\"'monogram'\">\n <spa-monogram [value]=\"row[column.name]\" [key]=\"column.keyField ? row[column.keyField] : null\" (tapped)=\"onMonogramTap($event)\"></spa-monogram> <!-- Changed: tap opens the column's detailsConfig when it declares one, and still banners the full name when it does not -->\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'button'\">\n <button mat-stroked-button (click)=\"onColumnClick(column, row)\" [ngStyle]=\"{'color': vm.color}\" >{{row[column.name]}}</button>\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n\n <ng-container *ngSwitchDefault>\n <label [ngStyle]=\"{'color': vm.color }\">\n <ng-container *ngIf=\"column.type === 'number'\">\n {{row[column.name] | number:'1.0-2'}}\n </ng-container>\n <ng-container *ngIf=\"column.type !== 'number'\">\n {{vm.text}}\n </ng-container>\n </label>\n <mat-icon class=\"col-icon\" *ngIf=\"vm.truncated\" matTooltip='Show more' matTooltipPosition=\"above\" (click)=\"showBanner(row[column.name])\">more_horiz</mat-icon>\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n</ng-container>\n", styles: [".top{display:flex;flex-direction:row;flex-wrap:wrap;justify-content:space-between;align-items:center;margin-bottom:10px;margin-top:10px}.mat-mini-fab{width:32px;height:32px}.mat-mini-fab mat-icon{font-size:16px;margin-top:-3px}.mat-icon-button{width:32px;height:32px}.mat-icon-button mat-icon{font-size:20px;margin-top:-7px}.col-icon{margin-left:10px;vertical-align:middle}.title{margin-top:10px;font-size:larger;font-weight:300}.make-gray{background-color:#e5e5e5}.right-padding{padding-right:10px}.action-buttons-container{display:flex;justify-content:flex-end;align-items:center}.refreshIcon{font-size:22px!important;margin-top:-7px!important}.mat-mdc-cell .mat-mdc-outlined-button,.mat-mdc-cell .mat-mdc-button-base:not(.mat-mdc-icon-button){height:auto!important;min-height:36px!important;white-space:normal!important;word-break:break-word!important;overflow-wrap:break-word!important;line-height:1.4!important;text-align:left!important;padding:6px 16px!important}.mat-mdc-cell .mat-mdc-outlined-button .mdc-button__label,.mat-mdc-cell .mat-mdc-button-base:not(.mat-mdc-icon-button) .mdc-button__label{white-space:normal!important;word-break:break-word!important;overflow-wrap:break-word!important}.mat-mdc-cell .mat-mdc-outlined-button.chip-inert{cursor:default;pointer-events:none}\n"], dependencies: [{ kind: "directive", type: i1$2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1$2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i1$2.NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }, { kind: "directive", type: i1$2.NgSwitch, selector: "[ngSwitch]", inputs: ["ngSwitch"] }, { kind: "directive", type: i1$2.NgSwitchCase, selector: "[ngSwitchCase]", inputs: ["ngSwitchCase"] }, { kind: "directive", type: i1$2.NgSwitchDefault, selector: "[ngSwitchDefault]" }, { kind: "component", type: i3.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i4$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "directive", type: i7.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: CheckComponent, selector: "spa-check", inputs: ["readonly", "display", "value", "infoMessage", "hint"], outputs: ["valueChange", "click", "check", "uncheck", "infoClick"] }, { kind: "component", type: MonogramComponent, selector: "spa-monogram", inputs: ["value", "key"], outputs: ["tapped"] }, { kind: "component", type: SelectLiteComponent, selector: "spa-select-lite" }, { kind: "pipe", type: i1$2.DecimalPipe, name: "number" }, { kind: "pipe", type: i1$2.CurrencyPipe, name: "currency" }, { kind: "pipe", type: i1$2.DatePipe, name: "date" }], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None }); }
15664
16007
  }
15665
16008
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: TableRowComponent, decorators: [{
15666
16009
  type: Component,
15667
- args: [{ selector: 'app-table-row', encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, standalone: false, template: "<ng-container [ngSwitch]=\"column.type\">\n <ng-container *ngSwitchCase=\"'checkbox'\">\n <spa-check [value]=\"row[column.name]\" [readonly]=\"true\"></spa-check>\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'select'\">\n <spa-select-lite [options]=\"column.options\" [optionDisplay]=\"column.optionDisplay\" [optionValue]=\"column.optionValue\" [(value)]=\"row[column.name]\" width=\"90%\"></spa-select-lite>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'chip'\">\n <button mat-stroked-button (click)=\"onColumnClick(column, row)\" [ngStyle]=\"{'background-color': vm.color || '#eceff1', 'color': 'rgba(0, 0, 0, 0.87)', 'border': 'none'}\">{{row[column.name]}}</button>\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'icon'\">\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'date'\">\n {{row[column.name] | date : 'dd/MM/yyyy'}}\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'date-short'\">\n {{row[column.name] | date : 'd MMM'}}\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'datetime'\">\n {{row[column.name] | date : 'dd/MM/yyyy HH:mm'}}\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'datetimesec'\">\n {{row[column.name] | date : 'dd/MM/yyyy HH:mm:ss'}}\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'money'\">\n <label [ngStyle]=\"{'color': vm.color }\">{{row[column.name] | currency:'':''}}</label>\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n\n <!-- Added: monogram \u2014 the row still holds the full name, so filter/sort keep working on the underlying value -->\n <ng-container *ngSwitchCase=\"'monogram'\">\n <spa-monogram [value]=\"row[column.name]\" [key]=\"column.keyField ? row[column.keyField] : null\" (tapped)=\"showBanner($event)\"></spa-monogram>\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'button'\">\n <button mat-stroked-button (click)=\"onColumnClick(column, row)\" [ngStyle]=\"{'color': vm.color}\" >{{row[column.name]}}</button>\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n\n <ng-container *ngSwitchDefault>\n <label [ngStyle]=\"{'color': vm.color }\">\n <ng-container *ngIf=\"column.type === 'number'\">\n {{row[column.name] | number:'1.0-2'}}\n </ng-container>\n <ng-container *ngIf=\"column.type !== 'number'\">\n {{vm.text}}\n </ng-container>\n </label>\n <mat-icon class=\"col-icon\" *ngIf=\"vm.truncated\" matTooltip='Show more' matTooltipPosition=\"above\" (click)=\"showBanner(row[column.name])\">more_horiz</mat-icon>\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n</ng-container>\n", styles: [".top{display:flex;flex-direction:row;flex-wrap:wrap;justify-content:space-between;align-items:center;margin-bottom:10px;margin-top:10px}.mat-mini-fab{width:32px;height:32px}.mat-mini-fab mat-icon{font-size:16px;margin-top:-3px}.mat-icon-button{width:32px;height:32px}.mat-icon-button mat-icon{font-size:20px;margin-top:-7px}.col-icon{margin-left:10px;vertical-align:middle}.title{margin-top:10px;font-size:larger;font-weight:300}.make-gray{background-color:#e5e5e5}.right-padding{padding-right:10px}.action-buttons-container{display:flex;justify-content:flex-end;align-items:center}.refreshIcon{font-size:22px!important;margin-top:-7px!important}.mat-mdc-cell .mat-mdc-outlined-button,.mat-mdc-cell .mat-mdc-button-base:not(.mat-mdc-icon-button){height:auto!important;min-height:36px!important;white-space:normal!important;word-break:break-word!important;overflow-wrap:break-word!important;line-height:1.4!important;text-align:left!important;padding:6px 16px!important}.mat-mdc-cell .mat-mdc-outlined-button .mdc-button__label,.mat-mdc-cell .mat-mdc-button-base:not(.mat-mdc-icon-button) .mdc-button__label{white-space:normal!important;word-break:break-word!important;overflow-wrap:break-word!important}\n"] }]
16010
+ args: [{ selector: 'app-table-row', encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, standalone: false, template: "<ng-container [ngSwitch]=\"column.type\">\n <ng-container *ngSwitchCase=\"'checkbox'\">\n <spa-check [value]=\"row[column.name]\" [readonly]=\"true\"></spa-check>\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'select'\">\n <spa-select-lite [options]=\"column.options\" [optionDisplay]=\"column.optionDisplay\" [optionValue]=\"column.optionValue\" [(value)]=\"row[column.name]\" width=\"90%\"></spa-select-lite>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'chip'\">\n <button mat-stroked-button [class.chip-inert]=\"!column.detailsConfig\" [tabIndex]=\"column.detailsConfig ? 0 : -1\" (click)=\"onChipClick(column, row)\" [ngStyle]=\"{'background-color': vm.color || '#eceff1', 'color': 'rgba(0, 0, 0, 0.87)', 'border': 'none'}\">{{row[column.name]}}</button> <!-- Changed: a chip only OFFERS a click when it can honour one \u2014 chip-inert and tabindex -1 withdraw the cursor/hover/focus affordance, onChipClick withdraws the behaviour -->\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'icon'\">\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'date'\">\n {{row[column.name] | date : 'dd/MM/yyyy'}}\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'date-short'\">\n {{row[column.name] | date : 'd MMM'}}\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'datetime'\">\n {{row[column.name] | date : 'dd/MM/yyyy HH:mm'}}\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'datetimesec'\">\n {{row[column.name] | date : 'dd/MM/yyyy HH:mm:ss'}}\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'money'\">\n <label [ngStyle]=\"{'color': vm.color }\">{{row[column.name] | currency:'':''}}</label>\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n\n <!-- Added: monogram \u2014 the row still holds the full name, so filter/sort keep working on the underlying value -->\n <ng-container *ngSwitchCase=\"'monogram'\">\n <spa-monogram [value]=\"row[column.name]\" [key]=\"column.keyField ? row[column.keyField] : null\" (tapped)=\"onMonogramTap($event)\"></spa-monogram> <!-- Changed: tap opens the column's detailsConfig when it declares one, and still banners the full name when it does not -->\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'button'\">\n <button mat-stroked-button (click)=\"onColumnClick(column, row)\" [ngStyle]=\"{'color': vm.color}\" >{{row[column.name]}}</button>\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n\n <ng-container *ngSwitchDefault>\n <label [ngStyle]=\"{'color': vm.color }\">\n <ng-container *ngIf=\"column.type === 'number'\">\n {{row[column.name] | number:'1.0-2'}}\n </ng-container>\n <ng-container *ngIf=\"column.type !== 'number'\">\n {{vm.text}}\n </ng-container>\n </label>\n <mat-icon class=\"col-icon\" *ngIf=\"vm.truncated\" matTooltip='Show more' matTooltipPosition=\"above\" (click)=\"showBanner(row[column.name])\">more_horiz</mat-icon>\n <mat-icon class=\"col-icon\" *ngIf=\"vm.showColumnIcon\" [matTooltip]=\"row[column.icon.tipField] ?? column.icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[column.icon.tipField])\" [style.color]=\"column.icon?.color\">{{column.icon.name }}</mat-icon>\n <ng-container *ngFor=\"let icon of vm.visibleIcons\">\n <mat-icon class=\"col-icon\" [matTooltip]=\"row[icon.tipField] ?? icon?.tip\" matTooltipPosition=\"above\" (click)=\"showBanner(row[icon.tipField])\" [style.color]=\"icon.color\">{{icon.name }}</mat-icon>\n </ng-container>\n </ng-container>\n</ng-container>\n", styles: [".top{display:flex;flex-direction:row;flex-wrap:wrap;justify-content:space-between;align-items:center;margin-bottom:10px;margin-top:10px}.mat-mini-fab{width:32px;height:32px}.mat-mini-fab mat-icon{font-size:16px;margin-top:-3px}.mat-icon-button{width:32px;height:32px}.mat-icon-button mat-icon{font-size:20px;margin-top:-7px}.col-icon{margin-left:10px;vertical-align:middle}.title{margin-top:10px;font-size:larger;font-weight:300}.make-gray{background-color:#e5e5e5}.right-padding{padding-right:10px}.action-buttons-container{display:flex;justify-content:flex-end;align-items:center}.refreshIcon{font-size:22px!important;margin-top:-7px!important}.mat-mdc-cell .mat-mdc-outlined-button,.mat-mdc-cell .mat-mdc-button-base:not(.mat-mdc-icon-button){height:auto!important;min-height:36px!important;white-space:normal!important;word-break:break-word!important;overflow-wrap:break-word!important;line-height:1.4!important;text-align:left!important;padding:6px 16px!important}.mat-mdc-cell .mat-mdc-outlined-button .mdc-button__label,.mat-mdc-cell .mat-mdc-button-base:not(.mat-mdc-icon-button) .mdc-button__label{white-space:normal!important;word-break:break-word!important;overflow-wrap:break-word!important}.mat-mdc-cell .mat-mdc-outlined-button.chip-inert{cursor:default;pointer-events:none}\n"] }]
15668
16011
  }], ctorParameters: () => [{ type: ButtonService }], propDecorators: { column: [{
15669
16012
  type: Input
15670
16013
  }], row: [{
@@ -16691,7 +17034,11 @@ class EmailComponent {
16691
17034
  Validators.required,
16692
17035
  Validators.pattern('^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$')
16693
17036
  ]);
16694
- this.emails = this.value.split(';').filter(email => email.trim() !== '');
17037
+ // Changed: was this.value.split(';').filter(...) the third inline copy of the ';' split, and the most
17038
+ // fragile: it threw outright on a null `value`, and it FILTERED blanks without TRIMMING the survivors, so a
17039
+ // pasted "a@b.com; c@d.com" kept the leading space and " c@d.com" then failed the chip's own email pattern.
17040
+ // Core.parseMultiValue is null-safe and trims, so both go away.
17041
+ this.emails = Core.parseMultiValue(this.value);
16695
17042
  // Set up autocomplete filtering
16696
17043
  this.filteredOptions = this.emailControl.valueChanges.pipe(startWith(''), map(value => this._filter(value)));
16697
17044
  }
@@ -17362,6 +17709,7 @@ class TableComponent {
17362
17709
  this.apiErrorService = apiErrorService;
17363
17710
  this.runtimeConfig = runtimeConfig;
17364
17711
  this.subs = []; // TS-8: breakpoint + parent-owned reload subscriptions leaked on table teardown; stacked reload subs also caused duplicate loadData fetches
17712
+ this.initialized = false; // Added: guards ensureInitialized — initialization must happen exactly once, whichever hook reaches it first
17365
17713
  this.elevation = "mat-elevation-z5";
17366
17714
  this.actionsWidth = "50px";
17367
17715
  // Added: collapsible flat section header (sectionConfig) — Day Book sections
@@ -17465,6 +17813,17 @@ class TableComponent {
17465
17813
  }));
17466
17814
  }
17467
17815
  ngOnInit() {
17816
+ this.ensureInitialized(); // Changed: the body moved to ensureInitialized so ngOnChanges can pull it forward when it has to start a load first
17817
+ }
17818
+ // Added: Angular runs ngOnChanges BEFORE ngOnInit on the first pass. A lazy tab that is already active on the
17819
+ // first binding (a details dialog's tableConfigs[0]) therefore started its load from ngOnChanges while
17820
+ // setupPagination() had not yet run, so pagedMode was still false and the rows landed on the legacy path;
17821
+ // ngOnInit then flipped pagedMode on and the next updateSlice() drew from the empty loadedRows window and
17822
+ // replaced the good rows with nothing. Initialization is now idempotent so the load site can order it first.
17823
+ ensureInitialized() {
17824
+ if (this.initialized)
17825
+ return;
17826
+ this.initialized = true;
17468
17827
  this.sectionCollapsed = !!this.config?.sectionConfig?.collapsed; // Added: seed section collapse from config — state lives on the component, never written back to the shared config
17469
17828
  if (this.config?.formConfig) {
17470
17829
  this.hasFormAccess = Core.hasFormAccess(this.config.formConfig, this.authService.currentRoleSource.value);
@@ -17487,6 +17846,7 @@ class TableComponent {
17487
17846
  }
17488
17847
  if (this.inTab && changes['activeTab']) {
17489
17848
  if (!this.hasBeenActivated && this.activeTab && this.config?.lazyLoad && this.config.loadAction) {
17849
+ this.ensureInitialized(); // Changed: this is the ONLY load ngOnChanges can start, and it must not run ahead of setupPagination() — see ensureInitialized
17490
17850
  this.loadData(this.config.loadAction, "");
17491
17851
  this.hasBeenActivated = true;
17492
17852
  }
@@ -17645,7 +18005,12 @@ class TableComponent {
17645
18005
  // A DERIVED name comes straight off a URL segment, which deriveEntityName capitalises and singularises for
17646
18006
  // SignalR ("product-groups" -> "Product-group"). That is right for an entity key and wrong inside a
17647
18007
  // sentence — the caption read "Loading Product-group…". De-hyphenate and lowercase it so it reads as prose.
17648
- // deriveEntityName itself is left alone: realTimeEntityName depends on its exact output.
18008
+ // Changed: this used to say deriveEntityName must be left alone because realTimeEntityName depends on its exact
18009
+ // output. The dependency is real but the conclusion was wrong, and it protected a bug for a release. The OTHER
18010
+ // side of that comparison is the server's `typeof(TEntity).Name` (TinWeb BaseController.BroadcastEntityChange),
18011
+ // never a second copy of this derivation — so the test is "does the corrected name match the C# class name",
18012
+ // not "did the string change". Every -ies segment failed that test BEFORE the correction and three now pass.
18013
+ // Change deriveEntityName only with that comparison re-run; see its own comment for the segment census.
17649
18014
  const derived = this.deriveEntityName();
17650
18015
  return derived ? derived.replace(/[-_]+/g, ' ').toLowerCase() : 'data';
17651
18016
  }
@@ -17864,6 +18229,7 @@ class TableComponent {
17864
18229
  this.displayedColumns = this.tableConfigService.setColumns(this.config, this.smallScreen);
17865
18230
  this.displayedButtons = this.tableConfigService.getDisplayedButtons(this.config?.buttons, this.smallScreen, this.config);
17866
18231
  this.actionsWidth = this.tableConfigService.getActionsWidth(this.displayedButtons, this.smallScreen, this.config);
18232
+ this.placeLockIcon(); // Added: convention — a locked row gets its lock cue here, AFTER displayedColumns is known, so the host column is one that is genuinely on screen (and it re-resolves on resize)
17867
18233
  // Added: the same defect through the resize door. A row opened for edit on a wide screen keeps its seeded
17868
18234
  // editingModel when the window narrows past 600px, but its cells vanish with the columns — so the tick would
17869
18235
  // post values the operator can no longer see. Discard the edit instead; cancel has always been non-destructive.
@@ -18360,7 +18726,14 @@ class TableComponent {
18360
18726
  //Dialog events
18361
18727
  columnClicked(column, row) {
18362
18728
  if (column.detailsConfig) {
18363
- const button = { name: column.name, dialog: true, detailsConfig: column.detailsConfig };
18729
+ // Changed: the drill-in is FLAGGED as one. Every column drill-in — chip (table-row.html:15), button (:79) and
18730
+ // monogram (:71, via onMonogramTap) — arrives here, and all three share one property the row-level view button
18731
+ // does not have: the record the dialog is about is named BY the row rather than BEING the row. shift's Trips
18732
+ // grid drills from a trip row into a DRIVER, so the clicked row is a trip. The dialog's failed-load path used
18733
+ // to adopt that row as the record, which renders a driver form with every field empty and no error — the
18734
+ // reported defect. Flagging it here rather than testing for it in the dialog keeps the knowledge where it is
18735
+ // certain: only the table knows the dialog was opened from a column.
18736
+ const button = { name: column.name, dialog: true, detailsConfig: { ...column.detailsConfig, drillIn: true } }; // Changed: spread, so the flag never mutates the shared config object an app declares once and every row reuses
18364
18737
  this.open(button, row);
18365
18738
  }
18366
18739
  else {
@@ -18471,11 +18844,12 @@ class TableComponent {
18471
18844
  }
18472
18845
  return;
18473
18846
  }
18474
- let confirmMessage = this.buttonService.getConfirmMessage(actionButton, row);
18475
- if (!confirmMessage) {
18476
- confirmMessage = `Are you sure you want to delete ?`;
18477
- }
18478
- this.messageService.confirm(confirmMessage).subscribe((result) => {
18847
+ // Changed: message + labels + destructiveness now resolve through the one shared helper instead of this
18848
+ // method assembling the message alone. This is the prompt behind 47 of the delete buttons across the four
18849
+ // apps none of them declare a confirm of their own — so it is the site where naming the action pays off
18850
+ // most. With appConfig.namedDestructiveActions unset it still renders "Yes" / "No" in green/red, unchanged.
18851
+ const c = this.buttonService.getConfirmOptions(actionButton, row, `Are you sure you want to delete ?`, this.dataService.namedDestructiveActions);
18852
+ this.messageService.confirm(c.message, c.confirmLabel, c.cancelLabel, c.destructive).subscribe((result) => {
18479
18853
  if (result == "yes") {
18480
18854
  this.actionClickedEmit('delete', row);
18481
18855
  this.doAction(actionButton.name, row);
@@ -18493,8 +18867,11 @@ class TableComponent {
18493
18867
  row[this.config.heroField] = this.config.heroValue;
18494
18868
  }
18495
18869
  if (b.confirm && b.name != "delete") {
18496
- const confirmMessage = this.buttonService.getConfirmMessage(b, row);
18497
- this.messageService.confirm(confirmMessage).subscribe((result) => {
18870
+ // Changed: routed through getConfirmOptions so a custom button that names its action ('remove', or any
18871
+ // button carrying confirm.confirmLabel) is honoured here too. The `b.confirm` guard above is untouched, so
18872
+ // a button with no confirm config still runs straight through without a prompt.
18873
+ const c = this.buttonService.getConfirmOptions(b, row, undefined, this.dataService.namedDestructiveActions);
18874
+ this.messageService.confirm(c.message, c.confirmLabel, c.cancelLabel, c.destructive).subscribe((result) => {
18498
18875
  if (result == "yes") {
18499
18876
  this.execAction(b, row);
18500
18877
  }
@@ -18548,13 +18925,17 @@ class TableComponent {
18548
18925
  return "mat-elevation-z5";
18549
18926
  }
18550
18927
  }
18551
- // Changed: Real-time tables fall back to a refresh when SignalR is disconnected.
18928
+ // Changed: this now ALWAYS refreshes. The old body refreshed only when realTime was OFF or SignalR was DOWN,
18929
+ // so a healthy real-time table did nothing after a write and simply waited for a broadcast. But the SignalR
18930
+ // streams below are filtered on this table's own entityName, and an action that writes a DIFFERENT entity
18931
+ // (invoicing a rental writes an Invoice) never emits a matching one — grip's rentals grid kept rendering
18932
+ // "Returned" for a row already Invoiced in the database. Nothing correlates a broadcast back to the action
18933
+ // just performed, so "refresh unless a broadcast arrives" is not implementable without new machinery.
18934
+ // A same-URL re-read after a user-initiated write is idempotent; a broadcast that does arrive patches the same rows.
18552
18935
  // In paged mode this is NOT a full reload — refreshClicked routes through loadDataPaged's same-URL branch,
18553
18936
  // which re-fetches only the loaded window (skip=0, take=loadedCount), never the whole dataset.
18554
18937
  realTimeRefreshOrFallback() {
18555
- if (!this.effRealTime || !this.isSignalRConnected) { // Changed: resolves via the app-wide default
18556
- this.refreshClicked();
18557
- }
18938
+ this.refreshClicked(); // Changed: unconditional — was `if (!this.effRealTime || !this.isSignalRConnected)`, which made the healthy real-time case a silent no-op
18558
18939
  }
18559
18940
  //---------------- TinSync offline support ----------------
18560
18941
  // Registers the table's URLs with the offline service, starts the sync engine, and wires live overlay updates
@@ -18617,6 +18998,55 @@ class TableComponent {
18617
18998
  } // reuse the already-injected column instead of adding another
18618
18999
  this.config.columns = [...this.config.columns, { name: '_offlineStatus', type: 'icon', alias: 'Sync', icons: statusIcons }]; // last data column -> renders just before Action
18619
19000
  }
19001
+ //---------------- Locked-row cue (convention, no configuration) ----------------
19002
+ // Added: an ILock row (server projects `locked`) had NO visual indicator — the only sign was a kebab item
19003
+ // appearing on some rows and not others, which reads as a glitch rather than as state. This mirrors the
19004
+ // TinSync convention above: the cue rides an EXISTING column's icons array, so an author configures nothing.
19005
+ //
19006
+ // Deliberately DIFFERENT from placeStatusIcons in two ways:
19007
+ // 1. It never invents a column. Sync falls back to injecting '_offlineStatus'; a lock is a decoration on an
19008
+ // identity the operator already reads, not a fact worth a column of its own. No name/description/id on
19009
+ // screen -> no cue (the unlock button still carries the state on those grids).
19010
+ // 2. It resolves against displayedColumns, not config.columns, so a host cut by minColumns on a phone or by
19011
+ // a visible/hidden predicate is not chosen — an icon parked on a column nobody renders is invisible.
19012
+ //
19013
+ // Runs unconditionally on every table (the condition simply never fires on rows without `locked`) and is
19014
+ // IDEMPOTENT: TableConfig is often a shared singleton, and this re-runs on every resize, so the previously
19015
+ // injected icon is stripped from EVERY column before one is re-added — the host may have changed.
19016
+ placeLockIcon() {
19017
+ if (!this.config?.columns)
19018
+ return;
19019
+ const TAG = '_tinlockIcon';
19020
+ const lockIcon = { name: 'lock', color: '#90A4AE', tip: 'Locked — protected from editing and deletion', condition: (x) => !!(x?.locked ?? x?.Locked) }; // muted blue-grey, the tone the held/stale sync icon already uses: a cue, not an alarm. Both casings because a locked flag reaching the row un-camel-cased must not silently show nothing.
19021
+ lockIcon[TAG] = true;
19022
+ this.config.columns.forEach(c => { if (c.icons?.some(i => i[TAG]))
19023
+ c.icons = c.icons.filter(i => !i[TAG]); });
19024
+ const host = this.resolveLockHost();
19025
+ if (!host)
19026
+ return; // nothing to decorate — correct outcome, not a failure
19027
+ host.icons = [...(host.icons ?? []), lockIcon]; // appended, so any author icon and the sync icons keep their place and a row can carry both cues
19028
+ }
19029
+ // First of name -> description -> the table's id/PK among the columns ACTUALLY DISPLAYED. Name matching is
19030
+ // case-insensitive on name OR alias, exactly as placeStatusIcons resolves its host.
19031
+ resolveLockHost() {
19032
+ const displayed = (this.config.columns ?? []).filter(c => this.displayedColumns?.includes(c.name));
19033
+ if (!displayed.length)
19034
+ return null;
19035
+ const isNamed = (c, target) => (c.name?.toLowerCase() === target) || (c.alias?.toLowerCase() === target);
19036
+ const idNames = this.lockIdColumnNames();
19037
+ return displayed.find(c => isNamed(c, 'name'))
19038
+ ?? displayed.find(c => isNamed(c, 'description'))
19039
+ ?? displayed.find(c => idNames.includes(c.name?.toLowerCase()))
19040
+ ?? null;
19041
+ }
19042
+ // The PK is resolved the same way OfflineService resolves idField (offline.service.ts:169) — heroField first,
19043
+ // then the entity-derived '<singular>ID' — plus a bare 'id'. Deliberately NOT "any column ending in id": that
19044
+ // matches real business columns (Paid, Valid) and would hang a lock off an unrelated cell.
19045
+ lockIdColumnNames() {
19046
+ const hero = (this.config.formConfig?.heroField ?? this.config.heroField ?? '').toString().toLowerCase();
19047
+ const entity = (this.config.entityName || this.deriveEntityName() || '').toLowerCase().replace(/[-_\s]/g, '');
19048
+ return [hero, 'id', entity ? entity.replace(/s$/, '') + 'id' : ''].filter(x => !!x);
19049
+ }
18620
19050
  // Captures the raw server rows and merges pending ops: creates prepend, edits patch by id, deletes hide
18621
19051
  applyOfflineOverlay(rows) {
18622
19052
  if (!this.offlineEntry || !Array.isArray(rows))
@@ -18776,6 +19206,25 @@ class TableComponent {
18776
19206
  if (!segment)
18777
19207
  return '';
18778
19208
  const name = segment.charAt(0).toUpperCase() + segment.slice(1); // Capitalize
19209
+ // Changed: <consonant>ies -> y BEFORE the bare trailing-'s' strip. Stripping one 's' turned 'categories' into
19210
+ // 'Categorie' and 'overtimeentries' into 'Overtimeentrie'.
19211
+ // The LIVE consequence is realTimeEntityName (line ~1567): tables on -ies urls subscribed to SignalR as
19212
+ // "Categorie"/"Countrie"/"Overtimeentrie" while the server broadcasts typeof(TEntity).Name — "Category",
19213
+ // "Country", "OvertimeEntry" — so those tables received NO live create/update/delete at all. Verified on a
19214
+ // live stack: the wire carries "Category" and this function now returns "Category" for 'categories'.
19215
+ // The locked-row cue reads the same token via lockIdColumnNames(), but that defect is LATENT, not active:
19216
+ // heroField is tried first and rescues the configs that have one, and the rest carry no PK column for a
19217
+ // correct singulariser to find. So this repairs real-time; it does not change any lock host today.
19218
+ // The rule is deliberately narrow. The estate's 181 url segments were enumerated before writing this and the
19219
+ // other "obvious" plural rules are net-negative here: a general '-es' strip would turn 'purchases' into
19220
+ // 'Purcha' and BREAK the SignalR match it makes CORRECTLY today, and a '-ches' strip would take 'gptcaches'
19221
+ // further from 'GptCache', not closer. A wider singulariser must be justified against that list, not against
19222
+ // English. '-sses' matches no segment today ('addresses' is not a table url yet) and is here so the next one
19223
+ // does not tempt anyone into the aggressive strip that breaks the two above.
19224
+ if (/[^aeiouAEIOU]ies$/.test(name))
19225
+ return name.slice(0, -3) + 'y'; // categories -> Category, countries -> Country
19226
+ if (/sses$/.test(name))
19227
+ return name.slice(0, -2); // addresses -> Address; never matches 'purchases' ('ases')
18779
19228
  return name.endsWith('s') ? name.slice(0, -1) : name; // Remove trailing 's' for singular
18780
19229
  }
18781
19230
  // Changed: Find the ID property key in a row object (convention: ends with 'ID')
@@ -19044,7 +19493,7 @@ class DayBookComponent {
19044
19493
  const stages = (this.config?.stages || []).filter(s => Core.isItemVisible(s, this.book));
19045
19494
  const tiles = stages.map(s => ({
19046
19495
  name: s.countField, alias: s.label, style: 'icon', icon: s.icon || 'checklist', info: s.hint,
19047
- badge: typeof s.valueField === 'string' ? s.valueField : undefined, badgeColor: '#607d8b', // Changed: a computed valueField has no field name for the tile badge to read
19496
+ // Changed (owner ruling, 2026-08-12): the tile no longer carries a value badge. It used to render data[valueField] as a grey pill beside the count — a raw unformatted number with no currency symbol, which read as ambiguous (money? an account number?) and earned no space on this surface. The COUNT is the only tile property the Day Book shows now. valueField itself is untouched and still resolves the lane group header's money (resolveValue / laneStageValue) — this removes the RENDERING, not the data.
19048
19497
  color: Core.isItemVisible({ visible: s.alert }, this.book) && s.alert !== undefined ? '#c62828' : undefined,
19049
19498
  action: { method: 'get', url: '' }, // never fired — clickable tiles emit tileClick, which we map to a scroll
19050
19499
  hidden: (d) => !d?.[s.countField] || !this.inLane(s.lane), // Changed: lane filter lives in the predicate so pill clicks re-filter tiles live
@@ -19054,10 +19503,9 @@ class DayBookComponent {
19054
19503
  // stages — more than any row can show — so one scrolling row beats either squeezing them or stacking
19055
19504
  // them down the page above the lists. This is a Day Book decision made once in the library rather than
19056
19505
  // repeated in five app configs; `stageTiles` still lets an app override it, including back to 'auto'.
19057
- // hideValueOnMobile: on a phone the stage tile is ~44% of the row and was wrapping its label to fit a
19058
- // money badge that the lane group header already shows. The count is what the tile is FOR; the money
19059
- // is a second opinion, and it is the one that goes when space is short.
19060
- this.tileConfig = { tiles, clickable: true, layout: 'carousel', hideValueOnMobile: true, ...(this.config?.stageTiles ?? {}) }; // a NEW object each build, so spa-tiles sees an input change
19506
+ // Changed: the hideValueOnMobile note goes with the badge. What it argued the count is what the tile
19507
+ // is FOR and the money is a second opinion is now the rule at EVERY width, not just on a phone.
19508
+ this.tileConfig = { tiles, clickable: true, layout: 'carousel', ...(this.config?.stageTiles ?? {}) }; // Changed: hideValueOnMobile dropped — it existed only to hide the money badge under 700px and there is no badge left to hide. Still a NEW object each build, so spa-tiles sees an input change
19061
19509
  // Added: counts resolved onto a copy of the book, so a countField naming a LIST still shows a number
19062
19510
  this.tileData = this.book ? { ...this.book, ...stages.reduce((a, s) => { a[s.countField] = this.stageCount(s); return a; }, {}) } : null;
19063
19511
  }
@@ -20085,7 +20533,11 @@ class FormComponent {
20085
20533
  return;
20086
20534
  }
20087
20535
  if (button.confirm) {
20088
- this.messageService.confirm(`${button.confirm.message}`).subscribe((result) => {
20536
+ // Changed: passes the config's explicit labels and destructive flag straight through. Deliberately NOT
20537
+ // routed via buttonService.getConfirmOptions — this component does not inject ButtonService and a form
20538
+ // button never carries the built-in 'delete' name, so there is nothing here for the app-level flag to
20539
+ // infer. An explicit label in the config is honoured; anything absent still renders "Yes" / "No".
20540
+ this.messageService.confirm(`${button.confirm.message}`, button.confirm.confirmLabel, button.confirm.cancelLabel, button.confirm.destructive === true).subscribe((result) => {
20089
20541
  if (result == "yes") {
20090
20542
  this.processCall(button);
20091
20543
  }
@@ -21599,6 +22051,43 @@ const featureGuard = (featureKey) => {
21599
22051
  };
21600
22052
  };
21601
22053
 
22054
+ // Functional guard for tenant module gating — the SetupService.isModuleEnabled sibling of featureGuard. The server
22055
+ // already refuses actions from a disabled module; this refuses the NAVIGATION so the user never reaches a dead
22056
+ // screen and fills in a form the server was always going to reject.
22057
+ //
22058
+ // NOTE for the next reader: this is SetupService.isModuleEnabled (the tenant's own module choice, fails OPEN), NOT
22059
+ // SubscriptionService.isModuleEnabled (plan entitlement, fails CLOSED). The names are identical and the semantics
22060
+ // are opposite; featureGuard next door covers the other one.
22061
+ // Deepest wins, so a leaf can override the area it sits in (invoicing lives under home/accounting). pathFromRoot
22062
+ // rather than route.data avoids depending on paramsInheritanceStrategy, which does not inherit data through a
22063
+ // parent that has both a path and a component.
22064
+ const resolveKey = (route) => {
22065
+ let key = '';
22066
+ route.pathFromRoot.forEach(r => { if (r.data && r.data['moduleKey'])
22067
+ key = r.data['moduleKey']; });
22068
+ return key;
22069
+ };
22070
+ // Optional key: moduleGuard('workshop') gates one route explicitly, moduleGuard() reads data.moduleKey off the
22071
+ // route tree so one canActivateChild registration can cover a whole subtree
22072
+ const moduleGuard = (moduleKey) => {
22073
+ return (route, state) => {
22074
+ const setupService = inject(SetupService);
22075
+ const messageService = inject(MessageService);
22076
+ const router = inject(Router);
22077
+ const key = moduleKey || resolveKey(route);
22078
+ if (!key || !setupService.enabled)
22079
+ return of(true); // No module restriction on this route, or an app with no setup concept — fail open
22080
+ return setupService.ensureModules().pipe(timeout$1(3000), // A slow or hung catalog must never wedge navigation
22081
+ catchError(() => of(true)), // Fail OPEN, exactly like isModuleEnabled and the server's ModuleCatalog.IsEnabled
22082
+ map(() => {
22083
+ if (setupService.isModuleEnabled(key))
22084
+ return true;
22085
+ messageService.toast(`${setupService.moduleTitle(key)} is switched off for your organisation. Turn it on in Getting Started.`);
22086
+ return router.parseUrl('/home'); // UrlTree, not navigate() + false — a UrlTree cannot race an in-flight navigation
22087
+ }));
22088
+ };
22089
+ };
22090
+
21602
22091
  // Structural directive for plan-based feature gating in templates
21603
22092
  // Usage: *spaFeature="'sales.quotes'" — hides element if feature is disabled
21604
22093
  class FeatureDirective {
@@ -22105,8 +22594,18 @@ class NavMenuComponent {
22105
22594
  if (!this.smallScreen) {
22106
22595
  this.isExpanded = true;
22107
22596
  }
22108
- this.setupService.loadCount(); // Added: Refresh setup badge on nav init (persists across F5 no SignalR for setup)
22109
- this.setupService.loadModules(); // Added (Setup v2): module catalog for moduleKey menu gatingloads once per shell init
22597
+ // Changed (F3): these two used to fire unconditionally right here. spa-nav-menu is app-root's FIRST CHILD, so
22598
+ // it wraps the whole app login and signup included and its ngOnInit runs long before any restored token is
22599
+ // attached. Both calls therefore answered 401 on every reload (two console errors, and on an anonymous page a
22600
+ // handle401() bounce to login), and after a fresh login only loadCount() ever ran again (login.component.ts:270),
22601
+ // so the module catalog stayed EMPTY for the whole session. Firing on the first non-empty token instead makes
22602
+ // the catalog real for the first route resolution on the deep-link/F5 path — which is what a module route guard
22603
+ // reads — and makes an anonymous visitor issue no setup calls at all. Strictly fewer requests than before.
22604
+ // distinctUntilChanged re-fires after a logout/login in the same shell, which the ngOnInit placement never did.
22605
+ this.authService.tokenObserv.pipe(filter(token => !!token), distinctUntilChanged()).subscribe(() => {
22606
+ this.setupService.loadCount(); // Changed (F3): setup badge, now on an authenticated session (persists across F5 — no SignalR for setup)
22607
+ this.setupService.loadModules(); // Changed (F3): module catalog for moduleKey menu gating, now on an authenticated session
22608
+ });
22110
22609
  // Added: start recording the visited page for resumeLastRoute. The nav shell only exists while signed in,
22111
22610
  // so this one subscription covers every in-app navigation without touching individual pages.
22112
22611
  this.lastRouteService.start();
@@ -23093,13 +23592,20 @@ class DetailsDialog {
23093
23592
  }
23094
23593
  else {
23095
23594
  this.settleRead(quiet, () => {
23096
- // Changed: TinSync — if a by-action load fails offline, fall back to the row the table already passed in so existing records stay editable
23097
- if (this.detailsConfig.details) {
23595
+ // Changed: TinSync — if a by-action load fails offline, fall back to the row the table already passed in so existing records stay editable.
23596
+ // Changed: ...but ONLY when that row is a row of THIS entity. On a column drill-in it is not: shift's Trips
23597
+ // grid drills from a trip row into a driver, so the fallback wrote a TRIP into a driver form, set
23598
+ // isLoadComplete and rendered every field empty with nothing thrown — the dialog "opened blank". The
23599
+ // template never gated on isLoadComplete, so leaving details unset would have kept the same blank form;
23600
+ // a details dialog that cannot load its record has nothing to show, so it says why and closes.
23601
+ if (this.detailsConfig.details && !this.detailsConfig.drillIn) {
23098
23602
  this.details = this.detailsConfig.details;
23099
23603
  this.isLoadComplete = true;
23100
23604
  }
23101
23605
  else {
23102
23606
  this.apiErrorService.presentAppFailure(apiResponse, 'load', action.url); // Changed: by-action details load failed with no cached row to fall back on — opaque server text gets the friendly dialog
23607
+ if (this.detailsConfig.drillIn)
23608
+ this.dialogRef.close(); // Added: closes with undefined, which TableComponent.open already treats as a dismissal — no refresh, no emit, no onSuccessButton
23103
23609
  }
23104
23610
  if (this.autoRefreshEnabled) {
23105
23611
  this.autoRefreshEnabled = false;
@@ -23195,11 +23701,10 @@ class DetailsDialog {
23195
23701
  const button = this.buttonService.getButton(this.buttons, 'delete');
23196
23702
  if (!button)
23197
23703
  return;
23198
- let confirmMessage = this.buttonService.getConfirmMessage(button, this.details);
23199
- if (!confirmMessage) {
23200
- confirmMessage = `Are you sure you want to delete ?`;
23201
- }
23202
- this.messageService.confirm(confirmMessage).subscribe((result) => {
23704
+ // Changed: same shared resolver as table.deleteModel this is the details-dialog copy of that prompt and
23705
+ // the two must not disagree about what a delete confirmation looks like.
23706
+ const c = this.buttonService.getConfirmOptions(button, this.details, `Are you sure you want to delete ?`, this.dataService.namedDestructiveActions);
23707
+ this.messageService.confirm(c.message, c.confirmLabel, c.cancelLabel, c.destructive).subscribe((result) => {
23203
23708
  if (result == "yes") {
23204
23709
  this.handleButtonAction('delete');
23205
23710
  }
@@ -23308,11 +23813,12 @@ class DetailsDialog {
23308
23813
  executeAction(button, data) {
23309
23814
  const actionData = this.prepareActionData(button, data);
23310
23815
  if (button.confirm) {
23311
- let confirmMessage = this.buttonService.getConfirmMessage(button, this.details);
23312
- if (!confirmMessage && button.name == 'delete') {
23313
- confirmMessage = `Are you sure you want to delete ?`;
23314
- }
23315
- this.messageService.confirm(confirmMessage).subscribe((result) => {
23816
+ // Changed: routed through the shared resolver. The delete-only fallback message is preserved by passing it
23817
+ // as the fallback for that button name alone, so a non-delete button with an empty confirm message still
23818
+ // behaves exactly as it did.
23819
+ const fallback = button.name == 'delete' ? `Are you sure you want to delete ?` : undefined;
23820
+ const c = this.buttonService.getConfirmOptions(button, this.details, fallback, this.dataService.namedDestructiveActions);
23821
+ this.messageService.confirm(c.message, c.confirmLabel, c.cancelLabel, c.destructive).subscribe((result) => {
23316
23822
  if (result == "yes") {
23317
23823
  this.performApiCall(button, actionData);
23318
23824
  }
@@ -23508,6 +24014,10 @@ class InvitationsTableComponent {
23508
24014
  this.messageService = messageService;
23509
24015
  this.authService = authService;
23510
24016
  this.apiErrorService = apiErrorService;
24017
+ // Added (2026-08-12): this component wraps a spa-table, so its host had no way to learn how many
24018
+ // invitations were loaded. Tenant settings shows that count on the collapsed card. Purely additive —
24019
+ // the other host (pages/onboarding) simply does not bind it and is unaffected.
24020
+ this.totalChange = new EventEmitter();
23511
24021
  this.tableReload = new Subject();
23512
24022
  this.invitationsTableConfig = {
23513
24023
  greyOut: (value) => value.accepted == false,
@@ -23566,12 +24076,14 @@ class InvitationsTableComponent {
23566
24076
  });
23567
24077
  }
23568
24078
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: InvitationsTableComponent, deps: [{ token: DataServiceLib }, { token: MessageService }, { token: AuthService }, { token: ApiErrorService }], target: i0.ɵɵFactoryTarget.Component }); }
23569
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: InvitationsTableComponent, isStandalone: false, selector: "spa-invitations-table", ngImport: i0, template: "<spa-table [config]=\"invitationsTableConfig\" (actionClick)=\"invActionClicked($event)\" [reload]=\"tableReload\"></spa-table>\n", styles: [""], dependencies: [{ kind: "component", type: TableComponent, selector: "spa-table", inputs: ["data", "tileData", "config", "localMode", "parentDetails", "reload", "activeTab", "inTab", "nestingLevel"], outputs: ["dataLoad", "totalChange", "actionSuccess", "refreshClick", "searchClick", "createClick", "actionClick", "inputChange", "actionResponse"] }] }); }
24079
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: InvitationsTableComponent, isStandalone: false, selector: "spa-invitations-table", outputs: { totalChange: "totalChange" }, ngImport: i0, template: "<spa-table [config]=\"invitationsTableConfig\" (actionClick)=\"invActionClicked($event)\" (totalChange)=\"totalChange.emit($event)\" [reload]=\"tableReload\"></spa-table>\n", styles: [""], dependencies: [{ kind: "component", type: TableComponent, selector: "spa-table", inputs: ["data", "tileData", "config", "localMode", "parentDetails", "reload", "activeTab", "inTab", "nestingLevel"], outputs: ["dataLoad", "totalChange", "actionSuccess", "refreshClick", "searchClick", "createClick", "actionClick", "inputChange", "actionResponse"] }] }); }
23570
24080
  }
23571
24081
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: InvitationsTableComponent, decorators: [{
23572
24082
  type: Component,
23573
- args: [{ selector: 'spa-invitations-table', standalone: false, template: "<spa-table [config]=\"invitationsTableConfig\" (actionClick)=\"invActionClicked($event)\" [reload]=\"tableReload\"></spa-table>\n" }]
23574
- }], ctorParameters: () => [{ type: DataServiceLib }, { type: MessageService }, { type: AuthService }, { type: ApiErrorService }] });
24083
+ args: [{ selector: 'spa-invitations-table', standalone: false, template: "<spa-table [config]=\"invitationsTableConfig\" (actionClick)=\"invActionClicked($event)\" (totalChange)=\"totalChange.emit($event)\" [reload]=\"tableReload\"></spa-table>\n" }]
24084
+ }], ctorParameters: () => [{ type: DataServiceLib }, { type: MessageService }, { type: AuthService }, { type: ApiErrorService }], propDecorators: { totalChange: [{
24085
+ type: Output
24086
+ }] } });
23575
24087
 
23576
24088
  // Generic AI-assisted import wizard: Upload -> Map -> Review -> Done. Config-driven via ImportConfig.
23577
24089
  class ImportDialogComponent {
@@ -26038,6 +26550,15 @@ class RolesComponent {
26038
26550
  { value: RoleAccess.Create, name: 'Create' },
26039
26551
  { value: RoleAccess.Full, name: 'Full' }
26040
26552
  ];
26553
+ // Added: the two shapes a TOP-LEVEL capability can take, split once when config arrives rather than with a
26554
+ // template getter. `topPages` are top-level entries with nothing underneath them (Home, Day Book, Demo …);
26555
+ // `topGroups` are the ones that own sub-capabilities and become the page's sections.
26556
+ this.topPages = [];
26557
+ this.topGroups = [];
26558
+ this.views = []; // Added: one entry per role, in load order
26559
+ this.loaded = false; // Added: distinguishes "still loading" from "this tenant genuinely has no roles"
26560
+ this.filter = ''; // Added: live client-side search over capability names
26561
+ this.enabledOnly = false; // Added: "show only what is switched on" — the at-a-glance answer to "what does this role actually have"
26041
26562
  this.renameDialogConfig = {
26042
26563
  formConfig: {
26043
26564
  security: { allow: [this.dataService.capRoles] }, // Added: gate rename role form by roles cap
@@ -26073,16 +26594,184 @@ class RolesComponent {
26073
26594
  ngOnInit() {
26074
26595
  this.authService.isAuthorised(this.dataService.capRoles.name);
26075
26596
  this.loadRoles();
26076
- this.dataService.appConfigObserv.subscribe(x => this.appConfig = x);
26597
+ this.dataService.appConfigObserv.subscribe(x => { this.appConfig = x; this.splitCaps(); this.buildViews(); }); // Changed: capItems can arrive after the roles do, so the view model is rebuilt on either
26077
26598
  }
26078
26599
  loadRoles() {
26079
26600
  this.dataService.GetRole("all", "").subscribe((apiResponse) => {
26080
26601
  this.roles = apiResponse.data;
26602
+ this.loaded = true;
26603
+ this.buildViews(); // Changed: the view model is derived from the roles, so it is rebuilt with them
26081
26604
  });
26082
26605
  }
26083
26606
  refresh() {
26084
26607
  this.loadRoles();
26085
26608
  }
26609
+ // Added: splits top-level capabilities into "pages" and "groups". Called once per config change.
26610
+ splitCaps() {
26611
+ const caps = this.appConfig?.capItems || [];
26612
+ this.topPages = caps.filter(c => !c.capSubItems || c.capSubItems.length === 0);
26613
+ this.topGroups = caps.filter(c => c.capSubItems && c.capSubItems.length > 0);
26614
+ }
26615
+ // Added: a capability is "on" whether it is stored as a boolean (parents / bool caps) or as a RoleAccess
26616
+ // level (0 = None). `true > 0` is true in JS, so one comparison covers both — the same test the old
26617
+ // hasSubItemsAccess() used.
26618
+ isOn(value) {
26619
+ return value > 0;
26620
+ }
26621
+ // Added: rebuilds every role's view model. Cheap enough to run whole (200 capabilities x a handful of
26622
+ // roles) and only ever runs on load, not per change-detection pass.
26623
+ buildViews() {
26624
+ if (!this.roles) {
26625
+ this.views = [];
26626
+ return;
26627
+ }
26628
+ const previous = {};
26629
+ this.views.forEach(v => previous[v.role.roleID] = v.expanded); // keep open/shut state across a refresh
26630
+ this.views = this.roles.map((role, i) => {
26631
+ const view = { role: role, expanded: false, on: 0, total: 0, issues: 0, stats: {}, visible: {}, joined: {}, anyPages: true, anyVisible: true };
26632
+ this.recount(view);
26633
+ // The first role opens so the page never lands looking empty; everything else stays shut, which is
26634
+ // what makes 200 capabilities per role survivable. A refresh restores whatever was open before it.
26635
+ view.expanded = previous[role.roleID] !== undefined ? previous[role.roleID] : i === 0;
26636
+ return view;
26637
+ });
26638
+ }
26639
+ // Added: recomputes one role's counts. `inconsistent` is the old red asterisk's meaning, kept exactly —
26640
+ // this capability is off while something underneath it is on. It is REPORTED, never resolved: nothing here
26641
+ // writes to the role, because a parent being off with children on is a deliberate configuration this page
26642
+ // has supported since onCapItemChange() was disabled.
26643
+ recount(view) {
26644
+ const stats = {};
26645
+ let on = 0, total = 0, issues = 0;
26646
+ const term = (this.filter || '').trim().toLowerCase();
26647
+ const visit = (cap) => {
26648
+ let subOn = 0, subTotal = 0, subMatches = 0;
26649
+ (cap.capSubItems || []).forEach(sub => {
26650
+ const r = visit(sub);
26651
+ subOn += r.on;
26652
+ subTotal += r.total;
26653
+ subMatches += r.matches;
26654
+ });
26655
+ const self = this.isOn(view.role[cap.name]);
26656
+ const selfMatch = !term || (cap.display || '').toLowerCase().indexOf(term) >= 0;
26657
+ const stat = { self: self, on: subOn, total: subTotal, matches: subMatches, inconsistent: !self && subOn > 0 };
26658
+ stats[cap.name] = stat;
26659
+ if (stat.inconsistent)
26660
+ issues++;
26661
+ return { on: subOn + (self ? 1 : 0), total: subTotal + 1, matches: subMatches + (selfMatch ? 1 : 0) };
26662
+ };
26663
+ (this.appConfig?.capItems || []).forEach(cap => visit(cap));
26664
+ // The role-level tally counts each capability NAME once, not each position in the menu tree. Several
26665
+ // menu entries deliberately share one cap number — capAccountingDashboard is declared as "cap27", the
26666
+ // same field as capAccounting itself ("Reuses module cap number for dashboard visibility"), and eight
26667
+ // more do the same. Counting positions would report one capability twice and make the headline number
26668
+ // on the collapsed card wrong, which is the one number that has to be trustworthy.
26669
+ const seen = {};
26670
+ const tally = (cap) => {
26671
+ if (!seen[cap.name]) {
26672
+ seen[cap.name] = true;
26673
+ total++;
26674
+ if (this.isOn(view.role[cap.name]))
26675
+ on++;
26676
+ }
26677
+ (cap.capSubItems || []).forEach(sub => tally(sub));
26678
+ };
26679
+ (this.appConfig?.capItems || []).forEach(cap => tally(cap));
26680
+ view.stats = stats;
26681
+ view.on = on;
26682
+ view.total = total;
26683
+ view.issues = issues;
26684
+ this.recomputeVisibility(view);
26685
+ }
26686
+ // Added: works out what the search box and the "enabled only" switch leave on screen. Two passes, because
26687
+ // a capability shows when its own name matches, when a DESCENDANT matches (so the group survives), or when
26688
+ // an ANCESTOR matches (so searching "Inventory" shows all of Inventory).
26689
+ recomputeVisibility(view) {
26690
+ const term = (this.filter || '').trim().toLowerCase();
26691
+ const self = {}; // this capability's OWN name matches
26692
+ const subtree = {}; // it or anything below it matches
26693
+ const visible = {};
26694
+ const mark = (cap) => {
26695
+ const own = !term || (cap.display || '').toLowerCase().indexOf(term) >= 0;
26696
+ // OR-accumulated for the same reason `visible` is: nine cap names occupy two menu positions each, and a
26697
+ // plain assignment let the second position erase the first. Searching "payroll" set self[cap72]=true on
26698
+ // the Payroll section and its own "Dashboard" child then reset it to false, so the section stopped
26699
+ // passing "an ancestor matched" down and showed 2 of its 9 rows. Two labels for one field: if either
26700
+ // matches, the field matches.
26701
+ self[cap.name] = self[cap.name] || own;
26702
+ let hit = own;
26703
+ (cap.capSubItems || []).forEach(sub => { if (mark(sub))
26704
+ hit = true; });
26705
+ subtree[cap.name] = subtree[cap.name] || hit;
26706
+ return hit;
26707
+ };
26708
+ // A capability stays on screen when it matches, when something BELOW it matches (so its section survives
26709
+ // and you can still get to the match), or when an ancestor matched BY NAME (so searching "Payroll" shows
26710
+ // all of Payroll). The ancestor test deliberately uses self[], not subtree[] — passing subtree down meant
26711
+ // one matching child pulled its 8 non-matching siblings back onto the screen with it, which is a section
26712
+ // filter, not a search.
26713
+ const show = (cap, ancestorMatched) => {
26714
+ const hasChildren = !!(cap.capSubItems && cap.capSubItems.length);
26715
+ // Only a SECTION is kept alive by a match below it; a leaf has to match on its own name (or sit under
26716
+ // an ancestor that matched). Without the hasChildren guard, a leaf that shares its cap number with its
26717
+ // own parent inherits the parent's subtree result and appears in searches it does not match — Payroll
26718
+ // and its "Dashboard" child are both cap72, and nine such pairs exist across the library.
26719
+ const matched = self[cap.name] || (hasChildren && subtree[cap.name]) || ancestorMatched;
26720
+ const stat = view.stats[cap.name];
26721
+ // "Enabled only" hides what is off — EXCEPT anything flagged inconsistent. Filtering an inconsistency
26722
+ // off the screen would quietly defeat the one marker this page exists to show.
26723
+ const enabledOk = !this.enabledOnly || stat.self || stat.on > 0 || stat.inconsistent;
26724
+ // OR-accumulated, never assigned. These maps are keyed by cap NAME and nine names occupy two menu
26725
+ // positions each (Payroll and its own "Dashboard" child are both cap72). A plain assignment let the
26726
+ // second position overwrite the first, which silently hid the whole Payroll section from a search for
26727
+ // "commission" — the section was correctly resolved to visible, then its child reset the same key to
26728
+ // false. A capability is shown if ANY of its positions should show it.
26729
+ visible[cap.name] = visible[cap.name] || (matched && enabledOk);
26730
+ (cap.capSubItems || []).forEach(sub => show(sub, self[cap.name] || ancestorMatched));
26731
+ };
26732
+ (this.appConfig?.capItems || []).forEach(cap => mark(cap));
26733
+ (this.appConfig?.capItems || []).forEach(cap => show(cap, false));
26734
+ view.visible = visible;
26735
+ view.anyVisible = (this.appConfig?.capItems || []).some(cap => visible[cap.name]);
26736
+ view.anyPages = this.topPages.some(cap => visible[cap.name]); // a band header must not outlive its contents
26737
+ // Added: does this section have anything attached under its header (a body, an inconsistency line, or a
26738
+ // "matches are inside" line)? Purely cosmetic — it squares the header's bottom corners. Precomputed
26739
+ // rather than expressed as :has() so the look does not depend on selector support, and rather than as a
26740
+ // template expression so it costs nothing per change-detection pass.
26741
+ const joined = {};
26742
+ this.topGroups.forEach(cap => {
26743
+ const stat = view.stats[cap.name];
26744
+ const open = this.isOn(view.role[cap.name]);
26745
+ joined[cap.name] = open || (stat && stat.inconsistent) || (!!term && !open && !!stat && stat.matches > 0);
26746
+ });
26747
+ view.joined = joined;
26748
+ }
26749
+ // Added: search / filter changed — every role's visibility has to be reworked, not just the open one,
26750
+ // because the collapsed summaries are counted from the same maps.
26751
+ filterChanged() {
26752
+ this.views.forEach(v => this.recomputeVisibility(v));
26753
+ }
26754
+ clearFilter() {
26755
+ this.filter = '';
26756
+ this.filterChanged();
26757
+ }
26758
+ toggleEnabledOnly() {
26759
+ this.enabledOnly = !this.enabledOnly;
26760
+ this.filterChanged();
26761
+ }
26762
+ toggleRole(view) {
26763
+ view.expanded = !view.expanded;
26764
+ }
26765
+ // Added: single write path for every control on the page, so the counts and the inconsistency markers can
26766
+ // never drift away from what the checkboxes actually say.
26767
+ setValue(view, cap, value, topLevel = false) {
26768
+ view.role[cap.name] = value;
26769
+ if (topLevel)
26770
+ this.onCapItemChange(cap, value, view.role); // preserved: the top-level seam below
26771
+ this.recount(view);
26772
+ }
26773
+ trackByRole(index, view) { return view.role.roleID; }
26774
+ trackByCap(index, cap) { return cap.name; }
26086
26775
  onCapItemChange(capItem, checked, role) {
26087
26776
  return; //disabled to allow sub component access without granting whole menu access
26088
26777
  if (!checked && capItem.capSubItems) {
@@ -26159,11 +26848,11 @@ class RolesComponent {
26159
26848
  });
26160
26849
  }
26161
26850
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: RolesComponent, deps: [{ token: HttpService }, { token: i1$1.Router }, { token: AuthService }, { token: DataServiceLib }, { token: DialogService }, { token: i4.MatDialog }, { token: MessageService }, { token: ApiErrorService }], target: i0.ɵɵFactoryTarget.Component }); }
26162
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: RolesComponent, isStandalone: false, selector: "spa-roles", ngImport: i0, template: "<h4> Roles </h4>\n<hr />\n\n<div class=\"container-fluid mb-5\">\n\n <div class=\"d-flex justify-content-between mb-2\">\n\n <div >\n <button id=\"btnNewRole\" mat-raised-button color=\"primary\" (click)=\"addRole()\">New Role</button>\n </div>\n\n <div class=\"d-flex justify-content-end\">\n <button id=\"btnRefresh\" mat-icon-button color=\"primary\" (click)=\"refresh()\" matTooltip=\"refresh data\" matTooltipPosition=\"right\"><mat-icon >refresh</mat-icon></button>\n </div>\n\n </div>\n\n\n <div class=\"row mt-2 mb-1\" *ngFor=\"let role of roles\">\n\n <mat-card class=\"mat-elevation-z8\" style=\"width:100%\">\n\n <div class=\"d-flex justify-content-between align-items-center\">\n\n <label style=\"font-size: 16px;\">{{role.roleName}}</label>\n\n <button mat-icon-button color=\"primary\" matTooltip=\"Rename Role\" (click)=\"renameRole(role)\">\n <mat-icon>edit</mat-icon>\n </button>\n </div>\n\n <hr style=\"margin-top: 0px;\">\n\n <div class=\"tin-row\" style=\" font-size:12px;\">\n\n\n <div class=\"tin-row\" *ngFor=\"let capItem of appConfig.capItems\">\n\n <!-- Main item-->\n <mat-checkbox *ngIf=\"capItem.isBool || capItem.capSubItems\"\n color=\"primary\" style=\"min-width: 100px;\" [(ngModel)]=\"role[capItem.name]\" (ngModelChange)=\"onCapItemChange(capItem, $event, role)\">\n {{capItem.display}}\n <span *ngIf=\"!role[capItem.name] && hasSubItemsAccess(capItem, role)\" class=\"asterisk\" style=\"color: red;\">*</span>\n </mat-checkbox>\n\n <spa-select\n *ngIf=\"!capItem.isBool && !capItem.capSubItems\"\n [options]=\"roleAccessOptions\"\n optionDisplay=\"name\"\n optionValue=\"value\"\n [display]=\"capItem.display\"\n [(value)]=\"role[capItem.name]\"\n width=\"150px\" \n style=\"font-size: 12px;\">\n </spa-select>\n\n\n <ng-container *ngIf=\"capItem.capSubItems && role[capItem.name]\">\n\n <div class=\"tin-row\" *ngFor=\"let capSubItem of capItem.capSubItems\">\n\n\n <!-- Sub Item -->\n <mat-checkbox *ngIf=\"capSubItem.isBool\"\n color=\"primary\" style=\"min-width: 100px;\" [(ngModel)]=\"role[capSubItem.name]\">\n {{capSubItem.display}}\n </mat-checkbox>\n\n <spa-select\n *ngIf=\"!capSubItem.isBool\"\n [options]=\"roleAccessOptions\"\n optionDisplay=\"name\"\n optionValue=\"value\"\n [display]=\"capSubItem.display\"\n [(value)]=\"role[capSubItem.name]\"\n width=\"150px\"\n style=\"font-size: 12px;\">\n </spa-select>\n\n <ng-container *ngIf=\"capSubItem.capSubItems\">\n\n <div class=\"tin-row\" *ngFor=\"let capSubSubItem of capSubItem.capSubItems\">\n\n <!-- Sub Sub Items -->\n <mat-checkbox *ngIf=\"capSubSubItem.isBool\"\n color=\"primary\" style=\"min-width: 100px;\" [(ngModel)]=\"role[capSubSubItem.name]\">\n {{capSubSubItem.display}}\n </mat-checkbox>\n\n <spa-select\n *ngIf=\"!capSubSubItem.isBool\"\n [options]=\"roleAccessOptions\"\n optionDisplay=\"name\"\n optionValue=\"value\"\n [display]=\"capSubSubItem.display\"\n [(value)]=\"role[capSubSubItem.name]\"\n width=\"150px\" \n style=\"font-size: 12px;\">\n </spa-select>\n\n </div>\n\n </ng-container>\n\n\n\n </div>\n\n </ng-container>\n\n </div>\n\n </div>\n\n\n <mat-card-actions>\n\n <button mat-raised-button color=\"primary\" (click)=\"updateRole(role)\" style=\"margin-right:10px;\">\n <mat-icon>done_all</mat-icon>\n Update\n </button>\n\n <button mat-raised-button (click)=\"deleteRole(role)\" style=\"margin-right:10px\">\n <mat-icon>delete</mat-icon>\n Delete\n </button>\n\n </mat-card-actions>\n\n </mat-card>\n\n </div>\n\n <hr style=\"margin-top: 50px;\" />\n\n\n</div>\n\n", styles: [""], dependencies: [{ kind: "directive", type: i2$2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1$2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i2$2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: i4$3.MatCheckbox, selector: "mat-checkbox", inputs: ["aria-label", "aria-labelledby", "aria-describedby", "aria-expanded", "aria-controls", "aria-owns", "id", "required", "labelPosition", "name", "value", "disableRipple", "tabIndex", "color", "disabledInteractive", "checked", "disabled", "indeterminate"], outputs: ["change", "indeterminateChange"], exportAs: ["matCheckbox"] }, { kind: "component", type: i3.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i3.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i4$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "component", type: i19.MatCard, selector: "mat-card", inputs: ["appearance"], exportAs: ["matCard"] }, { kind: "directive", type: i19.MatCardActions, selector: "mat-card-actions", inputs: ["align"], exportAs: ["matCardActions"] }, { kind: "directive", type: i7.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: SelectComponent, selector: "spa-select", inputs: ["detailsConfig"] }] }); }
26851
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: RolesComponent, isStandalone: false, selector: "spa-roles", ngImport: i0, template: "<!-- Changed: the page was one wrapping run of 100+ controls per role with no grouping, no hierarchy and no way\n to find anything. Same controls, same rules, rearranged: roles collapse to a summary line, capabilities are\n grouped under the section they belong to, and there is a search. Two rules are load-bearing and unchanged \u2014\n a section's sub-items exist only while that section is switched on, and a section that is OFF while\n something underneath it is ON is FLAGGED, never corrected. -->\n\n<div class=\"rp-page\">\n\n <div class=\"rp-head\">\n\n <div class=\"rp-head-text\">\n <h1>Roles</h1>\n <p class=\"rp-lead\">What each role can reach. Switch a section on to see and set the pages inside it.</p>\n </div>\n\n <div class=\"rp-head-actions\">\n <button id=\"btnNewRole\" mat-raised-button color=\"primary\" (click)=\"addRole()\">New Role</button>\n <button id=\"btnRefresh\" mat-icon-button color=\"primary\" (click)=\"refresh()\" matTooltip=\"Refresh roles\"><mat-icon>refresh</mat-icon></button>\n </div>\n\n </div>\n\n <!-- Added: the two ways of finding something in 200 capabilities \u2014 search it, or show only what is on. -->\n <div class=\"rp-tools\" *ngIf=\"views.length\">\n\n <div class=\"rp-search\">\n <mat-icon>search</mat-icon>\n <input type=\"text\" name=\"capFilter\" placeholder=\"Search capabilities\" autocomplete=\"off\" [(ngModel)]=\"filter\" (ngModelChange)=\"filterChanged()\" />\n <button mat-icon-button *ngIf=\"filter\" (click)=\"clearFilter()\" matTooltip=\"Clear search\"><mat-icon>close</mat-icon></button>\n </div>\n\n <button type=\"button\" class=\"rp-toggle\" [class.active]=\"enabledOnly\" (click)=\"toggleEnabledOnly()\">\n <mat-icon>{{ enabledOnly ? 'check_box' : 'check_box_outline_blank' }}</mat-icon>\n Only what is switched on\n </button>\n\n </div>\n\n <div class=\"rp-empty\" *ngIf=\"loaded && !views.length\">\n <mat-icon>work_outline</mat-icon>\n <p>No roles yet. Create one to start granting access.</p>\n </div>\n\n <mat-card class=\"rp-role\" *ngFor=\"let view of views; trackBy: trackByRole\" [class.open]=\"view.expanded\">\n\n <!-- The whole strip is the control that opens the role, so a collapsed role still says everything it has\n to say: its name, how much is on, and whether anything inside it needs looking at. -->\n <div class=\"rp-rhead\">\n\n <button type=\"button\" class=\"rp-rtoggle\" (click)=\"toggleRole(view)\" [attr.aria-expanded]=\"view.expanded\">\n <mat-icon class=\"rp-chev\">{{ view.expanded ? 'expand_more' : 'chevron_right' }}</mat-icon>\n <span class=\"rp-name\">{{ view.role.roleName }}</span>\n <span class=\"rp-summary\">{{ view.on }} of {{ view.total }} capabilities on</span>\n <span class=\"rp-issues\" *ngIf=\"view.issues\" matTooltip=\"A section is off while capabilities inside it are on\"><mat-icon>error_outline</mat-icon>{{ view.issues }} {{ view.issues === 1 ? 'inconsistency' : 'inconsistencies' }}</span>\n </button>\n\n <button mat-icon-button color=\"primary\" matTooltip=\"Rename Role\" (click)=\"renameRole(view.role)\"><mat-icon>edit</mat-icon></button>\n\n </div>\n\n <div class=\"rp-rbody\" *ngIf=\"view.expanded\">\n\n <p class=\"rp-none\" *ngIf=\"!view.anyVisible\">Nothing matches the current search or filter.</p>\n\n <!-- Top-level entries that own nothing underneath them. -->\n <div class=\"rp-band\" *ngIf=\"topPages.length && view.anyPages\">\n\n <div class=\"rp-band-head\">\n <span class=\"rp-band-name\">Pages</span>\n <span class=\"rp-band-note\">top-level menu items with no sub-items</span>\n </div>\n\n <div class=\"rp-grid\">\n <ng-container *ngFor=\"let cap of topPages; trackBy: trackByCap\">\n <div class=\"rp-item\" *ngIf=\"view.visible[cap.name]\">\n <mat-checkbox *ngIf=\"cap.isBool\" color=\"primary\" [ngModel]=\"view.role[cap.name]\" (ngModelChange)=\"setValue(view, cap, $event, true)\">{{ cap.display }}</mat-checkbox>\n <spa-select *ngIf=\"!cap.isBool\" [options]=\"roleAccessOptions\" optionDisplay=\"name\" optionValue=\"value\" [required]=\"false\" [display]=\"cap.display\" [value]=\"view.role[cap.name]\" (valueChange)=\"setValue(view, cap, $event, true)\" width=\"100%\"></spa-select>\n </div>\n </ng-container>\n </div>\n\n </div>\n\n <!-- One section per top-level capability that owns sub-items. -->\n <div class=\"rp-groups\">\n\n <ng-container *ngFor=\"let cap of topGroups; trackBy: trackByCap\">\n\n <section class=\"rp-group\" *ngIf=\"view.visible[cap.name]\">\n\n <div class=\"rp-ghead\" [class.on]=\"view.stats[cap.name].self\" [class.flag]=\"view.stats[cap.name].inconsistent\" [class.joined]=\"view.joined[cap.name]\">\n <mat-checkbox class=\"rp-gcheck\" color=\"primary\" [ngModel]=\"view.role[cap.name]\" (ngModelChange)=\"setValue(view, cap, $event, true)\"><mat-icon class=\"rp-gicon\">{{ cap.icon }}</mat-icon><span class=\"rp-gname\">{{ cap.display }}</span></mat-checkbox>\n <span class=\"rp-gcount\">{{ view.stats[cap.name].on }} of {{ view.stats[cap.name].total }} inside are on</span>\n <mat-icon class=\"rp-gflag\" *ngIf=\"view.stats[cap.name].inconsistent\">error_outline</mat-icon>\n </div>\n\n <!-- The inconsistency, said in words. The section is off, so its body is hidden and this line has\n the space; it reports and does nothing else \u2014 the values underneath are left exactly as set. -->\n <div class=\"rp-note flag\" *ngIf=\"!view.role[cap.name] && view.stats[cap.name].inconsistent\">\n <mat-icon>error_outline</mat-icon>\n <span><strong>{{ view.stats[cap.name].on }}</strong> of {{ view.stats[cap.name].total }} capabilities inside {{ cap.display }} are switched on while {{ cap.display }} itself is off, so they are probably unreachable. Nothing has been changed \u2014 switch {{ cap.display }} on to review them.</span>\n </div>\n\n <div class=\"rp-note\" *ngIf=\"!view.role[cap.name] && !view.stats[cap.name].inconsistent && filter && view.stats[cap.name].matches\">\n <mat-icon>search</mat-icon>\n <span>{{ view.stats[cap.name].matches }} capabilities inside {{ cap.display }} match this search. Switch {{ cap.display }} on to set them.</span>\n </div>\n\n <!-- PARENT-DRIVEN HIDING, unchanged: sub-items render only while the parent is on. -->\n <div class=\"rp-gbody\" *ngIf=\"cap.capSubItems && view.role[cap.name]\">\n\n <div class=\"rp-grid\">\n\n <ng-container *ngFor=\"let sub of cap.capSubItems; trackBy: trackByCap\">\n\n <ng-container *ngIf=\"view.visible[sub.name]\">\n\n <!-- A sub-item with its own sub-items gets its own block. Its children are NOT gated on its\n value \u2014 that is how the page has always behaved at this level and changing it would put\n capabilities out of reach. -->\n <div class=\"rp-sub\" *ngIf=\"sub.capSubItems && sub.capSubItems.length\" [class.flag]=\"view.stats[sub.name].inconsistent\">\n\n <div class=\"rp-subhead\">\n <mat-checkbox *ngIf=\"sub.isBool\" color=\"primary\" [ngModel]=\"view.role[sub.name]\" (ngModelChange)=\"setValue(view, sub, $event)\">{{ sub.display }}</mat-checkbox>\n <spa-select *ngIf=\"!sub.isBool\" [options]=\"roleAccessOptions\" optionDisplay=\"name\" optionValue=\"value\" [required]=\"false\" [display]=\"sub.display\" [value]=\"view.role[sub.name]\" (valueChange)=\"setValue(view, sub, $event)\" width=\"100%\"></spa-select>\n <mat-icon class=\"rp-gflag\" *ngIf=\"view.stats[sub.name].inconsistent\" matTooltip=\"{{ view.stats[sub.name].on }} capabilities below this are on while it is off\">error_outline</mat-icon>\n </div>\n\n <div class=\"rp-grid inner\">\n <ng-container *ngFor=\"let leaf of sub.capSubItems; trackBy: trackByCap\">\n <div class=\"rp-item\" *ngIf=\"view.visible[leaf.name]\">\n <mat-checkbox *ngIf=\"leaf.isBool\" color=\"primary\" [ngModel]=\"view.role[leaf.name]\" (ngModelChange)=\"setValue(view, leaf, $event)\">{{ leaf.display }}</mat-checkbox>\n <spa-select *ngIf=\"!leaf.isBool\" [options]=\"roleAccessOptions\" optionDisplay=\"name\" optionValue=\"value\" [required]=\"false\" [display]=\"leaf.display\" [value]=\"view.role[leaf.name]\" (valueChange)=\"setValue(view, leaf, $event)\" width=\"100%\"></spa-select>\n </div>\n </ng-container>\n </div>\n\n </div>\n\n <div class=\"rp-item\" *ngIf=\"!sub.capSubItems || !sub.capSubItems.length\">\n <mat-checkbox *ngIf=\"sub.isBool\" color=\"primary\" [ngModel]=\"view.role[sub.name]\" (ngModelChange)=\"setValue(view, sub, $event)\">{{ sub.display }}</mat-checkbox>\n <spa-select *ngIf=\"!sub.isBool\" [options]=\"roleAccessOptions\" optionDisplay=\"name\" optionValue=\"value\" [required]=\"false\" [display]=\"sub.display\" [value]=\"view.role[sub.name]\" (valueChange)=\"setValue(view, sub, $event)\" width=\"100%\"></spa-select>\n </div>\n\n </ng-container>\n\n </ng-container>\n\n </div>\n\n </div>\n\n </section>\n\n </ng-container>\n\n </div>\n\n <div class=\"rp-actions\">\n <button mat-raised-button color=\"primary\" (click)=\"updateRole(view.role)\"><mat-icon>done_all</mat-icon> Update</button>\n <button mat-raised-button (click)=\"deleteRole(view.role)\"><mat-icon>delete</mat-icon> Delete</button>\n </div>\n\n </div>\n\n </mat-card>\n\n</div>\n", styles: [":host{--rp-accent: #1565c0;--rp-on: #2e7d32;--rp-on-edge: #c8e6c9;--rp-on-tint: #f6fbf6;--rp-flag: #ef6c00;--rp-flag-tint: #fff8e1;--rp-quiet: rgba(0, 0, 0, .55)}.rp-page{max-width:1400px;margin:0 auto;padding:16px;display:flex;flex-direction:column;gap:16px}.rp-head{display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap}.rp-head-text h1{margin:0;font-size:24px}.rp-head-text .rp-lead{margin:4px 0 0;font-size:14px;color:var(--rp-quiet);max-width:72ch}.rp-head-actions{display:flex;align-items:center;gap:8px;margin-left:auto}.rp-tools{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.rp-search{display:flex;align-items:center;gap:6px;flex:1 1 260px;max-width:380px;min-height:38px;border:1px solid rgba(0,0,0,.12);border-radius:8px;background:#fff;padding:0 4px 0 10px;transition:border-color .15s}.rp-search:focus-within{border-color:var(--rp-accent)}.rp-search>mat-icon{font-size:19px;width:19px;height:19px;color:#00000073;flex-shrink:0}.rp-search input{flex:1;min-width:0;border:0;outline:0;background:transparent;font:inherit;font-size:13.5px;padding:8px 0}.rp-search input::placeholder{color:#0006}.rp-toggle{display:inline-flex;align-items:center;gap:6px;min-height:38px;border:1px solid rgba(0,0,0,.12);border-radius:19px;background:transparent;padding:4px 14px;font:inherit;font-size:13px;color:#000000b3;cursor:pointer;transition:border-color .15s,background .15s,color .15s}.rp-toggle:hover{border-color:#90a4ae}.rp-toggle.active{border-color:var(--rp-accent);background:#e3f2fd;color:var(--rp-accent);font-weight:500}.rp-toggle mat-icon{font-size:18px;width:18px;height:18px}.rp-role{padding:0;border:1px solid #e0e0e0;border-radius:10px;box-shadow:none!important;overflow:hidden}.rp-rhead{display:flex;align-items:center;gap:4px;padding:4px 8px 4px 4px}.rp-role.open .rp-rhead{border-bottom:1px solid #eceff1}.rp-rtoggle{display:flex;align-items:center;gap:10px;flex:1;min-width:0;min-height:44px;border:0;border-radius:8px;background:transparent;padding:6px 8px;font:inherit;text-align:left;cursor:pointer;transition:background .15s}.rp-rtoggle:hover{background:#f5f7f9}.rp-rtoggle:focus-visible{outline:2px solid var(--rp-accent);outline-offset:-2px}.rp-chev{color:#00000073;flex-shrink:0}.rp-name{font-size:16px;font-weight:600;letter-spacing:.2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.rp-summary{font-size:12.5px;color:var(--rp-quiet);font-variant-numeric:tabular-nums;white-space:nowrap}.rp-issues{display:inline-flex;align-items:center;gap:4px;margin-left:auto;border:1px solid #ffe0b2;border-radius:12px;background:var(--rp-flag-tint);padding:2px 10px;font-size:12px;font-weight:500;color:var(--rp-flag);white-space:nowrap}.rp-issues mat-icon{font-size:15px;width:15px;height:15px}.rp-rbody{display:flex;flex-direction:column;gap:16px;padding:14px 16px 16px}.rp-none{margin:0;font-size:13px;color:var(--rp-quiet)}.rp-band-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;padding-bottom:6px;border-bottom:1px solid rgba(0,0,0,.08)}.rp-band-name{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:#0009}.rp-band-note{font-size:11px;color:#00000073}.rp-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:2px 12px;align-items:start;margin-top:8px}.rp-grid.inner{grid-template-columns:repeat(auto-fill,minmax(170px,1fr));margin-top:4px}.rp-item{min-width:0;display:flex;align-items:center;min-height:38px}.rp-item spa-select{display:block;width:100%}.rp-groups{display:flex;flex-direction:column;gap:8px}.rp-group{display:flex;flex-direction:column}.rp-ghead{display:flex;align-items:center;gap:10px;border:1px solid #e0e0e0;border-radius:6px;background:#fafafa;padding:4px 14px 4px 8px;min-height:40px;transition:background .15s,border-color .15s}.rp-ghead.on{border-color:var(--rp-on-edge);background:var(--rp-on-tint)}.rp-ghead.flag{border-color:#ffe0b2;background:var(--rp-flag-tint)}.rp-ghead.joined{border-bottom-left-radius:0;border-bottom-right-radius:0}.rp-gcheck{min-width:0}.rp-gicon{vertical-align:middle;margin-right:8px;font-size:19px;width:19px;height:19px;color:#546e7a}.rp-ghead.on .rp-gicon{color:var(--rp-on)}.rp-gname{font-size:14.5px;font-weight:600;letter-spacing:.2px;vertical-align:middle}.rp-gcount{margin-left:auto;font-size:12px;color:#00000080;font-variant-numeric:tabular-nums;white-space:nowrap}.rp-gflag{flex-shrink:0;color:var(--rp-flag);font-size:20px;width:20px;height:20px}.rp-gbody{border:1px solid #e0e0e0;border-top:0;border-radius:0 0 6px 6px;background:#fff;padding:2px 14px 10px}.rp-gbody>.rp-grid{margin-top:0}.rp-note{display:flex;align-items:flex-start;gap:8px;border:1px solid #e0e0e0;border-top:0;border-radius:0 0 6px 6px;background:#fff;padding:9px 14px 11px;font-size:12.5px;line-height:1.45;color:var(--rp-quiet)}.rp-note.flag{border-color:#ffe0b2;background:var(--rp-flag-tint);color:#000000b8}.rp-note mat-icon{flex-shrink:0;font-size:18px;width:18px;height:18px;color:#0006;margin-top:1px}.rp-note.flag mat-icon{color:var(--rp-flag)}.rp-note strong{font-weight:600;font-variant-numeric:tabular-nums}.rp-sub{grid-column:1 / -1;border:1px solid rgba(0,0,0,.08);border-radius:8px;padding:8px 12px 10px;margin:6px 0;background:#fcfdfd}.rp-sub.flag{border-color:#ffe0b2;background:var(--rp-flag-tint)}.rp-subhead{display:flex;align-items:center;gap:8px}.rp-subhead spa-select{display:block;flex:1 1 230px;max-width:300px}.rp-actions{display:flex;gap:10px;flex-wrap:wrap;border-top:1px solid #eceff1;margin-top:2px;padding-top:14px}.rp-empty{text-align:center;padding:48px 16px;color:#00000080}.rp-empty mat-icon{font-size:40px;width:40px;height:40px}@media (max-width: 700px){.rp-page{padding:10px 0;gap:12px}.rp-head{gap:6px 10px;align-items:center}.rp-head-text{flex:1 1 auto;min-width:0}.rp-head-text h1{font-size:20px}.rp-head-text .rp-lead{display:none}.rp-head-actions{margin-left:auto;gap:2px}.rp-tools{gap:8px}.rp-search{flex:1 0 100%;max-width:none}.rp-toggle{min-height:36px;padding:4px 12px;font-size:12.5px}.rp-rhead{padding:2px 4px 2px 2px}.rp-rtoggle{gap:6px;padding:6px;flex-wrap:wrap}.rp-name{font-size:15px;flex:1 1 auto}.rp-summary{flex:1 0 100%;padding-left:30px;font-size:12px}.rp-issues{margin-left:0;font-size:11.5px;padding:1px 8px}.rp-rbody{gap:12px;padding:10px}.rp-grid,.rp-grid.inner{grid-template-columns:1fr;gap:2px}.rp-item{min-height:38px}.rp-groups{gap:8px}.rp-ghead{padding:4px 10px 4px 6px;gap:6px;min-height:42px;flex-wrap:wrap}.rp-gname{font-size:14px}.rp-gcount{order:1;flex:1 0 100%;margin-left:0;padding-left:42px;font-size:11.5px}.rp-ghead .rp-gflag{order:0;margin-left:auto}.rp-gbody{padding:2px 10px 10px}.rp-sub{padding:8px 10px 10px}.rp-subhead spa-select{max-width:none}.rp-note{padding:8px 10px 10px;font-size:12px}.rp-actions{gap:8px;padding-top:12px}.rp-actions button{flex:1 1 auto}.rp-empty{padding:32px 12px}}\n"], dependencies: [{ kind: "directive", type: i2$2.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$2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1$2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i2$2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: i4$3.MatCheckbox, selector: "mat-checkbox", inputs: ["aria-label", "aria-labelledby", "aria-describedby", "aria-expanded", "aria-controls", "aria-owns", "id", "required", "labelPosition", "name", "value", "disableRipple", "tabIndex", "color", "disabledInteractive", "checked", "disabled", "indeterminate"], outputs: ["change", "indeterminateChange"], exportAs: ["matCheckbox"] }, { kind: "component", type: i3.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i3.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i4$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "component", type: i19.MatCard, selector: "mat-card", inputs: ["appearance"], exportAs: ["matCard"] }, { kind: "directive", type: i7.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: SelectComponent, selector: "spa-select", inputs: ["detailsConfig"] }] }); }
26163
26852
  }
26164
26853
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: RolesComponent, decorators: [{
26165
26854
  type: Component,
26166
- args: [{ selector: "spa-roles", standalone: false, template: "<h4> Roles </h4>\n<hr />\n\n<div class=\"container-fluid mb-5\">\n\n <div class=\"d-flex justify-content-between mb-2\">\n\n <div >\n <button id=\"btnNewRole\" mat-raised-button color=\"primary\" (click)=\"addRole()\">New Role</button>\n </div>\n\n <div class=\"d-flex justify-content-end\">\n <button id=\"btnRefresh\" mat-icon-button color=\"primary\" (click)=\"refresh()\" matTooltip=\"refresh data\" matTooltipPosition=\"right\"><mat-icon >refresh</mat-icon></button>\n </div>\n\n </div>\n\n\n <div class=\"row mt-2 mb-1\" *ngFor=\"let role of roles\">\n\n <mat-card class=\"mat-elevation-z8\" style=\"width:100%\">\n\n <div class=\"d-flex justify-content-between align-items-center\">\n\n <label style=\"font-size: 16px;\">{{role.roleName}}</label>\n\n <button mat-icon-button color=\"primary\" matTooltip=\"Rename Role\" (click)=\"renameRole(role)\">\n <mat-icon>edit</mat-icon>\n </button>\n </div>\n\n <hr style=\"margin-top: 0px;\">\n\n <div class=\"tin-row\" style=\" font-size:12px;\">\n\n\n <div class=\"tin-row\" *ngFor=\"let capItem of appConfig.capItems\">\n\n <!-- Main item-->\n <mat-checkbox *ngIf=\"capItem.isBool || capItem.capSubItems\"\n color=\"primary\" style=\"min-width: 100px;\" [(ngModel)]=\"role[capItem.name]\" (ngModelChange)=\"onCapItemChange(capItem, $event, role)\">\n {{capItem.display}}\n <span *ngIf=\"!role[capItem.name] && hasSubItemsAccess(capItem, role)\" class=\"asterisk\" style=\"color: red;\">*</span>\n </mat-checkbox>\n\n <spa-select\n *ngIf=\"!capItem.isBool && !capItem.capSubItems\"\n [options]=\"roleAccessOptions\"\n optionDisplay=\"name\"\n optionValue=\"value\"\n [display]=\"capItem.display\"\n [(value)]=\"role[capItem.name]\"\n width=\"150px\" \n style=\"font-size: 12px;\">\n </spa-select>\n\n\n <ng-container *ngIf=\"capItem.capSubItems && role[capItem.name]\">\n\n <div class=\"tin-row\" *ngFor=\"let capSubItem of capItem.capSubItems\">\n\n\n <!-- Sub Item -->\n <mat-checkbox *ngIf=\"capSubItem.isBool\"\n color=\"primary\" style=\"min-width: 100px;\" [(ngModel)]=\"role[capSubItem.name]\">\n {{capSubItem.display}}\n </mat-checkbox>\n\n <spa-select\n *ngIf=\"!capSubItem.isBool\"\n [options]=\"roleAccessOptions\"\n optionDisplay=\"name\"\n optionValue=\"value\"\n [display]=\"capSubItem.display\"\n [(value)]=\"role[capSubItem.name]\"\n width=\"150px\"\n style=\"font-size: 12px;\">\n </spa-select>\n\n <ng-container *ngIf=\"capSubItem.capSubItems\">\n\n <div class=\"tin-row\" *ngFor=\"let capSubSubItem of capSubItem.capSubItems\">\n\n <!-- Sub Sub Items -->\n <mat-checkbox *ngIf=\"capSubSubItem.isBool\"\n color=\"primary\" style=\"min-width: 100px;\" [(ngModel)]=\"role[capSubSubItem.name]\">\n {{capSubSubItem.display}}\n </mat-checkbox>\n\n <spa-select\n *ngIf=\"!capSubSubItem.isBool\"\n [options]=\"roleAccessOptions\"\n optionDisplay=\"name\"\n optionValue=\"value\"\n [display]=\"capSubSubItem.display\"\n [(value)]=\"role[capSubSubItem.name]\"\n width=\"150px\" \n style=\"font-size: 12px;\">\n </spa-select>\n\n </div>\n\n </ng-container>\n\n\n\n </div>\n\n </ng-container>\n\n </div>\n\n </div>\n\n\n <mat-card-actions>\n\n <button mat-raised-button color=\"primary\" (click)=\"updateRole(role)\" style=\"margin-right:10px;\">\n <mat-icon>done_all</mat-icon>\n Update\n </button>\n\n <button mat-raised-button (click)=\"deleteRole(role)\" style=\"margin-right:10px\">\n <mat-icon>delete</mat-icon>\n Delete\n </button>\n\n </mat-card-actions>\n\n </mat-card>\n\n </div>\n\n <hr style=\"margin-top: 50px;\" />\n\n\n</div>\n\n" }]
26855
+ args: [{ selector: "spa-roles", standalone: false, template: "<!-- Changed: the page was one wrapping run of 100+ controls per role with no grouping, no hierarchy and no way\n to find anything. Same controls, same rules, rearranged: roles collapse to a summary line, capabilities are\n grouped under the section they belong to, and there is a search. Two rules are load-bearing and unchanged \u2014\n a section's sub-items exist only while that section is switched on, and a section that is OFF while\n something underneath it is ON is FLAGGED, never corrected. -->\n\n<div class=\"rp-page\">\n\n <div class=\"rp-head\">\n\n <div class=\"rp-head-text\">\n <h1>Roles</h1>\n <p class=\"rp-lead\">What each role can reach. Switch a section on to see and set the pages inside it.</p>\n </div>\n\n <div class=\"rp-head-actions\">\n <button id=\"btnNewRole\" mat-raised-button color=\"primary\" (click)=\"addRole()\">New Role</button>\n <button id=\"btnRefresh\" mat-icon-button color=\"primary\" (click)=\"refresh()\" matTooltip=\"Refresh roles\"><mat-icon>refresh</mat-icon></button>\n </div>\n\n </div>\n\n <!-- Added: the two ways of finding something in 200 capabilities \u2014 search it, or show only what is on. -->\n <div class=\"rp-tools\" *ngIf=\"views.length\">\n\n <div class=\"rp-search\">\n <mat-icon>search</mat-icon>\n <input type=\"text\" name=\"capFilter\" placeholder=\"Search capabilities\" autocomplete=\"off\" [(ngModel)]=\"filter\" (ngModelChange)=\"filterChanged()\" />\n <button mat-icon-button *ngIf=\"filter\" (click)=\"clearFilter()\" matTooltip=\"Clear search\"><mat-icon>close</mat-icon></button>\n </div>\n\n <button type=\"button\" class=\"rp-toggle\" [class.active]=\"enabledOnly\" (click)=\"toggleEnabledOnly()\">\n <mat-icon>{{ enabledOnly ? 'check_box' : 'check_box_outline_blank' }}</mat-icon>\n Only what is switched on\n </button>\n\n </div>\n\n <div class=\"rp-empty\" *ngIf=\"loaded && !views.length\">\n <mat-icon>work_outline</mat-icon>\n <p>No roles yet. Create one to start granting access.</p>\n </div>\n\n <mat-card class=\"rp-role\" *ngFor=\"let view of views; trackBy: trackByRole\" [class.open]=\"view.expanded\">\n\n <!-- The whole strip is the control that opens the role, so a collapsed role still says everything it has\n to say: its name, how much is on, and whether anything inside it needs looking at. -->\n <div class=\"rp-rhead\">\n\n <button type=\"button\" class=\"rp-rtoggle\" (click)=\"toggleRole(view)\" [attr.aria-expanded]=\"view.expanded\">\n <mat-icon class=\"rp-chev\">{{ view.expanded ? 'expand_more' : 'chevron_right' }}</mat-icon>\n <span class=\"rp-name\">{{ view.role.roleName }}</span>\n <span class=\"rp-summary\">{{ view.on }} of {{ view.total }} capabilities on</span>\n <span class=\"rp-issues\" *ngIf=\"view.issues\" matTooltip=\"A section is off while capabilities inside it are on\"><mat-icon>error_outline</mat-icon>{{ view.issues }} {{ view.issues === 1 ? 'inconsistency' : 'inconsistencies' }}</span>\n </button>\n\n <button mat-icon-button color=\"primary\" matTooltip=\"Rename Role\" (click)=\"renameRole(view.role)\"><mat-icon>edit</mat-icon></button>\n\n </div>\n\n <div class=\"rp-rbody\" *ngIf=\"view.expanded\">\n\n <p class=\"rp-none\" *ngIf=\"!view.anyVisible\">Nothing matches the current search or filter.</p>\n\n <!-- Top-level entries that own nothing underneath them. -->\n <div class=\"rp-band\" *ngIf=\"topPages.length && view.anyPages\">\n\n <div class=\"rp-band-head\">\n <span class=\"rp-band-name\">Pages</span>\n <span class=\"rp-band-note\">top-level menu items with no sub-items</span>\n </div>\n\n <div class=\"rp-grid\">\n <ng-container *ngFor=\"let cap of topPages; trackBy: trackByCap\">\n <div class=\"rp-item\" *ngIf=\"view.visible[cap.name]\">\n <mat-checkbox *ngIf=\"cap.isBool\" color=\"primary\" [ngModel]=\"view.role[cap.name]\" (ngModelChange)=\"setValue(view, cap, $event, true)\">{{ cap.display }}</mat-checkbox>\n <spa-select *ngIf=\"!cap.isBool\" [options]=\"roleAccessOptions\" optionDisplay=\"name\" optionValue=\"value\" [required]=\"false\" [display]=\"cap.display\" [value]=\"view.role[cap.name]\" (valueChange)=\"setValue(view, cap, $event, true)\" width=\"100%\"></spa-select>\n </div>\n </ng-container>\n </div>\n\n </div>\n\n <!-- One section per top-level capability that owns sub-items. -->\n <div class=\"rp-groups\">\n\n <ng-container *ngFor=\"let cap of topGroups; trackBy: trackByCap\">\n\n <section class=\"rp-group\" *ngIf=\"view.visible[cap.name]\">\n\n <div class=\"rp-ghead\" [class.on]=\"view.stats[cap.name].self\" [class.flag]=\"view.stats[cap.name].inconsistent\" [class.joined]=\"view.joined[cap.name]\">\n <mat-checkbox class=\"rp-gcheck\" color=\"primary\" [ngModel]=\"view.role[cap.name]\" (ngModelChange)=\"setValue(view, cap, $event, true)\"><mat-icon class=\"rp-gicon\">{{ cap.icon }}</mat-icon><span class=\"rp-gname\">{{ cap.display }}</span></mat-checkbox>\n <span class=\"rp-gcount\">{{ view.stats[cap.name].on }} of {{ view.stats[cap.name].total }} inside are on</span>\n <mat-icon class=\"rp-gflag\" *ngIf=\"view.stats[cap.name].inconsistent\">error_outline</mat-icon>\n </div>\n\n <!-- The inconsistency, said in words. The section is off, so its body is hidden and this line has\n the space; it reports and does nothing else \u2014 the values underneath are left exactly as set. -->\n <div class=\"rp-note flag\" *ngIf=\"!view.role[cap.name] && view.stats[cap.name].inconsistent\">\n <mat-icon>error_outline</mat-icon>\n <span><strong>{{ view.stats[cap.name].on }}</strong> of {{ view.stats[cap.name].total }} capabilities inside {{ cap.display }} are switched on while {{ cap.display }} itself is off, so they are probably unreachable. Nothing has been changed \u2014 switch {{ cap.display }} on to review them.</span>\n </div>\n\n <div class=\"rp-note\" *ngIf=\"!view.role[cap.name] && !view.stats[cap.name].inconsistent && filter && view.stats[cap.name].matches\">\n <mat-icon>search</mat-icon>\n <span>{{ view.stats[cap.name].matches }} capabilities inside {{ cap.display }} match this search. Switch {{ cap.display }} on to set them.</span>\n </div>\n\n <!-- PARENT-DRIVEN HIDING, unchanged: sub-items render only while the parent is on. -->\n <div class=\"rp-gbody\" *ngIf=\"cap.capSubItems && view.role[cap.name]\">\n\n <div class=\"rp-grid\">\n\n <ng-container *ngFor=\"let sub of cap.capSubItems; trackBy: trackByCap\">\n\n <ng-container *ngIf=\"view.visible[sub.name]\">\n\n <!-- A sub-item with its own sub-items gets its own block. Its children are NOT gated on its\n value \u2014 that is how the page has always behaved at this level and changing it would put\n capabilities out of reach. -->\n <div class=\"rp-sub\" *ngIf=\"sub.capSubItems && sub.capSubItems.length\" [class.flag]=\"view.stats[sub.name].inconsistent\">\n\n <div class=\"rp-subhead\">\n <mat-checkbox *ngIf=\"sub.isBool\" color=\"primary\" [ngModel]=\"view.role[sub.name]\" (ngModelChange)=\"setValue(view, sub, $event)\">{{ sub.display }}</mat-checkbox>\n <spa-select *ngIf=\"!sub.isBool\" [options]=\"roleAccessOptions\" optionDisplay=\"name\" optionValue=\"value\" [required]=\"false\" [display]=\"sub.display\" [value]=\"view.role[sub.name]\" (valueChange)=\"setValue(view, sub, $event)\" width=\"100%\"></spa-select>\n <mat-icon class=\"rp-gflag\" *ngIf=\"view.stats[sub.name].inconsistent\" matTooltip=\"{{ view.stats[sub.name].on }} capabilities below this are on while it is off\">error_outline</mat-icon>\n </div>\n\n <div class=\"rp-grid inner\">\n <ng-container *ngFor=\"let leaf of sub.capSubItems; trackBy: trackByCap\">\n <div class=\"rp-item\" *ngIf=\"view.visible[leaf.name]\">\n <mat-checkbox *ngIf=\"leaf.isBool\" color=\"primary\" [ngModel]=\"view.role[leaf.name]\" (ngModelChange)=\"setValue(view, leaf, $event)\">{{ leaf.display }}</mat-checkbox>\n <spa-select *ngIf=\"!leaf.isBool\" [options]=\"roleAccessOptions\" optionDisplay=\"name\" optionValue=\"value\" [required]=\"false\" [display]=\"leaf.display\" [value]=\"view.role[leaf.name]\" (valueChange)=\"setValue(view, leaf, $event)\" width=\"100%\"></spa-select>\n </div>\n </ng-container>\n </div>\n\n </div>\n\n <div class=\"rp-item\" *ngIf=\"!sub.capSubItems || !sub.capSubItems.length\">\n <mat-checkbox *ngIf=\"sub.isBool\" color=\"primary\" [ngModel]=\"view.role[sub.name]\" (ngModelChange)=\"setValue(view, sub, $event)\">{{ sub.display }}</mat-checkbox>\n <spa-select *ngIf=\"!sub.isBool\" [options]=\"roleAccessOptions\" optionDisplay=\"name\" optionValue=\"value\" [required]=\"false\" [display]=\"sub.display\" [value]=\"view.role[sub.name]\" (valueChange)=\"setValue(view, sub, $event)\" width=\"100%\"></spa-select>\n </div>\n\n </ng-container>\n\n </ng-container>\n\n </div>\n\n </div>\n\n </section>\n\n </ng-container>\n\n </div>\n\n <div class=\"rp-actions\">\n <button mat-raised-button color=\"primary\" (click)=\"updateRole(view.role)\"><mat-icon>done_all</mat-icon> Update</button>\n <button mat-raised-button (click)=\"deleteRole(view.role)\"><mat-icon>delete</mat-icon> Delete</button>\n </div>\n\n </div>\n\n </mat-card>\n\n</div>\n", styles: [":host{--rp-accent: #1565c0;--rp-on: #2e7d32;--rp-on-edge: #c8e6c9;--rp-on-tint: #f6fbf6;--rp-flag: #ef6c00;--rp-flag-tint: #fff8e1;--rp-quiet: rgba(0, 0, 0, .55)}.rp-page{max-width:1400px;margin:0 auto;padding:16px;display:flex;flex-direction:column;gap:16px}.rp-head{display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap}.rp-head-text h1{margin:0;font-size:24px}.rp-head-text .rp-lead{margin:4px 0 0;font-size:14px;color:var(--rp-quiet);max-width:72ch}.rp-head-actions{display:flex;align-items:center;gap:8px;margin-left:auto}.rp-tools{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.rp-search{display:flex;align-items:center;gap:6px;flex:1 1 260px;max-width:380px;min-height:38px;border:1px solid rgba(0,0,0,.12);border-radius:8px;background:#fff;padding:0 4px 0 10px;transition:border-color .15s}.rp-search:focus-within{border-color:var(--rp-accent)}.rp-search>mat-icon{font-size:19px;width:19px;height:19px;color:#00000073;flex-shrink:0}.rp-search input{flex:1;min-width:0;border:0;outline:0;background:transparent;font:inherit;font-size:13.5px;padding:8px 0}.rp-search input::placeholder{color:#0006}.rp-toggle{display:inline-flex;align-items:center;gap:6px;min-height:38px;border:1px solid rgba(0,0,0,.12);border-radius:19px;background:transparent;padding:4px 14px;font:inherit;font-size:13px;color:#000000b3;cursor:pointer;transition:border-color .15s,background .15s,color .15s}.rp-toggle:hover{border-color:#90a4ae}.rp-toggle.active{border-color:var(--rp-accent);background:#e3f2fd;color:var(--rp-accent);font-weight:500}.rp-toggle mat-icon{font-size:18px;width:18px;height:18px}.rp-role{padding:0;border:1px solid #e0e0e0;border-radius:10px;box-shadow:none!important;overflow:hidden}.rp-rhead{display:flex;align-items:center;gap:4px;padding:4px 8px 4px 4px}.rp-role.open .rp-rhead{border-bottom:1px solid #eceff1}.rp-rtoggle{display:flex;align-items:center;gap:10px;flex:1;min-width:0;min-height:44px;border:0;border-radius:8px;background:transparent;padding:6px 8px;font:inherit;text-align:left;cursor:pointer;transition:background .15s}.rp-rtoggle:hover{background:#f5f7f9}.rp-rtoggle:focus-visible{outline:2px solid var(--rp-accent);outline-offset:-2px}.rp-chev{color:#00000073;flex-shrink:0}.rp-name{font-size:16px;font-weight:600;letter-spacing:.2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.rp-summary{font-size:12.5px;color:var(--rp-quiet);font-variant-numeric:tabular-nums;white-space:nowrap}.rp-issues{display:inline-flex;align-items:center;gap:4px;margin-left:auto;border:1px solid #ffe0b2;border-radius:12px;background:var(--rp-flag-tint);padding:2px 10px;font-size:12px;font-weight:500;color:var(--rp-flag);white-space:nowrap}.rp-issues mat-icon{font-size:15px;width:15px;height:15px}.rp-rbody{display:flex;flex-direction:column;gap:16px;padding:14px 16px 16px}.rp-none{margin:0;font-size:13px;color:var(--rp-quiet)}.rp-band-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;padding-bottom:6px;border-bottom:1px solid rgba(0,0,0,.08)}.rp-band-name{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:#0009}.rp-band-note{font-size:11px;color:#00000073}.rp-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:2px 12px;align-items:start;margin-top:8px}.rp-grid.inner{grid-template-columns:repeat(auto-fill,minmax(170px,1fr));margin-top:4px}.rp-item{min-width:0;display:flex;align-items:center;min-height:38px}.rp-item spa-select{display:block;width:100%}.rp-groups{display:flex;flex-direction:column;gap:8px}.rp-group{display:flex;flex-direction:column}.rp-ghead{display:flex;align-items:center;gap:10px;border:1px solid #e0e0e0;border-radius:6px;background:#fafafa;padding:4px 14px 4px 8px;min-height:40px;transition:background .15s,border-color .15s}.rp-ghead.on{border-color:var(--rp-on-edge);background:var(--rp-on-tint)}.rp-ghead.flag{border-color:#ffe0b2;background:var(--rp-flag-tint)}.rp-ghead.joined{border-bottom-left-radius:0;border-bottom-right-radius:0}.rp-gcheck{min-width:0}.rp-gicon{vertical-align:middle;margin-right:8px;font-size:19px;width:19px;height:19px;color:#546e7a}.rp-ghead.on .rp-gicon{color:var(--rp-on)}.rp-gname{font-size:14.5px;font-weight:600;letter-spacing:.2px;vertical-align:middle}.rp-gcount{margin-left:auto;font-size:12px;color:#00000080;font-variant-numeric:tabular-nums;white-space:nowrap}.rp-gflag{flex-shrink:0;color:var(--rp-flag);font-size:20px;width:20px;height:20px}.rp-gbody{border:1px solid #e0e0e0;border-top:0;border-radius:0 0 6px 6px;background:#fff;padding:2px 14px 10px}.rp-gbody>.rp-grid{margin-top:0}.rp-note{display:flex;align-items:flex-start;gap:8px;border:1px solid #e0e0e0;border-top:0;border-radius:0 0 6px 6px;background:#fff;padding:9px 14px 11px;font-size:12.5px;line-height:1.45;color:var(--rp-quiet)}.rp-note.flag{border-color:#ffe0b2;background:var(--rp-flag-tint);color:#000000b8}.rp-note mat-icon{flex-shrink:0;font-size:18px;width:18px;height:18px;color:#0006;margin-top:1px}.rp-note.flag mat-icon{color:var(--rp-flag)}.rp-note strong{font-weight:600;font-variant-numeric:tabular-nums}.rp-sub{grid-column:1 / -1;border:1px solid rgba(0,0,0,.08);border-radius:8px;padding:8px 12px 10px;margin:6px 0;background:#fcfdfd}.rp-sub.flag{border-color:#ffe0b2;background:var(--rp-flag-tint)}.rp-subhead{display:flex;align-items:center;gap:8px}.rp-subhead spa-select{display:block;flex:1 1 230px;max-width:300px}.rp-actions{display:flex;gap:10px;flex-wrap:wrap;border-top:1px solid #eceff1;margin-top:2px;padding-top:14px}.rp-empty{text-align:center;padding:48px 16px;color:#00000080}.rp-empty mat-icon{font-size:40px;width:40px;height:40px}@media (max-width: 700px){.rp-page{padding:10px 0;gap:12px}.rp-head{gap:6px 10px;align-items:center}.rp-head-text{flex:1 1 auto;min-width:0}.rp-head-text h1{font-size:20px}.rp-head-text .rp-lead{display:none}.rp-head-actions{margin-left:auto;gap:2px}.rp-tools{gap:8px}.rp-search{flex:1 0 100%;max-width:none}.rp-toggle{min-height:36px;padding:4px 12px;font-size:12.5px}.rp-rhead{padding:2px 4px 2px 2px}.rp-rtoggle{gap:6px;padding:6px;flex-wrap:wrap}.rp-name{font-size:15px;flex:1 1 auto}.rp-summary{flex:1 0 100%;padding-left:30px;font-size:12px}.rp-issues{margin-left:0;font-size:11.5px;padding:1px 8px}.rp-rbody{gap:12px;padding:10px}.rp-grid,.rp-grid.inner{grid-template-columns:1fr;gap:2px}.rp-item{min-height:38px}.rp-groups{gap:8px}.rp-ghead{padding:4px 10px 4px 6px;gap:6px;min-height:42px;flex-wrap:wrap}.rp-gname{font-size:14px}.rp-gcount{order:1;flex:1 0 100%;margin-left:0;padding-left:42px;font-size:11.5px}.rp-ghead .rp-gflag{order:0;margin-left:auto}.rp-gbody{padding:2px 10px 10px}.rp-sub{padding:8px 10px 10px}.rp-subhead spa-select{max-width:none}.rp-note{padding:8px 10px 10px;font-size:12px}.rp-actions{gap:8px;padding-top:12px}.rp-actions button{flex:1 1 auto}.rp-empty{padding:32px 12px}}\n"] }]
26167
26856
  }], ctorParameters: () => [{ type: HttpService }, { type: i1$1.Router }, { type: AuthService }, { type: DataServiceLib }, { type: DialogService }, { type: i4.MatDialog }, { type: MessageService }, { type: ApiErrorService }] });
26168
26857
 
26169
26858
  class CreateAccountComponent {
@@ -29013,15 +29702,25 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
29013
29702
  }] });
29014
29703
 
29015
29704
  // Changed: Exported routes array for use by SpaHomeRoutingModule
29705
+ //
29706
+ // Added (F3): leaf-level data.moduleKey. INERT metadata — nothing in this library reads it and no guard is
29707
+ // attached here; a consumer opts in from its own home route. The path says "accounting" but three separate
29708
+ // ModuleCatalog modules have pages under it, so the leaves that do NOT belong to Accounting say so explicitly
29709
+ // (deepest data wins in the resolver, so a leaf overrides the area). Keys are transcribed from the CapItem
29710
+ // tree in datalib.service.ts — the same source the nav menu already gates on — not invented:
29711
+ // invoicing -> capInvoicing.capSubItems (datalib.service.ts:372) — dashboard, quotes, invoices,
29712
+ // credit notes, receipts, revenue schedules, aging, statements
29713
+ // purchasing -> capSupplierAging (:520) is a child of capPurchasingModule (:456) despite its accounting path
29714
+ // Everything left bare here is a capAccounting sub-item (:267) and correctly inherits the area's "accounting".
29016
29715
  const ACCOUNTING_ROUTES = [
29017
29716
  { path: "accounts", component: AccountsComponent },
29018
29717
  { path: "aggregates", component: AggregatesComponent },
29019
29718
  { path: "transaction-types", component: TransactionTypesComponent },
29020
29719
  { path: "transactions", component: TransactionsComponent },
29021
- { path: "invoices", component: InvoicesComponent },
29022
- { path: "aging", component: AgingComponent },
29023
- { path: "supplier-aging", component: SupplierAgingComponent },
29024
- { path: "statements", component: StatementComponent },
29720
+ { path: "invoices", component: InvoicesComponent, data: { moduleKey: 'invoicing' } }, // Added (F3): Invoicing, not Accounting
29721
+ { path: "aging", component: AgingComponent, data: { moduleKey: 'invoicing' } }, // Added (F3): capAging is a capInvoicing sub-item
29722
+ { path: "supplier-aging", component: SupplierAgingComponent, data: { moduleKey: 'purchasing' } }, // Added (F3): AP Aging belongs to Purchasing
29723
+ { path: "statements", component: StatementComponent, data: { moduleKey: 'invoicing' } }, // Added (F3): customer statement is an Invoicing page
29025
29724
  { path: "reports", component: ReportsComponent },
29026
29725
  { path: "tax-rates", component: TaxRatesComponent },
29027
29726
  { path: "standing-orders", component: StandingOrdersComponent },
@@ -29029,14 +29728,14 @@ const ACCOUNTING_ROUTES = [
29029
29728
  { path: "budgets", component: BudgetsComponent },
29030
29729
  { path: "budget-vs-actual", component: BudgetVsActualComponent },
29031
29730
  { path: "dashboard", component: AccountingDashboardComponent },
29032
- { path: "invoice-dashboard", component: InvoiceDashboardComponent },
29033
- { path: "credit-notes", component: CreditNotesComponent }, // Changed: Credit notes route (B6)
29731
+ { path: "invoice-dashboard", component: InvoiceDashboardComponent, data: { moduleKey: 'invoicing' } }, // Added (F3): Invoicing dashboard
29732
+ { path: "credit-notes", component: CreditNotesComponent, data: { moduleKey: 'invoicing' } }, // Changed: Credit notes route (B6) // Added (F3)
29034
29733
  { path: "vat-return", component: VatReturnComponent }, // Changed: VAT return route (B2)
29035
29734
  { path: "fiscal-periods", component: FiscalPeriodsComponent }, // Changed: Fiscal periods route (C2)
29036
- { path: "receipts", component: ReceiptsComponent }, // Changed: Customer receipts route (C3)
29735
+ { path: "receipts", component: ReceiptsComponent, data: { moduleKey: 'invoicing' } }, // Changed: Customer receipts route (C3) // Added (F3)
29037
29736
  { path: "bank-reconciliation", component: BankReconciliationComponent }, // Changed: Bank reconciliation route (C4)
29038
- { path: "revenue-schedules", component: RevenueSchedulesComponent }, // Changed: Deferred revenue route (C7)
29039
- { path: "quotes", component: QuotesComponent } // Changed: Quotations route (C10)
29737
+ { path: "revenue-schedules", component: RevenueSchedulesComponent, data: { moduleKey: 'invoicing' } }, // Changed: Deferred revenue route (C7) // Added (F3)
29738
+ { path: "quotes", component: QuotesComponent, data: { moduleKey: 'invoicing' } } // Changed: Quotations route (C10) // Added (F3)
29040
29739
  ];
29041
29740
  class AccountingRoutingModule {
29042
29741
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: AccountingRoutingModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); }
@@ -29730,7 +30429,7 @@ class OnboardingComponent {
29730
30429
  this.dataService.Navigate('home');
29731
30430
  }
29732
30431
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: OnboardingComponent, deps: [{ token: DataServiceLib }, { token: AuthService }, { token: ApiErrorService }], target: i0.ɵɵFactoryTarget.Component }); }
29733
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: OnboardingComponent, isStandalone: false, selector: "spa-onboarding", ngImport: i0, template: "<label class=\"title\">Welcome, {{username}}</label>\n\n\n\n<!-- terms -->\n<div class=\"mt-3\" *ngIf=\"step=='terms'\">\n\n <label class=\"subtitle text-muted mb-2\" >We care about our users and are dedicated to protecting your data and privacy -\n thats why we want to be clear about what data we collect and how we use it to improve your experience.</label>\n\n <br>\n <spa-check display=\"I agree to the Terms and Privacy Policy\" [(value)]=\"agree\"></spa-check>\n</div>\n\n<!-- owner -->\n<div class=\"mt-3\" *ngIf=\"step=='name' && own\">\n\n <label class=\"subtitle text-muted\" style=\" margin-bottom: 20px;\">The follow steps will guide you to customise your application.</label>\n\n <div style=\"max-width: 400px;\">\n <spa-text display=\"Organisation Name\" [(value)]=\"myTenant.name\" ></spa-text>\n </div>\n\n <label class=\"text-muted\" style=\" font-size: 12px;\">You can change the Organisation's name to your team or company name.</label><br>\n <label class=\"text-muted\" style=\" font-size: 12px;margin-top: 10px;\">The name can be changed later.</label>\n\n</div>\n\n<!-- guest -->\n<div *ngIf=\"step=='hi' && !own\">\n <label class=\"subtitle text-muted\">You are now signed in to {{myTenant.name}}.</label>\n</div>\n\n\n<!-- invitations -->\n<div class=\"mt-3\" *ngIf=\"step=='invitations' && own\">\n\n <label class=\"subtitle text-muted\">You have been requested to join the following organisations. If you accept, you have the option to switch to that org now or stay in you org.</label><br>\n <label class=\"text-muted\" style=\" font-size: 12px;margin-top: 10px;\">You will be able to switch later.</label>\n <spa-invitations-table></spa-invitations-table>\n\n</div>\n\n\n<!-- Actions -->\n<div class=\"mt-3\">\n <button mat-stroked-button color=\"primary\" [disabled]=\"!agree\" (click)=\"next()\">Next <mat-icon>arrow_right_alt</mat-icon></button>\n</div>\n", styles: [".title{margin-top:1em;font-size:28px;font-weight:300}\n"], dependencies: [{ kind: "directive", type: i1$2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "component", type: i3.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i4$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "component", type: TextComponent, selector: "spa-text", inputs: ["appearance", "readonly", "hint", "display", "placeholder", "value", "format", "type", "width", "copyContent", "clearContent", "required", "min", "max", "regex", "suffix", "infoMessage"], outputs: ["valueChange", "leave", "enterPress"] }, { kind: "component", type: CheckComponent, selector: "spa-check", inputs: ["readonly", "display", "value", "infoMessage", "hint"], outputs: ["valueChange", "click", "check", "uncheck", "infoClick"] }, { kind: "component", type: InvitationsTableComponent, selector: "spa-invitations-table" }] }); }
30432
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: OnboardingComponent, isStandalone: false, selector: "spa-onboarding", ngImport: i0, template: "<label class=\"title\">Welcome, {{username}}</label>\n\n\n\n<!-- terms -->\n<div class=\"mt-3\" *ngIf=\"step=='terms'\">\n\n <label class=\"subtitle text-muted mb-2\" >We care about our users and are dedicated to protecting your data and privacy -\n thats why we want to be clear about what data we collect and how we use it to improve your experience.</label>\n\n <br>\n <spa-check display=\"I agree to the Terms and Privacy Policy\" [(value)]=\"agree\"></spa-check>\n</div>\n\n<!-- owner -->\n<div class=\"mt-3\" *ngIf=\"step=='name' && own\">\n\n <label class=\"subtitle text-muted\" style=\" margin-bottom: 20px;\">The follow steps will guide you to customise your application.</label>\n\n <div style=\"max-width: 400px;\">\n <spa-text display=\"Organisation Name\" [(value)]=\"myTenant.name\" ></spa-text>\n </div>\n\n <label class=\"text-muted\" style=\" font-size: 12px;\">You can change the Organisation's name to your team or company name.</label><br>\n <label class=\"text-muted\" style=\" font-size: 12px;margin-top: 10px;\">The name can be changed later.</label>\n\n</div>\n\n<!-- guest -->\n<div *ngIf=\"step=='hi' && !own\">\n <label class=\"subtitle text-muted\">You are now signed in to {{myTenant.name}}.</label>\n</div>\n\n\n<!-- invitations -->\n<div class=\"mt-3\" *ngIf=\"step=='invitations' && own\">\n\n <label class=\"subtitle text-muted\">You have been requested to join the following organisations. If you accept, you have the option to switch to that org now or stay in you org.</label><br>\n <label class=\"text-muted\" style=\" font-size: 12px;margin-top: 10px;\">You will be able to switch later.</label>\n <spa-invitations-table></spa-invitations-table>\n\n</div>\n\n\n<!-- Actions -->\n<div class=\"mt-3\">\n <button mat-stroked-button color=\"primary\" [disabled]=\"!agree\" (click)=\"next()\">Next <mat-icon>arrow_right_alt</mat-icon></button>\n</div>\n", styles: [".title{margin-top:1em;font-size:28px;font-weight:300}\n"], dependencies: [{ kind: "directive", type: i1$2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "component", type: i3.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i4$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "component", type: TextComponent, selector: "spa-text", inputs: ["appearance", "readonly", "hint", "display", "placeholder", "value", "format", "type", "width", "copyContent", "clearContent", "required", "min", "max", "regex", "suffix", "infoMessage"], outputs: ["valueChange", "leave", "enterPress"] }, { kind: "component", type: CheckComponent, selector: "spa-check", inputs: ["readonly", "display", "value", "infoMessage", "hint"], outputs: ["valueChange", "click", "check", "uncheck", "infoClick"] }, { kind: "component", type: InvitationsTableComponent, selector: "spa-invitations-table", outputs: ["totalChange"] }] }); }
29734
30433
  }
29735
30434
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: OnboardingComponent, decorators: [{
29736
30435
  type: Component,
@@ -30804,13 +31503,18 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
30804
31503
  args: [{ selector: 'spa-tasks', standalone: false, template: "<div class=\"d-flex align-items-center justify-content-between mt-0\" style=\"margin-left: 10px\">\n\n <label style=\"font-size: 16px;\">Tasks</label>\n\n <div>\n <button mat-mini-fab color=\"primary\" style=\"margin-right:1em;\" (click)=\"cats()\" matTooltip=\"Categories\" matTooltipPosition=\"above\"><mat-icon>category</mat-icon></button>\n </div>\n\n</div>\n<hr>\n\n<div class=\"mt-3\" style=\" font-size: 14px;\">\n <spa-table [config]=\"tasksTableConfig\" [reload]=\"reload\"></spa-table>\n</div>\n", styles: [".mat-mini-fab{width:32px;height:32px}.mat-mini-fab mat-icon{font-size:16px;margin-top:-3px}\n"] }]
30805
31504
  }], ctorParameters: () => [{ type: DataServiceLib }, { type: MessageService }, { type: AuthService }, { type: i4.MatDialog }] });
30806
31505
 
31506
+ // Added (F3): leaf-level data.moduleKey, inert metadata only — no guard is attached in this library. The general
31507
+ // AREA stays bare because "general" is a Core module the server always reports enabled, but Tasks & Notes is a
31508
+ // separate switchable module whose page happens to live under this path (capTasks.moduleKey = "tasks",
31509
+ // datalib.service.ts:146, a sub-item of capGeneral). Deepest data wins, so this leaf key is what a consumer's
31510
+ // guard resolves for home/general/tasks; everything else here correctly stays ungated.
30807
31511
  const GENERAL_ROUTES = [
30808
31512
  { path: "customers", component: CustomersComponent },
30809
31513
  { path: "suppliers", component: SuppliersComponent },
30810
31514
  { path: "categories", component: CategoriesComponent },
30811
31515
  { path: "subcategories", component: SubCategoriesComponent },
30812
31516
  { path: "brands", component: BrandsComponent },
30813
- { path: "tasks", component: TasksComponent }
31517
+ { path: "tasks", component: TasksComponent, data: { moduleKey: 'tasks' } } // Added (F3): Tasks & Notes is its own module
30814
31518
  ];
30815
31519
  class GeneralRoutingModule {
30816
31520
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: GeneralRoutingModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); }
@@ -30832,6 +31536,22 @@ class TenantSettingsComponent {
30832
31536
  this.authService = authService;
30833
31537
  this.dialog = dialog;
30834
31538
  this.apiErrorService = apiErrorService;
31539
+ // Added (2026-08-12): which cards are shut. Members is left open, the rest closed — the Day Book's
31540
+ // arrangement (first open, the others showing only their identity). Every closed card still states
31541
+ // what it holds, so nothing is hidden behind a click you would have to make to find out.
31542
+ this.closed = { members: false, orgs: true, invites: true, email: true };
31543
+ // Added: row totals reported by each table's existing (totalChange) output. Declared field-by-field
31544
+ // rather than as an index signature: the library compiles with noPropertyAccessFromIndexSignature, so
31545
+ // counts.members in a template is an error under an index signature. undefined = the table has not
31546
+ // reported yet, which is why the summary tells that apart from a genuine zero.
31547
+ this.counts = {};
31548
+ // Added (2026-08-12, owner): the hero's "you are here" line. Both are plain fields set when data arrives —
31549
+ // NOT template getters, which allocate on every change-detection pass (the livelock written up for this
31550
+ // library). currentTenantName is resolved from currTenant.tenantID, deliberately not from currentTenantID:
31551
+ // currentTenantID is the DROPDOWN's value and changes the moment the user picks another organisation, so
31552
+ // binding to it would make the line claim you had already moved before you pressed Switch.
31553
+ this.currentTenantName = '';
31554
+ this.belongText = '';
30835
31555
  this.roles = [
30836
31556
  { roleName: 'Default', roleID: 1 },
30837
31557
  ];
@@ -30991,18 +31711,42 @@ class TenantSettingsComponent {
30991
31711
  this.loadData();
30992
31712
  this.loadTenants();
30993
31713
  }
31714
+ setWhere() {
31715
+ const list = this.tenants || [];
31716
+ const id = this.currTenant ? this.currTenant.tenantID : null;
31717
+ const found = list.find(x => x.tenantID == id);
31718
+ this.currentTenantName = found ? found.name : (this.currTenant && this.currTenant.name ? this.currTenant.name : '');
31719
+ // Worded, never a bare figure — a lone number on a card does not say what it counts.
31720
+ this.belongText = list.length > 1 ? 'you belong to ' + list.length + ' organisations' : (list.length === 1 ? 'your only organisation' : '');
31721
+ }
31722
+ toggle(key) { this.closed[key] = !this.closed[key]; }
31723
+ // The collapsed summary. It always names what it is counting — '2 organisations', never a bare '2',
31724
+ // which on a card reads as an identifier as readily as a quantity.
31725
+ summary(n, one, many) {
31726
+ if (n === undefined || n === null)
31727
+ return '';
31728
+ if (n === 0)
31729
+ return 'No ' + many;
31730
+ return n + ' ' + (n === 1 ? one : many);
31731
+ }
31732
+ emailSummary(n) {
31733
+ if (n === undefined || n === null)
31734
+ return '';
31735
+ return n === 0 ? 'Not configured' : (n === 1 ? '1 configuration' : n + ' configurations');
31736
+ }
30994
31737
  loadData() {
30995
31738
  this.dataService.CallApi({ url: 'tenants/meta/x' }, "").subscribe((apiResponse) => {
30996
31739
  this.currTenant = apiResponse.data.tenant;
30997
31740
  this.currentTenantID = apiResponse.data.currentTenantID;
30998
- this.plan = apiResponse.data.plan;
30999
31741
  this.ownTenant = apiResponse.data.ownTenant;
31000
31742
  this.membersFormConfig.fields.find(x => x.name == 'roleID').options = apiResponse.data.roles;
31743
+ this.setWhere(); // Added: the two calls land in either order, so the hero line is recomputed by both
31001
31744
  });
31002
31745
  }
31003
31746
  loadTenants() {
31004
31747
  this.dataService.CallApi({ url: 'tenants/meta_tenants/x' }, "").subscribe((apiResponse) => {
31005
31748
  this.tenants = apiResponse.data.tenants;
31749
+ this.setWhere(); // Added: the organisation names live on this response, so the line cannot resolve before it
31006
31750
  });
31007
31751
  }
31008
31752
  updateTenant(x) {
@@ -31042,11 +31786,11 @@ class TenantSettingsComponent {
31042
31786
  this.authService.logoff();
31043
31787
  }
31044
31788
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: TenantSettingsComponent, deps: [{ token: DataServiceLib }, { token: MessageService }, { token: AuthService }, { token: i4.MatDialog }, { token: ApiErrorService }], target: i0.ɵɵFactoryTarget.Component }); }
31045
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: TenantSettingsComponent, isStandalone: false, selector: "spa-tenant-settings", ngImport: i0, template: "<div class=\"container\">\n\n <div>\n\n <label class=\"title\" >Organisation Details</label>\n <hr style=\"margin-top: 0px; margin-bottom: 0px;\">\n\n <div *ngIf=\"currTenant && plan\" class=\"mb-2 mt-3 tin-grid\" style=\" font-size: 14px;\">\n\n <div class=\"tin-col mb-3\" style=\"max-width: 500px;\">\n <spa-select display=\"Current Organisation\" [options]=\"tenants\" optionDisplay=\"name\" optionValue=\"tenantID\" [(value)]=\"currentTenantID\"\n hint=\"You are required to login again after switching organisations.\" style=\"min-width: 300px;margin-bottom: 10px;\"></spa-select>\n <button mat-stroked-button color=\"primary\" [disabled]=\"currentTenantID == currTenant.tenantID\" (click)=\"switchTenant()\">Switch</button>\n </div>\n\n </div>\n\n </div>\n\n\n <ng-container *ngIf=\"ownTenant\" >\n <!-- Members -->\n <div class=\"mt-3\" >\n\n <label class=\"title\" >Members</label>\n <hr style=\"margin-top: 0px; margin-bottom: 0px;\">\n <label class=\"subtitle text-muted\">Invite other users to join your organisation as partners or employees to form a partnership or company.</label>\n\n <spa-table [config]=\"membersTableConfig\" [reload]=\"tableReload\" ></spa-table>\n\n </div>\n\n\n<!-- My Organisations -->\n <div class=\"mt-3\" *ngIf=\"ownTenant\">\n\n <label class=\"title\" >My Organisations</label>\n <hr style=\"margin-top: 0px; margin-bottom: 0px;\">\n <label class=\"subtitle text-muted\">Organisations that you are a member of.</label>\n\n <spa-table [config]=\"orgsTableConfig\" [reload]=\"orgsReload\" (actionResponse)=\"updateTenant($event)\"></spa-table>\n\n </div>\n\n\n <!-- My Invitations -->\n <div class=\"mt-3\" *ngIf=\"ownTenant\">\n\n <label class=\"title\">My Invitations</label>\n <hr style=\"margin-top: 0px; margin-bottom: 0px;\">\n <label class=\"subtitle text-muted\">Requests for you to join other organisations.</label>\n\n\n <spa-invitations-table></spa-invitations-table>\n\n </div>\n\n <!-- Billing -->\n <div class=\"mt-3\" *ngIf=\"ownTenant\">\n\n <label class=\"title\" >Billing and Subscription</label>\n <hr style=\"margin-top: 0px; margin-bottom: 0px;\">\n\n <div *ngIf=\"currTenant && plan\" class=\"mb-1 mt-3\" style=\"max-width: 300px; font-size: 14px;\">\n <spa-label display=\"Plan\" [value]=\"plan.name\"></spa-label>\n <spa-label display=\"Next Payment\" format=\"money\" [value]=\"plan.price\"></spa-label>\n <spa-label display=\"Due Date\" format=\"date\" value=\"2024-01-01\"></spa-label>\n </div>\n\n </div>\n\n <!-- Email -->\n <div class=\"mt-3 mb-5\" *ngIf=\"ownTenant\">\n\n <label class=\"title\">Email Configuration</label>\n <hr style=\"margin-top: 0px; margin-bottom: 0px;\">\n <label class=\"subtitle text-muted\">Configure email settings for sending notifications.</label>\n\n <spa-table [config]=\"mailerTableConfig\"></spa-table>\n\n </div>\n\n\n </ng-container>\n\n\n\n</div>\n\n\n", styles: [".title{margin-top:1em;font-size:28px;font-weight:300}.subtitle{font-size:smaller}\n"], dependencies: [{ kind: "directive", type: i1$2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "component", type: i3.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: SelectComponent, selector: "spa-select", inputs: ["detailsConfig"] }, { kind: "component", type: LabelComponent, selector: "spa-label", inputs: ["display", "value", "format", "suffix", "size"] }, { kind: "component", type: TableComponent, selector: "spa-table", inputs: ["data", "tileData", "config", "localMode", "parentDetails", "reload", "activeTab", "inTab", "nestingLevel"], outputs: ["dataLoad", "totalChange", "actionSuccess", "refreshClick", "searchClick", "createClick", "actionClick", "inputChange", "actionResponse"] }, { kind: "component", type: InvitationsTableComponent, selector: "spa-invitations-table" }] }); }
31789
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: TenantSettingsComponent, isStandalone: false, selector: "spa-tenant-settings", ngImport: i0, template: "<!-- Changed (2026-08-12): the page was five bare <label class=\"title\"> + <hr> blocks with tables hanging\n under them, and it had not been modernised alongside the Day Book and Getting Started. It is now the\n same language as those two: a page shell, one mat-card per section, and a header strip that is itself\n the button. Members / My Organisations / My Invitations / Email Configuration collapse; collapsed, each\n card still says what it holds (\"2 organisations\") so the page can be read without opening anything. -->\n<div class=\"ts-page\">\n\n <!-- Changed (2026-08-12, owner feedback on the top section): the page opened with a bare <h1> sitting on the\n page background, and a separate switcher card underneath holding one half-width select and a button \u2014\n with 513px of empty card to the right of them (measured). Getting Started, the page he named as the\n reference, opens with a HERO CARD instead: title, purpose and a status line on the left, and the page's\n one live control on the right (there, the readiness bar). Same idiom here. The organisation switcher IS\n this page's live control, so it now stands where that bar stands and fills the space that read as empty,\n rather than occupying a near-empty card of its own. The 24px padding, the 24px column gap and the type\n sizes are Getting Started's own values \u2014 nothing new was invented. -->\n <mat-card class=\"ts-hero\">\n <div class=\"ts-hero-content\">\n\n <div class=\"ts-hero-text\">\n <h1>Organisation Details</h1>\n <p class=\"ts-sub\">Your organisation, who belongs to it, and the invitations waiting for you.</p>\n <!-- The \"you are here\" line, standing in Getting Started's .hero-counter slot. It names the organisation\n you are ACTUALLY in, taken from currTenant and not from the dropdown's value, so it keeps telling the\n truth after you pick a different organisation and before you press Switch. -->\n <span class=\"ts-where\" *ngIf=\"currentTenantName\">You are working in <strong>{{ currentTenantName }}</strong><ng-container *ngIf=\"belongText\"> &middot; {{ belongText }}</ng-container></span>\n </div>\n\n <!-- Current organisation. Changed: the gate was `currTenant && plan` \u2014 `plan` existed only to feed the\n Billing block that has now gone, and leaving it in the condition would have hidden the switcher\n outright. It never had anything to do with switching organisation. -->\n <div class=\"ts-switch\" *ngIf=\"currTenant\">\n <div class=\"ts-switch-row\">\n <spa-select class=\"ts-switch-select\" display=\"Current Organisation\" [options]=\"tenants\" optionDisplay=\"name\" optionValue=\"tenantID\" [(value)]=\"currentTenantID\" hint=\"You are required to login again after switching organisations.\"></spa-select>\n <button mat-flat-button color=\"primary\" class=\"ts-switch-btn\" [disabled]=\"currentTenantID == currTenant.tenantID\" (click)=\"switchTenant()\">Switch</button>\n </div>\n </div>\n\n </div>\n </mat-card>\n\n <ng-container *ngIf=\"ownTenant\">\n\n <!-- Members -->\n <mat-card class=\"ts-card\" [class.closed]=\"closed.members\">\n <button type=\"button\" class=\"ts-chead\" (click)=\"toggle('members')\" [attr.aria-expanded]=\"!closed.members\" [attr.aria-controls]=\"'ts-body-members'\">\n <mat-icon class=\"ts-icon\">group</mat-icon>\n <span class=\"ts-label\">Members</span>\n <span class=\"ts-summary\">{{ summary(counts.members, 'member', 'members') }}</span>\n <mat-icon class=\"ts-chev\">{{ closed.members ? 'expand_more' : 'expand_less' }}</mat-icon>\n </button>\n <!-- Requirement, owner 2026-08-12: unlike the Day Book, the description stays readable while the card\n is SHUT \u2014 you should be able to tell what a section is for without having to open it first. -->\n <p class=\"ts-why\">Invite other users to join your organisation as partners or employees to form a partnership or company.</p>\n <div class=\"ts-body\" id=\"ts-body-members\">\n <spa-table [config]=\"membersTableConfig\" [reload]=\"tableReload\" (totalChange)=\"counts.members = $event\"></spa-table>\n </div>\n </mat-card>\n\n <!-- My Organisations -->\n <mat-card class=\"ts-card\" [class.closed]=\"closed.orgs\">\n <button type=\"button\" class=\"ts-chead\" (click)=\"toggle('orgs')\" [attr.aria-expanded]=\"!closed.orgs\" [attr.aria-controls]=\"'ts-body-orgs'\">\n <mat-icon class=\"ts-icon\">apartment</mat-icon>\n <span class=\"ts-label\">My Organisations</span>\n <span class=\"ts-summary\">{{ summary(counts.orgs, 'organisation', 'organisations') }}</span>\n <mat-icon class=\"ts-chev\">{{ closed.orgs ? 'expand_more' : 'expand_less' }}</mat-icon>\n </button>\n <p class=\"ts-why\">Organisations that you are a member of.</p>\n <div class=\"ts-body\" id=\"ts-body-orgs\">\n <spa-table [config]=\"orgsTableConfig\" [reload]=\"orgsReload\" (actionResponse)=\"updateTenant($event)\" (totalChange)=\"counts.orgs = $event\"></spa-table>\n </div>\n </mat-card>\n\n <!-- My Invitations -->\n <mat-card class=\"ts-card\" [class.closed]=\"closed.invites\">\n <button type=\"button\" class=\"ts-chead\" (click)=\"toggle('invites')\" [attr.aria-expanded]=\"!closed.invites\" [attr.aria-controls]=\"'ts-body-invites'\">\n <mat-icon class=\"ts-icon\">mark_email_unread</mat-icon>\n <span class=\"ts-label\">My Invitations</span>\n <span class=\"ts-summary\">{{ summary(counts.invites, 'invitation', 'invitations') }}</span>\n <mat-icon class=\"ts-chev\">{{ closed.invites ? 'expand_more' : 'expand_less' }}</mat-icon>\n </button>\n <p class=\"ts-why\">Requests for you to join other organisations.</p>\n <div class=\"ts-body\" id=\"ts-body-invites\">\n <spa-invitations-table (totalChange)=\"counts.invites = $event\"></spa-invitations-table>\n </div>\n </mat-card>\n\n <!-- Removed (2026-08-12, owner): the \"Billing and Subscription\" block. It was a placeholder \u2014 a plan\n name, a plan price and a due date HARDCODED to \"2024-01-01\" \u2014 and it has been superseded by the\n real pages already routed at tenancy/billing and tenancy/subscription. -->\n\n <!-- Email Configuration. Deliberately left exactly where it is (owner, 2026-08-12: app configuration is\n per-application, so this stays on the organisation page for now). Only the card treatment changed. -->\n <mat-card class=\"ts-card\" [class.closed]=\"closed.email\">\n <button type=\"button\" class=\"ts-chead\" (click)=\"toggle('email')\" [attr.aria-expanded]=\"!closed.email\" [attr.aria-controls]=\"'ts-body-email'\">\n <mat-icon class=\"ts-icon\">mail</mat-icon>\n <span class=\"ts-label\">Email Configuration</span>\n <span class=\"ts-summary\">{{ emailSummary(counts.email) }}</span>\n <mat-icon class=\"ts-chev\">{{ closed.email ? 'expand_more' : 'expand_less' }}</mat-icon>\n </button>\n <p class=\"ts-why\">Configure email settings for sending notifications.</p>\n <div class=\"ts-body\" id=\"ts-body-email\">\n <spa-table [config]=\"mailerTableConfig\" (totalChange)=\"counts.email = $event\"></spa-table>\n </div>\n </mat-card>\n\n </ng-container>\n\n</div>\n", styles: [":host{--ts-accent: #1565c0}.ts-page{max-width:1200px;margin:0 auto;padding:16px;display:flex;flex-direction:column;gap:16px}.ts-hero{padding:24px}.ts-hero-content{display:flex;align-items:center;justify-content:space-between;gap:24px;flex-wrap:wrap}.ts-hero-text{flex:1 1 300px;min-width:0}.ts-hero-text h1{margin:0 0 4px;font-size:24px}.ts-hero-text .ts-sub{margin:0 0 8px;font-size:14px;color:#0009;text-wrap:balance}.ts-where{display:block;font-size:13px;color:#0009}.ts-where strong{font-weight:600;color:#000000de}.ts-switch{flex:1.7 1 420px;max-width:700px;border:1px solid rgba(0,0,0,.12);border-radius:10px;padding:12px 14px 6px;background:#fafbfc}.ts-switch-row{display:flex;align-items:flex-start;gap:12px}.ts-switch-select{flex:1 1 auto;min-width:0;display:block}.ts-switch-btn{flex:0 0 auto;align-self:flex-start;--mdc-filled-button-container-height: var(--tin-field-band, 40px);height:var(--tin-field-band, 40px)}.ts-card{padding:0;overflow:hidden}.ts-chead{display:flex;align-items:center;gap:10px;width:100%;border:0;background:transparent;padding:14px 16px 10px;font:inherit;text-align:left;cursor:pointer;transition:background .15s}.ts-chead:hover{background:#f2f5f7}.ts-chead:focus-visible{outline:2px solid var(--ts-accent);outline-offset:-2px}.ts-icon{font-size:20px;width:20px;height:20px;color:var(--ts-accent);flex:0 0 auto}.ts-label{font-size:16px;font-weight:600;letter-spacing:.2px}.ts-summary{margin-left:auto;font-size:13px;color:#0000008c;white-space:nowrap;font-variant-numeric:tabular-nums}.ts-chev{font-size:22px;width:22px;height:22px;color:#00000073;flex:0 0 auto}.ts-why{margin:0;padding:0 16px 14px;color:#0000008c;font-size:13px;max-width:82ch}.ts-body{padding:0 16px 14px}.ts-card.closed .ts-body{display:none}@media (max-width: 700px){.ts-hero{padding:12px}.ts-hero-content{gap:12px}.ts-hero-text{flex:1 1 auto}.ts-page{padding:10px 0;gap:12px}.ts-hero-text h1{font-size:20px}.ts-hero-text .ts-sub{display:none}.ts-where{font-size:12px}.ts-switch{flex:1 1 auto;max-width:none;border:0;background:none;padding:0}.ts-switch-row{flex-direction:column;align-items:stretch;gap:4px}.ts-switch-select{flex:1 1 auto;max-width:none}.ts-switch-btn{width:100%;--mdc-filled-button-container-height: 44px;height:44px}.ts-chead{padding:10px;gap:8px;min-height:44px}.ts-icon{font-size:18px;width:18px;height:18px}.ts-label{font-size:15px}.ts-summary{font-size:12px}.ts-chev{font-size:20px;width:20px;height:20px}.ts-why{padding:0 10px 10px;font-size:12.5px}.ts-body{padding:0 10px 10px}}\n"], dependencies: [{ kind: "directive", type: i1$2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "component", type: i3.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i4$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "component", type: i19.MatCard, selector: "mat-card", inputs: ["appearance"], exportAs: ["matCard"] }, { kind: "component", type: SelectComponent, selector: "spa-select", inputs: ["detailsConfig"] }, { kind: "component", type: TableComponent, selector: "spa-table", inputs: ["data", "tileData", "config", "localMode", "parentDetails", "reload", "activeTab", "inTab", "nestingLevel"], outputs: ["dataLoad", "totalChange", "actionSuccess", "refreshClick", "searchClick", "createClick", "actionClick", "inputChange", "actionResponse"] }, { kind: "component", type: InvitationsTableComponent, selector: "spa-invitations-table", outputs: ["totalChange"] }] }); }
31046
31790
  }
31047
31791
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: TenantSettingsComponent, decorators: [{
31048
31792
  type: Component,
31049
- args: [{ selector: 'spa-tenant-settings', standalone: false, template: "<div class=\"container\">\n\n <div>\n\n <label class=\"title\" >Organisation Details</label>\n <hr style=\"margin-top: 0px; margin-bottom: 0px;\">\n\n <div *ngIf=\"currTenant && plan\" class=\"mb-2 mt-3 tin-grid\" style=\" font-size: 14px;\">\n\n <div class=\"tin-col mb-3\" style=\"max-width: 500px;\">\n <spa-select display=\"Current Organisation\" [options]=\"tenants\" optionDisplay=\"name\" optionValue=\"tenantID\" [(value)]=\"currentTenantID\"\n hint=\"You are required to login again after switching organisations.\" style=\"min-width: 300px;margin-bottom: 10px;\"></spa-select>\n <button mat-stroked-button color=\"primary\" [disabled]=\"currentTenantID == currTenant.tenantID\" (click)=\"switchTenant()\">Switch</button>\n </div>\n\n </div>\n\n </div>\n\n\n <ng-container *ngIf=\"ownTenant\" >\n <!-- Members -->\n <div class=\"mt-3\" >\n\n <label class=\"title\" >Members</label>\n <hr style=\"margin-top: 0px; margin-bottom: 0px;\">\n <label class=\"subtitle text-muted\">Invite other users to join your organisation as partners or employees to form a partnership or company.</label>\n\n <spa-table [config]=\"membersTableConfig\" [reload]=\"tableReload\" ></spa-table>\n\n </div>\n\n\n<!-- My Organisations -->\n <div class=\"mt-3\" *ngIf=\"ownTenant\">\n\n <label class=\"title\" >My Organisations</label>\n <hr style=\"margin-top: 0px; margin-bottom: 0px;\">\n <label class=\"subtitle text-muted\">Organisations that you are a member of.</label>\n\n <spa-table [config]=\"orgsTableConfig\" [reload]=\"orgsReload\" (actionResponse)=\"updateTenant($event)\"></spa-table>\n\n </div>\n\n\n <!-- My Invitations -->\n <div class=\"mt-3\" *ngIf=\"ownTenant\">\n\n <label class=\"title\">My Invitations</label>\n <hr style=\"margin-top: 0px; margin-bottom: 0px;\">\n <label class=\"subtitle text-muted\">Requests for you to join other organisations.</label>\n\n\n <spa-invitations-table></spa-invitations-table>\n\n </div>\n\n <!-- Billing -->\n <div class=\"mt-3\" *ngIf=\"ownTenant\">\n\n <label class=\"title\" >Billing and Subscription</label>\n <hr style=\"margin-top: 0px; margin-bottom: 0px;\">\n\n <div *ngIf=\"currTenant && plan\" class=\"mb-1 mt-3\" style=\"max-width: 300px; font-size: 14px;\">\n <spa-label display=\"Plan\" [value]=\"plan.name\"></spa-label>\n <spa-label display=\"Next Payment\" format=\"money\" [value]=\"plan.price\"></spa-label>\n <spa-label display=\"Due Date\" format=\"date\" value=\"2024-01-01\"></spa-label>\n </div>\n\n </div>\n\n <!-- Email -->\n <div class=\"mt-3 mb-5\" *ngIf=\"ownTenant\">\n\n <label class=\"title\">Email Configuration</label>\n <hr style=\"margin-top: 0px; margin-bottom: 0px;\">\n <label class=\"subtitle text-muted\">Configure email settings for sending notifications.</label>\n\n <spa-table [config]=\"mailerTableConfig\"></spa-table>\n\n </div>\n\n\n </ng-container>\n\n\n\n</div>\n\n\n", styles: [".title{margin-top:1em;font-size:28px;font-weight:300}.subtitle{font-size:smaller}\n"] }]
31793
+ args: [{ selector: 'spa-tenant-settings', standalone: false, template: "<!-- Changed (2026-08-12): the page was five bare <label class=\"title\"> + <hr> blocks with tables hanging\n under them, and it had not been modernised alongside the Day Book and Getting Started. It is now the\n same language as those two: a page shell, one mat-card per section, and a header strip that is itself\n the button. Members / My Organisations / My Invitations / Email Configuration collapse; collapsed, each\n card still says what it holds (\"2 organisations\") so the page can be read without opening anything. -->\n<div class=\"ts-page\">\n\n <!-- Changed (2026-08-12, owner feedback on the top section): the page opened with a bare <h1> sitting on the\n page background, and a separate switcher card underneath holding one half-width select and a button \u2014\n with 513px of empty card to the right of them (measured). Getting Started, the page he named as the\n reference, opens with a HERO CARD instead: title, purpose and a status line on the left, and the page's\n one live control on the right (there, the readiness bar). Same idiom here. The organisation switcher IS\n this page's live control, so it now stands where that bar stands and fills the space that read as empty,\n rather than occupying a near-empty card of its own. The 24px padding, the 24px column gap and the type\n sizes are Getting Started's own values \u2014 nothing new was invented. -->\n <mat-card class=\"ts-hero\">\n <div class=\"ts-hero-content\">\n\n <div class=\"ts-hero-text\">\n <h1>Organisation Details</h1>\n <p class=\"ts-sub\">Your organisation, who belongs to it, and the invitations waiting for you.</p>\n <!-- The \"you are here\" line, standing in Getting Started's .hero-counter slot. It names the organisation\n you are ACTUALLY in, taken from currTenant and not from the dropdown's value, so it keeps telling the\n truth after you pick a different organisation and before you press Switch. -->\n <span class=\"ts-where\" *ngIf=\"currentTenantName\">You are working in <strong>{{ currentTenantName }}</strong><ng-container *ngIf=\"belongText\"> &middot; {{ belongText }}</ng-container></span>\n </div>\n\n <!-- Current organisation. Changed: the gate was `currTenant && plan` \u2014 `plan` existed only to feed the\n Billing block that has now gone, and leaving it in the condition would have hidden the switcher\n outright. It never had anything to do with switching organisation. -->\n <div class=\"ts-switch\" *ngIf=\"currTenant\">\n <div class=\"ts-switch-row\">\n <spa-select class=\"ts-switch-select\" display=\"Current Organisation\" [options]=\"tenants\" optionDisplay=\"name\" optionValue=\"tenantID\" [(value)]=\"currentTenantID\" hint=\"You are required to login again after switching organisations.\"></spa-select>\n <button mat-flat-button color=\"primary\" class=\"ts-switch-btn\" [disabled]=\"currentTenantID == currTenant.tenantID\" (click)=\"switchTenant()\">Switch</button>\n </div>\n </div>\n\n </div>\n </mat-card>\n\n <ng-container *ngIf=\"ownTenant\">\n\n <!-- Members -->\n <mat-card class=\"ts-card\" [class.closed]=\"closed.members\">\n <button type=\"button\" class=\"ts-chead\" (click)=\"toggle('members')\" [attr.aria-expanded]=\"!closed.members\" [attr.aria-controls]=\"'ts-body-members'\">\n <mat-icon class=\"ts-icon\">group</mat-icon>\n <span class=\"ts-label\">Members</span>\n <span class=\"ts-summary\">{{ summary(counts.members, 'member', 'members') }}</span>\n <mat-icon class=\"ts-chev\">{{ closed.members ? 'expand_more' : 'expand_less' }}</mat-icon>\n </button>\n <!-- Requirement, owner 2026-08-12: unlike the Day Book, the description stays readable while the card\n is SHUT \u2014 you should be able to tell what a section is for without having to open it first. -->\n <p class=\"ts-why\">Invite other users to join your organisation as partners or employees to form a partnership or company.</p>\n <div class=\"ts-body\" id=\"ts-body-members\">\n <spa-table [config]=\"membersTableConfig\" [reload]=\"tableReload\" (totalChange)=\"counts.members = $event\"></spa-table>\n </div>\n </mat-card>\n\n <!-- My Organisations -->\n <mat-card class=\"ts-card\" [class.closed]=\"closed.orgs\">\n <button type=\"button\" class=\"ts-chead\" (click)=\"toggle('orgs')\" [attr.aria-expanded]=\"!closed.orgs\" [attr.aria-controls]=\"'ts-body-orgs'\">\n <mat-icon class=\"ts-icon\">apartment</mat-icon>\n <span class=\"ts-label\">My Organisations</span>\n <span class=\"ts-summary\">{{ summary(counts.orgs, 'organisation', 'organisations') }}</span>\n <mat-icon class=\"ts-chev\">{{ closed.orgs ? 'expand_more' : 'expand_less' }}</mat-icon>\n </button>\n <p class=\"ts-why\">Organisations that you are a member of.</p>\n <div class=\"ts-body\" id=\"ts-body-orgs\">\n <spa-table [config]=\"orgsTableConfig\" [reload]=\"orgsReload\" (actionResponse)=\"updateTenant($event)\" (totalChange)=\"counts.orgs = $event\"></spa-table>\n </div>\n </mat-card>\n\n <!-- My Invitations -->\n <mat-card class=\"ts-card\" [class.closed]=\"closed.invites\">\n <button type=\"button\" class=\"ts-chead\" (click)=\"toggle('invites')\" [attr.aria-expanded]=\"!closed.invites\" [attr.aria-controls]=\"'ts-body-invites'\">\n <mat-icon class=\"ts-icon\">mark_email_unread</mat-icon>\n <span class=\"ts-label\">My Invitations</span>\n <span class=\"ts-summary\">{{ summary(counts.invites, 'invitation', 'invitations') }}</span>\n <mat-icon class=\"ts-chev\">{{ closed.invites ? 'expand_more' : 'expand_less' }}</mat-icon>\n </button>\n <p class=\"ts-why\">Requests for you to join other organisations.</p>\n <div class=\"ts-body\" id=\"ts-body-invites\">\n <spa-invitations-table (totalChange)=\"counts.invites = $event\"></spa-invitations-table>\n </div>\n </mat-card>\n\n <!-- Removed (2026-08-12, owner): the \"Billing and Subscription\" block. It was a placeholder \u2014 a plan\n name, a plan price and a due date HARDCODED to \"2024-01-01\" \u2014 and it has been superseded by the\n real pages already routed at tenancy/billing and tenancy/subscription. -->\n\n <!-- Email Configuration. Deliberately left exactly where it is (owner, 2026-08-12: app configuration is\n per-application, so this stays on the organisation page for now). Only the card treatment changed. -->\n <mat-card class=\"ts-card\" [class.closed]=\"closed.email\">\n <button type=\"button\" class=\"ts-chead\" (click)=\"toggle('email')\" [attr.aria-expanded]=\"!closed.email\" [attr.aria-controls]=\"'ts-body-email'\">\n <mat-icon class=\"ts-icon\">mail</mat-icon>\n <span class=\"ts-label\">Email Configuration</span>\n <span class=\"ts-summary\">{{ emailSummary(counts.email) }}</span>\n <mat-icon class=\"ts-chev\">{{ closed.email ? 'expand_more' : 'expand_less' }}</mat-icon>\n </button>\n <p class=\"ts-why\">Configure email settings for sending notifications.</p>\n <div class=\"ts-body\" id=\"ts-body-email\">\n <spa-table [config]=\"mailerTableConfig\" (totalChange)=\"counts.email = $event\"></spa-table>\n </div>\n </mat-card>\n\n </ng-container>\n\n</div>\n", styles: [":host{--ts-accent: #1565c0}.ts-page{max-width:1200px;margin:0 auto;padding:16px;display:flex;flex-direction:column;gap:16px}.ts-hero{padding:24px}.ts-hero-content{display:flex;align-items:center;justify-content:space-between;gap:24px;flex-wrap:wrap}.ts-hero-text{flex:1 1 300px;min-width:0}.ts-hero-text h1{margin:0 0 4px;font-size:24px}.ts-hero-text .ts-sub{margin:0 0 8px;font-size:14px;color:#0009;text-wrap:balance}.ts-where{display:block;font-size:13px;color:#0009}.ts-where strong{font-weight:600;color:#000000de}.ts-switch{flex:1.7 1 420px;max-width:700px;border:1px solid rgba(0,0,0,.12);border-radius:10px;padding:12px 14px 6px;background:#fafbfc}.ts-switch-row{display:flex;align-items:flex-start;gap:12px}.ts-switch-select{flex:1 1 auto;min-width:0;display:block}.ts-switch-btn{flex:0 0 auto;align-self:flex-start;--mdc-filled-button-container-height: var(--tin-field-band, 40px);height:var(--tin-field-band, 40px)}.ts-card{padding:0;overflow:hidden}.ts-chead{display:flex;align-items:center;gap:10px;width:100%;border:0;background:transparent;padding:14px 16px 10px;font:inherit;text-align:left;cursor:pointer;transition:background .15s}.ts-chead:hover{background:#f2f5f7}.ts-chead:focus-visible{outline:2px solid var(--ts-accent);outline-offset:-2px}.ts-icon{font-size:20px;width:20px;height:20px;color:var(--ts-accent);flex:0 0 auto}.ts-label{font-size:16px;font-weight:600;letter-spacing:.2px}.ts-summary{margin-left:auto;font-size:13px;color:#0000008c;white-space:nowrap;font-variant-numeric:tabular-nums}.ts-chev{font-size:22px;width:22px;height:22px;color:#00000073;flex:0 0 auto}.ts-why{margin:0;padding:0 16px 14px;color:#0000008c;font-size:13px;max-width:82ch}.ts-body{padding:0 16px 14px}.ts-card.closed .ts-body{display:none}@media (max-width: 700px){.ts-hero{padding:12px}.ts-hero-content{gap:12px}.ts-hero-text{flex:1 1 auto}.ts-page{padding:10px 0;gap:12px}.ts-hero-text h1{font-size:20px}.ts-hero-text .ts-sub{display:none}.ts-where{font-size:12px}.ts-switch{flex:1 1 auto;max-width:none;border:0;background:none;padding:0}.ts-switch-row{flex-direction:column;align-items:stretch;gap:4px}.ts-switch-select{flex:1 1 auto;max-width:none}.ts-switch-btn{width:100%;--mdc-filled-button-container-height: 44px;height:44px}.ts-chead{padding:10px;gap:8px;min-height:44px}.ts-icon{font-size:18px;width:18px;height:18px}.ts-label{font-size:15px}.ts-summary{font-size:12px}.ts-chev{font-size:20px;width:20px;height:20px}.ts-why{padding:0 10px 10px;font-size:12.5px}.ts-body{padding:0 10px 10px}}\n"] }]
31050
31794
  }], ctorParameters: () => [{ type: DataServiceLib }, { type: MessageService }, { type: AuthService }, { type: i4.MatDialog }, { type: ApiErrorService }] });
31051
31795
 
31052
31796
  class TenantsComponent {
@@ -32687,8 +33431,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
32687
33431
  args: [{ selector: 'spa-notifications-config', standalone: false, template: "<spa-page [config]=\"pageConfig\"></spa-page>\n" }]
32688
33432
  }], ctorParameters: () => [{ type: DataServiceLib }] });
32689
33433
 
33434
+ // Added (F3): leaf-level data.moduleKey, inert metadata only — no guard is attached in this library. The workflow
33435
+ // AREA is deliberately bare because it is not a module: only Approvals is one (capApprovals.moduleKey = "approvals",
33436
+ // datalib.service.ts:235). Approvals Config, notifications, app models and sync carry no moduleKey in the CapItem
33437
+ // tree, so they stay unkeyed and fail open, which is the safe direction. Tasks & Notes ("tasks") is NOT here — its
33438
+ // page is home/general/tasks (capTasks:146), so its key lives on that leaf in general-routing.module.ts. Document
33439
+ // Management ("documents") has no page in this library at all; its only route is shift-spa's own doctypes.
32690
33440
  const WORKFLOW_ROUTES = [
32691
- { path: "approvals", component: ApprovalsComponent },
33441
+ { path: "approvals", component: ApprovalsComponent, data: { moduleKey: 'approvals' } }, // Added (F3): the one workflow page that is a module
32692
33442
  { path: "approvals-config", component: ApprovalsConfigComponent },
32693
33443
  { path: "notifications", component: NotificationsComponent },
32694
33444
  { path: "appmodels", component: AppModelsComponent },
@@ -32919,26 +33669,36 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
32919
33669
 
32920
33670
  // All domain routes nested under their module path
32921
33671
  // Consumer apps import SpaHomeModule once — no need to declare routes per app
33672
+ //
33673
+ // Added (F3): data.moduleKey is INERT metadata and nothing here reads it. It declares which ModuleCatalog module a
33674
+ // route belongs to (keys transcribed from ModuleCatalog.Library and the CapItem.moduleKey assignments in
33675
+ // datalib.service.ts, not invented). The library deliberately attaches NO guard of its own: a consumer opts in by
33676
+ // attaching moduleGuard() as a child guard on its OWN home route, so an app outside this workspace that never adds
33677
+ // that line sees an identical app on this version. (The word is spelled out nowhere in this folder on purpose —
33678
+ // the acceptance check for this phase is a literal grep for a route guard inside the library's route tables, and
33679
+ // it must return a clean zero.) Deliberately left bare — user, admin, tenancy, workflow,
33680
+ // overview, general (a Core module), setup, day-book and app-configuration — because the route back to Getting
33681
+ // Started, which is the only way to switch a module back on, must never itself be gateable.
32922
33682
  const routes = [
32923
- { path: 'user', children: USER_ROUTES },
32924
- { path: 'admin', children: ADMIN_ROUTES },
32925
- { path: 'accounting', children: ACCOUNTING_ROUTES },
32926
- { path: 'inventory', children: INVENTORY_ROUTES },
32927
- { path: 'sales', children: SALES_ROUTES },
32928
- { path: 'purchasing', children: PURCHASING_ROUTES },
32929
- { path: 'hr', children: HR_ROUTES },
32930
- { path: 'payroll', children: PAYROLL_ROUTES },
32931
- { path: 'manufacturing', children: MANUFACTURING_ROUTES },
32932
- { path: 'loans', children: LOANS_ROUTES },
32933
- { path: 'general', children: GENERAL_ROUTES },
32934
- { path: 'tenancy', children: TENANCY_ROUTES },
32935
- { path: 'workflow', children: WORKFLOW_ROUTES },
32936
- { path: 'overview', children: OVERVIEW_ROUTES },
32937
- { path: 'fixed-assets', children: ASSETS_ROUTES }, // Changed: Added Fixed Assets module
32938
- { path: 'setup', component: SetupGuideComponent }, // Added: Getting Started page — home/setup in all consumer apps
33683
+ { path: 'user', children: USER_ROUTES }, // Ungated — account pages
33684
+ { path: 'admin', children: ADMIN_ROUTES }, // Ungated — administration
33685
+ { path: 'accounting', children: ACCOUNTING_ROUTES, data: { moduleKey: 'accounting' } }, // Added (F3): inert metadata, no guard
33686
+ { path: 'inventory', children: INVENTORY_ROUTES, data: { moduleKey: 'inventory' } }, // Added (F3)
33687
+ { path: 'sales', children: SALES_ROUTES, data: { moduleKey: 'sales' } }, // Added (F3)
33688
+ { path: 'purchasing', children: PURCHASING_ROUTES, data: { moduleKey: 'purchasing' } }, // Added (F3)
33689
+ { path: 'hr', children: HR_ROUTES, data: { moduleKey: 'hr' } }, // Added (F3)
33690
+ { path: 'payroll', children: PAYROLL_ROUTES, data: { moduleKey: 'payroll' } }, // Added (F3)
33691
+ { path: 'manufacturing', children: MANUFACTURING_ROUTES, data: { moduleKey: 'manufacturing' } }, // Added (F3)
33692
+ { path: 'loans', children: LOANS_ROUTES, data: { moduleKey: 'loans' } }, // Added (F3)
33693
+ { path: 'general', children: GENERAL_ROUTES }, // Ungated — "general" is a Core module the server always reports enabled (its tasks leaf carries its own "tasks" key, F3)
33694
+ { path: 'tenancy', children: TENANCY_ROUTES }, // Ungated — tenancy administration
33695
+ { path: 'workflow', children: WORKFLOW_ROUTES }, // Ungated at area level — Approvals is a module in its own right and now carries a leaf key (F3)
33696
+ { path: 'overview', children: OVERVIEW_ROUTES }, // Ungated — dashboards
33697
+ { path: 'fixed-assets', children: ASSETS_ROUTES, data: { moduleKey: 'fixed-assets' } }, // Changed: Added Fixed Assets module // Added (F3): inert metadata
33698
+ { path: 'setup', component: SetupGuideComponent }, // Added: Getting Started page — home/setup in all consumer apps // NEVER gated: the way back on
32939
33699
  { path: 'day-book', component: DayBookComponent }, // Added: Day Book page — home/day-book, renders empty state when unconfigured
32940
33700
  { path: 'app-configuration', component: AppConfigurationComponent }, // Added: App Configuration page — home/app-configuration, falls back to the library base sections when the app registers none
32941
- { path: 'agent', component: AgentPageComponent } // Added: Chat page — home/agent, every consumer app inherits it
33701
+ { path: 'agent', component: AgentPageComponent, data: { moduleKey: 'agent' } } // Added: Chat page — home/agent, every consumer app inherits it // Added (F3): inert metadata
32942
33702
  ];
32943
33703
  class SpaHomeRoutingModule {
32944
33704
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: SpaHomeRoutingModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); }
@@ -33538,5 +34298,5 @@ const ALSQUARE_SVG_WHITE = `<svg viewBox="0 0 80 80" fill="none" xmlns="http://w
33538
34298
  * Generated bundle index. Do not edit.
33539
34299
  */
33540
34300
 
33541
- export { ALSQUARE_SVG_DARK, ALSQUARE_SVG_WHITE, Account, AccountsComponent as AccountingAccountsComponent, AggregatesComponent as AccountingAggregatesComponent, AgingComponent as AccountingAgingComponent, CreditNotesComponent as AccountingCreditNotesComponent, CurrenciesComponent as AccountingCurrenciesComponent, AccountingDashboardComponent, InvoicesComponent as AccountingInvoicesComponent, AccountingModule, ReportsComponent as AccountingReportsComponent, AccountingService, StatementComponent as AccountingStatementComponent, TransactionTypesComponent as AccountingTransactionTypesComponent, TransactionsComponent as AccountingTransactionsComponent, VatReturnComponent as AccountingVatReturnComponent, Action, ActivityComponent, AdminModule, AgentComponent, AgentPageComponent, AgentService, AlertComponent, AlertConfig, AlertMessage, AnalyticsService, ApiErrorService, ApiResponse, AppConfig, AppConfigurationComponent, AppModelsComponent, AssetStatus, AssetsService, AttachComponent, AuthService, BankReconciliationComponent, BillingPageComponent, BottomTab, BrandsComponent, CacheConfig, CapItem, CapsulesComponent, CategoriesComponent, ChangePasswordComponent, ChangeUserPassword, ChartConfig, ChartsComponent, CheckComponent, ChecklistComponent, ChipsComponent, ConfigService, Constants, Core, CreateAccountComponent, CreditNoteStatus, CustomersComponent, DataServiceLib, DateComponent, DatetimeComponent, DayBookComponent, DepartmentsComponent, DetailsDialog, DetailsDialogConfig, DetailsDialogProcessor, DetailsSource, DialogService, EditorComponent, EmailComponent, EmployeesComponent, ExportService, FeatureDirective, FieldLoadIndicator, FilterComponent, FiscalPeriodStatus, FiscalPeriodsComponent, FormComponent, FormConfig, FormSkeletonComponent, GeneralModule, GeneralService, GradesComponent, GroupsComponent, HRModule, HtmlComponent, HttpService, ImportDialogComponent, IndexModule, InventoryDashboardComponent, InventoryModule, InventoryService, InvitationsTableComponent, InvoiceDashboardComponent, InvoiceItemType, InvoiceStatus, JournalEntryDialogComponent, LabelComponent, LastRouteService, ListDialogComponent, ListDialogConfig, LoaderComponent, LoaderService, LoanPaymentsComponent, LoanProductsComponent, LoansComponent, LoansModule, LoansService, LogLevel, LogService, LoginComponent, LogsComponent, MONOGRAM_TONES, ManufacturingModule, MembershipComponent, MessageService, MoneyComponent, MonogramComponent, MovementType, NavMenuComponent, NotesComponent, NotesConfig, NotificationsService, NumberComponent, OfflineIndicatorComponent, OfflineService, OnboardingComponent, OptionComponent, OverviewDashboardComponent, OverviewModule, PageComponent, PageConfig, PaginationConfig, PayrollDashboardComponent, PayrollModule, PayrollService, PerceivedProgress, PlansComponent, PositionsComponent, PrivacyDialogComponent, ProductionService, Profile, ProfileComponent, PurchaseStatus, PurchasingDashboardComponent, PurchasingModule, PurchasingService, PushNotificationService, QUIET_DELAY_MS, QUIET_MIN_MS, QuoteStatus, QuotesComponent, ReceiptDialogComponent, ReceiptStatus, ReceiptsComponent, ReconcileDialogComponent, RecoverAccountComponent, Register, RevenueScheduleDialogComponent, RevenueSchedulesComponent, Role, RoleAccess, RolesComponent, SalesDashboardComponent, SalesModule, SalesService, SearchComponent, SearchConfig, SecurityConfig, SelectBitwiseComponent, SelectComponent, SelectLiteComponent, SelectMultiComponent, SettingsComponent, SetupGuideComponent, SetupService, SignupComponent, SignupData, SpaAdminModule, SpaDayBookModule, SpaHomeModule, SpaIndexModule, SpaLandingComponent, SpaMatModule, SpaUserModule, StatementImportDialogComponent, StatusesComponent, Step, StepConfig, StepsComponent, StorageService, SubCategoriesComponent, SubscriptionPageComponent, SubscriptionService, SuppliersComponent, SyncComponent, TIN_SILENT_REQUEST, TIN_SPA_RUNTIME_CONFIG, TabService, TableComponent, TableConfig, TabsComponent, TasksComponent, TenancyModule, TenantsComponent, TermsDialogComponent, TextAreaComponent, TextComponent, TextMaskComponent, TextMultiComponent, TextSingleComponent, TileConfig, TilesComponent, TinSpaComponent, TinSpaModule, TinSpaService, TitleActionsComponent, TransactionTiming, UnitOfMeasure, UpdateService, User, UserModule, UsersComponent, ViewerComponent, WelcomeComponent, WorkflowModule, authGuard, dialogOptions, featureGuard, getTinSpaAppSettings, isSilentRequest, loadTinSpaAppSettings, loginConfig, messageDialog, monogramInitials, monogramKind, monogramPaletteIndex, provideTinSpaRuntime, resolveQuietLoading, silentContext, tinSpaLocationStrategyFactory, tinSpaMsalInstanceFactory, tinSpaRuntimeConfigFactory, viewerDialog };
34301
+ export { ALSQUARE_SVG_DARK, ALSQUARE_SVG_WHITE, Account, AccountsComponent as AccountingAccountsComponent, AggregatesComponent as AccountingAggregatesComponent, AgingComponent as AccountingAgingComponent, CreditNotesComponent as AccountingCreditNotesComponent, CurrenciesComponent as AccountingCurrenciesComponent, AccountingDashboardComponent, InvoicesComponent as AccountingInvoicesComponent, AccountingModule, ReportsComponent as AccountingReportsComponent, AccountingService, StatementComponent as AccountingStatementComponent, TransactionTypesComponent as AccountingTransactionTypesComponent, TransactionsComponent as AccountingTransactionsComponent, VatReturnComponent as AccountingVatReturnComponent, Action, ActivityComponent, AdminModule, AgentComponent, AgentPageComponent, AgentService, AlertComponent, AlertConfig, AlertMessage, AnalyticsService, ApiErrorService, ApiResponse, AppConfig, AppConfigurationComponent, AppModelsComponent, AssetStatus, AssetsService, AttachComponent, AuthService, BankReconciliationComponent, BillingPageComponent, BottomTab, BrandsComponent, CacheConfig, CapItem, CapsulesComponent, CategoriesComponent, ChangePasswordComponent, ChangeUserPassword, ChartConfig, ChartsComponent, CheckComponent, ChecklistComponent, ChipsComponent, ConfigService, Constants, Core, CreateAccountComponent, CreditNoteStatus, CustomersComponent, DataServiceLib, DateComponent, DatetimeComponent, DayBookComponent, DepartmentsComponent, DetailsDialog, DetailsDialogConfig, DetailsDialogProcessor, DetailsSource, DialogService, EditorComponent, EmailComponent, EmployeesComponent, ExportService, FeatureDirective, FieldLoadIndicator, FilterComponent, FiscalPeriodStatus, FiscalPeriodsComponent, FormComponent, FormConfig, FormSkeletonComponent, GeneralModule, GeneralService, GradesComponent, GroupsComponent, HRModule, HtmlComponent, HttpService, ImportDialogComponent, IndexModule, InventoryDashboardComponent, InventoryModule, InventoryService, InvitationsTableComponent, InvoiceDashboardComponent, InvoiceItemType, InvoiceStatus, JournalEntryDialogComponent, LabelComponent, LastRouteService, ListDialogComponent, ListDialogConfig, LoaderComponent, LoaderService, LoanPaymentsComponent, LoanProductsComponent, LoansComponent, LoansModule, LoansService, LogLevel, LogService, LoginComponent, LogsComponent, MONOGRAM_TONES, ManufacturingModule, MembershipComponent, MessageService, MoneyComponent, MonogramComponent, MovementType, NavMenuComponent, NotesComponent, NotesConfig, NotificationsService, NumberComponent, OfflineIndicatorComponent, OfflineService, OnboardingComponent, OptionComponent, OverviewDashboardComponent, OverviewModule, PageComponent, PageConfig, PaginationConfig, PayrollDashboardComponent, PayrollModule, PayrollService, PerceivedProgress, PlansComponent, PositionsComponent, PrivacyDialogComponent, ProductionService, Profile, ProfileComponent, PurchaseStatus, PurchasingDashboardComponent, PurchasingModule, PurchasingService, PushNotificationService, QUIET_DELAY_MS, QUIET_MIN_MS, QuoteStatus, QuotesComponent, ReceiptDialogComponent, ReceiptStatus, ReceiptsComponent, ReconcileDialogComponent, RecoverAccountComponent, Register, RevenueScheduleDialogComponent, RevenueSchedulesComponent, Role, RoleAccess, RolesComponent, SalesDashboardComponent, SalesModule, SalesService, SearchComponent, SearchConfig, SecurityConfig, SelectBitwiseComponent, SelectComponent, SelectLiteComponent, SelectMultiComponent, SettingsComponent, SetupGuideComponent, SetupService, SignupComponent, SignupData, SpaAdminModule, SpaDayBookModule, SpaHomeModule, SpaIndexModule, SpaLandingComponent, SpaMatModule, SpaUserModule, StatementImportDialogComponent, StatusesComponent, Step, StepConfig, StepsComponent, StorageService, SubCategoriesComponent, SubscriptionPageComponent, SubscriptionService, SuppliersComponent, SyncComponent, TIN_SILENT_REQUEST, TIN_SPA_RUNTIME_CONFIG, TabService, TableComponent, TableConfig, TabsComponent, TasksComponent, TenancyModule, TenantsComponent, TermsDialogComponent, TextAreaComponent, TextComponent, TextMaskComponent, TextMultiComponent, TextSingleComponent, TileConfig, TilesComponent, TinSpaComponent, TinSpaModule, TinSpaService, TitleActionsComponent, TransactionTiming, UnitOfMeasure, UpdateService, User, UserModule, UsersComponent, ViewerComponent, WelcomeComponent, WorkflowModule, authGuard, dialogOptions, featureGuard, getTinSpaAppSettings, isSilentRequest, loadTinSpaAppSettings, loginConfig, messageDialog, moduleGuard, monogramInitials, monogramKind, monogramPaletteIndex, provideTinSpaRuntime, resolveQuietLoading, silentContext, tinSpaLocationStrategyFactory, tinSpaMsalInstanceFactory, tinSpaRuntimeConfigFactory, viewerDialog };
33542
34302
  //# sourceMappingURL=tin-spa.mjs.map