tin-spa 20.14.32 → 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';
@@ -432,6 +432,30 @@ class Core {
432
432
  const onlyNumbers = value.replace(/[^\d.-]/g, '');
433
433
  return onlyNumbers;
434
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
+ }
435
459
  static getFirstDayOfMonth() {
436
460
  const date = new Date();
437
461
  return new Date(date.getFullYear(), date.getMonth(), 1);
@@ -454,20 +478,13 @@ class Core {
454
478
  }
455
479
  }
456
480
  static isValidEmailList(list) {
457
- if (list.includes(";")) {
458
- let emails = list.split(";");
459
- for (var email of emails) {
460
- if (this.emailIsValid(email) == false) {
461
- return false;
462
- }
463
- }
464
- }
465
- else {
466
- if (this.emailIsValid(list) == false) {
467
- return false;
468
- }
469
- }
470
- 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));
471
488
  }
472
489
  static { this.nullDate = "01 Jan 1969"; }
473
490
  static isNumber(value) {
@@ -3730,6 +3747,7 @@ class DataServiceLib {
3730
3747
  this.capEmployees = new CapItem;
3731
3748
  this.capDepartments = new CapItem;
3732
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
3733
3751
  this.capGrades = new CapItem;
3734
3752
  this.capPayroll = new CapItem; // Changed: Added Payroll module sidebar items
3735
3753
  this.capSalaryStructures = new CapItem;
@@ -3816,6 +3834,11 @@ class DataServiceLib {
3816
3834
  includeAudit: true,
3817
3835
  };
3818
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' } };
3819
3842
  this.departmentDetailsConfig = {
3820
3843
  formConfig: this.departmentFormConfig,
3821
3844
  heroField: 'departmentID',
@@ -3835,6 +3858,8 @@ class DataServiceLib {
3835
3858
  includeAudit: true,
3836
3859
  };
3837
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' } };
3838
3863
  this.positionDetailsConfig = {
3839
3864
  formConfig: this.positionFormConfig,
3840
3865
  heroField: 'positionID',
@@ -4089,6 +4114,7 @@ class DataServiceLib {
4089
4114
  },
4090
4115
  this.editPositionButton,
4091
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
4092
4118
  ],
4093
4119
  loadAction: { url: 'positions/all/x' },
4094
4120
  formConfig: this.positionFormConfig,
@@ -4132,6 +4158,7 @@ class DataServiceLib {
4132
4158
  },
4133
4159
  this.editDepartmentButton,
4134
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
4135
4162
  ],
4136
4163
  loadAction: { url: 'departments/all/x' },
4137
4164
  formConfig: this.departmentFormConfig,
@@ -4735,7 +4762,17 @@ class DataServiceLib {
4735
4762
  this.capHR.moduleKey = "hr"; // Added (Setup v3)
4736
4763
  this.capHR.link = "home/hr/employees";
4737
4764
  this.capHR.icon = "diversity_3";
4738
- 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;
4739
4776
  this.capEmployees.name = "cap18";
4740
4777
  this.capEmployees.display = "Employees";
4741
4778
  this.capEmployees.link = "home/hr/employees";
@@ -5085,6 +5122,13 @@ class DataServiceLib {
5085
5122
  get modernDialogs() {
5086
5123
  return this.appConfig?.dialogStyle === 'modern';
5087
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
+ }
5088
5132
  // Added: panel class for the overlay pane. Dialogs apply this so surface-level styling (radius, shadow,
5089
5133
  // header band) can live in tin-styles.css, which every consumer app already loads globally — component CSS
5090
5134
  // cannot reach the Material surface because it is an ancestor of the component's own view.
@@ -5370,6 +5414,16 @@ class messageDialog {
5370
5414
  this.data = data;
5371
5415
  this.dataService = dataService;
5372
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;
5373
5427
  // Added: splits the body into paragraphs on blank lines. The messages themselves already separate "what
5374
5428
  // happened" from "what to do next" that way, but the old template rendered the whole thing as one run of
5375
5429
  // text — which is precisely why nobody read past the first line. The first paragraph is styled as a lead,
@@ -5387,12 +5441,16 @@ class messageDialog {
5387
5441
  this._confirmLabel = this.data.confirmLabel; // Added: optional caller-supplied action label for the affirmative button
5388
5442
  this._cancelLabel = this.data.cancelLabel; // Added: optional caller-supplied label for the dismissive button
5389
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
5390
5445
  // Added: resolve the style once, at open time. The panel class carries surface-level styling (radius,
5391
5446
  // shadow) that component CSS cannot reach, because the Material surface is an ancestor of this view.
5392
5447
  this.modern = !!this.dataService?.modernDialogs;
5393
5448
  if (this.modern)
5394
5449
  this.dialogRef.addPanelClass('tin-dialog-modern');
5395
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; }
5396
5454
  // Added: per-type presentation. Kept as a lookup rather than scattered *ngIf branches so a new type is one
5397
5455
  // entry here instead of an edit in three places — the old template hardcoded its heading per type, which is
5398
5456
  // exactly how the caller-supplied subject came to be silently dropped for errors.
@@ -5404,7 +5462,6 @@ class messageDialog {
5404
5462
  confirm: { icon: 'help_outline', title: 'Just confirming' },
5405
5463
  }; }
5406
5464
  get spec() { return messageDialog.TYPES[this.messageType] ?? messageDialog.TYPES.info; }
5407
- get iconName() { return this.spec.icon; }
5408
5465
  // Changed: the caller's subject now wins for EVERY type. Previously the heading was hardcoded per type and
5409
5466
  // the subject was only rendered for 'info', so a friendly title like "No connection" never appeared on an
5410
5467
  // error — the user saw the bare word "Error" instead.
@@ -5450,11 +5507,11 @@ class messageDialog {
5450
5507
  this.dialogRef.close(resp);
5451
5508
  }
5452
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 }); }
5453
- 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" }] }); }
5454
5511
  }
5455
5512
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: messageDialog, decorators: [{
5456
5513
  type: Component,
5457
- 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"] }]
5458
5515
  }], ctorParameters: () => [{ type: i4.MatDialogRef }, { type: undefined, decorators: [{
5459
5516
  type: Inject,
5460
5517
  args: [MAT_DIALOG_DATA]
@@ -5534,13 +5591,17 @@ class MessageService {
5534
5591
  // ⚠️ Do NOT change the defaults to make every confirm read better: piglet-spa (38) and shift-spa (13) select
5535
5592
  // these buttons by their literal text via getByRole('button', { name: 'Yes' }), and the owner reverted a
5536
5593
  // hardcoded relabel on 2026-08-06. Opt in per call site.
5537
- 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) {
5538
5599
  let type = "confirm";
5539
5600
  let subject = "";
5540
5601
  let details = msg;
5541
5602
  const dialogRef = this.dialog.open(messageDialog, {
5542
5603
  width: "400px",
5543
- data: { type, subject, details, confirmLabel, cancelLabel },
5604
+ data: { type, subject, details, confirmLabel, cancelLabel, destructive },
5544
5605
  });
5545
5606
  return dialogRef.afterClosed().pipe(mergeMap((result) => {
5546
5607
  return of(result);
@@ -10743,6 +10804,47 @@ class ButtonService {
10743
10804
  // Return static message
10744
10805
  return button.confirm.message;
10745
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
+ }
10746
10848
  getButtonProperties(button, row, config) {
10747
10849
  return {
10748
10850
  color: this.getButtonColor(button, row),
@@ -11721,6 +11823,8 @@ class SetupService {
11721
11823
  // Added (v2): module catalog — empty means the app has no module concept (nothing is ever hidden)
11722
11824
  this.modules = new BehaviorSubject([]);
11723
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
11724
11828
  } // Changed: injected for loadStatus only
11725
11829
  get enabled() {
11726
11830
  return !!this.dataService.appConfig?.setupConfig?.enabled;
@@ -11759,6 +11863,7 @@ class SetupService {
11759
11863
  this.status.next(apiResponse.data);
11760
11864
  this.pendingCount.next(apiResponse.data?.pending ?? 0);
11761
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
11762
11867
  }
11763
11868
  },
11764
11869
  error: () => { }
@@ -11776,10 +11881,30 @@ class SetupService {
11776
11881
  // a dialog becomes a wall.
11777
11882
  if (apiResponse.success)
11778
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"
11779
11885
  },
11780
- 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
11781
11887
  });
11782
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
+ }
11783
11908
  // Added (v2): nav-menu gate — unknown keys and apps without modules always pass (fail open)
11784
11909
  isModuleEnabled(key) {
11785
11910
  if (!key)
@@ -11825,6 +11950,26 @@ class SetupService {
11825
11950
  applyApprovalPresets(items) {
11826
11951
  return this.dataService.CallApi({ url: 'setup/approvals', method: 'post', skipCache: true }, { items });
11827
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
+ }
11828
11973
  refresh() {
11829
11974
  this.loadStatus();
11830
11975
  }
@@ -12124,6 +12269,16 @@ class SetupGuideComponent {
12124
12269
  this.roleTemplates = [];
12125
12270
  this.selectedTemplates = {};
12126
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;
12127
12282
  // Added (presets): picker state. `groups` is materialized (same rule as moduleGroups above); selected/role are
12128
12283
  // plain maps keyed by preset key so a reload never clobbers a manual choice.
12129
12284
  // Changed (presets P4): one object per domain instead of a field per domain — see PresetPicker above.
@@ -12185,6 +12340,8 @@ class SetupGuideComponent {
12185
12340
  this.buildGroups();
12186
12341
  if (this.hasRoleTemplatesStep())
12187
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
12188
12345
  // Added (presets): only load a picker when the backend actually serves its step. The approvals step is gated
12189
12346
  // on the approvals module, so turning that module off makes the step — and this call — disappear with it.
12190
12347
  this.pickers.forEach(picker => { if (this.hasPresetsStep(picker))
@@ -12373,6 +12530,122 @@ class SetupGuideComponent {
12373
12530
  return;
12374
12531
  this.selectedTemplates[tpl.key] = !this.selectedTemplates[tpl.key];
12375
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
+ }
12376
12649
  //---------- Preset pickers (notifications, sign-off) ----------
12377
12650
  // ONE code path, driven by the PresetPicker objects above. Tick a row, pick who it goes to, apply. Both backends
12378
12651
  // are create-only and skip anything already configured, so neither can overwrite a rule the user has since
@@ -12491,6 +12764,48 @@ class SetupGuideComponent {
12491
12764
  removeDemoData() {
12492
12765
  this.runDemoAction('demo-data/remove', 'This permanently deletes every example record created by demo data. Continue?', 'Demo data removed successfully');
12493
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
+ }
12494
12809
  runDemoAction(url, question, success) {
12495
12810
  if (this.demoBusy)
12496
12811
  return;
@@ -12536,11 +12851,11 @@ class SetupGuideComponent {
12536
12851
  });
12537
12852
  }
12538
12853
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: SetupGuideComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
12539
- 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"] }] }); }
12540
12855
  }
12541
12856
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: SetupGuideComponent, decorators: [{
12542
12857
  type: Component,
12543
- 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"] }]
12544
12859
  }] });
12545
12860
 
12546
12861
  // Quiet Loading — perceived-progress engine (FSD D1).
@@ -13988,21 +14303,11 @@ class TextMultiComponent {
13988
14303
  this.loadIndicator = new FieldLoadIndicator();
13989
14304
  this.hoverChange = new EventEmitter();
13990
14305
  } // Changed: injected ApiErrorService so a failed option load is no longer silent
13991
- // Added: identical normalisation to SelectMultiComponent.toValueList, deliberately kept the same shape and
13992
- // name so the two multi-value components cannot drift. `value` is documented as a ';'-delimited string, but
13993
- // the truthiness guard below used to hand anything truthy straight to .split() — an array (any form whose
13994
- // backend DTO takes a List<int> rewrites the bound property in place before posting) or a bare scalar both
13995
- // threw "split is not a function". select-multi hit exactly that in piglet; nothing feeds text-multi an array
13996
- // today, so this is hardening ahead of the first caller that does, not a repair.
13997
- toValueList(raw) {
13998
- if (Array.isArray(raw))
13999
- return raw.filter(v => v !== null && v !== undefined && String(v).trim() !== '').map(v => String(v).trim()); // Added: the List<int> shape
14000
- if (!raw)
14001
- return []; // Added: preserves the previous falsy-means-nothing-entered rule for null/undefined/''
14002
- return String(raw).split(';').filter(v => v.trim() !== '').map(v => v.trim()); // Changed: String() first, so a bare scalar no longer throws either
14003
- }
14004
14306
  ngOnInit() {
14005
- 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);
14006
14311
  this.setupAutoComplete();
14007
14312
  this.getData(this.loadAction);
14008
14313
  }
@@ -14230,21 +14535,11 @@ class SelectMultiComponent {
14230
14535
  }
14231
14536
  this.initializeValues();
14232
14537
  }
14233
- // Added: `value` is documented as a ';'-delimited string, but real callers legitimately hold an array —
14234
- // any form whose backend DTO takes a List<int> rewrites the bound property in place before posting
14235
- // (piglet has four: wean, sell, move, treat). That array flowed straight back down [(value)] and
14236
- // this.value.split(';') threw "split is not a function" on every subsequent change-detection pass.
14237
- // Normalise on the way in rather than type-guarding at one call site, and keep emitting the ';' string
14238
- // on the way out so no existing consumer's parsing changes.
14239
- toValueList(raw) {
14240
- if (Array.isArray(raw))
14241
- return raw.filter(v => v !== null && v !== undefined && String(v).trim() !== '').map(v => String(v).trim()); // Added: the List<int> shape
14242
- if (!raw)
14243
- return []; // Added: preserves the previous falsy-means-nothing-selected rule for null/undefined/''
14244
- 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
14245
- }
14246
14538
  initializeValues() {
14247
- 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);
14248
14543
  if (requested.length > 0) { // Changed: gate on the parsed request, not on truthiness of a raw value that may be an array
14249
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
14250
14545
  this.selectedValues = values;
@@ -15664,6 +15959,33 @@ class TableRowComponent {
15664
15959
  showBanner(message) {
15665
15960
  this.showBannerEvent.emit(message);
15666
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
+ }
15667
15989
  textDisplayed(row, column) {
15668
15990
  let txt = row[column.name];
15669
15991
  let max = !this.smallScreen ? column?.maxLength : column?.maxLength / 2;
@@ -15681,11 +16003,11 @@ class TableRowComponent {
15681
16003
  return false;
15682
16004
  }
15683
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 }); }
15684
- 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 }); }
15685
16007
  }
15686
16008
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: TableRowComponent, decorators: [{
15687
16009
  type: Component,
15688
- 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"] }]
15689
16011
  }], ctorParameters: () => [{ type: ButtonService }], propDecorators: { column: [{
15690
16012
  type: Input
15691
16013
  }], row: [{
@@ -16712,7 +17034,11 @@ class EmailComponent {
16712
17034
  Validators.required,
16713
17035
  Validators.pattern('^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$')
16714
17036
  ]);
16715
- 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);
16716
17042
  // Set up autocomplete filtering
16717
17043
  this.filteredOptions = this.emailControl.valueChanges.pipe(startWith(''), map(value => this._filter(value)));
16718
17044
  }
@@ -17679,7 +18005,12 @@ class TableComponent {
17679
18005
  // A DERIVED name comes straight off a URL segment, which deriveEntityName capitalises and singularises for
17680
18006
  // SignalR ("product-groups" -> "Product-group"). That is right for an entity key and wrong inside a
17681
18007
  // sentence — the caption read "Loading Product-group…". De-hyphenate and lowercase it so it reads as prose.
17682
- // 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.
17683
18014
  const derived = this.deriveEntityName();
17684
18015
  return derived ? derived.replace(/[-_]+/g, ' ').toLowerCase() : 'data';
17685
18016
  }
@@ -17898,6 +18229,7 @@ class TableComponent {
17898
18229
  this.displayedColumns = this.tableConfigService.setColumns(this.config, this.smallScreen);
17899
18230
  this.displayedButtons = this.tableConfigService.getDisplayedButtons(this.config?.buttons, this.smallScreen, this.config);
17900
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)
17901
18233
  // Added: the same defect through the resize door. A row opened for edit on a wide screen keeps its seeded
17902
18234
  // editingModel when the window narrows past 600px, but its cells vanish with the columns — so the tick would
17903
18235
  // post values the operator can no longer see. Discard the edit instead; cancel has always been non-destructive.
@@ -18394,7 +18726,14 @@ class TableComponent {
18394
18726
  //Dialog events
18395
18727
  columnClicked(column, row) {
18396
18728
  if (column.detailsConfig) {
18397
- 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
18398
18737
  this.open(button, row);
18399
18738
  }
18400
18739
  else {
@@ -18505,11 +18844,12 @@ class TableComponent {
18505
18844
  }
18506
18845
  return;
18507
18846
  }
18508
- let confirmMessage = this.buttonService.getConfirmMessage(actionButton, row);
18509
- if (!confirmMessage) {
18510
- confirmMessage = `Are you sure you want to delete ?`;
18511
- }
18512
- 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) => {
18513
18853
  if (result == "yes") {
18514
18854
  this.actionClickedEmit('delete', row);
18515
18855
  this.doAction(actionButton.name, row);
@@ -18527,8 +18867,11 @@ class TableComponent {
18527
18867
  row[this.config.heroField] = this.config.heroValue;
18528
18868
  }
18529
18869
  if (b.confirm && b.name != "delete") {
18530
- const confirmMessage = this.buttonService.getConfirmMessage(b, row);
18531
- 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) => {
18532
18875
  if (result == "yes") {
18533
18876
  this.execAction(b, row);
18534
18877
  }
@@ -18655,6 +18998,55 @@ class TableComponent {
18655
18998
  } // reuse the already-injected column instead of adding another
18656
18999
  this.config.columns = [...this.config.columns, { name: '_offlineStatus', type: 'icon', alias: 'Sync', icons: statusIcons }]; // last data column -> renders just before Action
18657
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
+ }
18658
19050
  // Captures the raw server rows and merges pending ops: creates prepend, edits patch by id, deletes hide
18659
19051
  applyOfflineOverlay(rows) {
18660
19052
  if (!this.offlineEntry || !Array.isArray(rows))
@@ -18814,6 +19206,25 @@ class TableComponent {
18814
19206
  if (!segment)
18815
19207
  return '';
18816
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')
18817
19228
  return name.endsWith('s') ? name.slice(0, -1) : name; // Remove trailing 's' for singular
18818
19229
  }
18819
19230
  // Changed: Find the ID property key in a row object (convention: ends with 'ID')
@@ -19082,7 +19493,7 @@ class DayBookComponent {
19082
19493
  const stages = (this.config?.stages || []).filter(s => Core.isItemVisible(s, this.book));
19083
19494
  const tiles = stages.map(s => ({
19084
19495
  name: s.countField, alias: s.label, style: 'icon', icon: s.icon || 'checklist', info: s.hint,
19085
- 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.
19086
19497
  color: Core.isItemVisible({ visible: s.alert }, this.book) && s.alert !== undefined ? '#c62828' : undefined,
19087
19498
  action: { method: 'get', url: '' }, // never fired — clickable tiles emit tileClick, which we map to a scroll
19088
19499
  hidden: (d) => !d?.[s.countField] || !this.inLane(s.lane), // Changed: lane filter lives in the predicate so pill clicks re-filter tiles live
@@ -19092,10 +19503,9 @@ class DayBookComponent {
19092
19503
  // stages — more than any row can show — so one scrolling row beats either squeezing them or stacking
19093
19504
  // them down the page above the lists. This is a Day Book decision made once in the library rather than
19094
19505
  // repeated in five app configs; `stageTiles` still lets an app override it, including back to 'auto'.
19095
- // hideValueOnMobile: on a phone the stage tile is ~44% of the row and was wrapping its label to fit a
19096
- // money badge that the lane group header already shows. The count is what the tile is FOR; the money
19097
- // is a second opinion, and it is the one that goes when space is short.
19098
- 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
19099
19509
  // Added: counts resolved onto a copy of the book, so a countField naming a LIST still shows a number
19100
19510
  this.tileData = this.book ? { ...this.book, ...stages.reduce((a, s) => { a[s.countField] = this.stageCount(s); return a; }, {}) } : null;
19101
19511
  }
@@ -20123,7 +20533,11 @@ class FormComponent {
20123
20533
  return;
20124
20534
  }
20125
20535
  if (button.confirm) {
20126
- 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) => {
20127
20541
  if (result == "yes") {
20128
20542
  this.processCall(button);
20129
20543
  }
@@ -21637,6 +22051,43 @@ const featureGuard = (featureKey) => {
21637
22051
  };
21638
22052
  };
21639
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
+
21640
22091
  // Structural directive for plan-based feature gating in templates
21641
22092
  // Usage: *spaFeature="'sales.quotes'" — hides element if feature is disabled
21642
22093
  class FeatureDirective {
@@ -22143,8 +22594,18 @@ class NavMenuComponent {
22143
22594
  if (!this.smallScreen) {
22144
22595
  this.isExpanded = true;
22145
22596
  }
22146
- this.setupService.loadCount(); // Added: Refresh setup badge on nav init (persists across F5 no SignalR for setup)
22147
- 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
+ });
22148
22609
  // Added: start recording the visited page for resumeLastRoute. The nav shell only exists while signed in,
22149
22610
  // so this one subscription covers every in-app navigation without touching individual pages.
22150
22611
  this.lastRouteService.start();
@@ -23131,13 +23592,20 @@ class DetailsDialog {
23131
23592
  }
23132
23593
  else {
23133
23594
  this.settleRead(quiet, () => {
23134
- // Changed: TinSync — if a by-action load fails offline, fall back to the row the table already passed in so existing records stay editable
23135
- 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) {
23136
23602
  this.details = this.detailsConfig.details;
23137
23603
  this.isLoadComplete = true;
23138
23604
  }
23139
23605
  else {
23140
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
23141
23609
  }
23142
23610
  if (this.autoRefreshEnabled) {
23143
23611
  this.autoRefreshEnabled = false;
@@ -23233,11 +23701,10 @@ class DetailsDialog {
23233
23701
  const button = this.buttonService.getButton(this.buttons, 'delete');
23234
23702
  if (!button)
23235
23703
  return;
23236
- let confirmMessage = this.buttonService.getConfirmMessage(button, this.details);
23237
- if (!confirmMessage) {
23238
- confirmMessage = `Are you sure you want to delete ?`;
23239
- }
23240
- 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) => {
23241
23708
  if (result == "yes") {
23242
23709
  this.handleButtonAction('delete');
23243
23710
  }
@@ -23346,11 +23813,12 @@ class DetailsDialog {
23346
23813
  executeAction(button, data) {
23347
23814
  const actionData = this.prepareActionData(button, data);
23348
23815
  if (button.confirm) {
23349
- let confirmMessage = this.buttonService.getConfirmMessage(button, this.details);
23350
- if (!confirmMessage && button.name == 'delete') {
23351
- confirmMessage = `Are you sure you want to delete ?`;
23352
- }
23353
- 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) => {
23354
23822
  if (result == "yes") {
23355
23823
  this.performApiCall(button, actionData);
23356
23824
  }
@@ -23546,6 +24014,10 @@ class InvitationsTableComponent {
23546
24014
  this.messageService = messageService;
23547
24015
  this.authService = authService;
23548
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();
23549
24021
  this.tableReload = new Subject();
23550
24022
  this.invitationsTableConfig = {
23551
24023
  greyOut: (value) => value.accepted == false,
@@ -23604,12 +24076,14 @@ class InvitationsTableComponent {
23604
24076
  });
23605
24077
  }
23606
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 }); }
23607
- 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"] }] }); }
23608
24080
  }
23609
24081
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: InvitationsTableComponent, decorators: [{
23610
24082
  type: Component,
23611
- args: [{ selector: 'spa-invitations-table', standalone: false, template: "<spa-table [config]=\"invitationsTableConfig\" (actionClick)=\"invActionClicked($event)\" [reload]=\"tableReload\"></spa-table>\n" }]
23612
- }], 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
+ }] } });
23613
24087
 
23614
24088
  // Generic AI-assisted import wizard: Upload -> Map -> Review -> Done. Config-driven via ImportConfig.
23615
24089
  class ImportDialogComponent {
@@ -26076,6 +26550,15 @@ class RolesComponent {
26076
26550
  { value: RoleAccess.Create, name: 'Create' },
26077
26551
  { value: RoleAccess.Full, name: 'Full' }
26078
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"
26079
26562
  this.renameDialogConfig = {
26080
26563
  formConfig: {
26081
26564
  security: { allow: [this.dataService.capRoles] }, // Added: gate rename role form by roles cap
@@ -26111,16 +26594,184 @@ class RolesComponent {
26111
26594
  ngOnInit() {
26112
26595
  this.authService.isAuthorised(this.dataService.capRoles.name);
26113
26596
  this.loadRoles();
26114
- 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
26115
26598
  }
26116
26599
  loadRoles() {
26117
26600
  this.dataService.GetRole("all", "").subscribe((apiResponse) => {
26118
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
26119
26604
  });
26120
26605
  }
26121
26606
  refresh() {
26122
26607
  this.loadRoles();
26123
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; }
26124
26775
  onCapItemChange(capItem, checked, role) {
26125
26776
  return; //disabled to allow sub component access without granting whole menu access
26126
26777
  if (!checked && capItem.capSubItems) {
@@ -26197,11 +26848,11 @@ class RolesComponent {
26197
26848
  });
26198
26849
  }
26199
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 }); }
26200
- 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"] }] }); }
26201
26852
  }
26202
26853
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: RolesComponent, decorators: [{
26203
26854
  type: Component,
26204
- 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"] }]
26205
26856
  }], ctorParameters: () => [{ type: HttpService }, { type: i1$1.Router }, { type: AuthService }, { type: DataServiceLib }, { type: DialogService }, { type: i4.MatDialog }, { type: MessageService }, { type: ApiErrorService }] });
26206
26857
 
26207
26858
  class CreateAccountComponent {
@@ -29051,15 +29702,25 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
29051
29702
  }] });
29052
29703
 
29053
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".
29054
29715
  const ACCOUNTING_ROUTES = [
29055
29716
  { path: "accounts", component: AccountsComponent },
29056
29717
  { path: "aggregates", component: AggregatesComponent },
29057
29718
  { path: "transaction-types", component: TransactionTypesComponent },
29058
29719
  { path: "transactions", component: TransactionsComponent },
29059
- { path: "invoices", component: InvoicesComponent },
29060
- { path: "aging", component: AgingComponent },
29061
- { path: "supplier-aging", component: SupplierAgingComponent },
29062
- { 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
29063
29724
  { path: "reports", component: ReportsComponent },
29064
29725
  { path: "tax-rates", component: TaxRatesComponent },
29065
29726
  { path: "standing-orders", component: StandingOrdersComponent },
@@ -29067,14 +29728,14 @@ const ACCOUNTING_ROUTES = [
29067
29728
  { path: "budgets", component: BudgetsComponent },
29068
29729
  { path: "budget-vs-actual", component: BudgetVsActualComponent },
29069
29730
  { path: "dashboard", component: AccountingDashboardComponent },
29070
- { path: "invoice-dashboard", component: InvoiceDashboardComponent },
29071
- { 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)
29072
29733
  { path: "vat-return", component: VatReturnComponent }, // Changed: VAT return route (B2)
29073
29734
  { path: "fiscal-periods", component: FiscalPeriodsComponent }, // Changed: Fiscal periods route (C2)
29074
- { path: "receipts", component: ReceiptsComponent }, // Changed: Customer receipts route (C3)
29735
+ { path: "receipts", component: ReceiptsComponent, data: { moduleKey: 'invoicing' } }, // Changed: Customer receipts route (C3) // Added (F3)
29075
29736
  { path: "bank-reconciliation", component: BankReconciliationComponent }, // Changed: Bank reconciliation route (C4)
29076
- { path: "revenue-schedules", component: RevenueSchedulesComponent }, // Changed: Deferred revenue route (C7)
29077
- { 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)
29078
29739
  ];
29079
29740
  class AccountingRoutingModule {
29080
29741
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: AccountingRoutingModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); }
@@ -29768,7 +30429,7 @@ class OnboardingComponent {
29768
30429
  this.dataService.Navigate('home');
29769
30430
  }
29770
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 }); }
29771
- 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"] }] }); }
29772
30433
  }
29773
30434
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: OnboardingComponent, decorators: [{
29774
30435
  type: Component,
@@ -30842,13 +31503,18 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
30842
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"] }]
30843
31504
  }], ctorParameters: () => [{ type: DataServiceLib }, { type: MessageService }, { type: AuthService }, { type: i4.MatDialog }] });
30844
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.
30845
31511
  const GENERAL_ROUTES = [
30846
31512
  { path: "customers", component: CustomersComponent },
30847
31513
  { path: "suppliers", component: SuppliersComponent },
30848
31514
  { path: "categories", component: CategoriesComponent },
30849
31515
  { path: "subcategories", component: SubCategoriesComponent },
30850
31516
  { path: "brands", component: BrandsComponent },
30851
- { path: "tasks", component: TasksComponent }
31517
+ { path: "tasks", component: TasksComponent, data: { moduleKey: 'tasks' } } // Added (F3): Tasks & Notes is its own module
30852
31518
  ];
30853
31519
  class GeneralRoutingModule {
30854
31520
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: GeneralRoutingModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); }
@@ -30870,6 +31536,22 @@ class TenantSettingsComponent {
30870
31536
  this.authService = authService;
30871
31537
  this.dialog = dialog;
30872
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 = '';
30873
31555
  this.roles = [
30874
31556
  { roleName: 'Default', roleID: 1 },
30875
31557
  ];
@@ -31029,18 +31711,42 @@ class TenantSettingsComponent {
31029
31711
  this.loadData();
31030
31712
  this.loadTenants();
31031
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
+ }
31032
31737
  loadData() {
31033
31738
  this.dataService.CallApi({ url: 'tenants/meta/x' }, "").subscribe((apiResponse) => {
31034
31739
  this.currTenant = apiResponse.data.tenant;
31035
31740
  this.currentTenantID = apiResponse.data.currentTenantID;
31036
- this.plan = apiResponse.data.plan;
31037
31741
  this.ownTenant = apiResponse.data.ownTenant;
31038
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
31039
31744
  });
31040
31745
  }
31041
31746
  loadTenants() {
31042
31747
  this.dataService.CallApi({ url: 'tenants/meta_tenants/x' }, "").subscribe((apiResponse) => {
31043
31748
  this.tenants = apiResponse.data.tenants;
31749
+ this.setWhere(); // Added: the organisation names live on this response, so the line cannot resolve before it
31044
31750
  });
31045
31751
  }
31046
31752
  updateTenant(x) {
@@ -31080,11 +31786,11 @@ class TenantSettingsComponent {
31080
31786
  this.authService.logoff();
31081
31787
  }
31082
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 }); }
31083
- 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"] }] }); }
31084
31790
  }
31085
31791
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: TenantSettingsComponent, decorators: [{
31086
31792
  type: Component,
31087
- 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"] }]
31088
31794
  }], ctorParameters: () => [{ type: DataServiceLib }, { type: MessageService }, { type: AuthService }, { type: i4.MatDialog }, { type: ApiErrorService }] });
31089
31795
 
31090
31796
  class TenantsComponent {
@@ -32725,8 +33431,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
32725
33431
  args: [{ selector: 'spa-notifications-config', standalone: false, template: "<spa-page [config]=\"pageConfig\"></spa-page>\n" }]
32726
33432
  }], ctorParameters: () => [{ type: DataServiceLib }] });
32727
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.
32728
33440
  const WORKFLOW_ROUTES = [
32729
- { path: "approvals", component: ApprovalsComponent },
33441
+ { path: "approvals", component: ApprovalsComponent, data: { moduleKey: 'approvals' } }, // Added (F3): the one workflow page that is a module
32730
33442
  { path: "approvals-config", component: ApprovalsConfigComponent },
32731
33443
  { path: "notifications", component: NotificationsComponent },
32732
33444
  { path: "appmodels", component: AppModelsComponent },
@@ -32957,26 +33669,36 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
32957
33669
 
32958
33670
  // All domain routes nested under their module path
32959
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.
32960
33682
  const routes = [
32961
- { path: 'user', children: USER_ROUTES },
32962
- { path: 'admin', children: ADMIN_ROUTES },
32963
- { path: 'accounting', children: ACCOUNTING_ROUTES },
32964
- { path: 'inventory', children: INVENTORY_ROUTES },
32965
- { path: 'sales', children: SALES_ROUTES },
32966
- { path: 'purchasing', children: PURCHASING_ROUTES },
32967
- { path: 'hr', children: HR_ROUTES },
32968
- { path: 'payroll', children: PAYROLL_ROUTES },
32969
- { path: 'manufacturing', children: MANUFACTURING_ROUTES },
32970
- { path: 'loans', children: LOANS_ROUTES },
32971
- { path: 'general', children: GENERAL_ROUTES },
32972
- { path: 'tenancy', children: TENANCY_ROUTES },
32973
- { path: 'workflow', children: WORKFLOW_ROUTES },
32974
- { path: 'overview', children: OVERVIEW_ROUTES },
32975
- { path: 'fixed-assets', children: ASSETS_ROUTES }, // Changed: Added Fixed Assets module
32976
- { 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
32977
33699
  { path: 'day-book', component: DayBookComponent }, // Added: Day Book page — home/day-book, renders empty state when unconfigured
32978
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
32979
- { 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
32980
33702
  ];
32981
33703
  class SpaHomeRoutingModule {
32982
33704
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: SpaHomeRoutingModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); }
@@ -33576,5 +34298,5 @@ const ALSQUARE_SVG_WHITE = `<svg viewBox="0 0 80 80" fill="none" xmlns="http://w
33576
34298
  * Generated bundle index. Do not edit.
33577
34299
  */
33578
34300
 
33579
- 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 };
33580
34302
  //# sourceMappingURL=tin-spa.mjs.map