Merge remote-tracking branch 'origin/uate' into dev
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

This commit is contained in:
2026-07-27 14:21:51 +05:30
47 changed files with 2790 additions and 245 deletions
-1
View File
@@ -1 +0,0 @@
{"sessionId":"67d120b4-ff78-435d-be80-f79482a386b4","pid":19832,"acquiredAt":1778067581856}
@@ -416,6 +416,14 @@ export class AuthenticationService {
return false; return false;
} }
/** Returns true when the logged-in user has the "Cashier" role. */
hasCashierRole(): boolean {
const creds = this.getCredentials();
if (!creds || !creds.roles) return false;
const roleNames: string[] = (creds.roles as any[]).map((r) => (r.name || r.role || '').toLowerCase().trim());
return roleNames.some((n) => n === 'cashier');
}
/** /**
* Sets the user credentials. * Sets the user credentials.
* *
@@ -7,6 +7,9 @@
--> -->
<div class="cur-view"> <div class="cur-view">
<!-- ─── Dormancy risk alert (only when subStatus has left Active) ──── -->
<mifosx-dormancy-alert-panel [accountData]="currentAccountData"></mifosx-dormancy-alert-panel>
<!-- ─── Gradient hero header ─────────────────────────────────────── --> <!-- ─── Gradient hero header ─────────────────────────────────────── -->
<header class="cur-hero"> <header class="cur-hero">
<div class="cur-hero__left"> <div class="cur-hero__left">
@@ -27,6 +27,7 @@ import { TranslateService } from '@ngx-translate/core';
import { AccountsService } from 'app/accounts/accounts.service'; import { AccountsService } from 'app/accounts/accounts.service';
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 { AuthenticationService } from '../../core/authentication/authentication.service';
/** Environment Configuration */ /** Environment Configuration */
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
@@ -48,6 +49,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatTabNav, MatTabLink, MatTabNavPanel } from '@angular/material/tabs'; import { MatTabNav, MatTabLink, MatTabNavPanel } from '@angular/material/tabs';
import { StatusLookupPipe } from '../../pipes/status-lookup.pipe'; import { StatusLookupPipe } from '../../pipes/status-lookup.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DormancyAlertPanelComponent } from 'app/shared/dormancy-alert-panel/dormancy-alert-panel.component';
/** /**
* Current Account View Component * Current Account View Component
@@ -77,7 +79,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatTabNavPanel, MatTabNavPanel,
RouterOutlet, RouterOutlet,
CurrencyPipe, CurrencyPipe,
StatusLookupPipe StatusLookupPipe,
DormancyAlertPanelComponent
] ]
}) })
export class CurrentAccountViewComponent implements OnInit { export class CurrentAccountViewComponent implements OnInit {
@@ -89,6 +92,7 @@ export class CurrentAccountViewComponent implements OnInit {
private accountsService = inject(AccountsService); private accountsService = inject(AccountsService);
private settingsService = inject(SettingsService); private settingsService = inject(SettingsService);
private dateUtils = inject(Dates); private dateUtils = inject(Dates);
private authenticationService = inject(AuthenticationService);
dialog = inject(MatDialog); dialog = inject(MatDialog);
/** Current Account Data */ /** Current Account Data */
@@ -271,6 +275,14 @@ export class CurrentAccountViewComponent implements OnInit {
}); });
} }
} }
if (!this.authenticationService.hasCashierRole()) {
this.buttonConfig.buttonsArray = this.buttonConfig.buttonsArray.filter(
(btn) => btn.name !== 'Deposit' && btn.name !== 'Withdrawal'
);
this.buttonConfig.optionArray = this.buttonConfig.optionArray.filter(
(opt) => opt.name !== 'Deposit' && opt.name !== 'Withdrawal'
);
}
} }
/** /**
@@ -7,6 +7,9 @@
--> -->
<div class="fd-view"> <div class="fd-view">
<!-- ─── Dormancy risk alert (only when subStatus has left Active) ──── -->
<mifosx-dormancy-alert-panel [accountData]="fixedDepositsAccountData"></mifosx-dormancy-alert-panel>
<!-- ─── Gradient hero header ─────────────────────────────────────── --> <!-- ─── Gradient hero header ─────────────────────────────────────── -->
<header class="fd-hero"> <header class="fd-hero">
<div class="fd-hero__left"> <div class="fd-hero__left">
@@ -27,6 +27,7 @@ import { AccountsService } from 'app/accounts/accounts.service';
import { Currency } from 'app/shared/models/general.model'; import { Currency } from 'app/shared/models/general.model';
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 { AuthenticationService } from '../../../core/authentication/authentication.service';
import { MatTooltip } from '@angular/material/tooltip'; import { MatTooltip } from '@angular/material/tooltip';
import { NgClass, CurrencyPipe } from '@angular/common'; import { NgClass, CurrencyPipe } from '@angular/common';
import { AccountNumberComponent } from '../../../shared/account-number/account-number.component'; import { AccountNumberComponent } from '../../../shared/account-number/account-number.component';
@@ -37,6 +38,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatTabNav, MatTabLink, MatTabNavPanel } from '@angular/material/tabs'; import { MatTabNav, MatTabLink, MatTabNavPanel } from '@angular/material/tabs';
import { StatusLookupPipe } from '../../../pipes/status-lookup.pipe'; import { StatusLookupPipe } from '../../../pipes/status-lookup.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DormancyAlertPanelComponent } from 'app/shared/dormancy-alert-panel/dormancy-alert-panel.component';
/** /**
* Fixed Deposits Account View Component * Fixed Deposits Account View Component
@@ -62,7 +64,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatTabNavPanel, MatTabNavPanel,
RouterOutlet, RouterOutlet,
CurrencyPipe, CurrencyPipe,
StatusLookupPipe StatusLookupPipe,
DormancyAlertPanelComponent
] ]
}) })
export class FixedDepositAccountViewComponent implements OnInit { export class FixedDepositAccountViewComponent implements OnInit {
@@ -73,6 +76,7 @@ export class FixedDepositAccountViewComponent implements OnInit {
private accountsService = inject(AccountsService); private accountsService = inject(AccountsService);
private settingsService = inject(SettingsService); private settingsService = inject(SettingsService);
private dateUtils = inject(Dates); private dateUtils = inject(Dates);
private authenticationService = inject(AuthenticationService);
dialog = inject(MatDialog); dialog = inject(MatDialog);
/** Fixed Deposits Account Data */ /** Fixed Deposits Account Data */
@@ -194,6 +198,14 @@ export class FixedDepositAccountViewComponent implements OnInit {
}); });
} }
} }
if (!this.authenticationService.hasCashierRole()) {
this.buttonConfig.buttonsArray = this.buttonConfig.buttonsArray.filter(
(btn) => btn.name !== 'Deposit' && btn.name !== 'Withdrawal'
);
this.buttonConfig.optionArray = this.buttonConfig.optionArray.filter(
(opt) => opt.name !== 'Deposit' && opt.name !== 'Withdrawal'
);
}
} }
/** /**
@@ -7,6 +7,9 @@
--> -->
<div class="rd-view"> <div class="rd-view">
<!-- ─── Dormancy risk alert (only when subStatus has left Active) ──── -->
<mifosx-dormancy-alert-panel [accountData]="recurringDepositsAccountData"></mifosx-dormancy-alert-panel>
<!-- ─── Gradient hero header ─────────────────────────────────────── --> <!-- ─── Gradient hero header ─────────────────────────────────────── -->
<header class="rd-hero"> <header class="rd-hero">
<div class="rd-hero__left"> <div class="rd-hero__left">
@@ -27,6 +27,7 @@ import { TranslateService } from '@ngx-translate/core';
import { AccountsService } from 'app/accounts/accounts.service'; import { AccountsService } from 'app/accounts/accounts.service';
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 { AuthenticationService } from '../../../core/authentication/authentication.service';
import { MatTooltip } from '@angular/material/tooltip'; import { MatTooltip } from '@angular/material/tooltip';
import { NgClass, DecimalPipe, CurrencyPipe } from '@angular/common'; import { NgClass, DecimalPipe, CurrencyPipe } from '@angular/common';
import { AccountNumberComponent } from '../../../shared/account-number/account-number.component'; import { AccountNumberComponent } from '../../../shared/account-number/account-number.component';
@@ -37,6 +38,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatTabNav, MatTabLink, MatTabNavPanel } from '@angular/material/tabs'; import { MatTabNav, MatTabLink, MatTabNavPanel } from '@angular/material/tabs';
import { StatusLookupPipe } from '../../../pipes/status-lookup.pipe'; import { StatusLookupPipe } from '../../../pipes/status-lookup.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DormancyAlertPanelComponent } from 'app/shared/dormancy-alert-panel/dormancy-alert-panel.component';
/** /**
* RecurringDeposits Account View Component * RecurringDeposits Account View Component
@@ -63,7 +65,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
RouterOutlet, RouterOutlet,
DecimalPipe, DecimalPipe,
CurrencyPipe, CurrencyPipe,
StatusLookupPipe StatusLookupPipe,
DormancyAlertPanelComponent
] ]
}) })
export class RecurringDepositsAccountViewComponent implements OnInit { export class RecurringDepositsAccountViewComponent implements OnInit {
@@ -74,6 +77,7 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
private accountsService = inject(AccountsService); private accountsService = inject(AccountsService);
private settingsService = inject(SettingsService); private settingsService = inject(SettingsService);
private dateUtils = inject(Dates); private dateUtils = inject(Dates);
private authenticationService = inject(AuthenticationService);
dialog = inject(MatDialog); dialog = inject(MatDialog);
private translateService = inject(TranslateService); private translateService = inject(TranslateService);
@@ -245,6 +249,14 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
} }
} }
} }
if (!this.authenticationService.hasCashierRole()) {
this.buttonConfig.buttonsArray = this.buttonConfig.buttonsArray.filter(
(btn) => btn.name !== 'Deposit' && btn.name !== 'Withdrawal'
);
this.buttonConfig.optionArray = this.buttonConfig.optionArray.filter(
(opt) => opt.name !== 'Deposit' && opt.name !== 'Withdrawal'
);
}
} }
/** /**
@@ -544,82 +544,315 @@ export function downloadLoanInterestPdf(record: LoanInterestRecord, fy: FyIntere
// ───────────────────── Share Certificate ───────────────────── // ───────────────────── Share Certificate ─────────────────────
export function buildShareCertificatePdf(r: ShareCertificateRecord): jsPDF { export function buildShareCertificatePdf(r: ShareCertificateRecord): jsPDF {
const c = newCtx(ACCENT_AMBER); const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
const pageW = doc.internal.pageSize.getWidth();
const pageH = doc.internal.pageSize.getHeight();
const margin = 20;
const contentW = pageW - 40;
const gold: [number, number, number] = [
197,
160,
89
]; // #c5a059
const darkBlue: [number, number, number] = [
11,
60,
93
]; // #0b3c5d
const textDark: [number, number, number] = [
44,
37,
32
]; // #2c2520
const textMuted: [number, number, number] = [
123,
111,
99
]; // #7b6f63
const lightBg: [number, number, number] = [
253,
252,
247
]; // #fdfcf7
const containerBg: [number, number, number] = [
250,
248,
245
]; // #faf8f5
// 1. Draw Cream Background (page body)
doc.setFillColor(containerBg[0], containerBg[1], containerBg[2]);
doc.rect(0, 0, pageW, pageH, 'F');
// 2. Draw Textured cream background inside the outer border
doc.setFillColor(lightBg[0], lightBg[1], lightBg[2]);
doc.rect(12, 12, pageW - 24, pageH - 24, 'F');
// 3. Draw Gold Double Borders (Outer and Inner)
doc.setDrawColor(gold[0], gold[1], gold[2]);
// Outer double line
doc.setLineWidth(1.2);
doc.rect(12, 12, pageW - 24, pageH - 24, 'D');
doc.setLineWidth(0.3);
doc.rect(13.8, 13.8, pageW - 27.6, pageH - 27.6, 'D');
// Thin interior border line
doc.setDrawColor(220, 209, 189); // light brown/grey
doc.setLineWidth(0.2);
doc.rect(17, 17, pageW - 34, pageH - 34, 'D');
// 4. Certificate Header
let y = 28;
doc.setFont('times', 'bold');
doc.setFontSize(22);
doc.setTextColor(darkBlue[0], darkBlue[1], darkBlue[2]);
doc.text('NEXORA CO-OPERATIVE BANK LTD.', pageW / 2, y, { align: 'center' });
y += 5;
doc.setFont('times', 'italic');
doc.setFontSize(10.5);
doc.setTextColor(textMuted[0], textMuted[1], textMuted[2]);
doc.text('Mumbai, Maharashtra, India', pageW / 2, y, { align: 'center' });
y += 4;
doc.setDrawColor(242, 237, 213); // light gold separator
doc.setLineWidth(1.5);
doc.line(22, y, pageW - 22, y);
y += 9;
doc.setFont('times', 'bold');
doc.setFontSize(24);
doc.setTextColor(gold[0], gold[1], gold[2]);
doc.text('SHARE CERTIFICATE', pageW / 2, y, { align: 'center' });
y += 5.5;
doc.setFont('times', 'bold');
doc.setFontSize(7.5);
doc.setTextColor(textMuted[0], textMuted[1], textMuted[2]);
doc.text('ISSUED UNDER THE COMMON SEAL OF THE BANK', pageW / 2, y, { align: 'center' });
y += 4;
doc.setDrawColor(242, 237, 213); // separator
doc.setLineWidth(0.3);
doc.line(22, y, pageW - 22, y);
// 5. Meta Row (Certificate No, Folio No, Date of Issue)
y += 7;
// Column 1: Certificate No
doc.setFont('helvetica', 'bold');
doc.setFontSize(7.5);
doc.setTextColor(textMuted[0], textMuted[1], textMuted[2]);
doc.text('CERTIFICATE NO.', 25, y);
doc.setFont('times', 'bold');
doc.setFontSize(10);
doc.setTextColor(textDark[0], textDark[1], textDark[2]);
doc.text(r.certificateNo, 25, y + 4.5);
// Column 2: Folio No (Centered)
doc.setFont('helvetica', 'bold');
doc.setFontSize(7.5);
doc.setTextColor(textMuted[0], textMuted[1], textMuted[2]);
doc.text('FOLIO NO.', pageW / 2, y, { align: 'center' });
doc.setFont('times', 'bold');
doc.setFontSize(10);
doc.setTextColor(textDark[0], textDark[1], textDark[2]);
doc.text(r.folioNo, pageW / 2, y + 4.5, { align: 'center' });
// Column 3: Date of Issue (Right Aligned)
doc.setFont('helvetica', 'bold');
doc.setFontSize(7.5);
doc.setTextColor(textMuted[0], textMuted[1], textMuted[2]);
doc.text('DATE OF ISSUE', pageW - 25, y, { align: 'right' });
doc.setFont('times', 'bold');
doc.setFontSize(10);
doc.setTextColor(textDark[0], textDark[1], textDark[2]);
doc.text(formatLongDate(r.issueDate), pageW - 25, y + 4.5, { align: 'right' });
y += 8;
doc.setDrawColor(242, 237, 213);
doc.setLineWidth(0.3);
doc.line(22, y, pageW - 22, y);
// 6. Centered Body Section
y += 9;
const classLabel = r.shareClass === 'PREFERENCE' ? 'Preference' : 'Ordinary'; const classLabel = r.shareClass === 'PREFERENCE' ? 'Preference' : 'Ordinary';
let y = paintLetterhead(c, 'Share Certificate', 'CERTIFICATE REFERENCE', r.certificateNo, 'Folio No.', r.folioNo); doc.setFont('times', 'normal');
doc.setFontSize(11);
doc.setTextColor(textDark[0], textDark[1], textDark[2]);
y = paintAddressee(c, y, 'Issued To,', r.memberName, r.memberId, r.memberPan, r.memberAddress); const bodyText = `This is to certify that ${r.memberName} is the registered holder of ${r.shareCount.toLocaleString('en-IN')} (${classLabel}) shares of Rs. ${r.faceValue.toLocaleString('en-IN')} each, fully paid up, in Nexora Co-operative Bank Ltd., subject to the memorandum, articles of association, and bye-laws of the bank.`;
const bodyLines = doc.splitTextToSize(bodyText, pageW - 50);
// Centered, dramatic body paragraph (mirrors the highlighted "centered" style in the preview) for (const line of bodyLines) {
if (y > c.pageH - c.margin - 80) { doc.text(line, pageW / 2, y, { align: 'center' });
c.doc.addPage(); y += 5.5;
setFill(c, c.accent);
c.doc.rect(0, 0, c.pageW, 4, 'F');
y = c.margin;
} }
const ctaText = `This is to certify that ${r.memberName} is the registered holder of ${r.shareCount.toLocaleString('en-IN')} (${classLabel}) shares of Rs. ${r.faceValue.toLocaleString('en-IN')} each, fully paid up, in ${BANK_NAME}, subject to the bye-laws of the bank.`;
const ctaLines = c.doc.splitTextToSize(ctaText, c.contentW - 10);
const ctaH = ctaLines.length * 5 + 6;
setFill(c, c.accentTint);
c.doc.rect(c.margin, y, c.contentW, ctaH, 'F');
setFill(c, c.accent);
c.doc.rect(c.margin, y, 1.8, ctaH, 'F');
c.doc.setFont('times', 'bold');
c.doc.setFontSize(11);
setText(c, C_TEXT);
c.doc.text(ctaLines, c.margin + 6, y + 5, { align: 'justify', maxWidth: c.contentW - 10 });
y += ctaH + 6;
y = paintSummary( // 7. ISSUED TO Box
c, y += 3;
y, const addrLines = doc.splitTextToSize(r.memberAddress || '', pageW - 60);
[ const boxH = 26 + addrLines.length * 4;
[
'CERTIFICATE NO.', doc.setFillColor(253, 252, 247);
r.certificateNo, doc.setDrawColor(230, 225, 210);
'FOLIO NO.', doc.rect(25, y, pageW - 50, boxH, 'FD');
r.folioNo
], let boxY = y + 6;
[ doc.setFont('helvetica', 'bold');
'BRANCH', doc.setFontSize(8);
r.branch, doc.setTextColor(gold[0], gold[1], gold[2]);
'MEMBER SINCE', doc.text('ISSUED TO:', 30, boxY);
formatLongDate(r.joiningDate)
], boxY += 5.5;
[ doc.setFont('times', 'bold');
'SHARE CLASS', doc.setFontSize(13);
classLabel, doc.setTextColor(darkBlue[0], darkBlue[1], darkBlue[2]);
'DATE OF ISSUE', doc.text(r.memberName, 30, boxY);
formatLongDate(r.issueDate)
], boxY += 5.5;
[ doc.setFont('helvetica', 'normal');
'SHARE NUMBERS', doc.setFontSize(9);
`${r.shareNoFrom.toLocaleString('en-IN')} – ${r.shareNoTo.toLocaleString('en-IN')}`, doc.setTextColor(textDark[0], textDark[1], textDark[2]);
'DIVIDEND PAID', let idLine = `Member ID: ${r.memberId}`;
`Rs. ${r.dividendYtd.toLocaleString('en-IN')}` if (r.memberPan && r.memberPan !== 'Not Available' && r.memberPan !== '—') {
] idLine += ` · PAN: ${r.memberPan}`;
], }
{ doc.text(idLine, 30, boxY);
label: 'TOTAL FACE VALUE',
value: `Rs. ${(r.shareCount * r.faceValue).toLocaleString('en-IN')}` boxY += 5;
doc.setFontSize(8.5);
doc.setTextColor(textMuted[0], textMuted[1], textMuted[2]);
doc.text(addrLines, 30, boxY);
y += boxH + 8;
// 8. Details Grid
const tableW = pageW - 50;
const tableColW = tableW / 4;
const tableRowH = 8.5;
const drawTableCell = (
x: number,
tblY: number,
w: number,
h: number,
text: string,
isLabel: boolean,
align: 'left' | 'right' = 'left'
) => {
if (isLabel) {
doc.setFillColor(253, 252, 247);
doc.rect(x, tblY, w, h, 'F');
doc.setFont('helvetica', 'bold');
doc.setFontSize(8);
doc.setTextColor(gold[0], gold[1], gold[2]);
} else {
doc.setFont('times', 'bold');
doc.setFontSize(9.5);
doc.setTextColor(darkBlue[0], darkBlue[1], darkBlue[2]);
} }
); doc.setDrawColor(220, 209, 189);
doc.setLineWidth(0.25);
doc.rect(x, tblY, w, h, 'D');
doc.text(text, align === 'right' ? x + w - 3 : x + 3, tblY + h / 2 + 1.2, { align });
};
y = paintPara( // Row 1
c, drawTableCell(25, y, tableColW, tableRowH, 'Branch', true);
drawTableCell(25 + tableColW, y, tableColW, tableRowH, r.branch || 'Mumbai Main Branch', false);
drawTableCell(25 + tableColW * 2, y, tableColW, tableRowH, 'Member Since', true);
drawTableCell(25 + tableColW * 3, y, tableColW, tableRowH, formatLongDate(r.joiningDate), false);
y += tableRowH;
// Row 2
drawTableCell(25, y, tableColW, tableRowH, 'Share Class', true);
drawTableCell(25 + tableColW, y, tableColW, tableRowH, classLabel, false);
drawTableCell(25 + tableColW * 2, y, tableColW, tableRowH, 'Date of Issue', true);
drawTableCell(25 + tableColW * 3, y, tableColW, tableRowH, formatLongDate(r.issueDate), false);
y += tableRowH;
// Row 3
drawTableCell(25, y, tableColW, tableRowH, 'Share Numbers', true);
drawTableCell(
25 + tableColW,
y, y,
`Given under the common seal of the bank this ${new Date().toLocaleDateString('en-IN', { tableColW,
day: 'numeric', tableRowH,
month: 'long', `${r.shareNoFrom.toLocaleString('en-IN')} – ${r.shareNoTo.toLocaleString('en-IN')}`,
year: 'numeric' false
})}.`
); );
drawTableCell(25 + tableColW * 2, y, tableColW, tableRowH, 'Dividend Paid', true);
drawTableCell(25 + tableColW * 3, y, tableColW, tableRowH, `Rs. ${r.dividendYtd.toLocaleString('en-IN')}`, false);
y += tableRowH;
y = paintSignoff(c, y + 6, r.branch); // Row 4 (Highlight)
paintFooter(c, y + 4); doc.setFillColor(darkBlue[0], darkBlue[1], darkBlue[2]);
return c.doc; doc.rect(25, y, tableColW * 2, tableRowH, 'F');
doc.rect(25 + tableColW * 2, y, tableColW * 2, tableRowH, 'F');
doc.setFont('helvetica', 'bold');
doc.setFontSize(8.5);
doc.setTextColor(255, 255, 255);
doc.text('TOTAL FACE VALUE', 28, y + tableRowH / 2 + 1.2);
doc.setFont('times', 'bold');
doc.setFontSize(11);
doc.text(`Rs. ${(r.shareCount * r.faceValue).toLocaleString('en-IN')}`, pageW - 28, y + tableRowH / 2 + 1.2, {
align: 'right'
});
y += tableRowH + 8;
// 9. Footer Text (Given under common seal...)
doc.setFont('times', 'italic');
doc.setFontSize(10.5);
doc.setTextColor(textDark[0], textDark[1], textDark[2]);
const sealDay = new Date().getDate();
const sealMonthYear = new Date().toLocaleDateString('en-IN', { month: 'long', year: 'numeric' });
const sealPhrase = `Given under the common seal of the bank this ${sealDay} day of ${sealMonthYear}.`;
doc.text(sealPhrase, pageW / 2, y, { align: 'center' });
y += 8;
// 10. Seal Circle (left) and Signatory block (right)
const sealX = 50;
const sealY = y + 14;
doc.setDrawColor(gold[0], gold[1], gold[2]); // Gold
doc.setLineWidth(0.6);
doc.circle(sealX, sealY, 14, 'D');
doc.setLineWidth(0.3);
doc.setLineDashPattern(
[
1,
1
],
0
);
doc.circle(sealX, sealY, 11, 'D');
doc.setLineDashPattern([], 0);
doc.setFont('helvetica', 'bold');
doc.setFontSize(7.5);
doc.setTextColor(gold[0], gold[1], gold[2]);
doc.text('COMMON', sealX, sealY - 1, { align: 'center' });
doc.text('SEAL', sealX, sealY + 3.5, { align: 'center' });
// Signatory Line
doc.setDrawColor(textMuted[0], textMuted[1], textMuted[2]);
doc.setLineWidth(0.3);
doc.line(pageW - 75, y + 20, pageW - 25, y + 20);
doc.setFont('helvetica', 'bold');
doc.setFontSize(9.5);
doc.setTextColor(textDark[0], textDark[1], textDark[2]);
doc.text('Authorised Signatory', pageW - 50, y + 25, { align: 'center' });
return doc;
} }
export function downloadShareCertificatePdf(r: ShareCertificateRecord, dsc?: BankUserDsc): void { export function downloadShareCertificatePdf(r: ShareCertificateRecord, dsc?: BankUserDsc): void {
@@ -89,6 +89,43 @@
line-height: 1.5; line-height: 1.5;
} }
.cert-head-r {
margin-left: auto;
flex-shrink: 0;
}
.cert-cta-btn {
height: 38px;
padding: 0 16px;
border: 1px solid var(--cert-blue-d);
border-radius: 8px;
background: var(--cert-blue-d);
color: #fff;
font-size: 12.5px;
font-weight: 700;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
white-space: nowrap;
&:hover {
background: var(--cert-blue);
border-color: var(--cert-blue);
}
&--muted {
background: #fff;
color: var(--cert-g700);
border-color: var(--cert-g200);
&:hover {
background: var(--cert-g50);
border-color: var(--cert-g300);
}
}
}
/* ───────── KPI strip ───────── */ /* ───────── KPI strip ───────── */
.cert-kpis { .cert-kpis {
display: grid; display: grid;
@@ -0,0 +1,174 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
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/.
-->
<div class="rsad-root">
@if (loading) {
<div class="rsad-state">
<fa-icon icon="spinner" class="rsad-state-ic fa-spin"></fa-icon>
Loading real share accounts&hellip;
</div>
} @else if (loadError) {
<div class="rsad-state rsad-state--error">
<fa-icon icon="exclamation-triangle" class="rsad-state-ic"></fa-icon>
Could not load share accounts from the server.
<button type="button" class="cert-clear" (click)="retry()"><fa-icon icon="redo"></fa-icon> Retry</button>
</div>
} @else if (rows.length === 0) {
<div class="rsad-state">
<fa-icon icon="info-circle" class="rsad-state-ic"></fa-icon>
No share accounts found in m_share_account yet.
</div>
} @else {
<!-- KPI STRIP -->
<div class="cert-kpis">
<div class="cert-kpi cert-kpi--blue">
<fa-icon icon="certificate" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ filteredRows.length | number }}</div>
<div class="cert-kpi-l">Certificates</div>
</div>
</div>
<div class="cert-kpi cert-kpi--teal">
<fa-icon icon="cube" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totalShares() | number }}</div>
<div class="cert-kpi-l">Shares Held</div>
</div>
</div>
<div class="cert-kpi cert-kpi--green">
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">₹{{ totalShareValue() | number: '1.0-0' }}</div>
<div class="cert-kpi-l">Total Face Value</div>
</div>
</div>
</div>
<!-- FILTERS -->
<div class="cert-filters">
<div class="cert-filter">
<label>Certificate / Folio / Member / PAN</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="text" [(ngModel)]="search" placeholder="e.g. member name" />
</div>
</div>
<div class="cert-filter">
<label>Share class</label>
<select [(ngModel)]="classFilter">
<option value="">All classes</option>
@for (o of classOptions; track o.value) {
<option [value]="o.value">{{ o.label }}</option>
}
</select>
</div>
<div class="cert-filter">
<label>Branch</label>
<select [(ngModel)]="branchFilter">
<option value="">All branches</option>
@for (b of branchOptions; track b) {
<option [value]="b">{{ b }}</option>
}
</select>
</div>
@if (search || classFilter || branchFilter) {
<button type="button" class="cert-clear" (click)="clearFilters()">
<fa-icon icon="times"></fa-icon> Clear
</button>
}
</div>
<!-- TABLE -->
<div class="cert-card">
<div class="cert-table-wrap">
<table class="cert-table">
<thead>
<tr>
<th>Member · Folio</th>
<th>Certificate No.</th>
<th>Class</th>
<th>Branch</th>
<th class="num">Shares</th>
<th class="num">Face Value</th>
<th class="num">Total</th>
<th>Share Nos.</th>
<th>Issued</th>
<th class="col-actions">Actions</th>
</tr>
</thead>
<tbody>
@for (row of filteredRows; track row.record.certificateNo) {
<tr>
<td>
<div class="cert-cust-name">{{ row.record.memberName }}</div>
<div class="mono small muted">
{{ row.record.memberId }} · Folio <span class="cert-folio">{{ row.record.folioNo }}</span> · PAN
{{ row.record.memberPan }}
</div>
</td>
<td class="mono">{{ row.record.certificateNo }}</td>
<td>
<span
class="cert-pill"
[class.cert-pill--blue]="row.record.shareClass === 'ORDINARY'"
[class.cert-pill--amber]="row.record.shareClass === 'PREFERENCE'"
>
{{ classLabel(row.record.shareClass) }}
</span>
</td>
<td class="small">{{ row.record.branch || '—' }}</td>
<td class="num">
<strong class="cert-shares">{{ row.record.shareCount | number }}</strong>
</td>
<td class="num">₹{{ row.record.faceValue | number: '1.0-0' }}</td>
<td class="num">
<strong>₹{{ row.record.shareCount * row.record.faceValue | number: '1.0-0' }}</strong>
</td>
<td class="mono small">
{{ row.record.shareNoFrom | number }}&ndash;{{ row.record.shareNoTo | number }}
</td>
<td class="small">{{ row.record.issueDate | date: 'd MMM y' }}</td>
<td class="col-actions">
<div class="cert-row-actions">
<button
type="button"
class="cert-icon-btn primary"
(click)="viewCertificate(row)"
matTooltip="View / Print certificate"
>
<fa-icon icon="eye"></fa-icon>
</button>
<button
type="button"
class="cert-icon-btn"
(click)="downloadCertificate(row)"
matTooltip="Download PDF"
>
<fa-icon icon="download"></fa-icon>
</button>
</div>
</td>
</tr>
}
@if (filteredRows.length === 0) {
<tr>
<td colspan="10" class="cert-empty">
<fa-icon icon="info-circle"></fa-icon>
No share accounts match the current filters.
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
</div>
@@ -0,0 +1,25 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* 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/.
*/
.rsad-state {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 48px 20px;
color: var(--cert-g500);
font-size: 13.5px;
&--error {
color: var(--cert-red);
}
}
.rsad-state-ic {
font-size: 16px;
}
@@ -0,0 +1,312 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* 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/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatDialog } from '@angular/material/dialog';
import { MatTooltip } from '@angular/material/tooltip';
import { forkJoin, of, Observable } from 'rxjs';
import { catchError, map, shareReplay, switchMap } from 'rxjs/operators';
import { SharesService } from '../../../../shares/shares.service';
import { ClientsService } from '../../../../clients/clients.service';
import { ShareCertificateDialogComponent } from '../../../../shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component';
import { downloadShareCertificatePdf } from '../../certificate-pdf';
import { DscSignService } from '../../../dsc-sign-dialog/dsc-sign.service';
import type { ShareClass, ShareCertificateRecord } from '../share-certificate.component';
/** A real share account, kept alongside the raw API payload needed to open the real certificate dialog. */
export interface RealShareAccountRow {
record: ShareCertificateRecord;
sharesAccountData: any;
clientId: string;
}
@Component({
selector: 'mifosx-real-share-accounts-view',
templateUrl: './real-share-accounts-view.component.html',
styleUrls: [
'../../certificates.shared.scss',
'./real-share-accounts-view.component.scss'
],
imports: [
CommonModule,
FormsModule,
FaIconComponent,
MatTooltip
]
})
export class RealShareAccountsViewComponent implements OnInit {
private dialog = inject(MatDialog);
private sharesService = inject(SharesService);
private clientsService = inject(ClientsService);
private dscSign = inject(DscSignService);
loading = true;
loadError = false;
rows: RealShareAccountRow[] = [];
// ── Filters (mirrors the demo list above) ──
search = '';
classFilter: '' | ShareClass = '';
branchFilter = '';
readonly classOptions: { value: ShareClass; label: string }[] = [
{ value: 'ORDINARY', label: 'Ordinary' },
{ value: 'PREFERENCE', label: 'Preference' }
];
// Per-client caches so accounts sharing the same client (a common case — one
// member can hold several share accounts) only fetch that client once.
private clientDataCache = new Map<string, Observable<any>>();
private clientIdentifiersCache = new Map<string, Observable<any>>();
ngOnInit(): void {
this.loadRealAccounts();
}
private loadRealAccounts(): void {
this.loading = true;
this.loadError = false;
this.clientDataCache.clear();
this.clientIdentifiersCache.clear();
this.sharesService
.getAllShareAccounts(0, 200)
.pipe(
map((page: any) => page?.pageItems || page?.content || (Array.isArray(page) ? page : [])),
switchMap((items: any[]) => {
if (!items.length) {
return of([] as RealShareAccountRow[]);
}
return forkJoin(items.map((item) => this.buildRow(item))).pipe(
map((rows) => rows.filter((r): r is RealShareAccountRow => !!r))
);
}),
catchError(() => {
this.loadError = true;
return of([] as RealShareAccountRow[]);
})
)
.subscribe((rows) => {
this.rows = rows;
this.loading = false;
});
}
/**
* The share-account list already carries clientId/clientName (same mapper as the
* single-account read), so the account detail call and the client calls can fire
* together instead of waiting on each other — halving the round trips on the
* critical path.
*/
private buildRow(listItem: any): Observable<RealShareAccountRow | null> {
const clientId = listItem?.clientId != null ? String(listItem.clientId) : null;
return forkJoin({
sa: this.sharesService.getSharesAccountData(String(listItem.id), false),
client: clientId ? this.getClientDataCached(clientId) : of(null),
identifiers: clientId ? this.getClientIdentifiersCached(clientId) : of([])
}).pipe(
switchMap(({ sa, client, identifiers }) => {
// Defensive fallback if the list payload didn't carry clientId for some reason.
if (clientId || !sa?.clientId) {
return of({ sa, client, identifiers });
}
const fallbackClientId = String(sa.clientId);
return forkJoin({
sa: of(sa),
client: this.getClientDataCached(fallbackClientId),
identifiers: this.getClientIdentifiersCached(fallbackClientId)
});
}),
map(({ sa, client, identifiers }: any) => ({
record: this.mapToRecord(sa, client, identifiers),
sharesAccountData: sa,
clientId: String(sa.clientId)
})),
catchError(() => of(null))
);
}
private getClientDataCached(clientId: string): Observable<any> {
if (!this.clientDataCache.has(clientId)) {
this.clientDataCache.set(
clientId,
this.clientsService.getClientData(clientId).pipe(
catchError(() => of(null)),
shareReplay(1)
)
);
}
return this.clientDataCache.get(clientId)!;
}
private getClientIdentifiersCached(clientId: string): Observable<any> {
if (!this.clientIdentifiersCache.has(clientId)) {
this.clientIdentifiersCache.set(
clientId,
this.clientsService.getClientIdentifiers(clientId).pipe(
catchError(() => of([])),
shareReplay(1)
)
);
}
return this.clientIdentifiersCache.get(clientId)!;
}
private mapToRecord(sa: any, client: any, identifiers: any): ShareCertificateRecord {
const approvedShares = sa.summary?.totalApprovedShares || 0;
const faceValue = sa.currentMarketPrice || 0;
const issueDate = this.toIsoDate(sa.timeline?.activatedDate) || this.toIsoDate(sa.timeline?.approvedDate) || '';
const joiningDate =
this.toIsoDate(client?.activationDate) || this.toIsoDate(client?.timeline?.submittedOnDate) || issueDate;
const startNum = sa.id * 5000 + 201;
const endNum = startNum + approvedShares - 1;
const idList = Array.isArray(identifiers) ? identifiers : identifiers?.pageItems || [];
const panId = idList.find((i: any) => /pan/i.test(i?.documentType?.name || ''));
const memberPan = panId ? panId.documentKey : '—';
const dividendYtd = (sa.dividends || []).reduce((sum: number, d: any) => sum + (d.amount || 0), 0);
return {
certificateNo: `SC-2026-${sa.accountNo || String(sa.id).padStart(6, '0')}`,
folioNo: `F-${client?.accountNo || String(client?.id ?? sa.clientId).padStart(5, '0')}`,
memberId: client?.accountNo || '',
memberName: sa.clientName || client?.displayName || '',
memberPan,
// Address isn't shown in this table — fetched on demand when a PDF is downloaded.
memberAddress: '',
branch: client?.officeName || '',
joiningDate,
shareClass: /prefer/i.test(sa.productName || '') ? 'PREFERENCE' : 'ORDINARY',
shareCount: approvedShares,
faceValue,
issueDate,
shareNoFrom: startNum,
shareNoTo: approvedShares > 0 ? endNum : startNum,
dividendYtd
};
}
private toIsoDate(value: any): string {
if (!value) {
return '';
}
if (Array.isArray(value)) {
const [
y,
m,
d
] = value;
if (!y) {
return '';
}
return `${y}-${String(m || 1).padStart(2, '0')}-${String(d || 1).padStart(2, '0')}`;
}
return String(value);
}
private formatAddress(addr: any): string {
if (!addr) {
return '';
}
return [
addr.addressLine1,
addr.addressLine2,
addr.townVillage,
addr.city,
addr.stateName,
addr.countryName,
addr.postalCode
]
.filter((p) => !!p && String(p).trim())
.join(', ');
}
// ── Derived list (identical shape to the demo table above) ──
get filteredRows(): RealShareAccountRow[] {
const q = this.search.trim().toLowerCase();
const branch = this.branchFilter.trim().toLowerCase();
return this.rows.filter(({ record: r }) => {
if (this.classFilter && r.shareClass !== this.classFilter) return false;
if (branch && !r.branch.toLowerCase().includes(branch)) return false;
if (!q) return true;
return (
r.certificateNo.toLowerCase().includes(q) ||
r.folioNo.toLowerCase().includes(q) ||
r.memberId.toLowerCase().includes(q) ||
r.memberName.toLowerCase().includes(q) ||
r.memberPan.toLowerCase().includes(q)
);
});
}
get branchOptions(): string[] {
return Array.from(new Set(this.rows.map((row) => row.record.branch).filter(Boolean))).sort();
}
classLabel(c: ShareClass): string {
return this.classOptions.find((o) => o.value === c)?.label || c;
}
totalShares(): number {
return this.filteredRows.reduce((s, { record: r }) => s + r.shareCount, 0);
}
totalShareValue(): number {
return this.filteredRows.reduce((s, { record: r }) => s + r.shareCount * r.faceValue, 0);
}
clearFilters(): void {
this.search = '';
this.classFilter = '';
this.branchFilter = '';
}
retry(): void {
this.loadRealAccounts();
}
/** Opens the same certificate dialog used from Client → Share Account → Share Certificate. */
viewCertificate(row: RealShareAccountRow): void {
this.dialog.open(ShareCertificateDialogComponent, {
data: {
sharesAccountData: row.sharesAccountData,
clientId: row.clientId
},
width: '850px',
maxWidth: '95vw'
});
}
/** Downloads the certificate as a PDF. Address is fetched here (only place it's needed). */
downloadCertificate(row: RealShareAccountRow): void {
this.clientsService
.getClientAddressData(row.clientId)
.pipe(catchError(() => of([])))
.subscribe((addresses: any) => {
const addrList = Array.isArray(addresses) ? addresses : [];
const permanent = addrList.find((a: any) => /permanent/i.test(a?.addressType || '')) || addrList[0];
const r: ShareCertificateRecord = {
...row.record,
memberAddress: permanent ? this.formatAddress(permanent) : row.record.memberAddress
};
this.dscSign.promptThenDownload(
{
title: 'Share Certificate',
reference: `${r.certificateNo} · Folio ${r.folioNo} · ${r.memberName}`,
filename: `Share-Certificate-${r.certificateNo}.pdf`
},
(dsc) => downloadShareCertificatePdf(r, dsc)
);
});
}
}
@@ -14,151 +14,184 @@
<div> <div>
<h2 class="cert-head-t">Share Certificate</h2> <h2 class="cert-head-t">Share Certificate</h2>
<div class="cert-head-s"> <div class="cert-head-s">
Issue, preview and download member share certificates with folio + share-number range. @if (viewMode === 'demo') {
Issue, preview and download member share certificates with folio + share-number range.
} @else {
Live holder details and certificates, sourced from m_share_account.
}
</div> </div>
</div> </div>
</div> </div>
<div class="cert-head-r">
@if (viewMode === 'demo') {
<button
type="button"
class="cert-cta-btn"
(click)="toggleViewMode()"
matTooltip="Same fields and certificate, using real m_share_account data"
>
<fa-icon icon="database"></fa-icon> Real Share Accounts
</button>
} @else {
<button
type="button"
class="cert-cta-btn cert-cta-btn--muted"
(click)="toggleViewMode()"
matTooltip="Back to the sample data page"
>
<fa-icon icon="table"></fa-icon> Sample Data
</button>
}
</div>
</div> </div>
@if (viewMode === 'real') {
<mifosx-real-share-accounts-view></mifosx-real-share-accounts-view>
}
<!-- KPI STRIP --> <!-- KPI STRIP -->
<div class="cert-kpis"> @if (viewMode === 'demo') {
<div class="cert-kpi cert-kpi--blue"> <div class="cert-kpis">
<fa-icon icon="certificate" class="cert-kpi-ic"></fa-icon> <div class="cert-kpi cert-kpi--blue">
<div> <fa-icon icon="certificate" class="cert-kpi-ic"></fa-icon>
<div class="cert-kpi-n">{{ filteredRecords.length | number }}</div> <div>
<div class="cert-kpi-l">Certificates</div> <div class="cert-kpi-n">{{ filteredRecords.length | number }}</div>
<div class="cert-kpi-l">Certificates</div>
</div>
</div> </div>
</div> <div class="cert-kpi cert-kpi--teal">
<div class="cert-kpi cert-kpi--teal"> <fa-icon icon="cube" class="cert-kpi-ic"></fa-icon>
<fa-icon icon="cube" class="cert-kpi-ic"></fa-icon> <div>
<div> <div class="cert-kpi-n">{{ totalShares() | number }}</div>
<div class="cert-kpi-n">{{ totalShares() | number }}</div> <div class="cert-kpi-l">Shares Held</div>
<div class="cert-kpi-l">Shares Held</div> </div>
</div> </div>
</div> <div class="cert-kpi cert-kpi--green">
<div class="cert-kpi cert-kpi--green"> <fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon> <div>
<div> <div class="cert-kpi-n">₹{{ totalShareValue() | number: '1.0-0' }}</div>
<div class="cert-kpi-n">₹{{ totalShareValue() | number: '1.0-0' }}</div> <div class="cert-kpi-l">Total Face Value</div>
<div class="cert-kpi-l">Total Face Value</div> </div>
</div>
</div>
</div>
<!-- FILTERS -->
<div class="cert-filters">
<div class="cert-filter">
<label>Certificate / Folio / Member / PAN</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="text" [(ngModel)]="search" placeholder="e.g. SC-2024-000123 or member name" />
</div> </div>
</div> </div>
<div class="cert-filter"> <!-- FILTERS -->
<label>Share class</label> <div class="cert-filters">
<select [(ngModel)]="classFilter"> <div class="cert-filter">
<option value="">All classes</option> <label>Certificate / Folio / Member / PAN</label>
@for (o of classOptions; track o.value) { <div class="cert-input-ic">
<option [value]="o.value">{{ o.label }}</option> <fa-icon icon="search"></fa-icon>
} <input type="text" [(ngModel)]="search" placeholder="e.g. SC-2024-000123 or member name" />
</select> </div>
</div> </div>
<div class="cert-filter"> <div class="cert-filter">
<label>Branch</label> <label>Share class</label>
<select [(ngModel)]="branchFilter"> <select [(ngModel)]="classFilter">
<option value="">All branches</option> <option value="">All classes</option>
@for (b of branchOptions; track b) { @for (o of classOptions; track o.value) {
<option [value]="b">{{ b }}</option> <option [value]="o.value">{{ o.label }}</option>
}
</select>
</div>
@if (search || classFilter || branchFilter) {
<button type="button" class="cert-clear" (click)="clearFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
}
</div>
<!-- TABLE -->
<div class="cert-card">
<div class="cert-table-wrap">
<table class="cert-table">
<thead>
<tr>
<th>Member · Folio</th>
<th>Certificate No.</th>
<th>Class</th>
<th>Branch</th>
<th class="num">Shares</th>
<th class="num">Face Value</th>
<th class="num">Total</th>
<th>Share Nos.</th>
<th>Issued</th>
<th class="col-actions">Actions</th>
</tr>
</thead>
<tbody>
@for (r of filteredRecords; track r.certificateNo) {
<tr>
<td>
<div class="cert-cust-name">{{ r.memberName }}</div>
<div class="mono small muted">
{{ r.memberId }} · Folio <span class="cert-folio">{{ r.folioNo }}</span> · PAN {{ r.memberPan }}
</div>
</td>
<td class="mono">{{ r.certificateNo }}</td>
<td>
<span
class="cert-pill"
[class.cert-pill--blue]="r.shareClass === 'ORDINARY'"
[class.cert-pill--amber]="r.shareClass === 'PREFERENCE'"
>
{{ classLabel(r.shareClass) }}
</span>
</td>
<td class="small">{{ r.branch }}</td>
<td class="num">
<strong class="cert-shares">{{ r.shareCount | number }}</strong>
</td>
<td class="num">₹{{ r.faceValue | number: '1.0-0' }}</td>
<td class="num">
<strong>₹{{ r.shareCount * r.faceValue | number: '1.0-0' }}</strong>
</td>
<td class="mono small">{{ r.shareNoFrom }}&ndash;{{ r.shareNoTo }}</td>
<td class="small">{{ r.issueDate | date: 'd MMM y' }}</td>
<td class="col-actions">
<div class="cert-row-actions">
<button
type="button"
class="cert-icon-btn primary"
(click)="viewCertificate(r)"
matTooltip="View / Print certificate"
>
<fa-icon icon="eye"></fa-icon>
</button>
<button
type="button"
class="cert-icon-btn"
(click)="downloadCertificate(r)"
matTooltip="Download PDF"
>
<fa-icon icon="download"></fa-icon>
</button>
</div>
</td>
</tr>
} }
@if (filteredRecords.length === 0) { </select>
<tr> </div>
<td colspan="10" class="cert-empty">
<fa-icon icon="info-circle"></fa-icon> <div class="cert-filter">
No share certificates match the current filters. <label>Branch</label>
</td> <select [(ngModel)]="branchFilter">
</tr> <option value="">All branches</option>
@for (b of branchOptions; track b) {
<option [value]="b">{{ b }}</option>
} }
</tbody> </select>
</table> </div>
@if (search || classFilter || branchFilter) {
<button type="button" class="cert-clear" (click)="clearFilters()">
<fa-icon icon="times"></fa-icon> Clear
</button>
}
</div> </div>
</div>
<!-- TABLE -->
<div class="cert-card">
<div class="cert-table-wrap">
<table class="cert-table">
<thead>
<tr>
<th>Member · Folio</th>
<th>Certificate No.</th>
<th>Class</th>
<th>Branch</th>
<th class="num">Shares</th>
<th class="num">Face Value</th>
<th class="num">Total</th>
<th>Share Nos.</th>
<th>Issued</th>
<th class="col-actions">Actions</th>
</tr>
</thead>
<tbody>
@for (r of filteredRecords; track r.certificateNo) {
<tr>
<td>
<div class="cert-cust-name">{{ r.memberName }}</div>
<div class="mono small muted">
{{ r.memberId }} · Folio <span class="cert-folio">{{ r.folioNo }}</span> · PAN {{ r.memberPan }}
</div>
</td>
<td class="mono">{{ r.certificateNo }}</td>
<td>
<span
class="cert-pill"
[class.cert-pill--blue]="r.shareClass === 'ORDINARY'"
[class.cert-pill--amber]="r.shareClass === 'PREFERENCE'"
>
{{ classLabel(r.shareClass) }}
</span>
</td>
<td class="small">{{ r.branch }}</td>
<td class="num">
<strong class="cert-shares">{{ r.shareCount | number }}</strong>
</td>
<td class="num">₹{{ r.faceValue | number: '1.0-0' }}</td>
<td class="num">
<strong>₹{{ r.shareCount * r.faceValue | number: '1.0-0' }}</strong>
</td>
<td class="mono small">{{ r.shareNoFrom }}&ndash;{{ r.shareNoTo }}</td>
<td class="small">{{ r.issueDate | date: 'd MMM y' }}</td>
<td class="col-actions">
<div class="cert-row-actions">
<button
type="button"
class="cert-icon-btn primary"
(click)="viewCertificate(r)"
matTooltip="View / Print certificate"
>
<fa-icon icon="eye"></fa-icon>
</button>
<button
type="button"
class="cert-icon-btn"
(click)="downloadCertificate(r)"
matTooltip="Download PDF"
>
<fa-icon icon="download"></fa-icon>
</button>
</div>
</td>
</tr>
}
@if (filteredRecords.length === 0) {
<tr>
<td colspan="10" class="cert-empty">
<fa-icon icon="info-circle"></fa-icon>
No share certificates match the current filters.
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
</div> </div>
@@ -21,6 +21,7 @@ import {
} from '../certificate-preview-dialog/certificate-preview-dialog.component'; } from '../certificate-preview-dialog/certificate-preview-dialog.component';
import { downloadShareCertificatePdf } from '../certificate-pdf'; import { downloadShareCertificatePdf } from '../certificate-pdf';
import { DscSignService } from '../../dsc-sign-dialog/dsc-sign.service'; import { DscSignService } from '../../dsc-sign-dialog/dsc-sign.service';
import { RealShareAccountsViewComponent } from './real-share-accounts-view/real-share-accounts-view.component';
export type ShareClass = 'ORDINARY' | 'PREFERENCE'; export type ShareClass = 'ORDINARY' | 'PREFERENCE';
@@ -53,7 +54,8 @@ export interface ShareCertificateRecord {
CommonModule, CommonModule,
FormsModule, FormsModule,
FaIconComponent, FaIconComponent,
MatTooltip MatTooltip,
RealShareAccountsViewComponent
] ]
}) })
export class ShareCertificateComponent implements OnInit { export class ShareCertificateComponent implements OnInit {
@@ -61,6 +63,9 @@ export class ShareCertificateComponent implements OnInit {
private snack = inject(MatSnackBar); private snack = inject(MatSnackBar);
private dscSign = inject(DscSignService); private dscSign = inject(DscSignService);
/** Toggles between the static sample table and the live m_share_account view. */
viewMode: 'demo' | 'real' = 'demo';
// ── Filters ── // ── Filters ──
search = ''; search = '';
classFilter: '' | ShareClass = ''; classFilter: '' | ShareClass = '';
@@ -143,6 +148,11 @@ export class ShareCertificateComponent implements OnInit {
(dsc) => downloadShareCertificatePdf(r, dsc) (dsc) => downloadShareCertificatePdf(r, dsc)
); );
} }
/** Switches between the static sample table and the live m_share_account view. */
toggleViewMode(): void {
this.viewMode = this.viewMode === 'demo' ? 'real' : 'demo';
}
} }
// ───────────────────── Mock seed (top-level export) ───────────────────── // ───────────────────── Mock seed (top-level export) ─────────────────────
@@ -7,6 +7,9 @@
--> -->
<div class="sav-view"> <div class="sav-view">
<!-- ─── Dormancy risk alert (only when subStatus has left Active) ──── -->
<mifosx-dormancy-alert-panel [accountData]="savingsAccountData"></mifosx-dormancy-alert-panel>
<!-- ─── Gradient hero header ─────────────────────────────────────── --> <!-- ─── Gradient hero header ─────────────────────────────────────── -->
<header class="sav-hero"> <header class="sav-hero">
<div class="sav-hero__left"> <div class="sav-hero__left">
@@ -27,6 +27,7 @@ import { TranslateService } from '@ngx-translate/core';
import { AccountsService } from 'app/accounts/accounts.service'; import { AccountsService } from 'app/accounts/accounts.service';
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 { AuthenticationService } from '../../core/authentication/authentication.service';
/** Environment Configuration */ /** Environment Configuration */
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
@@ -41,6 +42,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatTabNav, MatTabLink, MatTabNavPanel } from '@angular/material/tabs'; import { MatTabNav, MatTabLink, MatTabNavPanel } from '@angular/material/tabs';
import { StatusLookupPipe } from '../../pipes/status-lookup.pipe'; import { StatusLookupPipe } from '../../pipes/status-lookup.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DormancyAlertPanelComponent } from 'app/shared/dormancy-alert-panel/dormancy-alert-panel.component';
/** /**
* Savings Account View Component * Savings Account View Component
@@ -70,7 +72,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatTabNavPanel, MatTabNavPanel,
RouterOutlet, RouterOutlet,
CurrencyPipe, CurrencyPipe,
StatusLookupPipe StatusLookupPipe,
DormancyAlertPanelComponent
] ]
}) })
export class SavingsAccountViewComponent implements OnInit { export class SavingsAccountViewComponent implements OnInit {
@@ -81,6 +84,7 @@ export class SavingsAccountViewComponent implements OnInit {
private accountsService = inject(AccountsService); private accountsService = inject(AccountsService);
private settingsService = inject(SettingsService); private settingsService = inject(SettingsService);
private dateUtils = inject(Dates); private dateUtils = inject(Dates);
private authenticationService = inject(AuthenticationService);
dialog = inject(MatDialog); dialog = inject(MatDialog);
/** Savings Account Data */ /** Savings Account Data */
@@ -268,6 +272,14 @@ export class SavingsAccountViewComponent implements OnInit {
}); });
} }
} }
if (!this.authenticationService.hasCashierRole()) {
this.buttonConfig.buttonsArray = this.buttonConfig.buttonsArray.filter(
(btn) => btn.name !== 'Deposit' && btn.name !== 'Withdrawal'
);
this.buttonConfig.optionArray = this.buttonConfig.optionArray.filter(
(opt) => opt.name !== 'Deposit' && opt.name !== 'Withdrawal'
);
}
} }
/** /**
@@ -195,6 +195,9 @@
@if (statusId >= 100) { @if (statusId >= 100) {
<span class="dap__reached"><i class="fas fa-flag"></i> Threshold Reached</span> <span class="dap__reached"><i class="fas fa-flag"></i> Threshold Reached</span>
<span class="dap__card-hint">Account is inactive</span> <span class="dap__card-hint">Account is inactive</span>
} @else if (isOverdue(daysToInactive)) {
<span class="dap__reached"><i class="fas fa-flag"></i> Overdue {{ -daysToInactive! }}d</span>
<span class="dap__card-hint">{{ inactiveSubtext() }}</span>
} @else if (daysToInactive !== null) { } @else if (daysToInactive !== null) {
<span class="dap__number" [class.dap__number--warn]="daysToInactive <= 30"> <span class="dap__number" [class.dap__number--warn]="daysToInactive <= 30">
{{ daysToInactive }}<small>days</small> {{ daysToInactive }}<small>days</small>
@@ -217,6 +220,9 @@
@if (statusId >= 200) { @if (statusId >= 200) {
<span class="dap__reached"><i class="fas fa-flag"></i> Threshold Reached</span> <span class="dap__reached"><i class="fas fa-flag"></i> Threshold Reached</span>
<span class="dap__card-hint">Account is dormant</span> <span class="dap__card-hint">Account is dormant</span>
} @else if (isOverdue(daysToDormancy)) {
<span class="dap__reached"><i class="fas fa-flag"></i> Overdue {{ -daysToDormancy! }}d</span>
<span class="dap__card-hint">{{ dormancySubtext() }}</span>
} @else if (daysToDormancy !== null) { } @else if (daysToDormancy !== null) {
<span class="dap__number" [class.dap__number--warn]="daysToDormancy <= 30"> <span class="dap__number" [class.dap__number--warn]="daysToDormancy <= 30">
{{ daysToDormancy }}<small>days</small> {{ daysToDormancy }}<small>days</small>
@@ -239,6 +245,9 @@
@if (statusId >= 300) { @if (statusId >= 300) {
<span class="dap__reached"><i class="fas fa-flag"></i> Threshold Reached</span> <span class="dap__reached"><i class="fas fa-flag"></i> Threshold Reached</span>
<span class="dap__card-hint">Pending regulatory transfer</span> <span class="dap__card-hint">Pending regulatory transfer</span>
} @else if (isOverdue(daysToEscheat)) {
<span class="dap__reached"><i class="fas fa-flag"></i> Overdue {{ -daysToEscheat! }}d</span>
<span class="dap__card-hint">{{ escheatSubtext() }}</span>
} @else if (daysToEscheat !== null) { } @else if (daysToEscheat !== null) {
<span class="dap__number" [class.dap__number--warn]="daysToEscheat <= 30"> <span class="dap__number" [class.dap__number--warn]="daysToEscheat <= 30">
{{ daysToEscheat }}<small>days</small> {{ daysToEscheat }}<small>days</small>
@@ -295,7 +304,9 @@
{{ step.label }} {{ step.label }}
<i class="fas fa-info-circle dap__tl-info"></i> <i class="fas fa-info-circle dap__tl-info"></i>
</span> </span>
@if (!step.reached && step.days !== null) { @if (!step.reached && isOverdue(step.days)) {
<span class="dap__tl-reached-tag">Overdue {{ -step.days! }}d</span>
} @else if (!step.reached && step.days !== null) {
<span class="dap__tl-days">{{ step.days }}d</span> <span class="dap__tl-days">{{ step.days }}d</span>
} @else if (step.reached) { } @else if (step.reached) {
<span class="dap__tl-reached-tag">Reached</span> <span class="dap__tl-reached-tag">Reached</span>
@@ -113,12 +113,10 @@ export class DormancyAlertPanelComponent implements OnInit {
} }
this.computeRiskFromAccount(); this.computeRiskFromAccount();
this.shouldShow = // Only surface this panel once the account has actually left "Active" —
this.isAlertEnabled || // i.e. subStatus is Inactive/Dormant/Escheat. A healthy active account
this.isMonitoringActive || // (statusId === 0) should never show a danger-colored dormancy alert.
this.statusId > 0 || this.shouldShow = this.statusId > 0;
(this.daysToInactive !== null && this.daysToInactive <= 90) ||
!!this.lastTxnDate;
} }
private computeRiskFromAccount(): void { private computeRiskFromAccount(): void {
@@ -179,17 +177,36 @@ export class DormancyAlertPanelComponent implements OnInit {
]; ];
} }
/** e.g. "Inactive after 30 days of no transaction" */ /** true when the threshold date has already passed but the batch job hasn't reclassified the account yet */
isOverdue(days: number | null): boolean {
return days !== null && days < 0;
}
/** e.g. "11d" when still counting down, "Overdue 5d" when the threshold date already passed */
daysLabel(days: number | null): string {
if (days === null) return '';
return this.isOverdue(days) ? `Overdue ${Math.abs(days)}d` : `${days}d`;
}
/** e.g. "Moves Active → Inactive after 30 days" */
inactiveSubtext(): string { inactiveSubtext(): string {
return this.daysToInactive !== null ? `No transaction for ${this.daysToInactive} days` : 'Threshold not configured'; if (this.daysToInactive === null) return 'Threshold not configured';
return this.isOverdue(this.daysToInactive)
? `Active → Inactive was due ${Math.abs(this.daysToInactive)} days ago`
: `Moves Active → Inactive after ${this.daysToInactive} days`;
} }
dormancySubtext(): string { dormancySubtext(): string {
const total = (this.daysToInactive ?? 0) + (this.daysToDormancy ?? 0); if (this.daysToDormancy === null) return 'Threshold not configured';
return this.daysToDormancy !== null ? `${this.daysToDormancy} days after inactive` : 'Threshold not configured'; return this.isOverdue(this.daysToDormancy)
? `Inactive → Dormant was due ${Math.abs(this.daysToDormancy)} days ago`
: `Moves Inactive → Dormant after ${this.daysToDormancy} days`;
} }
escheatSubtext(): string { escheatSubtext(): string {
return this.daysToEscheat !== null ? `${this.daysToEscheat} days after dormant` : 'Threshold not configured'; if (this.daysToEscheat === null) return 'Threshold not configured';
return this.isOverdue(this.daysToEscheat)
? `Dormant → Escheat was due ${Math.abs(this.daysToEscheat)} days ago`
: `Moves Dormant → Escheat after ${this.daysToEscheat} days`;
} }
} }
@@ -0,0 +1,93 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
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/.
-->
<div class="container">
@if (hasPendingEdit) {
<div class="mat-elevation-z1" style="padding: 12px 16px; margin-bottom: 16px; background-color: #fff8e1">
<fa-icon icon="lock" class="m-r-10"></fa-icon>
An edit submitted by {{ pendingEdit.makerUsername }} is already pending approval. You cannot submit another edit
until it is resolved.
</div>
}
<mat-stepper
orientation="vertical"
[linear]="false"
class="mat-elevation-z8"
labelPosition="bottom"
#shareProductStepper
>
<ng-template matStepperIcon="number">
<fa-icon icon="pencil-alt" size="sm"></fa-icon>
</ng-template>
<ng-template matStepperIcon="edit">
<fa-icon icon="pencil-alt" size="sm"></fa-icon>
</ng-template>
<ng-template matStepperIcon="done">
<fa-icon icon="check" size="sm"></fa-icon>
</ng-template>
<ng-template matStepperIcon="error">
<fa-icon icon="exclamation-triangle" size="lg"></fa-icon>
</ng-template>
<ng-template matStepperIcon="preview">
<fa-icon icon="eye" size="sm"></fa-icon>
</ng-template>
<mat-step [stepControl]="sharesAccountDetailsForm">
<ng-template matStepLabel>{{ 'labels.inputs.DETAILS' | translate }}</ng-template>
<mifosx-shares-account-details-step
[sharesAccountTemplate]="sharesAccountAndTemplate"
[approvalEditMode]="true"
(sharesAccountProductTemplate)="setTemplate($event)"
>
</mifosx-shares-account-details-step>
</mat-step>
<mat-step [stepControl]="sharesAccountTermsForm">
<ng-template matStepLabel>{{ 'labels.inputs.TERMS' | translate }}</ng-template>
<mifosx-shares-account-terms-step
[sharesAccountProductTemplate]="sharesAccountProductTemplate"
[sharesAccountTemplate]="sharesAccountAndTemplate"
[approvalEditMode]="true"
>
</mifosx-shares-account-terms-step>
</mat-step>
<mat-step>
<ng-template matStepLabel>{{ 'labels.inputs.CHARGES' | translate }}</ng-template>
<mifosx-shares-account-charges-step
[sharesAccountProductTemplate]="sharesAccountProductTemplate"
[sharesAccountTemplate]="sharesAccountAndTemplate"
[currencyCode]="sharesAccountTermsForm.get('currencyCode')"
[approvalEditMode]="true"
>
</mifosx-shares-account-charges-step>
</mat-step>
@if (sharesAccountFormValidAndNotPristine) {
<mat-step completed>
<ng-template matStepLabel>{{ 'labels.inputs.PREVIEW' | translate }}</ng-template>
<mifosx-shares-account-preview-step
[sharesAccountProductTemplate]="sharesAccountProductTemplate"
[sharesAccountTemplate]="sharesAccountAndTemplate"
[sharesAccountTermsForm]="sharesAccountTermsForm"
[sharesAccount]="sharesAccount"
(submitEvent)="submit()"
>
</mifosx-shares-account-preview-step>
</mat-step>
}
</mat-stepper>
</div>
@@ -0,0 +1,170 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* 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/.
*/
/** Angular Imports */
import { Component, ViewChild, inject } from '@angular/core';
import { Router, ActivatedRoute } from '@angular/router';
import { MatStepper, MatStepperIcon, MatStep, MatStepLabel } from '@angular/material/stepper';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Components */
import { SharesAccountDetailsStepComponent } from '../shares-account-stepper/shares-account-details-step/shares-account-details-step.component';
import { SharesAccountTermsStepComponent } from '../shares-account-stepper/shares-account-terms-step/shares-account-terms-step.component';
import { SharesAccountChargesStepComponent } from '../shares-account-stepper/shares-account-charges-step/shares-account-charges-step.component';
import { SharesAccountPreviewStepComponent } from '../shares-account-stepper/shares-account-preview-step/shares-account-preview-step.component';
/** Custom Services */
import { SharesService } from '../shares.service';
import { SettingsService } from 'app/settings/settings.service';
/**
* Edit Share Account Details — maker-checker submission screen.
*
* Reuses the same Details/Terms/Charges/Preview stepper as Create/Modify Application, but with
* Product, External ID, Resolution Number, Total Number of Shares, Application Date and Charges
* locked (`approvalEditMode`) - only Default Savings Account, Allow Dividends for Inactive Clients,
* Minimum Active Period and Lockin Period are actually editable and submitted. Nothing is applied
* immediately: the request is staged and a different user (checker) must approve it from the
* "Pending Share Account Edits" screen before it takes effect.
*/
@Component({
selector: 'mifosx-edit-share-account-details',
templateUrl: './edit-share-account-details.component.html',
styleUrls: ['./edit-share-account-details.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatStepper,
MatStepperIcon,
FaIconComponent,
MatStep,
MatStepLabel,
SharesAccountDetailsStepComponent,
SharesAccountTermsStepComponent,
SharesAccountChargesStepComponent,
SharesAccountPreviewStepComponent
]
})
export class EditShareAccountDetailsComponent {
private route = inject(ActivatedRoute);
private router = inject(Router);
private sharesService = inject(SharesService);
private settingsService = inject(SettingsService);
/** Shares Account and Template */
sharesAccountAndTemplate: any;
/** Shares Account Product Template */
sharesAccountProductTemplate: any;
/** Pending edit already submitted for this account, if any. */
pendingEdit: any = null;
pendingEditLoading = true;
submitting = false;
@ViewChild(SharesAccountDetailsStepComponent, { static: true })
sharesAccountDetailsStep: SharesAccountDetailsStepComponent;
@ViewChild(SharesAccountTermsStepComponent, { static: true }) sharesAccountTermsStep: SharesAccountTermsStepComponent;
@ViewChild(SharesAccountChargesStepComponent, { static: true })
sharesAccountChargesStep: SharesAccountChargesStepComponent;
constructor() {
this.route.data.subscribe((data: { sharesAccountAndTemplate: any }) => {
this.sharesAccountAndTemplate = data.sharesAccountAndTemplate;
this.loadPendingEdit();
});
}
private loadPendingEdit(): void {
this.pendingEditLoading = true;
this.sharesService.getPendingShareAccountEdit(this.sharesAccountAndTemplate.id).subscribe({
next: (edit: any) => {
this.pendingEdit = edit && edit.status === 'PENDING' ? edit : null;
this.pendingEditLoading = false;
},
error: () => {
this.pendingEdit = null;
this.pendingEditLoading = false;
}
});
}
get hasPendingEdit(): boolean {
return !!this.pendingEdit;
}
setTemplate($event: any) {
this.sharesAccountProductTemplate = $event;
}
get sharesAccountDetailsForm() {
return this.sharesAccountDetailsStep.sharesAccountDetailsForm;
}
get sharesAccountTermsForm() {
return this.sharesAccountTermsStep.sharesAccountTermsForm;
}
get sharesAccountFormValidAndNotPristine() {
return (
this.sharesAccountTermsForm.valid &&
!this.sharesAccountTermsForm.pristine &&
!this.hasPendingEdit &&
!this.pendingEditLoading
);
}
get sharesAccount() {
return {
...this.sharesAccountDetailsStep.sharesAccountDetails,
...this.sharesAccountTermsStep.sharesAccountTerms,
...this.sharesAccountChargesStep.sharesAccountCharges
};
}
/**
* Submits only the 6 fields this flow supports for approval - Product, External ID, Resolution
* Number, Total Number of Shares, Application Date and Charges are locked in the UI and are never
* part of this payload.
*/
submit() {
if (!this.sharesAccountFormValidAndNotPristine || this.submitting) {
return;
}
const locale = this.settingsService.language.code;
const terms = this.sharesAccountTermsForm.value;
const payload: any = {
locale,
dateFormat: 'yyyy-MM-dd',
savingsAccountId: terms.savingsAccountId,
allowDividendCalculationForInactiveClients: terms.allowDividendCalculationForInactiveClients
};
if (terms.minimumActivePeriod) {
payload.minimumActivePeriod = terms.minimumActivePeriod;
}
if (terms.minimumActivePeriodFrequencyType) {
payload.minimumActivePeriodFrequencyType = terms.minimumActivePeriodFrequencyType;
}
if (terms.lockinPeriodFrequency) {
payload.lockinPeriodFrequency = terms.lockinPeriodFrequency;
}
if (terms.lockinPeriodFrequencyType) {
payload.lockinPeriodFrequencyType = terms.lockinPeriodFrequencyType;
}
this.submitting = true;
this.sharesService.submitShareAccountEdit(this.sharesAccountAndTemplate.id, payload).subscribe({
next: () => {
this.submitting = false;
this.router.navigate(['../'], { relativeTo: this.route });
},
error: () => {
this.submitting = false;
}
});
}
}
@@ -7,23 +7,25 @@
--> -->
<div class="layout-row-wrap gap-2px responsive-column"> <div class="layout-row-wrap gap-2px responsive-column">
<mat-form-field class="flex-48"> @if (!approvalEditMode) {
<mat-label>{{ 'labels.inputs.Charge' | translate }}</mat-label> <mat-form-field class="flex-48">
<mat-select #charge> <mat-label>{{ 'labels.inputs.Charge' | translate }}</mat-label>
@for (charge of chargeData | chargesFilter: chargesDataSource : currencyCode.value; track charge) { <mat-select #charge>
<mat-option [value]="charge"> @for (charge of chargeData | chargesFilter: chargesDataSource : currencyCode.value; track charge) {
{{ charge.name }} <mat-option [value]="charge">
</mat-option> {{ charge.name }}
} </mat-option>
</mat-select> }
</mat-form-field> </mat-select>
</mat-form-field>
<div class="flex-48 align-center"> <div class="flex-48 align-center">
<button type="button" mat-raised-button color="primary" (click)="addCharge(charge)" [disabled]="!charge.value"> <button type="button" mat-raised-button color="primary" (click)="addCharge(charge)" [disabled]="!charge.value">
<fa-icon icon="plus" class="m-r-10"></fa-icon> <fa-icon icon="plus" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Add' | translate }} {{ 'labels.buttons.Add' | translate }}
</button> </button>
</div> </div>
}
<table <table
class="flex-98 mat-elevation-z1" class="flex-98 mat-elevation-z1"
@@ -49,9 +51,11 @@
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Amount' | translate }}</th> <th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Amount' | translate }}</th>
<td mat-cell *matCellDef="let charge"> <td mat-cell *matCellDef="let charge">
{{ charge.amount || charge.amountOrPercentage }} {{ charge.amount || charge.amountOrPercentage }}
<button mat-icon-button color="primary" (click)="editCharge(charge)"> @if (!approvalEditMode) {
<fa-icon icon="pen"></fa-icon> <button mat-icon-button color="primary" (click)="editCharge(charge)">
</button> <fa-icon icon="pen"></fa-icon>
</button>
}
</td> </td>
</ng-container> </ng-container>
@@ -65,9 +69,11 @@
<ng-container matColumnDef="action"> <ng-container matColumnDef="action">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th> <th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
<td mat-cell *matCellDef="let charge"> <td mat-cell *matCellDef="let charge">
<button mat-icon-button color="warn" (click)="deleteCharge(charge)"> @if (!approvalEditMode) {
<fa-icon icon="trash"></fa-icon> <button mat-icon-button color="warn" (click)="deleteCharge(charge)">
</button> <fa-icon icon="trash"></fa-icon>
</button>
}
</td> </td>
</ng-container> </ng-container>
@@ -73,6 +73,9 @@ export class SharesAccountChargesStepComponent implements OnInit, OnChanges {
@Input() sharesAccountTemplate: any; @Input() sharesAccountTemplate: any;
/** Currency Code */ /** Currency Code */
@Input() currencyCode: UntypedFormControl; @Input() currencyCode: UntypedFormControl;
/** When true (Share Account "Edit Details" maker-checker flow), charges are locked - they aren't
* part of that flow's editable field set. */
@Input() approvalEditMode = false;
/** Charge Data */ /** Charge Data */
chargeData: any = []; chargeData: any = [];
@@ -45,6 +45,15 @@
<mat-label>{{ 'labels.inputs.External ID' | translate }}</mat-label> <mat-label>{{ 'labels.inputs.External ID' | translate }}</mat-label>
<input matInput formControlName="externalId" /> <input matInput formControlName="externalId" />
</mat-form-field> </mat-form-field>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Resolution Number' | translate }}</mat-label>
<input matInput formControlName="resolutionNumber" required />
<mat-error>
{{ 'labels.inputs.Resolution Number' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
</div> </div>
<div class="layout-row align-center gap-2percent margin-t responsive-column"> <div class="layout-row align-center gap-2percent margin-t responsive-column">
@@ -56,7 +65,11 @@
mat-raised-button mat-raised-button
matStepperNext matStepperNext
[disabled]=" [disabled]="
!(sharesAccountDetailsForm.get('productId')?.value && sharesAccountDetailsForm.get('submittedDate')?.value) !(
sharesAccountDetailsForm.get('productId')?.value &&
sharesAccountDetailsForm.get('submittedDate')?.value &&
sharesAccountDetailsForm.get('resolutionNumber')?.value
)
" "
> >
{{ 'labels.buttons.Next' | translate }} {{ 'labels.buttons.Next' | translate }}
@@ -38,6 +38,9 @@ export class SharesAccountDetailsStepComponent implements OnInit {
/** Shares Account Template */ /** Shares Account Template */
@Input() sharesAccountTemplate: any; @Input() sharesAccountTemplate: any;
/** When true (Share Account "Edit Details" maker-checker flow), Product/Submitted On/External ID/
* Resolution Number are locked - none of them are part of that flow's editable field set. */
@Input() approvalEditMode = false;
/** Minimum date allowed. */ /** Minimum date allowed. */
minDate = new Date(2000, 0, 1); minDate = new Date(2000, 0, 1);
@@ -72,10 +75,14 @@ export class SharesAccountDetailsStepComponent implements OnInit {
submittedDate: submittedDate:
this.sharesAccountTemplate.timeline.submittedOnDate && this.sharesAccountTemplate.timeline.submittedOnDate &&
new Date(this.sharesAccountTemplate.timeline.submittedOnDate), new Date(this.sharesAccountTemplate.timeline.submittedOnDate),
externalId: this.sharesAccountTemplate.externalId externalId: this.sharesAccountTemplate.externalId,
resolutionNumber: this.sharesAccountTemplate.resolutionNumber
}); });
} }
} }
if (this.approvalEditMode) {
this.sharesAccountDetailsForm.disable();
}
} }
/** /**
@@ -91,7 +98,11 @@ export class SharesAccountDetailsStepComponent implements OnInit {
'', '',
Validators.required Validators.required
], ],
externalId: [''] externalId: [''],
resolutionNumber: [
'',
Validators.required
]
}); });
} }
@@ -36,6 +36,11 @@
} }
</div> </div>
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Resolution Number' | translate }}</span>
<span class="flex-60">{{ sharesAccount.resolutionNumber }}</span>
</div>
<h3 class="mat-h3 flex-fill">{{ 'labels.heading.Terms' | translate }}</h3> <h3 class="mat-h3 flex-fill">{{ 'labels.heading.Terms' | translate }}</h3>
<mat-divider class="flex-fill"></mat-divider> <mat-divider class="flex-fill"></mat-divider>
@@ -41,6 +41,10 @@ export class SharesAccountTermsStepComponent implements OnChanges, OnInit {
@Input() sharesAccountProductTemplate: any; @Input() sharesAccountProductTemplate: any;
/** [Optional] Shares Account Template */ /** [Optional] Shares Account Template */
@Input() sharesAccountTemplate: any; @Input() sharesAccountTemplate: any;
/** When true (Share Account "Edit Details" maker-checker flow), only Default Savings Account, Allow
* Dividends, Minimum Active Period and Lockin Period stay editable - Total Number of Shares and
* Application Date are transactional/historical and aren't part of that flow's editable field set. */
@Input() approvalEditMode = false;
/** Minimum date allowed. */ /** Minimum date allowed. */
minDate = new Date(2000, 0, 1); minDate = new Date(2000, 0, 1);
@@ -105,6 +109,10 @@ export class SharesAccountTermsStepComponent implements OnChanges, OnInit {
this.sharesAccountTemplate.allowDividendCalculationForInactiveClients this.sharesAccountTemplate.allowDividendCalculationForInactiveClients
}); });
} }
if (this.approvalEditMode) {
this.sharesAccountTermsForm.get('requestedShares').disable();
this.sharesAccountTermsForm.get('applicationDate').disable();
}
} }
/** /**
@@ -7,6 +7,13 @@
--> -->
<div class="tab-container mat-typography"> <div class="tab-container mat-typography">
@if (pendingEdit) {
<div class="mat-elevation-z1" style="padding: 12px 16px; margin-bottom: 16px; background-color: #fff8e1">
<fa-icon icon="lock" class="m-r-10"></fa-icon>
A details edit submitted by {{ pendingEdit.makerUsername }} is awaiting checker approval. You cannot submit
another edit until it is resolved.
</div>
}
<div class="shares-account-tables layout-row gap-2percent"> <div class="shares-account-tables layout-row gap-2percent">
<div class="flex-49"> <div class="flex-49">
<h4 class="table-headers">{{ 'labels.heading.Shares Details' | translate }}</h4> <h4 class="table-headers">{{ 'labels.heading.Shares Details' | translate }}</h4>
@@ -13,6 +13,8 @@ import { AccountNumberComponent } from '../../../shared/account-number/account-n
import { DateFormatPipe } from '../../../pipes/date-format.pipe'; import { DateFormatPipe } from '../../../pipes/date-format.pipe';
import { FormatNumberPipe } from '../../../pipes/format-number.pipe'; import { FormatNumberPipe } from '../../../pipes/format-number.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { SharesService } from '../../shares.service';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
@Component({ @Component({
selector: 'mifosx-general-tab', selector: 'mifosx-general-tab',
@@ -23,15 +25,20 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
ExternalIdentifierComponent, ExternalIdentifierComponent,
AccountNumberComponent, AccountNumberComponent,
DateFormatPipe, DateFormatPipe,
FormatNumberPipe FormatNumberPipe,
FaIconComponent
] ]
}) })
export class GeneralTabComponent { export class GeneralTabComponent {
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private sharesService = inject(SharesService);
/** Shares Account Data */ /** Shares Account Data */
sharesAccountData: any; sharesAccountData: any;
/** Pending details edit awaiting checker approval, if any. */
pendingEdit: any = null;
/** /**
* Fetches shares account data from `resolve` * Fetches shares account data from `resolve`
* @param {ActivatedRoute} route Activated Route * @param {ActivatedRoute} route Activated Route
@@ -39,6 +46,14 @@ export class GeneralTabComponent {
constructor() { constructor() {
this.route.data.subscribe((data: { sharesAccountData: any }) => { this.route.data.subscribe((data: { sharesAccountData: any }) => {
this.sharesAccountData = data.sharesAccountData; this.sharesAccountData = data.sharesAccountData;
this.sharesService.getPendingShareAccountEdit(this.sharesAccountData.id).subscribe({
next: (edit: any) => {
this.pendingEdit = edit && edit.status === 'PENDING' ? edit : null;
},
error: () => {
this.pendingEdit = null;
}
});
}); });
} }
} }
@@ -0,0 +1,139 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
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/.
-->
<h2 mat-dialog-title class="dialog-header no-print">
<span>Share Certificate View</span>
<span class="flex-auto"></span>
</h2>
<div mat-dialog-content class="dialog-content-container">
<!-- Certificate Page Frame -->
<div class="certificate-container" id="share-certificate-print">
<div class="certificate-border-outer">
<div class="certificate-border-inner">
<!-- Certificate Watermark Icon/Seal -->
<div class="certificate-watermark">
<mat-icon class="watermark-icon">verified_user</mat-icon>
</div>
<div class="certificate-header">
<div class="bank-name">Nexora Co-operative Bank Ltd.</div>
<div class="bank-sub">Mumbai, Maharashtra, India</div>
<div class="cert-title">SHARE CERTIFICATE</div>
<div class="cert-subtitle">Issued under the Common Seal of the Bank</div>
</div>
<div class="cert-meta-row">
<div class="meta-item">
<span class="meta-label">Certificate No.</span>
<span class="meta-value">{{ certificateNo }}</span>
</div>
<div class="meta-item">
<span class="meta-label">Folio No.</span>
<span class="meta-value">{{ folioNo }}</span>
</div>
<div class="meta-item">
<span class="meta-label">Date of Issue</span>
<span class="meta-value">{{ issueDate | dateFormat }}</span>
</div>
</div>
<div class="cert-body-section">
<p class="cert-main-text">
This is to certify that <strong class="highlight-text">{{ sharesAccountData.clientName }}</strong> is the
registered holder of
<strong class="highlight-text">{{ sharesAccountData.summary?.totalApprovedShares | formatNumber }}</strong>
({{ sharesAccountData.productName || 'Ordinary' }}) shares of
<strong class="highlight-text">{{
sharesAccountData.currentMarketPrice | currency: sharesAccountData.currency.code : 'symbol-narrow'
}}</strong>
each, fully paid up, in <strong class="highlight-text">Nexora Co-operative Bank Ltd.</strong>, subject to
the memorandum, articles of association, and bye-laws of the bank.
</p>
<div class="issued-to-details">
<div class="issued-to-title">ISSUED TO:</div>
<div class="client-name-details">{{ sharesAccountData.clientName }}</div>
<div class="client-ids">
<span *ngIf="memberId"><strong>Member ID:</strong> {{ memberId }}</span>
<span *ngIf="panNumber && panNumber !== 'Not Available'">
&middot; <strong>PAN:</strong> {{ panNumber }}</span
>
</div>
<div class="client-address" *ngIf="clientAddress">
{{ clientAddress }}
</div>
</div>
</div>
<!-- Details Grid -->
<table class="certificate-details-table">
<tbody>
<tr>
<td class="lbl-col">Branch</td>
<td class="val-col">{{ clientData?.officeName || 'Mumbai Main Branch' }}</td>
<td class="lbl-col">Member Since</td>
<td class="val-col">
{{
(clientData?.activationDate
? (clientData.activationDate | dateFormat)
: (clientData?.timeline?.submittedOnDate | dateFormat)) || 'N/A'
}}
</td>
</tr>
<tr>
<td class="lbl-col">Share Class</td>
<td class="val-col">{{ sharesAccountData.productName || 'Ordinary' }}</td>
<td class="lbl-col">Date of Issue</td>
<td class="val-col">{{ issueDate | dateFormat }}</td>
</tr>
<tr>
<td class="lbl-col">Share Numbers</td>
<td class="val-col">{{ shareNumbersRange }}</td>
<td class="lbl-col">Dividend Paid (Cumulative)</td>
<td class="val-col">
{{ cumulativeDividend | currency: sharesAccountData.currency.code : 'symbol-narrow' }}
</td>
</tr>
<tr class="highlight-row">
<td colspan="2" class="lbl-col-highlight">TOTAL FACE VALUE</td>
<td colspan="2" class="val-col-highlight">
{{ totalFaceValue | currency: sharesAccountData.currency.code : 'symbol-narrow' }}
</td>
</tr>
</tbody>
</table>
<div class="cert-footer">
<div class="seal-container">
<div class="seal-ring">
<div class="seal-text">COMMON SEAL</div>
</div>
</div>
<div class="closing-text">
Given under the common seal of the bank this {{ todayDate | date: 'd' }} day of
{{ todayDate | date: 'MMMM yyyy' }}.
</div>
<div class="signatures-container">
<div class="signature-block">
<div class="signature-line"></div>
<div class="signatory-title">Authorised Signatory</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<mat-dialog-actions align="end" class="no-print">
<button mat-button mat-dialog-close>{{ 'labels.buttons.Close' | translate }}</button>
<button mat-raised-button color="primary" (click)="printCertificate()">
<mat-icon class="m-r-5">print</mat-icon>
Print Certificate
</button>
</mat-dialog-actions>
@@ -0,0 +1,351 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* 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/.
*/
.dialog-header {
display: flex;
align-items: center;
border-bottom: 1px solid #e0e0e0;
padding-bottom: 12px;
margin-bottom: 16px;
font-weight: 500;
color: #1a252c;
}
.dialog-content-container {
max-height: 80vh;
overflow-y: auto;
padding: 10px;
}
/* Certificate Outer Container */
.certificate-container {
background-color: #faf8f5;
padding: 20px;
font-family: Georgia, 'Times New Roman', Times, serif;
color: #2c2520;
box-shadow: 0 4px 20px rgb(0 0 0 / 8%);
border-radius: 8px;
max-width: 800px;
margin: 0 auto;
}
/* Classic Ornate Border System */
.certificate-border-outer {
border: 5px double #c5a059; /* Gold double border */
padding: 8px;
background-color: #fdfcf7; /* Textured light cream */
border-radius: 4px;
position: relative;
}
.certificate-border-inner {
border: 1px solid #dcd1bd;
padding: 30px;
border-radius: 2px;
position: relative;
overflow: hidden;
}
/* Watermark */
.certificate-watermark {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
opacity: 0.03;
pointer-events: none;
z-index: 1;
.watermark-icon {
font-size: 280px;
width: 280px;
height: 280px;
color: #c5a059;
}
}
.certificate-header,
.cert-meta-row,
.cert-body-section,
.certificate-details-table,
.cert-footer {
position: relative;
z-index: 2; /* Ensure content is above watermark */
}
/* Header styling */
.certificate-header {
text-align: center;
margin-bottom: 30px;
border-bottom: 2px solid #f2edd5;
padding-bottom: 20px;
.bank-name {
font-size: 26px;
font-weight: bold;
color: #0b3c5d; /* Dark Premium Blue */
text-transform: uppercase;
letter-spacing: 1.5px;
margin-bottom: 4px;
}
.bank-sub {
font-size: 13px;
color: #7b6f63;
font-style: italic;
letter-spacing: 1px;
margin-bottom: 20px;
}
.cert-title {
font-size: 28px;
font-weight: 400;
color: #c5a059; /* Classic Gold */
letter-spacing: 4px;
margin-bottom: 5px;
font-family: Georgia, serif;
}
.cert-subtitle {
font-size: 11px;
color: #7b6f63;
text-transform: uppercase;
letter-spacing: 2px;
}
}
/* Meta Data Grid (Top of cert) */
.cert-meta-row {
display: flex;
justify-content: space-between;
margin-bottom: 30px;
border-bottom: 1px solid #f2edd5;
padding-bottom: 15px;
.meta-item {
display: flex;
flex-direction: column;
.meta-label {
font-size: 11px;
text-transform: uppercase;
color: #928374;
letter-spacing: 1px;
margin-bottom: 3px;
}
.meta-value {
font-size: 15px;
font-weight: bold;
color: #322f2b;
}
}
}
/* Main Body text */
.cert-body-section {
margin-bottom: 35px;
.cert-main-text {
font-size: 15px;
line-height: 1.8;
text-align: center;
color: #4a453f;
margin-bottom: 25px;
.highlight-text {
color: #1a202c;
font-weight: 600;
border-bottom: 1px dotted #c5a059;
padding-bottom: 1px;
}
}
}
/* Issued To Details Block */
.issued-to-details {
border: 1px solid #f0e6d2;
background-color: #fbfaf5;
padding: 15px 20px;
border-radius: 4px;
margin: 20px auto;
max-width: 90%;
text-align: left;
.issued-to-title {
font-size: 10px;
font-weight: bold;
color: #c5a059;
letter-spacing: 1.5px;
margin-bottom: 6px;
}
.client-name-details {
font-size: 18px;
font-weight: bold;
color: #0b3c5d;
margin-bottom: 4px;
}
.client-ids {
font-size: 12px;
color: #666;
margin-bottom: 6px;
}
.client-address {
font-size: 12px;
color: #7b6f63;
line-height: 1.4;
}
}
/* Certificate Data Table */
.certificate-details-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 40px;
td {
padding: 10px 12px;
border: 1px solid #ede8db;
font-size: 13px;
}
.lbl-col {
background-color: #f7f5ee;
font-weight: bold;
color: #7b6f63;
width: 25%;
}
.val-col {
color: #322f2b;
width: 25%;
}
.highlight-row {
background-color: #f5f2e9;
border-top: 2px solid #c5a059;
}
.lbl-col-highlight {
font-weight: bold;
color: #0b3c5d;
font-size: 14px;
letter-spacing: 1px;
text-align: right;
padding-right: 20px;
}
.val-col-highlight {
font-size: 16px;
font-weight: bold;
color: #b8860b; /* Dark Goldenrod */
}
}
/* Certificate Footer Seal / Signatures */
.cert-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 20px;
.seal-container {
width: 25%;
.seal-ring {
width: 90px;
height: 90px;
border: 2px dashed #b8860b;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto;
transform: rotate(-15deg);
.seal-text {
font-size: 10px;
font-weight: bold;
color: #b8860b;
text-align: center;
line-height: 1.2;
letter-spacing: 1px;
}
}
}
.closing-text {
width: 45%;
font-size: 12px;
color: #7b6f63;
font-style: italic;
line-height: 1.5;
text-align: center;
}
.signatures-container {
width: 30%;
text-align: center;
.signature-block {
display: inline-block;
width: 80%;
.signature-line {
border-bottom: 1px solid #7b6f63;
margin-bottom: 8px;
height: 40px; /* Space for signature */
}
.signatory-title {
font-size: 12px;
color: #7b6f63;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.5px;
}
}
}
}
/* Print CSS */
@media print {
/* Hide non-overlay contents during print */
body > :not(.cdk-overlay-container) {
display: none !important;
}
/* Make certificate fill page */
#share-certificate-print {
position: absolute;
left: 0;
top: 0;
width: 100% !important;
height: 100% !important;
padding: 0 !important;
margin: 0 !important;
background-color: #fff !important;
box-shadow: none !important;
}
.certificate-border-outer {
border-width: 8px !important;
background-color: #fff !important;
}
.no-print {
display: none !important;
}
.mat-mdc-dialog-container {
padding: 0 !important;
}
}
@@ -0,0 +1,145 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* 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/.
*/
import { Component, OnInit, inject } from '@angular/core';
import {
MAT_DIALOG_DATA,
MatDialogRef,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose
} from '@angular/material/dialog';
import { ClientsService } from 'app/clients/clients.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
import { FormatNumberPipe } from '../../../pipes/format-number.pipe';
import { CurrencyPipe } from '@angular/common';
import { MatButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
@Component({
selector: 'mifosx-share-certificate-dialog',
templateUrl: './share-certificate-dialog.component.html',
styleUrls: ['./share-certificate-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose,
DateFormatPipe,
FormatNumberPipe,
CurrencyPipe,
MatButton,
MatIcon
]
})
export class ShareCertificateDialogComponent implements OnInit {
dialogRef = inject<MatDialogRef<ShareCertificateDialogComponent>>(MatDialogRef);
data = inject(MAT_DIALOG_DATA);
clientsService = inject(ClientsService);
sharesAccountData: any;
clientId: string;
clientData: any;
clientAddress: string = '';
panNumber: string = 'Not Available';
memberId: string = '';
certificateNo: string = '';
folioNo: string = '';
shareNumbersRange: string = '';
cumulativeDividend: number = 0;
totalFaceValue: number = 0;
issueDate: Date = new Date();
todayDate: Date = new Date();
ngOnInit() {
this.sharesAccountData = this.data.sharesAccountData;
this.clientId = String(this.data.clientId);
// Initialize shares certificate values
this.issueDate = this.sharesAccountData.timeline?.activatedDate
? new Date(this.sharesAccountData.timeline.activatedDate)
: new Date();
this.certificateNo = `SC-2026-${this.sharesAccountData.accountNo || String(this.sharesAccountData.id).padStart(6, '0')}`;
// Calculate total face value
const approvedShares = this.sharesAccountData.summary?.totalApprovedShares || 0;
const price = this.sharesAccountData.currentMarketPrice || 0;
this.totalFaceValue = approvedShares * price;
// Calculate share numbers range (seeded dynamically from account ID)
const startNum = this.sharesAccountData.id * 5000 + 201;
const endNum = startNum + approvedShares - 1;
this.shareNumbersRange = approvedShares > 0 ? `${startNum.toLocaleString()} – ${endNum.toLocaleString()}` : 'None';
// Calculate cumulative dividends
if (this.sharesAccountData.dividends) {
this.cumulativeDividend = this.sharesAccountData.dividends.reduce(
(sum: number, d: any) => sum + (d.amount || 0),
0
);
}
// Fetch client details
this.clientsService.getClientData(this.clientId).subscribe({
next: (client: any) => {
this.clientData = client;
this.memberId = client.accountNo || '';
this.folioNo = `F-${client.accountNo || String(client.id).padStart(5, '0')}`;
},
error: () => {}
});
// Fetch address details
this.clientsService.getClientAddressData(this.clientId).subscribe({
next: (addresses: any[]) => {
if (addresses && addresses.length > 0) {
const permanent = addresses.find((a) => /permanent/i.test(a?.addressType || '')) || addresses[0];
this.clientAddress = this.formatAddress(permanent);
}
},
error: () => {}
});
// Fetch identifiers (for PAN number)
this.clientsService.getClientIdentifiers(this.clientId).subscribe({
next: (identifiers: any[]) => {
const idList = Array.isArray(identifiers) ? identifiers : (identifiers as any)?.pageItems || [];
const panId = idList.find((id: any) => /pan/i.test(id?.documentType?.name || ''));
if (panId) {
this.panNumber = panId.documentKey;
}
},
error: () => {}
});
}
formatAddress(addr: any): string {
if (!addr) return '';
return [
addr.addressLine1,
addr.addressLine2,
addr.townVillage,
addr.city,
addr.stateName,
addr.countryName,
addr.postalCode
]
.filter((p) => !!p && String(p).trim())
.join(', ');
}
printCertificate() {
window.print();
}
}
@@ -71,7 +71,7 @@
</div> </div>
<span class="flex-auto"></span> <span class="flex-auto"></span>
<div class="flex-40"> <div class="flex-10" style="display: flex; justify-content: flex-end; align-items: flex-start">
@if (buttonConfig.singleButtons.length > 0) { @if (buttonConfig.singleButtons.length > 0) {
<button <button
mat-icon-button mat-icon-button
@@ -105,7 +105,15 @@
<mat-menu #More="matMenu"> <mat-menu #More="matMenu">
@for (option of buttonConfig.options; track option) { @for (option of buttonConfig.options; track option) {
<span> <span>
<button mat-menu-item *mifosxHasPermission="option.taskPermissionName" (click)="doAction(option.name)"> <button
mat-menu-item
*mifosxHasPermission="option.taskPermissionName"
[disabled]="option.name === 'Edit Details'"
[matTooltip]="
option.name === 'Edit Details' ? ('tooltips.Editing details is disabled' | translate) : ''
"
(click)="doAction(option.name)"
>
{{ 'labels.menus.' + option.name | translate }} {{ 'labels.menus.' + option.name | translate }}
</button> </button>
</span> </span>
@@ -13,6 +13,7 @@ import { MatDialog } from '@angular/material/dialog';
/** Custom Dialogs */ /** Custom Dialogs */
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component'; import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
import { ShareCertificateDialogComponent } from './share-certificate-dialog/share-certificate-dialog.component';
/** Custom Services */ /** Custom Services */
import { SharesService } from '../shares.service'; import { SharesService } from '../shares.service';
@@ -98,6 +99,9 @@ export class SharesAccountViewComponent implements OnInit {
}); });
} }
/** Whether this account has a details edit awaiting checker approval. */
hasPendingEdit = false;
ngOnInit() { ngOnInit() {
this.setConditionalButtons(); this.setConditionalButtons();
if (this.router.url.includes('clients')) { if (this.router.url.includes('clients')) {
@@ -107,6 +111,17 @@ export class SharesAccountViewComponent implements OnInit {
} else if (this.router.url.includes('centers')) { } else if (this.router.url.includes('centers')) {
this.entityType = 'Center'; this.entityType = 'Center';
} }
this.sharesService.getPendingShareAccountEdit(this.sharesAccountData.id).subscribe({
next: (edit: any) => {
this.hasPendingEdit = !!edit && edit.status === 'PENDING';
if (this.hasPendingEdit) {
this.buttonConfig.removeOption('Edit Details');
}
},
error: () => {
this.hasPendingEdit = false;
}
});
} }
/** /**
@@ -164,6 +179,12 @@ export class SharesAccountViewComponent implements OnInit {
case 'Modify Application': case 'Modify Application':
this.router.navigate(['edit'], { relativeTo: this.route }); this.router.navigate(['edit'], { relativeTo: this.route });
break; break;
case 'Edit Details':
this.router.navigate(['edit-details'], { relativeTo: this.route });
break;
case 'Share Certificate':
this.viewShareCertificate();
break;
case 'Delete': case 'Delete':
this.deleteSharesAccount(); this.deleteSharesAccount();
break; break;
@@ -185,4 +206,15 @@ export class SharesAccountViewComponent implements OnInit {
} }
}); });
} }
private viewShareCertificate() {
this.dialog.open(ShareCertificateDialogComponent, {
data: {
sharesAccountData: this.sharesAccountData,
clientId: this.sharesAccountData.clientId
},
width: '850px',
maxWidth: '95vw'
});
}
} }
@@ -95,6 +95,14 @@ export class SharesButtonsConfiguration {
switch (status) { switch (status) {
case 'Active': case 'Active':
this.optionArray = [ this.optionArray = [
{
name: 'Edit Details',
taskPermissionName: 'SUBMITEDIT_SHAREACCOUNT'
},
{
name: 'Share Certificate',
taskPermissionName: 'READ_SHAREACCOUNTPURCHASE'
},
{ {
name: 'Close', name: 'Close',
taskPermissionName: 'CLOSE_SHAREACCOUNT' taskPermissionName: 'CLOSE_SHAREACCOUNT'
@@ -128,4 +136,12 @@ export class SharesButtonsConfiguration {
const index = buttonNames.indexOf(name); const index = buttonNames.indexOf(name);
this.buttonsArray.splice(index, 1); this.buttonsArray.splice(index, 1);
} }
removeOption(name: string) {
const optionNames = this.optionArray.map((entry) => entry.name);
const index = optionNames.indexOf(name);
if (index > -1) {
this.optionArray.splice(index, 1);
}
}
} }
@@ -73,7 +73,9 @@ 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; this.transactionsData = this.shareAccountData.purchasedShares.filter(
(transaction: any) => transaction.type.value !== 'Charge Payment'
);
}); });
} }
+9
View File
@@ -18,6 +18,7 @@ import { DividendsTabComponent } from './shares-account-view/dividends-tab/divid
import { CreateSharesAccountComponent } from './create-shares-account/create-shares-account.component'; import { CreateSharesAccountComponent } from './create-shares-account/create-shares-account.component';
import { EditSharesAccountComponent } from './edit-shares-account/edit-shares-account.component'; import { EditSharesAccountComponent } from './edit-shares-account/edit-shares-account.component';
import { SharesAccountActionsComponent } from './shares-account-actions/shares-account-actions.component'; import { SharesAccountActionsComponent } from './shares-account-actions/shares-account-actions.component';
import { EditShareAccountDetailsComponent } from './edit-share-account-details/edit-share-account-details.component';
/** Custom Resolvers */ /** Custom Resolvers */
import { SharesAccountViewResolver } from './common-resolvers/share-account-view.resolver'; import { SharesAccountViewResolver } from './common-resolvers/share-account-view.resolver';
@@ -89,6 +90,14 @@ const routes: Routes = [
sharesAccountAndTemplate: SharesAccountAndTemplateResolver sharesAccountAndTemplate: SharesAccountAndTemplateResolver
} }
}, },
{
path: 'edit-details',
data: { title: 'Edit Share Account Details', breadcrumb: 'Edit Details', routeParamBreadcrumb: false },
component: EditShareAccountDetailsComponent,
resolve: {
sharesAccountAndTemplate: SharesAccountAndTemplateResolver
}
},
{ {
path: 'actions/:name', path: 'actions/:name',
data: { title: 'Shares Account Actions', breadcrumb: 'Actions', routeParamBreadcrumb: 'name' }, data: { title: 'Shares Account Actions', breadcrumb: 'Actions', routeParamBreadcrumb: 'name' },
+51
View File
@@ -32,6 +32,16 @@ export class SharesService {
return this.http.get(`/accounts/share/${accountId}`, { params: httpParams }); return this.http.get(`/accounts/share/${accountId}`, { params: httpParams });
} }
/**
* @param {number} offset Paging offset.
* @param {number} limit Paging size limit.
* @returns {Observable<any>} A page of all (active) share accounts, backed by m_share_account.
*/
getAllShareAccounts(offset: number = 0, limit: number = 200): Observable<any> {
const httpParams = new HttpParams().set('offset', offset.toString()).set('limit', limit.toString());
return this.http.get('/accounts/share', { params: httpParams });
}
/** /**
* @param {string} clientId Client Id assosciated with shares account. * @param {string} clientId Client Id assosciated with shares account.
* @param {string} productId Product Id * @param {string} productId Product Id
@@ -78,4 +88,45 @@ export class SharesService {
const httpParams = new HttpParams().set('command', command); const httpParams = new HttpParams().set('command', command);
return this.http.post(`/accounts/share/${accountId}`, data, { params: httpParams }); return this.http.post(`/accounts/share/${accountId}`, data, { params: httpParams });
} }
/**
* @param {string} accountId Shares Account Id.
* @param {any} data Details edit payload.
* @returns {Observable<any>}
*/
submitShareAccountEdit(accountId: string, data: any): Observable<any> {
return this.http.post(`/shareaccounts/${accountId}/edit`, data);
}
/**
* @param {string} accountId Shares Account Id.
* @returns {Observable<any>} The pending details edit for this account, if any.
*/
getPendingShareAccountEdit(accountId: string): Observable<any> {
return this.http.get(`/shareaccounts/${accountId}/edit/pending`);
}
/**
* @returns {Observable<any>} All Share Account details edits pending approval.
*/
getAllPendingShareAccountEdits(): Observable<any> {
return this.http.get(`/shareaccounts/edit/pending`);
}
/**
* @param {string} accountId Shares Account Id.
* @returns {Observable<any>}
*/
approveShareAccountEdit(accountId: string): Observable<any> {
return this.http.post(`/shareaccounts/${accountId}/edit/approve`, {});
}
/**
* @param {string} accountId Shares Account Id.
* @param {string} rejectReason Optional reason for rejection.
* @returns {Observable<any>}
*/
rejectShareAccountEdit(accountId: string, rejectReason?: string): Observable<any> {
return this.http.post(`/shareaccounts/${accountId}/edit/reject`, { rejectReason: rejectReason || '' });
}
} }
@@ -0,0 +1,108 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
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/.
-->
<div class="psae-tab-content">
@if (showData) {
<div class="psae-action-bar">
<mat-form-field appearance="outline" class="psae-search-field">
<mat-icon matPrefix>search</mat-icon>
<input #filterInput matInput placeholder="Search by account number" (keyup)="applyFilter(filterInput.value)" />
</mat-form-field>
</div>
<div class="table-container">
<table mat-table [dataSource]="dataSource" class="modern-table">
<ng-container matColumnDef="accountNo">
<th mat-header-cell *matHeaderCellDef>Account No.</th>
<td mat-cell *matCellDef="let edit">{{ edit.accountNo }}</td>
</ng-container>
<ng-container matColumnDef="clientName">
<th mat-header-cell *matHeaderCellDef>Client</th>
<td mat-cell *matCellDef="let edit">{{ edit.clientName }}</td>
</ng-container>
<ng-container matColumnDef="makerUsername">
<th mat-header-cell *matHeaderCellDef>Submitted By</th>
<td mat-cell *matCellDef="let edit">{{ edit.makerUsername }}</td>
</ng-container>
<ng-container matColumnDef="createdOn">
<th mat-header-cell *matHeaderCellDef>Submitted On</th>
<td mat-cell *matCellDef="let edit">{{ formatDate(edit.createdOn) }}</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let edit">
<span class="status-badge status-badge--pending">{{ edit.status }}</span>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let edit">
<button mat-stroked-button class="psae-btn psae-btn-view" (click)="viewCompare(edit)">
<fa-icon icon="eye" class="m-r-10"></fa-icon>Compare
</button>
<button mat-stroked-button class="psae-btn psae-btn-approve" (click)="approve(edit)">
<fa-icon icon="check" class="m-r-10"></fa-icon>Approve
</button>
<button mat-stroked-button class="psae-btn psae-btn-reject" (click)="reject(edit)">
<fa-icon icon="ban" class="m-r-10"></fa-icon>Reject
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="table-row"></tr>
</table>
</div>
@if (selectedEdit) {
<div class="psae-diff-panel">
<div class="psae-diff-header">
<h3>Proposed Changes &mdash; Account "{{ selectedEdit.accountNo }}"</h3>
<button mat-icon-button (click)="closeCompare()"><fa-icon icon="times"></fa-icon></button>
</div>
<div class="psae-diff-block">
<h4>Details</h4>
<table class="psae-diff-table">
<thead>
<tr>
<th>Field</th>
<th>Proposed Value</th>
</tr>
</thead>
<tbody>
@for (row of entries(selectedEdit); track row.key) {
<tr>
<td class="psae-diff-label">{{ row.key }}</td>
<td class="psae-diff-proposed">{{ row.value }}</td>
</tr>
}
</tbody>
</table>
</div>
@if (selectedEdit.rejectReason) {
<p class="psae-reject-reason"><strong>Reject Reason:</strong> {{ selectedEdit.rejectReason }}</p>
}
</div>
}
}
@if (!showData) {
<div class="empty-state">
<fa-icon icon="hourglass-half" class="empty-icon"></fa-icon>
<p class="empty-title">No pending account edits</p>
<p class="empty-text">There are no Share Account details edits awaiting approval at this time.</p>
</div>
}
</div>
@@ -0,0 +1,190 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* 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/.
*/
$border: #e2e8f0;
$text-muted: #64748b;
.psae-tab-content {
padding: 16px 0;
}
.psae-action-bar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.psae-search-field {
flex: 1;
min-width: 200px;
max-width: 320px;
}
.psae-btn {
font-size: 11.5px;
font-weight: 600;
border-radius: 8px !important;
height: 32px;
padding: 0 10px;
margin-right: 6px;
fa-icon {
margin-right: 4px;
font-size: 11px;
}
}
.psae-btn-view {
border-color: #2563eb;
color: #2563eb;
&:hover {
background: #eff6ff;
}
}
.psae-btn-approve {
border-color: #16a34a;
color: #16a34a;
&:hover {
background: #f0fdf4;
}
}
.psae-btn-reject {
border-color: #dc2626;
color: #dc2626;
&:hover {
background: #fef2f2;
}
}
.status-badge {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
&--pending {
background: #fef9c3;
color: #854d0e;
}
}
.psae-diff-panel {
margin-top: 20px;
border: 1px solid $border;
border-radius: 12px;
background: #fff;
padding: 16px;
}
.psae-diff-header {
display: flex;
align-items: center;
justify-content: space-between;
h3 {
margin: 0;
font-size: 14px;
font-weight: 700;
}
}
.psae-diff-block {
margin-top: 16px;
h4 {
margin: 0 0 6px;
font-size: 12.5px;
font-weight: 700;
color: #0d1b2a;
text-transform: uppercase;
letter-spacing: 0.03em;
}
}
.psae-diff-loading {
color: $text-muted;
font-size: 13px;
}
.psae-diff-table {
width: 100%;
border-collapse: collapse;
margin-top: 4px;
font-size: 12.5px;
th,
td {
text-align: left;
padding: 8px 10px;
border-bottom: 1px solid $border;
}
th {
color: $text-muted;
font-weight: 600;
}
}
.psae-diff-label {
font-weight: 600;
color: #0d1b2a;
}
.psae-diff-proposed {
color: #15803d;
font-weight: 600;
}
.psae-reject-reason {
margin-top: 14px;
font-size: 12.5px;
color: #b91c1c;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 24px;
background: #fff;
border: 1px dashed $border;
border-radius: 14px;
text-align: center;
margin: 16px 0;
.empty-icon {
font-size: 42px;
color: #cbd5e1;
margin-bottom: 14px;
}
.empty-title {
margin: 0 0 4px;
font-size: 15px;
font-weight: 700;
color: #0d1b2a;
}
.empty-text {
margin: 0;
font-size: 12.5px;
color: $text-muted;
max-width: 380px;
line-height: 1.5;
}
}
@@ -0,0 +1,192 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* 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/.
*/
import { Component, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import {
MatTableDataSource,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow
} from '@angular/material/table';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatIconButton } from '@angular/material/button';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { SharesService } from 'app/shares/shares.service';
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component';
import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base';
import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Checker-facing list of Share Account details edits awaiting approval.
* Mirrors PendingSavingsAccountEditsComponent's list + compare + approve/reject pattern.
*/
@Component({
selector: 'mifosx-pending-share-account-edits',
templateUrl: './pending-share-account-edits.component.html',
styleUrls: ['./pending-share-account-edits.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
MatIcon,
MatIconButton
]
})
export class PendingShareAccountEditsComponent {
private route = inject(ActivatedRoute);
private router = inject(Router);
private dialog = inject(MatDialog);
private sharesService = inject(SharesService);
dataSource: MatTableDataSource<any>;
showData = false;
displayedColumns: string[] = [
'accountNo',
'clientName',
'makerUsername',
'createdOn',
'status',
'actions'
];
selectedEdit: any = null;
constructor() {
this.route.data.subscribe((data: { pendingShareAccountEditsData: any }) => {
const items = data.pendingShareAccountEditsData || [];
this.showData = items.length > 0;
this.dataSource = new MatTableDataSource(items);
});
}
applyFilter(filterValue: string = '') {
this.dataSource.filter = filterValue.trim().toLowerCase();
}
formatDate(date: any): string {
if (!date) return '';
const d = Array.isArray(date)
? new Date(date[0], date[1] - 1, date[2], date[3] || 0, date[4] || 0)
: new Date(date);
return isNaN(d.getTime()) ? String(date) : d.toLocaleString('en-IN');
}
/** Readable list of "key: value" pairs for the proposed details changes. */
entries(edit: any): { key: string; value: any }[] {
if (!edit) return [];
const fields: [string, any][] = [
[
'Linked Savings Account',
edit.savingsAccountNo
],
[
'Allow Dividends for Inactive Clients',
edit.allowDividendCalculationForInactiveClients
],
[
'Lockin Period Frequency',
edit.lockinPeriodFrequency
],
[
'Minimum Active Period Frequency',
edit.minimumActivePeriodFrequency
]
];
return fields
.filter(
([
,
value
]) => value !== null && value !== undefined
)
.map(
([
key,
value
]) => ({ key, value })
);
}
viewCompare(edit: any): void {
this.selectedEdit = this.selectedEdit === edit ? null : edit;
}
closeCompare(): void {
this.selectedEdit = null;
}
approve(edit: any): void {
const dialogRef = this.dialog.open(ConfirmationDialogComponent, {
data: {
heading: 'Approve Share Account Edit',
dialogContext: `Apply the pending details edit for account "${edit.accountNo}"? This cannot be undone.`,
type: 'basic'
}
});
dialogRef.afterClosed().subscribe((response: any) => {
if (response?.confirm) {
this.sharesService.approveShareAccountEdit(edit.shareAccountId).subscribe(() => this.reload());
}
});
}
reject(edit: any): void {
const formfields: FormfieldBase[] = [
new InputBase({
controlName: 'rejectReason',
label: 'Reason for rejection',
value: '',
type: 'text',
required: false
})
];
const dialogRef = this.dialog.open(FormDialogComponent, {
data: {
title: `Reject edit for account "${edit.accountNo}"`,
layout: { addButtonText: 'Reject' },
formfields
}
});
dialogRef.afterClosed().subscribe((response: any) => {
if (response?.data) {
const rejectReason = response.data.value.rejectReason || '';
this.sharesService.rejectShareAccountEdit(edit.shareAccountId, rejectReason).subscribe(() => this.reload());
}
});
}
reload(): void {
this.closeCompare();
const url = this.router.url;
this.router
.navigateByUrl('/checker-inbox-and-tasks', { skipLocationChange: true })
.then(() => this.router.navigate([url]));
}
}
@@ -118,6 +118,18 @@
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon> <fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
<span>Pending Account Edits</span> <span>Pending Account Edits</span>
</a> </a>
<a
mat-tab-link
class="ac-tab"
[routerLink]="['./pending-share-account-edits']"
routerLinkActive
#pendingShareAccountEdits="routerLinkActive"
[active]="pendingShareAccountEdits.isActive"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
>
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
<span>Pending Share Account Edits</span>
</a>
<a <a
mat-tab-link mat-tab-link
class="ac-tab" class="ac-tab"
@@ -0,0 +1,25 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* 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/.
*/
import { Injectable, inject } from '@angular/core';
import { Resolve } from '@angular/router';
import { Observable } from 'rxjs';
import { SharesService } from 'app/shares/shares.service';
/**
* Resolves all Share Account details edits currently awaiting checker approval, for the Pending Share
* Account Edits screen.
*/
@Injectable()
export class GetPendingShareAccountEdits implements Resolve<any> {
private sharesService = inject(SharesService);
resolve(): Observable<any> {
return this.sharesService.getAllPendingShareAccountEdits();
}
}
+12 -1
View File
@@ -29,6 +29,7 @@ import { TerminatedAccountComponent } from './checker-inbox-and-tasks-tabs/termi
import { PendingSavingsProductsComponent } from './checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component'; import { PendingSavingsProductsComponent } from './checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component';
import { ChequebookRequestsComponent } from './checker-inbox-and-tasks-tabs/chequebook-requests/chequebook-requests.component'; import { ChequebookRequestsComponent } from './checker-inbox-and-tasks-tabs/chequebook-requests/chequebook-requests.component';
import { PendingSavingsAccountEditsComponent } from './checker-inbox-and-tasks-tabs/pending-savings-account-edits/pending-savings-account-edits.component'; import { PendingSavingsAccountEditsComponent } from './checker-inbox-and-tasks-tabs/pending-savings-account-edits/pending-savings-account-edits.component';
import { PendingShareAccountEditsComponent } from './checker-inbox-and-tasks-tabs/pending-share-account-edits/pending-share-account-edits.component';
import { ViewCheckerInboxComponent } from './view-checker-inbox/view-checker-inbox.component'; import { ViewCheckerInboxComponent } from './view-checker-inbox/view-checker-inbox.component';
/** Custom Resolvers */ /** Custom Resolvers */
@@ -46,6 +47,7 @@ import { GetTerminatedAccounts } from './common-resolvers/getTerminatedAccounts.
import { GetPendingSavingsProducts } from './common-resolvers/getPendingSavingsProducts.resolver'; import { GetPendingSavingsProducts } from './common-resolvers/getPendingSavingsProducts.resolver';
import { GetPendingSavingsAccountEdits } from './common-resolvers/getPendingSavingsAccountEdits.resolver'; import { GetPendingSavingsAccountEdits } from './common-resolvers/getPendingSavingsAccountEdits.resolver';
import { GetPendingCurrentAccountEdits } from './common-resolvers/getPendingCurrentAccountEdits.resolver'; import { GetPendingCurrentAccountEdits } from './common-resolvers/getPendingCurrentAccountEdits.resolver';
import { GetPendingShareAccountEdits } from './common-resolvers/getPendingShareAccountEdits.resolver';
/** Tasks Routes */ /** Tasks Routes */
const routes: Routes = [ const routes: Routes = [
@@ -142,6 +144,14 @@ const routes: Routes = [
path: 'pending-current-account-edits', path: 'pending-current-account-edits',
redirectTo: 'pending-savings-account-edits' redirectTo: 'pending-savings-account-edits'
}, },
{
path: 'pending-share-account-edits',
component: PendingShareAccountEditsComponent,
data: { title: 'Pending Share Account Edits' },
resolve: {
pendingShareAccountEditsData: GetPendingShareAccountEdits
}
},
{ {
path: 'withdrawal-requests', path: 'withdrawal-requests',
component: WithdrawalRequestsComponent, component: WithdrawalRequestsComponent,
@@ -221,7 +231,8 @@ const routes: Routes = [
GetTerminatedAccounts, GetTerminatedAccounts,
GetPendingSavingsProducts, GetPendingSavingsProducts,
GetPendingSavingsAccountEdits, GetPendingSavingsAccountEdits,
GetPendingCurrentAccountEdits GetPendingCurrentAccountEdits,
GetPendingShareAccountEdits
] ]
}) })
export class TasksRoutingModule {} export class TasksRoutingModule {}
+3
View File
@@ -2037,6 +2037,7 @@
"External ID lookup failed": "External ID lookup failed", "External ID lookup failed": "External ID lookup failed",
"External ID lookup timed out": "External ID lookup timed out", "External ID lookup timed out": "External ID lookup timed out",
"External ID not found": "External ID not found", "External ID not found": "External ID not found",
"Resolution Number": "Resolution Number",
"External ID verified successfully": "External ID verified successfully", "External ID verified successfully": "External ID verified successfully",
"External Id": "External Id", "External Id": "External Id",
"External Reference ID": "External Reference ID", "External Reference ID": "External Reference ID",
@@ -3102,6 +3103,7 @@
"Working with Code": "Working with Code" "Working with Code": "Working with Code"
}, },
"menus": { "menus": {
"Share Certificate": "Share Certificate",
"Unassign Staff": "Unassign Staff", "Unassign Staff": "Unassign Staff",
"Accounting": "Accounting", "Accounting": "Accounting",
"Account Overview": "Account Overview", "Account Overview": "Account Overview",
@@ -4493,6 +4495,7 @@
"A unique identifier for the share product": "A unique identifier for the share product", "A unique identifier for the share product": "A unique identifier for the share product",
"Add future share value with date range": "Add future share value with date range", "Add future share value with date range": "Add future share value with date range",
"Activate": "Activate", "Activate": "Activate",
"Editing details is disabled": "Editing details is disabled",
"Add": "Add", "Add": "Add",
"Add Filters": "Add Filters", "Add Filters": "Add Filters",
"Analytics": "Analytics Dashboard (Apache Superset)", "Analytics": "Analytics Dashboard (Apache Superset)",
+1
View File
@@ -15,3 +15,4 @@ export default {
'allow_switching_backend_instance': true 'allow_switching_backend_instance': true
}; };
/* tslint:enable */ /* tslint:enable */