diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.html b/src/app/cashier/transaction-entry/transaction-entry.component.html
index 98ae9647e..de55bc0f0 100644
--- a/src/app/cashier/transaction-entry/transaction-entry.component.html
+++ b/src/app/cashier/transaction-entry/transaction-entry.component.html
@@ -178,7 +178,7 @@
(row.dbCr = 'Cr')
"
>
- @for (a of acTypOptions; track a) {
+ @for (a of acTypOptionsFor(row); track a) {
}
@@ -432,8 +432,14 @@
: 'Auto-selects next available leaflet and opens walk-in purchaser form'
"
>
- post_add
- Fill DD Details & Send for Approval
+ {{
+ row.ddDetails ? 'edit_note' : 'post_add'
+ }}
+
+ {{
+ row.ddDetails ? 'DD ' + row.ddDetails.ddNumber + ' · Edit Details' : 'Fill DD Details'
+ }}
@@ -1529,7 +1535,7 @@
(row.dbCr = 'Cr')
"
>
- @for (a of acTypOptions; track a) {
+ @for (a of acTypOptionsFor(row); track a) {
}
@@ -1756,7 +1762,7 @@
"
>
post_add
- Fill DD Details & Send for Approval
+ Fill DD Details
diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts
index 5a49e956d..f787adb1c 100644
--- a/src/app/cashier/transaction-entry/transaction-entry.component.ts
+++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts
@@ -57,6 +57,27 @@ interface TxnRow {
* account — governs the share redemption (see submitShareRedemptionTransfer()). */
resolutionNumber?: string;
meetingDate?: string;
+ /**
+ * Beneficiary and leaflet captured in the DD dialog, held on the row until Save.
+ *
+ * The dialog used to submit the request itself, which left the worksheet's own Save button doing
+ * nothing for a DD row — the clerk filled the dialog, the request was already gone, and pressing
+ * Save afterwards had no effect. Filling in details and committing them are two different
+ * actions, so the dialog now only records what was entered and Save is what sends it.
+ */
+ ddDetails?: {
+ leafletId: number;
+ ddNumber: string;
+ beneficiaryName: string;
+ payableAtBranch: string;
+ payableAtCity: string;
+ /** Present only for a walk-in cash draft. */
+ purchaser?: { name: string; address: string; aadhaar: string };
+ /** Note-and-coin breakdown, cash drafts only. */
+ denominations?: { [key: string]: number };
+ fundingAccountId?: number;
+ fundingClientId?: number;
+ };
}
interface Denomination {
@@ -1671,6 +1692,33 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
'GL',
'LA'
];
+
+ /**
+ * Account types a clerk may fund a demand draft from.
+ *
+ * A clerk raises a draft against a member's own balance, so the only sensible sources are the
+ * accounts a member actually holds money in. GL was offered here too, and picking it quietly
+ * switched the row onto the cash path — the one that asks for a walk-in purchaser's name and a
+ * note-and-coin count, neither of which a clerk debiting an account has. The option is removed
+ * rather than validated afterwards, so the wrong path cannot be reached at all.
+ */
+ private static readonly DD_CLERK_ACTYPS = [
+ 'SD',
+ 'CA'
+ ];
+
+ /**
+ * The account types this particular row may use.
+ *
+ * Every other row keeps the full list; only a clerk's DD row is narrowed, because that is the
+ * only row where the account type silently decides which funding flow runs.
+ */
+ acTypOptionsFor(row: TxnRow): string[] {
+ if (this.isClerk && row.typ === 'DD') {
+ return TransactionEntryComponent.DD_CLERK_ACTYPS;
+ }
+ return this.acTypOptions;
+ }
dbCrOptions = [
'Dr',
'Cr'
@@ -2230,12 +2278,13 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// panel never appears and the row would post as an ordinary entry with the charges silently
// dropped.
//
- // A clerk funds a draft through an account or an instrument instead, and their TXN TP. list no
- // longer offers cash, so defaulting to it would open the field on a value it cannot show.
- // isDdCashGlRow() still holds for them through AC.TP = GL.
+ // A clerk funds a draft by debiting a member's own account, so their row opens on savings and
+ // is debited, and GL is not on their AC.TP list at all (see acTypOptionsFor). Opening them on
+ // GL put the row on the cash path, which asks for a walk-in purchaser and a note-and-coin
+ // count that a clerk debiting an account does not have.
case 'DD':
- row.dbCr = 'Cr';
- row.acTyp = 'GL';
+ row.dbCr = this.isClerk ? 'Dr' : 'Cr';
+ row.acTyp = this.isClerk ? 'SD' : 'GL';
row.paymentTypeId = this.isClerk ? this.findTransferPaymentTypeId() : this.findCashPaymentTypeId();
row.chequeNo = '';
row.cheqDate = '';
@@ -2416,13 +2465,34 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
});
+ // Recorded on the row, not sent. Save is what commits the worksheet, and a DD row that
+ // submitted itself here left that button with nothing to do — so the clerk had no way to
+ // review the entered details, and no way to abandon them either.
dialogRef.afterClosed().subscribe((result) => {
if (!result) return;
- if (accountDebit) {
- this.submitAccountDebitDd(row, leaflet, result, fundingAccountId!, fundingClientId!);
- } else {
- this.submitWalkinCashDd(row, leaflet, result);
- }
+ row.ddDetails = {
+ leafletId: leaflet.id,
+ ddNumber: leaflet.ddNumber,
+ beneficiaryName: result.beneficiaryName,
+ payableAtBranch: result.payableAtBranch,
+ payableAtCity: result.payableAtCity,
+ purchaser: accountDebit
+ ? undefined
+ : {
+ name: result.walkinPurchaserName,
+ address: result.walkinPurchaserAddress,
+ aadhaar: result.walkinPurchaserAadhaar
+ },
+ fundingAccountId: accountDebit ? fundingAccountId! : undefined,
+ fundingClientId: accountDebit ? fundingClientId! : undefined
+ };
+ this.showAlert('success', 'DD Details Captured', [
+ `DD No. ${leaflet.ddNumber} · in favour of ${result.beneficiaryName}.`,
+ accountDebit
+ ? `₹${this.ddTotalPayableFor(row)} will be debited from ${row.acNo} once a checker approves.`
+ : `₹${this.ddTotalPayableFor(row)} to be collected at the counter.`,
+ 'Press Save to send this draft for approval.'
+ ]);
});
},
error: (err) => {
@@ -2480,6 +2550,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
row.amount = null;
row.ddCharges = null;
row.description = '';
+ // Cleared with the row: leaving the captured details behind would let a second Save raise
+ // the same draft again against a leaflet that has already been used.
+ row.ddDetails = undefined;
},
error: (err) => {
this.saving = false;
@@ -2488,6 +2561,53 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
});
}
+ /**
+ * Sends the drafts on the worksheet for approval.
+ *
+ * Each row carries the beneficiary details the dialog captured; a row that was never filled in is
+ * refused by name rather than skipped, because a clerk who pressed Save expecting one draft and
+ * got none has no way of telling which row was missing.
+ */
+ private saveDdRows(ddRows: TxnRow[]): void {
+ const unfilled = ddRows.filter((r) => !r.ddDetails);
+ if (unfilled.length > 0) {
+ this.showAlert('error', 'DD Details Not Entered', [
+ unfilled.length === 1
+ ? 'One DD row has no beneficiary details yet.'
+ : `${unfilled.length} DD rows have no beneficiary details yet.`,
+ 'Use "Fill DD Details & Send for Approval" on each DD row first, then press Save.'
+ ]);
+ return;
+ }
+
+ for (const row of ddRows) {
+ const details = row.ddDetails!;
+ const leaflet = { id: details.leafletId, ddNumber: details.ddNumber };
+ if (this.isDdAccountDebitRow(row)) {
+ this.submitAccountDebitDd(
+ row,
+ leaflet,
+ {
+ beneficiaryName: details.beneficiaryName,
+ payableAtBranch: details.payableAtBranch,
+ payableAtCity: details.payableAtCity
+ },
+ details.fundingAccountId!,
+ details.fundingClientId!
+ );
+ } else {
+ this.submitWalkinCashDd(row, leaflet, {
+ beneficiaryName: details.beneficiaryName,
+ payableAtBranch: details.payableAtBranch,
+ payableAtCity: details.payableAtCity,
+ walkinPurchaserName: details.purchaser?.name,
+ walkinPurchaserAddress: details.purchaser?.address,
+ walkinPurchaserAadhaar: details.purchaser?.aadhaar
+ });
+ }
+ }
+ }
+
private submitWalkinCashDd(row: TxnRow, leaflet: any, purchaserData: any): void {
// Read through countFor, which knows the map is keyed by kind:value. Indexing by d.id looked
// right but matched nothing, so every cash DD went in with no breakdown at all and its money
@@ -2557,6 +2677,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
row.amount = null;
row.ddCharges = null;
row.description = '';
+ // See the account-debit path: a second Save must not re-raise a draft already issued.
+ row.ddDetails = undefined;
this.clearDenominations();
},
error: (err) => {
@@ -4361,6 +4483,15 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
onSave(): void {
+ // Demand drafts commit here like everything else on the worksheet. Checked first because a DD
+ // row shares its shape with the ordinary entries below — without this it would fall through and
+ // post as a plain transaction, with the draft never raised and the charges silently dropped.
+ const ddRows = this.rows.filter((r) => this.isDdRow(r));
+ if (ddRows.length > 0) {
+ this.saveDdRows(ddRows);
+ return;
+ }
+
// Clerk bank transfer — N DR savings/CA + N CR savings/CA, immediate execution. 'Fund Transfer'
// is the same flow under a second TYPE label — onTypChange() already defaults it identically to
// 'Enter Bank Transfer', so submission has to recognise both or a share redemption or bank
diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html
index 57311257a..dd1e065c3 100644
--- a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html
+++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html
@@ -82,12 +82,13 @@
{{ kycDocumentAlertTitle }}
}
diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.scss b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.scss
index 101a3e073..0f4520920 100644
--- a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.scss
+++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.scss
@@ -1681,3 +1681,129 @@
.kyc-doc-scans-link__go {
margin-left: auto;
}
+
+/* The scan itself, beside the number and dates it belongs to. */
+.kyc-doc-scans {
+ margin-top: 10px;
+ padding: 12px;
+ border: 1px solid #e2e8f0;
+ border-radius: 8px;
+ background: #fff;
+}
+
+.kyc-doc-scans__head {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 10px;
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 0.03em;
+ text-transform: uppercase;
+ color: #475569;
+
+ mat-icon {
+ font-size: 18px;
+ width: 18px;
+ height: 18px;
+ color: #64748b;
+ }
+}
+
+.kyc-doc-scans__empty {
+ margin-bottom: 10px;
+ font-size: 12.5px;
+ color: #64748b;
+}
+
+.kyc-doc-scans__sides {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 14px;
+}
+
+.kyc-doc-side {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ width: 190px;
+}
+
+.kyc-doc-side__label {
+ font-size: 11.5px;
+ font-weight: 700;
+ color: #334155;
+}
+
+/* Fixed height so front and back line up whatever the scan's aspect ratio is. */
+.kyc-doc-side__frame {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 118px;
+ padding: 6px;
+ border: 1px solid #e2e8f0;
+ border-radius: 6px;
+ background: #f8fafc;
+ overflow: hidden;
+
+ img {
+ max-width: 100%;
+ max-height: 100%;
+ object-fit: contain;
+ border-radius: 3px;
+ }
+}
+
+.kyc-doc-side__none {
+ font-size: 12px;
+ color: #94a3b8;
+}
+
+.kyc-doc-side__file {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 4px;
+ padding: 0 6px;
+ font-size: 11px;
+ color: #475569;
+ text-align: center;
+ word-break: break-word;
+
+ mat-icon {
+ color: #64748b;
+ }
+}
+
+.kyc-doc-side__btn {
+ font-size: 12px;
+
+ mat-progress-spinner {
+ display: inline-block;
+ margin-right: 6px;
+ vertical-align: middle;
+ }
+}
+
+.kyc-doc-scans__msg {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-top: 10px;
+ font-size: 12.5px;
+
+ mat-icon {
+ font-size: 17px;
+ width: 17px;
+ height: 17px;
+ }
+
+ &--err {
+ color: #b91c1c;
+ }
+
+ &--ok {
+ color: #15803d;
+ }
+}
diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts
index 365e9c0a7..ecfdd0210 100644
--- a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts
+++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts
@@ -11,7 +11,7 @@ import { Component, OnInit, OnDestroy, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { UntypedFormBuilder, UntypedFormGroup, Validators, FormsModule } from '@angular/forms';
import { MatDialog } from '@angular/material/dialog';
-import { catchError, of, Subject, takeUntil } from 'rxjs';
+import { catchError, forkJoin, map, of, Subject, takeUntil } from 'rxjs';
/** Custom Services */
import { ClientsService } from '../../clients.service';
@@ -30,6 +30,11 @@ import {
AadhaarReceiptDialogComponent,
AadhaarReceiptResult
} from '../../shared/aadhaar-receipt-dialog/aadhaar-receipt-dialog.component';
+import {
+ KycDocumentUpdateDialogComponent,
+ KycDocumentUpdateData,
+ KycDocumentUpdateResult
+} from '../../shared/kyc-document-update-dialog/kyc-document-update-dialog.component';
import { Dates } from 'app/core/utils/dates';
import { jsPDF } from 'jspdf';
import { MatIcon } from '@angular/material/icon';
@@ -38,6 +43,15 @@ import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+/** One side of the scanned KYC document, with the preview once it has been fetched. */
+interface DocScanSide {
+ documentId: string;
+ /** The identifier row this scan hangs off; the two sides can sit on different rows. */
+ identifierId: string;
+ fileName: string;
+ previewUrl: string | null;
+}
+
/**
* KYC Tab Component.
* Displays and manages KYC (Know Your Customer) details for an existing client.
@@ -149,9 +163,39 @@ export class KycTabComponent implements OnInit, OnDestroy {
exceptionReason: [[]]
});
+ // ── Officially valid document scans ────────────────────────────────────────
+
+ /**
+ * The scans of the document named above, by side.
+ *
+ * The screen used to show the document's number and dates but not the image behind them, and
+ * sent the officer to the client edit screen to change it. Re-collecting an expiring document is
+ * the one thing this panel exists for, so the scan is shown and replaced here.
+ */
+ docScans: { front: DocScanSide | null; back: DocScanSide | null } = { front: null, back: null };
+
+ /** The two sides, in display order, so the template loops rather than repeating itself. */
+ readonly docScanSlots: ('front' | 'back')[] = ['front', 'back'];
+
+ docScanOf(slot: 'front' | 'back') {
+ return this.docScans[slot];
+ }
+
+ /** Identifier row replacements are filed against. Null when the document has no identifier. */
+ private docIdentifierId: string | null = null;
+ /** Every identifier row carrying this document, since the sides can be split across them. */
+ private docIdentifierIds: string[] = [];
+ docScansLoading = false;
+ /** Which side is mid-upload, so only that button shows a spinner. */
+ docScanUploading: 'front' | 'back' | null = null;
+ docScanError: string | null = null;
+ docScanSuccess: string | null = null;
+
ngOnInit(): void {
this.route.parent.params.subscribe((params: any) => {
this.clientId = params['clientId'];
+ // Scans are matched against the document number on the KYC record, so they are loaded once
+ // that record has arrived rather than alongside it.
this.loadKycData();
this.loadClientDateOfBirth();
});
@@ -190,6 +234,8 @@ export class KycTabComponent implements OnInit, OnDestroy {
isException: data.isException || false,
exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : []
});
+ // Now that the document number is known, the scans filed under it can be found.
+ this.loadDocumentScans();
}
});
}
@@ -1307,6 +1353,297 @@ I further confirm that:
doc.save(`Self_Declaration_${cifNumber}_${clientName.replace(/\s/g, '_')}.pdf`);
}
+ // ── Document scans ─────────────────────────────────────────────────────────
+
+ /**
+ * Finds the identifier the KYC document was filed under and loads its scans.
+ *
+ * The KYC record keeps the document's name and number but not the identifier id, so the two are
+ * matched on the number — that is what makes them the same document, and it survives the client
+ * holding several identifiers of the same type.
+ */
+ private loadDocumentScans(): void {
+ if (!this.clientId) {
+ return;
+ }
+ this.docScansLoading = true;
+ this.clientsService
+ .getClientIdentifiers(this.clientId)
+ .pipe(catchError(() => of([] as any[])))
+ .subscribe((identifiers: any[]) => {
+ const wantedKey = (this.kycData?.kycDocumentNumber || '').toString().trim();
+ const wantedName = (this.kycData?.kycDocumentName || '').toString().trim().toLowerCase();
+ const norm = (s: any) => (s || '').toString().trim().toLowerCase();
+
+ // Every identifier carrying this document number, not just the first. The onboarding step
+ // files each side against its own identifier row, so this client's Aadhaar front sits on
+ // one row and the back on another under the same number - taking only the first match
+ // showed the front and reported the back as missing.
+ let matches = (identifiers || []).filter((i) => wantedKey && norm(i.documentKey) === norm(wantedKey));
+ if (!matches.length) {
+ matches = (identifiers || []).filter((i) => wantedName && norm(i.documentType?.name) === wantedName);
+ }
+
+ if (!matches.length) {
+ this.docScansLoading = false;
+ this.docIdentifierId = null;
+ this.docIdentifierIds = [];
+ this.docScans = { front: null, back: null };
+ return;
+ }
+ // Replacements are filed against the first row, so the sides stay together from here on.
+ this.docIdentifierId = String(matches[0].id);
+ this.docIdentifierIds = matches.map((m: any) => String(m.id));
+ this.fetchScansFor(this.docIdentifierIds);
+ });
+ }
+
+ /**
+ * Sides are read off the document name, which is how they were written at upload
+ * (" — Front" / "— Back"). Where the name says nothing, upload order stands in, front
+ * first, rather than leaving a scan the officer cannot see.
+ */
+ private fetchScansFor(identifierIds: string[]): void {
+ forkJoin(
+ identifierIds.map((id) =>
+ this.clientsService.getClientIdentificationDocuments(id).pipe(
+ // Tagged with the row it came from, because the download call needs the parent id and
+ // the sides can be spread across more than one.
+ map((docs: any) => ((docs as any[]) || []).map((d) => ({ ...d, identifierId: id }))),
+ catchError(() => of([] as any[]))
+ )
+ )
+ ).subscribe((perIdentifier: any[][]) => {
+ this.docScansLoading = false;
+ // This runs again after every replace, so the previews being thrown away are revoked
+ // rather than left holding their blobs.
+ for (const old of [this.docScans.front, this.docScans.back]) {
+ if (old?.previewUrl) {
+ URL.revokeObjectURL(old.previewUrl);
+ }
+ }
+
+ // Oldest first by document id, so the newest upload for a side is the last one written and
+ // therefore the one that stands. Identifier order cannot do this job: a replacement is filed
+ // against the first row while the original sits on the second, so reading the rows in order
+ // let a document uploaded months ago overwrite the scan collected a moment ago - the officer
+ // pressed Replace, saw "replaced", and the old image came straight back.
+ const docs = perIdentifier.flat().sort((a, b) => Number(a.id) - Number(b.id));
+ const sides: typeof this.docScans = { front: null, back: null };
+ const unlabelled: any[] = [];
+
+ const entryOf = (d: any): DocScanSide => ({
+ documentId: String(d.id),
+ identifierId: String(d.identifierId),
+ fileName: d.fileName || d.name,
+ previewUrl: null
+ });
+
+ for (const d of docs) {
+ const text = `${d.name || ''} ${d.fileName || ''}`.toLowerCase();
+ // Later uploads win, so a replaced side shows the new scan rather than the original.
+ if (text.includes('back')) {
+ sides.back = entryOf(d);
+ } else if (text.includes('front')) {
+ sides.front = entryOf(d);
+ } else {
+ unlabelled.push(d);
+ }
+ }
+ for (const d of unlabelled) {
+ if (!sides.front) {
+ sides.front = entryOf(d);
+ } else if (!sides.back) {
+ sides.back = entryOf(d);
+ }
+ }
+
+ this.docScans = sides;
+ for (const key of ['front', 'back'] as const) {
+ if (sides[key]) {
+ this.loadScanPreview(sides[key]!);
+ }
+ }
+ });
+ }
+
+ private loadScanPreview(side: DocScanSide): void {
+ this.clientsService
+ .downloadClientIdentificationDocument(side.identifierId, side.documentId)
+ .pipe(catchError(() => of(null)))
+ .subscribe((blob: Blob | null) => {
+ if (!blob || !blob.type?.startsWith('image/')) {
+ return;
+ }
+ // Revoked in ngOnDestroy; a preview left behind holds the blob in memory for the life of
+ // the tab.
+ side.previewUrl = URL.createObjectURL(blob);
+ });
+ }
+
+ /**
+ * Replaces one side with a freshly collected scan.
+ *
+ * The new file is uploaded under the same side name, which is what the readers here and on the
+ * client screen key on, so the latest upload becomes the scan on show. The old file is left in
+ * place deliberately: a KYC document that has been re-collected is a record of what was held
+ * before and when it changed, and destroying it would leave the audit trail with a gap.
+ */
+ onDocScanSelected(event: Event, slot: 'front' | 'back'): void {
+ const input = event.target as HTMLInputElement;
+ const file = input.files?.[0];
+ // Cleared so choosing the same filename twice still fires a change event.
+ input.value = '';
+ if (!file || !this.docIdentifierId) {
+ return;
+ }
+
+ this.docScanError = null;
+ this.docScanSuccess = null;
+ this.docScanUploading = slot;
+
+ const label = slot === 'front' ? 'Front' : 'Back';
+ const docName = this.kycForm.get('kycDocumentName')?.value || this.kycData?.kycDocumentName || 'KYC document';
+
+ const formData = new FormData();
+ formData.append('file', file);
+ formData.append('name', `${docName} — ${label}`);
+ formData.append('description', `Re-collected on ${new Date().toLocaleDateString('en-IN')}`);
+
+ this.clientsService.uploadClientIdentifierDocument(this.docIdentifierId, formData).subscribe({
+ next: () => {
+ this.docScanUploading = null;
+ this.docScanSuccess = `${label} side replaced.`;
+ // Re-read every row, not just the one written to: the other side still lives on its own.
+ this.fetchScansFor(this.docIdentifierIds);
+ },
+ error: (err: any) => {
+ this.docScanUploading = null;
+ this.docScanError =
+ err?.error?.defaultUserMessage || err?.error?.developerMessage || `Could not replace the ${label.toLowerCase()} side.`;
+ }
+ });
+ }
+
+ /**
+ * A verified record only accepts an amendment with a reason on it, so the dialog has to ask for
+ * one. 300 is VERIFIED; the code form is checked too, since either can arrive on the payload.
+ */
+ private get isKycVerified(): boolean {
+ return this.kycData?.kycStatus === 300 || this.kycData?.kycStatusCode === 'clientKycStatus.verified';
+ }
+
+ /**
+ * Re-collects the officially valid document from its own row.
+ *
+ * PAN and Aadhaar are each handled on their row; this one sent the officer into edit mode on the
+ * whole KYC form to do the same job. The dialog asks for the number, the dates and each side of
+ * the scan together, which is what re-collecting a document actually is.
+ */
+ updateKycDocument(): void {
+ if (!this.kycId) {
+ this.docScanError = 'KYC record not found. Save KYC details first.';
+ return;
+ }
+
+ this.dialog
+ .open(KycDocumentUpdateDialogComponent, {
+ width: '640px',
+ maxWidth: '95vw',
+ disableClose: true,
+ data: {
+ documentName: this.kycData?.kycDocumentName || 'KYC document',
+ documentNumber: this.kycData?.kycDocumentNumber || '',
+ issueDate: this.parseApiDate(this.kycData?.kycDocIssueDate),
+ expiryDate: this.parseApiDate(this.kycData?.kycDocExpiryDate),
+ reason: this.showKycDocumentAlert ? this.kycDocumentAlertMessage : '',
+ currentFront: this.docScans.front?.previewUrl || null,
+ currentBack: this.docScans.back?.previewUrl || null,
+ requiresRemarks: this.isKycVerified
+ } as KycDocumentUpdateData
+ })
+ .afterClosed()
+ .subscribe((result: KycDocumentUpdateResult | undefined) => {
+ if (result) {
+ this.persistKycDocument(result);
+ }
+ });
+ }
+
+ /**
+ * Saves the corrected details, then uploads whichever sides were re-scanned.
+ *
+ * The details go first: they are the record, and a scan attached to a document whose number was
+ * never corrected would file the new image under the old identity.
+ */
+ private persistKycDocument(result: KycDocumentUpdateResult): void {
+ this.saving = true;
+ this.docScanError = null;
+ this.docScanSuccess = null;
+
+ const dateFormat = 'dd MMMM yyyy';
+ const payload: any = {
+ locale: 'en',
+ dateFormat,
+ kycDocumentName: this.kycData?.kycDocumentName || null,
+ kycDocumentNumber: result.documentNumber || null,
+ kycDocIssueDate: result.issueDate ? this.dateUtils.formatDate(result.issueDate, dateFormat) : null,
+ kycDocExpiryDate: result.expiryDate ? this.dateUtils.formatDate(result.expiryDate, dateFormat) : null
+ };
+ // The server refuses an amendment to a verified record without one, and records it as the
+ // reason the document changed.
+ if (result.remarks) {
+ payload.remarks = result.remarks;
+ }
+
+ this.clientsService.updateClientKyc(this.clientId, this.kycId!, payload).subscribe({
+ next: () => {
+ this.saving = false;
+ this.docScanSuccess = 'Document updated.';
+ this.loadKycData();
+ this.uploadReplacedSides(result);
+ },
+ error: (err: any) => {
+ this.saving = false;
+ this.docScanError =
+ err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not update the document.';
+ }
+ });
+ }
+
+ /** Sends each re-scanned side through the same upload path the panel below uses. */
+ private uploadReplacedSides(result: KycDocumentUpdateResult): void {
+ if (!this.docIdentifierId) {
+ return;
+ }
+ const sides: { slot: 'front' | 'back'; file: File }[] = [];
+ if (result.front) {
+ sides.push({ slot: 'front', file: result.front });
+ }
+ if (result.back) {
+ sides.push({ slot: 'back', file: result.back });
+ }
+ if (!sides.length) {
+ return;
+ }
+
+ const docName = this.kycData?.kycDocumentName || 'KYC document';
+ forkJoin(
+ sides.map(({ slot, file }) => {
+ const formData = new FormData();
+ formData.append('file', file);
+ formData.append('name', `${docName} — ${slot === 'front' ? 'Front' : 'Back'}`);
+ formData.append('description', `Re-collected on ${new Date().toLocaleDateString('en-IN')}`);
+ return this.clientsService
+ .uploadClientIdentifierDocument(this.docIdentifierId!, formData)
+ .pipe(catchError(() => of(null)));
+ })
+ ).subscribe(() => {
+ this.docScanSuccess = 'Document and scan updated.';
+ this.fetchScansFor(this.docIdentifierIds);
+ });
+ }
+
/** Handle file selection for signed declaration upload */
onDeclarationFileSelected(event: Event): void {
const input = event.target as HTMLInputElement;
@@ -1365,5 +1702,11 @@ I further confirm that:
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
+ // Object URLs are held by the browser until revoked, so the scan blobs would outlive the tab.
+ for (const side of [this.docScans.front, this.docScans.back]) {
+ if (side?.previewUrl) {
+ URL.revokeObjectURL(side.previewUrl);
+ }
+ }
}
}
diff --git a/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.html b/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.html
new file mode 100644
index 000000000..6e646f72a
--- /dev/null
+++ b/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.html
@@ -0,0 +1,137 @@
+
+
+
+ description
+ Update {{ data.documentName }}
+
+
+
+
+ @if (data.reason) {
+
+ schedule
+ {{ data.reason }}
+
+ }
+
+
+
+
+
Scanned copy
+
+ Leave a side untouched to keep the scan already on file. The previous copy is kept either way.
+