@nauticana/sail 1.1.24 → 1.1.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/README.md +46 -12
- package/package.json +2 -1
- package/src/component/abstract/base_table.ts +19 -4
- package/src/component/dashboard/stat_tile.html +29 -0
- package/src/component/dashboard/stat_tile.ts +46 -0
- package/src/component/document/document_review_queue.html +24 -0
- package/src/component/document/document_review_queue.ts +67 -0
- package/src/component/document/document_upload.html +31 -0
- package/src/component/document/document_upload.ts +113 -0
- package/src/component/document/partner_documents.html +33 -0
- package/src/component/document/partner_documents.ts +92 -0
- package/src/component/payment/user_payment_methods.html +1 -1
- package/src/component/payment/user_payment_methods.ts +6 -6
- package/src/component/table/action_params_dialog.html +50 -0
- package/src/component/table/action_params_dialog.ts +107 -0
- package/src/index.ts +6 -0
- package/src/model/appdata.ts +10 -0
- package/src/model/document.ts +36 -0
- package/src/service/label.service.ts +22 -0
- package/src/service/notification_inbox.service.ts +54 -15
- package/src/service/partner_document.service.ts +111 -0
- package/src/service/realtime.service.ts +5 -3
- package/src/service/rest_service.ts +11 -1
- package/src/service/rest_url.ts +7 -4
- package/src/service/user_payment_method.service.ts +8 -14
package/README.md
CHANGED
|
@@ -20,15 +20,16 @@ A shared Angular component library for building CRUD-based admin frontends. Prov
|
|
|
20
20
|
| **Account** | `MyAccountComponent` (self-service hub), `ProfileEditorComponent` (name/locale immediate; email/phone verify-before-apply) |
|
|
21
21
|
| **Auth** | `ConsentGateComponent`, `OtpInputComponent`, `SocialLoginComponent` |
|
|
22
22
|
| **Billing** | `PlanSelectorComponent`, `PriceSelectorComponent`, `CheckoutButtonComponent`, `PaymentMethodsComponent`, `PortalButtonComponent`, `UsageMeterComponent`, `StatusChipComponent`, `TrialBannerComponent`, `SeatSelectorComponent`, `DunningBannerComponent` |
|
|
23
|
-
| **Dashboard** | `DashboardShellComponent`, `DataCardComponent`, `LockedOverlayComponent`, `ActionCenterComponent`, `EntitySelectorComponent`, `VerificationFlowComponent` |
|
|
23
|
+
| **Dashboard** | `DashboardShellComponent`, `DataCardComponent`, `StatTileComponent`, `LockedOverlayComponent`, `ActionCenterComponent`, `EntitySelectorComponent`, `VerificationFlowComponent` |
|
|
24
24
|
| **Payout** | `PayoutProviderOnboardingComponent`, `PayoutBankInfoFormComponent` |
|
|
25
25
|
| **Agency** | `AgencyClientsComponent`, `AgencyStatusBannerComponent`, `AgencyAcceptComponent`, `AgencyEarningsSummaryComponent`, `CommissionLedgerComponent`, `AgencyPayoutHistoryComponent`, `AgencyPayoutProfileComponent` |
|
|
26
26
|
| **Notifications** | `NotificationCenterComponent`, `NotificationInboxService` |
|
|
27
|
+
| **Documents** | `DocumentUploadComponent`, `PartnerDocumentsComponent`, `DocumentReviewQueueComponent`, `PartnerDocumentService` |
|
|
27
28
|
| **Payments / SCA** | `UserPaymentMethodsComponent`, `ScaConfirmComponent`, `ScaConfirmer` (port), `SCA_CONFIRMER` (token) |
|
|
28
|
-
| **Services** | `BaseAuthService` (OTP / social / push / deleteAccount / logoutEverywhere / profile: `getProfile`, `updateProfile`, `request`+`confirmEmailChange`, `request`+`confirmPhoneChange`), `BillingService`, `AgencyService`, `BackendService`, `PayoutService`, `UserPaymentMethodService`, `NotificationInboxService`, `loadScript()`, `authInterceptor`, `apiResponseInterceptor` |
|
|
29
|
+
| **Services** | `BaseAuthService` (OTP / social / push / deleteAccount / logoutEverywhere / profile: `getProfile`, `updateProfile`, `request`+`confirmEmailChange`, `request`+`confirmPhoneChange`), `BillingService`, `AgencyService`, `BackendService`, `PayoutService`, `UserPaymentMethodService`, `PartnerDocumentService`, `NotificationInboxService`, `loadScript()`, `authInterceptor`, `apiResponseInterceptor` |
|
|
29
30
|
| **Abstracts** | `BaseTable`, `BaseForm`, `BaseView`, `AbstractEditableView`, `BaseAsync`, `BaseRestService` |
|
|
30
31
|
| **Config** | `SAIL_GUI_CONFIG`, `SailGuiConfig`, `configureRestUrls()` |
|
|
31
|
-
| **Models** | `ApplicationData`, `TableDefinition`, `SiudAction`, `ApplicationMenu`, `ConstantValue`, `UserAccount`, `RestReport`, `ReportParam`, `TrustedDevice`, `PublicPlan`, `PlanPrice`, `PaymentMethod`, `Subscription`, `Invoice`, `CheckoutRequest`/`CheckoutResponse`, `PortalResponse`, `UsageMeter`, `ChargeResult`, `UserPaymentMethod`, `TableAction`, `ReusableAccount`, `PayoutOnboardingSession`, `BankInfoFormValue`, `CountryProfile`, `OtpRequest`/`OtpResponse`, `SignupConsent`, `ConsentState`, `ConsentOption`, `SocialProvider`, `PushPlatform`, `InboxMessage`/`InboxPage`, 2FA types, etc. |
|
|
32
|
+
| **Models** | `ApplicationData`, `TableDefinition`, `SiudAction`, `ApplicationMenu`, `ConstantValue`, `UserAccount`, `RestReport`, `ReportParam`, `TrustedDevice`, `PublicPlan`, `PlanPrice`, `PaymentMethod`, `Subscription`, `Invoice`, `CheckoutRequest`/`CheckoutResponse`, `PortalResponse`, `UsageMeter`, `ChargeResult`, `UserPaymentMethod`, `TableAction`, `TableActionParameter`, `ReusableAccount`, `PayoutOnboardingSession`, `BankInfoFormValue`, `CountryProfile`, `OtpRequest`/`OtpResponse`, `SignupConsent`, `ConsentState`, `ConsentOption`, `SocialProvider`, `PushPlatform`, `InboxMessage`/`InboxPage`, `PartnerDocument`/`DocumentUploadRequest`, 2FA types, etc. |
|
|
32
33
|
| **Decorators** | `@IsSailString()`, `@IsSailNumeric()` (class-validator based) |
|
|
33
34
|
| **Utils** | `titleCase()`, `fromPriceLabel()`, `formatMinorCurrency()` |
|
|
34
35
|
|
|
@@ -269,7 +270,7 @@ this.labels.loadLabels('ride_type').subscribe(() => {
|
|
|
269
270
|
});
|
|
270
271
|
```
|
|
271
272
|
|
|
272
|
-
Concurrent loads share one application-data subscription, completed domains stay cached, and application-data failures can be retried.
|
|
273
|
+
Concurrent loads share one application-data subscription, completed domains stay cached, and application-data failures can be retried. `getLabel` falls back to the raw key; `requireLabel` throws instead, for values that must not render as a raw code. `options(domain)` returns every `{code, caption}` of a loaded domain, sorted by caption, for picklists.
|
|
273
274
|
|
|
274
275
|
## Status-filtered API errors
|
|
275
276
|
|
|
@@ -400,7 +401,7 @@ Every keel login response (keel v1.2.57+) carries `refreshToken` beside `token`;
|
|
|
400
401
|
|
|
401
402
|
### Realtime
|
|
402
403
|
|
|
403
|
-
`RealtimeService` (root-provided) is the client for keel's `realtime.Hub`. `connect()` opens `RestURL.realtimeURL` (`/public/ws`) on the configured backend host with the stored JWT, reconnects with backoff, and re-subscribes every channel after a reconnect. `channel<T>(name)` returns the payloads keel broadcasts on that channel; `userFrames<T>()` returns payloads addressed to the user; `send(frame)` forwards an application frame to the hub's `OnMessage`. A subscription keel's `CanSubscribe` rejects is logged and exposed on `lastError`. Channel names and payload shapes are app-owned.
|
|
404
|
+
`RealtimeService` (root-provided) is the client for keel's `realtime.Hub`. `connect()` opens `RestURL.realtimeURL` (`/public/ws`) on the configured backend host with the stored JWT, reconnects with backoff, and re-subscribes every channel after a reconnect. `channel<T>(name)` returns the payloads keel broadcasts on that channel; `userFrames<T>()` returns payloads addressed to the user, including keel's `{op: "notification"}` frames; `send(frame)` forwards an application frame to the hub's `OnMessage`. A subscription keel's `CanSubscribe` rejects is logged and exposed on `lastError`. Channel names and payload shapes are app-owned.
|
|
404
405
|
|
|
405
406
|
## Billing
|
|
406
407
|
|
|
@@ -568,6 +569,10 @@ A `TableAction` with `kind: 'R'` (redirect — the `constant_value` code) makes
|
|
|
568
569
|
|
|
569
570
|
> **Response-shape contract.** The generic redirect-action path reads a bare `url` because it's polymorphic — one dispatch for checkout / portal / export / etc., where the action's identity lives in the metadata, not the payload. keel's `WrapTableAction` enforces no shape; your inner handler must return `{ url }`. The *typed* billing endpoints keep specific names instead (`createCheckout()` → `checkoutUrl`, `createPortalSession()` → `portalUrl`). So the same portal concept has two response shapes depending on the path: wire it as `<sail-portal-button>` (typed → `portalUrl`) **or** as a `table_action` redirect (generic → `url`), not both.
|
|
570
571
|
|
|
572
|
+
### Action parameters (v1.1.25)
|
|
573
|
+
|
|
574
|
+
An action with `parameters` (keel v1.2.75 `table_action_parameter`) opens a dialog before posting: one field per parameter, typed by `dataType` (`boolean` checkbox, numeric, `date`, `timestamp`, `file`, otherwise text), or a dropdown when `lookupTable` names a table in the client cache (a `foreign_key_lookup` dropdown table); a numeric `dataType` still posts the chosen key as a number. The dialog shows `confirmMessage` in place of the native prompt and posts `{...primaryKeyValues(record), ...values}`; empty optional values are left out. A `file` parameter turns the post into `multipart/form-data`, so a table-level `upload` action whose parameters are `file`, `document_type` (lookup `document_type`), `title`, … posts straight to keel's `DocumentHandler.Upload`. An action without parameters behaves as before.
|
|
575
|
+
|
|
571
576
|
### Status chip (v1.0.1)
|
|
572
577
|
|
|
573
578
|
`<sail-status-chip>` resolves a code to its caption from the client-cache `constant_value` dictionary — no per-app `@switch`. It defaults to the `SUBSCRIPTION_STATUS` domain (so `T` trialing / `X` past-due render with no app code) and works for any constant domain via `[domain]`. The code is exposed as `[attr.data-status]` so the app can colour the chip:
|
|
@@ -970,6 +975,7 @@ Horizontal building blocks for an entitlement-aware dashboard. All are presentat
|
|
|
970
975
|
- **`DataCardComponent`** (`sail-data-card`) — card chrome for one visualization with `[state]` ∈ `loading | ready | empty | error | locked | no-source`. The chart is supplied as an `<ng-template>` and instantiated **only** in `ready`, so during loading/error/locked/no-source it never exists (no hidden canvas). `description` (required) is the screen-reader text alternative; `[tableHeaders]`/`[tableRows]` render a visually-hidden (`.sr-only`) data table. Emits `upgrade`, `connect`, and `retry`. `[connectLabel]="''"` renders the `no-source` state without a button — a source whose collector does not exist yet has nothing to connect.
|
|
971
976
|
- **`LockedOverlayComponent`** (`sail-locked-overlay`) — entitlement gate. Wraps protected content given as an `<ng-template>`; when `[locked]` the template is never instantiated, so **no data exists behind the gate** (DOM, canvas, or network) — the strict free-tier rule. Emits `upgrade`.
|
|
972
977
|
- **`NoSourceOverlayComponent`** (`sail-no-source-overlay`) — source-readiness gate. Takes `[sourceName]` and emits `connect`; while `[unavailable]`, its protected `<ng-template>` is never instantiated. Use this for included features whose provider is not connected or whose collector is unavailable—never show an upgrade prompt for this state. Set `[ctaLabel]="''"` when there is nothing to connect, and no button renders.
|
|
978
|
+
- **`StatTileComponent`** (`sail-stat-tile`) — one number: `label`, `value`, optional `target`, `progress` (0..1, a `role="progressbar"`), `delta` with `deltaGood` (`true | false | null`, the `.good` / `.bad` hook) and `trend` (`number[]`, a decorative sparkline, hidden under two points). Values are preformatted strings; the tile does no formatting. Styles: `.stat-tile` / `-label` / `-value` / `-target` / `-delta` / `-progress` / `-progress-fill` / `-trend`.
|
|
973
979
|
- **`ActionCenterComponent`** (`sail-action-center`) — prioritized next-best-action list from `[actions]: ActionItem[]` (sorted by `priority`, done items show a check). Emits `act`.
|
|
974
980
|
- **`EntitySelectorComponent`** (`sail-entity-selector`) — scope dropdown over `[entities]: EntityOption[]`; resolves `[selected]` or the first active entity and emits `selectionChange`.
|
|
975
981
|
- **`VerificationFlowComponent`** (`sail-verification-flow`) — self-service request → enter-code → verified UI (a 2FA sibling), backend-agnostic: emits `requestVerification` / `confirmCode`, driven by `[step]` + `[errorMessage]` the host feeds back. `[resendAfterSeconds]` holds `Resend code` for a cooldown and `[codeExpiresInSeconds]` blocks `Confirm` once the issued code expires; both default to `0`, which keeps the button always enabled and shows no expiry. Both countdowns restart when the component emits `requestVerification`. `[disabled]` blocks all actions while the host is processing one. `[errorMessage]` also renders in the `idle` step, so a failed send is reported where it happened.
|
|
@@ -1075,8 +1081,10 @@ readonly unread = inject(NotificationInboxService).unreadCount; // Signal<numb
|
|
|
1075
1081
|
| `load(limit)` / `loadMore(limit)` | Default 20; keel caps a page at 100 and the client clamps to match. |
|
|
1076
1082
|
|
|
1077
1083
|
Message ids are server-side `BIGINT`s serialized as strings. Both mark-read routes
|
|
1078
|
-
return `204`, so the service updates its cache locally.
|
|
1079
|
-
|
|
1084
|
+
return `204`, so the service updates its cache locally. After the first successful `load()`, each
|
|
1085
|
+
`{op: "notification"}` frame from keel's `realtime.UserDispatcher` (v1.2.79+) on a
|
|
1086
|
+
connected `RealtimeService` re-fetches the newest page into the cache. Call `reset()`
|
|
1087
|
+
on logout.
|
|
1080
1088
|
|
|
1081
1089
|
```html
|
|
1082
1090
|
<sail-notification-center
|
|
@@ -1100,6 +1108,31 @@ Ships no CSS — style
|
|
|
1100
1108
|
`.notification-item`, `.notification-item--unread` and
|
|
1101
1109
|
`.notification-item__{select,static,title,body,time,read}`.
|
|
1102
1110
|
|
|
1111
|
+
## Partner documents (v1.1.26)
|
|
1112
|
+
|
|
1113
|
+
`PartnerDocumentService` covers generic `partner_document` reads plus keel's
|
|
1114
|
+
`handler.DocumentHandler` (keel **v1.2.79+**): `list(userId?)`, `pending()`,
|
|
1115
|
+
`upload(file, request)` (progress events), `previewUrl(id)` and `review(id, approve,
|
|
1116
|
+
notes)`. The defaults assume the handler is mounted at
|
|
1117
|
+
`/api/v1/partner_document/{upload,preview,review}`; override the three
|
|
1118
|
+
`partnerDocument*URL` values otherwise. Authorization stays in keel's hooks.
|
|
1119
|
+
|
|
1120
|
+
```html
|
|
1121
|
+
<sail-document-upload documentType="driver_license" [maxBytes]="10485760"
|
|
1122
|
+
[canUpload]="attested()" (uploaded)="onUploaded($event)" (failed)="onFailed($event)">
|
|
1123
|
+
<!-- app-specific attestations -->
|
|
1124
|
+
</sail-document-upload>
|
|
1125
|
+
```
|
|
1126
|
+
|
|
1127
|
+
Images preview inline, PDFs open in a new tab. `(failed)` hands over the raw error;
|
|
1128
|
+
branch on `errorCode(err)` for keel's `document_*` codes. Status captions come from
|
|
1129
|
+
the `partner_document_status` domain through `LabelService`.
|
|
1130
|
+
|
|
1131
|
+
`<sail-partner-documents>` shows the latest version per type and offers replacement
|
|
1132
|
+
uploads for rejected or expiring documents. `<sail-document-review-queue>` lists
|
|
1133
|
+
pending rows with an inline preview and approve/reject controls. Both rely on the
|
|
1134
|
+
app's generic `partner_document` read grants and the handler authorization hooks.
|
|
1135
|
+
|
|
1103
1136
|
## Grouped review actions and diffs (v1.1.19)
|
|
1104
1137
|
|
|
1105
1138
|
`GroupedBulkActionComponent` (`sail-grouped-bulk-action`) groups eligible rows
|
|
@@ -1476,8 +1509,8 @@ The v0.6 / v0.7 line introduced three additive feature groups against keel v0.7.
|
|
|
1476
1509
|
| `@nauticana/sail` | `PayoutService` | keel/payout API client — onboarding, reuse, status, bank-version replacement, beneficiary registration |
|
|
1477
1510
|
| `@nauticana/sail` | `PayoutProviderOnboardingComponent` (`<sail-payout-provider-onboarding>`) | Drop-in onboarding step with reuse picker, hosted-KYC handoff, and asynchronous-confirmation output |
|
|
1478
1511
|
| `@nauticana/sail` | `PayoutBankInfoFormComponent` (`<sail-payout-bank-info-form>`) | Tax + payout details form (country / currency / tax ID / billing address / agreement) |
|
|
1479
|
-
| `@nauticana/sail` | `UserPaymentMethodService` | Saved-card list /
|
|
1480
|
-
| `@nauticana/sail` | `UserPaymentMethodsComponent` (`<sail-user-payment-methods>`) | List of saved cards/wallets with set-default +
|
|
1512
|
+
| `@nauticana/sail` | `UserPaymentMethodService` | Saved-card list / remove / set-default API client |
|
|
1513
|
+
| `@nauticana/sail` | `UserPaymentMethodsComponent` (`<sail-user-payment-methods>`) | List of saved cards/wallets with set-default + remove |
|
|
1481
1514
|
| `@nauticana/sail` | `TableAction`, `ReusableAccount`, `PayoutOnboardingSession`, `BankInfoFormValue`, `CountryProfile`, `UserPaymentMethod`, `DEFAULT_COUNTRY_PROFILES` | Model types / defaults |
|
|
1482
1515
|
|
|
1483
1516
|
### 2. New keel endpoints
|
|
@@ -1492,10 +1525,11 @@ Make sure your keel deployment exposes these — they're all under `/api/v1/`:
|
|
|
1492
1525
|
| `/api/v1/payout/status` | POST | `{ complete: true }` once the active partner's row has a `providerAccountId` |
|
|
1493
1526
|
| `/api/v1/payout/bank/replace` | POST | Supersede the active bank-info version and insert its replacement atomically; the app must wire the handler's `SealTaxID` hook |
|
|
1494
1527
|
| `/api/v1/payout/beneficiary/register` | POST | Create and link a provider beneficiary from provider-collected details |
|
|
1495
|
-
| `/api/v1/
|
|
1528
|
+
| `/api/v1/user_payment_method/set_default` | POST | `{id}` — sets one row as default, clears the rest |
|
|
1529
|
+
| `/api/v1/user_payment_method/remove` | POST | `{id}` — detaches the method at the provider, then deletes the row (keel v1.2.79) |
|
|
1496
1530
|
| `/api/v1/{table}/{action_name}` | POST | Per-table custom actions resolved from `basis.table_action` |
|
|
1497
1531
|
|
|
1498
|
-
`list`
|
|
1532
|
+
`list` for `user_payment_method` is keel's generic REST CRUD, auto-scoped to the caller. keel grants no generic DELETE on the table, so removal goes through `remove`.
|
|
1499
1533
|
|
|
1500
1534
|
### 3. Payout onboarding wiring
|
|
1501
1535
|
|
|
@@ -1534,7 +1568,7 @@ For apps that operate on a single keel partner, the reuse picker stays empty and
|
|
|
1534
1568
|
[title]="'Payment Methods'"
|
|
1535
1569
|
(addClicked)="goToSetupIntent()"
|
|
1536
1570
|
(defaultChanged)="onDefaultChanged($event)"
|
|
1537
|
-
(
|
|
1571
|
+
(removed)="onRemoved($event)">
|
|
1538
1572
|
</sail-user-payment-methods>
|
|
1539
1573
|
```
|
|
1540
1574
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nauticana/sail",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.26",
|
|
4
4
|
"description": "Metadata-driven Angular component library for CRUD admin frontends backed by the keel Go backend. Tables, forms, navigation, OTP/2FA/social auth, billing, payout onboarding.",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"exports": {
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"@angular/core": "^21.0.0",
|
|
53
53
|
"@angular/forms": "^21.0.0",
|
|
54
54
|
"@angular/material": "^21.0.0",
|
|
55
|
+
"@angular/platform-browser": "^21.0.0",
|
|
55
56
|
"@angular/router": "^21.0.0",
|
|
56
57
|
"class-validator": "^0.15.1",
|
|
57
58
|
"rxjs": "~7.8.0"
|
|
@@ -8,6 +8,7 @@ import { titleCase } from "../../util/text";
|
|
|
8
8
|
import { errorDetail } from "../../util/errors";
|
|
9
9
|
import { MatDialog } from "@angular/material/dialog";
|
|
10
10
|
import { RevealDialog } from "../table/reveal_dialog";
|
|
11
|
+
import { ActionParamsDialog } from "../table/action_params_dialog";
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
/** CRUD verb used by `requireAuth()` to compose the "Missing authorization" alert. */
|
|
@@ -20,7 +21,7 @@ const ISO_PREFIX = /^\d{4}-\d{2}-\d{2}T/;
|
|
|
20
21
|
export abstract class BaseTable {
|
|
21
22
|
protected readonly cacheService = inject(BaseAuthService);
|
|
22
23
|
protected readonly backendService = inject(BackendService);
|
|
23
|
-
//
|
|
24
|
+
// Action dialogs are rare — resolve MatDialog lazily so every BaseTable subclass doesn't eagerly depend on it.
|
|
24
25
|
readonly #injector = inject(Injector);
|
|
25
26
|
protected readonly config: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
|
|
26
27
|
protected readonly dialogWidth: string = '400px';
|
|
@@ -127,9 +128,10 @@ export abstract class BaseTable {
|
|
|
127
128
|
* 2. Subclass-specific pre-flight guard via `beforeExecuteAction()` —
|
|
128
129
|
* returns false to abort silently (the hook has already shown its
|
|
129
130
|
* own alert).
|
|
130
|
-
* 3.
|
|
131
|
+
* 3. A parameter dialog when the action declares `parameters`
|
|
132
|
+
* (it shows `confirmMessage`), otherwise the confirm prompt
|
|
131
133
|
* 4. POST `{}` for table-level actions or `primaryKeyValues(record)`
|
|
132
|
-
* for record-specific actions
|
|
134
|
+
* for record-specific actions, merged with the parameter values
|
|
133
135
|
* 5. On success, dispatch `onActionSuccess()` so subclasses can refresh
|
|
134
136
|
* their views; on error, log and alert.
|
|
135
137
|
*
|
|
@@ -141,8 +143,21 @@ export abstract class BaseTable {
|
|
|
141
143
|
return;
|
|
142
144
|
}
|
|
143
145
|
if (!this.beforeExecuteAction(action, record)) return;
|
|
146
|
+
const key = action.recordSpecific && record ? this.primaryKeyValues(record) : {};
|
|
147
|
+
if (action.parameters?.length) {
|
|
148
|
+
this.#injector.get(MatDialog)
|
|
149
|
+
.open<ActionParamsDialog, TableAction, Record<string, unknown>>(ActionParamsDialog, { data: action, width: this.dialogWidth })
|
|
150
|
+
.afterClosed()
|
|
151
|
+
.subscribe((values) => {
|
|
152
|
+
if (values) this.postAction(action, {...key, ...values}, record);
|
|
153
|
+
});
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
144
156
|
if (action.confirmMessage && !confirm(action.confirmMessage)) return;
|
|
145
|
-
|
|
157
|
+
this.postAction(action, key, record);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private postAction(action: TableAction, body: Record<string, unknown>, record?: Record<string, unknown>): void {
|
|
146
161
|
this.backendService.executeAction<Record<string, unknown>>(action.method, body).subscribe({
|
|
147
162
|
next: (resp) => {
|
|
148
163
|
const url = resp?.['url'];
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<div class="stat-tile">
|
|
2
|
+
<p class="stat-tile-label">{{ label() }}</p>
|
|
3
|
+
<p class="stat-tile-value">
|
|
4
|
+
{{ value() }}
|
|
5
|
+
@if (target()) { <span class="stat-tile-target">/ {{ target() }}</span> }
|
|
6
|
+
</p>
|
|
7
|
+
@if (delta()) {
|
|
8
|
+
<p class="stat-tile-delta" [class.good]="deltaGood() === true" [class.bad]="deltaGood() === false">
|
|
9
|
+
{{ delta() }}
|
|
10
|
+
@if (deltaGood() !== null) { <span class="sr-only">{{ deltaGood() ? '(improving)' : '(worsening)' }}</span> }
|
|
11
|
+
</p>
|
|
12
|
+
}
|
|
13
|
+
@let percent = progressPercent();
|
|
14
|
+
@if (percent !== null) {
|
|
15
|
+
<div class="stat-tile-progress" role="progressbar"
|
|
16
|
+
[attr.aria-label]="label()"
|
|
17
|
+
aria-valuemin="0"
|
|
18
|
+
aria-valuemax="100"
|
|
19
|
+
[attr.aria-valuenow]="percent"
|
|
20
|
+
[attr.aria-valuetext]="valueText()">
|
|
21
|
+
<div class="stat-tile-progress-fill" [style.width.%]="percent"></div>
|
|
22
|
+
</div>
|
|
23
|
+
}
|
|
24
|
+
@if (sparkline()) {
|
|
25
|
+
<svg class="stat-tile-trend" viewBox="0 0 100 24" preserveAspectRatio="none" aria-hidden="true" focusable="false">
|
|
26
|
+
<polyline [attr.points]="sparkline()" fill="none" stroke="currentColor" vector-effect="non-scaling-stroke" />
|
|
27
|
+
</svg>
|
|
28
|
+
}
|
|
29
|
+
</div>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, ViewEncapsulation, computed, input } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One dashboard number with an optional target, progress, delta and trend.
|
|
5
|
+
* Values arrive preformatted: the caller knows whether a number is money, a
|
|
6
|
+
* percentage or a count.
|
|
7
|
+
*/
|
|
8
|
+
@Component({
|
|
9
|
+
selector: 'sail-stat-tile',
|
|
10
|
+
templateUrl: './stat_tile.html',
|
|
11
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
12
|
+
encapsulation: ViewEncapsulation.None,
|
|
13
|
+
})
|
|
14
|
+
export class StatTileComponent {
|
|
15
|
+
readonly label = input.required<string>();
|
|
16
|
+
readonly value = input.required<string>();
|
|
17
|
+
readonly target = input<string>('');
|
|
18
|
+
/** 0..1; clamped. */
|
|
19
|
+
readonly progress = input<number | null>(null);
|
|
20
|
+
readonly delta = input<string>('');
|
|
21
|
+
/** true / false colours the delta as an improvement / regression; null leaves it neutral. */
|
|
22
|
+
readonly deltaGood = input<boolean | null>(null);
|
|
23
|
+
readonly trend = input<number[]>([]);
|
|
24
|
+
|
|
25
|
+
readonly progressPercent = computed(() => {
|
|
26
|
+
const p = this.progress();
|
|
27
|
+
return p == null || isNaN(p) ? null : Math.round(Math.min(1, Math.max(0, p)) * 100);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
readonly valueText = computed(() => this.target() ? `${this.value()} of ${this.target()}` : this.value());
|
|
31
|
+
|
|
32
|
+
/** SVG polyline points in a 100×24 box; '' under two points. */
|
|
33
|
+
readonly sparkline = computed(() => {
|
|
34
|
+
const points = this.trend().filter((n) => Number.isFinite(n));
|
|
35
|
+
if (points.length < 2) return '';
|
|
36
|
+
const min = Math.min(...points);
|
|
37
|
+
const range = Math.max(...points) - min;
|
|
38
|
+
return points
|
|
39
|
+
.map((n, i) => {
|
|
40
|
+
const x = (i / (points.length - 1)) * 100;
|
|
41
|
+
const y = range === 0 ? 12 : 22 - ((n - min) / range) * 20;
|
|
42
|
+
return `${x.toFixed(2)},${y.toFixed(2)}`;
|
|
43
|
+
})
|
|
44
|
+
.join(' ');
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<section class="document-review-queue" [attr.aria-busy]="loading()">
|
|
2
|
+
@if (errorMessage()) { <p class="document-review-queue__error" role="alert">{{ errorMessage() }}</p> }
|
|
3
|
+
@if (!loading() && !documents().length) {
|
|
4
|
+
<p class="document-review-queue__empty">No documents are awaiting review.</p>
|
|
5
|
+
}
|
|
6
|
+
@for (doc of documents(); track doc.Id) {
|
|
7
|
+
<article class="document-review">
|
|
8
|
+
<h3>{{ doc.Title || doc.DocumentType }}</h3>
|
|
9
|
+
<p>{{ doc.FileName }} · version {{ doc.VersionNo }}</p>
|
|
10
|
+
<button matButton type="button" (click)="showPreview(doc)">Preview</button>
|
|
11
|
+
@if (preview()?.id === doc.Id) {
|
|
12
|
+
<iframe class="document-review__preview" [src]="preview()!.url" [title]="'Preview of ' + (doc.Title || doc.FileName)"></iframe>
|
|
13
|
+
}
|
|
14
|
+
<mat-form-field appearance="outline">
|
|
15
|
+
<mat-label>Review notes</mat-label>
|
|
16
|
+
<textarea matInput [value]="notes()[doc.Id]" (input)="setNotes(doc.Id, $event)"></textarea>
|
|
17
|
+
</mat-form-field>
|
|
18
|
+
<div class="document-review__actions">
|
|
19
|
+
<button matButton="filled" type="button" [disabled]="reviewing() === doc.Id" (click)="review(doc, true)">Approve</button>
|
|
20
|
+
<button matButton type="button" [disabled]="reviewing() === doc.Id" (click)="review(doc, false)">Reject</button>
|
|
21
|
+
</div>
|
|
22
|
+
</article>
|
|
23
|
+
}
|
|
24
|
+
</section>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, OnInit, SecurityContext, ViewEncapsulation, inject, signal } from '@angular/core';
|
|
2
|
+
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
|
3
|
+
import { MatButtonModule } from '@angular/material/button';
|
|
4
|
+
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
5
|
+
import { MatInputModule } from '@angular/material/input';
|
|
6
|
+
import { BaseAsync } from '../abstract/base_async';
|
|
7
|
+
import { PartnerDocument } from '../../model/document';
|
|
8
|
+
import { PartnerDocumentService } from '../../service/partner_document.service';
|
|
9
|
+
|
|
10
|
+
@Component({
|
|
11
|
+
selector: 'sail-document-review-queue',
|
|
12
|
+
templateUrl: './document_review_queue.html',
|
|
13
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
14
|
+
encapsulation: ViewEncapsulation.None,
|
|
15
|
+
imports: [MatButtonModule, MatFormFieldModule, MatInputModule],
|
|
16
|
+
})
|
|
17
|
+
export class DocumentReviewQueueComponent extends BaseAsync implements OnInit {
|
|
18
|
+
private readonly service = inject(PartnerDocumentService);
|
|
19
|
+
private readonly sanitizer = inject(DomSanitizer);
|
|
20
|
+
|
|
21
|
+
readonly documents = signal<PartnerDocument[]>([]);
|
|
22
|
+
readonly preview = signal<{ id: number; url: SafeResourceUrl } | null>(null);
|
|
23
|
+
readonly reviewing = signal<number | null>(null);
|
|
24
|
+
readonly notes = signal<Record<number, string>>({});
|
|
25
|
+
|
|
26
|
+
ngOnInit(): void {
|
|
27
|
+
this.reload();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
reload(): void {
|
|
31
|
+
this.run(this.service.pending(), (rows) => this.documents.set(rows), 'Could not load pending documents.');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
showPreview(doc: PartnerDocument): void {
|
|
35
|
+
this.service.previewUrl(doc.Id).subscribe({
|
|
36
|
+
next: (url) => {
|
|
37
|
+
const safe = this.sanitizer.sanitize(SecurityContext.URL, url);
|
|
38
|
+
if (!safe) {
|
|
39
|
+
this.errorMessage.set('The document preview URL is invalid.');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
this.preview.set({ id: doc.Id, url: this.sanitizer.bypassSecurityTrustResourceUrl(safe) });
|
|
43
|
+
},
|
|
44
|
+
error: (err) => this.setError(err, 'Could not preview the document.'),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
setNotes(id: number, event: Event): void {
|
|
49
|
+
const value = (event.target as HTMLTextAreaElement).value;
|
|
50
|
+
this.notes.update((notes) => ({ ...notes, [id]: value }));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
review(doc: PartnerDocument, approve: boolean): void {
|
|
54
|
+
this.reviewing.set(doc.Id);
|
|
55
|
+
this.service.review(doc.Id, approve, this.notes()[doc.Id] ?? '').subscribe({
|
|
56
|
+
next: () => {
|
|
57
|
+
this.documents.update((rows) => rows.filter((row) => row.Id !== doc.Id));
|
|
58
|
+
if (this.preview()?.id === doc.Id) this.preview.set(null);
|
|
59
|
+
this.reviewing.set(null);
|
|
60
|
+
},
|
|
61
|
+
error: (err) => {
|
|
62
|
+
this.reviewing.set(null);
|
|
63
|
+
this.setError(err, 'Could not review the document.');
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<div class="document-upload">
|
|
2
|
+
@if (!file()) {
|
|
3
|
+
<button matButton="outlined" type="button" class="document-upload__pick" (click)="picker.click()">
|
|
4
|
+
<mat-icon>upload_file</mat-icon> {{ label() }}
|
|
5
|
+
</button>
|
|
6
|
+
} @else {
|
|
7
|
+
<div class="document-upload__preview">
|
|
8
|
+
@if (imageUrl(); as src) {
|
|
9
|
+
<img [src]="src" [alt]="file()!.name" class="document-upload__image">
|
|
10
|
+
} @else if (pdfUrl(); as src) {
|
|
11
|
+
<a [href]="src" target="_blank" rel="noopener" class="document-upload__pdf">Open preview</a>
|
|
12
|
+
}
|
|
13
|
+
<span class="document-upload__name">{{ file()!.name }}</span>
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
<ng-content />
|
|
17
|
+
|
|
18
|
+
<div class="document-upload__actions">
|
|
19
|
+
<button matButton="filled" type="button" (click)="upload()" [disabled]="!canUpload() || uploading()">Upload</button>
|
|
20
|
+
<button matButton="outlined" type="button" (click)="picker.click()" [disabled]="uploading()">Choose another</button>
|
|
21
|
+
</div>
|
|
22
|
+
}
|
|
23
|
+
<input #picker type="file" hidden [accept]="accept()" (change)="pick($event)" [attr.aria-label]="label()">
|
|
24
|
+
|
|
25
|
+
@if (progress() !== null) {
|
|
26
|
+
<mat-progress-bar mode="determinate" [value]="progress()" aria-label="Upload progress" />
|
|
27
|
+
}
|
|
28
|
+
@if (errorMessage()) {
|
|
29
|
+
<p class="document-upload__error" role="alert">{{ errorMessage() }}</p>
|
|
30
|
+
}
|
|
31
|
+
</div>
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, DestroyRef, ViewEncapsulation, computed, inject, input, output, signal } from '@angular/core';
|
|
2
|
+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
3
|
+
import { HttpEventType } from '@angular/common/http';
|
|
4
|
+
import { MatButtonModule } from '@angular/material/button';
|
|
5
|
+
import { MatIconModule } from '@angular/material/icon';
|
|
6
|
+
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
|
7
|
+
import { PartnerDocumentService } from '../../service/partner_document.service';
|
|
8
|
+
import { PartnerDocument } from '../../model/document';
|
|
9
|
+
import { errorDetail } from '../../util/errors';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Picks, previews and uploads one keel partner document. App-specific
|
|
13
|
+
* attestations are projected content; bind `[canUpload]` to their state.
|
|
14
|
+
* Ships no CSS — style the `.document-upload*` classes globally.
|
|
15
|
+
*/
|
|
16
|
+
@Component({
|
|
17
|
+
selector: 'sail-document-upload',
|
|
18
|
+
templateUrl: './document_upload.html',
|
|
19
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
20
|
+
encapsulation: ViewEncapsulation.None,
|
|
21
|
+
imports: [MatButtonModule, MatIconModule, MatProgressBarModule],
|
|
22
|
+
})
|
|
23
|
+
export class DocumentUploadComponent {
|
|
24
|
+
private readonly documents = inject(PartnerDocumentService);
|
|
25
|
+
private readonly destroyRef = inject(DestroyRef);
|
|
26
|
+
|
|
27
|
+
readonly documentType = input.required<string>();
|
|
28
|
+
readonly title = input('');
|
|
29
|
+
readonly documentNumber = input('');
|
|
30
|
+
/** YYYY-MM-DD. */
|
|
31
|
+
readonly expiresOn = input('');
|
|
32
|
+
readonly userId = input<number | undefined>(undefined);
|
|
33
|
+
readonly accept = input('image/*,application/pdf');
|
|
34
|
+
/** Client-side size cap; 0 leaves the limit to keel's document type. */
|
|
35
|
+
readonly maxBytes = input(0);
|
|
36
|
+
readonly label = input('Choose file');
|
|
37
|
+
readonly canUpload = input(true);
|
|
38
|
+
|
|
39
|
+
readonly uploaded = output<PartnerDocument>();
|
|
40
|
+
/** The raw error; branch on `errorCode(err)` (keel's `document_*` codes). */
|
|
41
|
+
readonly failed = output<unknown>();
|
|
42
|
+
|
|
43
|
+
readonly file = signal<File | null>(null);
|
|
44
|
+
readonly progress = signal<number | null>(null);
|
|
45
|
+
readonly errorMessage = signal('');
|
|
46
|
+
private readonly objectUrl = signal<string | null>(null);
|
|
47
|
+
|
|
48
|
+
readonly isImage = computed(() => this.file()?.type.startsWith('image/') ?? false);
|
|
49
|
+
readonly imageUrl = computed(() => (this.isImage() ? this.objectUrl() : null));
|
|
50
|
+
readonly pdfUrl = computed(() => (this.file()?.type === 'application/pdf' ? this.objectUrl() : null));
|
|
51
|
+
readonly uploading = computed(() => this.progress() !== null);
|
|
52
|
+
|
|
53
|
+
constructor() {
|
|
54
|
+
this.destroyRef.onDestroy(() => this.revokePreview());
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
pick(event: Event): void {
|
|
58
|
+
const input = event.target as HTMLInputElement;
|
|
59
|
+
const file = input.files?.[0] ?? null;
|
|
60
|
+
input.value = '';
|
|
61
|
+
this.errorMessage.set('');
|
|
62
|
+
this.revokePreview();
|
|
63
|
+
this.file.set(null);
|
|
64
|
+
if (!file) return;
|
|
65
|
+
const max = this.maxBytes();
|
|
66
|
+
if (max > 0 && file.size > max) {
|
|
67
|
+
this.errorMessage.set(`The file exceeds the ${(max / 1048576).toFixed(1)} MB limit.`);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
this.file.set(file);
|
|
71
|
+
this.objectUrl.set(URL.createObjectURL(file));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
upload(): void {
|
|
75
|
+
const file = this.file();
|
|
76
|
+
if (!file || !this.canUpload() || this.uploading()) return;
|
|
77
|
+
this.errorMessage.set('');
|
|
78
|
+
this.progress.set(0);
|
|
79
|
+
this.documents.upload(file, {
|
|
80
|
+
documentType: this.documentType(),
|
|
81
|
+
title: this.title() || undefined,
|
|
82
|
+
documentNumber: this.documentNumber() || undefined,
|
|
83
|
+
expiresOn: this.expiresOn() || undefined,
|
|
84
|
+
userId: this.userId(),
|
|
85
|
+
}).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
|
86
|
+
next: (event) => {
|
|
87
|
+
if (event.type === HttpEventType.UploadProgress && event.total) {
|
|
88
|
+
this.progress.set(Math.round((100 * event.loaded) / event.total));
|
|
89
|
+
} else if (event.type === HttpEventType.Response && event.body) {
|
|
90
|
+
this.progress.set(null);
|
|
91
|
+
this.clear();
|
|
92
|
+
this.uploaded.emit(event.body);
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
error: (err) => {
|
|
96
|
+
this.progress.set(null);
|
|
97
|
+
this.errorMessage.set(errorDetail(err, 'Upload failed.'));
|
|
98
|
+
this.failed.emit(err);
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
clear(): void {
|
|
104
|
+
this.revokePreview();
|
|
105
|
+
this.file.set(null);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private revokePreview(): void {
|
|
109
|
+
const url = this.objectUrl();
|
|
110
|
+
if (url) URL.revokeObjectURL(url);
|
|
111
|
+
this.objectUrl.set(null);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<section class="partner-documents" [attr.aria-busy]="loading()">
|
|
2
|
+
@if (heading()) { <h2 class="partner-documents__title">{{ heading() }}</h2> }
|
|
3
|
+
@if (errorMessage()) { <p class="partner-documents__error" role="alert">{{ errorMessage() }}</p> }
|
|
4
|
+
|
|
5
|
+
@if (!loading() && !latest().length) {
|
|
6
|
+
<p class="partner-documents__empty">{{ emptyMessage() }}</p>
|
|
7
|
+
} @else {
|
|
8
|
+
<ul class="partner-documents__list">
|
|
9
|
+
@for (doc of latest(); track doc.Id) {
|
|
10
|
+
<li class="partner-document" [class.partner-document--rejected]="doc.Status === 'N'">
|
|
11
|
+
<div class="partner-document__details">
|
|
12
|
+
<strong>{{ doc.Title || doc.DocumentType }}</strong>
|
|
13
|
+
<span>{{ status(doc.Status) }} · version {{ doc.VersionNo }}</span>
|
|
14
|
+
@if (doc.ExpiresOn) { <span>Expires {{ doc.ExpiresOn | date }}</span> }
|
|
15
|
+
@if (doc.ReviewerNotes) { <span>{{ doc.ReviewerNotes }}</span> }
|
|
16
|
+
</div>
|
|
17
|
+
<div class="partner-document__actions">
|
|
18
|
+
<button matButton type="button" (click)="preview(doc)">
|
|
19
|
+
<mat-icon>visibility</mat-icon> Preview
|
|
20
|
+
</button>
|
|
21
|
+
@if (canReupload(doc)) {
|
|
22
|
+
<button matButton type="button" (click)="reuploading.set(doc)">Upload replacement</button>
|
|
23
|
+
}
|
|
24
|
+
</div>
|
|
25
|
+
@if (reuploading()?.Id === doc.Id) {
|
|
26
|
+
<sail-document-upload [documentType]="doc.DocumentType" [title]="doc.Title"
|
|
27
|
+
[documentNumber]="doc.DocumentNumber ?? ''" [userId]="doc.UserId ?? undefined" (uploaded)="uploaded()" />
|
|
28
|
+
}
|
|
29
|
+
</li>
|
|
30
|
+
}
|
|
31
|
+
</ul>
|
|
32
|
+
}
|
|
33
|
+
</section>
|