diff --git a/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.html b/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.html index d5b1e8492..72cf485b2 100644 --- a/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.html +++ b/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.html @@ -139,6 +139,21 @@ {{ familyMemberForm.get('remarks')?.value }} } + @if (isSpouse && familyMemberForm.get('children')?.value?.length > 0) { +
+ Children +
+ @for (child of familyMemberForm.get('children')?.value; track $index) { +
+ {{ child.fullName }} + @if (child.dateOfBirth) { + · DOB: {{ child.dateOfBirth | date: 'dd MMM yyyy' }} + } +
+ } +
+
+ } } @@ -447,6 +462,25 @@ Remarks + + + @if (isSpouse) { +
Children
+
+ Does this family member have a child/children? + @if (familyMemberForm.get('hasChildren')?.value) { + + } +
+ } } diff --git a/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.ts b/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.ts index 552c39daf..2b941792b 100644 --- a/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.ts +++ b/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.ts @@ -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; diff --git a/src/app/clients/clients.module.ts b/src/app/clients/clients.module.ts index 5171c0131..bd13c7eca 100644 --- a/src/app/clients/clients.module.ts +++ b/src/app/clients/clients.module.ts @@ -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, diff --git a/src/app/clients/edit-client/edit-client.component.html b/src/app/clients/edit-client/edit-client.component.html index 92740d87c..ab2a69972 100644 --- a/src/app/clients/edit-client/edit-client.component.html +++ b/src/app/clients/edit-client/edit-client.component.html @@ -84,14 +84,70 @@ phone_iphone Mobile Number +
+ + Old mobile number not accessible (lost / deactivated SIM) + +
- + @if (!oldMobileNotAccessible) { + + } @else { +
+
+ Upload Police Complaint / FIR Copy for Lost Mobile +
+
+ + + @if (uploadingFir) { + + } + @if (firDocumentId && !uploadingFir) { + + check_circle FIR + Uploaded Successfully + + } +
+
+ } { + 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, diff --git a/src/app/core/shell/shell.component.ts b/src/app/core/shell/shell.component.ts index 0ceee8562..9ce65daac 100644 --- a/src/app/core/shell/shell.component.ts +++ b/src/app/core/shell/shell.component.ts @@ -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. */ diff --git a/src/app/fiu/fiu.component.html b/src/app/fiu/fiu.component.html index a4c1ce3f9..69a529ac5 100644 --- a/src/app/fiu/fiu.component.html +++ b/src/app/fiu/fiu.component.html @@ -295,6 +295,53 @@
+ + +
+
+

Monthly CTR Reports

+

+ Threshold: ₹{{ bank.cashThreshold | number }} per single txn / connected aggregate + per month +

+
+
+ + +
+
+
+ + + + + + + + + + + + @for (r of amlCtrReports; track r.id) { + + + + + + + + } @empty { + + + + } + +
MonthGenerated OnRecordsStatusSubmitted On
{{ r.reportMonth }}{{ r.generatedOn | date: 'medium' }}{{ r.recordCount }} + {{ r.status }} + {{ r.submittedOn ? (r.submittedOn | date: 'medium') : '—' }}
No CTR reports generated yet.
+
} @@ -307,6 +354,13 @@

Live AML Alerts (Awaiting Triage)

Flagged by the monitoring engine for suspicious behavioral patterns — triage and whitelist or escalate

+
diff --git a/src/app/products/saving-products/saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component.html b/src/app/products/saving-products/saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component.html index d2db28c86..639cfbe47 100644 --- a/src/app/products/saving-products/saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component.html +++ b/src/app/products/saving-products/saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component.html @@ -115,6 +115,51 @@ } + +
+
+

Map Payment Types to Specific Funds

+ +
+
+ @for (row of paymentChannelToFundSourceMappings.controls; track $index; let i = $index) { +
+
+
+ + Payment Type + + @for (paymentType of paymentTypeOptions; track paymentType.id) { + {{ paymentType.name }} + } + + +
+ +
+ + Fund Source + + @for (glAccount of fundSourceAccountOptions; track glAccount.id) { + + {{ '(' + glAccount.glCode + ') ' + glAccount.name }} + + } + + +
+
+ +
+ } +
+
} diff --git a/src/app/products/saving-products/saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component.ts b/src/app/products/saving-products/saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component.ts index 88e88a51d..986472df6 100644 --- a/src/app/products/saving-products/saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component.ts +++ b/src/app/products/saving-products/saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component.ts @@ -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 = {}) { + 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); diff --git a/src/app/products/share-products/share-product-stepper/share-product-accounting-step/share-product-accounting-step.component.ts b/src/app/products/share-products/share-product-stepper/share-product-accounting-step/share-product-accounting-step.component.ts index cd20da7bc..14693833d 100644 --- a/src/app/products/share-products/share-product-stepper/share-product-accounting-step/share-product-accounting-step.component.ts +++ b/src/app/products/share-products/share-product-stepper/share-product-accounting-step/share-product-accounting-step.component.ts @@ -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 } ], diff --git a/src/app/shares/share-holders/share-holders.component.html b/src/app/shares/share-holders/share-holders.component.html index daae3fe22..15adb5d11 100644 --- a/src/app/shares/share-holders/share-holders.component.html +++ b/src/app/shares/share-holders/share-holders.component.html @@ -20,11 +20,11 @@
- + - + All Branches @@ -33,7 +33,7 @@ - + All Products @@ -42,7 +42,7 @@ - + All Status Active @@ -53,7 +53,7 @@ - + diff --git a/src/app/shares/share-holders/share-holders.component.scss b/src/app/shares/share-holders/share-holders.component.scss index 6d41b20a0..588b29b94 100644 --- a/src/app/shares/share-holders/share-holders.component.scss +++ b/src/app/shares/share-holders/share-holders.component.scss @@ -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; +} diff --git a/src/app/shares/shares-account-view/shares-account-view.component.html b/src/app/shares/shares-account-view/shares-account-view.component.html index 7b1d31e33..006ee30a5 100644 --- a/src/app/shares/shares-account-view/shares-account-view.component.html +++ b/src/app/shares/shares-account-view/shares-account-view.component.html @@ -152,6 +152,16 @@ > {{ 'labels.inputs.Transactions' | translate }} + + Passbook + My Share Requests), + // never a direct one-step action from this view. ]; break; case 'Submitted and pending approval': diff --git a/src/app/shares/shares-account-view/transactions-tab/transactions-tab.component.html b/src/app/shares/shares-account-view/transactions-tab/transactions-tab.component.html index f3941f52b..769d76d05 100644 --- a/src/app/shares/shares-account-view/transactions-tab/transactions-tab.component.html +++ b/src/app/shares/shares-account-view/transactions-tab/transactions-tab.component.html @@ -54,6 +54,25 @@ + +
+ + + + + + + +
Share Balance{{ transaction.runningBalance }}Resolution No. / Meeting Date + @if (transaction.type.value === 'Redeem') { + {{ transaction.resolutionNumber || '—' }} + @if (transaction.meetingDate) { + ({{ transaction.meetingDate | dateFormat }}) + } + } @else { + — + } +
diff --git a/src/app/shares/shares-account-view/transactions-tab/transactions-tab.component.ts b/src/app/shares/shares-account-view/transactions-tab/transactions-tab.component.ts index c28640324..46a7e3db0 100644 --- a/src/app/shares/shares-account-view/transactions-tab/transactions-tab.component.ts +++ b/src/app/shares/shares-account-view/transactions-tab/transactions-tab.component.ts @@ -56,14 +56,18 @@ export class TransactionsTabComponent implements OnInit { transactionsData: any; /** Data source for transactions table. */ dataSource: MatTableDataSource; - /** 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(); }); } diff --git a/src/app/shares/shares-routing.module.ts b/src/app/shares/shares-routing.module.ts index 4c5193aef..d12979e83 100644 --- a/src/app/shares/shares-routing.module.ts +++ b/src/app/shares/shares-routing.module.ts @@ -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, diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.html index 98f557aed..4942b051f 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.html +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.html @@ -126,6 +126,29 @@ Actions + + + + {{ edit.emailSent ? 'Sent' : 'Not Sent' }} + + @if (edit.emailSent) { + + + {{ edit.emailOpenedAt ? 'Seen' : 'Not Seen' }} + + } + + @@ -137,6 +160,16 @@ Reject } + @if (edit.emailSent) { + + } diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.scss index 61ffa8119..642138ffd 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.scss +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.scss @@ -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; + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.ts index c3fdc1c3b..40ba63a9d 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.ts @@ -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 = { 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(); }