@syndew/oluso-plugin-verification 0.0.0-stage → 1.30.1

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.
@@ -0,0 +1,1407 @@
1
+ import * as i0 from '@angular/core';
2
+ import { inject, Injectable, signal, Component, computed } from '@angular/core';
3
+ import * as i1$1 from '@angular/common';
4
+ import { CommonModule } from '@angular/common';
5
+ import * as i1 from '@angular/forms';
6
+ import { FormsModule } from '@angular/forms';
7
+ import { forkJoin } from 'rxjs';
8
+ import { ToastService, extractErrorMessage, ButtonComponent, IconComponent, PaginationComponent, LocaleDatePipe, PermissionsService, openFetchedFile, PromptDialogComponent } from '@syndew/oluso-ui-core';
9
+ import { HttpParams, HttpErrorResponse } from '@angular/common/http';
10
+ import { ApiService } from '@syndew/oluso-admin-ui';
11
+ import { Router, ActivatedRoute, RouterLink } from '@angular/router';
12
+
13
+ /** Business (KYB) verification: providers, settings, businesses, applicants and reviews (api/admin/business-verification). */
14
+ class VerificationService {
15
+ api = inject(ApiService);
16
+ endpoint = '/business-verification';
17
+ getProviders() {
18
+ return this.api.get(`${this.endpoint}/providers`);
19
+ }
20
+ getCountries() {
21
+ return this.api.get(`${this.endpoint}/countries`);
22
+ }
23
+ getSettings() {
24
+ return this.api.get(`${this.endpoint}/settings`);
25
+ }
26
+ updateSettings(input) {
27
+ return this.api.put(`${this.endpoint}/settings`, input);
28
+ }
29
+ testProvider(providerId) {
30
+ return this.api.post(`${this.endpoint}/providers/${encodeURIComponent(providerId)}/test`, {});
31
+ }
32
+ listBusinesses(status, search, page, pageSize) {
33
+ const params = { page, pageSize };
34
+ if (status)
35
+ params['status'] = status;
36
+ if (search)
37
+ params['search'] = search;
38
+ return this.api.get(`${this.endpoint}/businesses`, params);
39
+ }
40
+ getBusiness(id) {
41
+ return this.api.get(`${this.endpoint}/businesses/${encodeURIComponent(id)}`);
42
+ }
43
+ reviewQueue(page, pageSize) {
44
+ return this.api.get(`${this.endpoint}/review-queue`, { page, pageSize });
45
+ }
46
+ reviewBusiness(id, decision, note) {
47
+ return this.api.post(`${this.endpoint}/businesses/${encodeURIComponent(id)}/review`, { decision, note });
48
+ }
49
+ reviewApplicant(id, decision, note) {
50
+ return this.api.post(`${this.endpoint}/applicants/${encodeURIComponent(id)}/review`, { decision, note });
51
+ }
52
+ refreshApplicant(id) {
53
+ return this.api.post(`${this.endpoint}/applicants/${encodeURIComponent(id)}/refresh`, {});
54
+ }
55
+ downloadDocument(applicantId, documentId) {
56
+ return this.api.getBlob(`${this.endpoint}/applicants/${encodeURIComponent(applicantId)}/documents/${encodeURIComponent(documentId)}`, new HttpParams());
57
+ }
58
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: VerificationService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
59
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: VerificationService, providedIn: 'root' });
60
+ }
61
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: VerificationService, decorators: [{
62
+ type: Injectable,
63
+ args: [{ providedIn: 'root' }]
64
+ }] });
65
+
66
+ class VerificationSettingsTabComponent {
67
+ service = inject(VerificationService);
68
+ toast = inject(ToastService);
69
+ loading = signal(true, ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
70
+ saving = signal(false, ...(ngDevMode ? [{ debugName: "saving" }] : /* istanbul ignore next */ []));
71
+ testing = signal(null, ...(ngDevMode ? [{ debugName: "testing" }] : /* istanbul ignore next */ []));
72
+ loadError = signal(null, ...(ngDevMode ? [{ debugName: "loadError" }] : /* istanbul ignore next */ []));
73
+ providers = signal([], ...(ngDevMode ? [{ debugName: "providers" }] : /* istanbul ignore next */ []));
74
+ countries = signal([], ...(ngDevMode ? [{ debugName: "countries" }] : /* istanbul ignore next */ []));
75
+ testResults = signal({}, ...(ngDevMode ? [{ debugName: "testResults" }] : /* istanbul ignore next */ []));
76
+ routing = {};
77
+ reuseDays = 0;
78
+ ngOnInit() {
79
+ forkJoin({
80
+ providers: this.service.getProviders(),
81
+ countries: this.service.getCountries(),
82
+ settings: this.service.getSettings(),
83
+ }).subscribe({
84
+ next: ({ providers, countries, settings }) => {
85
+ this.countries.set(countries);
86
+ this.apply(providers, settings);
87
+ this.loading.set(false);
88
+ },
89
+ error: (err) => {
90
+ this.loadError.set(extractErrorMessage(err, 'Failed to load business verification settings'));
91
+ this.loading.set(false);
92
+ },
93
+ });
94
+ }
95
+ countryNames(codes) {
96
+ return codes.map((code) => this.countries().find((c) => c.code === code)?.name ?? code).join(', ');
97
+ }
98
+ providerName(id) {
99
+ return this.providers().find((p) => p.info.id === id)?.info.displayName ?? id;
100
+ }
101
+ copy(text) {
102
+ navigator.clipboard.writeText(text).then(() => this.toast.success('Webhook URL copied'), () => this.toast.error('Copy failed; select the URL and copy it manually'));
103
+ }
104
+ save() {
105
+ if (this.saving())
106
+ return;
107
+ this.saving.set(true);
108
+ const businessRouting = {};
109
+ for (const [country, providerId] of Object.entries(this.routing)) {
110
+ if (providerId)
111
+ businessRouting[country] = providerId;
112
+ }
113
+ this.service
114
+ .updateSettings({
115
+ providers: this.providers().map((p) => ({
116
+ providerId: p.info.id,
117
+ enabled: p.enabled,
118
+ environment: p.environment,
119
+ credentials: { ...p.credentials },
120
+ options: { ...p.options },
121
+ })),
122
+ businessRouting,
123
+ businessReuseMaxAgeDays: Number(this.reuseDays) || 0,
124
+ })
125
+ .subscribe({
126
+ next: (settings) => {
127
+ this.apply(this.providers().map((p) => p.info), settings);
128
+ this.saving.set(false);
129
+ this.toast.success('Business verification settings saved');
130
+ },
131
+ error: (err) => {
132
+ this.saving.set(false);
133
+ this.toast.error(extractErrorMessage(err, 'Failed to save business verification settings'));
134
+ },
135
+ });
136
+ }
137
+ test(providerId) {
138
+ this.testing.set(providerId);
139
+ this.service.testProvider(providerId).subscribe({
140
+ next: (result) => {
141
+ this.testResults.update((all) => ({ ...all, [providerId]: result }));
142
+ this.testing.set(null);
143
+ },
144
+ error: (err) => {
145
+ this.testResults.update((all) => ({ ...all, [providerId]: { success: false, message: extractErrorMessage(err, 'Test failed') } }));
146
+ this.testing.set(null);
147
+ },
148
+ });
149
+ }
150
+ apply(infos, settings) {
151
+ this.providers.set(infos.map((info) => {
152
+ const saved = settings.providers.find((p) => p.providerId === info.id);
153
+ const credentials = {};
154
+ for (const field of info.credentialFields) {
155
+ credentials[field.name] = field.secret ? '' : saved?.credentials[field.name] ?? '';
156
+ }
157
+ const options = {};
158
+ for (const option of info.options) {
159
+ options[option.name] = saved?.options[option.name] ?? option.default;
160
+ }
161
+ return {
162
+ info,
163
+ enabled: saved?.enabled ?? false,
164
+ environment: saved?.environment ?? 'Sandbox',
165
+ credentials,
166
+ storedSecrets: saved?.storedSecrets ?? [],
167
+ options,
168
+ };
169
+ }));
170
+ this.routing = {};
171
+ for (const c of this.countries()) {
172
+ this.routing[c.code] = settings.businessRouting[c.code] ?? '';
173
+ }
174
+ this.reuseDays = settings.businessReuseMaxAgeDays;
175
+ }
176
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: VerificationSettingsTabComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
177
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.25", type: VerificationSettingsTabComponent, isStandalone: true, selector: "verification-settings-tab", ngImport: i0, template: `
178
+ @if (loading()) {
179
+ <div class="flex items-center justify-center py-12">
180
+ <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
181
+ </div>
182
+ } @else if (loadError()) {
183
+ <div class="bg-red-50 border border-red-200 rounded-md p-4"><p class="text-sm text-red-600">{{ loadError() }}</p></div>
184
+ } @else {
185
+ <form (ngSubmit)="save()" class="space-y-6">
186
+ <div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
187
+ <div class="flex items-start gap-3">
188
+ <ui-icon name="information-circle" class="h-5 w-5 text-blue-500 mt-0.5 flex-shrink-0" />
189
+ <div class="text-sm text-blue-800">
190
+ <p class="font-medium">Business verification (KYB)</p>
191
+ <p class="mt-1">
192
+ The Business Verification journey step checks a business against its company registry, or through the
193
+ provider's hosted flow, using the provider you route its country to. Credentials are stored encrypted and
194
+ never shown again. Use sandbox keys while testing: sandbox lookups use test data and aren't billed.
195
+ </p>
196
+ </div>
197
+ </div>
198
+ </div>
199
+
200
+ @for (p of providers(); track p.info.id) {
201
+ <div class="rounded-lg border border-gray-200 p-4 space-y-4">
202
+ <div class="flex flex-wrap items-center justify-between gap-3">
203
+ <div>
204
+ <p class="text-sm font-semibold text-gray-900">{{ p.info.displayName }}</p>
205
+ <p class="text-xs text-gray-500">Company registries: {{ countryNames(p.info.businessCountries) }}</p>
206
+ @if (p.info.hostedCountries.length > 0) {
207
+ <p class="text-xs text-gray-500">Hosted verification: {{ countryNames(p.info.hostedCountries) }}</p>
208
+ }
209
+ </div>
210
+ <label class="flex items-center gap-2">
211
+ <input type="checkbox" [(ngModel)]="p.enabled" [name]="p.info.id + '-enabled'" class="rounded border-gray-300 text-primary-600" />
212
+ <span class="text-sm text-gray-700">Enabled</span>
213
+ </label>
214
+ </div>
215
+
216
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
217
+ <div>
218
+ <label class="block text-sm font-medium text-gray-700" [for]="p.info.id + '-environment'">Environment</label>
219
+ <select [id]="p.info.id + '-environment'" [(ngModel)]="p.environment" [name]="p.info.id + '-environment'" class="mt-1 w-full">
220
+ <option value="Sandbox">Sandbox (test data, not billed)</option>
221
+ <option value="Live">Live (billed per lookup)</option>
222
+ </select>
223
+ <p class="mt-1 text-xs text-gray-500">Switching environment clears stored secrets; enter the keys for the new one.</p>
224
+ </div>
225
+
226
+ @for (o of p.info.options; track o.name) {
227
+ <div>
228
+ <label class="block text-sm font-medium text-gray-700" [for]="p.info.id + '-' + o.name">{{ o.label }}</label>
229
+ <select [id]="p.info.id + '-' + o.name" [(ngModel)]="p.options[o.name]" [name]="p.info.id + '-' + o.name" class="mt-1 w-full">
230
+ @for (c of o.choices; track c) {
231
+ <option [value]="c">{{ c }}</option>
232
+ }
233
+ </select>
234
+ @if (o.description) { <p class="mt-1 text-xs text-gray-500">{{ o.description }}</p> }
235
+ </div>
236
+ }
237
+
238
+ @for (f of p.info.credentialFields; track f.name) {
239
+ <div>
240
+ <label class="block text-sm font-medium text-gray-700" [for]="p.info.id + '-' + f.name">
241
+ {{ f.label }}
242
+ @if (!f.required) { <span class="text-xs font-normal text-gray-500">(hosted verification only)</span> }
243
+ </label>
244
+ @if (f.secret) {
245
+ <input type="password" [id]="p.info.id + '-' + f.name" [(ngModel)]="p.credentials[f.name]" [name]="p.info.id + '-' + f.name"
246
+ class="mt-1 w-full" autocomplete="new-password"
247
+ [placeholder]="p.storedSecrets.includes(f.name) ? 'Stored. Leave blank to keep.' : 'Not set'" />
248
+ } @else {
249
+ <input type="text" [id]="p.info.id + '-' + f.name" [(ngModel)]="p.credentials[f.name]" [name]="p.info.id + '-' + f.name"
250
+ class="mt-1 w-full" autocomplete="off" />
251
+ }
252
+ @if (f.description) { <p class="mt-1 text-xs text-gray-500">{{ f.description }}</p> }
253
+ </div>
254
+ }
255
+ </div>
256
+
257
+ @if (p.info.webhookUrl) {
258
+ <div class="rounded-md bg-gray-50 border border-gray-200 p-3">
259
+ <p class="text-xs font-medium text-gray-700">Webhook URL for {{ p.info.displayName }}</p>
260
+ <div class="mt-1 flex items-center gap-2">
261
+ <code class="text-xs break-all text-gray-800">{{ p.info.webhookUrl }}</code>
262
+ <ui-button type="button" variant="ghost" size="sm" (click)="copy(p.info.webhookUrl)">Copy</ui-button>
263
+ </div>
264
+ <p class="mt-1 text-xs text-gray-500">Give this to the provider for hosted verification results. Requests are accepted only when their signature verifies with the webhook secret above.</p>
265
+ </div>
266
+ }
267
+
268
+ <div class="flex items-center gap-3">
269
+ <ui-button type="button" variant="secondary" (click)="test(p.info.id)" [disabled]="testing() === p.info.id">
270
+ {{ testing() === p.info.id ? 'Testing…' : 'Test saved credentials' }}
271
+ </ui-button>
272
+ @if (testResults()[p.info.id]; as r) {
273
+ <span class="text-sm" [class.text-green-700]="r.success" [class.text-red-600]="!r.success">{{ r.message }}</span>
274
+ }
275
+ </div>
276
+ </div>
277
+ }
278
+
279
+ <div class="rounded-lg border border-gray-200 p-4 space-y-4">
280
+ <div>
281
+ <p class="text-sm font-semibold text-gray-900">Country routing</p>
282
+ <p class="text-xs text-gray-500">Which provider verifies businesses registered in each country. A country with no provider can't be verified; the journey step fails instead of trying another provider.</p>
283
+ </div>
284
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
285
+ @for (c of countries(); track c.code) {
286
+ <div>
287
+ <label class="block text-sm font-medium text-gray-700" [for]="'route-' + c.code">{{ c.name }} ({{ c.code }})</label>
288
+ <select [id]="'route-' + c.code" [(ngModel)]="routing[c.code]" [name]="'route-' + c.code" class="mt-1 w-full">
289
+ <option value="">Not routed</option>
290
+ @for (id of c.providerIds; track id) {
291
+ <option [value]="id">{{ providerName(id) }}</option>
292
+ }
293
+ </select>
294
+ </div>
295
+ }
296
+ </div>
297
+ </div>
298
+
299
+ <div class="rounded-lg border border-gray-200 p-4 space-y-2 max-w-md">
300
+ <label class="block text-sm font-semibold text-gray-900" for="reuse-days">Reuse registry results for (days)</label>
301
+ <input id="reuse-days" type="number" min="0" max="365" [(ngModel)]="reuseDays" name="reuseDays" class="w-32" />
302
+ <p class="text-xs text-gray-500">When the same registration number is verified again within this many days, the earlier registry result is reused instead of paying for a new lookup. 0 always looks up again.</p>
303
+ </div>
304
+
305
+ <div class="flex items-center gap-3">
306
+ <ui-button type="submit" [disabled]="saving()">{{ saving() ? 'Saving…' : 'Save' }}</ui-button>
307
+ </div>
308
+ </form>
309
+ }
310
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.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: i1.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i1.CheckboxControlValueAccessor, selector: "input[type=checkbox][formControlName],input[type=checkbox][formControl],input[type=checkbox][ngModel]" }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.MinValidator, selector: "input[type=number][min][formControlName],input[type=number][min][formControl],input[type=number][min][ngModel]", inputs: ["min"] }, { kind: "directive", type: i1.MaxValidator, selector: "input[type=number][max][formControlName],input[type=number][max][formControl],input[type=number][max][ngModel]", inputs: ["max"] }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i1.NgForm, selector: "form:not([ngNoForm]):not([formGroup]):not([formArray]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "component", type: ButtonComponent, selector: "ui-button", inputs: ["variant", "size", "disabled", "loading", "type", "fullWidth"], outputs: ["clicked"] }, { kind: "component", type: IconComponent, selector: "ui-icon", inputs: ["name", "strokeWidth"] }] });
311
+ }
312
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: VerificationSettingsTabComponent, decorators: [{
313
+ type: Component,
314
+ args: [{
315
+ selector: 'verification-settings-tab',
316
+ standalone: true,
317
+ imports: [CommonModule, FormsModule, ButtonComponent, IconComponent],
318
+ template: `
319
+ @if (loading()) {
320
+ <div class="flex items-center justify-center py-12">
321
+ <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
322
+ </div>
323
+ } @else if (loadError()) {
324
+ <div class="bg-red-50 border border-red-200 rounded-md p-4"><p class="text-sm text-red-600">{{ loadError() }}</p></div>
325
+ } @else {
326
+ <form (ngSubmit)="save()" class="space-y-6">
327
+ <div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
328
+ <div class="flex items-start gap-3">
329
+ <ui-icon name="information-circle" class="h-5 w-5 text-blue-500 mt-0.5 flex-shrink-0" />
330
+ <div class="text-sm text-blue-800">
331
+ <p class="font-medium">Business verification (KYB)</p>
332
+ <p class="mt-1">
333
+ The Business Verification journey step checks a business against its company registry, or through the
334
+ provider's hosted flow, using the provider you route its country to. Credentials are stored encrypted and
335
+ never shown again. Use sandbox keys while testing: sandbox lookups use test data and aren't billed.
336
+ </p>
337
+ </div>
338
+ </div>
339
+ </div>
340
+
341
+ @for (p of providers(); track p.info.id) {
342
+ <div class="rounded-lg border border-gray-200 p-4 space-y-4">
343
+ <div class="flex flex-wrap items-center justify-between gap-3">
344
+ <div>
345
+ <p class="text-sm font-semibold text-gray-900">{{ p.info.displayName }}</p>
346
+ <p class="text-xs text-gray-500">Company registries: {{ countryNames(p.info.businessCountries) }}</p>
347
+ @if (p.info.hostedCountries.length > 0) {
348
+ <p class="text-xs text-gray-500">Hosted verification: {{ countryNames(p.info.hostedCountries) }}</p>
349
+ }
350
+ </div>
351
+ <label class="flex items-center gap-2">
352
+ <input type="checkbox" [(ngModel)]="p.enabled" [name]="p.info.id + '-enabled'" class="rounded border-gray-300 text-primary-600" />
353
+ <span class="text-sm text-gray-700">Enabled</span>
354
+ </label>
355
+ </div>
356
+
357
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
358
+ <div>
359
+ <label class="block text-sm font-medium text-gray-700" [for]="p.info.id + '-environment'">Environment</label>
360
+ <select [id]="p.info.id + '-environment'" [(ngModel)]="p.environment" [name]="p.info.id + '-environment'" class="mt-1 w-full">
361
+ <option value="Sandbox">Sandbox (test data, not billed)</option>
362
+ <option value="Live">Live (billed per lookup)</option>
363
+ </select>
364
+ <p class="mt-1 text-xs text-gray-500">Switching environment clears stored secrets; enter the keys for the new one.</p>
365
+ </div>
366
+
367
+ @for (o of p.info.options; track o.name) {
368
+ <div>
369
+ <label class="block text-sm font-medium text-gray-700" [for]="p.info.id + '-' + o.name">{{ o.label }}</label>
370
+ <select [id]="p.info.id + '-' + o.name" [(ngModel)]="p.options[o.name]" [name]="p.info.id + '-' + o.name" class="mt-1 w-full">
371
+ @for (c of o.choices; track c) {
372
+ <option [value]="c">{{ c }}</option>
373
+ }
374
+ </select>
375
+ @if (o.description) { <p class="mt-1 text-xs text-gray-500">{{ o.description }}</p> }
376
+ </div>
377
+ }
378
+
379
+ @for (f of p.info.credentialFields; track f.name) {
380
+ <div>
381
+ <label class="block text-sm font-medium text-gray-700" [for]="p.info.id + '-' + f.name">
382
+ {{ f.label }}
383
+ @if (!f.required) { <span class="text-xs font-normal text-gray-500">(hosted verification only)</span> }
384
+ </label>
385
+ @if (f.secret) {
386
+ <input type="password" [id]="p.info.id + '-' + f.name" [(ngModel)]="p.credentials[f.name]" [name]="p.info.id + '-' + f.name"
387
+ class="mt-1 w-full" autocomplete="new-password"
388
+ [placeholder]="p.storedSecrets.includes(f.name) ? 'Stored. Leave blank to keep.' : 'Not set'" />
389
+ } @else {
390
+ <input type="text" [id]="p.info.id + '-' + f.name" [(ngModel)]="p.credentials[f.name]" [name]="p.info.id + '-' + f.name"
391
+ class="mt-1 w-full" autocomplete="off" />
392
+ }
393
+ @if (f.description) { <p class="mt-1 text-xs text-gray-500">{{ f.description }}</p> }
394
+ </div>
395
+ }
396
+ </div>
397
+
398
+ @if (p.info.webhookUrl) {
399
+ <div class="rounded-md bg-gray-50 border border-gray-200 p-3">
400
+ <p class="text-xs font-medium text-gray-700">Webhook URL for {{ p.info.displayName }}</p>
401
+ <div class="mt-1 flex items-center gap-2">
402
+ <code class="text-xs break-all text-gray-800">{{ p.info.webhookUrl }}</code>
403
+ <ui-button type="button" variant="ghost" size="sm" (click)="copy(p.info.webhookUrl)">Copy</ui-button>
404
+ </div>
405
+ <p class="mt-1 text-xs text-gray-500">Give this to the provider for hosted verification results. Requests are accepted only when their signature verifies with the webhook secret above.</p>
406
+ </div>
407
+ }
408
+
409
+ <div class="flex items-center gap-3">
410
+ <ui-button type="button" variant="secondary" (click)="test(p.info.id)" [disabled]="testing() === p.info.id">
411
+ {{ testing() === p.info.id ? 'Testing…' : 'Test saved credentials' }}
412
+ </ui-button>
413
+ @if (testResults()[p.info.id]; as r) {
414
+ <span class="text-sm" [class.text-green-700]="r.success" [class.text-red-600]="!r.success">{{ r.message }}</span>
415
+ }
416
+ </div>
417
+ </div>
418
+ }
419
+
420
+ <div class="rounded-lg border border-gray-200 p-4 space-y-4">
421
+ <div>
422
+ <p class="text-sm font-semibold text-gray-900">Country routing</p>
423
+ <p class="text-xs text-gray-500">Which provider verifies businesses registered in each country. A country with no provider can't be verified; the journey step fails instead of trying another provider.</p>
424
+ </div>
425
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
426
+ @for (c of countries(); track c.code) {
427
+ <div>
428
+ <label class="block text-sm font-medium text-gray-700" [for]="'route-' + c.code">{{ c.name }} ({{ c.code }})</label>
429
+ <select [id]="'route-' + c.code" [(ngModel)]="routing[c.code]" [name]="'route-' + c.code" class="mt-1 w-full">
430
+ <option value="">Not routed</option>
431
+ @for (id of c.providerIds; track id) {
432
+ <option [value]="id">{{ providerName(id) }}</option>
433
+ }
434
+ </select>
435
+ </div>
436
+ }
437
+ </div>
438
+ </div>
439
+
440
+ <div class="rounded-lg border border-gray-200 p-4 space-y-2 max-w-md">
441
+ <label class="block text-sm font-semibold text-gray-900" for="reuse-days">Reuse registry results for (days)</label>
442
+ <input id="reuse-days" type="number" min="0" max="365" [(ngModel)]="reuseDays" name="reuseDays" class="w-32" />
443
+ <p class="text-xs text-gray-500">When the same registration number is verified again within this many days, the earlier registry result is reused instead of paying for a new lookup. 0 always looks up again.</p>
444
+ </div>
445
+
446
+ <div class="flex items-center gap-3">
447
+ <ui-button type="submit" [disabled]="saving()">{{ saving() ? 'Saving…' : 'Save' }}</ui-button>
448
+ </div>
449
+ </form>
450
+ }
451
+ `,
452
+ }]
453
+ }] });
454
+
455
+ const VERIFICATION_ROUTES = [
456
+ {
457
+ path: '',
458
+ redirectTo: 'businesses',
459
+ pathMatch: 'full',
460
+ },
461
+ {
462
+ path: 'businesses',
463
+ loadComponent: () => Promise.resolve().then(function () { return businessesPage_component; }).then((m) => m.BusinessesPageComponent),
464
+ data: { title: 'Verified businesses', helpContext: 'admin.verification.businesses' },
465
+ },
466
+ {
467
+ path: 'businesses/:id',
468
+ loadComponent: () => Promise.resolve().then(function () { return businessDetailPage_component; }).then((m) => m.BusinessDetailPageComponent),
469
+ data: { title: 'Business', helpContext: 'admin.verification.businesses' },
470
+ },
471
+ {
472
+ path: 'review',
473
+ loadComponent: () => Promise.resolve().then(function () { return reviewQueuePage_component; }).then((m) => m.ReviewQueuePageComponent),
474
+ data: { title: 'Verification review queue', helpContext: 'admin.verification.businesses' },
475
+ },
476
+ ];
477
+
478
+ /**
479
+ * Business (KYB) verification admin plugin: verified businesses with the people acting
480
+ * for them and their documents, the review queue, and the provider settings tab.
481
+ */
482
+ const VERIFICATION_PLUGIN = {
483
+ name: 'oluso-verification',
484
+ version: '1.0.0',
485
+ routes: [
486
+ {
487
+ path: 'verification',
488
+ children: VERIFICATION_ROUTES,
489
+ },
490
+ ],
491
+ navItems: [
492
+ {
493
+ id: 'verification',
494
+ label: 'Business Verification',
495
+ path: '/verification',
496
+ icon: 'building-office-2',
497
+ order: 15,
498
+ permission: 'business_verification.read',
499
+ mergeIntoPath: '/experience',
500
+ children: [
501
+ { label: 'Businesses', path: '/verification/businesses', icon: 'building-office-2', order: 0 },
502
+ { label: 'Review queue', path: '/verification/review', icon: 'inbox', order: 10 },
503
+ ],
504
+ },
505
+ ],
506
+ settingsTabs: [
507
+ {
508
+ id: 'business-verification',
509
+ label: 'Business verification',
510
+ icon: 'building-office-2',
511
+ component: VerificationSettingsTabComponent,
512
+ order: 39,
513
+ permission: 'business_verification.manage',
514
+ },
515
+ ],
516
+ };
517
+
518
+ const STATUS_LABELS = {
519
+ pending: 'Pending',
520
+ in_review: 'In review',
521
+ verified: 'Verified',
522
+ rejected: 'Rejected',
523
+ failed: 'Failed',
524
+ };
525
+ const STATUS_CLASSES = {
526
+ pending: 'bg-blue-100 text-blue-800',
527
+ in_review: 'bg-amber-100 text-amber-800',
528
+ verified: 'bg-green-100 text-green-800',
529
+ rejected: 'bg-red-100 text-red-800',
530
+ failed: 'bg-gray-200 text-gray-700',
531
+ };
532
+ const REASON_LABELS = {
533
+ not_found: 'Not found in the registry',
534
+ inactive: 'Not trading',
535
+ name_mismatch: 'Business name mismatch',
536
+ director_mismatch: 'Not a listed director',
537
+ provider_unavailable: 'Provider unavailable',
538
+ awaiting_provider: 'Waiting on the provider',
539
+ awaiting_review: 'Waiting on a reviewer',
540
+ documents_required: 'Documents requested',
541
+ rejected_by_provider: 'Rejected by the provider',
542
+ rejected_by_reviewer: 'Rejected by a reviewer',
543
+ abandoned: 'Applicant abandoned the provider flow',
544
+ };
545
+ const RELATIONSHIP_LABELS = {
546
+ registry_director_match: 'Director on the registry',
547
+ authorized_representative_declared: 'Declared representative',
548
+ provider_verified: 'Verified by the provider',
549
+ };
550
+
551
+ class BusinessesPageComponent {
552
+ service = inject(VerificationService);
553
+ toast = inject(ToastService);
554
+ router = inject(Router);
555
+ statuses = Object.keys(STATUS_LABELS);
556
+ loading = signal(true, ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
557
+ items = signal([], ...(ngDevMode ? [{ debugName: "items" }] : /* istanbul ignore next */ []));
558
+ total = signal(0, ...(ngDevMode ? [{ debugName: "total" }] : /* istanbul ignore next */ []));
559
+ page = signal(1, ...(ngDevMode ? [{ debugName: "page" }] : /* istanbul ignore next */ []));
560
+ pageSize = signal(25, ...(ngDevMode ? [{ debugName: "pageSize" }] : /* istanbul ignore next */ []));
561
+ totalPages = computed(() => Math.max(1, Math.ceil(this.total() / this.pageSize())), ...(ngDevMode ? [{ debugName: "totalPages" }] : /* istanbul ignore next */ []));
562
+ status = '';
563
+ search = '';
564
+ ngOnInit() {
565
+ this.load();
566
+ }
567
+ statusLabel(status) {
568
+ return STATUS_LABELS[status] ?? status;
569
+ }
570
+ badge(status) {
571
+ return STATUS_CLASSES[status] ?? 'bg-gray-100 text-gray-700';
572
+ }
573
+ reason(reason) {
574
+ return REASON_LABELS[reason] ?? reason;
575
+ }
576
+ reload() {
577
+ this.page.set(1);
578
+ this.load();
579
+ }
580
+ goTo(page) {
581
+ this.page.set(page);
582
+ this.load();
583
+ }
584
+ resize(size) {
585
+ this.pageSize.set(size);
586
+ this.reload();
587
+ }
588
+ open(business) {
589
+ this.router.navigate(['/verification/businesses', business.id]);
590
+ }
591
+ load() {
592
+ this.loading.set(true);
593
+ this.service.listBusinesses(this.status, this.search.trim(), this.page(), this.pageSize()).subscribe({
594
+ next: (list) => {
595
+ this.items.set(list.items);
596
+ this.total.set(list.totalCount);
597
+ this.loading.set(false);
598
+ },
599
+ error: (err) => {
600
+ this.loading.set(false);
601
+ this.toast.error(extractErrorMessage(err, 'Failed to load businesses'));
602
+ },
603
+ });
604
+ }
605
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: BusinessesPageComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
606
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.25", type: BusinessesPageComponent, isStandalone: true, selector: "verification-businesses-page", ngImport: i0, template: `
607
+ <div class="space-y-6">
608
+ <div class="flex flex-wrap items-end justify-between gap-3">
609
+ <div>
610
+ <h1 class="text-2xl font-semibold text-gray-900">Verified businesses</h1>
611
+ <p class="text-sm text-gray-500">Businesses checked by the Business Verification journey step, one per registration number, with the people acting for them.</p>
612
+ </div>
613
+ <div class="flex flex-wrap items-center gap-2">
614
+ <input type="search" [(ngModel)]="search" (keyup.enter)="reload()" name="search" placeholder="Name or registration no." class="w-56" aria-label="Search businesses" />
615
+ <label for="status-filter" class="text-sm text-gray-600">Status</label>
616
+ <select id="status-filter" [(ngModel)]="status" (ngModelChange)="reload()" name="status">
617
+ <option value="">All</option>
618
+ @for (s of statuses; track s) {
619
+ <option [value]="s">{{ statusLabel(s) }}</option>
620
+ }
621
+ </select>
622
+ </div>
623
+ </div>
624
+
625
+ @if (loading()) {
626
+ <div class="flex items-center justify-center py-12">
627
+ <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
628
+ </div>
629
+ } @else if (items().length === 0) {
630
+ <p class="text-sm text-gray-500 py-12 text-center">No businesses{{ status || search ? ' match' : ' yet' }}.</p>
631
+ } @else {
632
+ <div class="overflow-x-auto rounded-lg border border-gray-200">
633
+ <table class="min-w-full divide-y divide-gray-200 text-sm">
634
+ <thead class="bg-gray-50 text-xs uppercase text-gray-500">
635
+ <tr>
636
+ <th class="px-4 py-2 text-left">Business</th>
637
+ <th class="px-4 py-2 text-left">Registration no.</th>
638
+ <th class="px-4 py-2 text-left">Country</th>
639
+ <th class="px-4 py-2 text-left">Applicants</th>
640
+ <th class="px-4 py-2 text-left">Registry checked</th>
641
+ <th class="px-4 py-2 text-left">Status</th>
642
+ <th class="px-4 py-2 text-left">Updated</th>
643
+ </tr>
644
+ </thead>
645
+ <tbody class="divide-y divide-gray-100 bg-white">
646
+ @for (b of items(); track b.id) {
647
+ <tr class="cursor-pointer hover:bg-gray-50" (click)="open(b)">
648
+ <td class="px-4 py-2 font-medium text-gray-900">{{ b.registeredName || '—' }}</td>
649
+ <td class="px-4 py-2 font-mono text-xs">{{ b.registrationNumber }}</td>
650
+ <td class="px-4 py-2">{{ b.country }}</td>
651
+ <td class="px-4 py-2">{{ b.applicantCount }}</td>
652
+ <td class="px-4 py-2 text-gray-600">{{ b.registryCheckedAt ? (b.registryCheckedAt | localeDate: 'medium') : '—' }}</td>
653
+ <td class="px-4 py-2">
654
+ <span class="px-2 py-0.5 rounded-full text-xs font-medium" [ngClass]="badge(b.status)">{{ statusLabel(b.status) }}</span>
655
+ @if (b.statusReason) { <span class="ml-1 text-xs text-gray-500">{{ reason(b.statusReason) }}</span> }
656
+ </td>
657
+ <td class="px-4 py-2 text-gray-600">{{ b.updatedAt | localeDate: 'medium' }}</td>
658
+ </tr>
659
+ }
660
+ </tbody>
661
+ </table>
662
+ </div>
663
+ <ui-pagination [page]="page()" [pageSize]="pageSize()" [totalCount]="total()" [totalPages]="totalPages()"
664
+ (pageChange)="goTo($event)" (pageSizeChange)="resize($event)" />
665
+ }
666
+ </div>
667
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1$1.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.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: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: PaginationComponent, selector: "ui-pagination", inputs: ["page", "pageSize", "totalCount", "totalPages", "pageSizeOptions", "showPageSize"], outputs: ["pageChange", "pageSizeChange"] }, { kind: "pipe", type: LocaleDatePipe, name: "localeDate" }] });
668
+ }
669
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: BusinessesPageComponent, decorators: [{
670
+ type: Component,
671
+ args: [{
672
+ selector: 'verification-businesses-page',
673
+ standalone: true,
674
+ imports: [CommonModule, FormsModule, LocaleDatePipe, PaginationComponent],
675
+ template: `
676
+ <div class="space-y-6">
677
+ <div class="flex flex-wrap items-end justify-between gap-3">
678
+ <div>
679
+ <h1 class="text-2xl font-semibold text-gray-900">Verified businesses</h1>
680
+ <p class="text-sm text-gray-500">Businesses checked by the Business Verification journey step, one per registration number, with the people acting for them.</p>
681
+ </div>
682
+ <div class="flex flex-wrap items-center gap-2">
683
+ <input type="search" [(ngModel)]="search" (keyup.enter)="reload()" name="search" placeholder="Name or registration no." class="w-56" aria-label="Search businesses" />
684
+ <label for="status-filter" class="text-sm text-gray-600">Status</label>
685
+ <select id="status-filter" [(ngModel)]="status" (ngModelChange)="reload()" name="status">
686
+ <option value="">All</option>
687
+ @for (s of statuses; track s) {
688
+ <option [value]="s">{{ statusLabel(s) }}</option>
689
+ }
690
+ </select>
691
+ </div>
692
+ </div>
693
+
694
+ @if (loading()) {
695
+ <div class="flex items-center justify-center py-12">
696
+ <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
697
+ </div>
698
+ } @else if (items().length === 0) {
699
+ <p class="text-sm text-gray-500 py-12 text-center">No businesses{{ status || search ? ' match' : ' yet' }}.</p>
700
+ } @else {
701
+ <div class="overflow-x-auto rounded-lg border border-gray-200">
702
+ <table class="min-w-full divide-y divide-gray-200 text-sm">
703
+ <thead class="bg-gray-50 text-xs uppercase text-gray-500">
704
+ <tr>
705
+ <th class="px-4 py-2 text-left">Business</th>
706
+ <th class="px-4 py-2 text-left">Registration no.</th>
707
+ <th class="px-4 py-2 text-left">Country</th>
708
+ <th class="px-4 py-2 text-left">Applicants</th>
709
+ <th class="px-4 py-2 text-left">Registry checked</th>
710
+ <th class="px-4 py-2 text-left">Status</th>
711
+ <th class="px-4 py-2 text-left">Updated</th>
712
+ </tr>
713
+ </thead>
714
+ <tbody class="divide-y divide-gray-100 bg-white">
715
+ @for (b of items(); track b.id) {
716
+ <tr class="cursor-pointer hover:bg-gray-50" (click)="open(b)">
717
+ <td class="px-4 py-2 font-medium text-gray-900">{{ b.registeredName || '—' }}</td>
718
+ <td class="px-4 py-2 font-mono text-xs">{{ b.registrationNumber }}</td>
719
+ <td class="px-4 py-2">{{ b.country }}</td>
720
+ <td class="px-4 py-2">{{ b.applicantCount }}</td>
721
+ <td class="px-4 py-2 text-gray-600">{{ b.registryCheckedAt ? (b.registryCheckedAt | localeDate: 'medium') : '—' }}</td>
722
+ <td class="px-4 py-2">
723
+ <span class="px-2 py-0.5 rounded-full text-xs font-medium" [ngClass]="badge(b.status)">{{ statusLabel(b.status) }}</span>
724
+ @if (b.statusReason) { <span class="ml-1 text-xs text-gray-500">{{ reason(b.statusReason) }}</span> }
725
+ </td>
726
+ <td class="px-4 py-2 text-gray-600">{{ b.updatedAt | localeDate: 'medium' }}</td>
727
+ </tr>
728
+ }
729
+ </tbody>
730
+ </table>
731
+ </div>
732
+ <ui-pagination [page]="page()" [pageSize]="pageSize()" [totalCount]="total()" [totalPages]="totalPages()"
733
+ (pageChange)="goTo($event)" (pageSizeChange)="resize($event)" />
734
+ }
735
+ </div>
736
+ `,
737
+ }]
738
+ }] });
739
+
740
+ var businessesPage_component = /*#__PURE__*/Object.freeze({
741
+ __proto__: null,
742
+ BusinessesPageComponent: BusinessesPageComponent
743
+ });
744
+
745
+ const CHECK_KIND_LABELS = {
746
+ lookup: 'Registry lookup',
747
+ reuse: 'Reused registry result',
748
+ hosted_session: 'Hosted verification',
749
+ review: 'Review',
750
+ };
751
+ class BusinessDetailPageComponent {
752
+ service = inject(VerificationService);
753
+ toast = inject(ToastService);
754
+ permissions = inject(PermissionsService);
755
+ route = inject(ActivatedRoute);
756
+ loading = signal(true, ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
757
+ business = signal(null, ...(ngDevMode ? [{ debugName: "business" }] : /* istanbul ignore next */ []));
758
+ pending = signal(null, ...(ngDevMode ? [{ debugName: "pending" }] : /* istanbul ignore next */ []));
759
+ reviewing = signal(false, ...(ngDevMode ? [{ debugName: "reviewing" }] : /* istanbul ignore next */ []));
760
+ refreshing = signal(null, ...(ngDevMode ? [{ debugName: "refreshing" }] : /* istanbul ignore next */ []));
761
+ downloading = signal(null, ...(ngDevMode ? [{ debugName: "downloading" }] : /* istanbul ignore next */ []));
762
+ canReview = computed(() => this.permissions.hasPermission('business_verification.review'), ...(ngDevMode ? [{ debugName: "canReview" }] : /* istanbul ignore next */ []));
763
+ dialogTitle = computed(() => {
764
+ const p = this.pending();
765
+ if (!p)
766
+ return '';
767
+ const what = p.target === 'business' ? 'business' : 'applicant';
768
+ return p.decision === 'approve' ? `Approve ${what}` : `Reject ${what}`;
769
+ }, ...(ngDevMode ? [{ debugName: "dialogTitle" }] : /* istanbul ignore next */ []));
770
+ dialogMessage = computed(() => {
771
+ const p = this.pending();
772
+ if (!p)
773
+ return '';
774
+ if (p.target === 'business') {
775
+ return p.decision === 'approve'
776
+ ? 'Mark the business as verified. People waiting on it are verified when the step asked for nothing more, otherwise they move to review.'
777
+ : 'Reject the business. Everyone waiting on it is rejected; webhooks and workflows receive the decision.';
778
+ }
779
+ return p.decision === 'approve'
780
+ ? 'Let this person act for the business. Webhooks and workflows receive the decision and the applicant is emailed if they asked.'
781
+ : 'Refuse this person. Webhooks and workflows receive the decision and the applicant is emailed if they asked.';
782
+ }, ...(ngDevMode ? [{ debugName: "dialogMessage" }] : /* istanbul ignore next */ []));
783
+ ngOnInit() {
784
+ this.load();
785
+ }
786
+ statusLabel(status) {
787
+ return STATUS_LABELS[status] ?? status;
788
+ }
789
+ badge(status) {
790
+ return STATUS_CLASSES[status] ?? 'bg-gray-100 text-gray-700';
791
+ }
792
+ reason(reason) {
793
+ return REASON_LABELS[reason] ?? reason;
794
+ }
795
+ relationship(relationship) {
796
+ return RELATIONSHIP_LABELS[relationship] ?? relationship;
797
+ }
798
+ kind(kind) {
799
+ return CHECK_KIND_LABELS[kind] ?? kind;
800
+ }
801
+ match(value) {
802
+ if (value === true)
803
+ return 'Matched';
804
+ if (value === false)
805
+ return 'Did not match';
806
+ return 'Not checked';
807
+ }
808
+ size(bytes) {
809
+ return bytes >= 1024 * 1024 ? `${(bytes / (1024 * 1024)).toFixed(1)} MB` : `${Math.max(1, Math.round(bytes / 1024))} KB`;
810
+ }
811
+ ask(target, id, decision) {
812
+ this.pending.set({ target, id, decision });
813
+ }
814
+ review(note) {
815
+ const p = this.pending();
816
+ if (!p)
817
+ return;
818
+ this.reviewing.set(true);
819
+ const done = (message) => {
820
+ this.reviewing.set(false);
821
+ this.pending.set(null);
822
+ this.toast.success(message);
823
+ this.load();
824
+ };
825
+ const failed = (err) => {
826
+ this.reviewing.set(false);
827
+ this.pending.set(null);
828
+ this.toast.error(extractErrorMessage(err, 'Failed to record the review'));
829
+ if (err instanceof HttpErrorResponse && err.status === 409)
830
+ this.load();
831
+ };
832
+ const text = note.trim() || undefined;
833
+ if (p.target === 'business') {
834
+ this.service.reviewBusiness(p.id, p.decision, text).subscribe({
835
+ next: () => done(p.decision === 'approve' ? 'Business verified' : 'Business rejected'),
836
+ error: failed,
837
+ });
838
+ }
839
+ else {
840
+ this.service.reviewApplicant(p.id, p.decision, text).subscribe({
841
+ next: () => done(p.decision === 'approve' ? 'Applicant approved' : 'Applicant rejected'),
842
+ error: failed,
843
+ });
844
+ }
845
+ }
846
+ refresh(applicant) {
847
+ this.refreshing.set(applicant.id);
848
+ this.service.refreshApplicant(applicant.id).subscribe({
849
+ next: (updated) => {
850
+ this.refreshing.set(null);
851
+ this.toast.success(updated.status === applicant.status ? 'The provider has no decision yet' : 'Updated from the provider');
852
+ this.load();
853
+ },
854
+ error: (err) => {
855
+ this.refreshing.set(null);
856
+ this.toast.error(extractErrorMessage(err, 'Failed to ask the provider'));
857
+ },
858
+ });
859
+ }
860
+ openDocument(applicant, document) {
861
+ this.downloading.set(document.id);
862
+ this.service.downloadDocument(applicant.id, document.id).subscribe({
863
+ next: (blob) => {
864
+ this.downloading.set(null);
865
+ openFetchedFile(blob, document.fileName);
866
+ },
867
+ error: (err) => {
868
+ this.downloading.set(null);
869
+ this.toast.error(extractErrorMessage(err, 'Failed to open the document'));
870
+ },
871
+ });
872
+ }
873
+ load() {
874
+ const id = this.route.snapshot.paramMap.get('id');
875
+ if (!id)
876
+ return;
877
+ this.loading.set(this.business() === null);
878
+ this.service.getBusiness(id).subscribe({
879
+ next: (business) => {
880
+ this.business.set(business);
881
+ this.loading.set(false);
882
+ },
883
+ error: (err) => {
884
+ this.loading.set(false);
885
+ this.toast.error(extractErrorMessage(err, 'Failed to load the business'));
886
+ },
887
+ });
888
+ }
889
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: BusinessDetailPageComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
890
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.25", type: BusinessDetailPageComponent, isStandalone: true, selector: "verification-business-detail-page", ngImport: i0, template: `
891
+ <div class="space-y-6">
892
+ <a routerLink="/verification/businesses" class="text-sm text-primary-700 hover:underline">← Verified businesses</a>
893
+
894
+ @if (loading()) {
895
+ <div class="flex items-center justify-center py-12">
896
+ <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
897
+ </div>
898
+ } @else if (business(); as b) {
899
+ <div class="flex flex-wrap items-start justify-between gap-3">
900
+ <div>
901
+ <h1 class="text-2xl font-semibold text-gray-900">{{ b.registeredName || b.registrationNumber }}</h1>
902
+ <p class="text-sm text-gray-500 font-mono">{{ b.country }} · {{ b.registrationNumber }}</p>
903
+ </div>
904
+ <div class="flex items-center gap-2">
905
+ <span class="px-2 py-0.5 rounded-full text-xs font-medium" [ngClass]="badge(b.status)">{{ statusLabel(b.status) }}</span>
906
+ @if (b.statusReason) { <span class="text-xs text-gray-500">{{ reason(b.statusReason) }}</span> }
907
+ </div>
908
+ </div>
909
+
910
+ @if (b.statusMessage) {
911
+ <div class="bg-gray-50 border border-gray-200 rounded p-3 text-xs text-gray-700">{{ b.statusMessage }}</div>
912
+ }
913
+
914
+ @if (canReview() && (b.status === 'pending' || b.status === 'in_review')) {
915
+ <div class="rounded-lg border border-amber-200 bg-amber-50 p-4 flex flex-wrap items-center justify-between gap-3">
916
+ <p class="text-sm text-amber-900">This business has no registry decision yet. You can decide it yourself; people waiting on it follow your decision.</p>
917
+ <div class="flex gap-2">
918
+ <ui-button size="sm" (click)="ask('business', b.id, 'approve')">Verify business</ui-button>
919
+ <ui-button size="sm" variant="danger" (click)="ask('business', b.id, 'reject')">Reject business</ui-button>
920
+ </div>
921
+ </div>
922
+ }
923
+
924
+ <section class="rounded-lg border border-gray-200 p-4">
925
+ <h2 class="text-sm font-semibold text-gray-900 mb-3">Registry record</h2>
926
+ <dl class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-3 text-sm">
927
+ <div><dt class="text-xs text-gray-500">Registered name</dt><dd>{{ b.registeredName || '—' }}</dd></div>
928
+ <div><dt class="text-xs text-gray-500">Registration type</dt><dd>{{ b.registrationType || '—' }}</dd></div>
929
+ <div><dt class="text-xs text-gray-500">Entity type</dt><dd>{{ b.entityType || '—' }}</dd></div>
930
+ <div><dt class="text-xs text-gray-500">Registered on</dt><dd>{{ b.registrationDate || '—' }}</dd></div>
931
+ <div><dt class="text-xs text-gray-500">Registry status</dt><dd>{{ b.rawStatus || 'Not reported' }} ({{ b.activityStatus }})</dd></div>
932
+ <div><dt class="text-xs text-gray-500">Provider</dt><dd>{{ b.registryProviderId || '—' }}{{ b.registryEnvironment ? ' · ' + b.registryEnvironment : '' }}</dd></div>
933
+ <div class="sm:col-span-2 lg:col-span-3"><dt class="text-xs text-gray-500">Address</dt><dd>{{ b.registeredAddress || '—' }}</dd></div>
934
+ <div><dt class="text-xs text-gray-500">Snapshot taken</dt><dd>{{ b.registryCheckedAt ? (b.registryCheckedAt | localeDate: 'medium') : 'No snapshot yet' }}</dd></div>
935
+ <div><dt class="text-xs text-gray-500">Decided</dt><dd>{{ b.decidedAt ? (b.decidedAt | localeDate: 'medium') : '—' }}</dd></div>
936
+ @if (b.reviewedAt) {
937
+ <div><dt class="text-xs text-gray-500">Reviewed</dt><dd>{{ b.reviewedAt | localeDate: 'medium' }}{{ b.reviewNote ? ' — ' + b.reviewNote : '' }}</dd></div>
938
+ }
939
+ </dl>
940
+ <div class="mt-4">
941
+ <p class="text-xs font-medium text-gray-500 mb-1">Directors and other listed people</p>
942
+ @if (b.people.length === 0) {
943
+ <p class="text-xs text-gray-500">None returned.</p>
944
+ } @else {
945
+ <ul class="text-sm divide-y divide-gray-100 border border-gray-200 rounded">
946
+ @for (p of b.people; track $index) {
947
+ <li class="px-3 py-1.5 flex justify-between gap-3"><span>{{ p.name }}</span><span class="text-gray-500">{{ p.role || '—' }}</span></li>
948
+ }
949
+ </ul>
950
+ }
951
+ </div>
952
+ </section>
953
+
954
+ <section class="rounded-lg border border-gray-200 p-4 space-y-4">
955
+ <h2 class="text-sm font-semibold text-gray-900">People acting for this business</h2>
956
+ @for (a of b.applicants; track a.id) {
957
+ <div class="rounded-md border border-gray-100 p-3 space-y-2">
958
+ <div class="flex flex-wrap items-center justify-between gap-2">
959
+ <div>
960
+ <p class="text-sm font-medium text-gray-900">{{ a.subjectName || a.subjectId || 'Before sign-up' }}</p>
961
+ <p class="text-xs text-gray-500">{{ relationship(a.relationship) }} · evidence required: {{ a.evidenceRequirement }}</p>
962
+ </div>
963
+ <div class="flex items-center gap-2">
964
+ <span class="px-2 py-0.5 rounded-full text-xs font-medium" [ngClass]="badge(a.status)">{{ statusLabel(a.status) }}</span>
965
+ @if (a.statusReason) { <span class="text-xs text-gray-500">{{ reason(a.statusReason) }}</span> }
966
+ </div>
967
+ </div>
968
+ <dl class="grid grid-cols-1 sm:grid-cols-3 gap-x-6 gap-y-2 text-xs">
969
+ <div><dt class="text-gray-500">Name entered</dt><dd class="text-gray-900">{{ a.providedBusinessName || '—' }}</dd></div>
970
+ <div><dt class="text-gray-500">Name match</dt><dd class="text-gray-900">{{ match(a.nameMatched) }}</dd></div>
971
+ <div><dt class="text-gray-500">Director match</dt><dd class="text-gray-900">{{ match(a.directorMatched) }}{{ a.matchedPersonName ? ' (' + a.matchedPersonName + (a.matchedPersonRole ? ', ' + a.matchedPersonRole : '') + ')' : '' }}</dd></div>
972
+ <div><dt class="text-gray-500">Submitted</dt><dd class="text-gray-900">{{ a.createdAt | localeDate: 'medium' }}</dd></div>
973
+ <div><dt class="text-gray-500">Decided</dt><dd class="text-gray-900">{{ a.decidedAt ? (a.decidedAt | localeDate: 'medium') : '—' }}</dd></div>
974
+ <div><dt class="text-gray-500">Outcome email</dt><dd class="text-gray-900">{{ a.outcomeEmailRequested ? 'Requested' : '—' }}</dd></div>
975
+ @if (a.reviewedAt) {
976
+ <div class="sm:col-span-3"><dt class="text-gray-500">Reviewed</dt><dd class="text-gray-900">{{ a.reviewedAt | localeDate: 'medium' }}{{ a.reviewNote ? ' — ' + a.reviewNote : '' }}</dd></div>
977
+ }
978
+ </dl>
979
+ @if (a.documents.length > 0) {
980
+ <div>
981
+ <p class="text-xs font-medium text-gray-500 mb-1">Documents</p>
982
+ <ul class="space-y-1">
983
+ @for (d of a.documents; track d.id) {
984
+ <li class="flex items-center justify-between gap-2 text-sm">
985
+ <span class="truncate">{{ d.fileName }} <span class="text-xs text-gray-500">{{ size(d.sizeBytes) }} · {{ d.createdAt | localeDate: 'medium' }}</span></span>
986
+ <ui-button size="xs" variant="secondary" (click)="openDocument(a, d)" [disabled]="downloading() === d.id">Open</ui-button>
987
+ </li>
988
+ }
989
+ </ul>
990
+ </div>
991
+ }
992
+ @if (canReview() && (a.status === 'pending' || a.status === 'in_review')) {
993
+ <div class="flex flex-wrap gap-2 pt-1">
994
+ @if (b.status === 'verified') {
995
+ <ui-button size="sm" (click)="ask('applicant', a.id, 'approve')">Approve applicant</ui-button>
996
+ }
997
+ <ui-button size="sm" variant="danger" (click)="ask('applicant', a.id, 'reject')">Reject applicant</ui-button>
998
+ @if (a.statusReason === 'awaiting_provider') {
999
+ <ui-button size="sm" variant="secondary" (click)="refresh(a)" [disabled]="refreshing() === a.id">Ask the provider again</ui-button>
1000
+ }
1001
+ </div>
1002
+ }
1003
+ </div>
1004
+ } @empty {
1005
+ <p class="text-xs text-gray-500">Nobody yet.</p>
1006
+ }
1007
+ </section>
1008
+
1009
+ <section class="rounded-lg border border-gray-200 p-4">
1010
+ <h2 class="text-sm font-semibold text-gray-900 mb-3">Checks</h2>
1011
+ <div class="overflow-x-auto">
1012
+ <table class="min-w-full divide-y divide-gray-200 text-xs">
1013
+ <thead class="bg-gray-50 uppercase text-gray-500">
1014
+ <tr>
1015
+ <th class="px-3 py-2 text-left">When</th>
1016
+ <th class="px-3 py-2 text-left">Check</th>
1017
+ <th class="px-3 py-2 text-left">Outcome</th>
1018
+ <th class="px-3 py-2 text-left">Provider</th>
1019
+ <th class="px-3 py-2 text-left">Reference</th>
1020
+ <th class="px-3 py-2 text-left">By</th>
1021
+ <th class="px-3 py-2 text-left">Detail</th>
1022
+ </tr>
1023
+ </thead>
1024
+ <tbody class="divide-y divide-gray-100">
1025
+ @for (c of b.checks; track c.id) {
1026
+ <tr>
1027
+ <td class="px-3 py-2 whitespace-nowrap">{{ c.createdAt | localeDate: 'medium' }}</td>
1028
+ <td class="px-3 py-2">{{ kind(c.kind) }}</td>
1029
+ <td class="px-3 py-2">{{ c.outcome }}</td>
1030
+ <td class="px-3 py-2">{{ c.providerId || '—' }}{{ c.environment ? ' · ' + c.environment : '' }}</td>
1031
+ <td class="px-3 py-2 font-mono break-all">{{ c.sessionReference || c.providerReference || '—' }}</td>
1032
+ <td class="px-3 py-2">{{ c.originKind || '—' }}{{ c.originName ? ': ' + c.originName : '' }}</td>
1033
+ <td class="px-3 py-2 text-gray-600">{{ c.message || '' }}</td>
1034
+ </tr>
1035
+ }
1036
+ </tbody>
1037
+ </table>
1038
+ </div>
1039
+ </section>
1040
+ }
1041
+ </div>
1042
+
1043
+ <ui-prompt-dialog
1044
+ [open]="!!pending()"
1045
+ [title]="dialogTitle()"
1046
+ [message]="dialogMessage()"
1047
+ label="Note (optional)"
1048
+ inputType="textarea"
1049
+ [maxLength]="1000"
1050
+ [confirmText]="pending()?.decision === 'approve' ? 'Approve' : 'Reject'"
1051
+ [loading]="reviewing()"
1052
+ (confirmed)="review($event)"
1053
+ (cancelled)="pending.set(null)" />
1054
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1$1.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "routerLink"] }, { kind: "component", type: ButtonComponent, selector: "ui-button", inputs: ["variant", "size", "disabled", "loading", "type", "fullWidth"], outputs: ["clicked"] }, { kind: "component", type: PromptDialogComponent, selector: "ui-prompt-dialog", inputs: ["open", "title", "message", "label", "placeholder", "initialValue", "inputType", "required", "maxLength", "validate", "confirmText", "cancelText", "loading", "closeOnBackdrop", "closeOnEscape"], outputs: ["confirmed", "cancelled"] }, { kind: "pipe", type: LocaleDatePipe, name: "localeDate" }] });
1055
+ }
1056
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: BusinessDetailPageComponent, decorators: [{
1057
+ type: Component,
1058
+ args: [{
1059
+ selector: 'verification-business-detail-page',
1060
+ standalone: true,
1061
+ imports: [CommonModule, RouterLink, ButtonComponent, LocaleDatePipe, PromptDialogComponent],
1062
+ template: `
1063
+ <div class="space-y-6">
1064
+ <a routerLink="/verification/businesses" class="text-sm text-primary-700 hover:underline">← Verified businesses</a>
1065
+
1066
+ @if (loading()) {
1067
+ <div class="flex items-center justify-center py-12">
1068
+ <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
1069
+ </div>
1070
+ } @else if (business(); as b) {
1071
+ <div class="flex flex-wrap items-start justify-between gap-3">
1072
+ <div>
1073
+ <h1 class="text-2xl font-semibold text-gray-900">{{ b.registeredName || b.registrationNumber }}</h1>
1074
+ <p class="text-sm text-gray-500 font-mono">{{ b.country }} · {{ b.registrationNumber }}</p>
1075
+ </div>
1076
+ <div class="flex items-center gap-2">
1077
+ <span class="px-2 py-0.5 rounded-full text-xs font-medium" [ngClass]="badge(b.status)">{{ statusLabel(b.status) }}</span>
1078
+ @if (b.statusReason) { <span class="text-xs text-gray-500">{{ reason(b.statusReason) }}</span> }
1079
+ </div>
1080
+ </div>
1081
+
1082
+ @if (b.statusMessage) {
1083
+ <div class="bg-gray-50 border border-gray-200 rounded p-3 text-xs text-gray-700">{{ b.statusMessage }}</div>
1084
+ }
1085
+
1086
+ @if (canReview() && (b.status === 'pending' || b.status === 'in_review')) {
1087
+ <div class="rounded-lg border border-amber-200 bg-amber-50 p-4 flex flex-wrap items-center justify-between gap-3">
1088
+ <p class="text-sm text-amber-900">This business has no registry decision yet. You can decide it yourself; people waiting on it follow your decision.</p>
1089
+ <div class="flex gap-2">
1090
+ <ui-button size="sm" (click)="ask('business', b.id, 'approve')">Verify business</ui-button>
1091
+ <ui-button size="sm" variant="danger" (click)="ask('business', b.id, 'reject')">Reject business</ui-button>
1092
+ </div>
1093
+ </div>
1094
+ }
1095
+
1096
+ <section class="rounded-lg border border-gray-200 p-4">
1097
+ <h2 class="text-sm font-semibold text-gray-900 mb-3">Registry record</h2>
1098
+ <dl class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-3 text-sm">
1099
+ <div><dt class="text-xs text-gray-500">Registered name</dt><dd>{{ b.registeredName || '—' }}</dd></div>
1100
+ <div><dt class="text-xs text-gray-500">Registration type</dt><dd>{{ b.registrationType || '—' }}</dd></div>
1101
+ <div><dt class="text-xs text-gray-500">Entity type</dt><dd>{{ b.entityType || '—' }}</dd></div>
1102
+ <div><dt class="text-xs text-gray-500">Registered on</dt><dd>{{ b.registrationDate || '—' }}</dd></div>
1103
+ <div><dt class="text-xs text-gray-500">Registry status</dt><dd>{{ b.rawStatus || 'Not reported' }} ({{ b.activityStatus }})</dd></div>
1104
+ <div><dt class="text-xs text-gray-500">Provider</dt><dd>{{ b.registryProviderId || '—' }}{{ b.registryEnvironment ? ' · ' + b.registryEnvironment : '' }}</dd></div>
1105
+ <div class="sm:col-span-2 lg:col-span-3"><dt class="text-xs text-gray-500">Address</dt><dd>{{ b.registeredAddress || '—' }}</dd></div>
1106
+ <div><dt class="text-xs text-gray-500">Snapshot taken</dt><dd>{{ b.registryCheckedAt ? (b.registryCheckedAt | localeDate: 'medium') : 'No snapshot yet' }}</dd></div>
1107
+ <div><dt class="text-xs text-gray-500">Decided</dt><dd>{{ b.decidedAt ? (b.decidedAt | localeDate: 'medium') : '—' }}</dd></div>
1108
+ @if (b.reviewedAt) {
1109
+ <div><dt class="text-xs text-gray-500">Reviewed</dt><dd>{{ b.reviewedAt | localeDate: 'medium' }}{{ b.reviewNote ? ' — ' + b.reviewNote : '' }}</dd></div>
1110
+ }
1111
+ </dl>
1112
+ <div class="mt-4">
1113
+ <p class="text-xs font-medium text-gray-500 mb-1">Directors and other listed people</p>
1114
+ @if (b.people.length === 0) {
1115
+ <p class="text-xs text-gray-500">None returned.</p>
1116
+ } @else {
1117
+ <ul class="text-sm divide-y divide-gray-100 border border-gray-200 rounded">
1118
+ @for (p of b.people; track $index) {
1119
+ <li class="px-3 py-1.5 flex justify-between gap-3"><span>{{ p.name }}</span><span class="text-gray-500">{{ p.role || '—' }}</span></li>
1120
+ }
1121
+ </ul>
1122
+ }
1123
+ </div>
1124
+ </section>
1125
+
1126
+ <section class="rounded-lg border border-gray-200 p-4 space-y-4">
1127
+ <h2 class="text-sm font-semibold text-gray-900">People acting for this business</h2>
1128
+ @for (a of b.applicants; track a.id) {
1129
+ <div class="rounded-md border border-gray-100 p-3 space-y-2">
1130
+ <div class="flex flex-wrap items-center justify-between gap-2">
1131
+ <div>
1132
+ <p class="text-sm font-medium text-gray-900">{{ a.subjectName || a.subjectId || 'Before sign-up' }}</p>
1133
+ <p class="text-xs text-gray-500">{{ relationship(a.relationship) }} · evidence required: {{ a.evidenceRequirement }}</p>
1134
+ </div>
1135
+ <div class="flex items-center gap-2">
1136
+ <span class="px-2 py-0.5 rounded-full text-xs font-medium" [ngClass]="badge(a.status)">{{ statusLabel(a.status) }}</span>
1137
+ @if (a.statusReason) { <span class="text-xs text-gray-500">{{ reason(a.statusReason) }}</span> }
1138
+ </div>
1139
+ </div>
1140
+ <dl class="grid grid-cols-1 sm:grid-cols-3 gap-x-6 gap-y-2 text-xs">
1141
+ <div><dt class="text-gray-500">Name entered</dt><dd class="text-gray-900">{{ a.providedBusinessName || '—' }}</dd></div>
1142
+ <div><dt class="text-gray-500">Name match</dt><dd class="text-gray-900">{{ match(a.nameMatched) }}</dd></div>
1143
+ <div><dt class="text-gray-500">Director match</dt><dd class="text-gray-900">{{ match(a.directorMatched) }}{{ a.matchedPersonName ? ' (' + a.matchedPersonName + (a.matchedPersonRole ? ', ' + a.matchedPersonRole : '') + ')' : '' }}</dd></div>
1144
+ <div><dt class="text-gray-500">Submitted</dt><dd class="text-gray-900">{{ a.createdAt | localeDate: 'medium' }}</dd></div>
1145
+ <div><dt class="text-gray-500">Decided</dt><dd class="text-gray-900">{{ a.decidedAt ? (a.decidedAt | localeDate: 'medium') : '—' }}</dd></div>
1146
+ <div><dt class="text-gray-500">Outcome email</dt><dd class="text-gray-900">{{ a.outcomeEmailRequested ? 'Requested' : '—' }}</dd></div>
1147
+ @if (a.reviewedAt) {
1148
+ <div class="sm:col-span-3"><dt class="text-gray-500">Reviewed</dt><dd class="text-gray-900">{{ a.reviewedAt | localeDate: 'medium' }}{{ a.reviewNote ? ' — ' + a.reviewNote : '' }}</dd></div>
1149
+ }
1150
+ </dl>
1151
+ @if (a.documents.length > 0) {
1152
+ <div>
1153
+ <p class="text-xs font-medium text-gray-500 mb-1">Documents</p>
1154
+ <ul class="space-y-1">
1155
+ @for (d of a.documents; track d.id) {
1156
+ <li class="flex items-center justify-between gap-2 text-sm">
1157
+ <span class="truncate">{{ d.fileName }} <span class="text-xs text-gray-500">{{ size(d.sizeBytes) }} · {{ d.createdAt | localeDate: 'medium' }}</span></span>
1158
+ <ui-button size="xs" variant="secondary" (click)="openDocument(a, d)" [disabled]="downloading() === d.id">Open</ui-button>
1159
+ </li>
1160
+ }
1161
+ </ul>
1162
+ </div>
1163
+ }
1164
+ @if (canReview() && (a.status === 'pending' || a.status === 'in_review')) {
1165
+ <div class="flex flex-wrap gap-2 pt-1">
1166
+ @if (b.status === 'verified') {
1167
+ <ui-button size="sm" (click)="ask('applicant', a.id, 'approve')">Approve applicant</ui-button>
1168
+ }
1169
+ <ui-button size="sm" variant="danger" (click)="ask('applicant', a.id, 'reject')">Reject applicant</ui-button>
1170
+ @if (a.statusReason === 'awaiting_provider') {
1171
+ <ui-button size="sm" variant="secondary" (click)="refresh(a)" [disabled]="refreshing() === a.id">Ask the provider again</ui-button>
1172
+ }
1173
+ </div>
1174
+ }
1175
+ </div>
1176
+ } @empty {
1177
+ <p class="text-xs text-gray-500">Nobody yet.</p>
1178
+ }
1179
+ </section>
1180
+
1181
+ <section class="rounded-lg border border-gray-200 p-4">
1182
+ <h2 class="text-sm font-semibold text-gray-900 mb-3">Checks</h2>
1183
+ <div class="overflow-x-auto">
1184
+ <table class="min-w-full divide-y divide-gray-200 text-xs">
1185
+ <thead class="bg-gray-50 uppercase text-gray-500">
1186
+ <tr>
1187
+ <th class="px-3 py-2 text-left">When</th>
1188
+ <th class="px-3 py-2 text-left">Check</th>
1189
+ <th class="px-3 py-2 text-left">Outcome</th>
1190
+ <th class="px-3 py-2 text-left">Provider</th>
1191
+ <th class="px-3 py-2 text-left">Reference</th>
1192
+ <th class="px-3 py-2 text-left">By</th>
1193
+ <th class="px-3 py-2 text-left">Detail</th>
1194
+ </tr>
1195
+ </thead>
1196
+ <tbody class="divide-y divide-gray-100">
1197
+ @for (c of b.checks; track c.id) {
1198
+ <tr>
1199
+ <td class="px-3 py-2 whitespace-nowrap">{{ c.createdAt | localeDate: 'medium' }}</td>
1200
+ <td class="px-3 py-2">{{ kind(c.kind) }}</td>
1201
+ <td class="px-3 py-2">{{ c.outcome }}</td>
1202
+ <td class="px-3 py-2">{{ c.providerId || '—' }}{{ c.environment ? ' · ' + c.environment : '' }}</td>
1203
+ <td class="px-3 py-2 font-mono break-all">{{ c.sessionReference || c.providerReference || '—' }}</td>
1204
+ <td class="px-3 py-2">{{ c.originKind || '—' }}{{ c.originName ? ': ' + c.originName : '' }}</td>
1205
+ <td class="px-3 py-2 text-gray-600">{{ c.message || '' }}</td>
1206
+ </tr>
1207
+ }
1208
+ </tbody>
1209
+ </table>
1210
+ </div>
1211
+ </section>
1212
+ }
1213
+ </div>
1214
+
1215
+ <ui-prompt-dialog
1216
+ [open]="!!pending()"
1217
+ [title]="dialogTitle()"
1218
+ [message]="dialogMessage()"
1219
+ label="Note (optional)"
1220
+ inputType="textarea"
1221
+ [maxLength]="1000"
1222
+ [confirmText]="pending()?.decision === 'approve' ? 'Approve' : 'Reject'"
1223
+ [loading]="reviewing()"
1224
+ (confirmed)="review($event)"
1225
+ (cancelled)="pending.set(null)" />
1226
+ `,
1227
+ }]
1228
+ }] });
1229
+
1230
+ var businessDetailPage_component = /*#__PURE__*/Object.freeze({
1231
+ __proto__: null,
1232
+ BusinessDetailPageComponent: BusinessDetailPageComponent
1233
+ });
1234
+
1235
+ class ReviewQueuePageComponent {
1236
+ service = inject(VerificationService);
1237
+ toast = inject(ToastService);
1238
+ loading = signal(true, ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
1239
+ items = signal([], ...(ngDevMode ? [{ debugName: "items" }] : /* istanbul ignore next */ []));
1240
+ total = signal(0, ...(ngDevMode ? [{ debugName: "total" }] : /* istanbul ignore next */ []));
1241
+ page = signal(1, ...(ngDevMode ? [{ debugName: "page" }] : /* istanbul ignore next */ []));
1242
+ pageSize = signal(25, ...(ngDevMode ? [{ debugName: "pageSize" }] : /* istanbul ignore next */ []));
1243
+ totalPages = computed(() => Math.max(1, Math.ceil(this.total() / this.pageSize())), ...(ngDevMode ? [{ debugName: "totalPages" }] : /* istanbul ignore next */ []));
1244
+ ngOnInit() {
1245
+ this.load();
1246
+ }
1247
+ statusLabel(status) {
1248
+ return STATUS_LABELS[status] ?? status;
1249
+ }
1250
+ badge(status) {
1251
+ return STATUS_CLASSES[status] ?? 'bg-gray-100 text-gray-700';
1252
+ }
1253
+ reason(reason) {
1254
+ return REASON_LABELS[reason] ?? reason;
1255
+ }
1256
+ relationship(relationship) {
1257
+ return RELATIONSHIP_LABELS[relationship] ?? relationship;
1258
+ }
1259
+ goTo(page) {
1260
+ this.page.set(page);
1261
+ this.load();
1262
+ }
1263
+ resize(size) {
1264
+ this.pageSize.set(size);
1265
+ this.page.set(1);
1266
+ this.load();
1267
+ }
1268
+ load() {
1269
+ this.loading.set(true);
1270
+ this.service.reviewQueue(this.page(), this.pageSize()).subscribe({
1271
+ next: (queue) => {
1272
+ this.items.set(queue.items);
1273
+ this.total.set(queue.totalCount);
1274
+ this.loading.set(false);
1275
+ },
1276
+ error: (err) => {
1277
+ this.loading.set(false);
1278
+ this.toast.error(extractErrorMessage(err, 'Failed to load the review queue'));
1279
+ },
1280
+ });
1281
+ }
1282
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: ReviewQueuePageComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1283
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.25", type: ReviewQueuePageComponent, isStandalone: true, selector: "verification-review-queue-page", ngImport: i0, template: `
1284
+ <div class="space-y-6">
1285
+ <div>
1286
+ <h1 class="text-2xl font-semibold text-gray-900">Verification review queue</h1>
1287
+ <p class="text-sm text-gray-500">People waiting on a decision to act for a business: documents to check, a provider that couldn't answer, or a hosted check still open. Oldest first.</p>
1288
+ </div>
1289
+
1290
+ @if (loading()) {
1291
+ <div class="flex items-center justify-center py-12">
1292
+ <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
1293
+ </div>
1294
+ } @else if (items().length === 0) {
1295
+ <p class="text-sm text-gray-500 py-12 text-center">Nobody is waiting on a decision.</p>
1296
+ } @else {
1297
+ <div class="overflow-x-auto rounded-lg border border-gray-200">
1298
+ <table class="min-w-full divide-y divide-gray-200 text-sm">
1299
+ <thead class="bg-gray-50 text-xs uppercase text-gray-500">
1300
+ <tr>
1301
+ <th class="px-4 py-2 text-left">Waiting since</th>
1302
+ <th class="px-4 py-2 text-left">Business</th>
1303
+ <th class="px-4 py-2 text-left">Applicant</th>
1304
+ <th class="px-4 py-2 text-left">Grounds</th>
1305
+ <th class="px-4 py-2 text-left">Documents</th>
1306
+ <th class="px-4 py-2 text-left">Status</th>
1307
+ </tr>
1308
+ </thead>
1309
+ <tbody class="divide-y divide-gray-100 bg-white">
1310
+ @for (item of items(); track item.applicant.id) {
1311
+ <tr>
1312
+ <td class="px-4 py-2 text-gray-600">{{ item.applicant.updatedAt | localeDate: 'medium' }}</td>
1313
+ <td class="px-4 py-2">
1314
+ <a class="font-medium text-primary-700 hover:underline" [routerLink]="['/verification/businesses', item.business.id]">{{ item.business.registeredName || item.business.registrationNumber }}</a>
1315
+ <div class="text-xs text-gray-500 font-mono">{{ item.business.country }} · {{ item.business.registrationNumber }}</div>
1316
+ </td>
1317
+ <td class="px-4 py-2">{{ item.applicant.subjectName || (item.applicant.subjectId ? item.applicant.subjectId : 'Before sign-up') }}</td>
1318
+ <td class="px-4 py-2">{{ relationship(item.applicant.relationship) }}</td>
1319
+ <td class="px-4 py-2">{{ item.applicant.documents.length }}</td>
1320
+ <td class="px-4 py-2">
1321
+ <span class="px-2 py-0.5 rounded-full text-xs font-medium" [ngClass]="badge(item.applicant.status)">{{ statusLabel(item.applicant.status) }}</span>
1322
+ @if (item.applicant.statusReason) { <span class="ml-1 text-xs text-gray-500">{{ reason(item.applicant.statusReason) }}</span> }
1323
+ </td>
1324
+ </tr>
1325
+ }
1326
+ </tbody>
1327
+ </table>
1328
+ </div>
1329
+ <ui-pagination [page]="page()" [pageSize]="pageSize()" [totalCount]="total()" [totalPages]="totalPages()"
1330
+ (pageChange)="goTo($event)" (pageSizeChange)="resize($event)" />
1331
+ }
1332
+ </div>
1333
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1$1.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "routerLink"] }, { kind: "component", type: PaginationComponent, selector: "ui-pagination", inputs: ["page", "pageSize", "totalCount", "totalPages", "pageSizeOptions", "showPageSize"], outputs: ["pageChange", "pageSizeChange"] }, { kind: "pipe", type: LocaleDatePipe, name: "localeDate" }] });
1334
+ }
1335
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: ReviewQueuePageComponent, decorators: [{
1336
+ type: Component,
1337
+ args: [{
1338
+ selector: 'verification-review-queue-page',
1339
+ standalone: true,
1340
+ imports: [CommonModule, RouterLink, LocaleDatePipe, PaginationComponent],
1341
+ template: `
1342
+ <div class="space-y-6">
1343
+ <div>
1344
+ <h1 class="text-2xl font-semibold text-gray-900">Verification review queue</h1>
1345
+ <p class="text-sm text-gray-500">People waiting on a decision to act for a business: documents to check, a provider that couldn't answer, or a hosted check still open. Oldest first.</p>
1346
+ </div>
1347
+
1348
+ @if (loading()) {
1349
+ <div class="flex items-center justify-center py-12">
1350
+ <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
1351
+ </div>
1352
+ } @else if (items().length === 0) {
1353
+ <p class="text-sm text-gray-500 py-12 text-center">Nobody is waiting on a decision.</p>
1354
+ } @else {
1355
+ <div class="overflow-x-auto rounded-lg border border-gray-200">
1356
+ <table class="min-w-full divide-y divide-gray-200 text-sm">
1357
+ <thead class="bg-gray-50 text-xs uppercase text-gray-500">
1358
+ <tr>
1359
+ <th class="px-4 py-2 text-left">Waiting since</th>
1360
+ <th class="px-4 py-2 text-left">Business</th>
1361
+ <th class="px-4 py-2 text-left">Applicant</th>
1362
+ <th class="px-4 py-2 text-left">Grounds</th>
1363
+ <th class="px-4 py-2 text-left">Documents</th>
1364
+ <th class="px-4 py-2 text-left">Status</th>
1365
+ </tr>
1366
+ </thead>
1367
+ <tbody class="divide-y divide-gray-100 bg-white">
1368
+ @for (item of items(); track item.applicant.id) {
1369
+ <tr>
1370
+ <td class="px-4 py-2 text-gray-600">{{ item.applicant.updatedAt | localeDate: 'medium' }}</td>
1371
+ <td class="px-4 py-2">
1372
+ <a class="font-medium text-primary-700 hover:underline" [routerLink]="['/verification/businesses', item.business.id]">{{ item.business.registeredName || item.business.registrationNumber }}</a>
1373
+ <div class="text-xs text-gray-500 font-mono">{{ item.business.country }} · {{ item.business.registrationNumber }}</div>
1374
+ </td>
1375
+ <td class="px-4 py-2">{{ item.applicant.subjectName || (item.applicant.subjectId ? item.applicant.subjectId : 'Before sign-up') }}</td>
1376
+ <td class="px-4 py-2">{{ relationship(item.applicant.relationship) }}</td>
1377
+ <td class="px-4 py-2">{{ item.applicant.documents.length }}</td>
1378
+ <td class="px-4 py-2">
1379
+ <span class="px-2 py-0.5 rounded-full text-xs font-medium" [ngClass]="badge(item.applicant.status)">{{ statusLabel(item.applicant.status) }}</span>
1380
+ @if (item.applicant.statusReason) { <span class="ml-1 text-xs text-gray-500">{{ reason(item.applicant.statusReason) }}</span> }
1381
+ </td>
1382
+ </tr>
1383
+ }
1384
+ </tbody>
1385
+ </table>
1386
+ </div>
1387
+ <ui-pagination [page]="page()" [pageSize]="pageSize()" [totalCount]="total()" [totalPages]="totalPages()"
1388
+ (pageChange)="goTo($event)" (pageSizeChange)="resize($event)" />
1389
+ }
1390
+ </div>
1391
+ `,
1392
+ }]
1393
+ }] });
1394
+
1395
+ var reviewQueuePage_component = /*#__PURE__*/Object.freeze({
1396
+ __proto__: null,
1397
+ ReviewQueuePageComponent: ReviewQueuePageComponent
1398
+ });
1399
+
1400
+ // Business (KYB) verification plugin
1401
+
1402
+ /**
1403
+ * Generated bundle index. Do not edit.
1404
+ */
1405
+
1406
+ export { BusinessDetailPageComponent, BusinessesPageComponent, REASON_LABELS, RELATIONSHIP_LABELS, ReviewQueuePageComponent, STATUS_CLASSES, STATUS_LABELS, VERIFICATION_PLUGIN, VERIFICATION_ROUTES, VerificationService, VerificationSettingsTabComponent };
1407
+ //# sourceMappingURL=syndew-oluso-plugin-verification.mjs.map