tin-spa 20.14.22 → 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
  }
@@ -2432,7 +2857,7 @@ class OfflineService {
2432
2857
  if (!this.online$.value) {
2433
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)
2434
2859
  if (this.syncStarted)
2435
- 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
2436
2861
  }
2437
2862
  // Marry the two: whenever ping confirms we're reachable, revive SignalR if its own auto-reconnect gave up.
2438
2863
  // No-op when the hub is already connected, so this is safe to call on every online confirmation.
@@ -2468,10 +2893,10 @@ class OfflineService {
2468
2893
  this.zone.runOutsideAngular(() => {
2469
2894
  this.periodicTimer = setInterval(() => { if (this.online)
2470
2895
  this.hasPending().then(has => { if (has)
2471
- 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
2472
2897
  });
2473
2898
  if (this.online)
2474
- this.drain();
2899
+ this.drain(true); // Changed: auto=true — sync starting up is the app's decision, not the user's
2475
2900
  }
2476
2901
  // Added: reclaim work parked by a previous session. The sync engine otherwise only starts when a page happens
2477
2902
  // to register an offline table, so changes captured before a sign-out sat there until the user wandered back
@@ -2486,9 +2911,24 @@ class OfflineService {
2486
2911
  }
2487
2912
  catch { /* nothing parked, or the store isn't available */ }
2488
2913
  }
2489
- 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) {
2490
2920
  if (this.draining || !this.online)
2491
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;
2492
2932
  this.draining = true;
2493
2933
  const runner = () => this.drainLoop().finally(() => { this.draining = false; });
2494
2934
  const locks = navigator.locks;
@@ -2520,7 +2960,7 @@ class OfflineService {
2520
2960
  op.status = 'pending';
2521
2961
  op.attempts++;
2522
2962
  await this.outbox.put(op);
2523
- 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
2524
2964
  break; // strict FIFO: a transient failure halts the drain until backoff/reconnect
2525
2965
  }
2526
2966
  if (outcome === 'synced')
@@ -2542,6 +2982,7 @@ class OfflineService {
2542
2982
  const payload = { ...op.payload };
2543
2983
  if (op.refMap && Object.keys(op.refMap).length)
2544
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
2545
2986
  let response;
2546
2987
  try {
2547
2988
  response = await firstValueFrom(this.httpService.Post(op.url, payload));
@@ -2551,6 +2992,14 @@ class OfflineService {
2551
2992
  if (status === 0 || status === 408 || status === 429 || status >= 500) {
2552
2993
  if (status === 0)
2553
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;
2554
3003
  return 'transient';
2555
3004
  }
2556
3005
  return this.finishOp(op, 'failed', err?.message ?? `HTTP ${status}`);
@@ -2610,11 +3059,32 @@ class OfflineService {
2610
3059
  await this.outbox.put(op);
2611
3060
  }
2612
3061
  }
2613
- 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) {
2614
3073
  if (this.backoffTimer)
2615
3074
  clearTimeout(this.backoffTimer);
2616
- const cap = Math.min(5000 * Math.pow(2, attempts), 300000); // exponential 5s·2^n capped 5min, full jitter
2617
- 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);
2618
3088
  }
2619
3089
  //================ Sync-tray actions ================
2620
3090
  async retryOp(opId) {
@@ -2624,6 +3094,7 @@ class OfflineService {
2624
3094
  op.status = 'pending';
2625
3095
  op.attempts = 0;
2626
3096
  op.enqueuedAt = Date.now();
3097
+ this.retryNotBefore = 0; // Added: a deliberate user retry clears any server-stated wait, exactly as it clears attempts
2627
3098
  await this.outbox.put(op);
2628
3099
  this.drain();
2629
3100
  }
@@ -2644,6 +3115,7 @@ class OfflineService {
2644
3115
  op.status = 'pending';
2645
3116
  op.attempts = 0;
2646
3117
  op.enqueuedAt = Date.now();
3118
+ this.retryNotBefore = 0; // Added: same as retryOp — an informed, explicit choice is not an automatic drain
2647
3119
  await this.outbox.put(op);
2648
3120
  this.drain();
2649
3121
  }
@@ -2661,13 +3133,13 @@ class OfflineService {
2661
3133
  hash(v) { let h = 0; for (let i = 0; i < v.length; i++)
2662
3134
  h = ((h << 5) - h + v.charCodeAt(i)) | 0; return Math.abs(h).toString(36); }
2663
3135
  sanitize(v) { return v.replace(/[^a-zA-Z0-9_-]/g, '_').toLowerCase(); }
2664
- 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 }); }
2665
3137
  static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: OfflineService, providedIn: 'root' }); }
2666
3138
  }
2667
3139
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: OfflineService, decorators: [{
2668
3140
  type: Injectable,
2669
3141
  args: [{ providedIn: 'root' }]
2670
- }], 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: [{
2671
3143
  type: Optional
2672
3144
  }, {
2673
3145
  type: Inject,
@@ -3239,6 +3711,7 @@ class DataServiceLib {
3239
3711
  this.capPayrollRuns = new CapItem;
3240
3712
  this.capCommissionConfigs = new CapItem; // Changed: Added for Commission module
3241
3713
  this.capCommissionEntries = new CapItem; // Changed: Added for Commission module
3714
+ this.capCommissionStatement = new CapItem; // Added (Phase 6): Driver Commission Statement (cap86)
3242
3715
  this.capSalaryAdvances = new CapItem; // Changed: Added for Salary Advance module
3243
3716
  this.capOvertimeEntries = new CapItem; // Changed: Added for Overtime module
3244
3717
  this.capApprovals = new CapItem;
@@ -4254,7 +4727,7 @@ class DataServiceLib {
4254
4727
  this.capPayroll.display = "Payroll";
4255
4728
  this.capPayroll.moduleKey = "payroll"; // Added (Setup v3)
4256
4729
  this.capPayroll.icon = "payments";
4257
- 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
4258
4731
  this.capPayrollDashboard.name = "cap72"; // Changed: Reuses module cap number for dashboard visibility
4259
4732
  this.capPayrollDashboard.display = "Dashboard";
4260
4733
  this.capPayrollDashboard.link = "home/payroll/dashboard";
@@ -4281,6 +4754,14 @@ class DataServiceLib {
4281
4754
  this.capCommissionEntries.display = "Commission Entries";
4282
4755
  this.capCommissionEntries.link = "home/payroll/commission-entries";
4283
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";
4284
4765
  // Changed: Added Salary Advances sidebar item
4285
4766
  this.capSalaryAdvances.name = "cap78";
4286
4767
  this.capSalaryAdvances.display = "Salary Advances";
@@ -5863,7 +6344,12 @@ class InventoryService {
5863
6344
  heroField: 'requisitionID'
5864
6345
  };
5865
6346
  this.requisitionEditButton = { name: 'edit', dialog: true, action: { url: 'requisitions?action=edit', method: 'post' } };
5866
- 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 };
5867
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 };
5868
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 };
5869
6355
  this.requisitionDetailsConfig = {
@@ -6676,6 +7162,54 @@ class AccountingService {
6676
7162
  searchAction: { url: 'invoices/search?action=statement-search', method: 'post' }
6677
7163
  }
6678
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
+ };
6679
7213
  //--------------------------Tax Rates-------------------------
6680
7214
  // Changed: Tax Rate form configuration for VAT management
6681
7215
  this.taxRateFormConfig = {
@@ -8384,7 +8918,10 @@ class SalesService {
8384
8918
  heroField: 'salesOrderID'
8385
8919
  };
8386
8920
  this.salesOrderEditButton = { name: 'edit', dialog: true, action: { url: 'salesorders?action=edit', method: 'post' } };
8387
- 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 };
8388
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 };
8389
8926
  this.salesOrderDetailsConfig = {
8390
8927
  formConfig: this.salesOrderFormConfig,
@@ -8567,7 +9104,7 @@ class SalesService {
8567
9104
  heroField: 'saleID',
8568
9105
  stepConfig: this.saleStepConfig,
8569
9106
  buttons: [
8570
- { 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 },
8571
9108
  this.saleRecordPaymentButton
8572
9109
  ]
8573
9110
  };
@@ -9631,35 +10168,133 @@ class PayrollService {
9631
10168
  formConfig: this.employeeSalaryComponentFormConfig,
9632
10169
  };
9633
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' } };
9634
10175
  this.commissionConfigFormConfig = {
9635
10176
  security: { allow: [this.dataService.capCommissionConfigs] }, // Added: gate commission config form by commission configs cap
9636
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
+ },
9637
10191
  fields: [
9638
- { name: 'employeeID', type: 'select', alias: 'Employee', loadAction: { url: 'employees/list/x' }, required: true },
9639
- { name: 'name', type: 'text', required: true },
9640
- { name: 'metricType', alias: 'Metric Type', type: 'select', loadAction: { url: 'commissionconfigs/list/metric-type' }, required: true },
9641
- { name: 'rateType', alias: 'Rate Type', type: 'select', loadAction: { url: 'commissionconfigs/list/rate-type' }, required: true },
9642
- { 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"' },
9643
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' },
9644
10231
  ],
9645
10232
  loadAction: { url: 'commissionconfigs/id' },
9646
10233
  heroField: 'commissionConfigID',
9647
10234
  includeAudit: true,
9648
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' } };
9649
10279
  this.commissionConfigsTableConfig = {
9650
10280
  showFilter: true,
9651
10281
  flatButtons: true,
9652
- minColumns: ['employeeName', 'name', 'rate'],
10282
+ minColumns: ['scopeName', 'name', 'rate'],
9653
10283
  columns: [
9654
- { 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
+ },
9655
10287
  { name: 'name', type: 'text' },
9656
- { 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
9657
10289
  { name: 'rateTypeName', type: 'text', alias: 'Rate Type' },
9658
10290
  { name: 'rate', type: 'text', alias: 'Rate' },
10291
+ { name: 'tierCount', type: 'text', alias: 'Tiers' },
10292
+ { name: 'earnEventName', type: 'text', alias: 'Earned When' },
9659
10293
  { name: 'isActive', type: 'checkbox', alias: 'Active' },
9660
10294
  ],
9661
10295
  buttons: [
9662
- { name: 'create', display: 'Create', dialog: true, action: { url: 'commissionconfigs?action=create', method: 'post' } },
10296
+ this.createCommissionConfigButton,
10297
+ this.viewCommissionConfigButton,
9663
10298
  { name: 'edit', dialog: true, action: { url: 'commissionconfigs?action=edit', method: 'post' } },
9664
10299
  { name: 'delete', dialog: true, action: { url: 'commissionconfigs?action=delete', method: 'post' } },
9665
10300
  ],
@@ -9676,13 +10311,15 @@ class PayrollService {
9676
10311
  flatButtons: true,
9677
10312
  columns: [
9678
10313
  { name: 'name', type: 'text' },
9679
- { name: 'metricTypeName', type: 'text', alias: 'Metric Type' },
10314
+ { name: 'basisName', type: 'text', alias: 'Basis' }, // Changed (Phase 5): matches the main list
9680
10315
  { name: 'rateTypeName', type: 'text', alias: 'Rate Type' },
9681
10316
  { name: 'rate', type: 'text', alias: 'Rate' },
10317
+ { name: 'tierCount', type: 'text', alias: 'Tiers' },
9682
10318
  { name: 'isActive', type: 'checkbox', alias: 'Active' },
9683
10319
  ],
9684
10320
  buttons: [
9685
- { 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,
9686
10323
  { name: 'edit', dialog: true, action: { url: 'commissionconfigs?action=edit', method: 'post' } },
9687
10324
  { name: 'delete', dialog: true, action: { url: 'commissionconfigs?action=delete', method: 'post' } },
9688
10325
  ],
@@ -9690,15 +10327,30 @@ class PayrollService {
9690
10327
  formConfig: this.commissionConfigFormConfig,
9691
10328
  };
9692
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 };
9693
10344
  this.commissionEntryFormConfig = {
9694
10345
  security: { allow: [this.dataService.capCommissionEntries] }, // Added: gate commission entry form by commission entries cap
9695
10346
  title: 'Commission Entry',
9696
10347
  fields: [
9697
- { name: 'employeeID', type: 'select', alias: 'Employee', loadAction: { url: 'employees/list/x' }, required: true },
9698
- { name: 'periodYear', alias: 'Year', type: 'number', required: true },
9699
- { name: 'periodMonth', alias: 'Month', type: 'number', required: true },
9700
- { name: 'metricValue', alias: 'Metric Value', type: 'number', required: true },
9701
- { 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
9702
10354
  // Changed: sectioned — who earned what for which period is the entry; where it came from is provenance,
9703
10355
  // read only when a figure is being queried
9704
10356
  { name: 'source', type: 'section', alias: 'Source & Description', collapseOnView: true },
@@ -9710,23 +10362,70 @@ class PayrollService {
9710
10362
  heroField: 'commissionEntryID',
9711
10363
  includeAudit: true,
9712
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 };
9713
10399
  this.commissionEntriesTableConfig = {
9714
10400
  showFilter: true,
9715
10401
  flatButtons: true,
9716
- minColumns: ['employeeName', 'commissionAmount', 'periodYear'],
10402
+ minColumns: ['employeeName', 'commissionAmount', 'statusName'],
9717
10403
  columns: [
9718
10404
  { name: 'employeeName', type: 'text', alias: 'Employee' },
9719
10405
  { name: 'periodYear', type: 'text', alias: 'Year' },
9720
10406
  { name: 'periodMonth', type: 'text', alias: 'Month' },
9721
10407
  { name: 'metricValue', type: 'text', alias: 'Metric Value' },
9722
- { 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
9723
10419
  { name: 'description', type: 'text' },
9724
10420
  { name: 'sourceType', type: 'text', alias: 'Source' },
9725
10421
  ],
9726
10422
  buttons: [
9727
10423
  { name: 'create', display: 'Create', dialog: true, action: { url: 'commissionentries?action=create', method: 'post' } },
9728
- { name: 'edit', dialog: true, action: { url: 'commissionentries?action=edit', method: 'post' } },
9729
- { 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
9730
10429
  ],
9731
10430
  loadAction: { url: 'commissionentries/all/x' },
9732
10431
  formConfig: this.commissionEntryFormConfig,
@@ -9744,14 +10443,21 @@ class PayrollService {
9744
10443
  { name: 'periodYear', type: 'text', alias: 'Year' },
9745
10444
  { name: 'periodMonth', type: 'text', alias: 'Month' },
9746
10445
  { name: 'metricValue', type: 'text', alias: 'Metric Value' },
9747
- { 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' },
9748
10451
  { name: 'description', type: 'text' },
9749
10452
  { name: 'sourceType', type: 'text', alias: 'Source' },
9750
10453
  ],
9751
10454
  buttons: [
9752
10455
  { name: 'create', display: 'Create', dialog: true, action: { url: 'commissionentries?action=create', method: 'post' } },
9753
- { name: 'edit', dialog: true, action: { url: 'commissionentries?action=edit', method: 'post' } },
9754
- { 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 },
9755
10461
  ],
9756
10462
  loadAction: { url: 'commissionentries/x/x' }, loadCriteria: 'employee', loadIDField: 'employeeID',
9757
10463
  formConfig: this.commissionEntryFormConfig,
@@ -9877,6 +10583,10 @@ class PayrollService {
9877
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
9878
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
9879
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 };
9880
10590
  this.payrollRunFormConfig = {
9881
10591
  security: { allow: [this.dataService.capPayrollRuns] }, // Added: gate payroll run form by payroll runs cap
9882
10592
  title: 'Payroll Run',
@@ -9912,7 +10622,7 @@ class PayrollService {
9912
10622
  detailsConfig: {
9913
10623
  formConfig: this.payrollRunFormConfig,
9914
10624
  heroField: 'payrollRunID',
9915
- buttons: [this.submitPayrollButton, this.postPayrollButton, this.markPaidButton],
10625
+ buttons: [this.recalculatePayrollButton, this.submitPayrollButton, this.postPayrollButton, this.markPaidButton],
9916
10626
  tableConfigs: [this.payslipsChildTableConfig],
9917
10627
  }
9918
10628
  },
@@ -10280,6 +10990,13 @@ class TabService {
10280
10990
  this.dataService.CallApi(transformedAction, '', quiet ? { silent: true } : undefined).subscribe({
10281
10991
  // Changed: an unsuccessful response used to leave the badge silently blank with nothing recording that it
10282
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.
10283
11000
  next: (apiResponse) => {
10284
11001
  if (apiResponse.success) {
10285
11002
  tabCounts[tabIndex] = apiResponse.data;
@@ -10527,6 +11244,12 @@ class NotificationsService {
10527
11244
  // Changed: Uncommented HTTP poll for initial count on login
10528
11245
  loadNotifications() {
10529
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.
10530
11253
  if (apiResponse.success) {
10531
11254
  this.notificationCount.next(apiResponse.data);
10532
11255
  }
@@ -10547,9 +11270,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
10547
11270
 
10548
11271
  // Frontend service for Agent chat — state management, API calls, and SignalR subscriptions (renamed from AssistantService)
10549
11272
  class AgentService {
10550
- constructor(dataService, signalRService) {
11273
+ constructor(dataService, signalRService, apiErrorService // Changed: injected for loadMessages and deleteConversation only
11274
+ ) {
10551
11275
  this.dataService = dataService;
10552
11276
  this.signalRService = signalRService;
11277
+ this.apiErrorService = apiErrorService;
10553
11278
  this.messages = new BehaviorSubject([]);
10554
11279
  this.conversations = new BehaviorSubject([]);
10555
11280
  this.typing = new BehaviorSubject(false);
@@ -10595,6 +11320,9 @@ class AgentService {
10595
11320
  // Load agent config (greeting + suggested questions) from backend
10596
11321
  loadConfig() {
10597
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.
10598
11326
  if (res.success && res.data) {
10599
11327
  this.greeting.next(res.data.greeting || '');
10600
11328
  this.suggestedQuestions.next(res.data.suggestedQuestions || []);
@@ -10604,6 +11332,13 @@ class AgentService {
10604
11332
  // Load user's conversation list
10605
11333
  loadConversations() {
10606
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.
10607
11342
  if (res.success) {
10608
11343
  this.conversations.next(res.data || []);
10609
11344
  }
@@ -10613,6 +11348,12 @@ class AgentService {
10613
11348
  loadMessages(conversationId) {
10614
11349
  this.currentConversationId.next(conversationId);
10615
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`);
10616
11357
  if (res.success) {
10617
11358
  this.messages.next((res.data || []).map((m) => ({
10618
11359
  conversationId: m.conversationID,
@@ -10783,6 +11524,10 @@ class AgentService {
10783
11524
  this.messages.next([]);
10784
11525
  this.currentConversationId.next(null);
10785
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.
10786
11531
  if (res.success && res.data) {
10787
11532
  this.currentConversationId.next(res.data.conversationID);
10788
11533
  }
@@ -10791,6 +11536,12 @@ class AgentService {
10791
11536
  // Delete a conversation
10792
11537
  deleteConversation(id) {
10793
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`);
10794
11545
  if (res.success) {
10795
11546
  this.loadConversations();
10796
11547
  if (this.currentConversationId.value === id) {
@@ -10828,13 +11579,13 @@ class AgentService {
10828
11579
  }
10829
11580
  });
10830
11581
  }
10831
- 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 }); }
10832
11583
  static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: AgentService, providedIn: 'root' }); }
10833
11584
  }
10834
11585
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: AgentService, decorators: [{
10835
11586
  type: Injectable,
10836
11587
  args: [{ providedIn: 'root' }]
10837
- }], ctorParameters: () => [{ type: DataServiceLib }, { type: SignalRService }] });
11588
+ }], ctorParameters: () => [{ type: DataServiceLib }, { type: SignalRService }, { type: ApiErrorService }] });
10838
11589
 
10839
11590
  class AnalyticsService {
10840
11591
  constructor(router) {
@@ -10882,248 +11633,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
10882
11633
  args: [{ providedIn: 'root' }]
10883
11634
  }], ctorParameters: () => [{ type: i1$1.Router }] });
10884
11635
 
10885
- class ApiErrorService {
10886
- constructor(messageService) {
10887
- this.messageService = messageService;
10888
- // One dialog per KIND per window. Keyed by kind rather than by URL on purpose: when the backend goes down,
10889
- // twenty different URLs fail for one single reason, and the user needs to be told that reason once.
10890
- this.lastShownAt = new Map();
10891
- /** How long a given kind stays suppressed after being shown. The user described "the same 10 seconds or so". */
10892
- this.suppressWindowMs = 10000;
10893
- // Added: submits dedupe over a much shorter window than background reads. Long enough to swallow a
10894
- // double-click or a duplicated emission, short enough that a user who reads the dialog, dismisses it and
10895
- // deliberately tries again is told again rather than met with silence.
10896
- this.submitSuppressWindowMs = 1500;
10897
- //================ Application-level failures (HTTP 200 + success:false) ================
10898
- // Added: fragments that mark a message as machine-authored rather than human-authored. Matched
10899
- // case-insensitively against the whole message. The list is deliberately about IMPLEMENTATION LEAKS, not
10900
- // about severity — a leaked identifier is useless to the user however serious the underlying fault is.
10901
- this.opaqueMarkers = [
10902
- // NOTE: matched as a SUBSTRING, never by equality. TinWeb's catch-all now appends a correlation code —
10903
- // "Error processing request (reference K7M2QP4T)" — so an equality test would classify every catch-all
10904
- // failure as a business message and show the raw text, which is worse than the behaviour this replaces.
10905
- 'error processing request', // TinWeb BaseController/BaseController2/BaseService catch-all — by far the most common
10906
- 'invalid column name', // SQL leaking through after a schema/migration drift
10907
- 'sqlexception',
10908
- 'object reference not set',
10909
- 'an error occurred while', // EF Core's standard preamble ("...while updating the entries")
10910
- 'inner exception',
10911
- 'stack trace',
10912
- 'the underlying provider failed',
10913
- 'index was outside the bounds',
10914
- 'timeout expired',
10915
- 'exception', // catches DbUpdateException, InvalidOperationException, ... by suffix
10916
- // Added: raw SqlException text. TinCore's BaseRepository.SetErrorResp returns ex.InnerException.Message
10917
- // verbatim on HTTP 200, so a bad foreign key currently shows the user the database, table, column AND
10918
- // constraint names — jargon and a schema leak at once. These never carry a correlation reference, because
10919
- // they never reach the catch-all that mints one.
10920
- //
10921
- // Each fragment below is SQL Server's own wording. None of them can occur in a hand-authored message like
10922
- // "Registration number already in use" or "Customer not found", which is why they are matched as phrases
10923
- // rather than on single words such as "duplicate" or "constraint" that a human might legitimately write.
10924
- 'statement conflicted with the', // "The INSERT statement conflicted with the FOREIGN KEY constraint ..."
10925
- 'foreign key constraint',
10926
- 'unique key constraint',
10927
- 'primary key constraint',
10928
- 'check constraint',
10929
- 'cannot insert the value null',
10930
- 'string or binary data would be truncated',
10931
- 'violation of ', // "Violation of PRIMARY KEY constraint 'PK_...'"
10932
- 'duplicate key', // "Cannot insert duplicate key row in object ..."
10933
- ];
10934
- }
10935
- // Classification is by status code first, because that is the only signal that is reliable across browsers.
10936
- // `statusText === 'Unknown Error'` is kept as a secondary network signal since some browsers report a failed
10937
- // connection that way while still surfacing status 0.
10938
- classify(error) {
10939
- const status = error?.status ?? 0;
10940
- // status 0 means the request never reached a server: offline, DNS failure, connection refused, CORS block.
10941
- // This is the single most common case for a field user and the one that was rendering blank.
10942
- if (status === 0 || error?.statusText === 'Unknown Error') {
10943
- return {
10944
- kind: 'network',
10945
- title: 'No connection',
10946
- message: `We couldn't reach the server. Please check your internet connection and try again.\n\n` +
10947
- `If your connection is working, the service may be temporarily unavailable — your work is not lost, and you can try again in a moment.`,
10948
- };
10949
- }
10950
- if (status === 408 || status === 504) {
10951
- return {
10952
- kind: 'timeout',
10953
- title: 'Taking too long',
10954
- message: `The server is taking longer than expected to respond.\n\nPlease wait a moment and try again.`,
10955
- };
10956
- }
10957
- if (status === 503) {
10958
- return {
10959
- kind: 'unavailable',
10960
- title: 'Service unavailable',
10961
- message: `The service is temporarily unavailable, usually because it is being updated.\n\nPlease try again in a few minutes.`,
10962
- };
10963
- }
10964
- // A 400 is almost never the user's doing — the app sent a payload the server rejected. Saying "check your
10965
- // input" would send them hunting for a mistake they did not make, so this points at support instead.
10966
- if (status === 400 || status === 422) {
10967
- return {
10968
- kind: 'badrequest',
10969
- title: 'Something went wrong',
10970
- message: `We couldn't complete that action because of a technical problem in the app — not anything you did wrong.\n\n` +
10971
- `Please try again. If it keeps happening, contact your administrator so it can be looked into.`,
10972
- };
10973
- }
10974
- if (status === 403) {
10975
- return {
10976
- kind: 'forbidden',
10977
- title: 'Not allowed',
10978
- message: `You don't have permission to do that.\n\nIf you think you should, please contact your administrator.`,
10979
- };
10980
- }
10981
- if (status === 404) {
10982
- return {
10983
- kind: 'notfound',
10984
- title: 'Not found',
10985
- message: `We couldn't find what you were looking for. It may have been moved or deleted by someone else.\n\n` +
10986
- `Try refreshing the page. If it keeps happening, contact your administrator.`,
10987
- };
10988
- }
10989
- if (status >= 500) {
10990
- return {
10991
- kind: 'server',
10992
- title: 'Something went wrong',
10993
- message: `The server ran into a problem while handling your request.\n\n` +
10994
- `Please try again shortly. If it keeps happening, contact your administrator.`,
10995
- };
10996
- }
10997
- return {
10998
- kind: 'unknown',
10999
- title: 'Something went wrong',
11000
- message: `We couldn't complete that action.\n\nPlease try again. If it keeps happening, contact your administrator.`,
11001
- };
11002
- }
11003
- /** True when this kind was already shown inside the suppression window — i.e. the user has already been told. */
11004
- // Changed: window is now a parameter (defaulting to the existing suppressWindowMs, so present() is unaffected)
11005
- // because a submit failure needs a much shorter one than a burst of background reads — see presentAppFailure().
11006
- isSuppressed(kind, windowMs = this.suppressWindowMs) {
11007
- const last = this.lastShownAt.get(kind);
11008
- return last != null && (Date.now() - last) < windowMs;
11009
- }
11010
- // Presents at most ONE dialog per kind per window. The technical detail still goes to the console, where a
11011
- // developer wants it, so nothing is lost by keeping it out of the dialog.
11012
- present(error) {
11013
- const info = this.classify(error);
11014
- // Added: a 5xx from TinWeb's exception middleware now carries "(reference X)" in its body Message —
11015
- // pull it out so the dialog can quote it as a footnote, exactly as presentAppFailure() does for the
11016
- // 200-with-success:false catch-alls. Only the 'server' kind can carry one; a transport failure has no
11017
- // body at all (error.error is a ProgressEvent), which extractReference() tolerates as null.
11018
- if (info.kind === 'server') {
11019
- const body = error?.error;
11020
- info.reference = this.extractReference(body?.Message ?? body?.message) ?? undefined;
11021
- }
11022
- 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
11023
- if (this.isSuppressed(info.kind))
11024
- return info; // already told them — a second dialog adds nothing
11025
- this.lastShownAt.set(info.kind, Date.now());
11026
- this.messageService.errorWithSubject(info.title, info.message, info.reference); // Changed: pass the reference through so the dialog renders the quiet footnote
11027
- return info;
11028
- }
11029
- // Added: splits the server's message into 'opaque' (tells the user nothing, must not be shown raw) and
11030
- // 'business' (a deliberate sentence the backend author wrote for a human, genuinely worth reading).
11031
- //
11032
- // Written as a deny-list rather than an allow-list on purpose: business messages are open-ended and
11033
- // app-specific, so there is nothing to enumerate, whereas the ways a .NET exception leaks are few and stable.
11034
- // The consequence of the default is the safe one — an unrecognised message is treated as business and shown,
11035
- // so a real message is never swallowed; only recognisably technical text is replaced.
11036
- classifyMessage(message) {
11037
- const text = (message ?? '').trim();
11038
- if (!text)
11039
- return 'opaque'; // nothing to show — a blank toast is the very failure mode this service exists to kill
11040
- const lower = text.toLowerCase();
11041
- if (this.opaqueMarkers.some(marker => lower.includes(marker)))
11042
- return 'opaque';
11043
- if (/\bsystem\.[a-z]/i.test(text))
11044
- return 'opaque'; // a .NET namespace ("System.NullReferenceException")
11045
- if (/\bat\s+[\w.]+\s*\(/.test(text))
11046
- return 'opaque'; // a stack frame ("at Namespace.Method(")
11047
- if (text.length > 300)
11048
- return 'opaque'; // no human writes a 300-char error; this is a dump
11049
- return 'business';
11050
- }
11051
- // Added: pulls TinWeb's correlation code out of a catch-all message. The backend writes the same code into
11052
- // its log line as "[ref XXXXXXXX]", so quoting it in the dialog is what lets support jump straight to the log
11053
- // row instead of hunting by timestamp.
11054
- //
11055
- // 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
11056
- // characters — but the pattern accepts 6-12 so a future length change does not silently stop matching. The
11057
- // reference is only ever EXTRACTED here; the rest of the raw message is still discarded, never shown.
11058
- extractReference(message) {
11059
- const match = (message ?? '').match(/\breference\s+([A-Z2-9]{6,12})\b/i);
11060
- return match ? match[1].toUpperCase() : null;
11061
- }
11062
- // Added: the sibling to present() for failures that arrive as HTTP 200 with success:false.
11063
- //
11064
- // Opaque -> the friendly dialog, through the SAME lastShownAt suppression map present() uses, so a burst
11065
- // (a dashboard firing twenty calls at a broken backend) still yields exactly one dialog.
11066
- // Business -> the existing toast, unchanged in weight. Short, meaningful, non-blocking.
11067
- //
11068
- // Why 'submit' gets its own dedupe key rather than sharing with loads: suppression exists to collapse a BURST
11069
- // of background calls that all failed for one reason. A create/edit submit is not part of a burst — it is one
11070
- // deliberate action, and it is the exact case where a missed message leads the user to press the button again
11071
- // and duplicate the record. Sharing a key would let unrelated background load failures suppress the dialog
11072
- // for the one interaction that most needs it. Keeping a (short) window on the submit key still collapses a
11073
- // double-click, while letting a genuine retry a few seconds later speak up again.
11074
- presentAppFailure(response, context = 'action', url) {
11075
- const message = response?.message;
11076
- const failureClass = this.classifyMessage(message);
11077
- // Always log the raw response and URL, exactly as present() does — the developer loses nothing by the
11078
- // dialog being friendly, because the technical detail is right here.
11079
- const reference = this.extractReference(message); // Added: null for SqlException leaks and older backends, which never mint one
11080
- 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
11081
- if (failureClass === 'business') {
11082
- // Changed: dropped the "Error: " prefix that every call site used to prepend. Verified nothing depends on
11083
- // it — no test in ng-space or any of the four consumer apps asserts on the literal "Error: ". The prefix
11084
- // only ever restated what the red-tinted message already conveyed, and it pushed the useful words right.
11085
- this.messageService.toast(message);
11086
- return failureClass;
11087
- }
11088
- const kind = context === 'submit' ? 'appfailure-submit' : 'appfailure';
11089
- if (this.isSuppressed(kind, context === 'submit' ? this.submitSuppressWindowMs : this.suppressWindowMs))
11090
- return failureClass;
11091
- this.lastShownAt.set(kind, Date.now());
11092
- // Copy matches classify() above: name the problem, absolve the user, say what to do next. The submit
11093
- // variant adds the two things that specifically stop a duplicate — that the data is still there, and that
11094
- // pressing the button again is not the fix.
11095
- // Changed: the reference is passed as its own argument rather than appended to the body, so the dialog can
11096
- // render it as a quiet footnote. It is omitted entirely when there isn't one — a SqlException leak and an
11097
- // older backend both arrive without a reference, and neither should show a dangling "quote reference" line.
11098
- if (context === 'submit') {
11099
- this.messageService.errorWithSubject('Not saved', `We couldn't save that because of a technical problem — not anything you did wrong.\n\n` +
11100
- `Nothing was saved, and your details are still on the form, so you can try again without re-entering them.\n\n` +
11101
- `If it keeps happening, please contact your administrator rather than submitting repeatedly, as that can create duplicates.`, reference ?? undefined);
11102
- }
11103
- else {
11104
- this.messageService.errorWithSubject('Something went wrong', `We couldn't complete that action because of a technical problem — not anything you did wrong.\n\n` +
11105
- `Please try again. If it keeps happening, contact your administrator.`, reference ?? undefined);
11106
- }
11107
- return failureClass;
11108
- }
11109
- /** Clears suppression so the next failure speaks up again — call after a successful request or reconnect. */
11110
- reset() {
11111
- this.lastShownAt.clear();
11112
- }
11113
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: ApiErrorService, deps: [{ token: MessageService }], target: i0.ɵɵFactoryTarget.Injectable }); }
11114
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: ApiErrorService, providedIn: 'root' }); }
11115
- }
11116
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: ApiErrorService, decorators: [{
11117
- type: Injectable,
11118
- args: [{ providedIn: 'root' }]
11119
- }], ctorParameters: () => [{ type: MessageService }] });
11120
-
11121
11636
  // Setup readiness ("Getting Started") service — NotificationsService mirror without SignalR.
11122
11637
  // Dormant unless appConfig.setupConfig.enabled; API failures leave count at 0 so the badge stays hidden.
11123
11638
  // v2 adds the module catalog (drives step + nav-menu gating) and predefined role templates.
11124
11639
  class SetupService {
11125
- constructor(dataService) {
11640
+ constructor(dataService, apiErrorService) {
11126
11641
  this.dataService = dataService;
11642
+ this.apiErrorService = apiErrorService;
11127
11643
  this.pendingCount = new BehaviorSubject(0);
11128
11644
  this.pendingCount$ = this.pendingCount.asObservable();
11129
11645
  this.status = new BehaviorSubject(null);
@@ -11131,7 +11647,7 @@ class SetupService {
11131
11647
  // Added (v2): module catalog — empty means the app has no module concept (nothing is ever hidden)
11132
11648
  this.modules = new BehaviorSubject([]);
11133
11649
  this.modules$ = this.modules.asObservable();
11134
- }
11650
+ } // Changed: injected for loadStatus only
11135
11651
  get enabled() {
11136
11652
  return !!this.dataService.appConfig?.setupConfig?.enabled;
11137
11653
  }
@@ -11141,6 +11657,11 @@ class SetupService {
11141
11657
  return;
11142
11658
  this.dataService.CallApi({ url: 'setup/count', skipCache: true }).subscribe({
11143
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.
11144
11665
  if (apiResponse.success)
11145
11666
  this.pendingCount.next(apiResponse.data);
11146
11667
  },
@@ -11153,6 +11674,13 @@ class SetupService {
11153
11674
  return;
11154
11675
  this.dataService.CallApi({ url: 'setup/status', skipCache: true }).subscribe({
11155
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');
11156
11684
  if (apiResponse.success) {
11157
11685
  this.status.next(apiResponse.data);
11158
11686
  this.pendingCount.next(apiResponse.data?.pending ?? 0);
@@ -11168,6 +11696,10 @@ class SetupService {
11168
11696
  return;
11169
11697
  this.dataService.CallApi({ url: 'setup/modules', skipCache: true }).subscribe({
11170
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.
11171
11703
  if (apiResponse.success)
11172
11704
  this.modules.next(apiResponse.data ?? []);
11173
11705
  },
@@ -11196,10 +11728,33 @@ class SetupService {
11196
11728
  createRoles(keys) {
11197
11729
  return this.dataService.CallApi({ url: 'setup/roles', method: 'post', skipCache: true }, { keys });
11198
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
+ }
11199
11754
  refresh() {
11200
11755
  this.loadStatus();
11201
11756
  }
11202
- 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 }); }
11203
11758
  static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: SetupService, providedIn: 'root' }); }
11204
11759
  }
11205
11760
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: SetupService, decorators: [{
@@ -11207,7 +11762,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
11207
11762
  args: [{
11208
11763
  providedIn: 'root'
11209
11764
  }]
11210
- }], ctorParameters: () => [{ type: DataServiceLib }] });
11765
+ }], ctorParameters: () => [{ type: DataServiceLib }, { type: ApiErrorService }] });
11211
11766
 
11212
11767
  // Added: the app-wide App Configuration record, loaded once and shared. Apps used to each solve this
11213
11768
  // themselves — Shift kept a copy in localStorage that went stale the moment anyone saved on another
@@ -11231,6 +11786,11 @@ class ConfigService {
11231
11786
  this.loaded = true;
11232
11787
  this.dataService.CallApi({ url: 'configuration/get', skipCache: true }).subscribe({
11233
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.
11234
11794
  if (apiResponse.success)
11235
11795
  this.config.next(apiResponse.data || {});
11236
11796
  },
@@ -11429,6 +11989,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
11429
11989
  type: Input
11430
11990
  }] } });
11431
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}';
11432
12000
  // Getting Started page — renders the per-app setup readiness catalog (top-down groups, no wizard).
11433
12001
  // Step actions come from appConfig.setupConfig.stepActions (create dialog Button, route link, Excel
11434
12002
  // import wizard, or the predefined role-template picker). v2 adds the module picker: optional modules
@@ -11439,6 +12007,7 @@ class SetupGuideComponent {
11439
12007
  this.setupService = inject(SetupService);
11440
12008
  this.dialogService = inject(DialogService);
11441
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
11442
12011
  this.status = null;
11443
12012
  this.groups = []; // Changed: each group carries a spa-checklist config instead of raw steps
11444
12013
  this.modules = [];
@@ -11449,6 +12018,49 @@ class SetupGuideComponent {
11449
12018
  this.roleTemplates = [];
11450
12019
  this.selectedTemplates = {};
11451
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];
11452
12064
  //---------- Demo data ----------
11453
12065
  // Added: seed/remove example records. This used to sit on each app's hand-written App Configuration page,
11454
12066
  // which is where a user looking for it would never think to look — filling the system with example data is
@@ -11467,6 +12079,10 @@ class SetupGuideComponent {
11467
12079
  this.buildGroups();
11468
12080
  if (this.hasRoleTemplatesStep())
11469
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); });
11470
12086
  });
11471
12087
  this.setupService.loadStatus();
11472
12088
  }
@@ -11490,9 +12106,13 @@ class SetupGuideComponent {
11490
12106
  buildActions(step) {
11491
12107
  const act = this.getAction(step);
11492
12108
  const actions = [];
12109
+ const picker = this.pickerFor(step); // Changed (presets P4): whichever picker owns this step, if any
11493
12110
  if (this.hasRoleTemplates(step)) {
11494
12111
  actions.push({ type: 'custom', display: 'Create selected roles', primary: true, disabled: () => this.selectedTemplateCount === 0 || this.creatingRoles, onClick: () => this.createSelectedRoles() });
11495
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
+ }
11496
12116
  else if (act.button) {
11497
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
11498
12118
  }
@@ -11573,9 +12193,13 @@ class SetupGuideComponent {
11573
12193
  this.togglingKey = '';
11574
12194
  if (resp.success)
11575
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.
11576
12199
  else {
11577
12200
  mod.enabled = !target;
11578
12201
  this.recountModuleGroups();
12202
+ this.apiErrorService.presentAppFailure(resp, 'action', 'setup/module');
11579
12203
  }
11580
12204
  },
11581
12205
  error: () => { this.togglingKey = ''; mod.enabled = !target; this.recountModuleGroups(); }
@@ -11620,8 +12244,10 @@ class SetupGuideComponent {
11620
12244
  loadRoleTemplates() {
11621
12245
  this.setupService.loadRoleTemplates().subscribe({
11622
12246
  next: resp => {
11623
- if (!resp.success)
12247
+ if (!resp.success) {
12248
+ this.apiErrorService.presentAppFailure(resp, 'load', 'setup/roles');
11624
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"
11625
12251
  this.roleTemplates = resp.data || [];
11626
12252
  // Preselect everything not yet created — the common case is "give me all of these"
11627
12253
  this.roleTemplates.forEach(t => { if (this.selectedTemplates[t.key] === undefined)
@@ -11633,11 +12259,120 @@ class SetupGuideComponent {
11633
12259
  get selectedTemplateCount() {
11634
12260
  return this.roleTemplates.filter(t => !t.exists && this.selectedTemplates[t.key]).length;
11635
12261
  }
11636
- // Added: whole-row toggle for the restyled picker (state-icon rows replaced the checkboxes)
11637
- toggleTemplate(tpl) {
11638
- 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)
11639
12344
  return;
11640
- 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
+ });
11641
12376
  }
11642
12377
  get showDemoData() {
11643
12378
  return !!this.dataService.appConfig?.setupConfig?.demoData;
@@ -11658,9 +12393,16 @@ class SetupGuideComponent {
11658
12393
  this.dataService.CallApi({ url, method: 'post' }, {}).subscribe({
11659
12394
  next: (response) => {
11660
12395
  this.demoBusy = false;
11661
- this.messageService.toast(response.success ? success : (response.message || 'Demo data action failed'));
11662
- if (response.success)
11663
- 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);
11664
12406
  },
11665
12407
  error: () => this.demoBusy = false
11666
12408
  });
@@ -11678,16 +12420,19 @@ class SetupGuideComponent {
11678
12420
  this.loadRoleTemplates(); // refresh the Created chips
11679
12421
  this.setupService.refresh();
11680
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
+ }
11681
12426
  },
11682
12427
  error: () => this.creatingRoles = false
11683
12428
  });
11684
12429
  }
11685
12430
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: SetupGuideComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
11686
- 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: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}.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%}}\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"] }] }); }
11687
12432
  }
11688
12433
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: SetupGuideComponent, decorators: [{
11689
12434
  type: Component,
11690
- 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: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}.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%}}\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"] }]
11691
12436
  }] });
11692
12437
 
11693
12438
  // Quiet Loading — perceived-progress engine (FSD D1).
@@ -12751,7 +13496,7 @@ class DateComponent {
12751
13496
  return "";
12752
13497
  }
12753
13498
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: DateComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
12754
- 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"] }] }); }
12755
13500
  }
12756
13501
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: DateComponent, decorators: [{
12757
13502
  type: Component,
@@ -13525,7 +14270,7 @@ class EditorComponent {
13525
14270
  this.valueChange.emit(val);
13526
14271
  }
13527
14272
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: EditorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
13528
- 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"] }] }); }
13529
14274
  }
13530
14275
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: EditorComponent, decorators: [{
13531
14276
  type: Component,
@@ -14142,6 +14887,23 @@ class TilesComponent {
14142
14887
  isHidden(tile) {
14143
14888
  return !Core.isItemVisible(tile, this.data); // Changed: unified visible/hidden replaces hidden + hiddenCondition
14144
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
+ }
14145
14907
  // Changed: Returns true only when data[tile.name] is a displayable primitive and not already shown by the chart
14146
14908
  isTileValuePrimitive(tile) {
14147
14909
  if (tile.chart)
@@ -14306,11 +15068,11 @@ class TilesComponent {
14306
15068
  return [];
14307
15069
  }
14308
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 }); }
14309
- 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 }); }
14310
15072
  }
14311
15073
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: TilesComponent, decorators: [{
14312
15074
  type: Component,
14313
- 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"] }]
14314
15076
  }], ctorParameters: () => [{ type: DataServiceLib }, { type: MessageService }, { type: i0.ChangeDetectorRef }, { type: ApiErrorService }], propDecorators: { tileScroller: [{
14315
15077
  type: ViewChild,
14316
15078
  args: ['tileScroller']
@@ -16329,11 +17091,11 @@ class GroupsComponent {
16329
17091
  });
16330
17092
  }
16331
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 }); }
16332
- 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" }] }); }
16333
17095
  }
16334
17096
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: GroupsComponent, decorators: [{
16335
17097
  type: Component,
16336
- 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"] }]
16337
17099
  }], ctorParameters: () => [{ type: ConditionService }, { type: ButtonService }, { type: DataServiceLib }, { type: i2$1.MatSnackBar }, { type: ApiErrorService }], propDecorators: { config: [{
16338
17100
  type: Input
16339
17101
  }], dataSource: [{
@@ -16715,6 +17477,25 @@ class TableComponent {
16715
17477
  startInlineEdit(row) {
16716
17478
  this.editingRow = row;
16717
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
+ }
16718
17499
  }
16719
17500
  cancelInlineEdit() {
16720
17501
  this.editingRow = null;
@@ -17656,11 +18437,11 @@ class TableComponent {
17656
18437
  this.setPaginator();
17657
18438
  }
17658
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 }); }
17659
- 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" }] }); }
17660
18441
  }
17661
18442
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: TableComponent, decorators: [{
17662
18443
  type: Component,
17663
- 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"] }]
17664
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: [{
17665
18446
  type: Optional
17666
18447
  }, {
@@ -17777,8 +18558,10 @@ class DayBookComponent {
17777
18558
  next: (response) => {
17778
18559
  this.loading = false;
17779
18560
  this.setShowLoading(false);
17780
- if (!response.success)
18561
+ if (!response.success) {
18562
+ this.apiErrorService.presentAppFailure(response, 'load', this.config.loadAction.url);
17781
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
17782
18565
  this.book = response.data;
17783
18566
  this.rebuild();
17784
18567
  },
@@ -18444,8 +19227,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
18444
19227
  }] } });
18445
19228
 
18446
19229
  class NotesComponent {
18447
- constructor(dataService) {
19230
+ constructor(dataService, apiErrorService) {
18448
19231
  this.dataService = dataService;
19232
+ this.apiErrorService = apiErrorService;
18449
19233
  // Input properties
18450
19234
  this.title = "Notes";
18451
19235
  this.notes = []; // Data array if provided directly
@@ -18453,7 +19237,7 @@ class NotesComponent {
18453
19237
  this.nameField = "createdByName";
18454
19238
  this.dateField = "createdDate";
18455
19239
  this.commentField = "details";
18456
- }
19240
+ } // Changed: injected so a failed note load says so
18457
19241
  ngOnInit() {
18458
19242
  this.loadNotes();
18459
19243
  }
@@ -18480,19 +19264,25 @@ class NotesComponent {
18480
19264
  url = url.replace('/x', '/' + idValue);
18481
19265
  }
18482
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);
18483
19273
  if (apiResponse.success && apiResponse.data) {
18484
19274
  this.notes = apiResponse.data;
18485
19275
  }
18486
19276
  });
18487
19277
  }
18488
19278
  }
18489
- 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 }); }
18490
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" }] }); }
18491
19281
  }
18492
19282
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: NotesComponent, decorators: [{
18493
19283
  type: Component,
18494
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"] }]
18495
- }], ctorParameters: () => [{ type: DataServiceLib }], propDecorators: { title: [{
19285
+ }], ctorParameters: () => [{ type: DataServiceLib }, { type: ApiErrorService }], propDecorators: { title: [{
18496
19286
  type: Input
18497
19287
  }], notes: [{
18498
19288
  type: Input
@@ -18972,6 +19762,7 @@ class AppConfigurationComponent {
18972
19762
  this.messageService = inject(MessageService);
18973
19763
  this.authService = inject(AuthService);
18974
19764
  this.configService = inject(ConfigService);
19765
+ this.apiErrorService = inject(ApiErrorService); // Added: the load and the save both needed a real failure surface
18975
19766
  this.router = inject(Router); // Added (v3)
18976
19767
  this.configuration = {};
18977
19768
  this.loading = false;
@@ -19009,8 +19800,10 @@ class AppConfigurationComponent {
19009
19800
  this.dataService.CallApi(this.config?.loadAction || { url: 'configuration/get' }).subscribe({
19010
19801
  next: (response) => {
19011
19802
  this.loading = false;
19012
- if (!response.success)
19803
+ if (!response.success) {
19804
+ this.apiErrorService.presentAppFailure(response, 'load', (this.config?.loadAction || { url: 'configuration/get' }).url);
19013
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
19014
19807
  this.configuration = response.data || {}; // the backend creates the row on first read, so this is never empty in practice
19015
19808
  this.buildCards();
19016
19809
  },
@@ -19068,7 +19861,10 @@ class AppConfigurationComponent {
19068
19861
  this.configService.refresh(); // every app-side subscriber (nav gating, unit suffixes) sees the change now, not next login
19069
19862
  }
19070
19863
  else {
19071
- 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);
19072
19868
  }
19073
19869
  },
19074
19870
  error: () => this.saving = false
@@ -20581,7 +21377,7 @@ class StepsComponent {
20581
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 }); }
20582
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: [{
20583
21379
  provide: STEPPER_GLOBAL_OPTIONS, useValue: { displayDefaultIndicatorType: false }
20584
- }], 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"] }] }); }
20585
21381
  }
20586
21382
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: StepsComponent, decorators: [{
20587
21383
  type: Component,
@@ -21135,14 +21931,39 @@ class LoaderInterceptor {
21135
21931
  complete: () => observer.complete()
21136
21932
  });
21137
21933
  }
21138
- else {
21934
+ else if (result === 'rejected') {
21139
21935
  this.refreshResult$.next('FAILED'); // Unblock waiting requests
21140
- // Changed: gentle toast + login redirect instead of error dialog; on 'network' the refresh
21141
- // 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).
21142
21940
  this.authService.sessionExpired();
21143
21941
  this.messageService.toast('Please sign in to continue');
21144
21942
  observer.error(new Error('Token refresh failed'));
21145
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
+ }
21146
21967
  });
21147
21968
  });
21148
21969
  }
@@ -21390,7 +22211,7 @@ class TabsComponent {
21390
22211
  return this.reload || this.tableReloads[index];
21391
22212
  }
21392
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 }); }
21393
- 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"] }] }); }
21394
22215
  }
21395
22216
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: TabsComponent, decorators: [{
21396
22217
  type: Component,
@@ -22225,10 +23046,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
22225
23046
  }] }] });
22226
23047
 
22227
23048
  class InvitationsTableComponent {
22228
- constructor(dataService, messageService, authService) {
23049
+ constructor(dataService, messageService, authService, apiErrorService) {
22229
23050
  this.dataService = dataService;
22230
23051
  this.messageService = messageService;
22231
23052
  this.authService = authService;
23053
+ this.apiErrorService = apiErrorService;
22232
23054
  this.tableReload = new Subject();
22233
23055
  this.invitationsTableConfig = {
22234
23056
  greyOut: (value) => value.accepted == false,
@@ -22246,7 +23068,7 @@ class InvitationsTableComponent {
22246
23068
  ],
22247
23069
  loadAction: { url: 'tenants/invitations/x' },
22248
23070
  };
22249
- }
23071
+ } // Changed: injected ApiErrorService for the two membership actions
22250
23072
  ngOnInit() {
22251
23073
  }
22252
23074
  invActionClicked(x) {
@@ -22272,21 +23094,27 @@ class InvitationsTableComponent {
22272
23094
  this.messageService.toast("Switched Successfully, please login again");
22273
23095
  this.authService.logoff();
22274
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
+ }
22275
23100
  });
22276
23101
  }
22277
23102
  });
22278
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
+ }
22279
23107
  });
22280
23108
  }
22281
23109
  });
22282
23110
  }
22283
- 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 }); }
22284
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"] }] }); }
22285
23113
  }
22286
23114
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: InvitationsTableComponent, decorators: [{
22287
23115
  type: Component,
22288
23116
  args: [{ selector: 'spa-invitations-table', standalone: false, template: "<spa-table [config]=\"invitationsTableConfig\" (actionClick)=\"invActionClicked($event)\" [reload]=\"tableReload\"></spa-table>\n" }]
22289
- }], ctorParameters: () => [{ type: DataServiceLib }, { type: MessageService }, { type: AuthService }] });
23117
+ }], ctorParameters: () => [{ type: DataServiceLib }, { type: MessageService }, { type: AuthService }, { type: ApiErrorService }] });
22290
23118
 
22291
23119
  // Generic AI-assisted import wizard: Upload -> Map -> Review -> Done. Config-driven via ImportConfig.
22292
23120
  class ImportDialogComponent {
@@ -22313,10 +23141,12 @@ class ImportDialogComponent {
22313
23141
  rebuildReviewRows() {
22314
23142
  this.reviewRows = this.rows.map(r => ({ rowNumber: r.rowNumber, statusName: r.statusName, status: r.status, issueText: this.rowIssueText(r), ...(r.values || {}), __row: r }));
22315
23143
  }
22316
- 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) {
22317
23146
  this.httpService = httpService;
22318
23147
  this.http = http;
22319
23148
  this.messageService = messageService;
23149
+ this.apiErrorService = apiErrorService;
22320
23150
  this.dialogRef = dialogRef;
22321
23151
  this.config = config;
22322
23152
  this.loading = false;
@@ -22368,6 +23198,10 @@ class ImportDialogComponent {
22368
23198
  // Changed: a Committed (4) OR Reversed (6) batch is finished — reopening starts a NEW import instead of
22369
23199
  // resuming to a stale screen. Reversed was missed when Undo was added, so after undoing an import the
22370
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)
22371
23205
  if (r?.success && r.data?.hasItems && r.data.status !== 4 && r.data.status !== 6) {
22372
23206
  this.applyBatch(r.data);
22373
23207
  this.gotoStepForStatus();
@@ -22393,8 +23227,11 @@ class ImportDialogComponent {
22393
23227
  this.goToStep(1);
22394
23228
  }
22395
23229
  else {
22396
- 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
22397
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.
22398
23235
  }, () => { this.loading = false; this.messageService.error('Upload failed'); });
22399
23236
  }
22400
23237
  downloadTemplate() {
@@ -22459,7 +23296,7 @@ class ImportDialogComponent {
22459
23296
  this.loadRows(() => this.goToStep(2));
22460
23297
  }
22461
23298
  else {
22462
- 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
22463
23300
  }
22464
23301
  }, () => { this.loading = false; this.messageService.error('Mapping failed'); });
22465
23302
  }
@@ -22477,6 +23314,8 @@ class ImportDialogComponent {
22477
23314
  }
22478
23315
  loadRows(after) {
22479
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
22480
23319
  this.rows = (r?.success && r.data) ? r.data : [];
22481
23320
  this.buildReviewTable(); // Changed: (re)build the spa-table config + flattened rows whenever fresh rows land
22482
23321
  if (after)
@@ -22507,18 +23346,29 @@ class ImportDialogComponent {
22507
23346
  this.loading = true;
22508
23347
  this.httpService.Post(`import/row/${row.importRowID}`, values).subscribe(r => {
22509
23348
  this.loading = false;
22510
- this.editingRowId = null;
22511
- this.editingRow = null; // Changed: close the edit panel
22512
23349
  if (r?.success) {
23350
+ this.editingRowId = null; // Changed: only close the edit panel once the save actually succeeded
23351
+ this.editingRow = null;
22513
23352
  this.refreshBatch();
22514
23353
  }
22515
23354
  else {
22516
- 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}`);
22517
23363
  }
22518
23364
  }, () => { this.loading = false; this.messageService.error('Could not update row'); });
22519
23365
  }
22520
23366
  refreshBatch() {
22521
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)
22522
23372
  if (r?.success && r.data)
22523
23373
  this.applyBatch(r.data);
22524
23374
  this.loadRows();
@@ -22542,7 +23392,7 @@ class ImportDialogComponent {
22542
23392
  this.config.onComplete(r.data);
22543
23393
  }
22544
23394
  else {
22545
- 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
22546
23396
  this.refreshBatch();
22547
23397
  }
22548
23398
  }, () => { this.loading = false; this.messageService.error('Import failed'); });
@@ -22581,7 +23431,7 @@ class ImportDialogComponent {
22581
23431
  this.goToStep(0); // batch is Reversed — start fresh
22582
23432
  }
22583
23433
  else {
22584
- 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
22585
23435
  }
22586
23436
  }, () => { this.loading = false; this.messageService.error('Undo failed'); });
22587
23437
  });
@@ -22635,13 +23485,13 @@ class ImportDialogComponent {
22635
23485
  goToStep(index) {
22636
23486
  this.stepIndex = index; // Changed: drives the spa-steps indicator + the *ngIf step panes directly
22637
23487
  }
22638
- 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 }); }
22639
- 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" }] }); }
22640
23490
  }
22641
23491
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: ImportDialogComponent, decorators: [{
22642
23492
  type: Component,
22643
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"] }]
22644
- }], 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: [{
22645
23495
  type: Inject,
22646
23496
  args: [MAT_DIALOG_DATA]
22647
23497
  }] }] });
@@ -24548,6 +25398,7 @@ class UsersComponent {
24548
25398
  this.dataService = inject(DataServiceLib);
24549
25399
  this.dialog = inject(MatDialog);
24550
25400
  this.messageService = inject(MessageService);
25401
+ this.apiErrorService = inject(ApiErrorService); // Added: classifies the AD directory lookup failure
24551
25402
  this.userBaseFormConfig = {
24552
25403
  security: { allow: [this.dataService.capUsers] }, // Added: gate user form by users cap
24553
25404
  fields: [
@@ -24590,7 +25441,12 @@ class UsersComponent {
24590
25441
  this.messageService.toast('AD user found');
24591
25442
  }
24592
25443
  else {
24593
- 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}`);
24594
25450
  }
24595
25451
  });
24596
25452
  }
@@ -25365,6 +26221,9 @@ class AgingComponent {
25365
26221
  if (apiResponse.success) {
25366
26222
  this.summaryData = apiResponse.data;
25367
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
+ }
25368
26227
  });
25369
26228
  }
25370
26229
  // Changed: Post IFRS 9 bad-debt provision — confirms, calls invoices provision custom action, shows returned summary
@@ -25372,9 +26231,17 @@ class AgingComponent {
25372
26231
  this.messageService.confirm('Post the bad-debt provision? This posts the allowance delta per the aging matrix as a GL journal.').subscribe((result) => {
25373
26232
  if (result == 'yes') {
25374
26233
  this.dataServiceLib.CallApi({ url: 'invoices?action=provision', method: 'post' }, {}).subscribe((apiResponse) => {
25375
- this.messageService.toast(apiResponse.success ? apiResponse.message : 'Error: ' + apiResponse.message);
25376
- 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);
25377
26241
  this.loadSummaryData();
26242
+ }
26243
+ else
26244
+ this.apiErrorService.presentAppFailure(apiResponse, 'action', 'invoices?action=provision');
25378
26245
  });
25379
26246
  }
25380
26247
  });
@@ -25383,6 +26250,7 @@ class AgingComponent {
25383
26250
  this.dataServiceLib = dataServiceLib;
25384
26251
  this.accountingService = inject(AccountingService);
25385
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
25386
26254
  this.tileConfig = this.accountingService.agingTileConfig;
25387
26255
  // this.tileConfig = this.accountingService.agingTileConfig;
25388
26256
  this.agingTableConfigs = [
@@ -25464,11 +26332,15 @@ class SupplierAgingComponent {
25464
26332
  if (apiResponse.success) {
25465
26333
  this.summaryData = apiResponse.data;
25466
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
+ }
25467
26338
  });
25468
26339
  }
25469
26340
  constructor(dataServiceLib) {
25470
26341
  this.dataServiceLib = dataServiceLib;
25471
26342
  this.purchasingService = inject(PurchasingService);
26343
+ this.apiErrorService = inject(ApiErrorService); // Changed: Injected so a failed summary load says so
25472
26344
  this.tileConfig = this.purchasingService.apAgingTileConfig;
25473
26345
  // Changed: Build 5-tab aging table layout using AP aging base config from PurchasingService
25474
26346
  this.agingTableConfigs = [
@@ -25561,6 +26433,7 @@ class JournalEntryDialogComponent {
25561
26433
  this.httpService = inject(HttpService);
25562
26434
  this.http = inject(HttpClient);
25563
26435
  this.messageService = inject(MessageService);
26436
+ this.apiErrorService = inject(ApiErrorService); // Changed: injected so a rejected post is classified rather than echoed
25564
26437
  this.date = new Date().toISOString().substring(0, 10);
25565
26438
  this.description = '';
25566
26439
  this.journalType = 'Journal Entry'; // 'Opening Balance' posts the C2 take-on journal
@@ -25608,7 +26481,11 @@ class JournalEntryDialogComponent {
25608
26481
  this.dialogRef.close(true);
25609
26482
  }
25610
26483
  else {
25611
- 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');
25612
26489
  }
25613
26490
  });
25614
26491
  }
@@ -25860,7 +26737,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
25860
26737
  }]
25861
26738
  }] });
25862
26739
 
25863
- // 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.
25864
26745
  class StatementComponent {
25865
26746
  constructor() {
25866
26747
  this.accountingService = inject(AccountingService);
@@ -25871,20 +26752,27 @@ class StatementComponent {
25871
26752
  tableConfig: this.accountingService.statementTableConfig,
25872
26753
  searchTableConfig: this.accountingService.statementSearchTableConfig
25873
26754
  };
25874
- // 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.
25875
26758
  this.state = {
25876
- customer: { entityId: null, from: '', to: '', options: [], data: null },
25877
- 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
+ }
25878
26771
  };
25879
26772
  }
25880
- ngOnInit() {
25881
- this.loadOptions('customer', 'customers/list/x');
25882
- this.loadOptions('supplier', 'suppliers/list/x');
25883
- }
25884
- loadOptions(kind, url) {
25885
- this.http.get(`${this.httpService.apiUrl}${url}`)
25886
- .subscribe(res => this.state[kind].options = res?.data ?? res?.Data ?? []);
25887
- }
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() { }
25888
26776
  query(kind) {
25889
26777
  const s = this.state[kind];
25890
26778
  const params = [];
@@ -25896,10 +26784,16 @@ class StatementComponent {
25896
26784
  }
25897
26785
  load(kind) {
25898
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
25899
26789
  this.http.get(`${this.httpService.apiUrl}reports/${kind}-statement/${s.entityId}${this.query(kind)}`)
25900
- .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
+ });
25901
26794
  }
25902
- // 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.
25903
26797
  exportPdf(kind) {
25904
26798
  const s = this.state[kind];
25905
26799
  this.http.get(`${this.httpService.apiUrl}reports/pdf/${kind}-statement${this.query(kind) ? this.query(kind) + '&' : '?'}${kind}Id=${s.entityId}`, { responseType: 'blob' })
@@ -25944,21 +26838,12 @@ class StatementComponent {
25944
26838
 
25945
26839
  <ng-template #statementTab let-kind="kind">
25946
26840
  <div style="padding: 16px 0;">
25947
- <div style="display: flex; gap: 16px; align-items: center; flex-wrap: wrap;">
25948
- <mat-form-field style="min-width: 240px;">
25949
- <mat-label>{{ kind === 'customer' ? 'Customer' : 'Supplier' }}</mat-label>
25950
- <mat-select [(ngModel)]="state[kind].entityId">
25951
- <mat-option *ngFor="let option of state[kind].options" [value]="option.value">{{ option.name }}</mat-option>
25952
- </mat-select>
25953
- </mat-form-field>
25954
- <mat-form-field>
25955
- <mat-label>From</mat-label>
25956
- <input matInput type="date" [(ngModel)]="state[kind].from">
25957
- </mat-form-field>
25958
- <mat-form-field>
25959
- <mat-label>To</mat-label>
25960
- <input matInput type="date" [(ngModel)]="state[kind].to">
25961
- </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>
25962
26847
  <button mat-flat-button color="primary" (click)="load(kind)" [disabled]="!state[kind].entityId">Run</button>
25963
26848
  <button mat-stroked-button (click)="export(kind)" [disabled]="!state[kind].entityId">
25964
26849
  <mat-icon>download</mat-icon> Export to Excel
@@ -25968,41 +26853,18 @@ class StatementComponent {
25968
26853
  </button>
25969
26854
  </div>
25970
26855
 
25971
- <mat-card *ngIf="state[kind].data" style="padding: 16px;">
25972
- <div style="display: flex; gap: 32px; flex-wrap: wrap; margin-bottom: 16px;">
25973
- <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>
25974
- <div><small>Total Paid</small><br><b>{{ state[kind].data.totalPaid | number:'1.2-2' }}</b></div>
25975
- <div *ngIf="kind === 'customer'"><small>Total Credited</small><br><b>{{ state[kind].data.totalCredited | number:'1.2-2' }}</b></div>
25976
- <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>
25977
26862
  </div>
26863
+ </ng-container>
25978
26864
 
25979
- <table style="width: 100%; border-collapse: collapse;" *ngIf="state[kind].data.lines?.length; else noLines">
25980
- <tr style="text-align: left; border-bottom: 2px solid #333;">
25981
- <th style="padding: 6px;">Date</th>
25982
- <th style="padding: 6px;">Reference</th>
25983
- <th style="padding: 6px;">Type</th>
25984
- <th style="padding: 6px; text-align: right;">Total</th>
25985
- <th style="padding: 6px; text-align: right;">Paid</th>
25986
- <th style="padding: 6px; text-align: right;">Outstanding</th>
25987
- <th style="padding: 6px;">Status</th>
25988
- <th style="padding: 6px;">Aging</th>
25989
- </tr>
25990
- <tr *ngFor="let line of state[kind].data.lines" style="border-bottom: 1px solid #eee;">
25991
- <td style="padding: 6px;">{{ line.date | date:'mediumDate' }}</td>
25992
- <td style="padding: 6px;">{{ line.reference }}</td>
25993
- <td style="padding: 6px;">{{ line.type }}</td>
25994
- <td style="padding: 6px; text-align: right;">{{ line.total | number:'1.2-2' }}</td>
25995
- <td style="padding: 6px; text-align: right;">{{ line.paid | number:'1.2-2' }}</td>
25996
- <td style="padding: 6px; text-align: right;">{{ line.outstanding | number:'1.2-2' }}</td>
25997
- <td style="padding: 6px;">{{ line.status }}</td>
25998
- <td style="padding: 6px;">{{ line.agingBucket }}</td>
25999
- </tr>
26000
- </table>
26001
- <ng-template #noLines><p><em>No open items for the selected period</em></p></ng-template>
26002
- </mat-card>
26003
26865
  </div>
26004
26866
  </ng-template>
26005
- `, 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" }] }); }
26006
26868
  }
26007
26869
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: StatementComponent, decorators: [{
26008
26870
  type: Component,
@@ -26027,21 +26889,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
26027
26889
 
26028
26890
  <ng-template #statementTab let-kind="kind">
26029
26891
  <div style="padding: 16px 0;">
26030
- <div style="display: flex; gap: 16px; align-items: center; flex-wrap: wrap;">
26031
- <mat-form-field style="min-width: 240px;">
26032
- <mat-label>{{ kind === 'customer' ? 'Customer' : 'Supplier' }}</mat-label>
26033
- <mat-select [(ngModel)]="state[kind].entityId">
26034
- <mat-option *ngFor="let option of state[kind].options" [value]="option.value">{{ option.name }}</mat-option>
26035
- </mat-select>
26036
- </mat-form-field>
26037
- <mat-form-field>
26038
- <mat-label>From</mat-label>
26039
- <input matInput type="date" [(ngModel)]="state[kind].from">
26040
- </mat-form-field>
26041
- <mat-form-field>
26042
- <mat-label>To</mat-label>
26043
- <input matInput type="date" [(ngModel)]="state[kind].to">
26044
- </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>
26045
26898
  <button mat-flat-button color="primary" (click)="load(kind)" [disabled]="!state[kind].entityId">Run</button>
26046
26899
  <button mat-stroked-button (click)="export(kind)" [disabled]="!state[kind].entityId">
26047
26900
  <mat-icon>download</mat-icon> Export to Excel
@@ -26051,38 +26904,15 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
26051
26904
  </button>
26052
26905
  </div>
26053
26906
 
26054
- <mat-card *ngIf="state[kind].data" style="padding: 16px;">
26055
- <div style="display: flex; gap: 32px; flex-wrap: wrap; margin-bottom: 16px;">
26056
- <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>
26057
- <div><small>Total Paid</small><br><b>{{ state[kind].data.totalPaid | number:'1.2-2' }}</b></div>
26058
- <div *ngIf="kind === 'customer'"><small>Total Credited</small><br><b>{{ state[kind].data.totalCredited | number:'1.2-2' }}</b></div>
26059
- <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>
26060
26913
  </div>
26914
+ </ng-container>
26061
26915
 
26062
- <table style="width: 100%; border-collapse: collapse;" *ngIf="state[kind].data.lines?.length; else noLines">
26063
- <tr style="text-align: left; border-bottom: 2px solid #333;">
26064
- <th style="padding: 6px;">Date</th>
26065
- <th style="padding: 6px;">Reference</th>
26066
- <th style="padding: 6px;">Type</th>
26067
- <th style="padding: 6px; text-align: right;">Total</th>
26068
- <th style="padding: 6px; text-align: right;">Paid</th>
26069
- <th style="padding: 6px; text-align: right;">Outstanding</th>
26070
- <th style="padding: 6px;">Status</th>
26071
- <th style="padding: 6px;">Aging</th>
26072
- </tr>
26073
- <tr *ngFor="let line of state[kind].data.lines" style="border-bottom: 1px solid #eee;">
26074
- <td style="padding: 6px;">{{ line.date | date:'mediumDate' }}</td>
26075
- <td style="padding: 6px;">{{ line.reference }}</td>
26076
- <td style="padding: 6px;">{{ line.type }}</td>
26077
- <td style="padding: 6px; text-align: right;">{{ line.total | number:'1.2-2' }}</td>
26078
- <td style="padding: 6px; text-align: right;">{{ line.paid | number:'1.2-2' }}</td>
26079
- <td style="padding: 6px; text-align: right;">{{ line.outstanding | number:'1.2-2' }}</td>
26080
- <td style="padding: 6px;">{{ line.status }}</td>
26081
- <td style="padding: 6px;">{{ line.agingBucket }}</td>
26082
- </tr>
26083
- </table>
26084
- <ng-template #noLines><p><em>No open items for the selected period</em></p></ng-template>
26085
- </mat-card>
26086
26916
  </div>
26087
26917
  </ng-template>
26088
26918
  `,
@@ -26361,7 +27191,7 @@ class BudgetVsActualComponent {
26361
27191
  <hr>
26362
27192
 
26363
27193
  <div style="max-width: 300px; margin-bottom: 16px;">
26364
- <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 -->
26365
27195
  </div>
26366
27196
 
26367
27197
  <spa-tiles *ngIf="summaryTileConfig.loadAction" [config]="summaryTileConfig"></spa-tiles>
@@ -26378,7 +27208,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
26378
27208
  <hr>
26379
27209
 
26380
27210
  <div style="max-width: 300px; margin-bottom: 16px;">
26381
- <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 -->
26382
27212
  </div>
26383
27213
 
26384
27214
  <spa-tiles *ngIf="summaryTileConfig.loadAction" [config]="summaryTileConfig"></spa-tiles>
@@ -26762,7 +27592,7 @@ class VatReturnComponent {
26762
27592
  </mat-card>
26763
27593
  </mat-tab>
26764
27594
  </mat-tab-group>
26765
- `, 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" }] }); }
26766
27596
  }
26767
27597
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: VatReturnComponent, decorators: [{
26768
27598
  type: Component,
@@ -28369,9 +29199,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
28369
29199
  }], ctorParameters: () => [] });
28370
29200
 
28371
29201
  class OnboardingComponent {
28372
- constructor(dataService, authService) {
29202
+ constructor(dataService, authService, apiErrorService) {
28373
29203
  this.dataService = dataService;
28374
29204
  this.authService = authService;
29205
+ this.apiErrorService = apiErrorService;
28375
29206
  this.step = "terms";
28376
29207
  this.agree = false;
28377
29208
  this.own = true;
@@ -28391,7 +29222,7 @@ class OnboardingComponent {
28391
29222
  ],
28392
29223
  loadAction: { url: 'tenants/invitations/x' },
28393
29224
  };
28394
- }
29225
+ } // Changed: injected ApiErrorService for the rename step
28395
29226
  ngOnInit() {
28396
29227
  this.loadMeta();
28397
29228
  this.authService.loggedUserFullName.subscribe(x => this.username = x);
@@ -28408,6 +29239,9 @@ class OnboardingComponent {
28408
29239
  if (apiResponse.success) {
28409
29240
  this.authService.updateTenantName(this.myTenant.name);
28410
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
+ }
28411
29245
  });
28412
29246
  }
28413
29247
  next() {
@@ -28436,13 +29270,13 @@ class OnboardingComponent {
28436
29270
  home() {
28437
29271
  this.dataService.Navigate('home');
28438
29272
  }
28439
- 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 }); }
28440
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" }] }); }
28441
29275
  }
28442
29276
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: OnboardingComponent, decorators: [{
28443
29277
  type: Component,
28444
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"] }]
28445
- }], ctorParameters: () => [{ type: DataServiceLib }, { type: AuthService }] });
29279
+ }], ctorParameters: () => [{ type: DataServiceLib }, { type: AuthService }, { type: ApiErrorService }] });
28446
29280
 
28447
29281
  const HR_ROUTES = [
28448
29282
  { path: "employees", component: EmployeesComponent },
@@ -28564,6 +29398,493 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
28564
29398
  }]
28565
29399
  }] });
28566
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
+
28567
29888
  class SalaryAdvancesComponent {
28568
29889
  constructor() {
28569
29890
  this.payrollService = inject(PayrollService);
@@ -28749,6 +30070,7 @@ const PAYROLL_ROUTES = [
28749
30070
  { path: "runs", component: PayrollRunsComponent },
28750
30071
  { path: "commission-configs", component: CommissionConfigsComponent },
28751
30072
  { path: "commission-entries", component: CommissionEntriesComponent },
30073
+ { path: "commission-statement", component: CommissionStatementComponent }, // Added (Phase 6): cap86 — routed and registered, granted in no role template
28752
30074
  { path: "salary-advances", component: SalaryAdvancesComponent },
28753
30075
  { path: "overtime-entries", component: OvertimeEntriesComponent },
28754
30076
  { path: "dashboard", component: PayrollDashboardComponent }
@@ -29045,11 +30367,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
29045
30367
  }] });
29046
30368
 
29047
30369
  class TenantSettingsComponent {
29048
- constructor(dataService, messageService, authService, dialog) {
30370
+ constructor(dataService, messageService, authService, dialog, apiErrorService) {
29049
30371
  this.dataService = dataService;
29050
30372
  this.messageService = messageService;
29051
30373
  this.authService = authService;
29052
30374
  this.dialog = dialog;
30375
+ this.apiErrorService = apiErrorService;
29053
30376
  this.roles = [
29054
30377
  { roleName: 'Default', roleID: 1 },
29055
30378
  ];
@@ -29203,7 +30526,7 @@ class TenantSettingsComponent {
29203
30526
  loadAction: { url: 'mailerconfigs/all/x' },
29204
30527
  formConfig: this.mailerFormConfig
29205
30528
  };
29206
- }
30529
+ } // Changed: injected ApiErrorService for the switch failure
29207
30530
  ngOnInit() {
29208
30531
  this.authService.myRoleObserv.subscribe(rol => this.myRole = rol);
29209
30532
  this.loadData();
@@ -29248,6 +30571,9 @@ class TenantSettingsComponent {
29248
30571
  if (apiResponse.success) {
29249
30572
  this.forceLogin();
29250
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
+ }
29251
30577
  });
29252
30578
  }
29253
30579
  });
@@ -29256,13 +30582,13 @@ class TenantSettingsComponent {
29256
30582
  this.messageService.toast("Switched Successfully, please login again");
29257
30583
  this.authService.logoff();
29258
30584
  }
29259
- 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 }); }
29260
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" }] }); }
29261
30587
  }
29262
30588
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: TenantSettingsComponent, decorators: [{
29263
30589
  type: Component,
29264
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"] }]
29265
- }], ctorParameters: () => [{ type: DataServiceLib }, { type: MessageService }, { type: AuthService }, { type: i4.MatDialog }] });
30591
+ }], ctorParameters: () => [{ type: DataServiceLib }, { type: MessageService }, { type: AuthService }, { type: i4.MatDialog }, { type: ApiErrorService }] });
29266
30592
 
29267
30593
  class TenantsComponent {
29268
30594
  constructor() {
@@ -29544,10 +30870,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
29544
30870
 
29545
30871
  // Subscription management page — current plan display, plan comparison, upgrade/downgrade
29546
30872
  class SubscriptionPageComponent {
29547
- 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
+ ) {
29548
30875
  this.httpService = httpService;
29549
30876
  this.subscriptionService = subscriptionService;
29550
30877
  this.snackBar = snackBar;
30878
+ this.apiErrorService = apiErrorService;
29551
30879
  this.currentSubscription = null;
29552
30880
  this.availablePlans = [];
29553
30881
  this.loading = true;
@@ -29566,6 +30894,9 @@ class SubscriptionPageComponent {
29566
30894
  if (response?.success) {
29567
30895
  this.currentSubscription = response.data;
29568
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
+ }
29569
30900
  this.loading = false;
29570
30901
  },
29571
30902
  error: () => { this.loading = false; }
@@ -29578,6 +30909,9 @@ class SubscriptionPageComponent {
29578
30909
  if (response?.success) {
29579
30910
  this.availablePlans = response.data || [];
29580
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
+ }
29581
30915
  }
29582
30916
  });
29583
30917
  }
@@ -29617,7 +30951,7 @@ class SubscriptionPageComponent {
29617
30951
  this.subscriptionService.loadFeatures(); // Refresh enabled features for UI gating
29618
30952
  }
29619
30953
  else {
29620
- 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
29621
30955
  }
29622
30956
  },
29623
30957
  error: () => {
@@ -29626,7 +30960,7 @@ class SubscriptionPageComponent {
29626
30960
  }
29627
30961
  });
29628
30962
  }
29629
- 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 }); }
29630
30964
  static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: SubscriptionPageComponent, isStandalone: false, selector: "spa-subscription", ngImport: i0, template: `
29631
30965
  <div class="subscription-container">
29632
30966
  <h4 class="page-title"><mat-icon>card_membership</mat-icon> Subscription</h4>
@@ -29860,13 +31194,15 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
29860
31194
  </div>
29861
31195
  </div>
29862
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"] }]
29863
- }], ctorParameters: () => [{ type: HttpService }, { type: SubscriptionService }, { type: i2$1.MatSnackBar }] });
31197
+ }], ctorParameters: () => [{ type: HttpService }, { type: SubscriptionService }, { type: i2$1.MatSnackBar }, { type: ApiErrorService }] });
29864
31198
 
29865
31199
  // Billing page — invoice history table, invoice detail dialog, and payment flow dialog
29866
31200
  class BillingPageComponent {
29867
- constructor(httpService, snackBar) {
31201
+ constructor(httpService, snackBar, apiErrorService // Changed: injected so failures on this page are classified, not swallowed or echoed
31202
+ ) {
29868
31203
  this.httpService = httpService;
29869
31204
  this.snackBar = snackBar;
31205
+ this.apiErrorService = apiErrorService;
29870
31206
  this.currentSubscription = null;
29871
31207
  this.invoices = [];
29872
31208
  this.loading = true;
@@ -29890,6 +31226,9 @@ class BillingPageComponent {
29890
31226
  if (response?.success) {
29891
31227
  this.currentSubscription = response.data;
29892
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
+ }
29893
31232
  }
29894
31233
  });
29895
31234
  }
@@ -29900,6 +31239,9 @@ class BillingPageComponent {
29900
31239
  if (response?.success) {
29901
31240
  this.invoices = response.data || [];
29902
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
+ }
29903
31245
  this.loading = false;
29904
31246
  },
29905
31247
  error: () => { this.loading = false; }
@@ -29929,7 +31271,7 @@ class BillingPageComponent {
29929
31271
  this.showDetail = true;
29930
31272
  }
29931
31273
  else {
29932
- 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
29933
31275
  }
29934
31276
  },
29935
31277
  error: () => {
@@ -29967,7 +31309,7 @@ class BillingPageComponent {
29967
31309
  this.paymentResult = { success: false, redirectUrl: response.data.redirectUrl };
29968
31310
  }
29969
31311
  else {
29970
- 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
29971
31313
  }
29972
31314
  },
29973
31315
  error: () => {
@@ -29976,6 +31318,24 @@ class BillingPageComponent {
29976
31318
  }
29977
31319
  });
29978
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
+ }
29979
31339
  // Close the payment dialog
29980
31340
  closePayment() {
29981
31341
  this.showPayment = false;
@@ -29985,7 +31345,7 @@ class BillingPageComponent {
29985
31345
  this.loadInvoices(); // Refresh if payment was successful
29986
31346
  }
29987
31347
  }
29988
- 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 }); }
29989
31349
  static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: BillingPageComponent, isStandalone: false, selector: "spa-billing", ngImport: i0, template: `
29990
31350
  <div class="billing-container">
29991
31351
  <h4 class="page-title"><mat-icon>receipt_long</mat-icon> Billing & Invoices</h4>
@@ -30399,7 +31759,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
30399
31759
  </div>
30400
31760
  </div>
30401
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"] }]
30402
- }], ctorParameters: () => [{ type: HttpService }, { type: i2$1.MatSnackBar }] });
31762
+ }], ctorParameters: () => [{ type: HttpService }, { type: i2$1.MatSnackBar }, { type: ApiErrorService }] });
30403
31763
 
30404
31764
  const TENANCY_ROUTES = [
30405
31765
  { path: "settings", component: TenantSettingsComponent }, // Changed: Removed "tenant-" prefix — module path provides the namespace
@@ -30432,7 +31792,7 @@ class ApprovalsComponent {
30432
31792
  ngOnInit() {
30433
31793
  }
30434
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 }); }
30435
- 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"] }] }); }
30436
31796
  }
30437
31797
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: ApprovalsComponent, decorators: [{
30438
31798
  type: Component,
@@ -31338,6 +32698,7 @@ class PayrollModule {
31338
32698
  PayrollRunsComponent,
31339
32699
  CommissionConfigsComponent,
31340
32700
  CommissionEntriesComponent,
32701
+ CommissionStatementComponent,
31341
32702
  SalaryAdvancesComponent,
31342
32703
  OvertimeEntriesComponent,
31343
32704
  PayrollDashboardComponent], imports: [CommonModule,
@@ -31354,6 +32715,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
31354
32715
  PayrollRunsComponent,
31355
32716
  CommissionConfigsComponent,
31356
32717
  CommissionEntriesComponent,
32718
+ CommissionStatementComponent,
31357
32719
  SalaryAdvancesComponent,
31358
32720
  OvertimeEntriesComponent,
31359
32721
  PayrollDashboardComponent