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>
</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>
@@ -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;
+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 { 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,
+2 -2
View File
@@ -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. */
+54
View File
@@ -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>&#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>
}
@@ -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">
@@ -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>
@@ -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);
@@ -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();
});
}
+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 { 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,
@@ -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>
@@ -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;
}
}
@@ -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();
}