tin-spa 20.14.21 → 20.14.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fesm2022/tin-spa.mjs +1871 -464
- package/fesm2022/tin-spa.mjs.map +1 -1
- package/index.d.ts +218 -48
- package/package.json +1 -1
package/fesm2022/tin-spa.mjs
CHANGED
|
@@ -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
|
|
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
|
|
53
|
+
import * as i5 from '@angular/material/datepicker';
|
|
54
54
|
import { MatDatepickerModule } from '@angular/material/datepicker';
|
|
55
|
-
import * as i5$
|
|
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$
|
|
75
|
+
import * as i5$2 from '@angular/material/stepper';
|
|
76
76
|
import { MatStepperModule } from '@angular/material/stepper';
|
|
77
|
-
import * as
|
|
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,
|
|
2297
|
+
constructor(httpService, authService, storage, messageService, apiErrorService, // Added: shares ONE Retry-After parser with the dialog/toast path
|
|
2298
|
+
signalR, zone, runtimeConfig) {
|
|
1883
2299
|
this.httpService = httpService;
|
|
1884
2300
|
this.authService = authService;
|
|
1885
2301
|
this.storage = storage;
|
|
1886
2302
|
this.messageService = messageService;
|
|
2303
|
+
this.apiErrorService = apiErrorService;
|
|
1887
2304
|
this.signalR = signalR;
|
|
1888
2305
|
this.zone = zone;
|
|
1889
2306
|
this.runtimeConfig = runtimeConfig;
|
|
@@ -1903,6 +2320,14 @@ class OfflineService {
|
|
|
1903
2320
|
this.syncStarted = false;
|
|
1904
2321
|
this.draining = false;
|
|
1905
2322
|
this.backoffTimer = null;
|
|
2323
|
+
// Added: how long the SERVER asked us to wait, in ms, from the Retry-After on the failure that just halted the
|
|
2324
|
+
// drain — or null when it said nothing. Written by sendOp(), read once by drainLoop() on the very next line
|
|
2325
|
+
// after it returns, so it is never stale: sendOp() clears it on entry, before any I/O.
|
|
2326
|
+
this.serverRetryAfterMs = null;
|
|
2327
|
+
// Added: epoch ms before which no AUTOMATIC drain may send, set only when the SERVER stated a Retry-After.
|
|
2328
|
+
// Never set from the guessed attempt-count backoff — a reconnect drain jumping a guess is harmless and useful;
|
|
2329
|
+
// jumping an instruction is not.
|
|
2330
|
+
this.retryNotBefore = 0;
|
|
1906
2331
|
this.periodicTimer = null;
|
|
1907
2332
|
this.recoveryTimer = null;
|
|
1908
2333
|
}
|
|
@@ -1966,6 +2391,7 @@ class OfflineService {
|
|
|
1966
2391
|
this.registered$.next(true);
|
|
1967
2392
|
this.registerReads(config, entry);
|
|
1968
2393
|
this.registerLookups(config.formConfig, entry);
|
|
2394
|
+
this.registerButtonLookups(config.buttons, entry); // Added: the two-step-create dialog's form lives on a BUTTON, not on config.formConfig — see below
|
|
1969
2395
|
this.startSync();
|
|
1970
2396
|
return entry;
|
|
1971
2397
|
}
|
|
@@ -2035,6 +2461,33 @@ class OfflineService {
|
|
|
2035
2461
|
this.registerLookups(field.detailsConfig.formConfig, entry, depth + 1);
|
|
2036
2462
|
});
|
|
2037
2463
|
}
|
|
2464
|
+
// Added: the TWO-STEP CREATE gap. CLAUDE.md's documented pattern (Invoice, Sales/Purchase Order, Requisition,
|
|
2465
|
+
// Production Recipe/Order, Trip, Rental, Bundle/Check Template, Checklist, Alloc Template — 12 entities) puts the
|
|
2466
|
+
// real create form on `button.detailsConfig.formConfig`, NOT on `tableConfig.formConfig`. registerLookups only ever
|
|
2467
|
+
// walked the latter, so those forms' selects were never in the read seam: the urls were fetched online and thrown
|
|
2468
|
+
// away, and offline the dialog opened with every dropdown empty. Where a select is `required` that is not a
|
|
2469
|
+
// degraded form, it is an UNSUBMITTABLE one — the write queue behind it was unreachable from the UI.
|
|
2470
|
+
// (Observed, WS-1 trips/loads: opening Shift's trip-create dialog ONLINE cached vehicles/trailers/drivers — the
|
|
2471
|
+
// three the TABLE form declares — and not triptemplates or allocTemplates, which only the BUTTON form declares.)
|
|
2472
|
+
//
|
|
2473
|
+
// ⚠️ BOUNDED DELIBERATELY, and the boundary is not arbitrary — it is "does anything else already register this?":
|
|
2474
|
+
// • BUTTON detailsConfig forms ARE walked. A dialog form is not a spa-table; nothing else ever registers it, so
|
|
2475
|
+
// if this does not, nothing does.
|
|
2476
|
+
// • detailsConfig.tableConfigs are NOT walked. A nested tab renders through <spa-table> (tabs.component.html),
|
|
2477
|
+
// so its TableComponent calls offline.register() itself and registers its own reads AND its own lookups under
|
|
2478
|
+
// its OWN entity, cacheLookups allowlist and cacheRows. Walking them here would re-register another entity's
|
|
2479
|
+
// lookups under THIS entity's allowlist — duplicate work, filtered by the wrong config.
|
|
2480
|
+
// • detailsConfig.buttons are NOT walked, and neither are column detailsConfigs: one level of button is what the
|
|
2481
|
+
// documented pattern needs, and each extra level multiplies the walk for configs that share dialogs anyway.
|
|
2482
|
+
// Depth inside each form is unchanged — a button form enters registerLookups at depth 0, exactly like the table's
|
|
2483
|
+
// own form, so its fields' quick-add ("Add New") forms still register and nothing deeper does.
|
|
2484
|
+
// VOLUME: this only makes a url MATCHABLE; a cache entry is written solely when that url is actually fetched while
|
|
2485
|
+
// online (handleOfflineRead), so a registered-but-never-opened dialog costs one Map entry and no storage. Buttons
|
|
2486
|
+
// per table are single digits, and cacheLookups === false / an explicit allowlist still filter every url here.
|
|
2487
|
+
registerButtonLookups(buttons, entry) {
|
|
2488
|
+
buttons?.forEach(b => { if (b.detailsConfig?.formConfig)
|
|
2489
|
+
this.registerLookups(b.detailsConfig.formConfig, entry); });
|
|
2490
|
+
}
|
|
2038
2491
|
// Added: CACHE KEY DECISION — a paged read is cached under its BASE url, i.e. with `skip`/`take` stripped,
|
|
2039
2492
|
// NOT under the full url. The paging window is a view parameter over one resource, not a different resource:
|
|
2040
2493
|
// keying by the full url would mint an unbounded number of IndexedDB entries (take varies per fetch — see
|
|
@@ -2190,7 +2643,24 @@ class OfflineService {
|
|
|
2190
2643
|
this.meta.put({ key: 'sentinel', value: Date.now() }).catch(() => { });
|
|
2191
2644
|
return db;
|
|
2192
2645
|
}
|
|
2646
|
+
// Added: never write an entry that can never be read back. matchRead() is the SOLE read path
|
|
2647
|
+
// (datalib.service.ts CallApi), so a url it does not match is unreachable the moment it is stored — the write
|
|
2648
|
+
// silently succeeds, consumes the origin's storage quota, and leaves the store LOOKING populated while the UI
|
|
2649
|
+
// refuses the view. That is worse than not caching it: it lies to the next person who dumps the store.
|
|
2650
|
+
// How it happened (observed on Shift's Loads grid, WS-1): tileClicked REPLACES config.loadAction wholesale, and
|
|
2651
|
+
// TableComponent.cachePagedWindow() then writes under the swapped url stamped with the TABLE's entity. Three of
|
|
2652
|
+
// the four Loads tiles are same-entity so registerReads registered them; the fourth is `invoices/outstanding/x`,
|
|
2653
|
+
// a FOREIGN entity the same-entity guard deliberately skips — so it was written and could never be served, seen
|
|
2654
|
+
// offline as the blocking 'No connection' modal sitting on top of its own unreachable cached rows.
|
|
2655
|
+
// The fix is the SMALL one on purpose: making it matchable instead would mean this table registering an entity it
|
|
2656
|
+
// does not own, under another entity's cacheLookups/cacheRows and with none of that entity's queue semantics —
|
|
2657
|
+
// a cross-entity read-caching feature, not a bug fix, and the same crossing registerReads' guard already declined.
|
|
2658
|
+
// Guard, not silent tidy: every legitimate writer still passes. handleOfflineRead only calls this after matchRead
|
|
2659
|
+
// matched (and cacheKey() only strips paging, which matchRead's own base-url fallback already tolerates);
|
|
2660
|
+
// cachePagedWindow/applyRealTimeData write the table's registered url, or a per-parent/tile url a prefix covers.
|
|
2193
2661
|
putRead(url, entity, data, maxRows) {
|
|
2662
|
+
if (!this.matchRead(url))
|
|
2663
|
+
return Promise.resolve(null);
|
|
2194
2664
|
this.ensureOpen();
|
|
2195
2665
|
let stored = data;
|
|
2196
2666
|
if (Array.isArray(data))
|
|
@@ -2387,7 +2857,7 @@ class OfflineService {
|
|
|
2387
2857
|
if (!this.online$.value) {
|
|
2388
2858
|
this.zone.run(() => this.online$.next(true)); // TS-20: re-enter Angular zone only on actual state flip (timer ticks run outside the zone)
|
|
2389
2859
|
if (this.syncStarted)
|
|
2390
|
-
setTimeout(() => this.zone.run(() => this.drain()), Math.floor(Math.random() * 5000)); // TS-20: drain (toasts/emissions) must run in-zone even when triggered from an out-of-zone timer path; jittered so a reconnecting fleet doesn't stampede
|
|
2860
|
+
setTimeout(() => this.zone.run(() => this.drain(true)), Math.floor(Math.random() * 5000)); // TS-20: drain (toasts/emissions) must run in-zone even when triggered from an out-of-zone timer path; jittered so a reconnecting fleet doesn't stampede. Changed: auto=true — a reconnect must not jump a server-stated Retry-After
|
|
2391
2861
|
}
|
|
2392
2862
|
// Marry the two: whenever ping confirms we're reachable, revive SignalR if its own auto-reconnect gave up.
|
|
2393
2863
|
// No-op when the hub is already connected, so this is safe to call on every online confirmation.
|
|
@@ -2423,10 +2893,10 @@ class OfflineService {
|
|
|
2423
2893
|
this.zone.runOutsideAngular(() => {
|
|
2424
2894
|
this.periodicTimer = setInterval(() => { if (this.online)
|
|
2425
2895
|
this.hasPending().then(has => { if (has)
|
|
2426
|
-
this.zone.run(() => this.drain()); }); }, 60000);
|
|
2896
|
+
this.zone.run(() => this.drain(true)); }); }, 60000); // Changed: auto=true — the 60s sweep must not jump a server-stated Retry-After
|
|
2427
2897
|
});
|
|
2428
2898
|
if (this.online)
|
|
2429
|
-
this.drain();
|
|
2899
|
+
this.drain(true); // Changed: auto=true — sync starting up is the app's decision, not the user's
|
|
2430
2900
|
}
|
|
2431
2901
|
// Added: reclaim work parked by a previous session. The sync engine otherwise only starts when a page happens
|
|
2432
2902
|
// to register an offline table, so changes captured before a sign-out sat there until the user wandered back
|
|
@@ -2441,9 +2911,24 @@ class OfflineService {
|
|
|
2441
2911
|
}
|
|
2442
2912
|
catch { /* nothing parked, or the store isn't available */ }
|
|
2443
2913
|
}
|
|
2444
|
-
|
|
2914
|
+
// Changed: `auto` marks a drain the APP decided to run (reconnect, the 60s sweep, sync start) as opposed to one
|
|
2915
|
+
// the USER asked for. Only automatic drains are held back by a server-stated Retry-After — see retryNotBefore.
|
|
2916
|
+
// It defaults to false so every EXTERNAL caller keeps today's behaviour untouched: the sync tray's "Sync Now"
|
|
2917
|
+
// button (`sync.component.ts:29`) is a deliberate human action, and silently doing nothing when someone presses
|
|
2918
|
+
// a button is precisely the quiet-failure shape this codebase keeps getting bitten by.
|
|
2919
|
+
async drain(auto = false) {
|
|
2445
2920
|
if (this.draining || !this.online)
|
|
2446
2921
|
return;
|
|
2922
|
+
// Added: honouring Retry-After ONLY inside scheduleBackoff was not enough, and a 32-op drain proved it — the
|
|
2923
|
+
// retry arrived 9.5s after a 429 that said 20. The backoff timer was armed correctly, but a connectivity flip
|
|
2924
|
+
// (SignalR dropping and reconnecting is routine, and each flip schedules its own drain within 5s of coming
|
|
2925
|
+
// back) simply ran a drain in front of it. The wait has to bind every automatic entry point, not just the one
|
|
2926
|
+
// timer, or the app still hammers a server that told it exactly when to come back.
|
|
2927
|
+
//
|
|
2928
|
+
// Deliberately fail-open: this is in-memory only, so a reload forgets it — and that is correct, because a
|
|
2929
|
+
// reload also forgets the backoff timer, and a gate that outlived its own timer could strand the queue.
|
|
2930
|
+
if (auto && Date.now() < this.retryNotBefore)
|
|
2931
|
+
return;
|
|
2447
2932
|
this.draining = true;
|
|
2448
2933
|
const runner = () => this.drainLoop().finally(() => { this.draining = false; });
|
|
2449
2934
|
const locks = navigator.locks;
|
|
@@ -2475,7 +2960,7 @@ class OfflineService {
|
|
|
2475
2960
|
op.status = 'pending';
|
|
2476
2961
|
op.attempts++;
|
|
2477
2962
|
await this.outbox.put(op);
|
|
2478
|
-
this.scheduleBackoff(op.attempts);
|
|
2963
|
+
this.scheduleBackoff(op.attempts, this.serverRetryAfterMs); // Changed: pass the server's own Retry-After, which overrides the attempt-count guess when present
|
|
2479
2964
|
break; // strict FIFO: a transient failure halts the drain until backoff/reconnect
|
|
2480
2965
|
}
|
|
2481
2966
|
if (outcome === 'synced')
|
|
@@ -2497,6 +2982,7 @@ class OfflineService {
|
|
|
2497
2982
|
const payload = { ...op.payload };
|
|
2498
2983
|
if (op.refMap && Object.keys(op.refMap).length)
|
|
2499
2984
|
payload.clientRefs = JSON.stringify(op.refMap); // server resolves parent ClientRef -> real int FK
|
|
2985
|
+
this.serverRetryAfterMs = null; // Added: cleared BEFORE the I/O, so a wait from a previous op can never leak into this one's backoff
|
|
2500
2986
|
let response;
|
|
2501
2987
|
try {
|
|
2502
2988
|
response = await firstValueFrom(this.httpService.Post(op.url, payload));
|
|
@@ -2506,6 +2992,14 @@ class OfflineService {
|
|
|
2506
2992
|
if (status === 0 || status === 408 || status === 429 || status >= 500) {
|
|
2507
2993
|
if (status === 0)
|
|
2508
2994
|
this.reportNetworkFailure();
|
|
2995
|
+
// Added: the response headers were being thrown away here, and with them the one authoritative number in
|
|
2996
|
+
// the whole exchange. A 429 (and a 503) may carry Retry-After — TinWeb's limiter always sends it, computed
|
|
2997
|
+
// from the real window, and exposes it cross-origin — which is the server stating when it will stop
|
|
2998
|
+
// refusing. Guessing from an attempt count instead means we either come back too early, get refused again
|
|
2999
|
+
// and extend the block on a sliding window, or sit idle far longer than we needed to.
|
|
3000
|
+
// Parsed by ApiErrorService so there is exactly ONE implementation of the two legal header forms.
|
|
3001
|
+
const seconds = this.apiErrorService.retryAfterSeconds(err);
|
|
3002
|
+
this.serverRetryAfterMs = seconds > 0 ? seconds * 1000 : null;
|
|
2509
3003
|
return 'transient';
|
|
2510
3004
|
}
|
|
2511
3005
|
return this.finishOp(op, 'failed', err?.message ?? `HTTP ${status}`);
|
|
@@ -2565,11 +3059,32 @@ class OfflineService {
|
|
|
2565
3059
|
await this.outbox.put(op);
|
|
2566
3060
|
}
|
|
2567
3061
|
}
|
|
2568
|
-
|
|
3062
|
+
// Changed: takes the server's stated Retry-After (ms, null when it said nothing) and OVERRIDES the computed
|
|
3063
|
+
// backoff with it. Full jitter is right when we are guessing — it spreads a thundering herd across a window we
|
|
3064
|
+
// invented — but it is wrong once the server has told us the answer, because `random * cap` can return almost
|
|
3065
|
+
// zero and send us straight back into a limiter that has not reset. When Retry-After is present we wait the
|
|
3066
|
+
// whole of it, and jitter only the ADDITIONAL second, which keeps clients from returning in lockstep without
|
|
3067
|
+
// ever returning early.
|
|
3068
|
+
//
|
|
3069
|
+
// Clamped to an hour to match the clamp TinWeb applies to the header it sends, so a broken proxy inventing a
|
|
3070
|
+
// Retry-After of a year cannot strand the outbox. A stated wait LONGER than the old 5-minute cap is honoured
|
|
3071
|
+
// rather than capped at 5 minutes: coming back before the server said to only earns another refusal.
|
|
3072
|
+
scheduleBackoff(attempts, serverRetryAfterMs = null) {
|
|
2569
3073
|
if (this.backoffTimer)
|
|
2570
3074
|
clearTimeout(this.backoffTimer);
|
|
2571
|
-
|
|
2572
|
-
|
|
3075
|
+
let delay;
|
|
3076
|
+
if (serverRetryAfterMs && serverRetryAfterMs > 0) {
|
|
3077
|
+
delay = Math.min(serverRetryAfterMs, 3600000) + Math.floor(Math.random() * 1000); // obey the server, plus <=1s of de-sync jitter
|
|
3078
|
+
this.retryNotBefore = Date.now() + delay; // Added: binds the reconnect and periodic drains too, not just this timer
|
|
3079
|
+
}
|
|
3080
|
+
else {
|
|
3081
|
+
const cap = Math.min(5000 * Math.pow(2, attempts), 300000); // exponential 5s·2^n capped 5min, full jitter
|
|
3082
|
+
delay = Math.floor(Math.random() * cap);
|
|
3083
|
+
this.retryNotBefore = 0; // a guessed backoff never blocks another trigger — only a server instruction does
|
|
3084
|
+
}
|
|
3085
|
+
// This timer IS the scheduled resume, so it lifts its own gate rather than being held by it — a millisecond of
|
|
3086
|
+
// timer skew must not turn the wait into a permanent stall.
|
|
3087
|
+
this.backoffTimer = setTimeout(() => { this.retryNotBefore = 0; this.drain(); }, delay);
|
|
2573
3088
|
}
|
|
2574
3089
|
//================ Sync-tray actions ================
|
|
2575
3090
|
async retryOp(opId) {
|
|
@@ -2579,6 +3094,7 @@ class OfflineService {
|
|
|
2579
3094
|
op.status = 'pending';
|
|
2580
3095
|
op.attempts = 0;
|
|
2581
3096
|
op.enqueuedAt = Date.now();
|
|
3097
|
+
this.retryNotBefore = 0; // Added: a deliberate user retry clears any server-stated wait, exactly as it clears attempts
|
|
2582
3098
|
await this.outbox.put(op);
|
|
2583
3099
|
this.drain();
|
|
2584
3100
|
}
|
|
@@ -2599,6 +3115,7 @@ class OfflineService {
|
|
|
2599
3115
|
op.status = 'pending';
|
|
2600
3116
|
op.attempts = 0;
|
|
2601
3117
|
op.enqueuedAt = Date.now();
|
|
3118
|
+
this.retryNotBefore = 0; // Added: same as retryOp — an informed, explicit choice is not an automatic drain
|
|
2602
3119
|
await this.outbox.put(op);
|
|
2603
3120
|
this.drain();
|
|
2604
3121
|
}
|
|
@@ -2616,13 +3133,13 @@ class OfflineService {
|
|
|
2616
3133
|
hash(v) { let h = 0; for (let i = 0; i < v.length; i++)
|
|
2617
3134
|
h = ((h << 5) - h + v.charCodeAt(i)) | 0; return Math.abs(h).toString(36); }
|
|
2618
3135
|
sanitize(v) { return v.replace(/[^a-zA-Z0-9_-]/g, '_').toLowerCase(); }
|
|
2619
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: OfflineService, deps: [{ token: HttpService }, { token: AuthService }, { token: StorageService }, { token: MessageService }, { token: SignalRService }, { token: i0.NgZone }, { token: TIN_SPA_RUNTIME_CONFIG, optional: true }], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
3136
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: OfflineService, deps: [{ token: HttpService }, { token: AuthService }, { token: StorageService }, { token: MessageService }, { token: ApiErrorService }, { token: SignalRService }, { token: i0.NgZone }, { token: TIN_SPA_RUNTIME_CONFIG, optional: true }], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
2620
3137
|
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: OfflineService, providedIn: 'root' }); }
|
|
2621
3138
|
}
|
|
2622
3139
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: OfflineService, decorators: [{
|
|
2623
3140
|
type: Injectable,
|
|
2624
3141
|
args: [{ providedIn: 'root' }]
|
|
2625
|
-
}], ctorParameters: () => [{ type: HttpService }, { type: AuthService }, { type: StorageService }, { type: MessageService }, { type: SignalRService }, { type: i0.NgZone }, { type: undefined, decorators: [{
|
|
3142
|
+
}], ctorParameters: () => [{ type: HttpService }, { type: AuthService }, { type: StorageService }, { type: MessageService }, { type: ApiErrorService }, { type: SignalRService }, { type: i0.NgZone }, { type: undefined, decorators: [{
|
|
2626
3143
|
type: Optional
|
|
2627
3144
|
}, {
|
|
2628
3145
|
type: Inject,
|
|
@@ -3194,6 +3711,7 @@ class DataServiceLib {
|
|
|
3194
3711
|
this.capPayrollRuns = new CapItem;
|
|
3195
3712
|
this.capCommissionConfigs = new CapItem; // Changed: Added for Commission module
|
|
3196
3713
|
this.capCommissionEntries = new CapItem; // Changed: Added for Commission module
|
|
3714
|
+
this.capCommissionStatement = new CapItem; // Added (Phase 6): Driver Commission Statement (cap86)
|
|
3197
3715
|
this.capSalaryAdvances = new CapItem; // Changed: Added for Salary Advance module
|
|
3198
3716
|
this.capOvertimeEntries = new CapItem; // Changed: Added for Overtime module
|
|
3199
3717
|
this.capApprovals = new CapItem;
|
|
@@ -4209,7 +4727,7 @@ class DataServiceLib {
|
|
|
4209
4727
|
this.capPayroll.display = "Payroll";
|
|
4210
4728
|
this.capPayroll.moduleKey = "payroll"; // Added (Setup v3)
|
|
4211
4729
|
this.capPayroll.icon = "payments";
|
|
4212
|
-
this.capPayroll.capSubItems = [this.capPayrollDashboard, this.capSalaryStructures, this.capStatutoryDeductions, this.capPayrollRuns, this.capCommissionConfigs, this.capCommissionEntries, this.capSalaryAdvances, this.capOvertimeEntries]; // Changed:
|
|
4730
|
+
this.capPayroll.capSubItems = [this.capPayrollDashboard, this.capSalaryStructures, this.capStatutoryDeductions, this.capPayrollRuns, this.capCommissionConfigs, this.capCommissionEntries, this.capCommissionStatement, this.capSalaryAdvances, this.capOvertimeEntries]; // Changed (Phase 6): commission statement sits directly under the entries it explains
|
|
4213
4731
|
this.capPayrollDashboard.name = "cap72"; // Changed: Reuses module cap number for dashboard visibility
|
|
4214
4732
|
this.capPayrollDashboard.display = "Dashboard";
|
|
4215
4733
|
this.capPayrollDashboard.link = "home/payroll/dashboard";
|
|
@@ -4236,6 +4754,14 @@ class DataServiceLib {
|
|
|
4236
4754
|
this.capCommissionEntries.display = "Commission Entries";
|
|
4237
4755
|
this.capCommissionEntries.link = "home/payroll/commission-entries";
|
|
4238
4756
|
this.capCommissionEntries.icon = "request_quote";
|
|
4757
|
+
// Added (Phase 6): the Driver Commission Statement — cap86, its own capability rather than cap77's, because
|
|
4758
|
+
// it is the surface that GENERATES a period as well as reporting it, and because reading a whole period's
|
|
4759
|
+
// commission across every driver is a wider grant than working one entry. Deliberately in NO role template:
|
|
4760
|
+
// it is registered and reachable, and someone has to grant it on purpose.
|
|
4761
|
+
this.capCommissionStatement.name = "cap86";
|
|
4762
|
+
this.capCommissionStatement.display = "Commission Statement";
|
|
4763
|
+
this.capCommissionStatement.link = "home/payroll/commission-statement";
|
|
4764
|
+
this.capCommissionStatement.icon = "receipt_long";
|
|
4239
4765
|
// Changed: Added Salary Advances sidebar item
|
|
4240
4766
|
this.capSalaryAdvances.name = "cap78";
|
|
4241
4767
|
this.capSalaryAdvances.display = "Salary Advances";
|
|
@@ -5818,7 +6344,12 @@ class InventoryService {
|
|
|
5818
6344
|
heroField: 'requisitionID'
|
|
5819
6345
|
};
|
|
5820
6346
|
this.requisitionEditButton = { name: 'edit', dialog: true, action: { url: 'requisitions?action=edit', method: 'post' } };
|
|
5821
|
-
|
|
6347
|
+
// Changed: `inDialog: true` added. This button lives ONLY in requisitionDetailsConfig.buttons and is absent
|
|
6348
|
+
// from the table's own buttons, and Core.setupButtons filters a details dialog's extraButtons on exactly that
|
|
6349
|
+
// flag — so it rendered NOWHERE. Its two siblings below both carry it. Colour changed from the Material palette
|
|
6350
|
+
// name 'primary', which ButtonService.getButtonColor puts verbatim into [style.color] where it computes to the
|
|
6351
|
+
// inherited grey, to the house CSS keyword 'blue'.
|
|
6352
|
+
this.requisitionSubmitButton = { name: 'submit', display: 'Submit Requisition', icon: { name: 'send', color: 'blue' }, inDialog: true, action: { url: 'requisitions?action=submit', method: 'post', successMessage: 'Requisition Submitted' }, confirm: { message: 'Submit this requisition for approval?' }, visible: (row) => row.status === 0 };
|
|
5822
6353
|
this.requisitionIssueButton = { name: 'issue', display: 'Issue Items', icon: { name: 'check', color: 'green' }, inDialog: true, action: { url: 'requisitions?action=issue', method: 'post', successMessage: 'Items Issued' }, confirm: { message: 'Issue items for this requisition?' }, visible: (row) => row.pendingApproval === false && row.status === 0 };
|
|
5823
6354
|
this.requisitionCancelButton = { name: 'cancel', display: 'Cancel Request', icon: { name: 'close', color: 'red' }, inDialog: true, action: { url: 'requisitions?action=cancel', method: 'post', successMessage: 'Requisition Cancelled' }, confirm: { message: 'Cancel this requisition?' }, visible: (row) => row.status === 0 };
|
|
5824
6355
|
this.requisitionDetailsConfig = {
|
|
@@ -6631,6 +7162,54 @@ class AccountingService {
|
|
|
6631
7162
|
searchAction: { url: 'invoices/search?action=statement-search', method: 'post' }
|
|
6632
7163
|
}
|
|
6633
7164
|
};
|
|
7165
|
+
//--------------------------Open-item statement (customer / supplier)-------------------------
|
|
7166
|
+
// Added: the open-item statement was hand-written markup on the page (its own flex row of totals and its own
|
|
7167
|
+
// <table>). These configs move it onto spa-tiles + spa-table so it looks and behaves like every other page.
|
|
7168
|
+
// The figures the API returns are unrounded sums, hence format: 'money' on every tile.
|
|
7169
|
+
// Added: headline figures for the customer open-item statement
|
|
7170
|
+
this.customerStatementTileConfig = {
|
|
7171
|
+
tiles: [
|
|
7172
|
+
{ name: 'totalInvoiced', alias: 'Total Invoiced', icon: 'receipt_long', color: '#2196f3', format: 'money', info: 'Everything invoiced in the period' },
|
|
7173
|
+
{ name: 'totalPaid', alias: 'Total Paid', icon: 'payments', color: '#4CAF50', format: 'money', info: 'Receipts allocated against those invoices' },
|
|
7174
|
+
{ name: 'totalCredited', alias: 'Total Credited', icon: 'undo', color: '#FF9800', format: 'money', info: 'Credit notes raised in the period' },
|
|
7175
|
+
{ name: 'totalOutstanding', alias: 'Total Outstanding', icon: 'account_balance_wallet', format: 'money', color: x => (x?.totalOutstanding > 0 ? '#F44336' : '#4CAF50'), info: 'What the customer still owes' } // red while owing, green once settled — the colour the old hand-written card carried
|
|
7176
|
+
]
|
|
7177
|
+
};
|
|
7178
|
+
// Added: headline figures for the supplier open-item statement (no credit notes on the purchase side)
|
|
7179
|
+
this.supplierStatementTileConfig = {
|
|
7180
|
+
tiles: [
|
|
7181
|
+
{ name: 'totalPurchased', alias: 'Total Purchased', icon: 'shopping_cart', color: '#2196f3', format: 'money', info: 'Everything purchased on credit in the period' },
|
|
7182
|
+
{ name: 'totalPaid', alias: 'Total Paid', icon: 'payments', color: '#4CAF50', format: 'money', info: 'Payments made against those purchases' },
|
|
7183
|
+
{ name: 'totalOutstanding', alias: 'Total Outstanding', icon: 'account_balance_wallet', format: 'money', color: x => (x?.totalOutstanding > 0 ? '#F44336' : '#4CAF50'), info: 'What is still owed to the supplier' }
|
|
7184
|
+
]
|
|
7185
|
+
};
|
|
7186
|
+
// Added: the statement lines themselves. Customer and supplier lines have the SAME shape, so one config serves
|
|
7187
|
+
// both — the page clones it per tab so the two grids never share mutable state.
|
|
7188
|
+
this.statementLinesTableConfig = {
|
|
7189
|
+
showFilter: true,
|
|
7190
|
+
flatButtons: true,
|
|
7191
|
+
noDataMessage: 'No open items for the selected period',
|
|
7192
|
+
minColumns: ['date', 'reference', 'outstanding'],
|
|
7193
|
+
columns: [
|
|
7194
|
+
{ name: 'date', type: 'date', alias: 'Date' },
|
|
7195
|
+
{ name: 'reference', type: 'text', alias: 'Reference' },
|
|
7196
|
+
{ name: 'type', type: 'text', alias: 'Type' },
|
|
7197
|
+
{ name: 'total', type: 'money', alias: 'Total' },
|
|
7198
|
+
{ name: 'paid', type: 'money', alias: 'Paid' },
|
|
7199
|
+
{ name: 'outstanding', type: 'money', alias: 'Outstanding' },
|
|
7200
|
+
{ name: 'status', type: 'chip', alias: 'Status', // Changed: a chip, NOT type 'select' — a select column renders an editable dropdown, and a statement is read-only
|
|
7201
|
+
colors: [
|
|
7202
|
+
{ name: '#A5D6A7', condition: x => x.status == 'Paid' },
|
|
7203
|
+
{ name: '#FFD54F', condition: x => x.status == 'Paying' },
|
|
7204
|
+
{ name: '#90CAF9', condition: x => x.status == 'Submitted' },
|
|
7205
|
+
{ name: '#CE93D8', condition: x => x.status == 'Applied' },
|
|
7206
|
+
{ name: '#9E9E9E', condition: x => x.status == 'Written Off' }
|
|
7207
|
+
]
|
|
7208
|
+
},
|
|
7209
|
+
{ name: 'agingBucket', type: 'text', alias: 'Aging' }
|
|
7210
|
+
],
|
|
7211
|
+
buttons: []
|
|
7212
|
+
};
|
|
6634
7213
|
//--------------------------Tax Rates-------------------------
|
|
6635
7214
|
// Changed: Tax Rate form configuration for VAT management
|
|
6636
7215
|
this.taxRateFormConfig = {
|
|
@@ -8339,7 +8918,10 @@ class SalesService {
|
|
|
8339
8918
|
heroField: 'salesOrderID'
|
|
8340
8919
|
};
|
|
8341
8920
|
this.salesOrderEditButton = { name: 'edit', dialog: true, action: { url: 'salesorders?action=edit', method: 'post' } };
|
|
8342
|
-
|
|
8921
|
+
// Changed: colour was the Material palette name 'primary', which getButtonColor puts verbatim into
|
|
8922
|
+
// [style.color] and which therefore computed to the inherited grey. 'blue' is the house CSS keyword, and it
|
|
8923
|
+
// keeps 'green' meaning the terminal step (Mark Delivered, below).
|
|
8924
|
+
this.salesOrderConfirmButton = { name: 'confirm', display: 'Confirm Order', icon: { name: 'check_circle', color: 'blue' }, inDialog: true, action: { url: 'salesorders?action=confirm', method: 'post', successMessage: 'Order Confirmed' }, confirm: { message: 'Confirm this order?' }, visible: (row) => row.status === 0 };
|
|
8343
8925
|
this.salesOrderDeliverButton = { name: 'deliver', display: 'Mark Delivered', icon: { name: 'local_shipping', color: 'green' }, inDialog: true, action: { url: 'salesorders?action=deliver', method: 'post', successMessage: 'Order Delivered' }, confirm: { message: 'Mark this order as delivered?' }, visible: (row) => row.status === 1 };
|
|
8344
8926
|
this.salesOrderDetailsConfig = {
|
|
8345
8927
|
formConfig: this.salesOrderFormConfig,
|
|
@@ -8522,7 +9104,7 @@ class SalesService {
|
|
|
8522
9104
|
heroField: 'saleID',
|
|
8523
9105
|
stepConfig: this.saleStepConfig,
|
|
8524
9106
|
buttons: [
|
|
8525
|
-
{ name: 'complete', display: 'Complete Sale', color: '
|
|
9107
|
+
{ name: 'complete', display: 'Complete Sale', color: 'green', inDialog: true, action: { url: 'sales?action=complete', method: 'post' }, confirm: { message: 'Complete this sale? This will reduce inventory and create accounting entries.' }, visible: x => x.timing === 0 && x.paymentStatus !== 1 },
|
|
8526
9108
|
this.saleRecordPaymentButton
|
|
8527
9109
|
]
|
|
8528
9110
|
};
|
|
@@ -9586,35 +10168,133 @@ class PayrollService {
|
|
|
9586
10168
|
formConfig: this.employeeSalaryComponentFormConfig,
|
|
9587
10169
|
};
|
|
9588
10170
|
//--------------------------Commission Configs-------------------------
|
|
10171
|
+
// Added (Phase 5): a commission rule is a parent with tier children, which is the two-step create shape —
|
|
10172
|
+
// the create dialog captures the rule itself, then the details dialog opens with the Tiers tab. Extracted
|
|
10173
|
+
// before detailsConfig so it can be referenced without a forward reference.
|
|
10174
|
+
this.editCommissionConfigButton = { name: 'edit', dialog: true, inDialog: true, action: { url: 'commissionconfigs?action=edit', method: 'post' } };
|
|
9589
10175
|
this.commissionConfigFormConfig = {
|
|
9590
10176
|
security: { allow: [this.dataService.capCommissionConfigs] }, // Added: gate commission config form by commission configs cap
|
|
9591
10177
|
title: 'Commission Config',
|
|
10178
|
+
// Added (Phase 5): the two rules that make a config silently earn nothing, said out loud rather than left to
|
|
10179
|
+
// be discovered when a period pays 0.00. Both mirror refusals CommissionRuleEvaluator already makes.
|
|
10180
|
+
alertConfig: {
|
|
10181
|
+
compact: true,
|
|
10182
|
+
messages: [
|
|
10183
|
+
{ message: 'This rule is scoped to nobody — set an employee, a position, or tick Applies To All Employees, or it can never resolve', type: 'critical',
|
|
10184
|
+
visible: (x) => !!x.commissionConfigID && !x.employeeID && !x.positionID && !x.appliesToAllEmployees
|
|
10185
|
+
},
|
|
10186
|
+
{ message: 'Per-distance rates read trip mileage, which is only captured when Tyre Management is enabled — this rule will compute 0.00 until it is', type: 'warn',
|
|
10187
|
+
visible: (x) => x.basis === 4
|
|
10188
|
+
},
|
|
10189
|
+
]
|
|
10190
|
+
},
|
|
9592
10191
|
fields: [
|
|
9593
|
-
{ name: '
|
|
9594
|
-
{ name: 'name', type: 'text', required: true },
|
|
9595
|
-
{ name: 'metricType', alias: 'Metric Type', type: 'select', loadAction: { url: 'commissionconfigs/list/metric-type' }, required: true },
|
|
9596
|
-
{ name: 'rateType', alias: 'Rate Type', type: 'select', loadAction: { url: 'commissionconfigs/list/rate-type' }, required: true },
|
|
9597
|
-
{ name: 'rate', type: 'number', required: true },
|
|
10192
|
+
{ name: 'name', type: 'text', required: true, hint: 'How this arrangement is referred to, e.g. "Drivers 5% of load value"' },
|
|
9598
10193
|
{ name: 'isActive', type: 'checkbox', alias: 'Active', defaultValue: true },
|
|
10194
|
+
// Changed (Phase 5): who the rule resolves to. ResolveRules is a ladder — employee rules win, then position
|
|
10195
|
+
// rules, then the tenant-wide rule — and the levels do NOT stack, so only one level is offered at a time.
|
|
10196
|
+
{ name: 'scope', type: 'section', alias: 'Scope', collapseOnView: true },
|
|
10197
|
+
{ name: 'employeeID', type: 'select', alias: 'Employee', section: 'scope', nullable: true, loadAction: { url: 'employees/list/x' }, requiredCondition: (x) => !x.positionID && !x.appliesToAllEmployees, hint: 'Leave empty to scope by position or across the whole company' },
|
|
10198
|
+
{ name: 'positionID', type: 'select', alias: 'Position', section: 'scope', nullable: true, loadAction: { url: 'positions/list/x' }, hidden: (x) => x.employeeID > 0, hint: 'Everyone holding this position, e.g. every Driver' },
|
|
10199
|
+
{ name: 'appliesToAllEmployees', type: 'checkbox', alias: 'Applies To All Employees', section: 'scope', hidden: (x) => x.employeeID > 0 || x.positionID > 0 },
|
|
10200
|
+
{ name: 'priority', type: 'number', alias: 'Priority', section: 'scope', defaultValue: 0, hint: 'Higher wins when two rules resolve at the same level' },
|
|
10201
|
+
{ name: 'customerID', type: 'select', alias: 'Only For Customer', section: 'scope', nullable: true, loadAction: { url: 'customers/list/x' }, hideOnCreate: true },
|
|
10202
|
+
{ name: 'serviceItemID', type: 'select', alias: 'Only For Service', section: 'scope', nullable: true, loadAction: { url: 'serviceitems/list/x' }, hideOnCreate: true },
|
|
10203
|
+
{ name: 'selfSourcedOnly', type: 'checkbox', alias: 'Self Sourced Loads Only', section: 'scope', hidden: (x) => x.basis === 1 || x.basis === 4, infoMessage: 'Pays the person recorded as Sourced By on the load rather than the driver who carried it. Load-level bases only — a trip has no sourcer.' }, // Changed (Phase 6, browser pass): the hyphen is gone on purpose — every alias is piped through Core.camelToWords, which inserts a space before each capital, so "Self-Sourced" rendered as "Self- Sourced" on screen
|
|
10204
|
+
// Changed (Phase 5): Basis is what the engine actually measures. MetricType below is the superseded column,
|
|
10205
|
+
// kept in step so old rows stay coherent, but nothing computes from it any more.
|
|
10206
|
+
{ name: 'rateAndTiers', type: 'section', alias: 'Rate & Tiers' },
|
|
10207
|
+
{ name: 'basis', type: 'select', alias: 'Basis', section: 'rateAndTiers', required: true, defaultValue: 1, loadAction: { url: 'commissionconfigs/list/basis' },
|
|
10208
|
+
onSelectChange: (value, data) => {
|
|
10209
|
+
data.metricType = (value === 2 || value === 5) ? 2 : 1; // keep the superseded MetricType consistent with Basis
|
|
10210
|
+
if (value === 1 || value === 4) {
|
|
10211
|
+
data.earnEvent = 1;
|
|
10212
|
+
data.selfSourcedOnly = false;
|
|
10213
|
+
} // trip-level bases: the engine skips any other earn event, and has no sourcer to pay
|
|
10214
|
+
}
|
|
10215
|
+
},
|
|
10216
|
+
{ name: 'rateType', alias: 'Rate Type', type: 'select', section: 'rateAndTiers', loadAction: { url: 'commissionconfigs/list/rate-type' }, required: true },
|
|
10217
|
+
{ name: 'rate', type: 'number', alias: 'Rate', section: 'rateAndTiers', required: true, suffix: '%', hidden: (x) => x.rateType !== 2, hint: 'Percentage of the measured value. Used for any part of the metric no tier band covers.' },
|
|
10218
|
+
{ name: 'rate', type: 'number', alias: 'Rate', section: 'rateAndTiers', required: true, hidden: (x) => x.rateType === 2, hint: 'Amount per unit measured. Used for any part of the metric no tier band covers.' },
|
|
10219
|
+
{ name: 'metricType', type: 'select', hidden: true, defaultValue: 1 }, // superseded by Basis; posted only so existing rows keep a valid legacy value
|
|
10220
|
+
// Changed (Phase 5): hidden on create because a money input cannot post "empty" — it posts 0, and a Maximum
|
|
10221
|
+
// of 0 caps every period to nothing. Set deliberately on the saved rule instead of by accident on a new one.
|
|
10222
|
+
{ name: 'limits', type: 'section', alias: 'Limits', collapseOnView: true, hideOnCreate: true },
|
|
10223
|
+
{ name: 'minimumAmount', type: 'money', alias: 'Minimum Per Period', section: 'limits', hideOnCreate: true, clearContent: true, hint: 'Guaranteed floor. Leave empty for none.' },
|
|
10224
|
+
{ name: 'maximumAmount', type: 'money', alias: 'Maximum Per Period', section: 'limits', hideOnCreate: true, clearContent: true, hint: 'Cap. Leave empty for uncapped — a cap of 0 pays nothing.' },
|
|
10225
|
+
// Changed (Phase 5): same reason — a date input posts today, and an Effective From of today silently drops
|
|
10226
|
+
// every load earned earlier in the same month.
|
|
10227
|
+
{ name: 'timing', type: 'section', alias: 'Timing', collapseOnView: true },
|
|
10228
|
+
{ name: 'earnEvent', alias: 'Earned When', type: 'select', section: 'timing', required: true, defaultValue: 1, loadAction: { url: 'commissionconfigs/list/earn-event' }, readonlyCondition: (x) => x.basis === 1 || x.basis === 4, hint: 'Trip-level bases earn on completion only' },
|
|
10229
|
+
{ name: 'effectiveFrom', type: 'date', alias: 'Effective From', section: 'timing', hideOnCreate: true, clearContent: true, hint: 'Empty means the rule has always applied. Work before this date is excluded.' },
|
|
10230
|
+
{ name: 'effectiveTo', type: 'date', alias: 'Effective To', section: 'timing', hideOnCreate: true, clearContent: true, hint: 'Empty means the rule is open-ended' },
|
|
9599
10231
|
],
|
|
9600
10232
|
loadAction: { url: 'commissionconfigs/id' },
|
|
9601
10233
|
heroField: 'commissionConfigID',
|
|
9602
10234
|
includeAudit: true,
|
|
9603
10235
|
};
|
|
10236
|
+
//--------------------------Commission Config Tiers (child of CommissionConfig)-------------------------
|
|
10237
|
+
this.commissionConfigTierFormConfig = {
|
|
10238
|
+
security: { allow: [this.dataService.capCommissionConfigs] }, // Added: a tier is gated by its parent rule's cap
|
|
10239
|
+
title: 'Tier Band',
|
|
10240
|
+
fixedTitle: true,
|
|
10241
|
+
fields: [
|
|
10242
|
+
{ name: 'thresholdFrom', alias: 'From', type: 'number', required: true, defaultValue: 0, hint: 'Metric value where this band starts' },
|
|
10243
|
+
{ name: 'thresholdTo', alias: 'To', type: 'number', hint: 'Leave as 0 for the top, open-ended band' },
|
|
10244
|
+
{ name: 'rate', type: 'number', alias: 'Rate', required: true, hint: 'Applied only to the portion of the metric inside this band' },
|
|
10245
|
+
],
|
|
10246
|
+
loadAction: { url: 'commissionconfigtiers/id' },
|
|
10247
|
+
heroField: 'commissionConfigTierID',
|
|
10248
|
+
};
|
|
10249
|
+
this.commissionConfigTiersTableConfig = {
|
|
10250
|
+
tabTitle: 'Tiers',
|
|
10251
|
+
showFilter: false,
|
|
10252
|
+
elevation: 'none',
|
|
10253
|
+
flatButtons: true,
|
|
10254
|
+
noDataMessage: 'No bands — the flat rate on the rule applies to the whole metric',
|
|
10255
|
+
columns: [
|
|
10256
|
+
{ name: 'thresholdFrom', type: 'number', alias: 'From' },
|
|
10257
|
+
{ name: 'thresholdTo', type: 'number', alias: 'To',
|
|
10258
|
+
icons: [{ name: 'all_inclusive', color: 'grey', tip: 'Open-ended top band', condition: (row) => row?.isOpenEnded }]
|
|
10259
|
+
},
|
|
10260
|
+
{ name: 'rate', type: 'number', alias: 'Rate' },
|
|
10261
|
+
],
|
|
10262
|
+
buttons: [
|
|
10263
|
+
{ name: 'create', display: 'Add Band', dialog: true, action: { url: 'commissionconfigtiers?action=create', method: 'post' } },
|
|
10264
|
+
{ name: 'edit', dialog: true, action: { url: 'commissionconfigtiers?action=edit', method: 'post' } },
|
|
10265
|
+
{ name: 'delete', dialog: true, action: { url: 'commissionconfigtiers?action=delete', method: 'post' } },
|
|
10266
|
+
],
|
|
10267
|
+
loadAction: { url: 'commissionconfigtiers/x/x' }, loadCriteria: 'config', loadIDField: 'commissionConfigID',
|
|
10268
|
+
formConfig: this.commissionConfigTierFormConfig,
|
|
10269
|
+
};
|
|
10270
|
+
// Added (Phase 5): defined AFTER the tiers table and BEFORE the view/create buttons, per the two-step create pattern
|
|
10271
|
+
this.commissionConfigDetailsConfig = {
|
|
10272
|
+
formConfig: this.commissionConfigFormConfig,
|
|
10273
|
+
heroField: 'commissionConfigID',
|
|
10274
|
+
buttons: [this.editCommissionConfigButton],
|
|
10275
|
+
tableConfigs: [this.commissionConfigTiersTableConfig],
|
|
10276
|
+
};
|
|
10277
|
+
this.viewCommissionConfigButton = { name: 'view', dialog: true, detailsConfig: this.commissionConfigDetailsConfig };
|
|
10278
|
+
this.createCommissionConfigButton = { name: 'create', display: 'Create', dialog: true, onSuccessButton: this.viewCommissionConfigButton, action: { url: 'commissionconfigs?action=create', method: 'post' } };
|
|
9604
10279
|
this.commissionConfigsTableConfig = {
|
|
9605
10280
|
showFilter: true,
|
|
9606
10281
|
flatButtons: true,
|
|
9607
|
-
minColumns: ['
|
|
10282
|
+
minColumns: ['scopeName', 'name', 'rate'],
|
|
9608
10283
|
columns: [
|
|
9609
|
-
{ name: '
|
|
10284
|
+
{ name: 'scopeName', type: 'text', alias: 'Applies To', // Changed (Phase 5): employeeName is blank on a position or company-wide rule, which read as an unassigned rule
|
|
10285
|
+
colors: [{ name: 'red', condition: (row) => !row?.employeeID && !row?.positionID && !row?.appliesToAllEmployees }]
|
|
10286
|
+
},
|
|
9610
10287
|
{ name: 'name', type: 'text' },
|
|
9611
|
-
{ name: '
|
|
10288
|
+
{ name: 'basisName', type: 'text', alias: 'Basis' }, // Changed (Phase 5): Basis replaced MetricType as what the engine measures
|
|
9612
10289
|
{ name: 'rateTypeName', type: 'text', alias: 'Rate Type' },
|
|
9613
10290
|
{ name: 'rate', type: 'text', alias: 'Rate' },
|
|
10291
|
+
{ name: 'tierCount', type: 'text', alias: 'Tiers' },
|
|
10292
|
+
{ name: 'earnEventName', type: 'text', alias: 'Earned When' },
|
|
9614
10293
|
{ name: 'isActive', type: 'checkbox', alias: 'Active' },
|
|
9615
10294
|
],
|
|
9616
10295
|
buttons: [
|
|
9617
|
-
|
|
10296
|
+
this.createCommissionConfigButton,
|
|
10297
|
+
this.viewCommissionConfigButton,
|
|
9618
10298
|
{ name: 'edit', dialog: true, action: { url: 'commissionconfigs?action=edit', method: 'post' } },
|
|
9619
10299
|
{ name: 'delete', dialog: true, action: { url: 'commissionconfigs?action=delete', method: 'post' } },
|
|
9620
10300
|
],
|
|
@@ -9631,13 +10311,15 @@ class PayrollService {
|
|
|
9631
10311
|
flatButtons: true,
|
|
9632
10312
|
columns: [
|
|
9633
10313
|
{ name: 'name', type: 'text' },
|
|
9634
|
-
{ name: '
|
|
10314
|
+
{ name: 'basisName', type: 'text', alias: 'Basis' }, // Changed (Phase 5): matches the main list
|
|
9635
10315
|
{ name: 'rateTypeName', type: 'text', alias: 'Rate Type' },
|
|
9636
10316
|
{ name: 'rate', type: 'text', alias: 'Rate' },
|
|
10317
|
+
{ name: 'tierCount', type: 'text', alias: 'Tiers' },
|
|
9637
10318
|
{ name: 'isActive', type: 'checkbox', alias: 'Active' },
|
|
9638
10319
|
],
|
|
9639
10320
|
buttons: [
|
|
9640
|
-
{ name: 'create', display: 'Create', dialog: true, action: { url: 'commissionconfigs?action=create', method: 'post' } },
|
|
10321
|
+
{ name: 'create', display: 'Create', dialog: true, onSuccessButton: this.viewCommissionConfigButton, action: { url: 'commissionconfigs?action=create', method: 'post' } }, // Changed (Phase 5): the tiers tab is only reachable through the details dialog, so a rule created here opens into it too
|
|
10322
|
+
this.viewCommissionConfigButton,
|
|
9641
10323
|
{ name: 'edit', dialog: true, action: { url: 'commissionconfigs?action=edit', method: 'post' } },
|
|
9642
10324
|
{ name: 'delete', dialog: true, action: { url: 'commissionconfigs?action=delete', method: 'post' } },
|
|
9643
10325
|
],
|
|
@@ -9645,15 +10327,30 @@ class PayrollService {
|
|
|
9645
10327
|
formConfig: this.commissionConfigFormConfig,
|
|
9646
10328
|
};
|
|
9647
10329
|
//--------------------------Commission Entries-------------------------
|
|
10330
|
+
// Added (Phase 5): the approval gate, bound to the status CommissionEntriesController exposes
|
|
10331
|
+
// (1=Draft, 2=Approved, 3=Paid, 4=Reversed). Only Approved entries are payable — Draft is a proposal and
|
|
10332
|
+
// Paid is settled — and the server refuses BOTH transitions on an entry that has reached a payslip line,
|
|
10333
|
+
// so isPaid is tested here too rather than trusting status alone.
|
|
10334
|
+
// Changed (Phase 6, browser pass): the colours were 'primary' and 'warn' — Angular Material PALETTE names.
|
|
10335
|
+
// ButtonService.getButtonColor returns button.icon.color verbatim into [style.color], which needs a CSS colour,
|
|
10336
|
+
// so both computed to the inherited grey rgb(68,71,78) while edit rendered rgb(64,80,181) and delete rgb(244,67,54).
|
|
10337
|
+
// The button that authorises money was visually indistinguishable from every other icon in the row.
|
|
10338
|
+
// Added (Phase 6): `inDialog` is what puts a button in the details dialog's footer — Core.setupButtons filters
|
|
10339
|
+
// extraButtons on it, so without it the two gates existed on the row and vanished the moment the entry was
|
|
10340
|
+
// opened to read its breakdown. It is ignored by the row renderer, so one object serves both places and the
|
|
10341
|
+
// gate cannot drift between them.
|
|
10342
|
+
this.approveCommissionEntryButton = { name: 'approve', display: 'Approve', inDialog: true, icon: { name: 'check_circle', color: 'green' }, action: { url: 'commissionentries?action=approve', method: 'post', successMessage: 'Approved' }, confirm: { message: 'Approve this commission entry so it can be paid on a payslip?' }, visible: (row) => row?.status === 1 && !row?.isPaid };
|
|
10343
|
+
this.returnCommissionEntryButton = { name: 'return', display: 'Return to Draft', inDialog: true, icon: { name: 'undo', color: 'orange' }, action: { url: 'commissionentries?action=return', method: 'post', successMessage: 'Returned to Draft' }, confirm: { message: 'Return this entry to Draft? It will not be picked up by a payroll run until it is approved again.' }, visible: (row) => row?.status === 2 && !row?.isPaid };
|
|
9648
10344
|
this.commissionEntryFormConfig = {
|
|
9649
10345
|
security: { allow: [this.dataService.capCommissionEntries] }, // Added: gate commission entry form by commission entries cap
|
|
9650
10346
|
title: 'Commission Entry',
|
|
9651
10347
|
fields: [
|
|
9652
|
-
{ name: 'employeeID', type: 'select', alias: 'Employee', loadAction: { url: 'employees/list/x' }, required: true },
|
|
9653
|
-
{ name: 'periodYear', alias: 'Year', type: 'number', required: true },
|
|
9654
|
-
{ name: 'periodMonth', alias: 'Month', type: 'number', required: true },
|
|
9655
|
-
{ name: 'metricValue', alias: 'Metric Value', type: 'number', required: true },
|
|
9656
|
-
{ name: 'commissionAmount', alias: 'Commission Amount', type: 'money', required: true },
|
|
10348
|
+
{ name: 'employeeID', type: 'select', alias: 'Employee', loadAction: { url: 'employees/list/x' }, required: true, readonlyCondition: (x) => x.isPaid },
|
|
10349
|
+
{ name: 'periodYear', alias: 'Year', type: 'number', required: true, readonlyCondition: (x) => x.isPaid },
|
|
10350
|
+
{ name: 'periodMonth', alias: 'Month', type: 'number', required: true, readonlyCondition: (x) => x.isPaid },
|
|
10351
|
+
{ name: 'metricValue', alias: 'Metric Value', type: 'number', required: true, readonlyCondition: (x) => x.isPaid },
|
|
10352
|
+
{ name: 'commissionAmount', alias: 'Commission Amount', type: 'money', required: true, readonlyCondition: (x) => x.isPaid }, // Changed (Phase 5): a settled figure is history — the payslip line it paid is already out
|
|
10353
|
+
{ name: 'statusName', alias: 'Status', type: 'text', readonly: true, hideOnCreate: true }, // Added (Phase 5): the gate itself, so an entry that pays nothing reads as Draft rather than as a bug
|
|
9657
10354
|
// Changed: sectioned — who earned what for which period is the entry; where it came from is provenance,
|
|
9658
10355
|
// read only when a figure is being queried
|
|
9659
10356
|
{ name: 'source', type: 'section', alias: 'Source & Description', collapseOnView: true },
|
|
@@ -9665,23 +10362,70 @@ class PayrollService {
|
|
|
9665
10362
|
heroField: 'commissionEntryID',
|
|
9666
10363
|
includeAudit: true,
|
|
9667
10364
|
};
|
|
10365
|
+
//--------------------------Commission Entry Lines (child of CommissionEntry)-------------------------
|
|
10366
|
+
// Added (Phase 6): the breakdown that makes a total explainable. Deliberately has NO buttons — a line is
|
|
10367
|
+
// generated evidence and the header amount is the sum of these rows, so hand-editing one would make the
|
|
10368
|
+
// breakdown disagree with what payroll actually paid. CommissionEntryLinesController refuses the writes
|
|
10369
|
+
// server-side too; this is the same refusal said in the UI rather than only when a POST comes back.
|
|
10370
|
+
this.commissionEntryLinesTableConfig = {
|
|
10371
|
+
tabTitle: 'Lines',
|
|
10372
|
+
showFilter: false,
|
|
10373
|
+
elevation: 'none',
|
|
10374
|
+
flatButtons: true,
|
|
10375
|
+
noDataMessage: 'No breakdown — this entry was entered by hand rather than generated from trips',
|
|
10376
|
+
columns: [
|
|
10377
|
+
{ name: 'eventDate', type: 'date', alias: 'Earned On' },
|
|
10378
|
+
{ name: 'sourceDescription', type: 'text', alias: 'Source', // Changed (Phase 6): an adjustment line is the floor/cap correction, not a trip — it has no source to open
|
|
10379
|
+
icons: [{ name: 'tune', color: 'grey', tip: 'Adjustment — the rule\'s minimum or maximum moved the total, this is the difference', condition: (row) => row?.isAdjustment }]
|
|
10380
|
+
},
|
|
10381
|
+
{ name: 'sourceType', type: 'text', alias: 'Type' },
|
|
10382
|
+
{ name: 'metricValue', type: 'text', alias: 'Measured' },
|
|
10383
|
+
{ name: 'rateApplied', type: 'text', alias: 'Rate' },
|
|
10384
|
+
{ name: 'amount', type: 'money', alias: 'Amount' },
|
|
10385
|
+
],
|
|
10386
|
+
buttons: [],
|
|
10387
|
+
loadAction: { url: 'commissionentrylines/x/x' }, loadCriteria: 'entry', loadIDField: 'commissionEntryID',
|
|
10388
|
+
};
|
|
10389
|
+
// Added (Phase 6): defined AFTER the lines table and BEFORE the view button, per the two-step create pattern.
|
|
10390
|
+
// The approve/return buttons are the SAME objects the table row uses, so the gate cannot drift between the
|
|
10391
|
+
// two places it is offered — both read status and isPaid, and the server refuses both on a settled entry.
|
|
10392
|
+
this.commissionEntryDetailsConfig = {
|
|
10393
|
+
formConfig: this.commissionEntryFormConfig,
|
|
10394
|
+
heroField: 'commissionEntryID',
|
|
10395
|
+
buttons: [this.approveCommissionEntryButton, this.returnCommissionEntryButton],
|
|
10396
|
+
tableConfigs: [this.commissionEntryLinesTableConfig],
|
|
10397
|
+
};
|
|
10398
|
+
this.viewCommissionEntryButton = { name: 'view', dialog: true, detailsConfig: this.commissionEntryDetailsConfig };
|
|
9668
10399
|
this.commissionEntriesTableConfig = {
|
|
9669
10400
|
showFilter: true,
|
|
9670
10401
|
flatButtons: true,
|
|
9671
|
-
minColumns: ['employeeName', 'commissionAmount', '
|
|
10402
|
+
minColumns: ['employeeName', 'commissionAmount', 'statusName'],
|
|
9672
10403
|
columns: [
|
|
9673
10404
|
{ name: 'employeeName', type: 'text', alias: 'Employee' },
|
|
9674
10405
|
{ name: 'periodYear', type: 'text', alias: 'Year' },
|
|
9675
10406
|
{ name: 'periodMonth', type: 'text', alias: 'Month' },
|
|
9676
10407
|
{ name: 'metricValue', type: 'text', alias: 'Metric Value' },
|
|
9677
|
-
{ name: 'commissionAmount', type: 'money', alias: 'Amount'
|
|
10408
|
+
{ name: 'commissionAmount', type: 'money', alias: 'Amount', // Changed (Phase 5): a 0.00 entry is legitimate (per-km with mileage off, or a clawback netting out) but it must never sit silently
|
|
10409
|
+
icons: [{ name: 'error_outline', color: 'orange', tip: 'This entry computed to nothing. Per-distance rules read trip mileage, which needs Tyre Management enabled; a credit-settled invoice is also not treated as paid.', condition: (row) => row?.commissionAmount === 0 }]
|
|
10410
|
+
},
|
|
10411
|
+
{ name: 'statusName', type: 'text', alias: 'Status', // Changed (Phase 5): the approval gate — a Draft entry is invisible to payroll, which otherwise reads as commission being ignored
|
|
10412
|
+
colors: [
|
|
10413
|
+
{ name: 'grey', condition: (row) => row?.status === 1 },
|
|
10414
|
+
{ name: 'green', condition: (row) => row?.status === 2 },
|
|
10415
|
+
{ name: 'red', condition: (row) => row?.status === 4 },
|
|
10416
|
+
]
|
|
10417
|
+
},
|
|
10418
|
+
{ name: 'isPaid', type: 'checkbox', alias: 'Paid' }, // Changed (Phase 5): settled on a payslip line and no longer changeable, by either side
|
|
9678
10419
|
{ name: 'description', type: 'text' },
|
|
9679
10420
|
{ name: 'sourceType', type: 'text', alias: 'Source' },
|
|
9680
10421
|
],
|
|
9681
10422
|
buttons: [
|
|
9682
10423
|
{ name: 'create', display: 'Create', dialog: true, action: { url: 'commissionentries?action=create', method: 'post' } },
|
|
9683
|
-
|
|
9684
|
-
|
|
10424
|
+
this.viewCommissionEntryButton, // Added (Phase 6): the only way to the breakdown — a figure with no reachable explanation is what this phase exists to remove
|
|
10425
|
+
this.approveCommissionEntryButton,
|
|
10426
|
+
this.returnCommissionEntryButton,
|
|
10427
|
+
{ name: 'edit', dialog: true, action: { url: 'commissionentries?action=edit', method: 'post' }, visible: (row) => !row?.isPaid }, // Changed (Phase 5): mirrors the SalaryAdvance guard — a settled entry is not editable
|
|
10428
|
+
{ name: 'delete', dialog: true, action: { url: 'commissionentries?action=delete', method: 'post' }, visible: (row) => !row?.isPaid }, // Changed (Phase 5): deleting a paid entry would orphan the payslip line that settled it
|
|
9685
10429
|
],
|
|
9686
10430
|
loadAction: { url: 'commissionentries/all/x' },
|
|
9687
10431
|
formConfig: this.commissionEntryFormConfig,
|
|
@@ -9699,14 +10443,21 @@ class PayrollService {
|
|
|
9699
10443
|
{ name: 'periodYear', type: 'text', alias: 'Year' },
|
|
9700
10444
|
{ name: 'periodMonth', type: 'text', alias: 'Month' },
|
|
9701
10445
|
{ name: 'metricValue', type: 'text', alias: 'Metric Value' },
|
|
9702
|
-
{ name: 'commissionAmount', type: 'money', alias: 'Amount'
|
|
10446
|
+
{ name: 'commissionAmount', type: 'money', alias: 'Amount',
|
|
10447
|
+
icons: [{ name: 'error_outline', color: 'orange', tip: 'This entry computed to nothing — see the entry for why', condition: (row) => row?.commissionAmount === 0 }]
|
|
10448
|
+
},
|
|
10449
|
+
{ name: 'statusName', type: 'text', alias: 'Status' }, // Changed (Phase 5): same gate as the main list — Draft never reaches a payslip
|
|
10450
|
+
{ name: 'isPaid', type: 'checkbox', alias: 'Paid' },
|
|
9703
10451
|
{ name: 'description', type: 'text' },
|
|
9704
10452
|
{ name: 'sourceType', type: 'text', alias: 'Source' },
|
|
9705
10453
|
],
|
|
9706
10454
|
buttons: [
|
|
9707
10455
|
{ name: 'create', display: 'Create', dialog: true, action: { url: 'commissionentries?action=create', method: 'post' } },
|
|
9708
|
-
|
|
9709
|
-
|
|
10456
|
+
this.viewCommissionEntryButton, // Added (Phase 6): same breakdown, reached from the employee's own history
|
|
10457
|
+
this.approveCommissionEntryButton,
|
|
10458
|
+
this.returnCommissionEntryButton,
|
|
10459
|
+
{ name: 'edit', dialog: true, action: { url: 'commissionentries?action=edit', method: 'post' }, visible: (row) => !row?.isPaid }, // Changed (Phase 5): mirrors the main list's settled guard
|
|
10460
|
+
{ name: 'delete', dialog: true, action: { url: 'commissionentries?action=delete', method: 'post' }, visible: (row) => !row?.isPaid },
|
|
9710
10461
|
],
|
|
9711
10462
|
loadAction: { url: 'commissionentries/x/x' }, loadCriteria: 'employee', loadIDField: 'employeeID',
|
|
9712
10463
|
formConfig: this.commissionEntryFormConfig,
|
|
@@ -9832,6 +10583,10 @@ class PayrollService {
|
|
|
9832
10583
|
this.submitPayrollButton = { name: 'submit', display: 'Submit', inDialog: true, action: { url: 'payrollruns?action=submit', method: 'post' }, visible: (row) => row?.status === 1 }; // Changed: null-check row to prevent TypeError when dialog renders before row data loads
|
|
9833
10584
|
this.postPayrollButton = { name: 'post', display: 'Post', inDialog: true, action: { url: 'payrollruns?action=post', method: 'post' }, visible: (row) => row?.status === 2 }; // Changed: null-check row
|
|
9834
10585
|
this.markPaidButton = { name: 'paid', display: 'Mark Paid', inDialog: true, action: { url: 'payrollruns?action=paid', method: 'post' }, visible: (row) => row?.status === 3 }; // Changed: null-check row
|
|
10586
|
+
// Added (Phase 5): recalculate is how a late commission approval or a corrected salary structure reaches an
|
|
10587
|
+
// open run. DRAFT ONLY — the server refuses it at Submitted, Posted and Paid, and a button that exists and
|
|
10588
|
+
// always fails teaches people the product is broken. It discards and rebuilds the run's payslips, hence the confirm.
|
|
10589
|
+
this.recalculatePayrollButton = { name: 'recalculate', display: 'Recalculate', inDialog: true, action: { url: 'payrollruns?action=recalculate', method: 'post', successMessage: 'Payslips rebuilt' }, confirm: { message: 'Rebuild this run\'s payslips from current salary structures and approved commission? The existing payslips are discarded.' }, visible: (row) => row?.status === 1 };
|
|
9835
10590
|
this.payrollRunFormConfig = {
|
|
9836
10591
|
security: { allow: [this.dataService.capPayrollRuns] }, // Added: gate payroll run form by payroll runs cap
|
|
9837
10592
|
title: 'Payroll Run',
|
|
@@ -9867,7 +10622,7 @@ class PayrollService {
|
|
|
9867
10622
|
detailsConfig: {
|
|
9868
10623
|
formConfig: this.payrollRunFormConfig,
|
|
9869
10624
|
heroField: 'payrollRunID',
|
|
9870
|
-
buttons: [this.submitPayrollButton, this.postPayrollButton, this.markPaidButton],
|
|
10625
|
+
buttons: [this.recalculatePayrollButton, this.submitPayrollButton, this.postPayrollButton, this.markPaidButton],
|
|
9871
10626
|
tableConfigs: [this.payslipsChildTableConfig],
|
|
9872
10627
|
}
|
|
9873
10628
|
},
|
|
@@ -10235,6 +10990,13 @@ class TabService {
|
|
|
10235
10990
|
this.dataService.CallApi(transformedAction, '', quiet ? { silent: true } : undefined).subscribe({
|
|
10236
10991
|
// Changed: an unsuccessful response used to leave the badge silently blank with nothing recording that it
|
|
10237
10992
|
// had failed, so it was never asked for again. Both outcomes now report through onSettled.
|
|
10993
|
+
//
|
|
10994
|
+
// DELIBERATELY SILENT to the USER (WS-6 Phase 4 triage), and this one is already solved better than a
|
|
10995
|
+
// dialog could solve it. A tab-count badge is decoration on a tab strip that may hold a dozen tabs, all
|
|
10996
|
+
// counting at once against the same backend — converting would turn one outage into a dozen suppressed
|
|
10997
|
+
// duplicates of a message about a number. The failure is not swallowed: onSettled(false) hands it to the
|
|
10998
|
+
// host, which re-requests the badge. Reporting to the code that can FIX it beats reporting to a person
|
|
10999
|
+
// who cannot.
|
|
10238
11000
|
next: (apiResponse) => {
|
|
10239
11001
|
if (apiResponse.success) {
|
|
10240
11002
|
tabCounts[tabIndex] = apiResponse.data;
|
|
@@ -10482,6 +11244,12 @@ class NotificationsService {
|
|
|
10482
11244
|
// Changed: Uncommented HTTP poll for initial count on login
|
|
10483
11245
|
loadNotifications() {
|
|
10484
11246
|
this.dataService.CallApi({ url: 'notifications/count/x' }).subscribe((apiResponse) => {
|
|
11247
|
+
// DELIBERATELY SILENT (WS-6 Phase 4 triage). This is THE poll of the set, and the one where converting
|
|
11248
|
+
// would be most obviously wrong: it runs on login, on signup, on every visit to the notifications page,
|
|
11249
|
+
// on every push message forwarded from the service worker, and — the decisive one — from
|
|
11250
|
+
// signalRService.onReconnected. A flapping SignalR connection reconnects repeatedly, so a dialog here
|
|
11251
|
+
// would fire once per reconnect for as long as the network is unhappy. That is the dialog storm this
|
|
11252
|
+
// whole channel exists to prevent, arriving from the inside. A stale unread badge costs the user nothing.
|
|
10485
11253
|
if (apiResponse.success) {
|
|
10486
11254
|
this.notificationCount.next(apiResponse.data);
|
|
10487
11255
|
}
|
|
@@ -10502,9 +11270,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
|
|
|
10502
11270
|
|
|
10503
11271
|
// Frontend service for Agent chat — state management, API calls, and SignalR subscriptions (renamed from AssistantService)
|
|
10504
11272
|
class AgentService {
|
|
10505
|
-
constructor(dataService, signalRService
|
|
11273
|
+
constructor(dataService, signalRService, apiErrorService // Changed: injected for loadMessages and deleteConversation only
|
|
11274
|
+
) {
|
|
10506
11275
|
this.dataService = dataService;
|
|
10507
11276
|
this.signalRService = signalRService;
|
|
11277
|
+
this.apiErrorService = apiErrorService;
|
|
10508
11278
|
this.messages = new BehaviorSubject([]);
|
|
10509
11279
|
this.conversations = new BehaviorSubject([]);
|
|
10510
11280
|
this.typing = new BehaviorSubject(false);
|
|
@@ -10550,6 +11320,9 @@ class AgentService {
|
|
|
10550
11320
|
// Load agent config (greeting + suggested questions) from backend
|
|
10551
11321
|
loadConfig() {
|
|
10552
11322
|
this.dataService.CallApi({ url: `agent/config/${this.appName}` }).subscribe(res => {
|
|
11323
|
+
// DELIBERATELY SILENT (WS-6 Phase 4 triage). Greeting text and suggested-question chips are decoration
|
|
11324
|
+
// on a chat window that works perfectly without them — the user can still type. Nothing they can see is
|
|
11325
|
+
// wrong, so there is nothing truthful to tell them.
|
|
10553
11326
|
if (res.success && res.data) {
|
|
10554
11327
|
this.greeting.next(res.data.greeting || '');
|
|
10555
11328
|
this.suggestedQuestions.next(res.data.suggestedQuestions || []);
|
|
@@ -10559,6 +11332,13 @@ class AgentService {
|
|
|
10559
11332
|
// Load user's conversation list
|
|
10560
11333
|
loadConversations() {
|
|
10561
11334
|
this.dataService.CallApi({ url: 'agent/conversations' }).subscribe(res => {
|
|
11335
|
+
// DELIBERATELY SILENT (WS-6 Phase 4 triage), and this was the closest call in the sweep — an empty
|
|
11336
|
+
// thread list IS a false statement about the user's own history. It stays silent because of WHEN it
|
|
11337
|
+
// fires: agent.component.ts:212 calls it from the widget's ngOnInit, so it runs on every app-shell load
|
|
11338
|
+
// whether or not anyone opens the chat. Converting would put a dialog in front of every user of the app
|
|
11339
|
+
// for a list none of them had asked to see. loadMessages() below — which only ever runs because someone
|
|
11340
|
+
// clicked a specific thread — is the one that reports, and it covers the same outage the moment the
|
|
11341
|
+
// list is actually used.
|
|
10562
11342
|
if (res.success) {
|
|
10563
11343
|
this.conversations.next(res.data || []);
|
|
10564
11344
|
}
|
|
@@ -10568,6 +11348,12 @@ class AgentService {
|
|
|
10568
11348
|
loadMessages(conversationId) {
|
|
10569
11349
|
this.currentConversationId.next(conversationId);
|
|
10570
11350
|
this.dataService.CallApi({ url: `agent/conversations/${conversationId}/messages` }).subscribe(res => {
|
|
11351
|
+
// CONVERTED (WS-6 Phase 4 triage). Unlike everything else in this service, this only runs because the
|
|
11352
|
+
// user clicked a named conversation in the switcher. Failing left the thread pane holding the PREVIOUS
|
|
11353
|
+
// conversation's messages under the new conversation's id — so the next thing they typed would have
|
|
11354
|
+
// been sent into a thread they were not looking at.
|
|
11355
|
+
if (!res.success)
|
|
11356
|
+
this.apiErrorService.presentAppFailure(res, 'load', `agent/conversations/${conversationId}/messages`);
|
|
10571
11357
|
if (res.success) {
|
|
10572
11358
|
this.messages.next((res.data || []).map((m) => ({
|
|
10573
11359
|
conversationId: m.conversationID,
|
|
@@ -10738,6 +11524,10 @@ class AgentService {
|
|
|
10738
11524
|
this.messages.next([]);
|
|
10739
11525
|
this.currentConversationId.next(null);
|
|
10740
11526
|
this.dataService.CallApi({ url: 'agent/conversations', method: 'post' }, { appName: this.appName }).subscribe(res => {
|
|
11527
|
+
// DELIBERATELY SILENT (WS-6 Phase 4 triage). The UI has already done the thing the user asked for —
|
|
11528
|
+
// messages cleared, id nulled, a blank thread on screen — before this call is even made. All the server
|
|
11529
|
+
// round trip adds is a pre-allocated id, and sendMessage() creates one anyway when there is none. So a
|
|
11530
|
+
// failure here changes nothing the user can see or do.
|
|
10741
11531
|
if (res.success && res.data) {
|
|
10742
11532
|
this.currentConversationId.next(res.data.conversationID);
|
|
10743
11533
|
}
|
|
@@ -10746,6 +11536,12 @@ class AgentService {
|
|
|
10746
11536
|
// Delete a conversation
|
|
10747
11537
|
deleteConversation(id) {
|
|
10748
11538
|
this.dataService.CallApi({ url: `agent/conversations/${id}/delete`, method: 'post' }).subscribe(res => {
|
|
11539
|
+
// CONVERTED (WS-6 Phase 4 triage). A delete the user pressed. Failing left the conversation sitting in
|
|
11540
|
+
// the list exactly as it was, which reads as "the button did nothing" — and the answer to a button that
|
|
11541
|
+
// did nothing is to press it again. Note the comment on the URL below: this endpoint has already been
|
|
11542
|
+
// wrong once (a 405 on every call), and it was silence that let that survive.
|
|
11543
|
+
if (!res.success)
|
|
11544
|
+
this.apiErrorService.presentAppFailure(res, 'action', `agent/conversations/${id}/delete`);
|
|
10749
11545
|
if (res.success) {
|
|
10750
11546
|
this.loadConversations();
|
|
10751
11547
|
if (this.currentConversationId.value === id) {
|
|
@@ -10783,13 +11579,13 @@ class AgentService {
|
|
|
10783
11579
|
}
|
|
10784
11580
|
});
|
|
10785
11581
|
}
|
|
10786
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: AgentService, deps: [{ token: DataServiceLib }, { token: SignalRService }], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
11582
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: AgentService, deps: [{ token: DataServiceLib }, { token: SignalRService }, { token: ApiErrorService }], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
10787
11583
|
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: AgentService, providedIn: 'root' }); }
|
|
10788
11584
|
}
|
|
10789
11585
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: AgentService, decorators: [{
|
|
10790
11586
|
type: Injectable,
|
|
10791
11587
|
args: [{ providedIn: 'root' }]
|
|
10792
|
-
}], ctorParameters: () => [{ type: DataServiceLib }, { type: SignalRService }] });
|
|
11588
|
+
}], ctorParameters: () => [{ type: DataServiceLib }, { type: SignalRService }, { type: ApiErrorService }] });
|
|
10793
11589
|
|
|
10794
11590
|
class AnalyticsService {
|
|
10795
11591
|
constructor(router) {
|
|
@@ -10837,248 +11633,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
|
|
|
10837
11633
|
args: [{ providedIn: 'root' }]
|
|
10838
11634
|
}], ctorParameters: () => [{ type: i1$1.Router }] });
|
|
10839
11635
|
|
|
10840
|
-
class ApiErrorService {
|
|
10841
|
-
constructor(messageService) {
|
|
10842
|
-
this.messageService = messageService;
|
|
10843
|
-
// One dialog per KIND per window. Keyed by kind rather than by URL on purpose: when the backend goes down,
|
|
10844
|
-
// twenty different URLs fail for one single reason, and the user needs to be told that reason once.
|
|
10845
|
-
this.lastShownAt = new Map();
|
|
10846
|
-
/** How long a given kind stays suppressed after being shown. The user described "the same 10 seconds or so". */
|
|
10847
|
-
this.suppressWindowMs = 10000;
|
|
10848
|
-
// Added: submits dedupe over a much shorter window than background reads. Long enough to swallow a
|
|
10849
|
-
// double-click or a duplicated emission, short enough that a user who reads the dialog, dismisses it and
|
|
10850
|
-
// deliberately tries again is told again rather than met with silence.
|
|
10851
|
-
this.submitSuppressWindowMs = 1500;
|
|
10852
|
-
//================ Application-level failures (HTTP 200 + success:false) ================
|
|
10853
|
-
// Added: fragments that mark a message as machine-authored rather than human-authored. Matched
|
|
10854
|
-
// case-insensitively against the whole message. The list is deliberately about IMPLEMENTATION LEAKS, not
|
|
10855
|
-
// about severity — a leaked identifier is useless to the user however serious the underlying fault is.
|
|
10856
|
-
this.opaqueMarkers = [
|
|
10857
|
-
// NOTE: matched as a SUBSTRING, never by equality. TinWeb's catch-all now appends a correlation code —
|
|
10858
|
-
// "Error processing request (reference K7M2QP4T)" — so an equality test would classify every catch-all
|
|
10859
|
-
// failure as a business message and show the raw text, which is worse than the behaviour this replaces.
|
|
10860
|
-
'error processing request', // TinWeb BaseController/BaseController2/BaseService catch-all — by far the most common
|
|
10861
|
-
'invalid column name', // SQL leaking through after a schema/migration drift
|
|
10862
|
-
'sqlexception',
|
|
10863
|
-
'object reference not set',
|
|
10864
|
-
'an error occurred while', // EF Core's standard preamble ("...while updating the entries")
|
|
10865
|
-
'inner exception',
|
|
10866
|
-
'stack trace',
|
|
10867
|
-
'the underlying provider failed',
|
|
10868
|
-
'index was outside the bounds',
|
|
10869
|
-
'timeout expired',
|
|
10870
|
-
'exception', // catches DbUpdateException, InvalidOperationException, ... by suffix
|
|
10871
|
-
// Added: raw SqlException text. TinCore's BaseRepository.SetErrorResp returns ex.InnerException.Message
|
|
10872
|
-
// verbatim on HTTP 200, so a bad foreign key currently shows the user the database, table, column AND
|
|
10873
|
-
// constraint names — jargon and a schema leak at once. These never carry a correlation reference, because
|
|
10874
|
-
// they never reach the catch-all that mints one.
|
|
10875
|
-
//
|
|
10876
|
-
// Each fragment below is SQL Server's own wording. None of them can occur in a hand-authored message like
|
|
10877
|
-
// "Registration number already in use" or "Customer not found", which is why they are matched as phrases
|
|
10878
|
-
// rather than on single words such as "duplicate" or "constraint" that a human might legitimately write.
|
|
10879
|
-
'statement conflicted with the', // "The INSERT statement conflicted with the FOREIGN KEY constraint ..."
|
|
10880
|
-
'foreign key constraint',
|
|
10881
|
-
'unique key constraint',
|
|
10882
|
-
'primary key constraint',
|
|
10883
|
-
'check constraint',
|
|
10884
|
-
'cannot insert the value null',
|
|
10885
|
-
'string or binary data would be truncated',
|
|
10886
|
-
'violation of ', // "Violation of PRIMARY KEY constraint 'PK_...'"
|
|
10887
|
-
'duplicate key', // "Cannot insert duplicate key row in object ..."
|
|
10888
|
-
];
|
|
10889
|
-
}
|
|
10890
|
-
// Classification is by status code first, because that is the only signal that is reliable across browsers.
|
|
10891
|
-
// `statusText === 'Unknown Error'` is kept as a secondary network signal since some browsers report a failed
|
|
10892
|
-
// connection that way while still surfacing status 0.
|
|
10893
|
-
classify(error) {
|
|
10894
|
-
const status = error?.status ?? 0;
|
|
10895
|
-
// status 0 means the request never reached a server: offline, DNS failure, connection refused, CORS block.
|
|
10896
|
-
// This is the single most common case for a field user and the one that was rendering blank.
|
|
10897
|
-
if (status === 0 || error?.statusText === 'Unknown Error') {
|
|
10898
|
-
return {
|
|
10899
|
-
kind: 'network',
|
|
10900
|
-
title: 'No connection',
|
|
10901
|
-
message: `We couldn't reach the server. Please check your internet connection and try again.\n\n` +
|
|
10902
|
-
`If your connection is working, the service may be temporarily unavailable — your work is not lost, and you can try again in a moment.`,
|
|
10903
|
-
};
|
|
10904
|
-
}
|
|
10905
|
-
if (status === 408 || status === 504) {
|
|
10906
|
-
return {
|
|
10907
|
-
kind: 'timeout',
|
|
10908
|
-
title: 'Taking too long',
|
|
10909
|
-
message: `The server is taking longer than expected to respond.\n\nPlease wait a moment and try again.`,
|
|
10910
|
-
};
|
|
10911
|
-
}
|
|
10912
|
-
if (status === 503) {
|
|
10913
|
-
return {
|
|
10914
|
-
kind: 'unavailable',
|
|
10915
|
-
title: 'Service unavailable',
|
|
10916
|
-
message: `The service is temporarily unavailable, usually because it is being updated.\n\nPlease try again in a few minutes.`,
|
|
10917
|
-
};
|
|
10918
|
-
}
|
|
10919
|
-
// A 400 is almost never the user's doing — the app sent a payload the server rejected. Saying "check your
|
|
10920
|
-
// input" would send them hunting for a mistake they did not make, so this points at support instead.
|
|
10921
|
-
if (status === 400 || status === 422) {
|
|
10922
|
-
return {
|
|
10923
|
-
kind: 'badrequest',
|
|
10924
|
-
title: 'Something went wrong',
|
|
10925
|
-
message: `We couldn't complete that action because of a technical problem in the app — not anything you did wrong.\n\n` +
|
|
10926
|
-
`Please try again. If it keeps happening, contact your administrator so it can be looked into.`,
|
|
10927
|
-
};
|
|
10928
|
-
}
|
|
10929
|
-
if (status === 403) {
|
|
10930
|
-
return {
|
|
10931
|
-
kind: 'forbidden',
|
|
10932
|
-
title: 'Not allowed',
|
|
10933
|
-
message: `You don't have permission to do that.\n\nIf you think you should, please contact your administrator.`,
|
|
10934
|
-
};
|
|
10935
|
-
}
|
|
10936
|
-
if (status === 404) {
|
|
10937
|
-
return {
|
|
10938
|
-
kind: 'notfound',
|
|
10939
|
-
title: 'Not found',
|
|
10940
|
-
message: `We couldn't find what you were looking for. It may have been moved or deleted by someone else.\n\n` +
|
|
10941
|
-
`Try refreshing the page. If it keeps happening, contact your administrator.`,
|
|
10942
|
-
};
|
|
10943
|
-
}
|
|
10944
|
-
if (status >= 500) {
|
|
10945
|
-
return {
|
|
10946
|
-
kind: 'server',
|
|
10947
|
-
title: 'Something went wrong',
|
|
10948
|
-
message: `The server ran into a problem while handling your request.\n\n` +
|
|
10949
|
-
`Please try again shortly. If it keeps happening, contact your administrator.`,
|
|
10950
|
-
};
|
|
10951
|
-
}
|
|
10952
|
-
return {
|
|
10953
|
-
kind: 'unknown',
|
|
10954
|
-
title: 'Something went wrong',
|
|
10955
|
-
message: `We couldn't complete that action.\n\nPlease try again. If it keeps happening, contact your administrator.`,
|
|
10956
|
-
};
|
|
10957
|
-
}
|
|
10958
|
-
/** True when this kind was already shown inside the suppression window — i.e. the user has already been told. */
|
|
10959
|
-
// Changed: window is now a parameter (defaulting to the existing suppressWindowMs, so present() is unaffected)
|
|
10960
|
-
// because a submit failure needs a much shorter one than a burst of background reads — see presentAppFailure().
|
|
10961
|
-
isSuppressed(kind, windowMs = this.suppressWindowMs) {
|
|
10962
|
-
const last = this.lastShownAt.get(kind);
|
|
10963
|
-
return last != null && (Date.now() - last) < windowMs;
|
|
10964
|
-
}
|
|
10965
|
-
// Presents at most ONE dialog per kind per window. The technical detail still goes to the console, where a
|
|
10966
|
-
// developer wants it, so nothing is lost by keeping it out of the dialog.
|
|
10967
|
-
present(error) {
|
|
10968
|
-
const info = this.classify(error);
|
|
10969
|
-
// Added: a 5xx from TinWeb's exception middleware now carries "(reference X)" in its body Message —
|
|
10970
|
-
// pull it out so the dialog can quote it as a footnote, exactly as presentAppFailure() does for the
|
|
10971
|
-
// 200-with-success:false catch-alls. Only the 'server' kind can carry one; a transport failure has no
|
|
10972
|
-
// body at all (error.error is a ProgressEvent), which extractReference() tolerates as null.
|
|
10973
|
-
if (info.kind === 'server') {
|
|
10974
|
-
const body = error?.error;
|
|
10975
|
-
info.reference = this.extractReference(body?.Message ?? body?.message) ?? undefined;
|
|
10976
|
-
}
|
|
10977
|
-
console.error(`[tin-spa] API error (${info.kind})`, { status: error?.status, url: error?.url, reference: info.reference, error }); // Changed: log the reference so console and server log line tie together
|
|
10978
|
-
if (this.isSuppressed(info.kind))
|
|
10979
|
-
return info; // already told them — a second dialog adds nothing
|
|
10980
|
-
this.lastShownAt.set(info.kind, Date.now());
|
|
10981
|
-
this.messageService.errorWithSubject(info.title, info.message, info.reference); // Changed: pass the reference through so the dialog renders the quiet footnote
|
|
10982
|
-
return info;
|
|
10983
|
-
}
|
|
10984
|
-
// Added: splits the server's message into 'opaque' (tells the user nothing, must not be shown raw) and
|
|
10985
|
-
// 'business' (a deliberate sentence the backend author wrote for a human, genuinely worth reading).
|
|
10986
|
-
//
|
|
10987
|
-
// Written as a deny-list rather than an allow-list on purpose: business messages are open-ended and
|
|
10988
|
-
// app-specific, so there is nothing to enumerate, whereas the ways a .NET exception leaks are few and stable.
|
|
10989
|
-
// The consequence of the default is the safe one — an unrecognised message is treated as business and shown,
|
|
10990
|
-
// so a real message is never swallowed; only recognisably technical text is replaced.
|
|
10991
|
-
classifyMessage(message) {
|
|
10992
|
-
const text = (message ?? '').trim();
|
|
10993
|
-
if (!text)
|
|
10994
|
-
return 'opaque'; // nothing to show — a blank toast is the very failure mode this service exists to kill
|
|
10995
|
-
const lower = text.toLowerCase();
|
|
10996
|
-
if (this.opaqueMarkers.some(marker => lower.includes(marker)))
|
|
10997
|
-
return 'opaque';
|
|
10998
|
-
if (/\bsystem\.[a-z]/i.test(text))
|
|
10999
|
-
return 'opaque'; // a .NET namespace ("System.NullReferenceException")
|
|
11000
|
-
if (/\bat\s+[\w.]+\s*\(/.test(text))
|
|
11001
|
-
return 'opaque'; // a stack frame ("at Namespace.Method(")
|
|
11002
|
-
if (text.length > 300)
|
|
11003
|
-
return 'opaque'; // no human writes a 300-char error; this is a dump
|
|
11004
|
-
return 'business';
|
|
11005
|
-
}
|
|
11006
|
-
// Added: pulls TinWeb's correlation code out of a catch-all message. The backend writes the same code into
|
|
11007
|
-
// its log line as "[ref XXXXXXXX]", so quoting it in the dialog is what lets support jump straight to the log
|
|
11008
|
-
// row instead of hunting by timestamp.
|
|
11009
|
-
//
|
|
11010
|
-
// The alphabet is A-Z/2-9 with 0/O/1/I/L removed (they are misread down a phone line), and the code is 8
|
|
11011
|
-
// characters — but the pattern accepts 6-12 so a future length change does not silently stop matching. The
|
|
11012
|
-
// reference is only ever EXTRACTED here; the rest of the raw message is still discarded, never shown.
|
|
11013
|
-
extractReference(message) {
|
|
11014
|
-
const match = (message ?? '').match(/\breference\s+([A-Z2-9]{6,12})\b/i);
|
|
11015
|
-
return match ? match[1].toUpperCase() : null;
|
|
11016
|
-
}
|
|
11017
|
-
// Added: the sibling to present() for failures that arrive as HTTP 200 with success:false.
|
|
11018
|
-
//
|
|
11019
|
-
// Opaque -> the friendly dialog, through the SAME lastShownAt suppression map present() uses, so a burst
|
|
11020
|
-
// (a dashboard firing twenty calls at a broken backend) still yields exactly one dialog.
|
|
11021
|
-
// Business -> the existing toast, unchanged in weight. Short, meaningful, non-blocking.
|
|
11022
|
-
//
|
|
11023
|
-
// Why 'submit' gets its own dedupe key rather than sharing with loads: suppression exists to collapse a BURST
|
|
11024
|
-
// of background calls that all failed for one reason. A create/edit submit is not part of a burst — it is one
|
|
11025
|
-
// deliberate action, and it is the exact case where a missed message leads the user to press the button again
|
|
11026
|
-
// and duplicate the record. Sharing a key would let unrelated background load failures suppress the dialog
|
|
11027
|
-
// for the one interaction that most needs it. Keeping a (short) window on the submit key still collapses a
|
|
11028
|
-
// double-click, while letting a genuine retry a few seconds later speak up again.
|
|
11029
|
-
presentAppFailure(response, context = 'action', url) {
|
|
11030
|
-
const message = response?.message;
|
|
11031
|
-
const failureClass = this.classifyMessage(message);
|
|
11032
|
-
// Always log the raw response and URL, exactly as present() does — the developer loses nothing by the
|
|
11033
|
-
// dialog being friendly, because the technical detail is right here.
|
|
11034
|
-
const reference = this.extractReference(message); // Added: null for SqlException leaks and older backends, which never mint one
|
|
11035
|
-
console.error(`[tin-spa] API failure (${failureClass}/${context})`, { url, message, reference, response }); // Changed: log the reference too, so the browser console and the server log line can be tied together
|
|
11036
|
-
if (failureClass === 'business') {
|
|
11037
|
-
// Changed: dropped the "Error: " prefix that every call site used to prepend. Verified nothing depends on
|
|
11038
|
-
// it — no test in ng-space or any of the four consumer apps asserts on the literal "Error: ". The prefix
|
|
11039
|
-
// only ever restated what the red-tinted message already conveyed, and it pushed the useful words right.
|
|
11040
|
-
this.messageService.toast(message);
|
|
11041
|
-
return failureClass;
|
|
11042
|
-
}
|
|
11043
|
-
const kind = context === 'submit' ? 'appfailure-submit' : 'appfailure';
|
|
11044
|
-
if (this.isSuppressed(kind, context === 'submit' ? this.submitSuppressWindowMs : this.suppressWindowMs))
|
|
11045
|
-
return failureClass;
|
|
11046
|
-
this.lastShownAt.set(kind, Date.now());
|
|
11047
|
-
// Copy matches classify() above: name the problem, absolve the user, say what to do next. The submit
|
|
11048
|
-
// variant adds the two things that specifically stop a duplicate — that the data is still there, and that
|
|
11049
|
-
// pressing the button again is not the fix.
|
|
11050
|
-
// Changed: the reference is passed as its own argument rather than appended to the body, so the dialog can
|
|
11051
|
-
// render it as a quiet footnote. It is omitted entirely when there isn't one — a SqlException leak and an
|
|
11052
|
-
// older backend both arrive without a reference, and neither should show a dangling "quote reference" line.
|
|
11053
|
-
if (context === 'submit') {
|
|
11054
|
-
this.messageService.errorWithSubject('Not saved', `We couldn't save that because of a technical problem — not anything you did wrong.\n\n` +
|
|
11055
|
-
`Nothing was saved, and your details are still on the form, so you can try again without re-entering them.\n\n` +
|
|
11056
|
-
`If it keeps happening, please contact your administrator rather than submitting repeatedly, as that can create duplicates.`, reference ?? undefined);
|
|
11057
|
-
}
|
|
11058
|
-
else {
|
|
11059
|
-
this.messageService.errorWithSubject('Something went wrong', `We couldn't complete that action because of a technical problem — not anything you did wrong.\n\n` +
|
|
11060
|
-
`Please try again. If it keeps happening, contact your administrator.`, reference ?? undefined);
|
|
11061
|
-
}
|
|
11062
|
-
return failureClass;
|
|
11063
|
-
}
|
|
11064
|
-
/** Clears suppression so the next failure speaks up again — call after a successful request or reconnect. */
|
|
11065
|
-
reset() {
|
|
11066
|
-
this.lastShownAt.clear();
|
|
11067
|
-
}
|
|
11068
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: ApiErrorService, deps: [{ token: MessageService }], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
11069
|
-
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: ApiErrorService, providedIn: 'root' }); }
|
|
11070
|
-
}
|
|
11071
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: ApiErrorService, decorators: [{
|
|
11072
|
-
type: Injectable,
|
|
11073
|
-
args: [{ providedIn: 'root' }]
|
|
11074
|
-
}], ctorParameters: () => [{ type: MessageService }] });
|
|
11075
|
-
|
|
11076
11636
|
// Setup readiness ("Getting Started") service — NotificationsService mirror without SignalR.
|
|
11077
11637
|
// Dormant unless appConfig.setupConfig.enabled; API failures leave count at 0 so the badge stays hidden.
|
|
11078
11638
|
// v2 adds the module catalog (drives step + nav-menu gating) and predefined role templates.
|
|
11079
11639
|
class SetupService {
|
|
11080
|
-
constructor(dataService) {
|
|
11640
|
+
constructor(dataService, apiErrorService) {
|
|
11081
11641
|
this.dataService = dataService;
|
|
11642
|
+
this.apiErrorService = apiErrorService;
|
|
11082
11643
|
this.pendingCount = new BehaviorSubject(0);
|
|
11083
11644
|
this.pendingCount$ = this.pendingCount.asObservable();
|
|
11084
11645
|
this.status = new BehaviorSubject(null);
|
|
@@ -11086,7 +11647,7 @@ class SetupService {
|
|
|
11086
11647
|
// Added (v2): module catalog — empty means the app has no module concept (nothing is ever hidden)
|
|
11087
11648
|
this.modules = new BehaviorSubject([]);
|
|
11088
11649
|
this.modules$ = this.modules.asObservable();
|
|
11089
|
-
}
|
|
11650
|
+
} // Changed: injected for loadStatus only
|
|
11090
11651
|
get enabled() {
|
|
11091
11652
|
return !!this.dataService.appConfig?.setupConfig?.enabled;
|
|
11092
11653
|
}
|
|
@@ -11096,6 +11657,11 @@ class SetupService {
|
|
|
11096
11657
|
return;
|
|
11097
11658
|
this.dataService.CallApi({ url: 'setup/count', skipCache: true }).subscribe({
|
|
11098
11659
|
next: (apiResponse) => {
|
|
11660
|
+
// DELIBERATELY SILENT (WS-6 Phase 4 triage). A badge count, fired from login and from nav-menu init.
|
|
11661
|
+
// Failing leaves the badge hidden, which is the same thing it shows when there is genuinely nothing
|
|
11662
|
+
// pending — and "you have no setup left" is a harmless thing to under-report for a moment, because
|
|
11663
|
+
// the Getting Started PAGE itself reports properly (see loadStatus below). A dialog on nav init would
|
|
11664
|
+
// fire on every shell load for every user of an app whose SetupController is having a bad day.
|
|
11099
11665
|
if (apiResponse.success)
|
|
11100
11666
|
this.pendingCount.next(apiResponse.data);
|
|
11101
11667
|
},
|
|
@@ -11108,6 +11674,13 @@ class SetupService {
|
|
|
11108
11674
|
return;
|
|
11109
11675
|
this.dataService.CallApi({ url: 'setup/status', skipCache: true }).subscribe({
|
|
11110
11676
|
next: (apiResponse) => {
|
|
11677
|
+
// CONVERTED, unlike its two neighbours in this file, and the difference is who asked. This one has
|
|
11678
|
+
// exactly two callers — setup-guide.component.ts ngOnInit and refresh() from that same page — so it
|
|
11679
|
+
// only ever runs because someone opened Getting Started or just completed a step there. Failing left
|
|
11680
|
+
// the page rendering an empty checklist, which reads as "there is nothing to set up" to the one user
|
|
11681
|
+
// who is by definition new and has no way to know better. (WS-6 Phase 4 triage)
|
|
11682
|
+
if (!apiResponse.success)
|
|
11683
|
+
this.apiErrorService.presentAppFailure(apiResponse, 'load', 'setup/status');
|
|
11111
11684
|
if (apiResponse.success) {
|
|
11112
11685
|
this.status.next(apiResponse.data);
|
|
11113
11686
|
this.pendingCount.next(apiResponse.data?.pending ?? 0);
|
|
@@ -11123,6 +11696,10 @@ class SetupService {
|
|
|
11123
11696
|
return;
|
|
11124
11697
|
this.dataService.CallApi({ url: 'setup/modules', skipCache: true }).subscribe({
|
|
11125
11698
|
next: (apiResponse) => {
|
|
11699
|
+
// DELIBERATELY SILENT (WS-6 Phase 4 triage). Nav-menu init, and isModuleEnabled() below FAILS OPEN by
|
|
11700
|
+
// design — an empty catalog hides nothing, so a failure here makes the app MORE visible, never less.
|
|
11701
|
+
// There is no user-facing loss to report. It also runs on every shell init, which is precisely where
|
|
11702
|
+
// a dialog becomes a wall.
|
|
11126
11703
|
if (apiResponse.success)
|
|
11127
11704
|
this.modules.next(apiResponse.data ?? []);
|
|
11128
11705
|
},
|
|
@@ -11151,10 +11728,33 @@ class SetupService {
|
|
|
11151
11728
|
createRoles(keys) {
|
|
11152
11729
|
return this.dataService.CallApi({ url: 'setup/roles', method: 'post', skipCache: true }, { keys });
|
|
11153
11730
|
}
|
|
11731
|
+
// Added (presets): the notification rules on offer for this tenant, plus the roles the picker chooses a
|
|
11732
|
+
// recipient from. skipCache like every other setup call — Exists is stamped per request and a cached
|
|
11733
|
+
// catalogue would re-offer rules the user just created.
|
|
11734
|
+
loadNotificationPresets() {
|
|
11735
|
+
return this.dataService.CallApi({ url: 'setup/notificationpresets', skipCache: true });
|
|
11736
|
+
}
|
|
11737
|
+
// Added (presets): create notification rules from the ticked presets. Create-only server-side — unticking a
|
|
11738
|
+
// rule that already exists deletes nothing, so this can never undo a deliberate user choice.
|
|
11739
|
+
applyNotificationPresets(items, includeEmail) {
|
|
11740
|
+
return this.dataService.CallApi({ url: 'setup/notifications', method: 'post', skipCache: true }, { items, includeEmail });
|
|
11741
|
+
}
|
|
11742
|
+
// Added (presets P4): the approval rules on offer, plus the roles that can ACTUALLY approve — the backend filters
|
|
11743
|
+
// that list to roles somebody is a member of, because since SEC-10 only a member of the level's role can release a
|
|
11744
|
+
// held record, so offering an empty role would build a config nobody could action.
|
|
11745
|
+
loadApprovalPresets() {
|
|
11746
|
+
return this.dataService.CallApi({ url: 'setup/approvalpresets', skipCache: true });
|
|
11747
|
+
}
|
|
11748
|
+
// Added (presets P4): create approval rules from the ticked presets. Create-only server-side, exactly like the
|
|
11749
|
+
// notification apply. No includeEmail — an approval request is always in-app (an approver who misses it leaves
|
|
11750
|
+
// records held), so the body deliberately carries items alone.
|
|
11751
|
+
applyApprovalPresets(items) {
|
|
11752
|
+
return this.dataService.CallApi({ url: 'setup/approvals', method: 'post', skipCache: true }, { items });
|
|
11753
|
+
}
|
|
11154
11754
|
refresh() {
|
|
11155
11755
|
this.loadStatus();
|
|
11156
11756
|
}
|
|
11157
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: SetupService, deps: [{ token: DataServiceLib }], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
11757
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: SetupService, deps: [{ token: DataServiceLib }, { token: ApiErrorService }], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
11158
11758
|
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: SetupService, providedIn: 'root' }); }
|
|
11159
11759
|
}
|
|
11160
11760
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: SetupService, decorators: [{
|
|
@@ -11162,7 +11762,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
|
|
|
11162
11762
|
args: [{
|
|
11163
11763
|
providedIn: 'root'
|
|
11164
11764
|
}]
|
|
11165
|
-
}], ctorParameters: () => [{ type: DataServiceLib }] });
|
|
11765
|
+
}], ctorParameters: () => [{ type: DataServiceLib }, { type: ApiErrorService }] });
|
|
11166
11766
|
|
|
11167
11767
|
// Added: the app-wide App Configuration record, loaded once and shared. Apps used to each solve this
|
|
11168
11768
|
// themselves — Shift kept a copy in localStorage that went stale the moment anyone saved on another
|
|
@@ -11186,6 +11786,11 @@ class ConfigService {
|
|
|
11186
11786
|
this.loaded = true;
|
|
11187
11787
|
this.dataService.CallApi({ url: 'configuration/get', skipCache: true }).subscribe({
|
|
11188
11788
|
next: (apiResponse) => {
|
|
11789
|
+
// DELIBERATELY SILENT (WS-6 Phase 4 triage). This runs on login/bootstrap, before the user has asked
|
|
11790
|
+
// for anything, and everything downstream of it FAILS OPEN: an empty config hides no nav item, gates
|
|
11791
|
+
// no module and suffixes no unit. So a failure here costs cosmetics, not capability — and greeting
|
|
11792
|
+
// someone with an error dialog the instant they sign in, for an app that then works, is a worse lie
|
|
11793
|
+
// than saying nothing. The error callback below already absorbs the transport case for the same reason.
|
|
11189
11794
|
if (apiResponse.success)
|
|
11190
11795
|
this.config.next(apiResponse.data || {});
|
|
11191
11796
|
},
|
|
@@ -11384,6 +11989,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
|
|
|
11384
11989
|
type: Input
|
|
11385
11990
|
}] } });
|
|
11386
11991
|
|
|
11992
|
+
// Added (presets): the library step keys the pickers attach to. They come from PresetCatalog and are identical in
|
|
11993
|
+
// all five apps, so a picker needs no per-app setupConfig entry (unlike roleTemplates, whose step key is
|
|
11994
|
+
// app-chosen). Kept as named constants rather than literals sprinkled through the class.
|
|
11995
|
+
const NOTIFICATION_PRESETS_STEP = 'notification-presets';
|
|
11996
|
+
const APPROVAL_PRESETS_STEP = 'approval-presets'; // Added (presets P4) — PresetCatalog.StepApprovals
|
|
11997
|
+
// Added (presets): the placeholder PresetCatalog authors into every Effect sentence, marking where the role
|
|
11998
|
+
// select is embedded. Splitting on it is the ONLY thing the UI does to that sentence.
|
|
11999
|
+
const PRESET_ROLE_TOKEN = '{role}';
|
|
11387
12000
|
// Getting Started page — renders the per-app setup readiness catalog (top-down groups, no wizard).
|
|
11388
12001
|
// Step actions come from appConfig.setupConfig.stepActions (create dialog Button, route link, Excel
|
|
11389
12002
|
// import wizard, or the predefined role-template picker). v2 adds the module picker: optional modules
|
|
@@ -11394,6 +12007,7 @@ class SetupGuideComponent {
|
|
|
11394
12007
|
this.setupService = inject(SetupService);
|
|
11395
12008
|
this.dialogService = inject(DialogService);
|
|
11396
12009
|
this.messageService = inject(MessageService); // Added: Demo Data card
|
|
12010
|
+
this.apiErrorService = inject(ApiErrorService); // Added: routes this page's four unhandled failures through the shared classifier
|
|
11397
12011
|
this.status = null;
|
|
11398
12012
|
this.groups = []; // Changed: each group carries a spa-checklist config instead of raw steps
|
|
11399
12013
|
this.modules = [];
|
|
@@ -11404,6 +12018,49 @@ class SetupGuideComponent {
|
|
|
11404
12018
|
this.roleTemplates = [];
|
|
11405
12019
|
this.selectedTemplates = {};
|
|
11406
12020
|
this.creatingRoles = false;
|
|
12021
|
+
// Added (presets): picker state. `groups` is materialized (same rule as moduleGroups above); selected/role are
|
|
12022
|
+
// plain maps keyed by preset key so a reload never clobbers a manual choice.
|
|
12023
|
+
// Changed (presets P4): one object per domain instead of a field per domain — see PresetPicker above.
|
|
12024
|
+
this.includeEmail = false; // notifications only; approvals are always in-app
|
|
12025
|
+
this.notificationPicker = {
|
|
12026
|
+
stepKey: NOTIFICATION_PRESETS_STEP,
|
|
12027
|
+
defaultGroup: 'Notifications',
|
|
12028
|
+
// A notification addressed to nobody is inert, so with no roles we ask for roles instead of offering ticks
|
|
12029
|
+
emptyText: 'Create your team roles first — a notification needs somebody to go to.',
|
|
12030
|
+
doneText: 'Edit or remove it in Notif Config.',
|
|
12031
|
+
roleAria: 'Who to tell — ',
|
|
12032
|
+
showEmail: true,
|
|
12033
|
+
buttonLabel: count => count === 0 ? 'Turn on notifications' : count === 1 ? 'Turn on 1 notification' : `Turn on ${count} notifications`,
|
|
12034
|
+
toastLabel: created => created > 0 ? `${created} notification${created === 1 ? '' : 's'} turned on` : 'No new notifications were turned on',
|
|
12035
|
+
loadUrl: 'setup/notificationpresets',
|
|
12036
|
+
applyUrl: 'setup/notifications',
|
|
12037
|
+
load: () => this.setupService.loadNotificationPresets(),
|
|
12038
|
+
apply: items => this.setupService.applyNotificationPresets(items, this.includeEmail),
|
|
12039
|
+
presets: [], roles: [], groups: [], selected: {}, role: {}, count: 0, loaded: false, applying: false,
|
|
12040
|
+
action: { type: 'custom', display: 'Turn on notifications', primary: true, disabled: () => this.notificationPicker.count === 0 || this.notificationPicker.applying, onClick: () => this.applyPresets(this.notificationPicker) }
|
|
12041
|
+
};
|
|
12042
|
+
this.approvalPicker = {
|
|
12043
|
+
stepKey: APPROVAL_PRESETS_STEP,
|
|
12044
|
+
defaultGroup: 'Sign-off',
|
|
12045
|
+
// 🔴 NOT "create your team roles first". The backend offers only roles somebody is actually a MEMBER of, because
|
|
12046
|
+
// since SEC-10 only a member of the approver role can release a held record — so this list is empty in TWO
|
|
12047
|
+
// different situations: no roles at all, and roles that nobody has been added to. Naming only the first would be
|
|
12048
|
+
// flatly wrong in the second, and would send a user who already has roles off to create duplicates of them.
|
|
12049
|
+
emptyText: 'Nobody can approve anything yet — sign-off goes to a role, and only the people in that role can release a held record. Create your roles and add your team to one first.',
|
|
12050
|
+
doneText: 'Edit or remove it in Approvals Config.',
|
|
12051
|
+
roleAria: 'Who approves — ',
|
|
12052
|
+
showEmail: false, // an approval request is always in-app; an approver who misses it leaves records held
|
|
12053
|
+
buttonLabel: count => count === 0 ? 'Turn on sign-off' : count === 1 ? 'Require sign-off for 1 action' : `Require sign-off for ${count} actions`,
|
|
12054
|
+
toastLabel: created => created > 0 ? `Sign-off turned on for ${created} action${created === 1 ? '' : 's'}` : 'No new sign-off rules were added',
|
|
12055
|
+
loadUrl: 'setup/approvalpresets',
|
|
12056
|
+
applyUrl: 'setup/approvals',
|
|
12057
|
+
load: () => this.setupService.loadApprovalPresets(),
|
|
12058
|
+
apply: items => this.setupService.applyApprovalPresets(items),
|
|
12059
|
+
presets: [], roles: [], groups: [], selected: {}, role: {}, count: 0, loaded: false, applying: false,
|
|
12060
|
+
action: { type: 'custom', display: 'Turn on sign-off', primary: true, disabled: () => this.approvalPicker.count === 0 || this.approvalPicker.applying, onClick: () => this.applyPresets(this.approvalPicker) }
|
|
12061
|
+
};
|
|
12062
|
+
// Both pickers, in render order. Everything below iterates this rather than naming a domain.
|
|
12063
|
+
this.pickers = [this.notificationPicker, this.approvalPicker];
|
|
11407
12064
|
//---------- Demo data ----------
|
|
11408
12065
|
// Added: seed/remove example records. This used to sit on each app's hand-written App Configuration page,
|
|
11409
12066
|
// which is where a user looking for it would never think to look — filling the system with example data is
|
|
@@ -11422,6 +12079,10 @@ class SetupGuideComponent {
|
|
|
11422
12079
|
this.buildGroups();
|
|
11423
12080
|
if (this.hasRoleTemplatesStep())
|
|
11424
12081
|
this.loadRoleTemplates();
|
|
12082
|
+
// Added (presets): only load a picker when the backend actually serves its step. The approvals step is gated
|
|
12083
|
+
// on the approvals module, so turning that module off makes the step — and this call — disappear with it.
|
|
12084
|
+
this.pickers.forEach(picker => { if (this.hasPresetsStep(picker))
|
|
12085
|
+
this.loadPresets(picker); });
|
|
11425
12086
|
});
|
|
11426
12087
|
this.setupService.loadStatus();
|
|
11427
12088
|
}
|
|
@@ -11445,9 +12106,13 @@ class SetupGuideComponent {
|
|
|
11445
12106
|
buildActions(step) {
|
|
11446
12107
|
const act = this.getAction(step);
|
|
11447
12108
|
const actions = [];
|
|
12109
|
+
const picker = this.pickerFor(step); // Changed (presets P4): whichever picker owns this step, if any
|
|
11448
12110
|
if (this.hasRoleTemplates(step)) {
|
|
11449
12111
|
actions.push({ type: 'custom', display: 'Create selected roles', primary: true, disabled: () => this.selectedTemplateCount === 0 || this.creatingRoles, onClick: () => this.createSelectedRoles() });
|
|
11450
12112
|
}
|
|
12113
|
+
else if (picker) {
|
|
12114
|
+
actions.push(picker.action); // Added (presets): the same object every rebuild, so the mutated count label survives a status refresh
|
|
12115
|
+
}
|
|
11451
12116
|
else if (act.button) {
|
|
11452
12117
|
actions.push({ type: 'create', display: this.primaryLabel(step), primary: true, onClick: () => this.doPrimary(step) }); // Changed: filled primary only for create dialogs — a bare link is never promoted to a filled button
|
|
11453
12118
|
}
|
|
@@ -11528,9 +12193,13 @@ class SetupGuideComponent {
|
|
|
11528
12193
|
this.togglingKey = '';
|
|
11529
12194
|
if (resp.success)
|
|
11530
12195
|
this.setupService.refresh();
|
|
12196
|
+
// Changed: the optimistic revert stays exactly as it was — it is correct and must not be lost — but it
|
|
12197
|
+
// was the ONLY feedback. A switch the user flipped silently flipping itself back is not a failure
|
|
12198
|
+
// message, it is a glitch: the likeliest reading is that the click missed, so they flip it again.
|
|
11531
12199
|
else {
|
|
11532
12200
|
mod.enabled = !target;
|
|
11533
12201
|
this.recountModuleGroups();
|
|
12202
|
+
this.apiErrorService.presentAppFailure(resp, 'action', 'setup/module');
|
|
11534
12203
|
}
|
|
11535
12204
|
},
|
|
11536
12205
|
error: () => { this.togglingKey = ''; mod.enabled = !target; this.recountModuleGroups(); }
|
|
@@ -11575,8 +12244,10 @@ class SetupGuideComponent {
|
|
|
11575
12244
|
loadRoleTemplates() {
|
|
11576
12245
|
this.setupService.loadRoleTemplates().subscribe({
|
|
11577
12246
|
next: resp => {
|
|
11578
|
-
if (!resp.success)
|
|
12247
|
+
if (!resp.success) {
|
|
12248
|
+
this.apiErrorService.presentAppFailure(resp, 'load', 'setup/roles');
|
|
11579
12249
|
return;
|
|
12250
|
+
} // Changed: was a bare `return`. The picker then rendered zero templates, which reads as "this app ships no predefined roles" — a completely different statement from "we could not fetch them"
|
|
11580
12251
|
this.roleTemplates = resp.data || [];
|
|
11581
12252
|
// Preselect everything not yet created — the common case is "give me all of these"
|
|
11582
12253
|
this.roleTemplates.forEach(t => { if (this.selectedTemplates[t.key] === undefined)
|
|
@@ -11588,11 +12259,120 @@ class SetupGuideComponent {
|
|
|
11588
12259
|
get selectedTemplateCount() {
|
|
11589
12260
|
return this.roleTemplates.filter(t => !t.exists && this.selectedTemplates[t.key]).length;
|
|
11590
12261
|
}
|
|
11591
|
-
// Added: whole-row toggle for the restyled picker (state-icon rows replaced the checkboxes)
|
|
11592
|
-
toggleTemplate(tpl) {
|
|
11593
|
-
if (tpl.exists)
|
|
12262
|
+
// Added: whole-row toggle for the restyled picker (state-icon rows replaced the checkboxes)
|
|
12263
|
+
toggleTemplate(tpl) {
|
|
12264
|
+
if (tpl.exists)
|
|
12265
|
+
return;
|
|
12266
|
+
this.selectedTemplates[tpl.key] = !this.selectedTemplates[tpl.key];
|
|
12267
|
+
}
|
|
12268
|
+
//---------- Preset pickers (notifications, sign-off) ----------
|
|
12269
|
+
// ONE code path, driven by the PresetPicker objects above. Tick a row, pick who it goes to, apply. Both backends
|
|
12270
|
+
// are create-only and skip anything already configured, so neither can overwrite a rule the user has since
|
|
12271
|
+
// edited, nor resurrect one they deleted.
|
|
12272
|
+
// Which picker (if any) renders inside this step. Called from the template, so it must stay cheap and must
|
|
12273
|
+
// return the SAME object each pass — find() over a two-element array returning an existing reference does.
|
|
12274
|
+
pickerFor(step) {
|
|
12275
|
+
return step ? this.pickers.find(p => p.stepKey === step.key) : undefined;
|
|
12276
|
+
}
|
|
12277
|
+
hasPresetsStep(picker) {
|
|
12278
|
+
return (this.status?.steps || []).some(s => s.key === picker.stepKey);
|
|
12279
|
+
}
|
|
12280
|
+
loadPresets(picker) {
|
|
12281
|
+
picker.load().subscribe({
|
|
12282
|
+
next: (resp) => {
|
|
12283
|
+
// Same reasoning as loadRoleTemplates: a bare return renders an empty picker, which reads as "this app
|
|
12284
|
+
// offers no presets" — a completely different statement from "we could not fetch them".
|
|
12285
|
+
if (!resp.success) {
|
|
12286
|
+
this.apiErrorService.presentAppFailure(resp, 'load', picker.loadUrl);
|
|
12287
|
+
return;
|
|
12288
|
+
}
|
|
12289
|
+
picker.loaded = true;
|
|
12290
|
+
picker.presets = resp.data?.presets || [];
|
|
12291
|
+
picker.roles = resp.data?.roles || [];
|
|
12292
|
+
picker.presets.forEach(p => {
|
|
12293
|
+
if (picker.selected[p.key] === undefined)
|
|
12294
|
+
picker.selected[p.key] = !p.exists; // preselect what is not yet configured; the undefined guard preserves a manual untick across a reload
|
|
12295
|
+
if (picker.role[p.key] === undefined && p.roleID)
|
|
12296
|
+
picker.role[p.key] = p.roleID; // the server-resolved default, kept unless the user changes it
|
|
12297
|
+
// Changed (presets P4): fall back to the FIRST offered role when the server could resolve none. A <select>
|
|
12298
|
+
// with no selected option still DISPLAYS its first one, so without this the row showed a role it had not
|
|
12299
|
+
// stored — the tick then submitted nothing while the screen said otherwise. Reachable whenever the
|
|
12300
|
+
// onboarding user holds no role of their own, which is exactly when nothing else can be resolved. Every
|
|
12301
|
+
// offered role is a legitimate choice (for sign-off the backend has already filtered to actionable ones),
|
|
12302
|
+
// so the worst case is a default the user changes — never a control that lies about what it will do.
|
|
12303
|
+
if (picker.role[p.key] === undefined && picker.roles.length > 0)
|
|
12304
|
+
picker.role[p.key] = picker.roles[0].value;
|
|
12305
|
+
});
|
|
12306
|
+
this.buildPresetGroups(picker);
|
|
12307
|
+
},
|
|
12308
|
+
error: () => { }
|
|
12309
|
+
});
|
|
12310
|
+
}
|
|
12311
|
+
// Materialize the rows once per load: group in catalogue order and split each Effect sentence around the
|
|
12312
|
+
// {role} token. The sentence itself is authored in PresetCatalog server-side — this only cuts it in two so the
|
|
12313
|
+
// select can sit in the gap. Nothing here composes wording. That is why the approval sentences can carry their
|
|
12314
|
+
// own caveat about a deferred notification without this component knowing such a caveat exists.
|
|
12315
|
+
buildPresetGroups(picker) {
|
|
12316
|
+
const names = [];
|
|
12317
|
+
picker.presets.forEach(p => { const name = p.group || picker.defaultGroup; if (!names.includes(name))
|
|
12318
|
+
names.push(name); });
|
|
12319
|
+
picker.groups = names.map(name => {
|
|
12320
|
+
const rows = picker.presets.filter(p => (p.group || picker.defaultGroup) === name).map(p => this.toPresetRow(p));
|
|
12321
|
+
return { name, rows, on: 0, total: rows.length };
|
|
12322
|
+
});
|
|
12323
|
+
this.recountPresets(picker);
|
|
12324
|
+
}
|
|
12325
|
+
toPresetRow(preset) {
|
|
12326
|
+
const parts = (preset.effect || '').split(PRESET_ROLE_TOKEN);
|
|
12327
|
+
// No token (an app author wrote a sentence without one) → the whole sentence leads and the select follows it.
|
|
12328
|
+
// Still a server sentence, still not composed here.
|
|
12329
|
+
return { preset, before: parts[0], after: parts.length > 1 ? parts.slice(1).join(PRESET_ROLE_TOKEN) : '' };
|
|
12330
|
+
}
|
|
12331
|
+
// Counters + button label. "on" counts what will be on after applying: already configured plus newly ticked.
|
|
12332
|
+
recountPresets(picker) {
|
|
12333
|
+
picker.groups.forEach(g => g.on = g.rows.filter(r => r.preset.exists || picker.selected[r.preset.key]).length);
|
|
12334
|
+
// Changed (presets P4): the count now requires a chosen role, matching EXACTLY what applyPresets submits. It
|
|
12335
|
+
// previously counted ticks alone, so with no selectable role the button read "Turn on 3 notifications", was
|
|
12336
|
+
// enabled, and applied nothing — the failure mode this feature must not have. Where roles exist the server
|
|
12337
|
+
// always stamps a default, so nothing changes for the ordinary case.
|
|
12338
|
+
picker.count = picker.presets.filter(p => !p.exists && picker.selected[p.key] && picker.role[p.key]).length;
|
|
12339
|
+
picker.action.display = picker.buttonLabel(picker.count);
|
|
12340
|
+
}
|
|
12341
|
+
// Whole-row toggle, mirroring toggleTemplate — a configured row is inert, never untickable
|
|
12342
|
+
togglePreset(picker, preset) {
|
|
12343
|
+
if (preset.exists)
|
|
11594
12344
|
return;
|
|
11595
|
-
|
|
12345
|
+
picker.selected[preset.key] = !picker.selected[preset.key];
|
|
12346
|
+
this.recountPresets(picker);
|
|
12347
|
+
}
|
|
12348
|
+
// The role select lives inside the row, so its click must not reach the row's toggle
|
|
12349
|
+
setPresetRole(picker, preset, value) {
|
|
12350
|
+
picker.role[preset.key] = Number(value);
|
|
12351
|
+
this.recountPresets(picker); // a row that had no role now counts towards the button
|
|
12352
|
+
}
|
|
12353
|
+
toggleIncludeEmail() {
|
|
12354
|
+
this.includeEmail = !this.includeEmail;
|
|
12355
|
+
}
|
|
12356
|
+
applyPresets(picker) {
|
|
12357
|
+
// A preset with no role would create a rule addressed to nobody — or, for sign-off, a config nobody could
|
|
12358
|
+
// release — so it is never submitted
|
|
12359
|
+
const items = picker.presets.filter(p => !p.exists && picker.selected[p.key] && picker.role[p.key]).map(p => ({ key: p.key, roleID: picker.role[p.key] }));
|
|
12360
|
+
if (items.length === 0 || picker.applying)
|
|
12361
|
+
return;
|
|
12362
|
+
picker.applying = true;
|
|
12363
|
+
picker.apply(items).subscribe({
|
|
12364
|
+
next: (resp) => {
|
|
12365
|
+
picker.applying = false;
|
|
12366
|
+
if (!resp.success) {
|
|
12367
|
+
this.apiErrorService.presentAppFailure(resp, 'action', picker.applyUrl);
|
|
12368
|
+
return;
|
|
12369
|
+
} // never the server's own words on a failure
|
|
12370
|
+
this.messageService.toast(picker.toastLabel(resp.data || 0)); // call-site wording, like the Demo Data card
|
|
12371
|
+
this.loadPresets(picker); // re-reads Exists so the applied rows come back as Configured
|
|
12372
|
+
this.setupService.refresh(); // the step's count moves with the new configs
|
|
12373
|
+
},
|
|
12374
|
+
error: () => picker.applying = false
|
|
12375
|
+
});
|
|
11596
12376
|
}
|
|
11597
12377
|
get showDemoData() {
|
|
11598
12378
|
return !!this.dataService.appConfig?.setupConfig?.demoData;
|
|
@@ -11613,9 +12393,16 @@ class SetupGuideComponent {
|
|
|
11613
12393
|
this.dataService.CallApi({ url, method: 'post' }, {}).subscribe({
|
|
11614
12394
|
next: (response) => {
|
|
11615
12395
|
this.demoBusy = false;
|
|
11616
|
-
|
|
11617
|
-
|
|
11618
|
-
|
|
12396
|
+
// Changed: the SUCCESS path deliberately stays a toast — `success` is a real human sentence written
|
|
12397
|
+
// at the call site ("Demo data seeded successfully") and a non-blocking toast is the right weight
|
|
12398
|
+
// for it. Only the FAILURE branch moves: it was toasting `response.message`, i.e. the server's own
|
|
12399
|
+
// words, so seeding against a schema-behind database put "Invalid column name" on screen.
|
|
12400
|
+
if (response.success) {
|
|
12401
|
+
this.messageService.toast(success);
|
|
12402
|
+
this.setupService.refresh();
|
|
12403
|
+
} // seeded/removed records move the step counts
|
|
12404
|
+
else
|
|
12405
|
+
this.apiErrorService.presentAppFailure(response, 'action', url);
|
|
11619
12406
|
},
|
|
11620
12407
|
error: () => this.demoBusy = false
|
|
11621
12408
|
});
|
|
@@ -11633,16 +12420,19 @@ class SetupGuideComponent {
|
|
|
11633
12420
|
this.loadRoleTemplates(); // refresh the Created chips
|
|
11634
12421
|
this.setupService.refresh();
|
|
11635
12422
|
}
|
|
12423
|
+
else {
|
|
12424
|
+
this.apiErrorService.presentAppFailure(resp, 'action', 'setup/roles'); // Added: was silent. The Created chips simply never appeared, so the user's read is "the roles were created but the page did not refresh" — and the next thing they do is assign people to roles that do not exist
|
|
12425
|
+
}
|
|
11636
12426
|
},
|
|
11637
12427
|
error: () => this.creatingRoles = false
|
|
11638
12428
|
});
|
|
11639
12429
|
}
|
|
11640
12430
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: SetupGuideComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
11641
|
-
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: SetupGuideComponent, isStandalone: false, selector: "spa-setup-guide", ngImport: i0, template: "<div class=\"setup-page\" *ngIf=\"status\">\n\n <!-- Hero: overall readiness + celebration state at 100% -->\n <mat-card class=\"setup-hero\" [class.celebrate]=\"status.percent === 100\">\n <div class=\"hero-content\" *ngIf=\"status.percent < 100\">\n <div class=\"hero-text\">\n <h1>{{ title }}</h1>\n <p>Complete these steps to get your system ready for day-to-day operation.</p>\n <span class=\"hero-counter\">{{ status.completed }} of {{ status.total }} steps completed</span>\n </div>\n <div class=\"hero-progress\">\n <span class=\"hero-percent\">{{ status.percent }}%</span>\n <mat-progress-bar mode=\"determinate\" [value]=\"status.percent\"></mat-progress-bar>\n </div>\n </div>\n <div class=\"hero-content celebration\" *ngIf=\"status.percent === 100\">\n <mat-icon class=\"celebrate-icon\">celebration</mat-icon>\n <div class=\"hero-text\">\n <h1>You're all set!</h1>\n <p>All setup steps are complete \u2014 your system is ready to operate.</p>\n </div>\n </div>\n </mat-card>\n\n <!-- Module picker (v2): choose what the business uses; optional modules toggle steps + menus -->\n <mat-card class=\"setup-modules\" *ngIf=\"modules.length > 0\">\n <div class=\"group-header\">\n <h2>Your modules</h2>\n <span class=\"group-counter\">Tap a module to turn it on or off \u2014 you can change this anytime.</span>\n </div>\n <!-- Changed (v3): grouped \u2014 the picker now carries the whole library catalog, so cards sit under their\n group heading (the app's own modules first). A backend without groups yields one unnamed group,\n which renders as the original flat grid. -->\n <div class=\"module-section\" *ngFor=\"let mgroup of moduleGroups\">\n <div class=\"module-section-head\">\n <span class=\"module-section-name\">{{ mgroup.name }}</span>\n <span class=\"module-section-count\">{{ mgroup.enabled }} of {{ mgroup.total }} on</span>\n </div>\n <div class=\"module-grid\">\n <div class=\"module-card\" *ngFor=\"let mod of mgroup.modules\"\n [class.enabled]=\"mod.enabled\" [class.core]=\"mod.core\" [class.busy]=\"togglingKey === mod.key\"\n (click)=\"toggleModule(mod)\">\n <div class=\"module-head\">\n <mat-icon class=\"module-icon\">{{ mod.icon || 'extension' }}</mat-icon>\n <mat-icon class=\"module-state\" [class.on]=\"mod.enabled\">{{ mod.enabled ? 'check_circle' : 'radio_button_unchecked' }}</mat-icon>\n </div>\n <div class=\"module-title\">{{ mod.title }}</div>\n <div class=\"module-description\">{{ mod.description }}</div>\n <span class=\"module-chip\" *ngIf=\"mod.core\">Always on</span>\n </div>\n </div>\n </div>\n <div class=\"module-actions\" *ngIf=\"!modulesConfirmed\">\n <button mat-flat-button color=\"primary\" [disabled]=\"togglingKey !== ''\" (click)=\"confirmModules()\">Confirm selection</button>\n <span class=\"module-hint\">Happy with this selection? Confirm it to complete the step below.</span>\n </div>\n </mat-card>\n\n <!-- Category groups, top-down \u2014 Changed: flat spa-checklist replaces the raised mat-accordion -->\n <mat-card class=\"setup-group\" *ngFor=\"let group of groups\">\n <div class=\"group-header\">\n <h2>{{ group.name }}</h2>\n <span class=\"group-counter\">{{ group.completed }} of {{ group.total }}</span>\n </div>\n <spa-checklist [config]=\"group.config\" [itemTemplate]=\"stepExtrasTpl\"></spa-checklist>\n </mat-card>\n\n <!-- Demo data (opt-in via setupConfig.demoData) \u2014 moved here off the per-app configuration pages -->\n <mat-card class=\"setup-group setup-demo\" *ngIf=\"showDemoData\">\n <div class=\"group-header\">\n <h2>Demo data</h2>\n <span class=\"group-counter\">Explore with example records, then clear them out</span>\n </div>\n <p class=\"demo-description\">Seeding fills the system with example records so you can try it out before capturing anything real. Removing deletes only those example records.</p>\n <div class=\"demo-actions\">\n <button mat-flat-button color=\"primary\" [disabled]=\"demoBusy\" (click)=\"seedDemoData()\"><mat-icon>add_circle</mat-icon> Seed demo data</button>\n <button mat-stroked-button color=\"warn\" [disabled]=\"demoBusy\" (click)=\"removeDemoData()\"><mat-icon>delete</mat-icon> Remove demo data</button>\n </div>\n </mat-card>\n\n</div>\n\n<!-- Projected into the expanded checklist body: predefined roles picker on the roles step (v2) -->\n<ng-template #stepExtrasTpl let-item>\n <div class=\"role-templates\" *ngIf=\"hasRoleTemplates(item.data) && roleTemplates.length > 0\">\n <div class=\"role-template\" *ngFor=\"let tpl of roleTemplates\" [class.created]=\"tpl.exists\" [class.selected]=\"!tpl.exists && selectedTemplates[tpl.key]\" (click)=\"toggleTemplate(tpl)\">\n <div class=\"role-head\">\n <mat-icon class=\"role-state\" [class.on]=\"tpl.exists || selectedTemplates[tpl.key]\">{{ (tpl.exists || selectedTemplates[tpl.key]) ? 'check_circle' : 'radio_button_unchecked' }}</mat-icon>\n <span class=\"role-name\">{{ tpl.name }}</span>\n <span class=\"role-created\" *ngIf=\"tpl.exists\"><mat-icon>check</mat-icon>Created</span>\n </div>\n <div class=\"role-description\">{{ tpl.description }}</div>\n </div>\n </div>\n</ng-template>\n\n<!-- Graceful empty state (feature disabled or status unavailable) -->\n<div class=\"setup-empty\" *ngIf=\"!status\">\n <mat-icon>rocket_launch</mat-icon>\n <p>Setup status is not available yet.</p>\n</div>\n", styles: [".setup-page{max-width:860px;margin:0 auto;padding:16px;display:flex;flex-direction:column;gap:16px}.setup-hero{padding:24px}.hero-content{display:flex;align-items:center;justify-content:space-between;gap:24px;flex-wrap:wrap}.hero-text h1{margin:0 0 4px;font-size:24px}.hero-text p{margin:0 0 8px;color:#0009}.hero-counter{font-size:13px;color:#0009}.hero-progress{flex:1;min-width:220px;max-width:340px}.hero-percent{display:block;font-size:28px;font-weight:600;color:#2e7d32;margin-bottom:6px;text-align:right}.hero-progress mat-progress-bar{height:10px;border-radius:5px}.setup-hero.celebrate{background:linear-gradient(135deg,#e8f5e9,#f1f8e9)}.celebration{justify-content:flex-start}.celebrate-icon{font-size:48px;width:48px;height:48px;color:#2e7d32;animation:celebrate-pop .6s ease-out}@keyframes celebrate-pop{0%{transform:scale(.3);opacity:0}70%{transform:scale(1.15)}to{transform:scale(1);opacity:1}}.setup-group{padding:16px}.group-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}.group-header h2{margin:0;font-size:17px}.group-counter{font-size:12px;color:#0000008c}.setup-modules{padding:16px}.module-section+.module-section{margin-top:18px}.module-section-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;padding-bottom:6px;border-bottom:1px solid rgba(0,0,0,.08)}.module-section-name{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:#0009}.module-section-count{font-size:11px;color:#00000073;white-space:nowrap}.module-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px;margin-top:8px}.module-card{border:1px solid rgba(0,0,0,.12);border-radius:10px;padding:12px;cursor:pointer;transition:border-color .15s,background .15s,opacity .15s;opacity:.72;position:relative}.module-card:hover{border-color:#90a4ae}.module-card.enabled{border-color:#4caf50;background:#f6fbf6;opacity:1}.module-card.core{cursor:default}.module-card.busy{pointer-events:none;opacity:.5}.module-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}.module-icon{color:#546e7a}.module-card.enabled .module-icon{color:#2e7d32}.module-state{color:#b0bec5}.module-state.on{color:#4caf50}.module-title{font-weight:600;font-size:14px;margin-bottom:4px}.module-description{font-size:12px;color:#0009;min-height:30px}.module-chip{display:inline-block;margin-top:8px;background:#eceff1;color:#546e7a;border-radius:12px;padding:2px 10px;font-size:11px}.module-actions{display:flex;align-items:center;gap:12px;margin-top:14px}.module-hint{font-size:12px;color:#0000008c}.role-templates{display:flex;flex-direction:column;gap:8px;margin:0 0 12px}.role-template{border:1px solid rgba(0,0,0,.08);border-radius:8px;padding:10px 12px;cursor:pointer;transition:border-color .15s,background .15s}.role-template:hover{border-color:#90a4ae}.role-template.selected{border-color:#4caf50;background:#f6fbf6}.role-template.created{background:#f6fbf6;border-color:#c8e6c9;cursor:default}.role-head{display:flex;align-items:center;gap:8px}.role-state{color:#b0bec5;flex-shrink:0}.role-state.on{color:#4caf50}.role-name{font-weight:500;font-size:13px}.role-created{margin-left:auto;display:inline-flex;align-items:center;gap:4px;font-size:12px;color:#2e7d32}.role-created mat-icon{font-size:16px;width:16px;height:16px}.role-description{font-size:12px;color:#0009;margin:2px 0 0 32px}.setup-empty{text-align:center;padding:48px 16px;color:#00000080}.setup-empty mat-icon{font-size:40px;width:40px;height:40px}.demo-description{margin:8px 0 14px;font-size:13px;color:#0009}.demo-actions{display:flex;gap:12px;flex-wrap:wrap}\n"], dependencies: [{ kind: "directive", type: i1$2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1$2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "component", type: i3.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i4$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "component", type: i19.MatCard, selector: "mat-card", inputs: ["appearance"], exportAs: ["matCard"] }, { kind: "component", type: i5.MatProgressBar, selector: "mat-progress-bar", inputs: ["color", "value", "bufferValue", "mode"], outputs: ["animationEnd"], exportAs: ["matProgressBar"] }, { kind: "component", type: ChecklistComponent, selector: "spa-checklist", inputs: ["config", "itemTemplate"] }] }); }
|
|
12431
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: SetupGuideComponent, isStandalone: false, selector: "spa-setup-guide", ngImport: i0, template: "<div class=\"setup-page\" *ngIf=\"status\">\n\n <!-- Hero: overall readiness + celebration state at 100% -->\n <mat-card class=\"setup-hero\" [class.celebrate]=\"status.percent === 100\">\n <div class=\"hero-content\" *ngIf=\"status.percent < 100\">\n <div class=\"hero-text\">\n <h1>{{ title }}</h1>\n <p>Complete these steps to get your system ready for day-to-day operation.</p>\n <span class=\"hero-counter\">{{ status.completed }} of {{ status.total }} steps completed</span>\n </div>\n <div class=\"hero-progress\">\n <span class=\"hero-percent\">{{ status.percent }}%</span>\n <mat-progress-bar mode=\"determinate\" [value]=\"status.percent\"></mat-progress-bar>\n </div>\n </div>\n <div class=\"hero-content celebration\" *ngIf=\"status.percent === 100\">\n <mat-icon class=\"celebrate-icon\">celebration</mat-icon>\n <div class=\"hero-text\">\n <h1>You're all set!</h1>\n <p>All setup steps are complete \u2014 your system is ready to operate.</p>\n </div>\n </div>\n </mat-card>\n\n <!-- Module picker (v2): choose what the business uses; optional modules toggle steps + menus -->\n <mat-card class=\"setup-modules\" *ngIf=\"modules.length > 0\">\n <div class=\"group-header\">\n <h2>Your modules</h2>\n <span class=\"group-counter\">Tap a module to turn it on or off \u2014 you can change this anytime.</span>\n </div>\n <!-- Changed (v3): grouped \u2014 the picker now carries the whole library catalog, so cards sit under their\n group heading (the app's own modules first). A backend without groups yields one unnamed group,\n which renders as the original flat grid. -->\n <div class=\"module-section\" *ngFor=\"let mgroup of moduleGroups\">\n <div class=\"module-section-head\">\n <span class=\"module-section-name\">{{ mgroup.name }}</span>\n <span class=\"module-section-count\">{{ mgroup.enabled }} of {{ mgroup.total }} on</span>\n </div>\n <div class=\"module-grid\">\n <div class=\"module-card\" *ngFor=\"let mod of mgroup.modules\"\n [class.enabled]=\"mod.enabled\" [class.core]=\"mod.core\" [class.busy]=\"togglingKey === mod.key\"\n (click)=\"toggleModule(mod)\">\n <div class=\"module-head\">\n <mat-icon class=\"module-icon\">{{ mod.icon || 'extension' }}</mat-icon>\n <mat-icon class=\"module-state\" [class.on]=\"mod.enabled\">{{ mod.enabled ? 'check_circle' : 'radio_button_unchecked' }}</mat-icon>\n </div>\n <div class=\"module-title\">{{ mod.title }}</div>\n <div class=\"module-description\">{{ mod.description }}</div>\n <span class=\"module-chip\" *ngIf=\"mod.core\">Always on</span>\n </div>\n </div>\n </div>\n <div class=\"module-actions\" *ngIf=\"!modulesConfirmed\">\n <button mat-flat-button color=\"primary\" [disabled]=\"togglingKey !== ''\" (click)=\"confirmModules()\">Confirm selection</button>\n <span class=\"module-hint\">Happy with this selection? Confirm it to complete the step below.</span>\n </div>\n </mat-card>\n\n <!-- Category groups, top-down \u2014 Changed: flat spa-checklist replaces the raised mat-accordion -->\n <mat-card class=\"setup-group\" *ngFor=\"let group of groups\">\n <div class=\"group-header\">\n <h2>{{ group.name }}</h2>\n <span class=\"group-counter\">{{ group.completed }} of {{ group.total }}</span>\n </div>\n <spa-checklist [config]=\"group.config\" [itemTemplate]=\"stepExtrasTpl\"></spa-checklist>\n </mat-card>\n\n <!-- Demo data (opt-in via setupConfig.demoData) \u2014 moved here off the per-app configuration pages -->\n <mat-card class=\"setup-group setup-demo\" *ngIf=\"showDemoData\">\n <div class=\"group-header\">\n <h2>Demo data</h2>\n <span class=\"group-counter\">Explore with example records, then clear them out</span>\n </div>\n <p class=\"demo-description\">Seeding fills the system with example records so you can try it out before capturing anything real. Removing deletes only those example records.</p>\n <div class=\"demo-actions\">\n <button mat-flat-button color=\"primary\" [disabled]=\"demoBusy\" (click)=\"seedDemoData()\"><mat-icon>add_circle</mat-icon> Seed demo data</button>\n <button mat-stroked-button color=\"warn\" [disabled]=\"demoBusy\" (click)=\"removeDemoData()\"><mat-icon>delete</mat-icon> Remove demo data</button>\n </div>\n </mat-card>\n\n</div>\n\n<!-- Projected into the expanded checklist body: predefined roles picker on the roles step (v2) -->\n<ng-template #stepExtrasTpl let-item>\n <div class=\"role-templates\" *ngIf=\"hasRoleTemplates(item.data) && roleTemplates.length > 0\">\n <div class=\"role-template\" *ngFor=\"let tpl of roleTemplates\" [class.created]=\"tpl.exists\" [class.selected]=\"!tpl.exists && selectedTemplates[tpl.key]\" (click)=\"toggleTemplate(tpl)\">\n <div class=\"role-head\">\n <mat-icon class=\"role-state\" [class.on]=\"tpl.exists || selectedTemplates[tpl.key]\">{{ (tpl.exists || selectedTemplates[tpl.key]) ? 'check_circle' : 'radio_button_unchecked' }}</mat-icon>\n <span class=\"role-name\">{{ tpl.name }}</span>\n <span class=\"role-created\" *ngIf=\"tpl.exists\"><mat-icon>check</mat-icon>Created</span>\n </div>\n <div class=\"role-description\">{{ tpl.description }}</div>\n </div>\n </div>\n\n <!-- Added (presets): the preset picker. Same row as the role picker above \u2014 same state icon, same 8px stack,\n same colours \u2014 plus ONE sentence and ONE control.\n Changed (presets P4): rendered through ONE shared template for both the notification step and the sign-off\n step, parameterised by the picker object. The two sit one above the other on the same page, so a second\n near-identical block would drift and every difference would read as a mistake. -->\n <ng-container *ngIf=\"pickerFor(item.data) as picker\">\n <ng-container *ngTemplateOutlet=\"presetPickerTpl; context: { $implicit: picker }\"></ng-container>\n </ng-container>\n</ng-template>\n\n<!-- The one picker body. `picker` carries the domain: its rows, its roles, its wording, its apply call. -->\n<ng-template #presetPickerTpl let-picker>\n <div class=\"preset-picker\" *ngIf=\"picker.loaded && picker.presets.length > 0\">\n\n <!-- No selectable role \u2192 we ask for one instead of offering ticks that would build an inert rule. The wording\n is the picker's own, because \"no roles\" and \"roles nobody is in\" are different situations. -->\n <div class=\"preset-empty\" *ngIf=\"picker.roles.length === 0\">{{ picker.emptyText }}</div>\n\n <ng-container *ngIf=\"picker.roles.length > 0\">\n <div class=\"preset-section\" *ngFor=\"let pgroup of picker.groups\">\n <div class=\"preset-section-head\">\n <span class=\"preset-section-name\">{{ pgroup.name }}</span>\n <span class=\"preset-section-count\">{{ pgroup.on }} of {{ pgroup.total }}</span>\n </div>\n <div class=\"preset-row\" *ngFor=\"let row of pgroup.rows\" role=\"checkbox\" tabindex=\"0\"\n [class.configured]=\"row.preset.exists\" [class.selected]=\"!row.preset.exists && picker.selected[row.preset.key]\"\n [attr.aria-checked]=\"row.preset.exists || !!picker.selected[row.preset.key]\" [attr.aria-disabled]=\"row.preset.exists\"\n (click)=\"togglePreset(picker, row.preset)\"\n (keydown.enter)=\"togglePreset(picker, row.preset)\"\n (keydown.space)=\"togglePreset(picker, row.preset); $event.preventDefault()\">\n <div class=\"preset-head\">\n <mat-icon class=\"preset-state\" [class.on]=\"row.preset.exists || picker.selected[row.preset.key]\">{{ (row.preset.exists || picker.selected[row.preset.key]) ? 'check_circle' : 'radio_button_unchecked' }}</mat-icon>\n <span class=\"preset-name\">{{ row.preset.name }}</span>\n <span class=\"preset-configured\" *ngIf=\"row.preset.exists\"><mat-icon>check</mat-icon>Configured</span>\n </div>\n <div class=\"preset-description\">{{ row.preset.description }}</div>\n <!-- The effect sentence comes from the server whole; `before` and `after` are its two halves either side\n of the {role} placeholder, so reading the line IS reading the configuration. The sign-off sentences\n carry their own deferred-notification caveat, which is why there is no extra note here. -->\n <div class=\"preset-effect\" *ngIf=\"!row.preset.exists\">{{ row.before }}<select class=\"preset-role\" [attr.aria-label]=\"picker.roleAria + row.preset.name\"\n (click)=\"$event.stopPropagation()\"\n (keydown)=\"$event.stopPropagation()\"\n (change)=\"setPresetRole(picker, row.preset, $any($event.target).value)\">\n <option *ngFor=\"let role of picker.roles\" [value]=\"role.value\" [selected]=\"role.value === picker.role[row.preset.key]\">{{ role.name }}</option>\n </select>{{ row.after }}</div>\n <div class=\"preset-effect preset-done\" *ngIf=\"row.preset.exists\">{{ picker.doneText }}</div>\n </div>\n </div>\n\n <!-- One page-level channel choice, notifications only. In-app is the only channel that always works; SMS is a\n stub and is never offered; an approval request is always in-app, so the sign-off picker has no such line. -->\n <div class=\"preset-email\" *ngIf=\"picker.showEmail\" role=\"checkbox\" tabindex=\"0\" [attr.aria-checked]=\"includeEmail\"\n (click)=\"toggleIncludeEmail()\"\n (keydown.enter)=\"toggleIncludeEmail()\"\n (keydown.space)=\"toggleIncludeEmail(); $event.preventDefault()\">\n <mat-icon class=\"preset-state\" [class.on]=\"includeEmail\">{{ includeEmail ? 'check_box' : 'check_box_outline_blank' }}</mat-icon>\n <span class=\"preset-email-label\">Also send these by email</span>\n <span class=\"preset-email-hint\">In-app always. Email needs your mail settings.</span>\n </div>\n </ng-container>\n </div>\n</ng-template>\n\n<!-- Graceful empty state (feature disabled or status unavailable) -->\n<div class=\"setup-empty\" *ngIf=\"!status\">\n <mat-icon>rocket_launch</mat-icon>\n <p>Setup status is not available yet.</p>\n</div>\n", styles: [".setup-page{max-width:860px;margin:0 auto;padding:16px;display:flex;flex-direction:column;gap:16px}.setup-hero{padding:24px}.hero-content{display:flex;align-items:center;justify-content:space-between;gap:24px;flex-wrap:wrap}.hero-text h1{margin:0 0 4px;font-size:24px}.hero-text p{margin:0 0 8px;color:#0009}.hero-counter{font-size:13px;color:#0009}.hero-progress{flex:1;min-width:220px;max-width:340px}.hero-percent{display:block;font-size:28px;font-weight:600;color:#2e7d32;margin-bottom:6px;text-align:right}.hero-progress mat-progress-bar{height:10px;border-radius:5px}.setup-hero.celebrate{background:linear-gradient(135deg,#e8f5e9,#f1f8e9)}.celebration{justify-content:flex-start}.celebrate-icon{font-size:48px;width:48px;height:48px;color:#2e7d32;animation:celebrate-pop .6s ease-out}@keyframes celebrate-pop{0%{transform:scale(.3);opacity:0}70%{transform:scale(1.15)}to{transform:scale(1);opacity:1}}.setup-group{padding:16px}.group-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}.group-header h2{margin:0;font-size:17px}.group-counter{font-size:12px;color:#0000008c}.setup-modules{padding:16px}.module-section+.module-section{margin-top:18px}.module-section-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;padding-bottom:6px;border-bottom:1px solid rgba(0,0,0,.08)}.module-section-name{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:#0009}.module-section-count{font-size:11px;color:#00000073;white-space:nowrap}.module-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px;margin-top:8px}.module-card{border:1px solid rgba(0,0,0,.12);border-radius:10px;padding:12px;cursor:pointer;transition:border-color .15s,background .15s,opacity .15s;opacity:.72;position:relative}.module-card:hover{border-color:#90a4ae}.module-card.enabled{border-color:#4caf50;background:#f6fbf6;opacity:1}.module-card.core{cursor:default}.module-card.busy{pointer-events:none;opacity:.5}.module-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}.module-icon{color:#546e7a}.module-card.enabled .module-icon{color:#2e7d32}.module-state{color:#b0bec5}.module-state.on{color:#4caf50}.module-title{font-weight:600;font-size:14px;margin-bottom:4px}.module-description{font-size:12px;color:#0009;min-height:30px}.module-chip{display:inline-block;margin-top:8px;background:#eceff1;color:#546e7a;border-radius:12px;padding:2px 10px;font-size:11px}.module-actions{display:flex;align-items:center;gap:8px 12px;margin-top:14px;flex-wrap:wrap}.module-actions button{flex-shrink:0}.module-hint{font-size:12px;color:#0000008c}.role-templates{display:flex;flex-direction:column;gap:8px;margin:0 0 12px}.role-template{border:1px solid rgba(0,0,0,.08);border-radius:8px;padding:10px 12px;cursor:pointer;transition:border-color .15s,background .15s}.role-template:hover{border-color:#90a4ae}.role-template.selected{border-color:#4caf50;background:#f6fbf6}.role-template.created{background:#f6fbf6;border-color:#c8e6c9;cursor:default}.role-head{display:flex;align-items:center;gap:8px}.role-state{color:#b0bec5;flex-shrink:0}.role-state.on{color:#4caf50}.role-name{font-weight:500;font-size:13px}.role-created{margin-left:auto;display:inline-flex;align-items:center;gap:4px;font-size:12px;color:#2e7d32}.role-created mat-icon{font-size:16px;width:16px;height:16px}.role-description{font-size:12px;color:#0009;margin:2px 0 0 32px}.preset-picker{display:flex;flex-direction:column;gap:14px;margin:0 0 12px}.preset-empty{font-size:12px;line-height:18px;color:#0009;max-width:62ch}.preset-section{display:flex;flex-direction:column;gap:8px}.preset-section-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;padding-bottom:6px;border-bottom:1px solid rgba(0,0,0,.08)}.preset-section-name{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:#0009}.preset-section-count{font-size:11px;color:#00000073;white-space:nowrap}.preset-row{border:1px solid rgba(0,0,0,.08);border-radius:8px;padding:10px 12px;cursor:pointer;transition:border-color .15s,background .15s}.preset-row:hover{border-color:#90a4ae}.preset-row:focus-visible{outline:2px solid #4caf50;outline-offset:2px}.preset-row.selected{border-color:#4caf50;background:#f6fbf6}.preset-row.configured{background:#f6fbf6;border-color:#c8e6c9;cursor:default}.preset-head{display:flex;align-items:center;gap:8px}.preset-state{color:#b0bec5;flex-shrink:0}.preset-state.on{color:#4caf50}.preset-name{font-weight:500;font-size:13px}.preset-configured{margin-left:auto;display:inline-flex;align-items:center;gap:4px;font-size:12px;color:#2e7d32;white-space:nowrap}.preset-configured mat-icon{font-size:16px;width:16px;height:16px}.preset-description{font-size:12px;color:#0009;margin:2px 0 0 32px}.preset-effect{font-size:12px;line-height:22px;color:#000000c7;margin:4px 0 0 32px}.preset-done{color:#2e7d32}.preset-role{font-family:inherit;font-size:12px;font-weight:500;color:#2e7d32;background:#fff;border:1px solid #c8e6c9;border-radius:6px;padding:2px 4px;margin:0 2px;max-width:100%;cursor:pointer}.preset-role:hover{border-color:#4caf50}.preset-role:focus-visible{outline:2px solid #4caf50;outline-offset:1px}.preset-email{display:flex;align-items:center;gap:8px;flex-wrap:wrap;cursor:pointer;padding:2px 0}.preset-email:focus-visible{outline:2px solid #4caf50;outline-offset:2px;border-radius:6px}.preset-email-label{font-size:13px;font-weight:500}.preset-email-hint{font-size:12px;color:#0000008c}.setup-empty{text-align:center;padding:48px 16px;color:#00000080}.setup-empty mat-icon{font-size:40px;width:40px;height:40px}.demo-description{margin:8px 0 14px;font-size:13px;color:#0009}.demo-actions{display:flex;gap:12px;flex-wrap:wrap}@media (max-width: 700px){.module-hint{flex:0 0 100%}.preset-role{display:block;width:100%;margin:4px 0 0;padding:6px 8px}.preset-email-hint{flex:0 0 100%}}\n"], dependencies: [{ kind: "directive", type: i1$2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1$2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i1$2.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "directive", type: i2$2.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i2$2.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "component", type: i3.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i4$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "component", type: i19.MatCard, selector: "mat-card", inputs: ["appearance"], exportAs: ["matCard"] }, { kind: "component", type: i6$1.MatProgressBar, selector: "mat-progress-bar", inputs: ["color", "value", "bufferValue", "mode"], outputs: ["animationEnd"], exportAs: ["matProgressBar"] }, { kind: "component", type: ChecklistComponent, selector: "spa-checklist", inputs: ["config", "itemTemplate"] }] }); }
|
|
11642
12432
|
}
|
|
11643
12433
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: SetupGuideComponent, decorators: [{
|
|
11644
12434
|
type: Component,
|
|
11645
|
-
args: [{ selector: 'spa-setup-guide', standalone: false, template: "<div class=\"setup-page\" *ngIf=\"status\">\n\n <!-- Hero: overall readiness + celebration state at 100% -->\n <mat-card class=\"setup-hero\" [class.celebrate]=\"status.percent === 100\">\n <div class=\"hero-content\" *ngIf=\"status.percent < 100\">\n <div class=\"hero-text\">\n <h1>{{ title }}</h1>\n <p>Complete these steps to get your system ready for day-to-day operation.</p>\n <span class=\"hero-counter\">{{ status.completed }} of {{ status.total }} steps completed</span>\n </div>\n <div class=\"hero-progress\">\n <span class=\"hero-percent\">{{ status.percent }}%</span>\n <mat-progress-bar mode=\"determinate\" [value]=\"status.percent\"></mat-progress-bar>\n </div>\n </div>\n <div class=\"hero-content celebration\" *ngIf=\"status.percent === 100\">\n <mat-icon class=\"celebrate-icon\">celebration</mat-icon>\n <div class=\"hero-text\">\n <h1>You're all set!</h1>\n <p>All setup steps are complete \u2014 your system is ready to operate.</p>\n </div>\n </div>\n </mat-card>\n\n <!-- Module picker (v2): choose what the business uses; optional modules toggle steps + menus -->\n <mat-card class=\"setup-modules\" *ngIf=\"modules.length > 0\">\n <div class=\"group-header\">\n <h2>Your modules</h2>\n <span class=\"group-counter\">Tap a module to turn it on or off \u2014 you can change this anytime.</span>\n </div>\n <!-- Changed (v3): grouped \u2014 the picker now carries the whole library catalog, so cards sit under their\n group heading (the app's own modules first). A backend without groups yields one unnamed group,\n which renders as the original flat grid. -->\n <div class=\"module-section\" *ngFor=\"let mgroup of moduleGroups\">\n <div class=\"module-section-head\">\n <span class=\"module-section-name\">{{ mgroup.name }}</span>\n <span class=\"module-section-count\">{{ mgroup.enabled }} of {{ mgroup.total }} on</span>\n </div>\n <div class=\"module-grid\">\n <div class=\"module-card\" *ngFor=\"let mod of mgroup.modules\"\n [class.enabled]=\"mod.enabled\" [class.core]=\"mod.core\" [class.busy]=\"togglingKey === mod.key\"\n (click)=\"toggleModule(mod)\">\n <div class=\"module-head\">\n <mat-icon class=\"module-icon\">{{ mod.icon || 'extension' }}</mat-icon>\n <mat-icon class=\"module-state\" [class.on]=\"mod.enabled\">{{ mod.enabled ? 'check_circle' : 'radio_button_unchecked' }}</mat-icon>\n </div>\n <div class=\"module-title\">{{ mod.title }}</div>\n <div class=\"module-description\">{{ mod.description }}</div>\n <span class=\"module-chip\" *ngIf=\"mod.core\">Always on</span>\n </div>\n </div>\n </div>\n <div class=\"module-actions\" *ngIf=\"!modulesConfirmed\">\n <button mat-flat-button color=\"primary\" [disabled]=\"togglingKey !== ''\" (click)=\"confirmModules()\">Confirm selection</button>\n <span class=\"module-hint\">Happy with this selection? Confirm it to complete the step below.</span>\n </div>\n </mat-card>\n\n <!-- Category groups, top-down \u2014 Changed: flat spa-checklist replaces the raised mat-accordion -->\n <mat-card class=\"setup-group\" *ngFor=\"let group of groups\">\n <div class=\"group-header\">\n <h2>{{ group.name }}</h2>\n <span class=\"group-counter\">{{ group.completed }} of {{ group.total }}</span>\n </div>\n <spa-checklist [config]=\"group.config\" [itemTemplate]=\"stepExtrasTpl\"></spa-checklist>\n </mat-card>\n\n <!-- Demo data (opt-in via setupConfig.demoData) \u2014 moved here off the per-app configuration pages -->\n <mat-card class=\"setup-group setup-demo\" *ngIf=\"showDemoData\">\n <div class=\"group-header\">\n <h2>Demo data</h2>\n <span class=\"group-counter\">Explore with example records, then clear them out</span>\n </div>\n <p class=\"demo-description\">Seeding fills the system with example records so you can try it out before capturing anything real. Removing deletes only those example records.</p>\n <div class=\"demo-actions\">\n <button mat-flat-button color=\"primary\" [disabled]=\"demoBusy\" (click)=\"seedDemoData()\"><mat-icon>add_circle</mat-icon> Seed demo data</button>\n <button mat-stroked-button color=\"warn\" [disabled]=\"demoBusy\" (click)=\"removeDemoData()\"><mat-icon>delete</mat-icon> Remove demo data</button>\n </div>\n </mat-card>\n\n</div>\n\n<!-- Projected into the expanded checklist body: predefined roles picker on the roles step (v2) -->\n<ng-template #stepExtrasTpl let-item>\n <div class=\"role-templates\" *ngIf=\"hasRoleTemplates(item.data) && roleTemplates.length > 0\">\n <div class=\"role-template\" *ngFor=\"let tpl of roleTemplates\" [class.created]=\"tpl.exists\" [class.selected]=\"!tpl.exists && selectedTemplates[tpl.key]\" (click)=\"toggleTemplate(tpl)\">\n <div class=\"role-head\">\n <mat-icon class=\"role-state\" [class.on]=\"tpl.exists || selectedTemplates[tpl.key]\">{{ (tpl.exists || selectedTemplates[tpl.key]) ? 'check_circle' : 'radio_button_unchecked' }}</mat-icon>\n <span class=\"role-name\">{{ tpl.name }}</span>\n <span class=\"role-created\" *ngIf=\"tpl.exists\"><mat-icon>check</mat-icon>Created</span>\n </div>\n <div class=\"role-description\">{{ tpl.description }}</div>\n </div>\n </div>\n</ng-template>\n\n<!-- Graceful empty state (feature disabled or status unavailable) -->\n<div class=\"setup-empty\" *ngIf=\"!status\">\n <mat-icon>rocket_launch</mat-icon>\n <p>Setup status is not available yet.</p>\n</div>\n", styles: [".setup-page{max-width:860px;margin:0 auto;padding:16px;display:flex;flex-direction:column;gap:16px}.setup-hero{padding:24px}.hero-content{display:flex;align-items:center;justify-content:space-between;gap:24px;flex-wrap:wrap}.hero-text h1{margin:0 0 4px;font-size:24px}.hero-text p{margin:0 0 8px;color:#0009}.hero-counter{font-size:13px;color:#0009}.hero-progress{flex:1;min-width:220px;max-width:340px}.hero-percent{display:block;font-size:28px;font-weight:600;color:#2e7d32;margin-bottom:6px;text-align:right}.hero-progress mat-progress-bar{height:10px;border-radius:5px}.setup-hero.celebrate{background:linear-gradient(135deg,#e8f5e9,#f1f8e9)}.celebration{justify-content:flex-start}.celebrate-icon{font-size:48px;width:48px;height:48px;color:#2e7d32;animation:celebrate-pop .6s ease-out}@keyframes celebrate-pop{0%{transform:scale(.3);opacity:0}70%{transform:scale(1.15)}to{transform:scale(1);opacity:1}}.setup-group{padding:16px}.group-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}.group-header h2{margin:0;font-size:17px}.group-counter{font-size:12px;color:#0000008c}.setup-modules{padding:16px}.module-section+.module-section{margin-top:18px}.module-section-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;padding-bottom:6px;border-bottom:1px solid rgba(0,0,0,.08)}.module-section-name{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:#0009}.module-section-count{font-size:11px;color:#00000073;white-space:nowrap}.module-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px;margin-top:8px}.module-card{border:1px solid rgba(0,0,0,.12);border-radius:10px;padding:12px;cursor:pointer;transition:border-color .15s,background .15s,opacity .15s;opacity:.72;position:relative}.module-card:hover{border-color:#90a4ae}.module-card.enabled{border-color:#4caf50;background:#f6fbf6;opacity:1}.module-card.core{cursor:default}.module-card.busy{pointer-events:none;opacity:.5}.module-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}.module-icon{color:#546e7a}.module-card.enabled .module-icon{color:#2e7d32}.module-state{color:#b0bec5}.module-state.on{color:#4caf50}.module-title{font-weight:600;font-size:14px;margin-bottom:4px}.module-description{font-size:12px;color:#0009;min-height:30px}.module-chip{display:inline-block;margin-top:8px;background:#eceff1;color:#546e7a;border-radius:12px;padding:2px 10px;font-size:11px}.module-actions{display:flex;align-items:center;gap:12px;margin-top:14px}.module-hint{font-size:12px;color:#0000008c}.role-templates{display:flex;flex-direction:column;gap:8px;margin:0 0 12px}.role-template{border:1px solid rgba(0,0,0,.08);border-radius:8px;padding:10px 12px;cursor:pointer;transition:border-color .15s,background .15s}.role-template:hover{border-color:#90a4ae}.role-template.selected{border-color:#4caf50;background:#f6fbf6}.role-template.created{background:#f6fbf6;border-color:#c8e6c9;cursor:default}.role-head{display:flex;align-items:center;gap:8px}.role-state{color:#b0bec5;flex-shrink:0}.role-state.on{color:#4caf50}.role-name{font-weight:500;font-size:13px}.role-created{margin-left:auto;display:inline-flex;align-items:center;gap:4px;font-size:12px;color:#2e7d32}.role-created mat-icon{font-size:16px;width:16px;height:16px}.role-description{font-size:12px;color:#0009;margin:2px 0 0 32px}.setup-empty{text-align:center;padding:48px 16px;color:#00000080}.setup-empty mat-icon{font-size:40px;width:40px;height:40px}.demo-description{margin:8px 0 14px;font-size:13px;color:#0009}.demo-actions{display:flex;gap:12px;flex-wrap:wrap}\n"] }]
|
|
12435
|
+
args: [{ selector: 'spa-setup-guide', standalone: false, template: "<div class=\"setup-page\" *ngIf=\"status\">\n\n <!-- Hero: overall readiness + celebration state at 100% -->\n <mat-card class=\"setup-hero\" [class.celebrate]=\"status.percent === 100\">\n <div class=\"hero-content\" *ngIf=\"status.percent < 100\">\n <div class=\"hero-text\">\n <h1>{{ title }}</h1>\n <p>Complete these steps to get your system ready for day-to-day operation.</p>\n <span class=\"hero-counter\">{{ status.completed }} of {{ status.total }} steps completed</span>\n </div>\n <div class=\"hero-progress\">\n <span class=\"hero-percent\">{{ status.percent }}%</span>\n <mat-progress-bar mode=\"determinate\" [value]=\"status.percent\"></mat-progress-bar>\n </div>\n </div>\n <div class=\"hero-content celebration\" *ngIf=\"status.percent === 100\">\n <mat-icon class=\"celebrate-icon\">celebration</mat-icon>\n <div class=\"hero-text\">\n <h1>You're all set!</h1>\n <p>All setup steps are complete \u2014 your system is ready to operate.</p>\n </div>\n </div>\n </mat-card>\n\n <!-- Module picker (v2): choose what the business uses; optional modules toggle steps + menus -->\n <mat-card class=\"setup-modules\" *ngIf=\"modules.length > 0\">\n <div class=\"group-header\">\n <h2>Your modules</h2>\n <span class=\"group-counter\">Tap a module to turn it on or off \u2014 you can change this anytime.</span>\n </div>\n <!-- Changed (v3): grouped \u2014 the picker now carries the whole library catalog, so cards sit under their\n group heading (the app's own modules first). A backend without groups yields one unnamed group,\n which renders as the original flat grid. -->\n <div class=\"module-section\" *ngFor=\"let mgroup of moduleGroups\">\n <div class=\"module-section-head\">\n <span class=\"module-section-name\">{{ mgroup.name }}</span>\n <span class=\"module-section-count\">{{ mgroup.enabled }} of {{ mgroup.total }} on</span>\n </div>\n <div class=\"module-grid\">\n <div class=\"module-card\" *ngFor=\"let mod of mgroup.modules\"\n [class.enabled]=\"mod.enabled\" [class.core]=\"mod.core\" [class.busy]=\"togglingKey === mod.key\"\n (click)=\"toggleModule(mod)\">\n <div class=\"module-head\">\n <mat-icon class=\"module-icon\">{{ mod.icon || 'extension' }}</mat-icon>\n <mat-icon class=\"module-state\" [class.on]=\"mod.enabled\">{{ mod.enabled ? 'check_circle' : 'radio_button_unchecked' }}</mat-icon>\n </div>\n <div class=\"module-title\">{{ mod.title }}</div>\n <div class=\"module-description\">{{ mod.description }}</div>\n <span class=\"module-chip\" *ngIf=\"mod.core\">Always on</span>\n </div>\n </div>\n </div>\n <div class=\"module-actions\" *ngIf=\"!modulesConfirmed\">\n <button mat-flat-button color=\"primary\" [disabled]=\"togglingKey !== ''\" (click)=\"confirmModules()\">Confirm selection</button>\n <span class=\"module-hint\">Happy with this selection? Confirm it to complete the step below.</span>\n </div>\n </mat-card>\n\n <!-- Category groups, top-down \u2014 Changed: flat spa-checklist replaces the raised mat-accordion -->\n <mat-card class=\"setup-group\" *ngFor=\"let group of groups\">\n <div class=\"group-header\">\n <h2>{{ group.name }}</h2>\n <span class=\"group-counter\">{{ group.completed }} of {{ group.total }}</span>\n </div>\n <spa-checklist [config]=\"group.config\" [itemTemplate]=\"stepExtrasTpl\"></spa-checklist>\n </mat-card>\n\n <!-- Demo data (opt-in via setupConfig.demoData) \u2014 moved here off the per-app configuration pages -->\n <mat-card class=\"setup-group setup-demo\" *ngIf=\"showDemoData\">\n <div class=\"group-header\">\n <h2>Demo data</h2>\n <span class=\"group-counter\">Explore with example records, then clear them out</span>\n </div>\n <p class=\"demo-description\">Seeding fills the system with example records so you can try it out before capturing anything real. Removing deletes only those example records.</p>\n <div class=\"demo-actions\">\n <button mat-flat-button color=\"primary\" [disabled]=\"demoBusy\" (click)=\"seedDemoData()\"><mat-icon>add_circle</mat-icon> Seed demo data</button>\n <button mat-stroked-button color=\"warn\" [disabled]=\"demoBusy\" (click)=\"removeDemoData()\"><mat-icon>delete</mat-icon> Remove demo data</button>\n </div>\n </mat-card>\n\n</div>\n\n<!-- Projected into the expanded checklist body: predefined roles picker on the roles step (v2) -->\n<ng-template #stepExtrasTpl let-item>\n <div class=\"role-templates\" *ngIf=\"hasRoleTemplates(item.data) && roleTemplates.length > 0\">\n <div class=\"role-template\" *ngFor=\"let tpl of roleTemplates\" [class.created]=\"tpl.exists\" [class.selected]=\"!tpl.exists && selectedTemplates[tpl.key]\" (click)=\"toggleTemplate(tpl)\">\n <div class=\"role-head\">\n <mat-icon class=\"role-state\" [class.on]=\"tpl.exists || selectedTemplates[tpl.key]\">{{ (tpl.exists || selectedTemplates[tpl.key]) ? 'check_circle' : 'radio_button_unchecked' }}</mat-icon>\n <span class=\"role-name\">{{ tpl.name }}</span>\n <span class=\"role-created\" *ngIf=\"tpl.exists\"><mat-icon>check</mat-icon>Created</span>\n </div>\n <div class=\"role-description\">{{ tpl.description }}</div>\n </div>\n </div>\n\n <!-- Added (presets): the preset picker. Same row as the role picker above \u2014 same state icon, same 8px stack,\n same colours \u2014 plus ONE sentence and ONE control.\n Changed (presets P4): rendered through ONE shared template for both the notification step and the sign-off\n step, parameterised by the picker object. The two sit one above the other on the same page, so a second\n near-identical block would drift and every difference would read as a mistake. -->\n <ng-container *ngIf=\"pickerFor(item.data) as picker\">\n <ng-container *ngTemplateOutlet=\"presetPickerTpl; context: { $implicit: picker }\"></ng-container>\n </ng-container>\n</ng-template>\n\n<!-- The one picker body. `picker` carries the domain: its rows, its roles, its wording, its apply call. -->\n<ng-template #presetPickerTpl let-picker>\n <div class=\"preset-picker\" *ngIf=\"picker.loaded && picker.presets.length > 0\">\n\n <!-- No selectable role \u2192 we ask for one instead of offering ticks that would build an inert rule. The wording\n is the picker's own, because \"no roles\" and \"roles nobody is in\" are different situations. -->\n <div class=\"preset-empty\" *ngIf=\"picker.roles.length === 0\">{{ picker.emptyText }}</div>\n\n <ng-container *ngIf=\"picker.roles.length > 0\">\n <div class=\"preset-section\" *ngFor=\"let pgroup of picker.groups\">\n <div class=\"preset-section-head\">\n <span class=\"preset-section-name\">{{ pgroup.name }}</span>\n <span class=\"preset-section-count\">{{ pgroup.on }} of {{ pgroup.total }}</span>\n </div>\n <div class=\"preset-row\" *ngFor=\"let row of pgroup.rows\" role=\"checkbox\" tabindex=\"0\"\n [class.configured]=\"row.preset.exists\" [class.selected]=\"!row.preset.exists && picker.selected[row.preset.key]\"\n [attr.aria-checked]=\"row.preset.exists || !!picker.selected[row.preset.key]\" [attr.aria-disabled]=\"row.preset.exists\"\n (click)=\"togglePreset(picker, row.preset)\"\n (keydown.enter)=\"togglePreset(picker, row.preset)\"\n (keydown.space)=\"togglePreset(picker, row.preset); $event.preventDefault()\">\n <div class=\"preset-head\">\n <mat-icon class=\"preset-state\" [class.on]=\"row.preset.exists || picker.selected[row.preset.key]\">{{ (row.preset.exists || picker.selected[row.preset.key]) ? 'check_circle' : 'radio_button_unchecked' }}</mat-icon>\n <span class=\"preset-name\">{{ row.preset.name }}</span>\n <span class=\"preset-configured\" *ngIf=\"row.preset.exists\"><mat-icon>check</mat-icon>Configured</span>\n </div>\n <div class=\"preset-description\">{{ row.preset.description }}</div>\n <!-- The effect sentence comes from the server whole; `before` and `after` are its two halves either side\n of the {role} placeholder, so reading the line IS reading the configuration. The sign-off sentences\n carry their own deferred-notification caveat, which is why there is no extra note here. -->\n <div class=\"preset-effect\" *ngIf=\"!row.preset.exists\">{{ row.before }}<select class=\"preset-role\" [attr.aria-label]=\"picker.roleAria + row.preset.name\"\n (click)=\"$event.stopPropagation()\"\n (keydown)=\"$event.stopPropagation()\"\n (change)=\"setPresetRole(picker, row.preset, $any($event.target).value)\">\n <option *ngFor=\"let role of picker.roles\" [value]=\"role.value\" [selected]=\"role.value === picker.role[row.preset.key]\">{{ role.name }}</option>\n </select>{{ row.after }}</div>\n <div class=\"preset-effect preset-done\" *ngIf=\"row.preset.exists\">{{ picker.doneText }}</div>\n </div>\n </div>\n\n <!-- One page-level channel choice, notifications only. In-app is the only channel that always works; SMS is a\n stub and is never offered; an approval request is always in-app, so the sign-off picker has no such line. -->\n <div class=\"preset-email\" *ngIf=\"picker.showEmail\" role=\"checkbox\" tabindex=\"0\" [attr.aria-checked]=\"includeEmail\"\n (click)=\"toggleIncludeEmail()\"\n (keydown.enter)=\"toggleIncludeEmail()\"\n (keydown.space)=\"toggleIncludeEmail(); $event.preventDefault()\">\n <mat-icon class=\"preset-state\" [class.on]=\"includeEmail\">{{ includeEmail ? 'check_box' : 'check_box_outline_blank' }}</mat-icon>\n <span class=\"preset-email-label\">Also send these by email</span>\n <span class=\"preset-email-hint\">In-app always. Email needs your mail settings.</span>\n </div>\n </ng-container>\n </div>\n</ng-template>\n\n<!-- Graceful empty state (feature disabled or status unavailable) -->\n<div class=\"setup-empty\" *ngIf=\"!status\">\n <mat-icon>rocket_launch</mat-icon>\n <p>Setup status is not available yet.</p>\n</div>\n", styles: [".setup-page{max-width:860px;margin:0 auto;padding:16px;display:flex;flex-direction:column;gap:16px}.setup-hero{padding:24px}.hero-content{display:flex;align-items:center;justify-content:space-between;gap:24px;flex-wrap:wrap}.hero-text h1{margin:0 0 4px;font-size:24px}.hero-text p{margin:0 0 8px;color:#0009}.hero-counter{font-size:13px;color:#0009}.hero-progress{flex:1;min-width:220px;max-width:340px}.hero-percent{display:block;font-size:28px;font-weight:600;color:#2e7d32;margin-bottom:6px;text-align:right}.hero-progress mat-progress-bar{height:10px;border-radius:5px}.setup-hero.celebrate{background:linear-gradient(135deg,#e8f5e9,#f1f8e9)}.celebration{justify-content:flex-start}.celebrate-icon{font-size:48px;width:48px;height:48px;color:#2e7d32;animation:celebrate-pop .6s ease-out}@keyframes celebrate-pop{0%{transform:scale(.3);opacity:0}70%{transform:scale(1.15)}to{transform:scale(1);opacity:1}}.setup-group{padding:16px}.group-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}.group-header h2{margin:0;font-size:17px}.group-counter{font-size:12px;color:#0000008c}.setup-modules{padding:16px}.module-section+.module-section{margin-top:18px}.module-section-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;padding-bottom:6px;border-bottom:1px solid rgba(0,0,0,.08)}.module-section-name{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:#0009}.module-section-count{font-size:11px;color:#00000073;white-space:nowrap}.module-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px;margin-top:8px}.module-card{border:1px solid rgba(0,0,0,.12);border-radius:10px;padding:12px;cursor:pointer;transition:border-color .15s,background .15s,opacity .15s;opacity:.72;position:relative}.module-card:hover{border-color:#90a4ae}.module-card.enabled{border-color:#4caf50;background:#f6fbf6;opacity:1}.module-card.core{cursor:default}.module-card.busy{pointer-events:none;opacity:.5}.module-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}.module-icon{color:#546e7a}.module-card.enabled .module-icon{color:#2e7d32}.module-state{color:#b0bec5}.module-state.on{color:#4caf50}.module-title{font-weight:600;font-size:14px;margin-bottom:4px}.module-description{font-size:12px;color:#0009;min-height:30px}.module-chip{display:inline-block;margin-top:8px;background:#eceff1;color:#546e7a;border-radius:12px;padding:2px 10px;font-size:11px}.module-actions{display:flex;align-items:center;gap:8px 12px;margin-top:14px;flex-wrap:wrap}.module-actions button{flex-shrink:0}.module-hint{font-size:12px;color:#0000008c}.role-templates{display:flex;flex-direction:column;gap:8px;margin:0 0 12px}.role-template{border:1px solid rgba(0,0,0,.08);border-radius:8px;padding:10px 12px;cursor:pointer;transition:border-color .15s,background .15s}.role-template:hover{border-color:#90a4ae}.role-template.selected{border-color:#4caf50;background:#f6fbf6}.role-template.created{background:#f6fbf6;border-color:#c8e6c9;cursor:default}.role-head{display:flex;align-items:center;gap:8px}.role-state{color:#b0bec5;flex-shrink:0}.role-state.on{color:#4caf50}.role-name{font-weight:500;font-size:13px}.role-created{margin-left:auto;display:inline-flex;align-items:center;gap:4px;font-size:12px;color:#2e7d32}.role-created mat-icon{font-size:16px;width:16px;height:16px}.role-description{font-size:12px;color:#0009;margin:2px 0 0 32px}.preset-picker{display:flex;flex-direction:column;gap:14px;margin:0 0 12px}.preset-empty{font-size:12px;line-height:18px;color:#0009;max-width:62ch}.preset-section{display:flex;flex-direction:column;gap:8px}.preset-section-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;padding-bottom:6px;border-bottom:1px solid rgba(0,0,0,.08)}.preset-section-name{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:#0009}.preset-section-count{font-size:11px;color:#00000073;white-space:nowrap}.preset-row{border:1px solid rgba(0,0,0,.08);border-radius:8px;padding:10px 12px;cursor:pointer;transition:border-color .15s,background .15s}.preset-row:hover{border-color:#90a4ae}.preset-row:focus-visible{outline:2px solid #4caf50;outline-offset:2px}.preset-row.selected{border-color:#4caf50;background:#f6fbf6}.preset-row.configured{background:#f6fbf6;border-color:#c8e6c9;cursor:default}.preset-head{display:flex;align-items:center;gap:8px}.preset-state{color:#b0bec5;flex-shrink:0}.preset-state.on{color:#4caf50}.preset-name{font-weight:500;font-size:13px}.preset-configured{margin-left:auto;display:inline-flex;align-items:center;gap:4px;font-size:12px;color:#2e7d32;white-space:nowrap}.preset-configured mat-icon{font-size:16px;width:16px;height:16px}.preset-description{font-size:12px;color:#0009;margin:2px 0 0 32px}.preset-effect{font-size:12px;line-height:22px;color:#000000c7;margin:4px 0 0 32px}.preset-done{color:#2e7d32}.preset-role{font-family:inherit;font-size:12px;font-weight:500;color:#2e7d32;background:#fff;border:1px solid #c8e6c9;border-radius:6px;padding:2px 4px;margin:0 2px;max-width:100%;cursor:pointer}.preset-role:hover{border-color:#4caf50}.preset-role:focus-visible{outline:2px solid #4caf50;outline-offset:1px}.preset-email{display:flex;align-items:center;gap:8px;flex-wrap:wrap;cursor:pointer;padding:2px 0}.preset-email:focus-visible{outline:2px solid #4caf50;outline-offset:2px;border-radius:6px}.preset-email-label{font-size:13px;font-weight:500}.preset-email-hint{font-size:12px;color:#0000008c}.setup-empty{text-align:center;padding:48px 16px;color:#00000080}.setup-empty mat-icon{font-size:40px;width:40px;height:40px}.demo-description{margin:8px 0 14px;font-size:13px;color:#0009}.demo-actions{display:flex;gap:12px;flex-wrap:wrap}@media (max-width: 700px){.module-hint{flex:0 0 100%}.preset-role{display:block;width:100%;margin:4px 0 0;padding:6px 8px}.preset-email-hint{flex:0 0 100%}}\n"] }]
|
|
11646
12436
|
}] });
|
|
11647
12437
|
|
|
11648
12438
|
// Quiet Loading — perceived-progress engine (FSD D1).
|
|
@@ -12706,7 +13496,7 @@ class DateComponent {
|
|
|
12706
13496
|
return "";
|
|
12707
13497
|
}
|
|
12708
13498
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: DateComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
12709
|
-
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: DateComponent, isStandalone: false, selector: "spa-date", inputs: { required: "required", min: "min", max: "max", readonly: "readonly", hint: "hint", value: "value", display: "display", placeholder: "placeholder", width: "width", suffix: "suffix", infoMessage: "infoMessage", copyContent: "copyContent", clearContent: "clearContent" }, outputs: { valueChange: "valueChange" }, usesOnChanges: true, ngImport: i0, template: "<!-- Changed: Added mouse events for hover tracking, click handler for opening picker, and spa-suffix component -->\n<mat-form-field [class.spa-readonly]=\"readonly\" [ngStyle]=\"{'width':width ?? '100%'}\" subscriptSizing=\"dynamic\" (mouseenter)=\"onMouseEnter()\" (mouseleave)=\"onMouseLeave()\">\n <mat-label>{{display}}</mat-label>\n <input [formControl]=\"control\" [min]=\"minDate.value\" [max]=\"maxDate.value\" matInput [matDatepicker]=\"picker_date\" (dateInput)=\"onChangeEvent()\" [placeholder]=\"display\" [readonly]=\"true\" (click)=\"onInputClick(picker_date)\">\n <mat-datepicker #picker_date></mat-datepicker>\n <mat-error *ngIf=\"control.invalid\">{{validate(control)}}</mat-error>\n <div matSuffix class=\"suffix-icons\">\n <mat-datepicker-toggle [for]=\"picker_date\"></mat-datepicker-toggle>\n <spa-suffix [label]=\"suffix\" [infoMessage]=\"infoMessage\" [copyContent]=\"copyContent\" [clearContent]=\"clearContent\" [isHovered]=\"isHovered\" [(value)]=\"value\"></spa-suffix>\n </div>\n</mat-form-field>\n", styles: [""], dependencies: [{ kind: "directive", type: i1$2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i1$2.NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }, { kind: "directive", type: i2$2.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i2$2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i2$2.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "component", type: i3$1.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i3$1.MatLabel, selector: "mat-label" }, { kind: "directive", type: i3$1.MatError, selector: "mat-error, [matError]", inputs: ["id"] }, { kind: "directive", type: i3$1.MatSuffix, selector: "[matSuffix], [matIconSuffix], [matTextSuffix]", inputs: ["matTextSuffix"] }, { kind: "directive", type: i4$2.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "component", type: i5
|
|
13499
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: DateComponent, isStandalone: false, selector: "spa-date", inputs: { required: "required", min: "min", max: "max", readonly: "readonly", hint: "hint", value: "value", display: "display", placeholder: "placeholder", width: "width", suffix: "suffix", infoMessage: "infoMessage", copyContent: "copyContent", clearContent: "clearContent" }, outputs: { valueChange: "valueChange" }, usesOnChanges: true, ngImport: i0, template: "<!-- Changed: Added mouse events for hover tracking, click handler for opening picker, and spa-suffix component -->\n<mat-form-field [class.spa-readonly]=\"readonly\" [ngStyle]=\"{'width':width ?? '100%'}\" subscriptSizing=\"dynamic\" (mouseenter)=\"onMouseEnter()\" (mouseleave)=\"onMouseLeave()\">\n <mat-label>{{display}}</mat-label>\n <input [formControl]=\"control\" [min]=\"minDate.value\" [max]=\"maxDate.value\" matInput [matDatepicker]=\"picker_date\" (dateInput)=\"onChangeEvent()\" [placeholder]=\"display\" [readonly]=\"true\" (click)=\"onInputClick(picker_date)\">\n <mat-datepicker #picker_date></mat-datepicker>\n <mat-error *ngIf=\"control.invalid\">{{validate(control)}}</mat-error>\n <div matSuffix class=\"suffix-icons\">\n <mat-datepicker-toggle [for]=\"picker_date\"></mat-datepicker-toggle>\n <spa-suffix [label]=\"suffix\" [infoMessage]=\"infoMessage\" [copyContent]=\"copyContent\" [clearContent]=\"clearContent\" [isHovered]=\"isHovered\" [(value)]=\"value\"></spa-suffix>\n </div>\n</mat-form-field>\n", styles: [""], dependencies: [{ kind: "directive", type: i1$2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i1$2.NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }, { kind: "directive", type: i2$2.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i2$2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i2$2.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "component", type: i3$1.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i3$1.MatLabel, selector: "mat-label" }, { kind: "directive", type: i3$1.MatError, selector: "mat-error, [matError]", inputs: ["id"] }, { kind: "directive", type: i3$1.MatSuffix, selector: "[matSuffix], [matIconSuffix], [matTextSuffix]", inputs: ["matTextSuffix"] }, { kind: "directive", type: i4$2.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "component", type: i5.MatDatepicker, selector: "mat-datepicker", exportAs: ["matDatepicker"] }, { kind: "directive", type: i5.MatDatepickerInput, selector: "input[matDatepicker]", inputs: ["matDatepicker", "min", "max", "matDatepickerFilter"], exportAs: ["matDatepickerInput"] }, { kind: "component", type: i5.MatDatepickerToggle, selector: "mat-datepicker-toggle", inputs: ["for", "tabIndex", "aria-label", "disabled", "disableRipple"], exportAs: ["matDatepickerToggle"] }, { kind: "component", type: SuffixComponent, selector: "spa-suffix", inputs: ["label", "infoMessage", "copyContent", "isHovered", "clearContent", "value"], outputs: ["infoClick", "copyClick", "clearClick", "valueChange"] }] }); }
|
|
12710
13500
|
}
|
|
12711
13501
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: DateComponent, decorators: [{
|
|
12712
13502
|
type: Component,
|
|
@@ -13480,7 +14270,7 @@ class EditorComponent {
|
|
|
13480
14270
|
this.valueChange.emit(val);
|
|
13481
14271
|
}
|
|
13482
14272
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: EditorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
13483
|
-
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: EditorComponent, isStandalone: false, selector: "spa-editor", inputs: { display: "display", value: "value", readonly: "readonly", required: "required", hint: "hint", infoMessage: "infoMessage", placeholder: "placeholder", width: "width", height: "height", minHeight: "minHeight", defaultFontName: "defaultFontName", editorConfig: "editorConfig" }, outputs: { valueChange: "valueChange" }, usesOnChanges: true, ngImport: i0, template: "<!-- Rich-text / WYSIWYG editor field -->\n<div class=\"spa-editor-wrap\" [class.spa-readonly]=\"readonly\" [ngStyle]=\"{'width': width ?? '100%'}\">\n\n <div class=\"spa-editor-label\" *ngIf=\"display\">\n <label>{{display}}<span *ngIf=\"required\" class=\"spa-editor-req\"> *</span></label>\n <mat-icon *ngIf=\"infoMessage\" class=\"spa-editor-info\" matTooltip=\"{{infoMessage}}\" matTooltipPosition=\"above\">info</mat-icon>\n </div>\n\n <angular-editor\n [ngModel]=\"value\"\n (ngModelChange)=\"changed($event)\"\n [config]=\"config\"\n [placeholder]=\"placeholder\">\n </angular-editor>\n\n <div class=\"spa-editor-hint\" *ngIf=\"hint\">{{hint}}</div>\n</div>\n", styles: [".spa-editor-wrap{display:block;margin-right:5px;margin-bottom:4px}.spa-editor-label{display:flex;align-items:center;gap:6px;font-size:12px;color:#0009;margin-bottom:4px}.spa-editor-req{color:#f44336}.spa-editor-info{color:#4682b4;font-size:16px;width:16px;height:16px;cursor:help}.spa-editor-hint{font-size:11px;color:#0009;margin-top:2px}.spa-editor-wrap :deep(angular-editor .angular-editor-wrapper),.spa-editor-wrap angular-editor{border-radius:4px}\n"], dependencies: [{ kind: "directive", type: i1$2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i1$2.NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }, { kind: "directive", type: i2$2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i2$2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: i4$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "directive", type: i7.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: i5$
|
|
14273
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: EditorComponent, isStandalone: false, selector: "spa-editor", inputs: { display: "display", value: "value", readonly: "readonly", required: "required", hint: "hint", infoMessage: "infoMessage", placeholder: "placeholder", width: "width", height: "height", minHeight: "minHeight", defaultFontName: "defaultFontName", editorConfig: "editorConfig" }, outputs: { valueChange: "valueChange" }, usesOnChanges: true, ngImport: i0, template: "<!-- Rich-text / WYSIWYG editor field -->\n<div class=\"spa-editor-wrap\" [class.spa-readonly]=\"readonly\" [ngStyle]=\"{'width': width ?? '100%'}\">\n\n <div class=\"spa-editor-label\" *ngIf=\"display\">\n <label>{{display}}<span *ngIf=\"required\" class=\"spa-editor-req\"> *</span></label>\n <mat-icon *ngIf=\"infoMessage\" class=\"spa-editor-info\" matTooltip=\"{{infoMessage}}\" matTooltipPosition=\"above\">info</mat-icon>\n </div>\n\n <angular-editor\n [ngModel]=\"value\"\n (ngModelChange)=\"changed($event)\"\n [config]=\"config\"\n [placeholder]=\"placeholder\">\n </angular-editor>\n\n <div class=\"spa-editor-hint\" *ngIf=\"hint\">{{hint}}</div>\n</div>\n", styles: [".spa-editor-wrap{display:block;margin-right:5px;margin-bottom:4px}.spa-editor-label{display:flex;align-items:center;gap:6px;font-size:12px;color:#0009;margin-bottom:4px}.spa-editor-req{color:#f44336}.spa-editor-info{color:#4682b4;font-size:16px;width:16px;height:16px;cursor:help}.spa-editor-hint{font-size:11px;color:#0009;margin-top:2px}.spa-editor-wrap :deep(angular-editor .angular-editor-wrapper),.spa-editor-wrap angular-editor{border-radius:4px}\n"], dependencies: [{ kind: "directive", type: i1$2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i1$2.NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }, { kind: "directive", type: i2$2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i2$2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: i4$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "directive", type: i7.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: i5$1.AngularEditorComponent, selector: "angular-editor", inputs: ["id", "config", "placeholder", "tabIndex"], outputs: ["html", "viewMode", "blur", "focus"] }] }); }
|
|
13484
14274
|
}
|
|
13485
14275
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: EditorComponent, decorators: [{
|
|
13486
14276
|
type: Component,
|
|
@@ -14097,6 +14887,23 @@ class TilesComponent {
|
|
|
14097
14887
|
isHidden(tile) {
|
|
14098
14888
|
return !Core.isItemVisible(tile, this.data); // Changed: unified visible/hidden replaces hidden + hiddenCondition
|
|
14099
14889
|
}
|
|
14890
|
+
// Added: the tile figure as it should READ. Returns a primitive (never a new object), so binding it in the
|
|
14891
|
+
// template is safe under OnPush — the no-allocating-getters rule is about object identity, not strings.
|
|
14892
|
+
// Absent Tile.format the value is returned untouched, so every existing tile renders exactly as before.
|
|
14893
|
+
displayValue(tile) {
|
|
14894
|
+
const value = this.data?.[tile.name];
|
|
14895
|
+
if (value === null || value === undefined)
|
|
14896
|
+
return tile.chart ? value : 0; // Changed: preserves the old `?? 0` for non-chart tiles and the old blank for chart tiles
|
|
14897
|
+
if (typeof value !== 'number' || !tile.format)
|
|
14898
|
+
return value;
|
|
14899
|
+
if (tile.format === 'money')
|
|
14900
|
+
return value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
14901
|
+
return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
|
|
14902
|
+
}
|
|
14903
|
+
// Added: resolves Tile.color, which may now be a function of the tile data
|
|
14904
|
+
tileColor(tile) {
|
|
14905
|
+
return typeof tile.color === 'function' ? tile.color(this.data) : tile.color;
|
|
14906
|
+
}
|
|
14100
14907
|
// Changed: Returns true only when data[tile.name] is a displayable primitive and not already shown by the chart
|
|
14101
14908
|
isTileValuePrimitive(tile) {
|
|
14102
14909
|
if (tile.chart)
|
|
@@ -14261,11 +15068,11 @@ class TilesComponent {
|
|
|
14261
15068
|
return [];
|
|
14262
15069
|
}
|
|
14263
15070
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: TilesComponent, deps: [{ token: DataServiceLib }, { token: MessageService }, { token: i0.ChangeDetectorRef }, { token: ApiErrorService }], target: i0.ɵɵFactoryTarget.Component }); }
|
|
14264
|
-
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: TilesComponent, isStandalone: false, selector: "spa-tiles", inputs: { config: "config", lastSearch: "lastSearch", data: "data", reload: "reload" }, outputs: { tileActionSelected: "tileActionSelected", tileClick: "tileClick", tileUnClick: "tileUnClick" }, viewQueries: [{ propertyName: "tileScroller", first: true, predicate: ["tileScroller"], descendants: true }], usesOnChanges: true, ngImport: i0, template: "<!-- Tile row layout. THREE modes, and the DEFAULT is the original.\n 'auto' (default, and what every existing page gets): the original bootstrap .row/.col \u2014 tiles stretch to\n fill and shrink to a 150px floor, wrapping when they run out of room. This is byte-for-byte the markup\n that shipped before, restored on the owner's instruction 2026-08-08: the grid I briefly made the default\n forced ONE tile per row on a phone, because a 200px track floor does not fit twice in a ~380px viewport.\n 'wrap': a grid of equal tracks \u2014 opt in where uniform tile widths matter more than filling the row.\n 'carousel': one row, horizontal scroll. The Day Book opts into this.\n The tile body itself is ONE template shared by all three, so the modes can never drift apart. -->\n\n<!-- AUTO \u2014 the original layout, unchanged -->\n<div *ngIf=\"isAuto\" class=\"d-flex row align-items-center justify-content-between\" [class.hide-value-mobile]=\"hideValueMobile\">\n <ng-container *ngFor=\"let tile of tiles\">\n <!-- Changed: tile-clickable class gives pointer + hover lift only on tiles that actually respond to clicks -->\n <mat-card *ngIf=\"!isHidden(tile)\" class=\"col\" [class.tile-clickable]=\"isClickable(tile)\" [class.selected-tile]=\"tile.name === selectedTile\" style=\"margin-left: 5px;margin-right: 5px; padding: 10px 16px ; min-width: 150px; margin-top: 5px;\" (click)=\"clicked(tile)\">\n <ng-container *ngTemplateOutlet=\"tileBody; context: ctx(tile)\"></ng-container>\n </mat-card>\n </ng-container>\n</div>\n\n<!-- WRAP / CAROUSEL \u2014 opt-in only -->\n<div *ngIf=\"!isAuto\" class=\"spa-tiles-outer\">\n\n <!-- Carousel only: scroll affordances, shown solely when there is something to scroll to -->\n <button type=\"button\" class=\"tiles-nav\" *ngIf=\"isCarousel && canScrollLeft\" (click)=\"scrollTiles(-1)\" aria-label=\"Scroll tiles left\">\n <mat-icon>chevron_left</mat-icon>\n </button>\n\n <div #tileScroller class=\"spa-tiles\" [class.tiles-wrap]=\"!isCarousel\" [class.tiles-carousel]=\"isCarousel\" [class.hide-value-mobile]=\"hideValueMobile\" [ngStyle]=\"tilesStyle\" (scroll)=\"onTilesScroll()\">\n <ng-container *ngFor=\"let tile of tiles\">\n <mat-card *ngIf=\"!isHidden(tile)\" class=\"tile-card\" [class.tile-clickable]=\"isClickable(tile)\" [class.selected-tile]=\"tile.name === selectedTile\" (click)=\"clicked(tile)\">\n <ng-container *ngTemplateOutlet=\"tileBody; context: ctx(tile)\"></ng-container>\n </mat-card>\n </ng-container>\n </div>\n\n <button type=\"button\" class=\"tiles-nav\" *ngIf=\"isCarousel && canScrollRight\" (click)=\"scrollTiles(1)\" aria-label=\"Scroll tiles right\">\n <mat-icon>chevron_right</mat-icon>\n </button>\n\n</div>\n\n<!-- The tile body, shared by all three modes. ctx() returns a CACHED context object per tile \u2014 building\n `{ $implicit: tile }` inline would hand NgTemplateOutlet a new object every change-detection pass and\n re-create every tile's view, which is the documented cause of a real livelock in this library. -->\n<ng-template #tileBody let-tile>\n\n <!-- Changed: Chart-style tile \u2014 header, prominent chart, optional value, footer -->\n <ng-container *ngIf=\"tile.chart; else standardTile\">\n <!-- Changed: Header with tile name \u2014 left-aligned for better hierarchy -->\n <div class=\"tile-chart-header\">\n <span>{{tile.alias ?? tile.name | camelToWords}}</span>\n </div>\n\n <!-- Changed: Chart fills tile \u2014 uses helper method for reliable data detection -->\n <div class=\"tile-chart\" *ngIf=\"hasTileChartData(tile)\" [style.height.px]=\"tile.chart.height ?? 120\">\n <canvas baseChart\n [type]=\"tile.chart.type\"\n [data]=\"getTileMiniChartData(tile)\"\n [options]=\"getMiniChartOptions(tile)\"\n [plugins]=\"getTileChartPlugins(tile)\">\n </canvas>\n </div>\n\n <!-- Changed: Optional value display below chart \u2014 only show primitive values, skip chart data objects -->\n <div class=\"tile-chart-value\" *ngIf=\"tile.name && isTileValuePrimitive(tile)\">\n <span class=\"tile-chart-value-text\" [style.color]=\"tile.color\">{{tile.prefix ?? ''}}{{data?.[tile.name]}}<span *ngIf=\"tile.suffix\"> {{tile.suffix}}</span></span>\n </div>\n\n <!-- Changed: Footer with divider for chart tiles -->\n <div class=\"tile-footer\" *ngIf=\"tile.footer || tile.info\">\n <mat-divider></mat-divider>\n <div class=\"d-flex align-items-center\" style=\"gap: 4px; color: #9a9a9a; font-size: 12px; margin-top: 6px;\">\n <mat-icon *ngIf=\"tile.footerIcon\" style=\"font-size: 16px; width: 16px; height: 16px;\">{{tile.footerIcon}}</mat-icon>\n <mat-icon *ngIf=\"!tile.footerIcon && tile.info\" style=\"font-size: 16px; width: 16px; height: 16px; color: steelblue;\">info</mat-icon>\n <span>{{tile.footer ?? tile.info}}</span>\n </div>\n </div>\n </ng-container>\n\n <!-- Changed: Standard tile \u2014 Paper Dashboard style: icon left, label+value right, optional footer -->\n <ng-template #standardTile>\n <!-- Changed: Icon-style tile \u2014 icon left, label top-right, large value below -->\n <ng-container *ngIf=\"tile.icon; else basicTile\">\n <div class=\"tile-icon-row\">\n <div class=\"tile-icon-wrap\" [style.color]=\"tile.color ?? '#2196f3'\">\n <mat-icon>{{tile.icon}}</mat-icon>\n </div>\n <div class=\"tile-icon-content\">\n <div class=\"tile-icon-label\">{{tile.alias ?? tile.name | camelToWords}}</div>\n <!-- Changed (Quiet Loading D5): while the value is genuinely unknown the slot shows a shimmer chip instead of the old lying `0`; icon, label and footer render normally so the card never resizes. On a reload the previous number stays put and simply updates. -->\n <div class=\"tile-icon-value\" [style.color]=\"tile.color\" [attr.aria-busy]=\"showValueGhost(tile) ? 'true' : null\">\n <span *ngIf=\"tile.prefix\">{{tile.prefix}}</span><span *ngIf=\"showValueGhost(tile); else iconTileValue\" class=\"tin-skel tin-skel-text tile-value-ghost\" aria-hidden=\"true\"></span><ng-template #iconTileValue><span [class.tile-value-in]=\"effQuietLoading\">{{data?.[tile.name] ?? 0}}</span></ng-template><span *ngIf=\"tile.suffix\"> {{tile.suffix}}</span>\n <span *ngIf=\"tile.badge && data?.[tile.badge]\" class=\"tile-badge\" [style.backgroundColor]=\"tile.badgeColor ?? '#4caf50'\">{{data?.[tile.badge]}}</span>\n </div>\n </div>\n </div>\n <!-- Changed: Footer with divider \u2014 info tooltip or custom footer text -->\n <div class=\"tile-icon-footer\" *ngIf=\"tile.info || tile.footer\">\n <mat-divider></mat-divider>\n <div class=\"tile-icon-footer-content\">\n <mat-icon *ngIf=\"tile.footerIcon\" class=\"tile-icon-footer-icon\">{{tile.footerIcon}}</mat-icon>\n <mat-icon *ngIf=\"!tile.footerIcon && tile.info\" class=\"tile-icon-footer-icon\" style=\"color: steelblue;\">info</mat-icon>\n <span>{{tile.footer ?? tile.info}}</span>\n </div>\n </div>\n </ng-container>\n\n <!-- Basic tile fallback \u2014 centered number display (no icon) -->\n <ng-template #basicTile>\n <div class=\"row d-flex justify-content-center align-items-center\">\n <div style=\"text-align: center;font-size: 30px;\">\n <mat-label style=\"font-weight:bold;\" *ngIf=\"tile.prefix\" >{{tile.prefix}}</mat-label> \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> \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> \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> <!-- Changed: colour may be a function of the data; value honours Tile.format -->\n <mat-label style=\"font-weight:bold;\" *ngIf=\"tile.suffix\">{{tile.suffix}}</mat-label>\n <span *ngIf=\"tile.badge && data?.[tile.badge]\" class=\"tile-badge\" [style.backgroundColor]=\"tile.badgeColor ?? '#4caf50'\">{{data?.[tile.badge]}}</span>\n </div>\n </div>\n <div class=\"row d-flex justify-content-center align-items-center\">\n <div class=\"d-flex justify-content-center align-items-center\" style=\"text-align: center;\">\n <mat-label style=\"padding-left:5px;padding-right:5px; text-align: center;font-size: 14px;\">{{tile.alias ?? tile.name | camelToWords}}</mat-label>\n <mat-icon *ngIf=\"tile.info\" [matTooltip]=\"tile.info\" matTooltipPosition=\"above\" style=\"font-size: 20px; color:steelblue;\">info</mat-icon>\n </div>\n </div>\n </ng-template>\n </ng-template>\n\n</ng-template>\n", styles: [".card{min-width:180px;flex:1;display:flex;flex-direction:column;align-items:center;padding:5px 10px}.tiles{gap:1;row-gap:5px}.spa-tiles-outer{display:flex;align-items:center;gap:4px}.spa-tiles{flex:1;min-width:0}.tiles-wrap{display:grid;gap:10px;align-items:stretch}.tiles-carousel{display:flex;gap:10px;overflow-x:auto;scroll-behavior:smooth;scroll-snap-type:x proximity;padding-bottom:6px}.tiles-carousel>.tile-card{scroll-snap-align:start;flex:0 0 var(--tile-basis, 200px)}.tile-card{padding:10px 16px;margin:0}.tiles-nav{flex:0 0 auto;display:flex;align-items:center;justify-content:center;width:28px;height:28px;border:none;border-radius:50%;background:#0000000a;color:#666;cursor:pointer;transition:background-color .2s ease,color .2s ease}.tiles-nav:hover{background:#2196f31f;color:#2196f3}.tiles-nav mat-icon{font-size:20px;width:20px;height:20px}.col{transition:all .2s ease}.tile-clickable{cursor:pointer}.tile-clickable:hover{transform:translateY(-2px);box-shadow:0 4px 10px #00000021;background-color:#2196f312}.selected-tile{background-color:#e0e0e0;box-shadow:0 4px 8px #0003;transform:translateY(-2px);border:2px solid #3f51b5}.selected-tile mat-label{font-weight:700}.selected-tile:hover{background-color:#e0e0e0}.tile-chart-header{display:flex;justify-content:flex-start;font-size:11px;font-weight:500;color:#999;text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px}.tile-chart{width:100%;position:relative;margin-top:4px;display:flex;align-items:center;justify-content:center}.tile-chart canvas{width:100%!important;height:100%!important;max-height:inherit}.tile-chart-value{text-align:center;margin-top:6px}.tile-chart-value-text{font-size:22px;font-weight:600;letter-spacing:-.5px}.tile-badge{display:inline-block;font-size:12px;font-weight:500;color:#fff;padding:2px 8px;border-radius:12px;vertical-align:middle;margin-left:4px}.tile-footer{margin-top:8px}.tile-icon-row{display:flex;align-items:flex-start;gap:12px;padding:4px 0}.tile-icon-wrap{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:48px;height:48px}.tile-icon-wrap mat-icon{font-size:36px;width:36px;height:36px;opacity:.85}.tile-icon-content{flex:1;text-align:right;min-width:0}.tile-icon-label{font-size:12px;color:#999;text-transform:uppercase;letter-spacing:.3px;line-height:1.4}.tile-icon-value{font-size:26px;font-weight:600;line-height:1.2;letter-spacing:-.5px}.tile-icon-footer{margin-top:8px}.tile-icon-footer mat-divider{margin-bottom:6px}.tile-icon-footer-content{display:flex;align-items:center;gap:4px;font-size:12px;color:#999}.tile-icon-footer-icon{font-size:16px;width:16px;height:16px;color:#bbb}.tile-value-ghost{width:2.5ch;height:.72em;vertical-align:middle}.tile-value-in{animation:tile-value-in .15s ease-out both}@keyframes tile-value-in{0%{opacity:0}to{opacity:1}}@media (prefers-reduced-motion: reduce){.tile-value-in{animation:none}}@media (max-width: 700px){.tiles-nav{display:none}.tiles-carousel{gap:8px}.tiles-carousel>.tile-card{flex:0 0 44%}.tile-card{padding:8px 10px}.tile-icon-row{gap:8px;padding:2px 0}.tile-icon-wrap{width:30px;height:30px}.tile-icon-wrap mat-icon{font-size:26px;width:26px;height:26px}.tile-icon-label{font-size:10.5px;letter-spacing:.1px;line-height:1.25}.tile-icon-value{font-size:22px}.tile-icon-footer{margin-top:5px}.tile-icon-footer mat-divider{margin-bottom:4px}.tile-icon-footer-content{font-size:10.5px;gap:3px;line-height:1.25}.tile-icon-footer-icon{font-size:13px;width:13px;height:13px}.hide-value-mobile .tile-badge{display:none}}\n"], dependencies: [{ kind: "directive", type: i1$2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1$2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i1$2.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "directive", type: i1$2.NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }, { kind: "component", type: i4$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "directive", type: i3$1.MatLabel, selector: "mat-label" }, { kind: "component", type: i19.MatCard, selector: "mat-card", inputs: ["appearance"], exportAs: ["matCard"] }, { kind: "component", type: i17.MatDivider, selector: "mat-divider", inputs: ["vertical", "inset"] }, { kind: "directive", type: i7.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "directive", type: i10.BaseChartDirective, selector: "canvas[baseChart]", inputs: ["type", "legend", "data", "options", "plugins", "labels", "datasets"], outputs: ["chartClick", "chartHover"], exportAs: ["base-chart"] }, { kind: "pipe", type: CamelToWordsPipe, name: "camelToWords" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
14265
15072
|
}
|
|
14266
15073
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: TilesComponent, decorators: [{
|
|
14267
15074
|
type: Component,
|
|
14268
|
-
args: [{ selector: 'spa-tiles', changeDetection: ChangeDetectionStrategy.OnPush, standalone: false, template: "<!-- Tile row layout. THREE modes, and the DEFAULT is the original.\n 'auto' (default, and what every existing page gets): the original bootstrap .row/.col \u2014 tiles stretch to\n fill and shrink to a 150px floor, wrapping when they run out of room. This is byte-for-byte the markup\n that shipped before, restored on the owner's instruction 2026-08-08: the grid I briefly made the default\n forced ONE tile per row on a phone, because a 200px track floor does not fit twice in a ~380px viewport.\n 'wrap': a grid of equal tracks \u2014 opt in where uniform tile widths matter more than filling the row.\n 'carousel': one row, horizontal scroll. The Day Book opts into this.\n The tile body itself is ONE template shared by all three, so the modes can never drift apart. -->\n\n<!-- AUTO \u2014 the original layout, unchanged -->\n<div *ngIf=\"isAuto\" class=\"d-flex row align-items-center justify-content-between\" [class.hide-value-mobile]=\"hideValueMobile\">\n <ng-container *ngFor=\"let tile of tiles\">\n <!-- Changed: tile-clickable class gives pointer + hover lift only on tiles that actually respond to clicks -->\n <mat-card *ngIf=\"!isHidden(tile)\" class=\"col\" [class.tile-clickable]=\"isClickable(tile)\" [class.selected-tile]=\"tile.name === selectedTile\" style=\"margin-left: 5px;margin-right: 5px; padding: 10px 16px ; min-width: 150px; margin-top: 5px;\" (click)=\"clicked(tile)\">\n <ng-container *ngTemplateOutlet=\"tileBody; context: ctx(tile)\"></ng-container>\n </mat-card>\n </ng-container>\n</div>\n\n<!-- WRAP / CAROUSEL \u2014 opt-in only -->\n<div *ngIf=\"!isAuto\" class=\"spa-tiles-outer\">\n\n <!-- Carousel only: scroll affordances, shown solely when there is something to scroll to -->\n <button type=\"button\" class=\"tiles-nav\" *ngIf=\"isCarousel && canScrollLeft\" (click)=\"scrollTiles(-1)\" aria-label=\"Scroll tiles left\">\n <mat-icon>chevron_left</mat-icon>\n </button>\n\n <div #tileScroller class=\"spa-tiles\" [class.tiles-wrap]=\"!isCarousel\" [class.tiles-carousel]=\"isCarousel\" [class.hide-value-mobile]=\"hideValueMobile\" [ngStyle]=\"tilesStyle\" (scroll)=\"onTilesScroll()\">\n <ng-container *ngFor=\"let tile of tiles\">\n <mat-card *ngIf=\"!isHidden(tile)\" class=\"tile-card\" [class.tile-clickable]=\"isClickable(tile)\" [class.selected-tile]=\"tile.name === selectedTile\" (click)=\"clicked(tile)\">\n <ng-container *ngTemplateOutlet=\"tileBody; context: ctx(tile)\"></ng-container>\n </mat-card>\n </ng-container>\n </div>\n\n <button type=\"button\" class=\"tiles-nav\" *ngIf=\"isCarousel && canScrollRight\" (click)=\"scrollTiles(1)\" aria-label=\"Scroll tiles right\">\n <mat-icon>chevron_right</mat-icon>\n </button>\n\n</div>\n\n<!-- The tile body, shared by all three modes. ctx() returns a CACHED context object per tile \u2014 building\n `{ $implicit: tile }` inline would hand NgTemplateOutlet a new object every change-detection pass and\n re-create every tile's view, which is the documented cause of a real livelock in this library. -->\n<ng-template #tileBody let-tile>\n\n <!-- Changed: Chart-style tile \u2014 header, prominent chart, optional value, footer -->\n <ng-container *ngIf=\"tile.chart; else standardTile\">\n <!-- Changed: Header with tile name \u2014 left-aligned for better hierarchy -->\n <div class=\"tile-chart-header\">\n <span>{{tile.alias ?? tile.name | camelToWords}}</span>\n </div>\n\n <!-- Changed: Chart fills tile \u2014 uses helper method for reliable data detection -->\n <div class=\"tile-chart\" *ngIf=\"hasTileChartData(tile)\" [style.height.px]=\"tile.chart.height ?? 120\">\n <canvas baseChart\n [type]=\"tile.chart.type\"\n [data]=\"getTileMiniChartData(tile)\"\n [options]=\"getMiniChartOptions(tile)\"\n [plugins]=\"getTileChartPlugins(tile)\">\n </canvas>\n </div>\n\n <!-- Changed: Optional value display below chart \u2014 only show primitive values, skip chart data objects -->\n <div class=\"tile-chart-value\" *ngIf=\"tile.name && isTileValuePrimitive(tile)\">\n <span class=\"tile-chart-value-text\" [style.color]=\"tile.color\">{{tile.prefix ?? ''}}{{data?.[tile.name]}}<span *ngIf=\"tile.suffix\"> {{tile.suffix}}</span></span>\n </div>\n\n <!-- Changed: Footer with divider for chart tiles -->\n <div class=\"tile-footer\" *ngIf=\"tile.footer || tile.info\">\n <mat-divider></mat-divider>\n <div class=\"d-flex align-items-center\" style=\"gap: 4px; color: #9a9a9a; font-size: 12px; margin-top: 6px;\">\n <mat-icon *ngIf=\"tile.footerIcon\" style=\"font-size: 16px; width: 16px; height: 16px;\">{{tile.footerIcon}}</mat-icon>\n <mat-icon *ngIf=\"!tile.footerIcon && tile.info\" style=\"font-size: 16px; width: 16px; height: 16px; color: steelblue;\">info</mat-icon>\n <span>{{tile.footer ?? tile.info}}</span>\n </div>\n </div>\n </ng-container>\n\n <!-- Changed: Standard tile \u2014 Paper Dashboard style: icon left, label+value right, optional footer -->\n <ng-template #standardTile>\n <!-- Changed: Icon-style tile \u2014 icon left, label top-right, large value below -->\n <ng-container *ngIf=\"tile.icon; else basicTile\">\n <div class=\"tile-icon-row\">\n <div class=\"tile-icon-wrap\" [style.color]=\"tile.color ?? '#2196f3'\">\n <mat-icon>{{tile.icon}}</mat-icon>\n </div>\n <div class=\"tile-icon-content\">\n <div class=\"tile-icon-label\">{{tile.alias ?? tile.name | camelToWords}}</div>\n <!-- Changed (Quiet Loading D5): while the value is genuinely unknown the slot shows a shimmer chip instead of the old lying `0`; icon, label and footer render normally so the card never resizes. On a reload the previous number stays put and simply updates. -->\n <div class=\"tile-icon-value\" [style.color]=\"tile.color\" [attr.aria-busy]=\"showValueGhost(tile) ? 'true' : null\">\n <span *ngIf=\"tile.prefix\">{{tile.prefix}}</span><span *ngIf=\"showValueGhost(tile); else iconTileValue\" class=\"tin-skel tin-skel-text tile-value-ghost\" aria-hidden=\"true\"></span><ng-template #iconTileValue><span [class.tile-value-in]=\"effQuietLoading\">{{data?.[tile.name] ?? 0}}</span></ng-template><span *ngIf=\"tile.suffix\"> {{tile.suffix}}</span>\n <span *ngIf=\"tile.badge && data?.[tile.badge]\" class=\"tile-badge\" [style.backgroundColor]=\"tile.badgeColor ?? '#4caf50'\">{{data?.[tile.badge]}}</span>\n </div>\n </div>\n </div>\n <!-- Changed: Footer with divider \u2014 info tooltip or custom footer text -->\n <div class=\"tile-icon-footer\" *ngIf=\"tile.info || tile.footer\">\n <mat-divider></mat-divider>\n <div class=\"tile-icon-footer-content\">\n <mat-icon *ngIf=\"tile.footerIcon\" class=\"tile-icon-footer-icon\">{{tile.footerIcon}}</mat-icon>\n <mat-icon *ngIf=\"!tile.footerIcon && tile.info\" class=\"tile-icon-footer-icon\" style=\"color: steelblue;\">info</mat-icon>\n <span>{{tile.footer ?? tile.info}}</span>\n </div>\n </div>\n </ng-container>\n\n <!-- Basic tile fallback \u2014 centered number display (no icon) -->\n <ng-template #basicTile>\n <div class=\"row d-flex justify-content-center align-items-center\">\n <div style=\"text-align: center;font-size: 30px;\">\n <mat-label style=\"font-weight:bold;\" *ngIf=\"tile.prefix\" >{{tile.prefix}}</mat-label> \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> \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> \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> <!-- Changed: colour may be a function of the data; value honours Tile.format -->\n <mat-label style=\"font-weight:bold;\" *ngIf=\"tile.suffix\">{{tile.suffix}}</mat-label>\n <span *ngIf=\"tile.badge && data?.[tile.badge]\" class=\"tile-badge\" [style.backgroundColor]=\"tile.badgeColor ?? '#4caf50'\">{{data?.[tile.badge]}}</span>\n </div>\n </div>\n <div class=\"row d-flex justify-content-center align-items-center\">\n <div class=\"d-flex justify-content-center align-items-center\" style=\"text-align: center;\">\n <mat-label style=\"padding-left:5px;padding-right:5px; text-align: center;font-size: 14px;\">{{tile.alias ?? tile.name | camelToWords}}</mat-label>\n <mat-icon *ngIf=\"tile.info\" [matTooltip]=\"tile.info\" matTooltipPosition=\"above\" style=\"font-size: 20px; color:steelblue;\">info</mat-icon>\n </div>\n </div>\n </ng-template>\n </ng-template>\n\n</ng-template>\n", styles: [".card{min-width:180px;flex:1;display:flex;flex-direction:column;align-items:center;padding:5px 10px}.tiles{gap:1;row-gap:5px}.spa-tiles-outer{display:flex;align-items:center;gap:4px}.spa-tiles{flex:1;min-width:0}.tiles-wrap{display:grid;gap:10px;align-items:stretch}.tiles-carousel{display:flex;gap:10px;overflow-x:auto;scroll-behavior:smooth;scroll-snap-type:x proximity;padding-bottom:6px}.tiles-carousel>.tile-card{scroll-snap-align:start;flex:0 0 var(--tile-basis, 200px)}.tile-card{padding:10px 16px;margin:0}.tiles-nav{flex:0 0 auto;display:flex;align-items:center;justify-content:center;width:28px;height:28px;border:none;border-radius:50%;background:#0000000a;color:#666;cursor:pointer;transition:background-color .2s ease,color .2s ease}.tiles-nav:hover{background:#2196f31f;color:#2196f3}.tiles-nav mat-icon{font-size:20px;width:20px;height:20px}.col{transition:all .2s ease}.tile-clickable{cursor:pointer}.tile-clickable:hover{transform:translateY(-2px);box-shadow:0 4px 10px #00000021;background-color:#2196f312}.selected-tile{background-color:#e0e0e0;box-shadow:0 4px 8px #0003;transform:translateY(-2px);border:2px solid #3f51b5}.selected-tile mat-label{font-weight:700}.selected-tile:hover{background-color:#e0e0e0}.tile-chart-header{display:flex;justify-content:flex-start;font-size:11px;font-weight:500;color:#999;text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px}.tile-chart{width:100%;position:relative;margin-top:4px;display:flex;align-items:center;justify-content:center}.tile-chart canvas{width:100%!important;height:100%!important;max-height:inherit}.tile-chart-value{text-align:center;margin-top:6px}.tile-chart-value-text{font-size:22px;font-weight:600;letter-spacing:-.5px}.tile-badge{display:inline-block;font-size:12px;font-weight:500;color:#fff;padding:2px 8px;border-radius:12px;vertical-align:middle;margin-left:4px}.tile-footer{margin-top:8px}.tile-icon-row{display:flex;align-items:flex-start;gap:12px;padding:4px 0}.tile-icon-wrap{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:48px;height:48px}.tile-icon-wrap mat-icon{font-size:36px;width:36px;height:36px;opacity:.85}.tile-icon-content{flex:1;text-align:right;min-width:0}.tile-icon-label{font-size:12px;color:#999;text-transform:uppercase;letter-spacing:.3px;line-height:1.4}.tile-icon-value{font-size:26px;font-weight:600;line-height:1.2;letter-spacing:-.5px}.tile-icon-footer{margin-top:8px}.tile-icon-footer mat-divider{margin-bottom:6px}.tile-icon-footer-content{display:flex;align-items:center;gap:4px;font-size:12px;color:#999}.tile-icon-footer-icon{font-size:16px;width:16px;height:16px;color:#bbb}.tile-value-ghost{width:2.5ch;height:.72em;vertical-align:middle}.tile-value-in{animation:tile-value-in .15s ease-out both}@keyframes tile-value-in{0%{opacity:0}to{opacity:1}}@media (prefers-reduced-motion: reduce){.tile-value-in{animation:none}}@media (max-width: 700px){.tiles-nav{display:none}.tiles-carousel{gap:8px}.tiles-carousel>.tile-card{flex:0 0 44%}.tile-card{padding:8px 10px}.tile-icon-row{gap:8px;padding:2px 0}.tile-icon-wrap{width:30px;height:30px}.tile-icon-wrap mat-icon{font-size:26px;width:26px;height:26px}.tile-icon-label{font-size:10.5px;letter-spacing:.1px;line-height:1.25}.tile-icon-value{font-size:22px}.tile-icon-footer{margin-top:5px}.tile-icon-footer mat-divider{margin-bottom:4px}.tile-icon-footer-content{font-size:10.5px;gap:3px;line-height:1.25}.tile-icon-footer-icon{font-size:13px;width:13px;height:13px}.hide-value-mobile .tile-badge{display:none}}\n"] }]
|
|
14269
15076
|
}], ctorParameters: () => [{ type: DataServiceLib }, { type: MessageService }, { type: i0.ChangeDetectorRef }, { type: ApiErrorService }], propDecorators: { tileScroller: [{
|
|
14270
15077
|
type: ViewChild,
|
|
14271
15078
|
args: ['tileScroller']
|
|
@@ -16284,11 +17091,11 @@ class GroupsComponent {
|
|
|
16284
17091
|
});
|
|
16285
17092
|
}
|
|
16286
17093
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: GroupsComponent, deps: [{ token: ConditionService }, { token: ButtonService }, { token: DataServiceLib }, { token: i2$1.MatSnackBar }, { token: ApiErrorService }], target: i0.ɵɵFactoryTarget.Component }); }
|
|
16287
|
-
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: GroupsComponent, isStandalone: false, selector: "spa-groups", inputs: { config: "config", dataSource: "dataSource", displayedButtons: "displayedButtons", showOwnFilter: "showOwnFilter", filterText: "filterText" }, outputs: { actionClick: "actionClick" }, usesOnChanges: true, ngImport: i0, template: "<!-- Changed: this filter row was REMOVED from the grouped view's own markup and now lives in the table header,\n beside the action buttons, where every other table config puts it. It used to sit on a row of its own\n directly under the buttons row, leaving both rows half empty and visibly misaligned.\n The filtering logic here is untouched \u2014 only the input that drives it moved, and its text arrives via the\n filterText @Input relayed from the header. Kept as a fallback for a grouped view rendered WITHOUT the\n standard header (spa-groups can be used directly), so filtering is never simply lost. -->\n<div class=\"groups-filter\" *ngIf=\"showOwnFilter\">\n <mat-form-field appearance=\"outline\" class=\"filter-field\">\n <mat-icon matPrefix>search</mat-icon>\n <input matInput placeholder=\"Filter\" [(ngModel)]=\"filterText\" (ngModelChange)=\"applyFilter()\">\n <button *ngIf=\"filterText\" mat-icon-button matSuffix (click)=\"filterText = ''; applyFilter()\">\n <mat-icon>close</mat-icon>\n </button>\n </mat-form-field>\n</div>\n\n<div class=\"groups-container\" cdkDropListGroup>\n\n <mat-card *ngFor=\"let group of groupedData\" class=\"group-card\">\n\n <div class=\"group-header\">\n <div class=\"header-left\">\n <mat-icon *ngIf=\"group.icon\" [style.color]=\"group.color\" class=\"group-icon\">{{group.icon}}</mat-icon>\n <!-- Changed: added the native title attribute. On a phone the title now truncates with an ellipsis rather\n than wrapping to a second line, so the full name has to stay reachable somewhere \u2014 this is the\n zero-cost way to keep it. No tooltip directive, so nothing changes on touch. -->\n <label class=\"group-title\" [title]=\"group.displayName\">{{group.displayName}}</label>\n <label *ngIf=\"config.groupConfig.showGroupCount !== false\" class=\"group-count\">({{group.items.length}})</label>\n </div>\n\n <div class=\"header-right\">\n <button\n *ngFor=\"let button of getVisibleHeaderButtons(group)\"\n mat-icon-button\n [matTooltip]=\"getHeaderButtonTooltip(button)\"\n matTooltipPosition=\"above\"\n [style.color]=\"getHeaderButtonColor(button, group)\"\n [disabled]=\"isHeaderButtonDisabled(button, group)\"\n (click)=\"headerButtonClicked(button, group)\">\n <mat-icon>{{getHeaderButtonIcon(button)}}</mat-icon>\n </button>\n </div>\n </div>\n\n <hr class=\"group-divider\" />\n\n <!-- Empty state: show when no items and drag is disabled -->\n <div *ngIf=\"group.items.length === 0 && !config.groupConfig.dragEnabled\" class=\"empty-state\">\n <label>{{config.groupConfig.emptyGroupMessage ?? 'Empty'}}</label>\n </div>\n\n <!-- Changed: Replaced mat-chip-set with flex container of mat-stroked-button for cleaner button style -->\n <div\n *ngIf=\"group.items.length > 0 || config.groupConfig.dragEnabled\"\n cdkDropList\n [cdkDropListData]=\"group.items\"\n [cdkDropListDisabled]=\"!config.groupConfig.dragEnabled\"\n (cdkDropListDropped)=\"onDrop($event)\"\n (cdkDropListEntered)=\"onDropListEntered($event)\"\n (cdkDropListExited)=\"onDropListExited($event)\"\n [class.drop-highlight]=\"group === highlightedGroup\"\n class=\"drop-list items-container\">\n\n <!-- Empty state inside drop list when drag is enabled -->\n <div *ngIf=\"group.items.length === 0\" class=\"empty-state drop-empty\">\n <label>{{config.groupConfig.emptyGroupMessage ?? 'Empty'}}</label>\n </div>\n\n <button\n mat-stroked-button\n *ngFor=\"let item of group.items\"\n cdkDrag [cdkDragData]=\"item\"\n [cdkDragDisabled]=\"!config.groupConfig.dragEnabled\"\n [matMenuTriggerFor]=\"config.groupConfig.contextMenuEnabled !== false ? itemMenu : null\"\n [matMenuTriggerData]=\"{item: item, group: group}\"\n class=\"item-button\">\n\n <mat-icon\n *ngIf=\"getItemIcon(item)\"\n [style.color]=\"getItemIconColor(item)\"\n [matTooltip]=\"getItemIconTip(item)\"\n matTooltipPosition=\"above\"\n class=\"item-icon\">\n {{getItemIcon(item)}}\n </mat-icon>\n\n {{getItemText(item)}}\n\n <ng-container *ngFor=\"let additionalIcon of getVisibleAdditionalIcons(item)\">\n <mat-icon\n [style.color]=\"additionalIcon.color\"\n [matTooltip]=\"getIconTooltip(additionalIcon, item)\"\n matTooltipPosition=\"above\"\n class=\"item-additional-icon\">\n {{additionalIcon.name}}\n </mat-icon>\n </ng-container>\n\n </button>\n </div>\n\n </mat-card>\n\n</div>\n\n<mat-menu #itemMenu=\"matMenu\">\n <ng-template matMenuContent let-item=\"item\" let-group=\"group\">\n <button\n *ngFor=\"let button of getVisibleButtons(item)\"\n mat-menu-item\n [disabled]=\"isButtonDisabled(button, item)\"\n (click)=\"itemActionClicked(button.name, item, group)\">\n <mat-icon [style.color]=\"getButtonIconColor(button, item)\">{{getButtonIcon(button)}}</mat-icon>\n <span>{{button.display ?? button.tip ?? (button.name | titlecase)}}</span>\n </button>\n </ng-template>\n</mat-menu>\n", styles: [".groups-filter{display:flex;justify-content:flex-end;margin-bottom:8px;padding-right:10px}.filter-field{width:300px;font-size:13px}.filter-field ::ng-deep .mat-mdc-form-field-infix{padding-top:8px!important;padding-bottom:8px!important;min-height:36px}.groups-container{display:flex;flex-direction:column;gap:20px;padding-right:10px;margin-left:0}.group-card{width:100%;margin-bottom:0;padding:8px 16px 16px}.group-header{display:flex;justify-content:space-between;align-items:center;padding:0;min-height:32px}.header-left{display:flex;align-items:center;gap:5px}.header-right{display:flex;align-items:center;gap:4px}.group-icon{margin-right:5px;font-size:24px;width:24px;height:24px;line-height:24px}.group-title{font-size:24px;font-weight:300;line-height:1.2}.group-count{font-size:12px;font-weight:300;margin-left:5px;line-height:1.2}.group-divider{margin-top:8px;margin-bottom:10px}.empty-state{display:flex;justify-content:center;align-items:center;padding:10px}.empty-state label{color:#757575;font-style:italic}.items-container{display:flex;flex-wrap:wrap;gap:10px;padding:8px 0;align-items:center}.item-button{cursor:pointer;font-weight:400;letter-spacing:.01em;transition:box-shadow .2s ease,background-color .2s ease}.item-button:hover{box-shadow:0 1px 4px #00000026;background-color:#00000005}.item-icon{font-size:18px;width:18px;height:18px;margin-right:4px;vertical-align:middle}.item-additional-icon{font-size:16px;width:16px;height:16px;margin-left:6px;vertical-align:middle;opacity:.85}button[mat-icon-button]{width:32px;height:32px}button[mat-icon-button] mat-icon{font-size:18px;margin-top:-3px}.header-right button[mat-icon-button]{display:inline-flex;align-items:center;justify-content:center;padding:0;line-height:normal}.header-right button[mat-icon-button] mat-icon{margin:0;font-size:20px;width:20px;height:20px;line-height:20px;display:block}.header-right button[mat-icon-button] .mat-mdc-button-touch-target{display:none}@media (max-width: 700px){.groups-container{gap:12px;padding-right:0}.group-card{padding:8px 10px 10px}.header-left{flex:1 1 auto;min-width:0;gap:4px}.group-icon{flex:0 0 auto;margin-right:2px;font-size:20px;width:20px;height:20px;line-height:20px}.group-title{font-size:16px;font-weight:400;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.group-count{flex:0 0 auto;margin-left:2px;font-size:11px}.header-right{flex:0 0 auto;gap:0}.header-right button[mat-icon-button] mat-icon{font-size:18px;width:18px;height:18px;line-height:18px}.group-header{min-height:28px}.group-divider{margin-top:6px;margin-bottom:6px}.items-container{gap:6px;padding:4px 0}.item-button{min-width:0;height:32px;padding:0 9px;font-size:12.5px;letter-spacing:normal;border-radius:16px;border-color:#0000001f;--mdc-outlined-button-container-height: 32px;--mdc-outlined-button-container-shape: 16px;--mdc-outlined-button-label-text-size: 12.5px}.item-button ::ng-deep .mdc-button__label{letter-spacing:normal;display:inline-flex;align-items:center}.item-button ::ng-deep .mat-mdc-button-touch-target{height:100%}.item-icon{font-size:14px;width:14px;height:14px;margin-right:3px}.item-additional-icon{font-size:14px;width:14px;height:14px;margin-left:3px}.drop-list{min-height:32px}.empty-state{padding:4px 10px}}.drop-list{min-height:40px}.drop-empty{padding:5px 10px}.cdk-drag-preview{box-shadow:0 5px 5px -3px #0003,0 8px 10px 1px #00000024,0 3px 14px 2px #0000001f;opacity:.9;border-radius:4px}.cdk-drag-animating{transition:transform .25s cubic-bezier(0,0,.2,1)}.drop-highlight{border:2px dashed #90caf9;border-radius:8px;background:#90caf90d}.cdk-drop-list-dragging .item-button:not(.cdk-drag-placeholder){transition:transform .25s cubic-bezier(0,0,.2,1)}\n"], dependencies: [{ kind: "directive", type: i1$2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1$2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i2$2.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i2$2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i2$2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: i4$4.MatMenu, selector: "mat-menu", inputs: ["backdropClass", "aria-label", "aria-labelledby", "aria-describedby", "xPosition", "yPosition", "overlapTrigger", "hasBackdrop", "class", "classList"], outputs: ["closed", "close"], exportAs: ["matMenu"] }, { kind: "component", type: i4$4.MatMenuItem, selector: "[mat-menu-item]", inputs: ["role", "disabled", "disableRipple"], exportAs: ["matMenuItem"] }, { kind: "directive", type: i4$4.MatMenuContent, selector: "ng-template[matMenuContent]" }, { kind: "directive", type: i4$4.MatMenuTrigger, selector: "[mat-menu-trigger-for], [matMenuTriggerFor]", inputs: ["mat-menu-trigger-for", "matMenuTriggerFor", "matMenuTriggerData", "matMenuTriggerRestoreFocus"], outputs: ["menuOpened", "onMenuOpen", "menuClosed", "onMenuClose"], exportAs: ["matMenuTrigger"] }, { kind: "component", type: i3.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i3.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i4$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "component", type: i3$1.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i3$1.MatPrefix, selector: "[matPrefix], [matIconPrefix], [matTextPrefix]", inputs: ["matTextPrefix"] }, { kind: "directive", type: i3$1.MatSuffix, selector: "[matSuffix], [matIconSuffix], [matTextSuffix]", inputs: ["matTextSuffix"] }, { kind: "directive", type: i4$2.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "component", type: i19.MatCard, selector: "mat-card", inputs: ["appearance"], exportAs: ["matCard"] }, { kind: "directive", type: i7.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "directive", type: i15.CdkDropList, selector: "[cdkDropList], cdk-drop-list", inputs: ["cdkDropListConnectedTo", "cdkDropListData", "cdkDropListOrientation", "id", "cdkDropListLockAxis", "cdkDropListDisabled", "cdkDropListSortingDisabled", "cdkDropListEnterPredicate", "cdkDropListSortPredicate", "cdkDropListAutoScrollDisabled", "cdkDropListAutoScrollStep", "cdkDropListElementContainer", "cdkDropListHasAnchor"], outputs: ["cdkDropListDropped", "cdkDropListEntered", "cdkDropListExited", "cdkDropListSorted"], exportAs: ["cdkDropList"] }, { kind: "directive", type: i15.CdkDropListGroup, selector: "[cdkDropListGroup]", inputs: ["cdkDropListGroupDisabled"], exportAs: ["cdkDropListGroup"] }, { kind: "directive", type: i15.CdkDrag, selector: "[cdkDrag]", inputs: ["cdkDragData", "cdkDragLockAxis", "cdkDragRootElement", "cdkDragBoundary", "cdkDragStartDelay", "cdkDragFreeDragPosition", "cdkDragDisabled", "cdkDragConstrainPosition", "cdkDragPreviewClass", "cdkDragPreviewContainer", "cdkDragScale"], outputs: ["cdkDragStarted", "cdkDragReleased", "cdkDragEnded", "cdkDragEntered", "cdkDragExited", "cdkDragDropped", "cdkDragMoved"], exportAs: ["cdkDrag"] }, { kind: "pipe", type: i1$2.TitleCasePipe, name: "titlecase" }] }); }
|
|
17094
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: GroupsComponent, isStandalone: false, selector: "spa-groups", inputs: { config: "config", dataSource: "dataSource", displayedButtons: "displayedButtons", showOwnFilter: "showOwnFilter", filterText: "filterText" }, outputs: { actionClick: "actionClick" }, usesOnChanges: true, ngImport: i0, template: "<!-- Changed: this filter row was REMOVED from the grouped view's own markup and now lives in the table header,\n beside the action buttons, where every other table config puts it. It used to sit on a row of its own\n directly under the buttons row, leaving both rows half empty and visibly misaligned.\n The filtering logic here is untouched \u2014 only the input that drives it moved, and its text arrives via the\n filterText @Input relayed from the header. Kept as a fallback for a grouped view rendered WITHOUT the\n standard header (spa-groups can be used directly), so filtering is never simply lost. -->\n<div class=\"groups-filter\" *ngIf=\"showOwnFilter\">\n <mat-form-field appearance=\"outline\" class=\"filter-field\">\n <mat-icon matPrefix>search</mat-icon>\n <input matInput placeholder=\"Filter\" [(ngModel)]=\"filterText\" (ngModelChange)=\"applyFilter()\">\n <button *ngIf=\"filterText\" mat-icon-button matSuffix (click)=\"filterText = ''; applyFilter()\">\n <mat-icon>close</mat-icon>\n </button>\n </mat-form-field>\n</div>\n\n<div class=\"groups-container\" cdkDropListGroup>\n\n <mat-card *ngFor=\"let group of groupedData\" class=\"group-card\">\n\n <div class=\"group-header\">\n <div class=\"header-left\">\n <mat-icon *ngIf=\"group.icon\" [style.color]=\"group.color\" class=\"group-icon\">{{group.icon}}</mat-icon>\n <!-- Changed: added the native title attribute. On a phone the title now truncates with an ellipsis rather\n than wrapping to a second line, so the full name has to stay reachable somewhere \u2014 this is the\n zero-cost way to keep it. No tooltip directive, so nothing changes on touch. -->\n <label class=\"group-title\" [title]=\"group.displayName\">{{group.displayName}}</label>\n <label *ngIf=\"config.groupConfig.showGroupCount !== false\" class=\"group-count\">({{group.items.length}})</label>\n </div>\n\n <div class=\"header-right\">\n <button\n *ngFor=\"let button of getVisibleHeaderButtons(group)\"\n mat-icon-button\n [matTooltip]=\"getHeaderButtonTooltip(button)\"\n matTooltipPosition=\"above\"\n [style.color]=\"getHeaderButtonColor(button, group)\"\n [disabled]=\"isHeaderButtonDisabled(button, group)\"\n (click)=\"headerButtonClicked(button, group)\">\n <mat-icon>{{getHeaderButtonIcon(button)}}</mat-icon>\n </button>\n </div>\n </div>\n\n <hr class=\"group-divider\" />\n\n <!-- Empty state: show when no items and drag is disabled -->\n <div *ngIf=\"group.items.length === 0 && !config.groupConfig.dragEnabled\" class=\"empty-state\">\n <label>{{config.groupConfig.emptyGroupMessage ?? 'Empty'}}</label>\n </div>\n\n <!-- Changed: Replaced mat-chip-set with flex container of mat-stroked-button for cleaner button style -->\n <div\n *ngIf=\"group.items.length > 0 || config.groupConfig.dragEnabled\"\n cdkDropList\n [cdkDropListData]=\"group.items\"\n [cdkDropListDisabled]=\"!config.groupConfig.dragEnabled\"\n (cdkDropListDropped)=\"onDrop($event)\"\n (cdkDropListEntered)=\"onDropListEntered($event)\"\n (cdkDropListExited)=\"onDropListExited($event)\"\n [class.drop-highlight]=\"group === highlightedGroup\"\n class=\"drop-list items-container\">\n\n <!-- Empty state inside drop list when drag is enabled -->\n <div *ngIf=\"group.items.length === 0\" class=\"empty-state drop-empty\">\n <label>{{config.groupConfig.emptyGroupMessage ?? 'Empty'}}</label>\n </div>\n\n <button\n mat-stroked-button\n *ngFor=\"let item of group.items\"\n cdkDrag [cdkDragData]=\"item\"\n [cdkDragDisabled]=\"!config.groupConfig.dragEnabled\"\n [matMenuTriggerFor]=\"config.groupConfig.contextMenuEnabled !== false ? itemMenu : null\"\n [matMenuTriggerData]=\"{item: item, group: group}\"\n class=\"item-button\">\n\n <mat-icon\n *ngIf=\"getItemIcon(item)\"\n [style.color]=\"getItemIconColor(item)\"\n [matTooltip]=\"getItemIconTip(item)\"\n matTooltipPosition=\"above\"\n class=\"item-icon\">\n {{getItemIcon(item)}}\n </mat-icon>\n\n <!-- Changed: the item text moved into its own span so a runaway name can be capped and ellipsised without\n also squeezing the icons, which share this projection slot with it. The native title attribute keeps the\n full name recoverable on a truncated chip \u2014 same treatment already used on the group title above, and no\n tooltip directive, so touch behaviour is unchanged. -->\n <span class=\"item-label\" [title]=\"getItemText(item)\">{{getItemText(item)}}</span>\n\n <ng-container *ngFor=\"let additionalIcon of getVisibleAdditionalIcons(item)\">\n <mat-icon\n [style.color]=\"additionalIcon.color\"\n [matTooltip]=\"getIconTooltip(additionalIcon, item)\"\n matTooltipPosition=\"above\"\n class=\"item-additional-icon\">\n {{additionalIcon.name}}\n </mat-icon>\n </ng-container>\n\n </button>\n </div>\n\n </mat-card>\n\n</div>\n\n<mat-menu #itemMenu=\"matMenu\">\n <ng-template matMenuContent let-item=\"item\" let-group=\"group\">\n <button\n *ngFor=\"let button of getVisibleButtons(item)\"\n mat-menu-item\n [disabled]=\"isButtonDisabled(button, item)\"\n (click)=\"itemActionClicked(button.name, item, group)\">\n <mat-icon [style.color]=\"getButtonIconColor(button, item)\">{{getButtonIcon(button)}}</mat-icon>\n <span>{{button.display ?? button.tip ?? (button.name | titlecase)}}</span>\n </button>\n </ng-template>\n</mat-menu>\n", styles: [".groups-filter{display:flex;justify-content:flex-end;margin-bottom:8px;padding-right:10px}.filter-field{width:300px;font-size:13px}.filter-field ::ng-deep .mat-mdc-form-field-infix{padding-top:8px!important;padding-bottom:8px!important;min-height:36px}.groups-container{display:flex;flex-direction:column;gap:20px;padding-right:10px;margin-left:0}.group-card{width:100%;margin-bottom:0;padding:8px 16px 16px}.group-header{display:flex;justify-content:space-between;align-items:center;padding:0;min-height:32px}.header-left{display:flex;align-items:center;gap:5px}.header-right{display:flex;align-items:center;gap:4px}.group-icon{margin-right:5px;font-size:24px;width:24px;height:24px;line-height:24px}.group-title{font-size:24px;font-weight:300;line-height:1.2}.group-count{font-size:12px;font-weight:300;margin-left:5px;line-height:1.2}.group-divider{margin-top:8px;margin-bottom:10px}.empty-state{display:flex;justify-content:center;align-items:center;padding:10px}.empty-state label{color:#757575;font-style:italic}.items-container{display:flex;flex-wrap:wrap;gap:10px;padding:8px 0;align-items:center}.item-button{cursor:pointer;font-weight:400;letter-spacing:.01em;transition:box-shadow .2s ease,background-color .2s ease}.item-button:hover{box-shadow:0 1px 4px #00000026;background-color:#00000005}.item-label{display:inline-block;max-width:260px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:middle}.item-icon{font-size:18px;width:18px;height:18px;margin-right:4px;vertical-align:middle}.item-additional-icon{font-size:16px;width:16px;height:16px;margin-left:6px;vertical-align:middle;opacity:.85}button[mat-icon-button]{width:32px;height:32px}button[mat-icon-button] mat-icon{font-size:18px;margin-top:-3px}.header-right button[mat-icon-button]{display:inline-flex;align-items:center;justify-content:center;padding:0;line-height:normal}.header-right button[mat-icon-button] mat-icon{margin:0;font-size:20px;width:20px;height:20px;line-height:20px;display:block}.header-right button[mat-icon-button] .mat-mdc-button-touch-target{display:none}@media (max-width: 700px){.groups-container{gap:12px;padding-right:0}.group-card{padding:8px 10px 10px}.header-left{flex:1 1 auto;min-width:0;gap:4px}.group-icon{flex:0 0 auto;margin-right:2px;font-size:20px;width:20px;height:20px;line-height:20px}.group-title{font-size:16px;font-weight:400;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.group-count{flex:0 0 auto;margin-left:2px;font-size:11px}.header-right{flex:0 0 auto;gap:0}.header-right button[mat-icon-button] mat-icon{font-size:18px;width:18px;height:18px;line-height:18px}.group-header{min-height:28px}.group-divider{margin-top:6px;margin-bottom:6px}.items-container{gap:6px;padding:4px 0}.item-button{min-width:0;height:32px;padding:0 9px;font-size:12.5px;letter-spacing:normal;border-radius:16px;border-color:#0000001f;--mdc-outlined-button-container-height: 32px;--mdc-outlined-button-container-shape: 16px;--mdc-outlined-button-label-text-size: 12.5px}.item-button ::ng-deep .mdc-button__label{letter-spacing:normal;display:inline-flex;align-items:center}.item-button ::ng-deep .mat-mdc-button-touch-target{height:100%}.item-label{max-width:104px}.item-icon{font-size:14px;width:14px;height:14px;margin-right:3px}.item-additional-icon{font-size:14px;width:14px;height:14px;margin-left:3px}.drop-list{min-height:32px}.empty-state{padding:4px 10px}}.drop-list{min-height:40px}.drop-empty{padding:5px 10px}.cdk-drag-preview{box-shadow:0 5px 5px -3px #0003,0 8px 10px 1px #00000024,0 3px 14px 2px #0000001f;opacity:.9;border-radius:4px}.cdk-drag-animating{transition:transform .25s cubic-bezier(0,0,.2,1)}.drop-highlight{border:2px dashed #90caf9;border-radius:8px;background:#90caf90d}.cdk-drop-list-dragging .item-button:not(.cdk-drag-placeholder){transition:transform .25s cubic-bezier(0,0,.2,1)}\n"], dependencies: [{ kind: "directive", type: i1$2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1$2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i2$2.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i2$2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i2$2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: i4$4.MatMenu, selector: "mat-menu", inputs: ["backdropClass", "aria-label", "aria-labelledby", "aria-describedby", "xPosition", "yPosition", "overlapTrigger", "hasBackdrop", "class", "classList"], outputs: ["closed", "close"], exportAs: ["matMenu"] }, { kind: "component", type: i4$4.MatMenuItem, selector: "[mat-menu-item]", inputs: ["role", "disabled", "disableRipple"], exportAs: ["matMenuItem"] }, { kind: "directive", type: i4$4.MatMenuContent, selector: "ng-template[matMenuContent]" }, { kind: "directive", type: i4$4.MatMenuTrigger, selector: "[mat-menu-trigger-for], [matMenuTriggerFor]", inputs: ["mat-menu-trigger-for", "matMenuTriggerFor", "matMenuTriggerData", "matMenuTriggerRestoreFocus"], outputs: ["menuOpened", "onMenuOpen", "menuClosed", "onMenuClose"], exportAs: ["matMenuTrigger"] }, { kind: "component", type: i3.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i3.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i4$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "component", type: i3$1.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i3$1.MatPrefix, selector: "[matPrefix], [matIconPrefix], [matTextPrefix]", inputs: ["matTextPrefix"] }, { kind: "directive", type: i3$1.MatSuffix, selector: "[matSuffix], [matIconSuffix], [matTextSuffix]", inputs: ["matTextSuffix"] }, { kind: "directive", type: i4$2.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "component", type: i19.MatCard, selector: "mat-card", inputs: ["appearance"], exportAs: ["matCard"] }, { kind: "directive", type: i7.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "directive", type: i15.CdkDropList, selector: "[cdkDropList], cdk-drop-list", inputs: ["cdkDropListConnectedTo", "cdkDropListData", "cdkDropListOrientation", "id", "cdkDropListLockAxis", "cdkDropListDisabled", "cdkDropListSortingDisabled", "cdkDropListEnterPredicate", "cdkDropListSortPredicate", "cdkDropListAutoScrollDisabled", "cdkDropListAutoScrollStep", "cdkDropListElementContainer", "cdkDropListHasAnchor"], outputs: ["cdkDropListDropped", "cdkDropListEntered", "cdkDropListExited", "cdkDropListSorted"], exportAs: ["cdkDropList"] }, { kind: "directive", type: i15.CdkDropListGroup, selector: "[cdkDropListGroup]", inputs: ["cdkDropListGroupDisabled"], exportAs: ["cdkDropListGroup"] }, { kind: "directive", type: i15.CdkDrag, selector: "[cdkDrag]", inputs: ["cdkDragData", "cdkDragLockAxis", "cdkDragRootElement", "cdkDragBoundary", "cdkDragStartDelay", "cdkDragFreeDragPosition", "cdkDragDisabled", "cdkDragConstrainPosition", "cdkDragPreviewClass", "cdkDragPreviewContainer", "cdkDragScale"], outputs: ["cdkDragStarted", "cdkDragReleased", "cdkDragEnded", "cdkDragEntered", "cdkDragExited", "cdkDragDropped", "cdkDragMoved"], exportAs: ["cdkDrag"] }, { kind: "pipe", type: i1$2.TitleCasePipe, name: "titlecase" }] }); }
|
|
16288
17095
|
}
|
|
16289
17096
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: GroupsComponent, decorators: [{
|
|
16290
17097
|
type: Component,
|
|
16291
|
-
args: [{ selector: 'spa-groups', standalone: false, template: "<!-- Changed: this filter row was REMOVED from the grouped view's own markup and now lives in the table header,\n beside the action buttons, where every other table config puts it. It used to sit on a row of its own\n directly under the buttons row, leaving both rows half empty and visibly misaligned.\n The filtering logic here is untouched \u2014 only the input that drives it moved, and its text arrives via the\n filterText @Input relayed from the header. Kept as a fallback for a grouped view rendered WITHOUT the\n standard header (spa-groups can be used directly), so filtering is never simply lost. -->\n<div class=\"groups-filter\" *ngIf=\"showOwnFilter\">\n <mat-form-field appearance=\"outline\" class=\"filter-field\">\n <mat-icon matPrefix>search</mat-icon>\n <input matInput placeholder=\"Filter\" [(ngModel)]=\"filterText\" (ngModelChange)=\"applyFilter()\">\n <button *ngIf=\"filterText\" mat-icon-button matSuffix (click)=\"filterText = ''; applyFilter()\">\n <mat-icon>close</mat-icon>\n </button>\n </mat-form-field>\n</div>\n\n<div class=\"groups-container\" cdkDropListGroup>\n\n <mat-card *ngFor=\"let group of groupedData\" class=\"group-card\">\n\n <div class=\"group-header\">\n <div class=\"header-left\">\n <mat-icon *ngIf=\"group.icon\" [style.color]=\"group.color\" class=\"group-icon\">{{group.icon}}</mat-icon>\n <!-- Changed: added the native title attribute. On a phone the title now truncates with an ellipsis rather\n than wrapping to a second line, so the full name has to stay reachable somewhere \u2014 this is the\n zero-cost way to keep it. No tooltip directive, so nothing changes on touch. -->\n <label class=\"group-title\" [title]=\"group.displayName\">{{group.displayName}}</label>\n <label *ngIf=\"config.groupConfig.showGroupCount !== false\" class=\"group-count\">({{group.items.length}})</label>\n </div>\n\n <div class=\"header-right\">\n <button\n *ngFor=\"let button of getVisibleHeaderButtons(group)\"\n mat-icon-button\n [matTooltip]=\"getHeaderButtonTooltip(button)\"\n matTooltipPosition=\"above\"\n [style.color]=\"getHeaderButtonColor(button, group)\"\n [disabled]=\"isHeaderButtonDisabled(button, group)\"\n (click)=\"headerButtonClicked(button, group)\">\n <mat-icon>{{getHeaderButtonIcon(button)}}</mat-icon>\n </button>\n </div>\n </div>\n\n <hr class=\"group-divider\" />\n\n <!-- Empty state: show when no items and drag is disabled -->\n <div *ngIf=\"group.items.length === 0 && !config.groupConfig.dragEnabled\" class=\"empty-state\">\n <label>{{config.groupConfig.emptyGroupMessage ?? 'Empty'}}</label>\n </div>\n\n <!-- Changed: Replaced mat-chip-set with flex container of mat-stroked-button for cleaner button style -->\n <div\n *ngIf=\"group.items.length > 0 || config.groupConfig.dragEnabled\"\n cdkDropList\n [cdkDropListData]=\"group.items\"\n [cdkDropListDisabled]=\"!config.groupConfig.dragEnabled\"\n (cdkDropListDropped)=\"onDrop($event)\"\n (cdkDropListEntered)=\"onDropListEntered($event)\"\n (cdkDropListExited)=\"onDropListExited($event)\"\n [class.drop-highlight]=\"group === highlightedGroup\"\n class=\"drop-list items-container\">\n\n <!-- Empty state inside drop list when drag is enabled -->\n <div *ngIf=\"group.items.length === 0\" class=\"empty-state drop-empty\">\n <label>{{config.groupConfig.emptyGroupMessage ?? 'Empty'}}</label>\n </div>\n\n <button\n mat-stroked-button\n *ngFor=\"let item of group.items\"\n cdkDrag [cdkDragData]=\"item\"\n [cdkDragDisabled]=\"!config.groupConfig.dragEnabled\"\n [matMenuTriggerFor]=\"config.groupConfig.contextMenuEnabled !== false ? itemMenu : null\"\n [matMenuTriggerData]=\"{item: item, group: group}\"\n class=\"item-button\">\n\n <mat-icon\n *ngIf=\"getItemIcon(item)\"\n [style.color]=\"getItemIconColor(item)\"\n [matTooltip]=\"getItemIconTip(item)\"\n matTooltipPosition=\"above\"\n class=\"item-icon\">\n {{getItemIcon(item)}}\n </mat-icon>\n\n {{getItemText(item)}}
|
|
17098
|
+
args: [{ selector: 'spa-groups', standalone: false, template: "<!-- Changed: this filter row was REMOVED from the grouped view's own markup and now lives in the table header,\n beside the action buttons, where every other table config puts it. It used to sit on a row of its own\n directly under the buttons row, leaving both rows half empty and visibly misaligned.\n The filtering logic here is untouched \u2014 only the input that drives it moved, and its text arrives via the\n filterText @Input relayed from the header. Kept as a fallback for a grouped view rendered WITHOUT the\n standard header (spa-groups can be used directly), so filtering is never simply lost. -->\n<div class=\"groups-filter\" *ngIf=\"showOwnFilter\">\n <mat-form-field appearance=\"outline\" class=\"filter-field\">\n <mat-icon matPrefix>search</mat-icon>\n <input matInput placeholder=\"Filter\" [(ngModel)]=\"filterText\" (ngModelChange)=\"applyFilter()\">\n <button *ngIf=\"filterText\" mat-icon-button matSuffix (click)=\"filterText = ''; applyFilter()\">\n <mat-icon>close</mat-icon>\n </button>\n </mat-form-field>\n</div>\n\n<div class=\"groups-container\" cdkDropListGroup>\n\n <mat-card *ngFor=\"let group of groupedData\" class=\"group-card\">\n\n <div class=\"group-header\">\n <div class=\"header-left\">\n <mat-icon *ngIf=\"group.icon\" [style.color]=\"group.color\" class=\"group-icon\">{{group.icon}}</mat-icon>\n <!-- Changed: added the native title attribute. On a phone the title now truncates with an ellipsis rather\n than wrapping to a second line, so the full name has to stay reachable somewhere \u2014 this is the\n zero-cost way to keep it. No tooltip directive, so nothing changes on touch. -->\n <label class=\"group-title\" [title]=\"group.displayName\">{{group.displayName}}</label>\n <label *ngIf=\"config.groupConfig.showGroupCount !== false\" class=\"group-count\">({{group.items.length}})</label>\n </div>\n\n <div class=\"header-right\">\n <button\n *ngFor=\"let button of getVisibleHeaderButtons(group)\"\n mat-icon-button\n [matTooltip]=\"getHeaderButtonTooltip(button)\"\n matTooltipPosition=\"above\"\n [style.color]=\"getHeaderButtonColor(button, group)\"\n [disabled]=\"isHeaderButtonDisabled(button, group)\"\n (click)=\"headerButtonClicked(button, group)\">\n <mat-icon>{{getHeaderButtonIcon(button)}}</mat-icon>\n </button>\n </div>\n </div>\n\n <hr class=\"group-divider\" />\n\n <!-- Empty state: show when no items and drag is disabled -->\n <div *ngIf=\"group.items.length === 0 && !config.groupConfig.dragEnabled\" class=\"empty-state\">\n <label>{{config.groupConfig.emptyGroupMessage ?? 'Empty'}}</label>\n </div>\n\n <!-- Changed: Replaced mat-chip-set with flex container of mat-stroked-button for cleaner button style -->\n <div\n *ngIf=\"group.items.length > 0 || config.groupConfig.dragEnabled\"\n cdkDropList\n [cdkDropListData]=\"group.items\"\n [cdkDropListDisabled]=\"!config.groupConfig.dragEnabled\"\n (cdkDropListDropped)=\"onDrop($event)\"\n (cdkDropListEntered)=\"onDropListEntered($event)\"\n (cdkDropListExited)=\"onDropListExited($event)\"\n [class.drop-highlight]=\"group === highlightedGroup\"\n class=\"drop-list items-container\">\n\n <!-- Empty state inside drop list when drag is enabled -->\n <div *ngIf=\"group.items.length === 0\" class=\"empty-state drop-empty\">\n <label>{{config.groupConfig.emptyGroupMessage ?? 'Empty'}}</label>\n </div>\n\n <button\n mat-stroked-button\n *ngFor=\"let item of group.items\"\n cdkDrag [cdkDragData]=\"item\"\n [cdkDragDisabled]=\"!config.groupConfig.dragEnabled\"\n [matMenuTriggerFor]=\"config.groupConfig.contextMenuEnabled !== false ? itemMenu : null\"\n [matMenuTriggerData]=\"{item: item, group: group}\"\n class=\"item-button\">\n\n <mat-icon\n *ngIf=\"getItemIcon(item)\"\n [style.color]=\"getItemIconColor(item)\"\n [matTooltip]=\"getItemIconTip(item)\"\n matTooltipPosition=\"above\"\n class=\"item-icon\">\n {{getItemIcon(item)}}\n </mat-icon>\n\n <!-- Changed: the item text moved into its own span so a runaway name can be capped and ellipsised without\n also squeezing the icons, which share this projection slot with it. The native title attribute keeps the\n full name recoverable on a truncated chip \u2014 same treatment already used on the group title above, and no\n tooltip directive, so touch behaviour is unchanged. -->\n <span class=\"item-label\" [title]=\"getItemText(item)\">{{getItemText(item)}}</span>\n\n <ng-container *ngFor=\"let additionalIcon of getVisibleAdditionalIcons(item)\">\n <mat-icon\n [style.color]=\"additionalIcon.color\"\n [matTooltip]=\"getIconTooltip(additionalIcon, item)\"\n matTooltipPosition=\"above\"\n class=\"item-additional-icon\">\n {{additionalIcon.name}}\n </mat-icon>\n </ng-container>\n\n </button>\n </div>\n\n </mat-card>\n\n</div>\n\n<mat-menu #itemMenu=\"matMenu\">\n <ng-template matMenuContent let-item=\"item\" let-group=\"group\">\n <button\n *ngFor=\"let button of getVisibleButtons(item)\"\n mat-menu-item\n [disabled]=\"isButtonDisabled(button, item)\"\n (click)=\"itemActionClicked(button.name, item, group)\">\n <mat-icon [style.color]=\"getButtonIconColor(button, item)\">{{getButtonIcon(button)}}</mat-icon>\n <span>{{button.display ?? button.tip ?? (button.name | titlecase)}}</span>\n </button>\n </ng-template>\n</mat-menu>\n", styles: [".groups-filter{display:flex;justify-content:flex-end;margin-bottom:8px;padding-right:10px}.filter-field{width:300px;font-size:13px}.filter-field ::ng-deep .mat-mdc-form-field-infix{padding-top:8px!important;padding-bottom:8px!important;min-height:36px}.groups-container{display:flex;flex-direction:column;gap:20px;padding-right:10px;margin-left:0}.group-card{width:100%;margin-bottom:0;padding:8px 16px 16px}.group-header{display:flex;justify-content:space-between;align-items:center;padding:0;min-height:32px}.header-left{display:flex;align-items:center;gap:5px}.header-right{display:flex;align-items:center;gap:4px}.group-icon{margin-right:5px;font-size:24px;width:24px;height:24px;line-height:24px}.group-title{font-size:24px;font-weight:300;line-height:1.2}.group-count{font-size:12px;font-weight:300;margin-left:5px;line-height:1.2}.group-divider{margin-top:8px;margin-bottom:10px}.empty-state{display:flex;justify-content:center;align-items:center;padding:10px}.empty-state label{color:#757575;font-style:italic}.items-container{display:flex;flex-wrap:wrap;gap:10px;padding:8px 0;align-items:center}.item-button{cursor:pointer;font-weight:400;letter-spacing:.01em;transition:box-shadow .2s ease,background-color .2s ease}.item-button:hover{box-shadow:0 1px 4px #00000026;background-color:#00000005}.item-label{display:inline-block;max-width:260px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:middle}.item-icon{font-size:18px;width:18px;height:18px;margin-right:4px;vertical-align:middle}.item-additional-icon{font-size:16px;width:16px;height:16px;margin-left:6px;vertical-align:middle;opacity:.85}button[mat-icon-button]{width:32px;height:32px}button[mat-icon-button] mat-icon{font-size:18px;margin-top:-3px}.header-right button[mat-icon-button]{display:inline-flex;align-items:center;justify-content:center;padding:0;line-height:normal}.header-right button[mat-icon-button] mat-icon{margin:0;font-size:20px;width:20px;height:20px;line-height:20px;display:block}.header-right button[mat-icon-button] .mat-mdc-button-touch-target{display:none}@media (max-width: 700px){.groups-container{gap:12px;padding-right:0}.group-card{padding:8px 10px 10px}.header-left{flex:1 1 auto;min-width:0;gap:4px}.group-icon{flex:0 0 auto;margin-right:2px;font-size:20px;width:20px;height:20px;line-height:20px}.group-title{font-size:16px;font-weight:400;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.group-count{flex:0 0 auto;margin-left:2px;font-size:11px}.header-right{flex:0 0 auto;gap:0}.header-right button[mat-icon-button] mat-icon{font-size:18px;width:18px;height:18px;line-height:18px}.group-header{min-height:28px}.group-divider{margin-top:6px;margin-bottom:6px}.items-container{gap:6px;padding:4px 0}.item-button{min-width:0;height:32px;padding:0 9px;font-size:12.5px;letter-spacing:normal;border-radius:16px;border-color:#0000001f;--mdc-outlined-button-container-height: 32px;--mdc-outlined-button-container-shape: 16px;--mdc-outlined-button-label-text-size: 12.5px}.item-button ::ng-deep .mdc-button__label{letter-spacing:normal;display:inline-flex;align-items:center}.item-button ::ng-deep .mat-mdc-button-touch-target{height:100%}.item-label{max-width:104px}.item-icon{font-size:14px;width:14px;height:14px;margin-right:3px}.item-additional-icon{font-size:14px;width:14px;height:14px;margin-left:3px}.drop-list{min-height:32px}.empty-state{padding:4px 10px}}.drop-list{min-height:40px}.drop-empty{padding:5px 10px}.cdk-drag-preview{box-shadow:0 5px 5px -3px #0003,0 8px 10px 1px #00000024,0 3px 14px 2px #0000001f;opacity:.9;border-radius:4px}.cdk-drag-animating{transition:transform .25s cubic-bezier(0,0,.2,1)}.drop-highlight{border:2px dashed #90caf9;border-radius:8px;background:#90caf90d}.cdk-drop-list-dragging .item-button:not(.cdk-drag-placeholder){transition:transform .25s cubic-bezier(0,0,.2,1)}\n"] }]
|
|
16292
17099
|
}], ctorParameters: () => [{ type: ConditionService }, { type: ButtonService }, { type: DataServiceLib }, { type: i2$1.MatSnackBar }, { type: ApiErrorService }], propDecorators: { config: [{
|
|
16293
17100
|
type: Input
|
|
16294
17101
|
}], dataSource: [{
|
|
@@ -16670,6 +17477,25 @@ class TableComponent {
|
|
|
16670
17477
|
startInlineEdit(row) {
|
|
16671
17478
|
this.editingRow = row;
|
|
16672
17479
|
this.editingModel = { ...row }; // shallow copy — scalars only change, cancel restores by discarding
|
|
17480
|
+
this.seedInlineDefaults(row); // Added: a field the row does not carry would otherwise open blank
|
|
17481
|
+
}
|
|
17482
|
+
// Added: an inline-editable field the row has no value for (a weaning date on a "ready to wean" row) opened
|
|
17483
|
+
// empty, so every operator retyped the same obvious answer. It now takes the field's OWN defaultValue, exactly
|
|
17484
|
+
// as the dialog form does via Core.getInitialValue — including defaultValue:'now' for dates. defaultValue may
|
|
17485
|
+
// additionally be a FUNCTION OF THE ROW here (count -> row.alive), which the dialog path has no need of because
|
|
17486
|
+
// a create dialog has no row to read. Only null/undefined values are seeded, so real row data is never replaced.
|
|
17487
|
+
seedInlineDefaults(row) {
|
|
17488
|
+
if (!this.config.inlineEdit || !this.config.columns)
|
|
17489
|
+
return;
|
|
17490
|
+
for (const column of this.config.columns) {
|
|
17491
|
+
const field = this.getInlineField(column);
|
|
17492
|
+
if (!field || field.defaultValue === undefined || field.defaultValue === null)
|
|
17493
|
+
continue;
|
|
17494
|
+
const current = this.editingModel[field.name];
|
|
17495
|
+
if (current !== undefined && current !== null && current !== '')
|
|
17496
|
+
continue;
|
|
17497
|
+
this.editingModel[field.name] = typeof field.defaultValue === 'function' ? field.defaultValue(row) : Core.getInitialValue(field);
|
|
17498
|
+
}
|
|
16673
17499
|
}
|
|
16674
17500
|
cancelInlineEdit() {
|
|
16675
17501
|
this.editingRow = null;
|
|
@@ -17611,11 +18437,11 @@ class TableComponent {
|
|
|
17611
18437
|
this.setPaginator();
|
|
17612
18438
|
}
|
|
17613
18439
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: TableComponent, deps: [{ token: DataServiceLib }, { token: MessageService }, { token: i1$4.BreakpointObserver }, { token: i4.MatDialog }, { token: ButtonService }, { token: DialogService }, { token: TableConfigService }, { token: ConditionService }, { token: AuthService }, { token: SignalRService }, { token: OfflineService }, { token: ApiErrorService }, { token: TIN_SPA_RUNTIME_CONFIG, optional: true }], target: i0.ɵɵFactoryTarget.Component }); }
|
|
17614
|
-
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: TableComponent, isStandalone: false, selector: "spa-table", inputs: { data: "data", tileData: "tileData", config: "config", localMode: "localMode", parentDetails: "parentDetails", reload: "reload", activeTab: "activeTab", inTab: "inTab", nestingLevel: "nestingLevel" }, outputs: { dataLoad: "dataLoad", totalChange: "totalChange", actionSuccess: "actionSuccess", refreshClick: "refreshClick", searchClick: "searchClick", createClick: "createClick", actionClick: "actionClick", inputChange: "inputChange", actionResponse: "actionResponse" }, viewQueries: [{ propertyName: "tablePaginator", first: true, predicate: ["tablePaginator"], descendants: true }], usesOnChanges: true, ngImport: i0, template: "\n<ng-container *ngIf=\"hasFormAccess && !sectionHidden\"> <!-- Changed: sectionConfig.hideWhenEmpty hides the whole table -->\n\n <!-- Added: collapsible flat section header (sectionConfig) \u2014 1px border, no elevation, whole row toggles -->\n <div class=\"tbl-section-header\" *ngIf=\"config.sectionConfig\" (click)=\"toggleSection()\" [attr.aria-expanded]=\"!sectionCollapsed\" [class.tbl-section-static]=\"config.sectionConfig.collapsible === false\" role=\"button\" tabindex=\"0\" (keydown.enter)=\"toggleSection()\">\n <mat-icon class=\"tbl-section-icon\" *ngIf=\"config.sectionConfig.icon\">{{ config.sectionConfig.icon }}</mat-icon>\n <span class=\"tbl-section-title\">{{ config.sectionConfig.title }}</span>\n <span class=\"tbl-section-count\" *ngIf=\"config.sectionConfig.showCount !== false\">{{ dataSource?.length || 0 }}</span>\n <span class=\"tbl-section-chip\" *ngFor=\"let chip of sectionChips()\" [style.color]=\"chip.color\">{{ chip.text }}</span>\n <span class=\"tbl-section-spacer\"></span>\n <!-- Changed: the label is wrapped so a NARROW screen can drop it and leave an icon-only button. Only a\n button that HAS an icon loses its text \u2014 otherwise it would collapse to a blank square. -->\n <button mat-stroked-button color=\"primary\" *ngFor=\"let btn of sectionButtons()\" (click)=\"sectionButtonClicked(btn, $event)\" [matTooltip]=\"btn.display || btn.name\"><mat-icon *ngIf=\"btn.icon?.name\">{{ btn.icon.name }}</mat-icon><span class=\"tbl-section-btn-text\" [class.has-icon]=\"!!btn.icon?.name\">{{ btn.display || btn.name }}</span></button>\n <mat-icon class=\"tbl-section-chevron\" *ngIf=\"config.sectionConfig.collapsible !== false\">{{ sectionCollapsed ? 'expand_more' : 'expand_less' }}</mat-icon>\n </div>\n\n <!-- Added: the section's \"why this list exists\" line. It sits UNDER the title (a reason only makes sense once\n the list has been named) and only while the section is open \u2014 a shut section already has its own summary\n line, and a paragraph over the top of that is noise. -->\n <p class=\"tbl-section-caption\" *ngIf=\"config.sectionConfig?.caption && !sectionCollapsed\">{{ config.sectionConfig.caption }}</p>\n\n <!-- Added: collapsed section affordance \u2014 mirrors the Day Book \"Show the N\" pattern -->\n <div class=\"tbl-section-more\" *ngIf=\"config.sectionConfig && sectionCollapsed && (dataSource?.length || 0) > 0\">\n <button type=\"button\" class=\"tbl-section-link\" (click)=\"toggleSection()\">Show the {{ dataSource.length }}</button>\n </div>\n\n <ng-container *ngIf=\"!config.sectionConfig || !sectionCollapsed\"> <!-- Added: section collapse hides the table body -->\n\n <!-- Search -->\n <spa-search\n *ngIf=\"config.searchConfig\" [config]=\"config.searchConfig\" [smallScreen]=\"smallScreen\" [tableDataSource]=\"tableDataSource\" style=\"margin-bottom: 20px;\" (searchClick)=\"searchClicked($event)\">\n </spa-search>\n\n <!-- Header -->\n <app-table-header\n [config]=\"config\" [data]=\"dataSource\" [tableDataSource]=\"tableDataSource\" [tileConfig]=\"config.tileConfig\" [tileData]=\"tileData\" [tileReload]=\"tileReload\" [lastSearch]=\"lastSearch\" [smallScreen]=\"smallScreen\"\n [showFilterButton]=\"showFilterButton\" [isRealTime]=\"config.realTime\" [isConnected]=\"isSignalRConnected\" [refreshing]=\"loadingStage === 'refresh'\"\n (createClick)=\"newModel()\" (customClick)=\"customModel($event,null)\"\n (refreshClick)=\"refreshClicked()\" (tileClick)=\"tileClicked($event)\" (tileUnClick)=\"tileUnClicked($event)\" (filterChange)=\"filterChanged($event)\">\n </app-table-header>\n\n <!-- Added (Quiet Loading D4): refresh with data already on screen \u2014 a 2px line flush under the header and the\n spinning refresh icon are the ONLY signals. Rows stay live, clickable and un-dimmed while they swap. -->\n <div *ngIf=\"loadingStage === 'refresh'\" class=\"tin-load-line\" aria-hidden=\"true\">\n <span class=\"tin-load-line-fill\" [style.width.%]=\"progressPercent\"></span>\n </div>\n\n <!-- Added: paged-mode filter hint \u2014 the client filter only covers rows loaded so far -->\n <div *ngIf=\"pagedMode && filterActive && loadedRows.length < serverTotal\" class=\"paged-filter-hint\">\n <mat-icon>info</mat-icon>\n <span>Filtering only the {{loadedRows.length}} loaded rows of {{serverTotal}}. {{ config.searchConfig ? 'Use Search for complete results.' : 'Refine with search for complete results.' }}</span>\n </div>\n\n\n <!-- Table -->\n <div *ngIf=\"!config.viewType || config?.viewType === 'table'\">\n\n <p *ngIf=\"!config\"><em>Configure Table</em></p>\n <p *ngIf=\"!dataSource && !loadingStage\"><em>Loading...</em></p> <!-- Changed (Quiet Loading): the bare text is replaced by the stage below while a quiet load is on screen -->\n\n <!-- Added (Quiet Loading D3): first load, nothing on screen yet. The progress module is the hero (eased bar +\n counting percentage + caption) and the ghost rows hold the exact space the real rows will fill, so the\n table does not jump when data lands. Only ever rendered when quiet loading is on. -->\n <div *ngIf=\"loadingStage === 'initial'\" class=\"tin-load-stage\" role=\"status\" aria-busy=\"true\">\n\n <div class=\"tin-load-progress\">\n <div class=\"tin-load-bar\">\n <span class=\"tin-load-bar-fill\" [style.width.%]=\"progressPercent\"></span>\n </div>\n <span class=\"tin-load-percent\" aria-hidden=\"true\">{{progressDisplay}}%</span>\n </div>\n\n <div class=\"tin-load-caption\">Loading {{stageEntityName}}\u2026</div>\n\n <div class=\"tin-load-ghosts\">\n <div class=\"tin-load-ghost-row\" *ngFor=\"let r of ghostRows\">\n <div class=\"tin-load-ghost-cell\" *ngFor=\"let c of ghostColumns; let i = index\">\n <span class=\"tin-skel\" [style.width.%]=\"ghostWidth(i, c)\" [style.animation-delay.ms]=\"r * 120\"></span> <!-- staggered sweep: each row starts 120ms after the one above -->\n </div>\n </div>\n </div>\n\n </div>\n\n <div *ngIf=\"dataSource && loadingStage !== 'initial' && (!smallScreen || (smallScreen && dataSource?.length > 0))\" [class.tin-load-in]=\"effQuietLoading\"> <!-- Changed (Quiet Loading): the empty header-only table is suppressed while the initial stage stands in for it, and the real rows fade in where the ghosts were (D3) -->\n\n <table mat-table [dataSource]=\"tableDataSource\" [trackBy]=\"trackByRow\" [ngClass]=\"elevation\" [class.tin-no-col-headers]=\"config.hideColumnHeaders\"> <!-- Changed: optional column-header suppression -->\n\n <ng-container *ngFor=\"let column of config.columns\" [matColumnDef]=\"column.name\">\n <th mat-header-cell *matHeaderCellDef >{{ column.alias ?? column.name | camelToWords }}</th>\n <td mat-cell *matCellDef=\"let row;\" class=\"right-padding\" >\n\n <!-- Added: inline edit \u2014 editable cells swap to their form-field editor while the row is in edit mode -->\n <app-inline-cell *ngIf=\"isRowEditing(row) && getInlineField(column); else displayCell\" [field]=\"getInlineField(column)\" [data]=\"editingModel\"></app-inline-cell>\n\n <!-- Rows -->\n <ng-template #displayCell>\n <app-table-row [column]=\"column\" [row]=\"row\" [config]=\"config\" [smallScreen]=\"smallScreen\"\n (actionClick)=\"actionClicked(column.name, row)\" (columnClick)=\"columnClicked(column, row)\" (showBannerEvent)=\"showBanner($event)\">\n </app-table-row>\n </ng-template>\n\n </td>\n </ng-container>\n\n <ng-container matColumnDef=\"action\">\n <th mat-header-cell *matHeaderCellDef> Action </th>\n <td mat-cell *matCellDef=\"let row\" [ngStyle]=\"{width:false ? '20px' : actionsWidth}\">\n <div class=\"action-buttons-container\">\n\n <!-- Added: inline edit \u2014 while a row edits in place, its actions collapse to submit/cancel -->\n <ng-container *ngIf=\"isRowEditing(row); else rowActions\">\n <button mat-icon-button matTooltip=\"Save\" matTooltipPosition=\"above\" (click)=\"submitInlineEdit()\"><mat-icon class=\"inline-save\">check</mat-icon></button> <!-- Changed: dropped color=\"primary\" \u2014 the icon now carries a green save cue -->\n <button mat-icon-button matTooltip=\"Cancel\" matTooltipPosition=\"above\" (click)=\"cancelInlineEdit()\"><mat-icon class=\"inline-cancel\">close</mat-icon></button> <!-- Changed: red cancel cue -->\n </ng-container>\n\n <!-- Actions -->\n <ng-template #rowActions>\n <app-table-action\n [displayedButtons]=\"displayedButtons\" [config]=\"config\" [smallScreen]=\"smallScreen\" [row]=\"row\" (actionClick)=\"actionClicked($event.name, $event.row)\">\n </app-table-action>\n </ng-template>\n\n </div>\n </td>\n </ng-container>\n\n\n <tr mat-header-row *matHeaderRowDef=\"displayedColumns\"></tr>\n <tr mat-row *matRowDef=\"let row; columns: displayedColumns;\" [ngClass]=\"{'make-gray': (config.greyOut && config.greyOut(row)) || row.pendingApproval, 'row-editing': isRowEditing(row)}\"></tr> <!-- Changed: row-editing flags the row that is open for inline edit -->\n </table>\n\n </div>\n\n <!-- Changed: Removed *ngIf condition to keep paginator always in DOM and maintain ViewChild reference -->\n <!-- Changed: Added CSS class binding to hide when no data instead of conditional rendering -->\n <!-- Changed: Legacy paginator only renders in non-paged mode (pagedMode is constant per instance, set before first render) -->\n <mat-paginator *ngIf=\"!pagedMode\"\n #tablePaginator\n [pageSizeOptions]=\"config.pageSizes ?? [10, 20, 50]\"\n [ngClass]=\"{'paginator-hidden': !dataSource || (smallScreen && dataSource?.length === 0) || loadingStage === 'initial'}\"\n showFirstLastButtons>\n </mat-paginator>\n\n <!-- Added: manual paginator for server-side paged mode \u2014 fully state-bound, never attached to MatTableDataSource. Always visible: when filtering it pages the in-memory filtered subset (length = filtered count); otherwise the server window (length = true total). No first/last jump (would force fetching the whole gap). -->\n <mat-paginator *ngIf=\"pagedMode\"\n [length]=\"filterActive ? filteredRows.length : serverTotal + overlayDelta\"\n [pageIndex]=\"pageIndex\"\n [pageSize]=\"pageSize\"\n [pageSizeOptions]=\"config.pageSizes ?? [10, 20, 50]\"\n [ngClass]=\"{'paginator-hidden': !dataSource || (smallScreen && dataSource?.length === 0) || loadingStage === 'initial'}\"\n (page)=\"onServerPage($event)\">\n </mat-paginator>\n\n </div>\n \n <!-- Added (Quiet Loading): the initial-load stage for the NON-table views.\n Without this, card/capsule/grouped showed literally nothing during a first load \u2014 the view component\n renders an empty dataSource and the \"No Data\" line is suppressed while the stage owns the space.\n The progress header is identical to the table's so the two feel like one feature; only the ghost\n furniture differs, because column-shaped rows are wrong in a card grid. -->\n <div *ngIf=\"loadingStage === 'initial' && config?.viewType && config?.viewType !== 'table'\" class=\"tin-load-stage\" role=\"status\" aria-busy=\"true\">\n <div class=\"tin-load-progress\">\n <div class=\"tin-load-bar\">\n <span class=\"tin-load-bar-fill\" [style.width.%]=\"progressPercent\"></span>\n </div>\n <span class=\"tin-load-percent\" aria-hidden=\"true\">{{progressDisplay}}%</span>\n </div>\n <div class=\"tin-load-caption\">Loading {{stageEntityName}}\u2026</div>\n\n <div *ngIf=\"config?.viewType === 'capsule'\" class=\"tin-load-ghost-capsules\" aria-hidden=\"true\">\n <span class=\"tin-skel tin-load-ghost-capsule\" *ngFor=\"let c of ghostCapsules\"></span>\n </div>\n\n <div *ngIf=\"config?.viewType === 'card'\" class=\"tin-load-ghost-cards\" aria-hidden=\"true\">\n <div class=\"tin-load-ghost-card\" *ngFor=\"let c of ghostCards\">\n <span class=\"tin-skel tin-skel-text tin-load-ghost-card-title\"></span>\n <span class=\"tin-skel tin-skel-text\"></span>\n <span class=\"tin-skel tin-skel-text tin-load-ghost-card-short\"></span>\n </div>\n </div>\n\n <div *ngIf=\"config?.viewType === 'grouped'\" class=\"tin-load-ghost-groups\" aria-hidden=\"true\">\n <div class=\"tin-load-ghost-group\" *ngFor=\"let g of ghostGroups\">\n <span class=\"tin-skel tin-load-ghost-group-head\"></span>\n <!-- Pills, not rows: a group card's body is a wrap of chips, so full-width bars promised a table\n and the stage did not resemble what replaced it. -->\n <div class=\"tin-load-ghost-group-items\">\n <span class=\"tin-skel tin-load-ghost-capsule\" *ngFor=\"let r of ghostCards\"></span>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Capsules -->\n <spa-capsules *ngIf=\"config?.viewType === 'capsule' && loadingStage !== 'initial'\"\n [config]=\"config\"\n [dataSource]=\"dataSource\"\n [displayedButtons]=\"displayedButtons\"\n (actionClick)=\"actionClicked($event.name, $event.row)\">\n </spa-capsules>\n\n\n <!-- Cards -->\n <spa-cards *ngIf=\"config?.viewType === 'card' && loadingStage !== 'initial'\"\n [config]=\"config\"\n [dataSource]=\"dataSource\"\n [displayedButtons]=\"displayedButtons\"\n [smallScreen]=\"smallScreen\"\n (actionClick)=\"actionClicked($event.name, $event.row)\"\n (columnClick)=\"columnClicked($event.column, $event.row)\"\n (showBannerEvent)=\"showBanner($event)\">\n </spa-cards>\n\n <!-- Groups - Added: New grouped view type -->\n <!-- Changed: the grouped view no longer renders its own filter field. It used to sit on a row of its own\n beneath the buttons row, which left both rows half empty and, more importantly, was a SECOND filter with\n no refresh button. The standard header filter (which has refresh, like every other table) now drives it,\n with its text relayed in through filterText. -->\n <spa-groups *ngIf=\"config?.viewType === 'grouped' && loadingStage !== 'initial'\"\n [config]=\"config\"\n [dataSource]=\"dataSource\"\n [displayedButtons]=\"displayedButtons\"\n [showOwnFilter]=\"false\"\n [filterText]=\"groupFilterText\"\n (actionClick)=\"actionClicked($event.name, $event.row, $event.group, $event.button)\">\n </spa-groups>\n\n\n <div class=\"tin-center\">\n <p *ngIf=\"dataSource?.length == 0 && loadingStage !== 'initial'\"><em>{{config.noDataMessage ?? 'No Data'}}</em></p> <!-- Changed (Quiet Loading): the stage owns the space until it completes, then hands straight over to this message \u2014 no \"No Data\" flashing underneath the ghost rows -->\n </div>\n\n </ng-container> <!-- Added: end section-collapse wrapper -->\n\n</ng-container>\n\n\n<ng-container *ngIf=\"!hasFormAccess\">\n <div class=\"tin-center\">\n <p><em>Access Restricted</em></p>\n </div>\n</ng-container>\n\n", styles: [".top{display:flex;flex-direction:row;flex-wrap:wrap;justify-content:space-between;align-items:center;margin-bottom:10px;margin-top:10px}.mat-mini-fab{width:32px;height:32px}.mat-mini-fab mat-icon{font-size:16px;margin-top:-3px}.mat-icon-button{width:32px;height:32px}.mat-icon-button mat-icon{font-size:20px;margin-top:-7px}.col-icon{margin-left:10px}.title{margin-top:10px;font-size:larger;font-weight:300}.make-gray{background-color:#f5f5f5}tr.mat-mdc-row{transition:background-color .15s ease}tr.mat-mdc-row:hover{background-color:#2196f305}tr.mat-mdc-row.row-editing,tr.mat-mdc-row.row-editing:hover{background-color:#ffa00014}.inline-save{color:#2e7d32!important}.inline-cancel{color:#c62828!important}.right-padding{padding-right:10px}.action-buttons-container{display:flex;justify-content:flex-end;align-items:center}.refreshIcon{font-size:22px!important;margin-top:-7px!important}.dialog-container{display:flex;flex-direction:column;height:100%;max-height:100%;min-height:0}.dialog-content{flex:1;overflow:hidden;display:flex;flex-direction:column;min-height:0}.dialog-scroll-content{flex:1;overflow-y:auto;max-height:none!important;display:flex;flex-direction:column;min-height:0}.dialog-container:not(.dialog-has-tables){height:auto}.dialog-container:not(.dialog-has-tables) .dialog-scroll-content{flex:1 1 auto}.dialog-scroll-content>.tin-input{flex:1;display:flex;flex-direction:column}.dialog-scroll-content>.tin-input>spa-tabs{flex:1;display:flex;flex-direction:column}mat-dialog-actions{flex-shrink:0;justify-content:flex-start}.paginator-hidden{display:none!important}.paged-filter-hint{display:flex;align-items:center;gap:8px;margin:4px 0 8px;padding:6px 12px;border-radius:4px;background-color:#fff8e1;color:#795548;font-size:13px}.paged-filter-hint mat-icon{font-size:18px;width:18px;height:18px;color:#ffa000}.dlg-header-classic{padding-left:24px;padding-right:24px}.dlg-title-classic{font-size:20px;font-weight:500;margin-top:10px;margin-bottom:5px}.dialog-header-titles{display:flex;flex-direction:column;justify-content:center;min-width:0}.tin-dlg-head .dialog-header-titles label{margin:0}.tbl-section-header{display:flex;align-items:center;gap:10px;padding:10px 12px;margin-bottom:8px;border:1px solid rgba(0,0,0,.08);border-radius:10px;background:transparent;cursor:pointer;transition:border-color .15s}.tbl-section-header:hover{border-color:#90a4ae}.tbl-section-header.tbl-section-static{cursor:default}.tbl-section-icon{color:#546e7a}.tbl-section-title{font-size:14px;font-weight:600;color:#000000d1}.tbl-section-count{background:#e3f2fd;color:#1565c0;border-radius:12px;padding:2px 10px;font-size:12px}.tbl-section-chip{font-size:12px;font-weight:500;color:#0009}.tbl-section-spacer{margin-left:auto}.tbl-section-chevron{color:#90a4ae}.tbl-section-caption{margin:-4px 12px 10px;font-size:12.5px;line-height:1.45;color:#0000008c;max-width:82ch}.tbl-section-more{padding:0 12px 8px}.tbl-section-link{background:none;border:none;color:#1565c0;cursor:pointer;font-size:13px;padding:0}.tbl-section-link:hover{text-decoration:underline}.tbl-section-icon,.tbl-section-chevron,.tbl-section-count{flex:0 0 auto}.tbl-section-title{flex:1 1 auto;min-width:0}@media (max-width: 700px){.tbl-section-header{gap:8px;padding:10px}.tbl-section-chip,.tbl-section-btn-text.has-icon{display:none}.tbl-section-header button{min-width:0;padding:0 10px}}table.tin-no-col-headers tr.mat-mdc-header-row{display:none}\n"], dependencies: [{ kind: "directive", type: i1$2.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: i1$2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1$2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i1$2.NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }, { kind: "component", type: i14.MatTable, selector: "mat-table, table[mat-table]", exportAs: ["matTable"] }, { kind: "directive", type: i14.MatHeaderCellDef, selector: "[matHeaderCellDef]" }, { kind: "directive", type: i14.MatHeaderRowDef, selector: "[matHeaderRowDef]", inputs: ["matHeaderRowDef", "matHeaderRowDefSticky"] }, { kind: "directive", type: i14.MatColumnDef, selector: "[matColumnDef]", inputs: ["matColumnDef"] }, { kind: "directive", type: i14.MatCellDef, selector: "[matCellDef]" }, { kind: "directive", type: i14.MatRowDef, selector: "[matRowDef]", inputs: ["matRowDefColumns", "matRowDefWhen"] }, { kind: "directive", type: i14.MatHeaderCell, selector: "mat-header-cell, th[mat-header-cell]" }, { kind: "directive", type: i14.MatCell, selector: "mat-cell, td[mat-cell]" }, { kind: "component", type: i14.MatHeaderRow, selector: "mat-header-row, tr[mat-header-row]", exportAs: ["matHeaderRow"] }, { kind: "component", type: i14.MatRow, selector: "mat-row, tr[mat-row]", exportAs: ["matRow"] }, { kind: "component", type: i15$1.MatPaginator, selector: "mat-paginator", inputs: ["color", "pageIndex", "length", "pageSize", "pageSizeOptions", "hidePageSize", "showFirstLastButtons", "selectConfig", "disabled"], outputs: ["page"], exportAs: ["matPaginator"] }, { kind: "component", type: i3.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i3.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i4$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "directive", type: i7.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: SearchComponent, selector: "spa-search", inputs: ["config", "smallScreen", "tableDataSource"], outputs: ["searchClick"] }, { kind: "component", type: TableHeaderComponent, selector: "app-table-header", inputs: ["lastSearch", "config", "hideTitle", "tableDataSource", "tileConfig", "smallScreen", "tileReload", "showFilterButton", "data", "tileData", "isRealTime", "isConnected", "refreshing"], outputs: ["createClick", "customClick", "refreshClick", "tileClick", "tileUnClick", "filterChange"] }, { kind: "component", type: TableRowComponent, selector: "app-table-row", inputs: ["column", "row", "config", "smallScreen"], outputs: ["actionClick", "columnClick", "showBannerEvent"] }, { kind: "component", type: TableActionComponent, selector: "app-table-action", inputs: ["displayedButtons", "config", "row", "smallScreen"], outputs: ["actionClick"] }, { kind: "component", type: InlineCellComponent, selector: "app-inline-cell", inputs: ["field", "data"], outputs: ["valueChange"] }, { kind: "component", type: CapsulesComponent, selector: "spa-capsules", inputs: ["config", "dataSource", "displayedButtons"], outputs: ["actionClick"] }, { kind: "component", type: CardsComponent, selector: "spa-cards", inputs: ["config", "dataSource", "displayedButtons", "smallScreen"], outputs: ["actionClick", "columnClick", "showBannerEvent"] }, { kind: "component", type: GroupsComponent, selector: "spa-groups", inputs: ["config", "dataSource", "displayedButtons", "showOwnFilter", "filterText"], outputs: ["actionClick"] }, { kind: "pipe", type: CamelToWordsPipe, name: "camelToWords" }] }); }
|
|
18440
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: TableComponent, isStandalone: false, selector: "spa-table", inputs: { data: "data", tileData: "tileData", config: "config", localMode: "localMode", parentDetails: "parentDetails", reload: "reload", activeTab: "activeTab", inTab: "inTab", nestingLevel: "nestingLevel" }, outputs: { dataLoad: "dataLoad", totalChange: "totalChange", actionSuccess: "actionSuccess", refreshClick: "refreshClick", searchClick: "searchClick", createClick: "createClick", actionClick: "actionClick", inputChange: "inputChange", actionResponse: "actionResponse" }, viewQueries: [{ propertyName: "tablePaginator", first: true, predicate: ["tablePaginator"], descendants: true }], usesOnChanges: true, ngImport: i0, template: "\n<ng-container *ngIf=\"hasFormAccess && !sectionHidden\"> <!-- Changed: sectionConfig.hideWhenEmpty hides the whole table -->\n\n <!-- Added: collapsible flat section header (sectionConfig) \u2014 1px border, no elevation, whole row toggles -->\n <div class=\"tbl-section-header\" *ngIf=\"config.sectionConfig\" (click)=\"toggleSection()\" [attr.aria-expanded]=\"!sectionCollapsed\" [class.tbl-section-static]=\"config.sectionConfig.collapsible === false\" role=\"button\" tabindex=\"0\" (keydown.enter)=\"toggleSection()\">\n <mat-icon class=\"tbl-section-icon\" *ngIf=\"config.sectionConfig.icon\">{{ config.sectionConfig.icon }}</mat-icon>\n <span class=\"tbl-section-title\">{{ config.sectionConfig.title }}</span>\n <span class=\"tbl-section-count\" *ngIf=\"config.sectionConfig.showCount !== false\">{{ dataSource?.length || 0 }}</span>\n <span class=\"tbl-section-chip\" *ngFor=\"let chip of sectionChips()\" [style.color]=\"chip.color\">{{ chip.text }}</span>\n <span class=\"tbl-section-spacer\"></span>\n <!-- Changed: the label is wrapped so a NARROW screen can drop it and leave an icon-only button. Only a\n button that HAS an icon loses its text \u2014 otherwise it would collapse to a blank square. -->\n <button mat-stroked-button color=\"primary\" *ngFor=\"let btn of sectionButtons()\" (click)=\"sectionButtonClicked(btn, $event)\" [matTooltip]=\"btn.display || btn.name\"><mat-icon *ngIf=\"btn.icon?.name\">{{ btn.icon.name }}</mat-icon><span class=\"tbl-section-btn-text\" [class.has-icon]=\"!!btn.icon?.name\">{{ btn.display || btn.name }}</span></button>\n <mat-icon class=\"tbl-section-chevron\" *ngIf=\"config.sectionConfig.collapsible !== false\">{{ sectionCollapsed ? 'expand_more' : 'expand_less' }}</mat-icon>\n </div>\n\n <!-- Added: the section's \"why this list exists\" line. It sits UNDER the title (a reason only makes sense once\n the list has been named) and only while the section is open \u2014 a shut section already has its own summary\n line, and a paragraph over the top of that is noise. -->\n <p class=\"tbl-section-caption\" *ngIf=\"config.sectionConfig?.caption && !sectionCollapsed\">{{ config.sectionConfig.caption }}</p>\n\n <!-- Added: collapsed section affordance \u2014 mirrors the Day Book \"Show the N\" pattern -->\n <div class=\"tbl-section-more\" *ngIf=\"config.sectionConfig && sectionCollapsed && (dataSource?.length || 0) > 0\">\n <button type=\"button\" class=\"tbl-section-link\" (click)=\"toggleSection()\">Show the {{ dataSource.length }}</button>\n </div>\n\n <ng-container *ngIf=\"!config.sectionConfig || !sectionCollapsed\"> <!-- Added: section collapse hides the table body -->\n\n <!-- Search -->\n <spa-search\n *ngIf=\"config.searchConfig\" [config]=\"config.searchConfig\" [smallScreen]=\"smallScreen\" [tableDataSource]=\"tableDataSource\" style=\"margin-bottom: 20px;\" (searchClick)=\"searchClicked($event)\">\n </spa-search>\n\n <!-- Header -->\n <!-- Changed: [isRealTime] binds the RESOLVED value, not the raw config. It was `config.realTime`, so the live\n indicator only ever appeared on a table that set the flag ITSELF \u2014 a table relying on the app-wide\n `tableDefaults.realTime` was genuinely subscribed to SignalR (setupRealTimeSubscriptions and\n realTimeRefreshOrFallback both resolve through effRealTime) and simply never showed the dot. That is\n how \"Trips and Loads have no real time\" got reported: a status light disagreeing with the system it\n reports on. effRealTime also gets the inverse right \u2014 realTime:true under a global false still lights,\n and realTime:false under a global true stays dark. -->\n <app-table-header\n [config]=\"config\" [data]=\"dataSource\" [tableDataSource]=\"tableDataSource\" [tileConfig]=\"config.tileConfig\" [tileData]=\"tileData\" [tileReload]=\"tileReload\" [lastSearch]=\"lastSearch\" [smallScreen]=\"smallScreen\"\n [showFilterButton]=\"showFilterButton\" [isRealTime]=\"effRealTime\" [isConnected]=\"isSignalRConnected\" [refreshing]=\"loadingStage === 'refresh'\"\n (createClick)=\"newModel()\" (customClick)=\"customModel($event,null)\"\n (refreshClick)=\"refreshClicked()\" (tileClick)=\"tileClicked($event)\" (tileUnClick)=\"tileUnClicked($event)\" (filterChange)=\"filterChanged($event)\">\n </app-table-header>\n\n <!-- Added (Quiet Loading D4): refresh with data already on screen \u2014 a 2px line flush under the header and the\n spinning refresh icon are the ONLY signals. Rows stay live, clickable and un-dimmed while they swap. -->\n <div *ngIf=\"loadingStage === 'refresh'\" class=\"tin-load-line\" aria-hidden=\"true\">\n <span class=\"tin-load-line-fill\" [style.width.%]=\"progressPercent\"></span>\n </div>\n\n <!-- Added: paged-mode filter hint \u2014 the client filter only covers rows loaded so far -->\n <div *ngIf=\"pagedMode && filterActive && loadedRows.length < serverTotal\" class=\"paged-filter-hint\">\n <mat-icon>info</mat-icon>\n <span>Filtering only the {{loadedRows.length}} loaded rows of {{serverTotal}}. {{ config.searchConfig ? 'Use Search for complete results.' : 'Refine with search for complete results.' }}</span>\n </div>\n\n\n <!-- Table -->\n <div *ngIf=\"!config.viewType || config?.viewType === 'table'\">\n\n <p *ngIf=\"!config\"><em>Configure Table</em></p>\n <p *ngIf=\"!dataSource && !loadingStage\"><em>Loading...</em></p> <!-- Changed (Quiet Loading): the bare text is replaced by the stage below while a quiet load is on screen -->\n\n <!-- Added (Quiet Loading D3): first load, nothing on screen yet. The progress module is the hero (eased bar +\n counting percentage + caption) and the ghost rows hold the exact space the real rows will fill, so the\n table does not jump when data lands. Only ever rendered when quiet loading is on. -->\n <div *ngIf=\"loadingStage === 'initial'\" class=\"tin-load-stage\" role=\"status\" aria-busy=\"true\">\n\n <div class=\"tin-load-progress\">\n <div class=\"tin-load-bar\">\n <span class=\"tin-load-bar-fill\" [style.width.%]=\"progressPercent\"></span>\n </div>\n <span class=\"tin-load-percent\" aria-hidden=\"true\">{{progressDisplay}}%</span>\n </div>\n\n <div class=\"tin-load-caption\">Loading {{stageEntityName}}\u2026</div>\n\n <div class=\"tin-load-ghosts\">\n <div class=\"tin-load-ghost-row\" *ngFor=\"let r of ghostRows\">\n <div class=\"tin-load-ghost-cell\" *ngFor=\"let c of ghostColumns; let i = index\">\n <span class=\"tin-skel\" [style.width.%]=\"ghostWidth(i, c)\" [style.animation-delay.ms]=\"r * 120\"></span> <!-- staggered sweep: each row starts 120ms after the one above -->\n </div>\n </div>\n </div>\n\n </div>\n\n <div *ngIf=\"dataSource && loadingStage !== 'initial' && (!smallScreen || (smallScreen && dataSource?.length > 0))\" [class.tin-load-in]=\"effQuietLoading\"> <!-- Changed (Quiet Loading): the empty header-only table is suppressed while the initial stage stands in for it, and the real rows fade in where the ghosts were (D3) -->\n\n <table mat-table [dataSource]=\"tableDataSource\" [trackBy]=\"trackByRow\" [ngClass]=\"elevation\" [class.tin-no-col-headers]=\"config.hideColumnHeaders\"> <!-- Changed: optional column-header suppression -->\n\n <ng-container *ngFor=\"let column of config.columns\" [matColumnDef]=\"column.name\">\n <th mat-header-cell *matHeaderCellDef >{{ column.alias ?? column.name | camelToWords }}</th>\n <td mat-cell *matCellDef=\"let row;\" class=\"right-padding\" >\n\n <!-- Added: inline edit \u2014 editable cells swap to their form-field editor while the row is in edit mode -->\n <app-inline-cell *ngIf=\"isRowEditing(row) && getInlineField(column); else displayCell\" [field]=\"getInlineField(column)\" [data]=\"editingModel\"></app-inline-cell>\n\n <!-- Rows -->\n <ng-template #displayCell>\n <app-table-row [column]=\"column\" [row]=\"row\" [config]=\"config\" [smallScreen]=\"smallScreen\"\n (actionClick)=\"actionClicked(column.name, row)\" (columnClick)=\"columnClicked(column, row)\" (showBannerEvent)=\"showBanner($event)\">\n </app-table-row>\n </ng-template>\n\n </td>\n </ng-container>\n\n <ng-container matColumnDef=\"action\">\n <th mat-header-cell *matHeaderCellDef> Action </th>\n <td mat-cell *matCellDef=\"let row\" [ngStyle]=\"{width:false ? '20px' : actionsWidth}\">\n <div class=\"action-buttons-container\">\n\n <!-- Added: inline edit \u2014 while a row edits in place, its actions collapse to submit/cancel -->\n <ng-container *ngIf=\"isRowEditing(row); else rowActions\">\n <button mat-icon-button matTooltip=\"Save\" matTooltipPosition=\"above\" (click)=\"submitInlineEdit()\"><mat-icon class=\"inline-save\">check</mat-icon></button> <!-- Changed: dropped color=\"primary\" \u2014 the icon now carries a green save cue -->\n <button mat-icon-button matTooltip=\"Cancel\" matTooltipPosition=\"above\" (click)=\"cancelInlineEdit()\"><mat-icon class=\"inline-cancel\">close</mat-icon></button> <!-- Changed: red cancel cue -->\n </ng-container>\n\n <!-- Actions -->\n <ng-template #rowActions>\n <app-table-action\n [displayedButtons]=\"displayedButtons\" [config]=\"config\" [smallScreen]=\"smallScreen\" [row]=\"row\" (actionClick)=\"actionClicked($event.name, $event.row)\">\n </app-table-action>\n </ng-template>\n\n </div>\n </td>\n </ng-container>\n\n\n <tr mat-header-row *matHeaderRowDef=\"displayedColumns\"></tr>\n <tr mat-row *matRowDef=\"let row; columns: displayedColumns;\" [ngClass]=\"{'make-gray': (config.greyOut && config.greyOut(row)) || row.pendingApproval, 'row-editing': isRowEditing(row)}\"></tr> <!-- Changed: row-editing flags the row that is open for inline edit -->\n </table>\n\n </div>\n\n <!-- Changed: Removed *ngIf condition to keep paginator always in DOM and maintain ViewChild reference -->\n <!-- Changed: Added CSS class binding to hide when no data instead of conditional rendering -->\n <!-- Changed: Legacy paginator only renders in non-paged mode (pagedMode is constant per instance, set before first render) -->\n <!-- Changed: hidePageSize on a phone. The \"Items per page\" label and its select cost a whole row of a\n narrow screen for a control almost nobody touches there \u2014 the default is what gets used. The range\n (\"1 \u2013 10 of 54\") and the arrows stay, which is the part that is actually navigated. Bound to\n smallScreen, the component's existing breakpoint (max-width 600px, live via BreakpointObserver), so\n the paginator agrees with how this table already decides what \"mobile\" means rather than\n introducing a third breakpoint. -->\n <mat-paginator *ngIf=\"!pagedMode\"\n #tablePaginator\n [pageSizeOptions]=\"config.pageSizes ?? [10, 20, 50]\"\n [hidePageSize]=\"smallScreen\"\n [ngClass]=\"{'paginator-hidden': !dataSource || (smallScreen && dataSource?.length === 0) || loadingStage === 'initial'}\"\n showFirstLastButtons>\n </mat-paginator>\n\n <!-- Added: manual paginator for server-side paged mode \u2014 fully state-bound, never attached to MatTableDataSource. Always visible: when filtering it pages the in-memory filtered subset (length = filtered count); otherwise the server window (length = true total). No first/last jump (would force fetching the whole gap). -->\n <mat-paginator *ngIf=\"pagedMode\"\n [length]=\"filterActive ? filteredRows.length : serverTotal + overlayDelta\"\n [pageIndex]=\"pageIndex\"\n [pageSize]=\"pageSize\"\n [pageSizeOptions]=\"config.pageSizes ?? [10, 20, 50]\"\n [hidePageSize]=\"smallScreen\"\n [ngClass]=\"{'paginator-hidden': !dataSource || (smallScreen && dataSource?.length === 0) || loadingStage === 'initial'}\"\n (page)=\"onServerPage($event)\">\n </mat-paginator>\n\n </div>\n \n <!-- Added (Quiet Loading): the initial-load stage for the NON-table views.\n Without this, card/capsule/grouped showed literally nothing during a first load \u2014 the view component\n renders an empty dataSource and the \"No Data\" line is suppressed while the stage owns the space.\n The progress header is identical to the table's so the two feel like one feature; only the ghost\n furniture differs, because column-shaped rows are wrong in a card grid. -->\n <div *ngIf=\"loadingStage === 'initial' && config?.viewType && config?.viewType !== 'table'\" class=\"tin-load-stage\" role=\"status\" aria-busy=\"true\">\n <div class=\"tin-load-progress\">\n <div class=\"tin-load-bar\">\n <span class=\"tin-load-bar-fill\" [style.width.%]=\"progressPercent\"></span>\n </div>\n <span class=\"tin-load-percent\" aria-hidden=\"true\">{{progressDisplay}}%</span>\n </div>\n <div class=\"tin-load-caption\">Loading {{stageEntityName}}\u2026</div>\n\n <div *ngIf=\"config?.viewType === 'capsule'\" class=\"tin-load-ghost-capsules\" aria-hidden=\"true\">\n <span class=\"tin-skel tin-load-ghost-capsule\" *ngFor=\"let c of ghostCapsules\"></span>\n </div>\n\n <div *ngIf=\"config?.viewType === 'card'\" class=\"tin-load-ghost-cards\" aria-hidden=\"true\">\n <div class=\"tin-load-ghost-card\" *ngFor=\"let c of ghostCards\">\n <span class=\"tin-skel tin-skel-text tin-load-ghost-card-title\"></span>\n <span class=\"tin-skel tin-skel-text\"></span>\n <span class=\"tin-skel tin-skel-text tin-load-ghost-card-short\"></span>\n </div>\n </div>\n\n <div *ngIf=\"config?.viewType === 'grouped'\" class=\"tin-load-ghost-groups\" aria-hidden=\"true\">\n <div class=\"tin-load-ghost-group\" *ngFor=\"let g of ghostGroups\">\n <span class=\"tin-skel tin-load-ghost-group-head\"></span>\n <!-- Pills, not rows: a group card's body is a wrap of chips, so full-width bars promised a table\n and the stage did not resemble what replaced it. -->\n <div class=\"tin-load-ghost-group-items\">\n <span class=\"tin-skel tin-load-ghost-capsule\" *ngFor=\"let r of ghostCards\"></span>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Capsules -->\n <spa-capsules *ngIf=\"config?.viewType === 'capsule' && loadingStage !== 'initial'\"\n [config]=\"config\"\n [dataSource]=\"dataSource\"\n [displayedButtons]=\"displayedButtons\"\n (actionClick)=\"actionClicked($event.name, $event.row)\">\n </spa-capsules>\n\n\n <!-- Cards -->\n <spa-cards *ngIf=\"config?.viewType === 'card' && loadingStage !== 'initial'\"\n [config]=\"config\"\n [dataSource]=\"dataSource\"\n [displayedButtons]=\"displayedButtons\"\n [smallScreen]=\"smallScreen\"\n (actionClick)=\"actionClicked($event.name, $event.row)\"\n (columnClick)=\"columnClicked($event.column, $event.row)\"\n (showBannerEvent)=\"showBanner($event)\">\n </spa-cards>\n\n <!-- Groups - Added: New grouped view type -->\n <!-- Changed: the grouped view no longer renders its own filter field. It used to sit on a row of its own\n beneath the buttons row, which left both rows half empty and, more importantly, was a SECOND filter with\n no refresh button. The standard header filter (which has refresh, like every other table) now drives it,\n with its text relayed in through filterText. -->\n <spa-groups *ngIf=\"config?.viewType === 'grouped' && loadingStage !== 'initial'\"\n [config]=\"config\"\n [dataSource]=\"dataSource\"\n [displayedButtons]=\"displayedButtons\"\n [showOwnFilter]=\"false\"\n [filterText]=\"groupFilterText\"\n (actionClick)=\"actionClicked($event.name, $event.row, $event.group, $event.button)\">\n </spa-groups>\n\n\n <div class=\"tin-center\">\n <p *ngIf=\"dataSource?.length == 0 && loadingStage !== 'initial'\"><em>{{config.noDataMessage ?? 'No Data'}}</em></p> <!-- Changed (Quiet Loading): the stage owns the space until it completes, then hands straight over to this message \u2014 no \"No Data\" flashing underneath the ghost rows -->\n </div>\n\n </ng-container> <!-- Added: end section-collapse wrapper -->\n\n</ng-container>\n\n\n<ng-container *ngIf=\"!hasFormAccess\">\n <div class=\"tin-center\">\n <p><em>Access Restricted</em></p>\n </div>\n</ng-container>\n\n", styles: [".top{display:flex;flex-direction:row;flex-wrap:wrap;justify-content:space-between;align-items:center;margin-bottom:10px;margin-top:10px}.mat-mini-fab{width:32px;height:32px}.mat-mini-fab mat-icon{font-size:16px;margin-top:-3px}.mat-icon-button{width:32px;height:32px}.mat-icon-button mat-icon{font-size:20px;margin-top:-7px}.col-icon{margin-left:10px}.title{margin-top:10px;font-size:larger;font-weight:300}.make-gray{background-color:#f5f5f5}tr.mat-mdc-row{transition:background-color .15s ease}tr.mat-mdc-row:hover{background-color:#2196f305}tr.mat-mdc-row.row-editing,tr.mat-mdc-row.row-editing:hover{background-color:#ffa00014}.inline-save{color:#2e7d32!important}.inline-cancel{color:#c62828!important}.right-padding{padding-right:10px}.action-buttons-container{display:flex;justify-content:flex-end;align-items:center}.refreshIcon{font-size:22px!important;margin-top:-7px!important}.dialog-container{display:flex;flex-direction:column;height:100%;max-height:100%;min-height:0}.dialog-content{flex:1;overflow:hidden;display:flex;flex-direction:column;min-height:0}.dialog-scroll-content{flex:1;overflow-y:auto;max-height:none!important;display:flex;flex-direction:column;min-height:0}.dialog-container:not(.dialog-has-tables){height:auto}.dialog-container:not(.dialog-has-tables) .dialog-scroll-content{flex:1 1 auto}.dialog-scroll-content>.tin-input{flex:1;display:flex;flex-direction:column}.dialog-scroll-content>.tin-input>spa-tabs{flex:1;display:flex;flex-direction:column}mat-dialog-actions{flex-shrink:0;justify-content:flex-start}.paginator-hidden{display:none!important}.paged-filter-hint{display:flex;align-items:center;gap:8px;margin:4px 0 8px;padding:6px 12px;border-radius:4px;background-color:#fff8e1;color:#795548;font-size:13px}.paged-filter-hint mat-icon{font-size:18px;width:18px;height:18px;color:#ffa000}.dlg-header-classic{padding-left:24px;padding-right:24px}.dlg-title-classic{font-size:20px;font-weight:500;margin-top:10px;margin-bottom:5px}.dialog-header-titles{display:flex;flex-direction:column;justify-content:center;min-width:0}.tin-dlg-head .dialog-header-titles label{margin:0}.tbl-section-header{display:flex;align-items:center;gap:10px;padding:10px 12px;margin-bottom:8px;border:1px solid rgba(0,0,0,.08);border-radius:10px;background:transparent;cursor:pointer;transition:border-color .15s}.tbl-section-header:hover{border-color:#90a4ae}.tbl-section-header.tbl-section-static{cursor:default}.tbl-section-icon{color:#546e7a}.tbl-section-title{font-size:14px;font-weight:600;color:#000000d1}.tbl-section-count{background:#e3f2fd;color:#1565c0;border-radius:12px;padding:2px 10px;font-size:12px}.tbl-section-chip{font-size:12px;font-weight:500;color:#0009}.tbl-section-spacer{margin-left:auto}.tbl-section-chevron{color:#90a4ae}.tbl-section-caption{margin:-4px 12px 10px;font-size:12.5px;line-height:1.45;color:#0000008c;max-width:82ch}.tbl-section-more{padding:0 12px 8px}.tbl-section-link{background:none;border:none;color:#1565c0;cursor:pointer;font-size:13px;padding:0}.tbl-section-link:hover{text-decoration:underline}.tbl-section-icon,.tbl-section-chevron,.tbl-section-count{flex:0 0 auto}.tbl-section-title{flex:1 1 auto;min-width:0}@media (max-width: 700px){.tbl-section-header{gap:8px;padding:10px}.tbl-section-chip,.tbl-section-btn-text.has-icon{display:none}.tbl-section-header button{min-width:0;padding:0 10px}}table.tin-no-col-headers tr.mat-mdc-header-row{display:none}\n"], dependencies: [{ kind: "directive", type: i1$2.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: i1$2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1$2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i1$2.NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }, { kind: "component", type: i14.MatTable, selector: "mat-table, table[mat-table]", exportAs: ["matTable"] }, { kind: "directive", type: i14.MatHeaderCellDef, selector: "[matHeaderCellDef]" }, { kind: "directive", type: i14.MatHeaderRowDef, selector: "[matHeaderRowDef]", inputs: ["matHeaderRowDef", "matHeaderRowDefSticky"] }, { kind: "directive", type: i14.MatColumnDef, selector: "[matColumnDef]", inputs: ["matColumnDef"] }, { kind: "directive", type: i14.MatCellDef, selector: "[matCellDef]" }, { kind: "directive", type: i14.MatRowDef, selector: "[matRowDef]", inputs: ["matRowDefColumns", "matRowDefWhen"] }, { kind: "directive", type: i14.MatHeaderCell, selector: "mat-header-cell, th[mat-header-cell]" }, { kind: "directive", type: i14.MatCell, selector: "mat-cell, td[mat-cell]" }, { kind: "component", type: i14.MatHeaderRow, selector: "mat-header-row, tr[mat-header-row]", exportAs: ["matHeaderRow"] }, { kind: "component", type: i14.MatRow, selector: "mat-row, tr[mat-row]", exportAs: ["matRow"] }, { kind: "component", type: i15$1.MatPaginator, selector: "mat-paginator", inputs: ["color", "pageIndex", "length", "pageSize", "pageSizeOptions", "hidePageSize", "showFirstLastButtons", "selectConfig", "disabled"], outputs: ["page"], exportAs: ["matPaginator"] }, { kind: "component", type: i3.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i3.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i4$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "directive", type: i7.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: SearchComponent, selector: "spa-search", inputs: ["config", "smallScreen", "tableDataSource"], outputs: ["searchClick"] }, { kind: "component", type: TableHeaderComponent, selector: "app-table-header", inputs: ["lastSearch", "config", "hideTitle", "tableDataSource", "tileConfig", "smallScreen", "tileReload", "showFilterButton", "data", "tileData", "isRealTime", "isConnected", "refreshing"], outputs: ["createClick", "customClick", "refreshClick", "tileClick", "tileUnClick", "filterChange"] }, { kind: "component", type: TableRowComponent, selector: "app-table-row", inputs: ["column", "row", "config", "smallScreen"], outputs: ["actionClick", "columnClick", "showBannerEvent"] }, { kind: "component", type: TableActionComponent, selector: "app-table-action", inputs: ["displayedButtons", "config", "row", "smallScreen"], outputs: ["actionClick"] }, { kind: "component", type: InlineCellComponent, selector: "app-inline-cell", inputs: ["field", "data"], outputs: ["valueChange"] }, { kind: "component", type: CapsulesComponent, selector: "spa-capsules", inputs: ["config", "dataSource", "displayedButtons"], outputs: ["actionClick"] }, { kind: "component", type: CardsComponent, selector: "spa-cards", inputs: ["config", "dataSource", "displayedButtons", "smallScreen"], outputs: ["actionClick", "columnClick", "showBannerEvent"] }, { kind: "component", type: GroupsComponent, selector: "spa-groups", inputs: ["config", "dataSource", "displayedButtons", "showOwnFilter", "filterText"], outputs: ["actionClick"] }, { kind: "pipe", type: CamelToWordsPipe, name: "camelToWords" }] }); }
|
|
17615
18441
|
}
|
|
17616
18442
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: TableComponent, decorators: [{
|
|
17617
18443
|
type: Component,
|
|
17618
|
-
args: [{ selector: 'spa-table', standalone: false, template: "\n<ng-container *ngIf=\"hasFormAccess && !sectionHidden\"> <!-- Changed: sectionConfig.hideWhenEmpty hides the whole table -->\n\n <!-- Added: collapsible flat section header (sectionConfig) \u2014 1px border, no elevation, whole row toggles -->\n <div class=\"tbl-section-header\" *ngIf=\"config.sectionConfig\" (click)=\"toggleSection()\" [attr.aria-expanded]=\"!sectionCollapsed\" [class.tbl-section-static]=\"config.sectionConfig.collapsible === false\" role=\"button\" tabindex=\"0\" (keydown.enter)=\"toggleSection()\">\n <mat-icon class=\"tbl-section-icon\" *ngIf=\"config.sectionConfig.icon\">{{ config.sectionConfig.icon }}</mat-icon>\n <span class=\"tbl-section-title\">{{ config.sectionConfig.title }}</span>\n <span class=\"tbl-section-count\" *ngIf=\"config.sectionConfig.showCount !== false\">{{ dataSource?.length || 0 }}</span>\n <span class=\"tbl-section-chip\" *ngFor=\"let chip of sectionChips()\" [style.color]=\"chip.color\">{{ chip.text }}</span>\n <span class=\"tbl-section-spacer\"></span>\n <!-- Changed: the label is wrapped so a NARROW screen can drop it and leave an icon-only button. Only a\n button that HAS an icon loses its text \u2014 otherwise it would collapse to a blank square. -->\n <button mat-stroked-button color=\"primary\" *ngFor=\"let btn of sectionButtons()\" (click)=\"sectionButtonClicked(btn, $event)\" [matTooltip]=\"btn.display || btn.name\"><mat-icon *ngIf=\"btn.icon?.name\">{{ btn.icon.name }}</mat-icon><span class=\"tbl-section-btn-text\" [class.has-icon]=\"!!btn.icon?.name\">{{ btn.display || btn.name }}</span></button>\n <mat-icon class=\"tbl-section-chevron\" *ngIf=\"config.sectionConfig.collapsible !== false\">{{ sectionCollapsed ? 'expand_more' : 'expand_less' }}</mat-icon>\n </div>\n\n <!-- Added: the section's \"why this list exists\" line. It sits UNDER the title (a reason only makes sense once\n the list has been named) and only while the section is open \u2014 a shut section already has its own summary\n line, and a paragraph over the top of that is noise. -->\n <p class=\"tbl-section-caption\" *ngIf=\"config.sectionConfig?.caption && !sectionCollapsed\">{{ config.sectionConfig.caption }}</p>\n\n <!-- Added: collapsed section affordance \u2014 mirrors the Day Book \"Show the N\" pattern -->\n <div class=\"tbl-section-more\" *ngIf=\"config.sectionConfig && sectionCollapsed && (dataSource?.length || 0) > 0\">\n <button type=\"button\" class=\"tbl-section-link\" (click)=\"toggleSection()\">Show the {{ dataSource.length }}</button>\n </div>\n\n <ng-container *ngIf=\"!config.sectionConfig || !sectionCollapsed\"> <!-- Added: section collapse hides the table body -->\n\n <!-- Search -->\n <spa-search\n *ngIf=\"config.searchConfig\" [config]=\"config.searchConfig\" [smallScreen]=\"smallScreen\" [tableDataSource]=\"tableDataSource\" style=\"margin-bottom: 20px;\" (searchClick)=\"searchClicked($event)\">\n </spa-search>\n\n <!-- Header -->\n <app-table-header\n [config]=\"config\" [data]=\"dataSource\" [tableDataSource]=\"tableDataSource\" [tileConfig]=\"config.tileConfig\" [tileData]=\"tileData\" [tileReload]=\"tileReload\" [lastSearch]=\"lastSearch\" [smallScreen]=\"smallScreen\"\n [showFilterButton]=\"showFilterButton\" [isRealTime]=\"config.realTime\" [isConnected]=\"isSignalRConnected\" [refreshing]=\"loadingStage === 'refresh'\"\n (createClick)=\"newModel()\" (customClick)=\"customModel($event,null)\"\n (refreshClick)=\"refreshClicked()\" (tileClick)=\"tileClicked($event)\" (tileUnClick)=\"tileUnClicked($event)\" (filterChange)=\"filterChanged($event)\">\n </app-table-header>\n\n <!-- Added (Quiet Loading D4): refresh with data already on screen \u2014 a 2px line flush under the header and the\n spinning refresh icon are the ONLY signals. Rows stay live, clickable and un-dimmed while they swap. -->\n <div *ngIf=\"loadingStage === 'refresh'\" class=\"tin-load-line\" aria-hidden=\"true\">\n <span class=\"tin-load-line-fill\" [style.width.%]=\"progressPercent\"></span>\n </div>\n\n <!-- Added: paged-mode filter hint \u2014 the client filter only covers rows loaded so far -->\n <div *ngIf=\"pagedMode && filterActive && loadedRows.length < serverTotal\" class=\"paged-filter-hint\">\n <mat-icon>info</mat-icon>\n <span>Filtering only the {{loadedRows.length}} loaded rows of {{serverTotal}}. {{ config.searchConfig ? 'Use Search for complete results.' : 'Refine with search for complete results.' }}</span>\n </div>\n\n\n <!-- Table -->\n <div *ngIf=\"!config.viewType || config?.viewType === 'table'\">\n\n <p *ngIf=\"!config\"><em>Configure Table</em></p>\n <p *ngIf=\"!dataSource && !loadingStage\"><em>Loading...</em></p> <!-- Changed (Quiet Loading): the bare text is replaced by the stage below while a quiet load is on screen -->\n\n <!-- Added (Quiet Loading D3): first load, nothing on screen yet. The progress module is the hero (eased bar +\n counting percentage + caption) and the ghost rows hold the exact space the real rows will fill, so the\n table does not jump when data lands. Only ever rendered when quiet loading is on. -->\n <div *ngIf=\"loadingStage === 'initial'\" class=\"tin-load-stage\" role=\"status\" aria-busy=\"true\">\n\n <div class=\"tin-load-progress\">\n <div class=\"tin-load-bar\">\n <span class=\"tin-load-bar-fill\" [style.width.%]=\"progressPercent\"></span>\n </div>\n <span class=\"tin-load-percent\" aria-hidden=\"true\">{{progressDisplay}}%</span>\n </div>\n\n <div class=\"tin-load-caption\">Loading {{stageEntityName}}\u2026</div>\n\n <div class=\"tin-load-ghosts\">\n <div class=\"tin-load-ghost-row\" *ngFor=\"let r of ghostRows\">\n <div class=\"tin-load-ghost-cell\" *ngFor=\"let c of ghostColumns; let i = index\">\n <span class=\"tin-skel\" [style.width.%]=\"ghostWidth(i, c)\" [style.animation-delay.ms]=\"r * 120\"></span> <!-- staggered sweep: each row starts 120ms after the one above -->\n </div>\n </div>\n </div>\n\n </div>\n\n <div *ngIf=\"dataSource && loadingStage !== 'initial' && (!smallScreen || (smallScreen && dataSource?.length > 0))\" [class.tin-load-in]=\"effQuietLoading\"> <!-- Changed (Quiet Loading): the empty header-only table is suppressed while the initial stage stands in for it, and the real rows fade in where the ghosts were (D3) -->\n\n <table mat-table [dataSource]=\"tableDataSource\" [trackBy]=\"trackByRow\" [ngClass]=\"elevation\" [class.tin-no-col-headers]=\"config.hideColumnHeaders\"> <!-- Changed: optional column-header suppression -->\n\n <ng-container *ngFor=\"let column of config.columns\" [matColumnDef]=\"column.name\">\n <th mat-header-cell *matHeaderCellDef >{{ column.alias ?? column.name | camelToWords }}</th>\n <td mat-cell *matCellDef=\"let row;\" class=\"right-padding\" >\n\n <!-- Added: inline edit \u2014 editable cells swap to their form-field editor while the row is in edit mode -->\n <app-inline-cell *ngIf=\"isRowEditing(row) && getInlineField(column); else displayCell\" [field]=\"getInlineField(column)\" [data]=\"editingModel\"></app-inline-cell>\n\n <!-- Rows -->\n <ng-template #displayCell>\n <app-table-row [column]=\"column\" [row]=\"row\" [config]=\"config\" [smallScreen]=\"smallScreen\"\n (actionClick)=\"actionClicked(column.name, row)\" (columnClick)=\"columnClicked(column, row)\" (showBannerEvent)=\"showBanner($event)\">\n </app-table-row>\n </ng-template>\n\n </td>\n </ng-container>\n\n <ng-container matColumnDef=\"action\">\n <th mat-header-cell *matHeaderCellDef> Action </th>\n <td mat-cell *matCellDef=\"let row\" [ngStyle]=\"{width:false ? '20px' : actionsWidth}\">\n <div class=\"action-buttons-container\">\n\n <!-- Added: inline edit \u2014 while a row edits in place, its actions collapse to submit/cancel -->\n <ng-container *ngIf=\"isRowEditing(row); else rowActions\">\n <button mat-icon-button matTooltip=\"Save\" matTooltipPosition=\"above\" (click)=\"submitInlineEdit()\"><mat-icon class=\"inline-save\">check</mat-icon></button> <!-- Changed: dropped color=\"primary\" \u2014 the icon now carries a green save cue -->\n <button mat-icon-button matTooltip=\"Cancel\" matTooltipPosition=\"above\" (click)=\"cancelInlineEdit()\"><mat-icon class=\"inline-cancel\">close</mat-icon></button> <!-- Changed: red cancel cue -->\n </ng-container>\n\n <!-- Actions -->\n <ng-template #rowActions>\n <app-table-action\n [displayedButtons]=\"displayedButtons\" [config]=\"config\" [smallScreen]=\"smallScreen\" [row]=\"row\" (actionClick)=\"actionClicked($event.name, $event.row)\">\n </app-table-action>\n </ng-template>\n\n </div>\n </td>\n </ng-container>\n\n\n <tr mat-header-row *matHeaderRowDef=\"displayedColumns\"></tr>\n <tr mat-row *matRowDef=\"let row; columns: displayedColumns;\" [ngClass]=\"{'make-gray': (config.greyOut && config.greyOut(row)) || row.pendingApproval, 'row-editing': isRowEditing(row)}\"></tr> <!-- Changed: row-editing flags the row that is open for inline edit -->\n </table>\n\n </div>\n\n <!-- Changed: Removed *ngIf condition to keep paginator always in DOM and maintain ViewChild reference -->\n <!-- Changed: Added CSS class binding to hide when no data instead of conditional rendering -->\n <!-- Changed: Legacy paginator only renders in non-paged mode (pagedMode is constant per instance, set before first render) -->\n <mat-paginator *ngIf=\"!pagedMode\"\n #tablePaginator\n [pageSizeOptions]=\"config.pageSizes ?? [10, 20, 50]\"\n [ngClass]=\"{'paginator-hidden': !dataSource || (smallScreen && dataSource?.length === 0) || loadingStage === 'initial'}\"\n showFirstLastButtons>\n </mat-paginator>\n\n <!-- Added: manual paginator for server-side paged mode \u2014 fully state-bound, never attached to MatTableDataSource. Always visible: when filtering it pages the in-memory filtered subset (length = filtered count); otherwise the server window (length = true total). No first/last jump (would force fetching the whole gap). -->\n <mat-paginator *ngIf=\"pagedMode\"\n [length]=\"filterActive ? filteredRows.length : serverTotal + overlayDelta\"\n [pageIndex]=\"pageIndex\"\n [pageSize]=\"pageSize\"\n [pageSizeOptions]=\"config.pageSizes ?? [10, 20, 50]\"\n [ngClass]=\"{'paginator-hidden': !dataSource || (smallScreen && dataSource?.length === 0) || loadingStage === 'initial'}\"\n (page)=\"onServerPage($event)\">\n </mat-paginator>\n\n </div>\n \n <!-- Added (Quiet Loading): the initial-load stage for the NON-table views.\n Without this, card/capsule/grouped showed literally nothing during a first load \u2014 the view component\n renders an empty dataSource and the \"No Data\" line is suppressed while the stage owns the space.\n The progress header is identical to the table's so the two feel like one feature; only the ghost\n furniture differs, because column-shaped rows are wrong in a card grid. -->\n <div *ngIf=\"loadingStage === 'initial' && config?.viewType && config?.viewType !== 'table'\" class=\"tin-load-stage\" role=\"status\" aria-busy=\"true\">\n <div class=\"tin-load-progress\">\n <div class=\"tin-load-bar\">\n <span class=\"tin-load-bar-fill\" [style.width.%]=\"progressPercent\"></span>\n </div>\n <span class=\"tin-load-percent\" aria-hidden=\"true\">{{progressDisplay}}%</span>\n </div>\n <div class=\"tin-load-caption\">Loading {{stageEntityName}}\u2026</div>\n\n <div *ngIf=\"config?.viewType === 'capsule'\" class=\"tin-load-ghost-capsules\" aria-hidden=\"true\">\n <span class=\"tin-skel tin-load-ghost-capsule\" *ngFor=\"let c of ghostCapsules\"></span>\n </div>\n\n <div *ngIf=\"config?.viewType === 'card'\" class=\"tin-load-ghost-cards\" aria-hidden=\"true\">\n <div class=\"tin-load-ghost-card\" *ngFor=\"let c of ghostCards\">\n <span class=\"tin-skel tin-skel-text tin-load-ghost-card-title\"></span>\n <span class=\"tin-skel tin-skel-text\"></span>\n <span class=\"tin-skel tin-skel-text tin-load-ghost-card-short\"></span>\n </div>\n </div>\n\n <div *ngIf=\"config?.viewType === 'grouped'\" class=\"tin-load-ghost-groups\" aria-hidden=\"true\">\n <div class=\"tin-load-ghost-group\" *ngFor=\"let g of ghostGroups\">\n <span class=\"tin-skel tin-load-ghost-group-head\"></span>\n <!-- Pills, not rows: a group card's body is a wrap of chips, so full-width bars promised a table\n and the stage did not resemble what replaced it. -->\n <div class=\"tin-load-ghost-group-items\">\n <span class=\"tin-skel tin-load-ghost-capsule\" *ngFor=\"let r of ghostCards\"></span>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Capsules -->\n <spa-capsules *ngIf=\"config?.viewType === 'capsule' && loadingStage !== 'initial'\"\n [config]=\"config\"\n [dataSource]=\"dataSource\"\n [displayedButtons]=\"displayedButtons\"\n (actionClick)=\"actionClicked($event.name, $event.row)\">\n </spa-capsules>\n\n\n <!-- Cards -->\n <spa-cards *ngIf=\"config?.viewType === 'card' && loadingStage !== 'initial'\"\n [config]=\"config\"\n [dataSource]=\"dataSource\"\n [displayedButtons]=\"displayedButtons\"\n [smallScreen]=\"smallScreen\"\n (actionClick)=\"actionClicked($event.name, $event.row)\"\n (columnClick)=\"columnClicked($event.column, $event.row)\"\n (showBannerEvent)=\"showBanner($event)\">\n </spa-cards>\n\n <!-- Groups - Added: New grouped view type -->\n <!-- Changed: the grouped view no longer renders its own filter field. It used to sit on a row of its own\n beneath the buttons row, which left both rows half empty and, more importantly, was a SECOND filter with\n no refresh button. The standard header filter (which has refresh, like every other table) now drives it,\n with its text relayed in through filterText. -->\n <spa-groups *ngIf=\"config?.viewType === 'grouped' && loadingStage !== 'initial'\"\n [config]=\"config\"\n [dataSource]=\"dataSource\"\n [displayedButtons]=\"displayedButtons\"\n [showOwnFilter]=\"false\"\n [filterText]=\"groupFilterText\"\n (actionClick)=\"actionClicked($event.name, $event.row, $event.group, $event.button)\">\n </spa-groups>\n\n\n <div class=\"tin-center\">\n <p *ngIf=\"dataSource?.length == 0 && loadingStage !== 'initial'\"><em>{{config.noDataMessage ?? 'No Data'}}</em></p> <!-- Changed (Quiet Loading): the stage owns the space until it completes, then hands straight over to this message \u2014 no \"No Data\" flashing underneath the ghost rows -->\n </div>\n\n </ng-container> <!-- Added: end section-collapse wrapper -->\n\n</ng-container>\n\n\n<ng-container *ngIf=\"!hasFormAccess\">\n <div class=\"tin-center\">\n <p><em>Access Restricted</em></p>\n </div>\n</ng-container>\n\n", styles: [".top{display:flex;flex-direction:row;flex-wrap:wrap;justify-content:space-between;align-items:center;margin-bottom:10px;margin-top:10px}.mat-mini-fab{width:32px;height:32px}.mat-mini-fab mat-icon{font-size:16px;margin-top:-3px}.mat-icon-button{width:32px;height:32px}.mat-icon-button mat-icon{font-size:20px;margin-top:-7px}.col-icon{margin-left:10px}.title{margin-top:10px;font-size:larger;font-weight:300}.make-gray{background-color:#f5f5f5}tr.mat-mdc-row{transition:background-color .15s ease}tr.mat-mdc-row:hover{background-color:#2196f305}tr.mat-mdc-row.row-editing,tr.mat-mdc-row.row-editing:hover{background-color:#ffa00014}.inline-save{color:#2e7d32!important}.inline-cancel{color:#c62828!important}.right-padding{padding-right:10px}.action-buttons-container{display:flex;justify-content:flex-end;align-items:center}.refreshIcon{font-size:22px!important;margin-top:-7px!important}.dialog-container{display:flex;flex-direction:column;height:100%;max-height:100%;min-height:0}.dialog-content{flex:1;overflow:hidden;display:flex;flex-direction:column;min-height:0}.dialog-scroll-content{flex:1;overflow-y:auto;max-height:none!important;display:flex;flex-direction:column;min-height:0}.dialog-container:not(.dialog-has-tables){height:auto}.dialog-container:not(.dialog-has-tables) .dialog-scroll-content{flex:1 1 auto}.dialog-scroll-content>.tin-input{flex:1;display:flex;flex-direction:column}.dialog-scroll-content>.tin-input>spa-tabs{flex:1;display:flex;flex-direction:column}mat-dialog-actions{flex-shrink:0;justify-content:flex-start}.paginator-hidden{display:none!important}.paged-filter-hint{display:flex;align-items:center;gap:8px;margin:4px 0 8px;padding:6px 12px;border-radius:4px;background-color:#fff8e1;color:#795548;font-size:13px}.paged-filter-hint mat-icon{font-size:18px;width:18px;height:18px;color:#ffa000}.dlg-header-classic{padding-left:24px;padding-right:24px}.dlg-title-classic{font-size:20px;font-weight:500;margin-top:10px;margin-bottom:5px}.dialog-header-titles{display:flex;flex-direction:column;justify-content:center;min-width:0}.tin-dlg-head .dialog-header-titles label{margin:0}.tbl-section-header{display:flex;align-items:center;gap:10px;padding:10px 12px;margin-bottom:8px;border:1px solid rgba(0,0,0,.08);border-radius:10px;background:transparent;cursor:pointer;transition:border-color .15s}.tbl-section-header:hover{border-color:#90a4ae}.tbl-section-header.tbl-section-static{cursor:default}.tbl-section-icon{color:#546e7a}.tbl-section-title{font-size:14px;font-weight:600;color:#000000d1}.tbl-section-count{background:#e3f2fd;color:#1565c0;border-radius:12px;padding:2px 10px;font-size:12px}.tbl-section-chip{font-size:12px;font-weight:500;color:#0009}.tbl-section-spacer{margin-left:auto}.tbl-section-chevron{color:#90a4ae}.tbl-section-caption{margin:-4px 12px 10px;font-size:12.5px;line-height:1.45;color:#0000008c;max-width:82ch}.tbl-section-more{padding:0 12px 8px}.tbl-section-link{background:none;border:none;color:#1565c0;cursor:pointer;font-size:13px;padding:0}.tbl-section-link:hover{text-decoration:underline}.tbl-section-icon,.tbl-section-chevron,.tbl-section-count{flex:0 0 auto}.tbl-section-title{flex:1 1 auto;min-width:0}@media (max-width: 700px){.tbl-section-header{gap:8px;padding:10px}.tbl-section-chip,.tbl-section-btn-text.has-icon{display:none}.tbl-section-header button{min-width:0;padding:0 10px}}table.tin-no-col-headers tr.mat-mdc-header-row{display:none}\n"] }]
|
|
18444
|
+
args: [{ selector: 'spa-table', standalone: false, template: "\n<ng-container *ngIf=\"hasFormAccess && !sectionHidden\"> <!-- Changed: sectionConfig.hideWhenEmpty hides the whole table -->\n\n <!-- Added: collapsible flat section header (sectionConfig) \u2014 1px border, no elevation, whole row toggles -->\n <div class=\"tbl-section-header\" *ngIf=\"config.sectionConfig\" (click)=\"toggleSection()\" [attr.aria-expanded]=\"!sectionCollapsed\" [class.tbl-section-static]=\"config.sectionConfig.collapsible === false\" role=\"button\" tabindex=\"0\" (keydown.enter)=\"toggleSection()\">\n <mat-icon class=\"tbl-section-icon\" *ngIf=\"config.sectionConfig.icon\">{{ config.sectionConfig.icon }}</mat-icon>\n <span class=\"tbl-section-title\">{{ config.sectionConfig.title }}</span>\n <span class=\"tbl-section-count\" *ngIf=\"config.sectionConfig.showCount !== false\">{{ dataSource?.length || 0 }}</span>\n <span class=\"tbl-section-chip\" *ngFor=\"let chip of sectionChips()\" [style.color]=\"chip.color\">{{ chip.text }}</span>\n <span class=\"tbl-section-spacer\"></span>\n <!-- Changed: the label is wrapped so a NARROW screen can drop it and leave an icon-only button. Only a\n button that HAS an icon loses its text \u2014 otherwise it would collapse to a blank square. -->\n <button mat-stroked-button color=\"primary\" *ngFor=\"let btn of sectionButtons()\" (click)=\"sectionButtonClicked(btn, $event)\" [matTooltip]=\"btn.display || btn.name\"><mat-icon *ngIf=\"btn.icon?.name\">{{ btn.icon.name }}</mat-icon><span class=\"tbl-section-btn-text\" [class.has-icon]=\"!!btn.icon?.name\">{{ btn.display || btn.name }}</span></button>\n <mat-icon class=\"tbl-section-chevron\" *ngIf=\"config.sectionConfig.collapsible !== false\">{{ sectionCollapsed ? 'expand_more' : 'expand_less' }}</mat-icon>\n </div>\n\n <!-- Added: the section's \"why this list exists\" line. It sits UNDER the title (a reason only makes sense once\n the list has been named) and only while the section is open \u2014 a shut section already has its own summary\n line, and a paragraph over the top of that is noise. -->\n <p class=\"tbl-section-caption\" *ngIf=\"config.sectionConfig?.caption && !sectionCollapsed\">{{ config.sectionConfig.caption }}</p>\n\n <!-- Added: collapsed section affordance \u2014 mirrors the Day Book \"Show the N\" pattern -->\n <div class=\"tbl-section-more\" *ngIf=\"config.sectionConfig && sectionCollapsed && (dataSource?.length || 0) > 0\">\n <button type=\"button\" class=\"tbl-section-link\" (click)=\"toggleSection()\">Show the {{ dataSource.length }}</button>\n </div>\n\n <ng-container *ngIf=\"!config.sectionConfig || !sectionCollapsed\"> <!-- Added: section collapse hides the table body -->\n\n <!-- Search -->\n <spa-search\n *ngIf=\"config.searchConfig\" [config]=\"config.searchConfig\" [smallScreen]=\"smallScreen\" [tableDataSource]=\"tableDataSource\" style=\"margin-bottom: 20px;\" (searchClick)=\"searchClicked($event)\">\n </spa-search>\n\n <!-- Header -->\n <!-- Changed: [isRealTime] binds the RESOLVED value, not the raw config. It was `config.realTime`, so the live\n indicator only ever appeared on a table that set the flag ITSELF \u2014 a table relying on the app-wide\n `tableDefaults.realTime` was genuinely subscribed to SignalR (setupRealTimeSubscriptions and\n realTimeRefreshOrFallback both resolve through effRealTime) and simply never showed the dot. That is\n how \"Trips and Loads have no real time\" got reported: a status light disagreeing with the system it\n reports on. effRealTime also gets the inverse right \u2014 realTime:true under a global false still lights,\n and realTime:false under a global true stays dark. -->\n <app-table-header\n [config]=\"config\" [data]=\"dataSource\" [tableDataSource]=\"tableDataSource\" [tileConfig]=\"config.tileConfig\" [tileData]=\"tileData\" [tileReload]=\"tileReload\" [lastSearch]=\"lastSearch\" [smallScreen]=\"smallScreen\"\n [showFilterButton]=\"showFilterButton\" [isRealTime]=\"effRealTime\" [isConnected]=\"isSignalRConnected\" [refreshing]=\"loadingStage === 'refresh'\"\n (createClick)=\"newModel()\" (customClick)=\"customModel($event,null)\"\n (refreshClick)=\"refreshClicked()\" (tileClick)=\"tileClicked($event)\" (tileUnClick)=\"tileUnClicked($event)\" (filterChange)=\"filterChanged($event)\">\n </app-table-header>\n\n <!-- Added (Quiet Loading D4): refresh with data already on screen \u2014 a 2px line flush under the header and the\n spinning refresh icon are the ONLY signals. Rows stay live, clickable and un-dimmed while they swap. -->\n <div *ngIf=\"loadingStage === 'refresh'\" class=\"tin-load-line\" aria-hidden=\"true\">\n <span class=\"tin-load-line-fill\" [style.width.%]=\"progressPercent\"></span>\n </div>\n\n <!-- Added: paged-mode filter hint \u2014 the client filter only covers rows loaded so far -->\n <div *ngIf=\"pagedMode && filterActive && loadedRows.length < serverTotal\" class=\"paged-filter-hint\">\n <mat-icon>info</mat-icon>\n <span>Filtering only the {{loadedRows.length}} loaded rows of {{serverTotal}}. {{ config.searchConfig ? 'Use Search for complete results.' : 'Refine with search for complete results.' }}</span>\n </div>\n\n\n <!-- Table -->\n <div *ngIf=\"!config.viewType || config?.viewType === 'table'\">\n\n <p *ngIf=\"!config\"><em>Configure Table</em></p>\n <p *ngIf=\"!dataSource && !loadingStage\"><em>Loading...</em></p> <!-- Changed (Quiet Loading): the bare text is replaced by the stage below while a quiet load is on screen -->\n\n <!-- Added (Quiet Loading D3): first load, nothing on screen yet. The progress module is the hero (eased bar +\n counting percentage + caption) and the ghost rows hold the exact space the real rows will fill, so the\n table does not jump when data lands. Only ever rendered when quiet loading is on. -->\n <div *ngIf=\"loadingStage === 'initial'\" class=\"tin-load-stage\" role=\"status\" aria-busy=\"true\">\n\n <div class=\"tin-load-progress\">\n <div class=\"tin-load-bar\">\n <span class=\"tin-load-bar-fill\" [style.width.%]=\"progressPercent\"></span>\n </div>\n <span class=\"tin-load-percent\" aria-hidden=\"true\">{{progressDisplay}}%</span>\n </div>\n\n <div class=\"tin-load-caption\">Loading {{stageEntityName}}\u2026</div>\n\n <div class=\"tin-load-ghosts\">\n <div class=\"tin-load-ghost-row\" *ngFor=\"let r of ghostRows\">\n <div class=\"tin-load-ghost-cell\" *ngFor=\"let c of ghostColumns; let i = index\">\n <span class=\"tin-skel\" [style.width.%]=\"ghostWidth(i, c)\" [style.animation-delay.ms]=\"r * 120\"></span> <!-- staggered sweep: each row starts 120ms after the one above -->\n </div>\n </div>\n </div>\n\n </div>\n\n <div *ngIf=\"dataSource && loadingStage !== 'initial' && (!smallScreen || (smallScreen && dataSource?.length > 0))\" [class.tin-load-in]=\"effQuietLoading\"> <!-- Changed (Quiet Loading): the empty header-only table is suppressed while the initial stage stands in for it, and the real rows fade in where the ghosts were (D3) -->\n\n <table mat-table [dataSource]=\"tableDataSource\" [trackBy]=\"trackByRow\" [ngClass]=\"elevation\" [class.tin-no-col-headers]=\"config.hideColumnHeaders\"> <!-- Changed: optional column-header suppression -->\n\n <ng-container *ngFor=\"let column of config.columns\" [matColumnDef]=\"column.name\">\n <th mat-header-cell *matHeaderCellDef >{{ column.alias ?? column.name | camelToWords }}</th>\n <td mat-cell *matCellDef=\"let row;\" class=\"right-padding\" >\n\n <!-- Added: inline edit \u2014 editable cells swap to their form-field editor while the row is in edit mode -->\n <app-inline-cell *ngIf=\"isRowEditing(row) && getInlineField(column); else displayCell\" [field]=\"getInlineField(column)\" [data]=\"editingModel\"></app-inline-cell>\n\n <!-- Rows -->\n <ng-template #displayCell>\n <app-table-row [column]=\"column\" [row]=\"row\" [config]=\"config\" [smallScreen]=\"smallScreen\"\n (actionClick)=\"actionClicked(column.name, row)\" (columnClick)=\"columnClicked(column, row)\" (showBannerEvent)=\"showBanner($event)\">\n </app-table-row>\n </ng-template>\n\n </td>\n </ng-container>\n\n <ng-container matColumnDef=\"action\">\n <th mat-header-cell *matHeaderCellDef> Action </th>\n <td mat-cell *matCellDef=\"let row\" [ngStyle]=\"{width:false ? '20px' : actionsWidth}\">\n <div class=\"action-buttons-container\">\n\n <!-- Added: inline edit \u2014 while a row edits in place, its actions collapse to submit/cancel -->\n <ng-container *ngIf=\"isRowEditing(row); else rowActions\">\n <button mat-icon-button matTooltip=\"Save\" matTooltipPosition=\"above\" (click)=\"submitInlineEdit()\"><mat-icon class=\"inline-save\">check</mat-icon></button> <!-- Changed: dropped color=\"primary\" \u2014 the icon now carries a green save cue -->\n <button mat-icon-button matTooltip=\"Cancel\" matTooltipPosition=\"above\" (click)=\"cancelInlineEdit()\"><mat-icon class=\"inline-cancel\">close</mat-icon></button> <!-- Changed: red cancel cue -->\n </ng-container>\n\n <!-- Actions -->\n <ng-template #rowActions>\n <app-table-action\n [displayedButtons]=\"displayedButtons\" [config]=\"config\" [smallScreen]=\"smallScreen\" [row]=\"row\" (actionClick)=\"actionClicked($event.name, $event.row)\">\n </app-table-action>\n </ng-template>\n\n </div>\n </td>\n </ng-container>\n\n\n <tr mat-header-row *matHeaderRowDef=\"displayedColumns\"></tr>\n <tr mat-row *matRowDef=\"let row; columns: displayedColumns;\" [ngClass]=\"{'make-gray': (config.greyOut && config.greyOut(row)) || row.pendingApproval, 'row-editing': isRowEditing(row)}\"></tr> <!-- Changed: row-editing flags the row that is open for inline edit -->\n </table>\n\n </div>\n\n <!-- Changed: Removed *ngIf condition to keep paginator always in DOM and maintain ViewChild reference -->\n <!-- Changed: Added CSS class binding to hide when no data instead of conditional rendering -->\n <!-- Changed: Legacy paginator only renders in non-paged mode (pagedMode is constant per instance, set before first render) -->\n <!-- Changed: hidePageSize on a phone. The \"Items per page\" label and its select cost a whole row of a\n narrow screen for a control almost nobody touches there \u2014 the default is what gets used. The range\n (\"1 \u2013 10 of 54\") and the arrows stay, which is the part that is actually navigated. Bound to\n smallScreen, the component's existing breakpoint (max-width 600px, live via BreakpointObserver), so\n the paginator agrees with how this table already decides what \"mobile\" means rather than\n introducing a third breakpoint. -->\n <mat-paginator *ngIf=\"!pagedMode\"\n #tablePaginator\n [pageSizeOptions]=\"config.pageSizes ?? [10, 20, 50]\"\n [hidePageSize]=\"smallScreen\"\n [ngClass]=\"{'paginator-hidden': !dataSource || (smallScreen && dataSource?.length === 0) || loadingStage === 'initial'}\"\n showFirstLastButtons>\n </mat-paginator>\n\n <!-- Added: manual paginator for server-side paged mode \u2014 fully state-bound, never attached to MatTableDataSource. Always visible: when filtering it pages the in-memory filtered subset (length = filtered count); otherwise the server window (length = true total). No first/last jump (would force fetching the whole gap). -->\n <mat-paginator *ngIf=\"pagedMode\"\n [length]=\"filterActive ? filteredRows.length : serverTotal + overlayDelta\"\n [pageIndex]=\"pageIndex\"\n [pageSize]=\"pageSize\"\n [pageSizeOptions]=\"config.pageSizes ?? [10, 20, 50]\"\n [hidePageSize]=\"smallScreen\"\n [ngClass]=\"{'paginator-hidden': !dataSource || (smallScreen && dataSource?.length === 0) || loadingStage === 'initial'}\"\n (page)=\"onServerPage($event)\">\n </mat-paginator>\n\n </div>\n \n <!-- Added (Quiet Loading): the initial-load stage for the NON-table views.\n Without this, card/capsule/grouped showed literally nothing during a first load \u2014 the view component\n renders an empty dataSource and the \"No Data\" line is suppressed while the stage owns the space.\n The progress header is identical to the table's so the two feel like one feature; only the ghost\n furniture differs, because column-shaped rows are wrong in a card grid. -->\n <div *ngIf=\"loadingStage === 'initial' && config?.viewType && config?.viewType !== 'table'\" class=\"tin-load-stage\" role=\"status\" aria-busy=\"true\">\n <div class=\"tin-load-progress\">\n <div class=\"tin-load-bar\">\n <span class=\"tin-load-bar-fill\" [style.width.%]=\"progressPercent\"></span>\n </div>\n <span class=\"tin-load-percent\" aria-hidden=\"true\">{{progressDisplay}}%</span>\n </div>\n <div class=\"tin-load-caption\">Loading {{stageEntityName}}\u2026</div>\n\n <div *ngIf=\"config?.viewType === 'capsule'\" class=\"tin-load-ghost-capsules\" aria-hidden=\"true\">\n <span class=\"tin-skel tin-load-ghost-capsule\" *ngFor=\"let c of ghostCapsules\"></span>\n </div>\n\n <div *ngIf=\"config?.viewType === 'card'\" class=\"tin-load-ghost-cards\" aria-hidden=\"true\">\n <div class=\"tin-load-ghost-card\" *ngFor=\"let c of ghostCards\">\n <span class=\"tin-skel tin-skel-text tin-load-ghost-card-title\"></span>\n <span class=\"tin-skel tin-skel-text\"></span>\n <span class=\"tin-skel tin-skel-text tin-load-ghost-card-short\"></span>\n </div>\n </div>\n\n <div *ngIf=\"config?.viewType === 'grouped'\" class=\"tin-load-ghost-groups\" aria-hidden=\"true\">\n <div class=\"tin-load-ghost-group\" *ngFor=\"let g of ghostGroups\">\n <span class=\"tin-skel tin-load-ghost-group-head\"></span>\n <!-- Pills, not rows: a group card's body is a wrap of chips, so full-width bars promised a table\n and the stage did not resemble what replaced it. -->\n <div class=\"tin-load-ghost-group-items\">\n <span class=\"tin-skel tin-load-ghost-capsule\" *ngFor=\"let r of ghostCards\"></span>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Capsules -->\n <spa-capsules *ngIf=\"config?.viewType === 'capsule' && loadingStage !== 'initial'\"\n [config]=\"config\"\n [dataSource]=\"dataSource\"\n [displayedButtons]=\"displayedButtons\"\n (actionClick)=\"actionClicked($event.name, $event.row)\">\n </spa-capsules>\n\n\n <!-- Cards -->\n <spa-cards *ngIf=\"config?.viewType === 'card' && loadingStage !== 'initial'\"\n [config]=\"config\"\n [dataSource]=\"dataSource\"\n [displayedButtons]=\"displayedButtons\"\n [smallScreen]=\"smallScreen\"\n (actionClick)=\"actionClicked($event.name, $event.row)\"\n (columnClick)=\"columnClicked($event.column, $event.row)\"\n (showBannerEvent)=\"showBanner($event)\">\n </spa-cards>\n\n <!-- Groups - Added: New grouped view type -->\n <!-- Changed: the grouped view no longer renders its own filter field. It used to sit on a row of its own\n beneath the buttons row, which left both rows half empty and, more importantly, was a SECOND filter with\n no refresh button. The standard header filter (which has refresh, like every other table) now drives it,\n with its text relayed in through filterText. -->\n <spa-groups *ngIf=\"config?.viewType === 'grouped' && loadingStage !== 'initial'\"\n [config]=\"config\"\n [dataSource]=\"dataSource\"\n [displayedButtons]=\"displayedButtons\"\n [showOwnFilter]=\"false\"\n [filterText]=\"groupFilterText\"\n (actionClick)=\"actionClicked($event.name, $event.row, $event.group, $event.button)\">\n </spa-groups>\n\n\n <div class=\"tin-center\">\n <p *ngIf=\"dataSource?.length == 0 && loadingStage !== 'initial'\"><em>{{config.noDataMessage ?? 'No Data'}}</em></p> <!-- Changed (Quiet Loading): the stage owns the space until it completes, then hands straight over to this message \u2014 no \"No Data\" flashing underneath the ghost rows -->\n </div>\n\n </ng-container> <!-- Added: end section-collapse wrapper -->\n\n</ng-container>\n\n\n<ng-container *ngIf=\"!hasFormAccess\">\n <div class=\"tin-center\">\n <p><em>Access Restricted</em></p>\n </div>\n</ng-container>\n\n", styles: [".top{display:flex;flex-direction:row;flex-wrap:wrap;justify-content:space-between;align-items:center;margin-bottom:10px;margin-top:10px}.mat-mini-fab{width:32px;height:32px}.mat-mini-fab mat-icon{font-size:16px;margin-top:-3px}.mat-icon-button{width:32px;height:32px}.mat-icon-button mat-icon{font-size:20px;margin-top:-7px}.col-icon{margin-left:10px}.title{margin-top:10px;font-size:larger;font-weight:300}.make-gray{background-color:#f5f5f5}tr.mat-mdc-row{transition:background-color .15s ease}tr.mat-mdc-row:hover{background-color:#2196f305}tr.mat-mdc-row.row-editing,tr.mat-mdc-row.row-editing:hover{background-color:#ffa00014}.inline-save{color:#2e7d32!important}.inline-cancel{color:#c62828!important}.right-padding{padding-right:10px}.action-buttons-container{display:flex;justify-content:flex-end;align-items:center}.refreshIcon{font-size:22px!important;margin-top:-7px!important}.dialog-container{display:flex;flex-direction:column;height:100%;max-height:100%;min-height:0}.dialog-content{flex:1;overflow:hidden;display:flex;flex-direction:column;min-height:0}.dialog-scroll-content{flex:1;overflow-y:auto;max-height:none!important;display:flex;flex-direction:column;min-height:0}.dialog-container:not(.dialog-has-tables){height:auto}.dialog-container:not(.dialog-has-tables) .dialog-scroll-content{flex:1 1 auto}.dialog-scroll-content>.tin-input{flex:1;display:flex;flex-direction:column}.dialog-scroll-content>.tin-input>spa-tabs{flex:1;display:flex;flex-direction:column}mat-dialog-actions{flex-shrink:0;justify-content:flex-start}.paginator-hidden{display:none!important}.paged-filter-hint{display:flex;align-items:center;gap:8px;margin:4px 0 8px;padding:6px 12px;border-radius:4px;background-color:#fff8e1;color:#795548;font-size:13px}.paged-filter-hint mat-icon{font-size:18px;width:18px;height:18px;color:#ffa000}.dlg-header-classic{padding-left:24px;padding-right:24px}.dlg-title-classic{font-size:20px;font-weight:500;margin-top:10px;margin-bottom:5px}.dialog-header-titles{display:flex;flex-direction:column;justify-content:center;min-width:0}.tin-dlg-head .dialog-header-titles label{margin:0}.tbl-section-header{display:flex;align-items:center;gap:10px;padding:10px 12px;margin-bottom:8px;border:1px solid rgba(0,0,0,.08);border-radius:10px;background:transparent;cursor:pointer;transition:border-color .15s}.tbl-section-header:hover{border-color:#90a4ae}.tbl-section-header.tbl-section-static{cursor:default}.tbl-section-icon{color:#546e7a}.tbl-section-title{font-size:14px;font-weight:600;color:#000000d1}.tbl-section-count{background:#e3f2fd;color:#1565c0;border-radius:12px;padding:2px 10px;font-size:12px}.tbl-section-chip{font-size:12px;font-weight:500;color:#0009}.tbl-section-spacer{margin-left:auto}.tbl-section-chevron{color:#90a4ae}.tbl-section-caption{margin:-4px 12px 10px;font-size:12.5px;line-height:1.45;color:#0000008c;max-width:82ch}.tbl-section-more{padding:0 12px 8px}.tbl-section-link{background:none;border:none;color:#1565c0;cursor:pointer;font-size:13px;padding:0}.tbl-section-link:hover{text-decoration:underline}.tbl-section-icon,.tbl-section-chevron,.tbl-section-count{flex:0 0 auto}.tbl-section-title{flex:1 1 auto;min-width:0}@media (max-width: 700px){.tbl-section-header{gap:8px;padding:10px}.tbl-section-chip,.tbl-section-btn-text.has-icon{display:none}.tbl-section-header button{min-width:0;padding:0 10px}}table.tin-no-col-headers tr.mat-mdc-header-row{display:none}\n"] }]
|
|
17619
18445
|
}], ctorParameters: () => [{ type: DataServiceLib }, { type: MessageService }, { type: i1$4.BreakpointObserver }, { type: i4.MatDialog }, { type: ButtonService }, { type: DialogService }, { type: TableConfigService }, { type: ConditionService }, { type: AuthService }, { type: SignalRService }, { type: OfflineService }, { type: ApiErrorService }, { type: undefined, decorators: [{
|
|
17620
18446
|
type: Optional
|
|
17621
18447
|
}, {
|
|
@@ -17732,8 +18558,10 @@ class DayBookComponent {
|
|
|
17732
18558
|
next: (response) => {
|
|
17733
18559
|
this.loading = false;
|
|
17734
18560
|
this.setShowLoading(false);
|
|
17735
|
-
if (!response.success)
|
|
18561
|
+
if (!response.success) {
|
|
18562
|
+
this.apiErrorService.presentAppFailure(response, 'load', this.config.loadAction.url);
|
|
17736
18563
|
return;
|
|
18564
|
+
} // Changed: was a bare `return`, the last unhandled failure in this file — runPageAction below was already converted. rebuild() never ran, so the lanes, the tiles and every section stayed at their previous values: a Day Book that quietly shows yesterday's book is worse than one that admits it could not load
|
|
17737
18565
|
this.book = response.data;
|
|
17738
18566
|
this.rebuild();
|
|
17739
18567
|
},
|
|
@@ -18399,8 +19227,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
|
|
|
18399
19227
|
}] } });
|
|
18400
19228
|
|
|
18401
19229
|
class NotesComponent {
|
|
18402
|
-
constructor(dataService) {
|
|
19230
|
+
constructor(dataService, apiErrorService) {
|
|
18403
19231
|
this.dataService = dataService;
|
|
19232
|
+
this.apiErrorService = apiErrorService;
|
|
18404
19233
|
// Input properties
|
|
18405
19234
|
this.title = "Notes";
|
|
18406
19235
|
this.notes = []; // Data array if provided directly
|
|
@@ -18408,7 +19237,7 @@ class NotesComponent {
|
|
|
18408
19237
|
this.nameField = "createdByName";
|
|
18409
19238
|
this.dateField = "createdDate";
|
|
18410
19239
|
this.commentField = "details";
|
|
18411
|
-
}
|
|
19240
|
+
} // Changed: injected so a failed note load says so
|
|
18412
19241
|
ngOnInit() {
|
|
18413
19242
|
this.loadNotes();
|
|
18414
19243
|
}
|
|
@@ -18435,19 +19264,25 @@ class NotesComponent {
|
|
|
18435
19264
|
url = url.replace('/x', '/' + idValue);
|
|
18436
19265
|
}
|
|
18437
19266
|
this.dataService.CallApi({ ...this.loadAction, url }).subscribe((apiResponse) => {
|
|
19267
|
+
// CONVERTED (WS-6 Phase 4 triage). This is the same shape as the five field loads Phase 3 fixed —
|
|
19268
|
+
// notes attached to a record are content, not decoration, and an empty panel asserts "there are no
|
|
19269
|
+
// notes on this record", which is a statement someone will act on. It runs once per record view,
|
|
19270
|
+
// not on a timer, so there is no storm to cause.
|
|
19271
|
+
if (!apiResponse.success)
|
|
19272
|
+
this.apiErrorService.presentAppFailure(apiResponse, 'load', url);
|
|
18438
19273
|
if (apiResponse.success && apiResponse.data) {
|
|
18439
19274
|
this.notes = apiResponse.data;
|
|
18440
19275
|
}
|
|
18441
19276
|
});
|
|
18442
19277
|
}
|
|
18443
19278
|
}
|
|
18444
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: NotesComponent, deps: [{ token: DataServiceLib }], target: i0.ɵɵFactoryTarget.Component }); }
|
|
19279
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: NotesComponent, deps: [{ token: DataServiceLib }, { token: ApiErrorService }], target: i0.ɵɵFactoryTarget.Component }); }
|
|
18445
19280
|
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: NotesComponent, isStandalone: false, selector: "spa-notes", inputs: { title: "title", notes: "notes", loadAction: "loadAction", loadIDField: "loadIDField", data: "data", nameField: "nameField", dateField: "dateField", commentField: "commentField" }, ngImport: i0, template: " <div class=\"notes-container\" *ngIf=\"notes && notes.length > 0\">\n <div class=\"tin-input-row mt-3\" *ngIf=\"title != ''\">\n <mat-label style=\"font-size: 20px; font-weight: 300\">{{title}}</mat-label>\n </div>\n \n <ul class=\"list-group list-group-flush\" style=\"max-height:350px; margin-left:5px\">\n <li *ngFor=\"let note of notes\" class=\"list-group-item list-group-item-action flex-column align-items-start\">\n <div class=\"d-flex w-100 justify-content-between\">\n <div class=\"mb-0\">{{note[nameField] || note.createdByName}}</div>\n </div>\n <small>{{note[commentField] || note.details}} (<em>{{note[dateField] || note.createdDate | date: 'dd MMM yyyy HH:mm'}}</em>)</small>\n </li>\n </ul>\n </div>\n \n <div *ngIf=\"!notes || notes.length == 0\" class=\"d-flex justify-content-center row align-items-center\" style=\"max-height:200px\">\n No {{title.toLowerCase()}}\n </div>", styles: [".notes-container{padding:10px;border-radius:4px;margin-bottom:15px}.list-group-item{background-color:transparent;border-left:none;border-right:none;border-radius:0;transition:background-color .2s}.list-group-item:hover{background-color:#00000008}.list-group-item small{display:block;margin-top:5px}\n"], dependencies: [{ kind: "directive", type: i1$2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1$2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i3$1.MatLabel, selector: "mat-label" }, { kind: "pipe", type: i1$2.DatePipe, name: "date" }] }); }
|
|
18446
19281
|
}
|
|
18447
19282
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: NotesComponent, decorators: [{
|
|
18448
19283
|
type: Component,
|
|
18449
19284
|
args: [{ selector: 'spa-notes', standalone: false, template: " <div class=\"notes-container\" *ngIf=\"notes && notes.length > 0\">\n <div class=\"tin-input-row mt-3\" *ngIf=\"title != ''\">\n <mat-label style=\"font-size: 20px; font-weight: 300\">{{title}}</mat-label>\n </div>\n \n <ul class=\"list-group list-group-flush\" style=\"max-height:350px; margin-left:5px\">\n <li *ngFor=\"let note of notes\" class=\"list-group-item list-group-item-action flex-column align-items-start\">\n <div class=\"d-flex w-100 justify-content-between\">\n <div class=\"mb-0\">{{note[nameField] || note.createdByName}}</div>\n </div>\n <small>{{note[commentField] || note.details}} (<em>{{note[dateField] || note.createdDate | date: 'dd MMM yyyy HH:mm'}}</em>)</small>\n </li>\n </ul>\n </div>\n \n <div *ngIf=\"!notes || notes.length == 0\" class=\"d-flex justify-content-center row align-items-center\" style=\"max-height:200px\">\n No {{title.toLowerCase()}}\n </div>", styles: [".notes-container{padding:10px;border-radius:4px;margin-bottom:15px}.list-group-item{background-color:transparent;border-left:none;border-right:none;border-radius:0;transition:background-color .2s}.list-group-item:hover{background-color:#00000008}.list-group-item small{display:block;margin-top:5px}\n"] }]
|
|
18450
|
-
}], ctorParameters: () => [{ type: DataServiceLib }], propDecorators: { title: [{
|
|
19285
|
+
}], ctorParameters: () => [{ type: DataServiceLib }, { type: ApiErrorService }], propDecorators: { title: [{
|
|
18451
19286
|
type: Input
|
|
18452
19287
|
}], notes: [{
|
|
18453
19288
|
type: Input
|
|
@@ -18927,6 +19762,7 @@ class AppConfigurationComponent {
|
|
|
18927
19762
|
this.messageService = inject(MessageService);
|
|
18928
19763
|
this.authService = inject(AuthService);
|
|
18929
19764
|
this.configService = inject(ConfigService);
|
|
19765
|
+
this.apiErrorService = inject(ApiErrorService); // Added: the load and the save both needed a real failure surface
|
|
18930
19766
|
this.router = inject(Router); // Added (v3)
|
|
18931
19767
|
this.configuration = {};
|
|
18932
19768
|
this.loading = false;
|
|
@@ -18964,8 +19800,10 @@ class AppConfigurationComponent {
|
|
|
18964
19800
|
this.dataService.CallApi(this.config?.loadAction || { url: 'configuration/get' }).subscribe({
|
|
18965
19801
|
next: (response) => {
|
|
18966
19802
|
this.loading = false;
|
|
18967
|
-
if (!response.success)
|
|
19803
|
+
if (!response.success) {
|
|
19804
|
+
this.apiErrorService.presentAppFailure(response, 'load', (this.config?.loadAction || { url: 'configuration/get' }).url);
|
|
18968
19805
|
return;
|
|
19806
|
+
} // Changed: was a bare `return`, so buildCards() never ran and the page rendered its heading over nothing at all — the emptiest possible screen with no statement of why
|
|
18969
19807
|
this.configuration = response.data || {}; // the backend creates the row on first read, so this is never empty in practice
|
|
18970
19808
|
this.buildCards();
|
|
18971
19809
|
},
|
|
@@ -19023,7 +19861,10 @@ class AppConfigurationComponent {
|
|
|
19023
19861
|
this.configService.refresh(); // every app-side subscriber (nav gating, unit suffixes) sees the change now, not next login
|
|
19024
19862
|
}
|
|
19025
19863
|
else {
|
|
19026
|
-
|
|
19864
|
+
// Changed: was toast(response.message ...) — the server's raw words for 5 seconds while the page
|
|
19865
|
+
// still showed every edited value, so it looked saved. 'submit' is exactly right and literally
|
|
19866
|
+
// true here: the user's configuration edits ARE still on the form and do not need re-entering.
|
|
19867
|
+
this.apiErrorService.presentAppFailure(response, 'submit', (this.config?.saveAction || { url: 'configuration/update' }).url);
|
|
19027
19868
|
}
|
|
19028
19869
|
},
|
|
19029
19870
|
error: () => this.saving = false
|
|
@@ -20536,7 +21377,7 @@ class StepsComponent {
|
|
|
20536
21377
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: StepsComponent, deps: [{ token: i1$4.BreakpointObserver }, { token: DataServiceLib }, { token: ApiErrorService }], target: i0.ɵɵFactoryTarget.Component }); }
|
|
20537
21378
|
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: StepsComponent, isStandalone: false, selector: "spa-steps", inputs: { value: "value", config: "config", data: "data", activeIndex: "activeIndex" }, providers: [{
|
|
20538
21379
|
provide: STEPPER_GLOBAL_OPTIONS, useValue: { displayDefaultIndicatorType: false }
|
|
20539
|
-
}], viewQueries: [{ propertyName: "stepper", first: true, predicate: ["stepper"], descendants: true }], usesOnChanges: true, ngImport: i0, template: "\n<mat-horizontal-stepper class=\"transparent\" [labelPosition]=\"shouldShowLabels ? 'bottom' : 'end'\" #stepper [selectedIndex]=\"selectedIndex\">\n\n <mat-step *ngFor=\"let step of getVisibleSteps()\"\n [editable]=\"false\" [label]=\"shouldShowLabels ? step.name : ''\" [state]=\"step.icon ?? 'number'\">\n </mat-step>\n\n</mat-horizontal-stepper>\n", styles: [".transparent{background-color:#0000}:host ::ng-deep .mat-step-header{padding:5px!important}:host ::ng-deep .mat-stepper-horizontal-line{min-width:5px!important}:host ::ng-deep .mat-horizontal-content-container{padding:0!important}:host ::ng-deep .mat-horizontal-stepper-header{pointer-events:none!important}\n"], dependencies: [{ kind: "directive", type: i1$2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "component", type: i5$
|
|
21380
|
+
}], viewQueries: [{ propertyName: "stepper", first: true, predicate: ["stepper"], descendants: true }], usesOnChanges: true, ngImport: i0, template: "\n<mat-horizontal-stepper class=\"transparent\" [labelPosition]=\"shouldShowLabels ? 'bottom' : 'end'\" #stepper [selectedIndex]=\"selectedIndex\">\n\n <mat-step *ngFor=\"let step of getVisibleSteps()\"\n [editable]=\"false\" [label]=\"shouldShowLabels ? step.name : ''\" [state]=\"step.icon ?? 'number'\">\n </mat-step>\n\n</mat-horizontal-stepper>\n", styles: [".transparent{background-color:#0000}:host ::ng-deep .mat-step-header{padding:5px!important}:host ::ng-deep .mat-stepper-horizontal-line{min-width:5px!important}:host ::ng-deep .mat-horizontal-content-container{padding:0!important}:host ::ng-deep .mat-horizontal-stepper-header{pointer-events:none!important}\n"], dependencies: [{ kind: "directive", type: i1$2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "component", type: i5$2.MatStep, selector: "mat-step", inputs: ["color"], exportAs: ["matStep"] }, { kind: "component", type: i5$2.MatStepper, selector: "mat-stepper, mat-vertical-stepper, mat-horizontal-stepper, [matStepper]", inputs: ["disableRipple", "color", "labelPosition", "headerPosition", "animationDuration"], outputs: ["animationDone"], exportAs: ["matStepper", "matVerticalStepper", "matHorizontalStepper"] }] }); }
|
|
20540
21381
|
}
|
|
20541
21382
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: StepsComponent, decorators: [{
|
|
20542
21383
|
type: Component,
|
|
@@ -21090,14 +21931,39 @@ class LoaderInterceptor {
|
|
|
21090
21931
|
complete: () => observer.complete()
|
|
21091
21932
|
});
|
|
21092
21933
|
}
|
|
21093
|
-
else {
|
|
21934
|
+
else if (result === 'rejected') {
|
|
21094
21935
|
this.refreshResult$.next('FAILED'); // Unblock waiting requests
|
|
21095
|
-
// Changed: gentle toast + login redirect instead of error dialog
|
|
21096
|
-
//
|
|
21936
|
+
// Changed: gentle toast + login redirect instead of error dialog. This branch now fires ONLY for an
|
|
21937
|
+
// explicit server rejection — the refresh endpoint answered HTTP 200 with success:false, meaning the
|
|
21938
|
+
// refresh token is revoked or expired. That is the only outcome that genuinely ends a session, and it
|
|
21939
|
+
// is the only one where silentRefresh() clears the stored token (auth.service.ts:370-380).
|
|
21097
21940
|
this.authService.sessionExpired();
|
|
21098
21941
|
this.messageService.toast('Please sign in to continue');
|
|
21099
21942
|
observer.error(new Error('Token refresh failed'));
|
|
21100
21943
|
}
|
|
21944
|
+
else {
|
|
21945
|
+
// Changed: 'network' no longer logs the user out. silentRefresh() resolves 'network' for EVERY
|
|
21946
|
+
// non-2xx on User/refresh — status 0, 408, 429 and every 5xx alike — because HttpClient routes them
|
|
21947
|
+
// all to its error callback. Treating that as "session over" made a transient condition destroy a
|
|
21948
|
+
// session that was never invalid.
|
|
21949
|
+
//
|
|
21950
|
+
// The 429 case is the one that turns this from untidy into damaging: an office behind a single NAT
|
|
21951
|
+
// IP, whose tokens expire together after a shared morning sign-in, refreshes as one burst, trips one
|
|
21952
|
+
// shared rate limit, and logs ITSELF out — every user at once, for a condition that would have
|
|
21953
|
+
// cleared in seconds. 5xx and status 0 are grouped with it for the same reason: in none of them did
|
|
21954
|
+
// the server assert anything about the token, so inferring "your session is over" from them is an
|
|
21955
|
+
// inference the response does not support. Their common property is not the status number, it is the
|
|
21956
|
+
// ABSENCE of an authoritative rejection.
|
|
21957
|
+
//
|
|
21958
|
+
// Doing nothing destructive is also what auth.service.ts already does on its own refresh paths
|
|
21959
|
+
// (:265-269, :277-281, :299-304): only 'rejected' calls sessionExpired(), 'network' reschedules. This
|
|
21960
|
+
// branch was the single place in the codebase that disagreed. The refresh token is deliberately
|
|
21961
|
+
// preserved by silentRefresh(), the proactive timer will try again, and the next 401 re-enters here —
|
|
21962
|
+
// so the session recovers on its own with no user action.
|
|
21963
|
+
this.refreshResult$.next('FAILED'); // Unblock waiting requests — the ORIGINAL request still fails, only the session survives
|
|
21964
|
+
this.messageService.toast('The server is busy. You are still signed in — please try again in a moment.');
|
|
21965
|
+
observer.error(new Error('Token refresh unavailable'));
|
|
21966
|
+
}
|
|
21101
21967
|
});
|
|
21102
21968
|
});
|
|
21103
21969
|
}
|
|
@@ -21345,7 +22211,7 @@ class TabsComponent {
|
|
|
21345
22211
|
return this.reload || this.tableReloads[index];
|
|
21346
22212
|
}
|
|
21347
22213
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: TabsComponent, deps: [{ token: TabService }], target: i0.ɵɵFactoryTarget.Component }); }
|
|
21348
|
-
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: TabsComponent, isStandalone: false, selector: "spa-tabs", inputs: { tableConfigs: "tableConfigs", reload: "reload", reloadTab: "reloadTab", parentDetails: "parentDetails", localMode: "localMode", nestingLevel: "nestingLevel" }, outputs: { formRefresh: "formRefresh", actionSuccess: "actionSuccess" }, usesOnChanges: true, ngImport: i0, template: "<mat-tab-group (selectedTabChange)=\"onTabChange($event)\" [selectedIndex]=\"selectedTabIndex\">\n\n <!-- Changed: Use cached visibleTabs property to prevent infinite change detection loop -->\n <ng-container *ngFor=\"let tab of visibleTabs; let i = index\">\n <mat-tab><!-- TS-11: visibleTabs is already filtered via getVisibleTabs; dropped per-tab isTabVisible() call per CD -->\n\n <!-- Tab label with count pill.\n Changed: was matBadge, which renders as a faint superscript unless the consuming app includes\n Material's badge theme \u2014 several do not, so the number was effectively invisible. This pill is\n styled entirely in this component, so every app gets the same legible count.\n An empty tab still shows its count, muted rather than accented: saying \"nothing in here\" before\n the tab is opened is the whole point, and it saves the round trip of opening it. -->\n <ng-template matTabLabel>\n <span class=\"tab-label\">\n {{getTabTitle(tab.config)}}\n <span *ngIf=\"shouldShowBadge(tab.originalIndex)\"\n class=\"tab-count\"\n [class.tab-count-zero]=\"getTabCount(tab.originalIndex) === 0\">{{getTabCount(tab.originalIndex)}}</span>\n </span>\n </ng-template>\n\n <!-- Tab content: custom template when provided (tabTemplate), otherwise the lazy-loaded table.\n Templates keep the same lazy semantics \u2014 rendered only once the tab has been activated. -->\n <div *ngIf=\"shouldLoadTabData(i) && tab.config.tabTemplate\" class=\"tab-content\">\n <ng-container [ngTemplateOutlet]=\"tab.config.tabTemplate\"></ng-container>\n </div>\n\n <div *ngIf=\"shouldLoadTabData(i) && !tab.config.tabTemplate\" class=\"tab-content\">\n <spa-table\n [config]=\"tab.config\"\n [reload]=\"getReloadSubject(tab.originalIndex)\"\n [inTab]=\"true\"\n [activeTab]=\"selectedTabIndex === i\"\n [nestingLevel]=\"nestingLevel\"\n [localMode]=\"localMode\"\n [parentDetails]=\"parentDetails\"\n (totalChange)=\"onTabTotal(tab.originalIndex, $event)\"\n (actionSuccess)=\"onTableActionSuccess(tab.originalIndex, $event)\">\n </spa-table><!-- Changed: badge adopts the grid's filtered total so it can never show a global/unfiltered count --><!-- Changed: localMode + parentDetails passed through for one-step create local tables -->\n </div>\n\n <!-- Placeholder for non-loaded tabs -->\n <div *ngIf=\"!shouldLoadTabData(i)\" class=\"tab-placeholder\">\n <!-- Empty placeholder - content will load when tab is activated -->\n </div>\n\n </mat-tab>\n </ng-container>\n\n</mat-tab-group>", styles: [":host{flex:1;display:flex;flex-direction:column}:host ::ng-deep .mat-mdc-tab-group{flex:1;display:flex;flex-direction:column}:host ::ng-deep .mat-mdc-tab-body-wrapper{flex:1}.tab-content{padding-top:16px;overflow-x:auto}.tab-placeholder{min-height:100px;display:flex;align-items:center;justify-content:center}.tab-label{display:inline-flex;align-items:center;gap:8px}.tab-count{display:inline-flex;align-items:center;justify-content:center;min-width:20px;height:18px;padding:0 6px;border-radius:9px;background-color:#1976d2;color:#fff;font-size:11px;font-weight:600;line-height:1}.tab-count-zero{background-color:#d5d9de;color:#6b7280}.badge{background-color:#2196f3;color:#fff;border-radius:12px;padding:2px 8px;margin-left:8px;font-size:12px}\n"], dependencies: [{ kind: "directive", type: i1$2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1$2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i1$2.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "directive", type:
|
|
22214
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: TabsComponent, isStandalone: false, selector: "spa-tabs", inputs: { tableConfigs: "tableConfigs", reload: "reload", reloadTab: "reloadTab", parentDetails: "parentDetails", localMode: "localMode", nestingLevel: "nestingLevel" }, outputs: { formRefresh: "formRefresh", actionSuccess: "actionSuccess" }, usesOnChanges: true, ngImport: i0, template: "<mat-tab-group (selectedTabChange)=\"onTabChange($event)\" [selectedIndex]=\"selectedTabIndex\">\n\n <!-- Changed: Use cached visibleTabs property to prevent infinite change detection loop -->\n <ng-container *ngFor=\"let tab of visibleTabs; let i = index\">\n <mat-tab><!-- TS-11: visibleTabs is already filtered via getVisibleTabs; dropped per-tab isTabVisible() call per CD -->\n\n <!-- Tab label with count pill.\n Changed: was matBadge, which renders as a faint superscript unless the consuming app includes\n Material's badge theme \u2014 several do not, so the number was effectively invisible. This pill is\n styled entirely in this component, so every app gets the same legible count.\n An empty tab still shows its count, muted rather than accented: saying \"nothing in here\" before\n the tab is opened is the whole point, and it saves the round trip of opening it. -->\n <ng-template matTabLabel>\n <span class=\"tab-label\">\n {{getTabTitle(tab.config)}}\n <span *ngIf=\"shouldShowBadge(tab.originalIndex)\"\n class=\"tab-count\"\n [class.tab-count-zero]=\"getTabCount(tab.originalIndex) === 0\">{{getTabCount(tab.originalIndex)}}</span>\n </span>\n </ng-template>\n\n <!-- Tab content: custom template when provided (tabTemplate), otherwise the lazy-loaded table.\n Templates keep the same lazy semantics \u2014 rendered only once the tab has been activated. -->\n <div *ngIf=\"shouldLoadTabData(i) && tab.config.tabTemplate\" class=\"tab-content\">\n <ng-container [ngTemplateOutlet]=\"tab.config.tabTemplate\"></ng-container>\n </div>\n\n <div *ngIf=\"shouldLoadTabData(i) && !tab.config.tabTemplate\" class=\"tab-content\">\n <spa-table\n [config]=\"tab.config\"\n [reload]=\"getReloadSubject(tab.originalIndex)\"\n [inTab]=\"true\"\n [activeTab]=\"selectedTabIndex === i\"\n [nestingLevel]=\"nestingLevel\"\n [localMode]=\"localMode\"\n [parentDetails]=\"parentDetails\"\n (totalChange)=\"onTabTotal(tab.originalIndex, $event)\"\n (actionSuccess)=\"onTableActionSuccess(tab.originalIndex, $event)\">\n </spa-table><!-- Changed: badge adopts the grid's filtered total so it can never show a global/unfiltered count --><!-- Changed: localMode + parentDetails passed through for one-step create local tables -->\n </div>\n\n <!-- Placeholder for non-loaded tabs -->\n <div *ngIf=\"!shouldLoadTabData(i)\" class=\"tab-placeholder\">\n <!-- Empty placeholder - content will load when tab is activated -->\n </div>\n\n </mat-tab>\n </ng-container>\n\n</mat-tab-group>", styles: [":host{flex:1;display:flex;flex-direction:column}:host ::ng-deep .mat-mdc-tab-group{flex:1;display:flex;flex-direction:column}:host ::ng-deep .mat-mdc-tab-body-wrapper{flex:1}.tab-content{padding-top:16px;overflow-x:auto}.tab-placeholder{min-height:100px;display:flex;align-items:center;justify-content:center}.tab-label{display:inline-flex;align-items:center;gap:8px}.tab-count{display:inline-flex;align-items:center;justify-content:center;min-width:20px;height:18px;padding:0 6px;border-radius:9px;background-color:#1976d2;color:#fff;font-size:11px;font-weight:600;line-height:1}.tab-count-zero{background-color:#d5d9de;color:#6b7280}.badge{background-color:#2196f3;color:#fff;border-radius:12px;padding:2px 8px;margin-left:8px;font-size:12px}\n"], dependencies: [{ kind: "directive", type: i1$2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1$2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i1$2.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "directive", type: i3$2.MatTabLabel, selector: "[mat-tab-label], [matTabLabel]" }, { kind: "component", type: i3$2.MatTab, selector: "mat-tab", inputs: ["disabled", "label", "aria-label", "aria-labelledby", "labelClass", "bodyClass", "id"], exportAs: ["matTab"] }, { kind: "component", type: i3$2.MatTabGroup, selector: "mat-tab-group", inputs: ["color", "fitInkBarToContent", "mat-stretch-tabs", "mat-align-tabs", "dynamicHeight", "selectedIndex", "headerPosition", "animationDuration", "contentTabIndex", "disablePagination", "disableRipple", "preserveContent", "backgroundColor", "aria-label", "aria-labelledby"], outputs: ["selectedIndexChange", "focusChange", "animationDone", "selectedTabChange"], exportAs: ["matTabGroup"] }, { kind: "component", type: TableComponent, selector: "spa-table", inputs: ["data", "tileData", "config", "localMode", "parentDetails", "reload", "activeTab", "inTab", "nestingLevel"], outputs: ["dataLoad", "totalChange", "actionSuccess", "refreshClick", "searchClick", "createClick", "actionClick", "inputChange", "actionResponse"] }] }); }
|
|
21349
22215
|
}
|
|
21350
22216
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: TabsComponent, decorators: [{
|
|
21351
22217
|
type: Component,
|
|
@@ -22180,10 +23046,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
|
|
|
22180
23046
|
}] }] });
|
|
22181
23047
|
|
|
22182
23048
|
class InvitationsTableComponent {
|
|
22183
|
-
constructor(dataService, messageService, authService) {
|
|
23049
|
+
constructor(dataService, messageService, authService, apiErrorService) {
|
|
22184
23050
|
this.dataService = dataService;
|
|
22185
23051
|
this.messageService = messageService;
|
|
22186
23052
|
this.authService = authService;
|
|
23053
|
+
this.apiErrorService = apiErrorService;
|
|
22187
23054
|
this.tableReload = new Subject();
|
|
22188
23055
|
this.invitationsTableConfig = {
|
|
22189
23056
|
greyOut: (value) => value.accepted == false,
|
|
@@ -22201,7 +23068,7 @@ class InvitationsTableComponent {
|
|
|
22201
23068
|
],
|
|
22202
23069
|
loadAction: { url: 'tenants/invitations/x' },
|
|
22203
23070
|
};
|
|
22204
|
-
}
|
|
23071
|
+
} // Changed: injected ApiErrorService for the two membership actions
|
|
22205
23072
|
ngOnInit() {
|
|
22206
23073
|
}
|
|
22207
23074
|
invActionClicked(x) {
|
|
@@ -22227,21 +23094,27 @@ class InvitationsTableComponent {
|
|
|
22227
23094
|
this.messageService.toast("Switched Successfully, please login again");
|
|
22228
23095
|
this.authService.logoff();
|
|
22229
23096
|
}
|
|
23097
|
+
else {
|
|
23098
|
+
this.apiErrorService.presentAppFailure(apiResponse, 'action', 'tenants/dto?action=switch'); // Added: was silent. The invitation was already accepted at this point, so silence here left the user believing the switch had happened when they were still in the old organisation
|
|
23099
|
+
}
|
|
22230
23100
|
});
|
|
22231
23101
|
}
|
|
22232
23102
|
});
|
|
22233
23103
|
}
|
|
23104
|
+
else {
|
|
23105
|
+
this.apiErrorService.presentAppFailure(apiResponse, 'action', 'members/dto?action=' + action); // Added: was silent. Accept or Decline both left the row exactly as it was with no message, so the natural read is "it did not register my click" and the user presses it again
|
|
23106
|
+
}
|
|
22234
23107
|
});
|
|
22235
23108
|
}
|
|
22236
23109
|
});
|
|
22237
23110
|
}
|
|
22238
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: InvitationsTableComponent, deps: [{ token: DataServiceLib }, { token: MessageService }, { token: AuthService }], target: i0.ɵɵFactoryTarget.Component }); }
|
|
23111
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: InvitationsTableComponent, deps: [{ token: DataServiceLib }, { token: MessageService }, { token: AuthService }, { token: ApiErrorService }], target: i0.ɵɵFactoryTarget.Component }); }
|
|
22239
23112
|
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: InvitationsTableComponent, isStandalone: false, selector: "spa-invitations-table", ngImport: i0, template: "<spa-table [config]=\"invitationsTableConfig\" (actionClick)=\"invActionClicked($event)\" [reload]=\"tableReload\"></spa-table>\n", styles: [""], dependencies: [{ kind: "component", type: TableComponent, selector: "spa-table", inputs: ["data", "tileData", "config", "localMode", "parentDetails", "reload", "activeTab", "inTab", "nestingLevel"], outputs: ["dataLoad", "totalChange", "actionSuccess", "refreshClick", "searchClick", "createClick", "actionClick", "inputChange", "actionResponse"] }] }); }
|
|
22240
23113
|
}
|
|
22241
23114
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: InvitationsTableComponent, decorators: [{
|
|
22242
23115
|
type: Component,
|
|
22243
23116
|
args: [{ selector: 'spa-invitations-table', standalone: false, template: "<spa-table [config]=\"invitationsTableConfig\" (actionClick)=\"invActionClicked($event)\" [reload]=\"tableReload\"></spa-table>\n" }]
|
|
22244
|
-
}], ctorParameters: () => [{ type: DataServiceLib }, { type: MessageService }, { type: AuthService }] });
|
|
23117
|
+
}], ctorParameters: () => [{ type: DataServiceLib }, { type: MessageService }, { type: AuthService }, { type: ApiErrorService }] });
|
|
22245
23118
|
|
|
22246
23119
|
// Generic AI-assisted import wizard: Upload -> Map -> Review -> Done. Config-driven via ImportConfig.
|
|
22247
23120
|
class ImportDialogComponent {
|
|
@@ -22268,10 +23141,12 @@ class ImportDialogComponent {
|
|
|
22268
23141
|
rebuildReviewRows() {
|
|
22269
23142
|
this.reviewRows = this.rows.map(r => ({ rowNumber: r.rowNumber, statusName: r.statusName, status: r.status, issueText: this.rowIssueText(r), ...(r.values || {}), __row: r }));
|
|
22270
23143
|
}
|
|
22271
|
-
constructor(httpService, http, messageService,
|
|
23144
|
+
constructor(httpService, http, messageService, apiErrorService, // Changed: injected so a step failure is classified rather than shown verbatim
|
|
23145
|
+
dialogRef, config) {
|
|
22272
23146
|
this.httpService = httpService;
|
|
22273
23147
|
this.http = http;
|
|
22274
23148
|
this.messageService = messageService;
|
|
23149
|
+
this.apiErrorService = apiErrorService;
|
|
22275
23150
|
this.dialogRef = dialogRef;
|
|
22276
23151
|
this.config = config;
|
|
22277
23152
|
this.loading = false;
|
|
@@ -22323,6 +23198,10 @@ class ImportDialogComponent {
|
|
|
22323
23198
|
// Changed: a Committed (4) OR Reversed (6) batch is finished — reopening starts a NEW import instead of
|
|
22324
23199
|
// resuming to a stale screen. Reversed was missed when Undo was added, so after undoing an import the
|
|
22325
23200
|
// wizard reopened onto the undone batch's mapping step.
|
|
23201
|
+
// Deliberately silent: this is the "is there a batch to resume?" probe fired the instant the dialog
|
|
23202
|
+
// opens, before the user has asked for anything. When it fails the wizard falls back to step 0 — a
|
|
23203
|
+
// fully working fresh upload — so there is nothing for the user to do differently and nothing lost.
|
|
23204
|
+
// A dialog here would greet them with an error for a screen that works. (WS-6 Phase 4 triage)
|
|
22326
23205
|
if (r?.success && r.data?.hasItems && r.data.status !== 4 && r.data.status !== 6) {
|
|
22327
23206
|
this.applyBatch(r.data);
|
|
22328
23207
|
this.gotoStepForStatus();
|
|
@@ -22348,8 +23227,11 @@ class ImportDialogComponent {
|
|
|
22348
23227
|
this.goToStep(1);
|
|
22349
23228
|
}
|
|
22350
23229
|
else {
|
|
22351
|
-
this.messageService.error(r?.message
|
|
23230
|
+
this.apiErrorService.presentAppFailure(r, 'submit', `import/upload/${this.entity}`); // Changed: was messageService.error(r?.message ...), which put the server's own words in a dialog — "Error processing request", or a leaked SQL fragment when the parse fails. The file the user chose is still selected, which is exactly what the 'submit' copy promises
|
|
22352
23231
|
}
|
|
23232
|
+
// The transport-error callback below is deliberately left as-is: it shows FIXED text, never server
|
|
23233
|
+
// text, and the underlying HttpErrorResponse already reaches ApiErrorService through the interceptor.
|
|
23234
|
+
// Same decision, same reasoning, as groups.component.ts in WS-6 Phase 3.
|
|
22353
23235
|
}, () => { this.loading = false; this.messageService.error('Upload failed'); });
|
|
22354
23236
|
}
|
|
22355
23237
|
downloadTemplate() {
|
|
@@ -22414,7 +23296,7 @@ class ImportDialogComponent {
|
|
|
22414
23296
|
this.loadRows(() => this.goToStep(2));
|
|
22415
23297
|
}
|
|
22416
23298
|
else {
|
|
22417
|
-
this.messageService.error(r?.message
|
|
23299
|
+
this.apiErrorService.presentAppFailure(r, 'submit', `import/map/${this.batchId}`); // Changed: was messageService.error(r?.message ...). 'submit' is literally true here — the mapping the user built is still on screen and does not have to be redone
|
|
22418
23300
|
}
|
|
22419
23301
|
}, () => { this.loading = false; this.messageService.error('Mapping failed'); });
|
|
22420
23302
|
}
|
|
@@ -22432,6 +23314,8 @@ class ImportDialogComponent {
|
|
|
22432
23314
|
}
|
|
22433
23315
|
loadRows(after) {
|
|
22434
23316
|
this.httpService.Get(`import/rows/${this.batchId}`).subscribe(r => {
|
|
23317
|
+
if (!r?.success)
|
|
23318
|
+
this.apiErrorService.presentAppFailure(r, 'load', `import/rows/${this.batchId}`); // Added: was silent. A failed row fetch fell through to [] and rendered an EMPTY review grid — indistinguishable from a spreadsheet that mapped to nothing, which is the exact "empty or broken?" ambiguity WS-6 exists to kill
|
|
22435
23319
|
this.rows = (r?.success && r.data) ? r.data : [];
|
|
22436
23320
|
this.buildReviewTable(); // Changed: (re)build the spa-table config + flattened rows whenever fresh rows land
|
|
22437
23321
|
if (after)
|
|
@@ -22462,18 +23346,29 @@ class ImportDialogComponent {
|
|
|
22462
23346
|
this.loading = true;
|
|
22463
23347
|
this.httpService.Post(`import/row/${row.importRowID}`, values).subscribe(r => {
|
|
22464
23348
|
this.loading = false;
|
|
22465
|
-
this.editingRowId = null;
|
|
22466
|
-
this.editingRow = null; // Changed: close the edit panel
|
|
22467
23349
|
if (r?.success) {
|
|
23350
|
+
this.editingRowId = null; // Changed: only close the edit panel once the save actually succeeded
|
|
23351
|
+
this.editingRow = null;
|
|
22468
23352
|
this.refreshBatch();
|
|
22469
23353
|
}
|
|
22470
23354
|
else {
|
|
22471
|
-
this.
|
|
23355
|
+
// Changed: the panel is no longer closed before this branch. It was closed unconditionally, which
|
|
23356
|
+
// discarded everything the user had typed the moment a save failed — they had to reopen the row and
|
|
23357
|
+
// re-enter it from memory. The panel now stays open with editModel intact; Cancel remains the way out.
|
|
23358
|
+
// Changed: context is now 'submit' rather than 'action'. 'action' was chosen only because the panel
|
|
23359
|
+
// used to close regardless, which made the 'submit' copy ("your details are still on the form") a lie.
|
|
23360
|
+
// That is now literally true, and this is exactly the case that copy exists for — one deliberate save,
|
|
23361
|
+
// with its own dedupe key, where a missed message makes the user press Save again.
|
|
23362
|
+
this.apiErrorService.presentAppFailure(r, 'submit', `import/row/${row.importRowID}`);
|
|
22472
23363
|
}
|
|
22473
23364
|
}, () => { this.loading = false; this.messageService.error('Could not update row'); });
|
|
22474
23365
|
}
|
|
22475
23366
|
refreshBatch() {
|
|
22476
23367
|
this.httpService.Get(`import/batch/${this.batchId}`).subscribe(r => {
|
|
23368
|
+
// Deliberately silent: loadRows() below fires against the same backend a moment later and DOES report,
|
|
23369
|
+
// so anything that breaks this call is reported there. Adding a second call site for one outage is the
|
|
23370
|
+
// dialog storm this channel was built to stop. Counts going stale is the only consequence, and the
|
|
23371
|
+
// very next successful refresh corrects them. (WS-6 Phase 4 triage)
|
|
22477
23372
|
if (r?.success && r.data)
|
|
22478
23373
|
this.applyBatch(r.data);
|
|
22479
23374
|
this.loadRows();
|
|
@@ -22497,7 +23392,7 @@ class ImportDialogComponent {
|
|
|
22497
23392
|
this.config.onComplete(r.data);
|
|
22498
23393
|
}
|
|
22499
23394
|
else {
|
|
22500
|
-
this.messageService.error(r?.message
|
|
23395
|
+
this.apiErrorService.presentAppFailure(r, 'submit', `import/commit/${this.batchId}`); // Changed: was messageService.error(r?.message ...). This is the one press in the whole wizard that writes records, so it is exactly where the 'submit' copy — nothing was saved, do not press repeatedly, it can create duplicates — has to appear
|
|
22501
23396
|
this.refreshBatch();
|
|
22502
23397
|
}
|
|
22503
23398
|
}, () => { this.loading = false; this.messageService.error('Import failed'); });
|
|
@@ -22536,7 +23431,7 @@ class ImportDialogComponent {
|
|
|
22536
23431
|
this.goToStep(0); // batch is Reversed — start fresh
|
|
22537
23432
|
}
|
|
22538
23433
|
else {
|
|
22539
|
-
this.messageService.error(r?.message
|
|
23434
|
+
this.apiErrorService.presentAppFailure(r, 'action', `import/reverse/${this.batchId}`); // Changed: was messageService.error(r?.message ...). 'action' rather than 'submit' — this sits behind a confirm, there is no form still holding the user's input, and the 'submit' wording would describe a screen that is not there
|
|
22540
23435
|
}
|
|
22541
23436
|
}, () => { this.loading = false; this.messageService.error('Undo failed'); });
|
|
22542
23437
|
});
|
|
@@ -22590,13 +23485,13 @@ class ImportDialogComponent {
|
|
|
22590
23485
|
goToStep(index) {
|
|
22591
23486
|
this.stepIndex = index; // Changed: drives the spa-steps indicator + the *ngIf step panes directly
|
|
22592
23487
|
}
|
|
22593
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: ImportDialogComponent, deps: [{ token: HttpService }, { token: i1.HttpClient }, { token: MessageService }, { token: i4.MatDialogRef }, { token: MAT_DIALOG_DATA }], target: i0.ɵɵFactoryTarget.Component }); }
|
|
22594
|
-
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: ImportDialogComponent, isStandalone: false, selector: "spa-import-dialog", ngImport: i0, template: "<!-- Changed: shell rebuilt on the details-dialog conventions \u2014 modern band header (tin-dlg-head), scrollable\n mat-dialog-content, banded mat-dialog-actions footer (left-aligned, house rule). The stepper is spa-steps\n as a read-only position indicator driven by stepIndex; navigation is ONLY via the footer buttons. -->\n<div class=\"import-dialog\">\n\n <div class=\"tin-dlg-head import-head\">\n <h2 class=\"tin-dlg-title\">{{ title }}</h2>\n <button mat-icon-button class=\"import-close\" (click)=\"close()\" aria-label=\"Close\"><mat-icon>close</mat-icon></button>\n </div>\n\n <mat-progress-bar *ngIf=\"loading\" mode=\"indeterminate\" class=\"import-load-line\"></mat-progress-bar>\n\n <mat-dialog-content class=\"mat-typography dialog-scroll-content\">\n\n <spa-steps class=\"import-steps\" [config]=\"stepsConfig\" [activeIndex]=\"stepIndex\"></spa-steps>\n\n <!-- Step 1: Upload -->\n <div *ngIf=\"stepIndex === 0\" class=\"step-body\">\n <p class=\"hint\">Upload an Excel (.xlsx) file. Not sure of the format? Download a template with the expected columns.</p>\n <div class=\"upload-row\">\n <button mat-stroked-button color=\"primary\" (click)=\"fileInput.click()\"><mat-icon>attach_file</mat-icon>{{ selectedFile?.name || 'Choose file' }}</button>\n <input type=\"file\" #fileInput accept=\".xlsx\" (change)=\"onFileSelected($event)\" hidden />\n <button mat-stroked-button color=\"primary\" (click)=\"downloadTemplate()\"><mat-icon>download</mat-icon> Download template</button>\n </div>\n </div>\n\n <!-- Step 2: Map columns -->\n <div *ngIf=\"stepIndex === 1\" class=\"step-body\">\n <div *ngIf=\"unmappedRequired.length\" class=\"alert alert-warn\">\n Required fields not yet mapped: <strong>{{ unmappedRequired.join(', ') }}</strong>\n </div>\n\n <table class=\"map-table\">\n <thead>\n <tr><th>Spreadsheet column</th><th>Sample values</th><th>Maps to field</th><th>Match</th></tr>\n </thead>\n <tbody>\n <tr *ngFor=\"let m of mappings\">\n <td class=\"col-header\">{{ m.header }}</td>\n <td class=\"col-samples\">\n <span *ngFor=\"let s of samplesFor(m.header)\" class=\"sample\">{{ s }}</span>\n </td>\n <td>\n <mat-select [(ngModel)]=\"m.property\" (selectionChange)=\"onMappingChange(m)\" placeholder=\"\u2014 Ignore \u2014\">\n <mat-option [value]=\"null\">\u2014 Ignore \u2014</mat-option>\n <mat-option *ngFor=\"let f of availableFields(m.header)\" [value]=\"f.property\">\n {{ f.display }}<span *ngIf=\"f.required\"> *</span>\n </mat-option>\n </mat-select>\n </td>\n <td>\n <span class=\"chip\" [ngClass]=\"confidenceClass(m)\" [matTooltip]=\"m.reasoning || ''\">\n {{ m.property ? (m.method === 'AI' ? ((m.confidence * 100) | number:'1.0-0') + '%' : m.method) : 'Unmapped' }}\n </span>\n </td>\n </tr>\n </tbody>\n </table>\n </div>\n\n <!-- Step 3: Review -->\n <div *ngIf=\"stepIndex === 2\" class=\"step-body\">\n <!-- Changed: row edit panel \u2014 sits above the table because saving re-validates via the import endpoint -->\n <div class=\"edit-panel\" *ngIf=\"editingRow\">\n <div class=\"edit-title\">Edit row {{ editingRow.rowNumber }}</div>\n <div class=\"edit-fields\">\n <label class=\"edit-field\" *ngFor=\"let col of reviewColumns\">\n <span>{{ col.display }}</span>\n <input class=\"cell-input\" [(ngModel)]=\"editModel[col.property]\" />\n </label>\n </div>\n <div class=\"edit-actions\">\n <button mat-flat-button color=\"primary\" [disabled]=\"loading\" (click)=\"saveEdit()\">Save</button>\n <button mat-stroked-button (click)=\"cancelEdit()\">Cancel</button>\n </div>\n </div>\n\n <!-- Changed: the review grid IS spa-table with its INTEGRATED tiles (tileConfig on the TableConfig):\n clickable icon tiles filter the rows in memory \u2014 the \"Only show rows with problems\" checkbox is gone -->\n <spa-table *ngIf=\"reviewTableConfig\" [config]=\"reviewTableConfig\" [data]=\"reviewRows\" [tileData]=\"counts\"></spa-table>\n </div>\n\n <!-- Step 4: Done -->\n <div *ngIf=\"stepIndex === 3\" class=\"step-body done-body\">\n <mat-icon class=\"done-icon\">check_circle</mat-icon>\n <h3>Imported {{ committedCount }} record(s)</h3>\n <p *ngIf=\"counts.warning > 0\" class=\"hint\">{{ counts.warning }} row(s) imported with warnings.</p>\n </div>\n\n </mat-dialog-content>\n\n <!-- Changed: per-step actions moved into the banded footer, LEFT-aligned (house rule) -->\n <mat-dialog-actions>\n <button mat-flat-button color=\"primary\" *ngIf=\"stepIndex === 0\" [disabled]=\"!selectedFile || loading\" (click)=\"doUpload()\">Upload</button>\n <button mat-flat-button color=\"primary\" *ngIf=\"stepIndex === 1\" [disabled]=\"unmappedRequired.length > 0 || loading\" (click)=\"confirmMapping()\">Next</button>\n <button mat-flat-button color=\"primary\" *ngIf=\"stepIndex === 2\" [disabled]=\"counts.error > 0 || loading\" (click)=\"doCommit()\">Import {{ counts.total - counts.error }} record(s)</button>\n <button mat-stroked-button *ngIf=\"stepIndex === 1 || stepIndex === 2\" (click)=\"startOver()\">Start over</button>\n <button mat-flat-button color=\"primary\" *ngIf=\"stepIndex === 3\" (click)=\"close()\">Close</button>\n <button mat-stroked-button *ngIf=\"stepIndex === 3 && reversible && committedCount > 0\" [disabled]=\"loading\" (click)=\"undoImport()\"><mat-icon>undo</mat-icon>Undo import</button> <!-- Added (lifecycle) -->\n <button mat-button *ngIf=\"stepIndex !== 3\" (click)=\"close()\">Close</button>\n </mat-dialog-actions>\n\n</div>\n", styles: [".import-dialog{display:flex;flex-direction:column}.import-head{display:flex;align-items:center}.import-close{margin-left:auto}.import-load-line{height:2px}.import-steps{display:block;margin-bottom:4px}.import-tiles{display:block}.step-body{padding:12px 8px;display:flex;flex-direction:column;gap:14px}.hint{color:#0009;font-size:13px;margin:0}.upload-row{display:flex;align-items:center;gap:16px;flex-wrap:wrap}.alert{padding:8px 12px;border-radius:4px;font-size:13px}.alert-warn{background:#fff3e0;color:#8a5300;border:1px solid #ffcc80}.map-table{width:100%;border-collapse:collapse}.map-table th,.map-table td{text-align:left;padding:6px 8px;border-bottom:1px solid #eee;vertical-align:middle}.map-table th{font-size:12px;color:#0009;font-weight:600}.col-header{font-weight:600}.col-samples .sample{display:inline-block;background:#f2f2f2;border-radius:3px;padding:1px 6px;margin:1px 3px 1px 0;font-size:12px;color:#555}.chip{display:inline-block;padding:2px 8px;border-radius:10px;font-size:12px;color:#fff}.chip-green{background:#2e7d32}.chip-blue{background:#1565c0}.chip-orange{background:#ef6c00}.chip-grey{background:#9e9e9e}.chip-red{background:#c62828}.edit-panel{border:1px solid rgba(0,0,0,.12);border-radius:10px;padding:12px;display:flex;flex-direction:column;gap:10px}.edit-title{font-size:13px;font-weight:600;color:#000000b3}.edit-fields{display:flex;gap:12px;flex-wrap:wrap}.edit-field{display:flex;flex-direction:column;gap:4px;font-size:12px;color:#0009;min-width:160px}.edit-actions{display:flex;gap:8px}.cell-input{box-sizing:border-box;padding:6px 8px;border:1px solid rgba(0,0,0,.23);border-radius:4px;font:inherit}.done-body{align-items:center;text-align:center;padding:32px 8px}.done-icon{color:#2e7d32;font-size:56px;height:56px;width:56px}\n"], dependencies: [{ kind: "directive", type: i1$2.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: i1$2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1$2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i2$2.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i2$2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i2$2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: i3.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i3.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i4$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "directive", type: i4.MatDialogActions, selector: "[mat-dialog-actions], mat-dialog-actions, [matDialogActions]", inputs: ["align"] }, { kind: "directive", type: i4.MatDialogContent, selector: "[mat-dialog-content], mat-dialog-content, [matDialogContent]" }, { kind: "component", type: i7$1.MatSelect, selector: "mat-select", inputs: ["aria-describedby", "panelClass", "disabled", "disableRipple", "tabIndex", "hideSingleSelectionIndicator", "placeholder", "required", "multiple", "disableOptionCentering", "compareWith", "value", "aria-label", "aria-labelledby", "errorStateMatcher", "typeaheadDebounceInterval", "sortComparator", "id", "panelWidth", "canSelectNullableOptions"], outputs: ["openedChange", "opened", "closed", "selectionChange", "valueChange"], exportAs: ["matSelect"] }, { kind: "component", type: i7$1.MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "directive", type: i7.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: i5.MatProgressBar, selector: "mat-progress-bar", inputs: ["color", "value", "bufferValue", "mode"], outputs: ["animationEnd"], exportAs: ["matProgressBar"] }, { kind: "component", type: StepsComponent, selector: "spa-steps", inputs: ["value", "config", "data", "activeIndex"] }, { kind: "component", type: TableComponent, selector: "spa-table", inputs: ["data", "tileData", "config", "localMode", "parentDetails", "reload", "activeTab", "inTab", "nestingLevel"], outputs: ["dataLoad", "totalChange", "actionSuccess", "refreshClick", "searchClick", "createClick", "actionClick", "inputChange", "actionResponse"] }, { kind: "pipe", type: i1$2.DecimalPipe, name: "number" }] }); }
|
|
23488
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: ImportDialogComponent, deps: [{ token: HttpService }, { token: i1.HttpClient }, { token: MessageService }, { token: ApiErrorService }, { token: i4.MatDialogRef }, { token: MAT_DIALOG_DATA }], target: i0.ɵɵFactoryTarget.Component }); }
|
|
23489
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: ImportDialogComponent, isStandalone: false, selector: "spa-import-dialog", ngImport: i0, template: "<!-- Changed: shell rebuilt on the details-dialog conventions \u2014 modern band header (tin-dlg-head), scrollable\n mat-dialog-content, banded mat-dialog-actions footer (left-aligned, house rule). The stepper is spa-steps\n as a read-only position indicator driven by stepIndex; navigation is ONLY via the footer buttons. -->\n<div class=\"import-dialog\">\n\n <div class=\"tin-dlg-head import-head\">\n <h2 class=\"tin-dlg-title\">{{ title }}</h2>\n <button mat-icon-button class=\"import-close\" (click)=\"close()\" aria-label=\"Close\"><mat-icon>close</mat-icon></button>\n </div>\n\n <mat-progress-bar *ngIf=\"loading\" mode=\"indeterminate\" class=\"import-load-line\"></mat-progress-bar>\n\n <mat-dialog-content class=\"mat-typography dialog-scroll-content\">\n\n <spa-steps class=\"import-steps\" [config]=\"stepsConfig\" [activeIndex]=\"stepIndex\"></spa-steps>\n\n <!-- Step 1: Upload -->\n <div *ngIf=\"stepIndex === 0\" class=\"step-body\">\n <p class=\"hint\">Upload an Excel (.xlsx) file. Not sure of the format? Download a template with the expected columns.</p>\n <div class=\"upload-row\">\n <button mat-stroked-button color=\"primary\" (click)=\"fileInput.click()\"><mat-icon>attach_file</mat-icon>{{ selectedFile?.name || 'Choose file' }}</button>\n <input type=\"file\" #fileInput accept=\".xlsx\" (change)=\"onFileSelected($event)\" hidden />\n <button mat-stroked-button color=\"primary\" (click)=\"downloadTemplate()\"><mat-icon>download</mat-icon> Download template</button>\n </div>\n </div>\n\n <!-- Step 2: Map columns -->\n <div *ngIf=\"stepIndex === 1\" class=\"step-body\">\n <div *ngIf=\"unmappedRequired.length\" class=\"alert alert-warn\">\n Required fields not yet mapped: <strong>{{ unmappedRequired.join(', ') }}</strong>\n </div>\n\n <table class=\"map-table\">\n <thead>\n <tr><th>Spreadsheet column</th><th>Sample values</th><th>Maps to field</th><th>Match</th></tr>\n </thead>\n <tbody>\n <tr *ngFor=\"let m of mappings\">\n <td class=\"col-header\">{{ m.header }}</td>\n <td class=\"col-samples\">\n <span *ngFor=\"let s of samplesFor(m.header)\" class=\"sample\">{{ s }}</span>\n </td>\n <td>\n <mat-select [(ngModel)]=\"m.property\" (selectionChange)=\"onMappingChange(m)\" placeholder=\"\u2014 Ignore \u2014\">\n <mat-option [value]=\"null\">\u2014 Ignore \u2014</mat-option>\n <mat-option *ngFor=\"let f of availableFields(m.header)\" [value]=\"f.property\">\n {{ f.display }}<span *ngIf=\"f.required\"> *</span>\n </mat-option>\n </mat-select>\n </td>\n <td>\n <span class=\"chip\" [ngClass]=\"confidenceClass(m)\" [matTooltip]=\"m.reasoning || ''\">\n {{ m.property ? (m.method === 'AI' ? ((m.confidence * 100) | number:'1.0-0') + '%' : m.method) : 'Unmapped' }}\n </span>\n </td>\n </tr>\n </tbody>\n </table>\n </div>\n\n <!-- Step 3: Review -->\n <div *ngIf=\"stepIndex === 2\" class=\"step-body\">\n <!-- Changed: row edit panel \u2014 sits above the table because saving re-validates via the import endpoint -->\n <div class=\"edit-panel\" *ngIf=\"editingRow\">\n <div class=\"edit-title\">Edit row {{ editingRow.rowNumber }}</div>\n <div class=\"edit-fields\">\n <label class=\"edit-field\" *ngFor=\"let col of reviewColumns\">\n <span>{{ col.display }}</span>\n <input class=\"cell-input\" [(ngModel)]=\"editModel[col.property]\" />\n </label>\n </div>\n <div class=\"edit-actions\">\n <button mat-flat-button color=\"primary\" [disabled]=\"loading\" (click)=\"saveEdit()\">Save</button>\n <button mat-stroked-button (click)=\"cancelEdit()\">Cancel</button>\n </div>\n </div>\n\n <!-- Changed: the review grid IS spa-table with its INTEGRATED tiles (tileConfig on the TableConfig):\n clickable icon tiles filter the rows in memory \u2014 the \"Only show rows with problems\" checkbox is gone -->\n <spa-table *ngIf=\"reviewTableConfig\" [config]=\"reviewTableConfig\" [data]=\"reviewRows\" [tileData]=\"counts\"></spa-table>\n </div>\n\n <!-- Step 4: Done -->\n <div *ngIf=\"stepIndex === 3\" class=\"step-body done-body\">\n <mat-icon class=\"done-icon\">check_circle</mat-icon>\n <h3>Imported {{ committedCount }} record(s)</h3>\n <p *ngIf=\"counts.warning > 0\" class=\"hint\">{{ counts.warning }} row(s) imported with warnings.</p>\n </div>\n\n </mat-dialog-content>\n\n <!-- Changed: per-step actions moved into the banded footer, LEFT-aligned (house rule) -->\n <mat-dialog-actions>\n <button mat-flat-button color=\"primary\" *ngIf=\"stepIndex === 0\" [disabled]=\"!selectedFile || loading\" (click)=\"doUpload()\">Upload</button>\n <button mat-flat-button color=\"primary\" *ngIf=\"stepIndex === 1\" [disabled]=\"unmappedRequired.length > 0 || loading\" (click)=\"confirmMapping()\">Next</button>\n <button mat-flat-button color=\"primary\" *ngIf=\"stepIndex === 2\" [disabled]=\"counts.error > 0 || loading\" (click)=\"doCommit()\">Import {{ counts.total - counts.error }} record(s)</button>\n <button mat-stroked-button *ngIf=\"stepIndex === 1 || stepIndex === 2\" (click)=\"startOver()\">Start over</button>\n <button mat-flat-button color=\"primary\" *ngIf=\"stepIndex === 3\" (click)=\"close()\">Close</button>\n <button mat-stroked-button *ngIf=\"stepIndex === 3 && reversible && committedCount > 0\" [disabled]=\"loading\" (click)=\"undoImport()\"><mat-icon>undo</mat-icon>Undo import</button> <!-- Added (lifecycle) -->\n <button mat-button *ngIf=\"stepIndex !== 3\" (click)=\"close()\">Close</button>\n </mat-dialog-actions>\n\n</div>\n", styles: [".import-dialog{display:flex;flex-direction:column}.import-head{display:flex;align-items:center}.import-close{margin-left:auto}.import-load-line{height:2px}.import-steps{display:block;margin-bottom:4px}.import-tiles{display:block}.step-body{padding:12px 8px;display:flex;flex-direction:column;gap:14px}.hint{color:#0009;font-size:13px;margin:0}.upload-row{display:flex;align-items:center;gap:16px;flex-wrap:wrap}.alert{padding:8px 12px;border-radius:4px;font-size:13px}.alert-warn{background:#fff3e0;color:#8a5300;border:1px solid #ffcc80}.map-table{width:100%;border-collapse:collapse}.map-table th,.map-table td{text-align:left;padding:6px 8px;border-bottom:1px solid #eee;vertical-align:middle}.map-table th{font-size:12px;color:#0009;font-weight:600}.col-header{font-weight:600}.col-samples .sample{display:inline-block;background:#f2f2f2;border-radius:3px;padding:1px 6px;margin:1px 3px 1px 0;font-size:12px;color:#555}.chip{display:inline-block;padding:2px 8px;border-radius:10px;font-size:12px;color:#fff}.chip-green{background:#2e7d32}.chip-blue{background:#1565c0}.chip-orange{background:#ef6c00}.chip-grey{background:#9e9e9e}.chip-red{background:#c62828}.edit-panel{border:1px solid rgba(0,0,0,.12);border-radius:10px;padding:12px;display:flex;flex-direction:column;gap:10px}.edit-title{font-size:13px;font-weight:600;color:#000000b3}.edit-fields{display:flex;gap:12px;flex-wrap:wrap}.edit-field{display:flex;flex-direction:column;gap:4px;font-size:12px;color:#0009;min-width:160px}.edit-actions{display:flex;gap:8px}.cell-input{box-sizing:border-box;padding:6px 8px;border:1px solid rgba(0,0,0,.23);border-radius:4px;font:inherit}.done-body{align-items:center;text-align:center;padding:32px 8px}.done-icon{color:#2e7d32;font-size:56px;height:56px;width:56px}\n"], dependencies: [{ kind: "directive", type: i1$2.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: i1$2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1$2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i2$2.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i2$2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i2$2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: i3.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i3.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i4$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "directive", type: i4.MatDialogActions, selector: "[mat-dialog-actions], mat-dialog-actions, [matDialogActions]", inputs: ["align"] }, { kind: "directive", type: i4.MatDialogContent, selector: "[mat-dialog-content], mat-dialog-content, [matDialogContent]" }, { kind: "component", type: i7$1.MatSelect, selector: "mat-select", inputs: ["aria-describedby", "panelClass", "disabled", "disableRipple", "tabIndex", "hideSingleSelectionIndicator", "placeholder", "required", "multiple", "disableOptionCentering", "compareWith", "value", "aria-label", "aria-labelledby", "errorStateMatcher", "typeaheadDebounceInterval", "sortComparator", "id", "panelWidth", "canSelectNullableOptions"], outputs: ["openedChange", "opened", "closed", "selectionChange", "valueChange"], exportAs: ["matSelect"] }, { kind: "component", type: i7$1.MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "directive", type: i7.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: i6$1.MatProgressBar, selector: "mat-progress-bar", inputs: ["color", "value", "bufferValue", "mode"], outputs: ["animationEnd"], exportAs: ["matProgressBar"] }, { kind: "component", type: StepsComponent, selector: "spa-steps", inputs: ["value", "config", "data", "activeIndex"] }, { kind: "component", type: TableComponent, selector: "spa-table", inputs: ["data", "tileData", "config", "localMode", "parentDetails", "reload", "activeTab", "inTab", "nestingLevel"], outputs: ["dataLoad", "totalChange", "actionSuccess", "refreshClick", "searchClick", "createClick", "actionClick", "inputChange", "actionResponse"] }, { kind: "pipe", type: i1$2.DecimalPipe, name: "number" }] }); }
|
|
22595
23490
|
}
|
|
22596
23491
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: ImportDialogComponent, decorators: [{
|
|
22597
23492
|
type: Component,
|
|
22598
23493
|
args: [{ selector: 'spa-import-dialog', standalone: false, template: "<!-- Changed: shell rebuilt on the details-dialog conventions \u2014 modern band header (tin-dlg-head), scrollable\n mat-dialog-content, banded mat-dialog-actions footer (left-aligned, house rule). The stepper is spa-steps\n as a read-only position indicator driven by stepIndex; navigation is ONLY via the footer buttons. -->\n<div class=\"import-dialog\">\n\n <div class=\"tin-dlg-head import-head\">\n <h2 class=\"tin-dlg-title\">{{ title }}</h2>\n <button mat-icon-button class=\"import-close\" (click)=\"close()\" aria-label=\"Close\"><mat-icon>close</mat-icon></button>\n </div>\n\n <mat-progress-bar *ngIf=\"loading\" mode=\"indeterminate\" class=\"import-load-line\"></mat-progress-bar>\n\n <mat-dialog-content class=\"mat-typography dialog-scroll-content\">\n\n <spa-steps class=\"import-steps\" [config]=\"stepsConfig\" [activeIndex]=\"stepIndex\"></spa-steps>\n\n <!-- Step 1: Upload -->\n <div *ngIf=\"stepIndex === 0\" class=\"step-body\">\n <p class=\"hint\">Upload an Excel (.xlsx) file. Not sure of the format? Download a template with the expected columns.</p>\n <div class=\"upload-row\">\n <button mat-stroked-button color=\"primary\" (click)=\"fileInput.click()\"><mat-icon>attach_file</mat-icon>{{ selectedFile?.name || 'Choose file' }}</button>\n <input type=\"file\" #fileInput accept=\".xlsx\" (change)=\"onFileSelected($event)\" hidden />\n <button mat-stroked-button color=\"primary\" (click)=\"downloadTemplate()\"><mat-icon>download</mat-icon> Download template</button>\n </div>\n </div>\n\n <!-- Step 2: Map columns -->\n <div *ngIf=\"stepIndex === 1\" class=\"step-body\">\n <div *ngIf=\"unmappedRequired.length\" class=\"alert alert-warn\">\n Required fields not yet mapped: <strong>{{ unmappedRequired.join(', ') }}</strong>\n </div>\n\n <table class=\"map-table\">\n <thead>\n <tr><th>Spreadsheet column</th><th>Sample values</th><th>Maps to field</th><th>Match</th></tr>\n </thead>\n <tbody>\n <tr *ngFor=\"let m of mappings\">\n <td class=\"col-header\">{{ m.header }}</td>\n <td class=\"col-samples\">\n <span *ngFor=\"let s of samplesFor(m.header)\" class=\"sample\">{{ s }}</span>\n </td>\n <td>\n <mat-select [(ngModel)]=\"m.property\" (selectionChange)=\"onMappingChange(m)\" placeholder=\"\u2014 Ignore \u2014\">\n <mat-option [value]=\"null\">\u2014 Ignore \u2014</mat-option>\n <mat-option *ngFor=\"let f of availableFields(m.header)\" [value]=\"f.property\">\n {{ f.display }}<span *ngIf=\"f.required\"> *</span>\n </mat-option>\n </mat-select>\n </td>\n <td>\n <span class=\"chip\" [ngClass]=\"confidenceClass(m)\" [matTooltip]=\"m.reasoning || ''\">\n {{ m.property ? (m.method === 'AI' ? ((m.confidence * 100) | number:'1.0-0') + '%' : m.method) : 'Unmapped' }}\n </span>\n </td>\n </tr>\n </tbody>\n </table>\n </div>\n\n <!-- Step 3: Review -->\n <div *ngIf=\"stepIndex === 2\" class=\"step-body\">\n <!-- Changed: row edit panel \u2014 sits above the table because saving re-validates via the import endpoint -->\n <div class=\"edit-panel\" *ngIf=\"editingRow\">\n <div class=\"edit-title\">Edit row {{ editingRow.rowNumber }}</div>\n <div class=\"edit-fields\">\n <label class=\"edit-field\" *ngFor=\"let col of reviewColumns\">\n <span>{{ col.display }}</span>\n <input class=\"cell-input\" [(ngModel)]=\"editModel[col.property]\" />\n </label>\n </div>\n <div class=\"edit-actions\">\n <button mat-flat-button color=\"primary\" [disabled]=\"loading\" (click)=\"saveEdit()\">Save</button>\n <button mat-stroked-button (click)=\"cancelEdit()\">Cancel</button>\n </div>\n </div>\n\n <!-- Changed: the review grid IS spa-table with its INTEGRATED tiles (tileConfig on the TableConfig):\n clickable icon tiles filter the rows in memory \u2014 the \"Only show rows with problems\" checkbox is gone -->\n <spa-table *ngIf=\"reviewTableConfig\" [config]=\"reviewTableConfig\" [data]=\"reviewRows\" [tileData]=\"counts\"></spa-table>\n </div>\n\n <!-- Step 4: Done -->\n <div *ngIf=\"stepIndex === 3\" class=\"step-body done-body\">\n <mat-icon class=\"done-icon\">check_circle</mat-icon>\n <h3>Imported {{ committedCount }} record(s)</h3>\n <p *ngIf=\"counts.warning > 0\" class=\"hint\">{{ counts.warning }} row(s) imported with warnings.</p>\n </div>\n\n </mat-dialog-content>\n\n <!-- Changed: per-step actions moved into the banded footer, LEFT-aligned (house rule) -->\n <mat-dialog-actions>\n <button mat-flat-button color=\"primary\" *ngIf=\"stepIndex === 0\" [disabled]=\"!selectedFile || loading\" (click)=\"doUpload()\">Upload</button>\n <button mat-flat-button color=\"primary\" *ngIf=\"stepIndex === 1\" [disabled]=\"unmappedRequired.length > 0 || loading\" (click)=\"confirmMapping()\">Next</button>\n <button mat-flat-button color=\"primary\" *ngIf=\"stepIndex === 2\" [disabled]=\"counts.error > 0 || loading\" (click)=\"doCommit()\">Import {{ counts.total - counts.error }} record(s)</button>\n <button mat-stroked-button *ngIf=\"stepIndex === 1 || stepIndex === 2\" (click)=\"startOver()\">Start over</button>\n <button mat-flat-button color=\"primary\" *ngIf=\"stepIndex === 3\" (click)=\"close()\">Close</button>\n <button mat-stroked-button *ngIf=\"stepIndex === 3 && reversible && committedCount > 0\" [disabled]=\"loading\" (click)=\"undoImport()\"><mat-icon>undo</mat-icon>Undo import</button> <!-- Added (lifecycle) -->\n <button mat-button *ngIf=\"stepIndex !== 3\" (click)=\"close()\">Close</button>\n </mat-dialog-actions>\n\n</div>\n", styles: [".import-dialog{display:flex;flex-direction:column}.import-head{display:flex;align-items:center}.import-close{margin-left:auto}.import-load-line{height:2px}.import-steps{display:block;margin-bottom:4px}.import-tiles{display:block}.step-body{padding:12px 8px;display:flex;flex-direction:column;gap:14px}.hint{color:#0009;font-size:13px;margin:0}.upload-row{display:flex;align-items:center;gap:16px;flex-wrap:wrap}.alert{padding:8px 12px;border-radius:4px;font-size:13px}.alert-warn{background:#fff3e0;color:#8a5300;border:1px solid #ffcc80}.map-table{width:100%;border-collapse:collapse}.map-table th,.map-table td{text-align:left;padding:6px 8px;border-bottom:1px solid #eee;vertical-align:middle}.map-table th{font-size:12px;color:#0009;font-weight:600}.col-header{font-weight:600}.col-samples .sample{display:inline-block;background:#f2f2f2;border-radius:3px;padding:1px 6px;margin:1px 3px 1px 0;font-size:12px;color:#555}.chip{display:inline-block;padding:2px 8px;border-radius:10px;font-size:12px;color:#fff}.chip-green{background:#2e7d32}.chip-blue{background:#1565c0}.chip-orange{background:#ef6c00}.chip-grey{background:#9e9e9e}.chip-red{background:#c62828}.edit-panel{border:1px solid rgba(0,0,0,.12);border-radius:10px;padding:12px;display:flex;flex-direction:column;gap:10px}.edit-title{font-size:13px;font-weight:600;color:#000000b3}.edit-fields{display:flex;gap:12px;flex-wrap:wrap}.edit-field{display:flex;flex-direction:column;gap:4px;font-size:12px;color:#0009;min-width:160px}.edit-actions{display:flex;gap:8px}.cell-input{box-sizing:border-box;padding:6px 8px;border:1px solid rgba(0,0,0,.23);border-radius:4px;font:inherit}.done-body{align-items:center;text-align:center;padding:32px 8px}.done-icon{color:#2e7d32;font-size:56px;height:56px;width:56px}\n"] }]
|
|
22599
|
-
}], ctorParameters: () => [{ type: HttpService }, { type: i1.HttpClient }, { type: MessageService }, { type: i4.MatDialogRef }, { type: undefined, decorators: [{
|
|
23494
|
+
}], ctorParameters: () => [{ type: HttpService }, { type: i1.HttpClient }, { type: MessageService }, { type: ApiErrorService }, { type: i4.MatDialogRef }, { type: undefined, decorators: [{
|
|
22600
23495
|
type: Inject,
|
|
22601
23496
|
args: [MAT_DIALOG_DATA]
|
|
22602
23497
|
}] }] });
|
|
@@ -24503,6 +25398,7 @@ class UsersComponent {
|
|
|
24503
25398
|
this.dataService = inject(DataServiceLib);
|
|
24504
25399
|
this.dialog = inject(MatDialog);
|
|
24505
25400
|
this.messageService = inject(MessageService);
|
|
25401
|
+
this.apiErrorService = inject(ApiErrorService); // Added: classifies the AD directory lookup failure
|
|
24506
25402
|
this.userBaseFormConfig = {
|
|
24507
25403
|
security: { allow: [this.dataService.capUsers] }, // Added: gate user form by users cap
|
|
24508
25404
|
fields: [
|
|
@@ -24545,7 +25441,12 @@ class UsersComponent {
|
|
|
24545
25441
|
this.messageService.toast('AD user found');
|
|
24546
25442
|
}
|
|
24547
25443
|
else {
|
|
24548
|
-
|
|
25444
|
+
// Changed: was toast(response.message || 'AD user not found'), which echoed whatever the
|
|
25445
|
+
// directory lookup returned. CONVERTED rather than left silent because the user is watching
|
|
25446
|
+
// three fields that were supposed to auto-fill and did not. Behaviour is unchanged for the
|
|
25447
|
+
// common case — "AD user not found" is a human sentence, so classifyMessage keeps it a
|
|
25448
|
+
// toast — while an LDAP or connection dump now gets replaced instead of displayed.
|
|
25449
|
+
this.apiErrorService.presentAppFailure(response, 'load', `user/ad/${data.userName}`);
|
|
24549
25450
|
}
|
|
24550
25451
|
});
|
|
24551
25452
|
}
|
|
@@ -25320,6 +26221,9 @@ class AgingComponent {
|
|
|
25320
26221
|
if (apiResponse.success) {
|
|
25321
26222
|
this.summaryData = apiResponse.data;
|
|
25322
26223
|
}
|
|
26224
|
+
else {
|
|
26225
|
+
this.apiErrorService.presentAppFailure(apiResponse, 'load', 'invoices/aging-summary/x'); // Added: was silent. summaryData stayed undefined, the *ngIf dropped the whole tile strip, and an accounts-receivable page with NO aging totals reads as "you are owed nothing" rather than "we could not work it out"
|
|
26226
|
+
}
|
|
25323
26227
|
});
|
|
25324
26228
|
}
|
|
25325
26229
|
// Changed: Post IFRS 9 bad-debt provision — confirms, calls invoices provision custom action, shows returned summary
|
|
@@ -25327,9 +26231,17 @@ class AgingComponent {
|
|
|
25327
26231
|
this.messageService.confirm('Post the bad-debt provision? This posts the allowance delta per the aging matrix as a GL journal.').subscribe((result) => {
|
|
25328
26232
|
if (result == 'yes') {
|
|
25329
26233
|
this.dataServiceLib.CallApi({ url: 'invoices?action=provision', method: 'post' }, {}).subscribe((apiResponse) => {
|
|
25330
|
-
|
|
25331
|
-
|
|
26234
|
+
// Changed: REPLACES the old `'Error: ' + apiResponse.message` toast rather than layering on it.
|
|
26235
|
+
// That string prefixed the server's raw words with a second word for "error" and pushed the useful
|
|
26236
|
+
// part right; a leaked SQL fragment reached the user unchanged. 'action' rather than 'submit' —
|
|
26237
|
+
// this is a confirmed page button, not a form, so the 'submit' copy would describe a screen the
|
|
26238
|
+
// user is not looking at.
|
|
26239
|
+
if (apiResponse.success) {
|
|
26240
|
+
this.messageService.toast(apiResponse.message);
|
|
25332
26241
|
this.loadSummaryData();
|
|
26242
|
+
}
|
|
26243
|
+
else
|
|
26244
|
+
this.apiErrorService.presentAppFailure(apiResponse, 'action', 'invoices?action=provision');
|
|
25333
26245
|
});
|
|
25334
26246
|
}
|
|
25335
26247
|
});
|
|
@@ -25338,6 +26250,7 @@ class AgingComponent {
|
|
|
25338
26250
|
this.dataServiceLib = dataServiceLib;
|
|
25339
26251
|
this.accountingService = inject(AccountingService);
|
|
25340
26252
|
this.messageService = inject(MessageService); // Changed: Injected for provision confirm/toast
|
|
26253
|
+
this.apiErrorService = inject(ApiErrorService); // Changed: Injected for the summary load and the provision failure
|
|
25341
26254
|
this.tileConfig = this.accountingService.agingTileConfig;
|
|
25342
26255
|
// this.tileConfig = this.accountingService.agingTileConfig;
|
|
25343
26256
|
this.agingTableConfigs = [
|
|
@@ -25419,11 +26332,15 @@ class SupplierAgingComponent {
|
|
|
25419
26332
|
if (apiResponse.success) {
|
|
25420
26333
|
this.summaryData = apiResponse.data;
|
|
25421
26334
|
}
|
|
26335
|
+
else {
|
|
26336
|
+
this.apiErrorService.presentAppFailure(apiResponse, 'load', 'purchases/aging-summary/x'); // Added: was silent. The tile strip vanished behind its *ngIf, so an accounts-payable page that could not compute its totals looked like a company that owes nothing
|
|
26337
|
+
}
|
|
25422
26338
|
});
|
|
25423
26339
|
}
|
|
25424
26340
|
constructor(dataServiceLib) {
|
|
25425
26341
|
this.dataServiceLib = dataServiceLib;
|
|
25426
26342
|
this.purchasingService = inject(PurchasingService);
|
|
26343
|
+
this.apiErrorService = inject(ApiErrorService); // Changed: Injected so a failed summary load says so
|
|
25427
26344
|
this.tileConfig = this.purchasingService.apAgingTileConfig;
|
|
25428
26345
|
// Changed: Build 5-tab aging table layout using AP aging base config from PurchasingService
|
|
25429
26346
|
this.agingTableConfigs = [
|
|
@@ -25516,6 +26433,7 @@ class JournalEntryDialogComponent {
|
|
|
25516
26433
|
this.httpService = inject(HttpService);
|
|
25517
26434
|
this.http = inject(HttpClient);
|
|
25518
26435
|
this.messageService = inject(MessageService);
|
|
26436
|
+
this.apiErrorService = inject(ApiErrorService); // Changed: injected so a rejected post is classified rather than echoed
|
|
25519
26437
|
this.date = new Date().toISOString().substring(0, 10);
|
|
25520
26438
|
this.description = '';
|
|
25521
26439
|
this.journalType = 'Journal Entry'; // 'Opening Balance' posts the C2 take-on journal
|
|
@@ -25563,7 +26481,11 @@ class JournalEntryDialogComponent {
|
|
|
25563
26481
|
this.dialogRef.close(true);
|
|
25564
26482
|
}
|
|
25565
26483
|
else {
|
|
25566
|
-
|
|
26484
|
+
// Changed: was toast(res?.message ?? "Failed to post journal") — a 5-second snackbar at the bottom
|
|
26485
|
+
// of the screen carrying raw server text, while this dialog stayed open with every line still
|
|
26486
|
+
// filled in. That is the duplicate-record shape exactly: the obvious next move is to press Post
|
|
26487
|
+
// again. 'submit' because the entry IS still on the form and genuinely does not need re-typing.
|
|
26488
|
+
this.apiErrorService.presentAppFailure(res, 'submit', 'transactions/dto?action=create');
|
|
25567
26489
|
}
|
|
25568
26490
|
});
|
|
25569
26491
|
}
|
|
@@ -25815,7 +26737,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
|
|
|
25815
26737
|
}]
|
|
25816
26738
|
}] });
|
|
25817
26739
|
|
|
25818
|
-
// Statement page (B4) — invoice history plus open-item customer/supplier statements with Excel export
|
|
26740
|
+
// Statement page (B4) — invoice history plus open-item customer/supplier statements with Excel/PDF export
|
|
26741
|
+
// Changed: the two open-item tabs were hand-written markup (a flex row of totals and a raw <table>). They are
|
|
26742
|
+
// now the library's own spa-tiles + spa-table, driven by config in AccountingService, so the page picks up
|
|
26743
|
+
// every table behaviour centrally: responsive minColumns, the filter box, money formatting and empty states.
|
|
26744
|
+
// The PDF and Excel downloads are untouched — both are produced SERVER-SIDE and nothing here contributes to them.
|
|
25819
26745
|
class StatementComponent {
|
|
25820
26746
|
constructor() {
|
|
25821
26747
|
this.accountingService = inject(AccountingService);
|
|
@@ -25826,20 +26752,27 @@ class StatementComponent {
|
|
|
25826
26752
|
tableConfig: this.accountingService.statementTableConfig,
|
|
25827
26753
|
searchTableConfig: this.accountingService.statementSearchTableConfig
|
|
25828
26754
|
};
|
|
25829
|
-
// Per-kind UI state for the customer/supplier open-item tabs
|
|
26755
|
+
// Per-kind UI state for the customer/supplier open-item tabs.
|
|
26756
|
+
// Changed: each kind carries its OWN cloned table config — two grids are on screen at once and a TableConfig
|
|
26757
|
+
// is mutated by the table it belongs to, so sharing one object between the tabs would cross their state.
|
|
25830
26758
|
this.state = {
|
|
25831
|
-
customer: {
|
|
25832
|
-
|
|
26759
|
+
customer: {
|
|
26760
|
+
entityLabel: 'Customer', entityId: null, from: null, to: null, data: null, lines: [],
|
|
26761
|
+
listAction: { url: 'customers/list/x' },
|
|
26762
|
+
tileConfig: this.accountingService.customerStatementTileConfig,
|
|
26763
|
+
tableConfig: { ...this.accountingService.statementLinesTableConfig }
|
|
26764
|
+
},
|
|
26765
|
+
supplier: {
|
|
26766
|
+
entityLabel: 'Supplier', entityId: null, from: null, to: null, data: null, lines: [],
|
|
26767
|
+
listAction: { url: 'suppliers/list/x' },
|
|
26768
|
+
tileConfig: this.accountingService.supplierStatementTileConfig,
|
|
26769
|
+
tableConfig: { ...this.accountingService.statementLinesTableConfig }
|
|
26770
|
+
}
|
|
25833
26771
|
};
|
|
25834
26772
|
}
|
|
25835
|
-
|
|
25836
|
-
|
|
25837
|
-
|
|
25838
|
-
}
|
|
25839
|
-
loadOptions(kind, url) {
|
|
25840
|
-
this.http.get(`${this.httpService.apiUrl}${url}`)
|
|
25841
|
-
.subscribe(res => this.state[kind].options = res?.data ?? res?.Data ?? []);
|
|
25842
|
-
}
|
|
26773
|
+
// Changed: the customer/supplier option lists are loaded by spa-select-lite's own loadAction now, so there is
|
|
26774
|
+
// no hand-rolled options fetch left here. ngOnInit is kept for the interface and for future per-kind defaults.
|
|
26775
|
+
ngOnInit() { }
|
|
25843
26776
|
query(kind) {
|
|
25844
26777
|
const s = this.state[kind];
|
|
25845
26778
|
const params = [];
|
|
@@ -25851,10 +26784,16 @@ class StatementComponent {
|
|
|
25851
26784
|
}
|
|
25852
26785
|
load(kind) {
|
|
25853
26786
|
const s = this.state[kind];
|
|
26787
|
+
if (!s.entityId)
|
|
26788
|
+
return; // Added: the header refresh icon routes here too, and it must not fire before an entity is chosen
|
|
25854
26789
|
this.http.get(`${this.httpService.apiUrl}reports/${kind}-statement/${s.entityId}${this.query(kind)}`)
|
|
25855
|
-
.subscribe(res =>
|
|
26790
|
+
.subscribe(res => {
|
|
26791
|
+
s.data = res?.data ?? res?.Data ?? null;
|
|
26792
|
+
s.lines = s.data?.lines ?? []; // Changed: held as a stable reference — binding `data.lines || []` inline would hand spa-table a new array on every change-detection pass
|
|
26793
|
+
});
|
|
25856
26794
|
}
|
|
25857
|
-
// C8: open-item customer statement as PDF
|
|
26795
|
+
// C8: open-item customer statement as PDF — generated entirely server-side (reports/pdf/customer-statement);
|
|
26796
|
+
// this method only asks for the blob and saves it. Unchanged by the page rewrite.
|
|
25858
26797
|
exportPdf(kind) {
|
|
25859
26798
|
const s = this.state[kind];
|
|
25860
26799
|
this.http.get(`${this.httpService.apiUrl}reports/pdf/${kind}-statement${this.query(kind) ? this.query(kind) + '&' : '?'}${kind}Id=${s.entityId}`, { responseType: 'blob' })
|
|
@@ -25899,21 +26838,12 @@ class StatementComponent {
|
|
|
25899
26838
|
|
|
25900
26839
|
<ng-template #statementTab let-kind="kind">
|
|
25901
26840
|
<div style="padding: 16px 0;">
|
|
25902
|
-
|
|
25903
|
-
|
|
25904
|
-
|
|
25905
|
-
|
|
25906
|
-
|
|
25907
|
-
|
|
25908
|
-
</mat-form-field>
|
|
25909
|
-
<mat-form-field>
|
|
25910
|
-
<mat-label>From</mat-label>
|
|
25911
|
-
<input matInput type="date" [(ngModel)]="state[kind].from">
|
|
25912
|
-
</mat-form-field>
|
|
25913
|
-
<mat-form-field>
|
|
25914
|
-
<mat-label>To</mat-label>
|
|
25915
|
-
<input matInput type="date" [(ngModel)]="state[kind].to">
|
|
25916
|
-
</mat-form-field>
|
|
26841
|
+
|
|
26842
|
+
<!-- Changed: library inputs instead of raw mat-form-field markup — same three filters, same Run/Export/PDF rules -->
|
|
26843
|
+
<div style="display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 12px;">
|
|
26844
|
+
<spa-select-lite [display]="state[kind].entityLabel" [loadAction]="state[kind].listAction" optionDisplay="name" optionValue="value" [required]="false" [(value)]="state[kind].entityId" width="240px"></spa-select-lite><!-- optionDisplay/optionValue are NOT defaulted by spa-select-common; without them every option binds undefined and nothing can be selected -->
|
|
26845
|
+
<spa-date display="From" width="170px" [required]="false" [value]="state[kind].from" (valueChange)="state[kind].from = $event"></spa-date>
|
|
26846
|
+
<spa-date display="To" width="170px" [required]="false" [value]="state[kind].to" (valueChange)="state[kind].to = $event"></spa-date>
|
|
25917
26847
|
<button mat-flat-button color="primary" (click)="load(kind)" [disabled]="!state[kind].entityId">Run</button>
|
|
25918
26848
|
<button mat-stroked-button (click)="export(kind)" [disabled]="!state[kind].entityId">
|
|
25919
26849
|
<mat-icon>download</mat-icon> Export to Excel
|
|
@@ -25923,41 +26853,18 @@ class StatementComponent {
|
|
|
25923
26853
|
</button>
|
|
25924
26854
|
</div>
|
|
25925
26855
|
|
|
25926
|
-
|
|
25927
|
-
|
|
25928
|
-
|
|
25929
|
-
|
|
25930
|
-
|
|
25931
|
-
<
|
|
26856
|
+
<!-- Changed: totals are spa-tiles, lines are spa-table. Nothing renders until a statement has been run,
|
|
26857
|
+
which is exactly what the old *ngIf on the results card did. -->
|
|
26858
|
+
<ng-container *ngIf="state[kind].data">
|
|
26859
|
+
<spa-tiles [config]="state[kind].tileConfig" [data]="state[kind].data"></spa-tiles>
|
|
26860
|
+
<div style="margin-top: 12px;">
|
|
26861
|
+
<spa-table [config]="state[kind].tableConfig" [data]="state[kind].lines" (refreshClick)="load(kind)"></spa-table>
|
|
25932
26862
|
</div>
|
|
26863
|
+
</ng-container>
|
|
25933
26864
|
|
|
25934
|
-
<table style="width: 100%; border-collapse: collapse;" *ngIf="state[kind].data.lines?.length; else noLines">
|
|
25935
|
-
<tr style="text-align: left; border-bottom: 2px solid #333;">
|
|
25936
|
-
<th style="padding: 6px;">Date</th>
|
|
25937
|
-
<th style="padding: 6px;">Reference</th>
|
|
25938
|
-
<th style="padding: 6px;">Type</th>
|
|
25939
|
-
<th style="padding: 6px; text-align: right;">Total</th>
|
|
25940
|
-
<th style="padding: 6px; text-align: right;">Paid</th>
|
|
25941
|
-
<th style="padding: 6px; text-align: right;">Outstanding</th>
|
|
25942
|
-
<th style="padding: 6px;">Status</th>
|
|
25943
|
-
<th style="padding: 6px;">Aging</th>
|
|
25944
|
-
</tr>
|
|
25945
|
-
<tr *ngFor="let line of state[kind].data.lines" style="border-bottom: 1px solid #eee;">
|
|
25946
|
-
<td style="padding: 6px;">{{ line.date | date:'mediumDate' }}</td>
|
|
25947
|
-
<td style="padding: 6px;">{{ line.reference }}</td>
|
|
25948
|
-
<td style="padding: 6px;">{{ line.type }}</td>
|
|
25949
|
-
<td style="padding: 6px; text-align: right;">{{ line.total | number:'1.2-2' }}</td>
|
|
25950
|
-
<td style="padding: 6px; text-align: right;">{{ line.paid | number:'1.2-2' }}</td>
|
|
25951
|
-
<td style="padding: 6px; text-align: right;">{{ line.outstanding | number:'1.2-2' }}</td>
|
|
25952
|
-
<td style="padding: 6px;">{{ line.status }}</td>
|
|
25953
|
-
<td style="padding: 6px;">{{ line.agingBucket }}</td>
|
|
25954
|
-
</tr>
|
|
25955
|
-
</table>
|
|
25956
|
-
<ng-template #noLines><p><em>No open items for the selected period</em></p></ng-template>
|
|
25957
|
-
</mat-card>
|
|
25958
26865
|
</div>
|
|
25959
26866
|
</ng-template>
|
|
25960
|
-
`, isInline: true, dependencies: [{ kind: "directive", type: i1$2.
|
|
26867
|
+
`, isInline: true, dependencies: [{ kind: "directive", type: i1$2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i1$2.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "component", type: i3.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i4$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "component", type: i3$2.MatTab, selector: "mat-tab", inputs: ["disabled", "label", "aria-label", "aria-labelledby", "labelClass", "bodyClass", "id"], exportAs: ["matTab"] }, { kind: "component", type: i3$2.MatTabGroup, selector: "mat-tab-group", inputs: ["color", "fitInkBarToContent", "mat-stretch-tabs", "mat-align-tabs", "dynamicHeight", "selectedIndex", "headerPosition", "animationDuration", "contentTabIndex", "disablePagination", "disableRipple", "preserveContent", "backgroundColor", "aria-label", "aria-labelledby"], outputs: ["selectedIndexChange", "focusChange", "animationDone", "selectedTabChange"], exportAs: ["matTabGroup"] }, { kind: "component", type: TilesComponent, selector: "spa-tiles", inputs: ["config", "lastSearch", "data", "reload"], outputs: ["tileActionSelected", "tileClick", "tileUnClick"] }, { kind: "component", type: DateComponent, selector: "spa-date", inputs: ["required", "min", "max", "readonly", "hint", "value", "display", "placeholder", "width", "suffix", "infoMessage", "copyContent", "clearContent"], outputs: ["valueChange"] }, { kind: "component", type: TableComponent, selector: "spa-table", inputs: ["data", "tileData", "config", "localMode", "parentDetails", "reload", "activeTab", "inTab", "nestingLevel"], outputs: ["dataLoad", "totalChange", "actionSuccess", "refreshClick", "searchClick", "createClick", "actionClick", "inputChange", "actionResponse"] }, { kind: "component", type: PageComponent, selector: "spa-page", inputs: ["config"], outputs: ["searchModeActivated", "searchModeDeactivated", "refreshClick", "actionClick", "actionResponse", "inputChange", "createClick", "searchClick", "dataLoad", "titleActionChange"] }, { kind: "component", type: SelectLiteComponent, selector: "spa-select-lite" }] }); }
|
|
25961
26868
|
}
|
|
25962
26869
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: StatementComponent, decorators: [{
|
|
25963
26870
|
type: Component,
|
|
@@ -25982,21 +26889,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
|
|
|
25982
26889
|
|
|
25983
26890
|
<ng-template #statementTab let-kind="kind">
|
|
25984
26891
|
<div style="padding: 16px 0;">
|
|
25985
|
-
|
|
25986
|
-
|
|
25987
|
-
|
|
25988
|
-
|
|
25989
|
-
|
|
25990
|
-
|
|
25991
|
-
</mat-form-field>
|
|
25992
|
-
<mat-form-field>
|
|
25993
|
-
<mat-label>From</mat-label>
|
|
25994
|
-
<input matInput type="date" [(ngModel)]="state[kind].from">
|
|
25995
|
-
</mat-form-field>
|
|
25996
|
-
<mat-form-field>
|
|
25997
|
-
<mat-label>To</mat-label>
|
|
25998
|
-
<input matInput type="date" [(ngModel)]="state[kind].to">
|
|
25999
|
-
</mat-form-field>
|
|
26892
|
+
|
|
26893
|
+
<!-- Changed: library inputs instead of raw mat-form-field markup — same three filters, same Run/Export/PDF rules -->
|
|
26894
|
+
<div style="display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 12px;">
|
|
26895
|
+
<spa-select-lite [display]="state[kind].entityLabel" [loadAction]="state[kind].listAction" optionDisplay="name" optionValue="value" [required]="false" [(value)]="state[kind].entityId" width="240px"></spa-select-lite><!-- optionDisplay/optionValue are NOT defaulted by spa-select-common; without them every option binds undefined and nothing can be selected -->
|
|
26896
|
+
<spa-date display="From" width="170px" [required]="false" [value]="state[kind].from" (valueChange)="state[kind].from = $event"></spa-date>
|
|
26897
|
+
<spa-date display="To" width="170px" [required]="false" [value]="state[kind].to" (valueChange)="state[kind].to = $event"></spa-date>
|
|
26000
26898
|
<button mat-flat-button color="primary" (click)="load(kind)" [disabled]="!state[kind].entityId">Run</button>
|
|
26001
26899
|
<button mat-stroked-button (click)="export(kind)" [disabled]="!state[kind].entityId">
|
|
26002
26900
|
<mat-icon>download</mat-icon> Export to Excel
|
|
@@ -26006,38 +26904,15 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
|
|
|
26006
26904
|
</button>
|
|
26007
26905
|
</div>
|
|
26008
26906
|
|
|
26009
|
-
|
|
26010
|
-
|
|
26011
|
-
|
|
26012
|
-
|
|
26013
|
-
|
|
26014
|
-
<
|
|
26907
|
+
<!-- Changed: totals are spa-tiles, lines are spa-table. Nothing renders until a statement has been run,
|
|
26908
|
+
which is exactly what the old *ngIf on the results card did. -->
|
|
26909
|
+
<ng-container *ngIf="state[kind].data">
|
|
26910
|
+
<spa-tiles [config]="state[kind].tileConfig" [data]="state[kind].data"></spa-tiles>
|
|
26911
|
+
<div style="margin-top: 12px;">
|
|
26912
|
+
<spa-table [config]="state[kind].tableConfig" [data]="state[kind].lines" (refreshClick)="load(kind)"></spa-table>
|
|
26015
26913
|
</div>
|
|
26914
|
+
</ng-container>
|
|
26016
26915
|
|
|
26017
|
-
<table style="width: 100%; border-collapse: collapse;" *ngIf="state[kind].data.lines?.length; else noLines">
|
|
26018
|
-
<tr style="text-align: left; border-bottom: 2px solid #333;">
|
|
26019
|
-
<th style="padding: 6px;">Date</th>
|
|
26020
|
-
<th style="padding: 6px;">Reference</th>
|
|
26021
|
-
<th style="padding: 6px;">Type</th>
|
|
26022
|
-
<th style="padding: 6px; text-align: right;">Total</th>
|
|
26023
|
-
<th style="padding: 6px; text-align: right;">Paid</th>
|
|
26024
|
-
<th style="padding: 6px; text-align: right;">Outstanding</th>
|
|
26025
|
-
<th style="padding: 6px;">Status</th>
|
|
26026
|
-
<th style="padding: 6px;">Aging</th>
|
|
26027
|
-
</tr>
|
|
26028
|
-
<tr *ngFor="let line of state[kind].data.lines" style="border-bottom: 1px solid #eee;">
|
|
26029
|
-
<td style="padding: 6px;">{{ line.date | date:'mediumDate' }}</td>
|
|
26030
|
-
<td style="padding: 6px;">{{ line.reference }}</td>
|
|
26031
|
-
<td style="padding: 6px;">{{ line.type }}</td>
|
|
26032
|
-
<td style="padding: 6px; text-align: right;">{{ line.total | number:'1.2-2' }}</td>
|
|
26033
|
-
<td style="padding: 6px; text-align: right;">{{ line.paid | number:'1.2-2' }}</td>
|
|
26034
|
-
<td style="padding: 6px; text-align: right;">{{ line.outstanding | number:'1.2-2' }}</td>
|
|
26035
|
-
<td style="padding: 6px;">{{ line.status }}</td>
|
|
26036
|
-
<td style="padding: 6px;">{{ line.agingBucket }}</td>
|
|
26037
|
-
</tr>
|
|
26038
|
-
</table>
|
|
26039
|
-
<ng-template #noLines><p><em>No open items for the selected period</em></p></ng-template>
|
|
26040
|
-
</mat-card>
|
|
26041
26916
|
</div>
|
|
26042
26917
|
</ng-template>
|
|
26043
26918
|
`,
|
|
@@ -26316,7 +27191,7 @@ class BudgetVsActualComponent {
|
|
|
26316
27191
|
<hr>
|
|
26317
27192
|
|
|
26318
27193
|
<div style="max-width: 300px; margin-bottom: 16px;">
|
|
26319
|
-
<spa-select-lite display="Select Budget" [loadAction]="{ url: 'budgets/list/x' }" [(value)]="selectedBudgetID" (valueChange)="onBudgetSelected()"></spa-select-lite
|
|
27194
|
+
<spa-select-lite display="Select Budget" [loadAction]="{ url: 'budgets/list/x' }" optionDisplay="name" optionValue="value" [(value)]="selectedBudgetID" (valueChange)="onBudgetSelected()"></spa-select-lite><!-- Changed: optionDisplay/optionValue are NOT defaulted by spa-select-common (both default to ""), so every option rendered blank and bound undefined — no budget could be picked. 'name'/'value' match BaseController.GetList's ListOption shape, same as the statement page -->
|
|
26320
27195
|
</div>
|
|
26321
27196
|
|
|
26322
27197
|
<spa-tiles *ngIf="summaryTileConfig.loadAction" [config]="summaryTileConfig"></spa-tiles>
|
|
@@ -26333,7 +27208,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
|
|
|
26333
27208
|
<hr>
|
|
26334
27209
|
|
|
26335
27210
|
<div style="max-width: 300px; margin-bottom: 16px;">
|
|
26336
|
-
<spa-select-lite display="Select Budget" [loadAction]="{ url: 'budgets/list/x' }" [(value)]="selectedBudgetID" (valueChange)="onBudgetSelected()"></spa-select-lite
|
|
27211
|
+
<spa-select-lite display="Select Budget" [loadAction]="{ url: 'budgets/list/x' }" optionDisplay="name" optionValue="value" [(value)]="selectedBudgetID" (valueChange)="onBudgetSelected()"></spa-select-lite><!-- Changed: optionDisplay/optionValue are NOT defaulted by spa-select-common (both default to ""), so every option rendered blank and bound undefined — no budget could be picked. 'name'/'value' match BaseController.GetList's ListOption shape, same as the statement page -->
|
|
26337
27212
|
</div>
|
|
26338
27213
|
|
|
26339
27214
|
<spa-tiles *ngIf="summaryTileConfig.loadAction" [config]="summaryTileConfig"></spa-tiles>
|
|
@@ -26717,7 +27592,7 @@ class VatReturnComponent {
|
|
|
26717
27592
|
</mat-card>
|
|
26718
27593
|
</mat-tab>
|
|
26719
27594
|
</mat-tab-group>
|
|
26720
|
-
`, isInline: true, dependencies: [{ kind: "directive", type: i1$2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1$2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i2$2.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i2$2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i2$2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: i3.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i4$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "component", type:
|
|
27595
|
+
`, isInline: true, dependencies: [{ kind: "directive", type: i1$2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1$2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i2$2.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i2$2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i2$2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: i3.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i4$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "component", type: i3$2.MatTab, selector: "mat-tab", inputs: ["disabled", "label", "aria-label", "aria-labelledby", "labelClass", "bodyClass", "id"], exportAs: ["matTab"] }, { kind: "component", type: i3$2.MatTabGroup, selector: "mat-tab-group", inputs: ["color", "fitInkBarToContent", "mat-stretch-tabs", "mat-align-tabs", "dynamicHeight", "selectedIndex", "headerPosition", "animationDuration", "contentTabIndex", "disablePagination", "disableRipple", "preserveContent", "backgroundColor", "aria-label", "aria-labelledby"], outputs: ["selectedIndexChange", "focusChange", "animationDone", "selectedTabChange"], exportAs: ["matTabGroup"] }, { kind: "component", type: i3$1.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i3$1.MatLabel, selector: "mat-label" }, { kind: "directive", type: i4$2.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "component", type: i19.MatCard, selector: "mat-card", inputs: ["appearance"], exportAs: ["matCard"] }, { kind: "pipe", type: i1$2.DecimalPipe, name: "number" }, { kind: "pipe", type: i1$2.DatePipe, name: "date" }] }); }
|
|
26721
27596
|
}
|
|
26722
27597
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: VatReturnComponent, decorators: [{
|
|
26723
27598
|
type: Component,
|
|
@@ -28324,9 +29199,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
|
|
|
28324
29199
|
}], ctorParameters: () => [] });
|
|
28325
29200
|
|
|
28326
29201
|
class OnboardingComponent {
|
|
28327
|
-
constructor(dataService, authService) {
|
|
29202
|
+
constructor(dataService, authService, apiErrorService) {
|
|
28328
29203
|
this.dataService = dataService;
|
|
28329
29204
|
this.authService = authService;
|
|
29205
|
+
this.apiErrorService = apiErrorService;
|
|
28330
29206
|
this.step = "terms";
|
|
28331
29207
|
this.agree = false;
|
|
28332
29208
|
this.own = true;
|
|
@@ -28346,7 +29222,7 @@ class OnboardingComponent {
|
|
|
28346
29222
|
],
|
|
28347
29223
|
loadAction: { url: 'tenants/invitations/x' },
|
|
28348
29224
|
};
|
|
28349
|
-
}
|
|
29225
|
+
} // Changed: injected ApiErrorService for the rename step
|
|
28350
29226
|
ngOnInit() {
|
|
28351
29227
|
this.loadMeta();
|
|
28352
29228
|
this.authService.loggedUserFullName.subscribe(x => this.username = x);
|
|
@@ -28363,6 +29239,9 @@ class OnboardingComponent {
|
|
|
28363
29239
|
if (apiResponse.success) {
|
|
28364
29240
|
this.authService.updateTenantName(this.myTenant.name);
|
|
28365
29241
|
}
|
|
29242
|
+
else {
|
|
29243
|
+
this.apiErrorService.presentAppFailure(apiResponse, 'action', 'tenants/dto?action=rename'); // Added: was silent, and next() advances the wizard regardless of the outcome — so the user typed their company name, moved on, and only discovered weeks later that the organisation was still called whatever it defaulted to
|
|
29244
|
+
}
|
|
28366
29245
|
});
|
|
28367
29246
|
}
|
|
28368
29247
|
next() {
|
|
@@ -28391,13 +29270,13 @@ class OnboardingComponent {
|
|
|
28391
29270
|
home() {
|
|
28392
29271
|
this.dataService.Navigate('home');
|
|
28393
29272
|
}
|
|
28394
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: OnboardingComponent, deps: [{ token: DataServiceLib }, { token: AuthService }], target: i0.ɵɵFactoryTarget.Component }); }
|
|
29273
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: OnboardingComponent, deps: [{ token: DataServiceLib }, { token: AuthService }, { token: ApiErrorService }], target: i0.ɵɵFactoryTarget.Component }); }
|
|
28395
29274
|
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: OnboardingComponent, isStandalone: false, selector: "spa-onboarding", ngImport: i0, template: "<label class=\"title\">Welcome, {{username}}</label>\n\n\n\n<!-- terms -->\n<div class=\"mt-3\" *ngIf=\"step=='terms'\">\n\n <label class=\"subtitle text-muted mb-2\" >We care about our users and are dedicated to protecting your data and privacy -\n thats why we want to be clear about what data we collect and how we use it to improve your experience.</label>\n\n <br>\n <spa-check display=\"I agree to the Terms and Privacy Policy\" [(value)]=\"agree\"></spa-check>\n</div>\n\n<!-- owner -->\n<div class=\"mt-3\" *ngIf=\"step=='name' && own\">\n\n <label class=\"subtitle text-muted\" style=\" margin-bottom: 20px;\">The follow steps will guide you to customise your application.</label>\n\n <div style=\"max-width: 400px;\">\n <spa-text display=\"Organisation Name\" [(value)]=\"myTenant.name\" ></spa-text>\n </div>\n\n <label class=\"text-muted\" style=\" font-size: 12px;\">You can change the Organisation's name to your team or company name.</label><br>\n <label class=\"text-muted\" style=\" font-size: 12px;margin-top: 10px;\">The name can be changed later.</label>\n\n</div>\n\n<!-- guest -->\n<div *ngIf=\"step=='hi' && !own\">\n <label class=\"subtitle text-muted\">You are now signed in to {{myTenant.name}}.</label>\n</div>\n\n\n<!-- invitations -->\n<div class=\"mt-3\" *ngIf=\"step=='invitations' && own\">\n\n <label class=\"subtitle text-muted\">You have been requested to join the following organisations. If you accept, you have the option to switch to that org now or stay in you org.</label><br>\n <label class=\"text-muted\" style=\" font-size: 12px;margin-top: 10px;\">You will be able to switch later.</label>\n <spa-invitations-table></spa-invitations-table>\n\n</div>\n\n\n<!-- Actions -->\n<div class=\"mt-3\">\n <button mat-stroked-button color=\"primary\" [disabled]=\"!agree\" (click)=\"next()\">Next <mat-icon>arrow_right_alt</mat-icon></button>\n</div>\n", styles: [".title{margin-top:1em;font-size:28px;font-weight:300}\n"], dependencies: [{ kind: "directive", type: i1$2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "component", type: i3.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i4$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "component", type: TextComponent, selector: "spa-text", inputs: ["appearance", "readonly", "hint", "display", "placeholder", "value", "format", "type", "width", "copyContent", "clearContent", "required", "min", "max", "regex", "suffix", "infoMessage"], outputs: ["valueChange", "leave", "enterPress"] }, { kind: "component", type: CheckComponent, selector: "spa-check", inputs: ["readonly", "display", "value", "infoMessage"], outputs: ["valueChange", "click", "check", "uncheck", "infoClick"] }, { kind: "component", type: InvitationsTableComponent, selector: "spa-invitations-table" }] }); }
|
|
28396
29275
|
}
|
|
28397
29276
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: OnboardingComponent, decorators: [{
|
|
28398
29277
|
type: Component,
|
|
28399
29278
|
args: [{ selector: 'spa-onboarding', standalone: false, template: "<label class=\"title\">Welcome, {{username}}</label>\n\n\n\n<!-- terms -->\n<div class=\"mt-3\" *ngIf=\"step=='terms'\">\n\n <label class=\"subtitle text-muted mb-2\" >We care about our users and are dedicated to protecting your data and privacy -\n thats why we want to be clear about what data we collect and how we use it to improve your experience.</label>\n\n <br>\n <spa-check display=\"I agree to the Terms and Privacy Policy\" [(value)]=\"agree\"></spa-check>\n</div>\n\n<!-- owner -->\n<div class=\"mt-3\" *ngIf=\"step=='name' && own\">\n\n <label class=\"subtitle text-muted\" style=\" margin-bottom: 20px;\">The follow steps will guide you to customise your application.</label>\n\n <div style=\"max-width: 400px;\">\n <spa-text display=\"Organisation Name\" [(value)]=\"myTenant.name\" ></spa-text>\n </div>\n\n <label class=\"text-muted\" style=\" font-size: 12px;\">You can change the Organisation's name to your team or company name.</label><br>\n <label class=\"text-muted\" style=\" font-size: 12px;margin-top: 10px;\">The name can be changed later.</label>\n\n</div>\n\n<!-- guest -->\n<div *ngIf=\"step=='hi' && !own\">\n <label class=\"subtitle text-muted\">You are now signed in to {{myTenant.name}}.</label>\n</div>\n\n\n<!-- invitations -->\n<div class=\"mt-3\" *ngIf=\"step=='invitations' && own\">\n\n <label class=\"subtitle text-muted\">You have been requested to join the following organisations. If you accept, you have the option to switch to that org now or stay in you org.</label><br>\n <label class=\"text-muted\" style=\" font-size: 12px;margin-top: 10px;\">You will be able to switch later.</label>\n <spa-invitations-table></spa-invitations-table>\n\n</div>\n\n\n<!-- Actions -->\n<div class=\"mt-3\">\n <button mat-stroked-button color=\"primary\" [disabled]=\"!agree\" (click)=\"next()\">Next <mat-icon>arrow_right_alt</mat-icon></button>\n</div>\n", styles: [".title{margin-top:1em;font-size:28px;font-weight:300}\n"] }]
|
|
28400
|
-
}], ctorParameters: () => [{ type: DataServiceLib }, { type: AuthService }] });
|
|
29279
|
+
}], ctorParameters: () => [{ type: DataServiceLib }, { type: AuthService }, { type: ApiErrorService }] });
|
|
28401
29280
|
|
|
28402
29281
|
const HR_ROUTES = [
|
|
28403
29282
|
{ path: "employees", component: EmployeesComponent },
|
|
@@ -28519,6 +29398,493 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
|
|
|
28519
29398
|
}]
|
|
28520
29399
|
}] });
|
|
28521
29400
|
|
|
29401
|
+
// Added (Phase 6): the Driver Commission Statement, and the only place the response-level warnings from
|
|
29402
|
+
// preview and generate reach a human. Phases 2-5 surfaced the ROW-level ones — a 0.00 entry, a rule scoped
|
|
29403
|
+
// to nobody — but a rule skipped because it expired, and an invoice excluded because a credit note settled
|
|
29404
|
+
// it rather than cash, live on the RESPONSE, and nothing rendered them. `execAction` on the table component
|
|
29405
|
+
// toasts apiResponse.message and drops apiResponse.data, so a Preview button bound to a table config would
|
|
29406
|
+
// have shown the one-line summary and silently discarded every named reason. Hence a page.
|
|
29407
|
+
class CommissionStatementComponent {
|
|
29408
|
+
constructor() {
|
|
29409
|
+
this.dataService = inject(DataServiceLib);
|
|
29410
|
+
this.configService = inject(ConfigService);
|
|
29411
|
+
this.messageService = inject(MessageService);
|
|
29412
|
+
this.months = [
|
|
29413
|
+
{ value: 1, name: 'January' }, { value: 2, name: 'February' }, { value: 3, name: 'March' },
|
|
29414
|
+
{ value: 4, name: 'April' }, { value: 5, name: 'May' }, { value: 6, name: 'June' },
|
|
29415
|
+
{ value: 7, name: 'July' }, { value: 8, name: 'August' }, { value: 9, name: 'September' },
|
|
29416
|
+
{ value: 10, name: 'October' }, { value: 11, name: 'November' }, { value: 12, name: 'December' },
|
|
29417
|
+
];
|
|
29418
|
+
this.periodYear = new Date().getFullYear();
|
|
29419
|
+
this.periodMonth = new Date().getMonth() + 1;
|
|
29420
|
+
this.busy = false;
|
|
29421
|
+
this.previewResult = null;
|
|
29422
|
+
this.generateResult = null;
|
|
29423
|
+
this.entries = [];
|
|
29424
|
+
this.statementLoaded = false;
|
|
29425
|
+
this.totals = { draft: 0, approved: 0, paid: 0, reversed: 0, carried: 0 };
|
|
29426
|
+
// Reads the one shared App Configuration record. An app whose configuration has no EnableTripCommission
|
|
29427
|
+
// property simply gets undefined, so the generator hides — this fails CLOSED on purpose, matching the
|
|
29428
|
+
// server's own late-bound read of the flag. A button that offers to write commission in an app that does
|
|
29429
|
+
// not generate it is worse than no button.
|
|
29430
|
+
this.tripCommissionEnabled = false;
|
|
29431
|
+
}
|
|
29432
|
+
ngOnInit() {
|
|
29433
|
+
this.tripCommissionEnabled = !!this.configService.value?.enableTripCommission;
|
|
29434
|
+
this.configService.config$.subscribe((config) => {
|
|
29435
|
+
this.tripCommissionEnabled = !!config?.enableTripCommission;
|
|
29436
|
+
});
|
|
29437
|
+
this.loadStatement();
|
|
29438
|
+
}
|
|
29439
|
+
periodLabel() {
|
|
29440
|
+
return `${this.months.find(m => m.value === this.periodMonth)?.name ?? this.periodMonth} ${this.periodYear}`;
|
|
29441
|
+
}
|
|
29442
|
+
get previewTotal() {
|
|
29443
|
+
return (this.previewResult?.entries || []).reduce((sum, e) => sum + (e.commissionAmount || 0), 0);
|
|
29444
|
+
}
|
|
29445
|
+
statusClass(entry) {
|
|
29446
|
+
if (entry?.status === 1)
|
|
29447
|
+
return 'cs-status-draft';
|
|
29448
|
+
if (entry?.status === 2)
|
|
29449
|
+
return 'cs-status-approved';
|
|
29450
|
+
if (entry?.status === 4)
|
|
29451
|
+
return 'cs-status-reversed';
|
|
29452
|
+
return '';
|
|
29453
|
+
}
|
|
29454
|
+
// Changing the period invalidates both result panels — leaving July's preview on screen above August's
|
|
29455
|
+
// statement is how someone approves the wrong month.
|
|
29456
|
+
clearResults() {
|
|
29457
|
+
this.previewResult = null;
|
|
29458
|
+
this.generateResult = null;
|
|
29459
|
+
this.statementLoaded = false;
|
|
29460
|
+
}
|
|
29461
|
+
loadStatement() {
|
|
29462
|
+
if (!this.validPeriod())
|
|
29463
|
+
return;
|
|
29464
|
+
this.busy = true;
|
|
29465
|
+
this.dataService.CallApi({ url: `commissionentries/period/${this.periodYear}-${this.periodMonth}` }).subscribe({
|
|
29466
|
+
next: (res) => {
|
|
29467
|
+
this.busy = false;
|
|
29468
|
+
if (!res.success) {
|
|
29469
|
+
this.messageService.toast(res.message || 'Could not load the statement');
|
|
29470
|
+
return;
|
|
29471
|
+
}
|
|
29472
|
+
this.entries = res.data || [];
|
|
29473
|
+
this.statementLoaded = true;
|
|
29474
|
+
this.recalculateTotals();
|
|
29475
|
+
},
|
|
29476
|
+
error: () => { this.busy = false; }
|
|
29477
|
+
});
|
|
29478
|
+
}
|
|
29479
|
+
// Draft, Approved, Paid and Reversed are summed separately and never rolled into one figure. A single
|
|
29480
|
+
// "total commission" would read as owed when most of it is a proposal nobody has approved.
|
|
29481
|
+
recalculateTotals() {
|
|
29482
|
+
this.totals = { draft: 0, approved: 0, paid: 0, reversed: 0, carried: 0 };
|
|
29483
|
+
for (const e of this.entries) {
|
|
29484
|
+
const amount = e.commissionAmount || 0;
|
|
29485
|
+
if (e.isPaid || e.status === 3)
|
|
29486
|
+
this.totals.paid += amount;
|
|
29487
|
+
else if (e.status === 2)
|
|
29488
|
+
this.totals.approved += amount;
|
|
29489
|
+
else if (e.status === 4)
|
|
29490
|
+
this.totals.reversed += amount;
|
|
29491
|
+
else
|
|
29492
|
+
this.totals.draft += amount;
|
|
29493
|
+
if (e.isCarriedForward)
|
|
29494
|
+
this.totals.carried += amount;
|
|
29495
|
+
}
|
|
29496
|
+
}
|
|
29497
|
+
// PREVIEW WRITES NOTHING. It is gated on cap77 VIEW server-side for exactly that reason, and the result
|
|
29498
|
+
// panel repeats it — a figure on screen that looks committed is the failure this button exists to avoid.
|
|
29499
|
+
preview() {
|
|
29500
|
+
if (!this.validPeriod())
|
|
29501
|
+
return;
|
|
29502
|
+
this.busy = true;
|
|
29503
|
+
this.generateResult = null;
|
|
29504
|
+
this.dataService.CallApi({ url: 'shiftcommission/preview', method: 'post' }, { periodYear: this.periodYear, periodMonth: this.periodMonth }).subscribe({
|
|
29505
|
+
next: (res) => {
|
|
29506
|
+
this.busy = false;
|
|
29507
|
+
if (!res.success) {
|
|
29508
|
+
this.messageService.toast(res.message || 'Preview failed');
|
|
29509
|
+
return;
|
|
29510
|
+
}
|
|
29511
|
+
this.previewResult = res.data || {};
|
|
29512
|
+
},
|
|
29513
|
+
error: () => { this.busy = false; }
|
|
29514
|
+
});
|
|
29515
|
+
}
|
|
29516
|
+
// GENERATE WRITES. Every entry lands as Draft, so nothing here can pay anybody without a separate
|
|
29517
|
+
// approval — the confirm says so rather than leaving it to be discovered.
|
|
29518
|
+
generate() {
|
|
29519
|
+
if (!this.validPeriod())
|
|
29520
|
+
return;
|
|
29521
|
+
this.messageService.confirm(`Generate commission entries for ${this.periodLabel()}? They are created as Draft and still have to be approved before a payroll run can pay them. Regenerating a period reverses entries that were already approved or paid.`)
|
|
29522
|
+
.subscribe((answer) => {
|
|
29523
|
+
if (answer !== 'yes')
|
|
29524
|
+
return;
|
|
29525
|
+
this.busy = true;
|
|
29526
|
+
this.previewResult = null;
|
|
29527
|
+
this.dataService.CallApi({ url: 'shiftcommission/generate', method: 'post' }, { periodYear: this.periodYear, periodMonth: this.periodMonth }).subscribe({
|
|
29528
|
+
next: (res) => {
|
|
29529
|
+
this.busy = false;
|
|
29530
|
+
if (!res.success) {
|
|
29531
|
+
this.messageService.toast(res.message || 'Generation failed');
|
|
29532
|
+
return;
|
|
29533
|
+
}
|
|
29534
|
+
this.generateResult = res.data || {};
|
|
29535
|
+
this.loadStatement(); // the statement below must agree with what was just written
|
|
29536
|
+
},
|
|
29537
|
+
error: () => { this.busy = false; }
|
|
29538
|
+
});
|
|
29539
|
+
});
|
|
29540
|
+
}
|
|
29541
|
+
validPeriod() {
|
|
29542
|
+
if (!this.periodMonth || this.periodMonth < 1 || this.periodMonth > 12) {
|
|
29543
|
+
this.messageService.toast('Choose a month');
|
|
29544
|
+
return false;
|
|
29545
|
+
}
|
|
29546
|
+
if (!this.periodYear || this.periodYear < 2000 || this.periodYear > 2100) {
|
|
29547
|
+
this.messageService.toast('Year must be between 2000 and 2100');
|
|
29548
|
+
return false;
|
|
29549
|
+
}
|
|
29550
|
+
return true;
|
|
29551
|
+
}
|
|
29552
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: CommissionStatementComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
29553
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: CommissionStatementComponent, isStandalone: false, selector: "spa-commission-statement", ngImport: i0, template: `
|
|
29554
|
+
<div class="cs-container">
|
|
29555
|
+
|
|
29556
|
+
<h4 class="cs-title"><mat-icon>receipt_long</mat-icon> Driver Commission Statement</h4>
|
|
29557
|
+
|
|
29558
|
+
<!-- Period, and the two actions. Preview and Generate render only where trip commission is switched on,
|
|
29559
|
+
which in practice means Shift — every other app gets the statement without the generator. -->
|
|
29560
|
+
<div class="cs-bar">
|
|
29561
|
+
<div class="cs-period">
|
|
29562
|
+
<mat-form-field appearance="outline" class="cs-year">
|
|
29563
|
+
<mat-label>Year</mat-label>
|
|
29564
|
+
<input matInput type="number" [(ngModel)]="periodYear" (change)="clearResults()">
|
|
29565
|
+
</mat-form-field>
|
|
29566
|
+
<mat-form-field appearance="outline" class="cs-month">
|
|
29567
|
+
<mat-label>Month</mat-label>
|
|
29568
|
+
<mat-select [(ngModel)]="periodMonth" (selectionChange)="clearResults()">
|
|
29569
|
+
<mat-option *ngFor="let m of months" [value]="m.value">{{ m.name }}</mat-option>
|
|
29570
|
+
</mat-select>
|
|
29571
|
+
</mat-form-field>
|
|
29572
|
+
<button mat-stroked-button color="primary" (click)="loadStatement()" [disabled]="busy">Show Statement</button>
|
|
29573
|
+
</div>
|
|
29574
|
+
|
|
29575
|
+
<div class="cs-actions" *ngIf="tripCommissionEnabled">
|
|
29576
|
+
<button mat-stroked-button (click)="preview()" [disabled]="busy"><mat-icon>visibility</mat-icon> Preview (writes nothing)</button>
|
|
29577
|
+
<button mat-flat-button color="primary" (click)="generate()" [disabled]="busy"><mat-icon>playlist_add</mat-icon> Generate from Trips</button>
|
|
29578
|
+
</div>
|
|
29579
|
+
</div>
|
|
29580
|
+
|
|
29581
|
+
<div class="alert alert-secondary cs-note" *ngIf="!tripCommissionEnabled">
|
|
29582
|
+
Commission is not generated from trips in this app, so this page reports what has been recorded rather than
|
|
29583
|
+
proposing anything new.
|
|
29584
|
+
</div>
|
|
29585
|
+
|
|
29586
|
+
<!-- ============ PREVIEW RESULT — the whole point of this page ============ -->
|
|
29587
|
+
<div class="cs-panel" *ngIf="previewResult">
|
|
29588
|
+
<div class="alert alert-info cs-headline">
|
|
29589
|
+
<strong>Preview only — nothing was written.</strong>
|
|
29590
|
+
{{ previewResult.entries?.length || 0 }} {{ (previewResult.entries?.length === 1) ? 'entry' : 'entries' }}
|
|
29591
|
+
totalling {{ previewTotal | number:'1.2-2' }} for {{ previewResult.periodName || periodLabel() }}.
|
|
29592
|
+
Generating turns these into <strong>Draft</strong> entries, which still have to be approved before payroll can pay them.
|
|
29593
|
+
</div>
|
|
29594
|
+
|
|
29595
|
+
<!-- Response-level warnings: the ones that were invisible until now -->
|
|
29596
|
+
<div class="alert alert-warning" *ngIf="previewResult.warnings?.length">
|
|
29597
|
+
<strong>{{ previewResult.warnings.length }} warning{{ previewResult.warnings.length === 1 ? '' : 's' }} about this period</strong>
|
|
29598
|
+
<ul class="cs-list"><li *ngFor="let w of previewResult.warnings">{{ w }}</li></ul>
|
|
29599
|
+
</div>
|
|
29600
|
+
|
|
29601
|
+
<!-- Skipped rules, each with the reason the evaluator refused it -->
|
|
29602
|
+
<div class="alert alert-danger" *ngIf="previewResult.skippedRules?.length">
|
|
29603
|
+
<strong>{{ previewResult.skippedRules.length }} rule{{ previewResult.skippedRules.length === 1 ? '' : 's' }} paid nobody</strong>
|
|
29604
|
+
<ul class="cs-list">
|
|
29605
|
+
<li *ngFor="let s of previewResult.skippedRules"><strong>{{ s.configName }}</strong> — {{ s.reason }}</li>
|
|
29606
|
+
</ul>
|
|
29607
|
+
</div>
|
|
29608
|
+
|
|
29609
|
+
<table class="cs-table" *ngIf="previewResult.entries?.length">
|
|
29610
|
+
<thead>
|
|
29611
|
+
<tr>
|
|
29612
|
+
<th>Employee</th><th>Rule</th><th>Basis</th><th>Earned When</th>
|
|
29613
|
+
<th class="cs-num">Measured</th><th class="cs-num">Rate</th><th class="cs-num">Raw</th><th class="cs-num">Would Pay</th><th>Notes</th>
|
|
29614
|
+
</tr>
|
|
29615
|
+
</thead>
|
|
29616
|
+
<tbody>
|
|
29617
|
+
<tr *ngFor="let e of previewResult.entries">
|
|
29618
|
+
<td>{{ e.employeeName }}</td>
|
|
29619
|
+
<td>{{ e.configName }}</td>
|
|
29620
|
+
<td>{{ e.basisName }}</td>
|
|
29621
|
+
<td>{{ e.earnEventName }}</td>
|
|
29622
|
+
<td class="cs-num">{{ e.metricValue | number:'1.0-2' }}</td>
|
|
29623
|
+
<td class="cs-num">{{ e.rate | number:'1.0-4' }}</td>
|
|
29624
|
+
<td class="cs-num">{{ e.rawAmount | number:'1.2-2' }}</td>
|
|
29625
|
+
<td class="cs-num cs-strong" [class.cs-zero]="e.commissionAmount === 0">{{ e.commissionAmount | number:'1.2-2' }}</td>
|
|
29626
|
+
<td class="cs-notes">
|
|
29627
|
+
<span class="cs-flag" *ngIf="e.usedTiers">tiered</span>
|
|
29628
|
+
<span class="cs-flag" *ngIf="e.minimumApplied">floor applied</span>
|
|
29629
|
+
<span class="cs-flag" *ngIf="e.maximumApplied">cap applied</span>
|
|
29630
|
+
<span class="cs-warn" *ngFor="let w of e.warnings">{{ w }}</span>
|
|
29631
|
+
</td>
|
|
29632
|
+
</tr>
|
|
29633
|
+
</tbody>
|
|
29634
|
+
</table>
|
|
29635
|
+
</div>
|
|
29636
|
+
|
|
29637
|
+
<!-- ============ GENERATE RESULT ============ -->
|
|
29638
|
+
<div class="cs-panel" *ngIf="generateResult">
|
|
29639
|
+
<div class="alert alert-success cs-headline">
|
|
29640
|
+
<strong>{{ generateResult.entriesCreated }} Draft entr{{ generateResult.entriesCreated === 1 ? 'y' : 'ies' }}</strong>
|
|
29641
|
+
created from {{ generateResult.linesCreated }} source line{{ generateResult.linesCreated === 1 ? '' : 's' }},
|
|
29642
|
+
totalling {{ generateResult.totalAmount | number:'1.2-2' }} for {{ generateResult.periodName || periodLabel() }}.
|
|
29643
|
+
Draft entries do <strong>not</strong> reach a payslip until they are approved.
|
|
29644
|
+
</div>
|
|
29645
|
+
|
|
29646
|
+
<!-- What a regeneration MOVED. These three are the ones nobody must discover from a payslip. -->
|
|
29647
|
+
<div class="alert alert-warning" *ngIf="generateResult.draftEntriesReplaced || generateResult.approvedEntriesNeutralised || generateResult.paidEntriesReversed">
|
|
29648
|
+
<ul class="cs-list">
|
|
29649
|
+
<li *ngIf="generateResult.draftEntriesReplaced">{{ generateResult.draftEntriesReplaced }} previous draft entr{{ generateResult.draftEntriesReplaced === 1 ? 'y was' : 'ies were' }} replaced.</li>
|
|
29650
|
+
<li *ngIf="generateResult.approvedEntriesNeutralised"><strong>{{ generateResult.approvedEntriesNeutralised }} already-approved entr{{ generateResult.approvedEntriesNeutralised === 1 ? 'y was' : 'ies were' }} reversed and must be approved again.</strong></li>
|
|
29651
|
+
<li *ngIf="generateResult.paidEntriesReversed"><strong>{{ generateResult.paidEntriesReversed }} already-paid entr{{ generateResult.paidEntriesReversed === 1 ? 'y' : 'ies' }} raised a negative clawback awaiting approval.</strong> The original payment stands.</li>
|
|
29652
|
+
</ul>
|
|
29653
|
+
</div>
|
|
29654
|
+
|
|
29655
|
+
<div class="alert alert-warning" *ngIf="generateResult.entriesWithWarnings?.length">
|
|
29656
|
+
<strong>{{ generateResult.entriesWithWarnings.length }} entr{{ generateResult.entriesWithWarnings.length === 1 ? 'y needs' : 'ies need' }} attention before approval</strong>
|
|
29657
|
+
<ul class="cs-list">
|
|
29658
|
+
<li *ngFor="let e of generateResult.entriesWithWarnings">
|
|
29659
|
+
<strong>{{ e.employeeName }}</strong> — {{ e.configName }} — {{ e.commissionAmount | number:'1.2-2' }}
|
|
29660
|
+
<span *ngFor="let w of e.warnings" class="cs-warn">{{ w }}</span>
|
|
29661
|
+
</li>
|
|
29662
|
+
</ul>
|
|
29663
|
+
</div>
|
|
29664
|
+
|
|
29665
|
+
<div class="alert alert-warning" *ngIf="generateResult.warnings?.length">
|
|
29666
|
+
<ul class="cs-list"><li *ngFor="let w of generateResult.warnings">{{ w }}</li></ul>
|
|
29667
|
+
</div>
|
|
29668
|
+
|
|
29669
|
+
<div class="alert alert-danger" *ngIf="generateResult.skippedRules?.length">
|
|
29670
|
+
<strong>{{ generateResult.skippedRules.length }} rule{{ generateResult.skippedRules.length === 1 ? '' : 's' }} paid nobody</strong>
|
|
29671
|
+
<ul class="cs-list">
|
|
29672
|
+
<li *ngFor="let s of generateResult.skippedRules"><strong>{{ s.configName }}</strong> — {{ s.reason }}</li>
|
|
29673
|
+
</ul>
|
|
29674
|
+
</div>
|
|
29675
|
+
</div>
|
|
29676
|
+
|
|
29677
|
+
<!-- ============ THE STATEMENT ITSELF ============ -->
|
|
29678
|
+
<div class="cs-panel" *ngIf="statementLoaded">
|
|
29679
|
+
<h5 class="cs-section">Recorded for {{ periodLabel() }}</h5>
|
|
29680
|
+
|
|
29681
|
+
<div class="cs-totals">
|
|
29682
|
+
<div class="cs-total"><span class="cs-total-label">Proposed (Draft)</span><span class="cs-total-value cs-draft">{{ totals.draft | number:'1.2-2' }}</span><span class="cs-total-note">not payable until approved</span></div>
|
|
29683
|
+
<div class="cs-total"><span class="cs-total-label">Payable (Approved)</span><span class="cs-total-value cs-approved">{{ totals.approved | number:'1.2-2' }}</span><span class="cs-total-note">will be picked up by a payroll run</span></div>
|
|
29684
|
+
<div class="cs-total"><span class="cs-total-label">Settled (Paid)</span><span class="cs-total-value">{{ totals.paid | number:'1.2-2' }}</span><span class="cs-total-note">already on a payslip line</span></div>
|
|
29685
|
+
<div class="cs-total"><span class="cs-total-label">Reversed</span><span class="cs-total-value cs-reversed">{{ totals.reversed | number:'1.2-2' }}</span><span class="cs-total-note">clawbacks against earlier entries</span></div>
|
|
29686
|
+
<div class="cs-total"><span class="cs-total-label">Carried forward in</span><span class="cs-total-value">{{ totals.carried | number:'1.2-2' }}</span><span class="cs-total-note">earned in an earlier period</span></div>
|
|
29687
|
+
</div>
|
|
29688
|
+
|
|
29689
|
+
<div class="alert alert-secondary cs-note" *ngIf="!entries.length">
|
|
29690
|
+
Nothing has been recorded for this period yet.
|
|
29691
|
+
</div>
|
|
29692
|
+
|
|
29693
|
+
<table class="cs-table" *ngIf="entries.length">
|
|
29694
|
+
<thead>
|
|
29695
|
+
<tr><th>Employee</th><th>Description</th><th>Source</th><th class="cs-num">Measured</th><th class="cs-num">Amount</th><th>Status</th><th>Paid</th><th>Lines</th></tr>
|
|
29696
|
+
</thead>
|
|
29697
|
+
<tbody>
|
|
29698
|
+
<tr *ngFor="let e of entries">
|
|
29699
|
+
<td>{{ e.employeeName }}</td>
|
|
29700
|
+
<td>
|
|
29701
|
+
{{ e.description }}
|
|
29702
|
+
<span class="cs-flag" *ngIf="e.isCarriedForward">from {{ e.originPeriodMonth }}/{{ e.originPeriodYear }}</span>
|
|
29703
|
+
<span class="cs-flag cs-flag-warn" *ngIf="e.isReversal">reverses #{{ e.reversesCommissionEntryID }}</span>
|
|
29704
|
+
</td>
|
|
29705
|
+
<td>{{ e.sourceType }}</td>
|
|
29706
|
+
<td class="cs-num">{{ e.metricValue | number:'1.0-2' }}</td>
|
|
29707
|
+
<td class="cs-num cs-strong" [class.cs-negative]="e.commissionAmount < 0">{{ e.commissionAmount | number:'1.2-2' }}</td>
|
|
29708
|
+
<td><span [ngClass]="statusClass(e)">{{ e.statusName }}</span></td>
|
|
29709
|
+
<td>{{ e.isPaid ? 'Yes' : '' }}</td>
|
|
29710
|
+
<td>{{ e.lineCount }}</td>
|
|
29711
|
+
</tr>
|
|
29712
|
+
</tbody>
|
|
29713
|
+
</table>
|
|
29714
|
+
</div>
|
|
29715
|
+
|
|
29716
|
+
</div>
|
|
29717
|
+
`, isInline: true, styles: [".cs-container{padding:16px}.cs-title{display:flex;align-items:center;gap:8px;margin-bottom:16px;color:#333;font-weight:500}.cs-bar{display:flex;flex-wrap:wrap;gap:16px;align-items:center;justify-content:space-between;margin-bottom:8px}.cs-period,.cs-actions{display:flex;flex-wrap:wrap;gap:12px;align-items:center}.cs-year{width:110px}.cs-month{width:160px}.cs-note{font-size:14px}.cs-panel{margin-top:8px}.cs-headline{font-size:14px}.cs-section{color:#333;font-weight:500;margin:16px 0 8px}.cs-list{margin:6px 0 0;padding-left:20px}.cs-list li{margin-bottom:3px}.cs-table{width:100%;border-collapse:collapse;font-size:13px}.cs-table th{text-align:left;padding:8px 12px;background:#f5f5f5;border-bottom:2px solid #e0e0e0;font-weight:500;white-space:nowrap}.cs-table td{padding:8px 12px;border-bottom:1px solid #eee;vertical-align:top}.cs-table tr:hover{background:#fafafa}.cs-num{text-align:right;white-space:nowrap}.cs-strong{font-weight:600}.cs-zero{color:#e65100}.cs-negative{color:#c62828}.cs-notes{max-width:320px}.cs-flag{display:inline-block;margin:1px 4px 1px 0;padding:1px 7px;border-radius:10px;background:#eceff1;color:#455a64;font-size:11px;white-space:nowrap}.cs-flag-warn{background:#fff3cd;color:#664d03}.cs-warn{display:block;color:#664d03;font-size:12px;margin-top:2px}.cs-totals{display:flex;flex-wrap:wrap;gap:12px;margin-bottom:12px}.cs-total{display:flex;flex-direction:column;min-width:170px;flex:1 1 170px;padding:10px 14px;border:1px solid #e0e0e0;border-radius:6px;background:#fff}.cs-total-label{font-size:12px;color:#607d8b}.cs-total-value{font-size:20px;font-weight:600}.cs-total-note{font-size:11px;color:#90a4ae}.cs-draft{color:#757575}.cs-approved{color:#2e7d32}.cs-reversed{color:#c62828}.cs-status-draft{color:#757575}.cs-status-approved{color:#2e7d32;font-weight:600}.cs-status-reversed{color:#c62828}@media (max-width: 700px){.cs-bar{flex-direction:column;align-items:stretch}.cs-actions button{flex:1 1 auto}.cs-panel{overflow-x:auto}.cs-notes{max-width:none}}\n"], dependencies: [{ kind: "directive", type: i1$2.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: i1$2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1$2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i2$2.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i2$2.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i2$2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i2$2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: i3.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i4$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "component", type: i3$1.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i3$1.MatLabel, selector: "mat-label" }, { kind: "directive", type: i4$2.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "component", type: i7$1.MatSelect, selector: "mat-select", inputs: ["aria-describedby", "panelClass", "disabled", "disableRipple", "tabIndex", "hideSingleSelectionIndicator", "placeholder", "required", "multiple", "disableOptionCentering", "compareWith", "value", "aria-label", "aria-labelledby", "errorStateMatcher", "typeaheadDebounceInterval", "sortComparator", "id", "panelWidth", "canSelectNullableOptions"], outputs: ["openedChange", "opened", "closed", "selectionChange", "valueChange"], exportAs: ["matSelect"] }, { kind: "component", type: i7$1.MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "pipe", type: i1$2.DecimalPipe, name: "number" }] }); }
|
|
29718
|
+
}
|
|
29719
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: CommissionStatementComponent, decorators: [{
|
|
29720
|
+
type: Component,
|
|
29721
|
+
args: [{ selector: 'spa-commission-statement', template: `
|
|
29722
|
+
<div class="cs-container">
|
|
29723
|
+
|
|
29724
|
+
<h4 class="cs-title"><mat-icon>receipt_long</mat-icon> Driver Commission Statement</h4>
|
|
29725
|
+
|
|
29726
|
+
<!-- Period, and the two actions. Preview and Generate render only where trip commission is switched on,
|
|
29727
|
+
which in practice means Shift — every other app gets the statement without the generator. -->
|
|
29728
|
+
<div class="cs-bar">
|
|
29729
|
+
<div class="cs-period">
|
|
29730
|
+
<mat-form-field appearance="outline" class="cs-year">
|
|
29731
|
+
<mat-label>Year</mat-label>
|
|
29732
|
+
<input matInput type="number" [(ngModel)]="periodYear" (change)="clearResults()">
|
|
29733
|
+
</mat-form-field>
|
|
29734
|
+
<mat-form-field appearance="outline" class="cs-month">
|
|
29735
|
+
<mat-label>Month</mat-label>
|
|
29736
|
+
<mat-select [(ngModel)]="periodMonth" (selectionChange)="clearResults()">
|
|
29737
|
+
<mat-option *ngFor="let m of months" [value]="m.value">{{ m.name }}</mat-option>
|
|
29738
|
+
</mat-select>
|
|
29739
|
+
</mat-form-field>
|
|
29740
|
+
<button mat-stroked-button color="primary" (click)="loadStatement()" [disabled]="busy">Show Statement</button>
|
|
29741
|
+
</div>
|
|
29742
|
+
|
|
29743
|
+
<div class="cs-actions" *ngIf="tripCommissionEnabled">
|
|
29744
|
+
<button mat-stroked-button (click)="preview()" [disabled]="busy"><mat-icon>visibility</mat-icon> Preview (writes nothing)</button>
|
|
29745
|
+
<button mat-flat-button color="primary" (click)="generate()" [disabled]="busy"><mat-icon>playlist_add</mat-icon> Generate from Trips</button>
|
|
29746
|
+
</div>
|
|
29747
|
+
</div>
|
|
29748
|
+
|
|
29749
|
+
<div class="alert alert-secondary cs-note" *ngIf="!tripCommissionEnabled">
|
|
29750
|
+
Commission is not generated from trips in this app, so this page reports what has been recorded rather than
|
|
29751
|
+
proposing anything new.
|
|
29752
|
+
</div>
|
|
29753
|
+
|
|
29754
|
+
<!-- ============ PREVIEW RESULT — the whole point of this page ============ -->
|
|
29755
|
+
<div class="cs-panel" *ngIf="previewResult">
|
|
29756
|
+
<div class="alert alert-info cs-headline">
|
|
29757
|
+
<strong>Preview only — nothing was written.</strong>
|
|
29758
|
+
{{ previewResult.entries?.length || 0 }} {{ (previewResult.entries?.length === 1) ? 'entry' : 'entries' }}
|
|
29759
|
+
totalling {{ previewTotal | number:'1.2-2' }} for {{ previewResult.periodName || periodLabel() }}.
|
|
29760
|
+
Generating turns these into <strong>Draft</strong> entries, which still have to be approved before payroll can pay them.
|
|
29761
|
+
</div>
|
|
29762
|
+
|
|
29763
|
+
<!-- Response-level warnings: the ones that were invisible until now -->
|
|
29764
|
+
<div class="alert alert-warning" *ngIf="previewResult.warnings?.length">
|
|
29765
|
+
<strong>{{ previewResult.warnings.length }} warning{{ previewResult.warnings.length === 1 ? '' : 's' }} about this period</strong>
|
|
29766
|
+
<ul class="cs-list"><li *ngFor="let w of previewResult.warnings">{{ w }}</li></ul>
|
|
29767
|
+
</div>
|
|
29768
|
+
|
|
29769
|
+
<!-- Skipped rules, each with the reason the evaluator refused it -->
|
|
29770
|
+
<div class="alert alert-danger" *ngIf="previewResult.skippedRules?.length">
|
|
29771
|
+
<strong>{{ previewResult.skippedRules.length }} rule{{ previewResult.skippedRules.length === 1 ? '' : 's' }} paid nobody</strong>
|
|
29772
|
+
<ul class="cs-list">
|
|
29773
|
+
<li *ngFor="let s of previewResult.skippedRules"><strong>{{ s.configName }}</strong> — {{ s.reason }}</li>
|
|
29774
|
+
</ul>
|
|
29775
|
+
</div>
|
|
29776
|
+
|
|
29777
|
+
<table class="cs-table" *ngIf="previewResult.entries?.length">
|
|
29778
|
+
<thead>
|
|
29779
|
+
<tr>
|
|
29780
|
+
<th>Employee</th><th>Rule</th><th>Basis</th><th>Earned When</th>
|
|
29781
|
+
<th class="cs-num">Measured</th><th class="cs-num">Rate</th><th class="cs-num">Raw</th><th class="cs-num">Would Pay</th><th>Notes</th>
|
|
29782
|
+
</tr>
|
|
29783
|
+
</thead>
|
|
29784
|
+
<tbody>
|
|
29785
|
+
<tr *ngFor="let e of previewResult.entries">
|
|
29786
|
+
<td>{{ e.employeeName }}</td>
|
|
29787
|
+
<td>{{ e.configName }}</td>
|
|
29788
|
+
<td>{{ e.basisName }}</td>
|
|
29789
|
+
<td>{{ e.earnEventName }}</td>
|
|
29790
|
+
<td class="cs-num">{{ e.metricValue | number:'1.0-2' }}</td>
|
|
29791
|
+
<td class="cs-num">{{ e.rate | number:'1.0-4' }}</td>
|
|
29792
|
+
<td class="cs-num">{{ e.rawAmount | number:'1.2-2' }}</td>
|
|
29793
|
+
<td class="cs-num cs-strong" [class.cs-zero]="e.commissionAmount === 0">{{ e.commissionAmount | number:'1.2-2' }}</td>
|
|
29794
|
+
<td class="cs-notes">
|
|
29795
|
+
<span class="cs-flag" *ngIf="e.usedTiers">tiered</span>
|
|
29796
|
+
<span class="cs-flag" *ngIf="e.minimumApplied">floor applied</span>
|
|
29797
|
+
<span class="cs-flag" *ngIf="e.maximumApplied">cap applied</span>
|
|
29798
|
+
<span class="cs-warn" *ngFor="let w of e.warnings">{{ w }}</span>
|
|
29799
|
+
</td>
|
|
29800
|
+
</tr>
|
|
29801
|
+
</tbody>
|
|
29802
|
+
</table>
|
|
29803
|
+
</div>
|
|
29804
|
+
|
|
29805
|
+
<!-- ============ GENERATE RESULT ============ -->
|
|
29806
|
+
<div class="cs-panel" *ngIf="generateResult">
|
|
29807
|
+
<div class="alert alert-success cs-headline">
|
|
29808
|
+
<strong>{{ generateResult.entriesCreated }} Draft entr{{ generateResult.entriesCreated === 1 ? 'y' : 'ies' }}</strong>
|
|
29809
|
+
created from {{ generateResult.linesCreated }} source line{{ generateResult.linesCreated === 1 ? '' : 's' }},
|
|
29810
|
+
totalling {{ generateResult.totalAmount | number:'1.2-2' }} for {{ generateResult.periodName || periodLabel() }}.
|
|
29811
|
+
Draft entries do <strong>not</strong> reach a payslip until they are approved.
|
|
29812
|
+
</div>
|
|
29813
|
+
|
|
29814
|
+
<!-- What a regeneration MOVED. These three are the ones nobody must discover from a payslip. -->
|
|
29815
|
+
<div class="alert alert-warning" *ngIf="generateResult.draftEntriesReplaced || generateResult.approvedEntriesNeutralised || generateResult.paidEntriesReversed">
|
|
29816
|
+
<ul class="cs-list">
|
|
29817
|
+
<li *ngIf="generateResult.draftEntriesReplaced">{{ generateResult.draftEntriesReplaced }} previous draft entr{{ generateResult.draftEntriesReplaced === 1 ? 'y was' : 'ies were' }} replaced.</li>
|
|
29818
|
+
<li *ngIf="generateResult.approvedEntriesNeutralised"><strong>{{ generateResult.approvedEntriesNeutralised }} already-approved entr{{ generateResult.approvedEntriesNeutralised === 1 ? 'y was' : 'ies were' }} reversed and must be approved again.</strong></li>
|
|
29819
|
+
<li *ngIf="generateResult.paidEntriesReversed"><strong>{{ generateResult.paidEntriesReversed }} already-paid entr{{ generateResult.paidEntriesReversed === 1 ? 'y' : 'ies' }} raised a negative clawback awaiting approval.</strong> The original payment stands.</li>
|
|
29820
|
+
</ul>
|
|
29821
|
+
</div>
|
|
29822
|
+
|
|
29823
|
+
<div class="alert alert-warning" *ngIf="generateResult.entriesWithWarnings?.length">
|
|
29824
|
+
<strong>{{ generateResult.entriesWithWarnings.length }} entr{{ generateResult.entriesWithWarnings.length === 1 ? 'y needs' : 'ies need' }} attention before approval</strong>
|
|
29825
|
+
<ul class="cs-list">
|
|
29826
|
+
<li *ngFor="let e of generateResult.entriesWithWarnings">
|
|
29827
|
+
<strong>{{ e.employeeName }}</strong> — {{ e.configName }} — {{ e.commissionAmount | number:'1.2-2' }}
|
|
29828
|
+
<span *ngFor="let w of e.warnings" class="cs-warn">{{ w }}</span>
|
|
29829
|
+
</li>
|
|
29830
|
+
</ul>
|
|
29831
|
+
</div>
|
|
29832
|
+
|
|
29833
|
+
<div class="alert alert-warning" *ngIf="generateResult.warnings?.length">
|
|
29834
|
+
<ul class="cs-list"><li *ngFor="let w of generateResult.warnings">{{ w }}</li></ul>
|
|
29835
|
+
</div>
|
|
29836
|
+
|
|
29837
|
+
<div class="alert alert-danger" *ngIf="generateResult.skippedRules?.length">
|
|
29838
|
+
<strong>{{ generateResult.skippedRules.length }} rule{{ generateResult.skippedRules.length === 1 ? '' : 's' }} paid nobody</strong>
|
|
29839
|
+
<ul class="cs-list">
|
|
29840
|
+
<li *ngFor="let s of generateResult.skippedRules"><strong>{{ s.configName }}</strong> — {{ s.reason }}</li>
|
|
29841
|
+
</ul>
|
|
29842
|
+
</div>
|
|
29843
|
+
</div>
|
|
29844
|
+
|
|
29845
|
+
<!-- ============ THE STATEMENT ITSELF ============ -->
|
|
29846
|
+
<div class="cs-panel" *ngIf="statementLoaded">
|
|
29847
|
+
<h5 class="cs-section">Recorded for {{ periodLabel() }}</h5>
|
|
29848
|
+
|
|
29849
|
+
<div class="cs-totals">
|
|
29850
|
+
<div class="cs-total"><span class="cs-total-label">Proposed (Draft)</span><span class="cs-total-value cs-draft">{{ totals.draft | number:'1.2-2' }}</span><span class="cs-total-note">not payable until approved</span></div>
|
|
29851
|
+
<div class="cs-total"><span class="cs-total-label">Payable (Approved)</span><span class="cs-total-value cs-approved">{{ totals.approved | number:'1.2-2' }}</span><span class="cs-total-note">will be picked up by a payroll run</span></div>
|
|
29852
|
+
<div class="cs-total"><span class="cs-total-label">Settled (Paid)</span><span class="cs-total-value">{{ totals.paid | number:'1.2-2' }}</span><span class="cs-total-note">already on a payslip line</span></div>
|
|
29853
|
+
<div class="cs-total"><span class="cs-total-label">Reversed</span><span class="cs-total-value cs-reversed">{{ totals.reversed | number:'1.2-2' }}</span><span class="cs-total-note">clawbacks against earlier entries</span></div>
|
|
29854
|
+
<div class="cs-total"><span class="cs-total-label">Carried forward in</span><span class="cs-total-value">{{ totals.carried | number:'1.2-2' }}</span><span class="cs-total-note">earned in an earlier period</span></div>
|
|
29855
|
+
</div>
|
|
29856
|
+
|
|
29857
|
+
<div class="alert alert-secondary cs-note" *ngIf="!entries.length">
|
|
29858
|
+
Nothing has been recorded for this period yet.
|
|
29859
|
+
</div>
|
|
29860
|
+
|
|
29861
|
+
<table class="cs-table" *ngIf="entries.length">
|
|
29862
|
+
<thead>
|
|
29863
|
+
<tr><th>Employee</th><th>Description</th><th>Source</th><th class="cs-num">Measured</th><th class="cs-num">Amount</th><th>Status</th><th>Paid</th><th>Lines</th></tr>
|
|
29864
|
+
</thead>
|
|
29865
|
+
<tbody>
|
|
29866
|
+
<tr *ngFor="let e of entries">
|
|
29867
|
+
<td>{{ e.employeeName }}</td>
|
|
29868
|
+
<td>
|
|
29869
|
+
{{ e.description }}
|
|
29870
|
+
<span class="cs-flag" *ngIf="e.isCarriedForward">from {{ e.originPeriodMonth }}/{{ e.originPeriodYear }}</span>
|
|
29871
|
+
<span class="cs-flag cs-flag-warn" *ngIf="e.isReversal">reverses #{{ e.reversesCommissionEntryID }}</span>
|
|
29872
|
+
</td>
|
|
29873
|
+
<td>{{ e.sourceType }}</td>
|
|
29874
|
+
<td class="cs-num">{{ e.metricValue | number:'1.0-2' }}</td>
|
|
29875
|
+
<td class="cs-num cs-strong" [class.cs-negative]="e.commissionAmount < 0">{{ e.commissionAmount | number:'1.2-2' }}</td>
|
|
29876
|
+
<td><span [ngClass]="statusClass(e)">{{ e.statusName }}</span></td>
|
|
29877
|
+
<td>{{ e.isPaid ? 'Yes' : '' }}</td>
|
|
29878
|
+
<td>{{ e.lineCount }}</td>
|
|
29879
|
+
</tr>
|
|
29880
|
+
</tbody>
|
|
29881
|
+
</table>
|
|
29882
|
+
</div>
|
|
29883
|
+
|
|
29884
|
+
</div>
|
|
29885
|
+
`, standalone: false, styles: [".cs-container{padding:16px}.cs-title{display:flex;align-items:center;gap:8px;margin-bottom:16px;color:#333;font-weight:500}.cs-bar{display:flex;flex-wrap:wrap;gap:16px;align-items:center;justify-content:space-between;margin-bottom:8px}.cs-period,.cs-actions{display:flex;flex-wrap:wrap;gap:12px;align-items:center}.cs-year{width:110px}.cs-month{width:160px}.cs-note{font-size:14px}.cs-panel{margin-top:8px}.cs-headline{font-size:14px}.cs-section{color:#333;font-weight:500;margin:16px 0 8px}.cs-list{margin:6px 0 0;padding-left:20px}.cs-list li{margin-bottom:3px}.cs-table{width:100%;border-collapse:collapse;font-size:13px}.cs-table th{text-align:left;padding:8px 12px;background:#f5f5f5;border-bottom:2px solid #e0e0e0;font-weight:500;white-space:nowrap}.cs-table td{padding:8px 12px;border-bottom:1px solid #eee;vertical-align:top}.cs-table tr:hover{background:#fafafa}.cs-num{text-align:right;white-space:nowrap}.cs-strong{font-weight:600}.cs-zero{color:#e65100}.cs-negative{color:#c62828}.cs-notes{max-width:320px}.cs-flag{display:inline-block;margin:1px 4px 1px 0;padding:1px 7px;border-radius:10px;background:#eceff1;color:#455a64;font-size:11px;white-space:nowrap}.cs-flag-warn{background:#fff3cd;color:#664d03}.cs-warn{display:block;color:#664d03;font-size:12px;margin-top:2px}.cs-totals{display:flex;flex-wrap:wrap;gap:12px;margin-bottom:12px}.cs-total{display:flex;flex-direction:column;min-width:170px;flex:1 1 170px;padding:10px 14px;border:1px solid #e0e0e0;border-radius:6px;background:#fff}.cs-total-label{font-size:12px;color:#607d8b}.cs-total-value{font-size:20px;font-weight:600}.cs-total-note{font-size:11px;color:#90a4ae}.cs-draft{color:#757575}.cs-approved{color:#2e7d32}.cs-reversed{color:#c62828}.cs-status-draft{color:#757575}.cs-status-approved{color:#2e7d32;font-weight:600}.cs-status-reversed{color:#c62828}@media (max-width: 700px){.cs-bar{flex-direction:column;align-items:stretch}.cs-actions button{flex:1 1 auto}.cs-panel{overflow-x:auto}.cs-notes{max-width:none}}\n"] }]
|
|
29886
|
+
}] });
|
|
29887
|
+
|
|
28522
29888
|
class SalaryAdvancesComponent {
|
|
28523
29889
|
constructor() {
|
|
28524
29890
|
this.payrollService = inject(PayrollService);
|
|
@@ -28704,6 +30070,7 @@ const PAYROLL_ROUTES = [
|
|
|
28704
30070
|
{ path: "runs", component: PayrollRunsComponent },
|
|
28705
30071
|
{ path: "commission-configs", component: CommissionConfigsComponent },
|
|
28706
30072
|
{ path: "commission-entries", component: CommissionEntriesComponent },
|
|
30073
|
+
{ path: "commission-statement", component: CommissionStatementComponent }, // Added (Phase 6): cap86 — routed and registered, granted in no role template
|
|
28707
30074
|
{ path: "salary-advances", component: SalaryAdvancesComponent },
|
|
28708
30075
|
{ path: "overtime-entries", component: OvertimeEntriesComponent },
|
|
28709
30076
|
{ path: "dashboard", component: PayrollDashboardComponent }
|
|
@@ -29000,11 +30367,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
|
|
|
29000
30367
|
}] });
|
|
29001
30368
|
|
|
29002
30369
|
class TenantSettingsComponent {
|
|
29003
|
-
constructor(dataService, messageService, authService, dialog) {
|
|
30370
|
+
constructor(dataService, messageService, authService, dialog, apiErrorService) {
|
|
29004
30371
|
this.dataService = dataService;
|
|
29005
30372
|
this.messageService = messageService;
|
|
29006
30373
|
this.authService = authService;
|
|
29007
30374
|
this.dialog = dialog;
|
|
30375
|
+
this.apiErrorService = apiErrorService;
|
|
29008
30376
|
this.roles = [
|
|
29009
30377
|
{ roleName: 'Default', roleID: 1 },
|
|
29010
30378
|
];
|
|
@@ -29158,7 +30526,7 @@ class TenantSettingsComponent {
|
|
|
29158
30526
|
loadAction: { url: 'mailerconfigs/all/x' },
|
|
29159
30527
|
formConfig: this.mailerFormConfig
|
|
29160
30528
|
};
|
|
29161
|
-
}
|
|
30529
|
+
} // Changed: injected ApiErrorService for the switch failure
|
|
29162
30530
|
ngOnInit() {
|
|
29163
30531
|
this.authService.myRoleObserv.subscribe(rol => this.myRole = rol);
|
|
29164
30532
|
this.loadData();
|
|
@@ -29203,6 +30571,9 @@ class TenantSettingsComponent {
|
|
|
29203
30571
|
if (apiResponse.success) {
|
|
29204
30572
|
this.forceLogin();
|
|
29205
30573
|
}
|
|
30574
|
+
else {
|
|
30575
|
+
this.apiErrorService.presentAppFailure(apiResponse, 'action', 'tenants/dto?action=switch'); // Added: was silent. The user picked an organisation, confirmed it, and got NOTHING — no switch, no message — leaving them unable to tell whether they had changed organisation or not, on the one screen where that question matters most
|
|
30576
|
+
}
|
|
29206
30577
|
});
|
|
29207
30578
|
}
|
|
29208
30579
|
});
|
|
@@ -29211,13 +30582,13 @@ class TenantSettingsComponent {
|
|
|
29211
30582
|
this.messageService.toast("Switched Successfully, please login again");
|
|
29212
30583
|
this.authService.logoff();
|
|
29213
30584
|
}
|
|
29214
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: TenantSettingsComponent, deps: [{ token: DataServiceLib }, { token: MessageService }, { token: AuthService }, { token: i4.MatDialog }], target: i0.ɵɵFactoryTarget.Component }); }
|
|
30585
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: TenantSettingsComponent, deps: [{ token: DataServiceLib }, { token: MessageService }, { token: AuthService }, { token: i4.MatDialog }, { token: ApiErrorService }], target: i0.ɵɵFactoryTarget.Component }); }
|
|
29215
30586
|
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: TenantSettingsComponent, isStandalone: false, selector: "spa-tenant-settings", ngImport: i0, template: "<div class=\"container\">\n\n <div>\n\n <label class=\"title\" >Organisation Details</label>\n <hr style=\"margin-top: 0px; margin-bottom: 0px;\">\n\n <div *ngIf=\"currTenant && plan\" class=\"mb-2 mt-3 tin-grid\" style=\" font-size: 14px;\">\n\n <div class=\"tin-col mb-3\" style=\"max-width: 500px;\">\n <spa-select display=\"Current Organisation\" [options]=\"tenants\" optionDisplay=\"name\" optionValue=\"tenantID\" [(value)]=\"currentTenantID\"\n hint=\"You are required to login again after switching organisations.\" style=\"min-width: 300px;margin-bottom: 10px;\"></spa-select>\n <button mat-stroked-button color=\"primary\" [disabled]=\"currentTenantID == currTenant.tenantID\" (click)=\"switchTenant()\">Switch</button>\n </div>\n\n </div>\n\n </div>\n\n\n <ng-container *ngIf=\"ownTenant\" >\n <!-- Members -->\n <div class=\"mt-3\" >\n\n <label class=\"title\" >Members</label>\n <hr style=\"margin-top: 0px; margin-bottom: 0px;\">\n <label class=\"subtitle text-muted\">Invite other users to join your organisation as partners or employees to form a partnership or company.</label>\n\n <spa-table [config]=\"membersTableConfig\" [reload]=\"tableReload\" ></spa-table>\n\n </div>\n\n\n<!-- My Organisations -->\n <div class=\"mt-3\" *ngIf=\"ownTenant\">\n\n <label class=\"title\" >My Organisations</label>\n <hr style=\"margin-top: 0px; margin-bottom: 0px;\">\n <label class=\"subtitle text-muted\">Organisations that you are a member of.</label>\n\n <spa-table [config]=\"orgsTableConfig\" [reload]=\"orgsReload\" (actionResponse)=\"updateTenant($event)\"></spa-table>\n\n </div>\n\n\n <!-- My Invitations -->\n <div class=\"mt-3\" *ngIf=\"ownTenant\">\n\n <label class=\"title\">My Invitations</label>\n <hr style=\"margin-top: 0px; margin-bottom: 0px;\">\n <label class=\"subtitle text-muted\">Requests for you to join other organisations.</label>\n\n\n <spa-invitations-table></spa-invitations-table>\n\n </div>\n\n <!-- Billing -->\n <div class=\"mt-3\" *ngIf=\"ownTenant\">\n\n <label class=\"title\" >Billing and Subscription</label>\n <hr style=\"margin-top: 0px; margin-bottom: 0px;\">\n\n <div *ngIf=\"currTenant && plan\" class=\"mb-1 mt-3\" style=\"max-width: 300px; font-size: 14px;\">\n <spa-label display=\"Plan\" [value]=\"plan.name\"></spa-label>\n <spa-label display=\"Next Payment\" format=\"money\" [value]=\"plan.price\"></spa-label>\n <spa-label display=\"Due Date\" format=\"date\" value=\"2024-01-01\"></spa-label>\n </div>\n\n </div>\n\n <!-- Email -->\n <div class=\"mt-3 mb-5\" *ngIf=\"ownTenant\">\n\n <label class=\"title\">Email Configuration</label>\n <hr style=\"margin-top: 0px; margin-bottom: 0px;\">\n <label class=\"subtitle text-muted\">Configure email settings for sending notifications.</label>\n\n <spa-table [config]=\"mailerTableConfig\"></spa-table>\n\n </div>\n\n\n </ng-container>\n\n\n\n</div>\n\n\n", styles: [".title{margin-top:1em;font-size:28px;font-weight:300}.subtitle{font-size:smaller}\n"], dependencies: [{ kind: "directive", type: i1$2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "component", type: i3.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: SelectComponent, selector: "spa-select", inputs: ["detailsConfig"] }, { kind: "component", type: LabelComponent, selector: "spa-label", inputs: ["display", "value", "format", "suffix", "size"] }, { kind: "component", type: TableComponent, selector: "spa-table", inputs: ["data", "tileData", "config", "localMode", "parentDetails", "reload", "activeTab", "inTab", "nestingLevel"], outputs: ["dataLoad", "totalChange", "actionSuccess", "refreshClick", "searchClick", "createClick", "actionClick", "inputChange", "actionResponse"] }, { kind: "component", type: InvitationsTableComponent, selector: "spa-invitations-table" }] }); }
|
|
29216
30587
|
}
|
|
29217
30588
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: TenantSettingsComponent, decorators: [{
|
|
29218
30589
|
type: Component,
|
|
29219
30590
|
args: [{ selector: 'spa-tenant-settings', standalone: false, template: "<div class=\"container\">\n\n <div>\n\n <label class=\"title\" >Organisation Details</label>\n <hr style=\"margin-top: 0px; margin-bottom: 0px;\">\n\n <div *ngIf=\"currTenant && plan\" class=\"mb-2 mt-3 tin-grid\" style=\" font-size: 14px;\">\n\n <div class=\"tin-col mb-3\" style=\"max-width: 500px;\">\n <spa-select display=\"Current Organisation\" [options]=\"tenants\" optionDisplay=\"name\" optionValue=\"tenantID\" [(value)]=\"currentTenantID\"\n hint=\"You are required to login again after switching organisations.\" style=\"min-width: 300px;margin-bottom: 10px;\"></spa-select>\n <button mat-stroked-button color=\"primary\" [disabled]=\"currentTenantID == currTenant.tenantID\" (click)=\"switchTenant()\">Switch</button>\n </div>\n\n </div>\n\n </div>\n\n\n <ng-container *ngIf=\"ownTenant\" >\n <!-- Members -->\n <div class=\"mt-3\" >\n\n <label class=\"title\" >Members</label>\n <hr style=\"margin-top: 0px; margin-bottom: 0px;\">\n <label class=\"subtitle text-muted\">Invite other users to join your organisation as partners or employees to form a partnership or company.</label>\n\n <spa-table [config]=\"membersTableConfig\" [reload]=\"tableReload\" ></spa-table>\n\n </div>\n\n\n<!-- My Organisations -->\n <div class=\"mt-3\" *ngIf=\"ownTenant\">\n\n <label class=\"title\" >My Organisations</label>\n <hr style=\"margin-top: 0px; margin-bottom: 0px;\">\n <label class=\"subtitle text-muted\">Organisations that you are a member of.</label>\n\n <spa-table [config]=\"orgsTableConfig\" [reload]=\"orgsReload\" (actionResponse)=\"updateTenant($event)\"></spa-table>\n\n </div>\n\n\n <!-- My Invitations -->\n <div class=\"mt-3\" *ngIf=\"ownTenant\">\n\n <label class=\"title\">My Invitations</label>\n <hr style=\"margin-top: 0px; margin-bottom: 0px;\">\n <label class=\"subtitle text-muted\">Requests for you to join other organisations.</label>\n\n\n <spa-invitations-table></spa-invitations-table>\n\n </div>\n\n <!-- Billing -->\n <div class=\"mt-3\" *ngIf=\"ownTenant\">\n\n <label class=\"title\" >Billing and Subscription</label>\n <hr style=\"margin-top: 0px; margin-bottom: 0px;\">\n\n <div *ngIf=\"currTenant && plan\" class=\"mb-1 mt-3\" style=\"max-width: 300px; font-size: 14px;\">\n <spa-label display=\"Plan\" [value]=\"plan.name\"></spa-label>\n <spa-label display=\"Next Payment\" format=\"money\" [value]=\"plan.price\"></spa-label>\n <spa-label display=\"Due Date\" format=\"date\" value=\"2024-01-01\"></spa-label>\n </div>\n\n </div>\n\n <!-- Email -->\n <div class=\"mt-3 mb-5\" *ngIf=\"ownTenant\">\n\n <label class=\"title\">Email Configuration</label>\n <hr style=\"margin-top: 0px; margin-bottom: 0px;\">\n <label class=\"subtitle text-muted\">Configure email settings for sending notifications.</label>\n\n <spa-table [config]=\"mailerTableConfig\"></spa-table>\n\n </div>\n\n\n </ng-container>\n\n\n\n</div>\n\n\n", styles: [".title{margin-top:1em;font-size:28px;font-weight:300}.subtitle{font-size:smaller}\n"] }]
|
|
29220
|
-
}], ctorParameters: () => [{ type: DataServiceLib }, { type: MessageService }, { type: AuthService }, { type: i4.MatDialog }] });
|
|
30591
|
+
}], ctorParameters: () => [{ type: DataServiceLib }, { type: MessageService }, { type: AuthService }, { type: i4.MatDialog }, { type: ApiErrorService }] });
|
|
29221
30592
|
|
|
29222
30593
|
class TenantsComponent {
|
|
29223
30594
|
constructor() {
|
|
@@ -29499,10 +30870,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
|
|
|
29499
30870
|
|
|
29500
30871
|
// Subscription management page — current plan display, plan comparison, upgrade/downgrade
|
|
29501
30872
|
class SubscriptionPageComponent {
|
|
29502
|
-
constructor(httpService, subscriptionService, snackBar
|
|
30873
|
+
constructor(httpService, subscriptionService, snackBar, apiErrorService // Changed: injected so a failed load or plan change is classified rather than swallowed or echoed
|
|
30874
|
+
) {
|
|
29503
30875
|
this.httpService = httpService;
|
|
29504
30876
|
this.subscriptionService = subscriptionService;
|
|
29505
30877
|
this.snackBar = snackBar;
|
|
30878
|
+
this.apiErrorService = apiErrorService;
|
|
29506
30879
|
this.currentSubscription = null;
|
|
29507
30880
|
this.availablePlans = [];
|
|
29508
30881
|
this.loading = true;
|
|
@@ -29521,6 +30894,9 @@ class SubscriptionPageComponent {
|
|
|
29521
30894
|
if (response?.success) {
|
|
29522
30895
|
this.currentSubscription = response.data;
|
|
29523
30896
|
}
|
|
30897
|
+
else {
|
|
30898
|
+
this.apiErrorService.presentAppFailure(response, 'load', 'subscriptions/current'); // Added: was silent — the "your current plan" panel stayed blank, and a user comparing plans could not tell which one they were already on
|
|
30899
|
+
}
|
|
29524
30900
|
this.loading = false;
|
|
29525
30901
|
},
|
|
29526
30902
|
error: () => { this.loading = false; }
|
|
@@ -29533,6 +30909,9 @@ class SubscriptionPageComponent {
|
|
|
29533
30909
|
if (response?.success) {
|
|
29534
30910
|
this.availablePlans = response.data || [];
|
|
29535
30911
|
}
|
|
30912
|
+
else {
|
|
30913
|
+
this.apiErrorService.presentAppFailure(response, 'load', 'subscriptions/plans'); // Added: was silent. A plan-comparison page with no plans on it reads as "there is nothing to upgrade to", which is a commercially wrong thing to tell someone by accident
|
|
30914
|
+
}
|
|
29536
30915
|
}
|
|
29537
30916
|
});
|
|
29538
30917
|
}
|
|
@@ -29572,7 +30951,7 @@ class SubscriptionPageComponent {
|
|
|
29572
30951
|
this.subscriptionService.loadFeatures(); // Refresh enabled features for UI gating
|
|
29573
30952
|
}
|
|
29574
30953
|
else {
|
|
29575
|
-
this.
|
|
30954
|
+
this.apiErrorService.presentAppFailure(response, 'action', 'subscriptions/change'); // Changed: was a bare MatSnackBar carrying response.message verbatim for 3 seconds. 'action' rather than 'submit' — this sits behind a confirmation panel with nothing typed into it, so the 'submit' wording about a still-populated form would not be true
|
|
29576
30955
|
}
|
|
29577
30956
|
},
|
|
29578
30957
|
error: () => {
|
|
@@ -29581,7 +30960,7 @@ class SubscriptionPageComponent {
|
|
|
29581
30960
|
}
|
|
29582
30961
|
});
|
|
29583
30962
|
}
|
|
29584
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: SubscriptionPageComponent, deps: [{ token: HttpService }, { token: SubscriptionService }, { token: i2$1.MatSnackBar }], target: i0.ɵɵFactoryTarget.Component }); }
|
|
30963
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: SubscriptionPageComponent, deps: [{ token: HttpService }, { token: SubscriptionService }, { token: i2$1.MatSnackBar }, { token: ApiErrorService }], target: i0.ɵɵFactoryTarget.Component }); }
|
|
29585
30964
|
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: SubscriptionPageComponent, isStandalone: false, selector: "spa-subscription", ngImport: i0, template: `
|
|
29586
30965
|
<div class="subscription-container">
|
|
29587
30966
|
<h4 class="page-title"><mat-icon>card_membership</mat-icon> Subscription</h4>
|
|
@@ -29815,13 +31194,15 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
|
|
|
29815
31194
|
</div>
|
|
29816
31195
|
</div>
|
|
29817
31196
|
`, standalone: false, styles: [".subscription-container{padding:16px;max-width:1200px}.page-title{display:flex;align-items:center;gap:8px;margin-bottom:16px;color:#333;font-weight:500}.section-title{margin:24px 0 16px;color:#555}.current-plan-card{margin-bottom:24px}.plan-avatar{font-size:40px;width:40px;height:40px;color:#1976d2}.plan-details{display:flex;gap:32px;flex-wrap:wrap;padding:8px 0}.detail-item{display:flex;flex-direction:column}.detail-label{font-size:12px;color:#888;text-transform:uppercase}.detail-value{font-size:16px;font-weight:500}.status-badge{padding:2px 10px;border-radius:12px;font-size:12px;font-weight:500}.status-0{background:#e3f2fd;color:#1565c0}.status-1{background:#e8f5e9;color:#2e7d32}.status-2{background:#fff3e0;color:#e65100}.status-3{background:#fce4ec;color:#c62828}.status-4{background:#f5f5f5;color:#616161}.no-plan-message{display:flex;align-items:center;gap:8px;color:#666;margin:0}.plans-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:16px}.plan-card{display:flex;flex-direction:column}.plan-card.current{border:2px solid #1976d2}.plan-price{text-align:center;padding:16px 0}.price-amount{font-size:32px;font-weight:700;color:#1976d2}.price-period{font-size:14px;color:#888}.plan-limits{display:flex;gap:16px;justify-content:center;padding-bottom:12px}.limit-item{display:flex;align-items:center;gap:4px;font-size:13px;color:#666}.limit-item mat-icon{font-size:18px;width:18px;height:18px}.feature-list{padding:12px 0}.feature-item{display:flex;align-items:center;gap:8px;padding:4px 0;font-size:14px}.feature-check{color:#4caf50;font-size:18px;width:18px;height:18px}.feature-limit{font-size:12px;color:#999}.no-features{display:flex;align-items:center;gap:4px;color:#999;font-size:13px}mat-card-actions{margin-top:auto}mat-card-actions button{width:100%}.overlay{position:fixed;inset:0;background:#0006;display:flex;align-items:center;justify-content:center;z-index:1000}.confirm-dialog{max-width:440px;width:90%}.price-info{font-size:16px}.trial-info{display:flex;align-items:center;gap:4px;color:#1976d2;font-size:14px}\n"] }]
|
|
29818
|
-
}], ctorParameters: () => [{ type: HttpService }, { type: SubscriptionService }, { type: i2$1.MatSnackBar }] });
|
|
31197
|
+
}], ctorParameters: () => [{ type: HttpService }, { type: SubscriptionService }, { type: i2$1.MatSnackBar }, { type: ApiErrorService }] });
|
|
29819
31198
|
|
|
29820
31199
|
// Billing page — invoice history table, invoice detail dialog, and payment flow dialog
|
|
29821
31200
|
class BillingPageComponent {
|
|
29822
|
-
constructor(httpService, snackBar
|
|
31201
|
+
constructor(httpService, snackBar, apiErrorService // Changed: injected so failures on this page are classified, not swallowed or echoed
|
|
31202
|
+
) {
|
|
29823
31203
|
this.httpService = httpService;
|
|
29824
31204
|
this.snackBar = snackBar;
|
|
31205
|
+
this.apiErrorService = apiErrorService;
|
|
29825
31206
|
this.currentSubscription = null;
|
|
29826
31207
|
this.invoices = [];
|
|
29827
31208
|
this.loading = true;
|
|
@@ -29845,6 +31226,9 @@ class BillingPageComponent {
|
|
|
29845
31226
|
if (response?.success) {
|
|
29846
31227
|
this.currentSubscription = response.data;
|
|
29847
31228
|
}
|
|
31229
|
+
else {
|
|
31230
|
+
this.apiErrorService.presentAppFailure(response, 'load', 'subscriptions/current'); // Added: was silent — the subscription summary card simply did not render, so a tenant could not tell a broken read from having no plan
|
|
31231
|
+
}
|
|
29848
31232
|
}
|
|
29849
31233
|
});
|
|
29850
31234
|
}
|
|
@@ -29855,6 +31239,9 @@ class BillingPageComponent {
|
|
|
29855
31239
|
if (response?.success) {
|
|
29856
31240
|
this.invoices = response.data || [];
|
|
29857
31241
|
}
|
|
31242
|
+
else {
|
|
31243
|
+
this.apiErrorService.presentAppFailure(response, 'load', 'tenantinvoices/all/x'); // Added: was silent. An empty invoice list is a perfectly normal state for a new tenant, which is precisely why a FAILED one must not look the same — "you have no invoices" is a different fact from "we could not fetch them"
|
|
31244
|
+
}
|
|
29858
31245
|
this.loading = false;
|
|
29859
31246
|
},
|
|
29860
31247
|
error: () => { this.loading = false; }
|
|
@@ -29884,7 +31271,7 @@ class BillingPageComponent {
|
|
|
29884
31271
|
this.showDetail = true;
|
|
29885
31272
|
}
|
|
29886
31273
|
else {
|
|
29887
|
-
this.
|
|
31274
|
+
this.apiErrorService.presentAppFailure(response, 'action', 'tenantinvoices/detail/' + invoice.tenantInvoiceID); // Changed: was a bare MatSnackBar carrying response.message — raw server text for 3 seconds, ApiErrorService bypassed entirely, the same shape Phase 3 removed from groups.component.ts
|
|
29888
31275
|
}
|
|
29889
31276
|
},
|
|
29890
31277
|
error: () => {
|
|
@@ -29922,7 +31309,7 @@ class BillingPageComponent {
|
|
|
29922
31309
|
this.paymentResult = { success: false, redirectUrl: response.data.redirectUrl };
|
|
29923
31310
|
}
|
|
29924
31311
|
else {
|
|
29925
|
-
this.paymentResult = { success: false, message: response?.message || 'Payment failed'
|
|
31312
|
+
this.paymentResult = { success: false, message: this.paymentFailureText(response, 'payments/process/' + this.paymentInvoice.tenantInvoiceID) }; // Changed: was `response?.message || 'Payment failed'` rendered verbatim at the template's failure state
|
|
29926
31313
|
}
|
|
29927
31314
|
},
|
|
29928
31315
|
error: () => {
|
|
@@ -29931,6 +31318,24 @@ class BillingPageComponent {
|
|
|
29931
31318
|
}
|
|
29932
31319
|
});
|
|
29933
31320
|
}
|
|
31321
|
+
// Added: the payment dialog is the one place in this sweep where a MODAL is the WRONG answer. It already
|
|
31322
|
+
// owns a dedicated failure state — error icon, red panel, inside the dialog the user is staring at — so
|
|
31323
|
+
// stacking presentAppFailure's dialog on top of it would talk over a surface that is already doing the job,
|
|
31324
|
+
// and would leave the panel underneath still showing the raw sentence anyway.
|
|
31325
|
+
//
|
|
31326
|
+
// What was actually wrong was the TEXT, not the surface. So the classifier is reused directly: a message a
|
|
31327
|
+
// backend author wrote for a human ("Insufficient funds", "Card declined") is exactly what someone paying an
|
|
31328
|
+
// invoice needs and is shown unchanged, while an implementation leak is replaced. The reference, when the
|
|
31329
|
+
// catch-all minted one, is kept — a failed payment is the single most likely thing a tenant phones about.
|
|
31330
|
+
paymentFailureText(response, url) {
|
|
31331
|
+
const raw = response?.message;
|
|
31332
|
+
console.error('[tin-spa] payment failure', { url, message: raw, response }); // the technical detail still reaches the console, exactly as presentAppFailure does
|
|
31333
|
+
if (this.apiErrorService.classifyMessage(raw) === 'business')
|
|
31334
|
+
return raw;
|
|
31335
|
+
const reference = this.apiErrorService.extractReference(raw);
|
|
31336
|
+
return `We couldn't complete that payment because of a technical problem — not anything you did wrong. Nothing was charged.` +
|
|
31337
|
+
(reference ? ` Please try again shortly, and quote reference ${reference} if you contact your administrator.` : ` Please try again shortly.`);
|
|
31338
|
+
}
|
|
29934
31339
|
// Close the payment dialog
|
|
29935
31340
|
closePayment() {
|
|
29936
31341
|
this.showPayment = false;
|
|
@@ -29940,7 +31345,7 @@ class BillingPageComponent {
|
|
|
29940
31345
|
this.loadInvoices(); // Refresh if payment was successful
|
|
29941
31346
|
}
|
|
29942
31347
|
}
|
|
29943
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: BillingPageComponent, deps: [{ token: HttpService }, { token: i2$1.MatSnackBar }], target: i0.ɵɵFactoryTarget.Component }); }
|
|
31348
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: BillingPageComponent, deps: [{ token: HttpService }, { token: i2$1.MatSnackBar }, { token: ApiErrorService }], target: i0.ɵɵFactoryTarget.Component }); }
|
|
29944
31349
|
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: BillingPageComponent, isStandalone: false, selector: "spa-billing", ngImport: i0, template: `
|
|
29945
31350
|
<div class="billing-container">
|
|
29946
31351
|
<h4 class="page-title"><mat-icon>receipt_long</mat-icon> Billing & Invoices</h4>
|
|
@@ -30354,7 +31759,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
|
|
|
30354
31759
|
</div>
|
|
30355
31760
|
</div>
|
|
30356
31761
|
`, standalone: false, styles: [".billing-container{padding:16px;max-width:1200px}.page-title{display:flex;align-items:center;gap:8px;margin-bottom:16px;color:#333;font-weight:500}.summary-card{margin-bottom:24px}.summary-row{display:flex;gap:32px;flex-wrap:wrap}.summary-item{display:flex;flex-direction:column}.summary-label{font-size:12px;color:#888;text-transform:uppercase}.summary-value{font-size:16px;font-weight:500}.status-badge{padding:2px 10px;border-radius:12px;font-size:12px;font-weight:500}.status-0{background:#e3f2fd;color:#1565c0}.status-1{background:#e8f5e9;color:#2e7d32}.status-2{background:#fff3e0;color:#e65100}.status-3{background:#fce4ec;color:#c62828}.status-4{background:#f5f5f5;color:#616161}.invoices-card{margin-bottom:24px}.table-container{overflow-x:auto}.invoice-table,.transaction-table{width:100%;border-collapse:collapse}.invoice-table th,.invoice-table td,.transaction-table th,.transaction-table td{padding:10px 12px;text-align:left;border-bottom:1px solid #e0e0e0;font-size:14px}.invoice-table th,.transaction-table th{font-weight:500;color:#666;font-size:12px;text-transform:uppercase}.invoice-table tbody tr:hover{background:#f5f5f5}.invoice-status{padding:2px 8px;border-radius:10px;font-size:12px;font-weight:500}.inv-status-0{background:#fff3e0;color:#e65100}.inv-status-1{background:#e8f5e9;color:#2e7d32}.inv-status-2{background:#fce4ec;color:#c62828}.inv-status-3{background:#f3e5f5;color:#6a1b9a}.txn-status{padding:2px 8px;border-radius:10px;font-size:12px;font-weight:500}.txn-status-0{background:#e3f2fd;color:#1565c0}.txn-status-1{background:#fff3e0;color:#e65100}.txn-status-2{background:#e8f5e9;color:#2e7d32}.txn-status-3{background:#fce4ec;color:#c62828}.txn-status-4{background:#f5f5f5;color:#616161}.empty-state,.loading-state{display:flex;flex-direction:column;align-items:center;padding:32px;color:#999}.empty-state mat-icon{font-size:48px;width:48px;height:48px;margin-bottom:8px}.overlay{position:fixed;inset:0;background:#0006;display:flex;align-items:center;justify-content:center;z-index:1000}.detail-dialog{max-width:640px;width:90%;max-height:80vh;overflow-y:auto}.payment-dialog{max-width:440px;width:90%}.close-btn{position:absolute;top:8px;right:8px}.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px}.detail-field{display:flex;flex-direction:column}.field-label{font-size:12px;color:#888;text-transform:uppercase}.field-value{font-size:14px;font-weight:500}.transactions-section{margin-top:16px}.transactions-section h5{margin:0 0 8px;color:#555}.no-transactions{color:#999;font-size:13px}.payment-summary{margin-bottom:16px}.payment-summary p{margin:4px 0;font-size:15px}.payment-state{display:flex;flex-direction:column;align-items:center;padding:24px;text-align:center}.result-icon{font-size:48px;width:48px;height:48px;margin-bottom:8px}.success-icon{color:#4caf50}.redirect-icon{color:#1976d2}.error-icon{color:#f44336}.payment-ref{font-size:12px;color:#888}\n"] }]
|
|
30357
|
-
}], ctorParameters: () => [{ type: HttpService }, { type: i2$1.MatSnackBar }] });
|
|
31762
|
+
}], ctorParameters: () => [{ type: HttpService }, { type: i2$1.MatSnackBar }, { type: ApiErrorService }] });
|
|
30358
31763
|
|
|
30359
31764
|
const TENANCY_ROUTES = [
|
|
30360
31765
|
{ path: "settings", component: TenantSettingsComponent }, // Changed: Removed "tenant-" prefix — module path provides the namespace
|
|
@@ -30387,7 +31792,7 @@ class ApprovalsComponent {
|
|
|
30387
31792
|
ngOnInit() {
|
|
30388
31793
|
}
|
|
30389
31794
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: ApprovalsComponent, deps: [{ token: DataServiceLib }], target: i0.ɵɵFactoryTarget.Component }); }
|
|
30390
|
-
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: ApprovalsComponent, isStandalone: false, selector: "spa-approvals", ngImport: i0, template: "<h4>Approvals</h4>\n<hr>\n\n<mat-tab-group>\n <mat-tab label=\"Received\">\n <div class=\"mt-3\">\n <spa-table [config]=\"dataService.receivedApprovalsTableConfig\"></spa-table>\n </div>\n </mat-tab>\n <mat-tab label=\"Sent\">\n <div class=\"mt-3\">\n <spa-table [config]=\"dataService.sentApprovalsTableConfig\"></spa-table>\n </div>\n </mat-tab>\n</mat-tab-group>\n", styles: [""], dependencies: [{ kind: "component", type:
|
|
31795
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: ApprovalsComponent, isStandalone: false, selector: "spa-approvals", ngImport: i0, template: "<h4>Approvals</h4>\n<hr>\n\n<mat-tab-group>\n <mat-tab label=\"Received\">\n <div class=\"mt-3\">\n <spa-table [config]=\"dataService.receivedApprovalsTableConfig\"></spa-table>\n </div>\n </mat-tab>\n <mat-tab label=\"Sent\">\n <div class=\"mt-3\">\n <spa-table [config]=\"dataService.sentApprovalsTableConfig\"></spa-table>\n </div>\n </mat-tab>\n</mat-tab-group>\n", styles: [""], dependencies: [{ kind: "component", type: i3$2.MatTab, selector: "mat-tab", inputs: ["disabled", "label", "aria-label", "aria-labelledby", "labelClass", "bodyClass", "id"], exportAs: ["matTab"] }, { kind: "component", type: i3$2.MatTabGroup, selector: "mat-tab-group", inputs: ["color", "fitInkBarToContent", "mat-stretch-tabs", "mat-align-tabs", "dynamicHeight", "selectedIndex", "headerPosition", "animationDuration", "contentTabIndex", "disablePagination", "disableRipple", "preserveContent", "backgroundColor", "aria-label", "aria-labelledby"], outputs: ["selectedIndexChange", "focusChange", "animationDone", "selectedTabChange"], exportAs: ["matTabGroup"] }, { kind: "component", type: TableComponent, selector: "spa-table", inputs: ["data", "tileData", "config", "localMode", "parentDetails", "reload", "activeTab", "inTab", "nestingLevel"], outputs: ["dataLoad", "totalChange", "actionSuccess", "refreshClick", "searchClick", "createClick", "actionClick", "inputChange", "actionResponse"] }] }); }
|
|
30391
31796
|
}
|
|
30392
31797
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: ApprovalsComponent, decorators: [{
|
|
30393
31798
|
type: Component,
|
|
@@ -31293,6 +32698,7 @@ class PayrollModule {
|
|
|
31293
32698
|
PayrollRunsComponent,
|
|
31294
32699
|
CommissionConfigsComponent,
|
|
31295
32700
|
CommissionEntriesComponent,
|
|
32701
|
+
CommissionStatementComponent,
|
|
31296
32702
|
SalaryAdvancesComponent,
|
|
31297
32703
|
OvertimeEntriesComponent,
|
|
31298
32704
|
PayrollDashboardComponent], imports: [CommonModule,
|
|
@@ -31309,6 +32715,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
|
|
|
31309
32715
|
PayrollRunsComponent,
|
|
31310
32716
|
CommissionConfigsComponent,
|
|
31311
32717
|
CommissionEntriesComponent,
|
|
32718
|
+
CommissionStatementComponent,
|
|
31312
32719
|
SalaryAdvancesComponent,
|
|
31313
32720
|
OvertimeEntriesComponent,
|
|
31314
32721
|
PayrollDashboardComponent
|