fix: restore remaining content dropped by the dev merge (comprehensive audit)
Systematic file-by-file audit of demo-backup against dev to find any remaining instances of the same silent-drop pattern from the earlier merge (git's 3-way auto-resolve keeping demo-backup's side wherever it had independently touched nearby lines, even when dev's side was a genuine, unrelated addition). Restored: - clients.module.ts: missing ClientFamilyMemberChildDialogComponent registration (component existed but was never wired in) - client-family-member-dialog: full children-management flow (checkbox, form array, child dialog integration, view/edit summary blocks) - edit-client: FIR-exception flow for inaccessible old mobile numbers, and a hardened detectSensitiveFieldChanges() (null-guard, phone-digit normalization, dirty-checked comparisons to avoid false-positive Verify-Contact-Change prompts) - shell.component.ts: withdrawal-review URL matcher extended to cover the Share Withdrawal Request review route - fiu.component.html: AML alert status filter and monthly CTR reports table UI (backing logic already existed unused in the .ts) - Shares module: Passbook tab (component existed but had no route or tab link), running-balance/resolution columns on the transactions tab, subscript-sizing fixes and a pill-style filter-bar redesign on Share Holders - Savings/Share product accounting steps: Payment-Channel-to-Fund- Source mapping section, and Share Reference account restructuring - pending-client-edits: email status badges + "View Email" button (dialog component existed but nothing on this list triggered it) Everything else that still differs from dev was individually verified as either demo-backup's own independent work (Investment/Loan Origination/NPA/RBI-OSS modules, the Approval Center tab-nav redesign, AML/compliance review features, bank-name rebrand in demo seed data) or purely cosmetic formatting differences — left untouched.
This commit is contained in:
+34
@@ -139,6 +139,21 @@
|
||||
<span class="fm-readonly-value">{{ familyMemberForm.get('remarks')?.value }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (isSpouse && familyMemberForm.get('children')?.value?.length > 0) {
|
||||
<div class="fm-readonly-remarks">
|
||||
<span class="fm-readonly-label">Children</span>
|
||||
<div class="fm-children-list">
|
||||
@for (child of familyMemberForm.get('children')?.value; track $index) {
|
||||
<div class="fm-child-item" style="font-size: 13px; color: #555; margin-top: 4px">
|
||||
<span>{{ child.fullName }}</span>
|
||||
@if (child.dateOfBirth) {
|
||||
<span> · DOB: {{ child.dateOfBirth | date: 'dd MMM yyyy' }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- ═══ ADD / EDIT MODE ═══ -->
|
||||
@@ -447,6 +462,25 @@
|
||||
<mat-label>Remarks</mat-label>
|
||||
<textarea matInput formControlName="remarks" rows="2"></textarea>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Children Section -->
|
||||
@if (isSpouse) {
|
||||
<div class="fm-section-label" style="margin-top: 16px">Children</div>
|
||||
<div class="fm-checkbox-row" style="display: flex; align-items: center; justify-content: space-between">
|
||||
<mat-checkbox formControlName="hasChildren">Does this family member have a child/children?</mat-checkbox>
|
||||
@if (familyMemberForm.get('hasChildren')?.value) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
color="primary"
|
||||
(click)="openChildrenDialog()"
|
||||
style="margin-left: 16px"
|
||||
>
|
||||
<mat-icon>edit</mat-icon> Edit Children Details ({{ children.length }})
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
|
||||
+143
-2
@@ -17,7 +17,7 @@ import {
|
||||
MatDialogClose,
|
||||
MatDialogContent
|
||||
} from '@angular/material/dialog';
|
||||
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
|
||||
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormArray, Validators } from '@angular/forms';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
import { ClientsService } from 'app/clients/clients.service';
|
||||
@@ -58,6 +58,8 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
maxDate = new Date();
|
||||
|
||||
hasChildrenChecked = false;
|
||||
familyMemberForm: UntypedFormGroup;
|
||||
|
||||
/** Client search */
|
||||
@@ -197,6 +199,32 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
||||
// Re-fetch linked client details (profile, signature, KYC status)
|
||||
this.fetchLinkedClientData(m.linkedClientId.toString());
|
||||
}
|
||||
if (m.children && m.children.length > 0) {
|
||||
this.hasChildrenChecked = true;
|
||||
this.familyMemberForm.patchValue({ hasChildren: true });
|
||||
m.children.forEach((child: any) => {
|
||||
this.children.push(
|
||||
this.formBuilder.group({
|
||||
id: [child.id],
|
||||
fullName: [
|
||||
child.fullName,
|
||||
Validators.required
|
||||
],
|
||||
genderId: [
|
||||
child.genderId,
|
||||
Validators.required
|
||||
],
|
||||
dateOfBirth: [child.dateOfBirth ? new Date(child.dateOfBirth) : ''],
|
||||
nationality: [child.nationality || 'INDIAN'],
|
||||
aadhaarNumber: [child.aadhaarNumber || ''],
|
||||
panNumber: [
|
||||
child.panNumber || '',
|
||||
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)
|
||||
]
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
if (this.isViewMode) {
|
||||
this.familyMemberForm.disable();
|
||||
}
|
||||
@@ -225,8 +253,102 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
||||
isAlive: [true],
|
||||
marriageDate: [''],
|
||||
marriageAuthority: [''],
|
||||
linkedClientId: ['']
|
||||
linkedClientId: [''],
|
||||
hasChildren: [false],
|
||||
children: this.formBuilder.array([])
|
||||
});
|
||||
|
||||
this.familyMemberForm.get('hasChildren')?.valueChanges.subscribe((value) => {
|
||||
if (value && !this.hasChildrenChecked) {
|
||||
this.openChildrenDialog();
|
||||
} else if (!value) {
|
||||
this.hasChildrenChecked = false;
|
||||
this.children.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get children(): UntypedFormArray {
|
||||
return this.familyMemberForm.get('children') as UntypedFormArray;
|
||||
}
|
||||
|
||||
async openChildrenDialog() {
|
||||
// Dynamically import the dialog to avoid a circular dependency between the two dialogs.
|
||||
const { ClientFamilyMemberChildDialogComponent } =
|
||||
await import('../client-family-member-child-dialog/client-family-member-child-dialog.component');
|
||||
|
||||
const dialogRef = this.dialog.open(ClientFamilyMemberChildDialogComponent, {
|
||||
data: {
|
||||
children: this.children.value,
|
||||
genderIdOptions: this.data.options.genderIdOptions
|
||||
},
|
||||
width: '500px'
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((result: any[]) => {
|
||||
if (result) {
|
||||
this.children.clear();
|
||||
result.forEach((child) => {
|
||||
this.children.push(
|
||||
this.formBuilder.group({
|
||||
id: [child.id],
|
||||
fullName: [
|
||||
child.fullName,
|
||||
Validators.required
|
||||
],
|
||||
genderId: [
|
||||
child.genderId,
|
||||
Validators.required
|
||||
],
|
||||
dateOfBirth: [child.dateOfBirth],
|
||||
nationality: [child.nationality || 'INDIAN'],
|
||||
aadhaarNumber: [child.aadhaarNumber || ''],
|
||||
panNumber: [
|
||||
child.panNumber || '',
|
||||
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)
|
||||
]
|
||||
})
|
||||
);
|
||||
});
|
||||
this.hasChildrenChecked = true;
|
||||
this.familyMemberForm.patchValue({ hasChildren: true }, { emitEvent: false });
|
||||
} else if (this.children.length === 0) {
|
||||
this.hasChildrenChecked = false;
|
||||
this.familyMemberForm.patchValue({ hasChildren: false }, { emitEvent: false });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createChildFormGroup(): UntypedFormGroup {
|
||||
return this.formBuilder.group({
|
||||
id: [''],
|
||||
fullName: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
genderId: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
dateOfBirth: [''],
|
||||
nationality: ['INDIAN'],
|
||||
aadhaarNumber: [''],
|
||||
panNumber: [
|
||||
'',
|
||||
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
addChild(): void {
|
||||
this.children.push(this.createChildFormGroup());
|
||||
}
|
||||
|
||||
removeChild(index: number): void {
|
||||
this.children.removeAt(index);
|
||||
if (this.children.length === 0) {
|
||||
this.familyMemberForm.patchValue({ hasChildren: false });
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle marriage certificate file selection */
|
||||
@@ -481,6 +603,25 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
||||
member._linkedClient = this.selectedClient;
|
||||
}
|
||||
|
||||
// Format children dates
|
||||
if (this.isSpouse && member.children && member.children.length > 0) {
|
||||
member.children = member.children.map((child: any) => {
|
||||
const c = { ...child };
|
||||
if (c.dateOfBirth instanceof Date) {
|
||||
c.dateOfBirth = this.dateUtils.formatDate(c.dateOfBirth, dateFormat);
|
||||
} else if (!c.dateOfBirth) {
|
||||
delete c.dateOfBirth;
|
||||
}
|
||||
if (c.panNumber) {
|
||||
c.panNumber = c.panNumber.toUpperCase();
|
||||
}
|
||||
return c;
|
||||
});
|
||||
} else {
|
||||
delete member.children;
|
||||
}
|
||||
delete member.hasChildren;
|
||||
|
||||
// Attach marriage certificate file if present
|
||||
if (this.marriageCertFile) {
|
||||
member._marriageCertFile = this.marriageCertFile;
|
||||
|
||||
@@ -62,6 +62,7 @@ import { ClientFamilyMembersStepComponent } from './client-stepper/client-family
|
||||
import { ClientPreviewStepComponent } from './client-stepper/client-preview-step/client-preview-step.component';
|
||||
import { ClientAddressStepComponent } from './client-stepper/client-address-step/client-address-step.component';
|
||||
import { ClientFamilyMemberDialogComponent } from './client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component';
|
||||
import { ClientFamilyMemberChildDialogComponent } from './client-stepper/client-family-members-step/client-family-member-child-dialog/client-family-member-child-dialog.component';
|
||||
import { CaptureImageDialogComponent } from './clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component';
|
||||
import { AddClientCollateralComponent } from './clients-view/client-actions/add-client-collateral/add-client-collateral.component';
|
||||
import { ClientDatatableStepComponent } from './client-stepper/client-datatable-step/client-datatable-step.component';
|
||||
@@ -127,6 +128,7 @@ import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.compone
|
||||
ClientPreviewStepComponent,
|
||||
ClientAddressStepComponent,
|
||||
ClientFamilyMemberDialogComponent,
|
||||
ClientFamilyMemberChildDialogComponent,
|
||||
CaptureImageDialogComponent,
|
||||
AddClientCollateralComponent,
|
||||
ClientDatatableStepComponent,
|
||||
|
||||
@@ -84,14 +84,70 @@
|
||||
<mat-icon>phone_iphone</mat-icon>
|
||||
<span>Mobile Number</span>
|
||||
</div>
|
||||
<div class="contact-verify-panel__exception-row" style="margin-bottom: 12px">
|
||||
<mat-checkbox
|
||||
[checked]="oldMobileNotAccessible"
|
||||
(change)="onOldMobileNotAccessibleToggle($event.checked)"
|
||||
color="primary"
|
||||
>
|
||||
Old mobile number not accessible (lost / deactivated SIM)
|
||||
</mat-checkbox>
|
||||
</div>
|
||||
<div class="contact-verify-panel__grid">
|
||||
<mifosx-client-contact-otp-block
|
||||
[clientId]="clientId"
|
||||
channel="MOBILE"
|
||||
[destination]="contactChanges.oldMobile"
|
||||
label="Old Mobile Number"
|
||||
(stateChange)="onOldMobileOtpState($event)"
|
||||
></mifosx-client-contact-otp-block>
|
||||
@if (!oldMobileNotAccessible) {
|
||||
<mifosx-client-contact-otp-block
|
||||
[clientId]="clientId"
|
||||
channel="MOBILE"
|
||||
[destination]="contactChanges.oldMobile"
|
||||
label="Old Mobile Number"
|
||||
(stateChange)="onOldMobileOtpState($event)"
|
||||
></mifosx-client-contact-otp-block>
|
||||
} @else {
|
||||
<div
|
||||
class="fir-upload-box"
|
||||
style="padding: 12px; border: 1px dashed #cbd5e1; border-radius: 8px; background: #f8fafc"
|
||||
>
|
||||
<div style="font-weight: 500; font-size: 13px; margin-bottom: 6px; color: #475569">
|
||||
Upload Police Complaint / FIR Copy for Lost Mobile
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 12px">
|
||||
<input
|
||||
type="file"
|
||||
(change)="onFirFileSelected($event)"
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
#firFileInput
|
||||
hidden
|
||||
/>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
(click)="firFileInput.click()"
|
||||
[disabled]="uploadingFir"
|
||||
>
|
||||
<mat-icon>upload_file</mat-icon>
|
||||
{{ firFileName ? firFileName : 'Select FIR Document' }}
|
||||
</button>
|
||||
@if (uploadingFir) {
|
||||
<mat-progress-spinner mode="indeterminate" diameter="20"></mat-progress-spinner>
|
||||
}
|
||||
@if (firDocumentId && !uploadingFir) {
|
||||
<span
|
||||
style="
|
||||
color: #16a34a;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
"
|
||||
>
|
||||
<mat-icon style="font-size: 16px; width: 16px; height: 16px">check_circle</mat-icon> FIR
|
||||
Uploaded Successfully
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<mifosx-client-contact-otp-block
|
||||
[clientId]="clientId"
|
||||
channel="MOBILE"
|
||||
|
||||
@@ -33,6 +33,7 @@ import { ContactOtpBlockComponent, ContactOtpState } from './contact-otp-block/c
|
||||
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatDivider } from '@angular/material/divider';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
@@ -51,6 +52,7 @@ import { ORIGINAL_VALUES_PAYLOAD_KEY, buildOriginalValuesSnapshot } from '../sha
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatTabsModule,
|
||||
MatIconModule,
|
||||
MatCheckboxModule,
|
||||
MatDivider,
|
||||
FaIconComponent,
|
||||
MatProgressSpinner,
|
||||
@@ -108,6 +110,13 @@ export class EditClientComponent {
|
||||
newMobileOtpId: number | null = null;
|
||||
oldMobileVerified = false;
|
||||
newMobileVerified = false;
|
||||
|
||||
// FIR exception flow properties for inaccessible old mobile number
|
||||
oldMobileNotAccessible = false;
|
||||
firFileName = '';
|
||||
firDocumentId: number | null = null;
|
||||
uploadingFir = false;
|
||||
|
||||
oldEmailOtpId: number | null = null;
|
||||
newEmailOtpId: number | null = null;
|
||||
oldEmailVerified = false;
|
||||
@@ -215,11 +224,15 @@ export class EditClientComponent {
|
||||
return this.detectSensitiveFieldChanges(this.clientDataAndTemplate);
|
||||
}
|
||||
|
||||
/** Whether every OTP block required by the fields that actually changed has been verified. */
|
||||
/** Whether every OTP block required by the fields that actually changed has been verified
|
||||
* (or the FIR exception has been uploaded in place of old-mobile OTP verification). */
|
||||
get contactVerificationComplete(): boolean {
|
||||
const changes = this.contactChanges;
|
||||
if (changes.mobileChanged && !(this.oldMobileVerified && this.newMobileVerified)) {
|
||||
return false;
|
||||
if (changes.mobileChanged) {
|
||||
const isOldSatisfied = this.oldMobileNotAccessible ? this.firDocumentId != null : this.oldMobileVerified;
|
||||
if (!isOldSatisfied || !this.newMobileVerified) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (changes.emailChanged && !(this.oldEmailVerified && this.newEmailVerified)) {
|
||||
return false;
|
||||
@@ -227,6 +240,43 @@ export class EditClientComponent {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Toggle the "old mobile not accessible" exception — clears any in-progress old-mobile OTP. */
|
||||
onOldMobileNotAccessibleToggle(checked?: boolean): void {
|
||||
if (checked !== undefined) {
|
||||
this.oldMobileNotAccessible = checked;
|
||||
}
|
||||
if (this.oldMobileNotAccessible) {
|
||||
this.oldMobileOtpId = null;
|
||||
this.oldMobileVerified = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Upload the FIR / police-complaint copy that substitutes for old-mobile OTP verification. */
|
||||
onFirFileSelected(event: any): void {
|
||||
const file: File = event.target?.files?.[0];
|
||||
if (!file) return;
|
||||
this.firFileName = file.name;
|
||||
this.uploadingFir = true;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('name', 'FIR - Lost Mobile Number');
|
||||
formData.append('description', 'FIR police complaint copy for lost mobile number verification exception');
|
||||
|
||||
this.clientsService.uploadClientDocument(this.clientId, formData).subscribe({
|
||||
next: (res: any) => {
|
||||
this.uploadingFir = false;
|
||||
this.firDocumentId = res?.resourceId || res?.id || null;
|
||||
this.snackBar.open('FIR document uploaded successfully', 'Close', { duration: 3000 });
|
||||
},
|
||||
error: () => {
|
||||
this.uploadingFir = false;
|
||||
this.firDocumentId = null;
|
||||
this.snackBar.open('Failed to upload FIR document', 'Close', { duration: 4000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onOldMobileOtpState(state: ContactOtpState): void {
|
||||
this.oldMobileOtpId = state.otpId;
|
||||
this.oldMobileVerified = state.verified;
|
||||
@@ -254,6 +304,10 @@ export class EditClientComponent {
|
||||
this.newMobileOtpId = null;
|
||||
this.oldMobileVerified = false;
|
||||
this.newMobileVerified = false;
|
||||
this.oldMobileNotAccessible = false;
|
||||
this.firFileName = '';
|
||||
this.firDocumentId = null;
|
||||
this.uploadingFir = false;
|
||||
this.oldEmailOtpId = null;
|
||||
this.newEmailOtpId = null;
|
||||
this.oldEmailVerified = false;
|
||||
@@ -439,7 +493,11 @@ export class EditClientComponent {
|
||||
// email's when BOTH changed in the same save. See the class-level JSDoc above the OTP
|
||||
// state fields for the full naming rationale.
|
||||
if (changedFields.mobileChanged) {
|
||||
clientData.mobileOldOtpId = this.oldMobileOtpId;
|
||||
if (this.oldMobileNotAccessible && this.firDocumentId) {
|
||||
(clientData as any).oldMobileVerificationExceptionDocumentId = this.firDocumentId;
|
||||
} else {
|
||||
clientData.mobileOldOtpId = this.oldMobileOtpId;
|
||||
}
|
||||
clientData.mobileNewOtpId = this.newMobileOtpId;
|
||||
}
|
||||
if (changedFields.emailChanged) {
|
||||
@@ -630,16 +688,36 @@ export class EditClientComponent {
|
||||
|
||||
private detectSensitiveFieldChanges(originalData: any) {
|
||||
const form = this.clientGeneralForm;
|
||||
if (!form || !originalData) {
|
||||
return {
|
||||
nameChanged: false,
|
||||
mobileChanged: false,
|
||||
emailChanged: false,
|
||||
dobChanged: false,
|
||||
oldName: '',
|
||||
newName: '',
|
||||
oldMobile: '',
|
||||
newMobile: '',
|
||||
oldEmail: '',
|
||||
newEmail: '',
|
||||
oldDob: '',
|
||||
newDob: ''
|
||||
};
|
||||
}
|
||||
|
||||
const mobileControl = form.get('mobileNo');
|
||||
const emailControl = form.get('emailAddress');
|
||||
|
||||
// Trimmed so browser autofill adding invisible leading/trailing whitespace never reads as
|
||||
// a "change" — the masked preview shown in the Verify Contact Change panel only reveals the
|
||||
// first couple of characters, so a whitespace-only difference is otherwise invisible to the
|
||||
// user and just looks like the panel appeared for no reason. These trimmed-but-original-case
|
||||
// values are what get returned/sent as the actual OTP destination and saved value — only the
|
||||
// *comparison* (below) is further case-normalized for email, so we never silently rewrite the
|
||||
// customer's email casing into the saved record or the OTP destination.
|
||||
const newMobile = (form?.get('mobileNo')?.value || '').trim();
|
||||
const newEmail = (form?.get('emailAddress')?.value || '').trim();
|
||||
const newDobRaw = form?.get('dateOfBirth')?.value;
|
||||
// *comparison* (below) is further normalized for mobile/email, so we never silently rewrite the
|
||||
// customer's saved value or the OTP destination.
|
||||
const newMobile = (mobileControl?.value || '').toString().trim();
|
||||
const newEmail = (emailControl?.value || '').toString().trim();
|
||||
const newDobRaw = form.get('dateOfBirth')?.value;
|
||||
// Local calendar date, NOT `.toISOString()` — that converts to UTC first, which silently
|
||||
// shifts the date by a day in any positive-UTC-offset timezone (e.g. India, UTC+5:30:
|
||||
// midnight local on the 15th becomes 18:30 UTC on the 14th). That made this comparison
|
||||
@@ -647,8 +725,8 @@ export class EditClientComponent {
|
||||
// touched the date field, purely because the Date object round-tripped through the picker.
|
||||
const newDob = newDobRaw instanceof Date ? this.toLocalDateString(newDobRaw) : newDobRaw || '';
|
||||
|
||||
const oldMobile = (originalData.mobileNo || '').trim();
|
||||
const oldEmail = (originalData.emailAddress || '').trim();
|
||||
const oldMobile = (originalData.mobileNo || '').toString().trim();
|
||||
const oldEmail = (originalData.emailAddress || '').toString().trim();
|
||||
const oldDobRaw = originalData.dateOfBirth;
|
||||
const oldDob = Array.isArray(oldDobRaw)
|
||||
? `${oldDobRaw[0]}-${String(oldDobRaw[1]).padStart(2, '0')}-${String(oldDobRaw[2]).padStart(2, '0')}`
|
||||
@@ -681,12 +759,25 @@ export class EditClientComponent {
|
||||
fullname: originalData.fullname || ''
|
||||
});
|
||||
|
||||
// Normalize phone numbers by stripping non-digit characters (+91 9009009009 -> 9009009009)
|
||||
const normPhone = (val: string) => val.replace(/\D/g, '');
|
||||
const newMobileNorm = normPhone(newMobile);
|
||||
const oldMobileNorm = normPhone(oldMobile);
|
||||
|
||||
// Mobile/Email are considered changed ONLY when the control has been edited (dirty) AND
|
||||
// normalized values differ — otherwise a re-render/reformat of the control by the framework
|
||||
// (never actually touched by the user) would falsely surface the dual-OTP verification panel.
|
||||
const mobileChanged =
|
||||
!!oldMobileNorm && !!newMobileNorm && !!mobileControl?.dirty && newMobileNorm !== oldMobileNorm;
|
||||
// Case-insensitive: emails are not case-sensitive in practice, and comparing raw case
|
||||
// would flag a change (surfacing the dual-OTP panel) even when nothing meaningful changed.
|
||||
const emailChanged =
|
||||
!!oldEmail && !!newEmail && !!emailControl?.dirty && newEmail.toLowerCase() !== oldEmail.toLowerCase();
|
||||
|
||||
return {
|
||||
nameChanged: newName !== oldName,
|
||||
mobileChanged: newMobile !== oldMobile,
|
||||
// Case-insensitive: emails are not case-sensitive in practice, and comparing raw case
|
||||
// would flag a change (surfacing the dual-OTP panel) even when nothing meaningful changed.
|
||||
emailChanged: newEmail.toLowerCase() !== oldEmail.toLowerCase(),
|
||||
mobileChanged,
|
||||
emailChanged,
|
||||
dobChanged: newDob !== oldDob,
|
||||
oldName,
|
||||
newName,
|
||||
|
||||
@@ -188,9 +188,9 @@ export class ShellComponent implements OnInit, OnDestroy {
|
||||
return this.router.url;
|
||||
}
|
||||
|
||||
/** True on the manager's full-screen withdrawal-request review screen. */
|
||||
/** True on the manager's full-screen withdrawal-request (cash or share) review screen. */
|
||||
get isWithdrawalReviewUrl(): boolean {
|
||||
return /\/checker-inbox-and-tasks\/withdrawal-requests\/\d+\/review/.test(this.router.url);
|
||||
return /\/checker-inbox-and-tasks\/(share-)?withdrawal-requests\/\d+\/review/.test(this.router.url);
|
||||
}
|
||||
|
||||
/** ESC key — exit fullscreen at Clerk Dashboard / Election / review screen, restoring full shell header. */
|
||||
|
||||
@@ -295,6 +295,53 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LIVE: monthly CTR reports (mechanical, threshold-based) -->
|
||||
<div class="fiu-form-meta">
|
||||
<div class="fiu-form-title">
|
||||
<h3>Monthly CTR Reports</h3>
|
||||
<p>
|
||||
Threshold: <strong>₹{{ bank.cashThreshold | number }}</strong> per single txn / connected aggregate
|
||||
per month
|
||||
</p>
|
||||
</div>
|
||||
<div class="fiu-action-group">
|
||||
<input class="fiu-inp" type="month" [(ngModel)]="ctrGenerateMonth" />
|
||||
<button class="fiu-btn fiu-btn-g" *mifosxHasPermission="'REVIEW_AML_ALERT'" (click)="generateCtrReport()">
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fiu-tbl-wrap">
|
||||
<table class="fiu-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Month</th>
|
||||
<th>Generated On</th>
|
||||
<th class="ta-c">Records</th>
|
||||
<th>Status</th>
|
||||
<th>Submitted On</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of amlCtrReports; track r.id) {
|
||||
<tr>
|
||||
<td>{{ r.reportMonth }}</td>
|
||||
<td>{{ r.generatedOn | date: 'medium' }}</td>
|
||||
<td class="ta-c">{{ r.recordCount }}</td>
|
||||
<td>
|
||||
<span [class]="amlStatusClass(r.status)">{{ r.status }}</span>
|
||||
</td>
|
||||
<td>{{ r.submittedOn ? (r.submittedOn | date: 'medium') : '—' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="5" class="fiu-empty">No CTR reports generated yet.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -307,6 +354,13 @@
|
||||
<h3>Live AML Alerts (Awaiting Triage)</h3>
|
||||
<p>Flagged by the monitoring engine for suspicious behavioral patterns — triage and whitelist or escalate</p>
|
||||
</div>
|
||||
<select class="fiu-inp" [(ngModel)]="amlAlertStatusFilter" (ngModelChange)="loadAmlAlerts()">
|
||||
<option value="">All statuses</option>
|
||||
<option value="RAISED">Raised</option>
|
||||
<option value="UNDER_REVIEW">Under Review</option>
|
||||
<option value="WHITELISTED">Whitelisted</option>
|
||||
<option value="ESCALATED">Escalated</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="fiu-tbl-wrap">
|
||||
<table class="fiu-tbl">
|
||||
|
||||
+45
@@ -115,6 +115,51 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="spa-group">
|
||||
<div class="spa-group__header">
|
||||
<h4>Map Payment Types to Specific Funds</h4>
|
||||
<button type="button" class="spa-add-btn" (click)="addPaymentFundSourceMapping()">+ Add</button>
|
||||
</div>
|
||||
<div class="spa-list" [formArrayName]="'paymentChannelToFundSourceMappings'">
|
||||
@for (row of paymentChannelToFundSourceMappings.controls; track $index; let i = $index) {
|
||||
<div class="spa-other-row-wrap">
|
||||
<div class="spa-row spa-row--other" [formGroupName]="i">
|
||||
<div class="spa-row__meta spa-row__meta--other">
|
||||
<mat-form-field class="spa-row__other-field" appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Payment Type</mat-label>
|
||||
<mat-select formControlName="paymentTypeId">
|
||||
@for (paymentType of paymentTypeOptions; track paymentType.id) {
|
||||
<mat-option [value]="paymentType.id">{{ paymentType.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="spa-row__actions spa-row__actions--other">
|
||||
<mat-form-field class="spa-row__other-field" appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Fund Source</mat-label>
|
||||
<mat-select formControlName="fundSourceAccountId">
|
||||
@for (glAccount of fundSourceAccountOptions; track glAccount.id) {
|
||||
<mat-option [value]="glAccount.id">
|
||||
{{ '(' + glAccount.glCode + ') ' + glAccount.name }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="spa-remove-btn spa-remove-btn--side"
|
||||
(click)="removePaymentFundSourceMapping(i)"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+52
-1
@@ -50,6 +50,11 @@ type OtherGlMappingValue = {
|
||||
glAccountId: number | null;
|
||||
};
|
||||
|
||||
type PaymentFundSourceValue = {
|
||||
paymentTypeId: number | null;
|
||||
fundSourceAccountId: number | null;
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-saving-product-accounting-step',
|
||||
templateUrl: './saving-product-accounting-step.component.html',
|
||||
@@ -78,6 +83,11 @@ export class SavingProductAccountingStepComponent implements OnInit {
|
||||
expenseAccountOptions: GLAccount[] = [];
|
||||
incomeAccountOptions: GLAccount[] = [];
|
||||
assetAccountOptions: GLAccount[] = [];
|
||||
paymentTypeOptions: any[] = [];
|
||||
/** A payment-type Fund Source override can legitimately be a suspense/clearing Liability account
|
||||
* (e.g. an existing "Sundry" account bridging to another module) rather than only a cash-like
|
||||
* Asset account, so this field accepts either. */
|
||||
fundSourceAccountOptions: GLAccount[] = [];
|
||||
accountingSectionConfigs: AccountingSectionConfig[] = [];
|
||||
|
||||
constructor() {
|
||||
@@ -98,6 +108,8 @@ export class SavingProductAccountingStepComponent implements OnInit {
|
||||
this.liabilityAccountOptions = accountingMappingOptions.liabilityAccountOptions || [];
|
||||
this.expenseAccountOptions = accountingMappingOptions.expenseAccountOptions || [];
|
||||
this.incomeAccountOptions = accountingMappingOptions.incomeAccountOptions || [];
|
||||
this.paymentTypeOptions = this.savingProductsTemplate?.paymentTypeOptions || [];
|
||||
this.fundSourceAccountOptions = this.assetAccountOptions.concat(this.liabilityAccountOptions);
|
||||
this.buildAccountingSectionConfigs();
|
||||
this.savingProductAccountingForm.patchValue({
|
||||
accountingRule: accountingRuleId
|
||||
@@ -105,6 +117,7 @@ export class SavingProductAccountingStepComponent implements OnInit {
|
||||
|
||||
this.syncAccountingControls(accountingRuleId);
|
||||
this.patchAccountingValues(mappings);
|
||||
this.patchPaymentFundSourceMappings(this.savingProductsTemplate?.paymentChannelToFundSourceMappings || []);
|
||||
this.setOverdraftControl();
|
||||
this.setDormancyControl();
|
||||
}
|
||||
@@ -115,10 +128,48 @@ export class SavingProductAccountingStepComponent implements OnInit {
|
||||
assetsOtherGlMappings: this.formBuilder.array([]),
|
||||
liabilitiesOtherGlMappings: this.formBuilder.array([]),
|
||||
expensesOtherGlMappings: this.formBuilder.array([]),
|
||||
incomeOtherGlMappings: this.formBuilder.array([])
|
||||
incomeOtherGlMappings: this.formBuilder.array([]),
|
||||
paymentChannelToFundSourceMappings: this.formBuilder.array([])
|
||||
});
|
||||
}
|
||||
|
||||
get paymentChannelToFundSourceMappings(): UntypedFormArray {
|
||||
return this.savingProductAccountingForm.get('paymentChannelToFundSourceMappings') as UntypedFormArray;
|
||||
}
|
||||
|
||||
private createPaymentFundSourceGroup(value: Partial<PaymentFundSourceValue> = {}) {
|
||||
return this.formBuilder.group({
|
||||
paymentTypeId: [
|
||||
value.paymentTypeId ?? this.paymentTypeOptions[0]?.id ?? null,
|
||||
Validators.required
|
||||
],
|
||||
fundSourceAccountId: [
|
||||
value.fundSourceAccountId ?? this.fundSourceAccountOptions[0]?.id ?? null,
|
||||
Validators.required
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
private patchPaymentFundSourceMappings(mappings: any[]): void {
|
||||
this.paymentChannelToFundSourceMappings.clear();
|
||||
(mappings || []).forEach((mapping) => {
|
||||
this.paymentChannelToFundSourceMappings.push(
|
||||
this.createPaymentFundSourceGroup({
|
||||
paymentTypeId: mapping.paymentType?.id,
|
||||
fundSourceAccountId: mapping.fundSourceAccount?.id
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
addPaymentFundSourceMapping(): void {
|
||||
this.paymentChannelToFundSourceMappings.push(this.createPaymentFundSourceGroup());
|
||||
}
|
||||
|
||||
removePaymentFundSourceMapping(index: number): void {
|
||||
this.paymentChannelToFundSourceMappings.removeAt(index);
|
||||
}
|
||||
|
||||
private setConditionalControls() {
|
||||
this.savingProductAccountingForm.get('accountingRule')?.valueChanges.subscribe((accountingRule: number) => {
|
||||
this.syncAccountingControls(accountingRule);
|
||||
|
||||
+9
-2
@@ -74,6 +74,12 @@ export class ShareProductAccountingStepComponent implements OnInit {
|
||||
liabilityAccountData: GLAccount[] = [];
|
||||
incomeAccountData: GLAccount[] = [];
|
||||
equityAccountData: GLAccount[] = [];
|
||||
/** "Share Reference" is where a redemption's credit side lands pending settlement — some banks
|
||||
* model that as a receivable-like Asset (e.g. "Cash Vault", this field's original default),
|
||||
* others as a payable-like Liability suspense account (e.g. an existing "Sundry" account
|
||||
* already used elsewhere as Shares Suspense). Both are valid conventions, so this field accepts
|
||||
* either instead of Asset-only. */
|
||||
referenceAccountData: GLAccount[] = [];
|
||||
|
||||
constructor() {
|
||||
this.createShareProductAccountingForm();
|
||||
@@ -85,6 +91,7 @@ export class ShareProductAccountingStepComponent implements OnInit {
|
||||
this.liabilityAccountData = opts.liabilityAccountOptions || [];
|
||||
this.incomeAccountData = opts.incomeAccountOptions || [];
|
||||
this.equityAccountData = opts.equityAccountOptions || [];
|
||||
this.referenceAccountData = this.assetAccountData.concat(this.liabilityAccountData);
|
||||
|
||||
this.buildAccountingSectionConfigs();
|
||||
|
||||
@@ -135,10 +142,10 @@ export class ShareProductAccountingStepComponent implements OnInit {
|
||||
rows: [
|
||||
{
|
||||
key: 'shareReferenceId',
|
||||
label: 'Share Reference (Cash Vault)',
|
||||
label: 'Share Reference (Cash Vault or Suspense)',
|
||||
inputLabel: 'Share reference',
|
||||
controlName: 'shareReferenceId',
|
||||
options: this.assetAccountData,
|
||||
options: this.referenceAccountData,
|
||||
required: true
|
||||
}
|
||||
],
|
||||
|
||||
@@ -20,11 +20,11 @@
|
||||
</div>
|
||||
|
||||
<div class="table-filters">
|
||||
<mat-form-field class="table-search" appearance="outline">
|
||||
<mat-form-field class="table-search" appearance="outline" subscriptSizing="dynamic">
|
||||
<input matInput placeholder="Search by client name or account number" [formControl]="searchControl" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field class="table-filter" appearance="outline">
|
||||
<mat-form-field class="table-filter" appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-select [(value)]="branchFilter" (selectionChange)="applyFilters()" placeholder="Branch">
|
||||
<mat-option value="">All Branches</mat-option>
|
||||
<mat-option *ngFor="let office of offices" [value]="office.id">
|
||||
@@ -33,7 +33,7 @@
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field class="table-filter" appearance="outline">
|
||||
<mat-form-field class="table-filter" appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-select [(value)]="productFilter" (selectionChange)="applyFilters()" placeholder="Share Product">
|
||||
<mat-option value="">All Products</mat-option>
|
||||
<mat-option *ngFor="let product of products" [value]="product.id">
|
||||
@@ -42,7 +42,7 @@
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field class="table-filter" appearance="outline">
|
||||
<mat-form-field class="table-filter" appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-select [(value)]="statusFilter" (selectionChange)="applyFilters()" placeholder="Status">
|
||||
<mat-option value="">All Status</mat-option>
|
||||
<mat-option value="active">Active</mat-option>
|
||||
@@ -53,7 +53,7 @@
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field class="table-filter" appearance="outline" (click)="picker.open()">
|
||||
<mat-form-field class="table-filter" appearance="outline" subscriptSizing="dynamic" (click)="picker.open()">
|
||||
<mat-date-range-input [formGroup]="dateRangeForm" [rangePicker]="picker">
|
||||
<input matStartDate formControlName="start" placeholder="Start date" (dateChange)="applyFilters()" />
|
||||
<input matEndDate formControlName="end" placeholder="End date" (dateChange)="applyFilters()" />
|
||||
|
||||
@@ -5,37 +5,43 @@
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
margin: 1.5rem;
|
||||
padding: 0;
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--bg-card);
|
||||
box-shadow: var(--shadow-sm);
|
||||
margin: 1.5rem auto;
|
||||
max-width: 1400px;
|
||||
padding: 20px 24px;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 4%);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
margin-bottom: 20px;
|
||||
|
||||
.table-header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.table-title-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
gap: 12px;
|
||||
|
||||
.table-title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
color: #0f172a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
@@ -45,22 +51,19 @@
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
background: var(--bg-hover);
|
||||
background: #f1f5f9;
|
||||
border-radius: 6px;
|
||||
|
||||
span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
background: var(--primary-main);
|
||||
opacity: 0.8;
|
||||
background: #1353a0;
|
||||
|
||||
&:nth-child(2) {
|
||||
opacity: 0.6;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
@@ -75,53 +78,156 @@
|
||||
}
|
||||
|
||||
.table-count {
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-muted);
|
||||
border-radius: var(--radius-full);
|
||||
padding: 0.2rem 0.65rem;
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
}
|
||||
}
|
||||
|
||||
/* Pill-styled filter bar */
|
||||
.table-filters {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 12px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
|
||||
mat-form-field {
|
||||
width: 240px;
|
||||
margin-bottom: -1.25em;
|
||||
.table-search {
|
||||
flex: 1 1 200px;
|
||||
min-width: 180px;
|
||||
max-width: 280px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.table-filter {
|
||||
flex: 0 1 150px;
|
||||
min-width: 130px;
|
||||
max-width: 170px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Override Material Form Field Heights & Subscript Spacing */
|
||||
::ng-deep {
|
||||
.mat-mdc-form-field {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mat-mdc-form-field-subscript-wrapper {
|
||||
display: none !important;
|
||||
height: 0 !important;
|
||||
}
|
||||
|
||||
.mat-mdc-form-field-flex {
|
||||
height: 38px !important;
|
||||
background: #fff !important;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mat-mdc-form-field-infix {
|
||||
padding: 6px 0 !important;
|
||||
min-height: 38px !important;
|
||||
}
|
||||
|
||||
.mdc-notched-outline__leading {
|
||||
border-radius: 8px 0 0 8px !important;
|
||||
}
|
||||
|
||||
.mdc-notched-outline__trailing {
|
||||
border-radius: 0 8px 8px 0 !important;
|
||||
}
|
||||
|
||||
.mat-mdc-select-value,
|
||||
.mat-mdc-select-placeholder,
|
||||
input {
|
||||
font-size: 13px !important;
|
||||
}
|
||||
}
|
||||
|
||||
[mat-icon-button] {
|
||||
color: #64748b;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
|
||||
&:hover {
|
||||
color: #0f172a;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.table-container {
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
border-radius: 0 0 var(--radius-xl) var(--radius-xl);
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
|
||||
.table-loader {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
height: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.modern-table {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
|
||||
tr.mat-row {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
::ng-deep {
|
||||
.mat-mdc-header-row {
|
||||
background: #f8fafc;
|
||||
min-height: 38px;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-hover);
|
||||
.mat-mdc-header-cell {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #64748b;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding: 0 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mat-mdc-row {
|
||||
min-height: 42px;
|
||||
height: 42px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
transition: background-color 0.15s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:nth-child(even) {
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: #f1f5f9 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-mdc-cell {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: #0f172a;
|
||||
padding: 0 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mat-paginator {
|
||||
border-top: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
@@ -152,6 +152,16 @@
|
||||
>
|
||||
{{ 'labels.inputs.Transactions' | translate }}
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
[routerLink]="['./passbook']"
|
||||
routerLinkActive
|
||||
#passbook="routerLinkActive"
|
||||
[active]="passbook.isActive"
|
||||
*mifosxHasPermission="'READ_SHAREACCOUNTPURCHASE'"
|
||||
>
|
||||
Passbook
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
[routerLink]="['./charges']"
|
||||
|
||||
@@ -50,12 +50,10 @@ export class SharesButtonsConfiguration {
|
||||
name: 'Reject Additional Shares',
|
||||
icon: 'arrow-left',
|
||||
taskPermissionName: 'REJECTADDITIONAL_SHAREACCOUNT'
|
||||
},
|
||||
{
|
||||
name: 'Redeem Shares',
|
||||
icon: 'arrow-left',
|
||||
taskPermissionName: 'WITHDRAW_SAVINGSACCOUNT'
|
||||
}
|
||||
// 'Redeem Shares' is intentionally not offered here — a share withdrawal must go
|
||||
// through the Clerk Dashboard maker-checker flow (Clerk Dashboard > My Share Requests),
|
||||
// never a direct one-step action from this view.
|
||||
];
|
||||
break;
|
||||
case 'Submitted and pending approval':
|
||||
|
||||
@@ -54,6 +54,25 @@
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="runningBalance">
|
||||
<th mat-header-cell *matHeaderCellDef>Share Balance</th>
|
||||
<td mat-cell *matCellDef="let transaction">{{ transaction.runningBalance }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="resolutionMeeting">
|
||||
<th mat-header-cell *matHeaderCellDef>Resolution No. / Meeting Date</th>
|
||||
<td mat-cell *matCellDef="let transaction">
|
||||
@if (transaction.type.value === 'Redeem') {
|
||||
{{ transaction.resolutionNumber || '—' }}
|
||||
@if (transaction.meetingDate) {
|
||||
({{ transaction.meetingDate | dateFormat }})
|
||||
}
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
||||
</table>
|
||||
|
||||
@@ -56,14 +56,18 @@ export class TransactionsTabComponent implements OnInit {
|
||||
transactionsData: any;
|
||||
/** Data source for transactions table. */
|
||||
dataSource: MatTableDataSource<any>;
|
||||
/** Columns to be displayed in transactions table. */
|
||||
/** Columns to be displayed in transactions table — a share account's passbook: every
|
||||
* purchase/redeem entry plus the running share balance and, for a withdrawal, the Board
|
||||
* Resolution Number / Date of Meeting that authorised it. */
|
||||
displayedColumns: string[] = [
|
||||
'transactionDate',
|
||||
'transactionType',
|
||||
'totalShares',
|
||||
'purchasedOrRedeemedPrice',
|
||||
'chargeAmount',
|
||||
'amountRecievedOrReturned'
|
||||
'amountRecievedOrReturned',
|
||||
'runningBalance',
|
||||
'resolutionMeeting'
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -73,9 +77,19 @@ export class TransactionsTabComponent implements OnInit {
|
||||
constructor() {
|
||||
this.route.parent.data.subscribe((data: { sharesAccountData: any }) => {
|
||||
this.shareAccountData = data.sharesAccountData;
|
||||
this.transactionsData = this.shareAccountData.purchasedShares.filter(
|
||||
(transaction: any) => transaction.type.value !== 'Charge Payment'
|
||||
);
|
||||
this.transactionsData = this.shareAccountData.purchasedShares
|
||||
.filter((transaction: any) => transaction.type.value !== 'Charge Payment')
|
||||
.slice()
|
||||
.sort((a: any, b: any) => new Date(a.purchasedDate).getTime() - new Date(b.purchasedDate).getTime());
|
||||
|
||||
let runningBalance = 0;
|
||||
this.transactionsData.forEach((transaction: any) => {
|
||||
const shares = Number(transaction.numberOfShares) || 0;
|
||||
runningBalance += transaction.type.value === 'Redeem' ? -shares : shares;
|
||||
transaction.runningBalance = runningBalance;
|
||||
});
|
||||
// Passbook reads newest-first.
|
||||
this.transactionsData.reverse();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import { ShareAccountActionsResolver } from './common-resolvers/share-account-ac
|
||||
import { SharesAccountTemplateResolver } from './common-resolvers/shares-account-template.resolver';
|
||||
import { SharesAccountAndTemplateResolver } from './common-resolvers/share-account-and-template.resolver';
|
||||
import { GeneralTabComponent } from './shares-account-view/general-tab/general-tab.component';
|
||||
import { SharesPassbookTabComponent } from './shares-account-view/passbook-tab/passbook-tab.component';
|
||||
|
||||
/** Shares Routes */
|
||||
const routes: Routes = [
|
||||
@@ -75,6 +76,11 @@ const routes: Routes = [
|
||||
component: TransactionsTabComponent,
|
||||
data: { title: 'Shares Account Transactions', breadcrumb: 'Transactions', routeParamBreadcrumb: false }
|
||||
},
|
||||
{
|
||||
path: 'passbook',
|
||||
component: SharesPassbookTabComponent,
|
||||
data: { title: 'Shares Account Passbook', breadcrumb: 'Passbook', routeParamBreadcrumb: false }
|
||||
},
|
||||
{
|
||||
path: 'charges',
|
||||
component: ChargesTabComponent,
|
||||
|
||||
+33
@@ -126,6 +126,29 @@
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||
<td mat-cell *matCellDef="let edit">
|
||||
<!-- ── Email status badges (compact, always visible) ── -->
|
||||
<span
|
||||
class="pce-email-badge"
|
||||
[class.pce-email-badge--sent]="edit.emailSent"
|
||||
[class.pce-email-badge--nosent]="!edit.emailSent"
|
||||
[title]="edit.emailSent ? 'Email sent on ' + formatDate(edit.emailSentAt) : 'No confirmation email sent'"
|
||||
>
|
||||
<fa-icon icon="envelope"></fa-icon>
|
||||
{{ edit.emailSent ? 'Sent' : 'Not Sent' }}
|
||||
</span>
|
||||
@if (edit.emailSent) {
|
||||
<span
|
||||
class="pce-email-badge"
|
||||
[class.pce-email-badge--seen]="edit.emailOpenedAt"
|
||||
[class.pce-email-badge--notseen]="!edit.emailOpenedAt"
|
||||
[title]="edit.emailOpenedAt ? 'Opened on ' + formatDate(edit.emailOpenedAt) : 'Not yet opened'"
|
||||
>
|
||||
<fa-icon [icon]="edit.emailOpenedAt ? 'eye' : 'eye-slash'"></fa-icon>
|
||||
{{ edit.emailOpenedAt ? 'Seen' : 'Not Seen' }}
|
||||
</span>
|
||||
}
|
||||
|
||||
<!-- ── Action buttons ── -->
|
||||
<button mat-stroked-button class="pce-btn pce-btn-view" (click)="viewCompare(edit)">
|
||||
<fa-icon icon="eye" class="m-r-10"></fa-icon>Compare
|
||||
</button>
|
||||
@@ -137,6 +160,16 @@
|
||||
<fa-icon icon="ban" class="m-r-10"></fa-icon>Reject
|
||||
</button>
|
||||
}
|
||||
@if (edit.emailSent) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="pce-btn pce-btn-email"
|
||||
(click)="viewEmail(edit)"
|
||||
title="View the exact confirmation email sent to the client"
|
||||
>
|
||||
<fa-icon icon="envelope" class="m-r-10"></fa-icon>View Email
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
|
||||
+42
@@ -346,3 +346,45 @@ $text-muted: #64748b;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Email badges ───────────────────────────────────────────────────────── */
|
||||
.pce-email-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
margin-right: 6px;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
|
||||
fa-icon {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
&--sent {
|
||||
background: #e8f5e9;
|
||||
color: #1b5e20;
|
||||
border: 1px solid #c8e6c9;
|
||||
}
|
||||
|
||||
&--nosent {
|
||||
background: #f5f5f5;
|
||||
color: #9e9e9e;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
&--seen {
|
||||
background: #e3f2fd;
|
||||
color: #0d47a1;
|
||||
border: 1px solid #bbdefb;
|
||||
}
|
||||
|
||||
&--notseen {
|
||||
background: #fff3e0;
|
||||
color: #e65100;
|
||||
border: 1px solid #ffe0b2;
|
||||
}
|
||||
}
|
||||
|
||||
+49
-1
@@ -34,6 +34,7 @@ import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { NON_COMPARABLE_KEYS, PENDING_EDIT_FIELD_LABELS } from 'app/clients/shared/client-edit-diff.util';
|
||||
import { ClientEditCompareDialogComponent } from './client-edit-compare-dialog/client-edit-compare-dialog.component';
|
||||
import { ClientEditEmailDialogComponent } from './client-edit-email-dialog/client-edit-email-dialog.component';
|
||||
|
||||
/** Maps a payload key that holds a CodeValue/template id to the options array it should be
|
||||
* resolved against — populated from /clients/template plus the same per-code-value lookups the
|
||||
@@ -97,7 +98,8 @@ const NATIONALITY_LABELS: Record<string, string> = {
|
||||
MatRowDef,
|
||||
MatRow,
|
||||
MatIcon,
|
||||
MatPaginatorModule
|
||||
MatPaginatorModule,
|
||||
ClientEditEmailDialogComponent
|
||||
]
|
||||
})
|
||||
export class PendingClientEditsComponent implements OnInit {
|
||||
@@ -423,6 +425,52 @@ export class PendingClientEditsComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
/** Opens the email preview dialog for an edit row. Fetches the email content first (subject,
|
||||
* body, sent/opened timestamps) via the email-preview endpoint, then opens the dialog. */
|
||||
viewEmail(edit: any): void {
|
||||
this.clientsService.getClientEditEmailPreview(edit.clientId, edit.id).subscribe({
|
||||
next: (preview) => {
|
||||
try {
|
||||
this.dialog.open(ClientEditEmailDialogComponent, {
|
||||
width: '800px',
|
||||
maxWidth: '96vw',
|
||||
data: {
|
||||
emailSubject: preview?.emailSubject || null,
|
||||
emailBody: preview?.emailBody || null,
|
||||
emailSent: preview?.emailSent || false,
|
||||
emailSentAt: preview?.emailSentAt || null,
|
||||
emailOpenedAt: preview?.emailOpenedAt || null,
|
||||
emailFrom: preview?.emailFrom || null,
|
||||
emailTo: preview?.emailTo || null
|
||||
}
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.error('Dialog open failed (next): ', e);
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
try {
|
||||
// Fallback: open dialog with no-content state
|
||||
this.dialog.open(ClientEditEmailDialogComponent, {
|
||||
width: '800px',
|
||||
maxWidth: '96vw',
|
||||
data: {
|
||||
emailSubject: null,
|
||||
emailBody: null,
|
||||
emailSent: false,
|
||||
emailSentAt: null,
|
||||
emailOpenedAt: null,
|
||||
emailFrom: null,
|
||||
emailTo: null
|
||||
}
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.error('Dialog open failed (error): ', e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
reload(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user