fix: restore remaining content dropped by the dev merge (comprehensive audit)
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s

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:
2026-08-06 16:35:52 +05:30
parent 9f9c9bfab6
commit 4281a596af
20 changed files with 845 additions and 88 deletions
@@ -139,6 +139,21 @@
<span class="fm-readonly-value">{{ familyMemberForm.get('remarks')?.value }}</span> <span class="fm-readonly-value">{{ familyMemberForm.get('remarks')?.value }}</span>
</div> </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 ═══ --> <!-- ═══ ADD / EDIT MODE ═══ -->
@@ -447,6 +462,25 @@
<mat-label>Remarks</mat-label> <mat-label>Remarks</mat-label>
<textarea matInput formControlName="remarks" rows="2"></textarea> <textarea matInput formControlName="remarks" rows="2"></textarea>
</mat-form-field> </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> </form>
</mat-dialog-content> </mat-dialog-content>
@@ -17,7 +17,7 @@ import {
MatDialogClose, MatDialogClose,
MatDialogContent MatDialogContent
} from '@angular/material/dialog'; } 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 { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates'; import { Dates } from 'app/core/utils/dates';
import { ClientsService } from 'app/clients/clients.service'; import { ClientsService } from 'app/clients/clients.service';
@@ -58,6 +58,8 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
private dialog = inject(MatDialog); private dialog = inject(MatDialog);
maxDate = new Date(); maxDate = new Date();
hasChildrenChecked = false;
familyMemberForm: UntypedFormGroup; familyMemberForm: UntypedFormGroup;
/** Client search */ /** Client search */
@@ -197,6 +199,32 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
// Re-fetch linked client details (profile, signature, KYC status) // Re-fetch linked client details (profile, signature, KYC status)
this.fetchLinkedClientData(m.linkedClientId.toString()); 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) { if (this.isViewMode) {
this.familyMemberForm.disable(); this.familyMemberForm.disable();
} }
@@ -225,8 +253,102 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
isAlive: [true], isAlive: [true],
marriageDate: [''], marriageDate: [''],
marriageAuthority: [''], 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 */ /** Handle marriage certificate file selection */
@@ -481,6 +603,25 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
member._linkedClient = this.selectedClient; 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 // Attach marriage certificate file if present
if (this.marriageCertFile) { if (this.marriageCertFile) {
member._marriageCertFile = this.marriageCertFile; member._marriageCertFile = this.marriageCertFile;
+2
View File
@@ -62,6 +62,7 @@ import { ClientFamilyMembersStepComponent } from './client-stepper/client-family
import { ClientPreviewStepComponent } from './client-stepper/client-preview-step/client-preview-step.component'; 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 { 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 { 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 { 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 { 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'; 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, ClientPreviewStepComponent,
ClientAddressStepComponent, ClientAddressStepComponent,
ClientFamilyMemberDialogComponent, ClientFamilyMemberDialogComponent,
ClientFamilyMemberChildDialogComponent,
CaptureImageDialogComponent, CaptureImageDialogComponent,
AddClientCollateralComponent, AddClientCollateralComponent,
ClientDatatableStepComponent, ClientDatatableStepComponent,
@@ -84,14 +84,70 @@
<mat-icon>phone_iphone</mat-icon> <mat-icon>phone_iphone</mat-icon>
<span>Mobile Number</span> <span>Mobile Number</span>
</div> </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"> <div class="contact-verify-panel__grid">
<mifosx-client-contact-otp-block @if (!oldMobileNotAccessible) {
[clientId]="clientId" <mifosx-client-contact-otp-block
channel="MOBILE" [clientId]="clientId"
[destination]="contactChanges.oldMobile" channel="MOBILE"
label="Old Mobile Number" [destination]="contactChanges.oldMobile"
(stateChange)="onOldMobileOtpState($event)" label="Old Mobile Number"
></mifosx-client-contact-otp-block> (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 <mifosx-client-contact-otp-block
[clientId]="clientId" [clientId]="clientId"
channel="MOBILE" channel="MOBILE"
@@ -33,6 +33,7 @@ import { ContactOtpBlockComponent, ContactOtpState } from './contact-otp-block/c
import { MatTabsModule } from '@angular/material/tabs'; import { MatTabsModule } from '@angular/material/tabs';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDivider } from '@angular/material/divider'; import { MatDivider } from '@angular/material/divider';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatProgressSpinner } from '@angular/material/progress-spinner'; import { MatProgressSpinner } from '@angular/material/progress-spinner';
@@ -51,6 +52,7 @@ import { ORIGINAL_VALUES_PAYLOAD_KEY, buildOriginalValuesSnapshot } from '../sha
...STANDALONE_SHARED_IMPORTS, ...STANDALONE_SHARED_IMPORTS,
MatTabsModule, MatTabsModule,
MatIconModule, MatIconModule,
MatCheckboxModule,
MatDivider, MatDivider,
FaIconComponent, FaIconComponent,
MatProgressSpinner, MatProgressSpinner,
@@ -108,6 +110,13 @@ export class EditClientComponent {
newMobileOtpId: number | null = null; newMobileOtpId: number | null = null;
oldMobileVerified = false; oldMobileVerified = false;
newMobileVerified = 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; oldEmailOtpId: number | null = null;
newEmailOtpId: number | null = null; newEmailOtpId: number | null = null;
oldEmailVerified = false; oldEmailVerified = false;
@@ -215,11 +224,15 @@ export class EditClientComponent {
return this.detectSensitiveFieldChanges(this.clientDataAndTemplate); 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 { get contactVerificationComplete(): boolean {
const changes = this.contactChanges; const changes = this.contactChanges;
if (changes.mobileChanged && !(this.oldMobileVerified && this.newMobileVerified)) { if (changes.mobileChanged) {
return false; const isOldSatisfied = this.oldMobileNotAccessible ? this.firDocumentId != null : this.oldMobileVerified;
if (!isOldSatisfied || !this.newMobileVerified) {
return false;
}
} }
if (changes.emailChanged && !(this.oldEmailVerified && this.newEmailVerified)) { if (changes.emailChanged && !(this.oldEmailVerified && this.newEmailVerified)) {
return false; return false;
@@ -227,6 +240,43 @@ export class EditClientComponent {
return true; 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 { onOldMobileOtpState(state: ContactOtpState): void {
this.oldMobileOtpId = state.otpId; this.oldMobileOtpId = state.otpId;
this.oldMobileVerified = state.verified; this.oldMobileVerified = state.verified;
@@ -254,6 +304,10 @@ export class EditClientComponent {
this.newMobileOtpId = null; this.newMobileOtpId = null;
this.oldMobileVerified = false; this.oldMobileVerified = false;
this.newMobileVerified = false; this.newMobileVerified = false;
this.oldMobileNotAccessible = false;
this.firFileName = '';
this.firDocumentId = null;
this.uploadingFir = false;
this.oldEmailOtpId = null; this.oldEmailOtpId = null;
this.newEmailOtpId = null; this.newEmailOtpId = null;
this.oldEmailVerified = false; 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 // email's when BOTH changed in the same save. See the class-level JSDoc above the OTP
// state fields for the full naming rationale. // state fields for the full naming rationale.
if (changedFields.mobileChanged) { 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; clientData.mobileNewOtpId = this.newMobileOtpId;
} }
if (changedFields.emailChanged) { if (changedFields.emailChanged) {
@@ -630,16 +688,36 @@ export class EditClientComponent {
private detectSensitiveFieldChanges(originalData: any) { private detectSensitiveFieldChanges(originalData: any) {
const form = this.clientGeneralForm; 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 // 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 // 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 // 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 // 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 // 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 // *comparison* (below) is further normalized for mobile/email, so we never silently rewrite the
// customer's email casing into the saved record or the OTP destination. // customer's saved value or the OTP destination.
const newMobile = (form?.get('mobileNo')?.value || '').trim(); const newMobile = (mobileControl?.value || '').toString().trim();
const newEmail = (form?.get('emailAddress')?.value || '').trim(); const newEmail = (emailControl?.value || '').toString().trim();
const newDobRaw = form?.get('dateOfBirth')?.value; const newDobRaw = form.get('dateOfBirth')?.value;
// Local calendar date, NOT `.toISOString()` — that converts to UTC first, which silently // 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: // 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 // 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. // touched the date field, purely because the Date object round-tripped through the picker.
const newDob = newDobRaw instanceof Date ? this.toLocalDateString(newDobRaw) : newDobRaw || ''; const newDob = newDobRaw instanceof Date ? this.toLocalDateString(newDobRaw) : newDobRaw || '';
const oldMobile = (originalData.mobileNo || '').trim(); const oldMobile = (originalData.mobileNo || '').toString().trim();
const oldEmail = (originalData.emailAddress || '').trim(); const oldEmail = (originalData.emailAddress || '').toString().trim();
const oldDobRaw = originalData.dateOfBirth; const oldDobRaw = originalData.dateOfBirth;
const oldDob = Array.isArray(oldDobRaw) const oldDob = Array.isArray(oldDobRaw)
? `${oldDobRaw[0]}-${String(oldDobRaw[1]).padStart(2, '0')}-${String(oldDobRaw[2]).padStart(2, '0')}` ? `${oldDobRaw[0]}-${String(oldDobRaw[1]).padStart(2, '0')}-${String(oldDobRaw[2]).padStart(2, '0')}`
@@ -681,12 +759,25 @@ export class EditClientComponent {
fullname: originalData.fullname || '' 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 { return {
nameChanged: newName !== oldName, nameChanged: newName !== oldName,
mobileChanged: newMobile !== oldMobile, mobileChanged,
// Case-insensitive: emails are not case-sensitive in practice, and comparing raw case emailChanged,
// would flag a change (surfacing the dual-OTP panel) even when nothing meaningful changed.
emailChanged: newEmail.toLowerCase() !== oldEmail.toLowerCase(),
dobChanged: newDob !== oldDob, dobChanged: newDob !== oldDob,
oldName, oldName,
newName, newName,
+2 -2
View File
@@ -188,9 +188,9 @@ export class ShellComponent implements OnInit, OnDestroy {
return this.router.url; 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 { 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. */ /** ESC key — exit fullscreen at Clerk Dashboard / Election / review screen, restoring full shell header. */
+54
View File
@@ -295,6 +295,53 @@
</div> </div>
</div> </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>&#8377;{{ 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> </div>
} }
@@ -307,6 +354,13 @@
<h3>Live AML Alerts (Awaiting Triage)</h3> <h3>Live AML Alerts (Awaiting Triage)</h3>
<p>Flagged by the monitoring engine for suspicious behavioral patterns — triage and whitelist or escalate</p> <p>Flagged by the monitoring engine for suspicious behavioral patterns — triage and whitelist or escalate</p>
</div> </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>
<div class="fiu-tbl-wrap"> <div class="fiu-tbl-wrap">
<table class="fiu-tbl"> <table class="fiu-tbl">
@@ -115,6 +115,51 @@
</div> </div>
</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>
</div> </div>
@@ -50,6 +50,11 @@ type OtherGlMappingValue = {
glAccountId: number | null; glAccountId: number | null;
}; };
type PaymentFundSourceValue = {
paymentTypeId: number | null;
fundSourceAccountId: number | null;
};
@Component({ @Component({
selector: 'mifosx-saving-product-accounting-step', selector: 'mifosx-saving-product-accounting-step',
templateUrl: './saving-product-accounting-step.component.html', templateUrl: './saving-product-accounting-step.component.html',
@@ -78,6 +83,11 @@ export class SavingProductAccountingStepComponent implements OnInit {
expenseAccountOptions: GLAccount[] = []; expenseAccountOptions: GLAccount[] = [];
incomeAccountOptions: GLAccount[] = []; incomeAccountOptions: GLAccount[] = [];
assetAccountOptions: 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[] = []; accountingSectionConfigs: AccountingSectionConfig[] = [];
constructor() { constructor() {
@@ -98,6 +108,8 @@ export class SavingProductAccountingStepComponent implements OnInit {
this.liabilityAccountOptions = accountingMappingOptions.liabilityAccountOptions || []; this.liabilityAccountOptions = accountingMappingOptions.liabilityAccountOptions || [];
this.expenseAccountOptions = accountingMappingOptions.expenseAccountOptions || []; this.expenseAccountOptions = accountingMappingOptions.expenseAccountOptions || [];
this.incomeAccountOptions = accountingMappingOptions.incomeAccountOptions || []; this.incomeAccountOptions = accountingMappingOptions.incomeAccountOptions || [];
this.paymentTypeOptions = this.savingProductsTemplate?.paymentTypeOptions || [];
this.fundSourceAccountOptions = this.assetAccountOptions.concat(this.liabilityAccountOptions);
this.buildAccountingSectionConfigs(); this.buildAccountingSectionConfigs();
this.savingProductAccountingForm.patchValue({ this.savingProductAccountingForm.patchValue({
accountingRule: accountingRuleId accountingRule: accountingRuleId
@@ -105,6 +117,7 @@ export class SavingProductAccountingStepComponent implements OnInit {
this.syncAccountingControls(accountingRuleId); this.syncAccountingControls(accountingRuleId);
this.patchAccountingValues(mappings); this.patchAccountingValues(mappings);
this.patchPaymentFundSourceMappings(this.savingProductsTemplate?.paymentChannelToFundSourceMappings || []);
this.setOverdraftControl(); this.setOverdraftControl();
this.setDormancyControl(); this.setDormancyControl();
} }
@@ -115,10 +128,48 @@ export class SavingProductAccountingStepComponent implements OnInit {
assetsOtherGlMappings: this.formBuilder.array([]), assetsOtherGlMappings: this.formBuilder.array([]),
liabilitiesOtherGlMappings: this.formBuilder.array([]), liabilitiesOtherGlMappings: this.formBuilder.array([]),
expensesOtherGlMappings: 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() { private setConditionalControls() {
this.savingProductAccountingForm.get('accountingRule')?.valueChanges.subscribe((accountingRule: number) => { this.savingProductAccountingForm.get('accountingRule')?.valueChanges.subscribe((accountingRule: number) => {
this.syncAccountingControls(accountingRule); this.syncAccountingControls(accountingRule);
@@ -74,6 +74,12 @@ export class ShareProductAccountingStepComponent implements OnInit {
liabilityAccountData: GLAccount[] = []; liabilityAccountData: GLAccount[] = [];
incomeAccountData: GLAccount[] = []; incomeAccountData: GLAccount[] = [];
equityAccountData: 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() { constructor() {
this.createShareProductAccountingForm(); this.createShareProductAccountingForm();
@@ -85,6 +91,7 @@ export class ShareProductAccountingStepComponent implements OnInit {
this.liabilityAccountData = opts.liabilityAccountOptions || []; this.liabilityAccountData = opts.liabilityAccountOptions || [];
this.incomeAccountData = opts.incomeAccountOptions || []; this.incomeAccountData = opts.incomeAccountOptions || [];
this.equityAccountData = opts.equityAccountOptions || []; this.equityAccountData = opts.equityAccountOptions || [];
this.referenceAccountData = this.assetAccountData.concat(this.liabilityAccountData);
this.buildAccountingSectionConfigs(); this.buildAccountingSectionConfigs();
@@ -135,10 +142,10 @@ export class ShareProductAccountingStepComponent implements OnInit {
rows: [ rows: [
{ {
key: 'shareReferenceId', key: 'shareReferenceId',
label: 'Share Reference (Cash Vault)', label: 'Share Reference (Cash Vault or Suspense)',
inputLabel: 'Share reference', inputLabel: 'Share reference',
controlName: 'shareReferenceId', controlName: 'shareReferenceId',
options: this.assetAccountData, options: this.referenceAccountData,
required: true required: true
} }
], ],
@@ -20,11 +20,11 @@
</div> </div>
<div class="table-filters"> <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" /> <input matInput placeholder="Search by client name or account number" [formControl]="searchControl" />
</mat-form-field> </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-select [(value)]="branchFilter" (selectionChange)="applyFilters()" placeholder="Branch">
<mat-option value="">All Branches</mat-option> <mat-option value="">All Branches</mat-option>
<mat-option *ngFor="let office of offices" [value]="office.id"> <mat-option *ngFor="let office of offices" [value]="office.id">
@@ -33,7 +33,7 @@
</mat-select> </mat-select>
</mat-form-field> </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-select [(value)]="productFilter" (selectionChange)="applyFilters()" placeholder="Share Product">
<mat-option value="">All Products</mat-option> <mat-option value="">All Products</mat-option>
<mat-option *ngFor="let product of products" [value]="product.id"> <mat-option *ngFor="let product of products" [value]="product.id">
@@ -42,7 +42,7 @@
</mat-select> </mat-select>
</mat-form-field> </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-select [(value)]="statusFilter" (selectionChange)="applyFilters()" placeholder="Status">
<mat-option value="">All Status</mat-option> <mat-option value="">All Status</mat-option>
<mat-option value="active">Active</mat-option> <mat-option value="active">Active</mat-option>
@@ -53,7 +53,7 @@
</mat-select> </mat-select>
</mat-form-field> </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"> <mat-date-range-input [formGroup]="dateRangeForm" [rangePicker]="picker">
<input matStartDate formControlName="start" placeholder="Start date" (dateChange)="applyFilters()" /> <input matStartDate formControlName="start" placeholder="Start date" (dateChange)="applyFilters()" />
<input matEndDate formControlName="end" placeholder="End 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 * 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/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
:host {
display: block;
width: 100%;
}
.container { .container {
margin: 1.5rem; margin: 1.5rem auto;
padding: 0; max-width: 1400px;
border-radius: var(--radius-xl); padding: 20px 24px;
background: var(--bg-card); border-radius: 16px;
box-shadow: var(--shadow-sm); background: #fff;
border: 1px solid #e2e8f0;
box-shadow: 0 1px 3px rgb(0 0 0 / 4%);
box-sizing: border-box;
} }
.table-header { .table-header {
padding: 1.5rem; margin-bottom: 20px;
border-bottom: 1px solid var(--border-light);
.table-header-row { .table-header-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 1.5rem; margin-bottom: 16px;
flex-wrap: wrap;
gap: 1rem;
} }
.table-title-group { .table-title-group {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem; gap: 12px;
.table-title { .table-title {
margin: 0; margin: 0;
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 600; font-weight: 600;
color: var(--text-main); color: #0f172a;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
@@ -45,22 +51,19 @@
width: 24px; width: 24px;
height: 24px; height: 24px;
position: relative; position: relative;
display: flex; display: grid;
flex-wrap: wrap; grid-template-columns: 1fr 1fr;
gap: 2px; gap: 2px;
padding: 2px; padding: 2px;
background: var(--bg-hover); background: #f1f5f9;
border-radius: 6px; border-radius: 6px;
span { span {
width: 8px;
height: 8px;
border-radius: 2px; border-radius: 2px;
background: var(--primary-main); background: #1353a0;
opacity: 0.8;
&:nth-child(2) { &:nth-child(2) {
opacity: 0.6; opacity: 0.7;
} }
&:nth-child(3) { &:nth-child(3) {
@@ -75,53 +78,156 @@
} }
.table-count { .table-count {
padding: 0.25rem 0.75rem; padding: 0.2rem 0.65rem;
background: var(--bg-hover); background: #f1f5f9;
color: var(--text-muted); color: #64748b;
border-radius: var(--radius-full); border-radius: 12px;
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 500; font-weight: 500;
letter-spacing: 0.02em; font-family: 'Roboto Mono', monospace;
} }
} }
/* Pill-styled filter bar */
.table-filters { .table-filters {
display: flex; display: flex;
gap: 1rem;
align-items: center; align-items: center;
gap: 10px;
flex-wrap: wrap; flex-wrap: wrap;
padding: 8px 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
mat-form-field { .table-search {
width: 240px; flex: 1 1 200px;
margin-bottom: -1.25em; 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 { .table-container {
position: relative; border: 1px solid #e2e8f0;
overflow: auto; border-radius: 12px;
border-radius: 0 0 var(--radius-xl) var(--radius-xl); overflow: hidden;
background: #fff;
.table-loader { .table-loader {
position: absolute; height: 2px;
top: 0;
left: 0;
right: 0;
z-index: 10;
} }
} }
.modern-table { .modern-table {
width: 100%; width: 100%;
background: transparent; border-collapse: separate;
border-spacing: 0;
tr.mat-row { ::ng-deep {
cursor: pointer; .mat-mdc-header-row {
transition: background-color 0.2s ease; background: #f8fafc;
min-height: 38px;
height: 38px;
}
&:hover { .mat-mdc-header-cell {
background-color: var(--bg-hover); 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 }} {{ 'labels.inputs.Transactions' | translate }}
</a> </a>
<a
mat-tab-link
[routerLink]="['./passbook']"
routerLinkActive
#passbook="routerLinkActive"
[active]="passbook.isActive"
*mifosxHasPermission="'READ_SHAREACCOUNTPURCHASE'"
>
Passbook
</a>
<a <a
mat-tab-link mat-tab-link
[routerLink]="['./charges']" [routerLink]="['./charges']"
@@ -50,12 +50,10 @@ export class SharesButtonsConfiguration {
name: 'Reject Additional Shares', name: 'Reject Additional Shares',
icon: 'arrow-left', icon: 'arrow-left',
taskPermissionName: 'REJECTADDITIONAL_SHAREACCOUNT' 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; break;
case 'Submitted and pending approval': case 'Submitted and pending approval':
@@ -54,6 +54,25 @@
</td> </td>
</ng-container> </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-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr> <tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table> </table>
@@ -56,14 +56,18 @@ export class TransactionsTabComponent implements OnInit {
transactionsData: any; transactionsData: any;
/** Data source for transactions table. */ /** Data source for transactions table. */
dataSource: MatTableDataSource<any>; 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[] = [ displayedColumns: string[] = [
'transactionDate', 'transactionDate',
'transactionType', 'transactionType',
'totalShares', 'totalShares',
'purchasedOrRedeemedPrice', 'purchasedOrRedeemedPrice',
'chargeAmount', 'chargeAmount',
'amountRecievedOrReturned' 'amountRecievedOrReturned',
'runningBalance',
'resolutionMeeting'
]; ];
/** /**
@@ -73,9 +77,19 @@ export class TransactionsTabComponent implements OnInit {
constructor() { constructor() {
this.route.parent.data.subscribe((data: { sharesAccountData: any }) => { this.route.parent.data.subscribe((data: { sharesAccountData: any }) => {
this.shareAccountData = data.sharesAccountData; this.shareAccountData = data.sharesAccountData;
this.transactionsData = this.shareAccountData.purchasedShares.filter( this.transactionsData = this.shareAccountData.purchasedShares
(transaction: any) => transaction.type.value !== 'Charge Payment' .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();
}); });
} }
+6
View File
@@ -26,6 +26,7 @@ import { ShareAccountActionsResolver } from './common-resolvers/share-account-ac
import { SharesAccountTemplateResolver } from './common-resolvers/shares-account-template.resolver'; import { SharesAccountTemplateResolver } from './common-resolvers/shares-account-template.resolver';
import { SharesAccountAndTemplateResolver } from './common-resolvers/share-account-and-template.resolver'; import { SharesAccountAndTemplateResolver } from './common-resolvers/share-account-and-template.resolver';
import { GeneralTabComponent } from './shares-account-view/general-tab/general-tab.component'; import { GeneralTabComponent } from './shares-account-view/general-tab/general-tab.component';
import { SharesPassbookTabComponent } from './shares-account-view/passbook-tab/passbook-tab.component';
/** Shares Routes */ /** Shares Routes */
const routes: Routes = [ const routes: Routes = [
@@ -75,6 +76,11 @@ const routes: Routes = [
component: TransactionsTabComponent, component: TransactionsTabComponent,
data: { title: 'Shares Account Transactions', breadcrumb: 'Transactions', routeParamBreadcrumb: false } data: { title: 'Shares Account Transactions', breadcrumb: 'Transactions', routeParamBreadcrumb: false }
}, },
{
path: 'passbook',
component: SharesPassbookTabComponent,
data: { title: 'Shares Account Passbook', breadcrumb: 'Passbook', routeParamBreadcrumb: false }
},
{ {
path: 'charges', path: 'charges',
component: ChargesTabComponent, component: ChargesTabComponent,
@@ -126,6 +126,29 @@
<ng-container matColumnDef="actions"> <ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th> <th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let edit"> <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)"> <button mat-stroked-button class="pce-btn pce-btn-view" (click)="viewCompare(edit)">
<fa-icon icon="eye" class="m-r-10"></fa-icon>Compare <fa-icon icon="eye" class="m-r-10"></fa-icon>Compare
</button> </button>
@@ -137,6 +160,16 @@
<fa-icon icon="ban" class="m-r-10"></fa-icon>Reject <fa-icon icon="ban" class="m-r-10"></fa-icon>Reject
</button> </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> </td>
</ng-container> </ng-container>
@@ -346,3 +346,45 @@ $text-muted: #64748b;
line-height: 1.5; 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;
}
}
@@ -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 { 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 { 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 { 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 /** 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 * 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, MatRowDef,
MatRow, MatRow,
MatIcon, MatIcon,
MatPaginatorModule MatPaginatorModule,
ClientEditEmailDialogComponent
] ]
}) })
export class PendingClientEditsComponent implements OnInit { 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 { reload(): void {
this.load(); this.load();
} }