Client-stepper: pre-fill existing KYC/address proofs in edit, fix identifier saves
Edit-mode now shows the previously selected KYC document type + uploaded file thumbnail
(loads from /clients/{id}/identifiers + /client_identifiers/{id}/documents). Same for
address proof on the India address step. Closes the gap where editing a client showed
an empty file picker even when proof had been uploaded on create.
Identifier-save hardened across all three callsites (create-client, edit-client,
identities-tab.createIdentifierWithDocs) - the KYC chip picker proofId is a CODE
string ('AADHAAR'), but backend ClientIdentifierRequest.documentTypeId is Long, which
Spring rejects with HttpMessageNotReadableException -> 400. resolveDocumentTypeId
helper looks up the code against /kyc/proof-types?type=id and substitutes the numeric
CodeValue id before posting. Skips silently for constitution-specific non-individual
docs that have no CodeValue mapping.
KYC payload normalises panNumber / enterprisePan / gstinNumber / cinNumber via
toUpperCase + whitespace strip so user-typed lowercase or trailing spaces don't trip
the backend regex (validation.msg.CLIENTKYC.panNumber.does.not.match.regexp).
State name match in client-address save now normalises both sides (lowercase, collapse
underscores + spaces) so legacy MADHYA_PRADESH resolves to 'Madhya Pradesh' returned
by api.postalpincode.in. Backed by tenant migration 0297.
Identities-tab resolver flattened - was returning identifiers before the per-row
forkJoin(getClientIdentificationDocuments) completed, so the view rendered empty
documents arrays. Now uses switchMap + forkJoin so the resolver awaits completion.
Also bundles concurrent work-in-progress on letter-mgmt, organization, users,
bank-calendar, shell, nominee/notes/identifier dialogs, and theming.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
325228c514
commit
83ea57ccf0
@@ -104,14 +104,23 @@
|
||||
<div class="addr-field__value">{{ address.countyDistrict }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (address.stateProvinceId) {
|
||||
<div class="addr-field">
|
||||
<div class="addr-field__label">State</div>
|
||||
<div class="addr-field__value">
|
||||
{{ getSelectedValue('stateProvinceIdOptions', address.stateProvinceId)?.name || '-' }}
|
||||
</div>
|
||||
<!-- State — always render. The address payload may carry the value
|
||||
under `stateProvinceId` (numeric → resolve via template options),
|
||||
`stateName` / `stateProvinceName` (string), or be missing
|
||||
entirely. Render whatever we can find, otherwise show "—". -->
|
||||
<div class="addr-field">
|
||||
<div class="addr-field__label">State</div>
|
||||
<div class="addr-field__value">
|
||||
{{
|
||||
(address.stateProvinceId
|
||||
? getSelectedValue('stateProvinceIdOptions', address.stateProvinceId)?.name
|
||||
: null) ||
|
||||
address.stateName ||
|
||||
address.stateProvinceName ||
|
||||
'—'
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="addr-field">
|
||||
<div class="addr-field__label">Country</div>
|
||||
<div class="addr-field__value">
|
||||
|
||||
@@ -16,6 +16,9 @@ import { SelectBase } from 'app/shared/form-dialog/formfield/model/select-base';
|
||||
|
||||
/** Custom Components */
|
||||
import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component';
|
||||
import { EditAddressDialogComponent } from './edit-address-dialog/edit-address-dialog.component';
|
||||
import { SuccessDialogComponent } from 'app/shared/success-dialog/success-dialog.component';
|
||||
import { ErrorDialogComponent } from 'app/shared/error-dialog/error-dialog.component';
|
||||
|
||||
/** Custom Services */
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
@@ -87,29 +90,42 @@ export class AddressTabComponent {
|
||||
* Adds a client address.
|
||||
*/
|
||||
addAddress() {
|
||||
const data = {
|
||||
title:
|
||||
this.translateService.instant('labels.buttons.Add') +
|
||||
' ' +
|
||||
this.translateService.instant('labels.catalogs.Client') +
|
||||
' ' +
|
||||
this.translateService.instant('labels.heading.Address'),
|
||||
formfields: this.getAddressFormFields('add'),
|
||||
layout: { columns: 2, columnWidth: 350 }
|
||||
};
|
||||
const addAddressDialogRef = this.dialog.open(FormDialogComponent, { data });
|
||||
addAddressDialogRef.afterClosed().subscribe((response: any) => {
|
||||
if (response.data) {
|
||||
this.clientService
|
||||
.createClientAddress(this.clientId, response.data.value.addressType, response.data.value)
|
||||
.subscribe((res: any) => {
|
||||
const addressData = response.data.value;
|
||||
addressData.addressId = res.resourceId;
|
||||
addressData.addressType = this.getSelectedValue('addressTypeIdOptions', addressData.addressType).name;
|
||||
addressData.isActive = false;
|
||||
this.clientAddressData.push(addressData);
|
||||
});
|
||||
}
|
||||
// Use the Nexora dialog with PIN lookup + "Copy from existing address" picker.
|
||||
const ref = this.dialog.open(EditAddressDialogComponent, {
|
||||
data: {
|
||||
mode: 'add',
|
||||
title:
|
||||
this.translateService.instant('labels.buttons.Add') +
|
||||
' ' +
|
||||
this.translateService.instant('labels.catalogs.Client') +
|
||||
' ' +
|
||||
this.translateService.instant('labels.heading.Address'),
|
||||
address: null,
|
||||
template: this.clientAddressTemplate,
|
||||
fieldConfig: this.clientAddressFieldConfig,
|
||||
existingAddresses: this.clientAddressData || []
|
||||
},
|
||||
panelClass: 'ead-panel',
|
||||
autoFocus: false
|
||||
});
|
||||
ref.afterClosed().subscribe((response: any) => {
|
||||
if (!response?.value) return;
|
||||
const payload = { ...response.value };
|
||||
const typeName = this.getSelectedValue('addressTypeIdOptions', payload.addressType)?.name;
|
||||
this.clientService.createClientAddress(this.clientId, payload.addressType, payload).subscribe({
|
||||
next: (res: any) => {
|
||||
payload.addressId = res.resourceId;
|
||||
payload.addressType = typeName;
|
||||
payload.isActive = false;
|
||||
this.clientAddressData.push(payload);
|
||||
this.showSuccess('Address Added', `${typeName || 'Address'} has been added to this client's profile.`);
|
||||
},
|
||||
error: (err: any) => {
|
||||
const apiMsg =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'Please try again.';
|
||||
this.showError(`Failed to add address. ${apiMsg}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -119,31 +135,54 @@ export class AddressTabComponent {
|
||||
* @param {number} index address index
|
||||
*/
|
||||
editAddress(address: any, index: number) {
|
||||
const data = {
|
||||
title:
|
||||
this.translateService.instant('labels.buttons.Edit') +
|
||||
' ' +
|
||||
this.translateService.instant('labels.catalogs.Client') +
|
||||
' ' +
|
||||
this.translateService.instant('labels.heading.Address'),
|
||||
formfields: this.getAddressFormFields('edit', address),
|
||||
layout: { columns: 2, columnWidth: 350, addButtonText: 'Edit' }
|
||||
};
|
||||
const editAddressDialogRef = this.dialog.open(FormDialogComponent, { data });
|
||||
editAddressDialogRef.afterClosed().subscribe((response: any) => {
|
||||
if (response.data) {
|
||||
const addressData = response.data.value;
|
||||
addressData.addressId = address.addressId;
|
||||
addressData.isActive = address.isActive;
|
||||
this.clientService
|
||||
.editClientAddress(this.clientId, address.addressTypeId, addressData)
|
||||
.subscribe((res: any) => {
|
||||
addressData.addressTypeId = address.addressTypeId;
|
||||
addressData.addressType = address.addressType;
|
||||
this.clientAddressData[index] = addressData;
|
||||
});
|
||||
}
|
||||
// Open the Nexora edit dialog with PIN lookup (mirrors the stepper).
|
||||
const ref = this.dialog.open(EditAddressDialogComponent, {
|
||||
data: {
|
||||
title:
|
||||
this.translateService.instant('labels.buttons.Edit') +
|
||||
' ' +
|
||||
this.translateService.instant('labels.catalogs.Client') +
|
||||
' ' +
|
||||
this.translateService.instant('labels.heading.Address'),
|
||||
address,
|
||||
template: this.clientAddressTemplate,
|
||||
fieldConfig: this.clientAddressFieldConfig
|
||||
},
|
||||
panelClass: 'ead-panel',
|
||||
autoFocus: false
|
||||
});
|
||||
ref.afterClosed().subscribe((response: any) => {
|
||||
if (!response?.value) return;
|
||||
const addressData = { ...response.value };
|
||||
addressData.addressId = address.addressId;
|
||||
addressData.isActive = address.isActive;
|
||||
this.clientService.editClientAddress(this.clientId, address.addressTypeId, addressData).subscribe({
|
||||
next: () => {
|
||||
addressData.addressTypeId = address.addressTypeId;
|
||||
addressData.addressType = address.addressType;
|
||||
this.clientAddressData[index] = addressData;
|
||||
this.showSuccess('Address Updated', `${address.addressType || 'Address'} has been updated successfully.`);
|
||||
},
|
||||
error: (err: any) => {
|
||||
const apiMsg =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'Please try again.';
|
||||
this.showError(`Failed to update address. ${apiMsg}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Polished success modal — matches the rest of the Nexora UI. */
|
||||
private showSuccess(title: string, message: string): void {
|
||||
this.dialog.open(SuccessDialogComponent, {
|
||||
data: { title, message, buttonLabel: 'OK' },
|
||||
panelClass: 'sd-panel'
|
||||
});
|
||||
}
|
||||
|
||||
/** Polished error modal — single string body. */
|
||||
private showError(message: string): void {
|
||||
this.dialog.open(ErrorDialogComponent, { data: message });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -297,7 +336,9 @@ export class AddressTabComponent {
|
||||
this.isFieldEnabled('countyDistrict')
|
||||
? new InputBase({
|
||||
controlName: 'countyDistrict',
|
||||
label: this.translateService.instant('labels.inputs.State / Province'),
|
||||
// Was mistakenly labeled "State / Province" — that label belongs to
|
||||
// the stateProvinceId select above. This control holds the District.
|
||||
label: this.translateService.instant('labels.inputs.County / District'),
|
||||
value: address ? address.countyDistrict : '',
|
||||
type: 'text',
|
||||
order: 11
|
||||
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="ead-root">
|
||||
<div class="ead-head">
|
||||
<div class="ead-head__left">
|
||||
<fa-icon icon="map-marker-alt" class="ead-head__ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="ead-head__title">{{ data.title || (isAdd ? 'Add Client Address' : 'Edit Client Address') }}</h2>
|
||||
<div class="ead-head__sub">
|
||||
@if (isAdd) {
|
||||
Select address type and enter PIN to auto-fill city, district & state.
|
||||
} @else {
|
||||
Update postal details. Enter PIN to auto-fill city, district & state.
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="ead-x" (click)="cancel()" matTooltip="Close">
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form class="ead-body" [formGroup]="form" (ngSubmit)="submit()">
|
||||
<!-- Address Type + Copy-from (Add mode only) -->
|
||||
@if (isAdd) {
|
||||
<div class="ead-grid">
|
||||
<div class="ead-field">
|
||||
<label>Address Type *</label>
|
||||
<select formControlName="addressType">
|
||||
<option [ngValue]="''">— Select address type —</option>
|
||||
@for (t of addressTypeOptions; track t.id) {
|
||||
<option [ngValue]="t.id">{{ t.name }}</option>
|
||||
}
|
||||
</select>
|
||||
@if (form.get('addressType')?.touched && form.get('addressType')?.hasError('required')) {
|
||||
<div class="ead-err">Address type is required.</div>
|
||||
}
|
||||
</div>
|
||||
@if (copySourceOptions.length > 0) {
|
||||
<div class="ead-field">
|
||||
<label>Copy from existing address (optional)</label>
|
||||
<select
|
||||
[(ngModel)]="copyFromAddressId"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
(change)="onCopyFromAddress()"
|
||||
>
|
||||
<option [ngValue]="''">— Don't copy —</option>
|
||||
@for (a of copySourceOptions; track a.addressId) {
|
||||
<option [ngValue]="a.addressId">
|
||||
{{ a.addressType || 'Address' }} · {{ a.city || a.countyDistrict || '' }}
|
||||
@if (a.postalCode) {
|
||||
· {{ a.postalCode }}
|
||||
}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- PIN + Street -->
|
||||
<div class="ead-grid">
|
||||
<div class="ead-field">
|
||||
<label>Postal Code *</label>
|
||||
<div class="ead-input-wrap">
|
||||
<fa-icon icon="map-pin" class="ead-input-ic"></fa-icon>
|
||||
<input
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
formControlName="postalCode"
|
||||
placeholder="6-digit PIN"
|
||||
(input)="onPinInput()"
|
||||
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()"
|
||||
/>
|
||||
@if (pinStatus.loading) {
|
||||
<span class="ead-pin-status loading"
|
||||
><fa-icon icon="spinner" class="fa-spin"></fa-icon> Looking up…</span
|
||||
>
|
||||
} @else if (pinStatus.found) {
|
||||
<span class="ead-pin-status ok"><fa-icon icon="check-circle"></fa-icon> PIN verified</span>
|
||||
} @else if (pinStatus.error) {
|
||||
<span class="ead-pin-status err"
|
||||
><fa-icon icon="exclamation-triangle"></fa-icon> {{ pinStatus.message }}</span
|
||||
>
|
||||
}
|
||||
</div>
|
||||
@if (form.get('postalCode')?.touched && form.get('postalCode')?.hasError('pattern')) {
|
||||
<div class="ead-err">Enter a valid 6-digit PIN.</div>
|
||||
}
|
||||
</div>
|
||||
@if (isFieldEnabled('street')) {
|
||||
<div class="ead-field">
|
||||
<label>Street</label>
|
||||
<input type="text" formControlName="street" placeholder="Street, Road, Lane" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Address lines -->
|
||||
<div class="ead-grid">
|
||||
@if (isFieldEnabled('addressLine1')) {
|
||||
<div class="ead-field">
|
||||
<label>Address Line 1</label>
|
||||
<input type="text" formControlName="addressLine1" placeholder="House / Building No." />
|
||||
</div>
|
||||
}
|
||||
@if (isFieldEnabled('addressLine2')) {
|
||||
<div class="ead-field">
|
||||
<label>Address Line 2</label>
|
||||
<input type="text" formControlName="addressLine2" placeholder="Apartment / Block" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="ead-grid">
|
||||
@if (isFieldEnabled('addressLine3')) {
|
||||
<div class="ead-field">
|
||||
<label>Address Line 3</label>
|
||||
<input type="text" formControlName="addressLine3" placeholder="Locality / Sector" />
|
||||
</div>
|
||||
}
|
||||
@if (isFieldEnabled('townVillage')) {
|
||||
<div class="ead-field">
|
||||
<label>Town / Village (Locality)</label>
|
||||
<select formControlName="townVillage">
|
||||
<option [ngValue]="''">— Select —</option>
|
||||
@for (loc of effectiveLocalityOptions; track loc) {
|
||||
<option [ngValue]="loc">{{ loc }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- City / District / State -->
|
||||
<div class="ead-grid ead-grid--three">
|
||||
@if (isFieldEnabled('city')) {
|
||||
<div class="ead-field">
|
||||
<label>City</label>
|
||||
<input type="text" formControlName="city" placeholder="City" />
|
||||
</div>
|
||||
}
|
||||
@if (isFieldEnabled('countyDistrict')) {
|
||||
<div class="ead-field">
|
||||
<label>County / District</label>
|
||||
<input type="text" formControlName="countyDistrict" placeholder="District" />
|
||||
</div>
|
||||
}
|
||||
@if (isFieldEnabled('stateProvinceId')) {
|
||||
<div class="ead-field">
|
||||
<label>State / Province</label>
|
||||
<!-- Plain text — populated by the PIN service, mirrors create wizard.
|
||||
On submit we resolve to the matching stateProvinceId for the API. -->
|
||||
<input type="text" formControlName="state" placeholder="State / Province" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Country -->
|
||||
@if (isFieldEnabled('countryId')) {
|
||||
<div class="ead-grid">
|
||||
<div class="ead-field">
|
||||
<label>Country</label>
|
||||
<input type="text" formControlName="country" placeholder="Country" />
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="ead-actions">
|
||||
<button type="button" class="ead-btn ghost" (click)="cancel()">Cancel</button>
|
||||
<button type="submit" class="ead-btn primary" [disabled]="form.invalid">
|
||||
<fa-icon [icon]="isAdd ? 'plus' : 'check'"></fa-icon>
|
||||
{{ isAdd ? 'Add Address' : 'Save Changes' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
+274
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
$navy: #0d1b2a;
|
||||
$blue: #1e88e5;
|
||||
$blue-d: #1e40af;
|
||||
$green: #10b981;
|
||||
$red: #ef4444;
|
||||
$amber: #f59e0b;
|
||||
$purple: #7c3aed;
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ead-root {
|
||||
background: #fff;
|
||||
width: 760px;
|
||||
max-width: 96vw;
|
||||
max-height: 92vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ead-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: linear-gradient(180deg, #f8fafc 0%, #fff 100%);
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__ic {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, $blue 0%, $purple 100%);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 2px 6px -2px rgb(29 78 216 / 35%);
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: $navy;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
margin-top: 2px;
|
||||
font-size: 11.5px;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.ead-x {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
background: #fef2f2;
|
||||
color: $red;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
}
|
||||
|
||||
.ead-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ead-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px 14px;
|
||||
|
||||
&--three {
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.ead-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
height: 38px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: $navy;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: all 0.12s;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $blue;
|
||||
box-shadow: 0 0 0 3px rgb(30 136 229 / 14%);
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
appearance: none;
|
||||
background-image:
|
||||
linear-gradient(45deg, transparent 50%, #64748b 50%), linear-gradient(135deg, #64748b 50%, transparent 50%);
|
||||
background-position:
|
||||
calc(100% - 14px) 50%,
|
||||
calc(100% - 9px) 50%;
|
||||
background-size:
|
||||
5px 5px,
|
||||
5px 5px;
|
||||
background-repeat: no-repeat;
|
||||
padding-right: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.ead-input-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
padding-left: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
.ead-input-ic {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
color: $blue;
|
||||
font-size: 13px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ead-pin-status {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
|
||||
fa-icon {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
&.ok {
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
|
||||
&.loading {
|
||||
background: #eff6ff;
|
||||
color: $blue-d;
|
||||
border: 1px solid #bfdbfe;
|
||||
}
|
||||
|
||||
&.err {
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
}
|
||||
|
||||
.ead-err {
|
||||
font-size: 10.5px;
|
||||
color: $red;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ead-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
padding-top: 14px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.ead-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 38px;
|
||||
padding: 0 18px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
border: 1px solid transparent;
|
||||
|
||||
fa-icon {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
&.ghost {
|
||||
background: #fff;
|
||||
border-color: #d0d5dd;
|
||||
color: #475569;
|
||||
|
||||
&:hover {
|
||||
background: #f8fafc;
|
||||
border-color: #94a3b8;
|
||||
color: $navy;
|
||||
}
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background: linear-gradient(135deg, $blue 0%, $blue-d 100%);
|
||||
border-color: $blue-d;
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 6px -2px rgb(30 64 175 / 35%);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
box-shadow: 0 4px 12px -3px rgb(30 64 175 / 45%);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 640px) {
|
||||
.ead-grid,
|
||||
.ead-grid--three {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
+354
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule, ReactiveFormsModule, UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
export interface EditAddressDialogData {
|
||||
/** 'add' shows Address Type select + Copy-from picker; 'edit' hides them. */
|
||||
mode?: 'add' | 'edit';
|
||||
title: string;
|
||||
/** Existing address payload (edit) or null (add). */
|
||||
address: any;
|
||||
template: {
|
||||
addressTypeIdOptions?: { id: number; name: string }[];
|
||||
stateProvinceIdOptions?: { id: number; name: string }[];
|
||||
countryIdOptions?: { id: number; name: string }[];
|
||||
[k: string]: any;
|
||||
};
|
||||
/** Field-config rows from `clientAddressFieldConfig`; used to honour
|
||||
* the org's address-field visibility / enabling settings. */
|
||||
fieldConfig: { field: string; isEnabled: boolean }[];
|
||||
/** All currently-saved addresses for this client. In 'add' mode the user can
|
||||
* pick one as the source for "Copy from existing address". */
|
||||
existingAddresses?: any[];
|
||||
}
|
||||
|
||||
interface PinPostOffice {
|
||||
Name: string;
|
||||
Block?: string;
|
||||
Division?: string;
|
||||
District?: string;
|
||||
State?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit Client Address dialog (post-create, in client view).
|
||||
*
|
||||
* Mirrors the create-stepper's address experience:
|
||||
* • Postal Code field auto-triggers a PIN lookup
|
||||
* • Locality / Area becomes a dropdown of post offices for that PIN
|
||||
* • City / District / State auto-fill from the lookup (only when empty)
|
||||
* • State resolves to the matching `stateProvinceId` from the template
|
||||
*
|
||||
* On submit, returns the form value via `dialogRef.close({ value })`.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-edit-address-dialog',
|
||||
templateUrl: './edit-address-dialog.component.html',
|
||||
styleUrls: ['./edit-address-dialog.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
FormsModule,
|
||||
FaIconComponent,
|
||||
MatTooltip
|
||||
]
|
||||
})
|
||||
export class EditAddressDialogComponent implements OnInit {
|
||||
private fb = inject(UntypedFormBuilder);
|
||||
private http = inject(HttpClient);
|
||||
public dialogRef = inject(MatDialogRef<EditAddressDialogComponent>);
|
||||
public data = inject<EditAddressDialogData>(MAT_DIALOG_DATA);
|
||||
|
||||
form!: UntypedFormGroup;
|
||||
localityOptions: string[] = [];
|
||||
pinStatus: { loading: boolean; found: boolean; error: boolean; message: string } = {
|
||||
loading: false,
|
||||
found: false,
|
||||
error: false,
|
||||
message: ''
|
||||
};
|
||||
|
||||
/** Currently-selected source for "Copy from existing address" (id). */
|
||||
copyFromAddressId: number | '' = '';
|
||||
|
||||
private pinTimer: any = null;
|
||||
private pinCache: Record<string, PinPostOffice[]> = {};
|
||||
|
||||
get isAdd(): boolean {
|
||||
return this.data.mode === 'add';
|
||||
}
|
||||
|
||||
get stateOptions() {
|
||||
return this.data.template?.stateProvinceIdOptions || [];
|
||||
}
|
||||
get countryOptions() {
|
||||
return this.data.template?.countryIdOptions || [];
|
||||
}
|
||||
get addressTypeOptions() {
|
||||
return this.data.template?.addressTypeIdOptions || [];
|
||||
}
|
||||
/** Existing addresses that can be a "Copy from" source (excludes empties). */
|
||||
get copySourceOptions(): any[] {
|
||||
return (this.data.existingAddresses || []).filter((a) => a && (a.addressLine1 || a.postalCode));
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
const a = this.data.address || {};
|
||||
// Pre-resolve the saved stateProvinceId (numeric, from Fineract) to its
|
||||
// display name so the text field shows something meaningful on load.
|
||||
const savedStateName =
|
||||
a.stateName ||
|
||||
a.stateProvinceName ||
|
||||
(a.stateProvinceId ? (this.stateOptions.find((s) => s.id === a.stateProvinceId)?.name ?? '') : '');
|
||||
const savedCountryName =
|
||||
a.countryName || (a.countryId ? (this.countryOptions.find((c) => c.id === a.countryId)?.name ?? '') : '');
|
||||
|
||||
this.form = this.fb.group({
|
||||
...(this.isAdd
|
||||
? {
|
||||
addressType: [
|
||||
a.addressType || '',
|
||||
Validators.required
|
||||
]
|
||||
}
|
||||
: {}),
|
||||
postalCode: [
|
||||
a.postalCode || '',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.pattern(/^\d{6}$/)
|
||||
]
|
||||
],
|
||||
street: [a.street || ''],
|
||||
addressLine1: [a.addressLine1 || ''],
|
||||
addressLine2: [a.addressLine2 || ''],
|
||||
addressLine3: [a.addressLine3 || ''],
|
||||
townVillage: [a.townVillage || ''],
|
||||
city: [a.city || ''],
|
||||
countyDistrict: [a.countyDistrict || ''],
|
||||
// State + Country are plain TEXT now (matching the create-stepper). On
|
||||
// submit we resolve them to the matching template id when possible —
|
||||
// see `submit()`. We also keep the raw original ids around so we don't
|
||||
// lose them if the user doesn't touch the fields.
|
||||
state: [savedStateName],
|
||||
country: [savedCountryName || 'India'],
|
||||
// Hidden originals — used on submit when text wasn't changed.
|
||||
_origStateProvinceId: [a.stateProvinceId || ''],
|
||||
_origCountryId: [a.countryId || '']
|
||||
});
|
||||
|
||||
// If we already have a PIN on load, re-run the lookup so the locality
|
||||
// dropdown is populated and "PIN verified" badge shows in edit mode.
|
||||
if ((a.postalCode || '').toString().length === 6) {
|
||||
this.runPinLookup(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy address fields from an existing address (selected via the dropdown).
|
||||
* The Address Type is preserved — the user's current selection wins, since
|
||||
* the whole point of "Add" is creating a NEW address type.
|
||||
*/
|
||||
onCopyFromAddress(): void {
|
||||
const src = this.copySourceOptions.find((a) => String(a.addressId) === String(this.copyFromAddressId));
|
||||
if (!src) return;
|
||||
const srcStateName =
|
||||
src.stateName ||
|
||||
src.stateProvinceName ||
|
||||
(src.stateProvinceId ? (this.stateOptions.find((s) => s.id === src.stateProvinceId)?.name ?? '') : '');
|
||||
const srcCountryName =
|
||||
src.countryName ||
|
||||
(src.countryId ? (this.countryOptions.find((c) => c.id === src.countryId)?.name ?? 'India') : 'India');
|
||||
|
||||
const patch: Record<string, any> = {
|
||||
postalCode: src.postalCode || '',
|
||||
street: src.street || '',
|
||||
addressLine1: src.addressLine1 || '',
|
||||
addressLine2: src.addressLine2 || '',
|
||||
addressLine3: src.addressLine3 || '',
|
||||
townVillage: src.townVillage || '',
|
||||
city: src.city || '',
|
||||
countyDistrict: src.countyDistrict || '',
|
||||
state: srcStateName,
|
||||
country: srcCountryName,
|
||||
_origStateProvinceId: src.stateProvinceId || '',
|
||||
_origCountryId: src.countryId || ''
|
||||
};
|
||||
this.form.patchValue(patch);
|
||||
// Re-run the PIN lookup so the locality dropdown options + "PIN verified"
|
||||
// chip light up for the copied PIN.
|
||||
if ((patch['postalCode'] || '').toString().length === 6) {
|
||||
this.runPinLookup(true);
|
||||
}
|
||||
}
|
||||
|
||||
isFieldEnabled(field: string): boolean {
|
||||
const cfg = (this.data.fieldConfig || []).find((c) => c.field === field);
|
||||
return cfg ? !!cfg.isEnabled : true;
|
||||
}
|
||||
|
||||
/** PIN input: debounce + lookup. */
|
||||
onPinInput(): void {
|
||||
clearTimeout(this.pinTimer);
|
||||
const pin = (this.form.get('postalCode')?.value || '').toString().replace(/\D/g, '');
|
||||
this.form.get('postalCode')?.setValue(pin, { emitEvent: false });
|
||||
if (pin.length < 6) {
|
||||
this.pinStatus = { loading: false, found: false, error: false, message: '' };
|
||||
this.localityOptions = [];
|
||||
return;
|
||||
}
|
||||
this.pinTimer = setTimeout(() => this.runPinLookup(false), 550);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the post-office list for the current PIN and apply it.
|
||||
* @param preserve when true, don't overwrite existing values for
|
||||
* locality / city / district / state (used on load).
|
||||
*/
|
||||
private runPinLookup(preserve: boolean): void {
|
||||
const pin = (this.form.get('postalCode')?.value || '').toString();
|
||||
if (pin.length !== 6) return;
|
||||
if (this.pinCache[pin]) {
|
||||
this.applyPinData(this.pinCache[pin], preserve);
|
||||
this.pinStatus = { loading: false, found: true, error: false, message: 'Verified' };
|
||||
return;
|
||||
}
|
||||
this.pinStatus = { loading: true, found: false, error: false, message: 'Looking up...' };
|
||||
this.http.get<any[]>(`https://api.postalpincode.in/pincode/${pin}`).subscribe({
|
||||
next: (data: any) => {
|
||||
if (data?.[0]?.Status === 'Success') {
|
||||
this.pinCache[pin] = data[0].PostOffice;
|
||||
this.applyPinData(data[0].PostOffice, preserve);
|
||||
this.pinStatus = { loading: false, found: true, error: false, message: 'Verified' };
|
||||
} else {
|
||||
this.localityOptions = [];
|
||||
this.pinStatus = { loading: false, found: false, error: true, message: 'Invalid PIN' };
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.pinStatus = { loading: false, found: false, error: true, message: 'Network error' };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private applyPinData(offices: PinPostOffice[], preserve: boolean): void {
|
||||
const seen = new Set<string>();
|
||||
this.localityOptions = (offices || [])
|
||||
.filter((o) => {
|
||||
if (!o?.Name || seen.has(o.Name)) return false;
|
||||
seen.add(o.Name);
|
||||
return true;
|
||||
})
|
||||
.map((o) => o.Name);
|
||||
|
||||
const first = offices?.[0];
|
||||
if (!first) return;
|
||||
|
||||
const patch: Record<string, any> = {};
|
||||
|
||||
// Town / Village — pick the first locality from the lookup. Skip only when
|
||||
// there's already a saved value AND we're in preserve mode.
|
||||
if (!preserve || !this.form.get('townVillage')?.value) {
|
||||
if (this.localityOptions[0]) patch['townVillage'] = this.localityOptions[0];
|
||||
}
|
||||
|
||||
// City — Block / Division / District fallback chain
|
||||
if (!preserve || !this.form.get('city')?.value) {
|
||||
const cityValue = first.Block || first.Division || first.District || '';
|
||||
if (cityValue) patch['city'] = cityValue;
|
||||
}
|
||||
|
||||
// District
|
||||
if (!preserve || !this.form.get('countyDistrict')?.value) {
|
||||
if (first.District) patch['countyDistrict'] = first.District;
|
||||
}
|
||||
|
||||
// State — stored as plain TEXT in the form (same as the create wizard).
|
||||
// We display whatever the PIN service returns ("Madhya Pradesh", "Kerala",
|
||||
// etc.) without forcing it through the small Fineract dropdown which
|
||||
// sometimes only carries a handful of states.
|
||||
if (!preserve || !this.form.get('state')?.value) {
|
||||
if (first.State) patch['state'] = first.State;
|
||||
}
|
||||
|
||||
// Country — postalpincode.in only serves India. Default to "India" text.
|
||||
if (!preserve || !this.form.get('country')?.value) {
|
||||
patch['country'] = 'India';
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length > 0) {
|
||||
this.form.patchValue(patch);
|
||||
}
|
||||
}
|
||||
|
||||
/** Lowercase, collapse internal whitespace, trim — for fuzzy name matching. */
|
||||
private normalize(s: string | undefined | null): string {
|
||||
return (s || '').toString().toLowerCase().replace(/[-_]+/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/** Preserve the saved locality even if it's not in the lookup results. */
|
||||
get effectiveLocalityOptions(): string[] {
|
||||
const saved = this.form?.get('townVillage')?.value;
|
||||
if (saved && !this.localityOptions.includes(saved)) {
|
||||
return [
|
||||
saved,
|
||||
...this.localityOptions
|
||||
];
|
||||
}
|
||||
return this.localityOptions;
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.form.invalid) {
|
||||
this.form.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
// Build the API payload — Fineract expects `stateProvinceId` (numeric id)
|
||||
// and `countryId`, not the text names we've been displaying. Resolve via
|
||||
// the template options; fall back to the original ids if the user didn't
|
||||
// change the field.
|
||||
const raw = this.form.value;
|
||||
const payload: Record<string, any> = {
|
||||
postalCode: raw.postalCode,
|
||||
street: raw.street,
|
||||
addressLine1: raw.addressLine1,
|
||||
addressLine2: raw.addressLine2,
|
||||
addressLine3: raw.addressLine3,
|
||||
townVillage: raw.townVillage,
|
||||
city: raw.city,
|
||||
countyDistrict: raw.countyDistrict
|
||||
};
|
||||
if (raw.addressType) payload['addressType'] = raw.addressType;
|
||||
|
||||
const stateMatch = raw.state
|
||||
? this.stateOptions.find((s) => this.normalize(s.name) === this.normalize(raw.state))
|
||||
: null;
|
||||
payload['stateProvinceId'] = stateMatch ? stateMatch.id : raw._origStateProvinceId || '';
|
||||
|
||||
const countryMatch = raw.country
|
||||
? this.countryOptions.find((c) => this.normalize(c.name) === this.normalize(raw.country))
|
||||
: null;
|
||||
payload['countryId'] = countryMatch ? countryMatch.id : raw._origCountryId || '';
|
||||
|
||||
this.dialogRef.close({ value: payload });
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
}
|
||||
+17
-2
@@ -10,14 +10,29 @@
|
||||
<div>
|
||||
<form #formRef="ngForm" [formGroup]="noteForm">
|
||||
<mat-form-field>
|
||||
<textarea formControlName="note" matInput placeholder="{{ 'labels.text.Write a note' | translate }}"></textarea>
|
||||
<textarea
|
||||
formControlName="note"
|
||||
matInput
|
||||
placeholder="{{ 'labels.text.Write a note' | translate }}"
|
||||
matTooltip="At least two words required — e.g. 'Hello User', 'Updated mobile', 'Re-KYC done'."
|
||||
matTooltipPosition="above"
|
||||
matTooltipShowDelay="300"
|
||||
(keydown)="onNoteKeyDown($event)"
|
||||
(input)="onNoteInput($event)"
|
||||
></textarea>
|
||||
@if (noteForm.controls['note'].touched && noteForm.controls['note'].hasError('required')) {
|
||||
<mat-error>Note cannot be empty.</mat-error>
|
||||
}
|
||||
@if (noteForm.controls['note'].touched && noteForm.controls['note'].hasError('minWords')) {
|
||||
<mat-error>Note must contain at least 2 words (whitespace doesn't count).</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-raised-button mat-dialog-close>{{ 'labels.buttons.Cancel' | translate }}</button>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
[disabled]="noteForm.pristine"
|
||||
[disabled]="noteForm.pristine || noteForm.invalid"
|
||||
[mat-dialog-close]="{ editForm: noteForm }"
|
||||
>
|
||||
{{ 'labels.buttons.Confirm' | translate }}
|
||||
|
||||
+32
-2
@@ -15,8 +15,10 @@ import {
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
} from '@angular/material/dialog';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { minWordsValidator } from 'app/shared/validators/min-words.validator';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-edit-notes-dialog',
|
||||
@@ -26,7 +28,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatDialogTitle,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
MatDialogClose,
|
||||
MatTooltip
|
||||
]
|
||||
})
|
||||
export class EditNotesDialogComponent implements OnInit {
|
||||
@@ -42,10 +45,37 @@ export class EditNotesDialogComponent implements OnInit {
|
||||
|
||||
createNoteForm() {
|
||||
this.noteForm = this.formBuilder.group({
|
||||
// Same rule as the entity-notes Add form — reject whitespace / single-word.
|
||||
note: [
|
||||
this.data.noteContent,
|
||||
Validators.required
|
||||
[
|
||||
Validators.required,
|
||||
minWordsValidator(2)
|
||||
]
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
/** Block whitespace at position 0 of an otherwise-empty textarea. */
|
||||
onNoteKeyDown(event: KeyboardEvent): void {
|
||||
if (event.key !== ' ' && event.key !== 'Tab' && event.key !== 'Enter') return;
|
||||
const el = event.target as HTMLTextAreaElement;
|
||||
if (el.selectionStart === 0 && el.value.trim() === '') {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
/** Strip leading whitespace + collapse internal runs of spaces/tabs. */
|
||||
onNoteInput(event: Event): void {
|
||||
const el = event.target as HTMLTextAreaElement;
|
||||
const before = el.value;
|
||||
const after = before.replace(/^[ \t]+/, '').replace(/[ \t]{2,}/g, ' ');
|
||||
if (before !== after) {
|
||||
const cursorDelta = before.length - after.length;
|
||||
const pos = Math.max(0, (el.selectionStart ?? after.length) - cursorDelta);
|
||||
el.value = after;
|
||||
this.noteForm.get('note')!.setValue(after, { emitEvent: false });
|
||||
el.setSelectionRange(pos, pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ import { TranslateService } from '@ngx-translate/core';
|
||||
import { ClientsService } from '../../clients.service';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
/**
|
||||
* Identities Tab Component
|
||||
@@ -206,15 +208,57 @@ export class IdentitiesTabComponent implements OnDestroy {
|
||||
/**
|
||||
* Create one identifier from a KYC section payload, then upload each file
|
||||
* as a document attached to that identifier.
|
||||
*
|
||||
* `section.proofId` may arrive either as a numeric CodeValue id (when sourced from
|
||||
* /kyc/proof-types — the address-proof chip-picker is built from this list and works
|
||||
* straight away) or as a string CODE like "AADHAAR" / "PAN" (when sourced from the
|
||||
* KYC step's constitution-driven document list — that list isn't backed by
|
||||
* CodeValues). The backend's ClientIdentifierRequest.documentTypeId is a Long, so a
|
||||
* string proofId triggers Spring's HttpMessageNotReadableException → generic 400
|
||||
* "String instead of Number". Route through resolveDocumentTypeId so we always post
|
||||
* a numeric documentTypeId.
|
||||
*/
|
||||
private createIdentifierWithDocs(section: any, description: string, kind: 'id' | 'address'): void {
|
||||
const identifierData = {
|
||||
documentTypeId: section.proofId,
|
||||
status: 'Active',
|
||||
documentKey: kind === 'id' ? section.documentKey : `${section.proof?.name || 'Address Proof'} - ${this.clientId}`,
|
||||
description: kind === 'address' && section.address ? section.address : description
|
||||
};
|
||||
const proofName = section.proof?.name || (kind === 'address' ? 'Address Proof' : 'KYC Document');
|
||||
this.resolveDocumentTypeId(section.proofId, proofName).subscribe((documentTypeId: number | null) => {
|
||||
if (documentTypeId == null) {
|
||||
// No CodeValue match (e.g. a constitution-specific non-individual doc like
|
||||
// "Trust Deed" that hasn't been seeded). Skip silently rather than firing the
|
||||
// 400 — the parent already captured the file separately if needed.
|
||||
return;
|
||||
}
|
||||
const identifierData = {
|
||||
documentTypeId,
|
||||
status: 'Active',
|
||||
documentKey: kind === 'id' ? section.documentKey : `${proofName} - ${this.clientId}`,
|
||||
description: kind === 'address' && section.address ? section.address : description
|
||||
};
|
||||
|
||||
this.postIdentifierAndDocs(identifierData, section, proofName, description);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pass-through when proofId is already numeric (address proof, identities tab address
|
||||
* picker). Otherwise resolve the string code/name against the ID-proof CodeValues so
|
||||
* the backend gets a proper Long for documentTypeId.
|
||||
*/
|
||||
private resolveDocumentTypeId(proofId: any, proofName: string): Observable<number | null> {
|
||||
if (typeof proofId === 'number' && Number.isFinite(proofId)) {
|
||||
return of(proofId);
|
||||
}
|
||||
return this.clientService.getKycProofTypes('id').pipe(
|
||||
map((res: any) => {
|
||||
const list: any[] = res?.idProofs || [];
|
||||
const target = (proofName || '').trim().toLowerCase();
|
||||
const match = list.find((p: any) => (p?.name || '').trim().toLowerCase() === target);
|
||||
return (match?.id ?? null) as number | null;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Extracted post-and-upload-files step so the resolver wrapper stays readable. */
|
||||
private postIdentifierAndDocs(identifierData: any, section: any, proofName: string, description: string): void {
|
||||
this.clientService.addClientIdentifier(this.clientId, identifierData).subscribe({
|
||||
next: (res: any) => {
|
||||
const newIdentifierId = res.resourceId;
|
||||
|
||||
@@ -203,27 +203,54 @@
|
||||
<span class="kv-table__col kv-table__col--action"></span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- PAN-Aadhaar Link Row -->
|
||||
<div class="kv-table__row kv-table__row--link">
|
||||
<span class="kv-table__col kv-table__col--doc">
|
||||
<mat-icon class="kv-doc-icon">link</mat-icon> PAN–Aadhaar Link
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--num">Sec 139AA</span>
|
||||
<span class="kv-table__col kv-table__col--status">
|
||||
@if (kycData?.panAadhaarSeedingStatus === 'Y') {
|
||||
<span class="kv-status kv-status--ok"><mat-icon>check_circle</mat-icon> Linked</span>
|
||||
} @else if (kycData?.panAadhaarSeedingStatus === 'N') {
|
||||
<span class="kv-status kv-status--fail"><mat-icon>cancel</mat-icon> Not Linked</span>
|
||||
} @else if (kycData?.panAadhaarSeedingStatus === 'E') {
|
||||
<span class="kv-status kv-status--info"><mat-icon>info</mat-icon> Exempt</span>
|
||||
} @else {
|
||||
<span class="kv-status kv-status--pending"><mat-icon>radio_button_unchecked</mat-icon> Pending</span>
|
||||
}
|
||||
</span>
|
||||
<span class="kv-table__col kv-table__col--date">—</span>
|
||||
<span class="kv-table__col kv-table__col--action"></span>
|
||||
<!-- ─── PAN ↔ Aadhaar Link — premium status card (replaces the legacy
|
||||
table row). Dark gradient base with state-driven accent so the
|
||||
linking state is the first thing a reviewer sees. -->
|
||||
<div
|
||||
class="pal-card"
|
||||
[class.pal-card--ok]="kycData?.panAadhaarSeedingStatus === 'Y'"
|
||||
[class.pal-card--fail]="kycData?.panAadhaarSeedingStatus === 'N'"
|
||||
[class.pal-card--info]="kycData?.panAadhaarSeedingStatus === 'E'"
|
||||
[class.pal-card--pending]="
|
||||
kycData?.panAadhaarSeedingStatus !== 'Y' &&
|
||||
kycData?.panAadhaarSeedingStatus !== 'N' &&
|
||||
kycData?.panAadhaarSeedingStatus !== 'E'
|
||||
"
|
||||
>
|
||||
<div class="pal-card__left">
|
||||
<div class="pal-card__icon">
|
||||
<mat-icon>credit_card</mat-icon>
|
||||
</div>
|
||||
<div class="pal-card__txt">
|
||||
<div class="pal-card__small">Verification Status</div>
|
||||
<div class="pal-card__title">PAN ↔ Aadhaar</div>
|
||||
<div class="pal-card__status">
|
||||
@if (kycData?.panAadhaarSeedingStatus === 'Y') {
|
||||
<mat-icon>check_circle</mat-icon> Linked (Seeded)
|
||||
} @else if (kycData?.panAadhaarSeedingStatus === 'N') {
|
||||
<mat-icon>cancel</mat-icon> Not Linked
|
||||
} @else if (kycData?.panAadhaarSeedingStatus === 'E') {
|
||||
<mat-icon>info</mat-icon> Exempt
|
||||
} @else {
|
||||
<mat-icon>radio_button_unchecked</mat-icon> Pending
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pal-card__badge">
|
||||
@if (kycData?.panAadhaarSeedingStatus === 'Y') {
|
||||
<mat-icon>check</mat-icon>
|
||||
} @else if (kycData?.panAadhaarSeedingStatus === 'N') {
|
||||
<mat-icon>close</mat-icon>
|
||||
} @else if (kycData?.panAadhaarSeedingStatus === 'E') {
|
||||
<mat-icon>info</mat-icon>
|
||||
} @else {
|
||||
<mat-icon>schedule</mat-icon>
|
||||
}
|
||||
</div>
|
||||
<div class="pal-card__hint">As per Income Tax Portal — Section 139AA</div>
|
||||
</div>
|
||||
|
||||
<!-- Aadhaar OTP inline (shown below table when re-verifying) -->
|
||||
|
||||
@@ -989,3 +989,276 @@
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
PAN ↔ Aadhaar premium status card
|
||||
─── Replaces the legacy table-row treatment. Dark gradient with
|
||||
offset glow circles, large gradient icon, and a state-driven
|
||||
accent (green / red / blue / amber).
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
.pal-card {
|
||||
margin-top: 16px;
|
||||
width: 100%;
|
||||
min-height: 88px;
|
||||
padding: 14px 18px 30px;
|
||||
border-radius: 18px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: linear-gradient(135deg, #111827, #0f172a);
|
||||
border: 1px solid rgb(255 255 255 / 8%);
|
||||
box-shadow:
|
||||
0 12px 32px rgb(0 0 0 / 35%),
|
||||
inset 0 1px 1px rgb(255 255 255 / 5%);
|
||||
font-family: Inter, 'Segoe UI', Roboto, sans-serif;
|
||||
|
||||
/* Decorative glows — colors swap with the state variant */
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
background: #2563eb;
|
||||
border-radius: 50%;
|
||||
top: -110px;
|
||||
right: -70px;
|
||||
opacity: 0.18;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background: #22c55e;
|
||||
border-radius: 50%;
|
||||
bottom: -70px;
|
||||
left: -50px;
|
||||
opacity: 0.12;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(145deg, #2563eb, #06b6d4);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 22px;
|
||||
position: relative;
|
||||
box-shadow: 0 10px 20px rgb(37 99 235 / 35%);
|
||||
flex-shrink: 0;
|
||||
|
||||
mat-icon {
|
||||
font-size: 26px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgb(255 255 255 / 20%);
|
||||
}
|
||||
}
|
||||
|
||||
&__txt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__small {
|
||||
font-size: 10.5px;
|
||||
color: #94a3b8;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&__title {
|
||||
color: #fff;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
&__status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
padding: 4px 11px;
|
||||
border-radius: 50px;
|
||||
background: rgb(34 197 94 / 12%);
|
||||
border: 1px solid rgb(74 222 128 / 25%);
|
||||
color: #4ade80;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
backdrop-filter: blur(5px);
|
||||
width: fit-content;
|
||||
|
||||
mat-icon {
|
||||
font-size: 13px;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
&__badge {
|
||||
z-index: 2;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #22c55e, #16a34a);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
box-shadow: 0 0 18px rgb(34 197 94 / 45%);
|
||||
flex-shrink: 0;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
&__hint {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 16px;
|
||||
font-size: 10px;
|
||||
color: rgb(255 255 255 / 35%);
|
||||
font-style: italic;
|
||||
letter-spacing: 0.02em;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ─── State variants ─── */
|
||||
&--ok {
|
||||
&::before {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
&::after {
|
||||
background: #22c55e;
|
||||
}
|
||||
}
|
||||
|
||||
&--fail {
|
||||
border-color: rgb(239 68 68 / 22%);
|
||||
|
||||
&::before {
|
||||
background: #ef4444;
|
||||
opacity: 0.22;
|
||||
}
|
||||
|
||||
&::after {
|
||||
background: #f87171;
|
||||
opacity: 0.16;
|
||||
}
|
||||
|
||||
.pal-card__status {
|
||||
background: rgb(239 68 68 / 14%);
|
||||
border-color: rgb(248 113 113 / 32%);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.pal-card__badge {
|
||||
background: linear-gradient(135deg, #ef4444, #b91c1c);
|
||||
box-shadow: 0 0 18px rgb(239 68 68 / 45%);
|
||||
}
|
||||
|
||||
.pal-card__icon {
|
||||
background: linear-gradient(145deg, #ef4444, #f97316);
|
||||
box-shadow: 0 10px 20px rgb(239 68 68 / 35%);
|
||||
}
|
||||
}
|
||||
|
||||
&--info {
|
||||
border-color: rgb(59 130 246 / 22%);
|
||||
|
||||
&::before {
|
||||
background: #3b82f6;
|
||||
opacity: 0.22;
|
||||
}
|
||||
|
||||
&::after {
|
||||
background: #60a5fa;
|
||||
opacity: 0.14;
|
||||
}
|
||||
|
||||
.pal-card__status {
|
||||
background: rgb(59 130 246 / 14%);
|
||||
border-color: rgb(96 165 250 / 32%);
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.pal-card__badge {
|
||||
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
|
||||
box-shadow: 0 0 18px rgb(59 130 246 / 45%);
|
||||
}
|
||||
}
|
||||
|
||||
&--pending {
|
||||
border-color: rgb(245 158 11 / 22%);
|
||||
|
||||
&::before {
|
||||
background: #f59e0b;
|
||||
opacity: 0.22;
|
||||
}
|
||||
|
||||
&::after {
|
||||
background: #fbbf24;
|
||||
opacity: 0.14;
|
||||
}
|
||||
|
||||
.pal-card__status {
|
||||
background: rgb(245 158 11 / 14%);
|
||||
border-color: rgb(251 191 36 / 32%);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.pal-card__badge {
|
||||
background: linear-gradient(135deg, #f59e0b, #b45309);
|
||||
box-shadow: 0 0 18px rgb(245 158 11 / 45%);
|
||||
}
|
||||
|
||||
.pal-card__icon {
|
||||
background: linear-gradient(145deg, #f59e0b, #d97706);
|
||||
box-shadow: 0 10px 20px rgb(245 158 11 / 35%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 540px) {
|
||||
.pal-card {
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding-bottom: 36px;
|
||||
|
||||
&__hint {
|
||||
position: static;
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,6 +284,13 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (selfSelectError) {
|
||||
<div class="search-self-error">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
{{ selfSelectError }}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (selectedClient) {
|
||||
<!-- Linked Client Card -->
|
||||
<div class="nom-linked-card">
|
||||
@@ -431,6 +438,12 @@
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (guardianSelfSelectError) {
|
||||
<div class="search-self-error">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
{{ guardianSelfSelectError }}
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<span class="nom-guardian-selected"
|
||||
>{{ selectedGuardian.displayName }}
|
||||
|
||||
@@ -516,6 +516,27 @@ $primary-light: #e8f0fe;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
/* Shown when the user tries to pick themselves as nominee / guardian. */
|
||||
.search-self-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
padding: 6px 10px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 6px;
|
||||
color: #b91c1c;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Linked card */
|
||||
.nom-linked-card {
|
||||
display: flex;
|
||||
|
||||
@@ -295,9 +295,11 @@ export class NomineeTabComponent implements OnInit {
|
||||
this.searching = true;
|
||||
this.searchTimer = setTimeout(() => {
|
||||
// Nominee picker (post-create tab): nominees must be natural persons.
|
||||
// The host client is excluded from results so they can't be picked as
|
||||
// their own nominee.
|
||||
this.clientsService.searchByText(text, 0, 8, '', '', true, 'INDIVIDUAL').subscribe({
|
||||
next: (data: any) => {
|
||||
this.searchResults = data?.content || [];
|
||||
this.searchResults = (data?.content || []).filter((c: any) => !this.isSelfClient(c?.id));
|
||||
this.searching = false;
|
||||
this.clientsService.enrichSearchResults(this.searchResults);
|
||||
},
|
||||
@@ -316,7 +318,21 @@ export class NomineeTabComponent implements OnInit {
|
||||
}, 200);
|
||||
}
|
||||
|
||||
/** Self-check: prevent a client from being added as their own nominee/guardian. */
|
||||
isSelfClient(clientId: number | string | null | undefined): boolean {
|
||||
if (this.clientId == null || clientId == null) return false;
|
||||
return String(this.clientId) === String(clientId);
|
||||
}
|
||||
|
||||
/** Inline error shown above the picker when the user tries to pick themselves. */
|
||||
selfSelectError = '';
|
||||
|
||||
selectClient(client: any): void {
|
||||
if (this.isSelfClient(client?.id)) {
|
||||
this.selfSelectError = `You can't link this client as their own nominee. Please pick a different person.`;
|
||||
return;
|
||||
}
|
||||
this.selfSelectError = '';
|
||||
this.selectedClient = client;
|
||||
this.searchResults = [];
|
||||
this.searchText = '';
|
||||
@@ -414,9 +430,10 @@ export class NomineeTabComponent implements OnInit {
|
||||
this.guardianSearching = true;
|
||||
this.guardianSearchTimer = setTimeout(() => {
|
||||
// Guardian picker (post-create tab): guardians must be natural persons.
|
||||
// The host client is filtered out so they can't be picked as their own guardian.
|
||||
this.clientsService.searchByText(text, 0, 8, '', '', true, 'INDIVIDUAL').subscribe({
|
||||
next: (data: any) => {
|
||||
this.guardianSearchResults = data?.content || [];
|
||||
this.guardianSearchResults = (data?.content || []).filter((c: any) => !this.isSelfClient(c?.id));
|
||||
this.guardianSearching = false;
|
||||
this.clientsService.enrichSearchResults(this.guardianSearchResults);
|
||||
},
|
||||
@@ -428,7 +445,15 @@ export class NomineeTabComponent implements OnInit {
|
||||
}, 400);
|
||||
}
|
||||
|
||||
/** Inline error shown above the guardian picker when self-select is attempted. */
|
||||
guardianSelfSelectError = '';
|
||||
|
||||
selectGuardian(client: any): void {
|
||||
if (this.isSelfClient(client?.id)) {
|
||||
this.guardianSelfSelectError = `You can't link this client as their own guardian. Please pick a different person.`;
|
||||
return;
|
||||
}
|
||||
this.guardianSelfSelectError = '';
|
||||
this.selectedGuardian = client;
|
||||
this.guardianSearchResults = [];
|
||||
this.guardianSearchText = '';
|
||||
|
||||
Reference in New Issue
Block a user