tin-spa 20.14.21 → 20.14.26

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.
@@ -30,9 +30,11 @@ import * as i6 from '@angular/material/chips';
30
30
  import { MatChipsModule } from '@angular/material/chips';
31
31
  import * as i1$4 from '@angular/cdk/layout';
32
32
  import * as i1$5 from '@angular/service-worker';
33
+ import * as i2$2 from '@angular/forms';
34
+ import { FormControl, Validators, NG_VALUE_ACCESSOR, FormsModule, ReactiveFormsModule } from '@angular/forms';
33
35
  import * as i19 from '@angular/material/card';
34
36
  import { MatCardModule } from '@angular/material/card';
35
- import * as i5 from '@angular/material/progress-bar';
37
+ import * as i6$1 from '@angular/material/progress-bar';
36
38
  import { MatProgressBarModule } from '@angular/material/progress-bar';
37
39
  import * as i14 from '@angular/material/table';
38
40
  import { MatTableDataSource, MatTableModule } from '@angular/material/table';
@@ -40,8 +42,6 @@ import * as i15$1 from '@angular/material/paginator';
40
42
  import { MatPaginatorModule } from '@angular/material/paginator';
41
43
  import * as i7 from '@angular/material/tooltip';
42
44
  import { MatTooltipModule } from '@angular/material/tooltip';
43
- import * as i2$2 from '@angular/forms';
44
- import { FormControl, Validators, NG_VALUE_ACCESSOR, FormsModule, ReactiveFormsModule } from '@angular/forms';
45
45
  import * as i4$2 from '@angular/material/input';
46
46
  import { MatInputModule } from '@angular/material/input';
47
47
  import * as i4$3 from '@angular/material/checkbox';
@@ -50,9 +50,9 @@ import * as i7$1 from '@angular/material/select';
50
50
  import { MatSelect, MatSelectModule } from '@angular/material/select';
51
51
  import * as i13 from '@angular/material/autocomplete';
52
52
  import { MatAutocompleteModule } from '@angular/material/autocomplete';
53
- import * as i5$1 from '@angular/material/datepicker';
53
+ import * as i5 from '@angular/material/datepicker';
54
54
  import { MatDatepickerModule } from '@angular/material/datepicker';
55
- import * as i5$2 from '@kolkov/angular-editor';
55
+ import * as i5$1 from '@kolkov/angular-editor';
56
56
  import { AngularEditorModule } from '@kolkov/angular-editor';
57
57
  import * as i17 from '@angular/material/list';
58
58
  import { MatListModule } from '@angular/material/list';
@@ -72,9 +72,9 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
72
72
  import { MatRadioModule } from '@angular/material/radio';
73
73
  import { MatSliderModule } from '@angular/material/slider';
74
74
  import { MatSortModule } from '@angular/material/sort';
75
- import * as i5$3 from '@angular/material/stepper';
75
+ import * as i5$2 from '@angular/material/stepper';
76
76
  import { MatStepperModule } from '@angular/material/stepper';
77
- import * as i5$4 from '@angular/material/tabs';
77
+ import * as i3$2 from '@angular/material/tabs';
78
78
  import { MatTabsModule } from '@angular/material/tabs';
79
79
  import * as i19$1 from '@angular/material/sidenav';
80
80
  import { MatSidenavModule } from '@angular/material/sidenav';
@@ -1712,6 +1712,421 @@ function provideTinSpaRuntime(config) {
1712
1712
  ]);
1713
1713
  }
1714
1714
 
1715
+ class ApiErrorService {
1716
+ constructor(messageService) {
1717
+ this.messageService = messageService;
1718
+ // One dialog per KIND per window. Keyed by kind rather than by URL on purpose: when the backend goes down,
1719
+ // twenty different URLs fail for one single reason, and the user needs to be told that reason once.
1720
+ this.lastShownAt = new Map();
1721
+ /** How long a given kind stays suppressed after being shown. The user described "the same 10 seconds or so". */
1722
+ this.suppressWindowMs = 10000;
1723
+ // Added: submits dedupe over a much shorter window than background reads. Long enough to swallow a
1724
+ // double-click or a duplicated emission, short enough that a user who reads the dialog, dismisses it and
1725
+ // deliberately tries again is told again rather than met with silence.
1726
+ this.submitSuppressWindowMs = 1500;
1727
+ //================ Application-level failures (HTTP 200 + success:false) ================
1728
+ // Added: fragments that mark a message as machine-authored rather than human-authored. Matched
1729
+ // case-insensitively against the whole message. The list is deliberately about IMPLEMENTATION LEAKS, not
1730
+ // about severity — a leaked identifier is useless to the user however serious the underlying fault is.
1731
+ this.opaqueMarkers = [
1732
+ // NOTE: matched as a SUBSTRING, never by equality. TinWeb's catch-all now appends a correlation code —
1733
+ // "Error processing request (reference K7M2QP4T)" — so an equality test would classify every catch-all
1734
+ // failure as a business message and show the raw text, which is worse than the behaviour this replaces.
1735
+ 'error processing request', // TinWeb BaseController/BaseController2/BaseService catch-all — by far the most common
1736
+ 'invalid column name', // SQL leaking through after a schema/migration drift
1737
+ 'sqlexception',
1738
+ 'object reference not set',
1739
+ 'an error occurred while', // EF Core's standard preamble ("...while updating the entries")
1740
+ 'inner exception',
1741
+ 'stack trace',
1742
+ 'the underlying provider failed',
1743
+ 'index was outside the bounds',
1744
+ 'timeout expired',
1745
+ 'exception', // catches DbUpdateException, InvalidOperationException, ... by suffix
1746
+ // Raw SqlException text.
1747
+ //
1748
+ // Changed: this block used to say "TinCore's BaseRepository.SetErrorResp returns ex.InnerException.Message
1749
+ // verbatim on HTTP 200 ... These never carry a correlation reference". BOTH halves of that are now FALSE
1750
+ // and were left standing after the backend was fixed underneath them. `BaseRepository.BuildSafeMessage`
1751
+ // (`Sharp/TinCore/Repositories/BaseRepository.cs:382-400`, read 2026-08-09) now logs the real exception
1752
+ // out-of-band and returns `ClassifyFailure(ex) + " (reference X)"` — a friendly sentence WITH a reference.
1753
+ // So the schema leak this list was written against no longer comes from there, and the failures that
1754
+ // reach us from there do carry a code. Believing the old comment would lead the next reader to delete
1755
+ // these markers as obsolete, which is exactly the wrong conclusion — see below for why they still matter.
1756
+ //
1757
+ // These fragments are still LIVE, for two independent reasons, both verified rather than assumed:
1758
+ // 1. Other server paths still return raw exception text on HTTP 200 and never went through
1759
+ // BaseRepository at all — e.g. `TinWeb/Services/Agent/AgentApiService.cs:391` and `:480` return
1760
+ // `ex.InnerException?.Message ?? ex.Message` verbatim, which for a bad foreign key is precisely
1761
+ // "The INSERT statement conflicted with the FOREIGN KEY constraint ...". The accounting services
1762
+ // leak the same way but one level UP — `InvoiceService:311/347/681/1007/1108`,
1763
+ // `CreditNoteService:184`, `RevenueScheduleService:95/248` return the OUTER `ex.Message`, which for a
1764
+ // DbUpdateException is "An error occurred while updating the entries. See the inner exception for
1765
+ // details." That is why BOTH shapes are covered above: 'an error occurred while' and 'inner exception'
1766
+ // catch the outer form, the SQL phrases below catch the inner one. Neither list is redundant.
1767
+ // 2. TinCore is consumed as a PACKAGE (`TinWeb.csproj` pins `TinCore 10.0.12`, the release that carries
1768
+ // this fix), so a consumer app on an older published TinCore still has the verbatim-`ex.Message`
1769
+ // behaviour. tin-spa ships to four SPAs that are not upgraded in step, and a classifier that assumes
1770
+ // the newest backend would leak schema on every one that lags. Their own APIs leak independently of
1771
+ // TinCore too — `Sharp/{Shift,Pulse,Piglet,Grip,Test}` carry ~39 hand-written `Message = ex.Message`
1772
+ // style returns that never touch BaseRepository at all.
1773
+ //
1774
+ // Two entries are kept WITHOUT a live producer, stated plainly rather than quietly: 'stack trace' and
1775
+ // 'sqlexception' only appear in a full `ex.ToString()`, and no TinWeb/TinCore path puts one in a client
1776
+ // Message today (checked 2026-08-09). They stay because a deny-list entry costs one `includes()` and the
1777
+ // failure mode of removing one is a schema dump on a user's screen. Cheap insurance, not an oversight.
1778
+ //
1779
+ // Each fragment below is SQL Server's own wording. None of them can occur in a hand-authored message like
1780
+ // "Registration number already in use" or "Customer not found", which is why they are matched as phrases
1781
+ // rather than on single words such as "duplicate" or "constraint" that a human might legitimately write.
1782
+ // Re-checked one by one against what the server produces today: none of the six friendly sentences
1783
+ // `ClassifyFailure` can now return matches any marker here, so the fix upstream does not collide with
1784
+ // this list — the good sentences stay 'business' and are shown, exactly as intended.
1785
+ 'statement conflicted with the', // "The INSERT statement conflicted with the FOREIGN KEY constraint ..."
1786
+ 'foreign key constraint',
1787
+ 'unique key constraint',
1788
+ 'primary key constraint',
1789
+ 'check constraint',
1790
+ 'cannot insert the value null',
1791
+ 'string or binary data would be truncated',
1792
+ 'violation of ', // "Violation of PRIMARY KEY constraint 'PK_...'"
1793
+ 'duplicate key', // "Cannot insert duplicate key row in object ..."
1794
+ // Added 2026-08-09, found by re-checking this list against the backend rather than against the old comment.
1795
+ // `BaseRepository.ClassifyFailure` maps six recognisable SQL failures to genuinely useful sentences ("A
1796
+ // record with these details already exists.", "A required value is missing.") — those are real business
1797
+ // messages and correctly stay toasts. Its SEVENTH return is the unrecognised fall-through, and it is a
1798
+ // different animal: "Could not complete the operation. (reference X)" is human-SHAPED but carries exactly
1799
+ // as much information as "Error processing request" — which is the first entry in this list. It was being
1800
+ // classified 'business' purely because it is grammatical.
1801
+ //
1802
+ // That misclassification cost the user the one thing that failure has going for it. `BuildSafeMessage`
1803
+ // deliberately logs the real exception and mints a reference precisely BECAUSE it could not say what went
1804
+ // wrong — so this is the case where quoting the code to support is the whole remedy. As a toast the code
1805
+ // is stripped (uncopyable in 5 seconds, see presentAppFailure) and the user is left with a shrug. As a
1806
+ // dialog it renders as the footnote the message dialog already has, which is where a reference belongs.
1807
+ // Verified unique: this exact phrase occurs nowhere else in Sharp/** or in tin-spa, so it cannot shadow a
1808
+ // hand-authored message.
1809
+ 'could not complete the operation',
1810
+ ];
1811
+ }
1812
+ // Classification is by status code first, because that is the only signal that is reliable across browsers.
1813
+ // `statusText === 'Unknown Error'` is kept as a secondary network signal since some browsers report a failed
1814
+ // connection that way while still surfacing status 0.
1815
+ classify(error) {
1816
+ const status = error?.status ?? 0;
1817
+ // status 0 means the request never reached a server: offline, DNS failure, connection refused, CORS block.
1818
+ // This is the single most common case for a field user and the one that was rendering blank.
1819
+ if (status === 0 || error?.statusText === 'Unknown Error') {
1820
+ return {
1821
+ kind: 'network',
1822
+ title: 'No connection',
1823
+ message: `We couldn't reach the server. Please check your internet connection and try again.\n\n` +
1824
+ `If your connection is working, the service may be temporarily unavailable — your work is not lost, and you can try again in a moment.`,
1825
+ };
1826
+ }
1827
+ if (status === 408 || status === 504) {
1828
+ return {
1829
+ kind: 'timeout',
1830
+ title: 'Taking too long',
1831
+ message: `The server is taking longer than expected to respond.\n\nPlease wait a moment and try again.`,
1832
+ };
1833
+ }
1834
+ // Added: 429 Too Many Requests. Before this branch existed a 429 reached the 'unknown' fall-through at the
1835
+ // bottom of this method and told the user "Please try again" — inviting the exact retry the server just
1836
+ // refused. On a token-bucket or sliding-window limiter every one of those retries extends the block rather
1837
+ // than clearing it, so the friendly-sounding advice actively made the situation worse.
1838
+ //
1839
+ // Two further things this deliberately does NOT do:
1840
+ // * it does not read like the user's fault. A 429 is usually the app being chatty, or a shared office IP
1841
+ // hitting one limit for everyone — nothing the person at the keyboard did.
1842
+ // * it does not say "contact your administrator". This clears on its own; sending them to support for a
1843
+ // condition that resolves in under a minute wastes their time and support's.
1844
+ // The one instruction is to WAIT, with a duration when the server told us one.
1845
+ if (status === 429) {
1846
+ return {
1847
+ kind: 'ratelimited',
1848
+ title: 'Busy right now',
1849
+ message: `The server is handling more requests than it can take right now, so it has asked us to pause for a moment. Nothing was lost, and nothing is wrong with your work.\n\n` +
1850
+ `Please wait ${this.retryAfterText(error)} before trying that again — trying repeatedly makes the wait longer, not shorter.`,
1851
+ };
1852
+ }
1853
+ if (status === 503) {
1854
+ return {
1855
+ kind: 'unavailable',
1856
+ title: 'Service unavailable',
1857
+ message: `The service is temporarily unavailable, usually because it is being updated.\n\nPlease try again in a few minutes.`,
1858
+ };
1859
+ }
1860
+ // A 400 is almost never the user's doing — the app sent a payload the server rejected. Saying "check your
1861
+ // input" would send them hunting for a mistake they did not make, so this points at support instead.
1862
+ if (status === 400 || status === 422) {
1863
+ return {
1864
+ kind: 'badrequest',
1865
+ title: 'Something went wrong',
1866
+ message: `We couldn't complete that action because of a technical problem in the app — not anything you did wrong.\n\n` +
1867
+ `Please try again. If it keeps happening, contact your administrator so it can be looked into.`,
1868
+ };
1869
+ }
1870
+ if (status === 403) {
1871
+ return {
1872
+ kind: 'forbidden',
1873
+ title: 'Not allowed',
1874
+ message: `You don't have permission to do that.\n\nIf you think you should, please contact your administrator.`,
1875
+ };
1876
+ }
1877
+ if (status === 404) {
1878
+ return {
1879
+ kind: 'notfound',
1880
+ title: 'Not found',
1881
+ message: `We couldn't find what you were looking for. It may have been moved or deleted by someone else.\n\n` +
1882
+ `Try refreshing the page. If it keeps happening, contact your administrator.`,
1883
+ };
1884
+ }
1885
+ if (status >= 500) {
1886
+ return {
1887
+ kind: 'server',
1888
+ title: 'Something went wrong',
1889
+ message: `The server ran into a problem while handling your request.\n\n` +
1890
+ `Please try again shortly. If it keeps happening, contact your administrator.`,
1891
+ };
1892
+ }
1893
+ return {
1894
+ kind: 'unknown',
1895
+ title: 'Something went wrong',
1896
+ message: `We couldn't complete that action.\n\nPlease try again. If it keeps happening, contact your administrator.`,
1897
+ };
1898
+ }
1899
+ // Added: turns a Retry-After header into something a person can act on. RFC 9110 allows two forms — a delay
1900
+ // in seconds ("120") or an HTTP-date ("Wed, 21 Oct 2026 07:28:00 GMT") — and both are handled because which
1901
+ // one arrives depends on the proxy in front of the API, not on our own code.
1902
+ //
1903
+ // Seconds are rounded to the nearest 5 (and never below 5) rather than quoted exactly: "wait about 30 seconds"
1904
+ // is followed, "wait 27 seconds" invites someone to watch a clock and retry one second early. When the header
1905
+ // is missing or unparseable the phrasing stays deliberately vague — an honest approximation beats a precise
1906
+ // number we invented, and a limiter we cannot see the window of is exactly when not to promise one.
1907
+ retryAfterText(error) {
1908
+ const seconds = this.retryAfterSeconds(error); // Changed: parsing moved to retryAfterSeconds() so the sync layer can share it — see below
1909
+ if (!(seconds > 0))
1910
+ return 'a minute or so';
1911
+ if (seconds <= 90)
1912
+ return `about ${Math.max(5, Math.round(seconds / 5) * 5)} seconds`;
1913
+ const minutes = Math.round(seconds / 60);
1914
+ return `about ${minutes} minute${minutes === 1 ? '' : 's'}`;
1915
+ }
1916
+ // Added: the RAW parse, split out of retryAfterText() and made public so OfflineService can wait exactly as
1917
+ // long as the server asked instead of guessing from an attempt count. It is deliberately ONE parser: the
1918
+ // header has two legal forms (RFC 9110 delta-seconds and HTTP-date) and a second implementation of that would
1919
+ // drift from this one the first time either is touched.
1920
+ //
1921
+ // Returns 0 — never a negative, never NaN — when the header is absent, malformed, or already in the past.
1922
+ // Callers treat 0 as "the server said nothing", so a stale HTTP-date can never shorten a backoff to zero.
1923
+ retryAfterSeconds(error) {
1924
+ const raw = (error?.headers?.get ? error.headers.get('Retry-After') : null)?.trim() ?? '';
1925
+ let seconds = 0;
1926
+ if (/^\d+$/.test(raw)) {
1927
+ seconds = parseInt(raw, 10);
1928
+ }
1929
+ else if (raw) {
1930
+ const at = Date.parse(raw);
1931
+ if (!isNaN(at))
1932
+ seconds = Math.round((at - Date.now()) / 1000);
1933
+ }
1934
+ return seconds > 0 ? seconds : 0;
1935
+ }
1936
+ /** True when this kind was already shown inside the suppression window — i.e. the user has already been told. */
1937
+ // Changed: window is now a parameter (defaulting to the existing suppressWindowMs, so present() is unaffected)
1938
+ // because a submit failure needs a much shorter one than a burst of background reads — see presentAppFailure().
1939
+ isSuppressed(kind, windowMs = this.suppressWindowMs) {
1940
+ const last = this.lastShownAt.get(kind);
1941
+ return last != null && (Date.now() - last) < windowMs;
1942
+ }
1943
+ // Presents at most ONE message per kind per window. The technical detail still goes to the console, where a
1944
+ // developer wants it, so nothing is lost by keeping it out of the user's message.
1945
+ //
1946
+ // Changed: this comment used to read "at most ONE dialog per kind", and that stopped being true the moment
1947
+ // 'ratelimited' gained its own branch below and went to a toast instead. What is uniform here is the
1948
+ // SUPPRESSION — one message per kind per window — not the surface. The SURFACE is chosen per kind, and the
1949
+ // 'ratelimited' block states the test for choosing it: a failure that is transient, self-clearing and
1950
+ // presents the user with no decision to make does not earn a modal. Read that argument before adding a
1951
+ // second exception; it is about weight, not about the specific status code.
1952
+ present(error) {
1953
+ const info = this.classify(error);
1954
+ // Added: a 5xx from TinWeb's exception middleware now carries "(reference X)" in its body Message —
1955
+ // pull it out so the dialog can quote it as a footnote, exactly as presentAppFailure() does for the
1956
+ // 200-with-success:false catch-alls. Only the 'server' kind can carry one; a transport failure has no
1957
+ // body at all (error.error is a ProgressEvent), which extractReference() tolerates as null.
1958
+ if (info.kind === 'server') {
1959
+ const body = error?.error;
1960
+ info.reference = this.extractReference(body?.Message ?? body?.message) ?? undefined;
1961
+ }
1962
+ console.error(`[tin-spa] API error (${info.kind})`, { status: error?.status, url: error?.url, reference: info.reference, error }); // Changed: log the reference so console and server log line tie together
1963
+ if (this.isSuppressed(info.kind))
1964
+ return info; // already told them — a second dialog adds nothing
1965
+ this.lastShownAt.set(info.kind, Date.now());
1966
+ // Changed: a 429 gets a TOAST, not the modal every other kind gets. It is the only kind here that is
1967
+ // guaranteed transient and self-clearing — the server has already said, in Retry-After, exactly when it
1968
+ // will stop refusing. A modal is the wrong weight for that in three specific ways:
1969
+ //
1970
+ // * It BLOCKS. The user must dismiss a dialog to get back to work they were not prevented from doing —
1971
+ // nothing was rejected except one request, and the next one may well succeed.
1972
+ // * It DEMANDS a decision for a condition with no decision to make. The only correct action is to wait,
1973
+ // and waiting is what happens if the app says nothing at all.
1974
+ // * During an OFFLINE DRAIN it is actively wrong. The sync layer leaves the op queued and retries on its
1975
+ // own backoff, so a modal saying something went wrong directly contradicts the sync indicator in the
1976
+ // toolbar, which is showing work still pending and perfectly healthy.
1977
+ //
1978
+ // The suppression above is deliberately shared and applied BEFORE this branch: a burst of throttled calls —
1979
+ // which is the normal shape of a 429, since whatever tripped the limit is usually firing repeatedly — still
1980
+ // yields exactly ONE toast per window, not a stack of them.
1981
+ //
1982
+ // The toast text is NOT info.message. That is dialog copy: three sentences over two paragraphs, written to
1983
+ // be read at leisure in a 600px box. This surface is a 5-second snackbar, so the copy is compressed to the
1984
+ // three things that are load-bearing and nothing else — that the cause is the server and not them, roughly
1985
+ // how long to leave it, and that their work survived. info.message is still returned unchanged for callers.
1986
+ if (info.kind === 'ratelimited') {
1987
+ this.messageService.toast(`The server is busy — please wait ${this.retryAfterText(error)} before trying again. Nothing was lost.`);
1988
+ return info;
1989
+ }
1990
+ this.messageService.errorWithSubject(info.title, info.message, info.reference); // Changed: pass the reference through so the dialog renders the quiet footnote
1991
+ return info;
1992
+ }
1993
+ // Added: splits the server's message into 'opaque' (tells the user nothing, must not be shown raw) and
1994
+ // 'business' (a deliberate sentence the backend author wrote for a human, genuinely worth reading).
1995
+ //
1996
+ // Written as a deny-list rather than an allow-list on purpose: business messages are open-ended and
1997
+ // app-specific, so there is nothing to enumerate, whereas the ways a .NET exception leaks are few and stable.
1998
+ // The consequence of the default is the safe one — an unrecognised message is treated as business and shown,
1999
+ // so a real message is never swallowed; only recognisably technical text is replaced.
2000
+ classifyMessage(message) {
2001
+ const text = this.stripReference(message); // Changed: classify the SENTENCE, not the sentence plus the server's appended "(reference X)" — see below
2002
+ // Changed: the reference is removed before classifying for two concrete reasons, neither cosmetic.
2003
+ // First, the length test at the bottom is a heuristic about human authorship, and a 22-character machine
2004
+ // suffix should not push a 285-character human message over it. Second, a body that is ONLY "(reference X)"
2005
+ // now strips to empty and falls into the line below rather than toasting a bare code at the user — which
2006
+ // would be the blank-message failure this whole service exists to kill, in a new costume.
2007
+ if (!text)
2008
+ return 'opaque'; // nothing to show — a blank toast is the very failure mode this service exists to kill
2009
+ const lower = text.toLowerCase();
2010
+ if (this.opaqueMarkers.some(marker => lower.includes(marker)))
2011
+ return 'opaque';
2012
+ if (/\bsystem\.[a-z]/i.test(text))
2013
+ return 'opaque'; // a .NET namespace ("System.NullReferenceException")
2014
+ if (/\bat\s+[\w.]+\s*\(/.test(text))
2015
+ return 'opaque'; // a stack frame ("at Namespace.Method(")
2016
+ if (text.length > 300)
2017
+ return 'opaque'; // no human writes a 300-char error; this is a dump
2018
+ return 'business';
2019
+ }
2020
+ // Added: pulls TinWeb's correlation code out of a catch-all message. The backend writes the same code into
2021
+ // its log line as "[ref XXXXXXXX]", so quoting it in the dialog is what lets support jump straight to the log
2022
+ // row instead of hunting by timestamp.
2023
+ //
2024
+ // The alphabet is A-Z/2-9 with 0/O/1/I/L removed (they are misread down a phone line), and the code is 8
2025
+ // characters — but the pattern accepts 6-12 so a future length change does not silently stop matching. The
2026
+ // reference is only ever EXTRACTED here; the rest of the raw message is still discarded, never shown.
2027
+ extractReference(message) {
2028
+ const match = (message ?? '').match(/\breference\s+([A-Z2-9]{6,12})\b/i);
2029
+ return match ? match[1].toUpperCase() : null;
2030
+ }
2031
+ // Added: the sibling to extractReference() — it takes the code OUT of the sentence. Nothing is lost by this,
2032
+ // because extractReference() has already read the code off the same string and it still reaches the console
2033
+ // line in presentAppFailure() and, where the surface can hold it, the dialog footnote.
2034
+ //
2035
+ // This exists because the backend changed shape. Every failure from BaseRepository.BuildSafeMessage and from
2036
+ // TinWeb's controller catch-alls now ends in "(reference XXXXXXXX)", so the reference is no longer the rare
2037
+ // case it was when this service was written — it is on the majority of application-level failures, including
2038
+ // the friendly ones. In a DIALOG that code is an asset: the box stays open, it renders as a quiet footnote,
2039
+ // and the user can copy it. In a 5-second toast it is the opposite. Nobody transcribes eight random
2040
+ // characters from a snackbar before it fades, so all it does is push the words that DO matter — "A record
2041
+ // with these details already exists" — further from the eye and make a helpful sentence read like a fault
2042
+ // report. The rule this settles on: the reference belongs on surfaces that persist long enough to copy it.
2043
+ //
2044
+ // Deliberately requires the PARENTHESES rather than matching a bare "reference XXXX" anywhere in the text.
2045
+ // Every server path that appends one — BaseRepository:399, BaseController:190/688, BaseController2:175/219,
2046
+ // BaseService:112/134, ExceptionMiddleware — writes the parenthesised form, so requiring them costs nothing,
2047
+ // and it means a hand-authored sentence that legitimately says "quote reference on the invoice" survives.
2048
+ stripReference(message) {
2049
+ return (message ?? '')
2050
+ .replace(/\s*\(\s*reference\s+[A-Z2-9]{6,12}\s*\)/gi, '')
2051
+ .replace(/\s{2,}/g, ' ')
2052
+ .trim();
2053
+ }
2054
+ // Added: the sibling to present() for failures that arrive as HTTP 200 with success:false.
2055
+ //
2056
+ // Opaque -> the friendly dialog, through the SAME lastShownAt suppression map present() uses, so a burst
2057
+ // (a dashboard firing twenty calls at a broken backend) still yields exactly one dialog.
2058
+ // Business -> the existing toast, unchanged in weight. Short, meaningful, non-blocking.
2059
+ //
2060
+ // Why 'submit' gets its own dedupe key rather than sharing with loads: suppression exists to collapse a BURST
2061
+ // of background calls that all failed for one reason. A create/edit submit is not part of a burst — it is one
2062
+ // deliberate action, and it is the exact case where a missed message leads the user to press the button again
2063
+ // and duplicate the record. Sharing a key would let unrelated background load failures suppress the dialog
2064
+ // for the one interaction that most needs it. Keeping a (short) window on the submit key still collapses a
2065
+ // double-click, while letting a genuine retry a few seconds later speak up again.
2066
+ presentAppFailure(response, context = 'action', url) {
2067
+ const message = response?.message;
2068
+ const failureClass = this.classifyMessage(message);
2069
+ // Always log the raw response and URL, exactly as present() does — the developer loses nothing by the
2070
+ // dialog being friendly, because the technical detail is right here.
2071
+ const reference = this.extractReference(message); // Added: null for SqlException leaks and older backends, which never mint one
2072
+ console.error(`[tin-spa] API failure (${failureClass}/${context})`, { url, message, reference, response }); // Changed: log the reference too, so the browser console and the server log line can be tied together
2073
+ if (failureClass === 'business') {
2074
+ // Changed: dropped the "Error: " prefix that every call site used to prepend. Verified nothing depends on
2075
+ // it — no test in ng-space or any of the four consumer apps asserts on the literal "Error: ". The prefix
2076
+ // only ever restated what the red-tinted message already conveyed, and it pushed the useful words right.
2077
+ //
2078
+ // Changed: the toast now shows the message with "(reference X)" REMOVED. This is the same argument as the
2079
+ // dropped prefix, one step further. Since TinCore 10.0.12 every BaseRepository failure — including the
2080
+ // GOOD ones — arrives as `ClassifyFailure(ex) + " (reference X)"`, so a genuinely useful sentence now
2081
+ // reads "A record with these details already exists. (reference K7M2QP4T)". Twenty-two characters of
2082
+ // machine correlation sit where the eye stops, on a surface that lives five seconds and cannot be copied
2083
+ // from, and they change how the sentence is READ: a helpful instruction starts to look like a fault
2084
+ // report. The code is not lost — extractReference() has already read it off the same string and it is on
2085
+ // the console line above, where the developer who can use it is looking.
2086
+ //
2087
+ // The surface, deliberately, is MessageService.toast EXACTLY AS IT IS — no new variant, no second
2088
+ // argument, nothing added to the toast API. The toast surface belongs to WS-7 and the whole correction
2089
+ // here is which characters are handed to it.
2090
+ //
2091
+ // Safe by construction: classifyMessage() strips the same suffix and returns 'opaque' when what remains
2092
+ // is empty, so a body that was ONLY "(reference X)" never reaches this branch and this can never toast
2093
+ // an empty string.
2094
+ this.messageService.toast(this.stripReference(message));
2095
+ return failureClass;
2096
+ }
2097
+ const kind = context === 'submit' ? 'appfailure-submit' : 'appfailure';
2098
+ if (this.isSuppressed(kind, context === 'submit' ? this.submitSuppressWindowMs : this.suppressWindowMs))
2099
+ return failureClass;
2100
+ this.lastShownAt.set(kind, Date.now());
2101
+ // Copy matches classify() above: name the problem, absolve the user, say what to do next. The submit
2102
+ // variant adds the two things that specifically stop a duplicate — that the data is still there, and that
2103
+ // pressing the button again is not the fix.
2104
+ // Changed: the reference is passed as its own argument rather than appended to the body, so the dialog can
2105
+ // render it as a quiet footnote. It is omitted entirely when there isn't one — a SqlException leak and an
2106
+ // older backend both arrive without a reference, and neither should show a dangling "quote reference" line.
2107
+ if (context === 'submit') {
2108
+ this.messageService.errorWithSubject('Not saved', `We couldn't save that because of a technical problem — not anything you did wrong.\n\n` +
2109
+ `Nothing was saved, and your details are still on the form, so you can try again without re-entering them.\n\n` +
2110
+ `If it keeps happening, please contact your administrator rather than submitting repeatedly, as that can create duplicates.`, reference ?? undefined);
2111
+ }
2112
+ else {
2113
+ this.messageService.errorWithSubject('Something went wrong', `We couldn't complete that action because of a technical problem — not anything you did wrong.\n\n` +
2114
+ `Please try again. If it keeps happening, contact your administrator.`, reference ?? undefined);
2115
+ }
2116
+ return failureClass;
2117
+ }
2118
+ /** Clears suppression so the next failure speaks up again — call after a successful request or reconnect. */
2119
+ reset() {
2120
+ this.lastShownAt.clear();
2121
+ }
2122
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: ApiErrorService, deps: [{ token: MessageService }], target: i0.ɵɵFactoryTarget.Injectable }); }
2123
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: ApiErrorService, providedIn: 'root' }); }
2124
+ }
2125
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: ApiErrorService, decorators: [{
2126
+ type: Injectable,
2127
+ args: [{ providedIn: 'root' }]
2128
+ }], ctorParameters: () => [{ type: MessageService }] });
2129
+
1715
2130
  // Changed: Consolidated SignalR service — single hub connection for both notifications and entity broadcasts
1716
2131
  class SignalRService {
1717
2132
  // Changed: real-time is now driven centrally by tin-spa runtime config (provideTinSpaRuntime).
@@ -1879,11 +2294,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
1879
2294
  }] }] });
1880
2295
 
1881
2296
  class OfflineService {
1882
- constructor(httpService, authService, storage, messageService, signalR, zone, runtimeConfig) {
2297
+ constructor(httpService, authService, storage, messageService, apiErrorService, // Added: shares ONE Retry-After parser with the dialog/toast path
2298
+ signalR, zone, runtimeConfig) {
1883
2299
  this.httpService = httpService;
1884
2300
  this.authService = authService;
1885
2301
  this.storage = storage;
1886
2302
  this.messageService = messageService;
2303
+ this.apiErrorService = apiErrorService;
1887
2304
  this.signalR = signalR;
1888
2305
  this.zone = zone;
1889
2306
  this.runtimeConfig = runtimeConfig;
@@ -1903,6 +2320,14 @@ class OfflineService {
1903
2320
  this.syncStarted = false;
1904
2321
  this.draining = false;
1905
2322
  this.backoffTimer = null;
2323
+ // Added: how long the SERVER asked us to wait, in ms, from the Retry-After on the failure that just halted the
2324
+ // drain — or null when it said nothing. Written by sendOp(), read once by drainLoop() on the very next line
2325
+ // after it returns, so it is never stale: sendOp() clears it on entry, before any I/O.
2326
+ this.serverRetryAfterMs = null;
2327
+ // Added: epoch ms before which no AUTOMATIC drain may send, set only when the SERVER stated a Retry-After.
2328
+ // Never set from the guessed attempt-count backoff — a reconnect drain jumping a guess is harmless and useful;
2329
+ // jumping an instruction is not.
2330
+ this.retryNotBefore = 0;
1906
2331
  this.periodicTimer = null;
1907
2332
  this.recoveryTimer = null;
1908
2333
  }
@@ -1966,6 +2391,7 @@ class OfflineService {
1966
2391
  this.registered$.next(true);
1967
2392
  this.registerReads(config, entry);
1968
2393
  this.registerLookups(config.formConfig, entry);
2394
+ this.registerButtonLookups(config.buttons, entry); // Added: the two-step-create dialog's form lives on a BUTTON, not on config.formConfig — see below
1969
2395
  this.startSync();
1970
2396
  return entry;
1971
2397
  }
@@ -2035,6 +2461,33 @@ class OfflineService {
2035
2461
  this.registerLookups(field.detailsConfig.formConfig, entry, depth + 1);
2036
2462
  });
2037
2463
  }
2464
+ // Added: the TWO-STEP CREATE gap. CLAUDE.md's documented pattern (Invoice, Sales/Purchase Order, Requisition,
2465
+ // Production Recipe/Order, Trip, Rental, Bundle/Check Template, Checklist, Alloc Template — 12 entities) puts the
2466
+ // real create form on `button.detailsConfig.formConfig`, NOT on `tableConfig.formConfig`. registerLookups only ever
2467
+ // walked the latter, so those forms' selects were never in the read seam: the urls were fetched online and thrown
2468
+ // away, and offline the dialog opened with every dropdown empty. Where a select is `required` that is not a
2469
+ // degraded form, it is an UNSUBMITTABLE one — the write queue behind it was unreachable from the UI.
2470
+ // (Observed, WS-1 trips/loads: opening Shift's trip-create dialog ONLINE cached vehicles/trailers/drivers — the
2471
+ // three the TABLE form declares — and not triptemplates or allocTemplates, which only the BUTTON form declares.)
2472
+ //
2473
+ // ⚠️ BOUNDED DELIBERATELY, and the boundary is not arbitrary — it is "does anything else already register this?":
2474
+ // • BUTTON detailsConfig forms ARE walked. A dialog form is not a spa-table; nothing else ever registers it, so
2475
+ // if this does not, nothing does.
2476
+ // • detailsConfig.tableConfigs are NOT walked. A nested tab renders through <spa-table> (tabs.component.html),
2477
+ // so its TableComponent calls offline.register() itself and registers its own reads AND its own lookups under
2478
+ // its OWN entity, cacheLookups allowlist and cacheRows. Walking them here would re-register another entity's
2479
+ // lookups under THIS entity's allowlist — duplicate work, filtered by the wrong config.
2480
+ // • detailsConfig.buttons are NOT walked, and neither are column detailsConfigs: one level of button is what the
2481
+ // documented pattern needs, and each extra level multiplies the walk for configs that share dialogs anyway.
2482
+ // Depth inside each form is unchanged — a button form enters registerLookups at depth 0, exactly like the table's
2483
+ // own form, so its fields' quick-add ("Add New") forms still register and nothing deeper does.
2484
+ // VOLUME: this only makes a url MATCHABLE; a cache entry is written solely when that url is actually fetched while
2485
+ // online (handleOfflineRead), so a registered-but-never-opened dialog costs one Map entry and no storage. Buttons
2486
+ // per table are single digits, and cacheLookups === false / an explicit allowlist still filter every url here.
2487
+ registerButtonLookups(buttons, entry) {
2488
+ buttons?.forEach(b => { if (b.detailsConfig?.formConfig)
2489
+ this.registerLookups(b.detailsConfig.formConfig, entry); });
2490
+ }
2038
2491
  // Added: CACHE KEY DECISION — a paged read is cached under its BASE url, i.e. with `skip`/`take` stripped,
2039
2492
  // NOT under the full url. The paging window is a view parameter over one resource, not a different resource:
2040
2493
  // keying by the full url would mint an unbounded number of IndexedDB entries (take varies per fetch — see
@@ -2190,7 +2643,24 @@ class OfflineService {
2190
2643
  this.meta.put({ key: 'sentinel', value: Date.now() }).catch(() => { });
2191
2644
  return db;
2192
2645
  }
2646
+ // Added: never write an entry that can never be read back. matchRead() is the SOLE read path
2647
+ // (datalib.service.ts CallApi), so a url it does not match is unreachable the moment it is stored — the write
2648
+ // silently succeeds, consumes the origin's storage quota, and leaves the store LOOKING populated while the UI
2649
+ // refuses the view. That is worse than not caching it: it lies to the next person who dumps the store.
2650
+ // How it happened (observed on Shift's Loads grid, WS-1): tileClicked REPLACES config.loadAction wholesale, and
2651
+ // TableComponent.cachePagedWindow() then writes under the swapped url stamped with the TABLE's entity. Three of
2652
+ // the four Loads tiles are same-entity so registerReads registered them; the fourth is `invoices/outstanding/x`,
2653
+ // a FOREIGN entity the same-entity guard deliberately skips — so it was written and could never be served, seen
2654
+ // offline as the blocking 'No connection' modal sitting on top of its own unreachable cached rows.
2655
+ // The fix is the SMALL one on purpose: making it matchable instead would mean this table registering an entity it
2656
+ // does not own, under another entity's cacheLookups/cacheRows and with none of that entity's queue semantics —
2657
+ // a cross-entity read-caching feature, not a bug fix, and the same crossing registerReads' guard already declined.
2658
+ // Guard, not silent tidy: every legitimate writer still passes. handleOfflineRead only calls this after matchRead
2659
+ // matched (and cacheKey() only strips paging, which matchRead's own base-url fallback already tolerates);
2660
+ // cachePagedWindow/applyRealTimeData write the table's registered url, or a per-parent/tile url a prefix covers.
2193
2661
  putRead(url, entity, data, maxRows) {
2662
+ if (!this.matchRead(url))
2663
+ return Promise.resolve(null);
2194
2664
  this.ensureOpen();
2195
2665
  let stored = data;
2196
2666
  if (Array.isArray(data))
@@ -2387,7 +2857,7 @@ class OfflineService {
2387
2857
  if (!this.online$.value) {
2388
2858
  this.zone.run(() => this.online$.next(true)); // TS-20: re-enter Angular zone only on actual state flip (timer ticks run outside the zone)
2389
2859
  if (this.syncStarted)
2390
- setTimeout(() => this.zone.run(() => this.drain()), Math.floor(Math.random() * 5000)); // TS-20: drain (toasts/emissions) must run in-zone even when triggered from an out-of-zone timer path; jittered so a reconnecting fleet doesn't stampede
2860
+ setTimeout(() => this.zone.run(() => this.drain(true)), Math.floor(Math.random() * 5000)); // TS-20: drain (toasts/emissions) must run in-zone even when triggered from an out-of-zone timer path; jittered so a reconnecting fleet doesn't stampede. Changed: auto=true — a reconnect must not jump a server-stated Retry-After
2391
2861
  }
2392
2862
  // Marry the two: whenever ping confirms we're reachable, revive SignalR if its own auto-reconnect gave up.
2393
2863
  // No-op when the hub is already connected, so this is safe to call on every online confirmation.
@@ -2423,10 +2893,10 @@ class OfflineService {
2423
2893
  this.zone.runOutsideAngular(() => {
2424
2894
  this.periodicTimer = setInterval(() => { if (this.online)
2425
2895
  this.hasPending().then(has => { if (has)
2426
- this.zone.run(() => this.drain()); }); }, 60000);
2896
+ this.zone.run(() => this.drain(true)); }); }, 60000); // Changed: auto=true — the 60s sweep must not jump a server-stated Retry-After
2427
2897
  });
2428
2898
  if (this.online)
2429
- this.drain();
2899
+ this.drain(true); // Changed: auto=true — sync starting up is the app's decision, not the user's
2430
2900
  }
2431
2901
  // Added: reclaim work parked by a previous session. The sync engine otherwise only starts when a page happens
2432
2902
  // to register an offline table, so changes captured before a sign-out sat there until the user wandered back
@@ -2441,9 +2911,24 @@ class OfflineService {
2441
2911
  }
2442
2912
  catch { /* nothing parked, or the store isn't available */ }
2443
2913
  }
2444
- async drain() {
2914
+ // Changed: `auto` marks a drain the APP decided to run (reconnect, the 60s sweep, sync start) as opposed to one
2915
+ // the USER asked for. Only automatic drains are held back by a server-stated Retry-After — see retryNotBefore.
2916
+ // It defaults to false so every EXTERNAL caller keeps today's behaviour untouched: the sync tray's "Sync Now"
2917
+ // button (`sync.component.ts:29`) is a deliberate human action, and silently doing nothing when someone presses
2918
+ // a button is precisely the quiet-failure shape this codebase keeps getting bitten by.
2919
+ async drain(auto = false) {
2445
2920
  if (this.draining || !this.online)
2446
2921
  return;
2922
+ // Added: honouring Retry-After ONLY inside scheduleBackoff was not enough, and a 32-op drain proved it — the
2923
+ // retry arrived 9.5s after a 429 that said 20. The backoff timer was armed correctly, but a connectivity flip
2924
+ // (SignalR dropping and reconnecting is routine, and each flip schedules its own drain within 5s of coming
2925
+ // back) simply ran a drain in front of it. The wait has to bind every automatic entry point, not just the one
2926
+ // timer, or the app still hammers a server that told it exactly when to come back.
2927
+ //
2928
+ // Deliberately fail-open: this is in-memory only, so a reload forgets it — and that is correct, because a
2929
+ // reload also forgets the backoff timer, and a gate that outlived its own timer could strand the queue.
2930
+ if (auto && Date.now() < this.retryNotBefore)
2931
+ return;
2447
2932
  this.draining = true;
2448
2933
  const runner = () => this.drainLoop().finally(() => { this.draining = false; });
2449
2934
  const locks = navigator.locks;
@@ -2475,7 +2960,7 @@ class OfflineService {
2475
2960
  op.status = 'pending';
2476
2961
  op.attempts++;
2477
2962
  await this.outbox.put(op);
2478
- this.scheduleBackoff(op.attempts);
2963
+ this.scheduleBackoff(op.attempts, this.serverRetryAfterMs); // Changed: pass the server's own Retry-After, which overrides the attempt-count guess when present
2479
2964
  break; // strict FIFO: a transient failure halts the drain until backoff/reconnect
2480
2965
  }
2481
2966
  if (outcome === 'synced')
@@ -2497,6 +2982,7 @@ class OfflineService {
2497
2982
  const payload = { ...op.payload };
2498
2983
  if (op.refMap && Object.keys(op.refMap).length)
2499
2984
  payload.clientRefs = JSON.stringify(op.refMap); // server resolves parent ClientRef -> real int FK
2985
+ this.serverRetryAfterMs = null; // Added: cleared BEFORE the I/O, so a wait from a previous op can never leak into this one's backoff
2500
2986
  let response;
2501
2987
  try {
2502
2988
  response = await firstValueFrom(this.httpService.Post(op.url, payload));
@@ -2506,6 +2992,14 @@ class OfflineService {
2506
2992
  if (status === 0 || status === 408 || status === 429 || status >= 500) {
2507
2993
  if (status === 0)
2508
2994
  this.reportNetworkFailure();
2995
+ // Added: the response headers were being thrown away here, and with them the one authoritative number in
2996
+ // the whole exchange. A 429 (and a 503) may carry Retry-After — TinWeb's limiter always sends it, computed
2997
+ // from the real window, and exposes it cross-origin — which is the server stating when it will stop
2998
+ // refusing. Guessing from an attempt count instead means we either come back too early, get refused again
2999
+ // and extend the block on a sliding window, or sit idle far longer than we needed to.
3000
+ // Parsed by ApiErrorService so there is exactly ONE implementation of the two legal header forms.
3001
+ const seconds = this.apiErrorService.retryAfterSeconds(err);
3002
+ this.serverRetryAfterMs = seconds > 0 ? seconds * 1000 : null;
2509
3003
  return 'transient';
2510
3004
  }
2511
3005
  return this.finishOp(op, 'failed', err?.message ?? `HTTP ${status}`);
@@ -2565,11 +3059,32 @@ class OfflineService {
2565
3059
  await this.outbox.put(op);
2566
3060
  }
2567
3061
  }
2568
- scheduleBackoff(attempts) {
3062
+ // Changed: takes the server's stated Retry-After (ms, null when it said nothing) and OVERRIDES the computed
3063
+ // backoff with it. Full jitter is right when we are guessing — it spreads a thundering herd across a window we
3064
+ // invented — but it is wrong once the server has told us the answer, because `random * cap` can return almost
3065
+ // zero and send us straight back into a limiter that has not reset. When Retry-After is present we wait the
3066
+ // whole of it, and jitter only the ADDITIONAL second, which keeps clients from returning in lockstep without
3067
+ // ever returning early.
3068
+ //
3069
+ // Clamped to an hour to match the clamp TinWeb applies to the header it sends, so a broken proxy inventing a
3070
+ // Retry-After of a year cannot strand the outbox. A stated wait LONGER than the old 5-minute cap is honoured
3071
+ // rather than capped at 5 minutes: coming back before the server said to only earns another refusal.
3072
+ scheduleBackoff(attempts, serverRetryAfterMs = null) {
2569
3073
  if (this.backoffTimer)
2570
3074
  clearTimeout(this.backoffTimer);
2571
- const cap = Math.min(5000 * Math.pow(2, attempts), 300000); // exponential 5s·2^n capped 5min, full jitter
2572
- this.backoffTimer = setTimeout(() => this.drain(), Math.floor(Math.random() * cap));
3075
+ let delay;
3076
+ if (serverRetryAfterMs && serverRetryAfterMs > 0) {
3077
+ delay = Math.min(serverRetryAfterMs, 3600000) + Math.floor(Math.random() * 1000); // obey the server, plus <=1s of de-sync jitter
3078
+ this.retryNotBefore = Date.now() + delay; // Added: binds the reconnect and periodic drains too, not just this timer
3079
+ }
3080
+ else {
3081
+ const cap = Math.min(5000 * Math.pow(2, attempts), 300000); // exponential 5s·2^n capped 5min, full jitter
3082
+ delay = Math.floor(Math.random() * cap);
3083
+ this.retryNotBefore = 0; // a guessed backoff never blocks another trigger — only a server instruction does
3084
+ }
3085
+ // This timer IS the scheduled resume, so it lifts its own gate rather than being held by it — a millisecond of
3086
+ // timer skew must not turn the wait into a permanent stall.
3087
+ this.backoffTimer = setTimeout(() => { this.retryNotBefore = 0; this.drain(); }, delay);
2573
3088
  }
2574
3089
  //================ Sync-tray actions ================
2575
3090
  async retryOp(opId) {
@@ -2579,6 +3094,7 @@ class OfflineService {
2579
3094
  op.status = 'pending';
2580
3095
  op.attempts = 0;
2581
3096
  op.enqueuedAt = Date.now();
3097
+ this.retryNotBefore = 0; // Added: a deliberate user retry clears any server-stated wait, exactly as it clears attempts
2582
3098
  await this.outbox.put(op);
2583
3099
  this.drain();
2584
3100
  }
@@ -2599,6 +3115,7 @@ class OfflineService {
2599
3115
  op.status = 'pending';
2600
3116
  op.attempts = 0;
2601
3117
  op.enqueuedAt = Date.now();
3118
+ this.retryNotBefore = 0; // Added: same as retryOp — an informed, explicit choice is not an automatic drain
2602
3119
  await this.outbox.put(op);
2603
3120
  this.drain();
2604
3121
  }
@@ -2616,13 +3133,13 @@ class OfflineService {
2616
3133
  hash(v) { let h = 0; for (let i = 0; i < v.length; i++)
2617
3134
  h = ((h << 5) - h + v.charCodeAt(i)) | 0; return Math.abs(h).toString(36); }
2618
3135
  sanitize(v) { return v.replace(/[^a-zA-Z0-9_-]/g, '_').toLowerCase(); }
2619
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: OfflineService, deps: [{ token: HttpService }, { token: AuthService }, { token: StorageService }, { token: MessageService }, { token: SignalRService }, { token: i0.NgZone }, { token: TIN_SPA_RUNTIME_CONFIG, optional: true }], target: i0.ɵɵFactoryTarget.Injectable }); }
3136
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: OfflineService, deps: [{ token: HttpService }, { token: AuthService }, { token: StorageService }, { token: MessageService }, { token: ApiErrorService }, { token: SignalRService }, { token: i0.NgZone }, { token: TIN_SPA_RUNTIME_CONFIG, optional: true }], target: i0.ɵɵFactoryTarget.Injectable }); }
2620
3137
  static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: OfflineService, providedIn: 'root' }); }
2621
3138
  }
2622
3139
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: OfflineService, decorators: [{
2623
3140
  type: Injectable,
2624
3141
  args: [{ providedIn: 'root' }]
2625
- }], ctorParameters: () => [{ type: HttpService }, { type: AuthService }, { type: StorageService }, { type: MessageService }, { type: SignalRService }, { type: i0.NgZone }, { type: undefined, decorators: [{
3142
+ }], ctorParameters: () => [{ type: HttpService }, { type: AuthService }, { type: StorageService }, { type: MessageService }, { type: ApiErrorService }, { type: SignalRService }, { type: i0.NgZone }, { type: undefined, decorators: [{
2626
3143
  type: Optional
2627
3144
  }, {
2628
3145
  type: Inject,
@@ -3194,6 +3711,7 @@ class DataServiceLib {
3194
3711
  this.capPayrollRuns = new CapItem;
3195
3712
  this.capCommissionConfigs = new CapItem; // Changed: Added for Commission module
3196
3713
  this.capCommissionEntries = new CapItem; // Changed: Added for Commission module
3714
+ this.capCommissionStatement = new CapItem; // Added (Phase 6): Driver Commission Statement (cap86)
3197
3715
  this.capSalaryAdvances = new CapItem; // Changed: Added for Salary Advance module
3198
3716
  this.capOvertimeEntries = new CapItem; // Changed: Added for Overtime module
3199
3717
  this.capApprovals = new CapItem;
@@ -4209,7 +4727,7 @@ class DataServiceLib {
4209
4727
  this.capPayroll.display = "Payroll";
4210
4728
  this.capPayroll.moduleKey = "payroll"; // Added (Setup v3)
4211
4729
  this.capPayroll.icon = "payments";
4212
- this.capPayroll.capSubItems = [this.capPayrollDashboard, this.capSalaryStructures, this.capStatutoryDeductions, this.capPayrollRuns, this.capCommissionConfigs, this.capCommissionEntries, this.capSalaryAdvances, this.capOvertimeEntries]; // Changed: Added payroll dashboard as first item
4730
+ this.capPayroll.capSubItems = [this.capPayrollDashboard, this.capSalaryStructures, this.capStatutoryDeductions, this.capPayrollRuns, this.capCommissionConfigs, this.capCommissionEntries, this.capCommissionStatement, this.capSalaryAdvances, this.capOvertimeEntries]; // Changed (Phase 6): commission statement sits directly under the entries it explains
4213
4731
  this.capPayrollDashboard.name = "cap72"; // Changed: Reuses module cap number for dashboard visibility
4214
4732
  this.capPayrollDashboard.display = "Dashboard";
4215
4733
  this.capPayrollDashboard.link = "home/payroll/dashboard";
@@ -4236,6 +4754,14 @@ class DataServiceLib {
4236
4754
  this.capCommissionEntries.display = "Commission Entries";
4237
4755
  this.capCommissionEntries.link = "home/payroll/commission-entries";
4238
4756
  this.capCommissionEntries.icon = "request_quote";
4757
+ // Added (Phase 6): the Driver Commission Statement — cap86, its own capability rather than cap77's, because
4758
+ // it is the surface that GENERATES a period as well as reporting it, and because reading a whole period's
4759
+ // commission across every driver is a wider grant than working one entry. Deliberately in NO role template:
4760
+ // it is registered and reachable, and someone has to grant it on purpose.
4761
+ this.capCommissionStatement.name = "cap86";
4762
+ this.capCommissionStatement.display = "Commission Statement";
4763
+ this.capCommissionStatement.link = "home/payroll/commission-statement";
4764
+ this.capCommissionStatement.icon = "receipt_long";
4239
4765
  // Changed: Added Salary Advances sidebar item
4240
4766
  this.capSalaryAdvances.name = "cap78";
4241
4767
  this.capSalaryAdvances.display = "Salary Advances";
@@ -5818,7 +6344,12 @@ class InventoryService {
5818
6344
  heroField: 'requisitionID'
5819
6345
  };
5820
6346
  this.requisitionEditButton = { name: 'edit', dialog: true, action: { url: 'requisitions?action=edit', method: 'post' } };
5821
- this.requisitionSubmitButton = { name: 'submit', display: 'Submit Requisition', icon: { name: 'send', color: 'primary' }, action: { url: 'requisitions?action=submit', method: 'post', successMessage: 'Requisition Submitted' }, confirm: { message: 'Submit this requisition for approval?' }, visible: (row) => row.status === 0 };
6347
+ // Changed: `inDialog: true` added. This button lives ONLY in requisitionDetailsConfig.buttons and is absent
6348
+ // from the table's own buttons, and Core.setupButtons filters a details dialog's extraButtons on exactly that
6349
+ // flag — so it rendered NOWHERE. Its two siblings below both carry it. Colour changed from the Material palette
6350
+ // name 'primary', which ButtonService.getButtonColor puts verbatim into [style.color] where it computes to the
6351
+ // inherited grey, to the house CSS keyword 'blue'.
6352
+ this.requisitionSubmitButton = { name: 'submit', display: 'Submit Requisition', icon: { name: 'send', color: 'blue' }, inDialog: true, action: { url: 'requisitions?action=submit', method: 'post', successMessage: 'Requisition Submitted' }, confirm: { message: 'Submit this requisition for approval?' }, visible: (row) => row.status === 0 };
5822
6353
  this.requisitionIssueButton = { name: 'issue', display: 'Issue Items', icon: { name: 'check', color: 'green' }, inDialog: true, action: { url: 'requisitions?action=issue', method: 'post', successMessage: 'Items Issued' }, confirm: { message: 'Issue items for this requisition?' }, visible: (row) => row.pendingApproval === false && row.status === 0 };
5823
6354
  this.requisitionCancelButton = { name: 'cancel', display: 'Cancel Request', icon: { name: 'close', color: 'red' }, inDialog: true, action: { url: 'requisitions?action=cancel', method: 'post', successMessage: 'Requisition Cancelled' }, confirm: { message: 'Cancel this requisition?' }, visible: (row) => row.status === 0 };
5824
6355
  this.requisitionDetailsConfig = {
@@ -6631,6 +7162,54 @@ class AccountingService {
6631
7162
  searchAction: { url: 'invoices/search?action=statement-search', method: 'post' }
6632
7163
  }
6633
7164
  };
7165
+ //--------------------------Open-item statement (customer / supplier)-------------------------
7166
+ // Added: the open-item statement was hand-written markup on the page (its own flex row of totals and its own
7167
+ // <table>). These configs move it onto spa-tiles + spa-table so it looks and behaves like every other page.
7168
+ // The figures the API returns are unrounded sums, hence format: 'money' on every tile.
7169
+ // Added: headline figures for the customer open-item statement
7170
+ this.customerStatementTileConfig = {
7171
+ tiles: [
7172
+ { name: 'totalInvoiced', alias: 'Total Invoiced', icon: 'receipt_long', color: '#2196f3', format: 'money', info: 'Everything invoiced in the period' },
7173
+ { name: 'totalPaid', alias: 'Total Paid', icon: 'payments', color: '#4CAF50', format: 'money', info: 'Receipts allocated against those invoices' },
7174
+ { name: 'totalCredited', alias: 'Total Credited', icon: 'undo', color: '#FF9800', format: 'money', info: 'Credit notes raised in the period' },
7175
+ { name: 'totalOutstanding', alias: 'Total Outstanding', icon: 'account_balance_wallet', format: 'money', color: x => (x?.totalOutstanding > 0 ? '#F44336' : '#4CAF50'), info: 'What the customer still owes' } // red while owing, green once settled — the colour the old hand-written card carried
7176
+ ]
7177
+ };
7178
+ // Added: headline figures for the supplier open-item statement (no credit notes on the purchase side)
7179
+ this.supplierStatementTileConfig = {
7180
+ tiles: [
7181
+ { name: 'totalPurchased', alias: 'Total Purchased', icon: 'shopping_cart', color: '#2196f3', format: 'money', info: 'Everything purchased on credit in the period' },
7182
+ { name: 'totalPaid', alias: 'Total Paid', icon: 'payments', color: '#4CAF50', format: 'money', info: 'Payments made against those purchases' },
7183
+ { name: 'totalOutstanding', alias: 'Total Outstanding', icon: 'account_balance_wallet', format: 'money', color: x => (x?.totalOutstanding > 0 ? '#F44336' : '#4CAF50'), info: 'What is still owed to the supplier' }
7184
+ ]
7185
+ };
7186
+ // Added: the statement lines themselves. Customer and supplier lines have the SAME shape, so one config serves
7187
+ // both — the page clones it per tab so the two grids never share mutable state.
7188
+ this.statementLinesTableConfig = {
7189
+ showFilter: true,
7190
+ flatButtons: true,
7191
+ noDataMessage: 'No open items for the selected period',
7192
+ minColumns: ['date', 'reference', 'outstanding'],
7193
+ columns: [
7194
+ { name: 'date', type: 'date', alias: 'Date' },
7195
+ { name: 'reference', type: 'text', alias: 'Reference' },
7196
+ { name: 'type', type: 'text', alias: 'Type' },
7197
+ { name: 'total', type: 'money', alias: 'Total' },
7198
+ { name: 'paid', type: 'money', alias: 'Paid' },
7199
+ { name: 'outstanding', type: 'money', alias: 'Outstanding' },
7200
+ { name: 'status', type: 'chip', alias: 'Status', // Changed: a chip, NOT type 'select' — a select column renders an editable dropdown, and a statement is read-only
7201
+ colors: [
7202
+ { name: '#A5D6A7', condition: x => x.status == 'Paid' },
7203
+ { name: '#FFD54F', condition: x => x.status == 'Paying' },
7204
+ { name: '#90CAF9', condition: x => x.status == 'Submitted' },
7205
+ { name: '#CE93D8', condition: x => x.status == 'Applied' },
7206
+ { name: '#9E9E9E', condition: x => x.status == 'Written Off' }
7207
+ ]
7208
+ },
7209
+ { name: 'agingBucket', type: 'text', alias: 'Aging' }
7210
+ ],
7211
+ buttons: []
7212
+ };
6634
7213
  //--------------------------Tax Rates-------------------------
6635
7214
  // Changed: Tax Rate form configuration for VAT management
6636
7215
  this.taxRateFormConfig = {
@@ -8339,7 +8918,10 @@ class SalesService {
8339
8918
  heroField: 'salesOrderID'
8340
8919
  };
8341
8920
  this.salesOrderEditButton = { name: 'edit', dialog: true, action: { url: 'salesorders?action=edit', method: 'post' } };
8342
- this.salesOrderConfirmButton = { name: 'confirm', display: 'Confirm Order', icon: { name: 'check_circle', color: 'primary' }, inDialog: true, action: { url: 'salesorders?action=confirm', method: 'post', successMessage: 'Order Confirmed' }, confirm: { message: 'Confirm this order?' }, visible: (row) => row.status === 0 };
8921
+ // Changed: colour was the Material palette name 'primary', which getButtonColor puts verbatim into
8922
+ // [style.color] and which therefore computed to the inherited grey. 'blue' is the house CSS keyword, and it
8923
+ // keeps 'green' meaning the terminal step (Mark Delivered, below).
8924
+ this.salesOrderConfirmButton = { name: 'confirm', display: 'Confirm Order', icon: { name: 'check_circle', color: 'blue' }, inDialog: true, action: { url: 'salesorders?action=confirm', method: 'post', successMessage: 'Order Confirmed' }, confirm: { message: 'Confirm this order?' }, visible: (row) => row.status === 0 };
8343
8925
  this.salesOrderDeliverButton = { name: 'deliver', display: 'Mark Delivered', icon: { name: 'local_shipping', color: 'green' }, inDialog: true, action: { url: 'salesorders?action=deliver', method: 'post', successMessage: 'Order Delivered' }, confirm: { message: 'Mark this order as delivered?' }, visible: (row) => row.status === 1 };
8344
8926
  this.salesOrderDetailsConfig = {
8345
8927
  formConfig: this.salesOrderFormConfig,
@@ -8522,7 +9104,7 @@ class SalesService {
8522
9104
  heroField: 'saleID',
8523
9105
  stepConfig: this.saleStepConfig,
8524
9106
  buttons: [
8525
- { name: 'complete', display: 'Complete Sale', color: 'primary', inDialog: true, action: { url: 'sales?action=complete', method: 'post' }, confirm: { message: 'Complete this sale? This will reduce inventory and create accounting entries.' }, visible: x => x.timing === 0 && x.paymentStatus !== 1 },
9107
+ { name: 'complete', display: 'Complete Sale', color: 'green', inDialog: true, action: { url: 'sales?action=complete', method: 'post' }, confirm: { message: 'Complete this sale? This will reduce inventory and create accounting entries.' }, visible: x => x.timing === 0 && x.paymentStatus !== 1 },
8526
9108
  this.saleRecordPaymentButton
8527
9109
  ]
8528
9110
  };
@@ -9586,35 +10168,133 @@ class PayrollService {
9586
10168
  formConfig: this.employeeSalaryComponentFormConfig,
9587
10169
  };
9588
10170
  //--------------------------Commission Configs-------------------------
10171
+ // Added (Phase 5): a commission rule is a parent with tier children, which is the two-step create shape —
10172
+ // the create dialog captures the rule itself, then the details dialog opens with the Tiers tab. Extracted
10173
+ // before detailsConfig so it can be referenced without a forward reference.
10174
+ this.editCommissionConfigButton = { name: 'edit', dialog: true, inDialog: true, action: { url: 'commissionconfigs?action=edit', method: 'post' } };
9589
10175
  this.commissionConfigFormConfig = {
9590
10176
  security: { allow: [this.dataService.capCommissionConfigs] }, // Added: gate commission config form by commission configs cap
9591
10177
  title: 'Commission Config',
10178
+ // Added (Phase 5): the two rules that make a config silently earn nothing, said out loud rather than left to
10179
+ // be discovered when a period pays 0.00. Both mirror refusals CommissionRuleEvaluator already makes.
10180
+ alertConfig: {
10181
+ compact: true,
10182
+ messages: [
10183
+ { message: 'This rule is scoped to nobody — set an employee, a position, or tick Applies To All Employees, or it can never resolve', type: 'critical',
10184
+ visible: (x) => !!x.commissionConfigID && !x.employeeID && !x.positionID && !x.appliesToAllEmployees
10185
+ },
10186
+ { message: 'Per-distance rates read trip mileage, which is only captured when Tyre Management is enabled — this rule will compute 0.00 until it is', type: 'warn',
10187
+ visible: (x) => x.basis === 4
10188
+ },
10189
+ ]
10190
+ },
9592
10191
  fields: [
9593
- { name: 'employeeID', type: 'select', alias: 'Employee', loadAction: { url: 'employees/list/x' }, required: true },
9594
- { name: 'name', type: 'text', required: true },
9595
- { name: 'metricType', alias: 'Metric Type', type: 'select', loadAction: { url: 'commissionconfigs/list/metric-type' }, required: true },
9596
- { name: 'rateType', alias: 'Rate Type', type: 'select', loadAction: { url: 'commissionconfigs/list/rate-type' }, required: true },
9597
- { name: 'rate', type: 'number', required: true },
10192
+ { name: 'name', type: 'text', required: true, hint: 'How this arrangement is referred to, e.g. "Drivers 5% of load value"' },
9598
10193
  { name: 'isActive', type: 'checkbox', alias: 'Active', defaultValue: true },
10194
+ // Changed (Phase 5): who the rule resolves to. ResolveRules is a ladder — employee rules win, then position
10195
+ // rules, then the tenant-wide rule — and the levels do NOT stack, so only one level is offered at a time.
10196
+ { name: 'scope', type: 'section', alias: 'Scope', collapseOnView: true },
10197
+ { name: 'employeeID', type: 'select', alias: 'Employee', section: 'scope', nullable: true, loadAction: { url: 'employees/list/x' }, requiredCondition: (x) => !x.positionID && !x.appliesToAllEmployees, hint: 'Leave empty to scope by position or across the whole company' },
10198
+ { name: 'positionID', type: 'select', alias: 'Position', section: 'scope', nullable: true, loadAction: { url: 'positions/list/x' }, hidden: (x) => x.employeeID > 0, hint: 'Everyone holding this position, e.g. every Driver' },
10199
+ { name: 'appliesToAllEmployees', type: 'checkbox', alias: 'Applies To All Employees', section: 'scope', hidden: (x) => x.employeeID > 0 || x.positionID > 0 },
10200
+ { name: 'priority', type: 'number', alias: 'Priority', section: 'scope', defaultValue: 0, hint: 'Higher wins when two rules resolve at the same level' },
10201
+ { name: 'customerID', type: 'select', alias: 'Only For Customer', section: 'scope', nullable: true, loadAction: { url: 'customers/list/x' }, hideOnCreate: true },
10202
+ { name: 'serviceItemID', type: 'select', alias: 'Only For Service', section: 'scope', nullable: true, loadAction: { url: 'serviceitems/list/x' }, hideOnCreate: true },
10203
+ { name: 'selfSourcedOnly', type: 'checkbox', alias: 'Self Sourced Loads Only', section: 'scope', hidden: (x) => x.basis === 1 || x.basis === 4, infoMessage: 'Pays the person recorded as Sourced By on the load rather than the driver who carried it. Load-level bases only — a trip has no sourcer.' }, // Changed (Phase 6, browser pass): the hyphen is gone on purpose — every alias is piped through Core.camelToWords, which inserts a space before each capital, so "Self-Sourced" rendered as "Self- Sourced" on screen
10204
+ // Changed (Phase 5): Basis is what the engine actually measures. MetricType below is the superseded column,
10205
+ // kept in step so old rows stay coherent, but nothing computes from it any more.
10206
+ { name: 'rateAndTiers', type: 'section', alias: 'Rate & Tiers' },
10207
+ { name: 'basis', type: 'select', alias: 'Basis', section: 'rateAndTiers', required: true, defaultValue: 1, loadAction: { url: 'commissionconfigs/list/basis' },
10208
+ onSelectChange: (value, data) => {
10209
+ data.metricType = (value === 2 || value === 5) ? 2 : 1; // keep the superseded MetricType consistent with Basis
10210
+ if (value === 1 || value === 4) {
10211
+ data.earnEvent = 1;
10212
+ data.selfSourcedOnly = false;
10213
+ } // trip-level bases: the engine skips any other earn event, and has no sourcer to pay
10214
+ }
10215
+ },
10216
+ { name: 'rateType', alias: 'Rate Type', type: 'select', section: 'rateAndTiers', loadAction: { url: 'commissionconfigs/list/rate-type' }, required: true },
10217
+ { name: 'rate', type: 'number', alias: 'Rate', section: 'rateAndTiers', required: true, suffix: '%', hidden: (x) => x.rateType !== 2, hint: 'Percentage of the measured value. Used for any part of the metric no tier band covers.' },
10218
+ { name: 'rate', type: 'number', alias: 'Rate', section: 'rateAndTiers', required: true, hidden: (x) => x.rateType === 2, hint: 'Amount per unit measured. Used for any part of the metric no tier band covers.' },
10219
+ { name: 'metricType', type: 'select', hidden: true, defaultValue: 1 }, // superseded by Basis; posted only so existing rows keep a valid legacy value
10220
+ // Changed (Phase 5): hidden on create because a money input cannot post "empty" — it posts 0, and a Maximum
10221
+ // of 0 caps every period to nothing. Set deliberately on the saved rule instead of by accident on a new one.
10222
+ { name: 'limits', type: 'section', alias: 'Limits', collapseOnView: true, hideOnCreate: true },
10223
+ { name: 'minimumAmount', type: 'money', alias: 'Minimum Per Period', section: 'limits', hideOnCreate: true, clearContent: true, hint: 'Guaranteed floor. Leave empty for none.' },
10224
+ { name: 'maximumAmount', type: 'money', alias: 'Maximum Per Period', section: 'limits', hideOnCreate: true, clearContent: true, hint: 'Cap. Leave empty for uncapped — a cap of 0 pays nothing.' },
10225
+ // Changed (Phase 5): same reason — a date input posts today, and an Effective From of today silently drops
10226
+ // every load earned earlier in the same month.
10227
+ { name: 'timing', type: 'section', alias: 'Timing', collapseOnView: true },
10228
+ { name: 'earnEvent', alias: 'Earned When', type: 'select', section: 'timing', required: true, defaultValue: 1, loadAction: { url: 'commissionconfigs/list/earn-event' }, readonlyCondition: (x) => x.basis === 1 || x.basis === 4, hint: 'Trip-level bases earn on completion only' },
10229
+ { name: 'effectiveFrom', type: 'date', alias: 'Effective From', section: 'timing', hideOnCreate: true, clearContent: true, hint: 'Empty means the rule has always applied. Work before this date is excluded.' },
10230
+ { name: 'effectiveTo', type: 'date', alias: 'Effective To', section: 'timing', hideOnCreate: true, clearContent: true, hint: 'Empty means the rule is open-ended' },
9599
10231
  ],
9600
10232
  loadAction: { url: 'commissionconfigs/id' },
9601
10233
  heroField: 'commissionConfigID',
9602
10234
  includeAudit: true,
9603
10235
  };
10236
+ //--------------------------Commission Config Tiers (child of CommissionConfig)-------------------------
10237
+ this.commissionConfigTierFormConfig = {
10238
+ security: { allow: [this.dataService.capCommissionConfigs] }, // Added: a tier is gated by its parent rule's cap
10239
+ title: 'Tier Band',
10240
+ fixedTitle: true,
10241
+ fields: [
10242
+ { name: 'thresholdFrom', alias: 'From', type: 'number', required: true, defaultValue: 0, hint: 'Metric value where this band starts' },
10243
+ { name: 'thresholdTo', alias: 'To', type: 'number', hint: 'Leave as 0 for the top, open-ended band' },
10244
+ { name: 'rate', type: 'number', alias: 'Rate', required: true, hint: 'Applied only to the portion of the metric inside this band' },
10245
+ ],
10246
+ loadAction: { url: 'commissionconfigtiers/id' },
10247
+ heroField: 'commissionConfigTierID',
10248
+ };
10249
+ this.commissionConfigTiersTableConfig = {
10250
+ tabTitle: 'Tiers',
10251
+ showFilter: false,
10252
+ elevation: 'none',
10253
+ flatButtons: true,
10254
+ noDataMessage: 'No bands — the flat rate on the rule applies to the whole metric',
10255
+ columns: [
10256
+ { name: 'thresholdFrom', type: 'number', alias: 'From' },
10257
+ { name: 'thresholdTo', type: 'number', alias: 'To',
10258
+ icons: [{ name: 'all_inclusive', color: 'grey', tip: 'Open-ended top band', condition: (row) => row?.isOpenEnded }]
10259
+ },
10260
+ { name: 'rate', type: 'number', alias: 'Rate' },
10261
+ ],
10262
+ buttons: [
10263
+ { name: 'create', display: 'Add Band', dialog: true, action: { url: 'commissionconfigtiers?action=create', method: 'post' } },
10264
+ { name: 'edit', dialog: true, action: { url: 'commissionconfigtiers?action=edit', method: 'post' } },
10265
+ { name: 'delete', dialog: true, action: { url: 'commissionconfigtiers?action=delete', method: 'post' } },
10266
+ ],
10267
+ loadAction: { url: 'commissionconfigtiers/x/x' }, loadCriteria: 'config', loadIDField: 'commissionConfigID',
10268
+ formConfig: this.commissionConfigTierFormConfig,
10269
+ };
10270
+ // Added (Phase 5): defined AFTER the tiers table and BEFORE the view/create buttons, per the two-step create pattern
10271
+ this.commissionConfigDetailsConfig = {
10272
+ formConfig: this.commissionConfigFormConfig,
10273
+ heroField: 'commissionConfigID',
10274
+ buttons: [this.editCommissionConfigButton],
10275
+ tableConfigs: [this.commissionConfigTiersTableConfig],
10276
+ };
10277
+ this.viewCommissionConfigButton = { name: 'view', dialog: true, detailsConfig: this.commissionConfigDetailsConfig };
10278
+ this.createCommissionConfigButton = { name: 'create', display: 'Create', dialog: true, onSuccessButton: this.viewCommissionConfigButton, action: { url: 'commissionconfigs?action=create', method: 'post' } };
9604
10279
  this.commissionConfigsTableConfig = {
9605
10280
  showFilter: true,
9606
10281
  flatButtons: true,
9607
- minColumns: ['employeeName', 'name', 'rate'],
10282
+ minColumns: ['scopeName', 'name', 'rate'],
9608
10283
  columns: [
9609
- { name: 'employeeName', type: 'text', alias: 'Employee' },
10284
+ { name: 'scopeName', type: 'text', alias: 'Applies To', // Changed (Phase 5): employeeName is blank on a position or company-wide rule, which read as an unassigned rule
10285
+ colors: [{ name: 'red', condition: (row) => !row?.employeeID && !row?.positionID && !row?.appliesToAllEmployees }]
10286
+ },
9610
10287
  { name: 'name', type: 'text' },
9611
- { name: 'metricTypeName', type: 'text', alias: 'Metric Type' },
10288
+ { name: 'basisName', type: 'text', alias: 'Basis' }, // Changed (Phase 5): Basis replaced MetricType as what the engine measures
9612
10289
  { name: 'rateTypeName', type: 'text', alias: 'Rate Type' },
9613
10290
  { name: 'rate', type: 'text', alias: 'Rate' },
10291
+ { name: 'tierCount', type: 'text', alias: 'Tiers' },
10292
+ { name: 'earnEventName', type: 'text', alias: 'Earned When' },
9614
10293
  { name: 'isActive', type: 'checkbox', alias: 'Active' },
9615
10294
  ],
9616
10295
  buttons: [
9617
- { name: 'create', display: 'Create', dialog: true, action: { url: 'commissionconfigs?action=create', method: 'post' } },
10296
+ this.createCommissionConfigButton,
10297
+ this.viewCommissionConfigButton,
9618
10298
  { name: 'edit', dialog: true, action: { url: 'commissionconfigs?action=edit', method: 'post' } },
9619
10299
  { name: 'delete', dialog: true, action: { url: 'commissionconfigs?action=delete', method: 'post' } },
9620
10300
  ],
@@ -9631,13 +10311,15 @@ class PayrollService {
9631
10311
  flatButtons: true,
9632
10312
  columns: [
9633
10313
  { name: 'name', type: 'text' },
9634
- { name: 'metricTypeName', type: 'text', alias: 'Metric Type' },
10314
+ { name: 'basisName', type: 'text', alias: 'Basis' }, // Changed (Phase 5): matches the main list
9635
10315
  { name: 'rateTypeName', type: 'text', alias: 'Rate Type' },
9636
10316
  { name: 'rate', type: 'text', alias: 'Rate' },
10317
+ { name: 'tierCount', type: 'text', alias: 'Tiers' },
9637
10318
  { name: 'isActive', type: 'checkbox', alias: 'Active' },
9638
10319
  ],
9639
10320
  buttons: [
9640
- { name: 'create', display: 'Create', dialog: true, action: { url: 'commissionconfigs?action=create', method: 'post' } },
10321
+ { name: 'create', display: 'Create', dialog: true, onSuccessButton: this.viewCommissionConfigButton, action: { url: 'commissionconfigs?action=create', method: 'post' } }, // Changed (Phase 5): the tiers tab is only reachable through the details dialog, so a rule created here opens into it too
10322
+ this.viewCommissionConfigButton,
9641
10323
  { name: 'edit', dialog: true, action: { url: 'commissionconfigs?action=edit', method: 'post' } },
9642
10324
  { name: 'delete', dialog: true, action: { url: 'commissionconfigs?action=delete', method: 'post' } },
9643
10325
  ],
@@ -9645,15 +10327,30 @@ class PayrollService {
9645
10327
  formConfig: this.commissionConfigFormConfig,
9646
10328
  };
9647
10329
  //--------------------------Commission Entries-------------------------
10330
+ // Added (Phase 5): the approval gate, bound to the status CommissionEntriesController exposes
10331
+ // (1=Draft, 2=Approved, 3=Paid, 4=Reversed). Only Approved entries are payable — Draft is a proposal and
10332
+ // Paid is settled — and the server refuses BOTH transitions on an entry that has reached a payslip line,
10333
+ // so isPaid is tested here too rather than trusting status alone.
10334
+ // Changed (Phase 6, browser pass): the colours were 'primary' and 'warn' — Angular Material PALETTE names.
10335
+ // ButtonService.getButtonColor returns button.icon.color verbatim into [style.color], which needs a CSS colour,
10336
+ // so both computed to the inherited grey rgb(68,71,78) while edit rendered rgb(64,80,181) and delete rgb(244,67,54).
10337
+ // The button that authorises money was visually indistinguishable from every other icon in the row.
10338
+ // Added (Phase 6): `inDialog` is what puts a button in the details dialog's footer — Core.setupButtons filters
10339
+ // extraButtons on it, so without it the two gates existed on the row and vanished the moment the entry was
10340
+ // opened to read its breakdown. It is ignored by the row renderer, so one object serves both places and the
10341
+ // gate cannot drift between them.
10342
+ this.approveCommissionEntryButton = { name: 'approve', display: 'Approve', inDialog: true, icon: { name: 'check_circle', color: 'green' }, action: { url: 'commissionentries?action=approve', method: 'post', successMessage: 'Approved' }, confirm: { message: 'Approve this commission entry so it can be paid on a payslip?' }, visible: (row) => row?.status === 1 && !row?.isPaid };
10343
+ this.returnCommissionEntryButton = { name: 'return', display: 'Return to Draft', inDialog: true, icon: { name: 'undo', color: 'orange' }, action: { url: 'commissionentries?action=return', method: 'post', successMessage: 'Returned to Draft' }, confirm: { message: 'Return this entry to Draft? It will not be picked up by a payroll run until it is approved again.' }, visible: (row) => row?.status === 2 && !row?.isPaid };
9648
10344
  this.commissionEntryFormConfig = {
9649
10345
  security: { allow: [this.dataService.capCommissionEntries] }, // Added: gate commission entry form by commission entries cap
9650
10346
  title: 'Commission Entry',
9651
10347
  fields: [
9652
- { name: 'employeeID', type: 'select', alias: 'Employee', loadAction: { url: 'employees/list/x' }, required: true },
9653
- { name: 'periodYear', alias: 'Year', type: 'number', required: true },
9654
- { name: 'periodMonth', alias: 'Month', type: 'number', required: true },
9655
- { name: 'metricValue', alias: 'Metric Value', type: 'number', required: true },
9656
- { name: 'commissionAmount', alias: 'Commission Amount', type: 'money', required: true },
10348
+ { name: 'employeeID', type: 'select', alias: 'Employee', loadAction: { url: 'employees/list/x' }, required: true, readonlyCondition: (x) => x.isPaid },
10349
+ { name: 'periodYear', alias: 'Year', type: 'number', required: true, readonlyCondition: (x) => x.isPaid },
10350
+ { name: 'periodMonth', alias: 'Month', type: 'number', required: true, readonlyCondition: (x) => x.isPaid },
10351
+ { name: 'metricValue', alias: 'Metric Value', type: 'number', required: true, readonlyCondition: (x) => x.isPaid },
10352
+ { name: 'commissionAmount', alias: 'Commission Amount', type: 'money', required: true, readonlyCondition: (x) => x.isPaid }, // Changed (Phase 5): a settled figure is history — the payslip line it paid is already out
10353
+ { name: 'statusName', alias: 'Status', type: 'text', readonly: true, hideOnCreate: true }, // Added (Phase 5): the gate itself, so an entry that pays nothing reads as Draft rather than as a bug
9657
10354
  // Changed: sectioned — who earned what for which period is the entry; where it came from is provenance,
9658
10355
  // read only when a figure is being queried
9659
10356
  { name: 'source', type: 'section', alias: 'Source & Description', collapseOnView: true },
@@ -9665,23 +10362,70 @@ class PayrollService {
9665
10362
  heroField: 'commissionEntryID',
9666
10363
  includeAudit: true,
9667
10364
  };
10365
+ //--------------------------Commission Entry Lines (child of CommissionEntry)-------------------------
10366
+ // Added (Phase 6): the breakdown that makes a total explainable. Deliberately has NO buttons — a line is
10367
+ // generated evidence and the header amount is the sum of these rows, so hand-editing one would make the
10368
+ // breakdown disagree with what payroll actually paid. CommissionEntryLinesController refuses the writes
10369
+ // server-side too; this is the same refusal said in the UI rather than only when a POST comes back.
10370
+ this.commissionEntryLinesTableConfig = {
10371
+ tabTitle: 'Lines',
10372
+ showFilter: false,
10373
+ elevation: 'none',
10374
+ flatButtons: true,
10375
+ noDataMessage: 'No breakdown — this entry was entered by hand rather than generated from trips',
10376
+ columns: [
10377
+ { name: 'eventDate', type: 'date', alias: 'Earned On' },
10378
+ { name: 'sourceDescription', type: 'text', alias: 'Source', // Changed (Phase 6): an adjustment line is the floor/cap correction, not a trip — it has no source to open
10379
+ icons: [{ name: 'tune', color: 'grey', tip: 'Adjustment — the rule\'s minimum or maximum moved the total, this is the difference', condition: (row) => row?.isAdjustment }]
10380
+ },
10381
+ { name: 'sourceType', type: 'text', alias: 'Type' },
10382
+ { name: 'metricValue', type: 'text', alias: 'Measured' },
10383
+ { name: 'rateApplied', type: 'text', alias: 'Rate' },
10384
+ { name: 'amount', type: 'money', alias: 'Amount' },
10385
+ ],
10386
+ buttons: [],
10387
+ loadAction: { url: 'commissionentrylines/x/x' }, loadCriteria: 'entry', loadIDField: 'commissionEntryID',
10388
+ };
10389
+ // Added (Phase 6): defined AFTER the lines table and BEFORE the view button, per the two-step create pattern.
10390
+ // The approve/return buttons are the SAME objects the table row uses, so the gate cannot drift between the
10391
+ // two places it is offered — both read status and isPaid, and the server refuses both on a settled entry.
10392
+ this.commissionEntryDetailsConfig = {
10393
+ formConfig: this.commissionEntryFormConfig,
10394
+ heroField: 'commissionEntryID',
10395
+ buttons: [this.approveCommissionEntryButton, this.returnCommissionEntryButton],
10396
+ tableConfigs: [this.commissionEntryLinesTableConfig],
10397
+ };
10398
+ this.viewCommissionEntryButton = { name: 'view', dialog: true, detailsConfig: this.commissionEntryDetailsConfig };
9668
10399
  this.commissionEntriesTableConfig = {
9669
10400
  showFilter: true,
9670
10401
  flatButtons: true,
9671
- minColumns: ['employeeName', 'commissionAmount', 'periodYear'],
10402
+ minColumns: ['employeeName', 'commissionAmount', 'statusName'],
9672
10403
  columns: [
9673
10404
  { name: 'employeeName', type: 'text', alias: 'Employee' },
9674
10405
  { name: 'periodYear', type: 'text', alias: 'Year' },
9675
10406
  { name: 'periodMonth', type: 'text', alias: 'Month' },
9676
10407
  { name: 'metricValue', type: 'text', alias: 'Metric Value' },
9677
- { name: 'commissionAmount', type: 'money', alias: 'Amount' },
10408
+ { name: 'commissionAmount', type: 'money', alias: 'Amount', // Changed (Phase 5): a 0.00 entry is legitimate (per-km with mileage off, or a clawback netting out) but it must never sit silently
10409
+ icons: [{ name: 'error_outline', color: 'orange', tip: 'This entry computed to nothing. Per-distance rules read trip mileage, which needs Tyre Management enabled; a credit-settled invoice is also not treated as paid.', condition: (row) => row?.commissionAmount === 0 }]
10410
+ },
10411
+ { name: 'statusName', type: 'text', alias: 'Status', // Changed (Phase 5): the approval gate — a Draft entry is invisible to payroll, which otherwise reads as commission being ignored
10412
+ colors: [
10413
+ { name: 'grey', condition: (row) => row?.status === 1 },
10414
+ { name: 'green', condition: (row) => row?.status === 2 },
10415
+ { name: 'red', condition: (row) => row?.status === 4 },
10416
+ ]
10417
+ },
10418
+ { name: 'isPaid', type: 'checkbox', alias: 'Paid' }, // Changed (Phase 5): settled on a payslip line and no longer changeable, by either side
9678
10419
  { name: 'description', type: 'text' },
9679
10420
  { name: 'sourceType', type: 'text', alias: 'Source' },
9680
10421
  ],
9681
10422
  buttons: [
9682
10423
  { name: 'create', display: 'Create', dialog: true, action: { url: 'commissionentries?action=create', method: 'post' } },
9683
- { name: 'edit', dialog: true, action: { url: 'commissionentries?action=edit', method: 'post' } },
9684
- { name: 'delete', dialog: true, action: { url: 'commissionentries?action=delete', method: 'post' } },
10424
+ this.viewCommissionEntryButton, // Added (Phase 6): the only way to the breakdown a figure with no reachable explanation is what this phase exists to remove
10425
+ this.approveCommissionEntryButton,
10426
+ this.returnCommissionEntryButton,
10427
+ { name: 'edit', dialog: true, action: { url: 'commissionentries?action=edit', method: 'post' }, visible: (row) => !row?.isPaid }, // Changed (Phase 5): mirrors the SalaryAdvance guard — a settled entry is not editable
10428
+ { name: 'delete', dialog: true, action: { url: 'commissionentries?action=delete', method: 'post' }, visible: (row) => !row?.isPaid }, // Changed (Phase 5): deleting a paid entry would orphan the payslip line that settled it
9685
10429
  ],
9686
10430
  loadAction: { url: 'commissionentries/all/x' },
9687
10431
  formConfig: this.commissionEntryFormConfig,
@@ -9699,14 +10443,21 @@ class PayrollService {
9699
10443
  { name: 'periodYear', type: 'text', alias: 'Year' },
9700
10444
  { name: 'periodMonth', type: 'text', alias: 'Month' },
9701
10445
  { name: 'metricValue', type: 'text', alias: 'Metric Value' },
9702
- { name: 'commissionAmount', type: 'money', alias: 'Amount' },
10446
+ { name: 'commissionAmount', type: 'money', alias: 'Amount',
10447
+ icons: [{ name: 'error_outline', color: 'orange', tip: 'This entry computed to nothing — see the entry for why', condition: (row) => row?.commissionAmount === 0 }]
10448
+ },
10449
+ { name: 'statusName', type: 'text', alias: 'Status' }, // Changed (Phase 5): same gate as the main list — Draft never reaches a payslip
10450
+ { name: 'isPaid', type: 'checkbox', alias: 'Paid' },
9703
10451
  { name: 'description', type: 'text' },
9704
10452
  { name: 'sourceType', type: 'text', alias: 'Source' },
9705
10453
  ],
9706
10454
  buttons: [
9707
10455
  { name: 'create', display: 'Create', dialog: true, action: { url: 'commissionentries?action=create', method: 'post' } },
9708
- { name: 'edit', dialog: true, action: { url: 'commissionentries?action=edit', method: 'post' } },
9709
- { name: 'delete', dialog: true, action: { url: 'commissionentries?action=delete', method: 'post' } },
10456
+ this.viewCommissionEntryButton, // Added (Phase 6): same breakdown, reached from the employee's own history
10457
+ this.approveCommissionEntryButton,
10458
+ this.returnCommissionEntryButton,
10459
+ { name: 'edit', dialog: true, action: { url: 'commissionentries?action=edit', method: 'post' }, visible: (row) => !row?.isPaid }, // Changed (Phase 5): mirrors the main list's settled guard
10460
+ { name: 'delete', dialog: true, action: { url: 'commissionentries?action=delete', method: 'post' }, visible: (row) => !row?.isPaid },
9710
10461
  ],
9711
10462
  loadAction: { url: 'commissionentries/x/x' }, loadCriteria: 'employee', loadIDField: 'employeeID',
9712
10463
  formConfig: this.commissionEntryFormConfig,
@@ -9832,6 +10583,10 @@ class PayrollService {
9832
10583
  this.submitPayrollButton = { name: 'submit', display: 'Submit', inDialog: true, action: { url: 'payrollruns?action=submit', method: 'post' }, visible: (row) => row?.status === 1 }; // Changed: null-check row to prevent TypeError when dialog renders before row data loads
9833
10584
  this.postPayrollButton = { name: 'post', display: 'Post', inDialog: true, action: { url: 'payrollruns?action=post', method: 'post' }, visible: (row) => row?.status === 2 }; // Changed: null-check row
9834
10585
  this.markPaidButton = { name: 'paid', display: 'Mark Paid', inDialog: true, action: { url: 'payrollruns?action=paid', method: 'post' }, visible: (row) => row?.status === 3 }; // Changed: null-check row
10586
+ // Added (Phase 5): recalculate is how a late commission approval or a corrected salary structure reaches an
10587
+ // open run. DRAFT ONLY — the server refuses it at Submitted, Posted and Paid, and a button that exists and
10588
+ // always fails teaches people the product is broken. It discards and rebuilds the run's payslips, hence the confirm.
10589
+ this.recalculatePayrollButton = { name: 'recalculate', display: 'Recalculate', inDialog: true, action: { url: 'payrollruns?action=recalculate', method: 'post', successMessage: 'Payslips rebuilt' }, confirm: { message: 'Rebuild this run\'s payslips from current salary structures and approved commission? The existing payslips are discarded.' }, visible: (row) => row?.status === 1 };
9835
10590
  this.payrollRunFormConfig = {
9836
10591
  security: { allow: [this.dataService.capPayrollRuns] }, // Added: gate payroll run form by payroll runs cap
9837
10592
  title: 'Payroll Run',
@@ -9867,7 +10622,7 @@ class PayrollService {
9867
10622
  detailsConfig: {
9868
10623
  formConfig: this.payrollRunFormConfig,
9869
10624
  heroField: 'payrollRunID',
9870
- buttons: [this.submitPayrollButton, this.postPayrollButton, this.markPaidButton],
10625
+ buttons: [this.recalculatePayrollButton, this.submitPayrollButton, this.postPayrollButton, this.markPaidButton],
9871
10626
  tableConfigs: [this.payslipsChildTableConfig],
9872
10627
  }
9873
10628
  },
@@ -10235,6 +10990,13 @@ class TabService {
10235
10990
  this.dataService.CallApi(transformedAction, '', quiet ? { silent: true } : undefined).subscribe({
10236
10991
  // Changed: an unsuccessful response used to leave the badge silently blank with nothing recording that it
10237
10992
  // had failed, so it was never asked for again. Both outcomes now report through onSettled.
10993
+ //
10994
+ // DELIBERATELY SILENT to the USER (WS-6 Phase 4 triage), and this one is already solved better than a
10995
+ // dialog could solve it. A tab-count badge is decoration on a tab strip that may hold a dozen tabs, all
10996
+ // counting at once against the same backend — converting would turn one outage into a dozen suppressed
10997
+ // duplicates of a message about a number. The failure is not swallowed: onSettled(false) hands it to the
10998
+ // host, which re-requests the badge. Reporting to the code that can FIX it beats reporting to a person
10999
+ // who cannot.
10238
11000
  next: (apiResponse) => {
10239
11001
  if (apiResponse.success) {
10240
11002
  tabCounts[tabIndex] = apiResponse.data;
@@ -10482,6 +11244,12 @@ class NotificationsService {
10482
11244
  // Changed: Uncommented HTTP poll for initial count on login
10483
11245
  loadNotifications() {
10484
11246
  this.dataService.CallApi({ url: 'notifications/count/x' }).subscribe((apiResponse) => {
11247
+ // DELIBERATELY SILENT (WS-6 Phase 4 triage). This is THE poll of the set, and the one where converting
11248
+ // would be most obviously wrong: it runs on login, on signup, on every visit to the notifications page,
11249
+ // on every push message forwarded from the service worker, and — the decisive one — from
11250
+ // signalRService.onReconnected. A flapping SignalR connection reconnects repeatedly, so a dialog here
11251
+ // would fire once per reconnect for as long as the network is unhappy. That is the dialog storm this
11252
+ // whole channel exists to prevent, arriving from the inside. A stale unread badge costs the user nothing.
10485
11253
  if (apiResponse.success) {
10486
11254
  this.notificationCount.next(apiResponse.data);
10487
11255
  }
@@ -10502,9 +11270,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
10502
11270
 
10503
11271
  // Frontend service for Agent chat — state management, API calls, and SignalR subscriptions (renamed from AssistantService)
10504
11272
  class AgentService {
10505
- constructor(dataService, signalRService) {
11273
+ constructor(dataService, signalRService, apiErrorService // Changed: injected for loadMessages and deleteConversation only
11274
+ ) {
10506
11275
  this.dataService = dataService;
10507
11276
  this.signalRService = signalRService;
11277
+ this.apiErrorService = apiErrorService;
10508
11278
  this.messages = new BehaviorSubject([]);
10509
11279
  this.conversations = new BehaviorSubject([]);
10510
11280
  this.typing = new BehaviorSubject(false);
@@ -10550,6 +11320,9 @@ class AgentService {
10550
11320
  // Load agent config (greeting + suggested questions) from backend
10551
11321
  loadConfig() {
10552
11322
  this.dataService.CallApi({ url: `agent/config/${this.appName}` }).subscribe(res => {
11323
+ // DELIBERATELY SILENT (WS-6 Phase 4 triage). Greeting text and suggested-question chips are decoration
11324
+ // on a chat window that works perfectly without them — the user can still type. Nothing they can see is
11325
+ // wrong, so there is nothing truthful to tell them.
10553
11326
  if (res.success && res.data) {
10554
11327
  this.greeting.next(res.data.greeting || '');
10555
11328
  this.suggestedQuestions.next(res.data.suggestedQuestions || []);
@@ -10559,6 +11332,13 @@ class AgentService {
10559
11332
  // Load user's conversation list
10560
11333
  loadConversations() {
10561
11334
  this.dataService.CallApi({ url: 'agent/conversations' }).subscribe(res => {
11335
+ // DELIBERATELY SILENT (WS-6 Phase 4 triage), and this was the closest call in the sweep — an empty
11336
+ // thread list IS a false statement about the user's own history. It stays silent because of WHEN it
11337
+ // fires: agent.component.ts:212 calls it from the widget's ngOnInit, so it runs on every app-shell load
11338
+ // whether or not anyone opens the chat. Converting would put a dialog in front of every user of the app
11339
+ // for a list none of them had asked to see. loadMessages() below — which only ever runs because someone
11340
+ // clicked a specific thread — is the one that reports, and it covers the same outage the moment the
11341
+ // list is actually used.
10562
11342
  if (res.success) {
10563
11343
  this.conversations.next(res.data || []);
10564
11344
  }
@@ -10568,6 +11348,12 @@ class AgentService {
10568
11348
  loadMessages(conversationId) {
10569
11349
  this.currentConversationId.next(conversationId);
10570
11350
  this.dataService.CallApi({ url: `agent/conversations/${conversationId}/messages` }).subscribe(res => {
11351
+ // CONVERTED (WS-6 Phase 4 triage). Unlike everything else in this service, this only runs because the
11352
+ // user clicked a named conversation in the switcher. Failing left the thread pane holding the PREVIOUS
11353
+ // conversation's messages under the new conversation's id — so the next thing they typed would have
11354
+ // been sent into a thread they were not looking at.
11355
+ if (!res.success)
11356
+ this.apiErrorService.presentAppFailure(res, 'load', `agent/conversations/${conversationId}/messages`);
10571
11357
  if (res.success) {
10572
11358
  this.messages.next((res.data || []).map((m) => ({
10573
11359
  conversationId: m.conversationID,
@@ -10738,6 +11524,10 @@ class AgentService {
10738
11524
  this.messages.next([]);
10739
11525
  this.currentConversationId.next(null);
10740
11526
  this.dataService.CallApi({ url: 'agent/conversations', method: 'post' }, { appName: this.appName }).subscribe(res => {
11527
+ // DELIBERATELY SILENT (WS-6 Phase 4 triage). The UI has already done the thing the user asked for —
11528
+ // messages cleared, id nulled, a blank thread on screen — before this call is even made. All the server
11529
+ // round trip adds is a pre-allocated id, and sendMessage() creates one anyway when there is none. So a
11530
+ // failure here changes nothing the user can see or do.
10741
11531
  if (res.success && res.data) {
10742
11532
  this.currentConversationId.next(res.data.conversationID);
10743
11533
  }
@@ -10746,6 +11536,12 @@ class AgentService {
10746
11536
  // Delete a conversation
10747
11537
  deleteConversation(id) {
10748
11538
  this.dataService.CallApi({ url: `agent/conversations/${id}/delete`, method: 'post' }).subscribe(res => {
11539
+ // CONVERTED (WS-6 Phase 4 triage). A delete the user pressed. Failing left the conversation sitting in
11540
+ // the list exactly as it was, which reads as "the button did nothing" — and the answer to a button that
11541
+ // did nothing is to press it again. Note the comment on the URL below: this endpoint has already been
11542
+ // wrong once (a 405 on every call), and it was silence that let that survive.
11543
+ if (!res.success)
11544
+ this.apiErrorService.presentAppFailure(res, 'action', `agent/conversations/${id}/delete`);
10749
11545
  if (res.success) {
10750
11546
  this.loadConversations();
10751
11547
  if (this.currentConversationId.value === id) {
@@ -10783,13 +11579,13 @@ class AgentService {
10783
11579
  }
10784
11580
  });
10785
11581
  }
10786
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: AgentService, deps: [{ token: DataServiceLib }, { token: SignalRService }], target: i0.ɵɵFactoryTarget.Injectable }); }
11582
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: AgentService, deps: [{ token: DataServiceLib }, { token: SignalRService }, { token: ApiErrorService }], target: i0.ɵɵFactoryTarget.Injectable }); }
10787
11583
  static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: AgentService, providedIn: 'root' }); }
10788
11584
  }
10789
11585
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: AgentService, decorators: [{
10790
11586
  type: Injectable,
10791
11587
  args: [{ providedIn: 'root' }]
10792
- }], ctorParameters: () => [{ type: DataServiceLib }, { type: SignalRService }] });
11588
+ }], ctorParameters: () => [{ type: DataServiceLib }, { type: SignalRService }, { type: ApiErrorService }] });
10793
11589
 
10794
11590
  class AnalyticsService {
10795
11591
  constructor(router) {
@@ -10837,248 +11633,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
10837
11633
  args: [{ providedIn: 'root' }]
10838
11634
  }], ctorParameters: () => [{ type: i1$1.Router }] });
10839
11635
 
10840
- class ApiErrorService {
10841
- constructor(messageService) {
10842
- this.messageService = messageService;
10843
- // One dialog per KIND per window. Keyed by kind rather than by URL on purpose: when the backend goes down,
10844
- // twenty different URLs fail for one single reason, and the user needs to be told that reason once.
10845
- this.lastShownAt = new Map();
10846
- /** How long a given kind stays suppressed after being shown. The user described "the same 10 seconds or so". */
10847
- this.suppressWindowMs = 10000;
10848
- // Added: submits dedupe over a much shorter window than background reads. Long enough to swallow a
10849
- // double-click or a duplicated emission, short enough that a user who reads the dialog, dismisses it and
10850
- // deliberately tries again is told again rather than met with silence.
10851
- this.submitSuppressWindowMs = 1500;
10852
- //================ Application-level failures (HTTP 200 + success:false) ================
10853
- // Added: fragments that mark a message as machine-authored rather than human-authored. Matched
10854
- // case-insensitively against the whole message. The list is deliberately about IMPLEMENTATION LEAKS, not
10855
- // about severity — a leaked identifier is useless to the user however serious the underlying fault is.
10856
- this.opaqueMarkers = [
10857
- // NOTE: matched as a SUBSTRING, never by equality. TinWeb's catch-all now appends a correlation code —
10858
- // "Error processing request (reference K7M2QP4T)" — so an equality test would classify every catch-all
10859
- // failure as a business message and show the raw text, which is worse than the behaviour this replaces.
10860
- 'error processing request', // TinWeb BaseController/BaseController2/BaseService catch-all — by far the most common
10861
- 'invalid column name', // SQL leaking through after a schema/migration drift
10862
- 'sqlexception',
10863
- 'object reference not set',
10864
- 'an error occurred while', // EF Core's standard preamble ("...while updating the entries")
10865
- 'inner exception',
10866
- 'stack trace',
10867
- 'the underlying provider failed',
10868
- 'index was outside the bounds',
10869
- 'timeout expired',
10870
- 'exception', // catches DbUpdateException, InvalidOperationException, ... by suffix
10871
- // Added: raw SqlException text. TinCore's BaseRepository.SetErrorResp returns ex.InnerException.Message
10872
- // verbatim on HTTP 200, so a bad foreign key currently shows the user the database, table, column AND
10873
- // constraint names — jargon and a schema leak at once. These never carry a correlation reference, because
10874
- // they never reach the catch-all that mints one.
10875
- //
10876
- // Each fragment below is SQL Server's own wording. None of them can occur in a hand-authored message like
10877
- // "Registration number already in use" or "Customer not found", which is why they are matched as phrases
10878
- // rather than on single words such as "duplicate" or "constraint" that a human might legitimately write.
10879
- 'statement conflicted with the', // "The INSERT statement conflicted with the FOREIGN KEY constraint ..."
10880
- 'foreign key constraint',
10881
- 'unique key constraint',
10882
- 'primary key constraint',
10883
- 'check constraint',
10884
- 'cannot insert the value null',
10885
- 'string or binary data would be truncated',
10886
- 'violation of ', // "Violation of PRIMARY KEY constraint 'PK_...'"
10887
- 'duplicate key', // "Cannot insert duplicate key row in object ..."
10888
- ];
10889
- }
10890
- // Classification is by status code first, because that is the only signal that is reliable across browsers.
10891
- // `statusText === 'Unknown Error'` is kept as a secondary network signal since some browsers report a failed
10892
- // connection that way while still surfacing status 0.
10893
- classify(error) {
10894
- const status = error?.status ?? 0;
10895
- // status 0 means the request never reached a server: offline, DNS failure, connection refused, CORS block.
10896
- // This is the single most common case for a field user and the one that was rendering blank.
10897
- if (status === 0 || error?.statusText === 'Unknown Error') {
10898
- return {
10899
- kind: 'network',
10900
- title: 'No connection',
10901
- message: `We couldn't reach the server. Please check your internet connection and try again.\n\n` +
10902
- `If your connection is working, the service may be temporarily unavailable — your work is not lost, and you can try again in a moment.`,
10903
- };
10904
- }
10905
- if (status === 408 || status === 504) {
10906
- return {
10907
- kind: 'timeout',
10908
- title: 'Taking too long',
10909
- message: `The server is taking longer than expected to respond.\n\nPlease wait a moment and try again.`,
10910
- };
10911
- }
10912
- if (status === 503) {
10913
- return {
10914
- kind: 'unavailable',
10915
- title: 'Service unavailable',
10916
- message: `The service is temporarily unavailable, usually because it is being updated.\n\nPlease try again in a few minutes.`,
10917
- };
10918
- }
10919
- // A 400 is almost never the user's doing — the app sent a payload the server rejected. Saying "check your
10920
- // input" would send them hunting for a mistake they did not make, so this points at support instead.
10921
- if (status === 400 || status === 422) {
10922
- return {
10923
- kind: 'badrequest',
10924
- title: 'Something went wrong',
10925
- message: `We couldn't complete that action because of a technical problem in the app — not anything you did wrong.\n\n` +
10926
- `Please try again. If it keeps happening, contact your administrator so it can be looked into.`,
10927
- };
10928
- }
10929
- if (status === 403) {
10930
- return {
10931
- kind: 'forbidden',
10932
- title: 'Not allowed',
10933
- message: `You don't have permission to do that.\n\nIf you think you should, please contact your administrator.`,
10934
- };
10935
- }
10936
- if (status === 404) {
10937
- return {
10938
- kind: 'notfound',
10939
- title: 'Not found',
10940
- message: `We couldn't find what you were looking for. It may have been moved or deleted by someone else.\n\n` +
10941
- `Try refreshing the page. If it keeps happening, contact your administrator.`,
10942
- };
10943
- }
10944
- if (status >= 500) {
10945
- return {
10946
- kind: 'server',
10947
- title: 'Something went wrong',
10948
- message: `The server ran into a problem while handling your request.\n\n` +
10949
- `Please try again shortly. If it keeps happening, contact your administrator.`,
10950
- };
10951
- }
10952
- return {
10953
- kind: 'unknown',
10954
- title: 'Something went wrong',
10955
- message: `We couldn't complete that action.\n\nPlease try again. If it keeps happening, contact your administrator.`,
10956
- };
10957
- }
10958
- /** True when this kind was already shown inside the suppression window — i.e. the user has already been told. */
10959
- // Changed: window is now a parameter (defaulting to the existing suppressWindowMs, so present() is unaffected)
10960
- // because a submit failure needs a much shorter one than a burst of background reads — see presentAppFailure().
10961
- isSuppressed(kind, windowMs = this.suppressWindowMs) {
10962
- const last = this.lastShownAt.get(kind);
10963
- return last != null && (Date.now() - last) < windowMs;
10964
- }
10965
- // Presents at most ONE dialog per kind per window. The technical detail still goes to the console, where a
10966
- // developer wants it, so nothing is lost by keeping it out of the dialog.
10967
- present(error) {
10968
- const info = this.classify(error);
10969
- // Added: a 5xx from TinWeb's exception middleware now carries "(reference X)" in its body Message —
10970
- // pull it out so the dialog can quote it as a footnote, exactly as presentAppFailure() does for the
10971
- // 200-with-success:false catch-alls. Only the 'server' kind can carry one; a transport failure has no
10972
- // body at all (error.error is a ProgressEvent), which extractReference() tolerates as null.
10973
- if (info.kind === 'server') {
10974
- const body = error?.error;
10975
- info.reference = this.extractReference(body?.Message ?? body?.message) ?? undefined;
10976
- }
10977
- console.error(`[tin-spa] API error (${info.kind})`, { status: error?.status, url: error?.url, reference: info.reference, error }); // Changed: log the reference so console and server log line tie together
10978
- if (this.isSuppressed(info.kind))
10979
- return info; // already told them — a second dialog adds nothing
10980
- this.lastShownAt.set(info.kind, Date.now());
10981
- this.messageService.errorWithSubject(info.title, info.message, info.reference); // Changed: pass the reference through so the dialog renders the quiet footnote
10982
- return info;
10983
- }
10984
- // Added: splits the server's message into 'opaque' (tells the user nothing, must not be shown raw) and
10985
- // 'business' (a deliberate sentence the backend author wrote for a human, genuinely worth reading).
10986
- //
10987
- // Written as a deny-list rather than an allow-list on purpose: business messages are open-ended and
10988
- // app-specific, so there is nothing to enumerate, whereas the ways a .NET exception leaks are few and stable.
10989
- // The consequence of the default is the safe one — an unrecognised message is treated as business and shown,
10990
- // so a real message is never swallowed; only recognisably technical text is replaced.
10991
- classifyMessage(message) {
10992
- const text = (message ?? '').trim();
10993
- if (!text)
10994
- return 'opaque'; // nothing to show — a blank toast is the very failure mode this service exists to kill
10995
- const lower = text.toLowerCase();
10996
- if (this.opaqueMarkers.some(marker => lower.includes(marker)))
10997
- return 'opaque';
10998
- if (/\bsystem\.[a-z]/i.test(text))
10999
- return 'opaque'; // a .NET namespace ("System.NullReferenceException")
11000
- if (/\bat\s+[\w.]+\s*\(/.test(text))
11001
- return 'opaque'; // a stack frame ("at Namespace.Method(")
11002
- if (text.length > 300)
11003
- return 'opaque'; // no human writes a 300-char error; this is a dump
11004
- return 'business';
11005
- }
11006
- // Added: pulls TinWeb's correlation code out of a catch-all message. The backend writes the same code into
11007
- // its log line as "[ref XXXXXXXX]", so quoting it in the dialog is what lets support jump straight to the log
11008
- // row instead of hunting by timestamp.
11009
- //
11010
- // The alphabet is A-Z/2-9 with 0/O/1/I/L removed (they are misread down a phone line), and the code is 8
11011
- // characters — but the pattern accepts 6-12 so a future length change does not silently stop matching. The
11012
- // reference is only ever EXTRACTED here; the rest of the raw message is still discarded, never shown.
11013
- extractReference(message) {
11014
- const match = (message ?? '').match(/\breference\s+([A-Z2-9]{6,12})\b/i);
11015
- return match ? match[1].toUpperCase() : null;
11016
- }
11017
- // Added: the sibling to present() for failures that arrive as HTTP 200 with success:false.
11018
- //
11019
- // Opaque -> the friendly dialog, through the SAME lastShownAt suppression map present() uses, so a burst
11020
- // (a dashboard firing twenty calls at a broken backend) still yields exactly one dialog.
11021
- // Business -> the existing toast, unchanged in weight. Short, meaningful, non-blocking.
11022
- //
11023
- // Why 'submit' gets its own dedupe key rather than sharing with loads: suppression exists to collapse a BURST
11024
- // of background calls that all failed for one reason. A create/edit submit is not part of a burst — it is one
11025
- // deliberate action, and it is the exact case where a missed message leads the user to press the button again
11026
- // and duplicate the record. Sharing a key would let unrelated background load failures suppress the dialog
11027
- // for the one interaction that most needs it. Keeping a (short) window on the submit key still collapses a
11028
- // double-click, while letting a genuine retry a few seconds later speak up again.
11029
- presentAppFailure(response, context = 'action', url) {
11030
- const message = response?.message;
11031
- const failureClass = this.classifyMessage(message);
11032
- // Always log the raw response and URL, exactly as present() does — the developer loses nothing by the
11033
- // dialog being friendly, because the technical detail is right here.
11034
- const reference = this.extractReference(message); // Added: null for SqlException leaks and older backends, which never mint one
11035
- console.error(`[tin-spa] API failure (${failureClass}/${context})`, { url, message, reference, response }); // Changed: log the reference too, so the browser console and the server log line can be tied together
11036
- if (failureClass === 'business') {
11037
- // Changed: dropped the "Error: " prefix that every call site used to prepend. Verified nothing depends on
11038
- // it — no test in ng-space or any of the four consumer apps asserts on the literal "Error: ". The prefix
11039
- // only ever restated what the red-tinted message already conveyed, and it pushed the useful words right.
11040
- this.messageService.toast(message);
11041
- return failureClass;
11042
- }
11043
- const kind = context === 'submit' ? 'appfailure-submit' : 'appfailure';
11044
- if (this.isSuppressed(kind, context === 'submit' ? this.submitSuppressWindowMs : this.suppressWindowMs))
11045
- return failureClass;
11046
- this.lastShownAt.set(kind, Date.now());
11047
- // Copy matches classify() above: name the problem, absolve the user, say what to do next. The submit
11048
- // variant adds the two things that specifically stop a duplicate — that the data is still there, and that
11049
- // pressing the button again is not the fix.
11050
- // Changed: the reference is passed as its own argument rather than appended to the body, so the dialog can
11051
- // render it as a quiet footnote. It is omitted entirely when there isn't one — a SqlException leak and an
11052
- // older backend both arrive without a reference, and neither should show a dangling "quote reference" line.
11053
- if (context === 'submit') {
11054
- this.messageService.errorWithSubject('Not saved', `We couldn't save that because of a technical problem — not anything you did wrong.\n\n` +
11055
- `Nothing was saved, and your details are still on the form, so you can try again without re-entering them.\n\n` +
11056
- `If it keeps happening, please contact your administrator rather than submitting repeatedly, as that can create duplicates.`, reference ?? undefined);
11057
- }
11058
- else {
11059
- this.messageService.errorWithSubject('Something went wrong', `We couldn't complete that action because of a technical problem — not anything you did wrong.\n\n` +
11060
- `Please try again. If it keeps happening, contact your administrator.`, reference ?? undefined);
11061
- }
11062
- return failureClass;
11063
- }
11064
- /** Clears suppression so the next failure speaks up again — call after a successful request or reconnect. */
11065
- reset() {
11066
- this.lastShownAt.clear();
11067
- }
11068
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: ApiErrorService, deps: [{ token: MessageService }], target: i0.ɵɵFactoryTarget.Injectable }); }
11069
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: ApiErrorService, providedIn: 'root' }); }
11070
- }
11071
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: ApiErrorService, decorators: [{
11072
- type: Injectable,
11073
- args: [{ providedIn: 'root' }]
11074
- }], ctorParameters: () => [{ type: MessageService }] });
11075
-
11076
11636
  // Setup readiness ("Getting Started") service — NotificationsService mirror without SignalR.
11077
11637
  // Dormant unless appConfig.setupConfig.enabled; API failures leave count at 0 so the badge stays hidden.
11078
11638
  // v2 adds the module catalog (drives step + nav-menu gating) and predefined role templates.
11079
11639
  class SetupService {
11080
- constructor(dataService) {
11640
+ constructor(dataService, apiErrorService) {
11081
11641
  this.dataService = dataService;
11642
+ this.apiErrorService = apiErrorService;
11082
11643
  this.pendingCount = new BehaviorSubject(0);
11083
11644
  this.pendingCount$ = this.pendingCount.asObservable();
11084
11645
  this.status = new BehaviorSubject(null);
@@ -11086,7 +11647,7 @@ class SetupService {
11086
11647
  // Added (v2): module catalog — empty means the app has no module concept (nothing is ever hidden)
11087
11648
  this.modules = new BehaviorSubject([]);
11088
11649
  this.modules$ = this.modules.asObservable();
11089
- }
11650
+ } // Changed: injected for loadStatus only
11090
11651
  get enabled() {
11091
11652
  return !!this.dataService.appConfig?.setupConfig?.enabled;
11092
11653
  }
@@ -11096,6 +11657,11 @@ class SetupService {
11096
11657
  return;
11097
11658
  this.dataService.CallApi({ url: 'setup/count', skipCache: true }).subscribe({
11098
11659
  next: (apiResponse) => {
11660
+ // DELIBERATELY SILENT (WS-6 Phase 4 triage). A badge count, fired from login and from nav-menu init.
11661
+ // Failing leaves the badge hidden, which is the same thing it shows when there is genuinely nothing
11662
+ // pending — and "you have no setup left" is a harmless thing to under-report for a moment, because
11663
+ // the Getting Started PAGE itself reports properly (see loadStatus below). A dialog on nav init would
11664
+ // fire on every shell load for every user of an app whose SetupController is having a bad day.
11099
11665
  if (apiResponse.success)
11100
11666
  this.pendingCount.next(apiResponse.data);
11101
11667
  },
@@ -11108,6 +11674,13 @@ class SetupService {
11108
11674
  return;
11109
11675
  this.dataService.CallApi({ url: 'setup/status', skipCache: true }).subscribe({
11110
11676
  next: (apiResponse) => {
11677
+ // CONVERTED, unlike its two neighbours in this file, and the difference is who asked. This one has
11678
+ // exactly two callers — setup-guide.component.ts ngOnInit and refresh() from that same page — so it
11679
+ // only ever runs because someone opened Getting Started or just completed a step there. Failing left
11680
+ // the page rendering an empty checklist, which reads as "there is nothing to set up" to the one user
11681
+ // who is by definition new and has no way to know better. (WS-6 Phase 4 triage)
11682
+ if (!apiResponse.success)
11683
+ this.apiErrorService.presentAppFailure(apiResponse, 'load', 'setup/status');
11111
11684
  if (apiResponse.success) {
11112
11685
  this.status.next(apiResponse.data);
11113
11686
  this.pendingCount.next(apiResponse.data?.pending ?? 0);
@@ -11123,6 +11696,10 @@ class SetupService {
11123
11696
  return;
11124
11697
  this.dataService.CallApi({ url: 'setup/modules', skipCache: true }).subscribe({
11125
11698
  next: (apiResponse) => {
11699
+ // DELIBERATELY SILENT (WS-6 Phase 4 triage). Nav-menu init, and isModuleEnabled() below FAILS OPEN by
11700
+ // design — an empty catalog hides nothing, so a failure here makes the app MORE visible, never less.
11701
+ // There is no user-facing loss to report. It also runs on every shell init, which is precisely where
11702
+ // a dialog becomes a wall.
11126
11703
  if (apiResponse.success)
11127
11704
  this.modules.next(apiResponse.data ?? []);
11128
11705
  },
@@ -11151,10 +11728,33 @@ class SetupService {
11151
11728
  createRoles(keys) {
11152
11729
  return this.dataService.CallApi({ url: 'setup/roles', method: 'post', skipCache: true }, { keys });
11153
11730
  }
11731
+ // Added (presets): the notification rules on offer for this tenant, plus the roles the picker chooses a
11732
+ // recipient from. skipCache like every other setup call — Exists is stamped per request and a cached
11733
+ // catalogue would re-offer rules the user just created.
11734
+ loadNotificationPresets() {
11735
+ return this.dataService.CallApi({ url: 'setup/notificationpresets', skipCache: true });
11736
+ }
11737
+ // Added (presets): create notification rules from the ticked presets. Create-only server-side — unticking a
11738
+ // rule that already exists deletes nothing, so this can never undo a deliberate user choice.
11739
+ applyNotificationPresets(items, includeEmail) {
11740
+ return this.dataService.CallApi({ url: 'setup/notifications', method: 'post', skipCache: true }, { items, includeEmail });
11741
+ }
11742
+ // Added (presets P4): the approval rules on offer, plus the roles that can ACTUALLY approve — the backend filters
11743
+ // that list to roles somebody is a member of, because since SEC-10 only a member of the level's role can release a
11744
+ // held record, so offering an empty role would build a config nobody could action.
11745
+ loadApprovalPresets() {
11746
+ return this.dataService.CallApi({ url: 'setup/approvalpresets', skipCache: true });
11747
+ }
11748
+ // Added (presets P4): create approval rules from the ticked presets. Create-only server-side, exactly like the
11749
+ // notification apply. No includeEmail — an approval request is always in-app (an approver who misses it leaves
11750
+ // records held), so the body deliberately carries items alone.
11751
+ applyApprovalPresets(items) {
11752
+ return this.dataService.CallApi({ url: 'setup/approvals', method: 'post', skipCache: true }, { items });
11753
+ }
11154
11754
  refresh() {
11155
11755
  this.loadStatus();
11156
11756
  }
11157
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: SetupService, deps: [{ token: DataServiceLib }], target: i0.ɵɵFactoryTarget.Injectable }); }
11757
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: SetupService, deps: [{ token: DataServiceLib }, { token: ApiErrorService }], target: i0.ɵɵFactoryTarget.Injectable }); }
11158
11758
  static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: SetupService, providedIn: 'root' }); }
11159
11759
  }
11160
11760
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: SetupService, decorators: [{
@@ -11162,7 +11762,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
11162
11762
  args: [{
11163
11763
  providedIn: 'root'
11164
11764
  }]
11165
- }], ctorParameters: () => [{ type: DataServiceLib }] });
11765
+ }], ctorParameters: () => [{ type: DataServiceLib }, { type: ApiErrorService }] });
11166
11766
 
11167
11767
  // Added: the app-wide App Configuration record, loaded once and shared. Apps used to each solve this
11168
11768
  // themselves — Shift kept a copy in localStorage that went stale the moment anyone saved on another
@@ -11186,6 +11786,11 @@ class ConfigService {
11186
11786
  this.loaded = true;
11187
11787
  this.dataService.CallApi({ url: 'configuration/get', skipCache: true }).subscribe({
11188
11788
  next: (apiResponse) => {
11789
+ // DELIBERATELY SILENT (WS-6 Phase 4 triage). This runs on login/bootstrap, before the user has asked
11790
+ // for anything, and everything downstream of it FAILS OPEN: an empty config hides no nav item, gates
11791
+ // no module and suffixes no unit. So a failure here costs cosmetics, not capability — and greeting
11792
+ // someone with an error dialog the instant they sign in, for an app that then works, is a worse lie
11793
+ // than saying nothing. The error callback below already absorbs the transport case for the same reason.
11189
11794
  if (apiResponse.success)
11190
11795
  this.config.next(apiResponse.data || {});
11191
11796
  },
@@ -11384,6 +11989,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
11384
11989
  type: Input
11385
11990
  }] } });
11386
11991
 
11992
+ // Added (presets): the library step keys the pickers attach to. They come from PresetCatalog and are identical in
11993
+ // all five apps, so a picker needs no per-app setupConfig entry (unlike roleTemplates, whose step key is
11994
+ // app-chosen). Kept as named constants rather than literals sprinkled through the class.
11995
+ const NOTIFICATION_PRESETS_STEP = 'notification-presets';
11996
+ const APPROVAL_PRESETS_STEP = 'approval-presets'; // Added (presets P4) — PresetCatalog.StepApprovals
11997
+ // Added (presets): the placeholder PresetCatalog authors into every Effect sentence, marking where the role
11998
+ // select is embedded. Splitting on it is the ONLY thing the UI does to that sentence.
11999
+ const PRESET_ROLE_TOKEN = '{role}';
11387
12000
  // Getting Started page — renders the per-app setup readiness catalog (top-down groups, no wizard).
11388
12001
  // Step actions come from appConfig.setupConfig.stepActions (create dialog Button, route link, Excel
11389
12002
  // import wizard, or the predefined role-template picker). v2 adds the module picker: optional modules
@@ -11394,6 +12007,7 @@ class SetupGuideComponent {
11394
12007
  this.setupService = inject(SetupService);
11395
12008
  this.dialogService = inject(DialogService);
11396
12009
  this.messageService = inject(MessageService); // Added: Demo Data card
12010
+ this.apiErrorService = inject(ApiErrorService); // Added: routes this page's four unhandled failures through the shared classifier
11397
12011
  this.status = null;
11398
12012
  this.groups = []; // Changed: each group carries a spa-checklist config instead of raw steps
11399
12013
  this.modules = [];
@@ -11404,6 +12018,49 @@ class SetupGuideComponent {
11404
12018
  this.roleTemplates = [];
11405
12019
  this.selectedTemplates = {};
11406
12020
  this.creatingRoles = false;
12021
+ // Added (presets): picker state. `groups` is materialized (same rule as moduleGroups above); selected/role are
12022
+ // plain maps keyed by preset key so a reload never clobbers a manual choice.
12023
+ // Changed (presets P4): one object per domain instead of a field per domain — see PresetPicker above.
12024
+ this.includeEmail = false; // notifications only; approvals are always in-app
12025
+ this.notificationPicker = {
12026
+ stepKey: NOTIFICATION_PRESETS_STEP,
12027
+ defaultGroup: 'Notifications',
12028
+ // A notification addressed to nobody is inert, so with no roles we ask for roles instead of offering ticks
12029
+ emptyText: 'Create your team roles first — a notification needs somebody to go to.',
12030
+ doneText: 'Edit or remove it in Notif Config.',
12031
+ roleAria: 'Who to tell — ',
12032
+ showEmail: true,
12033
+ buttonLabel: count => count === 0 ? 'Turn on notifications' : count === 1 ? 'Turn on 1 notification' : `Turn on ${count} notifications`,
12034
+ toastLabel: created => created > 0 ? `${created} notification${created === 1 ? '' : 's'} turned on` : 'No new notifications were turned on',
12035
+ loadUrl: 'setup/notificationpresets',
12036
+ applyUrl: 'setup/notifications',
12037
+ load: () => this.setupService.loadNotificationPresets(),
12038
+ apply: items => this.setupService.applyNotificationPresets(items, this.includeEmail),
12039
+ presets: [], roles: [], groups: [], selected: {}, role: {}, count: 0, loaded: false, applying: false,
12040
+ action: { type: 'custom', display: 'Turn on notifications', primary: true, disabled: () => this.notificationPicker.count === 0 || this.notificationPicker.applying, onClick: () => this.applyPresets(this.notificationPicker) }
12041
+ };
12042
+ this.approvalPicker = {
12043
+ stepKey: APPROVAL_PRESETS_STEP,
12044
+ defaultGroup: 'Sign-off',
12045
+ // 🔴 NOT "create your team roles first". The backend offers only roles somebody is actually a MEMBER of, because
12046
+ // since SEC-10 only a member of the approver role can release a held record — so this list is empty in TWO
12047
+ // different situations: no roles at all, and roles that nobody has been added to. Naming only the first would be
12048
+ // flatly wrong in the second, and would send a user who already has roles off to create duplicates of them.
12049
+ emptyText: 'Nobody can approve anything yet — sign-off goes to a role, and only the people in that role can release a held record. Create your roles and add your team to one first.',
12050
+ doneText: 'Edit or remove it in Approvals Config.',
12051
+ roleAria: 'Who approves — ',
12052
+ showEmail: false, // an approval request is always in-app; an approver who misses it leaves records held
12053
+ buttonLabel: count => count === 0 ? 'Turn on sign-off' : count === 1 ? 'Require sign-off for 1 action' : `Require sign-off for ${count} actions`,
12054
+ toastLabel: created => created > 0 ? `Sign-off turned on for ${created} action${created === 1 ? '' : 's'}` : 'No new sign-off rules were added',
12055
+ loadUrl: 'setup/approvalpresets',
12056
+ applyUrl: 'setup/approvals',
12057
+ load: () => this.setupService.loadApprovalPresets(),
12058
+ apply: items => this.setupService.applyApprovalPresets(items),
12059
+ presets: [], roles: [], groups: [], selected: {}, role: {}, count: 0, loaded: false, applying: false,
12060
+ action: { type: 'custom', display: 'Turn on sign-off', primary: true, disabled: () => this.approvalPicker.count === 0 || this.approvalPicker.applying, onClick: () => this.applyPresets(this.approvalPicker) }
12061
+ };
12062
+ // Both pickers, in render order. Everything below iterates this rather than naming a domain.
12063
+ this.pickers = [this.notificationPicker, this.approvalPicker];
11407
12064
  //---------- Demo data ----------
11408
12065
  // Added: seed/remove example records. This used to sit on each app's hand-written App Configuration page,
11409
12066
  // which is where a user looking for it would never think to look — filling the system with example data is
@@ -11422,6 +12079,10 @@ class SetupGuideComponent {
11422
12079
  this.buildGroups();
11423
12080
  if (this.hasRoleTemplatesStep())
11424
12081
  this.loadRoleTemplates();
12082
+ // Added (presets): only load a picker when the backend actually serves its step. The approvals step is gated
12083
+ // on the approvals module, so turning that module off makes the step — and this call — disappear with it.
12084
+ this.pickers.forEach(picker => { if (this.hasPresetsStep(picker))
12085
+ this.loadPresets(picker); });
11425
12086
  });
11426
12087
  this.setupService.loadStatus();
11427
12088
  }
@@ -11445,9 +12106,13 @@ class SetupGuideComponent {
11445
12106
  buildActions(step) {
11446
12107
  const act = this.getAction(step);
11447
12108
  const actions = [];
12109
+ const picker = this.pickerFor(step); // Changed (presets P4): whichever picker owns this step, if any
11448
12110
  if (this.hasRoleTemplates(step)) {
11449
12111
  actions.push({ type: 'custom', display: 'Create selected roles', primary: true, disabled: () => this.selectedTemplateCount === 0 || this.creatingRoles, onClick: () => this.createSelectedRoles() });
11450
12112
  }
12113
+ else if (picker) {
12114
+ actions.push(picker.action); // Added (presets): the same object every rebuild, so the mutated count label survives a status refresh
12115
+ }
11451
12116
  else if (act.button) {
11452
12117
  actions.push({ type: 'create', display: this.primaryLabel(step), primary: true, onClick: () => this.doPrimary(step) }); // Changed: filled primary only for create dialogs — a bare link is never promoted to a filled button
11453
12118
  }
@@ -11528,9 +12193,13 @@ class SetupGuideComponent {
11528
12193
  this.togglingKey = '';
11529
12194
  if (resp.success)
11530
12195
  this.setupService.refresh();
12196
+ // Changed: the optimistic revert stays exactly as it was — it is correct and must not be lost — but it
12197
+ // was the ONLY feedback. A switch the user flipped silently flipping itself back is not a failure
12198
+ // message, it is a glitch: the likeliest reading is that the click missed, so they flip it again.
11531
12199
  else {
11532
12200
  mod.enabled = !target;
11533
12201
  this.recountModuleGroups();
12202
+ this.apiErrorService.presentAppFailure(resp, 'action', 'setup/module');
11534
12203
  }
11535
12204
  },
11536
12205
  error: () => { this.togglingKey = ''; mod.enabled = !target; this.recountModuleGroups(); }
@@ -11575,8 +12244,10 @@ class SetupGuideComponent {
11575
12244
  loadRoleTemplates() {
11576
12245
  this.setupService.loadRoleTemplates().subscribe({
11577
12246
  next: resp => {
11578
- if (!resp.success)
12247
+ if (!resp.success) {
12248
+ this.apiErrorService.presentAppFailure(resp, 'load', 'setup/roles');
11579
12249
  return;
12250
+ } // Changed: was a bare `return`. The picker then rendered zero templates, which reads as "this app ships no predefined roles" — a completely different statement from "we could not fetch them"
11580
12251
  this.roleTemplates = resp.data || [];
11581
12252
  // Preselect everything not yet created — the common case is "give me all of these"
11582
12253
  this.roleTemplates.forEach(t => { if (this.selectedTemplates[t.key] === undefined)
@@ -11588,11 +12259,120 @@ class SetupGuideComponent {
11588
12259
  get selectedTemplateCount() {
11589
12260
  return this.roleTemplates.filter(t => !t.exists && this.selectedTemplates[t.key]).length;
11590
12261
  }
11591
- // Added: whole-row toggle for the restyled picker (state-icon rows replaced the checkboxes)
11592
- toggleTemplate(tpl) {
11593
- if (tpl.exists)
12262
+ // Added: whole-row toggle for the restyled picker (state-icon rows replaced the checkboxes)
12263
+ toggleTemplate(tpl) {
12264
+ if (tpl.exists)
12265
+ return;
12266
+ this.selectedTemplates[tpl.key] = !this.selectedTemplates[tpl.key];
12267
+ }
12268
+ //---------- Preset pickers (notifications, sign-off) ----------
12269
+ // ONE code path, driven by the PresetPicker objects above. Tick a row, pick who it goes to, apply. Both backends
12270
+ // are create-only and skip anything already configured, so neither can overwrite a rule the user has since
12271
+ // edited, nor resurrect one they deleted.
12272
+ // Which picker (if any) renders inside this step. Called from the template, so it must stay cheap and must
12273
+ // return the SAME object each pass — find() over a two-element array returning an existing reference does.
12274
+ pickerFor(step) {
12275
+ return step ? this.pickers.find(p => p.stepKey === step.key) : undefined;
12276
+ }
12277
+ hasPresetsStep(picker) {
12278
+ return (this.status?.steps || []).some(s => s.key === picker.stepKey);
12279
+ }
12280
+ loadPresets(picker) {
12281
+ picker.load().subscribe({
12282
+ next: (resp) => {
12283
+ // Same reasoning as loadRoleTemplates: a bare return renders an empty picker, which reads as "this app
12284
+ // offers no presets" — a completely different statement from "we could not fetch them".
12285
+ if (!resp.success) {
12286
+ this.apiErrorService.presentAppFailure(resp, 'load', picker.loadUrl);
12287
+ return;
12288
+ }
12289
+ picker.loaded = true;
12290
+ picker.presets = resp.data?.presets || [];
12291
+ picker.roles = resp.data?.roles || [];
12292
+ picker.presets.forEach(p => {
12293
+ if (picker.selected[p.key] === undefined)
12294
+ picker.selected[p.key] = !p.exists; // preselect what is not yet configured; the undefined guard preserves a manual untick across a reload
12295
+ if (picker.role[p.key] === undefined && p.roleID)
12296
+ picker.role[p.key] = p.roleID; // the server-resolved default, kept unless the user changes it
12297
+ // Changed (presets P4): fall back to the FIRST offered role when the server could resolve none. A <select>
12298
+ // with no selected option still DISPLAYS its first one, so without this the row showed a role it had not
12299
+ // stored — the tick then submitted nothing while the screen said otherwise. Reachable whenever the
12300
+ // onboarding user holds no role of their own, which is exactly when nothing else can be resolved. Every
12301
+ // offered role is a legitimate choice (for sign-off the backend has already filtered to actionable ones),
12302
+ // so the worst case is a default the user changes — never a control that lies about what it will do.
12303
+ if (picker.role[p.key] === undefined && picker.roles.length > 0)
12304
+ picker.role[p.key] = picker.roles[0].value;
12305
+ });
12306
+ this.buildPresetGroups(picker);
12307
+ },
12308
+ error: () => { }
12309
+ });
12310
+ }
12311
+ // Materialize the rows once per load: group in catalogue order and split each Effect sentence around the
12312
+ // {role} token. The sentence itself is authored in PresetCatalog server-side — this only cuts it in two so the
12313
+ // select can sit in the gap. Nothing here composes wording. That is why the approval sentences can carry their
12314
+ // own caveat about a deferred notification without this component knowing such a caveat exists.
12315
+ buildPresetGroups(picker) {
12316
+ const names = [];
12317
+ picker.presets.forEach(p => { const name = p.group || picker.defaultGroup; if (!names.includes(name))
12318
+ names.push(name); });
12319
+ picker.groups = names.map(name => {
12320
+ const rows = picker.presets.filter(p => (p.group || picker.defaultGroup) === name).map(p => this.toPresetRow(p));
12321
+ return { name, rows, on: 0, total: rows.length };
12322
+ });
12323
+ this.recountPresets(picker);
12324
+ }
12325
+ toPresetRow(preset) {
12326
+ const parts = (preset.effect || '').split(PRESET_ROLE_TOKEN);
12327
+ // No token (an app author wrote a sentence without one) → the whole sentence leads and the select follows it.
12328
+ // Still a server sentence, still not composed here.
12329
+ return { preset, before: parts[0], after: parts.length > 1 ? parts.slice(1).join(PRESET_ROLE_TOKEN) : '' };
12330
+ }
12331
+ // Counters + button label. "on" counts what will be on after applying: already configured plus newly ticked.
12332
+ recountPresets(picker) {
12333
+ picker.groups.forEach(g => g.on = g.rows.filter(r => r.preset.exists || picker.selected[r.preset.key]).length);
12334
+ // Changed (presets P4): the count now requires a chosen role, matching EXACTLY what applyPresets submits. It
12335
+ // previously counted ticks alone, so with no selectable role the button read "Turn on 3 notifications", was
12336
+ // enabled, and applied nothing — the failure mode this feature must not have. Where roles exist the server
12337
+ // always stamps a default, so nothing changes for the ordinary case.
12338
+ picker.count = picker.presets.filter(p => !p.exists && picker.selected[p.key] && picker.role[p.key]).length;
12339
+ picker.action.display = picker.buttonLabel(picker.count);
12340
+ }
12341
+ // Whole-row toggle, mirroring toggleTemplate — a configured row is inert, never untickable
12342
+ togglePreset(picker, preset) {
12343
+ if (preset.exists)
11594
12344
  return;
11595
- this.selectedTemplates[tpl.key] = !this.selectedTemplates[tpl.key];
12345
+ picker.selected[preset.key] = !picker.selected[preset.key];
12346
+ this.recountPresets(picker);
12347
+ }
12348
+ // The role select lives inside the row, so its click must not reach the row's toggle
12349
+ setPresetRole(picker, preset, value) {
12350
+ picker.role[preset.key] = Number(value);
12351
+ this.recountPresets(picker); // a row that had no role now counts towards the button
12352
+ }
12353
+ toggleIncludeEmail() {
12354
+ this.includeEmail = !this.includeEmail;
12355
+ }
12356
+ applyPresets(picker) {
12357
+ // A preset with no role would create a rule addressed to nobody — or, for sign-off, a config nobody could
12358
+ // release — so it is never submitted
12359
+ const items = picker.presets.filter(p => !p.exists && picker.selected[p.key] && picker.role[p.key]).map(p => ({ key: p.key, roleID: picker.role[p.key] }));
12360
+ if (items.length === 0 || picker.applying)
12361
+ return;
12362
+ picker.applying = true;
12363
+ picker.apply(items).subscribe({
12364
+ next: (resp) => {
12365
+ picker.applying = false;
12366
+ if (!resp.success) {
12367
+ this.apiErrorService.presentAppFailure(resp, 'action', picker.applyUrl);
12368
+ return;
12369
+ } // never the server's own words on a failure
12370
+ this.messageService.toast(picker.toastLabel(resp.data || 0)); // call-site wording, like the Demo Data card
12371
+ this.loadPresets(picker); // re-reads Exists so the applied rows come back as Configured
12372
+ this.setupService.refresh(); // the step's count moves with the new configs
12373
+ },
12374
+ error: () => picker.applying = false
12375
+ });
11596
12376
  }
11597
12377
  get showDemoData() {
11598
12378
  return !!this.dataService.appConfig?.setupConfig?.demoData;
@@ -11613,9 +12393,16 @@ class SetupGuideComponent {
11613
12393
  this.dataService.CallApi({ url, method: 'post' }, {}).subscribe({
11614
12394
  next: (response) => {
11615
12395
  this.demoBusy = false;
11616
- this.messageService.toast(response.success ? success : (response.message || 'Demo data action failed'));
11617
- if (response.success)
11618
- this.setupService.refresh(); // seeded/removed records move the step counts
12396
+ // Changed: the SUCCESS path deliberately stays a toast `success` is a real human sentence written
12397
+ // at the call site ("Demo data seeded successfully") and a non-blocking toast is the right weight
12398
+ // for it. Only the FAILURE branch moves: it was toasting `response.message`, i.e. the server's own
12399
+ // words, so seeding against a schema-behind database put "Invalid column name" on screen.
12400
+ if (response.success) {
12401
+ this.messageService.toast(success);
12402
+ this.setupService.refresh();
12403
+ } // seeded/removed records move the step counts
12404
+ else
12405
+ this.apiErrorService.presentAppFailure(response, 'action', url);
11619
12406
  },
11620
12407
  error: () => this.demoBusy = false
11621
12408
  });
@@ -11633,16 +12420,19 @@ class SetupGuideComponent {
11633
12420
  this.loadRoleTemplates(); // refresh the Created chips
11634
12421
  this.setupService.refresh();
11635
12422
  }
12423
+ else {
12424
+ this.apiErrorService.presentAppFailure(resp, 'action', 'setup/roles'); // Added: was silent. The Created chips simply never appeared, so the user's read is "the roles were created but the page did not refresh" — and the next thing they do is assign people to roles that do not exist
12425
+ }
11636
12426
  },
11637
12427
  error: () => this.creatingRoles = false
11638
12428
  });
11639
12429
  }
11640
12430
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: SetupGuideComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
11641
- 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</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:12px;margin-top:14px}.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}.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}\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: "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: i5.MatProgressBar, selector: "mat-progress-bar", inputs: ["color", "value", "bufferValue", "mode"], outputs: ["animationEnd"], exportAs: ["matProgressBar"] }, { kind: "component", type: ChecklistComponent, selector: "spa-checklist", inputs: ["config", "itemTemplate"] }] }); }
12431
+ 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){.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"] }] }); }
11642
12432
  }
11643
12433
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: SetupGuideComponent, decorators: [{
11644
12434
  type: Component,
11645
- 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</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:12px;margin-top:14px}.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}.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}\n"] }]
12435
+ 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){.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"] }]
11646
12436
  }] });
11647
12437
 
11648
12438
  // Quiet Loading — perceived-progress engine (FSD D1).
@@ -12706,7 +13496,7 @@ class DateComponent {
12706
13496
  return "";
12707
13497
  }
12708
13498
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: DateComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
12709
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: DateComponent, isStandalone: false, selector: "spa-date", inputs: { required: "required", min: "min", max: "max", readonly: "readonly", hint: "hint", value: "value", display: "display", placeholder: "placeholder", width: "width", suffix: "suffix", infoMessage: "infoMessage", copyContent: "copyContent", clearContent: "clearContent" }, outputs: { valueChange: "valueChange" }, usesOnChanges: true, ngImport: i0, template: "<!-- Changed: Added mouse events for hover tracking, click handler for opening picker, and spa-suffix component -->\n<mat-form-field [class.spa-readonly]=\"readonly\" [ngStyle]=\"{'width':width ?? '100%'}\" subscriptSizing=\"dynamic\" (mouseenter)=\"onMouseEnter()\" (mouseleave)=\"onMouseLeave()\">\n <mat-label>{{display}}</mat-label>\n <input [formControl]=\"control\" [min]=\"minDate.value\" [max]=\"maxDate.value\" matInput [matDatepicker]=\"picker_date\" (dateInput)=\"onChangeEvent()\" [placeholder]=\"display\" [readonly]=\"true\" (click)=\"onInputClick(picker_date)\">\n <mat-datepicker #picker_date></mat-datepicker>\n <mat-error *ngIf=\"control.invalid\">{{validate(control)}}</mat-error>\n <div matSuffix class=\"suffix-icons\">\n <mat-datepicker-toggle [for]=\"picker_date\"></mat-datepicker-toggle>\n <spa-suffix [label]=\"suffix\" [infoMessage]=\"infoMessage\" [copyContent]=\"copyContent\" [clearContent]=\"clearContent\" [isHovered]=\"isHovered\" [(value)]=\"value\"></spa-suffix>\n </div>\n</mat-form-field>\n", styles: [""], dependencies: [{ 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: 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: i2$2.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "component", type: i3$1.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i3$1.MatLabel, selector: "mat-label" }, { kind: "directive", type: i3$1.MatError, selector: "mat-error, [matError]", inputs: ["id"] }, { kind: "directive", type: i3$1.MatSuffix, selector: "[matSuffix], [matIconSuffix], [matTextSuffix]", inputs: ["matTextSuffix"] }, { kind: "directive", type: i4$2.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "component", type: i5$1.MatDatepicker, selector: "mat-datepicker", exportAs: ["matDatepicker"] }, { kind: "directive", type: i5$1.MatDatepickerInput, selector: "input[matDatepicker]", inputs: ["matDatepicker", "min", "max", "matDatepickerFilter"], exportAs: ["matDatepickerInput"] }, { kind: "component", type: i5$1.MatDatepickerToggle, selector: "mat-datepicker-toggle", inputs: ["for", "tabIndex", "aria-label", "disabled", "disableRipple"], exportAs: ["matDatepickerToggle"] }, { kind: "component", type: SuffixComponent, selector: "spa-suffix", inputs: ["label", "infoMessage", "copyContent", "isHovered", "clearContent", "value"], outputs: ["infoClick", "copyClick", "clearClick", "valueChange"] }] }); }
13499
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: DateComponent, isStandalone: false, selector: "spa-date", inputs: { required: "required", min: "min", max: "max", readonly: "readonly", hint: "hint", value: "value", display: "display", placeholder: "placeholder", width: "width", suffix: "suffix", infoMessage: "infoMessage", copyContent: "copyContent", clearContent: "clearContent" }, outputs: { valueChange: "valueChange" }, usesOnChanges: true, ngImport: i0, template: "<!-- Changed: Added mouse events for hover tracking, click handler for opening picker, and spa-suffix component -->\n<mat-form-field [class.spa-readonly]=\"readonly\" [ngStyle]=\"{'width':width ?? '100%'}\" subscriptSizing=\"dynamic\" (mouseenter)=\"onMouseEnter()\" (mouseleave)=\"onMouseLeave()\">\n <mat-label>{{display}}</mat-label>\n <input [formControl]=\"control\" [min]=\"minDate.value\" [max]=\"maxDate.value\" matInput [matDatepicker]=\"picker_date\" (dateInput)=\"onChangeEvent()\" [placeholder]=\"display\" [readonly]=\"true\" (click)=\"onInputClick(picker_date)\">\n <mat-datepicker #picker_date></mat-datepicker>\n <mat-error *ngIf=\"control.invalid\">{{validate(control)}}</mat-error>\n <div matSuffix class=\"suffix-icons\">\n <mat-datepicker-toggle [for]=\"picker_date\"></mat-datepicker-toggle>\n <spa-suffix [label]=\"suffix\" [infoMessage]=\"infoMessage\" [copyContent]=\"copyContent\" [clearContent]=\"clearContent\" [isHovered]=\"isHovered\" [(value)]=\"value\"></spa-suffix>\n </div>\n</mat-form-field>\n", styles: [""], dependencies: [{ 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: 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: i2$2.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "component", type: i3$1.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i3$1.MatLabel, selector: "mat-label" }, { kind: "directive", type: i3$1.MatError, selector: "mat-error, [matError]", inputs: ["id"] }, { kind: "directive", type: i3$1.MatSuffix, selector: "[matSuffix], [matIconSuffix], [matTextSuffix]", inputs: ["matTextSuffix"] }, { kind: "directive", type: i4$2.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "component", type: i5.MatDatepicker, selector: "mat-datepicker", exportAs: ["matDatepicker"] }, { kind: "directive", type: i5.MatDatepickerInput, selector: "input[matDatepicker]", inputs: ["matDatepicker", "min", "max", "matDatepickerFilter"], exportAs: ["matDatepickerInput"] }, { kind: "component", type: i5.MatDatepickerToggle, selector: "mat-datepicker-toggle", inputs: ["for", "tabIndex", "aria-label", "disabled", "disableRipple"], exportAs: ["matDatepickerToggle"] }, { kind: "component", type: SuffixComponent, selector: "spa-suffix", inputs: ["label", "infoMessage", "copyContent", "isHovered", "clearContent", "value"], outputs: ["infoClick", "copyClick", "clearClick", "valueChange"] }] }); }
12710
13500
  }
12711
13501
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: DateComponent, decorators: [{
12712
13502
  type: Component,
@@ -13480,7 +14270,7 @@ class EditorComponent {
13480
14270
  this.valueChange.emit(val);
13481
14271
  }
13482
14272
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: EditorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
13483
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: EditorComponent, isStandalone: false, selector: "spa-editor", inputs: { display: "display", value: "value", readonly: "readonly", required: "required", hint: "hint", infoMessage: "infoMessage", placeholder: "placeholder", width: "width", height: "height", minHeight: "minHeight", defaultFontName: "defaultFontName", editorConfig: "editorConfig" }, outputs: { valueChange: "valueChange" }, usesOnChanges: true, ngImport: i0, template: "<!-- Rich-text / WYSIWYG editor field -->\n<div class=\"spa-editor-wrap\" [class.spa-readonly]=\"readonly\" [ngStyle]=\"{'width': width ?? '100%'}\">\n\n <div class=\"spa-editor-label\" *ngIf=\"display\">\n <label>{{display}}<span *ngIf=\"required\" class=\"spa-editor-req\"> *</span></label>\n <mat-icon *ngIf=\"infoMessage\" class=\"spa-editor-info\" matTooltip=\"{{infoMessage}}\" matTooltipPosition=\"above\">info</mat-icon>\n </div>\n\n <angular-editor\n [ngModel]=\"value\"\n (ngModelChange)=\"changed($event)\"\n [config]=\"config\"\n [placeholder]=\"placeholder\">\n </angular-editor>\n\n <div class=\"spa-editor-hint\" *ngIf=\"hint\">{{hint}}</div>\n</div>\n", styles: [".spa-editor-wrap{display:block;margin-right:5px;margin-bottom:4px}.spa-editor-label{display:flex;align-items:center;gap:6px;font-size:12px;color:#0009;margin-bottom:4px}.spa-editor-req{color:#f44336}.spa-editor-info{color:#4682b4;font-size:16px;width:16px;height:16px;cursor:help}.spa-editor-hint{font-size:11px;color:#0009;margin-top:2px}.spa-editor-wrap :deep(angular-editor .angular-editor-wrapper),.spa-editor-wrap angular-editor{border-radius:4px}\n"], dependencies: [{ 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: i2$2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { 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$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: i5$2.AngularEditorComponent, selector: "angular-editor", inputs: ["id", "config", "placeholder", "tabIndex"], outputs: ["html", "viewMode", "blur", "focus"] }] }); }
14273
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: EditorComponent, isStandalone: false, selector: "spa-editor", inputs: { display: "display", value: "value", readonly: "readonly", required: "required", hint: "hint", infoMessage: "infoMessage", placeholder: "placeholder", width: "width", height: "height", minHeight: "minHeight", defaultFontName: "defaultFontName", editorConfig: "editorConfig" }, outputs: { valueChange: "valueChange" }, usesOnChanges: true, ngImport: i0, template: "<!-- Rich-text / WYSIWYG editor field -->\n<div class=\"spa-editor-wrap\" [class.spa-readonly]=\"readonly\" [ngStyle]=\"{'width': width ?? '100%'}\">\n\n <div class=\"spa-editor-label\" *ngIf=\"display\">\n <label>{{display}}<span *ngIf=\"required\" class=\"spa-editor-req\"> *</span></label>\n <mat-icon *ngIf=\"infoMessage\" class=\"spa-editor-info\" matTooltip=\"{{infoMessage}}\" matTooltipPosition=\"above\">info</mat-icon>\n </div>\n\n <angular-editor\n [ngModel]=\"value\"\n (ngModelChange)=\"changed($event)\"\n [config]=\"config\"\n [placeholder]=\"placeholder\">\n </angular-editor>\n\n <div class=\"spa-editor-hint\" *ngIf=\"hint\">{{hint}}</div>\n</div>\n", styles: [".spa-editor-wrap{display:block;margin-right:5px;margin-bottom:4px}.spa-editor-label{display:flex;align-items:center;gap:6px;font-size:12px;color:#0009;margin-bottom:4px}.spa-editor-req{color:#f44336}.spa-editor-info{color:#4682b4;font-size:16px;width:16px;height:16px;cursor:help}.spa-editor-hint{font-size:11px;color:#0009;margin-top:2px}.spa-editor-wrap :deep(angular-editor .angular-editor-wrapper),.spa-editor-wrap angular-editor{border-radius:4px}\n"], dependencies: [{ 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: i2$2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { 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$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: i5$1.AngularEditorComponent, selector: "angular-editor", inputs: ["id", "config", "placeholder", "tabIndex"], outputs: ["html", "viewMode", "blur", "focus"] }] }); }
13484
14274
  }
13485
14275
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: EditorComponent, decorators: [{
13486
14276
  type: Component,
@@ -14097,6 +14887,23 @@ class TilesComponent {
14097
14887
  isHidden(tile) {
14098
14888
  return !Core.isItemVisible(tile, this.data); // Changed: unified visible/hidden replaces hidden + hiddenCondition
14099
14889
  }
14890
+ // Added: the tile figure as it should READ. Returns a primitive (never a new object), so binding it in the
14891
+ // template is safe under OnPush — the no-allocating-getters rule is about object identity, not strings.
14892
+ // Absent Tile.format the value is returned untouched, so every existing tile renders exactly as before.
14893
+ displayValue(tile) {
14894
+ const value = this.data?.[tile.name];
14895
+ if (value === null || value === undefined)
14896
+ return tile.chart ? value : 0; // Changed: preserves the old `?? 0` for non-chart tiles and the old blank for chart tiles
14897
+ if (typeof value !== 'number' || !tile.format)
14898
+ return value;
14899
+ if (tile.format === 'money')
14900
+ return value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
14901
+ return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
14902
+ }
14903
+ // Added: resolves Tile.color, which may now be a function of the tile data
14904
+ tileColor(tile) {
14905
+ return typeof tile.color === 'function' ? tile.color(this.data) : tile.color;
14906
+ }
14100
14907
  // Changed: Returns true only when data[tile.name] is a displayable primitive and not already shown by the chart
14101
14908
  isTileValuePrimitive(tile) {
14102
14909
  if (tile.chart)
@@ -14261,11 +15068,11 @@ class TilesComponent {
14261
15068
  return [];
14262
15069
  }
14263
15070
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: TilesComponent, deps: [{ token: DataServiceLib }, { token: MessageService }, { token: i0.ChangeDetectorRef }, { token: ApiErrorService }], target: i0.ɵɵFactoryTarget.Component }); }
14264
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: TilesComponent, isStandalone: false, selector: "spa-tiles", inputs: { config: "config", lastSearch: "lastSearch", data: "data", reload: "reload" }, outputs: { tileActionSelected: "tileActionSelected", tileClick: "tileClick", tileUnClick: "tileUnClick" }, viewQueries: [{ propertyName: "tileScroller", first: true, predicate: ["tileScroller"], descendants: true }], usesOnChanges: true, ngImport: i0, template: "<!-- Tile row layout. THREE modes, and the DEFAULT is the original.\n 'auto' (default, and what every existing page gets): the original bootstrap .row/.col \u2014 tiles stretch to\n fill and shrink to a 150px floor, wrapping when they run out of room. This is byte-for-byte the markup\n that shipped before, restored on the owner's instruction 2026-08-08: the grid I briefly made the default\n forced ONE tile per row on a phone, because a 200px track floor does not fit twice in a ~380px viewport.\n 'wrap': a grid of equal tracks \u2014 opt in where uniform tile widths matter more than filling the row.\n 'carousel': one row, horizontal scroll. The Day Book opts into this.\n The tile body itself is ONE template shared by all three, so the modes can never drift apart. -->\n\n<!-- AUTO \u2014 the original layout, unchanged -->\n<div *ngIf=\"isAuto\" class=\"d-flex row align-items-center justify-content-between\" [class.hide-value-mobile]=\"hideValueMobile\">\n <ng-container *ngFor=\"let tile of tiles\">\n <!-- Changed: tile-clickable class gives pointer + hover lift only on tiles that actually respond to clicks -->\n <mat-card *ngIf=\"!isHidden(tile)\" class=\"col\" [class.tile-clickable]=\"isClickable(tile)\" [class.selected-tile]=\"tile.name === selectedTile\" style=\"margin-left: 5px;margin-right: 5px; padding: 10px 16px ; min-width: 150px; margin-top: 5px;\" (click)=\"clicked(tile)\">\n <ng-container *ngTemplateOutlet=\"tileBody; context: ctx(tile)\"></ng-container>\n </mat-card>\n </ng-container>\n</div>\n\n<!-- WRAP / CAROUSEL \u2014 opt-in only -->\n<div *ngIf=\"!isAuto\" class=\"spa-tiles-outer\">\n\n <!-- Carousel only: scroll affordances, shown solely when there is something to scroll to -->\n <button type=\"button\" class=\"tiles-nav\" *ngIf=\"isCarousel && canScrollLeft\" (click)=\"scrollTiles(-1)\" aria-label=\"Scroll tiles left\">\n <mat-icon>chevron_left</mat-icon>\n </button>\n\n <div #tileScroller class=\"spa-tiles\" [class.tiles-wrap]=\"!isCarousel\" [class.tiles-carousel]=\"isCarousel\" [class.hide-value-mobile]=\"hideValueMobile\" [ngStyle]=\"tilesStyle\" (scroll)=\"onTilesScroll()\">\n <ng-container *ngFor=\"let tile of tiles\">\n <mat-card *ngIf=\"!isHidden(tile)\" class=\"tile-card\" [class.tile-clickable]=\"isClickable(tile)\" [class.selected-tile]=\"tile.name === selectedTile\" (click)=\"clicked(tile)\">\n <ng-container *ngTemplateOutlet=\"tileBody; context: ctx(tile)\"></ng-container>\n </mat-card>\n </ng-container>\n </div>\n\n <button type=\"button\" class=\"tiles-nav\" *ngIf=\"isCarousel && canScrollRight\" (click)=\"scrollTiles(1)\" aria-label=\"Scroll tiles right\">\n <mat-icon>chevron_right</mat-icon>\n </button>\n\n</div>\n\n<!-- The tile body, shared by all three modes. ctx() returns a CACHED context object per tile \u2014 building\n `{ $implicit: tile }` inline would hand NgTemplateOutlet a new object every change-detection pass and\n re-create every tile's view, which is the documented cause of a real livelock in this library. -->\n<ng-template #tileBody let-tile>\n\n <!-- Changed: Chart-style tile \u2014 header, prominent chart, optional value, footer -->\n <ng-container *ngIf=\"tile.chart; else standardTile\">\n <!-- Changed: Header with tile name \u2014 left-aligned for better hierarchy -->\n <div class=\"tile-chart-header\">\n <span>{{tile.alias ?? tile.name | camelToWords}}</span>\n </div>\n\n <!-- Changed: Chart fills tile \u2014 uses helper method for reliable data detection -->\n <div class=\"tile-chart\" *ngIf=\"hasTileChartData(tile)\" [style.height.px]=\"tile.chart.height ?? 120\">\n <canvas baseChart\n [type]=\"tile.chart.type\"\n [data]=\"getTileMiniChartData(tile)\"\n [options]=\"getMiniChartOptions(tile)\"\n [plugins]=\"getTileChartPlugins(tile)\">\n </canvas>\n </div>\n\n <!-- Changed: Optional value display below chart \u2014 only show primitive values, skip chart data objects -->\n <div class=\"tile-chart-value\" *ngIf=\"tile.name && isTileValuePrimitive(tile)\">\n <span class=\"tile-chart-value-text\" [style.color]=\"tile.color\">{{tile.prefix ?? ''}}{{data?.[tile.name]}}<span *ngIf=\"tile.suffix\"> {{tile.suffix}}</span></span>\n </div>\n\n <!-- Changed: Footer with divider for chart tiles -->\n <div class=\"tile-footer\" *ngIf=\"tile.footer || tile.info\">\n <mat-divider></mat-divider>\n <div class=\"d-flex align-items-center\" style=\"gap: 4px; color: #9a9a9a; font-size: 12px; margin-top: 6px;\">\n <mat-icon *ngIf=\"tile.footerIcon\" style=\"font-size: 16px; width: 16px; height: 16px;\">{{tile.footerIcon}}</mat-icon>\n <mat-icon *ngIf=\"!tile.footerIcon && tile.info\" style=\"font-size: 16px; width: 16px; height: 16px; color: steelblue;\">info</mat-icon>\n <span>{{tile.footer ?? tile.info}}</span>\n </div>\n </div>\n </ng-container>\n\n <!-- Changed: Standard tile \u2014 Paper Dashboard style: icon left, label+value right, optional footer -->\n <ng-template #standardTile>\n <!-- Changed: Icon-style tile \u2014 icon left, label top-right, large value below -->\n <ng-container *ngIf=\"tile.icon; else basicTile\">\n <div class=\"tile-icon-row\">\n <div class=\"tile-icon-wrap\" [style.color]=\"tile.color ?? '#2196f3'\">\n <mat-icon>{{tile.icon}}</mat-icon>\n </div>\n <div class=\"tile-icon-content\">\n <div class=\"tile-icon-label\">{{tile.alias ?? tile.name | camelToWords}}</div>\n <!-- Changed (Quiet Loading D5): while the value is genuinely unknown the slot shows a shimmer chip instead of the old lying `0`; icon, label and footer render normally so the card never resizes. On a reload the previous number stays put and simply updates. -->\n <div class=\"tile-icon-value\" [style.color]=\"tile.color\" [attr.aria-busy]=\"showValueGhost(tile) ? 'true' : null\">\n <span *ngIf=\"tile.prefix\">{{tile.prefix}}</span><span *ngIf=\"showValueGhost(tile); else iconTileValue\" class=\"tin-skel tin-skel-text tile-value-ghost\" aria-hidden=\"true\"></span><ng-template #iconTileValue><span [class.tile-value-in]=\"effQuietLoading\">{{data?.[tile.name] ?? 0}}</span></ng-template><span *ngIf=\"tile.suffix\"> {{tile.suffix}}</span>\n <span *ngIf=\"tile.badge && data?.[tile.badge]\" class=\"tile-badge\" [style.backgroundColor]=\"tile.badgeColor ?? '#4caf50'\">{{data?.[tile.badge]}}</span>\n </div>\n </div>\n </div>\n <!-- Changed: Footer with divider \u2014 info tooltip or custom footer text -->\n <div class=\"tile-icon-footer\" *ngIf=\"tile.info || tile.footer\">\n <mat-divider></mat-divider>\n <div class=\"tile-icon-footer-content\">\n <mat-icon *ngIf=\"tile.footerIcon\" class=\"tile-icon-footer-icon\">{{tile.footerIcon}}</mat-icon>\n <mat-icon *ngIf=\"!tile.footerIcon && tile.info\" class=\"tile-icon-footer-icon\" style=\"color: steelblue;\">info</mat-icon>\n <span>{{tile.footer ?? tile.info}}</span>\n </div>\n </div>\n </ng-container>\n\n <!-- Basic tile fallback \u2014 centered number display (no icon) -->\n <ng-template #basicTile>\n <div class=\"row d-flex justify-content-center align-items-center\">\n <div style=\"text-align: center;font-size: 30px;\">\n <mat-label style=\"font-weight:bold;\" *ngIf=\"tile.prefix\" >{{tile.prefix}}</mat-label> &nbsp;\n <!-- Changed (Quiet Loading D5): same value ghost as the icon tile \u2014 chip while unknown, real number otherwise -->\n <mat-label style=\"font-weight:bold; text-align: center;\" [ngStyle]=\"{'color':tile.color }\" [attr.aria-busy]=\"showValueGhost(tile) ? 'true' : null\"><span *ngIf=\"showValueGhost(tile); else basicTileValue\" class=\"tin-skel tin-skel-text tile-value-ghost\" aria-hidden=\"true\"></span><ng-template #basicTileValue><span [class.tile-value-in]=\"effQuietLoading\">{{data?.[tile.name] ?? 0}}</span></ng-template></mat-label>&nbsp;\n <mat-label style=\"font-weight:bold;\" *ngIf=\"tile.suffix\">{{tile.suffix}}</mat-label>\n <span *ngIf=\"tile.badge && data?.[tile.badge]\" class=\"tile-badge\" [style.backgroundColor]=\"tile.badgeColor ?? '#4caf50'\">{{data?.[tile.badge]}}</span>\n </div>\n </div>\n <div class=\"row d-flex justify-content-center align-items-center\">\n <div class=\"d-flex justify-content-center align-items-center\" style=\"text-align: center;\">\n <mat-label style=\"padding-left:5px;padding-right:5px; text-align: center;font-size: 14px;\">{{tile.alias ?? tile.name | camelToWords}}</mat-label>\n <mat-icon *ngIf=\"tile.info\" [matTooltip]=\"tile.info\" matTooltipPosition=\"above\" style=\"font-size: 20px; color:steelblue;\">info</mat-icon>\n </div>\n </div>\n </ng-template>\n </ng-template>\n\n</ng-template>\n", styles: [".card{min-width:180px;flex:1;display:flex;flex-direction:column;align-items:center;padding:5px 10px}.tiles{gap:1;row-gap:5px}.spa-tiles-outer{display:flex;align-items:center;gap:4px}.spa-tiles{flex:1;min-width:0}.tiles-wrap{display:grid;gap:10px;align-items:stretch}.tiles-carousel{display:flex;gap:10px;overflow-x:auto;scroll-behavior:smooth;scroll-snap-type:x proximity;padding-bottom:6px}.tiles-carousel>.tile-card{scroll-snap-align:start;flex:0 0 var(--tile-basis, 200px)}@media (max-width: 700px){.tiles-nav{display:none}.tiles-carousel{gap:8px}.tiles-carousel>.tile-card{flex:0 0 44%}.tile-card{padding:8px 10px}.tile-icon-row{gap:8px;padding:2px 0}.tile-icon-wrap{width:30px;height:30px}.tile-icon-wrap mat-icon{font-size:26px;width:26px;height:26px}.tile-icon-label{font-size:10.5px;letter-spacing:.1px;line-height:1.25}.tile-icon-value{font-size:22px}.tile-icon-footer{margin-top:5px}.tile-icon-footer mat-divider{margin-bottom:4px}.tile-icon-footer-content{font-size:10.5px;gap:3px;line-height:1.25}.tile-icon-footer-icon{font-size:13px;width:13px;height:13px}.hide-value-mobile .tile-badge{display:none}}.tile-card{padding:10px 16px;margin:0}.tiles-nav{flex:0 0 auto;display:flex;align-items:center;justify-content:center;width:28px;height:28px;border:none;border-radius:50%;background:#0000000a;color:#666;cursor:pointer;transition:background-color .2s ease,color .2s ease}.tiles-nav:hover{background:#2196f31f;color:#2196f3}.tiles-nav mat-icon{font-size:20px;width:20px;height:20px}.col{transition:all .2s ease}.tile-clickable{cursor:pointer}.tile-clickable:hover{transform:translateY(-2px);box-shadow:0 4px 10px #00000021;background-color:#2196f312}.selected-tile{background-color:#e0e0e0;box-shadow:0 4px 8px #0003;transform:translateY(-2px);border:2px solid #3f51b5}.selected-tile mat-label{font-weight:700}.selected-tile:hover{background-color:#e0e0e0}.tile-chart-header{display:flex;justify-content:flex-start;font-size:11px;font-weight:500;color:#999;text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px}.tile-chart{width:100%;position:relative;margin-top:4px;display:flex;align-items:center;justify-content:center}.tile-chart canvas{width:100%!important;height:100%!important;max-height:inherit}.tile-chart-value{text-align:center;margin-top:6px}.tile-chart-value-text{font-size:22px;font-weight:600;letter-spacing:-.5px}.tile-badge{display:inline-block;font-size:12px;font-weight:500;color:#fff;padding:2px 8px;border-radius:12px;vertical-align:middle;margin-left:4px}.tile-footer{margin-top:8px}.tile-icon-row{display:flex;align-items:flex-start;gap:12px;padding:4px 0}.tile-icon-wrap{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:48px;height:48px}.tile-icon-wrap mat-icon{font-size:36px;width:36px;height:36px;opacity:.85}.tile-icon-content{flex:1;text-align:right;min-width:0}.tile-icon-label{font-size:12px;color:#999;text-transform:uppercase;letter-spacing:.3px;line-height:1.4}.tile-icon-value{font-size:26px;font-weight:600;line-height:1.2;letter-spacing:-.5px}.tile-icon-footer{margin-top:8px}.tile-icon-footer mat-divider{margin-bottom:6px}.tile-icon-footer-content{display:flex;align-items:center;gap:4px;font-size:12px;color:#999}.tile-icon-footer-icon{font-size:16px;width:16px;height:16px;color:#bbb}.tile-value-ghost{width:2.5ch;height:.72em;vertical-align:middle}.tile-value-in{animation:tile-value-in .15s ease-out both}@keyframes tile-value-in{0%{opacity:0}to{opacity:1}}@media (prefers-reduced-motion: reduce){.tile-value-in{animation: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.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "directive", type: i1$2.NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }, { kind: "component", type: i4$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "directive", type: i3$1.MatLabel, selector: "mat-label" }, { kind: "component", type: i19.MatCard, selector: "mat-card", inputs: ["appearance"], exportAs: ["matCard"] }, { kind: "component", type: i17.MatDivider, selector: "mat-divider", inputs: ["vertical", "inset"] }, { kind: "directive", type: i7.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "directive", type: i10.BaseChartDirective, selector: "canvas[baseChart]", inputs: ["type", "legend", "data", "options", "plugins", "labels", "datasets"], outputs: ["chartClick", "chartHover"], exportAs: ["base-chart"] }, { kind: "pipe", type: CamelToWordsPipe, name: "camelToWords" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
15071
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: TilesComponent, isStandalone: false, selector: "spa-tiles", inputs: { config: "config", lastSearch: "lastSearch", data: "data", reload: "reload" }, outputs: { tileActionSelected: "tileActionSelected", tileClick: "tileClick", tileUnClick: "tileUnClick" }, viewQueries: [{ propertyName: "tileScroller", first: true, predicate: ["tileScroller"], descendants: true }], usesOnChanges: true, ngImport: i0, template: "<!-- Tile row layout. THREE modes, and the DEFAULT is the original.\n 'auto' (default, and what every existing page gets): the original bootstrap .row/.col \u2014 tiles stretch to\n fill and shrink to a 150px floor, wrapping when they run out of room. This is byte-for-byte the markup\n that shipped before, restored on the owner's instruction 2026-08-08: the grid I briefly made the default\n forced ONE tile per row on a phone, because a 200px track floor does not fit twice in a ~380px viewport.\n 'wrap': a grid of equal tracks \u2014 opt in where uniform tile widths matter more than filling the row.\n 'carousel': one row, horizontal scroll. The Day Book opts into this.\n The tile body itself is ONE template shared by all three, so the modes can never drift apart. -->\n\n<!-- AUTO \u2014 the original layout, unchanged -->\n<div *ngIf=\"isAuto\" class=\"d-flex row align-items-center justify-content-between\" [class.hide-value-mobile]=\"hideValueMobile\">\n <ng-container *ngFor=\"let tile of tiles\">\n <!-- Changed: tile-clickable class gives pointer + hover lift only on tiles that actually respond to clicks -->\n <mat-card *ngIf=\"!isHidden(tile)\" class=\"col\" [class.tile-clickable]=\"isClickable(tile)\" [class.selected-tile]=\"tile.name === selectedTile\" style=\"margin-left: 5px;margin-right: 5px; padding: 10px 16px ; min-width: 150px; margin-top: 5px;\" (click)=\"clicked(tile)\">\n <ng-container *ngTemplateOutlet=\"tileBody; context: ctx(tile)\"></ng-container>\n </mat-card>\n </ng-container>\n</div>\n\n<!-- WRAP / CAROUSEL \u2014 opt-in only -->\n<div *ngIf=\"!isAuto\" class=\"spa-tiles-outer\">\n\n <!-- Carousel only: scroll affordances, shown solely when there is something to scroll to -->\n <button type=\"button\" class=\"tiles-nav\" *ngIf=\"isCarousel && canScrollLeft\" (click)=\"scrollTiles(-1)\" aria-label=\"Scroll tiles left\">\n <mat-icon>chevron_left</mat-icon>\n </button>\n\n <div #tileScroller class=\"spa-tiles\" [class.tiles-wrap]=\"!isCarousel\" [class.tiles-carousel]=\"isCarousel\" [class.hide-value-mobile]=\"hideValueMobile\" [ngStyle]=\"tilesStyle\" (scroll)=\"onTilesScroll()\">\n <ng-container *ngFor=\"let tile of tiles\">\n <mat-card *ngIf=\"!isHidden(tile)\" class=\"tile-card\" [class.tile-clickable]=\"isClickable(tile)\" [class.selected-tile]=\"tile.name === selectedTile\" (click)=\"clicked(tile)\">\n <ng-container *ngTemplateOutlet=\"tileBody; context: ctx(tile)\"></ng-container>\n </mat-card>\n </ng-container>\n </div>\n\n <button type=\"button\" class=\"tiles-nav\" *ngIf=\"isCarousel && canScrollRight\" (click)=\"scrollTiles(1)\" aria-label=\"Scroll tiles right\">\n <mat-icon>chevron_right</mat-icon>\n </button>\n\n</div>\n\n<!-- The tile body, shared by all three modes. ctx() returns a CACHED context object per tile \u2014 building\n `{ $implicit: tile }` inline would hand NgTemplateOutlet a new object every change-detection pass and\n re-create every tile's view, which is the documented cause of a real livelock in this library. -->\n<ng-template #tileBody let-tile>\n\n <!-- Changed: Chart-style tile \u2014 header, prominent chart, optional value, footer -->\n <ng-container *ngIf=\"tile.chart; else standardTile\">\n <!-- Changed: Header with tile name \u2014 left-aligned for better hierarchy -->\n <div class=\"tile-chart-header\">\n <span>{{tile.alias ?? tile.name | camelToWords}}</span>\n </div>\n\n <!-- Changed: Chart fills tile \u2014 uses helper method for reliable data detection -->\n <div class=\"tile-chart\" *ngIf=\"hasTileChartData(tile)\" [style.height.px]=\"tile.chart.height ?? 120\">\n <canvas baseChart\n [type]=\"tile.chart.type\"\n [data]=\"getTileMiniChartData(tile)\"\n [options]=\"getMiniChartOptions(tile)\"\n [plugins]=\"getTileChartPlugins(tile)\">\n </canvas>\n </div>\n\n <!-- Changed: Optional value display below chart \u2014 only show primitive values, skip chart data objects -->\n <div class=\"tile-chart-value\" *ngIf=\"tile.name && isTileValuePrimitive(tile)\">\n <span class=\"tile-chart-value-text\" [style.color]=\"tileColor(tile)\">{{tile.prefix ?? ''}}{{displayValue(tile)}}<span *ngIf=\"tile.suffix\"> {{tile.suffix}}</span></span><!-- Changed: colour may be a function of the data; value honours Tile.format -->\n </div>\n\n <!-- Changed: Footer with divider for chart tiles -->\n <div class=\"tile-footer\" *ngIf=\"tile.footer || tile.info\">\n <mat-divider></mat-divider>\n <div class=\"d-flex align-items-center\" style=\"gap: 4px; color: #9a9a9a; font-size: 12px; margin-top: 6px;\">\n <mat-icon *ngIf=\"tile.footerIcon\" style=\"font-size: 16px; width: 16px; height: 16px;\">{{tile.footerIcon}}</mat-icon>\n <mat-icon *ngIf=\"!tile.footerIcon && tile.info\" style=\"font-size: 16px; width: 16px; height: 16px; color: steelblue;\">info</mat-icon>\n <span>{{tile.footer ?? tile.info}}</span>\n </div>\n </div>\n </ng-container>\n\n <!-- Changed: Standard tile \u2014 Paper Dashboard style: icon left, label+value right, optional footer -->\n <ng-template #standardTile>\n <!-- Changed: Icon-style tile \u2014 icon left, label top-right, large value below -->\n <ng-container *ngIf=\"tile.icon; else basicTile\">\n <div class=\"tile-icon-row\">\n <div class=\"tile-icon-wrap\" [style.color]=\"tileColor(tile) ?? '#2196f3'\"><!-- Changed: colour may be a function of the data -->\n <mat-icon>{{tile.icon}}</mat-icon>\n </div>\n <div class=\"tile-icon-content\">\n <div class=\"tile-icon-label\">{{tile.alias ?? tile.name | camelToWords}}</div>\n <!-- Changed (Quiet Loading D5): while the value is genuinely unknown the slot shows a shimmer chip instead of the old lying `0`; icon, label and footer render normally so the card never resizes. On a reload the previous number stays put and simply updates. -->\n <div class=\"tile-icon-value\" [style.color]=\"tileColor(tile)\" [attr.aria-busy]=\"showValueGhost(tile) ? 'true' : null\"><!-- Changed: colour may be a function of the data -->\n <span *ngIf=\"tile.prefix\">{{tile.prefix}}</span><span *ngIf=\"showValueGhost(tile); else iconTileValue\" class=\"tin-skel tin-skel-text tile-value-ghost\" aria-hidden=\"true\"></span><ng-template #iconTileValue><span [class.tile-value-in]=\"effQuietLoading\">{{displayValue(tile)}}</span></ng-template><span *ngIf=\"tile.suffix\"> {{tile.suffix}}</span><!-- Changed: value honours Tile.format; displayValue keeps the old `?? 0` -->\n <span *ngIf=\"tile.badge && data?.[tile.badge]\" class=\"tile-badge\" [style.backgroundColor]=\"tile.badgeColor ?? '#4caf50'\">{{data?.[tile.badge]}}</span>\n </div>\n </div>\n </div>\n <!-- Changed: Footer with divider \u2014 info tooltip or custom footer text -->\n <div class=\"tile-icon-footer\" *ngIf=\"tile.info || tile.footer\">\n <mat-divider></mat-divider>\n <div class=\"tile-icon-footer-content\">\n <mat-icon *ngIf=\"tile.footerIcon\" class=\"tile-icon-footer-icon\">{{tile.footerIcon}}</mat-icon>\n <mat-icon *ngIf=\"!tile.footerIcon && tile.info\" class=\"tile-icon-footer-icon\" style=\"color: steelblue;\">info</mat-icon>\n <span>{{tile.footer ?? tile.info}}</span>\n </div>\n </div>\n </ng-container>\n\n <!-- Basic tile fallback \u2014 centered number display (no icon) -->\n <ng-template #basicTile>\n <div class=\"row d-flex justify-content-center align-items-center\">\n <div style=\"text-align: center;font-size: 30px;\">\n <mat-label style=\"font-weight:bold;\" *ngIf=\"tile.prefix\" >{{tile.prefix}}</mat-label> &nbsp;\n <!-- Changed (Quiet Loading D5): same value ghost as the icon tile \u2014 chip while unknown, real number otherwise -->\n <mat-label style=\"font-weight:bold; text-align: center;\" [ngStyle]=\"{'color':tileColor(tile) }\" [attr.aria-busy]=\"showValueGhost(tile) ? 'true' : null\"><span *ngIf=\"showValueGhost(tile); else basicTileValue\" class=\"tin-skel tin-skel-text tile-value-ghost\" aria-hidden=\"true\"></span><ng-template #basicTileValue><span [class.tile-value-in]=\"effQuietLoading\">{{displayValue(tile)}}</span></ng-template></mat-label>&nbsp;<!-- Changed: colour may be a function of the data; value honours Tile.format -->\n <mat-label style=\"font-weight:bold;\" *ngIf=\"tile.suffix\">{{tile.suffix}}</mat-label>\n <span *ngIf=\"tile.badge && data?.[tile.badge]\" class=\"tile-badge\" [style.backgroundColor]=\"tile.badgeColor ?? '#4caf50'\">{{data?.[tile.badge]}}</span>\n </div>\n </div>\n <div class=\"row d-flex justify-content-center align-items-center\">\n <div class=\"d-flex justify-content-center align-items-center\" style=\"text-align: center;\">\n <mat-label style=\"padding-left:5px;padding-right:5px; text-align: center;font-size: 14px;\">{{tile.alias ?? tile.name | camelToWords}}</mat-label>\n <mat-icon *ngIf=\"tile.info\" [matTooltip]=\"tile.info\" matTooltipPosition=\"above\" style=\"font-size: 20px; color:steelblue;\">info</mat-icon>\n </div>\n </div>\n </ng-template>\n </ng-template>\n\n</ng-template>\n", styles: [".card{min-width:180px;flex:1;display:flex;flex-direction:column;align-items:center;padding:5px 10px}.tiles{gap:1;row-gap:5px}.spa-tiles-outer{display:flex;align-items:center;gap:4px}.spa-tiles{flex:1;min-width:0}.tiles-wrap{display:grid;gap:10px;align-items:stretch}.tiles-carousel{display:flex;gap:10px;overflow-x:auto;scroll-behavior:smooth;scroll-snap-type:x proximity;padding-bottom:6px}.tiles-carousel>.tile-card{scroll-snap-align:start;flex:0 0 var(--tile-basis, 200px)}.tile-card{padding:10px 16px;margin:0}.tiles-nav{flex:0 0 auto;display:flex;align-items:center;justify-content:center;width:28px;height:28px;border:none;border-radius:50%;background:#0000000a;color:#666;cursor:pointer;transition:background-color .2s ease,color .2s ease}.tiles-nav:hover{background:#2196f31f;color:#2196f3}.tiles-nav mat-icon{font-size:20px;width:20px;height:20px}.col{transition:all .2s ease}.tile-clickable{cursor:pointer}.tile-clickable:hover{transform:translateY(-2px);box-shadow:0 4px 10px #00000021;background-color:#2196f312}.selected-tile{background-color:#e0e0e0;box-shadow:0 4px 8px #0003;transform:translateY(-2px);border:2px solid #3f51b5}.selected-tile mat-label{font-weight:700}.selected-tile:hover{background-color:#e0e0e0}.tile-chart-header{display:flex;justify-content:flex-start;font-size:11px;font-weight:500;color:#999;text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px}.tile-chart{width:100%;position:relative;margin-top:4px;display:flex;align-items:center;justify-content:center}.tile-chart canvas{width:100%!important;height:100%!important;max-height:inherit}.tile-chart-value{text-align:center;margin-top:6px}.tile-chart-value-text{font-size:22px;font-weight:600;letter-spacing:-.5px}.tile-badge{display:inline-block;font-size:12px;font-weight:500;color:#fff;padding:2px 8px;border-radius:12px;vertical-align:middle;margin-left:4px}.tile-footer{margin-top:8px}.tile-icon-row{display:flex;align-items:flex-start;gap:12px;padding:4px 0}.tile-icon-wrap{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:48px;height:48px}.tile-icon-wrap mat-icon{font-size:36px;width:36px;height:36px;opacity:.85}.tile-icon-content{flex:1;text-align:right;min-width:0}.tile-icon-label{font-size:12px;color:#999;text-transform:uppercase;letter-spacing:.3px;line-height:1.4}.tile-icon-value{font-size:26px;font-weight:600;line-height:1.2;letter-spacing:-.5px}.tile-icon-footer{margin-top:8px}.tile-icon-footer mat-divider{margin-bottom:6px}.tile-icon-footer-content{display:flex;align-items:center;gap:4px;font-size:12px;color:#999}.tile-icon-footer-icon{font-size:16px;width:16px;height:16px;color:#bbb}.tile-value-ghost{width:2.5ch;height:.72em;vertical-align:middle}.tile-value-in{animation:tile-value-in .15s ease-out both}@keyframes tile-value-in{0%{opacity:0}to{opacity:1}}@media (prefers-reduced-motion: reduce){.tile-value-in{animation:none}}@media (max-width: 700px){.tiles-nav{display:none}.tiles-carousel{gap:8px}.tiles-carousel>.tile-card{flex:0 0 44%}.tile-card{padding:8px 10px}.tile-icon-row{gap:8px;padding:2px 0}.tile-icon-wrap{width:30px;height:30px}.tile-icon-wrap mat-icon{font-size:26px;width:26px;height:26px}.tile-icon-label{font-size:10.5px;letter-spacing:.1px;line-height:1.25}.tile-icon-value{font-size:22px}.tile-icon-footer{margin-top:5px}.tile-icon-footer mat-divider{margin-bottom:4px}.tile-icon-footer-content{font-size:10.5px;gap:3px;line-height:1.25}.tile-icon-footer-icon{font-size:13px;width:13px;height:13px}.hide-value-mobile .tile-badge{display: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.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "directive", type: i1$2.NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }, { kind: "component", type: i4$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "directive", type: i3$1.MatLabel, selector: "mat-label" }, { kind: "component", type: i19.MatCard, selector: "mat-card", inputs: ["appearance"], exportAs: ["matCard"] }, { kind: "component", type: i17.MatDivider, selector: "mat-divider", inputs: ["vertical", "inset"] }, { kind: "directive", type: i7.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "directive", type: i10.BaseChartDirective, selector: "canvas[baseChart]", inputs: ["type", "legend", "data", "options", "plugins", "labels", "datasets"], outputs: ["chartClick", "chartHover"], exportAs: ["base-chart"] }, { kind: "pipe", type: CamelToWordsPipe, name: "camelToWords" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
14265
15072
  }
14266
15073
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: TilesComponent, decorators: [{
14267
15074
  type: Component,
14268
- args: [{ selector: 'spa-tiles', changeDetection: ChangeDetectionStrategy.OnPush, standalone: false, template: "<!-- Tile row layout. THREE modes, and the DEFAULT is the original.\n 'auto' (default, and what every existing page gets): the original bootstrap .row/.col \u2014 tiles stretch to\n fill and shrink to a 150px floor, wrapping when they run out of room. This is byte-for-byte the markup\n that shipped before, restored on the owner's instruction 2026-08-08: the grid I briefly made the default\n forced ONE tile per row on a phone, because a 200px track floor does not fit twice in a ~380px viewport.\n 'wrap': a grid of equal tracks \u2014 opt in where uniform tile widths matter more than filling the row.\n 'carousel': one row, horizontal scroll. The Day Book opts into this.\n The tile body itself is ONE template shared by all three, so the modes can never drift apart. -->\n\n<!-- AUTO \u2014 the original layout, unchanged -->\n<div *ngIf=\"isAuto\" class=\"d-flex row align-items-center justify-content-between\" [class.hide-value-mobile]=\"hideValueMobile\">\n <ng-container *ngFor=\"let tile of tiles\">\n <!-- Changed: tile-clickable class gives pointer + hover lift only on tiles that actually respond to clicks -->\n <mat-card *ngIf=\"!isHidden(tile)\" class=\"col\" [class.tile-clickable]=\"isClickable(tile)\" [class.selected-tile]=\"tile.name === selectedTile\" style=\"margin-left: 5px;margin-right: 5px; padding: 10px 16px ; min-width: 150px; margin-top: 5px;\" (click)=\"clicked(tile)\">\n <ng-container *ngTemplateOutlet=\"tileBody; context: ctx(tile)\"></ng-container>\n </mat-card>\n </ng-container>\n</div>\n\n<!-- WRAP / CAROUSEL \u2014 opt-in only -->\n<div *ngIf=\"!isAuto\" class=\"spa-tiles-outer\">\n\n <!-- Carousel only: scroll affordances, shown solely when there is something to scroll to -->\n <button type=\"button\" class=\"tiles-nav\" *ngIf=\"isCarousel && canScrollLeft\" (click)=\"scrollTiles(-1)\" aria-label=\"Scroll tiles left\">\n <mat-icon>chevron_left</mat-icon>\n </button>\n\n <div #tileScroller class=\"spa-tiles\" [class.tiles-wrap]=\"!isCarousel\" [class.tiles-carousel]=\"isCarousel\" [class.hide-value-mobile]=\"hideValueMobile\" [ngStyle]=\"tilesStyle\" (scroll)=\"onTilesScroll()\">\n <ng-container *ngFor=\"let tile of tiles\">\n <mat-card *ngIf=\"!isHidden(tile)\" class=\"tile-card\" [class.tile-clickable]=\"isClickable(tile)\" [class.selected-tile]=\"tile.name === selectedTile\" (click)=\"clicked(tile)\">\n <ng-container *ngTemplateOutlet=\"tileBody; context: ctx(tile)\"></ng-container>\n </mat-card>\n </ng-container>\n </div>\n\n <button type=\"button\" class=\"tiles-nav\" *ngIf=\"isCarousel && canScrollRight\" (click)=\"scrollTiles(1)\" aria-label=\"Scroll tiles right\">\n <mat-icon>chevron_right</mat-icon>\n </button>\n\n</div>\n\n<!-- The tile body, shared by all three modes. ctx() returns a CACHED context object per tile \u2014 building\n `{ $implicit: tile }` inline would hand NgTemplateOutlet a new object every change-detection pass and\n re-create every tile's view, which is the documented cause of a real livelock in this library. -->\n<ng-template #tileBody let-tile>\n\n <!-- Changed: Chart-style tile \u2014 header, prominent chart, optional value, footer -->\n <ng-container *ngIf=\"tile.chart; else standardTile\">\n <!-- Changed: Header with tile name \u2014 left-aligned for better hierarchy -->\n <div class=\"tile-chart-header\">\n <span>{{tile.alias ?? tile.name | camelToWords}}</span>\n </div>\n\n <!-- Changed: Chart fills tile \u2014 uses helper method for reliable data detection -->\n <div class=\"tile-chart\" *ngIf=\"hasTileChartData(tile)\" [style.height.px]=\"tile.chart.height ?? 120\">\n <canvas baseChart\n [type]=\"tile.chart.type\"\n [data]=\"getTileMiniChartData(tile)\"\n [options]=\"getMiniChartOptions(tile)\"\n [plugins]=\"getTileChartPlugins(tile)\">\n </canvas>\n </div>\n\n <!-- Changed: Optional value display below chart \u2014 only show primitive values, skip chart data objects -->\n <div class=\"tile-chart-value\" *ngIf=\"tile.name && isTileValuePrimitive(tile)\">\n <span class=\"tile-chart-value-text\" [style.color]=\"tile.color\">{{tile.prefix ?? ''}}{{data?.[tile.name]}}<span *ngIf=\"tile.suffix\"> {{tile.suffix}}</span></span>\n </div>\n\n <!-- Changed: Footer with divider for chart tiles -->\n <div class=\"tile-footer\" *ngIf=\"tile.footer || tile.info\">\n <mat-divider></mat-divider>\n <div class=\"d-flex align-items-center\" style=\"gap: 4px; color: #9a9a9a; font-size: 12px; margin-top: 6px;\">\n <mat-icon *ngIf=\"tile.footerIcon\" style=\"font-size: 16px; width: 16px; height: 16px;\">{{tile.footerIcon}}</mat-icon>\n <mat-icon *ngIf=\"!tile.footerIcon && tile.info\" style=\"font-size: 16px; width: 16px; height: 16px; color: steelblue;\">info</mat-icon>\n <span>{{tile.footer ?? tile.info}}</span>\n </div>\n </div>\n </ng-container>\n\n <!-- Changed: Standard tile \u2014 Paper Dashboard style: icon left, label+value right, optional footer -->\n <ng-template #standardTile>\n <!-- Changed: Icon-style tile \u2014 icon left, label top-right, large value below -->\n <ng-container *ngIf=\"tile.icon; else basicTile\">\n <div class=\"tile-icon-row\">\n <div class=\"tile-icon-wrap\" [style.color]=\"tile.color ?? '#2196f3'\">\n <mat-icon>{{tile.icon}}</mat-icon>\n </div>\n <div class=\"tile-icon-content\">\n <div class=\"tile-icon-label\">{{tile.alias ?? tile.name | camelToWords}}</div>\n <!-- Changed (Quiet Loading D5): while the value is genuinely unknown the slot shows a shimmer chip instead of the old lying `0`; icon, label and footer render normally so the card never resizes. On a reload the previous number stays put and simply updates. -->\n <div class=\"tile-icon-value\" [style.color]=\"tile.color\" [attr.aria-busy]=\"showValueGhost(tile) ? 'true' : null\">\n <span *ngIf=\"tile.prefix\">{{tile.prefix}}</span><span *ngIf=\"showValueGhost(tile); else iconTileValue\" class=\"tin-skel tin-skel-text tile-value-ghost\" aria-hidden=\"true\"></span><ng-template #iconTileValue><span [class.tile-value-in]=\"effQuietLoading\">{{data?.[tile.name] ?? 0}}</span></ng-template><span *ngIf=\"tile.suffix\"> {{tile.suffix}}</span>\n <span *ngIf=\"tile.badge && data?.[tile.badge]\" class=\"tile-badge\" [style.backgroundColor]=\"tile.badgeColor ?? '#4caf50'\">{{data?.[tile.badge]}}</span>\n </div>\n </div>\n </div>\n <!-- Changed: Footer with divider \u2014 info tooltip or custom footer text -->\n <div class=\"tile-icon-footer\" *ngIf=\"tile.info || tile.footer\">\n <mat-divider></mat-divider>\n <div class=\"tile-icon-footer-content\">\n <mat-icon *ngIf=\"tile.footerIcon\" class=\"tile-icon-footer-icon\">{{tile.footerIcon}}</mat-icon>\n <mat-icon *ngIf=\"!tile.footerIcon && tile.info\" class=\"tile-icon-footer-icon\" style=\"color: steelblue;\">info</mat-icon>\n <span>{{tile.footer ?? tile.info}}</span>\n </div>\n </div>\n </ng-container>\n\n <!-- Basic tile fallback \u2014 centered number display (no icon) -->\n <ng-template #basicTile>\n <div class=\"row d-flex justify-content-center align-items-center\">\n <div style=\"text-align: center;font-size: 30px;\">\n <mat-label style=\"font-weight:bold;\" *ngIf=\"tile.prefix\" >{{tile.prefix}}</mat-label> &nbsp;\n <!-- Changed (Quiet Loading D5): same value ghost as the icon tile \u2014 chip while unknown, real number otherwise -->\n <mat-label style=\"font-weight:bold; text-align: center;\" [ngStyle]=\"{'color':tile.color }\" [attr.aria-busy]=\"showValueGhost(tile) ? 'true' : null\"><span *ngIf=\"showValueGhost(tile); else basicTileValue\" class=\"tin-skel tin-skel-text tile-value-ghost\" aria-hidden=\"true\"></span><ng-template #basicTileValue><span [class.tile-value-in]=\"effQuietLoading\">{{data?.[tile.name] ?? 0}}</span></ng-template></mat-label>&nbsp;\n <mat-label style=\"font-weight:bold;\" *ngIf=\"tile.suffix\">{{tile.suffix}}</mat-label>\n <span *ngIf=\"tile.badge && data?.[tile.badge]\" class=\"tile-badge\" [style.backgroundColor]=\"tile.badgeColor ?? '#4caf50'\">{{data?.[tile.badge]}}</span>\n </div>\n </div>\n <div class=\"row d-flex justify-content-center align-items-center\">\n <div class=\"d-flex justify-content-center align-items-center\" style=\"text-align: center;\">\n <mat-label style=\"padding-left:5px;padding-right:5px; text-align: center;font-size: 14px;\">{{tile.alias ?? tile.name | camelToWords}}</mat-label>\n <mat-icon *ngIf=\"tile.info\" [matTooltip]=\"tile.info\" matTooltipPosition=\"above\" style=\"font-size: 20px; color:steelblue;\">info</mat-icon>\n </div>\n </div>\n </ng-template>\n </ng-template>\n\n</ng-template>\n", styles: [".card{min-width:180px;flex:1;display:flex;flex-direction:column;align-items:center;padding:5px 10px}.tiles{gap:1;row-gap:5px}.spa-tiles-outer{display:flex;align-items:center;gap:4px}.spa-tiles{flex:1;min-width:0}.tiles-wrap{display:grid;gap:10px;align-items:stretch}.tiles-carousel{display:flex;gap:10px;overflow-x:auto;scroll-behavior:smooth;scroll-snap-type:x proximity;padding-bottom:6px}.tiles-carousel>.tile-card{scroll-snap-align:start;flex:0 0 var(--tile-basis, 200px)}@media (max-width: 700px){.tiles-nav{display:none}.tiles-carousel{gap:8px}.tiles-carousel>.tile-card{flex:0 0 44%}.tile-card{padding:8px 10px}.tile-icon-row{gap:8px;padding:2px 0}.tile-icon-wrap{width:30px;height:30px}.tile-icon-wrap mat-icon{font-size:26px;width:26px;height:26px}.tile-icon-label{font-size:10.5px;letter-spacing:.1px;line-height:1.25}.tile-icon-value{font-size:22px}.tile-icon-footer{margin-top:5px}.tile-icon-footer mat-divider{margin-bottom:4px}.tile-icon-footer-content{font-size:10.5px;gap:3px;line-height:1.25}.tile-icon-footer-icon{font-size:13px;width:13px;height:13px}.hide-value-mobile .tile-badge{display:none}}.tile-card{padding:10px 16px;margin:0}.tiles-nav{flex:0 0 auto;display:flex;align-items:center;justify-content:center;width:28px;height:28px;border:none;border-radius:50%;background:#0000000a;color:#666;cursor:pointer;transition:background-color .2s ease,color .2s ease}.tiles-nav:hover{background:#2196f31f;color:#2196f3}.tiles-nav mat-icon{font-size:20px;width:20px;height:20px}.col{transition:all .2s ease}.tile-clickable{cursor:pointer}.tile-clickable:hover{transform:translateY(-2px);box-shadow:0 4px 10px #00000021;background-color:#2196f312}.selected-tile{background-color:#e0e0e0;box-shadow:0 4px 8px #0003;transform:translateY(-2px);border:2px solid #3f51b5}.selected-tile mat-label{font-weight:700}.selected-tile:hover{background-color:#e0e0e0}.tile-chart-header{display:flex;justify-content:flex-start;font-size:11px;font-weight:500;color:#999;text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px}.tile-chart{width:100%;position:relative;margin-top:4px;display:flex;align-items:center;justify-content:center}.tile-chart canvas{width:100%!important;height:100%!important;max-height:inherit}.tile-chart-value{text-align:center;margin-top:6px}.tile-chart-value-text{font-size:22px;font-weight:600;letter-spacing:-.5px}.tile-badge{display:inline-block;font-size:12px;font-weight:500;color:#fff;padding:2px 8px;border-radius:12px;vertical-align:middle;margin-left:4px}.tile-footer{margin-top:8px}.tile-icon-row{display:flex;align-items:flex-start;gap:12px;padding:4px 0}.tile-icon-wrap{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:48px;height:48px}.tile-icon-wrap mat-icon{font-size:36px;width:36px;height:36px;opacity:.85}.tile-icon-content{flex:1;text-align:right;min-width:0}.tile-icon-label{font-size:12px;color:#999;text-transform:uppercase;letter-spacing:.3px;line-height:1.4}.tile-icon-value{font-size:26px;font-weight:600;line-height:1.2;letter-spacing:-.5px}.tile-icon-footer{margin-top:8px}.tile-icon-footer mat-divider{margin-bottom:6px}.tile-icon-footer-content{display:flex;align-items:center;gap:4px;font-size:12px;color:#999}.tile-icon-footer-icon{font-size:16px;width:16px;height:16px;color:#bbb}.tile-value-ghost{width:2.5ch;height:.72em;vertical-align:middle}.tile-value-in{animation:tile-value-in .15s ease-out both}@keyframes tile-value-in{0%{opacity:0}to{opacity:1}}@media (prefers-reduced-motion: reduce){.tile-value-in{animation:none}}\n"] }]
15075
+ args: [{ selector: 'spa-tiles', changeDetection: ChangeDetectionStrategy.OnPush, standalone: false, template: "<!-- Tile row layout. THREE modes, and the DEFAULT is the original.\n 'auto' (default, and what every existing page gets): the original bootstrap .row/.col \u2014 tiles stretch to\n fill and shrink to a 150px floor, wrapping when they run out of room. This is byte-for-byte the markup\n that shipped before, restored on the owner's instruction 2026-08-08: the grid I briefly made the default\n forced ONE tile per row on a phone, because a 200px track floor does not fit twice in a ~380px viewport.\n 'wrap': a grid of equal tracks \u2014 opt in where uniform tile widths matter more than filling the row.\n 'carousel': one row, horizontal scroll. The Day Book opts into this.\n The tile body itself is ONE template shared by all three, so the modes can never drift apart. -->\n\n<!-- AUTO \u2014 the original layout, unchanged -->\n<div *ngIf=\"isAuto\" class=\"d-flex row align-items-center justify-content-between\" [class.hide-value-mobile]=\"hideValueMobile\">\n <ng-container *ngFor=\"let tile of tiles\">\n <!-- Changed: tile-clickable class gives pointer + hover lift only on tiles that actually respond to clicks -->\n <mat-card *ngIf=\"!isHidden(tile)\" class=\"col\" [class.tile-clickable]=\"isClickable(tile)\" [class.selected-tile]=\"tile.name === selectedTile\" style=\"margin-left: 5px;margin-right: 5px; padding: 10px 16px ; min-width: 150px; margin-top: 5px;\" (click)=\"clicked(tile)\">\n <ng-container *ngTemplateOutlet=\"tileBody; context: ctx(tile)\"></ng-container>\n </mat-card>\n </ng-container>\n</div>\n\n<!-- WRAP / CAROUSEL \u2014 opt-in only -->\n<div *ngIf=\"!isAuto\" class=\"spa-tiles-outer\">\n\n <!-- Carousel only: scroll affordances, shown solely when there is something to scroll to -->\n <button type=\"button\" class=\"tiles-nav\" *ngIf=\"isCarousel && canScrollLeft\" (click)=\"scrollTiles(-1)\" aria-label=\"Scroll tiles left\">\n <mat-icon>chevron_left</mat-icon>\n </button>\n\n <div #tileScroller class=\"spa-tiles\" [class.tiles-wrap]=\"!isCarousel\" [class.tiles-carousel]=\"isCarousel\" [class.hide-value-mobile]=\"hideValueMobile\" [ngStyle]=\"tilesStyle\" (scroll)=\"onTilesScroll()\">\n <ng-container *ngFor=\"let tile of tiles\">\n <mat-card *ngIf=\"!isHidden(tile)\" class=\"tile-card\" [class.tile-clickable]=\"isClickable(tile)\" [class.selected-tile]=\"tile.name === selectedTile\" (click)=\"clicked(tile)\">\n <ng-container *ngTemplateOutlet=\"tileBody; context: ctx(tile)\"></ng-container>\n </mat-card>\n </ng-container>\n </div>\n\n <button type=\"button\" class=\"tiles-nav\" *ngIf=\"isCarousel && canScrollRight\" (click)=\"scrollTiles(1)\" aria-label=\"Scroll tiles right\">\n <mat-icon>chevron_right</mat-icon>\n </button>\n\n</div>\n\n<!-- The tile body, shared by all three modes. ctx() returns a CACHED context object per tile \u2014 building\n `{ $implicit: tile }` inline would hand NgTemplateOutlet a new object every change-detection pass and\n re-create every tile's view, which is the documented cause of a real livelock in this library. -->\n<ng-template #tileBody let-tile>\n\n <!-- Changed: Chart-style tile \u2014 header, prominent chart, optional value, footer -->\n <ng-container *ngIf=\"tile.chart; else standardTile\">\n <!-- Changed: Header with tile name \u2014 left-aligned for better hierarchy -->\n <div class=\"tile-chart-header\">\n <span>{{tile.alias ?? tile.name | camelToWords}}</span>\n </div>\n\n <!-- Changed: Chart fills tile \u2014 uses helper method for reliable data detection -->\n <div class=\"tile-chart\" *ngIf=\"hasTileChartData(tile)\" [style.height.px]=\"tile.chart.height ?? 120\">\n <canvas baseChart\n [type]=\"tile.chart.type\"\n [data]=\"getTileMiniChartData(tile)\"\n [options]=\"getMiniChartOptions(tile)\"\n [plugins]=\"getTileChartPlugins(tile)\">\n </canvas>\n </div>\n\n <!-- Changed: Optional value display below chart \u2014 only show primitive values, skip chart data objects -->\n <div class=\"tile-chart-value\" *ngIf=\"tile.name && isTileValuePrimitive(tile)\">\n <span class=\"tile-chart-value-text\" [style.color]=\"tileColor(tile)\">{{tile.prefix ?? ''}}{{displayValue(tile)}}<span *ngIf=\"tile.suffix\"> {{tile.suffix}}</span></span><!-- Changed: colour may be a function of the data; value honours Tile.format -->\n </div>\n\n <!-- Changed: Footer with divider for chart tiles -->\n <div class=\"tile-footer\" *ngIf=\"tile.footer || tile.info\">\n <mat-divider></mat-divider>\n <div class=\"d-flex align-items-center\" style=\"gap: 4px; color: #9a9a9a; font-size: 12px; margin-top: 6px;\">\n <mat-icon *ngIf=\"tile.footerIcon\" style=\"font-size: 16px; width: 16px; height: 16px;\">{{tile.footerIcon}}</mat-icon>\n <mat-icon *ngIf=\"!tile.footerIcon && tile.info\" style=\"font-size: 16px; width: 16px; height: 16px; color: steelblue;\">info</mat-icon>\n <span>{{tile.footer ?? tile.info}}</span>\n </div>\n </div>\n </ng-container>\n\n <!-- Changed: Standard tile \u2014 Paper Dashboard style: icon left, label+value right, optional footer -->\n <ng-template #standardTile>\n <!-- Changed: Icon-style tile \u2014 icon left, label top-right, large value below -->\n <ng-container *ngIf=\"tile.icon; else basicTile\">\n <div class=\"tile-icon-row\">\n <div class=\"tile-icon-wrap\" [style.color]=\"tileColor(tile) ?? '#2196f3'\"><!-- Changed: colour may be a function of the data -->\n <mat-icon>{{tile.icon}}</mat-icon>\n </div>\n <div class=\"tile-icon-content\">\n <div class=\"tile-icon-label\">{{tile.alias ?? tile.name | camelToWords}}</div>\n <!-- Changed (Quiet Loading D5): while the value is genuinely unknown the slot shows a shimmer chip instead of the old lying `0`; icon, label and footer render normally so the card never resizes. On a reload the previous number stays put and simply updates. -->\n <div class=\"tile-icon-value\" [style.color]=\"tileColor(tile)\" [attr.aria-busy]=\"showValueGhost(tile) ? 'true' : null\"><!-- Changed: colour may be a function of the data -->\n <span *ngIf=\"tile.prefix\">{{tile.prefix}}</span><span *ngIf=\"showValueGhost(tile); else iconTileValue\" class=\"tin-skel tin-skel-text tile-value-ghost\" aria-hidden=\"true\"></span><ng-template #iconTileValue><span [class.tile-value-in]=\"effQuietLoading\">{{displayValue(tile)}}</span></ng-template><span *ngIf=\"tile.suffix\"> {{tile.suffix}}</span><!-- Changed: value honours Tile.format; displayValue keeps the old `?? 0` -->\n <span *ngIf=\"tile.badge && data?.[tile.badge]\" class=\"tile-badge\" [style.backgroundColor]=\"tile.badgeColor ?? '#4caf50'\">{{data?.[tile.badge]}}</span>\n </div>\n </div>\n </div>\n <!-- Changed: Footer with divider \u2014 info tooltip or custom footer text -->\n <div class=\"tile-icon-footer\" *ngIf=\"tile.info || tile.footer\">\n <mat-divider></mat-divider>\n <div class=\"tile-icon-footer-content\">\n <mat-icon *ngIf=\"tile.footerIcon\" class=\"tile-icon-footer-icon\">{{tile.footerIcon}}</mat-icon>\n <mat-icon *ngIf=\"!tile.footerIcon && tile.info\" class=\"tile-icon-footer-icon\" style=\"color: steelblue;\">info</mat-icon>\n <span>{{tile.footer ?? tile.info}}</span>\n </div>\n </div>\n </ng-container>\n\n <!-- Basic tile fallback \u2014 centered number display (no icon) -->\n <ng-template #basicTile>\n <div class=\"row d-flex justify-content-center align-items-center\">\n <div style=\"text-align: center;font-size: 30px;\">\n <mat-label style=\"font-weight:bold;\" *ngIf=\"tile.prefix\" >{{tile.prefix}}</mat-label> &nbsp;\n <!-- Changed (Quiet Loading D5): same value ghost as the icon tile \u2014 chip while unknown, real number otherwise -->\n <mat-label style=\"font-weight:bold; text-align: center;\" [ngStyle]=\"{'color':tileColor(tile) }\" [attr.aria-busy]=\"showValueGhost(tile) ? 'true' : null\"><span *ngIf=\"showValueGhost(tile); else basicTileValue\" class=\"tin-skel tin-skel-text tile-value-ghost\" aria-hidden=\"true\"></span><ng-template #basicTileValue><span [class.tile-value-in]=\"effQuietLoading\">{{displayValue(tile)}}</span></ng-template></mat-label>&nbsp;<!-- Changed: colour may be a function of the data; value honours Tile.format -->\n <mat-label style=\"font-weight:bold;\" *ngIf=\"tile.suffix\">{{tile.suffix}}</mat-label>\n <span *ngIf=\"tile.badge && data?.[tile.badge]\" class=\"tile-badge\" [style.backgroundColor]=\"tile.badgeColor ?? '#4caf50'\">{{data?.[tile.badge]}}</span>\n </div>\n </div>\n <div class=\"row d-flex justify-content-center align-items-center\">\n <div class=\"d-flex justify-content-center align-items-center\" style=\"text-align: center;\">\n <mat-label style=\"padding-left:5px;padding-right:5px; text-align: center;font-size: 14px;\">{{tile.alias ?? tile.name | camelToWords}}</mat-label>\n <mat-icon *ngIf=\"tile.info\" [matTooltip]=\"tile.info\" matTooltipPosition=\"above\" style=\"font-size: 20px; color:steelblue;\">info</mat-icon>\n </div>\n </div>\n </ng-template>\n </ng-template>\n\n</ng-template>\n", styles: [".card{min-width:180px;flex:1;display:flex;flex-direction:column;align-items:center;padding:5px 10px}.tiles{gap:1;row-gap:5px}.spa-tiles-outer{display:flex;align-items:center;gap:4px}.spa-tiles{flex:1;min-width:0}.tiles-wrap{display:grid;gap:10px;align-items:stretch}.tiles-carousel{display:flex;gap:10px;overflow-x:auto;scroll-behavior:smooth;scroll-snap-type:x proximity;padding-bottom:6px}.tiles-carousel>.tile-card{scroll-snap-align:start;flex:0 0 var(--tile-basis, 200px)}.tile-card{padding:10px 16px;margin:0}.tiles-nav{flex:0 0 auto;display:flex;align-items:center;justify-content:center;width:28px;height:28px;border:none;border-radius:50%;background:#0000000a;color:#666;cursor:pointer;transition:background-color .2s ease,color .2s ease}.tiles-nav:hover{background:#2196f31f;color:#2196f3}.tiles-nav mat-icon{font-size:20px;width:20px;height:20px}.col{transition:all .2s ease}.tile-clickable{cursor:pointer}.tile-clickable:hover{transform:translateY(-2px);box-shadow:0 4px 10px #00000021;background-color:#2196f312}.selected-tile{background-color:#e0e0e0;box-shadow:0 4px 8px #0003;transform:translateY(-2px);border:2px solid #3f51b5}.selected-tile mat-label{font-weight:700}.selected-tile:hover{background-color:#e0e0e0}.tile-chart-header{display:flex;justify-content:flex-start;font-size:11px;font-weight:500;color:#999;text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px}.tile-chart{width:100%;position:relative;margin-top:4px;display:flex;align-items:center;justify-content:center}.tile-chart canvas{width:100%!important;height:100%!important;max-height:inherit}.tile-chart-value{text-align:center;margin-top:6px}.tile-chart-value-text{font-size:22px;font-weight:600;letter-spacing:-.5px}.tile-badge{display:inline-block;font-size:12px;font-weight:500;color:#fff;padding:2px 8px;border-radius:12px;vertical-align:middle;margin-left:4px}.tile-footer{margin-top:8px}.tile-icon-row{display:flex;align-items:flex-start;gap:12px;padding:4px 0}.tile-icon-wrap{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:48px;height:48px}.tile-icon-wrap mat-icon{font-size:36px;width:36px;height:36px;opacity:.85}.tile-icon-content{flex:1;text-align:right;min-width:0}.tile-icon-label{font-size:12px;color:#999;text-transform:uppercase;letter-spacing:.3px;line-height:1.4}.tile-icon-value{font-size:26px;font-weight:600;line-height:1.2;letter-spacing:-.5px}.tile-icon-footer{margin-top:8px}.tile-icon-footer mat-divider{margin-bottom:6px}.tile-icon-footer-content{display:flex;align-items:center;gap:4px;font-size:12px;color:#999}.tile-icon-footer-icon{font-size:16px;width:16px;height:16px;color:#bbb}.tile-value-ghost{width:2.5ch;height:.72em;vertical-align:middle}.tile-value-in{animation:tile-value-in .15s ease-out both}@keyframes tile-value-in{0%{opacity:0}to{opacity:1}}@media (prefers-reduced-motion: reduce){.tile-value-in{animation:none}}@media (max-width: 700px){.tiles-nav{display:none}.tiles-carousel{gap:8px}.tiles-carousel>.tile-card{flex:0 0 44%}.tile-card{padding:8px 10px}.tile-icon-row{gap:8px;padding:2px 0}.tile-icon-wrap{width:30px;height:30px}.tile-icon-wrap mat-icon{font-size:26px;width:26px;height:26px}.tile-icon-label{font-size:10.5px;letter-spacing:.1px;line-height:1.25}.tile-icon-value{font-size:22px}.tile-icon-footer{margin-top:5px}.tile-icon-footer mat-divider{margin-bottom:4px}.tile-icon-footer-content{font-size:10.5px;gap:3px;line-height:1.25}.tile-icon-footer-icon{font-size:13px;width:13px;height:13px}.hide-value-mobile .tile-badge{display:none}}\n"] }]
14269
15076
  }], ctorParameters: () => [{ type: DataServiceLib }, { type: MessageService }, { type: i0.ChangeDetectorRef }, { type: ApiErrorService }], propDecorators: { tileScroller: [{
14270
15077
  type: ViewChild,
14271
15078
  args: ['tileScroller']
@@ -16284,11 +17091,11 @@ class GroupsComponent {
16284
17091
  });
16285
17092
  }
16286
17093
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: GroupsComponent, deps: [{ token: ConditionService }, { token: ButtonService }, { token: DataServiceLib }, { token: i2$1.MatSnackBar }, { token: ApiErrorService }], target: i0.ɵɵFactoryTarget.Component }); }
16287
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: GroupsComponent, isStandalone: false, selector: "spa-groups", inputs: { config: "config", dataSource: "dataSource", displayedButtons: "displayedButtons", showOwnFilter: "showOwnFilter", filterText: "filterText" }, outputs: { actionClick: "actionClick" }, usesOnChanges: true, ngImport: i0, template: "<!-- Changed: this filter row was REMOVED from the grouped view's own markup and now lives in the table header,\n beside the action buttons, where every other table config puts it. It used to sit on a row of its own\n directly under the buttons row, leaving both rows half empty and visibly misaligned.\n The filtering logic here is untouched \u2014 only the input that drives it moved, and its text arrives via the\n filterText @Input relayed from the header. Kept as a fallback for a grouped view rendered WITHOUT the\n standard header (spa-groups can be used directly), so filtering is never simply lost. -->\n<div class=\"groups-filter\" *ngIf=\"showOwnFilter\">\n <mat-form-field appearance=\"outline\" class=\"filter-field\">\n <mat-icon matPrefix>search</mat-icon>\n <input matInput placeholder=\"Filter\" [(ngModel)]=\"filterText\" (ngModelChange)=\"applyFilter()\">\n <button *ngIf=\"filterText\" mat-icon-button matSuffix (click)=\"filterText = ''; applyFilter()\">\n <mat-icon>close</mat-icon>\n </button>\n </mat-form-field>\n</div>\n\n<div class=\"groups-container\" cdkDropListGroup>\n\n <mat-card *ngFor=\"let group of groupedData\" class=\"group-card\">\n\n <div class=\"group-header\">\n <div class=\"header-left\">\n <mat-icon *ngIf=\"group.icon\" [style.color]=\"group.color\" class=\"group-icon\">{{group.icon}}</mat-icon>\n <!-- Changed: added the native title attribute. On a phone the title now truncates with an ellipsis rather\n than wrapping to a second line, so the full name has to stay reachable somewhere \u2014 this is the\n zero-cost way to keep it. No tooltip directive, so nothing changes on touch. -->\n <label class=\"group-title\" [title]=\"group.displayName\">{{group.displayName}}</label>\n <label *ngIf=\"config.groupConfig.showGroupCount !== false\" class=\"group-count\">({{group.items.length}})</label>\n </div>\n\n <div class=\"header-right\">\n <button\n *ngFor=\"let button of getVisibleHeaderButtons(group)\"\n mat-icon-button\n [matTooltip]=\"getHeaderButtonTooltip(button)\"\n matTooltipPosition=\"above\"\n [style.color]=\"getHeaderButtonColor(button, group)\"\n [disabled]=\"isHeaderButtonDisabled(button, group)\"\n (click)=\"headerButtonClicked(button, group)\">\n <mat-icon>{{getHeaderButtonIcon(button)}}</mat-icon>\n </button>\n </div>\n </div>\n\n <hr class=\"group-divider\" />\n\n <!-- Empty state: show when no items and drag is disabled -->\n <div *ngIf=\"group.items.length === 0 && !config.groupConfig.dragEnabled\" class=\"empty-state\">\n <label>{{config.groupConfig.emptyGroupMessage ?? 'Empty'}}</label>\n </div>\n\n <!-- Changed: Replaced mat-chip-set with flex container of mat-stroked-button for cleaner button style -->\n <div\n *ngIf=\"group.items.length > 0 || config.groupConfig.dragEnabled\"\n cdkDropList\n [cdkDropListData]=\"group.items\"\n [cdkDropListDisabled]=\"!config.groupConfig.dragEnabled\"\n (cdkDropListDropped)=\"onDrop($event)\"\n (cdkDropListEntered)=\"onDropListEntered($event)\"\n (cdkDropListExited)=\"onDropListExited($event)\"\n [class.drop-highlight]=\"group === highlightedGroup\"\n class=\"drop-list items-container\">\n\n <!-- Empty state inside drop list when drag is enabled -->\n <div *ngIf=\"group.items.length === 0\" class=\"empty-state drop-empty\">\n <label>{{config.groupConfig.emptyGroupMessage ?? 'Empty'}}</label>\n </div>\n\n <button\n mat-stroked-button\n *ngFor=\"let item of group.items\"\n cdkDrag [cdkDragData]=\"item\"\n [cdkDragDisabled]=\"!config.groupConfig.dragEnabled\"\n [matMenuTriggerFor]=\"config.groupConfig.contextMenuEnabled !== false ? itemMenu : null\"\n [matMenuTriggerData]=\"{item: item, group: group}\"\n class=\"item-button\">\n\n <mat-icon\n *ngIf=\"getItemIcon(item)\"\n [style.color]=\"getItemIconColor(item)\"\n [matTooltip]=\"getItemIconTip(item)\"\n matTooltipPosition=\"above\"\n class=\"item-icon\">\n {{getItemIcon(item)}}\n </mat-icon>\n\n {{getItemText(item)}}\n\n <ng-container *ngFor=\"let additionalIcon of getVisibleAdditionalIcons(item)\">\n <mat-icon\n [style.color]=\"additionalIcon.color\"\n [matTooltip]=\"getIconTooltip(additionalIcon, item)\"\n matTooltipPosition=\"above\"\n class=\"item-additional-icon\">\n {{additionalIcon.name}}\n </mat-icon>\n </ng-container>\n\n </button>\n </div>\n\n </mat-card>\n\n</div>\n\n<mat-menu #itemMenu=\"matMenu\">\n <ng-template matMenuContent let-item=\"item\" let-group=\"group\">\n <button\n *ngFor=\"let button of getVisibleButtons(item)\"\n mat-menu-item\n [disabled]=\"isButtonDisabled(button, item)\"\n (click)=\"itemActionClicked(button.name, item, group)\">\n <mat-icon [style.color]=\"getButtonIconColor(button, item)\">{{getButtonIcon(button)}}</mat-icon>\n <span>{{button.display ?? button.tip ?? (button.name | titlecase)}}</span>\n </button>\n </ng-template>\n</mat-menu>\n", styles: [".groups-filter{display:flex;justify-content:flex-end;margin-bottom:8px;padding-right:10px}.filter-field{width:300px;font-size:13px}.filter-field ::ng-deep .mat-mdc-form-field-infix{padding-top:8px!important;padding-bottom:8px!important;min-height:36px}.groups-container{display:flex;flex-direction:column;gap:20px;padding-right:10px;margin-left:0}.group-card{width:100%;margin-bottom:0;padding:8px 16px 16px}.group-header{display:flex;justify-content:space-between;align-items:center;padding:0;min-height:32px}.header-left{display:flex;align-items:center;gap:5px}.header-right{display:flex;align-items:center;gap:4px}.group-icon{margin-right:5px;font-size:24px;width:24px;height:24px;line-height:24px}.group-title{font-size:24px;font-weight:300;line-height:1.2}.group-count{font-size:12px;font-weight:300;margin-left:5px;line-height:1.2}.group-divider{margin-top:8px;margin-bottom:10px}.empty-state{display:flex;justify-content:center;align-items:center;padding:10px}.empty-state label{color:#757575;font-style:italic}.items-container{display:flex;flex-wrap:wrap;gap:10px;padding:8px 0;align-items:center}.item-button{cursor:pointer;font-weight:400;letter-spacing:.01em;transition:box-shadow .2s ease,background-color .2s ease}.item-button:hover{box-shadow:0 1px 4px #00000026;background-color:#00000005}.item-icon{font-size:18px;width:18px;height:18px;margin-right:4px;vertical-align:middle}.item-additional-icon{font-size:16px;width:16px;height:16px;margin-left:6px;vertical-align:middle;opacity:.85}button[mat-icon-button]{width:32px;height:32px}button[mat-icon-button] mat-icon{font-size:18px;margin-top:-3px}.header-right button[mat-icon-button]{display:inline-flex;align-items:center;justify-content:center;padding:0;line-height:normal}.header-right button[mat-icon-button] mat-icon{margin:0;font-size:20px;width:20px;height:20px;line-height:20px;display:block}.header-right button[mat-icon-button] .mat-mdc-button-touch-target{display:none}@media (max-width: 700px){.groups-container{gap:12px;padding-right:0}.group-card{padding:8px 10px 10px}.header-left{flex:1 1 auto;min-width:0;gap:4px}.group-icon{flex:0 0 auto;margin-right:2px;font-size:20px;width:20px;height:20px;line-height:20px}.group-title{font-size:16px;font-weight:400;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.group-count{flex:0 0 auto;margin-left:2px;font-size:11px}.header-right{flex:0 0 auto;gap:0}.header-right button[mat-icon-button] mat-icon{font-size:18px;width:18px;height:18px;line-height:18px}.group-header{min-height:28px}.group-divider{margin-top:6px;margin-bottom:6px}.items-container{gap:6px;padding:4px 0}.item-button{min-width:0;height:32px;padding:0 9px;font-size:12.5px;letter-spacing:normal;border-radius:16px;border-color:#0000001f;--mdc-outlined-button-container-height: 32px;--mdc-outlined-button-container-shape: 16px;--mdc-outlined-button-label-text-size: 12.5px}.item-button ::ng-deep .mdc-button__label{letter-spacing:normal;display:inline-flex;align-items:center}.item-button ::ng-deep .mat-mdc-button-touch-target{height:100%}.item-icon{font-size:14px;width:14px;height:14px;margin-right:3px}.item-additional-icon{font-size:14px;width:14px;height:14px;margin-left:3px}.drop-list{min-height:32px}.empty-state{padding:4px 10px}}.drop-list{min-height:40px}.drop-empty{padding:5px 10px}.cdk-drag-preview{box-shadow:0 5px 5px -3px #0003,0 8px 10px 1px #00000024,0 3px 14px 2px #0000001f;opacity:.9;border-radius:4px}.cdk-drag-animating{transition:transform .25s cubic-bezier(0,0,.2,1)}.drop-highlight{border:2px dashed #90caf9;border-radius:8px;background:#90caf90d}.cdk-drop-list-dragging .item-button:not(.cdk-drag-placeholder){transition:transform .25s cubic-bezier(0,0,.2,1)}\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: 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: i2$2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: i4$4.MatMenu, selector: "mat-menu", inputs: ["backdropClass", "aria-label", "aria-labelledby", "aria-describedby", "xPosition", "yPosition", "overlapTrigger", "hasBackdrop", "class", "classList"], outputs: ["closed", "close"], exportAs: ["matMenu"] }, { kind: "component", type: i4$4.MatMenuItem, selector: "[mat-menu-item]", inputs: ["role", "disabled", "disableRipple"], exportAs: ["matMenuItem"] }, { kind: "directive", type: i4$4.MatMenuContent, selector: "ng-template[matMenuContent]" }, { kind: "directive", type: i4$4.MatMenuTrigger, selector: "[mat-menu-trigger-for], [matMenuTriggerFor]", inputs: ["mat-menu-trigger-for", "matMenuTriggerFor", "matMenuTriggerData", "matMenuTriggerRestoreFocus"], outputs: ["menuOpened", "onMenuOpen", "menuClosed", "onMenuClose"], exportAs: ["matMenuTrigger"] }, { 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: i3$1.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i3$1.MatPrefix, selector: "[matPrefix], [matIconPrefix], [matTextPrefix]", inputs: ["matTextPrefix"] }, { kind: "directive", type: i3$1.MatSuffix, selector: "[matSuffix], [matIconSuffix], [matTextSuffix]", inputs: ["matTextSuffix"] }, { kind: "directive", type: i4$2.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "component", type: 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: "directive", type: i15.CdkDropList, selector: "[cdkDropList], cdk-drop-list", inputs: ["cdkDropListConnectedTo", "cdkDropListData", "cdkDropListOrientation", "id", "cdkDropListLockAxis", "cdkDropListDisabled", "cdkDropListSortingDisabled", "cdkDropListEnterPredicate", "cdkDropListSortPredicate", "cdkDropListAutoScrollDisabled", "cdkDropListAutoScrollStep", "cdkDropListElementContainer", "cdkDropListHasAnchor"], outputs: ["cdkDropListDropped", "cdkDropListEntered", "cdkDropListExited", "cdkDropListSorted"], exportAs: ["cdkDropList"] }, { kind: "directive", type: i15.CdkDropListGroup, selector: "[cdkDropListGroup]", inputs: ["cdkDropListGroupDisabled"], exportAs: ["cdkDropListGroup"] }, { kind: "directive", type: i15.CdkDrag, selector: "[cdkDrag]", inputs: ["cdkDragData", "cdkDragLockAxis", "cdkDragRootElement", "cdkDragBoundary", "cdkDragStartDelay", "cdkDragFreeDragPosition", "cdkDragDisabled", "cdkDragConstrainPosition", "cdkDragPreviewClass", "cdkDragPreviewContainer", "cdkDragScale"], outputs: ["cdkDragStarted", "cdkDragReleased", "cdkDragEnded", "cdkDragEntered", "cdkDragExited", "cdkDragDropped", "cdkDragMoved"], exportAs: ["cdkDrag"] }, { kind: "pipe", type: i1$2.TitleCasePipe, name: "titlecase" }] }); }
17094
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: GroupsComponent, isStandalone: false, selector: "spa-groups", inputs: { config: "config", dataSource: "dataSource", displayedButtons: "displayedButtons", showOwnFilter: "showOwnFilter", filterText: "filterText" }, outputs: { actionClick: "actionClick" }, usesOnChanges: true, ngImport: i0, template: "<!-- Changed: this filter row was REMOVED from the grouped view's own markup and now lives in the table header,\n beside the action buttons, where every other table config puts it. It used to sit on a row of its own\n directly under the buttons row, leaving both rows half empty and visibly misaligned.\n The filtering logic here is untouched \u2014 only the input that drives it moved, and its text arrives via the\n filterText @Input relayed from the header. Kept as a fallback for a grouped view rendered WITHOUT the\n standard header (spa-groups can be used directly), so filtering is never simply lost. -->\n<div class=\"groups-filter\" *ngIf=\"showOwnFilter\">\n <mat-form-field appearance=\"outline\" class=\"filter-field\">\n <mat-icon matPrefix>search</mat-icon>\n <input matInput placeholder=\"Filter\" [(ngModel)]=\"filterText\" (ngModelChange)=\"applyFilter()\">\n <button *ngIf=\"filterText\" mat-icon-button matSuffix (click)=\"filterText = ''; applyFilter()\">\n <mat-icon>close</mat-icon>\n </button>\n </mat-form-field>\n</div>\n\n<div class=\"groups-container\" cdkDropListGroup>\n\n <mat-card *ngFor=\"let group of groupedData\" class=\"group-card\">\n\n <div class=\"group-header\">\n <div class=\"header-left\">\n <mat-icon *ngIf=\"group.icon\" [style.color]=\"group.color\" class=\"group-icon\">{{group.icon}}</mat-icon>\n <!-- Changed: added the native title attribute. On a phone the title now truncates with an ellipsis rather\n than wrapping to a second line, so the full name has to stay reachable somewhere \u2014 this is the\n zero-cost way to keep it. No tooltip directive, so nothing changes on touch. -->\n <label class=\"group-title\" [title]=\"group.displayName\">{{group.displayName}}</label>\n <label *ngIf=\"config.groupConfig.showGroupCount !== false\" class=\"group-count\">({{group.items.length}})</label>\n </div>\n\n <div class=\"header-right\">\n <button\n *ngFor=\"let button of getVisibleHeaderButtons(group)\"\n mat-icon-button\n [matTooltip]=\"getHeaderButtonTooltip(button)\"\n matTooltipPosition=\"above\"\n [style.color]=\"getHeaderButtonColor(button, group)\"\n [disabled]=\"isHeaderButtonDisabled(button, group)\"\n (click)=\"headerButtonClicked(button, group)\">\n <mat-icon>{{getHeaderButtonIcon(button)}}</mat-icon>\n </button>\n </div>\n </div>\n\n <hr class=\"group-divider\" />\n\n <!-- Empty state: show when no items and drag is disabled -->\n <div *ngIf=\"group.items.length === 0 && !config.groupConfig.dragEnabled\" class=\"empty-state\">\n <label>{{config.groupConfig.emptyGroupMessage ?? 'Empty'}}</label>\n </div>\n\n <!-- Changed: Replaced mat-chip-set with flex container of mat-stroked-button for cleaner button style -->\n <div\n *ngIf=\"group.items.length > 0 || config.groupConfig.dragEnabled\"\n cdkDropList\n [cdkDropListData]=\"group.items\"\n [cdkDropListDisabled]=\"!config.groupConfig.dragEnabled\"\n (cdkDropListDropped)=\"onDrop($event)\"\n (cdkDropListEntered)=\"onDropListEntered($event)\"\n (cdkDropListExited)=\"onDropListExited($event)\"\n [class.drop-highlight]=\"group === highlightedGroup\"\n class=\"drop-list items-container\">\n\n <!-- Empty state inside drop list when drag is enabled -->\n <div *ngIf=\"group.items.length === 0\" class=\"empty-state drop-empty\">\n <label>{{config.groupConfig.emptyGroupMessage ?? 'Empty'}}</label>\n </div>\n\n <button\n mat-stroked-button\n *ngFor=\"let item of group.items\"\n cdkDrag [cdkDragData]=\"item\"\n [cdkDragDisabled]=\"!config.groupConfig.dragEnabled\"\n [matMenuTriggerFor]=\"config.groupConfig.contextMenuEnabled !== false ? itemMenu : null\"\n [matMenuTriggerData]=\"{item: item, group: group}\"\n class=\"item-button\">\n\n <mat-icon\n *ngIf=\"getItemIcon(item)\"\n [style.color]=\"getItemIconColor(item)\"\n [matTooltip]=\"getItemIconTip(item)\"\n matTooltipPosition=\"above\"\n class=\"item-icon\">\n {{getItemIcon(item)}}\n </mat-icon>\n\n <!-- Changed: the item text moved into its own span so a runaway name can be capped and ellipsised without\n also squeezing the icons, which share this projection slot with it. The native title attribute keeps the\n full name recoverable on a truncated chip \u2014 same treatment already used on the group title above, and no\n tooltip directive, so touch behaviour is unchanged. -->\n <span class=\"item-label\" [title]=\"getItemText(item)\">{{getItemText(item)}}</span>\n\n <ng-container *ngFor=\"let additionalIcon of getVisibleAdditionalIcons(item)\">\n <mat-icon\n [style.color]=\"additionalIcon.color\"\n [matTooltip]=\"getIconTooltip(additionalIcon, item)\"\n matTooltipPosition=\"above\"\n class=\"item-additional-icon\">\n {{additionalIcon.name}}\n </mat-icon>\n </ng-container>\n\n </button>\n </div>\n\n </mat-card>\n\n</div>\n\n<mat-menu #itemMenu=\"matMenu\">\n <ng-template matMenuContent let-item=\"item\" let-group=\"group\">\n <button\n *ngFor=\"let button of getVisibleButtons(item)\"\n mat-menu-item\n [disabled]=\"isButtonDisabled(button, item)\"\n (click)=\"itemActionClicked(button.name, item, group)\">\n <mat-icon [style.color]=\"getButtonIconColor(button, item)\">{{getButtonIcon(button)}}</mat-icon>\n <span>{{button.display ?? button.tip ?? (button.name | titlecase)}}</span>\n </button>\n </ng-template>\n</mat-menu>\n", styles: [".groups-filter{display:flex;justify-content:flex-end;margin-bottom:8px;padding-right:10px}.filter-field{width:300px;font-size:13px}.filter-field ::ng-deep .mat-mdc-form-field-infix{padding-top:8px!important;padding-bottom:8px!important;min-height:36px}.groups-container{display:flex;flex-direction:column;gap:20px;padding-right:10px;margin-left:0}.group-card{width:100%;margin-bottom:0;padding:8px 16px 16px}.group-header{display:flex;justify-content:space-between;align-items:center;padding:0;min-height:32px}.header-left{display:flex;align-items:center;gap:5px}.header-right{display:flex;align-items:center;gap:4px}.group-icon{margin-right:5px;font-size:24px;width:24px;height:24px;line-height:24px}.group-title{font-size:24px;font-weight:300;line-height:1.2}.group-count{font-size:12px;font-weight:300;margin-left:5px;line-height:1.2}.group-divider{margin-top:8px;margin-bottom:10px}.empty-state{display:flex;justify-content:center;align-items:center;padding:10px}.empty-state label{color:#757575;font-style:italic}.items-container{display:flex;flex-wrap:wrap;gap:10px;padding:8px 0;align-items:center}.item-button{cursor:pointer;font-weight:400;letter-spacing:.01em;transition:box-shadow .2s ease,background-color .2s ease}.item-button:hover{box-shadow:0 1px 4px #00000026;background-color:#00000005}.item-label{display:inline-block;max-width:260px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:middle}.item-icon{font-size:18px;width:18px;height:18px;margin-right:4px;vertical-align:middle}.item-additional-icon{font-size:16px;width:16px;height:16px;margin-left:6px;vertical-align:middle;opacity:.85}button[mat-icon-button]{width:32px;height:32px}button[mat-icon-button] mat-icon{font-size:18px;margin-top:-3px}.header-right button[mat-icon-button]{display:inline-flex;align-items:center;justify-content:center;padding:0;line-height:normal}.header-right button[mat-icon-button] mat-icon{margin:0;font-size:20px;width:20px;height:20px;line-height:20px;display:block}.header-right button[mat-icon-button] .mat-mdc-button-touch-target{display:none}@media (max-width: 700px){.groups-container{gap:12px;padding-right:0}.group-card{padding:8px 10px 10px}.header-left{flex:1 1 auto;min-width:0;gap:4px}.group-icon{flex:0 0 auto;margin-right:2px;font-size:20px;width:20px;height:20px;line-height:20px}.group-title{font-size:16px;font-weight:400;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.group-count{flex:0 0 auto;margin-left:2px;font-size:11px}.header-right{flex:0 0 auto;gap:0}.header-right button[mat-icon-button] mat-icon{font-size:18px;width:18px;height:18px;line-height:18px}.group-header{min-height:28px}.group-divider{margin-top:6px;margin-bottom:6px}.items-container{gap:6px;padding:4px 0}.item-button{min-width:0;height:32px;padding:0 9px;font-size:12.5px;letter-spacing:normal;border-radius:16px;border-color:#0000001f;--mdc-outlined-button-container-height: 32px;--mdc-outlined-button-container-shape: 16px;--mdc-outlined-button-label-text-size: 12.5px}.item-button ::ng-deep .mdc-button__label{letter-spacing:normal;display:inline-flex;align-items:center}.item-button ::ng-deep .mat-mdc-button-touch-target{height:100%}.item-label{max-width:104px}.item-icon{font-size:14px;width:14px;height:14px;margin-right:3px}.item-additional-icon{font-size:14px;width:14px;height:14px;margin-left:3px}.drop-list{min-height:32px}.empty-state{padding:4px 10px}}.drop-list{min-height:40px}.drop-empty{padding:5px 10px}.cdk-drag-preview{box-shadow:0 5px 5px -3px #0003,0 8px 10px 1px #00000024,0 3px 14px 2px #0000001f;opacity:.9;border-radius:4px}.cdk-drag-animating{transition:transform .25s cubic-bezier(0,0,.2,1)}.drop-highlight{border:2px dashed #90caf9;border-radius:8px;background:#90caf90d}.cdk-drop-list-dragging .item-button:not(.cdk-drag-placeholder){transition:transform .25s cubic-bezier(0,0,.2,1)}\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: 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: i2$2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: i4$4.MatMenu, selector: "mat-menu", inputs: ["backdropClass", "aria-label", "aria-labelledby", "aria-describedby", "xPosition", "yPosition", "overlapTrigger", "hasBackdrop", "class", "classList"], outputs: ["closed", "close"], exportAs: ["matMenu"] }, { kind: "component", type: i4$4.MatMenuItem, selector: "[mat-menu-item]", inputs: ["role", "disabled", "disableRipple"], exportAs: ["matMenuItem"] }, { kind: "directive", type: i4$4.MatMenuContent, selector: "ng-template[matMenuContent]" }, { kind: "directive", type: i4$4.MatMenuTrigger, selector: "[mat-menu-trigger-for], [matMenuTriggerFor]", inputs: ["mat-menu-trigger-for", "matMenuTriggerFor", "matMenuTriggerData", "matMenuTriggerRestoreFocus"], outputs: ["menuOpened", "onMenuOpen", "menuClosed", "onMenuClose"], exportAs: ["matMenuTrigger"] }, { 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: i3$1.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i3$1.MatPrefix, selector: "[matPrefix], [matIconPrefix], [matTextPrefix]", inputs: ["matTextPrefix"] }, { kind: "directive", type: i3$1.MatSuffix, selector: "[matSuffix], [matIconSuffix], [matTextSuffix]", inputs: ["matTextSuffix"] }, { kind: "directive", type: i4$2.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "component", type: 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: "directive", type: i15.CdkDropList, selector: "[cdkDropList], cdk-drop-list", inputs: ["cdkDropListConnectedTo", "cdkDropListData", "cdkDropListOrientation", "id", "cdkDropListLockAxis", "cdkDropListDisabled", "cdkDropListSortingDisabled", "cdkDropListEnterPredicate", "cdkDropListSortPredicate", "cdkDropListAutoScrollDisabled", "cdkDropListAutoScrollStep", "cdkDropListElementContainer", "cdkDropListHasAnchor"], outputs: ["cdkDropListDropped", "cdkDropListEntered", "cdkDropListExited", "cdkDropListSorted"], exportAs: ["cdkDropList"] }, { kind: "directive", type: i15.CdkDropListGroup, selector: "[cdkDropListGroup]", inputs: ["cdkDropListGroupDisabled"], exportAs: ["cdkDropListGroup"] }, { kind: "directive", type: i15.CdkDrag, selector: "[cdkDrag]", inputs: ["cdkDragData", "cdkDragLockAxis", "cdkDragRootElement", "cdkDragBoundary", "cdkDragStartDelay", "cdkDragFreeDragPosition", "cdkDragDisabled", "cdkDragConstrainPosition", "cdkDragPreviewClass", "cdkDragPreviewContainer", "cdkDragScale"], outputs: ["cdkDragStarted", "cdkDragReleased", "cdkDragEnded", "cdkDragEntered", "cdkDragExited", "cdkDragDropped", "cdkDragMoved"], exportAs: ["cdkDrag"] }, { kind: "pipe", type: i1$2.TitleCasePipe, name: "titlecase" }] }); }
16288
17095
  }
16289
17096
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: GroupsComponent, decorators: [{
16290
17097
  type: Component,
16291
- args: [{ selector: 'spa-groups', standalone: false, template: "<!-- Changed: this filter row was REMOVED from the grouped view's own markup and now lives in the table header,\n beside the action buttons, where every other table config puts it. It used to sit on a row of its own\n directly under the buttons row, leaving both rows half empty and visibly misaligned.\n The filtering logic here is untouched \u2014 only the input that drives it moved, and its text arrives via the\n filterText @Input relayed from the header. Kept as a fallback for a grouped view rendered WITHOUT the\n standard header (spa-groups can be used directly), so filtering is never simply lost. -->\n<div class=\"groups-filter\" *ngIf=\"showOwnFilter\">\n <mat-form-field appearance=\"outline\" class=\"filter-field\">\n <mat-icon matPrefix>search</mat-icon>\n <input matInput placeholder=\"Filter\" [(ngModel)]=\"filterText\" (ngModelChange)=\"applyFilter()\">\n <button *ngIf=\"filterText\" mat-icon-button matSuffix (click)=\"filterText = ''; applyFilter()\">\n <mat-icon>close</mat-icon>\n </button>\n </mat-form-field>\n</div>\n\n<div class=\"groups-container\" cdkDropListGroup>\n\n <mat-card *ngFor=\"let group of groupedData\" class=\"group-card\">\n\n <div class=\"group-header\">\n <div class=\"header-left\">\n <mat-icon *ngIf=\"group.icon\" [style.color]=\"group.color\" class=\"group-icon\">{{group.icon}}</mat-icon>\n <!-- Changed: added the native title attribute. On a phone the title now truncates with an ellipsis rather\n than wrapping to a second line, so the full name has to stay reachable somewhere \u2014 this is the\n zero-cost way to keep it. No tooltip directive, so nothing changes on touch. -->\n <label class=\"group-title\" [title]=\"group.displayName\">{{group.displayName}}</label>\n <label *ngIf=\"config.groupConfig.showGroupCount !== false\" class=\"group-count\">({{group.items.length}})</label>\n </div>\n\n <div class=\"header-right\">\n <button\n *ngFor=\"let button of getVisibleHeaderButtons(group)\"\n mat-icon-button\n [matTooltip]=\"getHeaderButtonTooltip(button)\"\n matTooltipPosition=\"above\"\n [style.color]=\"getHeaderButtonColor(button, group)\"\n [disabled]=\"isHeaderButtonDisabled(button, group)\"\n (click)=\"headerButtonClicked(button, group)\">\n <mat-icon>{{getHeaderButtonIcon(button)}}</mat-icon>\n </button>\n </div>\n </div>\n\n <hr class=\"group-divider\" />\n\n <!-- Empty state: show when no items and drag is disabled -->\n <div *ngIf=\"group.items.length === 0 && !config.groupConfig.dragEnabled\" class=\"empty-state\">\n <label>{{config.groupConfig.emptyGroupMessage ?? 'Empty'}}</label>\n </div>\n\n <!-- Changed: Replaced mat-chip-set with flex container of mat-stroked-button for cleaner button style -->\n <div\n *ngIf=\"group.items.length > 0 || config.groupConfig.dragEnabled\"\n cdkDropList\n [cdkDropListData]=\"group.items\"\n [cdkDropListDisabled]=\"!config.groupConfig.dragEnabled\"\n (cdkDropListDropped)=\"onDrop($event)\"\n (cdkDropListEntered)=\"onDropListEntered($event)\"\n (cdkDropListExited)=\"onDropListExited($event)\"\n [class.drop-highlight]=\"group === highlightedGroup\"\n class=\"drop-list items-container\">\n\n <!-- Empty state inside drop list when drag is enabled -->\n <div *ngIf=\"group.items.length === 0\" class=\"empty-state drop-empty\">\n <label>{{config.groupConfig.emptyGroupMessage ?? 'Empty'}}</label>\n </div>\n\n <button\n mat-stroked-button\n *ngFor=\"let item of group.items\"\n cdkDrag [cdkDragData]=\"item\"\n [cdkDragDisabled]=\"!config.groupConfig.dragEnabled\"\n [matMenuTriggerFor]=\"config.groupConfig.contextMenuEnabled !== false ? itemMenu : null\"\n [matMenuTriggerData]=\"{item: item, group: group}\"\n class=\"item-button\">\n\n <mat-icon\n *ngIf=\"getItemIcon(item)\"\n [style.color]=\"getItemIconColor(item)\"\n [matTooltip]=\"getItemIconTip(item)\"\n matTooltipPosition=\"above\"\n class=\"item-icon\">\n {{getItemIcon(item)}}\n </mat-icon>\n\n {{getItemText(item)}}\n\n <ng-container *ngFor=\"let additionalIcon of getVisibleAdditionalIcons(item)\">\n <mat-icon\n [style.color]=\"additionalIcon.color\"\n [matTooltip]=\"getIconTooltip(additionalIcon, item)\"\n matTooltipPosition=\"above\"\n class=\"item-additional-icon\">\n {{additionalIcon.name}}\n </mat-icon>\n </ng-container>\n\n </button>\n </div>\n\n </mat-card>\n\n</div>\n\n<mat-menu #itemMenu=\"matMenu\">\n <ng-template matMenuContent let-item=\"item\" let-group=\"group\">\n <button\n *ngFor=\"let button of getVisibleButtons(item)\"\n mat-menu-item\n [disabled]=\"isButtonDisabled(button, item)\"\n (click)=\"itemActionClicked(button.name, item, group)\">\n <mat-icon [style.color]=\"getButtonIconColor(button, item)\">{{getButtonIcon(button)}}</mat-icon>\n <span>{{button.display ?? button.tip ?? (button.name | titlecase)}}</span>\n </button>\n </ng-template>\n</mat-menu>\n", styles: [".groups-filter{display:flex;justify-content:flex-end;margin-bottom:8px;padding-right:10px}.filter-field{width:300px;font-size:13px}.filter-field ::ng-deep .mat-mdc-form-field-infix{padding-top:8px!important;padding-bottom:8px!important;min-height:36px}.groups-container{display:flex;flex-direction:column;gap:20px;padding-right:10px;margin-left:0}.group-card{width:100%;margin-bottom:0;padding:8px 16px 16px}.group-header{display:flex;justify-content:space-between;align-items:center;padding:0;min-height:32px}.header-left{display:flex;align-items:center;gap:5px}.header-right{display:flex;align-items:center;gap:4px}.group-icon{margin-right:5px;font-size:24px;width:24px;height:24px;line-height:24px}.group-title{font-size:24px;font-weight:300;line-height:1.2}.group-count{font-size:12px;font-weight:300;margin-left:5px;line-height:1.2}.group-divider{margin-top:8px;margin-bottom:10px}.empty-state{display:flex;justify-content:center;align-items:center;padding:10px}.empty-state label{color:#757575;font-style:italic}.items-container{display:flex;flex-wrap:wrap;gap:10px;padding:8px 0;align-items:center}.item-button{cursor:pointer;font-weight:400;letter-spacing:.01em;transition:box-shadow .2s ease,background-color .2s ease}.item-button:hover{box-shadow:0 1px 4px #00000026;background-color:#00000005}.item-icon{font-size:18px;width:18px;height:18px;margin-right:4px;vertical-align:middle}.item-additional-icon{font-size:16px;width:16px;height:16px;margin-left:6px;vertical-align:middle;opacity:.85}button[mat-icon-button]{width:32px;height:32px}button[mat-icon-button] mat-icon{font-size:18px;margin-top:-3px}.header-right button[mat-icon-button]{display:inline-flex;align-items:center;justify-content:center;padding:0;line-height:normal}.header-right button[mat-icon-button] mat-icon{margin:0;font-size:20px;width:20px;height:20px;line-height:20px;display:block}.header-right button[mat-icon-button] .mat-mdc-button-touch-target{display:none}@media (max-width: 700px){.groups-container{gap:12px;padding-right:0}.group-card{padding:8px 10px 10px}.header-left{flex:1 1 auto;min-width:0;gap:4px}.group-icon{flex:0 0 auto;margin-right:2px;font-size:20px;width:20px;height:20px;line-height:20px}.group-title{font-size:16px;font-weight:400;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.group-count{flex:0 0 auto;margin-left:2px;font-size:11px}.header-right{flex:0 0 auto;gap:0}.header-right button[mat-icon-button] mat-icon{font-size:18px;width:18px;height:18px;line-height:18px}.group-header{min-height:28px}.group-divider{margin-top:6px;margin-bottom:6px}.items-container{gap:6px;padding:4px 0}.item-button{min-width:0;height:32px;padding:0 9px;font-size:12.5px;letter-spacing:normal;border-radius:16px;border-color:#0000001f;--mdc-outlined-button-container-height: 32px;--mdc-outlined-button-container-shape: 16px;--mdc-outlined-button-label-text-size: 12.5px}.item-button ::ng-deep .mdc-button__label{letter-spacing:normal;display:inline-flex;align-items:center}.item-button ::ng-deep .mat-mdc-button-touch-target{height:100%}.item-icon{font-size:14px;width:14px;height:14px;margin-right:3px}.item-additional-icon{font-size:14px;width:14px;height:14px;margin-left:3px}.drop-list{min-height:32px}.empty-state{padding:4px 10px}}.drop-list{min-height:40px}.drop-empty{padding:5px 10px}.cdk-drag-preview{box-shadow:0 5px 5px -3px #0003,0 8px 10px 1px #00000024,0 3px 14px 2px #0000001f;opacity:.9;border-radius:4px}.cdk-drag-animating{transition:transform .25s cubic-bezier(0,0,.2,1)}.drop-highlight{border:2px dashed #90caf9;border-radius:8px;background:#90caf90d}.cdk-drop-list-dragging .item-button:not(.cdk-drag-placeholder){transition:transform .25s cubic-bezier(0,0,.2,1)}\n"] }]
17098
+ args: [{ selector: 'spa-groups', standalone: false, template: "<!-- Changed: this filter row was REMOVED from the grouped view's own markup and now lives in the table header,\n beside the action buttons, where every other table config puts it. It used to sit on a row of its own\n directly under the buttons row, leaving both rows half empty and visibly misaligned.\n The filtering logic here is untouched \u2014 only the input that drives it moved, and its text arrives via the\n filterText @Input relayed from the header. Kept as a fallback for a grouped view rendered WITHOUT the\n standard header (spa-groups can be used directly), so filtering is never simply lost. -->\n<div class=\"groups-filter\" *ngIf=\"showOwnFilter\">\n <mat-form-field appearance=\"outline\" class=\"filter-field\">\n <mat-icon matPrefix>search</mat-icon>\n <input matInput placeholder=\"Filter\" [(ngModel)]=\"filterText\" (ngModelChange)=\"applyFilter()\">\n <button *ngIf=\"filterText\" mat-icon-button matSuffix (click)=\"filterText = ''; applyFilter()\">\n <mat-icon>close</mat-icon>\n </button>\n </mat-form-field>\n</div>\n\n<div class=\"groups-container\" cdkDropListGroup>\n\n <mat-card *ngFor=\"let group of groupedData\" class=\"group-card\">\n\n <div class=\"group-header\">\n <div class=\"header-left\">\n <mat-icon *ngIf=\"group.icon\" [style.color]=\"group.color\" class=\"group-icon\">{{group.icon}}</mat-icon>\n <!-- Changed: added the native title attribute. On a phone the title now truncates with an ellipsis rather\n than wrapping to a second line, so the full name has to stay reachable somewhere \u2014 this is the\n zero-cost way to keep it. No tooltip directive, so nothing changes on touch. -->\n <label class=\"group-title\" [title]=\"group.displayName\">{{group.displayName}}</label>\n <label *ngIf=\"config.groupConfig.showGroupCount !== false\" class=\"group-count\">({{group.items.length}})</label>\n </div>\n\n <div class=\"header-right\">\n <button\n *ngFor=\"let button of getVisibleHeaderButtons(group)\"\n mat-icon-button\n [matTooltip]=\"getHeaderButtonTooltip(button)\"\n matTooltipPosition=\"above\"\n [style.color]=\"getHeaderButtonColor(button, group)\"\n [disabled]=\"isHeaderButtonDisabled(button, group)\"\n (click)=\"headerButtonClicked(button, group)\">\n <mat-icon>{{getHeaderButtonIcon(button)}}</mat-icon>\n </button>\n </div>\n </div>\n\n <hr class=\"group-divider\" />\n\n <!-- Empty state: show when no items and drag is disabled -->\n <div *ngIf=\"group.items.length === 0 && !config.groupConfig.dragEnabled\" class=\"empty-state\">\n <label>{{config.groupConfig.emptyGroupMessage ?? 'Empty'}}</label>\n </div>\n\n <!-- Changed: Replaced mat-chip-set with flex container of mat-stroked-button for cleaner button style -->\n <div\n *ngIf=\"group.items.length > 0 || config.groupConfig.dragEnabled\"\n cdkDropList\n [cdkDropListData]=\"group.items\"\n [cdkDropListDisabled]=\"!config.groupConfig.dragEnabled\"\n (cdkDropListDropped)=\"onDrop($event)\"\n (cdkDropListEntered)=\"onDropListEntered($event)\"\n (cdkDropListExited)=\"onDropListExited($event)\"\n [class.drop-highlight]=\"group === highlightedGroup\"\n class=\"drop-list items-container\">\n\n <!-- Empty state inside drop list when drag is enabled -->\n <div *ngIf=\"group.items.length === 0\" class=\"empty-state drop-empty\">\n <label>{{config.groupConfig.emptyGroupMessage ?? 'Empty'}}</label>\n </div>\n\n <button\n mat-stroked-button\n *ngFor=\"let item of group.items\"\n cdkDrag [cdkDragData]=\"item\"\n [cdkDragDisabled]=\"!config.groupConfig.dragEnabled\"\n [matMenuTriggerFor]=\"config.groupConfig.contextMenuEnabled !== false ? itemMenu : null\"\n [matMenuTriggerData]=\"{item: item, group: group}\"\n class=\"item-button\">\n\n <mat-icon\n *ngIf=\"getItemIcon(item)\"\n [style.color]=\"getItemIconColor(item)\"\n [matTooltip]=\"getItemIconTip(item)\"\n matTooltipPosition=\"above\"\n class=\"item-icon\">\n {{getItemIcon(item)}}\n </mat-icon>\n\n <!-- Changed: the item text moved into its own span so a runaway name can be capped and ellipsised without\n also squeezing the icons, which share this projection slot with it. The native title attribute keeps the\n full name recoverable on a truncated chip \u2014 same treatment already used on the group title above, and no\n tooltip directive, so touch behaviour is unchanged. -->\n <span class=\"item-label\" [title]=\"getItemText(item)\">{{getItemText(item)}}</span>\n\n <ng-container *ngFor=\"let additionalIcon of getVisibleAdditionalIcons(item)\">\n <mat-icon\n [style.color]=\"additionalIcon.color\"\n [matTooltip]=\"getIconTooltip(additionalIcon, item)\"\n matTooltipPosition=\"above\"\n class=\"item-additional-icon\">\n {{additionalIcon.name}}\n </mat-icon>\n </ng-container>\n\n </button>\n </div>\n\n </mat-card>\n\n</div>\n\n<mat-menu #itemMenu=\"matMenu\">\n <ng-template matMenuContent let-item=\"item\" let-group=\"group\">\n <button\n *ngFor=\"let button of getVisibleButtons(item)\"\n mat-menu-item\n [disabled]=\"isButtonDisabled(button, item)\"\n (click)=\"itemActionClicked(button.name, item, group)\">\n <mat-icon [style.color]=\"getButtonIconColor(button, item)\">{{getButtonIcon(button)}}</mat-icon>\n <span>{{button.display ?? button.tip ?? (button.name | titlecase)}}</span>\n </button>\n </ng-template>\n</mat-menu>\n", styles: [".groups-filter{display:flex;justify-content:flex-end;margin-bottom:8px;padding-right:10px}.filter-field{width:300px;font-size:13px}.filter-field ::ng-deep .mat-mdc-form-field-infix{padding-top:8px!important;padding-bottom:8px!important;min-height:36px}.groups-container{display:flex;flex-direction:column;gap:20px;padding-right:10px;margin-left:0}.group-card{width:100%;margin-bottom:0;padding:8px 16px 16px}.group-header{display:flex;justify-content:space-between;align-items:center;padding:0;min-height:32px}.header-left{display:flex;align-items:center;gap:5px}.header-right{display:flex;align-items:center;gap:4px}.group-icon{margin-right:5px;font-size:24px;width:24px;height:24px;line-height:24px}.group-title{font-size:24px;font-weight:300;line-height:1.2}.group-count{font-size:12px;font-weight:300;margin-left:5px;line-height:1.2}.group-divider{margin-top:8px;margin-bottom:10px}.empty-state{display:flex;justify-content:center;align-items:center;padding:10px}.empty-state label{color:#757575;font-style:italic}.items-container{display:flex;flex-wrap:wrap;gap:10px;padding:8px 0;align-items:center}.item-button{cursor:pointer;font-weight:400;letter-spacing:.01em;transition:box-shadow .2s ease,background-color .2s ease}.item-button:hover{box-shadow:0 1px 4px #00000026;background-color:#00000005}.item-label{display:inline-block;max-width:260px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:middle}.item-icon{font-size:18px;width:18px;height:18px;margin-right:4px;vertical-align:middle}.item-additional-icon{font-size:16px;width:16px;height:16px;margin-left:6px;vertical-align:middle;opacity:.85}button[mat-icon-button]{width:32px;height:32px}button[mat-icon-button] mat-icon{font-size:18px;margin-top:-3px}.header-right button[mat-icon-button]{display:inline-flex;align-items:center;justify-content:center;padding:0;line-height:normal}.header-right button[mat-icon-button] mat-icon{margin:0;font-size:20px;width:20px;height:20px;line-height:20px;display:block}.header-right button[mat-icon-button] .mat-mdc-button-touch-target{display:none}@media (max-width: 700px){.groups-container{gap:12px;padding-right:0}.group-card{padding:8px 10px 10px}.header-left{flex:1 1 auto;min-width:0;gap:4px}.group-icon{flex:0 0 auto;margin-right:2px;font-size:20px;width:20px;height:20px;line-height:20px}.group-title{font-size:16px;font-weight:400;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.group-count{flex:0 0 auto;margin-left:2px;font-size:11px}.header-right{flex:0 0 auto;gap:0}.header-right button[mat-icon-button] mat-icon{font-size:18px;width:18px;height:18px;line-height:18px}.group-header{min-height:28px}.group-divider{margin-top:6px;margin-bottom:6px}.items-container{gap:6px;padding:4px 0}.item-button{min-width:0;height:32px;padding:0 9px;font-size:12.5px;letter-spacing:normal;border-radius:16px;border-color:#0000001f;--mdc-outlined-button-container-height: 32px;--mdc-outlined-button-container-shape: 16px;--mdc-outlined-button-label-text-size: 12.5px}.item-button ::ng-deep .mdc-button__label{letter-spacing:normal;display:inline-flex;align-items:center}.item-button ::ng-deep .mat-mdc-button-touch-target{height:100%}.item-label{max-width:104px}.item-icon{font-size:14px;width:14px;height:14px;margin-right:3px}.item-additional-icon{font-size:14px;width:14px;height:14px;margin-left:3px}.drop-list{min-height:32px}.empty-state{padding:4px 10px}}.drop-list{min-height:40px}.drop-empty{padding:5px 10px}.cdk-drag-preview{box-shadow:0 5px 5px -3px #0003,0 8px 10px 1px #00000024,0 3px 14px 2px #0000001f;opacity:.9;border-radius:4px}.cdk-drag-animating{transition:transform .25s cubic-bezier(0,0,.2,1)}.drop-highlight{border:2px dashed #90caf9;border-radius:8px;background:#90caf90d}.cdk-drop-list-dragging .item-button:not(.cdk-drag-placeholder){transition:transform .25s cubic-bezier(0,0,.2,1)}\n"] }]
16292
17099
  }], ctorParameters: () => [{ type: ConditionService }, { type: ButtonService }, { type: DataServiceLib }, { type: i2$1.MatSnackBar }, { type: ApiErrorService }], propDecorators: { config: [{
16293
17100
  type: Input
16294
17101
  }], dataSource: [{
@@ -16670,6 +17477,25 @@ class TableComponent {
16670
17477
  startInlineEdit(row) {
16671
17478
  this.editingRow = row;
16672
17479
  this.editingModel = { ...row }; // shallow copy — scalars only change, cancel restores by discarding
17480
+ this.seedInlineDefaults(row); // Added: a field the row does not carry would otherwise open blank
17481
+ }
17482
+ // Added: an inline-editable field the row has no value for (a weaning date on a "ready to wean" row) opened
17483
+ // empty, so every operator retyped the same obvious answer. It now takes the field's OWN defaultValue, exactly
17484
+ // as the dialog form does via Core.getInitialValue — including defaultValue:'now' for dates. defaultValue may
17485
+ // additionally be a FUNCTION OF THE ROW here (count -> row.alive), which the dialog path has no need of because
17486
+ // a create dialog has no row to read. Only null/undefined values are seeded, so real row data is never replaced.
17487
+ seedInlineDefaults(row) {
17488
+ if (!this.config.inlineEdit || !this.config.columns)
17489
+ return;
17490
+ for (const column of this.config.columns) {
17491
+ const field = this.getInlineField(column);
17492
+ if (!field || field.defaultValue === undefined || field.defaultValue === null)
17493
+ continue;
17494
+ const current = this.editingModel[field.name];
17495
+ if (current !== undefined && current !== null && current !== '')
17496
+ continue;
17497
+ this.editingModel[field.name] = typeof field.defaultValue === 'function' ? field.defaultValue(row) : Core.getInitialValue(field);
17498
+ }
16673
17499
  }
16674
17500
  cancelInlineEdit() {
16675
17501
  this.editingRow = null;
@@ -17611,11 +18437,11 @@ class TableComponent {
17611
18437
  this.setPaginator();
17612
18438
  }
17613
18439
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: TableComponent, deps: [{ token: DataServiceLib }, { token: MessageService }, { token: i1$4.BreakpointObserver }, { token: i4.MatDialog }, { token: ButtonService }, { token: DialogService }, { token: TableConfigService }, { token: ConditionService }, { token: AuthService }, { token: SignalRService }, { token: OfflineService }, { token: ApiErrorService }, { token: TIN_SPA_RUNTIME_CONFIG, optional: true }], target: i0.ɵɵFactoryTarget.Component }); }
17614
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: TableComponent, isStandalone: false, selector: "spa-table", inputs: { data: "data", tileData: "tileData", config: "config", localMode: "localMode", parentDetails: "parentDetails", reload: "reload", activeTab: "activeTab", inTab: "inTab", nestingLevel: "nestingLevel" }, outputs: { dataLoad: "dataLoad", totalChange: "totalChange", actionSuccess: "actionSuccess", refreshClick: "refreshClick", searchClick: "searchClick", createClick: "createClick", actionClick: "actionClick", inputChange: "inputChange", actionResponse: "actionResponse" }, viewQueries: [{ propertyName: "tablePaginator", first: true, predicate: ["tablePaginator"], descendants: true }], usesOnChanges: true, ngImport: i0, template: "\n<ng-container *ngIf=\"hasFormAccess && !sectionHidden\"> <!-- Changed: sectionConfig.hideWhenEmpty hides the whole table -->\n\n <!-- Added: collapsible flat section header (sectionConfig) \u2014 1px border, no elevation, whole row toggles -->\n <div class=\"tbl-section-header\" *ngIf=\"config.sectionConfig\" (click)=\"toggleSection()\" [attr.aria-expanded]=\"!sectionCollapsed\" [class.tbl-section-static]=\"config.sectionConfig.collapsible === false\" role=\"button\" tabindex=\"0\" (keydown.enter)=\"toggleSection()\">\n <mat-icon class=\"tbl-section-icon\" *ngIf=\"config.sectionConfig.icon\">{{ config.sectionConfig.icon }}</mat-icon>\n <span class=\"tbl-section-title\">{{ config.sectionConfig.title }}</span>\n <span class=\"tbl-section-count\" *ngIf=\"config.sectionConfig.showCount !== false\">{{ dataSource?.length || 0 }}</span>\n <span class=\"tbl-section-chip\" *ngFor=\"let chip of sectionChips()\" [style.color]=\"chip.color\">{{ chip.text }}</span>\n <span class=\"tbl-section-spacer\"></span>\n <!-- Changed: the label is wrapped so a NARROW screen can drop it and leave an icon-only button. Only a\n button that HAS an icon loses its text \u2014 otherwise it would collapse to a blank square. -->\n <button mat-stroked-button color=\"primary\" *ngFor=\"let btn of sectionButtons()\" (click)=\"sectionButtonClicked(btn, $event)\" [matTooltip]=\"btn.display || btn.name\"><mat-icon *ngIf=\"btn.icon?.name\">{{ btn.icon.name }}</mat-icon><span class=\"tbl-section-btn-text\" [class.has-icon]=\"!!btn.icon?.name\">{{ btn.display || btn.name }}</span></button>\n <mat-icon class=\"tbl-section-chevron\" *ngIf=\"config.sectionConfig.collapsible !== false\">{{ sectionCollapsed ? 'expand_more' : 'expand_less' }}</mat-icon>\n </div>\n\n <!-- Added: the section's \"why this list exists\" line. It sits UNDER the title (a reason only makes sense once\n the list has been named) and only while the section is open \u2014 a shut section already has its own summary\n line, and a paragraph over the top of that is noise. -->\n <p class=\"tbl-section-caption\" *ngIf=\"config.sectionConfig?.caption && !sectionCollapsed\">{{ config.sectionConfig.caption }}</p>\n\n <!-- Added: collapsed section affordance \u2014 mirrors the Day Book \"Show the N\" pattern -->\n <div class=\"tbl-section-more\" *ngIf=\"config.sectionConfig && sectionCollapsed && (dataSource?.length || 0) > 0\">\n <button type=\"button\" class=\"tbl-section-link\" (click)=\"toggleSection()\">Show the {{ dataSource.length }}</button>\n </div>\n\n <ng-container *ngIf=\"!config.sectionConfig || !sectionCollapsed\"> <!-- Added: section collapse hides the table body -->\n\n <!-- Search -->\n <spa-search\n *ngIf=\"config.searchConfig\" [config]=\"config.searchConfig\" [smallScreen]=\"smallScreen\" [tableDataSource]=\"tableDataSource\" style=\"margin-bottom: 20px;\" (searchClick)=\"searchClicked($event)\">\n </spa-search>\n\n <!-- Header -->\n <app-table-header\n [config]=\"config\" [data]=\"dataSource\" [tableDataSource]=\"tableDataSource\" [tileConfig]=\"config.tileConfig\" [tileData]=\"tileData\" [tileReload]=\"tileReload\" [lastSearch]=\"lastSearch\" [smallScreen]=\"smallScreen\"\n [showFilterButton]=\"showFilterButton\" [isRealTime]=\"config.realTime\" [isConnected]=\"isSignalRConnected\" [refreshing]=\"loadingStage === 'refresh'\"\n (createClick)=\"newModel()\" (customClick)=\"customModel($event,null)\"\n (refreshClick)=\"refreshClicked()\" (tileClick)=\"tileClicked($event)\" (tileUnClick)=\"tileUnClicked($event)\" (filterChange)=\"filterChanged($event)\">\n </app-table-header>\n\n <!-- Added (Quiet Loading D4): refresh with data already on screen \u2014 a 2px line flush under the header and the\n spinning refresh icon are the ONLY signals. Rows stay live, clickable and un-dimmed while they swap. -->\n <div *ngIf=\"loadingStage === 'refresh'\" class=\"tin-load-line\" aria-hidden=\"true\">\n <span class=\"tin-load-line-fill\" [style.width.%]=\"progressPercent\"></span>\n </div>\n\n <!-- Added: paged-mode filter hint \u2014 the client filter only covers rows loaded so far -->\n <div *ngIf=\"pagedMode && filterActive && loadedRows.length < serverTotal\" class=\"paged-filter-hint\">\n <mat-icon>info</mat-icon>\n <span>Filtering only the {{loadedRows.length}} loaded rows of {{serverTotal}}. {{ config.searchConfig ? 'Use Search for complete results.' : 'Refine with search for complete results.' }}</span>\n </div>\n\n\n <!-- Table -->\n <div *ngIf=\"!config.viewType || config?.viewType === 'table'\">\n\n <p *ngIf=\"!config\"><em>Configure Table</em></p>\n <p *ngIf=\"!dataSource && !loadingStage\"><em>Loading...</em></p> <!-- Changed (Quiet Loading): the bare text is replaced by the stage below while a quiet load is on screen -->\n\n <!-- Added (Quiet Loading D3): first load, nothing on screen yet. The progress module is the hero (eased bar +\n counting percentage + caption) and the ghost rows hold the exact space the real rows will fill, so the\n table does not jump when data lands. Only ever rendered when quiet loading is on. -->\n <div *ngIf=\"loadingStage === 'initial'\" class=\"tin-load-stage\" role=\"status\" aria-busy=\"true\">\n\n <div class=\"tin-load-progress\">\n <div class=\"tin-load-bar\">\n <span class=\"tin-load-bar-fill\" [style.width.%]=\"progressPercent\"></span>\n </div>\n <span class=\"tin-load-percent\" aria-hidden=\"true\">{{progressDisplay}}%</span>\n </div>\n\n <div class=\"tin-load-caption\">Loading {{stageEntityName}}\u2026</div>\n\n <div class=\"tin-load-ghosts\">\n <div class=\"tin-load-ghost-row\" *ngFor=\"let r of ghostRows\">\n <div class=\"tin-load-ghost-cell\" *ngFor=\"let c of ghostColumns; let i = index\">\n <span class=\"tin-skel\" [style.width.%]=\"ghostWidth(i, c)\" [style.animation-delay.ms]=\"r * 120\"></span> <!-- staggered sweep: each row starts 120ms after the one above -->\n </div>\n </div>\n </div>\n\n </div>\n\n <div *ngIf=\"dataSource && loadingStage !== 'initial' && (!smallScreen || (smallScreen && dataSource?.length > 0))\" [class.tin-load-in]=\"effQuietLoading\"> <!-- Changed (Quiet Loading): the empty header-only table is suppressed while the initial stage stands in for it, and the real rows fade in where the ghosts were (D3) -->\n\n <table mat-table [dataSource]=\"tableDataSource\" [trackBy]=\"trackByRow\" [ngClass]=\"elevation\" [class.tin-no-col-headers]=\"config.hideColumnHeaders\"> <!-- Changed: optional column-header suppression -->\n\n <ng-container *ngFor=\"let column of config.columns\" [matColumnDef]=\"column.name\">\n <th mat-header-cell *matHeaderCellDef >{{ column.alias ?? column.name | camelToWords }}</th>\n <td mat-cell *matCellDef=\"let row;\" class=\"right-padding\" >\n\n <!-- Added: inline edit \u2014 editable cells swap to their form-field editor while the row is in edit mode -->\n <app-inline-cell *ngIf=\"isRowEditing(row) && getInlineField(column); else displayCell\" [field]=\"getInlineField(column)\" [data]=\"editingModel\"></app-inline-cell>\n\n <!-- Rows -->\n <ng-template #displayCell>\n <app-table-row [column]=\"column\" [row]=\"row\" [config]=\"config\" [smallScreen]=\"smallScreen\"\n (actionClick)=\"actionClicked(column.name, row)\" (columnClick)=\"columnClicked(column, row)\" (showBannerEvent)=\"showBanner($event)\">\n </app-table-row>\n </ng-template>\n\n </td>\n </ng-container>\n\n <ng-container matColumnDef=\"action\">\n <th mat-header-cell *matHeaderCellDef> Action </th>\n <td mat-cell *matCellDef=\"let row\" [ngStyle]=\"{width:false ? '20px' : actionsWidth}\">\n <div class=\"action-buttons-container\">\n\n <!-- Added: inline edit \u2014 while a row edits in place, its actions collapse to submit/cancel -->\n <ng-container *ngIf=\"isRowEditing(row); else rowActions\">\n <button mat-icon-button matTooltip=\"Save\" matTooltipPosition=\"above\" (click)=\"submitInlineEdit()\"><mat-icon class=\"inline-save\">check</mat-icon></button> <!-- Changed: dropped color=\"primary\" \u2014 the icon now carries a green save cue -->\n <button mat-icon-button matTooltip=\"Cancel\" matTooltipPosition=\"above\" (click)=\"cancelInlineEdit()\"><mat-icon class=\"inline-cancel\">close</mat-icon></button> <!-- Changed: red cancel cue -->\n </ng-container>\n\n <!-- Actions -->\n <ng-template #rowActions>\n <app-table-action\n [displayedButtons]=\"displayedButtons\" [config]=\"config\" [smallScreen]=\"smallScreen\" [row]=\"row\" (actionClick)=\"actionClicked($event.name, $event.row)\">\n </app-table-action>\n </ng-template>\n\n </div>\n </td>\n </ng-container>\n\n\n <tr mat-header-row *matHeaderRowDef=\"displayedColumns\"></tr>\n <tr mat-row *matRowDef=\"let row; columns: displayedColumns;\" [ngClass]=\"{'make-gray': (config.greyOut && config.greyOut(row)) || row.pendingApproval, 'row-editing': isRowEditing(row)}\"></tr> <!-- Changed: row-editing flags the row that is open for inline edit -->\n </table>\n\n </div>\n\n <!-- Changed: Removed *ngIf condition to keep paginator always in DOM and maintain ViewChild reference -->\n <!-- Changed: Added CSS class binding to hide when no data instead of conditional rendering -->\n <!-- Changed: Legacy paginator only renders in non-paged mode (pagedMode is constant per instance, set before first render) -->\n <mat-paginator *ngIf=\"!pagedMode\"\n #tablePaginator\n [pageSizeOptions]=\"config.pageSizes ?? [10, 20, 50]\"\n [ngClass]=\"{'paginator-hidden': !dataSource || (smallScreen && dataSource?.length === 0) || loadingStage === 'initial'}\"\n showFirstLastButtons>\n </mat-paginator>\n\n <!-- Added: manual paginator for server-side paged mode \u2014 fully state-bound, never attached to MatTableDataSource. Always visible: when filtering it pages the in-memory filtered subset (length = filtered count); otherwise the server window (length = true total). No first/last jump (would force fetching the whole gap). -->\n <mat-paginator *ngIf=\"pagedMode\"\n [length]=\"filterActive ? filteredRows.length : serverTotal + overlayDelta\"\n [pageIndex]=\"pageIndex\"\n [pageSize]=\"pageSize\"\n [pageSizeOptions]=\"config.pageSizes ?? [10, 20, 50]\"\n [ngClass]=\"{'paginator-hidden': !dataSource || (smallScreen && dataSource?.length === 0) || loadingStage === 'initial'}\"\n (page)=\"onServerPage($event)\">\n </mat-paginator>\n\n </div>\n \n <!-- Added (Quiet Loading): the initial-load stage for the NON-table views.\n Without this, card/capsule/grouped showed literally nothing during a first load \u2014 the view component\n renders an empty dataSource and the \"No Data\" line is suppressed while the stage owns the space.\n The progress header is identical to the table's so the two feel like one feature; only the ghost\n furniture differs, because column-shaped rows are wrong in a card grid. -->\n <div *ngIf=\"loadingStage === 'initial' && config?.viewType && config?.viewType !== 'table'\" class=\"tin-load-stage\" role=\"status\" aria-busy=\"true\">\n <div class=\"tin-load-progress\">\n <div class=\"tin-load-bar\">\n <span class=\"tin-load-bar-fill\" [style.width.%]=\"progressPercent\"></span>\n </div>\n <span class=\"tin-load-percent\" aria-hidden=\"true\">{{progressDisplay}}%</span>\n </div>\n <div class=\"tin-load-caption\">Loading {{stageEntityName}}\u2026</div>\n\n <div *ngIf=\"config?.viewType === 'capsule'\" class=\"tin-load-ghost-capsules\" aria-hidden=\"true\">\n <span class=\"tin-skel tin-load-ghost-capsule\" *ngFor=\"let c of ghostCapsules\"></span>\n </div>\n\n <div *ngIf=\"config?.viewType === 'card'\" class=\"tin-load-ghost-cards\" aria-hidden=\"true\">\n <div class=\"tin-load-ghost-card\" *ngFor=\"let c of ghostCards\">\n <span class=\"tin-skel tin-skel-text tin-load-ghost-card-title\"></span>\n <span class=\"tin-skel tin-skel-text\"></span>\n <span class=\"tin-skel tin-skel-text tin-load-ghost-card-short\"></span>\n </div>\n </div>\n\n <div *ngIf=\"config?.viewType === 'grouped'\" class=\"tin-load-ghost-groups\" aria-hidden=\"true\">\n <div class=\"tin-load-ghost-group\" *ngFor=\"let g of ghostGroups\">\n <span class=\"tin-skel tin-load-ghost-group-head\"></span>\n <!-- Pills, not rows: a group card's body is a wrap of chips, so full-width bars promised a table\n and the stage did not resemble what replaced it. -->\n <div class=\"tin-load-ghost-group-items\">\n <span class=\"tin-skel tin-load-ghost-capsule\" *ngFor=\"let r of ghostCards\"></span>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Capsules -->\n <spa-capsules *ngIf=\"config?.viewType === 'capsule' && loadingStage !== 'initial'\"\n [config]=\"config\"\n [dataSource]=\"dataSource\"\n [displayedButtons]=\"displayedButtons\"\n (actionClick)=\"actionClicked($event.name, $event.row)\">\n </spa-capsules>\n\n\n <!-- Cards -->\n <spa-cards *ngIf=\"config?.viewType === 'card' && loadingStage !== 'initial'\"\n [config]=\"config\"\n [dataSource]=\"dataSource\"\n [displayedButtons]=\"displayedButtons\"\n [smallScreen]=\"smallScreen\"\n (actionClick)=\"actionClicked($event.name, $event.row)\"\n (columnClick)=\"columnClicked($event.column, $event.row)\"\n (showBannerEvent)=\"showBanner($event)\">\n </spa-cards>\n\n <!-- Groups - Added: New grouped view type -->\n <!-- Changed: the grouped view no longer renders its own filter field. It used to sit on a row of its own\n beneath the buttons row, which left both rows half empty and, more importantly, was a SECOND filter with\n no refresh button. The standard header filter (which has refresh, like every other table) now drives it,\n with its text relayed in through filterText. -->\n <spa-groups *ngIf=\"config?.viewType === 'grouped' && loadingStage !== 'initial'\"\n [config]=\"config\"\n [dataSource]=\"dataSource\"\n [displayedButtons]=\"displayedButtons\"\n [showOwnFilter]=\"false\"\n [filterText]=\"groupFilterText\"\n (actionClick)=\"actionClicked($event.name, $event.row, $event.group, $event.button)\">\n </spa-groups>\n\n\n <div class=\"tin-center\">\n <p *ngIf=\"dataSource?.length == 0 && loadingStage !== 'initial'\"><em>{{config.noDataMessage ?? 'No Data'}}</em></p> <!-- Changed (Quiet Loading): the stage owns the space until it completes, then hands straight over to this message \u2014 no \"No Data\" flashing underneath the ghost rows -->\n </div>\n\n </ng-container> <!-- Added: end section-collapse wrapper -->\n\n</ng-container>\n\n\n<ng-container *ngIf=\"!hasFormAccess\">\n <div class=\"tin-center\">\n <p><em>Access Restricted</em></p>\n </div>\n</ng-container>\n\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}.title{margin-top:10px;font-size:larger;font-weight:300}.make-gray{background-color:#f5f5f5}tr.mat-mdc-row{transition:background-color .15s ease}tr.mat-mdc-row:hover{background-color:#2196f305}tr.mat-mdc-row.row-editing,tr.mat-mdc-row.row-editing:hover{background-color:#ffa00014}.inline-save{color:#2e7d32!important}.inline-cancel{color:#c62828!important}.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}.dialog-container{display:flex;flex-direction:column;height:100%;max-height:100%;min-height:0}.dialog-content{flex:1;overflow:hidden;display:flex;flex-direction:column;min-height:0}.dialog-scroll-content{flex:1;overflow-y:auto;max-height:none!important;display:flex;flex-direction:column;min-height:0}.dialog-container:not(.dialog-has-tables){height:auto}.dialog-container:not(.dialog-has-tables) .dialog-scroll-content{flex:1 1 auto}.dialog-scroll-content>.tin-input{flex:1;display:flex;flex-direction:column}.dialog-scroll-content>.tin-input>spa-tabs{flex:1;display:flex;flex-direction:column}mat-dialog-actions{flex-shrink:0;justify-content:flex-start}.paginator-hidden{display:none!important}.paged-filter-hint{display:flex;align-items:center;gap:8px;margin:4px 0 8px;padding:6px 12px;border-radius:4px;background-color:#fff8e1;color:#795548;font-size:13px}.paged-filter-hint mat-icon{font-size:18px;width:18px;height:18px;color:#ffa000}.dlg-header-classic{padding-left:24px;padding-right:24px}.dlg-title-classic{font-size:20px;font-weight:500;margin-top:10px;margin-bottom:5px}.dialog-header-titles{display:flex;flex-direction:column;justify-content:center;min-width:0}.tin-dlg-head .dialog-header-titles label{margin:0}.tbl-section-header{display:flex;align-items:center;gap:10px;padding:10px 12px;margin-bottom:8px;border:1px solid rgba(0,0,0,.08);border-radius:10px;background:transparent;cursor:pointer;transition:border-color .15s}.tbl-section-header:hover{border-color:#90a4ae}.tbl-section-header.tbl-section-static{cursor:default}.tbl-section-icon{color:#546e7a}.tbl-section-title{font-size:14px;font-weight:600;color:#000000d1}.tbl-section-count{background:#e3f2fd;color:#1565c0;border-radius:12px;padding:2px 10px;font-size:12px}.tbl-section-chip{font-size:12px;font-weight:500;color:#0009}.tbl-section-spacer{margin-left:auto}.tbl-section-chevron{color:#90a4ae}.tbl-section-caption{margin:-4px 12px 10px;font-size:12.5px;line-height:1.45;color:#0000008c;max-width:82ch}.tbl-section-more{padding:0 12px 8px}.tbl-section-link{background:none;border:none;color:#1565c0;cursor:pointer;font-size:13px;padding:0}.tbl-section-link:hover{text-decoration:underline}.tbl-section-icon,.tbl-section-chevron,.tbl-section-count{flex:0 0 auto}.tbl-section-title{flex:1 1 auto;min-width:0}@media (max-width: 700px){.tbl-section-header{gap:8px;padding:10px}.tbl-section-chip,.tbl-section-btn-text.has-icon{display:none}.tbl-section-header button{min-width:0;padding:0 10px}}table.tin-no-col-headers tr.mat-mdc-header-row{display:none}\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: "directive", type: i1$2.NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }, { kind: "component", type: i14.MatTable, selector: "mat-table, table[mat-table]", exportAs: ["matTable"] }, { kind: "directive", type: i14.MatHeaderCellDef, selector: "[matHeaderCellDef]" }, { kind: "directive", type: i14.MatHeaderRowDef, selector: "[matHeaderRowDef]", inputs: ["matHeaderRowDef", "matHeaderRowDefSticky"] }, { kind: "directive", type: i14.MatColumnDef, selector: "[matColumnDef]", inputs: ["matColumnDef"] }, { kind: "directive", type: i14.MatCellDef, selector: "[matCellDef]" }, { kind: "directive", type: i14.MatRowDef, selector: "[matRowDef]", inputs: ["matRowDefColumns", "matRowDefWhen"] }, { kind: "directive", type: i14.MatHeaderCell, selector: "mat-header-cell, th[mat-header-cell]" }, { kind: "directive", type: i14.MatCell, selector: "mat-cell, td[mat-cell]" }, { kind: "component", type: i14.MatHeaderRow, selector: "mat-header-row, tr[mat-header-row]", exportAs: ["matHeaderRow"] }, { kind: "component", type: i14.MatRow, selector: "mat-row, tr[mat-row]", exportAs: ["matRow"] }, { kind: "component", type: i15$1.MatPaginator, selector: "mat-paginator", inputs: ["color", "pageIndex", "length", "pageSize", "pageSizeOptions", "hidePageSize", "showFirstLastButtons", "selectConfig", "disabled"], outputs: ["page"], exportAs: ["matPaginator"] }, { 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: "directive", type: i7.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: SearchComponent, selector: "spa-search", inputs: ["config", "smallScreen", "tableDataSource"], outputs: ["searchClick"] }, { kind: "component", type: TableHeaderComponent, selector: "app-table-header", inputs: ["lastSearch", "config", "hideTitle", "tableDataSource", "tileConfig", "smallScreen", "tileReload", "showFilterButton", "data", "tileData", "isRealTime", "isConnected", "refreshing"], outputs: ["createClick", "customClick", "refreshClick", "tileClick", "tileUnClick", "filterChange"] }, { kind: "component", type: TableRowComponent, selector: "app-table-row", inputs: ["column", "row", "config", "smallScreen"], outputs: ["actionClick", "columnClick", "showBannerEvent"] }, { kind: "component", type: TableActionComponent, selector: "app-table-action", inputs: ["displayedButtons", "config", "row", "smallScreen"], outputs: ["actionClick"] }, { kind: "component", type: InlineCellComponent, selector: "app-inline-cell", inputs: ["field", "data"], outputs: ["valueChange"] }, { kind: "component", type: CapsulesComponent, selector: "spa-capsules", inputs: ["config", "dataSource", "displayedButtons"], outputs: ["actionClick"] }, { kind: "component", type: CardsComponent, selector: "spa-cards", inputs: ["config", "dataSource", "displayedButtons", "smallScreen"], outputs: ["actionClick", "columnClick", "showBannerEvent"] }, { kind: "component", type: GroupsComponent, selector: "spa-groups", inputs: ["config", "dataSource", "displayedButtons", "showOwnFilter", "filterText"], outputs: ["actionClick"] }, { kind: "pipe", type: CamelToWordsPipe, name: "camelToWords" }] }); }
18440
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: TableComponent, isStandalone: false, selector: "spa-table", inputs: { data: "data", tileData: "tileData", config: "config", localMode: "localMode", parentDetails: "parentDetails", reload: "reload", activeTab: "activeTab", inTab: "inTab", nestingLevel: "nestingLevel" }, outputs: { dataLoad: "dataLoad", totalChange: "totalChange", actionSuccess: "actionSuccess", refreshClick: "refreshClick", searchClick: "searchClick", createClick: "createClick", actionClick: "actionClick", inputChange: "inputChange", actionResponse: "actionResponse" }, viewQueries: [{ propertyName: "tablePaginator", first: true, predicate: ["tablePaginator"], descendants: true }], usesOnChanges: true, ngImport: i0, template: "\n<ng-container *ngIf=\"hasFormAccess && !sectionHidden\"> <!-- Changed: sectionConfig.hideWhenEmpty hides the whole table -->\n\n <!-- Added: collapsible flat section header (sectionConfig) \u2014 1px border, no elevation, whole row toggles -->\n <div class=\"tbl-section-header\" *ngIf=\"config.sectionConfig\" (click)=\"toggleSection()\" [attr.aria-expanded]=\"!sectionCollapsed\" [class.tbl-section-static]=\"config.sectionConfig.collapsible === false\" role=\"button\" tabindex=\"0\" (keydown.enter)=\"toggleSection()\">\n <mat-icon class=\"tbl-section-icon\" *ngIf=\"config.sectionConfig.icon\">{{ config.sectionConfig.icon }}</mat-icon>\n <span class=\"tbl-section-title\">{{ config.sectionConfig.title }}</span>\n <span class=\"tbl-section-count\" *ngIf=\"config.sectionConfig.showCount !== false\">{{ dataSource?.length || 0 }}</span>\n <span class=\"tbl-section-chip\" *ngFor=\"let chip of sectionChips()\" [style.color]=\"chip.color\">{{ chip.text }}</span>\n <span class=\"tbl-section-spacer\"></span>\n <!-- Changed: the label is wrapped so a NARROW screen can drop it and leave an icon-only button. Only a\n button that HAS an icon loses its text \u2014 otherwise it would collapse to a blank square. -->\n <button mat-stroked-button color=\"primary\" *ngFor=\"let btn of sectionButtons()\" (click)=\"sectionButtonClicked(btn, $event)\" [matTooltip]=\"btn.display || btn.name\"><mat-icon *ngIf=\"btn.icon?.name\">{{ btn.icon.name }}</mat-icon><span class=\"tbl-section-btn-text\" [class.has-icon]=\"!!btn.icon?.name\">{{ btn.display || btn.name }}</span></button>\n <mat-icon class=\"tbl-section-chevron\" *ngIf=\"config.sectionConfig.collapsible !== false\">{{ sectionCollapsed ? 'expand_more' : 'expand_less' }}</mat-icon>\n </div>\n\n <!-- Added: the section's \"why this list exists\" line. It sits UNDER the title (a reason only makes sense once\n the list has been named) and only while the section is open \u2014 a shut section already has its own summary\n line, and a paragraph over the top of that is noise. -->\n <p class=\"tbl-section-caption\" *ngIf=\"config.sectionConfig?.caption && !sectionCollapsed\">{{ config.sectionConfig.caption }}</p>\n\n <!-- Added: collapsed section affordance \u2014 mirrors the Day Book \"Show the N\" pattern -->\n <div class=\"tbl-section-more\" *ngIf=\"config.sectionConfig && sectionCollapsed && (dataSource?.length || 0) > 0\">\n <button type=\"button\" class=\"tbl-section-link\" (click)=\"toggleSection()\">Show the {{ dataSource.length }}</button>\n </div>\n\n <ng-container *ngIf=\"!config.sectionConfig || !sectionCollapsed\"> <!-- Added: section collapse hides the table body -->\n\n <!-- Search -->\n <spa-search\n *ngIf=\"config.searchConfig\" [config]=\"config.searchConfig\" [smallScreen]=\"smallScreen\" [tableDataSource]=\"tableDataSource\" style=\"margin-bottom: 20px;\" (searchClick)=\"searchClicked($event)\">\n </spa-search>\n\n <!-- Header -->\n <!-- Changed: [isRealTime] binds the RESOLVED value, not the raw config. It was `config.realTime`, so the live\n indicator only ever appeared on a table that set the flag ITSELF \u2014 a table relying on the app-wide\n `tableDefaults.realTime` was genuinely subscribed to SignalR (setupRealTimeSubscriptions and\n realTimeRefreshOrFallback both resolve through effRealTime) and simply never showed the dot. That is\n how \"Trips and Loads have no real time\" got reported: a status light disagreeing with the system it\n reports on. effRealTime also gets the inverse right \u2014 realTime:true under a global false still lights,\n and realTime:false under a global true stays dark. -->\n <app-table-header\n [config]=\"config\" [data]=\"dataSource\" [tableDataSource]=\"tableDataSource\" [tileConfig]=\"config.tileConfig\" [tileData]=\"tileData\" [tileReload]=\"tileReload\" [lastSearch]=\"lastSearch\" [smallScreen]=\"smallScreen\"\n [showFilterButton]=\"showFilterButton\" [isRealTime]=\"effRealTime\" [isConnected]=\"isSignalRConnected\" [refreshing]=\"loadingStage === 'refresh'\"\n (createClick)=\"newModel()\" (customClick)=\"customModel($event,null)\"\n (refreshClick)=\"refreshClicked()\" (tileClick)=\"tileClicked($event)\" (tileUnClick)=\"tileUnClicked($event)\" (filterChange)=\"filterChanged($event)\">\n </app-table-header>\n\n <!-- Added (Quiet Loading D4): refresh with data already on screen \u2014 a 2px line flush under the header and the\n spinning refresh icon are the ONLY signals. Rows stay live, clickable and un-dimmed while they swap. -->\n <div *ngIf=\"loadingStage === 'refresh'\" class=\"tin-load-line\" aria-hidden=\"true\">\n <span class=\"tin-load-line-fill\" [style.width.%]=\"progressPercent\"></span>\n </div>\n\n <!-- Added: paged-mode filter hint \u2014 the client filter only covers rows loaded so far -->\n <div *ngIf=\"pagedMode && filterActive && loadedRows.length < serverTotal\" class=\"paged-filter-hint\">\n <mat-icon>info</mat-icon>\n <span>Filtering only the {{loadedRows.length}} loaded rows of {{serverTotal}}. {{ config.searchConfig ? 'Use Search for complete results.' : 'Refine with search for complete results.' }}</span>\n </div>\n\n\n <!-- Table -->\n <div *ngIf=\"!config.viewType || config?.viewType === 'table'\">\n\n <p *ngIf=\"!config\"><em>Configure Table</em></p>\n <p *ngIf=\"!dataSource && !loadingStage\"><em>Loading...</em></p> <!-- Changed (Quiet Loading): the bare text is replaced by the stage below while a quiet load is on screen -->\n\n <!-- Added (Quiet Loading D3): first load, nothing on screen yet. The progress module is the hero (eased bar +\n counting percentage + caption) and the ghost rows hold the exact space the real rows will fill, so the\n table does not jump when data lands. Only ever rendered when quiet loading is on. -->\n <div *ngIf=\"loadingStage === 'initial'\" class=\"tin-load-stage\" role=\"status\" aria-busy=\"true\">\n\n <div class=\"tin-load-progress\">\n <div class=\"tin-load-bar\">\n <span class=\"tin-load-bar-fill\" [style.width.%]=\"progressPercent\"></span>\n </div>\n <span class=\"tin-load-percent\" aria-hidden=\"true\">{{progressDisplay}}%</span>\n </div>\n\n <div class=\"tin-load-caption\">Loading {{stageEntityName}}\u2026</div>\n\n <div class=\"tin-load-ghosts\">\n <div class=\"tin-load-ghost-row\" *ngFor=\"let r of ghostRows\">\n <div class=\"tin-load-ghost-cell\" *ngFor=\"let c of ghostColumns; let i = index\">\n <span class=\"tin-skel\" [style.width.%]=\"ghostWidth(i, c)\" [style.animation-delay.ms]=\"r * 120\"></span> <!-- staggered sweep: each row starts 120ms after the one above -->\n </div>\n </div>\n </div>\n\n </div>\n\n <div *ngIf=\"dataSource && loadingStage !== 'initial' && (!smallScreen || (smallScreen && dataSource?.length > 0))\" [class.tin-load-in]=\"effQuietLoading\"> <!-- Changed (Quiet Loading): the empty header-only table is suppressed while the initial stage stands in for it, and the real rows fade in where the ghosts were (D3) -->\n\n <table mat-table [dataSource]=\"tableDataSource\" [trackBy]=\"trackByRow\" [ngClass]=\"elevation\" [class.tin-no-col-headers]=\"config.hideColumnHeaders\"> <!-- Changed: optional column-header suppression -->\n\n <ng-container *ngFor=\"let column of config.columns\" [matColumnDef]=\"column.name\">\n <th mat-header-cell *matHeaderCellDef >{{ column.alias ?? column.name | camelToWords }}</th>\n <td mat-cell *matCellDef=\"let row;\" class=\"right-padding\" >\n\n <!-- Added: inline edit \u2014 editable cells swap to their form-field editor while the row is in edit mode -->\n <app-inline-cell *ngIf=\"isRowEditing(row) && getInlineField(column); else displayCell\" [field]=\"getInlineField(column)\" [data]=\"editingModel\"></app-inline-cell>\n\n <!-- Rows -->\n <ng-template #displayCell>\n <app-table-row [column]=\"column\" [row]=\"row\" [config]=\"config\" [smallScreen]=\"smallScreen\"\n (actionClick)=\"actionClicked(column.name, row)\" (columnClick)=\"columnClicked(column, row)\" (showBannerEvent)=\"showBanner($event)\">\n </app-table-row>\n </ng-template>\n\n </td>\n </ng-container>\n\n <ng-container matColumnDef=\"action\">\n <th mat-header-cell *matHeaderCellDef> Action </th>\n <td mat-cell *matCellDef=\"let row\" [ngStyle]=\"{width:false ? '20px' : actionsWidth}\">\n <div class=\"action-buttons-container\">\n\n <!-- Added: inline edit \u2014 while a row edits in place, its actions collapse to submit/cancel -->\n <ng-container *ngIf=\"isRowEditing(row); else rowActions\">\n <button mat-icon-button matTooltip=\"Save\" matTooltipPosition=\"above\" (click)=\"submitInlineEdit()\"><mat-icon class=\"inline-save\">check</mat-icon></button> <!-- Changed: dropped color=\"primary\" \u2014 the icon now carries a green save cue -->\n <button mat-icon-button matTooltip=\"Cancel\" matTooltipPosition=\"above\" (click)=\"cancelInlineEdit()\"><mat-icon class=\"inline-cancel\">close</mat-icon></button> <!-- Changed: red cancel cue -->\n </ng-container>\n\n <!-- Actions -->\n <ng-template #rowActions>\n <app-table-action\n [displayedButtons]=\"displayedButtons\" [config]=\"config\" [smallScreen]=\"smallScreen\" [row]=\"row\" (actionClick)=\"actionClicked($event.name, $event.row)\">\n </app-table-action>\n </ng-template>\n\n </div>\n </td>\n </ng-container>\n\n\n <tr mat-header-row *matHeaderRowDef=\"displayedColumns\"></tr>\n <tr mat-row *matRowDef=\"let row; columns: displayedColumns;\" [ngClass]=\"{'make-gray': (config.greyOut && config.greyOut(row)) || row.pendingApproval, 'row-editing': isRowEditing(row)}\"></tr> <!-- Changed: row-editing flags the row that is open for inline edit -->\n </table>\n\n </div>\n\n <!-- Changed: Removed *ngIf condition to keep paginator always in DOM and maintain ViewChild reference -->\n <!-- Changed: Added CSS class binding to hide when no data instead of conditional rendering -->\n <!-- Changed: Legacy paginator only renders in non-paged mode (pagedMode is constant per instance, set before first render) -->\n <!-- Changed: hidePageSize on a phone. The \"Items per page\" label and its select cost a whole row of a\n narrow screen for a control almost nobody touches there \u2014 the default is what gets used. The range\n (\"1 \u2013 10 of 54\") and the arrows stay, which is the part that is actually navigated. Bound to\n smallScreen, the component's existing breakpoint (max-width 600px, live via BreakpointObserver), so\n the paginator agrees with how this table already decides what \"mobile\" means rather than\n introducing a third breakpoint. -->\n <mat-paginator *ngIf=\"!pagedMode\"\n #tablePaginator\n [pageSizeOptions]=\"config.pageSizes ?? [10, 20, 50]\"\n [hidePageSize]=\"smallScreen\"\n [ngClass]=\"{'paginator-hidden': !dataSource || (smallScreen && dataSource?.length === 0) || loadingStage === 'initial'}\"\n showFirstLastButtons>\n </mat-paginator>\n\n <!-- Added: manual paginator for server-side paged mode \u2014 fully state-bound, never attached to MatTableDataSource. Always visible: when filtering it pages the in-memory filtered subset (length = filtered count); otherwise the server window (length = true total). No first/last jump (would force fetching the whole gap). -->\n <mat-paginator *ngIf=\"pagedMode\"\n [length]=\"filterActive ? filteredRows.length : serverTotal + overlayDelta\"\n [pageIndex]=\"pageIndex\"\n [pageSize]=\"pageSize\"\n [pageSizeOptions]=\"config.pageSizes ?? [10, 20, 50]\"\n [hidePageSize]=\"smallScreen\"\n [ngClass]=\"{'paginator-hidden': !dataSource || (smallScreen && dataSource?.length === 0) || loadingStage === 'initial'}\"\n (page)=\"onServerPage($event)\">\n </mat-paginator>\n\n </div>\n \n <!-- Added (Quiet Loading): the initial-load stage for the NON-table views.\n Without this, card/capsule/grouped showed literally nothing during a first load \u2014 the view component\n renders an empty dataSource and the \"No Data\" line is suppressed while the stage owns the space.\n The progress header is identical to the table's so the two feel like one feature; only the ghost\n furniture differs, because column-shaped rows are wrong in a card grid. -->\n <div *ngIf=\"loadingStage === 'initial' && config?.viewType && config?.viewType !== 'table'\" class=\"tin-load-stage\" role=\"status\" aria-busy=\"true\">\n <div class=\"tin-load-progress\">\n <div class=\"tin-load-bar\">\n <span class=\"tin-load-bar-fill\" [style.width.%]=\"progressPercent\"></span>\n </div>\n <span class=\"tin-load-percent\" aria-hidden=\"true\">{{progressDisplay}}%</span>\n </div>\n <div class=\"tin-load-caption\">Loading {{stageEntityName}}\u2026</div>\n\n <div *ngIf=\"config?.viewType === 'capsule'\" class=\"tin-load-ghost-capsules\" aria-hidden=\"true\">\n <span class=\"tin-skel tin-load-ghost-capsule\" *ngFor=\"let c of ghostCapsules\"></span>\n </div>\n\n <div *ngIf=\"config?.viewType === 'card'\" class=\"tin-load-ghost-cards\" aria-hidden=\"true\">\n <div class=\"tin-load-ghost-card\" *ngFor=\"let c of ghostCards\">\n <span class=\"tin-skel tin-skel-text tin-load-ghost-card-title\"></span>\n <span class=\"tin-skel tin-skel-text\"></span>\n <span class=\"tin-skel tin-skel-text tin-load-ghost-card-short\"></span>\n </div>\n </div>\n\n <div *ngIf=\"config?.viewType === 'grouped'\" class=\"tin-load-ghost-groups\" aria-hidden=\"true\">\n <div class=\"tin-load-ghost-group\" *ngFor=\"let g of ghostGroups\">\n <span class=\"tin-skel tin-load-ghost-group-head\"></span>\n <!-- Pills, not rows: a group card's body is a wrap of chips, so full-width bars promised a table\n and the stage did not resemble what replaced it. -->\n <div class=\"tin-load-ghost-group-items\">\n <span class=\"tin-skel tin-load-ghost-capsule\" *ngFor=\"let r of ghostCards\"></span>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Capsules -->\n <spa-capsules *ngIf=\"config?.viewType === 'capsule' && loadingStage !== 'initial'\"\n [config]=\"config\"\n [dataSource]=\"dataSource\"\n [displayedButtons]=\"displayedButtons\"\n (actionClick)=\"actionClicked($event.name, $event.row)\">\n </spa-capsules>\n\n\n <!-- Cards -->\n <spa-cards *ngIf=\"config?.viewType === 'card' && loadingStage !== 'initial'\"\n [config]=\"config\"\n [dataSource]=\"dataSource\"\n [displayedButtons]=\"displayedButtons\"\n [smallScreen]=\"smallScreen\"\n (actionClick)=\"actionClicked($event.name, $event.row)\"\n (columnClick)=\"columnClicked($event.column, $event.row)\"\n (showBannerEvent)=\"showBanner($event)\">\n </spa-cards>\n\n <!-- Groups - Added: New grouped view type -->\n <!-- Changed: the grouped view no longer renders its own filter field. It used to sit on a row of its own\n beneath the buttons row, which left both rows half empty and, more importantly, was a SECOND filter with\n no refresh button. The standard header filter (which has refresh, like every other table) now drives it,\n with its text relayed in through filterText. -->\n <spa-groups *ngIf=\"config?.viewType === 'grouped' && loadingStage !== 'initial'\"\n [config]=\"config\"\n [dataSource]=\"dataSource\"\n [displayedButtons]=\"displayedButtons\"\n [showOwnFilter]=\"false\"\n [filterText]=\"groupFilterText\"\n (actionClick)=\"actionClicked($event.name, $event.row, $event.group, $event.button)\">\n </spa-groups>\n\n\n <div class=\"tin-center\">\n <p *ngIf=\"dataSource?.length == 0 && loadingStage !== 'initial'\"><em>{{config.noDataMessage ?? 'No Data'}}</em></p> <!-- Changed (Quiet Loading): the stage owns the space until it completes, then hands straight over to this message \u2014 no \"No Data\" flashing underneath the ghost rows -->\n </div>\n\n </ng-container> <!-- Added: end section-collapse wrapper -->\n\n</ng-container>\n\n\n<ng-container *ngIf=\"!hasFormAccess\">\n <div class=\"tin-center\">\n <p><em>Access Restricted</em></p>\n </div>\n</ng-container>\n\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}.title{margin-top:10px;font-size:larger;font-weight:300}.make-gray{background-color:#f5f5f5}tr.mat-mdc-row{transition:background-color .15s ease}tr.mat-mdc-row:hover{background-color:#2196f305}tr.mat-mdc-row.row-editing,tr.mat-mdc-row.row-editing:hover{background-color:#ffa00014}.inline-save{color:#2e7d32!important}.inline-cancel{color:#c62828!important}.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}.dialog-container{display:flex;flex-direction:column;height:100%;max-height:100%;min-height:0}.dialog-content{flex:1;overflow:hidden;display:flex;flex-direction:column;min-height:0}.dialog-scroll-content{flex:1;overflow-y:auto;max-height:none!important;display:flex;flex-direction:column;min-height:0}.dialog-container:not(.dialog-has-tables){height:auto}.dialog-container:not(.dialog-has-tables) .dialog-scroll-content{flex:1 1 auto}.dialog-scroll-content>.tin-input{flex:1;display:flex;flex-direction:column}.dialog-scroll-content>.tin-input>spa-tabs{flex:1;display:flex;flex-direction:column}mat-dialog-actions{flex-shrink:0;justify-content:flex-start}.paginator-hidden{display:none!important}.paged-filter-hint{display:flex;align-items:center;gap:8px;margin:4px 0 8px;padding:6px 12px;border-radius:4px;background-color:#fff8e1;color:#795548;font-size:13px}.paged-filter-hint mat-icon{font-size:18px;width:18px;height:18px;color:#ffa000}.dlg-header-classic{padding-left:24px;padding-right:24px}.dlg-title-classic{font-size:20px;font-weight:500;margin-top:10px;margin-bottom:5px}.dialog-header-titles{display:flex;flex-direction:column;justify-content:center;min-width:0}.tin-dlg-head .dialog-header-titles label{margin:0}.tbl-section-header{display:flex;align-items:center;gap:10px;padding:10px 12px;margin-bottom:8px;border:1px solid rgba(0,0,0,.08);border-radius:10px;background:transparent;cursor:pointer;transition:border-color .15s}.tbl-section-header:hover{border-color:#90a4ae}.tbl-section-header.tbl-section-static{cursor:default}.tbl-section-icon{color:#546e7a}.tbl-section-title{font-size:14px;font-weight:600;color:#000000d1}.tbl-section-count{background:#e3f2fd;color:#1565c0;border-radius:12px;padding:2px 10px;font-size:12px}.tbl-section-chip{font-size:12px;font-weight:500;color:#0009}.tbl-section-spacer{margin-left:auto}.tbl-section-chevron{color:#90a4ae}.tbl-section-caption{margin:-4px 12px 10px;font-size:12.5px;line-height:1.45;color:#0000008c;max-width:82ch}.tbl-section-more{padding:0 12px 8px}.tbl-section-link{background:none;border:none;color:#1565c0;cursor:pointer;font-size:13px;padding:0}.tbl-section-link:hover{text-decoration:underline}.tbl-section-icon,.tbl-section-chevron,.tbl-section-count{flex:0 0 auto}.tbl-section-title{flex:1 1 auto;min-width:0}@media (max-width: 700px){.tbl-section-header{gap:8px;padding:10px}.tbl-section-chip,.tbl-section-btn-text.has-icon{display:none}.tbl-section-header button{min-width:0;padding:0 10px}}table.tin-no-col-headers tr.mat-mdc-header-row{display:none}\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: "directive", type: i1$2.NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }, { kind: "component", type: i14.MatTable, selector: "mat-table, table[mat-table]", exportAs: ["matTable"] }, { kind: "directive", type: i14.MatHeaderCellDef, selector: "[matHeaderCellDef]" }, { kind: "directive", type: i14.MatHeaderRowDef, selector: "[matHeaderRowDef]", inputs: ["matHeaderRowDef", "matHeaderRowDefSticky"] }, { kind: "directive", type: i14.MatColumnDef, selector: "[matColumnDef]", inputs: ["matColumnDef"] }, { kind: "directive", type: i14.MatCellDef, selector: "[matCellDef]" }, { kind: "directive", type: i14.MatRowDef, selector: "[matRowDef]", inputs: ["matRowDefColumns", "matRowDefWhen"] }, { kind: "directive", type: i14.MatHeaderCell, selector: "mat-header-cell, th[mat-header-cell]" }, { kind: "directive", type: i14.MatCell, selector: "mat-cell, td[mat-cell]" }, { kind: "component", type: i14.MatHeaderRow, selector: "mat-header-row, tr[mat-header-row]", exportAs: ["matHeaderRow"] }, { kind: "component", type: i14.MatRow, selector: "mat-row, tr[mat-row]", exportAs: ["matRow"] }, { kind: "component", type: i15$1.MatPaginator, selector: "mat-paginator", inputs: ["color", "pageIndex", "length", "pageSize", "pageSizeOptions", "hidePageSize", "showFirstLastButtons", "selectConfig", "disabled"], outputs: ["page"], exportAs: ["matPaginator"] }, { 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: "directive", type: i7.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: SearchComponent, selector: "spa-search", inputs: ["config", "smallScreen", "tableDataSource"], outputs: ["searchClick"] }, { kind: "component", type: TableHeaderComponent, selector: "app-table-header", inputs: ["lastSearch", "config", "hideTitle", "tableDataSource", "tileConfig", "smallScreen", "tileReload", "showFilterButton", "data", "tileData", "isRealTime", "isConnected", "refreshing"], outputs: ["createClick", "customClick", "refreshClick", "tileClick", "tileUnClick", "filterChange"] }, { kind: "component", type: TableRowComponent, selector: "app-table-row", inputs: ["column", "row", "config", "smallScreen"], outputs: ["actionClick", "columnClick", "showBannerEvent"] }, { kind: "component", type: TableActionComponent, selector: "app-table-action", inputs: ["displayedButtons", "config", "row", "smallScreen"], outputs: ["actionClick"] }, { kind: "component", type: InlineCellComponent, selector: "app-inline-cell", inputs: ["field", "data"], outputs: ["valueChange"] }, { kind: "component", type: CapsulesComponent, selector: "spa-capsules", inputs: ["config", "dataSource", "displayedButtons"], outputs: ["actionClick"] }, { kind: "component", type: CardsComponent, selector: "spa-cards", inputs: ["config", "dataSource", "displayedButtons", "smallScreen"], outputs: ["actionClick", "columnClick", "showBannerEvent"] }, { kind: "component", type: GroupsComponent, selector: "spa-groups", inputs: ["config", "dataSource", "displayedButtons", "showOwnFilter", "filterText"], outputs: ["actionClick"] }, { kind: "pipe", type: CamelToWordsPipe, name: "camelToWords" }] }); }
17615
18441
  }
17616
18442
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: TableComponent, decorators: [{
17617
18443
  type: Component,
17618
- args: [{ selector: 'spa-table', standalone: false, template: "\n<ng-container *ngIf=\"hasFormAccess && !sectionHidden\"> <!-- Changed: sectionConfig.hideWhenEmpty hides the whole table -->\n\n <!-- Added: collapsible flat section header (sectionConfig) \u2014 1px border, no elevation, whole row toggles -->\n <div class=\"tbl-section-header\" *ngIf=\"config.sectionConfig\" (click)=\"toggleSection()\" [attr.aria-expanded]=\"!sectionCollapsed\" [class.tbl-section-static]=\"config.sectionConfig.collapsible === false\" role=\"button\" tabindex=\"0\" (keydown.enter)=\"toggleSection()\">\n <mat-icon class=\"tbl-section-icon\" *ngIf=\"config.sectionConfig.icon\">{{ config.sectionConfig.icon }}</mat-icon>\n <span class=\"tbl-section-title\">{{ config.sectionConfig.title }}</span>\n <span class=\"tbl-section-count\" *ngIf=\"config.sectionConfig.showCount !== false\">{{ dataSource?.length || 0 }}</span>\n <span class=\"tbl-section-chip\" *ngFor=\"let chip of sectionChips()\" [style.color]=\"chip.color\">{{ chip.text }}</span>\n <span class=\"tbl-section-spacer\"></span>\n <!-- Changed: the label is wrapped so a NARROW screen can drop it and leave an icon-only button. Only a\n button that HAS an icon loses its text \u2014 otherwise it would collapse to a blank square. -->\n <button mat-stroked-button color=\"primary\" *ngFor=\"let btn of sectionButtons()\" (click)=\"sectionButtonClicked(btn, $event)\" [matTooltip]=\"btn.display || btn.name\"><mat-icon *ngIf=\"btn.icon?.name\">{{ btn.icon.name }}</mat-icon><span class=\"tbl-section-btn-text\" [class.has-icon]=\"!!btn.icon?.name\">{{ btn.display || btn.name }}</span></button>\n <mat-icon class=\"tbl-section-chevron\" *ngIf=\"config.sectionConfig.collapsible !== false\">{{ sectionCollapsed ? 'expand_more' : 'expand_less' }}</mat-icon>\n </div>\n\n <!-- Added: the section's \"why this list exists\" line. It sits UNDER the title (a reason only makes sense once\n the list has been named) and only while the section is open \u2014 a shut section already has its own summary\n line, and a paragraph over the top of that is noise. -->\n <p class=\"tbl-section-caption\" *ngIf=\"config.sectionConfig?.caption && !sectionCollapsed\">{{ config.sectionConfig.caption }}</p>\n\n <!-- Added: collapsed section affordance \u2014 mirrors the Day Book \"Show the N\" pattern -->\n <div class=\"tbl-section-more\" *ngIf=\"config.sectionConfig && sectionCollapsed && (dataSource?.length || 0) > 0\">\n <button type=\"button\" class=\"tbl-section-link\" (click)=\"toggleSection()\">Show the {{ dataSource.length }}</button>\n </div>\n\n <ng-container *ngIf=\"!config.sectionConfig || !sectionCollapsed\"> <!-- Added: section collapse hides the table body -->\n\n <!-- Search -->\n <spa-search\n *ngIf=\"config.searchConfig\" [config]=\"config.searchConfig\" [smallScreen]=\"smallScreen\" [tableDataSource]=\"tableDataSource\" style=\"margin-bottom: 20px;\" (searchClick)=\"searchClicked($event)\">\n </spa-search>\n\n <!-- Header -->\n <app-table-header\n [config]=\"config\" [data]=\"dataSource\" [tableDataSource]=\"tableDataSource\" [tileConfig]=\"config.tileConfig\" [tileData]=\"tileData\" [tileReload]=\"tileReload\" [lastSearch]=\"lastSearch\" [smallScreen]=\"smallScreen\"\n [showFilterButton]=\"showFilterButton\" [isRealTime]=\"config.realTime\" [isConnected]=\"isSignalRConnected\" [refreshing]=\"loadingStage === 'refresh'\"\n (createClick)=\"newModel()\" (customClick)=\"customModel($event,null)\"\n (refreshClick)=\"refreshClicked()\" (tileClick)=\"tileClicked($event)\" (tileUnClick)=\"tileUnClicked($event)\" (filterChange)=\"filterChanged($event)\">\n </app-table-header>\n\n <!-- Added (Quiet Loading D4): refresh with data already on screen \u2014 a 2px line flush under the header and the\n spinning refresh icon are the ONLY signals. Rows stay live, clickable and un-dimmed while they swap. -->\n <div *ngIf=\"loadingStage === 'refresh'\" class=\"tin-load-line\" aria-hidden=\"true\">\n <span class=\"tin-load-line-fill\" [style.width.%]=\"progressPercent\"></span>\n </div>\n\n <!-- Added: paged-mode filter hint \u2014 the client filter only covers rows loaded so far -->\n <div *ngIf=\"pagedMode && filterActive && loadedRows.length < serverTotal\" class=\"paged-filter-hint\">\n <mat-icon>info</mat-icon>\n <span>Filtering only the {{loadedRows.length}} loaded rows of {{serverTotal}}. {{ config.searchConfig ? 'Use Search for complete results.' : 'Refine with search for complete results.' }}</span>\n </div>\n\n\n <!-- Table -->\n <div *ngIf=\"!config.viewType || config?.viewType === 'table'\">\n\n <p *ngIf=\"!config\"><em>Configure Table</em></p>\n <p *ngIf=\"!dataSource && !loadingStage\"><em>Loading...</em></p> <!-- Changed (Quiet Loading): the bare text is replaced by the stage below while a quiet load is on screen -->\n\n <!-- Added (Quiet Loading D3): first load, nothing on screen yet. The progress module is the hero (eased bar +\n counting percentage + caption) and the ghost rows hold the exact space the real rows will fill, so the\n table does not jump when data lands. Only ever rendered when quiet loading is on. -->\n <div *ngIf=\"loadingStage === 'initial'\" class=\"tin-load-stage\" role=\"status\" aria-busy=\"true\">\n\n <div class=\"tin-load-progress\">\n <div class=\"tin-load-bar\">\n <span class=\"tin-load-bar-fill\" [style.width.%]=\"progressPercent\"></span>\n </div>\n <span class=\"tin-load-percent\" aria-hidden=\"true\">{{progressDisplay}}%</span>\n </div>\n\n <div class=\"tin-load-caption\">Loading {{stageEntityName}}\u2026</div>\n\n <div class=\"tin-load-ghosts\">\n <div class=\"tin-load-ghost-row\" *ngFor=\"let r of ghostRows\">\n <div class=\"tin-load-ghost-cell\" *ngFor=\"let c of ghostColumns; let i = index\">\n <span class=\"tin-skel\" [style.width.%]=\"ghostWidth(i, c)\" [style.animation-delay.ms]=\"r * 120\"></span> <!-- staggered sweep: each row starts 120ms after the one above -->\n </div>\n </div>\n </div>\n\n </div>\n\n <div *ngIf=\"dataSource && loadingStage !== 'initial' && (!smallScreen || (smallScreen && dataSource?.length > 0))\" [class.tin-load-in]=\"effQuietLoading\"> <!-- Changed (Quiet Loading): the empty header-only table is suppressed while the initial stage stands in for it, and the real rows fade in where the ghosts were (D3) -->\n\n <table mat-table [dataSource]=\"tableDataSource\" [trackBy]=\"trackByRow\" [ngClass]=\"elevation\" [class.tin-no-col-headers]=\"config.hideColumnHeaders\"> <!-- Changed: optional column-header suppression -->\n\n <ng-container *ngFor=\"let column of config.columns\" [matColumnDef]=\"column.name\">\n <th mat-header-cell *matHeaderCellDef >{{ column.alias ?? column.name | camelToWords }}</th>\n <td mat-cell *matCellDef=\"let row;\" class=\"right-padding\" >\n\n <!-- Added: inline edit \u2014 editable cells swap to their form-field editor while the row is in edit mode -->\n <app-inline-cell *ngIf=\"isRowEditing(row) && getInlineField(column); else displayCell\" [field]=\"getInlineField(column)\" [data]=\"editingModel\"></app-inline-cell>\n\n <!-- Rows -->\n <ng-template #displayCell>\n <app-table-row [column]=\"column\" [row]=\"row\" [config]=\"config\" [smallScreen]=\"smallScreen\"\n (actionClick)=\"actionClicked(column.name, row)\" (columnClick)=\"columnClicked(column, row)\" (showBannerEvent)=\"showBanner($event)\">\n </app-table-row>\n </ng-template>\n\n </td>\n </ng-container>\n\n <ng-container matColumnDef=\"action\">\n <th mat-header-cell *matHeaderCellDef> Action </th>\n <td mat-cell *matCellDef=\"let row\" [ngStyle]=\"{width:false ? '20px' : actionsWidth}\">\n <div class=\"action-buttons-container\">\n\n <!-- Added: inline edit \u2014 while a row edits in place, its actions collapse to submit/cancel -->\n <ng-container *ngIf=\"isRowEditing(row); else rowActions\">\n <button mat-icon-button matTooltip=\"Save\" matTooltipPosition=\"above\" (click)=\"submitInlineEdit()\"><mat-icon class=\"inline-save\">check</mat-icon></button> <!-- Changed: dropped color=\"primary\" \u2014 the icon now carries a green save cue -->\n <button mat-icon-button matTooltip=\"Cancel\" matTooltipPosition=\"above\" (click)=\"cancelInlineEdit()\"><mat-icon class=\"inline-cancel\">close</mat-icon></button> <!-- Changed: red cancel cue -->\n </ng-container>\n\n <!-- Actions -->\n <ng-template #rowActions>\n <app-table-action\n [displayedButtons]=\"displayedButtons\" [config]=\"config\" [smallScreen]=\"smallScreen\" [row]=\"row\" (actionClick)=\"actionClicked($event.name, $event.row)\">\n </app-table-action>\n </ng-template>\n\n </div>\n </td>\n </ng-container>\n\n\n <tr mat-header-row *matHeaderRowDef=\"displayedColumns\"></tr>\n <tr mat-row *matRowDef=\"let row; columns: displayedColumns;\" [ngClass]=\"{'make-gray': (config.greyOut && config.greyOut(row)) || row.pendingApproval, 'row-editing': isRowEditing(row)}\"></tr> <!-- Changed: row-editing flags the row that is open for inline edit -->\n </table>\n\n </div>\n\n <!-- Changed: Removed *ngIf condition to keep paginator always in DOM and maintain ViewChild reference -->\n <!-- Changed: Added CSS class binding to hide when no data instead of conditional rendering -->\n <!-- Changed: Legacy paginator only renders in non-paged mode (pagedMode is constant per instance, set before first render) -->\n <mat-paginator *ngIf=\"!pagedMode\"\n #tablePaginator\n [pageSizeOptions]=\"config.pageSizes ?? [10, 20, 50]\"\n [ngClass]=\"{'paginator-hidden': !dataSource || (smallScreen && dataSource?.length === 0) || loadingStage === 'initial'}\"\n showFirstLastButtons>\n </mat-paginator>\n\n <!-- Added: manual paginator for server-side paged mode \u2014 fully state-bound, never attached to MatTableDataSource. Always visible: when filtering it pages the in-memory filtered subset (length = filtered count); otherwise the server window (length = true total). No first/last jump (would force fetching the whole gap). -->\n <mat-paginator *ngIf=\"pagedMode\"\n [length]=\"filterActive ? filteredRows.length : serverTotal + overlayDelta\"\n [pageIndex]=\"pageIndex\"\n [pageSize]=\"pageSize\"\n [pageSizeOptions]=\"config.pageSizes ?? [10, 20, 50]\"\n [ngClass]=\"{'paginator-hidden': !dataSource || (smallScreen && dataSource?.length === 0) || loadingStage === 'initial'}\"\n (page)=\"onServerPage($event)\">\n </mat-paginator>\n\n </div>\n \n <!-- Added (Quiet Loading): the initial-load stage for the NON-table views.\n Without this, card/capsule/grouped showed literally nothing during a first load \u2014 the view component\n renders an empty dataSource and the \"No Data\" line is suppressed while the stage owns the space.\n The progress header is identical to the table's so the two feel like one feature; only the ghost\n furniture differs, because column-shaped rows are wrong in a card grid. -->\n <div *ngIf=\"loadingStage === 'initial' && config?.viewType && config?.viewType !== 'table'\" class=\"tin-load-stage\" role=\"status\" aria-busy=\"true\">\n <div class=\"tin-load-progress\">\n <div class=\"tin-load-bar\">\n <span class=\"tin-load-bar-fill\" [style.width.%]=\"progressPercent\"></span>\n </div>\n <span class=\"tin-load-percent\" aria-hidden=\"true\">{{progressDisplay}}%</span>\n </div>\n <div class=\"tin-load-caption\">Loading {{stageEntityName}}\u2026</div>\n\n <div *ngIf=\"config?.viewType === 'capsule'\" class=\"tin-load-ghost-capsules\" aria-hidden=\"true\">\n <span class=\"tin-skel tin-load-ghost-capsule\" *ngFor=\"let c of ghostCapsules\"></span>\n </div>\n\n <div *ngIf=\"config?.viewType === 'card'\" class=\"tin-load-ghost-cards\" aria-hidden=\"true\">\n <div class=\"tin-load-ghost-card\" *ngFor=\"let c of ghostCards\">\n <span class=\"tin-skel tin-skel-text tin-load-ghost-card-title\"></span>\n <span class=\"tin-skel tin-skel-text\"></span>\n <span class=\"tin-skel tin-skel-text tin-load-ghost-card-short\"></span>\n </div>\n </div>\n\n <div *ngIf=\"config?.viewType === 'grouped'\" class=\"tin-load-ghost-groups\" aria-hidden=\"true\">\n <div class=\"tin-load-ghost-group\" *ngFor=\"let g of ghostGroups\">\n <span class=\"tin-skel tin-load-ghost-group-head\"></span>\n <!-- Pills, not rows: a group card's body is a wrap of chips, so full-width bars promised a table\n and the stage did not resemble what replaced it. -->\n <div class=\"tin-load-ghost-group-items\">\n <span class=\"tin-skel tin-load-ghost-capsule\" *ngFor=\"let r of ghostCards\"></span>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Capsules -->\n <spa-capsules *ngIf=\"config?.viewType === 'capsule' && loadingStage !== 'initial'\"\n [config]=\"config\"\n [dataSource]=\"dataSource\"\n [displayedButtons]=\"displayedButtons\"\n (actionClick)=\"actionClicked($event.name, $event.row)\">\n </spa-capsules>\n\n\n <!-- Cards -->\n <spa-cards *ngIf=\"config?.viewType === 'card' && loadingStage !== 'initial'\"\n [config]=\"config\"\n [dataSource]=\"dataSource\"\n [displayedButtons]=\"displayedButtons\"\n [smallScreen]=\"smallScreen\"\n (actionClick)=\"actionClicked($event.name, $event.row)\"\n (columnClick)=\"columnClicked($event.column, $event.row)\"\n (showBannerEvent)=\"showBanner($event)\">\n </spa-cards>\n\n <!-- Groups - Added: New grouped view type -->\n <!-- Changed: the grouped view no longer renders its own filter field. It used to sit on a row of its own\n beneath the buttons row, which left both rows half empty and, more importantly, was a SECOND filter with\n no refresh button. The standard header filter (which has refresh, like every other table) now drives it,\n with its text relayed in through filterText. -->\n <spa-groups *ngIf=\"config?.viewType === 'grouped' && loadingStage !== 'initial'\"\n [config]=\"config\"\n [dataSource]=\"dataSource\"\n [displayedButtons]=\"displayedButtons\"\n [showOwnFilter]=\"false\"\n [filterText]=\"groupFilterText\"\n (actionClick)=\"actionClicked($event.name, $event.row, $event.group, $event.button)\">\n </spa-groups>\n\n\n <div class=\"tin-center\">\n <p *ngIf=\"dataSource?.length == 0 && loadingStage !== 'initial'\"><em>{{config.noDataMessage ?? 'No Data'}}</em></p> <!-- Changed (Quiet Loading): the stage owns the space until it completes, then hands straight over to this message \u2014 no \"No Data\" flashing underneath the ghost rows -->\n </div>\n\n </ng-container> <!-- Added: end section-collapse wrapper -->\n\n</ng-container>\n\n\n<ng-container *ngIf=\"!hasFormAccess\">\n <div class=\"tin-center\">\n <p><em>Access Restricted</em></p>\n </div>\n</ng-container>\n\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}.title{margin-top:10px;font-size:larger;font-weight:300}.make-gray{background-color:#f5f5f5}tr.mat-mdc-row{transition:background-color .15s ease}tr.mat-mdc-row:hover{background-color:#2196f305}tr.mat-mdc-row.row-editing,tr.mat-mdc-row.row-editing:hover{background-color:#ffa00014}.inline-save{color:#2e7d32!important}.inline-cancel{color:#c62828!important}.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}.dialog-container{display:flex;flex-direction:column;height:100%;max-height:100%;min-height:0}.dialog-content{flex:1;overflow:hidden;display:flex;flex-direction:column;min-height:0}.dialog-scroll-content{flex:1;overflow-y:auto;max-height:none!important;display:flex;flex-direction:column;min-height:0}.dialog-container:not(.dialog-has-tables){height:auto}.dialog-container:not(.dialog-has-tables) .dialog-scroll-content{flex:1 1 auto}.dialog-scroll-content>.tin-input{flex:1;display:flex;flex-direction:column}.dialog-scroll-content>.tin-input>spa-tabs{flex:1;display:flex;flex-direction:column}mat-dialog-actions{flex-shrink:0;justify-content:flex-start}.paginator-hidden{display:none!important}.paged-filter-hint{display:flex;align-items:center;gap:8px;margin:4px 0 8px;padding:6px 12px;border-radius:4px;background-color:#fff8e1;color:#795548;font-size:13px}.paged-filter-hint mat-icon{font-size:18px;width:18px;height:18px;color:#ffa000}.dlg-header-classic{padding-left:24px;padding-right:24px}.dlg-title-classic{font-size:20px;font-weight:500;margin-top:10px;margin-bottom:5px}.dialog-header-titles{display:flex;flex-direction:column;justify-content:center;min-width:0}.tin-dlg-head .dialog-header-titles label{margin:0}.tbl-section-header{display:flex;align-items:center;gap:10px;padding:10px 12px;margin-bottom:8px;border:1px solid rgba(0,0,0,.08);border-radius:10px;background:transparent;cursor:pointer;transition:border-color .15s}.tbl-section-header:hover{border-color:#90a4ae}.tbl-section-header.tbl-section-static{cursor:default}.tbl-section-icon{color:#546e7a}.tbl-section-title{font-size:14px;font-weight:600;color:#000000d1}.tbl-section-count{background:#e3f2fd;color:#1565c0;border-radius:12px;padding:2px 10px;font-size:12px}.tbl-section-chip{font-size:12px;font-weight:500;color:#0009}.tbl-section-spacer{margin-left:auto}.tbl-section-chevron{color:#90a4ae}.tbl-section-caption{margin:-4px 12px 10px;font-size:12.5px;line-height:1.45;color:#0000008c;max-width:82ch}.tbl-section-more{padding:0 12px 8px}.tbl-section-link{background:none;border:none;color:#1565c0;cursor:pointer;font-size:13px;padding:0}.tbl-section-link:hover{text-decoration:underline}.tbl-section-icon,.tbl-section-chevron,.tbl-section-count{flex:0 0 auto}.tbl-section-title{flex:1 1 auto;min-width:0}@media (max-width: 700px){.tbl-section-header{gap:8px;padding:10px}.tbl-section-chip,.tbl-section-btn-text.has-icon{display:none}.tbl-section-header button{min-width:0;padding:0 10px}}table.tin-no-col-headers tr.mat-mdc-header-row{display:none}\n"] }]
18444
+ args: [{ selector: 'spa-table', standalone: false, template: "\n<ng-container *ngIf=\"hasFormAccess && !sectionHidden\"> <!-- Changed: sectionConfig.hideWhenEmpty hides the whole table -->\n\n <!-- Added: collapsible flat section header (sectionConfig) \u2014 1px border, no elevation, whole row toggles -->\n <div class=\"tbl-section-header\" *ngIf=\"config.sectionConfig\" (click)=\"toggleSection()\" [attr.aria-expanded]=\"!sectionCollapsed\" [class.tbl-section-static]=\"config.sectionConfig.collapsible === false\" role=\"button\" tabindex=\"0\" (keydown.enter)=\"toggleSection()\">\n <mat-icon class=\"tbl-section-icon\" *ngIf=\"config.sectionConfig.icon\">{{ config.sectionConfig.icon }}</mat-icon>\n <span class=\"tbl-section-title\">{{ config.sectionConfig.title }}</span>\n <span class=\"tbl-section-count\" *ngIf=\"config.sectionConfig.showCount !== false\">{{ dataSource?.length || 0 }}</span>\n <span class=\"tbl-section-chip\" *ngFor=\"let chip of sectionChips()\" [style.color]=\"chip.color\">{{ chip.text }}</span>\n <span class=\"tbl-section-spacer\"></span>\n <!-- Changed: the label is wrapped so a NARROW screen can drop it and leave an icon-only button. Only a\n button that HAS an icon loses its text \u2014 otherwise it would collapse to a blank square. -->\n <button mat-stroked-button color=\"primary\" *ngFor=\"let btn of sectionButtons()\" (click)=\"sectionButtonClicked(btn, $event)\" [matTooltip]=\"btn.display || btn.name\"><mat-icon *ngIf=\"btn.icon?.name\">{{ btn.icon.name }}</mat-icon><span class=\"tbl-section-btn-text\" [class.has-icon]=\"!!btn.icon?.name\">{{ btn.display || btn.name }}</span></button>\n <mat-icon class=\"tbl-section-chevron\" *ngIf=\"config.sectionConfig.collapsible !== false\">{{ sectionCollapsed ? 'expand_more' : 'expand_less' }}</mat-icon>\n </div>\n\n <!-- Added: the section's \"why this list exists\" line. It sits UNDER the title (a reason only makes sense once\n the list has been named) and only while the section is open \u2014 a shut section already has its own summary\n line, and a paragraph over the top of that is noise. -->\n <p class=\"tbl-section-caption\" *ngIf=\"config.sectionConfig?.caption && !sectionCollapsed\">{{ config.sectionConfig.caption }}</p>\n\n <!-- Added: collapsed section affordance \u2014 mirrors the Day Book \"Show the N\" pattern -->\n <div class=\"tbl-section-more\" *ngIf=\"config.sectionConfig && sectionCollapsed && (dataSource?.length || 0) > 0\">\n <button type=\"button\" class=\"tbl-section-link\" (click)=\"toggleSection()\">Show the {{ dataSource.length }}</button>\n </div>\n\n <ng-container *ngIf=\"!config.sectionConfig || !sectionCollapsed\"> <!-- Added: section collapse hides the table body -->\n\n <!-- Search -->\n <spa-search\n *ngIf=\"config.searchConfig\" [config]=\"config.searchConfig\" [smallScreen]=\"smallScreen\" [tableDataSource]=\"tableDataSource\" style=\"margin-bottom: 20px;\" (searchClick)=\"searchClicked($event)\">\n </spa-search>\n\n <!-- Header -->\n <!-- Changed: [isRealTime] binds the RESOLVED value, not the raw config. It was `config.realTime`, so the live\n indicator only ever appeared on a table that set the flag ITSELF \u2014 a table relying on the app-wide\n `tableDefaults.realTime` was genuinely subscribed to SignalR (setupRealTimeSubscriptions and\n realTimeRefreshOrFallback both resolve through effRealTime) and simply never showed the dot. That is\n how \"Trips and Loads have no real time\" got reported: a status light disagreeing with the system it\n reports on. effRealTime also gets the inverse right \u2014 realTime:true under a global false still lights,\n and realTime:false under a global true stays dark. -->\n <app-table-header\n [config]=\"config\" [data]=\"dataSource\" [tableDataSource]=\"tableDataSource\" [tileConfig]=\"config.tileConfig\" [tileData]=\"tileData\" [tileReload]=\"tileReload\" [lastSearch]=\"lastSearch\" [smallScreen]=\"smallScreen\"\n [showFilterButton]=\"showFilterButton\" [isRealTime]=\"effRealTime\" [isConnected]=\"isSignalRConnected\" [refreshing]=\"loadingStage === 'refresh'\"\n (createClick)=\"newModel()\" (customClick)=\"customModel($event,null)\"\n (refreshClick)=\"refreshClicked()\" (tileClick)=\"tileClicked($event)\" (tileUnClick)=\"tileUnClicked($event)\" (filterChange)=\"filterChanged($event)\">\n </app-table-header>\n\n <!-- Added (Quiet Loading D4): refresh with data already on screen \u2014 a 2px line flush under the header and the\n spinning refresh icon are the ONLY signals. Rows stay live, clickable and un-dimmed while they swap. -->\n <div *ngIf=\"loadingStage === 'refresh'\" class=\"tin-load-line\" aria-hidden=\"true\">\n <span class=\"tin-load-line-fill\" [style.width.%]=\"progressPercent\"></span>\n </div>\n\n <!-- Added: paged-mode filter hint \u2014 the client filter only covers rows loaded so far -->\n <div *ngIf=\"pagedMode && filterActive && loadedRows.length < serverTotal\" class=\"paged-filter-hint\">\n <mat-icon>info</mat-icon>\n <span>Filtering only the {{loadedRows.length}} loaded rows of {{serverTotal}}. {{ config.searchConfig ? 'Use Search for complete results.' : 'Refine with search for complete results.' }}</span>\n </div>\n\n\n <!-- Table -->\n <div *ngIf=\"!config.viewType || config?.viewType === 'table'\">\n\n <p *ngIf=\"!config\"><em>Configure Table</em></p>\n <p *ngIf=\"!dataSource && !loadingStage\"><em>Loading...</em></p> <!-- Changed (Quiet Loading): the bare text is replaced by the stage below while a quiet load is on screen -->\n\n <!-- Added (Quiet Loading D3): first load, nothing on screen yet. The progress module is the hero (eased bar +\n counting percentage + caption) and the ghost rows hold the exact space the real rows will fill, so the\n table does not jump when data lands. Only ever rendered when quiet loading is on. -->\n <div *ngIf=\"loadingStage === 'initial'\" class=\"tin-load-stage\" role=\"status\" aria-busy=\"true\">\n\n <div class=\"tin-load-progress\">\n <div class=\"tin-load-bar\">\n <span class=\"tin-load-bar-fill\" [style.width.%]=\"progressPercent\"></span>\n </div>\n <span class=\"tin-load-percent\" aria-hidden=\"true\">{{progressDisplay}}%</span>\n </div>\n\n <div class=\"tin-load-caption\">Loading {{stageEntityName}}\u2026</div>\n\n <div class=\"tin-load-ghosts\">\n <div class=\"tin-load-ghost-row\" *ngFor=\"let r of ghostRows\">\n <div class=\"tin-load-ghost-cell\" *ngFor=\"let c of ghostColumns; let i = index\">\n <span class=\"tin-skel\" [style.width.%]=\"ghostWidth(i, c)\" [style.animation-delay.ms]=\"r * 120\"></span> <!-- staggered sweep: each row starts 120ms after the one above -->\n </div>\n </div>\n </div>\n\n </div>\n\n <div *ngIf=\"dataSource && loadingStage !== 'initial' && (!smallScreen || (smallScreen && dataSource?.length > 0))\" [class.tin-load-in]=\"effQuietLoading\"> <!-- Changed (Quiet Loading): the empty header-only table is suppressed while the initial stage stands in for it, and the real rows fade in where the ghosts were (D3) -->\n\n <table mat-table [dataSource]=\"tableDataSource\" [trackBy]=\"trackByRow\" [ngClass]=\"elevation\" [class.tin-no-col-headers]=\"config.hideColumnHeaders\"> <!-- Changed: optional column-header suppression -->\n\n <ng-container *ngFor=\"let column of config.columns\" [matColumnDef]=\"column.name\">\n <th mat-header-cell *matHeaderCellDef >{{ column.alias ?? column.name | camelToWords }}</th>\n <td mat-cell *matCellDef=\"let row;\" class=\"right-padding\" >\n\n <!-- Added: inline edit \u2014 editable cells swap to their form-field editor while the row is in edit mode -->\n <app-inline-cell *ngIf=\"isRowEditing(row) && getInlineField(column); else displayCell\" [field]=\"getInlineField(column)\" [data]=\"editingModel\"></app-inline-cell>\n\n <!-- Rows -->\n <ng-template #displayCell>\n <app-table-row [column]=\"column\" [row]=\"row\" [config]=\"config\" [smallScreen]=\"smallScreen\"\n (actionClick)=\"actionClicked(column.name, row)\" (columnClick)=\"columnClicked(column, row)\" (showBannerEvent)=\"showBanner($event)\">\n </app-table-row>\n </ng-template>\n\n </td>\n </ng-container>\n\n <ng-container matColumnDef=\"action\">\n <th mat-header-cell *matHeaderCellDef> Action </th>\n <td mat-cell *matCellDef=\"let row\" [ngStyle]=\"{width:false ? '20px' : actionsWidth}\">\n <div class=\"action-buttons-container\">\n\n <!-- Added: inline edit \u2014 while a row edits in place, its actions collapse to submit/cancel -->\n <ng-container *ngIf=\"isRowEditing(row); else rowActions\">\n <button mat-icon-button matTooltip=\"Save\" matTooltipPosition=\"above\" (click)=\"submitInlineEdit()\"><mat-icon class=\"inline-save\">check</mat-icon></button> <!-- Changed: dropped color=\"primary\" \u2014 the icon now carries a green save cue -->\n <button mat-icon-button matTooltip=\"Cancel\" matTooltipPosition=\"above\" (click)=\"cancelInlineEdit()\"><mat-icon class=\"inline-cancel\">close</mat-icon></button> <!-- Changed: red cancel cue -->\n </ng-container>\n\n <!-- Actions -->\n <ng-template #rowActions>\n <app-table-action\n [displayedButtons]=\"displayedButtons\" [config]=\"config\" [smallScreen]=\"smallScreen\" [row]=\"row\" (actionClick)=\"actionClicked($event.name, $event.row)\">\n </app-table-action>\n </ng-template>\n\n </div>\n </td>\n </ng-container>\n\n\n <tr mat-header-row *matHeaderRowDef=\"displayedColumns\"></tr>\n <tr mat-row *matRowDef=\"let row; columns: displayedColumns;\" [ngClass]=\"{'make-gray': (config.greyOut && config.greyOut(row)) || row.pendingApproval, 'row-editing': isRowEditing(row)}\"></tr> <!-- Changed: row-editing flags the row that is open for inline edit -->\n </table>\n\n </div>\n\n <!-- Changed: Removed *ngIf condition to keep paginator always in DOM and maintain ViewChild reference -->\n <!-- Changed: Added CSS class binding to hide when no data instead of conditional rendering -->\n <!-- Changed: Legacy paginator only renders in non-paged mode (pagedMode is constant per instance, set before first render) -->\n <!-- Changed: hidePageSize on a phone. The \"Items per page\" label and its select cost a whole row of a\n narrow screen for a control almost nobody touches there \u2014 the default is what gets used. The range\n (\"1 \u2013 10 of 54\") and the arrows stay, which is the part that is actually navigated. Bound to\n smallScreen, the component's existing breakpoint (max-width 600px, live via BreakpointObserver), so\n the paginator agrees with how this table already decides what \"mobile\" means rather than\n introducing a third breakpoint. -->\n <mat-paginator *ngIf=\"!pagedMode\"\n #tablePaginator\n [pageSizeOptions]=\"config.pageSizes ?? [10, 20, 50]\"\n [hidePageSize]=\"smallScreen\"\n [ngClass]=\"{'paginator-hidden': !dataSource || (smallScreen && dataSource?.length === 0) || loadingStage === 'initial'}\"\n showFirstLastButtons>\n </mat-paginator>\n\n <!-- Added: manual paginator for server-side paged mode \u2014 fully state-bound, never attached to MatTableDataSource. Always visible: when filtering it pages the in-memory filtered subset (length = filtered count); otherwise the server window (length = true total). No first/last jump (would force fetching the whole gap). -->\n <mat-paginator *ngIf=\"pagedMode\"\n [length]=\"filterActive ? filteredRows.length : serverTotal + overlayDelta\"\n [pageIndex]=\"pageIndex\"\n [pageSize]=\"pageSize\"\n [pageSizeOptions]=\"config.pageSizes ?? [10, 20, 50]\"\n [hidePageSize]=\"smallScreen\"\n [ngClass]=\"{'paginator-hidden': !dataSource || (smallScreen && dataSource?.length === 0) || loadingStage === 'initial'}\"\n (page)=\"onServerPage($event)\">\n </mat-paginator>\n\n </div>\n \n <!-- Added (Quiet Loading): the initial-load stage for the NON-table views.\n Without this, card/capsule/grouped showed literally nothing during a first load \u2014 the view component\n renders an empty dataSource and the \"No Data\" line is suppressed while the stage owns the space.\n The progress header is identical to the table's so the two feel like one feature; only the ghost\n furniture differs, because column-shaped rows are wrong in a card grid. -->\n <div *ngIf=\"loadingStage === 'initial' && config?.viewType && config?.viewType !== 'table'\" class=\"tin-load-stage\" role=\"status\" aria-busy=\"true\">\n <div class=\"tin-load-progress\">\n <div class=\"tin-load-bar\">\n <span class=\"tin-load-bar-fill\" [style.width.%]=\"progressPercent\"></span>\n </div>\n <span class=\"tin-load-percent\" aria-hidden=\"true\">{{progressDisplay}}%</span>\n </div>\n <div class=\"tin-load-caption\">Loading {{stageEntityName}}\u2026</div>\n\n <div *ngIf=\"config?.viewType === 'capsule'\" class=\"tin-load-ghost-capsules\" aria-hidden=\"true\">\n <span class=\"tin-skel tin-load-ghost-capsule\" *ngFor=\"let c of ghostCapsules\"></span>\n </div>\n\n <div *ngIf=\"config?.viewType === 'card'\" class=\"tin-load-ghost-cards\" aria-hidden=\"true\">\n <div class=\"tin-load-ghost-card\" *ngFor=\"let c of ghostCards\">\n <span class=\"tin-skel tin-skel-text tin-load-ghost-card-title\"></span>\n <span class=\"tin-skel tin-skel-text\"></span>\n <span class=\"tin-skel tin-skel-text tin-load-ghost-card-short\"></span>\n </div>\n </div>\n\n <div *ngIf=\"config?.viewType === 'grouped'\" class=\"tin-load-ghost-groups\" aria-hidden=\"true\">\n <div class=\"tin-load-ghost-group\" *ngFor=\"let g of ghostGroups\">\n <span class=\"tin-skel tin-load-ghost-group-head\"></span>\n <!-- Pills, not rows: a group card's body is a wrap of chips, so full-width bars promised a table\n and the stage did not resemble what replaced it. -->\n <div class=\"tin-load-ghost-group-items\">\n <span class=\"tin-skel tin-load-ghost-capsule\" *ngFor=\"let r of ghostCards\"></span>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Capsules -->\n <spa-capsules *ngIf=\"config?.viewType === 'capsule' && loadingStage !== 'initial'\"\n [config]=\"config\"\n [dataSource]=\"dataSource\"\n [displayedButtons]=\"displayedButtons\"\n (actionClick)=\"actionClicked($event.name, $event.row)\">\n </spa-capsules>\n\n\n <!-- Cards -->\n <spa-cards *ngIf=\"config?.viewType === 'card' && loadingStage !== 'initial'\"\n [config]=\"config\"\n [dataSource]=\"dataSource\"\n [displayedButtons]=\"displayedButtons\"\n [smallScreen]=\"smallScreen\"\n (actionClick)=\"actionClicked($event.name, $event.row)\"\n (columnClick)=\"columnClicked($event.column, $event.row)\"\n (showBannerEvent)=\"showBanner($event)\">\n </spa-cards>\n\n <!-- Groups - Added: New grouped view type -->\n <!-- Changed: the grouped view no longer renders its own filter field. It used to sit on a row of its own\n beneath the buttons row, which left both rows half empty and, more importantly, was a SECOND filter with\n no refresh button. The standard header filter (which has refresh, like every other table) now drives it,\n with its text relayed in through filterText. -->\n <spa-groups *ngIf=\"config?.viewType === 'grouped' && loadingStage !== 'initial'\"\n [config]=\"config\"\n [dataSource]=\"dataSource\"\n [displayedButtons]=\"displayedButtons\"\n [showOwnFilter]=\"false\"\n [filterText]=\"groupFilterText\"\n (actionClick)=\"actionClicked($event.name, $event.row, $event.group, $event.button)\">\n </spa-groups>\n\n\n <div class=\"tin-center\">\n <p *ngIf=\"dataSource?.length == 0 && loadingStage !== 'initial'\"><em>{{config.noDataMessage ?? 'No Data'}}</em></p> <!-- Changed (Quiet Loading): the stage owns the space until it completes, then hands straight over to this message \u2014 no \"No Data\" flashing underneath the ghost rows -->\n </div>\n\n </ng-container> <!-- Added: end section-collapse wrapper -->\n\n</ng-container>\n\n\n<ng-container *ngIf=\"!hasFormAccess\">\n <div class=\"tin-center\">\n <p><em>Access Restricted</em></p>\n </div>\n</ng-container>\n\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}.title{margin-top:10px;font-size:larger;font-weight:300}.make-gray{background-color:#f5f5f5}tr.mat-mdc-row{transition:background-color .15s ease}tr.mat-mdc-row:hover{background-color:#2196f305}tr.mat-mdc-row.row-editing,tr.mat-mdc-row.row-editing:hover{background-color:#ffa00014}.inline-save{color:#2e7d32!important}.inline-cancel{color:#c62828!important}.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}.dialog-container{display:flex;flex-direction:column;height:100%;max-height:100%;min-height:0}.dialog-content{flex:1;overflow:hidden;display:flex;flex-direction:column;min-height:0}.dialog-scroll-content{flex:1;overflow-y:auto;max-height:none!important;display:flex;flex-direction:column;min-height:0}.dialog-container:not(.dialog-has-tables){height:auto}.dialog-container:not(.dialog-has-tables) .dialog-scroll-content{flex:1 1 auto}.dialog-scroll-content>.tin-input{flex:1;display:flex;flex-direction:column}.dialog-scroll-content>.tin-input>spa-tabs{flex:1;display:flex;flex-direction:column}mat-dialog-actions{flex-shrink:0;justify-content:flex-start}.paginator-hidden{display:none!important}.paged-filter-hint{display:flex;align-items:center;gap:8px;margin:4px 0 8px;padding:6px 12px;border-radius:4px;background-color:#fff8e1;color:#795548;font-size:13px}.paged-filter-hint mat-icon{font-size:18px;width:18px;height:18px;color:#ffa000}.dlg-header-classic{padding-left:24px;padding-right:24px}.dlg-title-classic{font-size:20px;font-weight:500;margin-top:10px;margin-bottom:5px}.dialog-header-titles{display:flex;flex-direction:column;justify-content:center;min-width:0}.tin-dlg-head .dialog-header-titles label{margin:0}.tbl-section-header{display:flex;align-items:center;gap:10px;padding:10px 12px;margin-bottom:8px;border:1px solid rgba(0,0,0,.08);border-radius:10px;background:transparent;cursor:pointer;transition:border-color .15s}.tbl-section-header:hover{border-color:#90a4ae}.tbl-section-header.tbl-section-static{cursor:default}.tbl-section-icon{color:#546e7a}.tbl-section-title{font-size:14px;font-weight:600;color:#000000d1}.tbl-section-count{background:#e3f2fd;color:#1565c0;border-radius:12px;padding:2px 10px;font-size:12px}.tbl-section-chip{font-size:12px;font-weight:500;color:#0009}.tbl-section-spacer{margin-left:auto}.tbl-section-chevron{color:#90a4ae}.tbl-section-caption{margin:-4px 12px 10px;font-size:12.5px;line-height:1.45;color:#0000008c;max-width:82ch}.tbl-section-more{padding:0 12px 8px}.tbl-section-link{background:none;border:none;color:#1565c0;cursor:pointer;font-size:13px;padding:0}.tbl-section-link:hover{text-decoration:underline}.tbl-section-icon,.tbl-section-chevron,.tbl-section-count{flex:0 0 auto}.tbl-section-title{flex:1 1 auto;min-width:0}@media (max-width: 700px){.tbl-section-header{gap:8px;padding:10px}.tbl-section-chip,.tbl-section-btn-text.has-icon{display:none}.tbl-section-header button{min-width:0;padding:0 10px}}table.tin-no-col-headers tr.mat-mdc-header-row{display:none}\n"] }]
17619
18445
  }], ctorParameters: () => [{ type: DataServiceLib }, { type: MessageService }, { type: i1$4.BreakpointObserver }, { type: i4.MatDialog }, { type: ButtonService }, { type: DialogService }, { type: TableConfigService }, { type: ConditionService }, { type: AuthService }, { type: SignalRService }, { type: OfflineService }, { type: ApiErrorService }, { type: undefined, decorators: [{
17620
18446
  type: Optional
17621
18447
  }, {
@@ -17732,8 +18558,10 @@ class DayBookComponent {
17732
18558
  next: (response) => {
17733
18559
  this.loading = false;
17734
18560
  this.setShowLoading(false);
17735
- if (!response.success)
18561
+ if (!response.success) {
18562
+ this.apiErrorService.presentAppFailure(response, 'load', this.config.loadAction.url);
17736
18563
  return;
18564
+ } // Changed: was a bare `return`, the last unhandled failure in this file — runPageAction below was already converted. rebuild() never ran, so the lanes, the tiles and every section stayed at their previous values: a Day Book that quietly shows yesterday's book is worse than one that admits it could not load
17737
18565
  this.book = response.data;
17738
18566
  this.rebuild();
17739
18567
  },
@@ -18399,8 +19227,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
18399
19227
  }] } });
18400
19228
 
18401
19229
  class NotesComponent {
18402
- constructor(dataService) {
19230
+ constructor(dataService, apiErrorService) {
18403
19231
  this.dataService = dataService;
19232
+ this.apiErrorService = apiErrorService;
18404
19233
  // Input properties
18405
19234
  this.title = "Notes";
18406
19235
  this.notes = []; // Data array if provided directly
@@ -18408,7 +19237,7 @@ class NotesComponent {
18408
19237
  this.nameField = "createdByName";
18409
19238
  this.dateField = "createdDate";
18410
19239
  this.commentField = "details";
18411
- }
19240
+ } // Changed: injected so a failed note load says so
18412
19241
  ngOnInit() {
18413
19242
  this.loadNotes();
18414
19243
  }
@@ -18435,19 +19264,25 @@ class NotesComponent {
18435
19264
  url = url.replace('/x', '/' + idValue);
18436
19265
  }
18437
19266
  this.dataService.CallApi({ ...this.loadAction, url }).subscribe((apiResponse) => {
19267
+ // CONVERTED (WS-6 Phase 4 triage). This is the same shape as the five field loads Phase 3 fixed —
19268
+ // notes attached to a record are content, not decoration, and an empty panel asserts "there are no
19269
+ // notes on this record", which is a statement someone will act on. It runs once per record view,
19270
+ // not on a timer, so there is no storm to cause.
19271
+ if (!apiResponse.success)
19272
+ this.apiErrorService.presentAppFailure(apiResponse, 'load', url);
18438
19273
  if (apiResponse.success && apiResponse.data) {
18439
19274
  this.notes = apiResponse.data;
18440
19275
  }
18441
19276
  });
18442
19277
  }
18443
19278
  }
18444
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: NotesComponent, deps: [{ token: DataServiceLib }], target: i0.ɵɵFactoryTarget.Component }); }
19279
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: NotesComponent, deps: [{ token: DataServiceLib }, { token: ApiErrorService }], target: i0.ɵɵFactoryTarget.Component }); }
18445
19280
  static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: NotesComponent, isStandalone: false, selector: "spa-notes", inputs: { title: "title", notes: "notes", loadAction: "loadAction", loadIDField: "loadIDField", data: "data", nameField: "nameField", dateField: "dateField", commentField: "commentField" }, ngImport: i0, template: " <div class=\"notes-container\" *ngIf=\"notes && notes.length > 0\">\n <div class=\"tin-input-row mt-3\" *ngIf=\"title != ''\">\n <mat-label style=\"font-size: 20px; font-weight: 300\">{{title}}</mat-label>\n </div>\n \n <ul class=\"list-group list-group-flush\" style=\"max-height:350px; margin-left:5px\">\n <li *ngFor=\"let note of notes\" class=\"list-group-item list-group-item-action flex-column align-items-start\">\n <div class=\"d-flex w-100 justify-content-between\">\n <div class=\"mb-0\">{{note[nameField] || note.createdByName}}</div>\n </div>\n <small>{{note[commentField] || note.details}} (<em>{{note[dateField] || note.createdDate | date: 'dd MMM yyyy HH:mm'}}</em>)</small>\n </li>\n </ul>\n </div>\n \n <div *ngIf=\"!notes || notes.length == 0\" class=\"d-flex justify-content-center row align-items-center\" style=\"max-height:200px\">\n No {{title.toLowerCase()}}\n </div>", styles: [".notes-container{padding:10px;border-radius:4px;margin-bottom:15px}.list-group-item{background-color:transparent;border-left:none;border-right:none;border-radius:0;transition:background-color .2s}.list-group-item:hover{background-color:#00000008}.list-group-item small{display:block;margin-top:5px}\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: i3$1.MatLabel, selector: "mat-label" }, { kind: "pipe", type: i1$2.DatePipe, name: "date" }] }); }
18446
19281
  }
18447
19282
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: NotesComponent, decorators: [{
18448
19283
  type: Component,
18449
19284
  args: [{ selector: 'spa-notes', standalone: false, template: " <div class=\"notes-container\" *ngIf=\"notes && notes.length > 0\">\n <div class=\"tin-input-row mt-3\" *ngIf=\"title != ''\">\n <mat-label style=\"font-size: 20px; font-weight: 300\">{{title}}</mat-label>\n </div>\n \n <ul class=\"list-group list-group-flush\" style=\"max-height:350px; margin-left:5px\">\n <li *ngFor=\"let note of notes\" class=\"list-group-item list-group-item-action flex-column align-items-start\">\n <div class=\"d-flex w-100 justify-content-between\">\n <div class=\"mb-0\">{{note[nameField] || note.createdByName}}</div>\n </div>\n <small>{{note[commentField] || note.details}} (<em>{{note[dateField] || note.createdDate | date: 'dd MMM yyyy HH:mm'}}</em>)</small>\n </li>\n </ul>\n </div>\n \n <div *ngIf=\"!notes || notes.length == 0\" class=\"d-flex justify-content-center row align-items-center\" style=\"max-height:200px\">\n No {{title.toLowerCase()}}\n </div>", styles: [".notes-container{padding:10px;border-radius:4px;margin-bottom:15px}.list-group-item{background-color:transparent;border-left:none;border-right:none;border-radius:0;transition:background-color .2s}.list-group-item:hover{background-color:#00000008}.list-group-item small{display:block;margin-top:5px}\n"] }]
18450
- }], ctorParameters: () => [{ type: DataServiceLib }], propDecorators: { title: [{
19285
+ }], ctorParameters: () => [{ type: DataServiceLib }, { type: ApiErrorService }], propDecorators: { title: [{
18451
19286
  type: Input
18452
19287
  }], notes: [{
18453
19288
  type: Input
@@ -18927,6 +19762,7 @@ class AppConfigurationComponent {
18927
19762
  this.messageService = inject(MessageService);
18928
19763
  this.authService = inject(AuthService);
18929
19764
  this.configService = inject(ConfigService);
19765
+ this.apiErrorService = inject(ApiErrorService); // Added: the load and the save both needed a real failure surface
18930
19766
  this.router = inject(Router); // Added (v3)
18931
19767
  this.configuration = {};
18932
19768
  this.loading = false;
@@ -18964,8 +19800,10 @@ class AppConfigurationComponent {
18964
19800
  this.dataService.CallApi(this.config?.loadAction || { url: 'configuration/get' }).subscribe({
18965
19801
  next: (response) => {
18966
19802
  this.loading = false;
18967
- if (!response.success)
19803
+ if (!response.success) {
19804
+ this.apiErrorService.presentAppFailure(response, 'load', (this.config?.loadAction || { url: 'configuration/get' }).url);
18968
19805
  return;
19806
+ } // Changed: was a bare `return`, so buildCards() never ran and the page rendered its heading over nothing at all — the emptiest possible screen with no statement of why
18969
19807
  this.configuration = response.data || {}; // the backend creates the row on first read, so this is never empty in practice
18970
19808
  this.buildCards();
18971
19809
  },
@@ -19023,7 +19861,10 @@ class AppConfigurationComponent {
19023
19861
  this.configService.refresh(); // every app-side subscriber (nav gating, unit suffixes) sees the change now, not next login
19024
19862
  }
19025
19863
  else {
19026
- this.messageService.toast(response.message || 'Error updating configuration');
19864
+ // Changed: was toast(response.message ...) — the server's raw words for 5 seconds while the page
19865
+ // still showed every edited value, so it looked saved. 'submit' is exactly right and literally
19866
+ // true here: the user's configuration edits ARE still on the form and do not need re-entering.
19867
+ this.apiErrorService.presentAppFailure(response, 'submit', (this.config?.saveAction || { url: 'configuration/update' }).url);
19027
19868
  }
19028
19869
  },
19029
19870
  error: () => this.saving = false
@@ -20536,7 +21377,7 @@ class StepsComponent {
20536
21377
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: StepsComponent, deps: [{ token: i1$4.BreakpointObserver }, { token: DataServiceLib }, { token: ApiErrorService }], target: i0.ɵɵFactoryTarget.Component }); }
20537
21378
  static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: StepsComponent, isStandalone: false, selector: "spa-steps", inputs: { value: "value", config: "config", data: "data", activeIndex: "activeIndex" }, providers: [{
20538
21379
  provide: STEPPER_GLOBAL_OPTIONS, useValue: { displayDefaultIndicatorType: false }
20539
- }], viewQueries: [{ propertyName: "stepper", first: true, predicate: ["stepper"], descendants: true }], usesOnChanges: true, ngImport: i0, template: "\n<mat-horizontal-stepper class=\"transparent\" [labelPosition]=\"shouldShowLabels ? 'bottom' : 'end'\" #stepper [selectedIndex]=\"selectedIndex\">\n\n <mat-step *ngFor=\"let step of getVisibleSteps()\"\n [editable]=\"false\" [label]=\"shouldShowLabels ? step.name : ''\" [state]=\"step.icon ?? 'number'\">\n </mat-step>\n\n</mat-horizontal-stepper>\n", styles: [".transparent{background-color:#0000}:host ::ng-deep .mat-step-header{padding:5px!important}:host ::ng-deep .mat-stepper-horizontal-line{min-width:5px!important}:host ::ng-deep .mat-horizontal-content-container{padding:0!important}:host ::ng-deep .mat-horizontal-stepper-header{pointer-events:none!important}\n"], dependencies: [{ kind: "directive", type: i1$2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "component", type: i5$3.MatStep, selector: "mat-step", inputs: ["color"], exportAs: ["matStep"] }, { kind: "component", type: i5$3.MatStepper, selector: "mat-stepper, mat-vertical-stepper, mat-horizontal-stepper, [matStepper]", inputs: ["disableRipple", "color", "labelPosition", "headerPosition", "animationDuration"], outputs: ["animationDone"], exportAs: ["matStepper", "matVerticalStepper", "matHorizontalStepper"] }] }); }
21380
+ }], viewQueries: [{ propertyName: "stepper", first: true, predicate: ["stepper"], descendants: true }], usesOnChanges: true, ngImport: i0, template: "\n<mat-horizontal-stepper class=\"transparent\" [labelPosition]=\"shouldShowLabels ? 'bottom' : 'end'\" #stepper [selectedIndex]=\"selectedIndex\">\n\n <mat-step *ngFor=\"let step of getVisibleSteps()\"\n [editable]=\"false\" [label]=\"shouldShowLabels ? step.name : ''\" [state]=\"step.icon ?? 'number'\">\n </mat-step>\n\n</mat-horizontal-stepper>\n", styles: [".transparent{background-color:#0000}:host ::ng-deep .mat-step-header{padding:5px!important}:host ::ng-deep .mat-stepper-horizontal-line{min-width:5px!important}:host ::ng-deep .mat-horizontal-content-container{padding:0!important}:host ::ng-deep .mat-horizontal-stepper-header{pointer-events:none!important}\n"], dependencies: [{ kind: "directive", type: i1$2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "component", type: i5$2.MatStep, selector: "mat-step", inputs: ["color"], exportAs: ["matStep"] }, { kind: "component", type: i5$2.MatStepper, selector: "mat-stepper, mat-vertical-stepper, mat-horizontal-stepper, [matStepper]", inputs: ["disableRipple", "color", "labelPosition", "headerPosition", "animationDuration"], outputs: ["animationDone"], exportAs: ["matStepper", "matVerticalStepper", "matHorizontalStepper"] }] }); }
20540
21381
  }
20541
21382
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: StepsComponent, decorators: [{
20542
21383
  type: Component,
@@ -21090,14 +21931,39 @@ class LoaderInterceptor {
21090
21931
  complete: () => observer.complete()
21091
21932
  });
21092
21933
  }
21093
- else {
21934
+ else if (result === 'rejected') {
21094
21935
  this.refreshResult$.next('FAILED'); // Unblock waiting requests
21095
- // Changed: gentle toast + login redirect instead of error dialog; on 'network' the refresh
21096
- // token is preserved so the login page can silently restore once connectivity returns
21936
+ // Changed: gentle toast + login redirect instead of error dialog. This branch now fires ONLY for an
21937
+ // explicit server rejection the refresh endpoint answered HTTP 200 with success:false, meaning the
21938
+ // refresh token is revoked or expired. That is the only outcome that genuinely ends a session, and it
21939
+ // is the only one where silentRefresh() clears the stored token (auth.service.ts:370-380).
21097
21940
  this.authService.sessionExpired();
21098
21941
  this.messageService.toast('Please sign in to continue');
21099
21942
  observer.error(new Error('Token refresh failed'));
21100
21943
  }
21944
+ else {
21945
+ // Changed: 'network' no longer logs the user out. silentRefresh() resolves 'network' for EVERY
21946
+ // non-2xx on User/refresh — status 0, 408, 429 and every 5xx alike — because HttpClient routes them
21947
+ // all to its error callback. Treating that as "session over" made a transient condition destroy a
21948
+ // session that was never invalid.
21949
+ //
21950
+ // The 429 case is the one that turns this from untidy into damaging: an office behind a single NAT
21951
+ // IP, whose tokens expire together after a shared morning sign-in, refreshes as one burst, trips one
21952
+ // shared rate limit, and logs ITSELF out — every user at once, for a condition that would have
21953
+ // cleared in seconds. 5xx and status 0 are grouped with it for the same reason: in none of them did
21954
+ // the server assert anything about the token, so inferring "your session is over" from them is an
21955
+ // inference the response does not support. Their common property is not the status number, it is the
21956
+ // ABSENCE of an authoritative rejection.
21957
+ //
21958
+ // Doing nothing destructive is also what auth.service.ts already does on its own refresh paths
21959
+ // (:265-269, :277-281, :299-304): only 'rejected' calls sessionExpired(), 'network' reschedules. This
21960
+ // branch was the single place in the codebase that disagreed. The refresh token is deliberately
21961
+ // preserved by silentRefresh(), the proactive timer will try again, and the next 401 re-enters here —
21962
+ // so the session recovers on its own with no user action.
21963
+ this.refreshResult$.next('FAILED'); // Unblock waiting requests — the ORIGINAL request still fails, only the session survives
21964
+ this.messageService.toast('The server is busy. You are still signed in — please try again in a moment.');
21965
+ observer.error(new Error('Token refresh unavailable'));
21966
+ }
21101
21967
  });
21102
21968
  });
21103
21969
  }
@@ -21345,7 +22211,7 @@ class TabsComponent {
21345
22211
  return this.reload || this.tableReloads[index];
21346
22212
  }
21347
22213
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: TabsComponent, deps: [{ token: TabService }], target: i0.ɵɵFactoryTarget.Component }); }
21348
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: TabsComponent, isStandalone: false, selector: "spa-tabs", inputs: { tableConfigs: "tableConfigs", reload: "reload", reloadTab: "reloadTab", parentDetails: "parentDetails", localMode: "localMode", nestingLevel: "nestingLevel" }, outputs: { formRefresh: "formRefresh", actionSuccess: "actionSuccess" }, usesOnChanges: true, ngImport: i0, template: "<mat-tab-group (selectedTabChange)=\"onTabChange($event)\" [selectedIndex]=\"selectedTabIndex\">\n\n <!-- Changed: Use cached visibleTabs property to prevent infinite change detection loop -->\n <ng-container *ngFor=\"let tab of visibleTabs; let i = index\">\n <mat-tab><!-- TS-11: visibleTabs is already filtered via getVisibleTabs; dropped per-tab isTabVisible() call per CD -->\n\n <!-- Tab label with count pill.\n Changed: was matBadge, which renders as a faint superscript unless the consuming app includes\n Material's badge theme \u2014 several do not, so the number was effectively invisible. This pill is\n styled entirely in this component, so every app gets the same legible count.\n An empty tab still shows its count, muted rather than accented: saying \"nothing in here\" before\n the tab is opened is the whole point, and it saves the round trip of opening it. -->\n <ng-template matTabLabel>\n <span class=\"tab-label\">\n {{getTabTitle(tab.config)}}\n <span *ngIf=\"shouldShowBadge(tab.originalIndex)\"\n class=\"tab-count\"\n [class.tab-count-zero]=\"getTabCount(tab.originalIndex) === 0\">{{getTabCount(tab.originalIndex)}}</span>\n </span>\n </ng-template>\n\n <!-- Tab content: custom template when provided (tabTemplate), otherwise the lazy-loaded table.\n Templates keep the same lazy semantics \u2014 rendered only once the tab has been activated. -->\n <div *ngIf=\"shouldLoadTabData(i) && tab.config.tabTemplate\" class=\"tab-content\">\n <ng-container [ngTemplateOutlet]=\"tab.config.tabTemplate\"></ng-container>\n </div>\n\n <div *ngIf=\"shouldLoadTabData(i) && !tab.config.tabTemplate\" class=\"tab-content\">\n <spa-table\n [config]=\"tab.config\"\n [reload]=\"getReloadSubject(tab.originalIndex)\"\n [inTab]=\"true\"\n [activeTab]=\"selectedTabIndex === i\"\n [nestingLevel]=\"nestingLevel\"\n [localMode]=\"localMode\"\n [parentDetails]=\"parentDetails\"\n (totalChange)=\"onTabTotal(tab.originalIndex, $event)\"\n (actionSuccess)=\"onTableActionSuccess(tab.originalIndex, $event)\">\n </spa-table><!-- Changed: badge adopts the grid's filtered total so it can never show a global/unfiltered count --><!-- Changed: localMode + parentDetails passed through for one-step create local tables -->\n </div>\n\n <!-- Placeholder for non-loaded tabs -->\n <div *ngIf=\"!shouldLoadTabData(i)\" class=\"tab-placeholder\">\n <!-- Empty placeholder - content will load when tab is activated -->\n </div>\n\n </mat-tab>\n </ng-container>\n\n</mat-tab-group>", styles: [":host{flex:1;display:flex;flex-direction:column}:host ::ng-deep .mat-mdc-tab-group{flex:1;display:flex;flex-direction:column}:host ::ng-deep .mat-mdc-tab-body-wrapper{flex:1}.tab-content{padding-top:16px;overflow-x:auto}.tab-placeholder{min-height:100px;display:flex;align-items:center;justify-content:center}.tab-label{display:inline-flex;align-items:center;gap:8px}.tab-count{display:inline-flex;align-items:center;justify-content:center;min-width:20px;height:18px;padding:0 6px;border-radius:9px;background-color:#1976d2;color:#fff;font-size:11px;font-weight:600;line-height:1}.tab-count-zero{background-color:#d5d9de;color:#6b7280}.badge{background-color:#2196f3;color:#fff;border-radius:12px;padding:2px 8px;margin-left:8px;font-size:12px}\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: i5$4.MatTabLabel, selector: "[mat-tab-label], [matTabLabel]" }, { kind: "component", type: i5$4.MatTab, selector: "mat-tab", inputs: ["disabled", "label", "aria-label", "aria-labelledby", "labelClass", "bodyClass", "id"], exportAs: ["matTab"] }, { kind: "component", type: i5$4.MatTabGroup, selector: "mat-tab-group", inputs: ["color", "fitInkBarToContent", "mat-stretch-tabs", "mat-align-tabs", "dynamicHeight", "selectedIndex", "headerPosition", "animationDuration", "contentTabIndex", "disablePagination", "disableRipple", "preserveContent", "backgroundColor", "aria-label", "aria-labelledby"], outputs: ["selectedIndexChange", "focusChange", "animationDone", "selectedTabChange"], exportAs: ["matTabGroup"] }, { 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"] }] }); }
22214
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: TabsComponent, isStandalone: false, selector: "spa-tabs", inputs: { tableConfigs: "tableConfigs", reload: "reload", reloadTab: "reloadTab", parentDetails: "parentDetails", localMode: "localMode", nestingLevel: "nestingLevel" }, outputs: { formRefresh: "formRefresh", actionSuccess: "actionSuccess" }, usesOnChanges: true, ngImport: i0, template: "<mat-tab-group (selectedTabChange)=\"onTabChange($event)\" [selectedIndex]=\"selectedTabIndex\">\n\n <!-- Changed: Use cached visibleTabs property to prevent infinite change detection loop -->\n <ng-container *ngFor=\"let tab of visibleTabs; let i = index\">\n <mat-tab><!-- TS-11: visibleTabs is already filtered via getVisibleTabs; dropped per-tab isTabVisible() call per CD -->\n\n <!-- Tab label with count pill.\n Changed: was matBadge, which renders as a faint superscript unless the consuming app includes\n Material's badge theme \u2014 several do not, so the number was effectively invisible. This pill is\n styled entirely in this component, so every app gets the same legible count.\n An empty tab still shows its count, muted rather than accented: saying \"nothing in here\" before\n the tab is opened is the whole point, and it saves the round trip of opening it. -->\n <ng-template matTabLabel>\n <span class=\"tab-label\">\n {{getTabTitle(tab.config)}}\n <span *ngIf=\"shouldShowBadge(tab.originalIndex)\"\n class=\"tab-count\"\n [class.tab-count-zero]=\"getTabCount(tab.originalIndex) === 0\">{{getTabCount(tab.originalIndex)}}</span>\n </span>\n </ng-template>\n\n <!-- Tab content: custom template when provided (tabTemplate), otherwise the lazy-loaded table.\n Templates keep the same lazy semantics \u2014 rendered only once the tab has been activated. -->\n <div *ngIf=\"shouldLoadTabData(i) && tab.config.tabTemplate\" class=\"tab-content\">\n <ng-container [ngTemplateOutlet]=\"tab.config.tabTemplate\"></ng-container>\n </div>\n\n <div *ngIf=\"shouldLoadTabData(i) && !tab.config.tabTemplate\" class=\"tab-content\">\n <spa-table\n [config]=\"tab.config\"\n [reload]=\"getReloadSubject(tab.originalIndex)\"\n [inTab]=\"true\"\n [activeTab]=\"selectedTabIndex === i\"\n [nestingLevel]=\"nestingLevel\"\n [localMode]=\"localMode\"\n [parentDetails]=\"parentDetails\"\n (totalChange)=\"onTabTotal(tab.originalIndex, $event)\"\n (actionSuccess)=\"onTableActionSuccess(tab.originalIndex, $event)\">\n </spa-table><!-- Changed: badge adopts the grid's filtered total so it can never show a global/unfiltered count --><!-- Changed: localMode + parentDetails passed through for one-step create local tables -->\n </div>\n\n <!-- Placeholder for non-loaded tabs -->\n <div *ngIf=\"!shouldLoadTabData(i)\" class=\"tab-placeholder\">\n <!-- Empty placeholder - content will load when tab is activated -->\n </div>\n\n </mat-tab>\n </ng-container>\n\n</mat-tab-group>", styles: [":host{flex:1;display:flex;flex-direction:column}:host ::ng-deep .mat-mdc-tab-group{flex:1;display:flex;flex-direction:column}:host ::ng-deep .mat-mdc-tab-body-wrapper{flex:1}.tab-content{padding-top:16px;overflow-x:auto}.tab-placeholder{min-height:100px;display:flex;align-items:center;justify-content:center}.tab-label{display:inline-flex;align-items:center;gap:8px}.tab-count{display:inline-flex;align-items:center;justify-content:center;min-width:20px;height:18px;padding:0 6px;border-radius:9px;background-color:#1976d2;color:#fff;font-size:11px;font-weight:600;line-height:1}.tab-count-zero{background-color:#d5d9de;color:#6b7280}.badge{background-color:#2196f3;color:#fff;border-radius:12px;padding:2px 8px;margin-left:8px;font-size:12px}\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: i3$2.MatTabLabel, selector: "[mat-tab-label], [matTabLabel]" }, { kind: "component", type: i3$2.MatTab, selector: "mat-tab", inputs: ["disabled", "label", "aria-label", "aria-labelledby", "labelClass", "bodyClass", "id"], exportAs: ["matTab"] }, { kind: "component", type: i3$2.MatTabGroup, selector: "mat-tab-group", inputs: ["color", "fitInkBarToContent", "mat-stretch-tabs", "mat-align-tabs", "dynamicHeight", "selectedIndex", "headerPosition", "animationDuration", "contentTabIndex", "disablePagination", "disableRipple", "preserveContent", "backgroundColor", "aria-label", "aria-labelledby"], outputs: ["selectedIndexChange", "focusChange", "animationDone", "selectedTabChange"], exportAs: ["matTabGroup"] }, { 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"] }] }); }
21349
22215
  }
21350
22216
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: TabsComponent, decorators: [{
21351
22217
  type: Component,
@@ -22180,10 +23046,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
22180
23046
  }] }] });
22181
23047
 
22182
23048
  class InvitationsTableComponent {
22183
- constructor(dataService, messageService, authService) {
23049
+ constructor(dataService, messageService, authService, apiErrorService) {
22184
23050
  this.dataService = dataService;
22185
23051
  this.messageService = messageService;
22186
23052
  this.authService = authService;
23053
+ this.apiErrorService = apiErrorService;
22187
23054
  this.tableReload = new Subject();
22188
23055
  this.invitationsTableConfig = {
22189
23056
  greyOut: (value) => value.accepted == false,
@@ -22201,7 +23068,7 @@ class InvitationsTableComponent {
22201
23068
  ],
22202
23069
  loadAction: { url: 'tenants/invitations/x' },
22203
23070
  };
22204
- }
23071
+ } // Changed: injected ApiErrorService for the two membership actions
22205
23072
  ngOnInit() {
22206
23073
  }
22207
23074
  invActionClicked(x) {
@@ -22227,21 +23094,27 @@ class InvitationsTableComponent {
22227
23094
  this.messageService.toast("Switched Successfully, please login again");
22228
23095
  this.authService.logoff();
22229
23096
  }
23097
+ else {
23098
+ this.apiErrorService.presentAppFailure(apiResponse, 'action', 'tenants/dto?action=switch'); // Added: was silent. The invitation was already accepted at this point, so silence here left the user believing the switch had happened when they were still in the old organisation
23099
+ }
22230
23100
  });
22231
23101
  }
22232
23102
  });
22233
23103
  }
23104
+ else {
23105
+ this.apiErrorService.presentAppFailure(apiResponse, 'action', 'members/dto?action=' + action); // Added: was silent. Accept or Decline both left the row exactly as it was with no message, so the natural read is "it did not register my click" and the user presses it again
23106
+ }
22234
23107
  });
22235
23108
  }
22236
23109
  });
22237
23110
  }
22238
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: InvitationsTableComponent, deps: [{ token: DataServiceLib }, { token: MessageService }, { token: AuthService }], target: i0.ɵɵFactoryTarget.Component }); }
23111
+ 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 }); }
22239
23112
  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"] }] }); }
22240
23113
  }
22241
23114
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: InvitationsTableComponent, decorators: [{
22242
23115
  type: Component,
22243
23116
  args: [{ selector: 'spa-invitations-table', standalone: false, template: "<spa-table [config]=\"invitationsTableConfig\" (actionClick)=\"invActionClicked($event)\" [reload]=\"tableReload\"></spa-table>\n" }]
22244
- }], ctorParameters: () => [{ type: DataServiceLib }, { type: MessageService }, { type: AuthService }] });
23117
+ }], ctorParameters: () => [{ type: DataServiceLib }, { type: MessageService }, { type: AuthService }, { type: ApiErrorService }] });
22245
23118
 
22246
23119
  // Generic AI-assisted import wizard: Upload -> Map -> Review -> Done. Config-driven via ImportConfig.
22247
23120
  class ImportDialogComponent {
@@ -22268,10 +23141,12 @@ class ImportDialogComponent {
22268
23141
  rebuildReviewRows() {
22269
23142
  this.reviewRows = this.rows.map(r => ({ rowNumber: r.rowNumber, statusName: r.statusName, status: r.status, issueText: this.rowIssueText(r), ...(r.values || {}), __row: r }));
22270
23143
  }
22271
- constructor(httpService, http, messageService, dialogRef, config) {
23144
+ constructor(httpService, http, messageService, apiErrorService, // Changed: injected so a step failure is classified rather than shown verbatim
23145
+ dialogRef, config) {
22272
23146
  this.httpService = httpService;
22273
23147
  this.http = http;
22274
23148
  this.messageService = messageService;
23149
+ this.apiErrorService = apiErrorService;
22275
23150
  this.dialogRef = dialogRef;
22276
23151
  this.config = config;
22277
23152
  this.loading = false;
@@ -22323,6 +23198,10 @@ class ImportDialogComponent {
22323
23198
  // Changed: a Committed (4) OR Reversed (6) batch is finished — reopening starts a NEW import instead of
22324
23199
  // resuming to a stale screen. Reversed was missed when Undo was added, so after undoing an import the
22325
23200
  // wizard reopened onto the undone batch's mapping step.
23201
+ // Deliberately silent: this is the "is there a batch to resume?" probe fired the instant the dialog
23202
+ // opens, before the user has asked for anything. When it fails the wizard falls back to step 0 — a
23203
+ // fully working fresh upload — so there is nothing for the user to do differently and nothing lost.
23204
+ // A dialog here would greet them with an error for a screen that works. (WS-6 Phase 4 triage)
22326
23205
  if (r?.success && r.data?.hasItems && r.data.status !== 4 && r.data.status !== 6) {
22327
23206
  this.applyBatch(r.data);
22328
23207
  this.gotoStepForStatus();
@@ -22348,8 +23227,11 @@ class ImportDialogComponent {
22348
23227
  this.goToStep(1);
22349
23228
  }
22350
23229
  else {
22351
- this.messageService.error(r?.message || 'Upload failed');
23230
+ this.apiErrorService.presentAppFailure(r, 'submit', `import/upload/${this.entity}`); // Changed: was messageService.error(r?.message ...), which put the server's own words in a dialog — "Error processing request", or a leaked SQL fragment when the parse fails. The file the user chose is still selected, which is exactly what the 'submit' copy promises
22352
23231
  }
23232
+ // The transport-error callback below is deliberately left as-is: it shows FIXED text, never server
23233
+ // text, and the underlying HttpErrorResponse already reaches ApiErrorService through the interceptor.
23234
+ // Same decision, same reasoning, as groups.component.ts in WS-6 Phase 3.
22353
23235
  }, () => { this.loading = false; this.messageService.error('Upload failed'); });
22354
23236
  }
22355
23237
  downloadTemplate() {
@@ -22414,7 +23296,7 @@ class ImportDialogComponent {
22414
23296
  this.loadRows(() => this.goToStep(2));
22415
23297
  }
22416
23298
  else {
22417
- this.messageService.error(r?.message || 'Mapping failed');
23299
+ this.apiErrorService.presentAppFailure(r, 'submit', `import/map/${this.batchId}`); // Changed: was messageService.error(r?.message ...). 'submit' is literally true here — the mapping the user built is still on screen and does not have to be redone
22418
23300
  }
22419
23301
  }, () => { this.loading = false; this.messageService.error('Mapping failed'); });
22420
23302
  }
@@ -22432,6 +23314,8 @@ class ImportDialogComponent {
22432
23314
  }
22433
23315
  loadRows(after) {
22434
23316
  this.httpService.Get(`import/rows/${this.batchId}`).subscribe(r => {
23317
+ if (!r?.success)
23318
+ this.apiErrorService.presentAppFailure(r, 'load', `import/rows/${this.batchId}`); // Added: was silent. A failed row fetch fell through to [] and rendered an EMPTY review grid — indistinguishable from a spreadsheet that mapped to nothing, which is the exact "empty or broken?" ambiguity WS-6 exists to kill
22435
23319
  this.rows = (r?.success && r.data) ? r.data : [];
22436
23320
  this.buildReviewTable(); // Changed: (re)build the spa-table config + flattened rows whenever fresh rows land
22437
23321
  if (after)
@@ -22462,18 +23346,29 @@ class ImportDialogComponent {
22462
23346
  this.loading = true;
22463
23347
  this.httpService.Post(`import/row/${row.importRowID}`, values).subscribe(r => {
22464
23348
  this.loading = false;
22465
- this.editingRowId = null;
22466
- this.editingRow = null; // Changed: close the edit panel
22467
23349
  if (r?.success) {
23350
+ this.editingRowId = null; // Changed: only close the edit panel once the save actually succeeded
23351
+ this.editingRow = null;
22468
23352
  this.refreshBatch();
22469
23353
  }
22470
23354
  else {
22471
- this.messageService.error(r?.message || 'Could not update row');
23355
+ // Changed: the panel is no longer closed before this branch. It was closed unconditionally, which
23356
+ // discarded everything the user had typed the moment a save failed — they had to reopen the row and
23357
+ // re-enter it from memory. The panel now stays open with editModel intact; Cancel remains the way out.
23358
+ // Changed: context is now 'submit' rather than 'action'. 'action' was chosen only because the panel
23359
+ // used to close regardless, which made the 'submit' copy ("your details are still on the form") a lie.
23360
+ // That is now literally true, and this is exactly the case that copy exists for — one deliberate save,
23361
+ // with its own dedupe key, where a missed message makes the user press Save again.
23362
+ this.apiErrorService.presentAppFailure(r, 'submit', `import/row/${row.importRowID}`);
22472
23363
  }
22473
23364
  }, () => { this.loading = false; this.messageService.error('Could not update row'); });
22474
23365
  }
22475
23366
  refreshBatch() {
22476
23367
  this.httpService.Get(`import/batch/${this.batchId}`).subscribe(r => {
23368
+ // Deliberately silent: loadRows() below fires against the same backend a moment later and DOES report,
23369
+ // so anything that breaks this call is reported there. Adding a second call site for one outage is the
23370
+ // dialog storm this channel was built to stop. Counts going stale is the only consequence, and the
23371
+ // very next successful refresh corrects them. (WS-6 Phase 4 triage)
22477
23372
  if (r?.success && r.data)
22478
23373
  this.applyBatch(r.data);
22479
23374
  this.loadRows();
@@ -22497,7 +23392,7 @@ class ImportDialogComponent {
22497
23392
  this.config.onComplete(r.data);
22498
23393
  }
22499
23394
  else {
22500
- this.messageService.error(r?.message || 'Import failed');
23395
+ this.apiErrorService.presentAppFailure(r, 'submit', `import/commit/${this.batchId}`); // Changed: was messageService.error(r?.message ...). This is the one press in the whole wizard that writes records, so it is exactly where the 'submit' copy — nothing was saved, do not press repeatedly, it can create duplicates — has to appear
22501
23396
  this.refreshBatch();
22502
23397
  }
22503
23398
  }, () => { this.loading = false; this.messageService.error('Import failed'); });
@@ -22536,7 +23431,7 @@ class ImportDialogComponent {
22536
23431
  this.goToStep(0); // batch is Reversed — start fresh
22537
23432
  }
22538
23433
  else {
22539
- this.messageService.error(r?.message || 'Undo failed');
23434
+ this.apiErrorService.presentAppFailure(r, 'action', `import/reverse/${this.batchId}`); // Changed: was messageService.error(r?.message ...). 'action' rather than 'submit' — this sits behind a confirm, there is no form still holding the user's input, and the 'submit' wording would describe a screen that is not there
22540
23435
  }
22541
23436
  }, () => { this.loading = false; this.messageService.error('Undo failed'); });
22542
23437
  });
@@ -22590,13 +23485,13 @@ class ImportDialogComponent {
22590
23485
  goToStep(index) {
22591
23486
  this.stepIndex = index; // Changed: drives the spa-steps indicator + the *ngIf step panes directly
22592
23487
  }
22593
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: ImportDialogComponent, deps: [{ token: HttpService }, { token: i1.HttpClient }, { token: MessageService }, { token: i4.MatDialogRef }, { token: MAT_DIALOG_DATA }], target: i0.ɵɵFactoryTarget.Component }); }
22594
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: ImportDialogComponent, isStandalone: false, selector: "spa-import-dialog", ngImport: i0, template: "<!-- Changed: shell rebuilt on the details-dialog conventions \u2014 modern band header (tin-dlg-head), scrollable\n mat-dialog-content, banded mat-dialog-actions footer (left-aligned, house rule). The stepper is spa-steps\n as a read-only position indicator driven by stepIndex; navigation is ONLY via the footer buttons. -->\n<div class=\"import-dialog\">\n\n <div class=\"tin-dlg-head import-head\">\n <h2 class=\"tin-dlg-title\">{{ title }}</h2>\n <button mat-icon-button class=\"import-close\" (click)=\"close()\" aria-label=\"Close\"><mat-icon>close</mat-icon></button>\n </div>\n\n <mat-progress-bar *ngIf=\"loading\" mode=\"indeterminate\" class=\"import-load-line\"></mat-progress-bar>\n\n <mat-dialog-content class=\"mat-typography dialog-scroll-content\">\n\n <spa-steps class=\"import-steps\" [config]=\"stepsConfig\" [activeIndex]=\"stepIndex\"></spa-steps>\n\n <!-- Step 1: Upload -->\n <div *ngIf=\"stepIndex === 0\" class=\"step-body\">\n <p class=\"hint\">Upload an Excel (.xlsx) file. Not sure of the format? Download a template with the expected columns.</p>\n <div class=\"upload-row\">\n <button mat-stroked-button color=\"primary\" (click)=\"fileInput.click()\"><mat-icon>attach_file</mat-icon>{{ selectedFile?.name || 'Choose file' }}</button>\n <input type=\"file\" #fileInput accept=\".xlsx\" (change)=\"onFileSelected($event)\" hidden />\n <button mat-stroked-button color=\"primary\" (click)=\"downloadTemplate()\"><mat-icon>download</mat-icon> Download template</button>\n </div>\n </div>\n\n <!-- Step 2: Map columns -->\n <div *ngIf=\"stepIndex === 1\" class=\"step-body\">\n <div *ngIf=\"unmappedRequired.length\" class=\"alert alert-warn\">\n Required fields not yet mapped: <strong>{{ unmappedRequired.join(', ') }}</strong>\n </div>\n\n <table class=\"map-table\">\n <thead>\n <tr><th>Spreadsheet column</th><th>Sample values</th><th>Maps to field</th><th>Match</th></tr>\n </thead>\n <tbody>\n <tr *ngFor=\"let m of mappings\">\n <td class=\"col-header\">{{ m.header }}</td>\n <td class=\"col-samples\">\n <span *ngFor=\"let s of samplesFor(m.header)\" class=\"sample\">{{ s }}</span>\n </td>\n <td>\n <mat-select [(ngModel)]=\"m.property\" (selectionChange)=\"onMappingChange(m)\" placeholder=\"\u2014 Ignore \u2014\">\n <mat-option [value]=\"null\">\u2014 Ignore \u2014</mat-option>\n <mat-option *ngFor=\"let f of availableFields(m.header)\" [value]=\"f.property\">\n {{ f.display }}<span *ngIf=\"f.required\"> *</span>\n </mat-option>\n </mat-select>\n </td>\n <td>\n <span class=\"chip\" [ngClass]=\"confidenceClass(m)\" [matTooltip]=\"m.reasoning || ''\">\n {{ m.property ? (m.method === 'AI' ? ((m.confidence * 100) | number:'1.0-0') + '%' : m.method) : 'Unmapped' }}\n </span>\n </td>\n </tr>\n </tbody>\n </table>\n </div>\n\n <!-- Step 3: Review -->\n <div *ngIf=\"stepIndex === 2\" class=\"step-body\">\n <!-- Changed: row edit panel \u2014 sits above the table because saving re-validates via the import endpoint -->\n <div class=\"edit-panel\" *ngIf=\"editingRow\">\n <div class=\"edit-title\">Edit row {{ editingRow.rowNumber }}</div>\n <div class=\"edit-fields\">\n <label class=\"edit-field\" *ngFor=\"let col of reviewColumns\">\n <span>{{ col.display }}</span>\n <input class=\"cell-input\" [(ngModel)]=\"editModel[col.property]\" />\n </label>\n </div>\n <div class=\"edit-actions\">\n <button mat-flat-button color=\"primary\" [disabled]=\"loading\" (click)=\"saveEdit()\">Save</button>\n <button mat-stroked-button (click)=\"cancelEdit()\">Cancel</button>\n </div>\n </div>\n\n <!-- Changed: the review grid IS spa-table with its INTEGRATED tiles (tileConfig on the TableConfig):\n clickable icon tiles filter the rows in memory \u2014 the \"Only show rows with problems\" checkbox is gone -->\n <spa-table *ngIf=\"reviewTableConfig\" [config]=\"reviewTableConfig\" [data]=\"reviewRows\" [tileData]=\"counts\"></spa-table>\n </div>\n\n <!-- Step 4: Done -->\n <div *ngIf=\"stepIndex === 3\" class=\"step-body done-body\">\n <mat-icon class=\"done-icon\">check_circle</mat-icon>\n <h3>Imported {{ committedCount }} record(s)</h3>\n <p *ngIf=\"counts.warning > 0\" class=\"hint\">{{ counts.warning }} row(s) imported with warnings.</p>\n </div>\n\n </mat-dialog-content>\n\n <!-- Changed: per-step actions moved into the banded footer, LEFT-aligned (house rule) -->\n <mat-dialog-actions>\n <button mat-flat-button color=\"primary\" *ngIf=\"stepIndex === 0\" [disabled]=\"!selectedFile || loading\" (click)=\"doUpload()\">Upload</button>\n <button mat-flat-button color=\"primary\" *ngIf=\"stepIndex === 1\" [disabled]=\"unmappedRequired.length > 0 || loading\" (click)=\"confirmMapping()\">Next</button>\n <button mat-flat-button color=\"primary\" *ngIf=\"stepIndex === 2\" [disabled]=\"counts.error > 0 || loading\" (click)=\"doCommit()\">Import {{ counts.total - counts.error }} record(s)</button>\n <button mat-stroked-button *ngIf=\"stepIndex === 1 || stepIndex === 2\" (click)=\"startOver()\">Start over</button>\n <button mat-flat-button color=\"primary\" *ngIf=\"stepIndex === 3\" (click)=\"close()\">Close</button>\n <button mat-stroked-button *ngIf=\"stepIndex === 3 && reversible && committedCount > 0\" [disabled]=\"loading\" (click)=\"undoImport()\"><mat-icon>undo</mat-icon>Undo import</button> <!-- Added (lifecycle) -->\n <button mat-button *ngIf=\"stepIndex !== 3\" (click)=\"close()\">Close</button>\n </mat-dialog-actions>\n\n</div>\n", styles: [".import-dialog{display:flex;flex-direction:column}.import-head{display:flex;align-items:center}.import-close{margin-left:auto}.import-load-line{height:2px}.import-steps{display:block;margin-bottom:4px}.import-tiles{display:block}.step-body{padding:12px 8px;display:flex;flex-direction:column;gap:14px}.hint{color:#0009;font-size:13px;margin:0}.upload-row{display:flex;align-items:center;gap:16px;flex-wrap:wrap}.alert{padding:8px 12px;border-radius:4px;font-size:13px}.alert-warn{background:#fff3e0;color:#8a5300;border:1px solid #ffcc80}.map-table{width:100%;border-collapse:collapse}.map-table th,.map-table td{text-align:left;padding:6px 8px;border-bottom:1px solid #eee;vertical-align:middle}.map-table th{font-size:12px;color:#0009;font-weight:600}.col-header{font-weight:600}.col-samples .sample{display:inline-block;background:#f2f2f2;border-radius:3px;padding:1px 6px;margin:1px 3px 1px 0;font-size:12px;color:#555}.chip{display:inline-block;padding:2px 8px;border-radius:10px;font-size:12px;color:#fff}.chip-green{background:#2e7d32}.chip-blue{background:#1565c0}.chip-orange{background:#ef6c00}.chip-grey{background:#9e9e9e}.chip-red{background:#c62828}.edit-panel{border:1px solid rgba(0,0,0,.12);border-radius:10px;padding:12px;display:flex;flex-direction:column;gap:10px}.edit-title{font-size:13px;font-weight:600;color:#000000b3}.edit-fields{display:flex;gap:12px;flex-wrap:wrap}.edit-field{display:flex;flex-direction:column;gap:4px;font-size:12px;color:#0009;min-width:160px}.edit-actions{display:flex;gap:8px}.cell-input{box-sizing:border-box;padding:6px 8px;border:1px solid rgba(0,0,0,.23);border-radius:4px;font:inherit}.done-body{align-items:center;text-align:center;padding:32px 8px}.done-icon{color:#2e7d32;font-size:56px;height:56px;width:56px}\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: "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: i2$2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { 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: "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: "component", type: i7$1.MatSelect, selector: "mat-select", inputs: ["aria-describedby", "panelClass", "disabled", "disableRipple", "tabIndex", "hideSingleSelectionIndicator", "placeholder", "required", "multiple", "disableOptionCentering", "compareWith", "value", "aria-label", "aria-labelledby", "errorStateMatcher", "typeaheadDebounceInterval", "sortComparator", "id", "panelWidth", "canSelectNullableOptions"], outputs: ["openedChange", "opened", "closed", "selectionChange", "valueChange"], exportAs: ["matSelect"] }, { kind: "component", type: i7$1.MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "directive", type: i7.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: i5.MatProgressBar, selector: "mat-progress-bar", inputs: ["color", "value", "bufferValue", "mode"], outputs: ["animationEnd"], exportAs: ["matProgressBar"] }, { kind: "component", type: StepsComponent, selector: "spa-steps", inputs: ["value", "config", "data", "activeIndex"] }, { 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: "pipe", type: i1$2.DecimalPipe, name: "number" }] }); }
23488
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: ImportDialogComponent, deps: [{ token: HttpService }, { token: i1.HttpClient }, { token: MessageService }, { token: ApiErrorService }, { token: i4.MatDialogRef }, { token: MAT_DIALOG_DATA }], target: i0.ɵɵFactoryTarget.Component }); }
23489
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: ImportDialogComponent, isStandalone: false, selector: "spa-import-dialog", ngImport: i0, template: "<!-- Changed: shell rebuilt on the details-dialog conventions \u2014 modern band header (tin-dlg-head), scrollable\n mat-dialog-content, banded mat-dialog-actions footer (left-aligned, house rule). The stepper is spa-steps\n as a read-only position indicator driven by stepIndex; navigation is ONLY via the footer buttons. -->\n<div class=\"import-dialog\">\n\n <div class=\"tin-dlg-head import-head\">\n <h2 class=\"tin-dlg-title\">{{ title }}</h2>\n <button mat-icon-button class=\"import-close\" (click)=\"close()\" aria-label=\"Close\"><mat-icon>close</mat-icon></button>\n </div>\n\n <mat-progress-bar *ngIf=\"loading\" mode=\"indeterminate\" class=\"import-load-line\"></mat-progress-bar>\n\n <mat-dialog-content class=\"mat-typography dialog-scroll-content\">\n\n <spa-steps class=\"import-steps\" [config]=\"stepsConfig\" [activeIndex]=\"stepIndex\"></spa-steps>\n\n <!-- Step 1: Upload -->\n <div *ngIf=\"stepIndex === 0\" class=\"step-body\">\n <p class=\"hint\">Upload an Excel (.xlsx) file. Not sure of the format? Download a template with the expected columns.</p>\n <div class=\"upload-row\">\n <button mat-stroked-button color=\"primary\" (click)=\"fileInput.click()\"><mat-icon>attach_file</mat-icon>{{ selectedFile?.name || 'Choose file' }}</button>\n <input type=\"file\" #fileInput accept=\".xlsx\" (change)=\"onFileSelected($event)\" hidden />\n <button mat-stroked-button color=\"primary\" (click)=\"downloadTemplate()\"><mat-icon>download</mat-icon> Download template</button>\n </div>\n </div>\n\n <!-- Step 2: Map columns -->\n <div *ngIf=\"stepIndex === 1\" class=\"step-body\">\n <div *ngIf=\"unmappedRequired.length\" class=\"alert alert-warn\">\n Required fields not yet mapped: <strong>{{ unmappedRequired.join(', ') }}</strong>\n </div>\n\n <table class=\"map-table\">\n <thead>\n <tr><th>Spreadsheet column</th><th>Sample values</th><th>Maps to field</th><th>Match</th></tr>\n </thead>\n <tbody>\n <tr *ngFor=\"let m of mappings\">\n <td class=\"col-header\">{{ m.header }}</td>\n <td class=\"col-samples\">\n <span *ngFor=\"let s of samplesFor(m.header)\" class=\"sample\">{{ s }}</span>\n </td>\n <td>\n <mat-select [(ngModel)]=\"m.property\" (selectionChange)=\"onMappingChange(m)\" placeholder=\"\u2014 Ignore \u2014\">\n <mat-option [value]=\"null\">\u2014 Ignore \u2014</mat-option>\n <mat-option *ngFor=\"let f of availableFields(m.header)\" [value]=\"f.property\">\n {{ f.display }}<span *ngIf=\"f.required\"> *</span>\n </mat-option>\n </mat-select>\n </td>\n <td>\n <span class=\"chip\" [ngClass]=\"confidenceClass(m)\" [matTooltip]=\"m.reasoning || ''\">\n {{ m.property ? (m.method === 'AI' ? ((m.confidence * 100) | number:'1.0-0') + '%' : m.method) : 'Unmapped' }}\n </span>\n </td>\n </tr>\n </tbody>\n </table>\n </div>\n\n <!-- Step 3: Review -->\n <div *ngIf=\"stepIndex === 2\" class=\"step-body\">\n <!-- Changed: row edit panel \u2014 sits above the table because saving re-validates via the import endpoint -->\n <div class=\"edit-panel\" *ngIf=\"editingRow\">\n <div class=\"edit-title\">Edit row {{ editingRow.rowNumber }}</div>\n <div class=\"edit-fields\">\n <label class=\"edit-field\" *ngFor=\"let col of reviewColumns\">\n <span>{{ col.display }}</span>\n <input class=\"cell-input\" [(ngModel)]=\"editModel[col.property]\" />\n </label>\n </div>\n <div class=\"edit-actions\">\n <button mat-flat-button color=\"primary\" [disabled]=\"loading\" (click)=\"saveEdit()\">Save</button>\n <button mat-stroked-button (click)=\"cancelEdit()\">Cancel</button>\n </div>\n </div>\n\n <!-- Changed: the review grid IS spa-table with its INTEGRATED tiles (tileConfig on the TableConfig):\n clickable icon tiles filter the rows in memory \u2014 the \"Only show rows with problems\" checkbox is gone -->\n <spa-table *ngIf=\"reviewTableConfig\" [config]=\"reviewTableConfig\" [data]=\"reviewRows\" [tileData]=\"counts\"></spa-table>\n </div>\n\n <!-- Step 4: Done -->\n <div *ngIf=\"stepIndex === 3\" class=\"step-body done-body\">\n <mat-icon class=\"done-icon\">check_circle</mat-icon>\n <h3>Imported {{ committedCount }} record(s)</h3>\n <p *ngIf=\"counts.warning > 0\" class=\"hint\">{{ counts.warning }} row(s) imported with warnings.</p>\n </div>\n\n </mat-dialog-content>\n\n <!-- Changed: per-step actions moved into the banded footer, LEFT-aligned (house rule) -->\n <mat-dialog-actions>\n <button mat-flat-button color=\"primary\" *ngIf=\"stepIndex === 0\" [disabled]=\"!selectedFile || loading\" (click)=\"doUpload()\">Upload</button>\n <button mat-flat-button color=\"primary\" *ngIf=\"stepIndex === 1\" [disabled]=\"unmappedRequired.length > 0 || loading\" (click)=\"confirmMapping()\">Next</button>\n <button mat-flat-button color=\"primary\" *ngIf=\"stepIndex === 2\" [disabled]=\"counts.error > 0 || loading\" (click)=\"doCommit()\">Import {{ counts.total - counts.error }} record(s)</button>\n <button mat-stroked-button *ngIf=\"stepIndex === 1 || stepIndex === 2\" (click)=\"startOver()\">Start over</button>\n <button mat-flat-button color=\"primary\" *ngIf=\"stepIndex === 3\" (click)=\"close()\">Close</button>\n <button mat-stroked-button *ngIf=\"stepIndex === 3 && reversible && committedCount > 0\" [disabled]=\"loading\" (click)=\"undoImport()\"><mat-icon>undo</mat-icon>Undo import</button> <!-- Added (lifecycle) -->\n <button mat-button *ngIf=\"stepIndex !== 3\" (click)=\"close()\">Close</button>\n </mat-dialog-actions>\n\n</div>\n", styles: [".import-dialog{display:flex;flex-direction:column}.import-head{display:flex;align-items:center}.import-close{margin-left:auto}.import-load-line{height:2px}.import-steps{display:block;margin-bottom:4px}.import-tiles{display:block}.step-body{padding:12px 8px;display:flex;flex-direction:column;gap:14px}.hint{color:#0009;font-size:13px;margin:0}.upload-row{display:flex;align-items:center;gap:16px;flex-wrap:wrap}.alert{padding:8px 12px;border-radius:4px;font-size:13px}.alert-warn{background:#fff3e0;color:#8a5300;border:1px solid #ffcc80}.map-table{width:100%;border-collapse:collapse}.map-table th,.map-table td{text-align:left;padding:6px 8px;border-bottom:1px solid #eee;vertical-align:middle}.map-table th{font-size:12px;color:#0009;font-weight:600}.col-header{font-weight:600}.col-samples .sample{display:inline-block;background:#f2f2f2;border-radius:3px;padding:1px 6px;margin:1px 3px 1px 0;font-size:12px;color:#555}.chip{display:inline-block;padding:2px 8px;border-radius:10px;font-size:12px;color:#fff}.chip-green{background:#2e7d32}.chip-blue{background:#1565c0}.chip-orange{background:#ef6c00}.chip-grey{background:#9e9e9e}.chip-red{background:#c62828}.edit-panel{border:1px solid rgba(0,0,0,.12);border-radius:10px;padding:12px;display:flex;flex-direction:column;gap:10px}.edit-title{font-size:13px;font-weight:600;color:#000000b3}.edit-fields{display:flex;gap:12px;flex-wrap:wrap}.edit-field{display:flex;flex-direction:column;gap:4px;font-size:12px;color:#0009;min-width:160px}.edit-actions{display:flex;gap:8px}.cell-input{box-sizing:border-box;padding:6px 8px;border:1px solid rgba(0,0,0,.23);border-radius:4px;font:inherit}.done-body{align-items:center;text-align:center;padding:32px 8px}.done-icon{color:#2e7d32;font-size:56px;height:56px;width:56px}\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: "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: i2$2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { 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: "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: "component", type: i7$1.MatSelect, selector: "mat-select", inputs: ["aria-describedby", "panelClass", "disabled", "disableRipple", "tabIndex", "hideSingleSelectionIndicator", "placeholder", "required", "multiple", "disableOptionCentering", "compareWith", "value", "aria-label", "aria-labelledby", "errorStateMatcher", "typeaheadDebounceInterval", "sortComparator", "id", "panelWidth", "canSelectNullableOptions"], outputs: ["openedChange", "opened", "closed", "selectionChange", "valueChange"], exportAs: ["matSelect"] }, { kind: "component", type: i7$1.MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "directive", type: i7.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: i6$1.MatProgressBar, selector: "mat-progress-bar", inputs: ["color", "value", "bufferValue", "mode"], outputs: ["animationEnd"], exportAs: ["matProgressBar"] }, { kind: "component", type: StepsComponent, selector: "spa-steps", inputs: ["value", "config", "data", "activeIndex"] }, { 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: "pipe", type: i1$2.DecimalPipe, name: "number" }] }); }
22595
23490
  }
22596
23491
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: ImportDialogComponent, decorators: [{
22597
23492
  type: Component,
22598
23493
  args: [{ selector: 'spa-import-dialog', standalone: false, template: "<!-- Changed: shell rebuilt on the details-dialog conventions \u2014 modern band header (tin-dlg-head), scrollable\n mat-dialog-content, banded mat-dialog-actions footer (left-aligned, house rule). The stepper is spa-steps\n as a read-only position indicator driven by stepIndex; navigation is ONLY via the footer buttons. -->\n<div class=\"import-dialog\">\n\n <div class=\"tin-dlg-head import-head\">\n <h2 class=\"tin-dlg-title\">{{ title }}</h2>\n <button mat-icon-button class=\"import-close\" (click)=\"close()\" aria-label=\"Close\"><mat-icon>close</mat-icon></button>\n </div>\n\n <mat-progress-bar *ngIf=\"loading\" mode=\"indeterminate\" class=\"import-load-line\"></mat-progress-bar>\n\n <mat-dialog-content class=\"mat-typography dialog-scroll-content\">\n\n <spa-steps class=\"import-steps\" [config]=\"stepsConfig\" [activeIndex]=\"stepIndex\"></spa-steps>\n\n <!-- Step 1: Upload -->\n <div *ngIf=\"stepIndex === 0\" class=\"step-body\">\n <p class=\"hint\">Upload an Excel (.xlsx) file. Not sure of the format? Download a template with the expected columns.</p>\n <div class=\"upload-row\">\n <button mat-stroked-button color=\"primary\" (click)=\"fileInput.click()\"><mat-icon>attach_file</mat-icon>{{ selectedFile?.name || 'Choose file' }}</button>\n <input type=\"file\" #fileInput accept=\".xlsx\" (change)=\"onFileSelected($event)\" hidden />\n <button mat-stroked-button color=\"primary\" (click)=\"downloadTemplate()\"><mat-icon>download</mat-icon> Download template</button>\n </div>\n </div>\n\n <!-- Step 2: Map columns -->\n <div *ngIf=\"stepIndex === 1\" class=\"step-body\">\n <div *ngIf=\"unmappedRequired.length\" class=\"alert alert-warn\">\n Required fields not yet mapped: <strong>{{ unmappedRequired.join(', ') }}</strong>\n </div>\n\n <table class=\"map-table\">\n <thead>\n <tr><th>Spreadsheet column</th><th>Sample values</th><th>Maps to field</th><th>Match</th></tr>\n </thead>\n <tbody>\n <tr *ngFor=\"let m of mappings\">\n <td class=\"col-header\">{{ m.header }}</td>\n <td class=\"col-samples\">\n <span *ngFor=\"let s of samplesFor(m.header)\" class=\"sample\">{{ s }}</span>\n </td>\n <td>\n <mat-select [(ngModel)]=\"m.property\" (selectionChange)=\"onMappingChange(m)\" placeholder=\"\u2014 Ignore \u2014\">\n <mat-option [value]=\"null\">\u2014 Ignore \u2014</mat-option>\n <mat-option *ngFor=\"let f of availableFields(m.header)\" [value]=\"f.property\">\n {{ f.display }}<span *ngIf=\"f.required\"> *</span>\n </mat-option>\n </mat-select>\n </td>\n <td>\n <span class=\"chip\" [ngClass]=\"confidenceClass(m)\" [matTooltip]=\"m.reasoning || ''\">\n {{ m.property ? (m.method === 'AI' ? ((m.confidence * 100) | number:'1.0-0') + '%' : m.method) : 'Unmapped' }}\n </span>\n </td>\n </tr>\n </tbody>\n </table>\n </div>\n\n <!-- Step 3: Review -->\n <div *ngIf=\"stepIndex === 2\" class=\"step-body\">\n <!-- Changed: row edit panel \u2014 sits above the table because saving re-validates via the import endpoint -->\n <div class=\"edit-panel\" *ngIf=\"editingRow\">\n <div class=\"edit-title\">Edit row {{ editingRow.rowNumber }}</div>\n <div class=\"edit-fields\">\n <label class=\"edit-field\" *ngFor=\"let col of reviewColumns\">\n <span>{{ col.display }}</span>\n <input class=\"cell-input\" [(ngModel)]=\"editModel[col.property]\" />\n </label>\n </div>\n <div class=\"edit-actions\">\n <button mat-flat-button color=\"primary\" [disabled]=\"loading\" (click)=\"saveEdit()\">Save</button>\n <button mat-stroked-button (click)=\"cancelEdit()\">Cancel</button>\n </div>\n </div>\n\n <!-- Changed: the review grid IS spa-table with its INTEGRATED tiles (tileConfig on the TableConfig):\n clickable icon tiles filter the rows in memory \u2014 the \"Only show rows with problems\" checkbox is gone -->\n <spa-table *ngIf=\"reviewTableConfig\" [config]=\"reviewTableConfig\" [data]=\"reviewRows\" [tileData]=\"counts\"></spa-table>\n </div>\n\n <!-- Step 4: Done -->\n <div *ngIf=\"stepIndex === 3\" class=\"step-body done-body\">\n <mat-icon class=\"done-icon\">check_circle</mat-icon>\n <h3>Imported {{ committedCount }} record(s)</h3>\n <p *ngIf=\"counts.warning > 0\" class=\"hint\">{{ counts.warning }} row(s) imported with warnings.</p>\n </div>\n\n </mat-dialog-content>\n\n <!-- Changed: per-step actions moved into the banded footer, LEFT-aligned (house rule) -->\n <mat-dialog-actions>\n <button mat-flat-button color=\"primary\" *ngIf=\"stepIndex === 0\" [disabled]=\"!selectedFile || loading\" (click)=\"doUpload()\">Upload</button>\n <button mat-flat-button color=\"primary\" *ngIf=\"stepIndex === 1\" [disabled]=\"unmappedRequired.length > 0 || loading\" (click)=\"confirmMapping()\">Next</button>\n <button mat-flat-button color=\"primary\" *ngIf=\"stepIndex === 2\" [disabled]=\"counts.error > 0 || loading\" (click)=\"doCommit()\">Import {{ counts.total - counts.error }} record(s)</button>\n <button mat-stroked-button *ngIf=\"stepIndex === 1 || stepIndex === 2\" (click)=\"startOver()\">Start over</button>\n <button mat-flat-button color=\"primary\" *ngIf=\"stepIndex === 3\" (click)=\"close()\">Close</button>\n <button mat-stroked-button *ngIf=\"stepIndex === 3 && reversible && committedCount > 0\" [disabled]=\"loading\" (click)=\"undoImport()\"><mat-icon>undo</mat-icon>Undo import</button> <!-- Added (lifecycle) -->\n <button mat-button *ngIf=\"stepIndex !== 3\" (click)=\"close()\">Close</button>\n </mat-dialog-actions>\n\n</div>\n", styles: [".import-dialog{display:flex;flex-direction:column}.import-head{display:flex;align-items:center}.import-close{margin-left:auto}.import-load-line{height:2px}.import-steps{display:block;margin-bottom:4px}.import-tiles{display:block}.step-body{padding:12px 8px;display:flex;flex-direction:column;gap:14px}.hint{color:#0009;font-size:13px;margin:0}.upload-row{display:flex;align-items:center;gap:16px;flex-wrap:wrap}.alert{padding:8px 12px;border-radius:4px;font-size:13px}.alert-warn{background:#fff3e0;color:#8a5300;border:1px solid #ffcc80}.map-table{width:100%;border-collapse:collapse}.map-table th,.map-table td{text-align:left;padding:6px 8px;border-bottom:1px solid #eee;vertical-align:middle}.map-table th{font-size:12px;color:#0009;font-weight:600}.col-header{font-weight:600}.col-samples .sample{display:inline-block;background:#f2f2f2;border-radius:3px;padding:1px 6px;margin:1px 3px 1px 0;font-size:12px;color:#555}.chip{display:inline-block;padding:2px 8px;border-radius:10px;font-size:12px;color:#fff}.chip-green{background:#2e7d32}.chip-blue{background:#1565c0}.chip-orange{background:#ef6c00}.chip-grey{background:#9e9e9e}.chip-red{background:#c62828}.edit-panel{border:1px solid rgba(0,0,0,.12);border-radius:10px;padding:12px;display:flex;flex-direction:column;gap:10px}.edit-title{font-size:13px;font-weight:600;color:#000000b3}.edit-fields{display:flex;gap:12px;flex-wrap:wrap}.edit-field{display:flex;flex-direction:column;gap:4px;font-size:12px;color:#0009;min-width:160px}.edit-actions{display:flex;gap:8px}.cell-input{box-sizing:border-box;padding:6px 8px;border:1px solid rgba(0,0,0,.23);border-radius:4px;font:inherit}.done-body{align-items:center;text-align:center;padding:32px 8px}.done-icon{color:#2e7d32;font-size:56px;height:56px;width:56px}\n"] }]
22599
- }], ctorParameters: () => [{ type: HttpService }, { type: i1.HttpClient }, { type: MessageService }, { type: i4.MatDialogRef }, { type: undefined, decorators: [{
23494
+ }], ctorParameters: () => [{ type: HttpService }, { type: i1.HttpClient }, { type: MessageService }, { type: ApiErrorService }, { type: i4.MatDialogRef }, { type: undefined, decorators: [{
22600
23495
  type: Inject,
22601
23496
  args: [MAT_DIALOG_DATA]
22602
23497
  }] }] });
@@ -24503,6 +25398,7 @@ class UsersComponent {
24503
25398
  this.dataService = inject(DataServiceLib);
24504
25399
  this.dialog = inject(MatDialog);
24505
25400
  this.messageService = inject(MessageService);
25401
+ this.apiErrorService = inject(ApiErrorService); // Added: classifies the AD directory lookup failure
24506
25402
  this.userBaseFormConfig = {
24507
25403
  security: { allow: [this.dataService.capUsers] }, // Added: gate user form by users cap
24508
25404
  fields: [
@@ -24545,7 +25441,12 @@ class UsersComponent {
24545
25441
  this.messageService.toast('AD user found');
24546
25442
  }
24547
25443
  else {
24548
- this.messageService.toast(response.message || 'AD user not found');
25444
+ // Changed: was toast(response.message || 'AD user not found'), which echoed whatever the
25445
+ // directory lookup returned. CONVERTED rather than left silent because the user is watching
25446
+ // three fields that were supposed to auto-fill and did not. Behaviour is unchanged for the
25447
+ // common case — "AD user not found" is a human sentence, so classifyMessage keeps it a
25448
+ // toast — while an LDAP or connection dump now gets replaced instead of displayed.
25449
+ this.apiErrorService.presentAppFailure(response, 'load', `user/ad/${data.userName}`);
24549
25450
  }
24550
25451
  });
24551
25452
  }
@@ -25320,6 +26221,9 @@ class AgingComponent {
25320
26221
  if (apiResponse.success) {
25321
26222
  this.summaryData = apiResponse.data;
25322
26223
  }
26224
+ else {
26225
+ this.apiErrorService.presentAppFailure(apiResponse, 'load', 'invoices/aging-summary/x'); // Added: was silent. summaryData stayed undefined, the *ngIf dropped the whole tile strip, and an accounts-receivable page with NO aging totals reads as "you are owed nothing" rather than "we could not work it out"
26226
+ }
25323
26227
  });
25324
26228
  }
25325
26229
  // Changed: Post IFRS 9 bad-debt provision — confirms, calls invoices provision custom action, shows returned summary
@@ -25327,9 +26231,17 @@ class AgingComponent {
25327
26231
  this.messageService.confirm('Post the bad-debt provision? This posts the allowance delta per the aging matrix as a GL journal.').subscribe((result) => {
25328
26232
  if (result == 'yes') {
25329
26233
  this.dataServiceLib.CallApi({ url: 'invoices?action=provision', method: 'post' }, {}).subscribe((apiResponse) => {
25330
- this.messageService.toast(apiResponse.success ? apiResponse.message : 'Error: ' + apiResponse.message);
25331
- if (apiResponse.success)
26234
+ // Changed: REPLACES the old `'Error: ' + apiResponse.message` toast rather than layering on it.
26235
+ // That string prefixed the server's raw words with a second word for "error" and pushed the useful
26236
+ // part right; a leaked SQL fragment reached the user unchanged. 'action' rather than 'submit' —
26237
+ // this is a confirmed page button, not a form, so the 'submit' copy would describe a screen the
26238
+ // user is not looking at.
26239
+ if (apiResponse.success) {
26240
+ this.messageService.toast(apiResponse.message);
25332
26241
  this.loadSummaryData();
26242
+ }
26243
+ else
26244
+ this.apiErrorService.presentAppFailure(apiResponse, 'action', 'invoices?action=provision');
25333
26245
  });
25334
26246
  }
25335
26247
  });
@@ -25338,6 +26250,7 @@ class AgingComponent {
25338
26250
  this.dataServiceLib = dataServiceLib;
25339
26251
  this.accountingService = inject(AccountingService);
25340
26252
  this.messageService = inject(MessageService); // Changed: Injected for provision confirm/toast
26253
+ this.apiErrorService = inject(ApiErrorService); // Changed: Injected for the summary load and the provision failure
25341
26254
  this.tileConfig = this.accountingService.agingTileConfig;
25342
26255
  // this.tileConfig = this.accountingService.agingTileConfig;
25343
26256
  this.agingTableConfigs = [
@@ -25419,11 +26332,15 @@ class SupplierAgingComponent {
25419
26332
  if (apiResponse.success) {
25420
26333
  this.summaryData = apiResponse.data;
25421
26334
  }
26335
+ else {
26336
+ this.apiErrorService.presentAppFailure(apiResponse, 'load', 'purchases/aging-summary/x'); // Added: was silent. The tile strip vanished behind its *ngIf, so an accounts-payable page that could not compute its totals looked like a company that owes nothing
26337
+ }
25422
26338
  });
25423
26339
  }
25424
26340
  constructor(dataServiceLib) {
25425
26341
  this.dataServiceLib = dataServiceLib;
25426
26342
  this.purchasingService = inject(PurchasingService);
26343
+ this.apiErrorService = inject(ApiErrorService); // Changed: Injected so a failed summary load says so
25427
26344
  this.tileConfig = this.purchasingService.apAgingTileConfig;
25428
26345
  // Changed: Build 5-tab aging table layout using AP aging base config from PurchasingService
25429
26346
  this.agingTableConfigs = [
@@ -25516,6 +26433,7 @@ class JournalEntryDialogComponent {
25516
26433
  this.httpService = inject(HttpService);
25517
26434
  this.http = inject(HttpClient);
25518
26435
  this.messageService = inject(MessageService);
26436
+ this.apiErrorService = inject(ApiErrorService); // Changed: injected so a rejected post is classified rather than echoed
25519
26437
  this.date = new Date().toISOString().substring(0, 10);
25520
26438
  this.description = '';
25521
26439
  this.journalType = 'Journal Entry'; // 'Opening Balance' posts the C2 take-on journal
@@ -25563,7 +26481,11 @@ class JournalEntryDialogComponent {
25563
26481
  this.dialogRef.close(true);
25564
26482
  }
25565
26483
  else {
25566
- this.messageService.toast(res?.message ?? "Failed to post journal");
26484
+ // Changed: was toast(res?.message ?? "Failed to post journal") — a 5-second snackbar at the bottom
26485
+ // of the screen carrying raw server text, while this dialog stayed open with every line still
26486
+ // filled in. That is the duplicate-record shape exactly: the obvious next move is to press Post
26487
+ // again. 'submit' because the entry IS still on the form and genuinely does not need re-typing.
26488
+ this.apiErrorService.presentAppFailure(res, 'submit', 'transactions/dto?action=create');
25567
26489
  }
25568
26490
  });
25569
26491
  }
@@ -25815,7 +26737,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
25815
26737
  }]
25816
26738
  }] });
25817
26739
 
25818
- // Statement page (B4) — invoice history plus open-item customer/supplier statements with Excel export
26740
+ // Statement page (B4) — invoice history plus open-item customer/supplier statements with Excel/PDF export
26741
+ // Changed: the two open-item tabs were hand-written markup (a flex row of totals and a raw <table>). They are
26742
+ // now the library's own spa-tiles + spa-table, driven by config in AccountingService, so the page picks up
26743
+ // every table behaviour centrally: responsive minColumns, the filter box, money formatting and empty states.
26744
+ // The PDF and Excel downloads are untouched — both are produced SERVER-SIDE and nothing here contributes to them.
25819
26745
  class StatementComponent {
25820
26746
  constructor() {
25821
26747
  this.accountingService = inject(AccountingService);
@@ -25826,20 +26752,27 @@ class StatementComponent {
25826
26752
  tableConfig: this.accountingService.statementTableConfig,
25827
26753
  searchTableConfig: this.accountingService.statementSearchTableConfig
25828
26754
  };
25829
- // Per-kind UI state for the customer/supplier open-item tabs
26755
+ // Per-kind UI state for the customer/supplier open-item tabs.
26756
+ // Changed: each kind carries its OWN cloned table config — two grids are on screen at once and a TableConfig
26757
+ // is mutated by the table it belongs to, so sharing one object between the tabs would cross their state.
25830
26758
  this.state = {
25831
- customer: { entityId: null, from: '', to: '', options: [], data: null },
25832
- supplier: { entityId: null, from: '', to: '', options: [], data: null }
26759
+ customer: {
26760
+ entityLabel: 'Customer', entityId: null, from: null, to: null, data: null, lines: [],
26761
+ listAction: { url: 'customers/list/x' },
26762
+ tileConfig: this.accountingService.customerStatementTileConfig,
26763
+ tableConfig: { ...this.accountingService.statementLinesTableConfig }
26764
+ },
26765
+ supplier: {
26766
+ entityLabel: 'Supplier', entityId: null, from: null, to: null, data: null, lines: [],
26767
+ listAction: { url: 'suppliers/list/x' },
26768
+ tileConfig: this.accountingService.supplierStatementTileConfig,
26769
+ tableConfig: { ...this.accountingService.statementLinesTableConfig }
26770
+ }
25833
26771
  };
25834
26772
  }
25835
- ngOnInit() {
25836
- this.loadOptions('customer', 'customers/list/x');
25837
- this.loadOptions('supplier', 'suppliers/list/x');
25838
- }
25839
- loadOptions(kind, url) {
25840
- this.http.get(`${this.httpService.apiUrl}${url}`)
25841
- .subscribe(res => this.state[kind].options = res?.data ?? res?.Data ?? []);
25842
- }
26773
+ // Changed: the customer/supplier option lists are loaded by spa-select-lite's own loadAction now, so there is
26774
+ // no hand-rolled options fetch left here. ngOnInit is kept for the interface and for future per-kind defaults.
26775
+ ngOnInit() { }
25843
26776
  query(kind) {
25844
26777
  const s = this.state[kind];
25845
26778
  const params = [];
@@ -25851,10 +26784,16 @@ class StatementComponent {
25851
26784
  }
25852
26785
  load(kind) {
25853
26786
  const s = this.state[kind];
26787
+ if (!s.entityId)
26788
+ return; // Added: the header refresh icon routes here too, and it must not fire before an entity is chosen
25854
26789
  this.http.get(`${this.httpService.apiUrl}reports/${kind}-statement/${s.entityId}${this.query(kind)}`)
25855
- .subscribe(res => s.data = res?.data ?? res?.Data ?? null);
26790
+ .subscribe(res => {
26791
+ s.data = res?.data ?? res?.Data ?? null;
26792
+ s.lines = s.data?.lines ?? []; // Changed: held as a stable reference — binding `data.lines || []` inline would hand spa-table a new array on every change-detection pass
26793
+ });
25856
26794
  }
25857
- // C8: open-item customer statement as PDF
26795
+ // C8: open-item customer statement as PDF — generated entirely server-side (reports/pdf/customer-statement);
26796
+ // this method only asks for the blob and saves it. Unchanged by the page rewrite.
25858
26797
  exportPdf(kind) {
25859
26798
  const s = this.state[kind];
25860
26799
  this.http.get(`${this.httpService.apiUrl}reports/pdf/${kind}-statement${this.query(kind) ? this.query(kind) + '&' : '?'}${kind}Id=${s.entityId}`, { responseType: 'blob' })
@@ -25899,21 +26838,12 @@ class StatementComponent {
25899
26838
 
25900
26839
  <ng-template #statementTab let-kind="kind">
25901
26840
  <div style="padding: 16px 0;">
25902
- <div style="display: flex; gap: 16px; align-items: center; flex-wrap: wrap;">
25903
- <mat-form-field style="min-width: 240px;">
25904
- <mat-label>{{ kind === 'customer' ? 'Customer' : 'Supplier' }}</mat-label>
25905
- <mat-select [(ngModel)]="state[kind].entityId">
25906
- <mat-option *ngFor="let option of state[kind].options" [value]="option.value">{{ option.name }}</mat-option>
25907
- </mat-select>
25908
- </mat-form-field>
25909
- <mat-form-field>
25910
- <mat-label>From</mat-label>
25911
- <input matInput type="date" [(ngModel)]="state[kind].from">
25912
- </mat-form-field>
25913
- <mat-form-field>
25914
- <mat-label>To</mat-label>
25915
- <input matInput type="date" [(ngModel)]="state[kind].to">
25916
- </mat-form-field>
26841
+
26842
+ <!-- Changed: library inputs instead of raw mat-form-field markup — same three filters, same Run/Export/PDF rules -->
26843
+ <div style="display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 12px;">
26844
+ <spa-select-lite [display]="state[kind].entityLabel" [loadAction]="state[kind].listAction" optionDisplay="name" optionValue="value" [required]="false" [(value)]="state[kind].entityId" width="240px"></spa-select-lite><!-- optionDisplay/optionValue are NOT defaulted by spa-select-common; without them every option binds undefined and nothing can be selected -->
26845
+ <spa-date display="From" width="170px" [required]="false" [value]="state[kind].from" (valueChange)="state[kind].from = $event"></spa-date>
26846
+ <spa-date display="To" width="170px" [required]="false" [value]="state[kind].to" (valueChange)="state[kind].to = $event"></spa-date>
25917
26847
  <button mat-flat-button color="primary" (click)="load(kind)" [disabled]="!state[kind].entityId">Run</button>
25918
26848
  <button mat-stroked-button (click)="export(kind)" [disabled]="!state[kind].entityId">
25919
26849
  <mat-icon>download</mat-icon> Export to Excel
@@ -25923,41 +26853,18 @@ class StatementComponent {
25923
26853
  </button>
25924
26854
  </div>
25925
26855
 
25926
- <mat-card *ngIf="state[kind].data" style="padding: 16px;">
25927
- <div style="display: flex; gap: 32px; flex-wrap: wrap; margin-bottom: 16px;">
25928
- <div><small>{{ kind === 'customer' ? 'Total Invoiced' : 'Total Purchased' }}</small><br><b>{{ (state[kind].data.totalInvoiced ?? state[kind].data.totalPurchased) | number:'1.2-2' }}</b></div>
25929
- <div><small>Total Paid</small><br><b>{{ state[kind].data.totalPaid | number:'1.2-2' }}</b></div>
25930
- <div *ngIf="kind === 'customer'"><small>Total Credited</small><br><b>{{ state[kind].data.totalCredited | number:'1.2-2' }}</b></div>
25931
- <div><small>Total Outstanding</small><br><b [style.color]="state[kind].data.totalOutstanding > 0 ? 'red' : 'green'">{{ state[kind].data.totalOutstanding | number:'1.2-2' }}</b></div>
26856
+ <!-- Changed: totals are spa-tiles, lines are spa-table. Nothing renders until a statement has been run,
26857
+ which is exactly what the old *ngIf on the results card did. -->
26858
+ <ng-container *ngIf="state[kind].data">
26859
+ <spa-tiles [config]="state[kind].tileConfig" [data]="state[kind].data"></spa-tiles>
26860
+ <div style="margin-top: 12px;">
26861
+ <spa-table [config]="state[kind].tableConfig" [data]="state[kind].lines" (refreshClick)="load(kind)"></spa-table>
25932
26862
  </div>
26863
+ </ng-container>
25933
26864
 
25934
- <table style="width: 100%; border-collapse: collapse;" *ngIf="state[kind].data.lines?.length; else noLines">
25935
- <tr style="text-align: left; border-bottom: 2px solid #333;">
25936
- <th style="padding: 6px;">Date</th>
25937
- <th style="padding: 6px;">Reference</th>
25938
- <th style="padding: 6px;">Type</th>
25939
- <th style="padding: 6px; text-align: right;">Total</th>
25940
- <th style="padding: 6px; text-align: right;">Paid</th>
25941
- <th style="padding: 6px; text-align: right;">Outstanding</th>
25942
- <th style="padding: 6px;">Status</th>
25943
- <th style="padding: 6px;">Aging</th>
25944
- </tr>
25945
- <tr *ngFor="let line of state[kind].data.lines" style="border-bottom: 1px solid #eee;">
25946
- <td style="padding: 6px;">{{ line.date | date:'mediumDate' }}</td>
25947
- <td style="padding: 6px;">{{ line.reference }}</td>
25948
- <td style="padding: 6px;">{{ line.type }}</td>
25949
- <td style="padding: 6px; text-align: right;">{{ line.total | number:'1.2-2' }}</td>
25950
- <td style="padding: 6px; text-align: right;">{{ line.paid | number:'1.2-2' }}</td>
25951
- <td style="padding: 6px; text-align: right;">{{ line.outstanding | number:'1.2-2' }}</td>
25952
- <td style="padding: 6px;">{{ line.status }}</td>
25953
- <td style="padding: 6px;">{{ line.agingBucket }}</td>
25954
- </tr>
25955
- </table>
25956
- <ng-template #noLines><p><em>No open items for the selected period</em></p></ng-template>
25957
- </mat-card>
25958
26865
  </div>
25959
26866
  </ng-template>
25960
- `, isInline: true, 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.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: i2$2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { 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: i5$4.MatTab, selector: "mat-tab", inputs: ["disabled", "label", "aria-label", "aria-labelledby", "labelClass", "bodyClass", "id"], exportAs: ["matTab"] }, { kind: "component", type: i5$4.MatTabGroup, selector: "mat-tab-group", inputs: ["color", "fitInkBarToContent", "mat-stretch-tabs", "mat-align-tabs", "dynamicHeight", "selectedIndex", "headerPosition", "animationDuration", "contentTabIndex", "disablePagination", "disableRipple", "preserveContent", "backgroundColor", "aria-label", "aria-labelledby"], outputs: ["selectedIndexChange", "focusChange", "animationDone", "selectedTabChange"], exportAs: ["matTabGroup"] }, { kind: "component", type: i3$1.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i3$1.MatLabel, selector: "mat-label" }, { kind: "directive", type: i4$2.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "component", type: i19.MatCard, selector: "mat-card", inputs: ["appearance"], exportAs: ["matCard"] }, { kind: "component", type: i7$1.MatSelect, selector: "mat-select", inputs: ["aria-describedby", "panelClass", "disabled", "disableRipple", "tabIndex", "hideSingleSelectionIndicator", "placeholder", "required", "multiple", "disableOptionCentering", "compareWith", "value", "aria-label", "aria-labelledby", "errorStateMatcher", "typeaheadDebounceInterval", "sortComparator", "id", "panelWidth", "canSelectNullableOptions"], outputs: ["openedChange", "opened", "closed", "selectionChange", "valueChange"], exportAs: ["matSelect"] }, { kind: "component", type: i7$1.MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "component", type: PageComponent, selector: "spa-page", inputs: ["config"], outputs: ["searchModeActivated", "searchModeDeactivated", "refreshClick", "actionClick", "actionResponse", "inputChange", "createClick", "searchClick", "dataLoad", "titleActionChange"] }, { kind: "pipe", type: i1$2.DecimalPipe, name: "number" }, { kind: "pipe", type: i1$2.DatePipe, name: "date" }] }); }
26867
+ `, isInline: true, dependencies: [{ 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: "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: i3$2.MatTab, selector: "mat-tab", inputs: ["disabled", "label", "aria-label", "aria-labelledby", "labelClass", "bodyClass", "id"], exportAs: ["matTab"] }, { kind: "component", type: i3$2.MatTabGroup, selector: "mat-tab-group", inputs: ["color", "fitInkBarToContent", "mat-stretch-tabs", "mat-align-tabs", "dynamicHeight", "selectedIndex", "headerPosition", "animationDuration", "contentTabIndex", "disablePagination", "disableRipple", "preserveContent", "backgroundColor", "aria-label", "aria-labelledby"], outputs: ["selectedIndexChange", "focusChange", "animationDone", "selectedTabChange"], exportAs: ["matTabGroup"] }, { kind: "component", type: TilesComponent, selector: "spa-tiles", inputs: ["config", "lastSearch", "data", "reload"], outputs: ["tileActionSelected", "tileClick", "tileUnClick"] }, { kind: "component", type: DateComponent, selector: "spa-date", inputs: ["required", "min", "max", "readonly", "hint", "value", "display", "placeholder", "width", "suffix", "infoMessage", "copyContent", "clearContent"], outputs: ["valueChange"] }, { 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: PageComponent, selector: "spa-page", inputs: ["config"], outputs: ["searchModeActivated", "searchModeDeactivated", "refreshClick", "actionClick", "actionResponse", "inputChange", "createClick", "searchClick", "dataLoad", "titleActionChange"] }, { kind: "component", type: SelectLiteComponent, selector: "spa-select-lite" }] }); }
25961
26868
  }
25962
26869
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: StatementComponent, decorators: [{
25963
26870
  type: Component,
@@ -25982,21 +26889,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
25982
26889
 
25983
26890
  <ng-template #statementTab let-kind="kind">
25984
26891
  <div style="padding: 16px 0;">
25985
- <div style="display: flex; gap: 16px; align-items: center; flex-wrap: wrap;">
25986
- <mat-form-field style="min-width: 240px;">
25987
- <mat-label>{{ kind === 'customer' ? 'Customer' : 'Supplier' }}</mat-label>
25988
- <mat-select [(ngModel)]="state[kind].entityId">
25989
- <mat-option *ngFor="let option of state[kind].options" [value]="option.value">{{ option.name }}</mat-option>
25990
- </mat-select>
25991
- </mat-form-field>
25992
- <mat-form-field>
25993
- <mat-label>From</mat-label>
25994
- <input matInput type="date" [(ngModel)]="state[kind].from">
25995
- </mat-form-field>
25996
- <mat-form-field>
25997
- <mat-label>To</mat-label>
25998
- <input matInput type="date" [(ngModel)]="state[kind].to">
25999
- </mat-form-field>
26892
+
26893
+ <!-- Changed: library inputs instead of raw mat-form-field markup — same three filters, same Run/Export/PDF rules -->
26894
+ <div style="display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 12px;">
26895
+ <spa-select-lite [display]="state[kind].entityLabel" [loadAction]="state[kind].listAction" optionDisplay="name" optionValue="value" [required]="false" [(value)]="state[kind].entityId" width="240px"></spa-select-lite><!-- optionDisplay/optionValue are NOT defaulted by spa-select-common; without them every option binds undefined and nothing can be selected -->
26896
+ <spa-date display="From" width="170px" [required]="false" [value]="state[kind].from" (valueChange)="state[kind].from = $event"></spa-date>
26897
+ <spa-date display="To" width="170px" [required]="false" [value]="state[kind].to" (valueChange)="state[kind].to = $event"></spa-date>
26000
26898
  <button mat-flat-button color="primary" (click)="load(kind)" [disabled]="!state[kind].entityId">Run</button>
26001
26899
  <button mat-stroked-button (click)="export(kind)" [disabled]="!state[kind].entityId">
26002
26900
  <mat-icon>download</mat-icon> Export to Excel
@@ -26006,38 +26904,15 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
26006
26904
  </button>
26007
26905
  </div>
26008
26906
 
26009
- <mat-card *ngIf="state[kind].data" style="padding: 16px;">
26010
- <div style="display: flex; gap: 32px; flex-wrap: wrap; margin-bottom: 16px;">
26011
- <div><small>{{ kind === 'customer' ? 'Total Invoiced' : 'Total Purchased' }}</small><br><b>{{ (state[kind].data.totalInvoiced ?? state[kind].data.totalPurchased) | number:'1.2-2' }}</b></div>
26012
- <div><small>Total Paid</small><br><b>{{ state[kind].data.totalPaid | number:'1.2-2' }}</b></div>
26013
- <div *ngIf="kind === 'customer'"><small>Total Credited</small><br><b>{{ state[kind].data.totalCredited | number:'1.2-2' }}</b></div>
26014
- <div><small>Total Outstanding</small><br><b [style.color]="state[kind].data.totalOutstanding > 0 ? 'red' : 'green'">{{ state[kind].data.totalOutstanding | number:'1.2-2' }}</b></div>
26907
+ <!-- Changed: totals are spa-tiles, lines are spa-table. Nothing renders until a statement has been run,
26908
+ which is exactly what the old *ngIf on the results card did. -->
26909
+ <ng-container *ngIf="state[kind].data">
26910
+ <spa-tiles [config]="state[kind].tileConfig" [data]="state[kind].data"></spa-tiles>
26911
+ <div style="margin-top: 12px;">
26912
+ <spa-table [config]="state[kind].tableConfig" [data]="state[kind].lines" (refreshClick)="load(kind)"></spa-table>
26015
26913
  </div>
26914
+ </ng-container>
26016
26915
 
26017
- <table style="width: 100%; border-collapse: collapse;" *ngIf="state[kind].data.lines?.length; else noLines">
26018
- <tr style="text-align: left; border-bottom: 2px solid #333;">
26019
- <th style="padding: 6px;">Date</th>
26020
- <th style="padding: 6px;">Reference</th>
26021
- <th style="padding: 6px;">Type</th>
26022
- <th style="padding: 6px; text-align: right;">Total</th>
26023
- <th style="padding: 6px; text-align: right;">Paid</th>
26024
- <th style="padding: 6px; text-align: right;">Outstanding</th>
26025
- <th style="padding: 6px;">Status</th>
26026
- <th style="padding: 6px;">Aging</th>
26027
- </tr>
26028
- <tr *ngFor="let line of state[kind].data.lines" style="border-bottom: 1px solid #eee;">
26029
- <td style="padding: 6px;">{{ line.date | date:'mediumDate' }}</td>
26030
- <td style="padding: 6px;">{{ line.reference }}</td>
26031
- <td style="padding: 6px;">{{ line.type }}</td>
26032
- <td style="padding: 6px; text-align: right;">{{ line.total | number:'1.2-2' }}</td>
26033
- <td style="padding: 6px; text-align: right;">{{ line.paid | number:'1.2-2' }}</td>
26034
- <td style="padding: 6px; text-align: right;">{{ line.outstanding | number:'1.2-2' }}</td>
26035
- <td style="padding: 6px;">{{ line.status }}</td>
26036
- <td style="padding: 6px;">{{ line.agingBucket }}</td>
26037
- </tr>
26038
- </table>
26039
- <ng-template #noLines><p><em>No open items for the selected period</em></p></ng-template>
26040
- </mat-card>
26041
26916
  </div>
26042
26917
  </ng-template>
26043
26918
  `,
@@ -26316,7 +27191,7 @@ class BudgetVsActualComponent {
26316
27191
  <hr>
26317
27192
 
26318
27193
  <div style="max-width: 300px; margin-bottom: 16px;">
26319
- <spa-select-lite display="Select Budget" [loadAction]="{ url: 'budgets/list/x' }" [(value)]="selectedBudgetID" (valueChange)="onBudgetSelected()"></spa-select-lite>
27194
+ <spa-select-lite display="Select Budget" [loadAction]="{ url: 'budgets/list/x' }" optionDisplay="name" optionValue="value" [(value)]="selectedBudgetID" (valueChange)="onBudgetSelected()"></spa-select-lite><!-- Changed: optionDisplay/optionValue are NOT defaulted by spa-select-common (both default to ""), so every option rendered blank and bound undefined — no budget could be picked. 'name'/'value' match BaseController.GetList's ListOption shape, same as the statement page -->
26320
27195
  </div>
26321
27196
 
26322
27197
  <spa-tiles *ngIf="summaryTileConfig.loadAction" [config]="summaryTileConfig"></spa-tiles>
@@ -26333,7 +27208,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
26333
27208
  <hr>
26334
27209
 
26335
27210
  <div style="max-width: 300px; margin-bottom: 16px;">
26336
- <spa-select-lite display="Select Budget" [loadAction]="{ url: 'budgets/list/x' }" [(value)]="selectedBudgetID" (valueChange)="onBudgetSelected()"></spa-select-lite>
27211
+ <spa-select-lite display="Select Budget" [loadAction]="{ url: 'budgets/list/x' }" optionDisplay="name" optionValue="value" [(value)]="selectedBudgetID" (valueChange)="onBudgetSelected()"></spa-select-lite><!-- Changed: optionDisplay/optionValue are NOT defaulted by spa-select-common (both default to ""), so every option rendered blank and bound undefined — no budget could be picked. 'name'/'value' match BaseController.GetList's ListOption shape, same as the statement page -->
26337
27212
  </div>
26338
27213
 
26339
27214
  <spa-tiles *ngIf="summaryTileConfig.loadAction" [config]="summaryTileConfig"></spa-tiles>
@@ -26717,7 +27592,7 @@ class VatReturnComponent {
26717
27592
  </mat-card>
26718
27593
  </mat-tab>
26719
27594
  </mat-tab-group>
26720
- `, isInline: true, 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: 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: i2$2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { 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: i5$4.MatTab, selector: "mat-tab", inputs: ["disabled", "label", "aria-label", "aria-labelledby", "labelClass", "bodyClass", "id"], exportAs: ["matTab"] }, { kind: "component", type: i5$4.MatTabGroup, selector: "mat-tab-group", inputs: ["color", "fitInkBarToContent", "mat-stretch-tabs", "mat-align-tabs", "dynamicHeight", "selectedIndex", "headerPosition", "animationDuration", "contentTabIndex", "disablePagination", "disableRipple", "preserveContent", "backgroundColor", "aria-label", "aria-labelledby"], outputs: ["selectedIndexChange", "focusChange", "animationDone", "selectedTabChange"], exportAs: ["matTabGroup"] }, { kind: "component", type: i3$1.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i3$1.MatLabel, selector: "mat-label" }, { kind: "directive", type: i4$2.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "component", type: i19.MatCard, selector: "mat-card", inputs: ["appearance"], exportAs: ["matCard"] }, { kind: "pipe", type: i1$2.DecimalPipe, name: "number" }, { kind: "pipe", type: i1$2.DatePipe, name: "date" }] }); }
27595
+ `, isInline: true, 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: 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: i2$2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { 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: i3$2.MatTab, selector: "mat-tab", inputs: ["disabled", "label", "aria-label", "aria-labelledby", "labelClass", "bodyClass", "id"], exportAs: ["matTab"] }, { kind: "component", type: i3$2.MatTabGroup, selector: "mat-tab-group", inputs: ["color", "fitInkBarToContent", "mat-stretch-tabs", "mat-align-tabs", "dynamicHeight", "selectedIndex", "headerPosition", "animationDuration", "contentTabIndex", "disablePagination", "disableRipple", "preserveContent", "backgroundColor", "aria-label", "aria-labelledby"], outputs: ["selectedIndexChange", "focusChange", "animationDone", "selectedTabChange"], exportAs: ["matTabGroup"] }, { kind: "component", type: i3$1.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i3$1.MatLabel, selector: "mat-label" }, { kind: "directive", type: i4$2.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "component", type: i19.MatCard, selector: "mat-card", inputs: ["appearance"], exportAs: ["matCard"] }, { kind: "pipe", type: i1$2.DecimalPipe, name: "number" }, { kind: "pipe", type: i1$2.DatePipe, name: "date" }] }); }
26721
27596
  }
26722
27597
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: VatReturnComponent, decorators: [{
26723
27598
  type: Component,
@@ -28324,9 +29199,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
28324
29199
  }], ctorParameters: () => [] });
28325
29200
 
28326
29201
  class OnboardingComponent {
28327
- constructor(dataService, authService) {
29202
+ constructor(dataService, authService, apiErrorService) {
28328
29203
  this.dataService = dataService;
28329
29204
  this.authService = authService;
29205
+ this.apiErrorService = apiErrorService;
28330
29206
  this.step = "terms";
28331
29207
  this.agree = false;
28332
29208
  this.own = true;
@@ -28346,7 +29222,7 @@ class OnboardingComponent {
28346
29222
  ],
28347
29223
  loadAction: { url: 'tenants/invitations/x' },
28348
29224
  };
28349
- }
29225
+ } // Changed: injected ApiErrorService for the rename step
28350
29226
  ngOnInit() {
28351
29227
  this.loadMeta();
28352
29228
  this.authService.loggedUserFullName.subscribe(x => this.username = x);
@@ -28363,6 +29239,9 @@ class OnboardingComponent {
28363
29239
  if (apiResponse.success) {
28364
29240
  this.authService.updateTenantName(this.myTenant.name);
28365
29241
  }
29242
+ else {
29243
+ this.apiErrorService.presentAppFailure(apiResponse, 'action', 'tenants/dto?action=rename'); // Added: was silent, and next() advances the wizard regardless of the outcome — so the user typed their company name, moved on, and only discovered weeks later that the organisation was still called whatever it defaulted to
29244
+ }
28366
29245
  });
28367
29246
  }
28368
29247
  next() {
@@ -28391,13 +29270,13 @@ class OnboardingComponent {
28391
29270
  home() {
28392
29271
  this.dataService.Navigate('home');
28393
29272
  }
28394
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: OnboardingComponent, deps: [{ token: DataServiceLib }, { token: AuthService }], target: i0.ɵɵFactoryTarget.Component }); }
29273
+ 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 }); }
28395
29274
  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"], outputs: ["valueChange", "click", "check", "uncheck", "infoClick"] }, { kind: "component", type: InvitationsTableComponent, selector: "spa-invitations-table" }] }); }
28396
29275
  }
28397
29276
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: OnboardingComponent, decorators: [{
28398
29277
  type: Component,
28399
29278
  args: [{ selector: 'spa-onboarding', standalone: false, 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"] }]
28400
- }], ctorParameters: () => [{ type: DataServiceLib }, { type: AuthService }] });
29279
+ }], ctorParameters: () => [{ type: DataServiceLib }, { type: AuthService }, { type: ApiErrorService }] });
28401
29280
 
28402
29281
  const HR_ROUTES = [
28403
29282
  { path: "employees", component: EmployeesComponent },
@@ -28519,6 +29398,493 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
28519
29398
  }]
28520
29399
  }] });
28521
29400
 
29401
+ // Added (Phase 6): the Driver Commission Statement, and the only place the response-level warnings from
29402
+ // preview and generate reach a human. Phases 2-5 surfaced the ROW-level ones — a 0.00 entry, a rule scoped
29403
+ // to nobody — but a rule skipped because it expired, and an invoice excluded because a credit note settled
29404
+ // it rather than cash, live on the RESPONSE, and nothing rendered them. `execAction` on the table component
29405
+ // toasts apiResponse.message and drops apiResponse.data, so a Preview button bound to a table config would
29406
+ // have shown the one-line summary and silently discarded every named reason. Hence a page.
29407
+ class CommissionStatementComponent {
29408
+ constructor() {
29409
+ this.dataService = inject(DataServiceLib);
29410
+ this.configService = inject(ConfigService);
29411
+ this.messageService = inject(MessageService);
29412
+ this.months = [
29413
+ { value: 1, name: 'January' }, { value: 2, name: 'February' }, { value: 3, name: 'March' },
29414
+ { value: 4, name: 'April' }, { value: 5, name: 'May' }, { value: 6, name: 'June' },
29415
+ { value: 7, name: 'July' }, { value: 8, name: 'August' }, { value: 9, name: 'September' },
29416
+ { value: 10, name: 'October' }, { value: 11, name: 'November' }, { value: 12, name: 'December' },
29417
+ ];
29418
+ this.periodYear = new Date().getFullYear();
29419
+ this.periodMonth = new Date().getMonth() + 1;
29420
+ this.busy = false;
29421
+ this.previewResult = null;
29422
+ this.generateResult = null;
29423
+ this.entries = [];
29424
+ this.statementLoaded = false;
29425
+ this.totals = { draft: 0, approved: 0, paid: 0, reversed: 0, carried: 0 };
29426
+ // Reads the one shared App Configuration record. An app whose configuration has no EnableTripCommission
29427
+ // property simply gets undefined, so the generator hides — this fails CLOSED on purpose, matching the
29428
+ // server's own late-bound read of the flag. A button that offers to write commission in an app that does
29429
+ // not generate it is worse than no button.
29430
+ this.tripCommissionEnabled = false;
29431
+ }
29432
+ ngOnInit() {
29433
+ this.tripCommissionEnabled = !!this.configService.value?.enableTripCommission;
29434
+ this.configService.config$.subscribe((config) => {
29435
+ this.tripCommissionEnabled = !!config?.enableTripCommission;
29436
+ });
29437
+ this.loadStatement();
29438
+ }
29439
+ periodLabel() {
29440
+ return `${this.months.find(m => m.value === this.periodMonth)?.name ?? this.periodMonth} ${this.periodYear}`;
29441
+ }
29442
+ get previewTotal() {
29443
+ return (this.previewResult?.entries || []).reduce((sum, e) => sum + (e.commissionAmount || 0), 0);
29444
+ }
29445
+ statusClass(entry) {
29446
+ if (entry?.status === 1)
29447
+ return 'cs-status-draft';
29448
+ if (entry?.status === 2)
29449
+ return 'cs-status-approved';
29450
+ if (entry?.status === 4)
29451
+ return 'cs-status-reversed';
29452
+ return '';
29453
+ }
29454
+ // Changing the period invalidates both result panels — leaving July's preview on screen above August's
29455
+ // statement is how someone approves the wrong month.
29456
+ clearResults() {
29457
+ this.previewResult = null;
29458
+ this.generateResult = null;
29459
+ this.statementLoaded = false;
29460
+ }
29461
+ loadStatement() {
29462
+ if (!this.validPeriod())
29463
+ return;
29464
+ this.busy = true;
29465
+ this.dataService.CallApi({ url: `commissionentries/period/${this.periodYear}-${this.periodMonth}` }).subscribe({
29466
+ next: (res) => {
29467
+ this.busy = false;
29468
+ if (!res.success) {
29469
+ this.messageService.toast(res.message || 'Could not load the statement');
29470
+ return;
29471
+ }
29472
+ this.entries = res.data || [];
29473
+ this.statementLoaded = true;
29474
+ this.recalculateTotals();
29475
+ },
29476
+ error: () => { this.busy = false; }
29477
+ });
29478
+ }
29479
+ // Draft, Approved, Paid and Reversed are summed separately and never rolled into one figure. A single
29480
+ // "total commission" would read as owed when most of it is a proposal nobody has approved.
29481
+ recalculateTotals() {
29482
+ this.totals = { draft: 0, approved: 0, paid: 0, reversed: 0, carried: 0 };
29483
+ for (const e of this.entries) {
29484
+ const amount = e.commissionAmount || 0;
29485
+ if (e.isPaid || e.status === 3)
29486
+ this.totals.paid += amount;
29487
+ else if (e.status === 2)
29488
+ this.totals.approved += amount;
29489
+ else if (e.status === 4)
29490
+ this.totals.reversed += amount;
29491
+ else
29492
+ this.totals.draft += amount;
29493
+ if (e.isCarriedForward)
29494
+ this.totals.carried += amount;
29495
+ }
29496
+ }
29497
+ // PREVIEW WRITES NOTHING. It is gated on cap77 VIEW server-side for exactly that reason, and the result
29498
+ // panel repeats it — a figure on screen that looks committed is the failure this button exists to avoid.
29499
+ preview() {
29500
+ if (!this.validPeriod())
29501
+ return;
29502
+ this.busy = true;
29503
+ this.generateResult = null;
29504
+ this.dataService.CallApi({ url: 'shiftcommission/preview', method: 'post' }, { periodYear: this.periodYear, periodMonth: this.periodMonth }).subscribe({
29505
+ next: (res) => {
29506
+ this.busy = false;
29507
+ if (!res.success) {
29508
+ this.messageService.toast(res.message || 'Preview failed');
29509
+ return;
29510
+ }
29511
+ this.previewResult = res.data || {};
29512
+ },
29513
+ error: () => { this.busy = false; }
29514
+ });
29515
+ }
29516
+ // GENERATE WRITES. Every entry lands as Draft, so nothing here can pay anybody without a separate
29517
+ // approval — the confirm says so rather than leaving it to be discovered.
29518
+ generate() {
29519
+ if (!this.validPeriod())
29520
+ return;
29521
+ this.messageService.confirm(`Generate commission entries for ${this.periodLabel()}? They are created as Draft and still have to be approved before a payroll run can pay them. Regenerating a period reverses entries that were already approved or paid.`)
29522
+ .subscribe((answer) => {
29523
+ if (answer !== 'yes')
29524
+ return;
29525
+ this.busy = true;
29526
+ this.previewResult = null;
29527
+ this.dataService.CallApi({ url: 'shiftcommission/generate', method: 'post' }, { periodYear: this.periodYear, periodMonth: this.periodMonth }).subscribe({
29528
+ next: (res) => {
29529
+ this.busy = false;
29530
+ if (!res.success) {
29531
+ this.messageService.toast(res.message || 'Generation failed');
29532
+ return;
29533
+ }
29534
+ this.generateResult = res.data || {};
29535
+ this.loadStatement(); // the statement below must agree with what was just written
29536
+ },
29537
+ error: () => { this.busy = false; }
29538
+ });
29539
+ });
29540
+ }
29541
+ validPeriod() {
29542
+ if (!this.periodMonth || this.periodMonth < 1 || this.periodMonth > 12) {
29543
+ this.messageService.toast('Choose a month');
29544
+ return false;
29545
+ }
29546
+ if (!this.periodYear || this.periodYear < 2000 || this.periodYear > 2100) {
29547
+ this.messageService.toast('Year must be between 2000 and 2100');
29548
+ return false;
29549
+ }
29550
+ return true;
29551
+ }
29552
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: CommissionStatementComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
29553
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: CommissionStatementComponent, isStandalone: false, selector: "spa-commission-statement", ngImport: i0, template: `
29554
+ <div class="cs-container">
29555
+
29556
+ <h4 class="cs-title"><mat-icon>receipt_long</mat-icon> Driver Commission Statement</h4>
29557
+
29558
+ <!-- Period, and the two actions. Preview and Generate render only where trip commission is switched on,
29559
+ which in practice means Shift — every other app gets the statement without the generator. -->
29560
+ <div class="cs-bar">
29561
+ <div class="cs-period">
29562
+ <mat-form-field appearance="outline" class="cs-year">
29563
+ <mat-label>Year</mat-label>
29564
+ <input matInput type="number" [(ngModel)]="periodYear" (change)="clearResults()">
29565
+ </mat-form-field>
29566
+ <mat-form-field appearance="outline" class="cs-month">
29567
+ <mat-label>Month</mat-label>
29568
+ <mat-select [(ngModel)]="periodMonth" (selectionChange)="clearResults()">
29569
+ <mat-option *ngFor="let m of months" [value]="m.value">{{ m.name }}</mat-option>
29570
+ </mat-select>
29571
+ </mat-form-field>
29572
+ <button mat-stroked-button color="primary" (click)="loadStatement()" [disabled]="busy">Show Statement</button>
29573
+ </div>
29574
+
29575
+ <div class="cs-actions" *ngIf="tripCommissionEnabled">
29576
+ <button mat-stroked-button (click)="preview()" [disabled]="busy"><mat-icon>visibility</mat-icon> Preview (writes nothing)</button>
29577
+ <button mat-flat-button color="primary" (click)="generate()" [disabled]="busy"><mat-icon>playlist_add</mat-icon> Generate from Trips</button>
29578
+ </div>
29579
+ </div>
29580
+
29581
+ <div class="alert alert-secondary cs-note" *ngIf="!tripCommissionEnabled">
29582
+ Commission is not generated from trips in this app, so this page reports what has been recorded rather than
29583
+ proposing anything new.
29584
+ </div>
29585
+
29586
+ <!-- ============ PREVIEW RESULT — the whole point of this page ============ -->
29587
+ <div class="cs-panel" *ngIf="previewResult">
29588
+ <div class="alert alert-info cs-headline">
29589
+ <strong>Preview only — nothing was written.</strong>
29590
+ {{ previewResult.entries?.length || 0 }} {{ (previewResult.entries?.length === 1) ? 'entry' : 'entries' }}
29591
+ totalling {{ previewTotal | number:'1.2-2' }} for {{ previewResult.periodName || periodLabel() }}.
29592
+ Generating turns these into <strong>Draft</strong> entries, which still have to be approved before payroll can pay them.
29593
+ </div>
29594
+
29595
+ <!-- Response-level warnings: the ones that were invisible until now -->
29596
+ <div class="alert alert-warning" *ngIf="previewResult.warnings?.length">
29597
+ <strong>{{ previewResult.warnings.length }} warning{{ previewResult.warnings.length === 1 ? '' : 's' }} about this period</strong>
29598
+ <ul class="cs-list"><li *ngFor="let w of previewResult.warnings">{{ w }}</li></ul>
29599
+ </div>
29600
+
29601
+ <!-- Skipped rules, each with the reason the evaluator refused it -->
29602
+ <div class="alert alert-danger" *ngIf="previewResult.skippedRules?.length">
29603
+ <strong>{{ previewResult.skippedRules.length }} rule{{ previewResult.skippedRules.length === 1 ? '' : 's' }} paid nobody</strong>
29604
+ <ul class="cs-list">
29605
+ <li *ngFor="let s of previewResult.skippedRules"><strong>{{ s.configName }}</strong> — {{ s.reason }}</li>
29606
+ </ul>
29607
+ </div>
29608
+
29609
+ <table class="cs-table" *ngIf="previewResult.entries?.length">
29610
+ <thead>
29611
+ <tr>
29612
+ <th>Employee</th><th>Rule</th><th>Basis</th><th>Earned When</th>
29613
+ <th class="cs-num">Measured</th><th class="cs-num">Rate</th><th class="cs-num">Raw</th><th class="cs-num">Would Pay</th><th>Notes</th>
29614
+ </tr>
29615
+ </thead>
29616
+ <tbody>
29617
+ <tr *ngFor="let e of previewResult.entries">
29618
+ <td>{{ e.employeeName }}</td>
29619
+ <td>{{ e.configName }}</td>
29620
+ <td>{{ e.basisName }}</td>
29621
+ <td>{{ e.earnEventName }}</td>
29622
+ <td class="cs-num">{{ e.metricValue | number:'1.0-2' }}</td>
29623
+ <td class="cs-num">{{ e.rate | number:'1.0-4' }}</td>
29624
+ <td class="cs-num">{{ e.rawAmount | number:'1.2-2' }}</td>
29625
+ <td class="cs-num cs-strong" [class.cs-zero]="e.commissionAmount === 0">{{ e.commissionAmount | number:'1.2-2' }}</td>
29626
+ <td class="cs-notes">
29627
+ <span class="cs-flag" *ngIf="e.usedTiers">tiered</span>
29628
+ <span class="cs-flag" *ngIf="e.minimumApplied">floor applied</span>
29629
+ <span class="cs-flag" *ngIf="e.maximumApplied">cap applied</span>
29630
+ <span class="cs-warn" *ngFor="let w of e.warnings">{{ w }}</span>
29631
+ </td>
29632
+ </tr>
29633
+ </tbody>
29634
+ </table>
29635
+ </div>
29636
+
29637
+ <!-- ============ GENERATE RESULT ============ -->
29638
+ <div class="cs-panel" *ngIf="generateResult">
29639
+ <div class="alert alert-success cs-headline">
29640
+ <strong>{{ generateResult.entriesCreated }} Draft entr{{ generateResult.entriesCreated === 1 ? 'y' : 'ies' }}</strong>
29641
+ created from {{ generateResult.linesCreated }} source line{{ generateResult.linesCreated === 1 ? '' : 's' }},
29642
+ totalling {{ generateResult.totalAmount | number:'1.2-2' }} for {{ generateResult.periodName || periodLabel() }}.
29643
+ Draft entries do <strong>not</strong> reach a payslip until they are approved.
29644
+ </div>
29645
+
29646
+ <!-- What a regeneration MOVED. These three are the ones nobody must discover from a payslip. -->
29647
+ <div class="alert alert-warning" *ngIf="generateResult.draftEntriesReplaced || generateResult.approvedEntriesNeutralised || generateResult.paidEntriesReversed">
29648
+ <ul class="cs-list">
29649
+ <li *ngIf="generateResult.draftEntriesReplaced">{{ generateResult.draftEntriesReplaced }} previous draft entr{{ generateResult.draftEntriesReplaced === 1 ? 'y was' : 'ies were' }} replaced.</li>
29650
+ <li *ngIf="generateResult.approvedEntriesNeutralised"><strong>{{ generateResult.approvedEntriesNeutralised }} already-approved entr{{ generateResult.approvedEntriesNeutralised === 1 ? 'y was' : 'ies were' }} reversed and must be approved again.</strong></li>
29651
+ <li *ngIf="generateResult.paidEntriesReversed"><strong>{{ generateResult.paidEntriesReversed }} already-paid entr{{ generateResult.paidEntriesReversed === 1 ? 'y' : 'ies' }} raised a negative clawback awaiting approval.</strong> The original payment stands.</li>
29652
+ </ul>
29653
+ </div>
29654
+
29655
+ <div class="alert alert-warning" *ngIf="generateResult.entriesWithWarnings?.length">
29656
+ <strong>{{ generateResult.entriesWithWarnings.length }} entr{{ generateResult.entriesWithWarnings.length === 1 ? 'y needs' : 'ies need' }} attention before approval</strong>
29657
+ <ul class="cs-list">
29658
+ <li *ngFor="let e of generateResult.entriesWithWarnings">
29659
+ <strong>{{ e.employeeName }}</strong> — {{ e.configName }} — {{ e.commissionAmount | number:'1.2-2' }}
29660
+ <span *ngFor="let w of e.warnings" class="cs-warn">{{ w }}</span>
29661
+ </li>
29662
+ </ul>
29663
+ </div>
29664
+
29665
+ <div class="alert alert-warning" *ngIf="generateResult.warnings?.length">
29666
+ <ul class="cs-list"><li *ngFor="let w of generateResult.warnings">{{ w }}</li></ul>
29667
+ </div>
29668
+
29669
+ <div class="alert alert-danger" *ngIf="generateResult.skippedRules?.length">
29670
+ <strong>{{ generateResult.skippedRules.length }} rule{{ generateResult.skippedRules.length === 1 ? '' : 's' }} paid nobody</strong>
29671
+ <ul class="cs-list">
29672
+ <li *ngFor="let s of generateResult.skippedRules"><strong>{{ s.configName }}</strong> — {{ s.reason }}</li>
29673
+ </ul>
29674
+ </div>
29675
+ </div>
29676
+
29677
+ <!-- ============ THE STATEMENT ITSELF ============ -->
29678
+ <div class="cs-panel" *ngIf="statementLoaded">
29679
+ <h5 class="cs-section">Recorded for {{ periodLabel() }}</h5>
29680
+
29681
+ <div class="cs-totals">
29682
+ <div class="cs-total"><span class="cs-total-label">Proposed (Draft)</span><span class="cs-total-value cs-draft">{{ totals.draft | number:'1.2-2' }}</span><span class="cs-total-note">not payable until approved</span></div>
29683
+ <div class="cs-total"><span class="cs-total-label">Payable (Approved)</span><span class="cs-total-value cs-approved">{{ totals.approved | number:'1.2-2' }}</span><span class="cs-total-note">will be picked up by a payroll run</span></div>
29684
+ <div class="cs-total"><span class="cs-total-label">Settled (Paid)</span><span class="cs-total-value">{{ totals.paid | number:'1.2-2' }}</span><span class="cs-total-note">already on a payslip line</span></div>
29685
+ <div class="cs-total"><span class="cs-total-label">Reversed</span><span class="cs-total-value cs-reversed">{{ totals.reversed | number:'1.2-2' }}</span><span class="cs-total-note">clawbacks against earlier entries</span></div>
29686
+ <div class="cs-total"><span class="cs-total-label">Carried forward in</span><span class="cs-total-value">{{ totals.carried | number:'1.2-2' }}</span><span class="cs-total-note">earned in an earlier period</span></div>
29687
+ </div>
29688
+
29689
+ <div class="alert alert-secondary cs-note" *ngIf="!entries.length">
29690
+ Nothing has been recorded for this period yet.
29691
+ </div>
29692
+
29693
+ <table class="cs-table" *ngIf="entries.length">
29694
+ <thead>
29695
+ <tr><th>Employee</th><th>Description</th><th>Source</th><th class="cs-num">Measured</th><th class="cs-num">Amount</th><th>Status</th><th>Paid</th><th>Lines</th></tr>
29696
+ </thead>
29697
+ <tbody>
29698
+ <tr *ngFor="let e of entries">
29699
+ <td>{{ e.employeeName }}</td>
29700
+ <td>
29701
+ {{ e.description }}
29702
+ <span class="cs-flag" *ngIf="e.isCarriedForward">from {{ e.originPeriodMonth }}/{{ e.originPeriodYear }}</span>
29703
+ <span class="cs-flag cs-flag-warn" *ngIf="e.isReversal">reverses #{{ e.reversesCommissionEntryID }}</span>
29704
+ </td>
29705
+ <td>{{ e.sourceType }}</td>
29706
+ <td class="cs-num">{{ e.metricValue | number:'1.0-2' }}</td>
29707
+ <td class="cs-num cs-strong" [class.cs-negative]="e.commissionAmount < 0">{{ e.commissionAmount | number:'1.2-2' }}</td>
29708
+ <td><span [ngClass]="statusClass(e)">{{ e.statusName }}</span></td>
29709
+ <td>{{ e.isPaid ? 'Yes' : '' }}</td>
29710
+ <td>{{ e.lineCount }}</td>
29711
+ </tr>
29712
+ </tbody>
29713
+ </table>
29714
+ </div>
29715
+
29716
+ </div>
29717
+ `, isInline: true, styles: [".cs-container{padding:16px}.cs-title{display:flex;align-items:center;gap:8px;margin-bottom:16px;color:#333;font-weight:500}.cs-bar{display:flex;flex-wrap:wrap;gap:16px;align-items:center;justify-content:space-between;margin-bottom:8px}.cs-period,.cs-actions{display:flex;flex-wrap:wrap;gap:12px;align-items:center}.cs-year{width:110px}.cs-month{width:160px}.cs-note{font-size:14px}.cs-panel{margin-top:8px}.cs-headline{font-size:14px}.cs-section{color:#333;font-weight:500;margin:16px 0 8px}.cs-list{margin:6px 0 0;padding-left:20px}.cs-list li{margin-bottom:3px}.cs-table{width:100%;border-collapse:collapse;font-size:13px}.cs-table th{text-align:left;padding:8px 12px;background:#f5f5f5;border-bottom:2px solid #e0e0e0;font-weight:500;white-space:nowrap}.cs-table td{padding:8px 12px;border-bottom:1px solid #eee;vertical-align:top}.cs-table tr:hover{background:#fafafa}.cs-num{text-align:right;white-space:nowrap}.cs-strong{font-weight:600}.cs-zero{color:#e65100}.cs-negative{color:#c62828}.cs-notes{max-width:320px}.cs-flag{display:inline-block;margin:1px 4px 1px 0;padding:1px 7px;border-radius:10px;background:#eceff1;color:#455a64;font-size:11px;white-space:nowrap}.cs-flag-warn{background:#fff3cd;color:#664d03}.cs-warn{display:block;color:#664d03;font-size:12px;margin-top:2px}.cs-totals{display:flex;flex-wrap:wrap;gap:12px;margin-bottom:12px}.cs-total{display:flex;flex-direction:column;min-width:170px;flex:1 1 170px;padding:10px 14px;border:1px solid #e0e0e0;border-radius:6px;background:#fff}.cs-total-label{font-size:12px;color:#607d8b}.cs-total-value{font-size:20px;font-weight:600}.cs-total-note{font-size:11px;color:#90a4ae}.cs-draft{color:#757575}.cs-approved{color:#2e7d32}.cs-reversed{color:#c62828}.cs-status-draft{color:#757575}.cs-status-approved{color:#2e7d32;font-weight:600}.cs-status-reversed{color:#c62828}@media (max-width: 700px){.cs-bar{flex-direction:column;align-items:stretch}.cs-actions button{flex:1 1 auto}.cs-panel{overflow-x:auto}.cs-notes{max-width:none}}\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: "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.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i2$2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i2$2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { 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: i3$1.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i3$1.MatLabel, selector: "mat-label" }, { kind: "directive", type: i4$2.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "component", type: i7$1.MatSelect, selector: "mat-select", inputs: ["aria-describedby", "panelClass", "disabled", "disableRipple", "tabIndex", "hideSingleSelectionIndicator", "placeholder", "required", "multiple", "disableOptionCentering", "compareWith", "value", "aria-label", "aria-labelledby", "errorStateMatcher", "typeaheadDebounceInterval", "sortComparator", "id", "panelWidth", "canSelectNullableOptions"], outputs: ["openedChange", "opened", "closed", "selectionChange", "valueChange"], exportAs: ["matSelect"] }, { kind: "component", type: i7$1.MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "pipe", type: i1$2.DecimalPipe, name: "number" }] }); }
29718
+ }
29719
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: CommissionStatementComponent, decorators: [{
29720
+ type: Component,
29721
+ args: [{ selector: 'spa-commission-statement', template: `
29722
+ <div class="cs-container">
29723
+
29724
+ <h4 class="cs-title"><mat-icon>receipt_long</mat-icon> Driver Commission Statement</h4>
29725
+
29726
+ <!-- Period, and the two actions. Preview and Generate render only where trip commission is switched on,
29727
+ which in practice means Shift — every other app gets the statement without the generator. -->
29728
+ <div class="cs-bar">
29729
+ <div class="cs-period">
29730
+ <mat-form-field appearance="outline" class="cs-year">
29731
+ <mat-label>Year</mat-label>
29732
+ <input matInput type="number" [(ngModel)]="periodYear" (change)="clearResults()">
29733
+ </mat-form-field>
29734
+ <mat-form-field appearance="outline" class="cs-month">
29735
+ <mat-label>Month</mat-label>
29736
+ <mat-select [(ngModel)]="periodMonth" (selectionChange)="clearResults()">
29737
+ <mat-option *ngFor="let m of months" [value]="m.value">{{ m.name }}</mat-option>
29738
+ </mat-select>
29739
+ </mat-form-field>
29740
+ <button mat-stroked-button color="primary" (click)="loadStatement()" [disabled]="busy">Show Statement</button>
29741
+ </div>
29742
+
29743
+ <div class="cs-actions" *ngIf="tripCommissionEnabled">
29744
+ <button mat-stroked-button (click)="preview()" [disabled]="busy"><mat-icon>visibility</mat-icon> Preview (writes nothing)</button>
29745
+ <button mat-flat-button color="primary" (click)="generate()" [disabled]="busy"><mat-icon>playlist_add</mat-icon> Generate from Trips</button>
29746
+ </div>
29747
+ </div>
29748
+
29749
+ <div class="alert alert-secondary cs-note" *ngIf="!tripCommissionEnabled">
29750
+ Commission is not generated from trips in this app, so this page reports what has been recorded rather than
29751
+ proposing anything new.
29752
+ </div>
29753
+
29754
+ <!-- ============ PREVIEW RESULT — the whole point of this page ============ -->
29755
+ <div class="cs-panel" *ngIf="previewResult">
29756
+ <div class="alert alert-info cs-headline">
29757
+ <strong>Preview only — nothing was written.</strong>
29758
+ {{ previewResult.entries?.length || 0 }} {{ (previewResult.entries?.length === 1) ? 'entry' : 'entries' }}
29759
+ totalling {{ previewTotal | number:'1.2-2' }} for {{ previewResult.periodName || periodLabel() }}.
29760
+ Generating turns these into <strong>Draft</strong> entries, which still have to be approved before payroll can pay them.
29761
+ </div>
29762
+
29763
+ <!-- Response-level warnings: the ones that were invisible until now -->
29764
+ <div class="alert alert-warning" *ngIf="previewResult.warnings?.length">
29765
+ <strong>{{ previewResult.warnings.length }} warning{{ previewResult.warnings.length === 1 ? '' : 's' }} about this period</strong>
29766
+ <ul class="cs-list"><li *ngFor="let w of previewResult.warnings">{{ w }}</li></ul>
29767
+ </div>
29768
+
29769
+ <!-- Skipped rules, each with the reason the evaluator refused it -->
29770
+ <div class="alert alert-danger" *ngIf="previewResult.skippedRules?.length">
29771
+ <strong>{{ previewResult.skippedRules.length }} rule{{ previewResult.skippedRules.length === 1 ? '' : 's' }} paid nobody</strong>
29772
+ <ul class="cs-list">
29773
+ <li *ngFor="let s of previewResult.skippedRules"><strong>{{ s.configName }}</strong> — {{ s.reason }}</li>
29774
+ </ul>
29775
+ </div>
29776
+
29777
+ <table class="cs-table" *ngIf="previewResult.entries?.length">
29778
+ <thead>
29779
+ <tr>
29780
+ <th>Employee</th><th>Rule</th><th>Basis</th><th>Earned When</th>
29781
+ <th class="cs-num">Measured</th><th class="cs-num">Rate</th><th class="cs-num">Raw</th><th class="cs-num">Would Pay</th><th>Notes</th>
29782
+ </tr>
29783
+ </thead>
29784
+ <tbody>
29785
+ <tr *ngFor="let e of previewResult.entries">
29786
+ <td>{{ e.employeeName }}</td>
29787
+ <td>{{ e.configName }}</td>
29788
+ <td>{{ e.basisName }}</td>
29789
+ <td>{{ e.earnEventName }}</td>
29790
+ <td class="cs-num">{{ e.metricValue | number:'1.0-2' }}</td>
29791
+ <td class="cs-num">{{ e.rate | number:'1.0-4' }}</td>
29792
+ <td class="cs-num">{{ e.rawAmount | number:'1.2-2' }}</td>
29793
+ <td class="cs-num cs-strong" [class.cs-zero]="e.commissionAmount === 0">{{ e.commissionAmount | number:'1.2-2' }}</td>
29794
+ <td class="cs-notes">
29795
+ <span class="cs-flag" *ngIf="e.usedTiers">tiered</span>
29796
+ <span class="cs-flag" *ngIf="e.minimumApplied">floor applied</span>
29797
+ <span class="cs-flag" *ngIf="e.maximumApplied">cap applied</span>
29798
+ <span class="cs-warn" *ngFor="let w of e.warnings">{{ w }}</span>
29799
+ </td>
29800
+ </tr>
29801
+ </tbody>
29802
+ </table>
29803
+ </div>
29804
+
29805
+ <!-- ============ GENERATE RESULT ============ -->
29806
+ <div class="cs-panel" *ngIf="generateResult">
29807
+ <div class="alert alert-success cs-headline">
29808
+ <strong>{{ generateResult.entriesCreated }} Draft entr{{ generateResult.entriesCreated === 1 ? 'y' : 'ies' }}</strong>
29809
+ created from {{ generateResult.linesCreated }} source line{{ generateResult.linesCreated === 1 ? '' : 's' }},
29810
+ totalling {{ generateResult.totalAmount | number:'1.2-2' }} for {{ generateResult.periodName || periodLabel() }}.
29811
+ Draft entries do <strong>not</strong> reach a payslip until they are approved.
29812
+ </div>
29813
+
29814
+ <!-- What a regeneration MOVED. These three are the ones nobody must discover from a payslip. -->
29815
+ <div class="alert alert-warning" *ngIf="generateResult.draftEntriesReplaced || generateResult.approvedEntriesNeutralised || generateResult.paidEntriesReversed">
29816
+ <ul class="cs-list">
29817
+ <li *ngIf="generateResult.draftEntriesReplaced">{{ generateResult.draftEntriesReplaced }} previous draft entr{{ generateResult.draftEntriesReplaced === 1 ? 'y was' : 'ies were' }} replaced.</li>
29818
+ <li *ngIf="generateResult.approvedEntriesNeutralised"><strong>{{ generateResult.approvedEntriesNeutralised }} already-approved entr{{ generateResult.approvedEntriesNeutralised === 1 ? 'y was' : 'ies were' }} reversed and must be approved again.</strong></li>
29819
+ <li *ngIf="generateResult.paidEntriesReversed"><strong>{{ generateResult.paidEntriesReversed }} already-paid entr{{ generateResult.paidEntriesReversed === 1 ? 'y' : 'ies' }} raised a negative clawback awaiting approval.</strong> The original payment stands.</li>
29820
+ </ul>
29821
+ </div>
29822
+
29823
+ <div class="alert alert-warning" *ngIf="generateResult.entriesWithWarnings?.length">
29824
+ <strong>{{ generateResult.entriesWithWarnings.length }} entr{{ generateResult.entriesWithWarnings.length === 1 ? 'y needs' : 'ies need' }} attention before approval</strong>
29825
+ <ul class="cs-list">
29826
+ <li *ngFor="let e of generateResult.entriesWithWarnings">
29827
+ <strong>{{ e.employeeName }}</strong> — {{ e.configName }} — {{ e.commissionAmount | number:'1.2-2' }}
29828
+ <span *ngFor="let w of e.warnings" class="cs-warn">{{ w }}</span>
29829
+ </li>
29830
+ </ul>
29831
+ </div>
29832
+
29833
+ <div class="alert alert-warning" *ngIf="generateResult.warnings?.length">
29834
+ <ul class="cs-list"><li *ngFor="let w of generateResult.warnings">{{ w }}</li></ul>
29835
+ </div>
29836
+
29837
+ <div class="alert alert-danger" *ngIf="generateResult.skippedRules?.length">
29838
+ <strong>{{ generateResult.skippedRules.length }} rule{{ generateResult.skippedRules.length === 1 ? '' : 's' }} paid nobody</strong>
29839
+ <ul class="cs-list">
29840
+ <li *ngFor="let s of generateResult.skippedRules"><strong>{{ s.configName }}</strong> — {{ s.reason }}</li>
29841
+ </ul>
29842
+ </div>
29843
+ </div>
29844
+
29845
+ <!-- ============ THE STATEMENT ITSELF ============ -->
29846
+ <div class="cs-panel" *ngIf="statementLoaded">
29847
+ <h5 class="cs-section">Recorded for {{ periodLabel() }}</h5>
29848
+
29849
+ <div class="cs-totals">
29850
+ <div class="cs-total"><span class="cs-total-label">Proposed (Draft)</span><span class="cs-total-value cs-draft">{{ totals.draft | number:'1.2-2' }}</span><span class="cs-total-note">not payable until approved</span></div>
29851
+ <div class="cs-total"><span class="cs-total-label">Payable (Approved)</span><span class="cs-total-value cs-approved">{{ totals.approved | number:'1.2-2' }}</span><span class="cs-total-note">will be picked up by a payroll run</span></div>
29852
+ <div class="cs-total"><span class="cs-total-label">Settled (Paid)</span><span class="cs-total-value">{{ totals.paid | number:'1.2-2' }}</span><span class="cs-total-note">already on a payslip line</span></div>
29853
+ <div class="cs-total"><span class="cs-total-label">Reversed</span><span class="cs-total-value cs-reversed">{{ totals.reversed | number:'1.2-2' }}</span><span class="cs-total-note">clawbacks against earlier entries</span></div>
29854
+ <div class="cs-total"><span class="cs-total-label">Carried forward in</span><span class="cs-total-value">{{ totals.carried | number:'1.2-2' }}</span><span class="cs-total-note">earned in an earlier period</span></div>
29855
+ </div>
29856
+
29857
+ <div class="alert alert-secondary cs-note" *ngIf="!entries.length">
29858
+ Nothing has been recorded for this period yet.
29859
+ </div>
29860
+
29861
+ <table class="cs-table" *ngIf="entries.length">
29862
+ <thead>
29863
+ <tr><th>Employee</th><th>Description</th><th>Source</th><th class="cs-num">Measured</th><th class="cs-num">Amount</th><th>Status</th><th>Paid</th><th>Lines</th></tr>
29864
+ </thead>
29865
+ <tbody>
29866
+ <tr *ngFor="let e of entries">
29867
+ <td>{{ e.employeeName }}</td>
29868
+ <td>
29869
+ {{ e.description }}
29870
+ <span class="cs-flag" *ngIf="e.isCarriedForward">from {{ e.originPeriodMonth }}/{{ e.originPeriodYear }}</span>
29871
+ <span class="cs-flag cs-flag-warn" *ngIf="e.isReversal">reverses #{{ e.reversesCommissionEntryID }}</span>
29872
+ </td>
29873
+ <td>{{ e.sourceType }}</td>
29874
+ <td class="cs-num">{{ e.metricValue | number:'1.0-2' }}</td>
29875
+ <td class="cs-num cs-strong" [class.cs-negative]="e.commissionAmount < 0">{{ e.commissionAmount | number:'1.2-2' }}</td>
29876
+ <td><span [ngClass]="statusClass(e)">{{ e.statusName }}</span></td>
29877
+ <td>{{ e.isPaid ? 'Yes' : '' }}</td>
29878
+ <td>{{ e.lineCount }}</td>
29879
+ </tr>
29880
+ </tbody>
29881
+ </table>
29882
+ </div>
29883
+
29884
+ </div>
29885
+ `, standalone: false, styles: [".cs-container{padding:16px}.cs-title{display:flex;align-items:center;gap:8px;margin-bottom:16px;color:#333;font-weight:500}.cs-bar{display:flex;flex-wrap:wrap;gap:16px;align-items:center;justify-content:space-between;margin-bottom:8px}.cs-period,.cs-actions{display:flex;flex-wrap:wrap;gap:12px;align-items:center}.cs-year{width:110px}.cs-month{width:160px}.cs-note{font-size:14px}.cs-panel{margin-top:8px}.cs-headline{font-size:14px}.cs-section{color:#333;font-weight:500;margin:16px 0 8px}.cs-list{margin:6px 0 0;padding-left:20px}.cs-list li{margin-bottom:3px}.cs-table{width:100%;border-collapse:collapse;font-size:13px}.cs-table th{text-align:left;padding:8px 12px;background:#f5f5f5;border-bottom:2px solid #e0e0e0;font-weight:500;white-space:nowrap}.cs-table td{padding:8px 12px;border-bottom:1px solid #eee;vertical-align:top}.cs-table tr:hover{background:#fafafa}.cs-num{text-align:right;white-space:nowrap}.cs-strong{font-weight:600}.cs-zero{color:#e65100}.cs-negative{color:#c62828}.cs-notes{max-width:320px}.cs-flag{display:inline-block;margin:1px 4px 1px 0;padding:1px 7px;border-radius:10px;background:#eceff1;color:#455a64;font-size:11px;white-space:nowrap}.cs-flag-warn{background:#fff3cd;color:#664d03}.cs-warn{display:block;color:#664d03;font-size:12px;margin-top:2px}.cs-totals{display:flex;flex-wrap:wrap;gap:12px;margin-bottom:12px}.cs-total{display:flex;flex-direction:column;min-width:170px;flex:1 1 170px;padding:10px 14px;border:1px solid #e0e0e0;border-radius:6px;background:#fff}.cs-total-label{font-size:12px;color:#607d8b}.cs-total-value{font-size:20px;font-weight:600}.cs-total-note{font-size:11px;color:#90a4ae}.cs-draft{color:#757575}.cs-approved{color:#2e7d32}.cs-reversed{color:#c62828}.cs-status-draft{color:#757575}.cs-status-approved{color:#2e7d32;font-weight:600}.cs-status-reversed{color:#c62828}@media (max-width: 700px){.cs-bar{flex-direction:column;align-items:stretch}.cs-actions button{flex:1 1 auto}.cs-panel{overflow-x:auto}.cs-notes{max-width:none}}\n"] }]
29886
+ }] });
29887
+
28522
29888
  class SalaryAdvancesComponent {
28523
29889
  constructor() {
28524
29890
  this.payrollService = inject(PayrollService);
@@ -28704,6 +30070,7 @@ const PAYROLL_ROUTES = [
28704
30070
  { path: "runs", component: PayrollRunsComponent },
28705
30071
  { path: "commission-configs", component: CommissionConfigsComponent },
28706
30072
  { path: "commission-entries", component: CommissionEntriesComponent },
30073
+ { path: "commission-statement", component: CommissionStatementComponent }, // Added (Phase 6): cap86 — routed and registered, granted in no role template
28707
30074
  { path: "salary-advances", component: SalaryAdvancesComponent },
28708
30075
  { path: "overtime-entries", component: OvertimeEntriesComponent },
28709
30076
  { path: "dashboard", component: PayrollDashboardComponent }
@@ -29000,11 +30367,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
29000
30367
  }] });
29001
30368
 
29002
30369
  class TenantSettingsComponent {
29003
- constructor(dataService, messageService, authService, dialog) {
30370
+ constructor(dataService, messageService, authService, dialog, apiErrorService) {
29004
30371
  this.dataService = dataService;
29005
30372
  this.messageService = messageService;
29006
30373
  this.authService = authService;
29007
30374
  this.dialog = dialog;
30375
+ this.apiErrorService = apiErrorService;
29008
30376
  this.roles = [
29009
30377
  { roleName: 'Default', roleID: 1 },
29010
30378
  ];
@@ -29158,7 +30526,7 @@ class TenantSettingsComponent {
29158
30526
  loadAction: { url: 'mailerconfigs/all/x' },
29159
30527
  formConfig: this.mailerFormConfig
29160
30528
  };
29161
- }
30529
+ } // Changed: injected ApiErrorService for the switch failure
29162
30530
  ngOnInit() {
29163
30531
  this.authService.myRoleObserv.subscribe(rol => this.myRole = rol);
29164
30532
  this.loadData();
@@ -29203,6 +30571,9 @@ class TenantSettingsComponent {
29203
30571
  if (apiResponse.success) {
29204
30572
  this.forceLogin();
29205
30573
  }
30574
+ else {
30575
+ this.apiErrorService.presentAppFailure(apiResponse, 'action', 'tenants/dto?action=switch'); // Added: was silent. The user picked an organisation, confirmed it, and got NOTHING — no switch, no message — leaving them unable to tell whether they had changed organisation or not, on the one screen where that question matters most
30576
+ }
29206
30577
  });
29207
30578
  }
29208
30579
  });
@@ -29211,13 +30582,13 @@ class TenantSettingsComponent {
29211
30582
  this.messageService.toast("Switched Successfully, please login again");
29212
30583
  this.authService.logoff();
29213
30584
  }
29214
- 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 }], target: i0.ɵɵFactoryTarget.Component }); }
30585
+ 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 }); }
29215
30586
  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" }] }); }
29216
30587
  }
29217
30588
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: TenantSettingsComponent, decorators: [{
29218
30589
  type: Component,
29219
30590
  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"] }]
29220
- }], ctorParameters: () => [{ type: DataServiceLib }, { type: MessageService }, { type: AuthService }, { type: i4.MatDialog }] });
30591
+ }], ctorParameters: () => [{ type: DataServiceLib }, { type: MessageService }, { type: AuthService }, { type: i4.MatDialog }, { type: ApiErrorService }] });
29221
30592
 
29222
30593
  class TenantsComponent {
29223
30594
  constructor() {
@@ -29499,10 +30870,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
29499
30870
 
29500
30871
  // Subscription management page — current plan display, plan comparison, upgrade/downgrade
29501
30872
  class SubscriptionPageComponent {
29502
- constructor(httpService, subscriptionService, snackBar) {
30873
+ constructor(httpService, subscriptionService, snackBar, apiErrorService // Changed: injected so a failed load or plan change is classified rather than swallowed or echoed
30874
+ ) {
29503
30875
  this.httpService = httpService;
29504
30876
  this.subscriptionService = subscriptionService;
29505
30877
  this.snackBar = snackBar;
30878
+ this.apiErrorService = apiErrorService;
29506
30879
  this.currentSubscription = null;
29507
30880
  this.availablePlans = [];
29508
30881
  this.loading = true;
@@ -29521,6 +30894,9 @@ class SubscriptionPageComponent {
29521
30894
  if (response?.success) {
29522
30895
  this.currentSubscription = response.data;
29523
30896
  }
30897
+ else {
30898
+ this.apiErrorService.presentAppFailure(response, 'load', 'subscriptions/current'); // Added: was silent — the "your current plan" panel stayed blank, and a user comparing plans could not tell which one they were already on
30899
+ }
29524
30900
  this.loading = false;
29525
30901
  },
29526
30902
  error: () => { this.loading = false; }
@@ -29533,6 +30909,9 @@ class SubscriptionPageComponent {
29533
30909
  if (response?.success) {
29534
30910
  this.availablePlans = response.data || [];
29535
30911
  }
30912
+ else {
30913
+ this.apiErrorService.presentAppFailure(response, 'load', 'subscriptions/plans'); // Added: was silent. A plan-comparison page with no plans on it reads as "there is nothing to upgrade to", which is a commercially wrong thing to tell someone by accident
30914
+ }
29536
30915
  }
29537
30916
  });
29538
30917
  }
@@ -29572,7 +30951,7 @@ class SubscriptionPageComponent {
29572
30951
  this.subscriptionService.loadFeatures(); // Refresh enabled features for UI gating
29573
30952
  }
29574
30953
  else {
29575
- this.snackBar.open(response?.message || 'Failed to change plan', '', { duration: 3000 });
30954
+ this.apiErrorService.presentAppFailure(response, 'action', 'subscriptions/change'); // Changed: was a bare MatSnackBar carrying response.message verbatim for 3 seconds. 'action' rather than 'submit' — this sits behind a confirmation panel with nothing typed into it, so the 'submit' wording about a still-populated form would not be true
29576
30955
  }
29577
30956
  },
29578
30957
  error: () => {
@@ -29581,7 +30960,7 @@ class SubscriptionPageComponent {
29581
30960
  }
29582
30961
  });
29583
30962
  }
29584
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: SubscriptionPageComponent, deps: [{ token: HttpService }, { token: SubscriptionService }, { token: i2$1.MatSnackBar }], target: i0.ɵɵFactoryTarget.Component }); }
30963
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: SubscriptionPageComponent, deps: [{ token: HttpService }, { token: SubscriptionService }, { token: i2$1.MatSnackBar }, { token: ApiErrorService }], target: i0.ɵɵFactoryTarget.Component }); }
29585
30964
  static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: SubscriptionPageComponent, isStandalone: false, selector: "spa-subscription", ngImport: i0, template: `
29586
30965
  <div class="subscription-container">
29587
30966
  <h4 class="page-title"><mat-icon>card_membership</mat-icon> Subscription</h4>
@@ -29815,13 +31194,15 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
29815
31194
  </div>
29816
31195
  </div>
29817
31196
  `, standalone: false, styles: [".subscription-container{padding:16px;max-width:1200px}.page-title{display:flex;align-items:center;gap:8px;margin-bottom:16px;color:#333;font-weight:500}.section-title{margin:24px 0 16px;color:#555}.current-plan-card{margin-bottom:24px}.plan-avatar{font-size:40px;width:40px;height:40px;color:#1976d2}.plan-details{display:flex;gap:32px;flex-wrap:wrap;padding:8px 0}.detail-item{display:flex;flex-direction:column}.detail-label{font-size:12px;color:#888;text-transform:uppercase}.detail-value{font-size:16px;font-weight:500}.status-badge{padding:2px 10px;border-radius:12px;font-size:12px;font-weight:500}.status-0{background:#e3f2fd;color:#1565c0}.status-1{background:#e8f5e9;color:#2e7d32}.status-2{background:#fff3e0;color:#e65100}.status-3{background:#fce4ec;color:#c62828}.status-4{background:#f5f5f5;color:#616161}.no-plan-message{display:flex;align-items:center;gap:8px;color:#666;margin:0}.plans-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:16px}.plan-card{display:flex;flex-direction:column}.plan-card.current{border:2px solid #1976d2}.plan-price{text-align:center;padding:16px 0}.price-amount{font-size:32px;font-weight:700;color:#1976d2}.price-period{font-size:14px;color:#888}.plan-limits{display:flex;gap:16px;justify-content:center;padding-bottom:12px}.limit-item{display:flex;align-items:center;gap:4px;font-size:13px;color:#666}.limit-item mat-icon{font-size:18px;width:18px;height:18px}.feature-list{padding:12px 0}.feature-item{display:flex;align-items:center;gap:8px;padding:4px 0;font-size:14px}.feature-check{color:#4caf50;font-size:18px;width:18px;height:18px}.feature-limit{font-size:12px;color:#999}.no-features{display:flex;align-items:center;gap:4px;color:#999;font-size:13px}mat-card-actions{margin-top:auto}mat-card-actions button{width:100%}.overlay{position:fixed;inset:0;background:#0006;display:flex;align-items:center;justify-content:center;z-index:1000}.confirm-dialog{max-width:440px;width:90%}.price-info{font-size:16px}.trial-info{display:flex;align-items:center;gap:4px;color:#1976d2;font-size:14px}\n"] }]
29818
- }], ctorParameters: () => [{ type: HttpService }, { type: SubscriptionService }, { type: i2$1.MatSnackBar }] });
31197
+ }], ctorParameters: () => [{ type: HttpService }, { type: SubscriptionService }, { type: i2$1.MatSnackBar }, { type: ApiErrorService }] });
29819
31198
 
29820
31199
  // Billing page — invoice history table, invoice detail dialog, and payment flow dialog
29821
31200
  class BillingPageComponent {
29822
- constructor(httpService, snackBar) {
31201
+ constructor(httpService, snackBar, apiErrorService // Changed: injected so failures on this page are classified, not swallowed or echoed
31202
+ ) {
29823
31203
  this.httpService = httpService;
29824
31204
  this.snackBar = snackBar;
31205
+ this.apiErrorService = apiErrorService;
29825
31206
  this.currentSubscription = null;
29826
31207
  this.invoices = [];
29827
31208
  this.loading = true;
@@ -29845,6 +31226,9 @@ class BillingPageComponent {
29845
31226
  if (response?.success) {
29846
31227
  this.currentSubscription = response.data;
29847
31228
  }
31229
+ else {
31230
+ this.apiErrorService.presentAppFailure(response, 'load', 'subscriptions/current'); // Added: was silent — the subscription summary card simply did not render, so a tenant could not tell a broken read from having no plan
31231
+ }
29848
31232
  }
29849
31233
  });
29850
31234
  }
@@ -29855,6 +31239,9 @@ class BillingPageComponent {
29855
31239
  if (response?.success) {
29856
31240
  this.invoices = response.data || [];
29857
31241
  }
31242
+ else {
31243
+ this.apiErrorService.presentAppFailure(response, 'load', 'tenantinvoices/all/x'); // Added: was silent. An empty invoice list is a perfectly normal state for a new tenant, which is precisely why a FAILED one must not look the same — "you have no invoices" is a different fact from "we could not fetch them"
31244
+ }
29858
31245
  this.loading = false;
29859
31246
  },
29860
31247
  error: () => { this.loading = false; }
@@ -29884,7 +31271,7 @@ class BillingPageComponent {
29884
31271
  this.showDetail = true;
29885
31272
  }
29886
31273
  else {
29887
- this.snackBar.open(response?.message || 'Failed to load invoice detail', '', { duration: 3000 });
31274
+ this.apiErrorService.presentAppFailure(response, 'action', 'tenantinvoices/detail/' + invoice.tenantInvoiceID); // Changed: was a bare MatSnackBar carrying response.message — raw server text for 3 seconds, ApiErrorService bypassed entirely, the same shape Phase 3 removed from groups.component.ts
29888
31275
  }
29889
31276
  },
29890
31277
  error: () => {
@@ -29922,7 +31309,7 @@ class BillingPageComponent {
29922
31309
  this.paymentResult = { success: false, redirectUrl: response.data.redirectUrl };
29923
31310
  }
29924
31311
  else {
29925
- this.paymentResult = { success: false, message: response?.message || 'Payment failed' };
31312
+ this.paymentResult = { success: false, message: this.paymentFailureText(response, 'payments/process/' + this.paymentInvoice.tenantInvoiceID) }; // Changed: was `response?.message || 'Payment failed'` rendered verbatim at the template's failure state
29926
31313
  }
29927
31314
  },
29928
31315
  error: () => {
@@ -29931,6 +31318,24 @@ class BillingPageComponent {
29931
31318
  }
29932
31319
  });
29933
31320
  }
31321
+ // Added: the payment dialog is the one place in this sweep where a MODAL is the WRONG answer. It already
31322
+ // owns a dedicated failure state — error icon, red panel, inside the dialog the user is staring at — so
31323
+ // stacking presentAppFailure's dialog on top of it would talk over a surface that is already doing the job,
31324
+ // and would leave the panel underneath still showing the raw sentence anyway.
31325
+ //
31326
+ // What was actually wrong was the TEXT, not the surface. So the classifier is reused directly: a message a
31327
+ // backend author wrote for a human ("Insufficient funds", "Card declined") is exactly what someone paying an
31328
+ // invoice needs and is shown unchanged, while an implementation leak is replaced. The reference, when the
31329
+ // catch-all minted one, is kept — a failed payment is the single most likely thing a tenant phones about.
31330
+ paymentFailureText(response, url) {
31331
+ const raw = response?.message;
31332
+ console.error('[tin-spa] payment failure', { url, message: raw, response }); // the technical detail still reaches the console, exactly as presentAppFailure does
31333
+ if (this.apiErrorService.classifyMessage(raw) === 'business')
31334
+ return raw;
31335
+ const reference = this.apiErrorService.extractReference(raw);
31336
+ return `We couldn't complete that payment because of a technical problem — not anything you did wrong. Nothing was charged.` +
31337
+ (reference ? ` Please try again shortly, and quote reference ${reference} if you contact your administrator.` : ` Please try again shortly.`);
31338
+ }
29934
31339
  // Close the payment dialog
29935
31340
  closePayment() {
29936
31341
  this.showPayment = false;
@@ -29940,7 +31345,7 @@ class BillingPageComponent {
29940
31345
  this.loadInvoices(); // Refresh if payment was successful
29941
31346
  }
29942
31347
  }
29943
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: BillingPageComponent, deps: [{ token: HttpService }, { token: i2$1.MatSnackBar }], target: i0.ɵɵFactoryTarget.Component }); }
31348
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: BillingPageComponent, deps: [{ token: HttpService }, { token: i2$1.MatSnackBar }, { token: ApiErrorService }], target: i0.ɵɵFactoryTarget.Component }); }
29944
31349
  static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: BillingPageComponent, isStandalone: false, selector: "spa-billing", ngImport: i0, template: `
29945
31350
  <div class="billing-container">
29946
31351
  <h4 class="page-title"><mat-icon>receipt_long</mat-icon> Billing & Invoices</h4>
@@ -30354,7 +31759,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
30354
31759
  </div>
30355
31760
  </div>
30356
31761
  `, standalone: false, styles: [".billing-container{padding:16px;max-width:1200px}.page-title{display:flex;align-items:center;gap:8px;margin-bottom:16px;color:#333;font-weight:500}.summary-card{margin-bottom:24px}.summary-row{display:flex;gap:32px;flex-wrap:wrap}.summary-item{display:flex;flex-direction:column}.summary-label{font-size:12px;color:#888;text-transform:uppercase}.summary-value{font-size:16px;font-weight:500}.status-badge{padding:2px 10px;border-radius:12px;font-size:12px;font-weight:500}.status-0{background:#e3f2fd;color:#1565c0}.status-1{background:#e8f5e9;color:#2e7d32}.status-2{background:#fff3e0;color:#e65100}.status-3{background:#fce4ec;color:#c62828}.status-4{background:#f5f5f5;color:#616161}.invoices-card{margin-bottom:24px}.table-container{overflow-x:auto}.invoice-table,.transaction-table{width:100%;border-collapse:collapse}.invoice-table th,.invoice-table td,.transaction-table th,.transaction-table td{padding:10px 12px;text-align:left;border-bottom:1px solid #e0e0e0;font-size:14px}.invoice-table th,.transaction-table th{font-weight:500;color:#666;font-size:12px;text-transform:uppercase}.invoice-table tbody tr:hover{background:#f5f5f5}.invoice-status{padding:2px 8px;border-radius:10px;font-size:12px;font-weight:500}.inv-status-0{background:#fff3e0;color:#e65100}.inv-status-1{background:#e8f5e9;color:#2e7d32}.inv-status-2{background:#fce4ec;color:#c62828}.inv-status-3{background:#f3e5f5;color:#6a1b9a}.txn-status{padding:2px 8px;border-radius:10px;font-size:12px;font-weight:500}.txn-status-0{background:#e3f2fd;color:#1565c0}.txn-status-1{background:#fff3e0;color:#e65100}.txn-status-2{background:#e8f5e9;color:#2e7d32}.txn-status-3{background:#fce4ec;color:#c62828}.txn-status-4{background:#f5f5f5;color:#616161}.empty-state,.loading-state{display:flex;flex-direction:column;align-items:center;padding:32px;color:#999}.empty-state mat-icon{font-size:48px;width:48px;height:48px;margin-bottom:8px}.overlay{position:fixed;inset:0;background:#0006;display:flex;align-items:center;justify-content:center;z-index:1000}.detail-dialog{max-width:640px;width:90%;max-height:80vh;overflow-y:auto}.payment-dialog{max-width:440px;width:90%}.close-btn{position:absolute;top:8px;right:8px}.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px}.detail-field{display:flex;flex-direction:column}.field-label{font-size:12px;color:#888;text-transform:uppercase}.field-value{font-size:14px;font-weight:500}.transactions-section{margin-top:16px}.transactions-section h5{margin:0 0 8px;color:#555}.no-transactions{color:#999;font-size:13px}.payment-summary{margin-bottom:16px}.payment-summary p{margin:4px 0;font-size:15px}.payment-state{display:flex;flex-direction:column;align-items:center;padding:24px;text-align:center}.result-icon{font-size:48px;width:48px;height:48px;margin-bottom:8px}.success-icon{color:#4caf50}.redirect-icon{color:#1976d2}.error-icon{color:#f44336}.payment-ref{font-size:12px;color:#888}\n"] }]
30357
- }], ctorParameters: () => [{ type: HttpService }, { type: i2$1.MatSnackBar }] });
31762
+ }], ctorParameters: () => [{ type: HttpService }, { type: i2$1.MatSnackBar }, { type: ApiErrorService }] });
30358
31763
 
30359
31764
  const TENANCY_ROUTES = [
30360
31765
  { path: "settings", component: TenantSettingsComponent }, // Changed: Removed "tenant-" prefix — module path provides the namespace
@@ -30387,7 +31792,7 @@ class ApprovalsComponent {
30387
31792
  ngOnInit() {
30388
31793
  }
30389
31794
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: ApprovalsComponent, deps: [{ token: DataServiceLib }], target: i0.ɵɵFactoryTarget.Component }); }
30390
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: ApprovalsComponent, isStandalone: false, selector: "spa-approvals", ngImport: i0, template: "<h4>Approvals</h4>\n<hr>\n\n<mat-tab-group>\n <mat-tab label=\"Received\">\n <div class=\"mt-3\">\n <spa-table [config]=\"dataService.receivedApprovalsTableConfig\"></spa-table>\n </div>\n </mat-tab>\n <mat-tab label=\"Sent\">\n <div class=\"mt-3\">\n <spa-table [config]=\"dataService.sentApprovalsTableConfig\"></spa-table>\n </div>\n </mat-tab>\n</mat-tab-group>\n", styles: [""], dependencies: [{ kind: "component", type: i5$4.MatTab, selector: "mat-tab", inputs: ["disabled", "label", "aria-label", "aria-labelledby", "labelClass", "bodyClass", "id"], exportAs: ["matTab"] }, { kind: "component", type: i5$4.MatTabGroup, selector: "mat-tab-group", inputs: ["color", "fitInkBarToContent", "mat-stretch-tabs", "mat-align-tabs", "dynamicHeight", "selectedIndex", "headerPosition", "animationDuration", "contentTabIndex", "disablePagination", "disableRipple", "preserveContent", "backgroundColor", "aria-label", "aria-labelledby"], outputs: ["selectedIndexChange", "focusChange", "animationDone", "selectedTabChange"], exportAs: ["matTabGroup"] }, { 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"] }] }); }
31795
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: ApprovalsComponent, isStandalone: false, selector: "spa-approvals", ngImport: i0, template: "<h4>Approvals</h4>\n<hr>\n\n<mat-tab-group>\n <mat-tab label=\"Received\">\n <div class=\"mt-3\">\n <spa-table [config]=\"dataService.receivedApprovalsTableConfig\"></spa-table>\n </div>\n </mat-tab>\n <mat-tab label=\"Sent\">\n <div class=\"mt-3\">\n <spa-table [config]=\"dataService.sentApprovalsTableConfig\"></spa-table>\n </div>\n </mat-tab>\n</mat-tab-group>\n", styles: [""], dependencies: [{ kind: "component", type: i3$2.MatTab, selector: "mat-tab", inputs: ["disabled", "label", "aria-label", "aria-labelledby", "labelClass", "bodyClass", "id"], exportAs: ["matTab"] }, { kind: "component", type: i3$2.MatTabGroup, selector: "mat-tab-group", inputs: ["color", "fitInkBarToContent", "mat-stretch-tabs", "mat-align-tabs", "dynamicHeight", "selectedIndex", "headerPosition", "animationDuration", "contentTabIndex", "disablePagination", "disableRipple", "preserveContent", "backgroundColor", "aria-label", "aria-labelledby"], outputs: ["selectedIndexChange", "focusChange", "animationDone", "selectedTabChange"], exportAs: ["matTabGroup"] }, { 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"] }] }); }
30391
31796
  }
30392
31797
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: ApprovalsComponent, decorators: [{
30393
31798
  type: Component,
@@ -31293,6 +32698,7 @@ class PayrollModule {
31293
32698
  PayrollRunsComponent,
31294
32699
  CommissionConfigsComponent,
31295
32700
  CommissionEntriesComponent,
32701
+ CommissionStatementComponent,
31296
32702
  SalaryAdvancesComponent,
31297
32703
  OvertimeEntriesComponent,
31298
32704
  PayrollDashboardComponent], imports: [CommonModule,
@@ -31309,6 +32715,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
31309
32715
  PayrollRunsComponent,
31310
32716
  CommissionConfigsComponent,
31311
32717
  CommissionEntriesComponent,
32718
+ CommissionStatementComponent,
31312
32719
  SalaryAdvancesComponent,
31313
32720
  OvertimeEntriesComponent,
31314
32721
  PayrollDashboardComponent