Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73871a0993 | ||
|
|
7b0cb5bb63 | ||
|
|
646e73d08a |
@@ -1321,6 +1321,11 @@
|
|||||||
kycForm.get('kycDocumentNumber')?.hasError('pattern') && kycForm.get('kycDocumentNumber')?.touched
|
kycForm.get('kycDocumentNumber')?.hasError('pattern') && kycForm.get('kycDocumentNumber')?.touched
|
||||||
) {
|
) {
|
||||||
<mat-error>Only letters, numbers, hyphens and slashes allowed</mat-error>
|
<mat-error>Only letters, numbers, hyphens and slashes allowed</mat-error>
|
||||||
|
} @else if (
|
||||||
|
kycForm.get('kycDocumentNumber')?.hasError('aadhaarLast4Mismatch') &&
|
||||||
|
kycForm.get('kycDocumentNumber')?.touched
|
||||||
|
) {
|
||||||
|
<mat-error>Last 4 digits do not match the Aadhaar number entered above</mat-error>
|
||||||
}
|
}
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,14 @@
|
|||||||
|
|
||||||
import { Component, Input, OnInit, OnChanges, OnDestroy, SimpleChanges, inject } from '@angular/core';
|
import { Component, Input, OnInit, OnChanges, OnDestroy, SimpleChanges, inject } from '@angular/core';
|
||||||
import { forkJoin, catchError, of } from 'rxjs';
|
import { forkJoin, catchError, of } from 'rxjs';
|
||||||
import { UntypedFormBuilder, UntypedFormGroup, Validators, FormsModule } from '@angular/forms';
|
import {
|
||||||
|
UntypedFormBuilder,
|
||||||
|
UntypedFormGroup,
|
||||||
|
Validators,
|
||||||
|
FormsModule,
|
||||||
|
AbstractControl,
|
||||||
|
ValidationErrors
|
||||||
|
} from '@angular/forms';
|
||||||
import {
|
import {
|
||||||
MatDialog,
|
MatDialog,
|
||||||
MAT_DIALOG_DATA,
|
MAT_DIALOG_DATA,
|
||||||
@@ -567,6 +574,28 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
/** Existing signature document ID (for re-upload/delete) */
|
/** Existing signature document ID (for re-upload/delete) */
|
||||||
existingSignatureDocId: string = '';
|
existingSignatureDocId: string = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-field check for the OVD "Document Number" field (kycDocumentNumber): when Aadhaar Card
|
||||||
|
* is the selected KYC document, its value must identify the same Aadhaar as the aadhaarNumber
|
||||||
|
* field captured earlier in this step - the last 4 digits are what's compared, since
|
||||||
|
* aadhaarNumber may hold either the raw 12-digit number or a masked value (masking only ever
|
||||||
|
* obscures the first 8 digits - see aadhaarMasked in the kycData getter below - so the last 4
|
||||||
|
* characters are always the real digits either way). Not applicable to any other document type,
|
||||||
|
* where kycDocumentNumber is unrelated to Aadhaar entirely.
|
||||||
|
*/
|
||||||
|
private aadhaarLast4MatchValidator = (control: AbstractControl): ValidationErrors | null => {
|
||||||
|
if (this.selectedKycDoc?.code !== 'AADHAAR') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const docNumber = (control.value || '').toString().trim();
|
||||||
|
const aadhaarValue = (control.parent?.get('aadhaarNumber')?.value || '').toString().trim();
|
||||||
|
if (docNumber.length < 4 || aadhaarValue.length < 4) {
|
||||||
|
// Required/pattern validators already flag an empty or too-short value on their own.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return docNumber.slice(-4) === aadhaarValue.slice(-4) ? null : { aadhaarLast4Mismatch: true };
|
||||||
|
};
|
||||||
|
|
||||||
/** KYC Form */
|
/** KYC Form */
|
||||||
kycForm: UntypedFormGroup = this.formBuilder.group({
|
kycForm: UntypedFormGroup = this.formBuilder.group({
|
||||||
hasPan: [true],
|
hasPan: [true],
|
||||||
@@ -616,7 +645,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
[
|
[
|
||||||
Validators.required,
|
Validators.required,
|
||||||
Validators.pattern(/^[A-Za-z0-9\-\/\s]+$/),
|
Validators.pattern(/^[A-Za-z0-9\-\/\s]+$/),
|
||||||
Validators.maxLength(30)
|
Validators.maxLength(30),
|
||||||
|
this.aadhaarLast4MatchValidator
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
kycDocIssueDate: [''],
|
kycDocIssueDate: [''],
|
||||||
@@ -739,6 +769,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
this.kycForm.get('ecgcSalFlag')?.disable();
|
this.kycForm.get('ecgcSalFlag')?.disable();
|
||||||
this.kycForm.get('dormantFlag')?.disable();
|
this.kycForm.get('dormantFlag')?.disable();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// aadhaarLast4MatchValidator (on kycDocumentNumber) reads this control's value directly rather
|
||||||
|
// than through its own valueChanges, so a re-check has to be triggered explicitly whenever the
|
||||||
|
// Aadhaar field itself changes - otherwise fixing aadhaarNumber after kycDocumentNumber was
|
||||||
|
// already entered would leave a stale mismatch error in place.
|
||||||
|
this.kycForm.get('aadhaarNumber')?.valueChanges.subscribe(() => {
|
||||||
|
this.kycForm.get('kycDocumentNumber')?.updateValueAndValidity({ onlySelf: true });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Seed the document-type picker from defaults (entity RBI docs vs. individual OVDs,
|
/** Seed the document-type picker from defaults (entity RBI docs vs. individual OVDs,
|
||||||
@@ -1009,6 +1047,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
// Reset uploads + merged preview when document type changes
|
// Reset uploads + merged preview when document type changes
|
||||||
this.kycDocFiles = { front: null, back: null };
|
this.kycDocFiles = { front: null, back: null };
|
||||||
this.kycDocMergedPreview = null;
|
this.kycDocMergedPreview = null;
|
||||||
|
// aadhaarLast4MatchValidator only applies while Aadhaar Card is selected - re-check so switching
|
||||||
|
// to/away from it immediately picks up (or clears) the mismatch error.
|
||||||
|
this.kycForm.get('kycDocumentNumber')?.updateValueAndValidity({ onlySelf: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
isKycDocSelected(proof: ProofType): boolean {
|
isKycDocSelected(proof: ProofType): boolean {
|
||||||
@@ -1189,6 +1230,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
// Sync chip-picker selection with the prefilled document name
|
// Sync chip-picker selection with the prefilled document name
|
||||||
if (data.kycDocumentName) {
|
if (data.kycDocumentName) {
|
||||||
this.selectedKycDoc = this.findKycDocProofByName(data.kycDocumentName);
|
this.selectedKycDoc = this.findKycDocProofByName(data.kycDocumentName);
|
||||||
|
// patchValue above ran kycDocumentNumber's validators before selectedKycDoc was known,
|
||||||
|
// so aadhaarLast4MatchValidator needs a fresh check now that it is.
|
||||||
|
this.kycForm.get('kycDocumentNumber')?.updateValueAndValidity({ onlySelf: true });
|
||||||
}
|
}
|
||||||
// Set verification statuses from existing data
|
// Set verification statuses from existing data
|
||||||
if (data.panVerified) {
|
if (data.panVerified) {
|
||||||
|
|||||||
+2
-78
@@ -51,53 +51,12 @@
|
|||||||
}
|
}
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
<mat-form-field appearance="outline">
|
|
||||||
<mat-label>Closure Reason</mat-label>
|
|
||||||
<mat-select formControlName="prematureClosureReasonId">
|
|
||||||
@for (reason of closureReasonOptions; track reason.id) {
|
|
||||||
<mat-option [value]="reason.id">{{ reason.label }}</mat-option>
|
|
||||||
}
|
|
||||||
</mat-select>
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
@if (loadingAmount) {
|
@if (loadingAmount) {
|
||||||
<div class="pcd__loading">
|
<div class="pcd__loading">
|
||||||
<mat-spinner diameter="28"></mat-spinner>
|
<mat-spinner diameter="28"></mat-spinner>
|
||||||
<span>Calculating premature-closure amount...</span>
|
<span>Calculating premature-closure amount...</span>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
@if (data.activatedOnDate || data.maturityDate || data.currentBalance !== undefined) {
|
|
||||||
<div class="pcd__preview pcd__preview--tenure">
|
|
||||||
@if (data.activatedOnDate) {
|
|
||||||
<div class="pcd__preview-row">
|
|
||||||
<span class="pcd__preview-label">Current Term Started</span>
|
|
||||||
<span class="pcd__preview-value">{{ activatedOnDateDisplay }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="pcd__preview-row">
|
|
||||||
<span class="pcd__preview-label">Tenure Completed (this term)</span>
|
|
||||||
<span class="pcd__preview-value"
|
|
||||||
>{{ tenureElapsedDisplay }} <span class="pcd__preview-sub">({{ daysElapsed }} days)</span></span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (data.maturityDate) {
|
|
||||||
<div class="pcd__preview-row">
|
|
||||||
<span class="pcd__preview-label">Scheduled Maturity Date</span>
|
|
||||||
<span class="pcd__preview-value"
|
|
||||||
>{{ maturityDateDisplay }}
|
|
||||||
<span class="pcd__preview-sub">({{ daysRemainingToMaturity }} days left)</span></span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (data.currentBalance !== undefined && data.currentBalance !== null) {
|
|
||||||
<div class="pcd__preview-row">
|
|
||||||
<span class="pcd__preview-label">Current Balance (as of today)</span>
|
|
||||||
<span class="pcd__preview-value">{{ data.currentBalance | formatNumber }}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (maturityAmount !== null && maturityAmount !== undefined) {
|
@if (maturityAmount !== null && maturityAmount !== undefined) {
|
||||||
<div class="pcd__preview">
|
<div class="pcd__preview">
|
||||||
@if (data.depositAmount) {
|
@if (data.depositAmount) {
|
||||||
@@ -112,27 +71,12 @@
|
|||||||
<span class="pcd__preview-value">{{ data.nominalAnnualInterestRate }}%</span>
|
<span class="pcd__preview-value">{{ data.nominalAnnualInterestRate }}%</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (effectiveInterestRate !== null && effectiveInterestRate !== undefined) {
|
|
||||||
<div class="pcd__preview-row pcd__preview-row--highlight">
|
|
||||||
<span class="pcd__preview-label">Effective Rate for this closure</span>
|
|
||||||
<span class="pcd__preview-value">{{ effectiveInterestRate }}%</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (data.preClosurePenalApplicable) {
|
@if (data.preClosurePenalApplicable) {
|
||||||
<div
|
<div class="pcd__preview-row">
|
||||||
class="pcd__preview-row"
|
|
||||||
[class.pcd__preview-row--waived]="prematureCloseForm.get('waivePreClosurePenalty').value"
|
|
||||||
>
|
|
||||||
<span class="pcd__preview-label"
|
<span class="pcd__preview-label"
|
||||||
>Pre-Closure Penalty (on {{ data.preClosurePenalInterestOnType?.value }})</span
|
>Pre-Closure Penalty (on {{ data.preClosurePenalInterestOnType?.value }})</span
|
||||||
>
|
>
|
||||||
<span class="pcd__preview-value">
|
<span class="pcd__preview-value">− {{ data.preClosurePenalInterest }}%</span>
|
||||||
@if (prematureCloseForm.get('waivePreClosurePenalty').value) {
|
|
||||||
<span class="pcd__penalty-waived-tag">Waived</span>
|
|
||||||
} @else {
|
|
||||||
− {{ data.preClosurePenalInterest }}%
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (interestEarned !== null) {
|
@if (interestEarned !== null) {
|
||||||
@@ -150,26 +94,6 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (data.preClosurePenalApplicable) {
|
|
||||||
@if (isAutoWaivedReason) {
|
|
||||||
<div class="pcd__waive-locked">
|
|
||||||
<fa-icon icon="check-circle" class="m-r-10"></fa-icon>
|
|
||||||
<span>
|
|
||||||
Penalty automatically waived -- <strong>{{ selectedReasonLabel }}</strong> always waives the
|
|
||||||
pre-closure penalty, regardless of this checkbox.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<mat-checkbox formControlName="waivePreClosurePenalty" class="pcd__waive-checkbox">
|
|
||||||
Waive the pre-closure penalty for this closure
|
|
||||||
<span class="pcd__waive-hint">
|
|
||||||
Interest recalculates at the full contracted rate instead of the penalty rate above. This does not
|
|
||||||
change the product's own penalty setting -- it applies to this one closure only.
|
|
||||||
</span>
|
|
||||||
</mat-checkbox>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="pcd__section-label" id="pcd-action-label">{{ 'labels.inputs.Action' | translate }}</div>
|
<div class="pcd__section-label" id="pcd-action-label">{{ 'labels.inputs.Action' | translate }}</div>
|
||||||
<div class="pcd__options" role="radiogroup" aria-labelledby="pcd-action-label">
|
<div class="pcd__options" role="radiogroup" aria-labelledby="pcd-action-label">
|
||||||
@for (closureOption of onAccountClosureOptions; track closureOption) {
|
@for (closureOption of onAccountClosureOptions; track closureOption) {
|
||||||
|
|||||||
-80
@@ -204,86 +204,6 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
}
|
}
|
||||||
|
|
||||||
&-sub {
|
|
||||||
font-family: 'DM Sans', sans-serif;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #94a3b8;
|
|
||||||
margin-left: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.pcd__preview--tenure {
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pcd__preview-row--waived {
|
|
||||||
.pcd__preview-label {
|
|
||||||
color: #16a34a;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.pcd__penalty-waived-tag {
|
|
||||||
font-family: 'DM Sans', sans-serif;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
color: #15803d;
|
|
||||||
background: #dcfce7;
|
|
||||||
border: 1px solid #86efac;
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 3px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pcd__waive-checkbox {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
margin: 0 0 18px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #0f172a;
|
|
||||||
|
|
||||||
::ng-deep .mdc-form-field {
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
::ng-deep .mdc-checkbox {
|
|
||||||
margin-top: -9px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.pcd__waive-locked {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 10px;
|
|
||||||
background: #f0fdf4;
|
|
||||||
border: 1px solid #bbf7d0;
|
|
||||||
border-left: 4px solid #16a34a;
|
|
||||||
border-radius: 10px;
|
|
||||||
color: #15803d;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.5;
|
|
||||||
padding: 12px 16px;
|
|
||||||
margin: 0 0 18px;
|
|
||||||
|
|
||||||
strong {
|
|
||||||
color: #166534;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.pcd__waive-hint {
|
|
||||||
display: block;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #64748b;
|
|
||||||
margin-top: 3px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pcd__section-label {
|
.pcd__section-label {
|
||||||
|
|||||||
+2
-174
@@ -67,29 +67,6 @@ export class PrematureCloseDialogComponent implements OnInit {
|
|||||||
[PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'Move the full balance to another savings account'
|
[PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'Move the full balance to another savings account'
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Mirrors the backend's PrematureClosureReasonType values exactly -- see that enum for why these
|
|
||||||
* four (everything but Normal) always waive the penalty by RBI policy rather than at the maker's
|
|
||||||
* discretion. `gatedBy` is the product-facility flag (from `data`) that must be true for the option
|
|
||||||
* to even be offered; `null` means always offered. Deceased Settlement / Nominee-Legal Heir Closure
|
|
||||||
* are gated because they are the product's own opt-in facilities (see SavingsProduct
|
|
||||||
* deceasedSettlement/noPenaltyNomineeHeirClosure) -- a product that never enabled one shouldn't let
|
|
||||||
* a maker pick it to bypass a penalty that product's terms don't actually exempt.
|
|
||||||
*/
|
|
||||||
private static readonly NORMAL_REASON_ID = 100;
|
|
||||||
private static readonly ALL_CLOSURE_REASONS: {
|
|
||||||
id: number;
|
|
||||||
label: string;
|
|
||||||
autoWaived: boolean;
|
|
||||||
gatedBy: 'deceasedSettlement' | 'noPenaltyNomineeHeirClosure' | null;
|
|
||||||
}[] = [
|
|
||||||
{ id: 100, label: 'Normal Premature Closure', autoWaived: false, gatedBy: null },
|
|
||||||
{ id: 200, label: 'Premature Extension / Renewal', autoWaived: true, gatedBy: null },
|
|
||||||
{ id: 300, label: 'Conversion to Another Term Deposit', autoWaived: true, gatedBy: null },
|
|
||||||
{ id: 400, label: 'Deceased Settlement', autoWaived: true, gatedBy: 'deceasedSettlement' },
|
|
||||||
{ id: 500, label: 'Nominee / Legal Heir Closure', autoWaived: true, gatedBy: 'noPenaltyNomineeHeirClosure' }
|
|
||||||
];
|
|
||||||
|
|
||||||
dialogRef = inject<MatDialogRef<PrematureCloseDialogComponent>>(MatDialogRef);
|
dialogRef = inject<MatDialogRef<PrematureCloseDialogComponent>>(MatDialogRef);
|
||||||
data: {
|
data: {
|
||||||
accountId: number | string;
|
accountId: number | string;
|
||||||
@@ -99,11 +76,6 @@ export class PrematureCloseDialogComponent implements OnInit {
|
|||||||
preClosurePenalApplicable?: boolean;
|
preClosurePenalApplicable?: boolean;
|
||||||
preClosurePenalInterest?: number;
|
preClosurePenalInterest?: number;
|
||||||
preClosurePenalInterestOnType?: { value: string };
|
preClosurePenalInterestOnType?: { value: string };
|
||||||
deceasedSettlement?: boolean;
|
|
||||||
noPenaltyNomineeHeirClosure?: boolean;
|
|
||||||
activatedOnDate?: any;
|
|
||||||
maturityDate?: any;
|
|
||||||
currentBalance?: number;
|
|
||||||
} = inject(MAT_DIALOG_DATA);
|
} = inject(MAT_DIALOG_DATA);
|
||||||
private formBuilder = inject(UntypedFormBuilder);
|
private formBuilder = inject(UntypedFormBuilder);
|
||||||
private fixedDepositsService = inject(FixedDepositsService);
|
private fixedDepositsService = inject(FixedDepositsService);
|
||||||
@@ -114,10 +86,6 @@ export class PrematureCloseDialogComponent implements OnInit {
|
|||||||
onAccountClosureOptions: any[] = [];
|
onAccountClosureOptions: any[] = [];
|
||||||
savingsAccountsData: any[] = [];
|
savingsAccountsData: any[] = [];
|
||||||
maturityAmount: number = null;
|
maturityAmount: number = null;
|
||||||
/** The rate actually resolved for THIS preview by the backend (actual-period vs contracted,
|
|
||||||
* penalty-deducted or not) -- distinct from data.nominalAnnualInterestRate, which is always the
|
|
||||||
* account's unmodified contracted rate. Read fresh on every recalculation. */
|
|
||||||
effectiveInterestRate: number = null;
|
|
||||||
loadingAmount = true;
|
loadingAmount = true;
|
||||||
loadFailed = false;
|
loadFailed = false;
|
||||||
submitting = false;
|
submitting = false;
|
||||||
@@ -131,66 +99,17 @@ export class PrematureCloseDialogComponent implements OnInit {
|
|||||||
closedOnDate: [
|
closedOnDate: [
|
||||||
this.maxDate,
|
this.maxDate,
|
||||||
Validators.required
|
Validators.required
|
||||||
],
|
]
|
||||||
// Both always present (not added conditionally like onAccountClosureId below) so submit()
|
|
||||||
// always carries a value, even for a product with no penalty configured at all -- the backend
|
|
||||||
// ignores them there anyway, since there is nothing to waive.
|
|
||||||
prematureClosureReasonId: [PrematureCloseDialogComponent.NORMAL_REASON_ID],
|
|
||||||
waivePreClosurePenalty: [false]
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.prematureCloseForm
|
this.prematureCloseForm
|
||||||
.get('closedOnDate')
|
.get('closedOnDate')
|
||||||
.valueChanges.subscribe((date: Date) => this.calculatePrematureAmount(date));
|
.valueChanges.subscribe((date: Date) => this.calculatePrematureAmount(date));
|
||||||
// Waiving/un-waiving recalculates the preview the same way changing the date does -- the maker
|
|
||||||
// should see the actual amount they're about to confirm, not the penalty-applied one, the moment
|
|
||||||
// they tick the box.
|
|
||||||
this.prematureCloseForm
|
|
||||||
.get('waivePreClosurePenalty')
|
|
||||||
.valueChanges.subscribe(() => this.calculatePrematureAmount(this.prematureCloseForm.get('closedOnDate').value));
|
|
||||||
// Picking an RBI-mandated waiver reason forces the checkbox on (see onClosureReasonChange) and,
|
|
||||||
// like it, recalculates the preview so the amount shown always matches the reason selected.
|
|
||||||
this.prematureCloseForm
|
|
||||||
.get('prematureClosureReasonId')
|
|
||||||
.valueChanges.subscribe((id: number) => this.onClosureReasonChange(id));
|
|
||||||
// Same date the form already starts with -- without this the amount/options never load unless the
|
// Same date the form already starts with -- without this the amount/options never load unless the
|
||||||
// maker happens to touch the date picker, which the full-page form this replaces required too.
|
// maker happens to touch the date picker, which the full-page form this replaces required too.
|
||||||
this.calculatePrematureAmount(this.maxDate);
|
this.calculatePrematureAmount(this.maxDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The reason list this account's product actually allows -- see ALL_CLOSURE_REASONS' `gatedBy`. */
|
|
||||||
get closureReasonOptions(): { id: number; label: string; autoWaived: boolean }[] {
|
|
||||||
return PrematureCloseDialogComponent.ALL_CLOSURE_REASONS.filter(
|
|
||||||
(reason) => !reason.gatedBy || this.data[reason.gatedBy]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True when the selected reason waives the penalty by policy, not maker choice -- the checkbox is
|
|
||||||
* hidden in favour of a plain statement of that fact when this is true (see the template). */
|
|
||||||
get isAutoWaivedReason(): boolean {
|
|
||||||
return !!this.selectedReason?.autoWaived;
|
|
||||||
}
|
|
||||||
|
|
||||||
get selectedReasonLabel(): string {
|
|
||||||
return this.selectedReason?.label ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
private get selectedReason(): { id: number; label: string; autoWaived: boolean } | undefined {
|
|
||||||
const id = this.prematureCloseForm?.get('prematureClosureReasonId')?.value;
|
|
||||||
return PrematureCloseDialogComponent.ALL_CLOSURE_REASONS.find((reason) => reason.id === id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private onClosureReasonChange(reasonId: number): void {
|
|
||||||
const reason = PrematureCloseDialogComponent.ALL_CLOSURE_REASONS.find((r) => r.id === reasonId);
|
|
||||||
if (reason?.autoWaived) {
|
|
||||||
// emitEvent: false -- the explicit calculatePrematureAmount call below already covers this
|
|
||||||
// change; without it, this setValue would also fire waivePreClosurePenalty's own subscription
|
|
||||||
// and the amount would be calculated twice for one reason change.
|
|
||||||
this.prematureCloseForm.get('waivePreClosurePenalty').setValue(true, { emitEvent: false });
|
|
||||||
}
|
|
||||||
this.calculatePrematureAmount(this.prematureCloseForm.get('closedOnDate').value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private calculatePrematureAmount(date: Date): void {
|
private calculatePrematureAmount(date: Date): void {
|
||||||
this.loadingAmount = true;
|
this.loadingAmount = true;
|
||||||
this.loadFailed = false;
|
this.loadFailed = false;
|
||||||
@@ -199,9 +118,7 @@ export class PrematureCloseDialogComponent implements OnInit {
|
|||||||
const data = {
|
const data = {
|
||||||
closedOnDate: this.dateUtils.formatDate(date, dateFormat),
|
closedOnDate: this.dateUtils.formatDate(date, dateFormat),
|
||||||
dateFormat,
|
dateFormat,
|
||||||
locale,
|
locale
|
||||||
prematureClosureReasonId: this.prematureCloseForm.get('prematureClosureReasonId')?.value,
|
|
||||||
waivePreClosurePenalty: !!this.prematureCloseForm.get('waivePreClosurePenalty')?.value
|
|
||||||
};
|
};
|
||||||
this.fixedDepositsService
|
this.fixedDepositsService
|
||||||
.executeFixedDepositsAccountCommand(this.data.accountId as string, 'calculatePrematureAmount', data)
|
.executeFixedDepositsAccountCommand(this.data.accountId as string, 'calculatePrematureAmount', data)
|
||||||
@@ -210,7 +127,6 @@ export class PrematureCloseDialogComponent implements OnInit {
|
|||||||
this.savingsAccountsData = response.savingsAccounts ?? [];
|
this.savingsAccountsData = response.savingsAccounts ?? [];
|
||||||
this.onAccountClosureOptions = response.onAccountClosureOptions ?? [];
|
this.onAccountClosureOptions = response.onAccountClosureOptions ?? [];
|
||||||
this.maturityAmount = response.maturityAmount;
|
this.maturityAmount = response.maturityAmount;
|
||||||
this.effectiveInterestRate = response.nominalAnnualInterestRate ?? null;
|
|
||||||
this.loadingAmount = false;
|
this.loadingAmount = false;
|
||||||
if (!this.prematureCloseForm.contains('onAccountClosureId')) {
|
if (!this.prematureCloseForm.contains('onAccountClosureId')) {
|
||||||
this.prematureCloseForm.addControl('onAccountClosureId', new UntypedFormControl('', Validators.required));
|
this.prematureCloseForm.addControl('onAccountClosureId', new UntypedFormControl('', Validators.required));
|
||||||
@@ -250,94 +166,6 @@ export class PrematureCloseDialogComponent implements OnInit {
|
|||||||
return Number(this.maturityAmount) - Number(this.data.depositAmount);
|
return Number(this.maturityAmount) - Number(this.data.depositAmount);
|
||||||
}
|
}
|
||||||
|
|
||||||
get activatedOnDateDisplay(): string {
|
|
||||||
return this.formatFullDate(this.data.activatedOnDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
get maturityDateDisplay(): string {
|
|
||||||
return this.formatFullDate(this.data.maturityDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The date every "as of" figure in this dialog is measured against -- the maker's own chosen Premature
|
|
||||||
* Close Date, falling back to Fineract's configured Business Date. Deliberately NOT the real-world
|
|
||||||
* clock (`new Date()`): Fineract's business date can lag behind the calendar (a bank's own admin
|
|
||||||
* advances it), and the backend's interest calculation is measured from THIS date, not today's actual
|
|
||||||
* date. Using the real clock here previously showed a "days elapsed" figure that disagreed with the
|
|
||||||
* interest the backend actually computed for the same closure -- both must read off the same clock.
|
|
||||||
*/
|
|
||||||
private get referenceDate(): Date {
|
|
||||||
const picked = this.prematureCloseForm?.get('closedOnDate')?.value;
|
|
||||||
return picked instanceof Date ? picked : this.maxDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Days from the current term's start to the chosen closure date -- 0 if the date is missing or the
|
|
||||||
* closure date is somehow before it, rather than a negative. */
|
|
||||||
get daysElapsed(): number | null {
|
|
||||||
const start = this.data.activatedOnDate ? this.dateUtils.parseDate(this.data.activatedOnDate) : null;
|
|
||||||
if (!start) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return Math.max(0, this.dateUtils.calculateDiff(start, this.referenceDate));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Days from the chosen closure date to the scheduled maturity date -- 0 once matured/overdue rather
|
|
||||||
* than negative, since this dialog is for a still-Active account closing early, not a matured one. */
|
|
||||||
get daysRemainingToMaturity(): number | null {
|
|
||||||
const maturity = this.data.maturityDate ? this.dateUtils.parseDate(this.data.maturityDate) : null;
|
|
||||||
if (!maturity) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return Math.max(0, this.dateUtils.calculateDiff(this.referenceDate, maturity));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Elapsed tenure as Years/Months/Days, the way a real passbook states it, rather than a raw day
|
|
||||||
* count that is hard to read for a multi-year FD. */
|
|
||||||
get tenureElapsedDisplay(): string {
|
|
||||||
const start = this.data.activatedOnDate ? this.dateUtils.parseDate(this.data.activatedOnDate) : null;
|
|
||||||
if (!start) {
|
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
const { years, months, days } = PrematureCloseDialogComponent.yearsMonthsDaysBetween(start, this.referenceDate);
|
|
||||||
const parts: string[] = [];
|
|
||||||
if (years > 0) {
|
|
||||||
parts.push(`${years} yr${years === 1 ? '' : 's'}`);
|
|
||||||
}
|
|
||||||
if (months > 0) {
|
|
||||||
parts.push(`${months} mo${months === 1 ? '' : 's'}`);
|
|
||||||
}
|
|
||||||
if (days > 0 || parts.length === 0) {
|
|
||||||
parts.push(`${days} day${days === 1 ? '' : 's'}`);
|
|
||||||
}
|
|
||||||
return parts.join(', ');
|
|
||||||
}
|
|
||||||
|
|
||||||
private formatFullDate(value: any): string {
|
|
||||||
if (!value) {
|
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
const parsed = this.dateUtils.parseDate(value);
|
|
||||||
// "28 September 2026" -- day/month-name/year, so this reads unambiguously regardless of the
|
|
||||||
// account's own configured short date format elsewhere in the app.
|
|
||||||
return this.dateUtils.formatDate(parsed, 'd MMMM y');
|
|
||||||
}
|
|
||||||
|
|
||||||
private static yearsMonthsDaysBetween(start: Date, end: Date): { years: number; months: number; days: number } {
|
|
||||||
let years = end.getFullYear() - start.getFullYear();
|
|
||||||
let months = end.getMonth() - start.getMonth();
|
|
||||||
let days = end.getDate() - start.getDate();
|
|
||||||
if (days < 0) {
|
|
||||||
months -= 1;
|
|
||||||
const daysInPreviousMonth = new Date(end.getFullYear(), end.getMonth(), 0).getDate();
|
|
||||||
days += daysInPreviousMonth;
|
|
||||||
}
|
|
||||||
if (months < 0) {
|
|
||||||
years -= 1;
|
|
||||||
months += 12;
|
|
||||||
}
|
|
||||||
return { years: Math.max(0, years), months: Math.max(0, months), days: Math.max(0, days) };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True when the chosen action needs a destination account but the customer has none to pick. */
|
/** True when the chosen action needs a destination account but the customer has none to pick. */
|
||||||
get noDestinationAccounts(): boolean {
|
get noDestinationAccounts(): boolean {
|
||||||
return (
|
return (
|
||||||
|
|||||||
+1
-27
@@ -298,18 +298,6 @@ export class FixedDepositAccountViewComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The start date of the term currently running -- the last entry in renewalHistory, which is ordered
|
|
||||||
* oldest-first (see RenewalHistoryTabComponent). Falls back to the account's own activation date for
|
|
||||||
* an FD that has never been renewed (or whose renewalHistory hasn't loaded), which is the same date
|
|
||||||
* in that case anyway.
|
|
||||||
*/
|
|
||||||
private get currentTermStartDate(): any {
|
|
||||||
const history = this.fixedDepositsAccountData?.renewalHistory;
|
|
||||||
const latestTerm = Array.isArray(history) && history.length > 0 ? history[history.length - 1] : null;
|
|
||||||
return latestTerm?.activatedondate ?? this.fixedDepositsAccountData?.timeline?.activatedOnDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens the Premature Close action -- Active-only, no reinvest option, since the deposit hasn't
|
* Opens the Premature Close action -- Active-only, no reinvest option, since the deposit hasn't
|
||||||
* matured yet.
|
* matured yet.
|
||||||
@@ -328,21 +316,7 @@ export class FixedDepositAccountViewComponent implements OnInit {
|
|||||||
nominalAnnualInterestRate: this.fixedDepositsAccountData.nominalAnnualInterestRate,
|
nominalAnnualInterestRate: this.fixedDepositsAccountData.nominalAnnualInterestRate,
|
||||||
preClosurePenalApplicable: this.fixedDepositsAccountData.preClosurePenalApplicable,
|
preClosurePenalApplicable: this.fixedDepositsAccountData.preClosurePenalApplicable,
|
||||||
preClosurePenalInterest: this.fixedDepositsAccountData.preClosurePenalInterest,
|
preClosurePenalInterest: this.fixedDepositsAccountData.preClosurePenalInterest,
|
||||||
preClosurePenalInterestOnType: this.fixedDepositsAccountData.preClosurePenalInterestOnType,
|
preClosurePenalInterestOnType: this.fixedDepositsAccountData.preClosurePenalInterestOnType
|
||||||
// Gate which RBI-mandated waiver reasons the dialog offers -- these two are the product's own
|
|
||||||
// facility toggles (Deceased Settlement / No Penalty Nominee-Heir Closure), off by default, so
|
|
||||||
// a product that never enabled them shouldn't offer a reason implying it did.
|
|
||||||
deceasedSettlement: this.fixedDepositsAccountData.deceasedSettlement,
|
|
||||||
noPenaltyNomineeHeirClosure: this.fixedDepositsAccountData.noPenaltyNomineeHeirClosure,
|
|
||||||
// Tenure context (current term's start date, maturity date, current balance) so the dialog can
|
|
||||||
// show how far into THIS term the account is and how much is actually sitting in it today, not
|
|
||||||
// just the penalty-adjusted amount calculatePrematureAmount returns. Deliberately the current
|
|
||||||
// term's own start (see currentTermStartDate), not the account's original activation -- a
|
|
||||||
// renewed FD's interest/tenure runs from the renewal, and showing the original activation date
|
|
||||||
// here would make an account renewed days ago look like it has been running for months.
|
|
||||||
activatedOnDate: this.currentTermStartDate,
|
|
||||||
maturityDate: this.fixedDepositsAccountData.maturityDate,
|
|
||||||
currentBalance: this.fixedDepositsAccountData.summary?.accountBalance
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
prematureCloseDialogRef.afterClosed().subscribe((response: any) => {
|
prematureCloseDialogRef.afterClosed().subscribe((response: any) => {
|
||||||
|
|||||||
+10
-8
@@ -41,8 +41,8 @@
|
|||||||
) {
|
) {
|
||||||
<div class="rh-node">
|
<div class="rh-node">
|
||||||
<div class="rh-node__rail">
|
<div class="rh-node__rail">
|
||||||
<span class="rh-node__dot" [class.rh-node__dot--active]="isTermActive(term, last)">
|
<span class="rh-node__dot" [class.rh-node__dot--active]="term.renewalstatus === 'ACTIVE'">
|
||||||
@if (isTermActive(term, last)) {
|
@if (term.renewalstatus === 'ACTIVE') {
|
||||||
<span class="rh-node__pulse"></span>
|
<span class="rh-node__pulse"></span>
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
@@ -51,15 +51,15 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rh-card" [class.rh-card--active]="isTermActive(term, last)">
|
<div class="rh-card" [class.rh-card--active]="term.renewalstatus === 'ACTIVE'">
|
||||||
<div class="rh-card__head">
|
<div class="rh-card__head">
|
||||||
<div class="rh-card__title-group">
|
<div class="rh-card__title-group">
|
||||||
<h4 class="rh-card__title">
|
<h4 class="rh-card__title">
|
||||||
{{ term.renewalcycleno === 0 ? 'Original Term' : 'Renewal ' + term.renewalcycleno }}
|
{{ term.renewalcycleno === 0 ? 'Original Term' : 'Renewal ' + term.renewalcycleno }}
|
||||||
</h4>
|
</h4>
|
||||||
<span class="rh-badge" [class.rh-badge--active]="isTermActive(term, last)">
|
<span class="rh-badge" [class.rh-badge--active]="term.renewalstatus === 'ACTIVE'">
|
||||||
<fa-icon [icon]="isTermActive(term, last) ? 'check-circle' : 'clock'"></fa-icon>
|
<fa-icon [icon]="term.renewalstatus === 'ACTIVE' ? 'check-circle' : 'clock'"></fa-icon>
|
||||||
{{ termStatusLabel(term, last) }}
|
{{ term.renewalstatus === 'ACTIVE' ? 'Active' : 'Matured' }}
|
||||||
</span>
|
</span>
|
||||||
@if (renewalTypeLabel(term)) {
|
@if (renewalTypeLabel(term)) {
|
||||||
<span class="rh-tag">
|
<span class="rh-tag">
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="rh-stat">
|
<div class="rh-stat">
|
||||||
<span class="rh-stat__label">{{
|
<span class="rh-stat__label">{{
|
||||||
isTermActive(term, last) ? 'Interest So Far' : 'Interest Earned'
|
term.renewalstatus === 'ACTIVE' ? 'Interest So Far' : 'Interest Earned'
|
||||||
}}</span>
|
}}</span>
|
||||||
<span class="rh-stat__value rh-stat__value--positive">
|
<span class="rh-stat__value rh-stat__value--positive">
|
||||||
{{
|
{{
|
||||||
@@ -106,7 +106,9 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="rh-stat rh-stat--highlight">
|
<div class="rh-stat rh-stat--highlight">
|
||||||
<span class="rh-stat__label">{{ maturityLabel(term, last) }}</span>
|
<span class="rh-stat__label">{{
|
||||||
|
term.maturedamount === null || term.maturedamount === undefined ? 'Projected Maturity' : 'Matured At'
|
||||||
|
}}</span>
|
||||||
<span class="rh-stat__value">
|
<span class="rh-stat__value">
|
||||||
{{ maturityFor(term) | currency: currency.code : 'symbol-narrow' }}
|
{{ maturityFor(term) | currency: currency.code : 'symbol-narrow' }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
-45
@@ -183,51 +183,6 @@ export class RenewalHistoryTabComponent implements OnInit {
|
|||||||
return term?.renewaltype === 'MANUAL' ? 'user-cog' : 'robot';
|
return term?.renewaltype === 'MANUAL' ? 'user-cog' : 'robot';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether one term's card should show as "still running". `term.renewalstatus` only ever flips from
|
|
||||||
* ACTIVE to MATURED when a term rolls into its successor (see FixedDepositRenewalHistory#markMatured
|
|
||||||
* on the backend) -- a premature closure never touches that row, so the latest term's archived status
|
|
||||||
* would read ACTIVE forever even after the whole account has closed. For the LAST term only, this
|
|
||||||
* cross-checks the account's own live status instead of trusting the archived flag; every earlier
|
|
||||||
* term is reliably matured already (something renewed past it), so its own renewalstatus is
|
|
||||||
* trustworthy as-is and doesn't need the live account status at all.
|
|
||||||
*/
|
|
||||||
isTermActive(term: any, isLast: boolean): boolean {
|
|
||||||
if (isLast && this.fixedDepositsAccountData?.status) {
|
|
||||||
return !!this.fixedDepositsAccountData.status.active;
|
|
||||||
}
|
|
||||||
return term?.renewalstatus === 'ACTIVE';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Badge text for one term's card -- see {@link isTermActive} for why the last term needs its own check. */
|
|
||||||
termStatusLabel(term: any, isLast: boolean): string {
|
|
||||||
if (this.isTermActive(term, isLast)) {
|
|
||||||
return 'Active';
|
|
||||||
}
|
|
||||||
if (isLast && this.fixedDepositsAccountData?.status?.prematureClosed) {
|
|
||||||
return 'Premature Closed';
|
|
||||||
}
|
|
||||||
return 'Matured';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Label for the maturity/value figure -- "Projected Maturity" only makes sense while the term is
|
|
||||||
* still genuinely running towards that projection. `term.maturedamount` (the archived settled value)
|
|
||||||
* is only ever written when a term rolls into its successor, same gap as {@link isTermActive}: a
|
|
||||||
* premature closure settles the account without ever touching that column, so the last term would
|
|
||||||
* keep reading as a live projection forever even though the figure showing is already the final,
|
|
||||||
* settled one (see {@link maturityFor}).
|
|
||||||
*/
|
|
||||||
maturityLabel(term: any, isLast: boolean): string {
|
|
||||||
if (term?.maturedamount != null) {
|
|
||||||
return 'Matured At';
|
|
||||||
}
|
|
||||||
if (isLast && !this.isTermActive(term, isLast)) {
|
|
||||||
return 'Closed At';
|
|
||||||
}
|
|
||||||
return 'Projected Maturity';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The rate-by-tenure chart for one term of this FD's history, normalised to one shape regardless of source.
|
* The rate-by-tenure chart for one term of this FD's history, normalised to one shape regardless of source.
|
||||||
*
|
*
|
||||||
|
|||||||
+5
-77
@@ -52,29 +52,16 @@ export class ProductEditDiffDialogComponent implements OnInit {
|
|||||||
productName = '';
|
productName = '';
|
||||||
rows: DiffRow[] = [];
|
rows: DiffRow[] = [];
|
||||||
|
|
||||||
/** Request plumbing the edit form has to send, but which isn't a product setting a checker reviews.
|
/** Request plumbing the edit form has to send, but which isn't a product setting a checker reviews. */
|
||||||
* otherGlMappings is here too -- it is a nested {assets,liabilities,expenses,income} document, not
|
|
||||||
* a scalar, so it is handled separately by namedGlMappingRows() rather than the generic loop below
|
|
||||||
* (which would otherwise dump it as a raw, unreadable JSON blob). */
|
|
||||||
private static readonly IGNORED_KEYS = new Set([
|
private static readonly IGNORED_KEYS = new Set([
|
||||||
'locale',
|
'locale',
|
||||||
'dateFormat',
|
'dateFormat',
|
||||||
'monthDayFormat',
|
'monthDayFormat',
|
||||||
'id',
|
'id',
|
||||||
'productId',
|
'productId',
|
||||||
'savingsProductId',
|
'savingsProductId'
|
||||||
'otherGlMappings'
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** The two reserved otherGlMappings entries a checker actually needs to see by name -- see
|
|
||||||
* AccountingProcessorHelper.SAVINGS_INTEREST_PAYABLE_MAPPING / SAVINGS_INTEREST_PAID_MAPPING. Any
|
|
||||||
* other entry in the assets/liabilities/expenses/income lists is a free-form "Other" row with no
|
|
||||||
* fixed identity to diff against by name, so those are left out of this dialog same as before. */
|
|
||||||
private static readonly NAMED_GL_MAPPINGS = [
|
|
||||||
'Savings Interest Payable',
|
|
||||||
'Savings Interest Paid'
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Payload fields the stored product exposes under a different path, so the "current" side has
|
* Payload fields the stored product exposes under a different path, so the "current" side has
|
||||||
* something to compare against instead of always reading as newly set.
|
* something to compare against instead of always reading as newly set.
|
||||||
@@ -128,9 +115,6 @@ export class ProductEditDiffDialogComponent implements OnInit {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private glAccountNamesById = new Map<string, string>();
|
private glAccountNamesById = new Map<string, string>();
|
||||||
/** Same pool as glAccountNamesById, keyed by GL code instead of id -- otherGlMappings entries (see
|
|
||||||
* namedGlMappingRows) identify an account by its code, not its id. */
|
|
||||||
private glAccountNamesByCode = new Map<string, string>();
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.productName = this.data.productName ?? '';
|
this.productName = this.data.productName ?? '';
|
||||||
@@ -147,11 +131,10 @@ export class ProductEditDiffDialogComponent implements OnInit {
|
|||||||
original: this.displayValue(originalValue, key, original),
|
original: this.displayValue(originalValue, key, original),
|
||||||
proposed: this.displayValue(proposed[key], key, original)
|
proposed: this.displayValue(proposed[key], key, original)
|
||||||
}))
|
}))
|
||||||
.concat(this.namedGlMappingRows(original, proposed))
|
|
||||||
.sort((a, b) => a.label.localeCompare(b.label));
|
.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pools every GL account the template offers so an account id/code can be shown as its name. */
|
/** Pools every GL account the template offers so an account id can be shown as its name. */
|
||||||
private buildGlAccountLookup(template: any): void {
|
private buildGlAccountLookup(template: any): void {
|
||||||
const optionGroups = template?.accountingMappingOptions ?? {};
|
const optionGroups = template?.accountingMappingOptions ?? {};
|
||||||
for (const group of Object.values<any>(optionGroups)) {
|
for (const group of Object.values<any>(optionGroups)) {
|
||||||
@@ -163,68 +146,13 @@ export class ProductEditDiffDialogComponent implements OnInit {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const label = account.name ?? account.glCode ?? null;
|
const label = account.name ?? account.glCode ?? null;
|
||||||
if (!label) {
|
if (label) {
|
||||||
continue;
|
this.glAccountNamesById.set(String(account.id), account.glCode ? `${label} (${account.glCode})` : label);
|
||||||
}
|
|
||||||
const display = account.glCode ? `${label} (${account.glCode})` : label;
|
|
||||||
this.glAccountNamesById.set(String(account.id), display);
|
|
||||||
if (account.glCode) {
|
|
||||||
this.glAccountNamesByCode.set(String(account.glCode), display);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Diffs the two reserved otherGlMappings entries by name across every category (assets/liabilities/
|
|
||||||
* expenses/income) rather than the generic key loop above, since otherGlMappings is a nested
|
|
||||||
* {assets,liabilities,expenses,income} document whose entries move category if the maker changes
|
|
||||||
* which account type they picked -- comparing category-by-category would miss that. `original`
|
|
||||||
* carries this document as otherGlMappingsMap (falling back to the older otherGlMappings key for a
|
|
||||||
* template response that hasn't been re-fetched since), `proposed` as otherGlMappings.
|
|
||||||
*/
|
|
||||||
private namedGlMappingRows(original: any, proposed: any): DiffRow[] {
|
|
||||||
const proposedOther = proposed?.otherGlMappings;
|
|
||||||
if (proposedOther === undefined) {
|
|
||||||
// Not part of this edit at all -- nothing to compare, same as any other field the maker didn't touch.
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const originalOther = original?.otherGlMappingsMap ?? original?.otherGlMappings;
|
|
||||||
|
|
||||||
const findByName = (doc: any, accountName: string): { glCode?: string } | null => {
|
|
||||||
const categories: any[] = doc ? Object.values(doc) : [];
|
|
||||||
for (const category of categories) {
|
|
||||||
if (!Array.isArray(category)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const match = category.find((entry: any) => entry?.accountName === accountName);
|
|
||||||
if (match) {
|
|
||||||
return match;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolve = (entry: { glCode?: string } | null): string => {
|
|
||||||
if (!entry?.glCode) {
|
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
return this.glAccountNamesByCode.get(String(entry.glCode)) ?? entry.glCode;
|
|
||||||
};
|
|
||||||
|
|
||||||
return ProductEditDiffDialogComponent.NAMED_GL_MAPPINGS.map((accountName) => {
|
|
||||||
const before = findByName(originalOther, accountName);
|
|
||||||
const after = findByName(proposedOther, accountName);
|
|
||||||
return { accountName, before, after };
|
|
||||||
})
|
|
||||||
.filter(({ before, after }) => (before?.glCode ?? null) !== (after?.glCode ?? null))
|
|
||||||
.map(({ accountName, before, after }) => ({
|
|
||||||
label: accountName,
|
|
||||||
original: resolve(before),
|
|
||||||
proposed: resolve(after)
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reads the current value for a payload field, following an alias or GL mapping when needed. */
|
/** Reads the current value for a payload field, following an alias or GL mapping when needed. */
|
||||||
private originalValueFor(key: string, original: any): any {
|
private originalValueFor(key: string, original: any): any {
|
||||||
const glMappingKey = ProductEditDiffDialogComponent.GL_FIELD_TO_MAPPING[key];
|
const glMappingKey = ProductEditDiffDialogComponent.GL_FIELD_TO_MAPPING[key];
|
||||||
|
|||||||
Reference in New Issue
Block a user