feat: add KYC document renewal alerts, Aadhaar/PAN exceptions, and AML recurring alerts UI; remove old journal AML marking
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
<!--
|
||||
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="aad-rcpt">
|
||||
<div class="aad-rcpt__header">
|
||||
<mat-icon class="aad-rcpt__header-icon">receipt_long</mat-icon>
|
||||
<div>
|
||||
<div class="aad-rcpt__title">Aadhaar Enrolment Receipt</div>
|
||||
<div class="aad-rcpt__subtitle">{{ data.clientName || 'Client' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-dialog-content class="aad-rcpt__body">
|
||||
<div class="aad-rcpt__reason">
|
||||
<mat-icon>info</mat-icon>
|
||||
<span>{{ data.reason }}</span>
|
||||
</div>
|
||||
|
||||
<p class="aad-rcpt__intro">
|
||||
For a customer who has applied for Aadhaar but has not been issued one yet, there is no Aadhaar number to run the
|
||||
OTP check against. Record the UIDAI enrolment slip instead — the client can then be created without an Aadhaar KYC
|
||||
exception being raised.
|
||||
</p>
|
||||
|
||||
<mat-form-field appearance="outline" class="aad-rcpt__field">
|
||||
<mat-label>Enrolment number (EID)</mat-label>
|
||||
<span matPrefix class="aad-rcpt__prefix"><mat-icon>confirmation_number</mat-icon></span>
|
||||
<input matInput [(ngModel)]="enrolmentId" placeholder="1234/56789/01234" maxlength="20" autocomplete="off" />
|
||||
<mat-hint [class.aad-rcpt__hint--bad]="enrolmentIdEntered && !enrolmentIdValid">
|
||||
{{ enrolmentIdHint }}
|
||||
</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="aad-rcpt__field">
|
||||
<mat-label>Enrolment date</mat-label>
|
||||
<input matInput [matDatepicker]="enrolPicker" [max]="today" [(ngModel)]="enrolmentDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="enrolPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #enrolPicker></mat-datepicker>
|
||||
<mat-hint>Date printed on the slip</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
@if (monthsSinceEnrolment !== null) {
|
||||
<div class="aad-rcpt__age" [class.aad-rcpt__age--stale]="receiptIsStale">
|
||||
<mat-icon>{{ receiptIsStale ? 'warning' : 'schedule' }}</mat-icon>
|
||||
<span>
|
||||
@if (receiptIsStale) {
|
||||
Enrolled {{ monthsSinceEnrolment }} months ago. An enrolment normally completes well inside six months —
|
||||
chase the customer for the Aadhaar itself. The receipt can still be recorded.
|
||||
} @else {
|
||||
Enrolled {{ monthsSinceEnrolment }} month{{ monthsSinceEnrolment === 1 ? '' : 's' }} ago.
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<mat-form-field appearance="outline" class="aad-rcpt__remarks">
|
||||
<mat-label>Remarks</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
rows="3"
|
||||
[(ngModel)]="remarks"
|
||||
maxlength="500"
|
||||
placeholder="What was sighted, and when the Aadhaar is expected"
|
||||
></textarea>
|
||||
<mat-hint>{{ remarks.length }}/500</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-checkbox class="aad-rcpt__sighted" [(ngModel)]="sighted">
|
||||
The original UIDAI enrolment slip has been sighted and a copy filed with the account opening form.
|
||||
</mat-checkbox>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end" class="aad-rcpt__actions">
|
||||
<button mat-stroked-button mat-dialog-close type="button">Cancel</button>
|
||||
<button mat-flat-button color="primary" type="button" [disabled]="!canSubmit" (click)="submit()">
|
||||
Save Receipt
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</div>
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.aad-rcpt {
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.aad-rcpt__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 24px 12px;
|
||||
border-bottom: 1px solid #bfdbfe;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.aad-rcpt__header-icon {
|
||||
color: #2563eb;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.aad-rcpt__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1e3a8a;
|
||||
}
|
||||
|
||||
.aad-rcpt__subtitle {
|
||||
font-size: 12px;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.aad-rcpt__body {
|
||||
padding: 18px 24px !important;
|
||||
font-size: 14px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.aad-rcpt__reason {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 14px;
|
||||
border-radius: 6px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 auto;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.aad-rcpt__intro {
|
||||
margin: 0 0 16px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.aad-rcpt__field,
|
||||
.aad-rcpt__remarks {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.aad-rcpt__prefix {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-right: 8px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.aad-rcpt__hint--bad {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
// Age of the slip — informational, so it reads as context rather than a blocking error.
|
||||
.aad-rcpt__age {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 2px 0 14px;
|
||||
padding: 9px 12px;
|
||||
border-radius: 6px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: #475569;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 auto;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&--stale {
|
||||
background: #fffbeb;
|
||||
border-color: #fde68a;
|
||||
color: #92400e;
|
||||
|
||||
mat-icon {
|
||||
color: #d97706;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.aad-rcpt__sighted {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
|
||||
::ng-deep .mdc-form-field > label {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.aad-rcpt__actions {
|
||||
padding: 10px 24px 16px !important;
|
||||
margin: 0 !important;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 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, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogRef,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
} from '@angular/material/dialog';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
export interface AadhaarReceiptDialogData {
|
||||
/** Client name, shown so the officer can check the slip belongs to the right person. */
|
||||
clientName: string;
|
||||
/** Message the Aadhaar step came back with, or why the officer is recording a receipt. */
|
||||
reason: string;
|
||||
/** Existing receipt, when the officer re-opens one already taken. */
|
||||
existing?: AadhaarReceiptResult | null;
|
||||
}
|
||||
|
||||
export interface AadhaarReceiptResult {
|
||||
/** 14-digit enrolment number as printed on the slip, separators kept. */
|
||||
enrolmentId: string;
|
||||
enrolmentDate: Date | null;
|
||||
remarks: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the UIDAI enrolment receipt accepted in place of a verified Aadhaar.
|
||||
*
|
||||
* A customer who has applied for Aadhaar but has not been issued one holds only an enrolment slip.
|
||||
* There is no Aadhaar number to send to the OTP flow, so no amount of retrying will clear the
|
||||
* check — the same dead end the PAN declaration resolves. Recording the slip lets the client be
|
||||
* created and waives the Aadhaar exception, while Aadhaar itself stays unverified.
|
||||
*
|
||||
* Only the 14-digit enrolment number is taken. The date and time printed beside it on the slip are
|
||||
* captured by the enrolment-date field, so asking for them again in the same box would just be a
|
||||
* second chance to mistype them.
|
||||
*
|
||||
* The number is validated on digit count rather than an exact layout: slips print it with varying
|
||||
* separators, and rejecting one because of a slash or a space would block a customer over
|
||||
* formatting rather than substance.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-aadhaar-receipt-dialog',
|
||||
templateUrl: './aadhaar-receipt-dialog.component.html',
|
||||
styleUrls: ['./aadhaar-receipt-dialog.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FormsModule,
|
||||
MatIcon,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
]
|
||||
})
|
||||
export class AadhaarReceiptDialogComponent {
|
||||
data = inject<AadhaarReceiptDialogData>(MAT_DIALOG_DATA);
|
||||
private dialogRef = inject(MatDialogRef<AadhaarReceiptDialogComponent>);
|
||||
|
||||
enrolmentId = '';
|
||||
enrolmentDate: Date | null = null;
|
||||
remarks = '';
|
||||
/** The officer confirms the original slip was sighted — nothing is saved without it. */
|
||||
sighted = false;
|
||||
|
||||
today = new Date();
|
||||
|
||||
constructor() {
|
||||
const existing = this.data.existing;
|
||||
if (existing) {
|
||||
this.enrolmentId = existing.enrolmentId || '';
|
||||
this.enrolmentDate = existing.enrolmentDate || null;
|
||||
this.remarks = existing.remarks || '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Digits only — separators on the printed slip are cosmetic and vary between forms. */
|
||||
private get enrolmentDigits(): string {
|
||||
return (this.enrolmentId || '').replace(/\D/g, '');
|
||||
}
|
||||
|
||||
get enrolmentIdEntered(): boolean {
|
||||
return this.enrolmentDigits.length > 0;
|
||||
}
|
||||
|
||||
/** UIDAI's enrolment number is 14 digits — the timestamp beside it is not part of it. */
|
||||
get enrolmentIdValid(): boolean {
|
||||
return this.enrolmentDigits.length === 14;
|
||||
}
|
||||
|
||||
/** How many digits are still missing, so the officer is told what to fix, not just that it failed. */
|
||||
get enrolmentIdHint(): string {
|
||||
const count = this.enrolmentDigits.length;
|
||||
if (!count) {
|
||||
return 'The 14-digit enrolment number only, e.g. 1234/56789/01234';
|
||||
}
|
||||
return `${count} of 14 digits`;
|
||||
}
|
||||
|
||||
/** A slip dated in the future is a keying error, not a valid enrolment. */
|
||||
get enrolmentDateValid(): boolean {
|
||||
return !!this.enrolmentDate && this.enrolmentDate.getTime() <= this.today.getTime();
|
||||
}
|
||||
|
||||
/** Whole months since enrolment — shown because a receipt is temporary by nature. */
|
||||
get monthsSinceEnrolment(): number | null {
|
||||
if (!this.enrolmentDate) {
|
||||
return null;
|
||||
}
|
||||
const months =
|
||||
(this.today.getFullYear() - this.enrolmentDate.getFullYear()) * 12 +
|
||||
(this.today.getMonth() - this.enrolmentDate.getMonth()) -
|
||||
(this.today.getDate() < this.enrolmentDate.getDate() ? 1 : 0);
|
||||
return Math.max(0, months);
|
||||
}
|
||||
|
||||
/**
|
||||
* An enrolment normally completes well inside six months, so an older slip is a prompt to chase
|
||||
* the customer for the Aadhaar itself. It is a warning, not a block: the branch may have good
|
||||
* reason to accept it, and the record shows the age either way.
|
||||
*/
|
||||
get receiptIsStale(): boolean {
|
||||
const months = this.monthsSinceEnrolment;
|
||||
return months !== null && months >= 6;
|
||||
}
|
||||
|
||||
get canSubmit(): boolean {
|
||||
return this.enrolmentIdValid && this.enrolmentDateValid && !!this.remarks.trim() && this.sighted;
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (!this.canSubmit) {
|
||||
return;
|
||||
}
|
||||
this.dialogRef.close({
|
||||
enrolmentId: this.enrolmentId.trim(),
|
||||
enrolmentDate: this.enrolmentDate,
|
||||
remarks: this.remarks.trim()
|
||||
} as AadhaarReceiptResult);
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,121 @@ export class KycUploadHelperService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a stacked front-above-back scan back into its two sides.
|
||||
*
|
||||
* Clients saved before the sides were stored separately carry one merged PNG, so a form that
|
||||
* wants a front image and a back image has nothing to show in the second slot. `mergeFrontBack`
|
||||
* lays the sides on a white canvas with a 20px gap between them, which leaves a band of
|
||||
* completely white rows across the full width — that band is the seam, and it is the only thing
|
||||
* in the file that says where one side ends and the other begins.
|
||||
*
|
||||
* Resolves null when no such seam is found, which is the honest answer for an image that was not
|
||||
* produced by that merge: better to show one scan than to cut a document in half at a guess.
|
||||
*/
|
||||
splitFrontBack(blob: Blob): Promise<{ front: Blob; back: Blob } | null> {
|
||||
if (!blob.type.startsWith('image/')) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
type Sides = { front: Blob; back: Blob } | null;
|
||||
return this.blobToImage(blob)
|
||||
.then((img): Sides | Promise<Sides> => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.naturalWidth || img.width;
|
||||
canvas.height = img.naturalHeight || img.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx || canvas.width === 0 || canvas.height === 0) {
|
||||
return null;
|
||||
}
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
const seam = this.findWhiteSeam(ctx, canvas.width, canvas.height);
|
||||
if (!seam) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Promise.all([
|
||||
this.cropToBlob(img, canvas.width, 0, seam.start),
|
||||
this.cropToBlob(img, canvas.width, seam.end, canvas.height - seam.end)
|
||||
]).then(
|
||||
([
|
||||
front,
|
||||
back
|
||||
]): Sides => (front && back ? { front, back } : null)
|
||||
);
|
||||
})
|
||||
.catch((): Sides => null);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tallest run of all-white rows in the middle of the image.
|
||||
*
|
||||
* Confined to the middle half because a scan often has white margins at the top and bottom, and
|
||||
* those are part of the document rather than the join between two of them.
|
||||
*/
|
||||
private findWhiteSeam(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number
|
||||
): { start: number; end: number } | null {
|
||||
const from = Math.floor(height * 0.25);
|
||||
const to = Math.ceil(height * 0.75);
|
||||
const step = Math.max(1, Math.floor(width / 64)); // sampling is enough to spot a non-white row
|
||||
|
||||
let bestStart = -1;
|
||||
let bestLength = 0;
|
||||
let runStart = -1;
|
||||
|
||||
for (let y = from; y <= to; y++) {
|
||||
let white = y < to;
|
||||
if (white) {
|
||||
const row = ctx.getImageData(0, y, width, 1).data;
|
||||
for (let x = 0; x < width; x += step) {
|
||||
const i = x * 4;
|
||||
if (row[i] < 245 || row[i + 1] < 245 || row[i + 2] < 245) {
|
||||
white = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (white) {
|
||||
if (runStart < 0) {
|
||||
runStart = y;
|
||||
}
|
||||
} else if (runStart >= 0) {
|
||||
if (y - runStart > bestLength) {
|
||||
bestStart = runStart;
|
||||
bestLength = y - runStart;
|
||||
}
|
||||
runStart = -1;
|
||||
}
|
||||
}
|
||||
|
||||
// The gap the merge inserts is 20px; anything much thinner is white space inside a document.
|
||||
return bestLength >= 12 ? { start: bestStart, end: bestStart + bestLength } : null;
|
||||
}
|
||||
|
||||
private cropToBlob(img: HTMLImageElement, width: number, top: number, height: number): Promise<Blob | null> {
|
||||
if (height <= 0) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
ctx.drawImage(img, 0, top, width, height, 0, 0, width, height);
|
||||
return new Promise((resolve) => canvas.toBlob((out) => resolve(out), 'image/png'));
|
||||
}
|
||||
|
||||
private blobToImage(blob: Blob): Promise<HTMLImageElement> {
|
||||
return this.fileToImage(blob as File);
|
||||
}
|
||||
|
||||
private fileToImage(file: File): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
<!--
|
||||
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="pan-decl">
|
||||
<div class="pan-decl__header">
|
||||
<mat-icon class="pan-decl__header-icon">assignment_late</mat-icon>
|
||||
<div>
|
||||
<div class="pan-decl__title">PAN Verification Failed — Take Declaration</div>
|
||||
<div class="pan-decl__subtitle">PAN {{ data.panNumber }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-dialog-content class="pan-decl__body">
|
||||
<div class="pan-decl__reason">
|
||||
<mat-icon>info</mat-icon>
|
||||
<span>{{ data.failureMessage }}</span>
|
||||
</div>
|
||||
|
||||
@if (!data.unverifiable) {
|
||||
<p class="pan-decl__intro">
|
||||
The Income Tax check matches on <strong>name and date of birth together</strong> and does not report which of
|
||||
the two differs. Enter the details exactly as printed on the customer's PAN card so the declaration records what
|
||||
actually does not match.
|
||||
</p>
|
||||
|
||||
<!-- Comparison: what we submitted vs what the PAN card says -->
|
||||
<div class="pan-decl__compare">
|
||||
<div class="pan-decl__compare-head">
|
||||
<span class="lbl"></span>
|
||||
<span class="col">Submitted</span>
|
||||
<span class="col">As on PAN card</span>
|
||||
<span class="res"></span>
|
||||
</div>
|
||||
|
||||
<div class="pan-decl__compare-row" [class.mismatch]="nameMismatch">
|
||||
<span class="lbl">Name</span>
|
||||
<span class="col submitted">{{ data.submittedName || '—' }}</span>
|
||||
<span class="col">
|
||||
<mat-form-field appearance="outline" class="pan-decl__field">
|
||||
<mat-label>Name on PAN</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[(ngModel)]="nameOnPan"
|
||||
(ngModelChange)="onCompare()"
|
||||
placeholder="e.g. RAJESH KUMAR SHARMA"
|
||||
maxlength="200"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</span>
|
||||
<span class="res">
|
||||
@if (compared && nameOnPan.trim()) {
|
||||
@if (nameMismatch) {
|
||||
<mat-icon class="res-icon res-icon--bad" aria-label="Does not match">cancel</mat-icon>
|
||||
} @else {
|
||||
<mat-icon class="res-icon res-icon--ok" aria-label="Matches">check_circle</mat-icon>
|
||||
}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="pan-decl__compare-row" [class.mismatch]="dobMismatch">
|
||||
<span class="lbl">Date of Birth</span>
|
||||
<span class="col submitted">{{ data.submittedDob || '—' }}</span>
|
||||
<span class="col">
|
||||
<mat-form-field appearance="outline" class="pan-decl__field">
|
||||
<mat-label>DOB on PAN</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[matDatepicker]="dobPicker"
|
||||
[max]="today"
|
||||
[(ngModel)]="dobOnPan"
|
||||
(ngModelChange)="onCompare()"
|
||||
/>
|
||||
<mat-datepicker-toggle matSuffix [for]="dobPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #dobPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
</span>
|
||||
<span class="res">
|
||||
@if (compared && dobOnPan) {
|
||||
@if (dobMismatch) {
|
||||
<mat-icon class="res-icon res-icon--bad" aria-label="Does not match">cancel</mat-icon>
|
||||
} @else {
|
||||
<mat-icon class="res-icon res-icon--ok" aria-label="Matches">check_circle</mat-icon>
|
||||
}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- What the declaration will state -->
|
||||
<div
|
||||
class="pan-decl__verdict"
|
||||
[class.pan-decl__verdict--pending]="!comparisonComplete"
|
||||
[class.pan-decl__verdict--clean]="nothingToDeclare"
|
||||
>
|
||||
<span class="pan-decl__verdict-label">Does not match:</span>
|
||||
<span class="pan-decl__verdict-value">{{ mismatchLabel }}</span>
|
||||
</div>
|
||||
|
||||
@if (!comparisonComplete) {
|
||||
<p class="pan-decl__note">Enter both PAN-card values to see exactly which detail differs.</p>
|
||||
}
|
||||
|
||||
@if (nothingToDeclare) {
|
||||
<p class="pan-decl__warn">
|
||||
<mat-icon>warning</mat-icon>
|
||||
The PAN-card details you entered are the same as the ones submitted, so there is nothing to declare. Correct
|
||||
the client's name or date of birth on the General step and verify PAN again.
|
||||
</p>
|
||||
}
|
||||
} @else {
|
||||
<p class="pan-decl__intro">
|
||||
PAN could not be checked against the Income Tax records. Record the declaration taken from the customer so the
|
||||
client can be created without a KYC exception being raised for PAN.
|
||||
</p>
|
||||
}
|
||||
|
||||
<!-- The signed paper is the declaration; it is uploaded, not described. -->
|
||||
<div class="pan-decl__upload">
|
||||
<div class="pan-decl__upload-label">Signed declaration</div>
|
||||
<p class="pan-decl__upload-help">
|
||||
Get the declaration signed by the customer and upload the scanned copy. It is filed against the client as the
|
||||
proof that holds the PAN exception off.
|
||||
</p>
|
||||
|
||||
@if (!hasDeclarationFile) {
|
||||
<label class="pan-decl__dropzone">
|
||||
<input type="file" [accept]="acceptedTypes" (change)="onDeclarationFile($event)" hidden />
|
||||
<mat-icon class="pan-decl__dropzone-icon">cloud_upload</mat-icon>
|
||||
<span class="pan-decl__dropzone-text">Click to upload signed declaration</span>
|
||||
<span class="pan-decl__dropzone-hint">PDF, JPG or PNG — up to 5 MB</span>
|
||||
</label>
|
||||
} @else {
|
||||
<div class="pan-decl__file">
|
||||
<mat-icon class="pan-decl__file-icon">description</mat-icon>
|
||||
<span class="pan-decl__file-meta">
|
||||
<span class="pan-decl__file-name">{{ fileName }}</span>
|
||||
<span class="pan-decl__file-sub">{{ fileSizeLabel }}</span>
|
||||
</span>
|
||||
<label class="pan-decl__file-replace">
|
||||
<input type="file" [accept]="acceptedTypes" (change)="onDeclarationFile($event)" hidden />
|
||||
Replace
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="pan-decl__file-remove"
|
||||
aria-label="Remove file"
|
||||
(click)="removeDeclarationFile()"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (fileError) {
|
||||
<p class="pan-decl__file-error">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>{{ fileError }}</span>
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end" class="pan-decl__actions">
|
||||
<button mat-stroked-button mat-dialog-close type="button">Cancel</button>
|
||||
<button mat-flat-button color="primary" type="button" [disabled]="!canSubmit" (click)="submit()">
|
||||
Save Declaration
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</div>
|
||||
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.pan-decl {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.pan-decl__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 24px 12px;
|
||||
border-bottom: 1px solid #fde68a;
|
||||
background: #fffbeb;
|
||||
}
|
||||
|
||||
.pan-decl__header-icon {
|
||||
color: #d97706;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.pan-decl__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.pan-decl__subtitle {
|
||||
font-size: 12px;
|
||||
color: #b45309;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.pan-decl__body {
|
||||
padding: 18px 24px !important;
|
||||
font-size: 14px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.pan-decl__reason {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 14px;
|
||||
border-radius: 6px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #991b1b;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.pan-decl__intro {
|
||||
margin: 0 0 16px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
// ── Submitted vs PAN-card comparison ───────────────────────────────────────
|
||||
.pan-decl__compare {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pan-decl__compare-head,
|
||||
.pan-decl__compare-row {
|
||||
display: grid;
|
||||
grid-template-columns: 110px 1fr 1fr 36px;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
.pan-decl__compare-head {
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.pan-decl__compare-row {
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
// A row the declaration will name — tinted so the officer can see the finding at a glance.
|
||||
&.mismatch {
|
||||
background: #fff7ed;
|
||||
}
|
||||
|
||||
.lbl {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.submitted {
|
||||
font-size: 13px;
|
||||
color: #0f172a;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.pan-decl__field {
|
||||
width: 100%;
|
||||
|
||||
::ng-deep .mat-mdc-form-field-subscript-wrapper {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.res {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.res-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.res-icon--ok {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.res-icon--bad {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
// ── Verdict ────────────────────────────────────────────────────────────────
|
||||
.pan-decl__verdict {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
|
||||
&--pending {
|
||||
background: #f8fafc;
|
||||
border-color: #e2e8f0;
|
||||
}
|
||||
|
||||
&--clean {
|
||||
background: #f0fdf4;
|
||||
border-color: #bbf7d0;
|
||||
}
|
||||
}
|
||||
|
||||
.pan-decl__verdict-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.pan-decl__verdict-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.pan-decl__note {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.pan-decl__warn {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 10px 0 0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: #b45309;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Signed declaration upload ──────────────────────────────────────────────
|
||||
.pan-decl__upload {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.pan-decl__upload-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.pan-decl__upload-help {
|
||||
margin: 4px 0 10px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.pan-decl__dropzone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 18px 24px;
|
||||
background: #fff;
|
||||
border: 2px dashed #cbd5e1;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f8fafc;
|
||||
border-color: #93c5fd;
|
||||
}
|
||||
}
|
||||
|
||||
.pan-decl__dropzone-icon {
|
||||
font-size: 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.pan-decl__dropzone-text {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.pan-decl__dropzone-hint {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.pan-decl__file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
|
||||
.pan-decl__file-icon {
|
||||
color: #16a34a;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.pan-decl__file-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.pan-decl__file-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #166534;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pan-decl__file-sub {
|
||||
font-size: 11px;
|
||||
color: #4d7c69;
|
||||
}
|
||||
|
||||
.pan-decl__file-replace {
|
||||
flex: 0 0 auto;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid #86efac;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #166534;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #ecfdf5;
|
||||
}
|
||||
}
|
||||
|
||||
.pan-decl__file-remove {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
.pan-decl__file-error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
margin: 8px 0 0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: #b91c1c;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.pan-decl__actions {
|
||||
padding: 10px 24px 16px !important;
|
||||
margin: 0 !important;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* 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, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogRef,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
} from '@angular/material/dialog';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/** Which client details differ from the PAN card. */
|
||||
export type PanMismatchField = 'NAME' | 'DOB';
|
||||
|
||||
export interface PanDeclarationDialogData {
|
||||
/** Name submitted on the General step — the value sent to the Income Tax check. */
|
||||
submittedName: string;
|
||||
/** Date of birth submitted on the General step, in the tenant's display format. */
|
||||
submittedDob: string;
|
||||
/** PAN being declared against. */
|
||||
panNumber: string;
|
||||
/** Message the verification came back with, shown so the officer knows why they are here. */
|
||||
failureMessage: string;
|
||||
/**
|
||||
* True when the check failed on something other than name/DOB (invalid PAN, vendor unreachable).
|
||||
* The PAN-card fields then have nothing to compare against, so the mismatch reads NONE and the
|
||||
* officer only has to record why the declaration is being taken.
|
||||
*/
|
||||
unverifiable?: boolean;
|
||||
/** Existing declaration, when the officer re-opens one already taken. */
|
||||
existing?: PanDeclarationResult | null;
|
||||
}
|
||||
|
||||
export interface PanDeclarationResult {
|
||||
/** 'NAME', 'DOB', 'NAME,DOB', or 'NONE' when PAN could not be checked at all. */
|
||||
mismatchFields: string;
|
||||
nameOnPan: string | null;
|
||||
dobOnPan: Date | null;
|
||||
/**
|
||||
* The scanned signed declaration, to be filed against the client. Null when an existing
|
||||
* declaration was re-opened and the officer did not replace the document already on file.
|
||||
*/
|
||||
file: File | null;
|
||||
/** Name of the declaration document on record — what the KYC record keeps as the reference. */
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the customer declaration that stands in for a passing PAN check.
|
||||
*
|
||||
* The Income Tax API matches on name *and* date of birth and tells us only that the pair failed —
|
||||
* never which half. So rather than show the officer a dead-end error, this dialog asks them to key
|
||||
* in the two values printed on the physical PAN card in front of them. Comparing those against what
|
||||
* was submitted is what produces a specific, defensible "NAME does not match" on the declaration,
|
||||
* and the PAN-card values are kept alongside it as the evidence for that statement.
|
||||
*
|
||||
* Name comparison is deliberately loose about case and internal spacing — "RAJESH KUMAR" and
|
||||
* "Rajesh Kumar" are the same name, and flagging them as a mismatch would put a declaration on file
|
||||
* that says something untrue.
|
||||
*
|
||||
* The declaration itself is taken as an upload rather than typed: what waives the PAN exception is
|
||||
* the paper the customer signed, so the scan of it is the record — an officer's typed note is not.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-pan-declaration-dialog',
|
||||
templateUrl: './pan-declaration-dialog.component.html',
|
||||
styleUrls: ['./pan-declaration-dialog.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FormsModule,
|
||||
MatIcon,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
]
|
||||
})
|
||||
export class PanDeclarationDialogComponent {
|
||||
data = inject<PanDeclarationDialogData>(MAT_DIALOG_DATA);
|
||||
private dialogRef = inject(MatDialogRef<PanDeclarationDialogComponent>);
|
||||
|
||||
/** Name exactly as printed on the PAN card. */
|
||||
nameOnPan = '';
|
||||
/** Date of birth exactly as printed on the PAN card. */
|
||||
dobOnPan: Date | null = null;
|
||||
|
||||
/** The scan the officer just picked. Null while an earlier upload is still the one on file. */
|
||||
declarationFile: File | null = null;
|
||||
/** Name of the document already filed, when re-opening a declaration that was taken before. */
|
||||
existingFileName = '';
|
||||
/** Why the picked file was rejected — shown in place of failing silently. */
|
||||
fileError = '';
|
||||
|
||||
/** Officer has not yet typed anything to compare against. */
|
||||
compared = false;
|
||||
|
||||
today = new Date();
|
||||
|
||||
readonly acceptedTypes = '.pdf,.jpg,.jpeg,.png';
|
||||
private readonly maxFileBytes = 5 * 1024 * 1024;
|
||||
private readonly acceptedExtensions = [
|
||||
'pdf',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'png'
|
||||
];
|
||||
|
||||
constructor() {
|
||||
const existing = this.data.existing;
|
||||
if (existing) {
|
||||
this.nameOnPan = existing.nameOnPan || '';
|
||||
this.dobOnPan = existing.dobOnPan || null;
|
||||
this.existingFileName = existing.fileName || '';
|
||||
this.compared = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** The document the declaration will be saved against — newly picked, or the one already filed. */
|
||||
get fileName(): string {
|
||||
return this.declarationFile?.name || this.existingFileName;
|
||||
}
|
||||
|
||||
get hasDeclarationFile(): boolean {
|
||||
return !!this.fileName;
|
||||
}
|
||||
|
||||
/** Size of the picked scan, or a note that the document on file is being kept as it is. */
|
||||
get fileSizeLabel(): string {
|
||||
if (!this.declarationFile) {
|
||||
return 'Already on file';
|
||||
}
|
||||
const kb = this.declarationFile.size / 1024;
|
||||
return kb < 1024 ? `${Math.max(1, Math.round(kb))} KB` : `${(kb / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
onDeclarationFile(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0] || null;
|
||||
input.value = ''; // allow re-picking the same filename
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
const extension = (file.name.split('.').pop() || '').toLowerCase();
|
||||
if (!this.acceptedExtensions.includes(extension)) {
|
||||
this.fileError = 'Upload the signed declaration as a PDF, JPG or PNG.';
|
||||
return;
|
||||
}
|
||||
if (file.size > this.maxFileBytes) {
|
||||
this.fileError = 'The file is larger than 5 MB. Scan at a lower resolution and try again.';
|
||||
return;
|
||||
}
|
||||
this.fileError = '';
|
||||
this.declarationFile = file;
|
||||
}
|
||||
|
||||
/** Drops the picked scan — and the one already filed, since neither may be left implied. */
|
||||
removeDeclarationFile(): void {
|
||||
this.declarationFile = null;
|
||||
this.existingFileName = '';
|
||||
this.fileError = '';
|
||||
}
|
||||
|
||||
/** Case- and spacing-insensitive: only a genuinely different name counts as a mismatch. */
|
||||
private normalizeName(value: string): string {
|
||||
return (value || '').trim().replace(/\s+/g, ' ').toUpperCase();
|
||||
}
|
||||
|
||||
private formatDob(value: Date | null): string {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
const dd = value.getDate().toString().padStart(2, '0');
|
||||
const mm = (value.getMonth() + 1).toString().padStart(2, '0');
|
||||
return `${dd}/${mm}/${value.getFullYear()}`;
|
||||
}
|
||||
|
||||
get nameMismatch(): boolean {
|
||||
if (this.data.unverifiable || !this.nameOnPan.trim()) {
|
||||
return false;
|
||||
}
|
||||
return this.normalizeName(this.nameOnPan) !== this.normalizeName(this.data.submittedName);
|
||||
}
|
||||
|
||||
get dobMismatch(): boolean {
|
||||
if (this.data.unverifiable || !this.dobOnPan) {
|
||||
return false;
|
||||
}
|
||||
// Compare on the rendered day/month/year so a time component on either value cannot matter.
|
||||
return this.formatDob(this.dobOnPan) !== this.normalizeDisplayDob(this.data.submittedDob);
|
||||
}
|
||||
|
||||
/** The submitted DOB arrives pre-formatted for display; reduce it to dd/MM/yyyy for comparison. */
|
||||
private normalizeDisplayDob(display: string): string {
|
||||
const parsed = new Date(display);
|
||||
return isNaN(parsed.getTime()) ? (display || '').trim() : this.formatDob(parsed);
|
||||
}
|
||||
|
||||
/** The fields the declaration will state as differing. */
|
||||
get mismatchFields(): PanMismatchField[] {
|
||||
const fields: PanMismatchField[] = [];
|
||||
if (this.nameMismatch) {
|
||||
fields.push('NAME');
|
||||
}
|
||||
if (this.dobMismatch) {
|
||||
fields.push('DOB');
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
get mismatchLabel(): string {
|
||||
if (this.data.unverifiable) {
|
||||
return 'PAN could not be verified';
|
||||
}
|
||||
const fields = this.mismatchFields;
|
||||
if (!fields.length) {
|
||||
return this.compared ? 'No difference found' : 'Not yet compared';
|
||||
}
|
||||
return fields.map((f) => (f === 'NAME' ? 'Name' : 'Date of Birth')).join(' and ');
|
||||
}
|
||||
|
||||
/** Both PAN-card values must be keyed in before we can honestly say what differs. */
|
||||
get comparisonComplete(): boolean {
|
||||
return !!this.nameOnPan.trim() && !!this.dobOnPan;
|
||||
}
|
||||
|
||||
onCompare(): void {
|
||||
this.compared = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* A declaration must actually declare something. When the officer keys in PAN-card values that
|
||||
* match what was submitted, the mismatch is somewhere else — nothing to sign off here, and the
|
||||
* right move is to retry the verification rather than paper over it.
|
||||
*/
|
||||
get nothingToDeclare(): boolean {
|
||||
return !this.data.unverifiable && this.comparisonComplete && this.mismatchFields.length === 0;
|
||||
}
|
||||
|
||||
get canSubmit(): boolean {
|
||||
// The signed scan is the declaration — without it there is nothing to waive the exception on.
|
||||
if (!this.hasDeclarationFile) {
|
||||
return false;
|
||||
}
|
||||
if (this.data.unverifiable) {
|
||||
return true;
|
||||
}
|
||||
return this.comparisonComplete && this.mismatchFields.length > 0;
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (!this.canSubmit) {
|
||||
return;
|
||||
}
|
||||
const result: PanDeclarationResult = {
|
||||
mismatchFields: this.data.unverifiable ? 'NONE' : this.mismatchFields.join(','),
|
||||
nameOnPan: this.data.unverifiable ? null : this.nameOnPan.trim(),
|
||||
dobOnPan: this.data.unverifiable ? null : this.dobOnPan,
|
||||
file: this.declarationFile,
|
||||
fileName: this.fileName
|
||||
};
|
||||
this.dialogRef.close(result);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user