feat: edit client — save per tab, DOB re-KYC alert, audit logs, KYC improvements

- Edit button per tab with Save & Next confirmation dialog
- DOB change triggers re-KYC alert, resets PAN verification, auto-fills remarks
- Mobile/email changes logged as audit notes via client notes API
- Redesigned confirmation dialog with icon + title, type-based theming
- KYC re-edit flow: remarks mandatory for VERIFIED records, re-edit API command
- Load existing photo & signature in edit KYC, immediate upload on change
- Populate all KYC fields (document, AML, form60/15) from API on edit
- Non-individual PAN verification uses same flow as individual
- Fix table overflow when sidebar panel is open
- Add fr unit to stylelint allowed-list

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aditya
2026-04-07 15:08:07 +05:30
co-authored by Claude Opus 4.6
parent b890d24bb7
commit 79e455c4ed
14 changed files with 1699 additions and 398 deletions
+2 -1
View File
@@ -67,7 +67,8 @@
"vh",
"deg",
"s",
"ms"
"ms",
"fr"
]
}
}
@@ -1,5 +1,9 @@
<!--
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="mat-typography" [formGroup]="kycForm">
@@ -22,7 +26,6 @@
</h4>
<div class="form-grid">
<!-- ══ INDIVIDUAL KYC ══ -->
@if (!isNonIndividual) {
<div class="form-field checkbox-field full-width">
@@ -35,9 +38,16 @@
<mat-form-field appearance="outline" class="verify-input">
<mat-label>PAN Number</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>credit_card</mat-icon></span>
<input matInput formControlName="panNumber" placeholder="e.g. ABCDE1234F"
maxlength="10" style="text-transform: uppercase"
(input)="kycForm.get('panNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })" />
<input
matInput
formControlName="panNumber"
placeholder="e.g. ABCDE1234F"
maxlength="10"
style="text-transform: uppercase"
(input)="
kycForm.get('panNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
"
/>
@if (kycForm.get('panNumber')?.hasError('pattern') && kycForm.get('panNumber')?.touched) {
<mat-error>Invalid PAN format</mat-error>
}
@@ -51,9 +61,16 @@
</mat-form-field>
<div class="verify-action">
@if (panVerifyStatus === 'NONE' || panVerifyStatus === 'FAILED') {
<button mat-raised-button color="primary" type="button" class="verify-btn-inline"
[disabled]="readOnly || !kycForm.get('panNumber')?.value || kycForm.get('panNumber')?.invalid || panVerifying"
(click)="verifyPan()">
<button
mat-raised-button
color="primary"
type="button"
class="verify-btn-inline"
[disabled]="
readOnly || !kycForm.get('panNumber')?.value || kycForm.get('panNumber')?.invalid || panVerifying
"
(click)="verifyPan()"
>
@if (panVerifying) {
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
} @else {
@@ -79,10 +96,18 @@
</div>
<div class="pan-result-row">
<span class="pan-result-label">PAN–Aadhaar Link Status</span>
<span class="pan-result-value" [class.seeded]="panAadhaarSeedingStatus?.toUpperCase() === 'Y'" [class.not-seeded]="panAadhaarSeedingStatus?.toUpperCase() === 'N'">
@if (panAadhaarSeedingStatus?.toUpperCase() === 'Y') { Linked }
@else if (panAadhaarSeedingStatus?.toUpperCase() === 'N') { Not Linked }
@else { — }
<span
class="pan-result-value"
[class.seeded]="panAadhaarSeedingStatus?.toUpperCase() === 'Y'"
[class.not-seeded]="panAadhaarSeedingStatus?.toUpperCase() === 'N'"
>
@if (panAadhaarSeedingStatus?.toUpperCase() === 'Y') {
Linked
} @else if (panAadhaarSeedingStatus?.toUpperCase() === 'N') {
Not Linked
} @else {
—
}
</span>
</div>
<div class="pan-result-row">
@@ -99,10 +124,22 @@
<mat-form-field appearance="outline" class="verify-input">
<mat-label>Aadhaar Number</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>fingerprint</mat-icon></span>
<input matInput formControlName="aadhaarNumber" placeholder="12-digit Aadhaar"
maxlength="12" [type]="aadhaarVisible ? 'text' : 'password'" [readonly]="aadhaarOtpSent"
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()" />
<button mat-icon-button matSuffix type="button" (click)="aadhaarVisible = !aadhaarVisible" class="toggle-vis-btn">
<input
matInput
formControlName="aadhaarNumber"
placeholder="12-digit Aadhaar"
maxlength="12"
[type]="aadhaarVisible ? 'text' : 'password'"
[readonly]="aadhaarOtpSent"
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()"
/>
<button
mat-icon-button
matSuffix
type="button"
(click)="aadhaarVisible = !aadhaarVisible"
class="toggle-vis-btn"
>
<fa-icon [icon]="aadhaarVisible ? 'eye-slash' : 'eye'"></fa-icon>
</button>
@if (kycForm.get('aadhaarNumber')?.hasError('pattern') && kycForm.get('aadhaarNumber')?.touched) {
@@ -114,9 +151,19 @@
</mat-form-field>
<div class="verify-action">
@if ((aadhaarVerifyStatus === 'NONE' || aadhaarVerifyStatus === 'FAILED') && !aadhaarOtpSent) {
<button mat-raised-button color="primary" type="button" class="verify-btn-inline"
[disabled]="readOnly || !kycForm.get('aadhaarNumber')?.value || kycForm.get('aadhaarNumber')?.invalid || aadhaarVerifying"
(click)="sendAadhaarOtp()">
<button
mat-raised-button
color="primary"
type="button"
class="verify-btn-inline"
[disabled]="
readOnly ||
!kycForm.get('aadhaarNumber')?.value ||
kycForm.get('aadhaarNumber')?.invalid ||
aadhaarVerifying
"
(click)="sendAadhaarOtp()"
>
@if (aadhaarVerifying) {
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
} @else {
@@ -146,18 +193,28 @@
<label class="otp-card__label">Enter OTP</label>
<div class="otp-digits" (paste)="onOtpPaste($event)">
@for (digit of otpDigits; track $index) {
<input type="text" inputmode="numeric" maxlength="1"
class="otp-digit-box" [class.filled]="digit"
<input
type="text"
inputmode="numeric"
maxlength="1"
class="otp-digit-box"
[class.filled]="digit"
[value]="digit"
(input)="onOtpDigitInput($index, $event)"
(keydown)="onOtpKeydown($index, $event)"
autocomplete="one-time-code" />
autocomplete="one-time-code"
/>
}
</div>
<div class="otp-card__actions">
<button mat-flat-button color="primary" type="button" class="otp-card__verify-btn"
<button
mat-flat-button
color="primary"
type="button"
class="otp-card__verify-btn"
[disabled]="readOnly || aadhaarOtp.length < 6 || aadhaarOtpVerifying"
(click)="verifyAadhaarOtp()">
(click)="verifyAadhaarOtp()"
>
@if (aadhaarOtpVerifying) {
<mat-progress-spinner diameter="16" mode="indeterminate" class="otp-spinner"></mat-progress-spinner>
Verifying...
@@ -173,8 +230,13 @@
</span>
} @else {
<span class="otp-card__resend-text">Didn't receive the code?</span>
<button mat-button type="button" class="otp-card__resend-btn"
[disabled]="readOnly || aadhaarVerifying" (click)="sendAadhaarOtp()">
<button
mat-button
type="button"
class="otp-card__resend-btn"
[disabled]="readOnly || aadhaarVerifying"
(click)="sendAadhaarOtp()"
>
Resend OTP
</button>
}
@@ -185,7 +247,9 @@
}
@if (aadhaarVerifyError) {
<div class="verify-error full-width"><fa-icon icon="exclamation-circle"></fa-icon> {{ aadhaarVerifyError }}</div>
<div class="verify-error full-width">
<fa-icon icon="exclamation-circle"></fa-icon> {{ aadhaarVerifyError }}
</div>
}
@if (aadhaarVerifyStatus === 'VERIFIED' && aadhaarVerifyName) {
<div class="aadhaar-result-card full-width">
@@ -203,14 +267,25 @@
<!-- PAN-Aadhaar Seeding Status -->
@if (kycForm.get('hasPan')?.value && panAadhaarSeedingStatus) {
<div class="form-field pan-link-status full-width">
<div class="pan-link-box" [class.linked]="panAadhaarSeedingStatus === 'Y'" [class.not-linked]="panAadhaarSeedingStatus === 'N'" [class.exempt]="panAadhaarSeedingStatus === 'E'">
<div
class="pan-link-box"
[class.linked]="panAadhaarSeedingStatus === 'Y'"
[class.not-linked]="panAadhaarSeedingStatus === 'N'"
[class.exempt]="panAadhaarSeedingStatus === 'E'"
>
<fa-icon icon="link" class="pan-link-icon"></fa-icon>
<div class="pan-link-info">
<span class="pan-link-label">PAN–Aadhaar Link Status</span>
<span class="pan-link-value">
@if (panAadhaarSeedingStatus === 'Y') { Linked (Seeded) }
@if (panAadhaarSeedingStatus === 'N') { Not Linked }
@if (panAadhaarSeedingStatus === 'E') { Exempt }
@if (panAadhaarSeedingStatus === 'Y') {
Linked (Seeded)
}
@if (panAadhaarSeedingStatus === 'N') {
Not Linked
}
@if (panAadhaarSeedingStatus === 'E') {
Exempt
}
</span>
</div>
<span class="pan-link-hint">As per Income Tax Portal — Section 139AA</span>
@@ -233,9 +308,16 @@
<!-- GSTIN (all non-individual) -->
<mat-form-field appearance="outline" class="form-field">
<mat-label>GSTIN Number</mat-label>
<input matInput formControlName="gstinNumber" placeholder="15-character GSTIN"
maxlength="15" style="text-transform: uppercase"
(input)="kycForm.get('gstinNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })" />
<input
matInput
formControlName="gstinNumber"
placeholder="15-character GSTIN"
maxlength="15"
style="text-transform: uppercase"
(input)="
kycForm.get('gstinNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
"
/>
@if (kycForm.get('gstinNumber')?.hasError('pattern') && kycForm.get('gstinNumber')?.touched) {
<mat-error>Invalid GSTIN format (e.g. 22AAAAA0000A1Z5)</mat-error>
}
@@ -248,9 +330,16 @@
<mat-form-field appearance="outline" class="verify-input">
<mat-label>PAN of Proprietor</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>credit_card</mat-icon></span>
<input matInput formControlName="panNumber" placeholder="e.g. ABCDE1234F"
maxlength="10" style="text-transform: uppercase"
(input)="kycForm.get('panNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })" />
<input
matInput
formControlName="panNumber"
placeholder="e.g. ABCDE1234F"
maxlength="10"
style="text-transform: uppercase"
(input)="
kycForm.get('panNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
"
/>
@if (kycForm.get('panNumber')?.hasError('pattern') && kycForm.get('panNumber')?.touched) {
<mat-error>Invalid PAN format</mat-error>
}
@@ -264,9 +353,20 @@
</mat-form-field>
<div class="verify-action">
@if (panVerifyStatus === 'NONE' || panVerifyStatus === 'FAILED') {
<button mat-raised-button color="primary" type="button" class="verify-btn-inline"
[disabled]="readOnly || !kycForm.get('panNumber')?.value || kycForm.get('panNumber')?.invalid || panVerifying || !clientId"
(click)="verifyPan()">
<button
mat-raised-button
color="primary"
type="button"
class="verify-btn-inline"
[disabled]="
readOnly ||
!kycForm.get('panNumber')?.value ||
kycForm.get('panNumber')?.invalid ||
panVerifying ||
!clientId
"
(click)="verifyPan()"
>
@if (panVerifying) {
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
} @else {
@@ -303,9 +403,16 @@
<mat-form-field appearance="outline" class="verify-input">
<mat-label>{{ constitutionMeta?.panLabel || 'PAN of Entity' }}</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>credit_card</mat-icon></span>
<input matInput formControlName="enterprisePan" placeholder="e.g. ABCDE1234F"
maxlength="10" style="text-transform: uppercase"
(input)="kycForm.get('enterprisePan')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })" />
<input
matInput
formControlName="enterprisePan"
placeholder="e.g. ABCDE1234F"
maxlength="10"
style="text-transform: uppercase"
(input)="
kycForm.get('enterprisePan')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
"
/>
@if (kycForm.get('enterprisePan')?.hasError('pattern') && kycForm.get('enterprisePan')?.touched) {
<mat-error>Invalid PAN format</mat-error>
}
@@ -319,9 +426,20 @@
</mat-form-field>
<div class="verify-action">
@if (entPanVerifyStatus === 'NONE' || entPanVerifyStatus === 'FAILED') {
<button mat-raised-button color="primary" type="button" class="verify-btn-inline"
[disabled]="readOnly || !kycForm.get('enterprisePan')?.value || kycForm.get('enterprisePan')?.invalid || entPanVerifying || !clientId"
(click)="verifyEnterprisePan()">
<button
mat-raised-button
color="primary"
type="button"
class="verify-btn-inline"
[disabled]="
readOnly ||
!kycForm.get('enterprisePan')?.value ||
kycForm.get('enterprisePan')?.invalid ||
entPanVerifying ||
!clientId
"
(click)="verifyEnterprisePan()"
>
@if (entPanVerifying) {
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
} @else {
@@ -360,9 +478,16 @@
<mat-form-field appearance="outline" class="verify-input">
<mat-label>CIN (Corporate Identity Number)</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>business</mat-icon></span>
<input matInput formControlName="cinNumber" maxlength="21" placeholder="From MCA"
<input
matInput
formControlName="cinNumber"
maxlength="21"
placeholder="From MCA"
style="text-transform: uppercase"
(input)="kycForm.get('cinNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })" />
(input)="
kycForm.get('cinNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
"
/>
@if (kycForm.get('cinNumber')?.hasError('pattern') && kycForm.get('cinNumber')?.touched) {
<mat-error>Invalid CIN format (21 characters)</mat-error>
}
@@ -372,9 +497,14 @@
</mat-form-field>
<div class="verify-action">
@if (cinVerifyStatus === 'NONE' || cinVerifyStatus === 'FAILED') {
<button mat-raised-button color="primary" type="button" class="verify-btn-inline"
<button
mat-raised-button
color="primary"
type="button"
class="verify-btn-inline"
[disabled]="readOnly || !kycForm.get('cinNumber')?.value || cinVerifying"
(click)="verifyCin()">
(click)="verifyCin()"
>
@if (cinVerifying) {
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
} @else {
@@ -396,7 +526,10 @@
<div class="cin-progress-steps">
@for (step of cinProgressSteps; track step.label; let i = $index) {
<div class="cin-step" [class.active]="i === cinCurrentStep" [class.done]="i < cinCurrentStep">
<fa-icon [icon]="i < cinCurrentStep ? 'check-circle' : step.icon" [class.done-icon]="i < cinCurrentStep"></fa-icon>
<fa-icon
[icon]="i < cinCurrentStep ? 'check-circle' : step.icon"
[class.done-icon]="i < cinCurrentStep"
></fa-icon>
<span>{{ step.label }}</span>
</div>
}
@@ -467,12 +600,22 @@
<mat-form-field appearance="outline" class="form-field">
<mat-label>Document Number</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>tag</mat-icon></span>
<input matInput required formControlName="kycDocumentNumber" placeholder="As printed on document" maxlength="30"
<input
matInput
required
formControlName="kycDocumentNumber"
placeholder="As printed on document"
maxlength="30"
style="text-transform: uppercase"
(input)="kycForm.get('kycDocumentNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })" />
(input)="
kycForm.get('kycDocumentNumber')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
"
/>
@if (kycForm.get('kycDocumentNumber')?.hasError('required') && kycForm.get('kycDocumentNumber')?.touched) {
<mat-error>Document number is <strong>required</strong></mat-error>
} @else if (kycForm.get('kycDocumentNumber')?.hasError('pattern') && kycForm.get('kycDocumentNumber')?.touched) {
} @else if (
kycForm.get('kycDocumentNumber')?.hasError('pattern') && kycForm.get('kycDocumentNumber')?.touched
) {
<mat-error>Only letters, numbers, hyphens and slashes allowed</mat-error>
}
</mat-form-field>
@@ -517,13 +660,20 @@
}
</mat-select-trigger>
<mat-option [value]="1">
<span class="risk-option"><span class="risk-meter risk-low"></span> Low Risk <span class="risk-hint">10 yr renewal</span></span>
<span class="risk-option"
><span class="risk-meter risk-low"></span> Low Risk <span class="risk-hint">10 yr renewal</span></span
>
</mat-option>
<mat-option [value]="2">
<span class="risk-option"><span class="risk-meter risk-medium"></span> Medium Risk <span class="risk-hint">8 yr renewal</span></span>
<span class="risk-option"
><span class="risk-meter risk-medium"></span> Medium Risk
<span class="risk-hint">8 yr renewal</span></span
>
</mat-option>
<mat-option [value]="3">
<span class="risk-option"><span class="risk-meter risk-high"></span> High Risk <span class="risk-hint">2 yr renewal</span></span>
<span class="risk-option"
><span class="risk-meter risk-high"></span> High Risk <span class="risk-hint">2 yr renewal</span></span
>
</mat-option>
</mat-select>
</mat-form-field>
@@ -535,9 +685,27 @@
</mat-form-field>
<mat-form-field appearance="outline" class="form-field full-width">
<mat-label>Remarks</mat-label>
<textarea matInput formControlName="remarks" rows="2"></textarea>
<mat-label>{{ isKycVerified ? 'Reason for Re-edit *' : 'Remarks' }}</mat-label>
<textarea
matInput
formControlName="remarks"
rows="2"
[placeholder]="isKycVerified ? 'Mandatory — explain why KYC is being re-edited' : ''"
></textarea>
@if (isKycVerified && !kycForm.get('remarks')?.value?.trim()) {
<mat-error>Remarks are mandatory when updating a verified KYC record</mat-error>
}
</mat-form-field>
@if (isKycVerified) {
<div class="kyc-reedit-alert">
<mat-icon class="kyc-reedit-alert__icon">info</mat-icon>
<span
>KYC is <strong>VERIFIED</strong>. Any update will reset the status to <strong>IN PROGRESS</strong> and
require PAN re-verification.</span
>
</div>
}
</div>
<!-- KYC Expiry Alert Cards -->
@@ -552,21 +720,38 @@
<div class="kyc-expiry-card" [class.active]="kycForm.get('riskCategory')?.value === rc.value">
<span class="risk-gauge" [class]="'risk-gauge--' + rc.color">
<svg viewBox="0 0 40 24" class="risk-gauge-svg">
<path d="M4 22 A16 16 0 0 1 36 22" fill="none" stroke="#e2e8f0" stroke-width="4" stroke-linecap="round"/>
<path d="M4 22 A16 16 0 0 1 36 22" fill="none"
<path
d="M4 22 A16 16 0 0 1 36 22"
fill="none"
stroke="#e2e8f0"
stroke-width="4"
stroke-linecap="round"
/>
<path
d="M4 22 A16 16 0 0 1 36 22"
fill="none"
[attr.stroke]="rc.color === 'green' ? '#22c55e' : rc.color === 'amber' ? '#f59e0b' : '#ef4444'"
stroke-width="4" stroke-linecap="round"
[attr.stroke-dasharray]="rc.color === 'green' ? '16 50' : rc.color === 'amber' ? '33 50' : '50 50'"/>
<line x1="20" y1="22"
stroke-width="4"
stroke-linecap="round"
[attr.stroke-dasharray]="rc.color === 'green' ? '16 50' : rc.color === 'amber' ? '33 50' : '50 50'"
/>
<line
x1="20"
y1="22"
[attr.x2]="rc.color === 'green' ? '10' : rc.color === 'amber' ? '20' : '30'"
[attr.y2]="rc.color === 'green' ? '10' : rc.color === 'amber' ? '6' : '10'"
stroke="#334155" stroke-width="2" stroke-linecap="round"/>
stroke="#334155"
stroke-width="2"
stroke-linecap="round"
/>
<circle cx="20" cy="22" r="2" fill="#334155" />
</svg>
</span>
<div class="kyc-expiry-card__info">
<span class="kyc-expiry-card__label">{{ rc.label }}</span>
<span class="kyc-expiry-card__cycle">Renewal every <strong>{{ rc.years }} years</strong></span>
<span class="kyc-expiry-card__cycle"
>Renewal every <strong>{{ rc.years }} years</strong></span
>
</div>
@if (kycForm.get('riskCategory')?.value === rc.value) {
<span class="kyc-expiry-card__next">Next: {{ getKycExpiryDate(rc.years) }}</span>
@@ -575,12 +760,17 @@
}
</div>
@if (kycForm.get('riskCategory')?.value) {
<div class="kyc-expiry-alert"
<div
class="kyc-expiry-alert"
[class.kyc-expiry-alert--low]="kycForm.get('riskCategory')?.value === 1"
[class.kyc-expiry-alert--medium]="kycForm.get('riskCategory')?.value === 2"
[class.kyc-expiry-alert--high]="kycForm.get('riskCategory')?.value === 3">
[class.kyc-expiry-alert--high]="kycForm.get('riskCategory')?.value === 3"
>
<fa-icon icon="exclamation-circle" class="kyc-expiry-alert__icon"></fa-icon>
<span>System will auto-alert before KYC expiry on <strong>{{ getKycExpiryDate(getRenewalYears()) }}</strong>.</span>
<span
>System will auto-alert before KYC expiry on <strong>{{ getKycExpiryDate(getRenewalYears()) }}</strong
>.</span
>
</div>
}
</div>
@@ -17,7 +17,7 @@
font-size: 15px;
font-weight: 600;
color: #333;
margin: 0 0 16px 0;
margin: 0 0 16px;
.section-icon {
color: var(--md-sys-color-primary, #1074b9);
@@ -119,7 +119,7 @@
&.active {
border-color: #1353a0;
background: #eff6ff;
box-shadow: 0 0 0 2px rgba(19, 83, 160, 0.12);
box-shadow: 0 0 0 2px rgb(19 83 160 / 12%);
}
&__dot {
@@ -128,9 +128,17 @@
border-radius: 50%;
flex-shrink: 0;
&.green { background: #10b981; }
&.amber { background: #f59e0b; }
&.red { background: #ef4444; }
&.green {
background: #10b981;
}
&.amber {
background: #f59e0b;
}
&.red {
background: #ef4444;
}
}
&__info {
@@ -186,7 +194,9 @@
color: #065f46;
border: 1px solid #a7f3d0;
.kyc-expiry-alert__icon { color: #10b981; }
.kyc-expiry-alert__icon {
color: #10b981;
}
}
&--medium {
@@ -194,7 +204,9 @@
color: #92400e;
border: 1px solid #fde68a;
.kyc-expiry-alert__icon { color: #f59e0b; }
.kyc-expiry-alert__icon {
color: #f59e0b;
}
}
&--high {
@@ -202,11 +214,13 @@
color: #991b1b;
border: 1px solid #fecaca;
.kyc-expiry-alert__icon { color: #ef4444; }
.kyc-expiry-alert__icon {
color: #ef4444;
}
}
}
@media (max-width: 768px) {
@media (width <= 768px) {
.kyc-expiry-cards {
grid-template-columns: 1fr;
}
@@ -451,7 +465,7 @@
border: 1px solid #e0e7ff;
overflow: hidden;
background: #fff;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.08);
box-shadow: 0 2px 8px rgb(99 102 241 / 8%);
margin-bottom: 8px;
}
@@ -475,7 +489,11 @@
color: #fff;
flex-shrink: 0;
mat-icon { font-size: 16px; width: 16px; height: 16px; }
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
.otp-card__header-text {
@@ -536,7 +554,7 @@
&:focus {
border-color: #1074b9;
background: #fff;
box-shadow: 0 0 0 2px rgba(16, 116, 185, 0.12);
box-shadow: 0 0 0 2px rgb(16 116 185 / 12%);
}
&.filled {
@@ -652,8 +670,10 @@
color: #065f46;
}
@media (max-width: 480px) {
.otp-digits { gap: 6px; }
@media (width <= 480px) {
.otp-digits {
gap: 6px;
}
.otp-digit-box {
width: 40px;
@@ -662,8 +682,13 @@
border-radius: 8px;
}
.otp-card__header { padding: 12px 14px; }
.otp-card__body { padding: 20px 14px; }
.otp-card__header {
padding: 12px 14px;
}
.otp-card__body {
padding: 20px 14px;
}
}
/* PAN-Aadhaar Link Status (read-only) */
@@ -683,22 +708,40 @@
&.linked {
border-color: #a7f3d0;
background: #ecfdf5;
.pan-link-value { color: #059669; }
.pan-link-icon { color: #059669; }
.pan-link-value {
color: #059669;
}
.pan-link-icon {
color: #059669;
}
}
&.not-linked {
border-color: #fecaca;
background: #fef2f2;
.pan-link-value { color: #dc2626; }
.pan-link-icon { color: #dc2626; }
.pan-link-value {
color: #dc2626;
}
.pan-link-icon {
color: #dc2626;
}
}
&.exempt {
border-color: #bfdbfe;
background: #eff6ff;
.pan-link-value { color: #2563eb; }
.pan-link-icon { color: #2563eb; }
.pan-link-value {
color: #2563eb;
}
.pan-link-icon {
color: #2563eb;
}
}
}
@@ -1037,7 +1080,7 @@
}
}
@media (max-width: 768px) {
@media (width <= 768px) {
.form-grid {
grid-template-columns: 1fr;
}
@@ -1073,7 +1116,7 @@
&__sub {
font-size: 11px;
color: rgba(255, 255, 255, 0.5);
color: rgb(255 255 255 / 50%);
margin: 2px 0 0;
}
}
@@ -1092,9 +1135,20 @@
border-radius: 50%;
flex-shrink: 0;
&.risk-low { background: #22c55e; box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.2); }
&.risk-medium { background: #f59e0b; box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.2); }
&.risk-high { background: #ef4444; box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.2); }
&.risk-low {
background: #22c55e;
box-shadow: 0 0 0 3px rgb(34 197 94 / 20%);
}
&.risk-medium {
background: #f59e0b;
box-shadow: 0 0 0 3px rgb(245 158 11 / 20%);
}
&.risk-high {
background: #ef4444;
box-shadow: 0 0 0 3px rgb(239 68 68 / 20%);
}
}
.risk-hint {
@@ -1133,3 +1187,30 @@
}
}
/* ─── Re-edit Info Alert ─── */
.kyc-reedit-alert {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 16px;
margin-top: 8px;
background: #eff6ff;
border: 1px solid #93c5fd;
border-radius: 8px;
font-size: 13px;
line-height: 1.5;
color: #1e40af;
&__icon {
color: #3b82f6;
font-size: 20px;
width: 20px;
height: 20px;
margin-top: 1px;
flex-shrink: 0;
}
strong {
font-weight: 600;
}
}
@@ -1,5 +1,9 @@
/**
* 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, Input, OnInit, OnChanges, OnDestroy, SimpleChanges, inject } from '@angular/core';
@@ -54,77 +58,88 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
kycId: string = '';
/** Whether existing KYC data has been loaded (edit mode) */
kycLoaded = false;
/** KYC status from API: 100=PENDING, 200=IN_PROGRESS, 300=VERIFIED */
kycStatus: number = 0;
/** Whether the KYC record is in VERIFIED state (needs re-edit command) */
get isKycVerified(): boolean {
return this.kycStatus === 300;
}
/** KYC Document Types — populated from API template, falls back to defaults */
kycDocumentTypes: { name: string; code: string; mandatory: boolean }[] = [];
/** Fallback document types when template API is unavailable */
private defaultDocumentTypes = [
'Aadhaar Card', 'Passport', 'PAN Card', 'Driving Licence',
'Voter ID', 'NREGA Job Card', 'National Population Register Letter'
'Aadhaar Card',
'Passport',
'PAN Card',
'Driving Licence',
'Voter ID',
'NREGA Job Card',
'National Population Register Letter'
];
/** Constitution-based KYC document types for non-individual clients */
private constitutionDocTypes: Record<string, { name: string; mandatory: boolean }[]> = {
'HUF': [
HUF: [
{ name: 'PAN Card of HUF', mandatory: true },
{ name: 'Declaration by Karta (with name, address & HUF member details)', mandatory: true },
{ name: 'KYC of Karta (OVDs + PAN + Photo)', mandatory: true },
{ name: 'Proof of Address of HUF', mandatory: false }
],
'PRIVATE_LIMITED': [
PRIVATE_LIMITED: [
{ name: 'Certificate of Incorporation', mandatory: true },
{ name: 'Memorandum & Articles of Association', mandatory: true },
{ name: 'PAN of Company', mandatory: true },
{ name: 'Board Resolution (authorizing signatories)', mandatory: true },
{ name: 'KYC of Authorized Signatories', mandatory: true }
],
'PUBLIC_LIMITED': [
PUBLIC_LIMITED: [
{ name: 'Certificate of Incorporation', mandatory: true },
{ name: 'Memorandum & Articles of Association', mandatory: true },
{ name: 'PAN of Company', mandatory: true },
{ name: 'Board Resolution (authorizing signatories)', mandatory: true },
{ name: 'KYC of Authorized Signatories', mandatory: true }
],
'LLP': [
LLP: [
{ name: 'Certificate of Incorporation', mandatory: true },
{ name: 'LLP Agreement', mandatory: true },
{ name: 'PAN of LLP', mandatory: true },
{ name: 'Designated Partners Resolution', mandatory: true },
{ name: 'KYC of Designated Partners / Authorized Signatories', mandatory: true }
],
'PARTNERSHIP': [
PARTNERSHIP: [
{ name: 'Registration Certificate', mandatory: false },
{ name: 'Partnership Deed', mandatory: true },
{ name: 'PAN of Firm', mandatory: true },
{ name: 'KYC of Authorized Signatories', mandatory: true }
],
'TRUST': [
TRUST: [
{ name: 'Registration Certificate', mandatory: true },
{ name: 'Trust Deed', mandatory: true },
{ name: 'PAN of Trust', mandatory: true },
{ name: 'KYC of Trustees / Authorized Signatories', mandatory: true }
],
'SOCIETY': [
SOCIETY: [
{ name: 'Registration Certificate', mandatory: true },
{ name: 'PAN of Society', mandatory: true },
{ name: 'KYC of Authorized Signatories', mandatory: true }
],
'PROPRIETORSHIP': [
PROPRIETORSHIP: [
{ name: 'Proof of Business Existence (GST Certificate / Shop License)', mandatory: true },
{ name: 'PAN of Proprietor', mandatory: true },
{ name: "Proprietor's KYC (OVDs + PAN + Photo)", mandatory: true }
],
'ASSOCIATION': [
ASSOCIATION: [
{ name: 'Registration Certificate', mandatory: true },
{ name: 'PAN', mandatory: true },
{ name: 'Resolution authorizing signatories', mandatory: true },
{ name: 'KYC of Authorized Signatories', mandatory: true }
],
'GOVT_PSU': [
GOVT_PSU: [
{ name: 'Government Notification / Order', mandatory: true },
{ name: 'PAN (if applicable)', mandatory: false },
{ name: 'Authorized Signatory KYC', mandatory: true }
],
'COOPERATIVE': [
COOPERATIVE: [
{ name: 'Registration Certificate', mandatory: true },
{ name: 'PAN', mandatory: true },
{ name: 'Resolution authorizing signatories', mandatory: true },
@@ -161,7 +176,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
aadhaarOtpReferenceId = '';
/** OTP digit boxes */
otpDigits: string[] = ['', '', '', '', '', ''];
otpDigits: string[] = [
'',
'',
'',
'',
'',
''
];
/** Resend countdown */
resendCountdown = 0;
private resendTimer: any = null;
@@ -221,7 +243,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
}
resetOtpDigits(): void {
this.otpDigits = ['', '', '', '', '', ''];
this.otpDigits = [
'',
'',
'',
'',
'',
''
];
this.aadhaarOtp = '';
}
@@ -254,25 +283,73 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
photographPreview: string | null = null;
signatureFile: File | null = null;
signaturePreview: string | null = null;
/** Existing signature document ID (for re-upload/delete) */
existingSignatureDocId: string = '';
/** KYC Form */
kycForm: UntypedFormGroup = this.formBuilder.group({
hasPan: [true],
panNumber: ['', [Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/), Validators.maxLength(10)]],
aadhaarNumber: ['', [Validators.pattern(/^\d{12}$/), Validators.maxLength(12)]],
panNumber: [
'',
[
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/),
Validators.maxLength(10)
]
],
aadhaarNumber: [
'',
[
Validators.pattern(/^\d{12}$/),
Validators.maxLength(12)
]
],
panAadhaarSeedingStatus: [''],
gstinNumber: ['', [Validators.pattern(/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/), Validators.maxLength(15)]],
enterprisePan: ['', [Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/), Validators.maxLength(10)]],
cinNumber: ['', [Validators.pattern(/^[A-Z]{1}[0-9]{5}[A-Z]{2}[0-9]{4}[A-Z]{3}[0-9]{6}$/), Validators.maxLength(21)]],
gstinNumber: [
'',
[
Validators.pattern(/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/),
Validators.maxLength(15)
]
],
enterprisePan: [
'',
[
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/),
Validators.maxLength(10)
]
],
cinNumber: [
'',
[
Validators.pattern(/^[A-Z]{1}[0-9]{5}[A-Z]{2}[0-9]{4}[A-Z]{3}[0-9]{6}$/),
Validators.maxLength(21)
]
],
incorporationDate: [''],
kycDocumentName: ['', Validators.required],
kycDocumentNumber: ['', [Validators.required, Validators.pattern(/^[A-Za-z0-9\-\/\s]+$/), Validators.maxLength(30)]],
kycDocumentName: [
'',
Validators.required
],
kycDocumentNumber: [
'',
[
Validators.required,
Validators.pattern(/^[A-Za-z0-9\-\/\s]+$/),
Validators.maxLength(30)
]
],
kycDocIssueDate: [''],
kycDocExpiryDate: [''],
riskCategory: [1],
remarks: [''],
amlCheckStatus: ['CLEAR', Validators.required],
bannedEntityMatch: ['NO', Validators.required],
amlCheckStatus: [
'CLEAR',
Validators.required
],
bannedEntityMatch: [
'NO',
Validators.required
],
willfulDefaulter: ['NO'],
ecgcSalFlag: ['NO'],
dormantFlag: ['ACTIVE'],
@@ -320,7 +397,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
private updateDocTypesForConstitution(): void {
const constitutionValue = this.constitutionMeta?.value;
if (constitutionValue && this.constitutionDocTypes[constitutionValue]) {
this.kycDocumentTypes = this.constitutionDocTypes[constitutionValue].map(doc => ({
this.kycDocumentTypes = this.constitutionDocTypes[constitutionValue].map((doc) => ({
name: doc.name,
code: doc.name,
mandatory: doc.mandatory
@@ -339,13 +416,51 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
this.kycLoaded = true;
if (data) {
this.kycId = data.id?.toString() || '';
this.kycStatus = data.status?.id || data.kycStatus || 0;
// Helper to parse API date arrays [y,m,d] or strings into Date objects
const parseDate = (val: any): Date | string => {
if (Array.isArray(val) && val.length >= 3) {
return new Date(val[0], val[1] - 1, val[2]);
}
if (val && typeof val === 'string') {
const d = new Date(val);
return isNaN(d.getTime()) ? '' : d;
}
return '';
};
this.kycForm.patchValue({
panNumber: data.panNumber || '',
aadhaarNumber: data.aadhaarNumberMasked || '',
gstinNumber: data.gstinNumber || '',
enterprisePan: data.enterprisePan || '',
cinNumber: data.cinNumber || '',
incorporationDate: parseDate(data.incorporationDate),
// KYC Document
kycDocumentName: data.kycDocumentName || '',
kycDocumentNumber: data.kycDocumentNumber || '',
kycDocIssueDate: parseDate(data.kycDocIssueDate),
kycDocExpiryDate: parseDate(data.kycDocExpiryDate),
// Risk & Remarks
riskCategory: data.riskCategory || 1,
remarks: data.remarks || '',
hasPan: !!data.panNumber
hasPan: !!data.panNumber,
// AML & Compliance
amlCheckStatus: data.amlCheckStatus || 'CLEAR',
bannedEntityMatch: data.bannedEntityMatch || 'NO',
willfulDefaulter: data.willfulDefaulter || 'NO',
ecgcSalFlag: data.ecgcSalFlag || 'NO',
dormantFlag: data.dormantFlag || 'ACTIVE',
// Account Status & Freeze
accountStatus: data.accountStatus || 'ACTIVE',
freezeReason: data.freezeReason || '',
freezeAuthority: data.freezeAuthority || '',
// Form 60 / Form 15
form60Type: data.form60Type || 'FORM_60',
form60Date: parseDate(data.form60Date),
form15Applicable: data.form15Applicable || false,
form15Type: data.form15Type || '15G',
form15FY: data.form15FY || ''
});
// Set verification statuses from existing data
if (data.panVerified) {
@@ -366,6 +481,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
this.panAadhaarSeedingStatus = data.panAadhaarSeedingStatus;
}
}
// Load existing photo & signature
this.loadExistingPhoto();
this.loadExistingSignature();
},
error: () => {
// No existing KYC — that's fine for new clients
@@ -374,6 +492,45 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
});
}
/** Fetch existing client profile photo */
private loadExistingPhoto(): void {
if (!this.clientId) return;
this.clientsService.getClientProfileImage(this.clientId).subscribe({
next: (imageData: any) => {
if (imageData) {
this.photographPreview = imageData;
}
},
error: () => {}
});
}
/** Fetch existing client signature from documents */
private loadExistingSignature(): void {
if (!this.clientId) return;
this.clientsService.getClientDocuments(this.clientId).subscribe({
next: (documents: any[]) => {
const sigDoc = documents?.find((d: any) => d.name === 'clientSignature');
if (sigDoc?.id) {
this.existingSignatureDocId = sigDoc.id.toString();
this.clientsService.getClientSignatureImage(this.clientId, this.existingSignatureDocId).subscribe({
next: async (blob: any) => {
const buffer = await blob.arrayBuffer();
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
this.signaturePreview = 'data:' + blob.type + ';base64,' + btoa(binary);
},
error: () => {}
});
}
},
error: () => {}
});
}
/** Load KYC template to get required documents based on client type */
private loadKycTemplate(): void {
if (!this.clientId || this.kycTemplateLoaded) return;
@@ -388,7 +545,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
mandatory: doc.mandatory ?? false
}));
// Update kycDocumentName validator based on mandatory docs
const hasMandatory = this.kycDocumentTypes.some(d => d.mandatory);
const hasMandatory = this.kycDocumentTypes.some((d) => d.mandatory);
if (hasMandatory) {
this.kycForm.get('kycDocumentName')?.setValidators(Validators.required);
}
@@ -398,7 +555,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
error: () => {
// Fallback to defaults if template API unavailable
this.kycTemplateLoaded = true;
this.kycDocumentTypes = this.defaultDocumentTypes.map(name => ({
this.kycDocumentTypes = this.defaultDocumentTypes.map((name) => ({
name,
code: name,
mandatory: false
@@ -410,19 +567,48 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
/** Save or update KYC, then execute callback */
private saveKycAndDo(payload: any, callback: () => void, onError: (msg: string) => void) {
if (this.kycId) {
// KYC already exists — use PUT to update (strip create-only fields)
const { kycType, ...updatePayload } = payload;
// If KYC is VERIFIED, remarks are mandatory — use re-edit command
if (this.isKycVerified) {
const remarks = this.kycForm.get('remarks')?.value?.trim();
if (!remarks) {
onError('Remarks are mandatory when updating a verified KYC record.');
this.kycForm.get('remarks')?.markAsTouched();
return;
}
updatePayload.remarks = remarks;
this.clientsService.reEditClientKyc(this.clientId, this.kycId, updatePayload).subscribe({
next: () => {
// Status transitions to IN_PROGRESS, PAN verification is invalidated
this.kycStatus = 200;
this.panVerifyStatus = 'NONE';
this.panVerifyName = '';
this.panVerifiedOn = '';
callback();
},
error: (err: any) =>
onError(
err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'Failed to re-edit KYC.'
)
});
} else {
// Normal update for non-verified records
this.clientsService.updateClientKyc(this.clientId, this.kycId, updatePayload).subscribe({
next: () => callback(),
error: (err: any) => onError(err?.error?.defaultUserMessage || 'Failed to update KYC.')
});
}
} else {
// First time — use POST to create
this.clientsService.saveClientKyc(this.clientId, payload).subscribe({
next: (res: any) => {
this.kycId = res?.resourceId?.toString() || '';
if (this.kycId) { callback(); }
else { onError('Failed to save KYC.'); }
if (this.kycId) {
callback();
} else {
onError('Failed to save KYC.');
}
},
error: (err: any) => onError(err?.error?.defaultUserMessage || 'Failed to save KYC.')
});
@@ -436,7 +622,10 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
return;
}
const pan = this.kycForm.value.panNumber;
if (!pan) { this.panVerifyError = 'Enter PAN number first.'; return; }
if (!pan) {
this.panVerifyError = 'Enter PAN number first.';
return;
}
this.panVerifying = true;
this.panVerifyError = '';
@@ -451,21 +640,31 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
aadhaarNumberMasked: aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : 'XXXXXXXX0000'
};
this.saveKycAndDo(panPayload, () => {
this.saveKycAndDo(
panPayload,
() => {
this.clientsService.verifyPAN(this.clientId, this.kycId).subscribe({
next: (res: any) => {
this.panVerifying = false;
if (res?.changes?.panVerified === true) {
// Full name from API
const resolvedName = res.changes?.panFullName
|| res.changes?.panHolderName
|| [res.changes?.panFirstName, res.changes?.panLastName].filter(Boolean).join(' ')
|| '';
const resolvedName =
res.changes?.panFullName ||
res.changes?.panHolderName ||
[
res.changes?.panFirstName,
res.changes?.panLastName
]
.filter(Boolean)
.join(' ') ||
'';
// If name is empty, the client name/DOB didn't match PAN holder
if (!resolvedName || !resolvedName.trim()) {
this.panVerifyStatus = 'FAILED';
this.showPanErrorAlert('PAN verification failed: Client name or date of birth does not match the PAN card holder. Please ensure the client\'s name and date of birth exactly match the PAN card details.');
this.showPanErrorAlert(
"PAN verification failed: Client name or date of birth does not match the PAN card holder. Please ensure the client's name and date of birth exactly match the PAN card details."
);
return;
}
@@ -493,40 +692,47 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
error: (err: any) => {
this.panVerifying = false;
this.panVerifyStatus = 'FAILED';
const msg = err?.error?.errors?.[0]?.defaultUserMessage
|| err?.error?.defaultUserMessage
|| 'PAN verification failed.';
const msg =
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
'PAN verification failed.';
this.showPanErrorAlert(msg);
}
});
}, (msg) => { this.panVerifying = false; this.showPanErrorAlert(msg); });
},
(msg) => {
this.panVerifying = false;
this.showPanErrorAlert(msg);
}
);
}
/** Verify Enterprise PAN (non-individual) — uses same verify-pan API */
/**
* Verify Enterprise PAN (non-individual) — same flow as individual PAN verification.
* Maps: Company PAN → panNumber, Company Name → client name, Date of Incorporation → DOB
*/
verifyEnterprisePan() {
if (!this.clientId) return;
const pan = this.kycForm.value.enterprisePan;
if (!pan || this.kycForm.get('enterprisePan')?.invalid) return;
this.entPanVerifying = true;
const dateFormat = this.settingsService.dateFormat;
const incorpDate = this.kycForm.value.incorporationDate;
// Same payload structure as individual — backend uses panNumber + client name/DOB for verification
const payload: any = {
locale: 'en',
kycType: 1,
riskCategory: this.kycForm.value.riskCategory || 1,
panNumber: pan,
dateFormat: this.settingsService.dateFormat
panNumber: pan, // Company PAN → panNumber
dateFormat,
aadhaarNumberMasked: 'XXXXXXXX0000' // Placeholder — not applicable for entities
};
if (incorpDate) {
payload.dateOfIncorporation = incorpDate instanceof Date
? this.dateUtils.formatDate(incorpDate, this.settingsService.dateFormat)
: incorpDate;
}
if (this.entityName) {
payload.companyFullName = this.entityName;
}
this.saveKycAndDo(payload, () => {
this.saveKycAndDo(
payload,
() => {
this.clientsService.verifyPAN(this.clientId, this.kycId).subscribe({
next: (res: any) => {
this.entPanVerifying = false;
@@ -538,20 +744,32 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
this.entPanVerifiedOn = `${rawDate[2]?.toString().padStart(2, '0')}/${rawDate[1]?.toString().padStart(2, '0')}/${rawDate[0]}`;
} else {
const now = new Date();
this.entPanVerifiedOn = rawDate || `${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`;
this.entPanVerifiedOn =
rawDate ||
`${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()}`;
}
} else {
this.entPanVerifyStatus = 'FAILED';
this.showPanErrorAlert(res?.changes?.panVerificationMessage || 'Enterprise PAN verification failed.');
this.showPanErrorAlert(
res?.changes?.panVerificationMessage ||
'Enterprise PAN verification failed. Ensure company name and date of incorporation match the PAN records.'
);
}
},
error: (err: any) => {
this.entPanVerifying = false;
this.entPanVerifyStatus = 'FAILED';
this.showPanErrorAlert(err?.error?.errors?.[0]?.defaultUserMessage || 'Enterprise PAN verification failed.');
this.showPanErrorAlert(
err?.error?.errors?.[0]?.defaultUserMessage || 'Enterprise PAN verification failed.'
);
}
});
}, (msg) => { this.entPanVerifying = false; this.showPanErrorAlert(msg); });
},
(msg) => {
this.entPanVerifying = false;
this.showPanErrorAlert(msg);
}
);
}
/** Verify CIN (dummy — no real API) */
@@ -563,7 +781,12 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
this.cinProgressValue = 0;
this.cinCurrentStep = 0;
const stepDurations = [800, 1000, 1200, 800];
const stepDurations = [
800,
1000,
1200,
800
];
const totalDuration = stepDurations.reduce((a, b) => a + b, 0);
let elapsed = 0;
const interval = 50;
@@ -573,7 +796,10 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
let accum = 0;
for (let i = 0; i < stepDurations.length; i++) {
accum += stepDurations[i];
if (elapsed <= accum) { this.cinCurrentStep = i; break; }
if (elapsed <= accum) {
this.cinCurrentStep = i;
break;
}
}
this.cinProgressValue = Math.min(Math.round((elapsed / totalDuration) * 100), 100);
@@ -598,7 +824,10 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
return;
}
const aadhaar = this.kycForm.value.aadhaarNumber;
if (!aadhaar || aadhaar.length !== 12) { this.aadhaarVerifyError = 'Enter valid 12-digit Aadhaar first.'; return; }
if (!aadhaar || aadhaar.length !== 12) {
this.aadhaarVerifyError = 'Enter valid 12-digit Aadhaar first.';
return;
}
this.aadhaarVerifying = true;
this.aadhaarVerifyError = '';
@@ -611,9 +840,13 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
aadhaarNumberMasked: aadhaarMasked
};
const panVal = this.kycForm.value.panNumber;
if (panVal) { aadhaarPayload.panNumber = panVal; }
if (panVal) {
aadhaarPayload.panNumber = panVal;
}
this.saveKycAndDo(aadhaarPayload, () => {
this.saveKycAndDo(
aadhaarPayload,
() => {
this.clientsService.sendAadhaarOtp(this.clientId, this.kycId, aadhaar).subscribe({
next: (res: any) => {
this.aadhaarVerifying = false;
@@ -627,7 +860,12 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
this.aadhaarVerifyError = err?.error?.defaultUserMessage || 'Failed to send OTP.';
}
});
}, (msg) => { this.aadhaarVerifying = false; this.aadhaarVerifyError = msg; });
},
(msg) => {
this.aadhaarVerifying = false;
this.aadhaarVerifyError = msg;
}
);
}
/** Verify Aadhaar OTP */
@@ -636,7 +874,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
this.aadhaarOtpVerifying = true;
this.aadhaarVerifyError = '';
this.clientsService.verifyAadhaarOtp(this.clientId, this.kycId, this.aadhaarOtp, this.aadhaarOtpReferenceId).subscribe({
this.clientsService
.verifyAadhaarOtp(this.clientId, this.kycId, this.aadhaarOtp, this.aadhaarOtpReferenceId)
.subscribe({
next: (res: any) => {
this.aadhaarOtpVerifying = false;
if (res?.changes?.aadhaarVerified === true) {
@@ -667,17 +907,34 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
getRenewalYears(): number {
switch (this.kycForm.value.riskCategory) {
case 1: return 10;
case 2: return 8;
case 3: return 2;
default: return 10;
case 1:
return 10;
case 2:
return 8;
case 3:
return 2;
default:
return 10;
}
}
getKycExpiryDate(years: number): string {
const d = new Date();
d.setFullYear(d.getFullYear() + years);
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec'
];
return `${String(d.getDate()).padStart(2, '0')} ${months[d.getMonth()]} ${d.getFullYear()}`;
}
@@ -688,11 +945,21 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
input.value = '';
const { ImageCropDialogComponent } = await import('./image-crop-dialog/image-crop-dialog.component');
const dialogRef = this.dialog.open(ImageCropDialogComponent, {
width: '680px', maxWidth: '95vw',
width: '680px',
maxWidth: '95vw',
data: { file, title: 'Crop Client Photograph', aspectRatio: 1, maxWidth: 300, maxHeight: 300 }
});
dialogRef.afterClosed().subscribe((result: any) => {
if (result) { this.photographFile = result.croppedFile; this.photographPreview = result.previewUrl; }
if (result) {
this.photographFile = result.croppedFile;
this.photographPreview = result.previewUrl;
// In edit mode, upload immediately
if (this.clientId) {
this.clientsService
.uploadClientProfileImage(this.clientId, result.croppedFile)
.subscribe({ error: () => {} });
}
}
});
}
@@ -703,16 +970,70 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
input.value = '';
const { ImageCropDialogComponent } = await import('./image-crop-dialog/image-crop-dialog.component');
const dialogRef = this.dialog.open(ImageCropDialogComponent, {
width: '680px', maxWidth: '95vw',
width: '680px',
maxWidth: '95vw',
data: { file, title: 'Crop Specimen Signature', aspectRatio: 2.5, maxWidth: 400, maxHeight: 160 }
});
dialogRef.afterClosed().subscribe((result: any) => {
if (result) { this.signatureFile = result.croppedFile; this.signaturePreview = result.previewUrl; }
if (result) {
this.signatureFile = result.croppedFile;
this.signaturePreview = result.previewUrl;
// In edit mode, delete old signature doc then upload new one
if (this.clientId) {
if (this.existingSignatureDocId) {
this.clientsService.deleteClientDocument(this.clientId, this.existingSignatureDocId).subscribe({
next: () => {
this.clientsService.uploadClientSignatureImage(this.clientId, result.croppedFile).subscribe({
next: (res: any) => {
if (res?.resourceId) this.existingSignatureDocId = res.resourceId.toString();
},
error: () => {}
});
},
error: () => {
this.clientsService.uploadClientSignatureImage(this.clientId, result.croppedFile).subscribe({
next: (res: any) => {
if (res?.resourceId) this.existingSignatureDocId = res.resourceId.toString();
},
error: () => {}
});
}
});
} else {
this.clientsService.uploadClientSignatureImage(this.clientId, result.croppedFile).subscribe({
next: (res: any) => {
if (res?.resourceId) this.existingSignatureDocId = res.resourceId.toString();
},
error: () => {}
});
}
}
}
});
}
removePhoto() { this.photographFile = null; this.photographPreview = null; }
removeSignature() { this.signatureFile = null; this.signaturePreview = null; }
removePhoto() {
this.photographFile = null;
this.photographPreview = null;
// In edit mode, delete from server
if (this.clientId) {
this.clientsService.deleteClientProfileImage(this.clientId).subscribe({ error: () => {} });
}
}
removeSignature() {
this.signatureFile = null;
this.signaturePreview = null;
// In edit mode, delete from server
if (this.clientId && this.existingSignatureDocId) {
this.clientsService.deleteClientDocument(this.clientId, this.existingSignatureDocId).subscribe({
next: () => {
this.existingSignatureDocId = '';
},
error: () => {}
});
}
}
get kycData(): any {
const formVal = this.readOnly ? this.kycForm.getRawValue() : this.kycForm.value;
@@ -720,15 +1041,16 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
const aadhaarMasked = aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : null;
const dateFormat = this.settingsService.dateFormat;
const v = formVal;
const formatDate = (d: any) => d instanceof Date ? this.dateUtils.formatDate(d, dateFormat) : (d || null);
const formatDate = (d: any) => (d instanceof Date ? this.dateUtils.formatDate(d, dateFormat) : d || null);
return {
locale: 'en',
dateFormat,
kycType: 1,
riskCategory: v.riskCategory || 1,
panNumber: v.hasPan ? (v.panNumber || null) : null,
aadhaarNumberMasked: aadhaarMasked,
// For non-individual: Company PAN is used as panNumber for verification
panNumber: this.isNonIndividual ? v.enterprisePan || null : v.hasPan ? v.panNumber || null : null,
aadhaarNumberMasked: this.isNonIndividual ? 'XXXXXXXX0000' : aadhaarMasked,
panAadhaarSeedingStatus: this.panAadhaarSeedingStatus || null,
gstinNumber: v.gstinNumber || null,
enterprisePan: v.enterprisePan || null,
@@ -761,7 +1083,15 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
get hasKycData(): boolean {
const v = this.readOnly ? this.kycForm.getRawValue() : this.kycForm.value;
return !!(v.panNumber || v.aadhaarNumber || v.gstinNumber || v.enterprisePan || v.cinNumber || v.kycDocumentName || v.form15Applicable);
return !!(
v.panNumber ||
v.aadhaarNumber ||
v.gstinNumber ||
v.enterprisePan ||
v.cinNumber ||
v.kycDocumentName ||
v.form15Applicable
);
}
}
@@ -784,22 +1114,44 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
</mat-dialog-actions>
</div>
`,
styles: [`
.pan-err-dialog { max-width: 460px; }
.pan-err-header {
display: flex; align-items: center; gap: 10px;
padding: 16px 24px 12px; border-bottom: 1px solid #fecaca;
background: #fef2f2; font-size: 16px; font-weight: 600; color: #991b1b;
styles: [
`
.pan-err-dialog {
max-width: 460px;
}
.pan-err-icon { color: #dc2626; }
.pan-err-body { padding: 18px 24px !important; font-size: 14px; line-height: 1.6; color: #475569; }
.pan-err-actions { padding: 10px 24px 16px !important; margin: 0 !important; }
`],
imports: [MatIcon, MatDialogContent, MatDialogActions, STANDALONE_SHARED_IMPORTS],
.pan-err-header {
display: flex;
align-items: center;
gap: 10px;
padding: 16px 24px 12px;
border-bottom: 1px solid #fecaca;
background: #fef2f2;
font-size: 16px;
font-weight: 600;
color: #991b1b;
}
.pan-err-icon {
color: #dc2626;
}
.pan-err-body {
padding: 18px 24px !important;
font-size: 14px;
line-height: 1.6;
color: #475569;
}
.pan-err-actions {
padding: 10px 24px 16px !important;
margin: 0 !important;
}
`
],
imports: [
MatIcon,
MatDialogContent,
MatDialogActions,
STANDALONE_SHARED_IMPORTS
]
})
export class PanErrorDialogComponent {
constructor(
@Inject(MAT_DIALOG_DATA) public data: { message: string }
) {}
data = inject<{ message: string }>(MAT_DIALOG_DATA);
}
+20 -8
View File
@@ -243,18 +243,18 @@ export class ClientsService {
};
for (const client of results) {
// Fetch DOB from client data
this.getClientData(client.id.toString()).pipe(
catchError(() => of(null))
).subscribe((data: any) => {
this.getClientData(client.id.toString())
.pipe(catchError(() => of(null)))
.subscribe((data: any) => {
if (data?.dateOfBirth) {
client.dateOfBirth = data.dateOfBirth;
}
});
// Fetch father/spouse from family members
this.getClientFamilyMembers(client.id.toString()).pipe(
catchError(() => of([]))
).subscribe((members: any) => {
this.getClientFamilyMembers(client.id.toString())
.pipe(catchError(() => of([])))
.subscribe((members: any) => {
const list = Array.isArray(members) ? members : [];
const spouse = list.find((m: any) => getRelName(m) === 'SPOUSE');
const father = list.find((m: any) => getRelName(m) === 'FATHER');
@@ -291,8 +291,12 @@ export class ClientsService {
getClientNominees(clientId: string, accountNo?: string, accountType?: string): Observable<any> {
let params = new HttpParams();
if (accountNo) { params = params.set('accountNo', accountNo); }
if (accountType) { params = params.set('accountType', accountType); }
if (accountNo) {
params = params.set('accountNo', accountNo);
}
if (accountType) {
params = params.set('accountType', accountType);
}
return this.http.get(`/clients/${clientId}/nominees`, { params });
}
@@ -599,6 +603,14 @@ export class ClientsService {
return this.http.put(`/clients/${clientId}/kyc/${kycId}`, kycData);
}
/**
* Re-edit a VERIFIED KYC record. Transitions status VERIFIED → IN_PROGRESS.
* Remarks are mandatory in the payload.
*/
reEditClientKyc(clientId: string, kycId: string, kycData: any): Observable<any> {
return this.http.put(`/clients/${clientId}/kyc/${kycId}?command=re-edit`, kycData);
}
/**
* Verifies PAN number via LincPay (reads PAN from the stored KYC record).
* @param clientId Client ID
@@ -12,7 +12,12 @@
<h2>{{ 'labels.heading.Edit Client' | translate }}</h2>
</div>
<mat-tab-group class="client-tabs" animationDuration="200ms" [preserveContent]="true" [(selectedIndex)]="selectedTabIndex">
<mat-tab-group
class="client-tabs"
animationDuration="200ms"
[preserveContent]="true"
[(selectedIndex)]="selectedTabIndex"
>
<!-- Tab 1: General Details -->
<mat-tab>
<ng-template mat-tab-label>
@@ -58,8 +63,18 @@
</button>
}
</div>
@if (dobChanged) {
<div class="rekyc-alert">
<mat-icon class="rekyc-alert__icon">warning</mat-icon>
<div class="rekyc-alert__text">
<strong>Re-KYC Required:</strong> Date of birth was changed. Please re-verify PAN and update the remarks
below before proceeding.
</div>
</div>
}
<div class="tab-section">
<mifosx-client-kyc-step #clientKyc
<mifosx-client-kyc-step
#clientKyc
[isNonIndividual]="legalFormType === 2"
[clientId]="clientId"
[readOnly]="isTabReadOnly(1)"
@@ -115,7 +130,8 @@
</button>
}
</div>
<mifosx-client-india-address-step #clientIndiaAddress
<mifosx-client-india-address-step
#clientIndiaAddress
[clientId]="clientId"
[readOnly]="isTabReadOnly(addressTabIdx)"
></mifosx-client-india-address-step>
@@ -141,7 +157,8 @@
</button>
}
</div>
<mifosx-client-family-members-step #clientFamilyMembers
<mifosx-client-family-members-step
#clientFamilyMembers
[clientTemplate]="clientDataAndTemplate"
[clientId]="clientId"
[readOnly]="isTabReadOnly(familyTabIdx)"
@@ -182,9 +199,18 @@
</button>
}
@if (selectedTabIndex < totalTabs - 1) {
<button mat-raised-button color="primary" (click)="onNext()">
<button mat-raised-button color="primary" (click)="onNext()" [disabled]="savingClient">
@if (savingClient) {
<mat-progress-spinner diameter="18" mode="indeterminate" class="m-r-10"></mat-progress-spinner>
Saving...
} @else if (isCurrentTabEdited()) {
<fa-icon icon="save" class="m-r-10"></fa-icon>
Save &amp; Next
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
} @else {
{{ 'labels.buttons.Next' | translate }}
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
}
</button>
}
@if (selectedTabIndex === totalTabs - 1 && areFormvalids()) {
@@ -48,11 +48,11 @@
.client-tabs {
background: var(--md-sys-color-surface, #fff);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
::ng-deep {
.mat-mdc-tab-header {
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
border-bottom: 1px solid rgb(0 0 0 / 8%);
}
.mat-mdc-tab {
@@ -97,7 +97,7 @@
align-items: center;
margin-top: 20px;
padding: 16px 0;
border-top: 1px solid rgba(0, 0, 0, 0.08);
border-top: 1px solid rgb(0 0 0 / 8%);
}
.action-bar-right {
@@ -106,7 +106,7 @@
}
.cancel-btn {
color: rgba(0, 0, 0, 0.6);
color: rgb(0 0 0 / 60%);
}
/* ─── Read-only tab state ─── */
@@ -149,3 +149,34 @@
color: #ef4444 !important;
border: 1px solid #fca5a5 !important;
}
/* ─── Re-KYC Alert Banner ─── */
.rekyc-alert {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 18px;
margin: 0 0 16px;
background: #fef3c7;
border: 1px solid #f59e0b;
border-radius: 8px;
color: #92400e;
&__icon {
color: #f59e0b;
font-size: 22px;
width: 22px;
height: 22px;
margin-top: 1px;
flex-shrink: 0;
}
&__text {
font-size: 13.5px;
line-height: 1.5;
strong {
font-weight: 600;
}
}
}
@@ -9,10 +9,12 @@
/** Angular Imports */
import { Component, QueryList, ViewChild, ViewChildren, inject } from '@angular/core';
import { Router, ActivatedRoute } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
/** Custom Services */
import { ClientsService } from '../clients.service';
import { SettingsService } from 'app/settings/settings.service';
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
/** Custom Components */
import { ClientGeneralStepComponent } from '../client-stepper/client-general-step/client-general-step.component';
@@ -60,6 +62,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
export class EditClientComponent {
private route = inject(ActivatedRoute);
private router = inject(Router);
private dialog = inject(MatDialog);
private clientsService = inject(ClientsService);
private settingsService = inject(SettingsService);
@@ -79,6 +82,9 @@ export class EditClientComponent {
/** Track which tabs are in edit mode (false = read-only, true = editable) */
tabEditMode: Record<number, boolean> = {};
/** Flag: DOB was changed — requires re-KYC (PAN re-verification) */
dobChanged = false;
/** Client data and template (merged object from resolver) */
clientDataAndTemplate: any;
/** Client ID */
@@ -139,8 +145,13 @@ export class EditClientComponent {
return areValids;
}
/** Whether the current tab has unsaved edits */
isCurrentTabEdited(): boolean {
return this.tabEditMode[this.selectedTabIndex] === true;
}
/**
* Next button handler — just navigates between tabs (client already exists in edit mode).
* Next button handler — saves changes via API if the current tab was edited, then moves to next tab.
*/
onNext() {
if (this.selectedTabIndex === 0) {
@@ -155,8 +166,270 @@ export class EditClientComponent {
return;
}
}
// If tab was edited, confirm and save before moving on
if (this.isCurrentTabEdited()) {
this.confirmAndSaveTab();
} else {
this.selectedTabIndex = this.selectedTabIndex + 1;
}
}
/**
* Show confirmation dialog and save the current tab's data via API.
* Shows a special warning if DOB was changed (re-KYC required).
*/
private confirmAndSaveTab() {
let heading = 'Confirm Edit';
let dialogContext = 'Are you sure you want to save the changes';
let dialogType = 'basic';
// Check if DOB changed on the General tab — warn about re-KYC
if (this.selectedTabIndex === 0) {
const changes = this.detectSensitiveFieldChanges(this.clientDataAndTemplate);
if (changes.dobChanged) {
heading = 'Re-KYC Required';
dialogContext =
'Date of birth has been changed. This will require PAN re-verification in the KYC section. A remark will be added automatically. Do you want to proceed';
dialogType = 'warn';
}
}
const dialogRef = this.dialog.open(ConfirmationDialogComponent, {
data: { heading, dialogContext, type: dialogType }
});
dialogRef.afterClosed().subscribe((response: any) => {
if (response?.confirm) {
this.saveCurrentTab();
}
});
}
/**
* Save the current tab's data via API, then advance to the next tab.
*/
private saveCurrentTab() {
const tabIndex = this.selectedTabIndex;
// General Details tab (always index 0)
if (tabIndex === 0) {
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
const clientData = { ...this.client, dateFormat, locale };
delete clientData.officeId;
// Detect changes to sensitive fields before saving
const originalData = this.clientDataAndTemplate;
const changedFields = this.detectSensitiveFieldChanges(originalData);
this.savingClient = true;
this.clientsService.updateClient(this.clientId, clientData).subscribe({
next: () => {
this.savingClient = false;
this.tabEditMode[tabIndex] = false;
// Log mobile/email changes as client notes for risk audit
this.logFieldChangeNotes(changedFields);
// If DOB changed, flag re-KYC requirement and reset PAN verification
if (changedFields.dobChanged) {
this.dobChanged = true;
this.tabEditMode[1] = true; // auto-enable KYC tab for re-verification
this.flagReKycRequired(changedFields.oldDob, changedFields.newDob);
}
// Update original data snapshot so subsequent edits compare against latest
if (changedFields.mobileChanged) {
originalData.mobileNo = changedFields.newMobile;
}
if (changedFields.emailChanged) {
originalData.emailAddress = changedFields.newEmail;
}
if (changedFields.dobChanged) {
originalData.dateOfBirth = changedFields.newDob;
}
this.selectedTabIndex = this.selectedTabIndex + 1;
},
error: () => {
this.savingClient = false;
}
});
return;
}
// KYC tab (always index 1)
if (tabIndex === 1) {
this.savingClient = true;
// Upload photo if selected
if (this.clientKycStep?.photographFile) {
this.clientsService
.uploadClientProfileImage(this.clientId, this.clientKycStep.photographFile)
.subscribe({ error: () => {} });
}
// Upload signature if selected
if (this.clientKycStep?.signatureFile) {
this.clientsService
.uploadClientSignatureImage(this.clientId, this.clientKycStep.signatureFile)
.subscribe({ error: () => {} });
}
// Save KYC data — use correct API based on KYC state
if (this.clientKycStep?.hasKycData) {
const kycData = this.clientKycStep.kycData;
const kycId = this.clientKycStep.kycId;
const isVerified = this.clientKycStep.isKycVerified;
// Validate remarks are present for verified KYC
if (isVerified && !kycData.remarks?.trim()) {
this.savingClient = false;
this.clientKycStep.kycForm.get('remarks')?.markAsTouched();
return;
}
let saveObs;
if (kycId && isVerified) {
// VERIFIED → re-edit command
const { kycType, ...updatePayload } = kycData;
saveObs = this.clientsService.reEditClientKyc(this.clientId, kycId, updatePayload);
} else if (kycId) {
// Existing but not verified → normal PUT
const { kycType, ...updatePayload } = kycData;
saveObs = this.clientsService.updateClientKyc(this.clientId, kycId, updatePayload);
} else {
// New → POST
saveObs = this.clientsService.saveClientKyc(this.clientId, kycData);
}
saveObs.subscribe({
next: (res: any) => {
this.savingClient = false;
this.tabEditMode[tabIndex] = false;
// If re-edit, update KYC component state
if (isVerified && this.clientKycStep) {
this.clientKycStep.kycStatus = 200;
this.clientKycStep.panVerifyStatus = 'NONE';
this.clientKycStep.panVerifyName = '';
this.clientKycStep.panVerifiedOn = '';
}
if (!kycId && res?.resourceId) {
this.clientKycStep.kycId = res.resourceId.toString();
}
this.selectedTabIndex = this.selectedTabIndex + 1;
},
error: () => {
this.savingClient = false;
}
});
} else {
this.savingClient = false;
this.tabEditMode[tabIndex] = false;
this.selectedTabIndex = this.selectedTabIndex + 1;
}
return;
}
// Address tab
const addressTabIdx = this.legalFormType === 2 ? 3 : 2;
if (tabIndex === addressTabIdx) {
this.savingClient = true;
if (this.clientIndiaAddressStep?.hasAddressData) {
this.saveClientAddresses(this.clientId);
}
this.savingClient = false;
this.tabEditMode[tabIndex] = false;
this.selectedTabIndex = this.selectedTabIndex + 1;
return;
}
// Family tab
const familyTabIdx = this.legalFormType === 2 ? 4 : 3;
if (tabIndex === familyTabIdx) {
this.savingClient = true;
if (this.clientFamilyMembersStep?.hasFamilyMembers) {
this.saveClientFamilyMembers(this.clientId);
}
this.savingClient = false;
this.tabEditMode[tabIndex] = false;
this.selectedTabIndex = this.selectedTabIndex + 1;
return;
}
// Default: just advance
this.tabEditMode[tabIndex] = false;
this.selectedTabIndex = this.selectedTabIndex + 1;
}
/**
* Compare current form values with original data to detect changes in DOB, mobile, email.
*/
private detectSensitiveFieldChanges(originalData: any) {
const form = this.clientGeneralForm;
const newMobile = form?.get('mobileNo')?.value || '';
const newEmail = form?.get('emailAddress')?.value || '';
const newDobRaw = form?.get('dateOfBirth')?.value;
const newDob = newDobRaw instanceof Date ? newDobRaw.toISOString().split('T')[0] : newDobRaw || '';
const oldMobile = originalData.mobileNo || '';
const oldEmail = originalData.emailAddress || '';
const oldDobRaw = originalData.dateOfBirth;
const oldDob = Array.isArray(oldDobRaw)
? `${oldDobRaw[0]}-${String(oldDobRaw[1]).padStart(2, '0')}-${String(oldDobRaw[2]).padStart(2, '0')}`
: oldDobRaw instanceof Date
? oldDobRaw.toISOString().split('T')[0]
: oldDobRaw || '';
return {
mobileChanged: newMobile !== oldMobile,
emailChanged: newEmail !== oldEmail,
dobChanged: newDob !== oldDob,
oldMobile,
newMobile,
oldEmail,
newEmail,
oldDob,
newDob
};
}
/**
* Save audit notes for mobile/email changes via the client notes API.
*/
private logFieldChangeNotes(changes: any) {
const timestamp = new Date().toISOString();
if (changes.mobileChanged) {
const note = `[AUDIT] Mobile number changed from "${changes.oldMobile}" to "${changes.newMobile}" on ${timestamp}`;
this.clientsService.createClientNote(this.clientId, { note }).subscribe({ error: () => {} });
}
if (changes.emailChanged) {
const note = `[AUDIT] Email address changed from "${changes.oldEmail}" to "${changes.newEmail}" on ${timestamp}`;
this.clientsService.createClientNote(this.clientId, { note }).subscribe({ error: () => {} });
}
}
/**
* When DOB changes, reset PAN verification in KYC step and pre-fill remark for re-KYC.
*/
private flagReKycRequired(oldDob: string, newDob: string) {
// Reset PAN verification status so user must re-verify
if (this.clientKycStep) {
this.clientKycStep.panVerifyStatus = 'NONE';
this.clientKycStep.panVerifyName = '';
this.clientKycStep.panVerifiedOn = '';
// Pre-fill remarks with re-KYC note
const remarkText = `[Re-KYC Required] Date of birth changed from ${oldDob} to ${newDob}. PAN re-verification is mandatory.`;
this.clientKycStep.kycForm.get('remarks')?.setValue(remarkText);
}
// Also log DOB change as an audit note
const timestamp = new Date().toISOString();
const note = `[AUDIT] Date of birth changed from "${oldDob}" to "${newDob}" on ${timestamp}. Re-KYC (PAN re-verification) required.`;
this.clientsService.createClientNote(this.clientId, { note }).subscribe({ error: () => {} });
}
legalFormChange(eventData: { legalForm: number }) {
this.legalFormType = eventData.legalForm;
@@ -185,12 +458,16 @@ export class EditClientComponent {
// Upload photo if selected
if (this.clientKycStep?.photographFile) {
this.clientsService.uploadClientProfileImage(this.clientId, this.clientKycStep.photographFile).subscribe({ error: () => {} });
this.clientsService
.uploadClientProfileImage(this.clientId, this.clientKycStep.photographFile)
.subscribe({ error: () => {} });
}
// Upload signature if selected
if (this.clientKycStep?.signatureFile) {
this.clientsService.uploadClientSignatureImage(this.clientId, this.clientKycStep.signatureFile).subscribe({ error: () => {} });
this.clientsService
.uploadClientSignatureImage(this.clientId, this.clientKycStep.signatureFile)
.subscribe({ error: () => {} });
}
// Save addresses from India address step
@@ -205,7 +482,20 @@ export class EditClientComponent {
// Save/update KYC data if available
if (this.clientKycStep?.hasKycData) {
this.clientsService.saveClientKyc(this.clientId, this.clientKycStep.kycData).subscribe({
const kycData = this.clientKycStep.kycData;
const kycId = this.clientKycStep.kycId;
const isVerified = this.clientKycStep.isKycVerified;
let kycObs;
if (kycId && isVerified) {
const { kycType, ...updatePayload } = kycData;
kycObs = this.clientsService.reEditClientKyc(this.clientId, kycId, updatePayload);
} else if (kycId) {
const { kycType, ...updatePayload } = kycData;
kycObs = this.clientsService.updateClientKyc(this.clientId, kycId, updatePayload);
} else {
kycObs = this.clientsService.saveClientKyc(this.clientId, kycData);
}
kycObs.subscribe({
next: () => navigateBack(),
error: () => navigateBack()
});
@@ -225,16 +515,36 @@ export class EditClientComponent {
private saveClientAddresses(clientId: string) {
const addressData = this.clientIndiaAddressStep.addressData;
const typeNameMap: Record<string, string[]> = {
permanent: ['Permanent', 'Permanent Address'],
current: ['Residential', 'Current', 'Current / Residential', 'Communication'],
office: ['Office', 'Office / Work', 'Business', 'Work'],
correspondence: ['Correspondence', 'Mailing', 'Postal']
permanent: [
'Permanent',
'Permanent Address'
],
current: [
'Residential',
'Current',
'Current / Residential',
'Communication'
],
office: [
'Office',
'Office / Work',
'Business',
'Work'
],
correspondence: [
'Correspondence',
'Mailing',
'Postal'
]
};
this.clientsService.getClientAddressTemplate().subscribe({
next: (template: any) => {
const typeOptions = template?.addressTypeIdOptions || [];
for (const [tab, addr] of Object.entries(addressData)) {
for (const [
tab,
addr
] of Object.entries(addressData)) {
if (!addr || !(addr as any).addressLine1) continue;
const possibleNames = typeNameMap[tab] || [tab];
@@ -259,8 +569,8 @@ export class EditClientComponent {
};
if ((addr as any).state && template?.stateProvinceIdOptions?.length) {
const stateMatch = template.stateProvinceIdOptions.find((s: any) =>
s.name?.toLowerCase() === (addr as any).state?.toLowerCase()
const stateMatch = template.stateProvinceIdOptions.find(
(s: any) => s.name?.toLowerCase() === (addr as any).state?.toLowerCase()
);
if (stateMatch) payload.stateProvinceId = stateMatch.id;
}
+33 -9
View File
@@ -90,6 +90,15 @@ $color: #e2e4ec;
.sidenav {
overflow-x: hidden;
position: relative;
// Constrain routed page content so tables/cards shrink when sidebar opens
mifosx-content {
display: block;
min-width: 0;
max-width: 100%;
overflow-x: hidden;
box-sizing: border-box;
}
}
// ===== 2-Row Main Header =====
@@ -142,15 +151,28 @@ $gray-700: #334155;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.01em;
transition: background 0.12s, color 0.12s;
transition:
background 0.12s,
color 0.12s;
user-select: none;
border-right: 1px solid $gray-200;
text-decoration: none;
font-family: 'DM Sans', 'Roboto', sans-serif;
font-family: 'DM Sans', Roboto, sans-serif;
&:last-child { border-right: none; }
&:hover { background: $brand-hover; color: $brand; }
&.active { background: $brand-light; color: $brand; font-weight: 600; }
&:last-child {
border-right: none;
}
&:hover {
background: $brand-hover;
color: $brand;
}
&.active {
background: $brand-light;
color: $brand;
font-weight: 600;
}
&.active::before {
content: '';
@@ -178,7 +200,9 @@ $gray-700: #334155;
.ni-caret {
font-size: 8px;
opacity: 0.3;
transition: transform 0.18s, opacity 0.18s;
transition:
transform 0.18s,
opacity 0.18s;
flex-shrink: 0;
.ni:hover & {
@@ -211,7 +235,7 @@ $gray-700: #334155;
min-height: 34px !important;
padding: 0 14px !important;
color: $gray-700 !important;
font-family: 'DM Sans', 'Roboto', sans-serif !important;
font-family: 'DM Sans', Roboto, sans-serif !important;
&:hover {
background: $brand-hover !important;
@@ -266,7 +290,7 @@ $gray-700: #334155;
width: 860px;
max-width: 95vw;
padding: 20px 24px;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.22);
box-shadow: 0 6px 24px rgb(0 0 0 / 22%);
border-radius: 0 0 10px 10px;
z-index: 1000;
border-top: 3px solid #0d47a1;
@@ -307,7 +331,7 @@ $gray-700: #334155;
outline: none;
border-color: #0d47a1;
background: #fff;
box-shadow: 0 0 0 3px rgba(13, 71, 161, 0.1);
box-shadow: 0 0 0 3px rgb(13 71 161 / 10%);
}
}
@@ -6,13 +6,42 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<h1 mat-dialog-title>{{ data.heading }}</h1>
<div mat-dialog-content>
<p>{{ data.dialogContext }} ?</p>
<div class="confirm-dialog" [class]="'confirm-dialog confirm-dialog--' + (data.type || 'basic')">
<!-- Icon + Title -->
<div class="confirm-header">
<div class="confirm-header__icon-wrap">
@switch (data.type) {
@case ('warn') {
<i class="fa fa-exclamation-triangle"></i>
}
@case ('Strong') {
<i class="fa fa-trash-alt"></i>
}
@default {
<i class="fa fa-pen-to-square"></i>
}
}
</div>
<mat-dialog-actions align="end">
<button mat-raised-button mat-dialog-close>{{ 'labels.buttons.Cancel' | translate }}</button>
<button mat-raised-button color="{{ color }}" [mat-dialog-close]="{ confirm: true }">
<h2 class="confirm-header__title">{{ data.heading }}</h2>
</div>
<!-- Body -->
<mat-dialog-content class="confirm-body">
<p class="confirm-body__text">{{ data.dialogContext }}</p>
</mat-dialog-content>
<!-- Actions -->
<mat-dialog-actions class="confirm-actions">
<button mat-stroked-button class="confirm-actions__cancel" mat-dialog-close>
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button
mat-flat-button
class="confirm-actions__confirm confirm-actions__confirm--{{ data.type || 'basic' }}"
[mat-dialog-close]="{ confirm: true }"
>
<i class="fa fa-check confirm-actions__btn-icon"></i>
{{ 'labels.buttons.Confirm' | translate }}
</button>
</mat-dialog-actions>
</div>
@@ -5,3 +5,145 @@
* 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/.
*/
$primary: #1a73e8;
$primary-light: #e8f0fe;
$amber: #f59e0b;
$amber-light: #fef7e0;
$red: #ef4444;
$red-light: #fef2f2;
$gray-100: #f1f3f4;
$gray-200: #e8eaed;
$gray-500: #9aa0a6;
$gray-700: #5f6368;
$gray-900: #202124;
$white: #fff;
.confirm-dialog {
display: flex;
flex-direction: column;
max-width: 460px;
min-width: 380px;
}
// ─── Header ───
.confirm-header {
display: flex;
align-items: center;
gap: 14px;
padding: 22px 24px 16px;
border-bottom: 1px solid $gray-200;
&__icon-wrap {
width: 42px;
height: 42px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
.confirm-dialog--basic & {
background: $primary-light;
i {
color: $primary;
}
}
.confirm-dialog--warn & {
background: $amber-light;
i {
color: $amber;
}
}
.confirm-dialog--Strong & {
background: $red-light;
i {
color: $red;
}
}
i {
font-size: 18px;
}
}
&__title {
margin: 0;
padding: 0;
font-size: 17px;
font-weight: 700;
color: $gray-900;
letter-spacing: -0.2px;
line-height: 1.25;
}
}
// ─── Body ───
.confirm-body {
padding: 18px 24px !important;
overflow: visible !important;
max-height: none !important;
&__text {
font-size: 13.5px;
line-height: 1.65;
color: $gray-700;
margin: 0;
}
}
// ─── Actions ───
.confirm-actions {
padding: 14px 24px 20px !important;
border-top: 1px solid $gray-200;
margin: 0 !important;
display: flex !important;
justify-content: flex-end !important;
align-items: center !important;
gap: 10px !important;
&__cancel {
border-radius: 8px !important;
font-size: 13px !important;
font-weight: 600 !important;
padding: 0 20px !important;
height: 38px !important;
color: $gray-700 !important;
border-color: $gray-200 !important;
}
&__confirm {
border-radius: 8px !important;
font-size: 13px !important;
font-weight: 600 !important;
padding: 0 24px !important;
height: 38px !important;
letter-spacing: 0.2px;
color: $white !important;
&--basic {
background: $primary !important;
box-shadow: 0 1px 3px rgba($primary, 0.3) !important;
}
&--warn {
background: $amber !important;
box-shadow: 0 1px 3px rgba($amber, 0.3) !important;
}
&--Strong {
background: $red !important;
box-shadow: 0 1px 3px rgba($red, 0.3) !important;
}
}
&__btn-icon {
margin-right: 6px;
font-size: 12px;
}
}
@@ -7,22 +7,21 @@
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import { Component, inject } from '@angular/core';
import {
MatDialogRef,
MAT_DIALOG_DATA,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose
} from '@angular/material/dialog';
import { DialogData } from 'app/core/dialogs/dialog-data.model';
import { Dialogs } from 'app/core/dialogs/dialogs';
import { CdkScrollable } from '@angular/cdk/scrolling';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Delete dialog component.
* Confirmation dialog component.
* Supports types: 'basic' (blue), 'warn' (amber), 'Strong' (red/destructive).
*/
@Component({
selector: 'mifosx-confirmation-dialog',
@@ -30,25 +29,13 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
styleUrls: ['./confirmation-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatDialogTitle,
CdkScrollable,
MatDialogContent,
MatDialogActions,
MatDialogClose
]
})
export class ConfirmationDialogComponent implements OnInit {
export class ConfirmationDialogComponent {
dialogRef = inject<MatDialogRef<ConfirmationDialogComponent>>(MatDialogRef);
private dialogs = inject(Dialogs);
data = inject<DialogData>(MAT_DIALOG_DATA);
color: string;
ngOnInit() {
this.setColor();
}
setColor() {
this.color = this.dialogs.setColor(this.data.type);
}
}
+150 -42
View File
@@ -23,6 +23,9 @@ $mono: 'Roboto Mono', 'DM Mono', monospace;
border-radius: 12px;
border: 1px solid $border;
box-shadow: 0 1px 3px rgb(0 0 0 / 5%);
min-width: 0;
max-width: 100%;
box-sizing: border-box;
// Legacy layout support
.layout-column,
@@ -45,14 +48,21 @@ $mono: 'Roboto Mono', 'DM Mono', monospace;
flex: 1;
min-width: 0;
&.m-r-30 { margin-right: 12px; }
&.m-r-30 {
margin-right: 12px;
}
mat-form-field {
min-width: 200px;
flex: 1;
&.search-box { width: auto; }
+ mat-form-field { margin-left: 12px; }
&.search-box {
width: auto;
}
+ mat-form-field {
margin-left: 12px;
}
}
mat-checkbox {
@@ -60,7 +70,9 @@ $mono: 'Roboto Mono', 'DM Mono', monospace;
flex-shrink: 0;
margin-left: 8px;
&.m-r-10 { margin-right: 8px; }
&.m-r-10 {
margin-right: 8px;
}
}
}
@@ -70,14 +82,18 @@ $mono: 'Roboto Mono', 'DM Mono', monospace;
flex-shrink: 0;
margin-left: auto;
&.gap-25px { gap: 10px; }
&.gap-25px {
gap: 10px;
}
button {
white-space: nowrap;
fa-icon {
&.m-r-10,
&.m-r-20 { margin-right: 6px; }
&.m-r-20 {
margin-right: 6px;
}
}
}
}
@@ -92,11 +108,16 @@ $mono: 'Roboto Mono', 'DM Mono', monospace;
.select-row {
cursor: pointer;
&:hover { background-color: $hover-bg; }
&:hover {
background-color: $hover-bg;
}
}
th,
td { padding: 10px 16px; }
td {
padding: 10px 16px;
}
th {
font-weight: 600;
@@ -110,7 +131,10 @@ $mono: 'Roboto Mono', 'DM Mono', monospace;
tr {
border-bottom: 1px solid #f1f5f9;
&:last-child { border-bottom: none; }
&:last-child {
border-bottom: none;
}
}
td {
@@ -125,7 +149,9 @@ $mono: 'Roboto Mono', 'DM Mono', monospace;
background: transparent;
}
mat-progress-bar { margin: 0; }
mat-progress-bar {
margin: 0;
}
}
// ===== Modern Table Header =====
@@ -167,10 +193,21 @@ $mono: 'Roboto Mono', 'DM Mono', monospace;
span {
border-radius: 2px;
&:nth-child(1) { background: $brand; }
&:nth-child(2) { background: #0ea5e9; }
&:nth-child(3) { background: #10b981; }
&:nth-child(4) { background: #cbd5e1; }
&:nth-child(1) {
background: $brand;
}
&:nth-child(2) {
background: #0ea5e9;
}
&:nth-child(3) {
background: #10b981;
}
&:nth-child(4) {
background: #cbd5e1;
}
}
}
}
@@ -198,7 +235,9 @@ $mono: 'Roboto Mono', 'DM Mono', monospace;
max-width: 360px;
::ng-deep {
.mat-mdc-form-field-flex { height: 38px; }
.mat-mdc-form-field-flex {
height: 38px;
}
.mat-mdc-form-field-infix {
padding: 6px 0;
@@ -206,8 +245,13 @@ $mono: 'Roboto Mono', 'DM Mono', monospace;
}
.mdc-notched-outline {
.mdc-notched-outline__leading { border-radius: 8px 0 0 8px; }
.mdc-notched-outline__trailing { border-radius: 0 8px 8px 0; }
.mdc-notched-outline__leading {
border-radius: 8px 0 0 8px;
}
.mdc-notched-outline__trailing {
border-radius: 0 8px 8px 0;
}
}
.mat-mdc-form-field-icon-prefix {
@@ -215,7 +259,9 @@ $mono: 'Roboto Mono', 'DM Mono', monospace;
color: $text-muted;
}
input { font-size: 13px; }
input {
font-size: 13px;
}
}
}
@@ -237,7 +283,10 @@ $mono: 'Roboto Mono', 'DM Mono', monospace;
height: 36px;
padding: 0 16px;
fa-icon { margin-right: 6px; font-size: 12px; }
fa-icon {
margin-right: 6px;
font-size: 12px;
}
}
.btn-import {
@@ -259,7 +308,7 @@ $mono: 'Roboto Mono', 'DM Mono', monospace;
.table-container {
border: 1px solid $border;
border-radius: 10px;
overflow: hidden;
overflow: hidden auto;
}
.table-loader {
@@ -294,7 +343,9 @@ $mono: 'Roboto Mono', 'DM Mono', monospace;
border-bottom: 1px solid #f1f5f9;
transition: background 0.1s;
&:last-child { border-bottom: none; }
&:last-child {
border-bottom: none;
}
&:nth-child(even) {
background: #fafbfc;
@@ -314,7 +365,11 @@ $mono: 'Roboto Mono', 'DM Mono', monospace;
font-weight: 400;
color: $text;
padding: 0 12px;
font-family: 'Inter', 'Roboto', -apple-system, sans-serif;
font-family:
Inter,
Roboto,
-apple-system,
sans-serif;
}
}
}
@@ -324,9 +379,15 @@ $mono: 'Roboto Mono', 'DM Mono', monospace;
font-weight: 500;
font-size: 13px;
cursor: pointer;
font-family: 'Inter', 'Roboto', -apple-system, sans-serif;
font-family:
Inter,
Roboto,
-apple-system,
sans-serif;
&:hover { text-decoration: underline; }
&:hover {
text-decoration: underline;
}
}
.cell-mono {
@@ -350,7 +411,11 @@ $mono: 'Roboto Mono', 'DM Mono', monospace;
letter-spacing: 0.02em;
text-transform: capitalize;
white-space: nowrap;
font-family: 'Inter', 'Roboto', -apple-system, sans-serif;
font-family:
Inter,
Roboto,
-apple-system,
sans-serif;
&::before {
content: '';
@@ -367,7 +432,9 @@ $mono: 'Roboto Mono', 'DM Mono', monospace;
background: #ecfdf5 !important;
color: #059669 !important;
&::before { background: #059669; }
&::before {
background: #059669;
}
}
.status-active-overdue,
@@ -375,7 +442,9 @@ $mono: 'Roboto Mono', 'DM Mono', monospace;
background: #fef2f2 !important;
color: #dc2626 !important;
&::before { background: #dc2626; }
&::before {
background: #dc2626;
}
}
.status-submitted-and-pending-approval,
@@ -383,14 +452,18 @@ $mono: 'Roboto Mono', 'DM Mono', monospace;
background: #fffbeb !important;
color: #d97706 !important;
&::before { background: #d97706; }
&::before {
background: #d97706;
}
}
.status-overpaid {
background: #eff6ff !important;
color: #2563eb !important;
&::before { background: #2563eb; }
&::before {
background: #2563eb;
}
}
}
@@ -402,8 +475,13 @@ $mono: 'Roboto Mono', 'DM Mono', monospace;
vertical-align: middle;
margin-right: 6px;
&.person { color: #2563eb; }
&.entity { color: #7c3aed; }
&.person {
color: #2563eb;
}
&.entity {
color: #7c3aed;
}
}
.legal-form-badge {
@@ -550,10 +628,15 @@ $mono: 'Roboto Mono', 'DM Mono', monospace;
min-width: 150px;
margin-bottom: 8px;
+ mat-form-field { margin-left: 0; }
+ mat-form-field {
margin-left: 0;
}
}
mat-checkbox { margin-left: 0; margin-top: 8px; }
mat-checkbox {
margin-left: 0;
margin-top: 8px;
}
}
.action-button {
@@ -561,25 +644,50 @@ $mono: 'Roboto Mono', 'DM Mono', monospace;
width: 100%;
justify-content: center;
&.gap-25px { gap: 10px; }
&.gap-25px {
gap: 10px;
}
}
}
}
.table-actions { flex-direction: column; align-items: stretch; }
.table-search { max-width: 100%; }
.table-buttons { margin-left: 0; justify-content: stretch; }
.table-buttons button { flex: 1; }
.table-actions {
flex-direction: column;
align-items: stretch;
}
.table-search {
max-width: 100%;
}
.table-buttons {
margin-left: 0;
justify-content: stretch;
}
.table-buttons button {
flex: 1;
}
}
// Legacy table styles
table {
width: 100%;
tr.select-row:hover { cursor: pointer; }
tr.select-row:hover {
cursor: pointer;
}
}
// Utility classes
.m-r-10 { margin-right: 10px !important; }
.m-r-20 { margin-right: 20px !important; }
.m-r-30 { margin-right: 30px !important; }
.m-r-10 {
margin-right: 10px !important;
}
.m-r-20 {
margin-right: 20px !important;
}
.m-r-30 {
margin-right: 30px !important;
}
+10 -2
View File
@@ -1,9 +1,17 @@
/**
* 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/.
*/
// IMPORTANT: THIS FILE IS AUTO GENERATED! DO NOT MANUALLY EDIT OR CHECKIN!
/* tslint:disable */
export default {
'mifos_x': {
'version': '260404',
'hash': 'gab0111142'
'version': '260407',
'hash': 'gb890d24bb'
},
'allow_switching_backend_instance': true
};