@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
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, OnInit, ViewEncapsulation, computed, inject, input, signal } from '@angular/core';
|
|
2
|
+
import { DatePipe } from '@angular/common';
|
|
3
|
+
import { MatButtonModule } from '@angular/material/button';
|
|
4
|
+
import { MatIconModule } from '@angular/material/icon';
|
|
5
|
+
import { forkJoin } from 'rxjs';
|
|
6
|
+
import { BaseAsync } from '../abstract/base_async';
|
|
7
|
+
import { PartnerDocument } from '../../model/document';
|
|
8
|
+
import { LabelService } from '../../service/label.service';
|
|
9
|
+
import { PartnerDocumentService } from '../../service/partner_document.service';
|
|
10
|
+
import { DocumentUploadComponent } from './document_upload';
|
|
11
|
+
|
|
12
|
+
@Component({
|
|
13
|
+
selector: 'sail-partner-documents',
|
|
14
|
+
templateUrl: './partner_documents.html',
|
|
15
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
16
|
+
encapsulation: ViewEncapsulation.None,
|
|
17
|
+
imports: [DatePipe, MatButtonModule, MatIconModule, DocumentUploadComponent],
|
|
18
|
+
})
|
|
19
|
+
export class PartnerDocumentsComponent extends BaseAsync implements OnInit {
|
|
20
|
+
private readonly service = inject(PartnerDocumentService);
|
|
21
|
+
private readonly labels = inject(LabelService);
|
|
22
|
+
|
|
23
|
+
readonly userId = input<number | undefined>(undefined);
|
|
24
|
+
readonly heading = input('Documents');
|
|
25
|
+
readonly emptyMessage = input('No documents uploaded.');
|
|
26
|
+
readonly expiringWithinDays = input(30);
|
|
27
|
+
|
|
28
|
+
readonly documents = signal<PartnerDocument[]>([]);
|
|
29
|
+
readonly reuploading = signal<PartnerDocument | null>(null);
|
|
30
|
+
readonly statusesReady = signal(false);
|
|
31
|
+
readonly latest = computed(() => latestByType(this.documents()));
|
|
32
|
+
|
|
33
|
+
ngOnInit(): void {
|
|
34
|
+
this.run(forkJoin({
|
|
35
|
+
labels: this.labels.loadLabels('partner_document_status'),
|
|
36
|
+
documents: this.service.list(this.userId()),
|
|
37
|
+
}), ({ documents }) => {
|
|
38
|
+
this.statusesReady.set(true);
|
|
39
|
+
this.documents.set(documents);
|
|
40
|
+
}, 'Could not load documents.');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
reload(): void {
|
|
44
|
+
this.run(this.service.list(this.userId()), (rows) => this.documents.set(rows), 'Could not load documents.');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
status(status: string): string {
|
|
48
|
+
return this.statusesReady() ? this.labels.requireLabel('partner_document_status', status) : '';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
canReupload(doc: PartnerDocument): boolean {
|
|
52
|
+
return doc.Status === 'N' || isExpiring(doc.ExpiresOn, this.expiringWithinDays());
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
preview(doc: PartnerDocument): void {
|
|
56
|
+
// Opened synchronously so the popup blocker allows it; 'noopener' would return null.
|
|
57
|
+
const tab = window.open('', '_blank');
|
|
58
|
+
if (tab) tab.opener = null;
|
|
59
|
+
this.service.previewUrl(doc.Id).subscribe({
|
|
60
|
+
next: (url) => {
|
|
61
|
+
if (tab) tab.location.href = url;
|
|
62
|
+
else window.open(url, '_blank', 'noopener');
|
|
63
|
+
},
|
|
64
|
+
error: (err) => {
|
|
65
|
+
tab?.close();
|
|
66
|
+
this.setError(err, 'Could not open the document.');
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
uploaded(): void {
|
|
72
|
+
this.reuploading.set(null);
|
|
73
|
+
this.reload();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function latestByType(rows: PartnerDocument[]): PartnerDocument[] {
|
|
78
|
+
const latest = new Map<string, PartnerDocument>();
|
|
79
|
+
for (const row of rows) {
|
|
80
|
+
const key = `${row.UserId ?? ''}:${row.DocumentType}`;
|
|
81
|
+
const current = latest.get(key);
|
|
82
|
+
if (!current || row.VersionNo > current.VersionNo) latest.set(key, row);
|
|
83
|
+
}
|
|
84
|
+
return [...latest.values()].sort((a, b) => a.DocumentType.localeCompare(b.DocumentType));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isExpiring(value: string | null, withinDays: number): boolean {
|
|
88
|
+
if (!value || withinDays < 0) return false;
|
|
89
|
+
const expires = new Date(`${value.slice(0, 10)}T00:00:00`);
|
|
90
|
+
if (Number.isNaN(expires.getTime())) return false;
|
|
91
|
+
return expires.getTime() <= Date.now() + withinDays * 86400000;
|
|
92
|
+
}
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
@if (!m.IsDefault) {
|
|
32
32
|
<button matButton (click)="setDefault(m)">Set default</button>
|
|
33
33
|
}
|
|
34
|
-
<button matIconButton (click)="remove(m)" aria-label="
|
|
34
|
+
<button matIconButton (click)="remove(m)" aria-label="Remove payment method">
|
|
35
35
|
<mat-icon>delete</mat-icon>
|
|
36
36
|
</button>
|
|
37
37
|
</div>
|
|
@@ -10,7 +10,7 @@ import { UserPaymentMethod } from '../../model/appdata';
|
|
|
10
10
|
* End-user saved payment methods.
|
|
11
11
|
*
|
|
12
12
|
* Drop-in screen that lists the caller's cards/wallets, with set-default
|
|
13
|
-
* +
|
|
13
|
+
* + remove actions. Inherits loading/error state from BaseAsync.
|
|
14
14
|
*
|
|
15
15
|
* Customise the heading and "add" CTA via inputs; emit (addClicked) so
|
|
16
16
|
* the consumer routes to its own SetupIntent flow: BillingService.createSetupIntent()
|
|
@@ -37,8 +37,8 @@ export class UserPaymentMethodsComponent extends BaseAsync implements OnInit {
|
|
|
37
37
|
readonly addClicked = output<void>();
|
|
38
38
|
/** Emitted after a successful set-default flip. */
|
|
39
39
|
readonly defaultChanged = output<UserPaymentMethod>();
|
|
40
|
-
/** Emitted after a
|
|
41
|
-
readonly
|
|
40
|
+
/** Emitted after a method is detached and removed. */
|
|
41
|
+
readonly removed = output<UserPaymentMethod>();
|
|
42
42
|
|
|
43
43
|
private readonly service = inject(UserPaymentMethodService);
|
|
44
44
|
|
|
@@ -78,12 +78,12 @@ export class UserPaymentMethodsComponent extends BaseAsync implements OnInit {
|
|
|
78
78
|
|
|
79
79
|
remove(m: UserPaymentMethod): void {
|
|
80
80
|
this.run(
|
|
81
|
-
this.service.
|
|
81
|
+
this.service.remove(m.Id),
|
|
82
82
|
() => {
|
|
83
83
|
this.methods.update((list) => list.filter((row) => row.Id !== m.Id));
|
|
84
|
-
this.
|
|
84
|
+
this.removed.emit(m);
|
|
85
85
|
},
|
|
86
|
-
'Failed to
|
|
86
|
+
'Failed to remove.',
|
|
87
87
|
);
|
|
88
88
|
}
|
|
89
89
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<h2 mat-dialog-title>{{ action.caption }}</h2>
|
|
2
|
+
<form [formGroup]="form" (ngSubmit)="submit()">
|
|
3
|
+
<mat-dialog-content>
|
|
4
|
+
@if (action.confirmMessage) {
|
|
5
|
+
<p>{{ action.confirmMessage }}</p>
|
|
6
|
+
}
|
|
7
|
+
@for (f of fields; track f.param.name) {
|
|
8
|
+
@switch (f.kind) {
|
|
9
|
+
@case ('checkbox') {
|
|
10
|
+
<mat-checkbox [formControlName]="f.param.name">{{ f.param.caption }}</mat-checkbox>
|
|
11
|
+
}
|
|
12
|
+
@case ('file') {
|
|
13
|
+
<div class="action-param-file">
|
|
14
|
+
<label [for]="'action-param-' + f.param.name">{{ f.param.caption }}@if (f.param.required) { * }</label>
|
|
15
|
+
<input type="file" [id]="'action-param-' + f.param.name" [required]="f.param.required"
|
|
16
|
+
(change)="pickFile(f.param.name, $event)">
|
|
17
|
+
@if (form.controls[f.param.name].touched && form.controls[f.param.name].invalid) {
|
|
18
|
+
<p class="action-param-error" role="alert">{{ f.param.caption }} is required</p>
|
|
19
|
+
}
|
|
20
|
+
</div>
|
|
21
|
+
}
|
|
22
|
+
@case ('select') {
|
|
23
|
+
<mat-form-field appearance="outline">
|
|
24
|
+
<mat-label>{{ f.param.caption }}</mat-label>
|
|
25
|
+
<mat-select [formControlName]="f.param.name" [required]="f.param.required">
|
|
26
|
+
@if (!f.param.required) {
|
|
27
|
+
<mat-option [value]="''">None</mat-option>
|
|
28
|
+
}
|
|
29
|
+
@for (opt of f.options; track opt.Value) {
|
|
30
|
+
<mat-option [value]="opt.Value">{{ opt.Caption }}</mat-option>
|
|
31
|
+
}
|
|
32
|
+
</mat-select>
|
|
33
|
+
<mat-error>{{ f.param.caption }} is required</mat-error>
|
|
34
|
+
</mat-form-field>
|
|
35
|
+
}
|
|
36
|
+
@default {
|
|
37
|
+
<mat-form-field appearance="outline">
|
|
38
|
+
<mat-label>{{ f.param.caption }}</mat-label>
|
|
39
|
+
<input matInput [type]="f.kind" [formControlName]="f.param.name" [required]="f.param.required">
|
|
40
|
+
<mat-error>{{ f.param.caption }} is required</mat-error>
|
|
41
|
+
</mat-form-field>
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
</mat-dialog-content>
|
|
46
|
+
<mat-dialog-actions align="end">
|
|
47
|
+
<button matButton type="button" mat-dialog-close>Cancel</button>
|
|
48
|
+
<button matButton="filled" type="submit">{{ action.caption }}</button>
|
|
49
|
+
</mat-dialog-actions>
|
|
50
|
+
</form>
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, inject } from "@angular/core";
|
|
2
|
+
import { FormControl, FormGroup, ReactiveFormsModule, ValidatorFn, Validators } from "@angular/forms";
|
|
3
|
+
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from "@angular/material/dialog";
|
|
4
|
+
import { MatButtonModule } from "@angular/material/button";
|
|
5
|
+
import { MatCheckboxModule } from "@angular/material/checkbox";
|
|
6
|
+
import { MatFormFieldModule } from "@angular/material/form-field";
|
|
7
|
+
import { MatInputModule } from "@angular/material/input";
|
|
8
|
+
import { MatSelectModule } from "@angular/material/select";
|
|
9
|
+
import { TableAction, TableActionParameter } from "../../model/appdata";
|
|
10
|
+
import { ConstantValue } from "../../model/common";
|
|
11
|
+
import { BaseAuthService } from "../../service/auth.service";
|
|
12
|
+
|
|
13
|
+
type ParamKind = 'checkbox' | 'number' | 'date' | 'datetime-local' | 'file' | 'text';
|
|
14
|
+
|
|
15
|
+
interface ParamField {
|
|
16
|
+
param: TableActionParameter;
|
|
17
|
+
kind: ParamKind | 'select';
|
|
18
|
+
/** From dataType; a lookup dropdown still posts a number when the column is numeric. */
|
|
19
|
+
wireKind: ParamKind;
|
|
20
|
+
options?: ConstantValue[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Collects a table action's declared parameters; closes with the typed values, or undefined on cancel. */
|
|
24
|
+
@Component({
|
|
25
|
+
selector: "sail-action-params-dialog",
|
|
26
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
27
|
+
imports: [
|
|
28
|
+
ReactiveFormsModule,
|
|
29
|
+
MatDialogModule,
|
|
30
|
+
MatButtonModule,
|
|
31
|
+
MatCheckboxModule,
|
|
32
|
+
MatFormFieldModule,
|
|
33
|
+
MatInputModule,
|
|
34
|
+
MatSelectModule,
|
|
35
|
+
],
|
|
36
|
+
templateUrl: "./action_params_dialog.html",
|
|
37
|
+
})
|
|
38
|
+
export class ActionParamsDialog {
|
|
39
|
+
private readonly auth = inject(BaseAuthService);
|
|
40
|
+
private readonly dialogRef = inject(MatDialogRef<ActionParamsDialog, Record<string, unknown>>);
|
|
41
|
+
readonly action = inject<TableAction>(MAT_DIALOG_DATA);
|
|
42
|
+
|
|
43
|
+
readonly fields: ParamField[] = (this.action.parameters ?? []).map((param) => {
|
|
44
|
+
const options = param.lookupTable ? this.auth.getTableValues(param.lookupTable) : undefined;
|
|
45
|
+
const wireKind = kindOf(param.dataType);
|
|
46
|
+
return { param, kind: options ? 'select' : wireKind, wireKind, options };
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
readonly form = new FormGroup(Object.fromEntries(this.fields.map((f) => {
|
|
50
|
+
const validators: ValidatorFn[] = f.param.required && f.kind !== 'checkbox' ? [Validators.required] : [];
|
|
51
|
+
const initial: unknown = f.kind === 'checkbox' ? false : '';
|
|
52
|
+
return [f.param.name, new FormControl(initial, validators)];
|
|
53
|
+
})));
|
|
54
|
+
|
|
55
|
+
submit(): void {
|
|
56
|
+
if (this.form.invalid) {
|
|
57
|
+
this.form.markAllAsTouched();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const values: Record<string, unknown> = {};
|
|
61
|
+
for (const f of this.fields) {
|
|
62
|
+
const raw = this.form.controls[f.param.name].value;
|
|
63
|
+
if (raw === '' || raw == null) continue;
|
|
64
|
+
values[f.param.name] = toWire(f.wireKind, raw);
|
|
65
|
+
}
|
|
66
|
+
this.dialogRef.close(values);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
pickFile(name: string, event: Event): void {
|
|
70
|
+
const control = this.form.controls[name];
|
|
71
|
+
control.setValue((event.target as HTMLInputElement).files?.[0] ?? '');
|
|
72
|
+
control.markAsTouched();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function kindOf(dataType: string): ParamKind {
|
|
77
|
+
switch ((dataType || '').toLowerCase()) {
|
|
78
|
+
case 'boolean':
|
|
79
|
+
return 'checkbox';
|
|
80
|
+
case 'int':
|
|
81
|
+
case 'integer':
|
|
82
|
+
case 'number':
|
|
83
|
+
case 'numeric':
|
|
84
|
+
case 'float':
|
|
85
|
+
case 'decimal':
|
|
86
|
+
return 'number';
|
|
87
|
+
case 'date':
|
|
88
|
+
return 'date';
|
|
89
|
+
case 'datetime':
|
|
90
|
+
case 'timestamp':
|
|
91
|
+
return 'datetime-local';
|
|
92
|
+
case 'file':
|
|
93
|
+
return 'file';
|
|
94
|
+
default:
|
|
95
|
+
return 'text';
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function toWire(kind: ParamKind, raw: unknown): unknown {
|
|
100
|
+
if (kind === 'number') return Number(raw);
|
|
101
|
+
// datetime-local holds local wall-clock time; keel expects a UTC instant.
|
|
102
|
+
if (kind === 'datetime-local') {
|
|
103
|
+
const d = new Date(String(raw));
|
|
104
|
+
return isNaN(d.getTime()) ? raw : d.toISOString();
|
|
105
|
+
}
|
|
106
|
+
return raw;
|
|
107
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -5,11 +5,13 @@ export * from './model/common';
|
|
|
5
5
|
export * from './model/dashboard';
|
|
6
6
|
export * from './model/oauth_connection';
|
|
7
7
|
export * from './model/notification';
|
|
8
|
+
export * from './model/document';
|
|
8
9
|
export * from './model/agency';
|
|
9
10
|
export * from './model/decorator';
|
|
10
11
|
export * from './component/abstract/base_table';
|
|
11
12
|
export * from './component/dashboard/dashboard_shell';
|
|
12
13
|
export * from './component/dashboard/data_card';
|
|
14
|
+
export * from './component/dashboard/stat_tile';
|
|
13
15
|
export * from './component/dashboard/locked_overlay';
|
|
14
16
|
export * from './component/dashboard/no_source_overlay';
|
|
15
17
|
export * from './component/dashboard/action_center';
|
|
@@ -69,6 +71,9 @@ export * from './component/agency/agency_payout_history';
|
|
|
69
71
|
export * from './component/agency/agency_payout_profile';
|
|
70
72
|
export * from './component/payment/user_payment_methods';
|
|
71
73
|
export * from './component/payment/sca_confirm';
|
|
74
|
+
export * from './component/document/document_upload';
|
|
75
|
+
export * from './component/document/partner_documents';
|
|
76
|
+
export * from './component/document/document_review_queue';
|
|
72
77
|
export * from './service/auth.service';
|
|
73
78
|
export * from './service/sca_confirmer';
|
|
74
79
|
export * from './service/domain_verifier';
|
|
@@ -78,6 +83,7 @@ export * from './service/agency.service';
|
|
|
78
83
|
export * from './service/oauth_connection.service';
|
|
79
84
|
export * from './service/notification_inbox.service';
|
|
80
85
|
export * from './service/user_payment_method.service';
|
|
86
|
+
export * from './service/partner_document.service';
|
|
81
87
|
export * from './service/script_loader';
|
|
82
88
|
export * from './service/auth.interceptor';
|
|
83
89
|
export * from './service/realtime.service';
|
package/src/model/appdata.ts
CHANGED
|
@@ -79,6 +79,16 @@ export interface TableAction {
|
|
|
79
79
|
authorityObject: string; // uppercased table_name — for canExecute()
|
|
80
80
|
authorityCheck: string; // uppercased action_name — for canExecute()
|
|
81
81
|
kind?: 'P' | 'R' | 'V'; // constant_value codes: P post (default) / R redirect (follow {url}) / V reveal (show returned secret once)
|
|
82
|
+
parameters?: TableActionParameter[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A value collected in a dialog before the action posts; merged into the body beside the record key. */
|
|
86
|
+
export interface TableActionParameter {
|
|
87
|
+
name: string;
|
|
88
|
+
caption: string;
|
|
89
|
+
dataType: string;
|
|
90
|
+
required: boolean;
|
|
91
|
+
lookupTable?: string; // offer this table's cached rows as choices
|
|
82
92
|
}
|
|
83
93
|
|
|
84
94
|
export interface ForeignKey {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** One `partner_document` row in Sail's generic REST field shape. */
|
|
2
|
+
export interface PartnerDocument {
|
|
3
|
+
Id: number;
|
|
4
|
+
ContrepId: string;
|
|
5
|
+
DocKey: string;
|
|
6
|
+
PartnerId: number;
|
|
7
|
+
/** null means the partner itself. */
|
|
8
|
+
UserId: number | null;
|
|
9
|
+
DocumentType: string;
|
|
10
|
+
Title: string;
|
|
11
|
+
FileName: string;
|
|
12
|
+
VersionNo: number;
|
|
13
|
+
DocumentNumber: string | null;
|
|
14
|
+
ExpiresOn: string | null;
|
|
15
|
+
OriginIp: string | null;
|
|
16
|
+
UploadedBy: number | null;
|
|
17
|
+
UploadedAt: string;
|
|
18
|
+
/** `partner_document_status`: P pending, Y approved, N rejected, X superseded, R retired. */
|
|
19
|
+
Status: string;
|
|
20
|
+
ReviewerId: number | null;
|
|
21
|
+
ReviewedAt: string | null;
|
|
22
|
+
ReviewerNotes: string | null;
|
|
23
|
+
SupersededAt: string | null;
|
|
24
|
+
RetiredAt: string | null;
|
|
25
|
+
PurgedAt: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface DocumentUploadRequest {
|
|
29
|
+
documentType: string;
|
|
30
|
+
title?: string;
|
|
31
|
+
documentNumber?: string;
|
|
32
|
+
/** YYYY-MM-DD. */
|
|
33
|
+
expiresOn?: string;
|
|
34
|
+
/** The member the document is about; omitted = the partner. */
|
|
35
|
+
userId?: number;
|
|
36
|
+
}
|
|
@@ -2,6 +2,11 @@ import { inject, Injectable } from "@angular/core";
|
|
|
2
2
|
import { Observable, map, of, shareReplay, take, tap } from "rxjs";
|
|
3
3
|
import { BaseAuthService } from "./auth.service";
|
|
4
4
|
|
|
5
|
+
export interface LabelOption {
|
|
6
|
+
code: string;
|
|
7
|
+
caption: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
5
10
|
@Injectable({ providedIn: 'root' })
|
|
6
11
|
export class LabelService {
|
|
7
12
|
private readonly auth = inject(BaseAuthService);
|
|
@@ -13,6 +18,23 @@ export class LabelService {
|
|
|
13
18
|
return this.labels.get(constantId)?.get(key) ?? key;
|
|
14
19
|
}
|
|
15
20
|
|
|
21
|
+
/** Caption of a backend-sourced value; throws when the domain is not loaded or lacks the key. */
|
|
22
|
+
requireLabel(constantId: string, key: string): string {
|
|
23
|
+
const domain = this.labels.get(constantId);
|
|
24
|
+
if (!domain) throw new Error(`sail: constant domain "${constantId}" is not loaded.`);
|
|
25
|
+
const caption = domain.get(key);
|
|
26
|
+
if (caption === undefined) throw new Error(`sail: constant domain "${constantId}" has no value "${key}".`);
|
|
27
|
+
return caption;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Every value of a loaded domain, by caption (keel's ConstantCache carries no display order). */
|
|
31
|
+
options(constantId: string): LabelOption[] {
|
|
32
|
+
const domain = this.labels.get(constantId);
|
|
33
|
+
if (!domain) throw new Error(`sail: constant domain "${constantId}" is not loaded.`);
|
|
34
|
+
return [...domain].map(([code, caption]) => ({ code, caption }))
|
|
35
|
+
.sort((a, b) => a.caption.localeCompare(b.caption));
|
|
36
|
+
}
|
|
37
|
+
|
|
16
38
|
/**
|
|
17
39
|
* Load one backend-provided `constant_value` domain from ApplicationData.
|
|
18
40
|
* Completed and concurrent requests are cached; a failed application-data
|
|
@@ -1,16 +1,24 @@
|
|
|
1
|
-
import { Injectable, Signal, signal } from '@angular/core';
|
|
1
|
+
import { Injectable, Signal, inject, signal } from '@angular/core';
|
|
2
2
|
import { HttpParams } from '@angular/common/http';
|
|
3
|
-
import { Observable, tap } from 'rxjs';
|
|
3
|
+
import { EMPTY, Observable, Subscription, catchError, filter, switchMap, tap } from 'rxjs';
|
|
4
4
|
import { BaseRestService } from './base_rest.service';
|
|
5
|
+
import { RealtimeService } from './realtime.service';
|
|
5
6
|
import { RestURL } from './rest_url';
|
|
6
7
|
import { InboxMessage, InboxPage } from '../model/notification';
|
|
7
8
|
|
|
8
9
|
export const INBOX_MAX_PAGE_SIZE = 100;
|
|
9
10
|
export const INBOX_DEFAULT_PAGE_SIZE = 20;
|
|
10
11
|
|
|
11
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* Stateful client for keel's `InboxHandler` (v1.2.71+). After `load()` it re-fetches
|
|
14
|
+
* the newest page on each realtime `notification` frame (keel `realtime.UserDispatcher`).
|
|
15
|
+
* Call `reset()` on logout.
|
|
16
|
+
*/
|
|
12
17
|
@Injectable({ providedIn: 'root' })
|
|
13
18
|
export class NotificationInboxService extends BaseRestService {
|
|
19
|
+
private readonly realtime = inject(RealtimeService);
|
|
20
|
+
private live: Subscription | null = null;
|
|
21
|
+
private pageSize = INBOX_DEFAULT_PAGE_SIZE;
|
|
14
22
|
private readonly cached = signal<InboxMessage[]>([]);
|
|
15
23
|
private readonly unread = signal(0);
|
|
16
24
|
private readonly older = signal(false);
|
|
@@ -20,17 +28,25 @@ export class NotificationInboxService extends BaseRestService {
|
|
|
20
28
|
readonly hasMore: Signal<boolean> = this.older.asReadonly();
|
|
21
29
|
|
|
22
30
|
load(limit = INBOX_DEFAULT_PAGE_SIZE): Observable<InboxPage> {
|
|
23
|
-
|
|
31
|
+
this.pageSize = pageSizeOf(limit);
|
|
32
|
+
return this.fetch('', limit).pipe(tap((page) => {
|
|
33
|
+
this.cached.set(page.messages);
|
|
34
|
+
this.older.set(this.isFull(page, limit));
|
|
35
|
+
this.follow();
|
|
36
|
+
}));
|
|
24
37
|
}
|
|
25
38
|
|
|
26
39
|
loadMore(limit = INBOX_DEFAULT_PAGE_SIZE): Observable<InboxPage> {
|
|
27
40
|
const loaded = this.cached();
|
|
28
41
|
const oldest = loaded.length ? loaded[loaded.length - 1].id : '';
|
|
29
42
|
return this.fetch(oldest, limit).pipe(
|
|
30
|
-
tap((page) =>
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
43
|
+
tap((page) => {
|
|
44
|
+
this.older.set(this.isFull(page, limit));
|
|
45
|
+
this.cached.update((rows) => {
|
|
46
|
+
const ids = new Set(rows.map((message) => message.id));
|
|
47
|
+
return rows.concat(page.messages.filter((message) => !ids.has(message.id)));
|
|
48
|
+
});
|
|
49
|
+
}),
|
|
34
50
|
);
|
|
35
51
|
}
|
|
36
52
|
|
|
@@ -54,23 +70,46 @@ export class NotificationInboxService extends BaseRestService {
|
|
|
54
70
|
}
|
|
55
71
|
|
|
56
72
|
reset(): void {
|
|
73
|
+
this.live?.unsubscribe();
|
|
74
|
+
this.live = null;
|
|
57
75
|
this.cached.set([]);
|
|
58
76
|
this.unread.set(0);
|
|
59
77
|
this.older.set(false);
|
|
60
78
|
}
|
|
61
79
|
|
|
80
|
+
/** The frame carries no message id, so the newest page is re-fetched and merged over the cache head. */
|
|
81
|
+
private follow(): void {
|
|
82
|
+
if (this.live) return;
|
|
83
|
+
this.live = this.realtime.userFrames<{ op?: unknown }>().pipe(
|
|
84
|
+
filter((frame) => frame?.op === 'notification'),
|
|
85
|
+
switchMap(() => this.fetch('', this.pageSize).pipe(
|
|
86
|
+
catchError((err) => {
|
|
87
|
+
console.error('notification inbox: refresh after realtime frame failed', err);
|
|
88
|
+
return EMPTY;
|
|
89
|
+
}),
|
|
90
|
+
)),
|
|
91
|
+
).subscribe((page) => {
|
|
92
|
+
const fresh = new Set(page.messages.map((message) => message.id));
|
|
93
|
+
this.cached.update((rows) => page.messages.concat(rows.filter((message) => !fresh.has(message.id))));
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
62
97
|
private fetch(before: string, limit: number): Observable<InboxPage> {
|
|
63
|
-
|
|
64
|
-
const size = Math.min(Math.max(1, requested), INBOX_MAX_PAGE_SIZE);
|
|
65
|
-
let params = new HttpParams().set('limit', size);
|
|
98
|
+
let params = new HttpParams().set('limit', pageSizeOf(limit));
|
|
66
99
|
if (before) params = params.set('before', before);
|
|
67
100
|
return this.http.get<InboxPage>(this.url(RestURL.notificationsURL), { params }).pipe(
|
|
68
|
-
tap((page) =>
|
|
69
|
-
this.unread.set(page.unreadCount);
|
|
70
|
-
this.older.set(page.messages.length >= size);
|
|
71
|
-
}),
|
|
101
|
+
tap((page) => this.unread.set(page.unreadCount)),
|
|
72
102
|
);
|
|
73
103
|
}
|
|
104
|
+
|
|
105
|
+
private isFull(page: InboxPage, limit: number): boolean {
|
|
106
|
+
return page.messages.length >= pageSizeOf(limit);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function pageSizeOf(limit: number): number {
|
|
111
|
+
const requested = Number.isFinite(limit) ? Math.trunc(limit) : INBOX_DEFAULT_PAGE_SIZE;
|
|
112
|
+
return Math.min(Math.max(1, requested), INBOX_MAX_PAGE_SIZE);
|
|
74
113
|
}
|
|
75
114
|
|
|
76
115
|
function nowIso(): string {
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { inject, Injectable } from '@angular/core';
|
|
2
|
+
import { HttpEvent, HttpEventType, HttpParams, HttpResponse } from '@angular/common/http';
|
|
3
|
+
import { Observable, map } from 'rxjs';
|
|
4
|
+
import { BaseRestService } from './base_rest.service';
|
|
5
|
+
import { BackendService } from './rest_service';
|
|
6
|
+
import { RestURL } from './rest_url';
|
|
7
|
+
import { DocumentUploadRequest, PartnerDocument } from '../model/document';
|
|
8
|
+
|
|
9
|
+
interface DocumentHandlerResponse {
|
|
10
|
+
ID: number;
|
|
11
|
+
ContRepID: string;
|
|
12
|
+
DocKey: string;
|
|
13
|
+
PartnerID: number;
|
|
14
|
+
UserID: number;
|
|
15
|
+
DocumentType: string;
|
|
16
|
+
Title: string;
|
|
17
|
+
FileName: string;
|
|
18
|
+
VersionNo: number;
|
|
19
|
+
DocumentNumber: string;
|
|
20
|
+
ExpiresOn: string;
|
|
21
|
+
OriginIP: string;
|
|
22
|
+
UploadedBy: number;
|
|
23
|
+
UploadedAt: string;
|
|
24
|
+
Status: string;
|
|
25
|
+
ReviewerID: number;
|
|
26
|
+
ReviewedAt: string;
|
|
27
|
+
ReviewerNotes: string;
|
|
28
|
+
SupersededAt: string;
|
|
29
|
+
RetiredAt: string;
|
|
30
|
+
PurgedAt: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Client for keel's `handler.DocumentHandler` and `partner_document` REST table. */
|
|
34
|
+
@Injectable({ providedIn: 'root' })
|
|
35
|
+
export class PartnerDocumentService extends BaseRestService {
|
|
36
|
+
private readonly backend = inject(BackendService);
|
|
37
|
+
|
|
38
|
+
list(userId?: number): Observable<PartnerDocument[]> {
|
|
39
|
+
const filter = userId === undefined ? undefined : { UserId: String(userId) };
|
|
40
|
+
return this.backend.list<PartnerDocument>('partner_document', filter);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
pending(): Observable<PartnerDocument[]> {
|
|
44
|
+
return this.backend.list<PartnerDocument>('partner_document', { Status: 'P' });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Multipart upload with progress events; the final response carries the stored document. */
|
|
48
|
+
upload(file: File, request: DocumentUploadRequest): Observable<HttpEvent<PartnerDocument>> {
|
|
49
|
+
const form = new FormData();
|
|
50
|
+
form.append('file', file);
|
|
51
|
+
form.append('document_type', request.documentType);
|
|
52
|
+
if (request.title) form.append('title', request.title);
|
|
53
|
+
if (request.documentNumber) form.append('document_number', request.documentNumber);
|
|
54
|
+
if (request.expiresOn) form.append('expires_on', request.expiresOn);
|
|
55
|
+
if (request.userId) form.append('user_id', String(request.userId));
|
|
56
|
+
return this.http.post<DocumentHandlerResponse>(this.url(RestURL.partnerDocumentUploadURL), form, {
|
|
57
|
+
reportProgress: true,
|
|
58
|
+
observe: 'events',
|
|
59
|
+
}).pipe(map((event) => normalizeUploadEvent(event)));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Short-lived signed read URL. */
|
|
63
|
+
previewUrl(id: number): Observable<string> {
|
|
64
|
+
const params = new HttpParams().set('id', id);
|
|
65
|
+
return this.http.get<{ url: string }>(this.url(RestURL.partnerDocumentPreviewURL), { params })
|
|
66
|
+
.pipe(map((resp) => resp.url));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
review(id: number, approve: boolean, notes = ''): Observable<void> {
|
|
70
|
+
// keel decodes the id with `json:"id,string"`.
|
|
71
|
+
return this.http.post<void>(this.url(RestURL.partnerDocumentReviewURL), { id: String(id), approve, notes });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function normalizeUploadEvent(event: HttpEvent<DocumentHandlerResponse>): HttpEvent<PartnerDocument> {
|
|
76
|
+
if (event.type !== HttpEventType.Response) return event;
|
|
77
|
+
return (event as HttpResponse<DocumentHandlerResponse>).clone({
|
|
78
|
+
body: event.body ? normalizeDocument(event.body) : null,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** DocumentHandler answers with keel's Go struct: zero values stand for the row's NULLs. */
|
|
83
|
+
function normalizeDocument(doc: DocumentHandlerResponse): PartnerDocument {
|
|
84
|
+
return {
|
|
85
|
+
Id: doc.ID,
|
|
86
|
+
ContrepId: doc.ContRepID,
|
|
87
|
+
DocKey: doc.DocKey,
|
|
88
|
+
PartnerId: doc.PartnerID,
|
|
89
|
+
UserId: doc.UserID || null,
|
|
90
|
+
DocumentType: doc.DocumentType,
|
|
91
|
+
Title: doc.Title,
|
|
92
|
+
FileName: doc.FileName,
|
|
93
|
+
VersionNo: doc.VersionNo,
|
|
94
|
+
DocumentNumber: doc.DocumentNumber || null,
|
|
95
|
+
ExpiresOn: timeOrNull(doc.ExpiresOn),
|
|
96
|
+
OriginIp: doc.OriginIP || null,
|
|
97
|
+
UploadedBy: doc.UploadedBy || null,
|
|
98
|
+
UploadedAt: doc.UploadedAt,
|
|
99
|
+
Status: doc.Status,
|
|
100
|
+
ReviewerId: doc.ReviewerID || null,
|
|
101
|
+
ReviewedAt: timeOrNull(doc.ReviewedAt),
|
|
102
|
+
ReviewerNotes: doc.ReviewerNotes || null,
|
|
103
|
+
SupersededAt: timeOrNull(doc.SupersededAt),
|
|
104
|
+
RetiredAt: timeOrNull(doc.RetiredAt),
|
|
105
|
+
PurgedAt: timeOrNull(doc.PurgedAt),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function timeOrNull(value: string): string | null {
|
|
110
|
+
return !value || value.startsWith('0001-01-01') ? null : value;
|
|
111
|
+
}
|