-
- ₹{{ b.amount | number: '1.0-0' }}
-
+
+
+ {{ b.date | date: 'EEE' }}
+ {{ b.date | date: 'd MMM' }}
-
}
diff --git a/src/app/digital/neft-rtgs-report/neft-rtgs-report.component.scss b/src/app/digital/neft-rtgs-report/neft-rtgs-report.component.scss
index c5b49f241..b93731f73 100644
--- a/src/app/digital/neft-rtgs-report/neft-rtgs-report.component.scss
+++ b/src/app/digital/neft-rtgs-report/neft-rtgs-report.component.scss
@@ -335,68 +335,106 @@
}
/* ───────── TREND CHART ───────── */
+
+/* ───────── Daily-throughput leaderboard (horizontal-bar list) ───────── */
.nrr-trend {
display: flex;
- align-items: flex-end;
- gap: 12px;
- min-height: 220px;
- padding: 10px 4px;
+ flex-direction: column;
+ gap: 4px;
+ padding: 6px 4px 2px;
}
-.nrr-trend-col {
- flex: 1;
- display: flex;
- flex-direction: column;
+.nrr-trend-row {
+ display: grid;
+ grid-template-columns: 96px 1fr auto auto;
+ column-gap: 14px;
align-items: center;
- gap: 8px;
- min-width: 60px;
+ padding: 8px;
+ border-radius: 8px;
+ transition: background 0.15s ease;
+
+ &:hover {
+ background: var(--r-g50);
+ }
}
-.nrr-trend-bar-wrap {
- width: 100%;
- height: 160px;
- display: flex;
- align-items: flex-end;
- justify-content: center;
-}
-
-.nrr-trend-bar {
- width: 75%;
- min-height: 6px;
- background: linear-gradient(180deg, var(--r-blue) 0%, var(--r-purple) 100%);
- border-radius: 6px 6px 0 0;
- position: relative;
- transition: height 0.3s ease;
-}
-
-.nrr-trend-amt {
- position: absolute;
- top: -22px;
- left: 50%;
- transform: translateX(-50%);
- font-size: 11px;
- font-weight: 700;
- color: var(--r-g700);
- white-space: nowrap;
-}
-
-.nrr-trend-meta {
- text-align: center;
+.nrr-trend-row-date {
display: flex;
flex-direction: column;
+ line-height: 1.15;
+}
+
+.nrr-trend-row-day {
+ font-size: 9.5px;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--r-g500);
+}
+
+.nrr-trend-row-md {
+ font-size: 13px;
+ font-weight: 700;
+ color: var(--r-g900);
+}
+
+.nrr-trend-row-bar-wrap {
+ position: relative;
+ height: 10px;
+ background: var(--r-g100);
+ border-radius: 999px;
+ overflow: hidden;
+ min-width: 80px;
+}
+
+.nrr-trend-row-bar {
+ height: 100%;
+ background: linear-gradient(90deg, var(--r-blue) 0%, var(--r-purple) 100%);
+ border-radius: 999px;
+ transition: width 0.3s ease;
+ min-width: 6px;
+}
+
+.nrr-trend-row-pills {
+ display: inline-flex;
gap: 4px;
}
-.nrr-trend-date {
- font-size: 11px;
- font-weight: 600;
- color: var(--r-g700);
+.nrr-trend-row-amt {
+ font-size: 13.5px;
+ font-weight: 700;
+ color: var(--r-g900);
+ font-variant-numeric: tabular-nums;
+ text-align: right;
+ min-width: 92px;
}
-.nrr-trend-split {
- display: flex;
- gap: 4px;
- justify-content: center;
+@media (width <= 640px) {
+ .nrr-trend-row {
+ grid-template-columns: 70px 1fr;
+ grid-template-areas:
+ 'date amt'
+ 'bar bar'
+ 'pills pills';
+ gap: 6px 8px;
+ }
+
+ .nrr-trend-row-date {
+ grid-area: date;
+ }
+
+ .nrr-trend-row-amt {
+ grid-area: amt;
+ min-width: 0;
+ }
+
+ .nrr-trend-row-bar-wrap {
+ grid-area: bar;
+ }
+
+ .nrr-trend-row-pills {
+ grid-area: pills;
+ }
}
/* ───────── TABLE ───────── */
diff --git a/src/app/digital/neft-rtgs-report/neft-rtgs-report.component.ts b/src/app/digital/neft-rtgs-report/neft-rtgs-report.component.ts
index 4f2a6f78b..7ba6522c9 100644
--- a/src/app/digital/neft-rtgs-report/neft-rtgs-report.component.ts
+++ b/src/app/digital/neft-rtgs-report/neft-rtgs-report.component.ts
@@ -110,7 +110,7 @@ export class NeftRtgsReportComponent implements OnInit {
];
ngOnInit(): void {
- this.txns = this.seedTransactions();
+ this.txns = seedNeftRtgsTransactions();
}
// ───────────────────── KPIs ─────────────────────
@@ -455,293 +455,302 @@ export class NeftRtgsReportComponent implements OnInit {
}
// ───────────────────── Mock seed data ─────────────────────
+ /** @deprecated Use the exported `seedNeftRtgsTransactions()` instead. */
private seedTransactions(): NeftRtgsTxn[] {
- const today = new Date();
- const dt = (offset: number, h = 10, m = 0): string => {
- const d = new Date(today);
- d.setDate(d.getDate() - offset);
- const pad = (n: number) => String(n).padStart(2, '0');
- return (
- d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(h) + ':' + pad(m) + ':00'
- );
- };
- const vd = (offset: number): string => dt(offset).slice(0, 10);
-
- return [
- {
- batchId: 'H2H-20260326-01',
- instructionId: 'ERP77889922',
- utrNumber: 'KKBKR5202608511',
- remitterAcc: '50200012345678',
- remitterBranch: 'Mumbai — BKC',
- remitterUser: 'Anita Desai',
- remitterChannel: 'BRANCH',
- beneAcc: '91000222333444',
- beneIfsc: 'HDFC0001234',
- beneName: 'Global Logistics Ltd',
- beneBank: 'HDFC Bank',
- paymentType: 'RTGS',
- amount: 550000,
- valueDate: vd(0),
- purposeCode: 'P0102',
- leiCode: '549300ABCDEF1234',
- processingState: 'SETTLED',
- sentTimestamp: dt(0, 14, 5)
- },
- {
- batchId: 'H2H-20260326-01',
- instructionId: 'ERP77889923',
- utrNumber: 'HDFCR5202608712',
- remitterAcc: '50200012345678',
- remitterBranch: 'Mumbai — BKC',
- remitterUser: 'Anita Desai',
- remitterChannel: 'BRANCH',
- beneAcc: '345678901234',
- beneIfsc: 'ICIC0000567',
- beneName: 'Bluestone Trading Co.',
- beneBank: 'ICICI Bank',
- paymentType: 'NEFT',
- amount: 78500,
- valueDate: vd(0),
- purposeCode: 'P0802',
- processingState: 'SENT',
- sentTimestamp: dt(0, 14, 6)
- },
- {
- batchId: 'H2H-20260325-04',
- instructionId: 'ERP77889811',
- utrNumber: 'SBINR5202608902',
- remitterAcc: '50200099887766',
- remitterBranch: 'Bengaluru — MG Road',
- remitterUser: 'Karthik Rao',
- remitterChannel: 'MOBILE',
- beneAcc: '912345678901',
- beneIfsc: 'SBIN0011223',
- beneName: 'Anita Verma',
- beneBank: 'State Bank of India',
- paymentType: 'NEFT',
- amount: 15000,
- valueDate: vd(1),
- purposeCode: 'P1301',
- processingState: 'SETTLED',
- sentTimestamp: dt(1, 11, 22)
- },
- {
- batchId: 'H2H-20260325-04',
- instructionId: 'ERP77889812',
- utrNumber: 'AXISR5202608811',
- remitterAcc: '50200099887766',
- remitterBranch: 'Bengaluru — MG Road',
- remitterUser: 'Karthik Rao',
- remitterChannel: 'MOBILE',
- beneAcc: '6789012345',
- beneIfsc: 'UTIB0000456',
- beneName: 'Rakesh Industries',
- beneBank: 'Axis Bank',
- paymentType: 'RTGS',
- amount: 1250000,
- valueDate: vd(1),
- purposeCode: 'P0801',
- leiCode: '549300XYZAB7890',
- processingState: 'RETURNED',
- returnCode: 'R01',
- returnReason: 'Insufficient Funds',
- sentTimestamp: dt(1, 11, 25)
- },
- {
- batchId: 'H2H-20260324-02',
- instructionId: 'ERP77889701',
- utrNumber: 'PUNBR5202608619',
- remitterAcc: '50100221122334',
- remitterBranch: 'Delhi — Connaught',
- remitterUser: 'Sushma Iyer',
- remitterChannel: 'BRANCH',
- beneAcc: '50100998877665',
- beneIfsc: 'PUNB0123400',
- beneName: 'Madhavi Singh',
- beneBank: 'Punjab National Bank',
- paymentType: 'NEFT',
- amount: 42000,
- valueDate: vd(2),
- purposeCode: 'P1006',
- processingState: 'SETTLED',
- sentTimestamp: dt(2, 9, 35)
- },
- {
- batchId: 'H2H-20260324-02',
- instructionId: 'ERP77889702',
- utrNumber: 'YESBR5202608520',
- remitterAcc: '50100221122334',
- remitterBranch: 'Delhi — Connaught',
- remitterUser: 'Sushma Iyer',
- remitterChannel: 'BRANCH',
- beneAcc: '11223344556677',
- beneIfsc: 'YESB0000123',
- beneName: 'Sigma Constructions',
- beneBank: 'YES Bank',
- paymentType: 'RTGS',
- amount: 825000,
- valueDate: vd(2),
- purposeCode: 'P0102',
- processingState: 'PENDING',
- sentTimestamp: dt(2, 9, 38)
- },
- {
- batchId: 'H2H-20260323-07',
- instructionId: 'ERP77889601',
- utrNumber: 'CNRBR5202608417',
- remitterAcc: '50100887766554',
- remitterBranch: 'Chennai — T. Nagar',
- remitterUser: 'Ramesh Krishnan',
- remitterChannel: 'MOBILE',
- beneAcc: '99887766554433',
- beneIfsc: 'CNRB0001890',
- beneName: 'Padma Rao',
- beneBank: 'Canara Bank',
- paymentType: 'NEFT',
- amount: 9800,
- valueDate: vd(3),
- purposeCode: 'P1302',
- processingState: 'SETTLED',
- sentTimestamp: dt(3, 16, 15)
- },
- {
- batchId: 'H2H-20260323-07',
- instructionId: 'ERP77889602',
- utrNumber: 'BARBR5202608318',
- remitterAcc: '50100887766554',
- remitterBranch: 'Chennai — T. Nagar',
- remitterUser: 'Ramesh Krishnan',
- remitterChannel: 'BRANCH',
- beneAcc: '50100123456987',
- beneIfsc: 'BARB0VJVATA',
- beneName: 'Vijaya Traders',
- beneBank: 'Bank of Baroda',
- paymentType: 'NEFT',
- amount: 125000,
- valueDate: vd(3),
- purposeCode: 'P0802',
- processingState: 'VALIDATED',
- sentTimestamp: dt(3, 16, 20)
- },
- {
- batchId: 'H2H-20260322-03',
- instructionId: 'ERP77889501',
- utrNumber: 'IDFBR5202608215',
- remitterAcc: '50200445566778',
- remitterBranch: 'Pune — Koregaon Park',
- remitterUser: 'Vikram Joshi',
- remitterChannel: 'BRANCH',
- beneAcc: '70010023456789',
- beneIfsc: 'IDFB0040101',
- beneName: 'Pinnacle Realty Pvt Ltd',
- beneBank: 'IDFC FIRST Bank',
- paymentType: 'RTGS',
- amount: 3450000,
- valueDate: vd(4),
- purposeCode: 'P0202',
- leiCode: '549300QRSTUV5678',
- processingState: 'SETTLED',
- sentTimestamp: dt(4, 12, 45)
- },
- {
- batchId: 'H2H-20260322-03',
- instructionId: 'ERP77889502',
- utrNumber: 'HDFCR5202608116',
- remitterAcc: '50200445566778',
- remitterBranch: 'Pune — Koregaon Park',
- remitterUser: 'Vikram Joshi',
- remitterChannel: 'MOBILE',
- beneAcc: '50100654321789',
- beneIfsc: 'HDFC0009876',
- beneName: 'Neha Kapoor',
- beneBank: 'HDFC Bank',
- paymentType: 'NEFT',
- amount: 25000,
- valueDate: vd(4),
- purposeCode: 'P1301',
- processingState: 'SETTLED',
- sentTimestamp: dt(4, 12, 50)
- },
- {
- batchId: 'H2H-20260321-05',
- instructionId: 'ERP77889411',
- utrNumber: 'KKBKR5202608013',
- remitterAcc: '50200778899001',
- remitterBranch: 'Hyderabad — Banjara',
- remitterUser: 'Lakshmi Naidu',
- remitterChannel: 'BRANCH',
- beneAcc: '12345678900011',
- beneIfsc: 'KKBK0001899',
- beneName: 'Aarav Enterprises',
- beneBank: 'Kotak Mahindra Bank',
- paymentType: 'NEFT',
- amount: 67000,
- valueDate: vd(5),
- purposeCode: 'P0802',
- processingState: 'RETURNED',
- returnCode: 'R03',
- returnReason: 'Account Closed',
- sentTimestamp: dt(5, 10, 5)
- },
- {
- batchId: 'H2H-20260321-05',
- instructionId: 'ERP77889412',
- utrNumber: 'SBINR5202607914',
- remitterAcc: '50200778899001',
- remitterBranch: 'Hyderabad — Banjara',
- remitterUser: 'Lakshmi Naidu',
- remitterChannel: 'BRANCH',
- beneAcc: '50100445566778',
- beneIfsc: 'SBIN0008765',
- beneName: 'Spectra Components',
- beneBank: 'State Bank of India',
- paymentType: 'RTGS',
- amount: 980000,
- valueDate: vd(5),
- purposeCode: 'P0801',
- leiCode: '549300LMNOPQ2468',
- processingState: 'SETTLED',
- sentTimestamp: dt(5, 10, 10)
- },
- {
- batchId: 'H2H-20260320-06',
- instructionId: 'ERP77889301',
- utrNumber: 'ICICR5202607812',
- remitterAcc: '50100334455667',
- remitterBranch: 'Kolkata — Park Street',
- remitterUser: 'Arjun Banerjee',
- remitterChannel: 'MOBILE',
- beneAcc: '88990011223344',
- beneIfsc: 'ICIC0001234',
- beneName: 'Eastern Logistics',
- beneBank: 'ICICI Bank',
- paymentType: 'NEFT',
- amount: 188000,
- valueDate: vd(6),
- purposeCode: 'P0802',
- processingState: 'SETTLED',
- sentTimestamp: dt(6, 14, 30)
- },
- {
- batchId: 'H2H-20260320-06',
- instructionId: 'ERP77889302',
- utrNumber: 'AXISR5202607711',
- remitterAcc: '50100334455667',
- remitterBranch: 'Kolkata — Park Street',
- remitterUser: 'Arjun Banerjee',
- remitterChannel: 'BRANCH',
- beneAcc: '50200998877665',
- beneIfsc: 'UTIB0001234',
- beneName: 'Indrani Sen',
- beneBank: 'Axis Bank',
- paymentType: 'NEFT',
- amount: 5500,
- valueDate: vd(6),
- purposeCode: 'P1302',
- processingState: 'SETTLED',
- sentTimestamp: dt(6, 14, 32)
- }
- ];
+ return seedNeftRtgsTransactions();
}
}
+
+// ───────────────────── Shared mock seed (top-level export) ─────────────────────
+/**
+ * Mock NEFT / RTGS history. Exported so the entry form (and any per-sender history
+ * dialogs) can render from the same dataset without each component owning its own
+ * copy. Replace with an API call when the back-end is wired up.
+ */
+export function seedNeftRtgsTransactions(): NeftRtgsTxn[] {
+ const today = new Date();
+ const dt = (offset: number, h = 10, m = 0): string => {
+ const d = new Date(today);
+ d.setDate(d.getDate() - offset);
+ const pad = (n: number) => String(n).padStart(2, '0');
+ return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(h) + ':' + pad(m) + ':00';
+ };
+ const vd = (offset: number): string => dt(offset).slice(0, 10);
+
+ return [
+ {
+ batchId: 'H2H-20260326-01',
+ instructionId: 'ERP77889922',
+ utrNumber: 'KKBKR5202608511',
+ remitterAcc: '50200012345678',
+ remitterBranch: 'Mumbai — BKC',
+ remitterUser: 'Anita Desai',
+ remitterChannel: 'BRANCH',
+ beneAcc: '91000222333444',
+ beneIfsc: 'HDFC0001234',
+ beneName: 'Global Logistics Ltd',
+ beneBank: 'HDFC Bank',
+ paymentType: 'RTGS',
+ amount: 550000,
+ valueDate: vd(0),
+ purposeCode: 'P0102',
+ leiCode: '549300ABCDEF1234',
+ processingState: 'SETTLED',
+ sentTimestamp: dt(0, 14, 5)
+ },
+ {
+ batchId: 'H2H-20260326-01',
+ instructionId: 'ERP77889923',
+ utrNumber: 'HDFCR5202608712',
+ remitterAcc: '50200012345678',
+ remitterBranch: 'Mumbai — BKC',
+ remitterUser: 'Anita Desai',
+ remitterChannel: 'BRANCH',
+ beneAcc: '345678901234',
+ beneIfsc: 'ICIC0000567',
+ beneName: 'Bluestone Trading Co.',
+ beneBank: 'ICICI Bank',
+ paymentType: 'NEFT',
+ amount: 78500,
+ valueDate: vd(0),
+ purposeCode: 'P0802',
+ processingState: 'SENT',
+ sentTimestamp: dt(0, 14, 6)
+ },
+ {
+ batchId: 'H2H-20260325-04',
+ instructionId: 'ERP77889811',
+ utrNumber: 'SBINR5202608902',
+ remitterAcc: '50200099887766',
+ remitterBranch: 'Bengaluru — MG Road',
+ remitterUser: 'Karthik Rao',
+ remitterChannel: 'MOBILE',
+ beneAcc: '912345678901',
+ beneIfsc: 'SBIN0011223',
+ beneName: 'Anita Verma',
+ beneBank: 'State Bank of India',
+ paymentType: 'NEFT',
+ amount: 15000,
+ valueDate: vd(1),
+ purposeCode: 'P1301',
+ processingState: 'SETTLED',
+ sentTimestamp: dt(1, 11, 22)
+ },
+ {
+ batchId: 'H2H-20260325-04',
+ instructionId: 'ERP77889812',
+ utrNumber: 'AXISR5202608811',
+ remitterAcc: '50200099887766',
+ remitterBranch: 'Bengaluru — MG Road',
+ remitterUser: 'Karthik Rao',
+ remitterChannel: 'MOBILE',
+ beneAcc: '6789012345',
+ beneIfsc: 'UTIB0000456',
+ beneName: 'Rakesh Industries',
+ beneBank: 'Axis Bank',
+ paymentType: 'RTGS',
+ amount: 1250000,
+ valueDate: vd(1),
+ purposeCode: 'P0801',
+ leiCode: '549300XYZAB7890',
+ processingState: 'RETURNED',
+ returnCode: 'R01',
+ returnReason: 'Insufficient Funds',
+ sentTimestamp: dt(1, 11, 25)
+ },
+ {
+ batchId: 'H2H-20260324-02',
+ instructionId: 'ERP77889701',
+ utrNumber: 'PUNBR5202608619',
+ remitterAcc: '50100221122334',
+ remitterBranch: 'Delhi — Connaught',
+ remitterUser: 'Sushma Iyer',
+ remitterChannel: 'BRANCH',
+ beneAcc: '50100998877665',
+ beneIfsc: 'PUNB0123400',
+ beneName: 'Madhavi Singh',
+ beneBank: 'Punjab National Bank',
+ paymentType: 'NEFT',
+ amount: 42000,
+ valueDate: vd(2),
+ purposeCode: 'P1006',
+ processingState: 'SETTLED',
+ sentTimestamp: dt(2, 9, 35)
+ },
+ {
+ batchId: 'H2H-20260324-02',
+ instructionId: 'ERP77889702',
+ utrNumber: 'YESBR5202608520',
+ remitterAcc: '50100221122334',
+ remitterBranch: 'Delhi — Connaught',
+ remitterUser: 'Sushma Iyer',
+ remitterChannel: 'BRANCH',
+ beneAcc: '11223344556677',
+ beneIfsc: 'YESB0000123',
+ beneName: 'Sigma Constructions',
+ beneBank: 'YES Bank',
+ paymentType: 'RTGS',
+ amount: 825000,
+ valueDate: vd(2),
+ purposeCode: 'P0102',
+ processingState: 'PENDING',
+ sentTimestamp: dt(2, 9, 38)
+ },
+ {
+ batchId: 'H2H-20260323-07',
+ instructionId: 'ERP77889601',
+ utrNumber: 'CNRBR5202608417',
+ remitterAcc: '50100887766554',
+ remitterBranch: 'Chennai — T. Nagar',
+ remitterUser: 'Ramesh Krishnan',
+ remitterChannel: 'MOBILE',
+ beneAcc: '99887766554433',
+ beneIfsc: 'CNRB0001890',
+ beneName: 'Padma Rao',
+ beneBank: 'Canara Bank',
+ paymentType: 'NEFT',
+ amount: 9800,
+ valueDate: vd(3),
+ purposeCode: 'P1302',
+ processingState: 'SETTLED',
+ sentTimestamp: dt(3, 16, 15)
+ },
+ {
+ batchId: 'H2H-20260323-07',
+ instructionId: 'ERP77889602',
+ utrNumber: 'BARBR5202608318',
+ remitterAcc: '50100887766554',
+ remitterBranch: 'Chennai — T. Nagar',
+ remitterUser: 'Ramesh Krishnan',
+ remitterChannel: 'BRANCH',
+ beneAcc: '50100123456987',
+ beneIfsc: 'BARB0VJVATA',
+ beneName: 'Vijaya Traders',
+ beneBank: 'Bank of Baroda',
+ paymentType: 'NEFT',
+ amount: 125000,
+ valueDate: vd(3),
+ purposeCode: 'P0802',
+ processingState: 'VALIDATED',
+ sentTimestamp: dt(3, 16, 20)
+ },
+ {
+ batchId: 'H2H-20260322-03',
+ instructionId: 'ERP77889501',
+ utrNumber: 'IDFBR5202608215',
+ remitterAcc: '50200445566778',
+ remitterBranch: 'Pune — Koregaon Park',
+ remitterUser: 'Vikram Joshi',
+ remitterChannel: 'BRANCH',
+ beneAcc: '70010023456789',
+ beneIfsc: 'IDFB0040101',
+ beneName: 'Pinnacle Realty Pvt Ltd',
+ beneBank: 'IDFC FIRST Bank',
+ paymentType: 'RTGS',
+ amount: 3450000,
+ valueDate: vd(4),
+ purposeCode: 'P0202',
+ leiCode: '549300QRSTUV5678',
+ processingState: 'SETTLED',
+ sentTimestamp: dt(4, 12, 45)
+ },
+ {
+ batchId: 'H2H-20260322-03',
+ instructionId: 'ERP77889502',
+ utrNumber: 'HDFCR5202608116',
+ remitterAcc: '50200445566778',
+ remitterBranch: 'Pune — Koregaon Park',
+ remitterUser: 'Vikram Joshi',
+ remitterChannel: 'MOBILE',
+ beneAcc: '50100654321789',
+ beneIfsc: 'HDFC0009876',
+ beneName: 'Neha Kapoor',
+ beneBank: 'HDFC Bank',
+ paymentType: 'NEFT',
+ amount: 25000,
+ valueDate: vd(4),
+ purposeCode: 'P1301',
+ processingState: 'SETTLED',
+ sentTimestamp: dt(4, 12, 50)
+ },
+ {
+ batchId: 'H2H-20260321-05',
+ instructionId: 'ERP77889411',
+ utrNumber: 'KKBKR5202608013',
+ remitterAcc: '50200778899001',
+ remitterBranch: 'Hyderabad — Banjara',
+ remitterUser: 'Lakshmi Naidu',
+ remitterChannel: 'BRANCH',
+ beneAcc: '12345678900011',
+ beneIfsc: 'KKBK0001899',
+ beneName: 'Aarav Enterprises',
+ beneBank: 'Kotak Mahindra Bank',
+ paymentType: 'NEFT',
+ amount: 67000,
+ valueDate: vd(5),
+ purposeCode: 'P0802',
+ processingState: 'RETURNED',
+ returnCode: 'R03',
+ returnReason: 'Account Closed',
+ sentTimestamp: dt(5, 10, 5)
+ },
+ {
+ batchId: 'H2H-20260321-05',
+ instructionId: 'ERP77889412',
+ utrNumber: 'SBINR5202607914',
+ remitterAcc: '50200778899001',
+ remitterBranch: 'Hyderabad — Banjara',
+ remitterUser: 'Lakshmi Naidu',
+ remitterChannel: 'BRANCH',
+ beneAcc: '50100445566778',
+ beneIfsc: 'SBIN0008765',
+ beneName: 'Spectra Components',
+ beneBank: 'State Bank of India',
+ paymentType: 'RTGS',
+ amount: 980000,
+ valueDate: vd(5),
+ purposeCode: 'P0801',
+ leiCode: '549300LMNOPQ2468',
+ processingState: 'SETTLED',
+ sentTimestamp: dt(5, 10, 10)
+ },
+ {
+ batchId: 'H2H-20260320-06',
+ instructionId: 'ERP77889301',
+ utrNumber: 'ICICR5202607812',
+ remitterAcc: '50100334455667',
+ remitterBranch: 'Kolkata — Park Street',
+ remitterUser: 'Arjun Banerjee',
+ remitterChannel: 'MOBILE',
+ beneAcc: '88990011223344',
+ beneIfsc: 'ICIC0001234',
+ beneName: 'Eastern Logistics',
+ beneBank: 'ICICI Bank',
+ paymentType: 'NEFT',
+ amount: 188000,
+ valueDate: vd(6),
+ purposeCode: 'P0802',
+ processingState: 'SETTLED',
+ sentTimestamp: dt(6, 14, 30)
+ },
+ {
+ batchId: 'H2H-20260320-06',
+ instructionId: 'ERP77889302',
+ utrNumber: 'AXISR5202607711',
+ remitterAcc: '50100334455667',
+ remitterBranch: 'Kolkata — Park Street',
+ remitterUser: 'Arjun Banerjee',
+ remitterChannel: 'BRANCH',
+ beneAcc: '50200998877665',
+ beneIfsc: 'UTIB0001234',
+ beneName: 'Indrani Sen',
+ beneBank: 'Axis Bank',
+ paymentType: 'NEFT',
+ amount: 5500,
+ valueDate: vd(6),
+ purposeCode: 'P1302',
+ processingState: 'SETTLED',
+ sentTimestamp: dt(6, 14, 32)
+ }
+ ];
+}
diff --git a/src/app/digital/sender-history-dialog/sender-history-dialog.component.html b/src/app/digital/sender-history-dialog/sender-history-dialog.component.html
new file mode 100644
index 000000000..89ae644ba
--- /dev/null
+++ b/src/app/digital/sender-history-dialog/sender-history-dialog.component.html
@@ -0,0 +1,117 @@
+
+
+
+
+
+
+
+
+
{{ data.senderName }} · NEFT / RTGS · Today
+
+ @if (data.senderAccountNo) {
+ {{ data.senderAccountNo }} ·
+ }
+ {{ totalCount | number }} txns today ·
+ ₹{{ totalAmount | number: '1.0-0' }}
+
+
+
+
+
+
+
+
+
+
+
+
{{ totalCount | number }}
+
Total
+
+
+
₹{{ totalAmount | number: '1.0-0' }}
+
Value
+
+
+
{{ settledCount | number }}
+
Settled ({{ successRate | number: '1.1-1' }}%)
+
+
+
{{ pendingCount | number }}
+
In-Flight
+
+
+
{{ returnedCount | number }}
+
Returned
+
+
+
+
+
+ @if (filteredTxns.length === 0) {
+
+
+
+
No NEFT / RTGS transactions today for this sender.
+
This view shows only today's outward transfers. Older history isn't included here.
+
+
+ } @else {
+
+
+
+
+ UTR
+ Beneficiary
+ IFSC
+ Type
+ Amount
+ Value Date
+ State
+ Sent
+
+
+
+ @for (t of filteredTxns; track t.utrNumber) {
+
+ {{ t.utrNumber }}
+
+ {{ t.beneName }}
+ {{ t.beneAcc }} · {{ t.beneBank }}
+
+ {{ t.beneIfsc }}
+
+
+ {{ t.paymentType }}
+
+
+
+ ₹{{ t.amount | number: '1.2-2' }}
+
+ {{ t.valueDate | date: 'd MMM y' }}
+
+
+ {{ t.processingState }}
+
+ @if (t.returnCode) {
+ {{ t.returnCode }} · {{ t.returnReason }}
+ }
+
+ {{ t.sentTimestamp }}
+
+ }
+
+
+
+ }
+
+
diff --git a/src/app/digital/sender-history-dialog/sender-history-dialog.component.scss b/src/app/digital/sender-history-dialog/sender-history-dialog.component.scss
new file mode 100644
index 000000000..de1bdcac6
--- /dev/null
+++ b/src/app/digital/sender-history-dialog/sender-history-dialog.component.scss
@@ -0,0 +1,315 @@
+/**
+ * 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/.
+ */
+
+:host {
+ --shd-blue: #1d4ed8;
+ --shd-blue-d: #1e40af;
+ --shd-blue-l: #eff6ff;
+ --shd-teal: #0d9488;
+ --shd-teal-l: #ccfbf1;
+ --shd-green: #15803d;
+ --shd-green-l: #dcfce7;
+ --shd-amber: #d97706;
+ --shd-amber-l: #fef3c7;
+ --shd-red: #b91c1c;
+ --shd-red-l: #fee2e2;
+ --shd-purple: #6d28d9;
+ --shd-purple-l: #ede9fe;
+ --shd-g50: #f8fafc;
+ --shd-g100: #f1f5f9;
+ --shd-g200: #e2e8f0;
+ --shd-g300: #cbd5e1;
+ --shd-g500: #64748b;
+ --shd-g700: #334155;
+ --shd-g900: #0f172a;
+
+ display: block;
+}
+
+.shd-root {
+ background: #fff;
+ padding: 16px 20px 22px;
+ max-width: 940px;
+ width: 100%;
+ max-height: 84vh;
+ display: flex;
+ flex-direction: column;
+}
+
+/* ───────── Header ───────── */
+.shd-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 14px;
+ padding-bottom: 12px;
+ margin-bottom: 14px;
+ border-bottom: 1px solid var(--shd-g200);
+}
+
+.shd-head-l {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.shd-head-ic {
+ width: 40px;
+ height: 40px;
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 12px;
+ background: var(--shd-blue);
+ color: #fff;
+ font-size: 18px;
+}
+
+.shd-head-t {
+ margin: 0;
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--shd-g900);
+ line-height: 1.25;
+
+ small {
+ color: var(--shd-g500);
+ font-weight: 500;
+ font-size: 12.5px;
+ margin-left: 4px;
+ }
+}
+
+.shd-head-s {
+ margin-top: 3px;
+ font-size: 12px;
+ color: var(--shd-g500);
+
+ strong {
+ color: var(--shd-g900);
+ }
+}
+
+.shd-x {
+ width: 32px;
+ height: 32px;
+ border-radius: 999px;
+ border: 1px solid var(--shd-g200);
+ background: #fff;
+ color: var(--shd-g500);
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 13px;
+ flex-shrink: 0;
+ transition:
+ background 0.12s,
+ color 0.12s,
+ border-color 0.12s;
+
+ &:hover {
+ background: var(--shd-red-l);
+ color: var(--shd-red);
+ border-color: #fecaca;
+ }
+}
+
+/* ───────── KPI strip ───────── */
+.shd-kpis {
+ display: grid;
+ grid-template-columns: repeat(5, 1fr);
+ gap: 8px;
+ margin-bottom: 14px;
+
+ @media (width <= 700px) {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+.shd-kpi {
+ padding: 10px 12px;
+ border-radius: 10px;
+ background: var(--shd-blue-l);
+ border: 1px solid #c7d2fe;
+ color: var(--shd-g900);
+
+ &--blue {
+ background: var(--shd-blue-l);
+ border-color: #c7d2fe;
+ }
+
+ &--teal {
+ background: var(--shd-teal-l);
+ border-color: #99f6e4;
+ }
+
+ &--green {
+ background: var(--shd-green-l);
+ border-color: #bbf7d0;
+ }
+
+ &--amber {
+ background: var(--shd-amber-l);
+ border-color: #fde68a;
+ }
+
+ &--red {
+ background: var(--shd-red-l);
+ border-color: #fecaca;
+ }
+}
+
+.shd-kpi-n {
+ font-size: 16px;
+ font-weight: 800;
+ color: var(--shd-g900);
+ font-variant-numeric: tabular-nums;
+ line-height: 1.15;
+}
+
+.shd-kpi-l {
+ font-size: 10.5px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: var(--shd-g500);
+ margin-top: 2px;
+}
+
+/* ───────── Body / table ───────── */
+.shd-body {
+ flex: 1;
+ overflow: auto;
+}
+
+.shd-empty {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ padding: 18px 16px;
+ border: 1px dashed var(--shd-g300);
+ border-radius: 10px;
+ background: var(--shd-g50);
+ color: var(--shd-g700);
+
+ fa-icon {
+ color: var(--shd-blue);
+ font-size: 18px;
+ margin-top: 2px;
+ }
+
+ p {
+ margin: 4px 0 0;
+ font-size: 12.5px;
+ color: var(--shd-g500);
+ }
+}
+
+.shd-table-wrap {
+ overflow: auto;
+ border: 1px solid var(--shd-g200);
+ border-radius: 10px;
+}
+
+.shd-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12.5px;
+
+ th {
+ text-align: left;
+ padding: 10px 12px;
+ background: var(--shd-g50);
+ color: var(--shd-g500);
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ font-size: 10.5px;
+ white-space: nowrap;
+ border-bottom: 1px solid var(--shd-g200);
+ }
+
+ td {
+ padding: 10px 12px;
+ border-bottom: 1px solid var(--shd-g100);
+ vertical-align: top;
+ color: var(--shd-g900);
+ }
+
+ tr:last-child td {
+ border-bottom: 0;
+ }
+
+ .num {
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+ }
+
+ .mono {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ }
+
+ .small {
+ font-size: 11.5px;
+ }
+
+ .muted {
+ color: var(--shd-g500);
+ }
+}
+
+/* ───────── Pills ───────── */
+.shd-pill {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 9px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ background: var(--shd-g100);
+ color: var(--shd-g700);
+ white-space: nowrap;
+
+ &.mini {
+ padding: 1px 7px;
+ font-size: 10px;
+ }
+
+ &--blue {
+ background: var(--shd-blue-l);
+ color: var(--shd-blue-d);
+ }
+
+ &--purple {
+ background: var(--shd-purple-l);
+ color: var(--shd-purple);
+ }
+
+ &--green {
+ background: var(--shd-green-l);
+ color: var(--shd-green);
+ }
+
+ &--teal {
+ background: var(--shd-teal-l);
+ color: var(--shd-teal);
+ }
+
+ &--amber {
+ background: var(--shd-amber-l);
+ color: var(--shd-amber);
+ }
+
+ &--red {
+ background: var(--shd-red-l);
+ color: var(--shd-red);
+ }
+}
diff --git a/src/app/digital/sender-history-dialog/sender-history-dialog.component.ts b/src/app/digital/sender-history-dialog/sender-history-dialog.component.ts
new file mode 100644
index 000000000..f35e4e83d
--- /dev/null
+++ b/src/app/digital/sender-history-dialog/sender-history-dialog.component.ts
@@ -0,0 +1,113 @@
+/**
+ * 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 { CommonModule } from '@angular/common';
+import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
+import { MatTooltip } from '@angular/material/tooltip';
+import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+
+import { NeftRtgsTxn, ProcessingState } from '../neft-rtgs-report/neft-rtgs-report.component';
+
+export interface SenderHistoryDialogData {
+ /** Sender's name as shown on the sender chip. */
+ senderName: string;
+ /** Picked sender account number (for exact-match filtering). */
+ senderAccountNo: string;
+ /** Full transaction history to filter (already-seeded mock or live API rows). */
+ txns: NeftRtgsTxn[];
+}
+
+@Component({
+ selector: 'mifosx-sender-history-dialog',
+ templateUrl: './sender-history-dialog.component.html',
+ styleUrls: ['./sender-history-dialog.component.scss'],
+ imports: [
+ ...STANDALONE_SHARED_IMPORTS,
+ CommonModule,
+ FaIconComponent,
+ MatTooltip
+ ]
+})
+export class SenderHistoryDialogComponent {
+ private dialogRef = inject(MatDialogRef
);
+ data: SenderHistoryDialogData = inject(MAT_DIALOG_DATA);
+
+ /** Today's date as YYYY-MM-DD — matches the `valueDate` shape in the txn seed. */
+ private get todayKey(): string {
+ const d = new Date();
+ const pad = (n: number) => String(n).padStart(2, '0');
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
+ }
+
+ /**
+ * Today's NEFT/RTGS rows for this sender — matched by exact A/C number when
+ * available, otherwise by case-insensitive holder name (since the demo seed
+ * and operator account list don't share account numbers). Older value dates
+ * are excluded; this view is intentionally a today-only snapshot.
+ */
+ get filteredTxns(): NeftRtgsTxn[] {
+ const acc = (this.data.senderAccountNo || '').trim().toLowerCase();
+ const name = (this.data.senderName || '').trim().toLowerCase();
+ const today = this.todayKey;
+ return (this.data.txns || []).filter((t) => {
+ if (t.valueDate !== today) return false;
+ if (acc && t.remitterAcc.toLowerCase() === acc) return true;
+ if (name && t.remitterUser.toLowerCase() === name) return true;
+ return false;
+ });
+ }
+
+ // ─── KPI getters ───
+ get totalCount(): number {
+ return this.filteredTxns.length;
+ }
+ get totalAmount(): number {
+ return this.filteredTxns.reduce((s, t) => s + t.amount, 0);
+ }
+ get settledCount(): number {
+ return this.filteredTxns.filter((t) => t.processingState === 'SETTLED').length;
+ }
+ get pendingCount(): number {
+ return this.filteredTxns.filter((t) => [
+ 'UPLOADED',
+ 'VALIDATED',
+ 'SENT',
+ 'PENDING'
+ ].includes(t.processingState)).length;
+ }
+ get returnedCount(): number {
+ return this.filteredTxns.filter((t) => t.processingState === 'RETURNED').length;
+ }
+ get successRate(): number {
+ if (this.totalCount === 0) return 0;
+ return (this.settledCount / this.totalCount) * 100;
+ }
+
+ stateColor(state: ProcessingState): string {
+ switch (state) {
+ case 'SETTLED':
+ return 'green';
+ case 'RETURNED':
+ return 'red';
+ case 'SENT':
+ return 'blue';
+ case 'VALIDATED':
+ return 'teal';
+ case 'UPLOADED':
+ return 'amber';
+ case 'PENDING':
+ return 'amber';
+ }
+ }
+
+ close(): void {
+ this.dialogRef.close();
+ }
+}
diff --git a/src/app/digital/upi-report/upi-report.component.html b/src/app/digital/upi-report/upi-report.component.html
new file mode 100644
index 000000000..2cdd8d03e
--- /dev/null
+++ b/src/app/digital/upi-report/upi-report.component.html
@@ -0,0 +1,425 @@
+
+
+
+
+
+
+
+
+
UPI — Report & Reconciliation
+
+ Digital · Unified Payments Interface · Showing
+ {{ totalCount | number }} transactions worth
+ ₹{{ totalAmount | number: '1.0-0' }}
+
+
+
+
+
+ Export CSV
+
+
+
+
+
+
+
+
+
+
{{ totalCount | number }}
+
Total Transactions
+
{{ p2pCount }} P2P · {{ p2mCount }} P2M
+
+
+
+
+
+
₹{{ totalAmount | number: '1.0-0' }}
+
Total Value
+
avg ₹{{ avgTicket | number: '1.0-0' }} / txn
+
+
+
+
+
+
{{ successCount | number }}
+
Successful
+
success {{ successRate | number: '1.1-1' }}%
+
+
+
+
+
+
{{ pendingCount | number }}
+
In-Flight
+
initiated / pending
+
+
+
+
+
+
{{ failedCount | number }}
+
Failed / Expired
+
check failure codes
+
+
+
+
+
+
+ @for (t of tabs; track t.id) {
+
+ {{ t.label }}
+
+ }
+
+
+
+ @if (activeTab === 'trends') {
+
+
+ Daily Throughput (Last 7 Value Dates)
+ P2P
+ P2M
+
+
+ @for (b of trendBuckets; track b.date) {
+
+
+ {{ b.date | date: 'EEE' }}
+ {{ b.date | date: 'd MMM' }}
+
+
+
+ P2P {{ b.p2p }}
+ P2M {{ b.p2m }}
+
+
₹{{ b.amount | number: '1.0-0' }}
+
+ }
+
+
+ }
+
+
+ @if (activeTab === 'bank') {
+
+
+ Payee / Handler Bank Distribution
+
+
+
+
+
+ Bank
+ Count
+ Amount
+ Success
+ Pending
+ Failed
+
+
+
+ @for (r of bankRows; track r.bank) {
+
+
+ {{ r.bank }}
+
+ {{ r.count | number }}
+
+ ₹{{ r.amount | number: '1.0-0' }}
+
+
+ {{ r.success }}
+
+
+ {{ r.pending }}
+
+
+ {{ r.failed }}
+
+
+ }
+
+
+
+
+ }
+
+
+ @if (activeTab === 'date') {
+
+
+ Value-Date Aggregation
+
+
+
+
+
+ Value Date
+ Count
+ P2P
+ P2M
+ Amount
+
+
+
+ @for (r of dateRows; track r.date) {
+
+ {{ r.date | date: 'EEE, d MMM y' }}
+ {{ r.count | number }}
+
+ {{ r.p2p }}
+
+
+ {{ r.p2m }}
+
+
+ ₹{{ r.amount | number: '1.0-0' }}
+
+
+ }
+
+
+
+
+ }
+
+
+ @if (activeTab === 'pending') {
+
+
+
+ Processing-State Funnel
+
+
+
+ Initiated / Pending
+ {{ pendingCount | number }}
+
+
+ Successful
+ {{ successCount | number }}
+
+
+ Failed / Expired / Declined
+ {{ failedCount | number }}
+
+
+
+
+
+
+
+
+
+
+ Failure-Code Breakdown
+
+ @if (failureCodeRows.length === 0) {
+
No failed transactions in current window.
+ } @else {
+
+
+
+ Code
+ Reason
+ Count
+ Amount
+
+
+
+ @for (r of failureCodeRows; track r.code) {
+
+
+ {{ r.code }}
+
+ {{ r.reason }}
+ {{ r.count }}
+ ₹{{ r.amount | number: '1.0-0' }}
+
+ }
+
+
+ }
+
+
+ }
+
+
+ @if (activeTab === 'app') {
+
+
+ UPI App Mix
+
+
+ @for (r of appRows; track r.app) {
+
+
+
+
+
+
{{ r.app }}
+
{{ r.count | number }} txns
+
₹{{ r.amount | number: '1.0-0' }}
+
+
+
+
{{ r.share | number: '1.1-1' }}% of value
+
+
+ }
+
+
+ }
+
+
+ @if (activeTab === 'user') {
+
+
+ Top Payers
+
+
+
+
+
+ Payer
+ Branch
+ Txns
+ Amount
+
+
+
+ @for (r of userRows; track r.user + r.branch) {
+
+
+ {{ r.user }}
+
+ {{ r.branch }}
+ {{ r.count | number }}
+
+ ₹{{ r.amount | number: '1.0-0' }}
+
+
+ }
+
+
+
+
+ }
+
+
+
+
+
Transactions
+
+
+
+
+
+
+ All Types
+ P2P · Person-to-Person
+ P2M · Person-to-Merchant
+ Collect Request
+
+
+ All States
+ Initiated
+ Pending
+ Success
+ Failed
+ Expired
+ Declined
+
+ @if (search || typeFilter || stateFilter) {
+
+ Clear
+
+ }
+
+
+
+
+
+
+ RRN / UPI Ref
+ Payer
+ Payee
+ App
+ Type
+ Amount
+ Value Date
+ State
+ Sent
+
+
+
+ @for (t of filteredTxns; track t.rrn) {
+
+
+ {{ t.rrn }}
+ {{ t.upiRef }}
+
+
+ {{ t.payerName }}
+ {{ t.payerVpa }}
+
+
+
+ {{ t.payeeName }}
+ @if (t.payeeType === 'MERCHANT') {
+ Merchant
+ }
+
+ {{ t.payeeVpa }} · {{ t.payeeBank }}
+
+
+ {{ t.payerApp }}
+
+
+
+ {{ t.txnType }}
+
+
+
+ ₹{{ t.amount | number: '1.2-2' }}
+
+ {{ t.valueDate | date: 'd MMM y' }}
+
+
+ {{ t.state }}
+
+ @if (t.failureCode) {
+ {{ t.failureCode }} · {{ t.failureReason }}
+ }
+
+ {{ t.sentTimestamp }}
+
+ }
+ @if (filteredTxns.length === 0) {
+
+ No transactions match the current filters.
+
+ }
+
+
+
+
+
diff --git a/src/app/digital/upi-report/upi-report.component.ts b/src/app/digital/upi-report/upi-report.component.ts
new file mode 100644
index 000000000..dc289c288
--- /dev/null
+++ b/src/app/digital/upi-report/upi-report.component.ts
@@ -0,0 +1,618 @@
+/**
+ * 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 } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+import { MatTooltip } from '@angular/material/tooltip';
+import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+
+/** Person-to-Person, Person-to-Merchant, or Collect-Request (payee-initiated). */
+export type UpiTxnType = 'P2P' | 'P2M' | 'COLLECT';
+export type UpiState = 'INITIATED' | 'PENDING' | 'SUCCESS' | 'FAILED' | 'EXPIRED' | 'DECLINED';
+export type UpiApp = 'BHIM' | 'PhonePe' | 'GPay' | 'Paytm' | 'Amazon Pay' | 'WhatsApp' | 'Other';
+
+export interface UpiTxn {
+ rrn: string;
+ upiRef: string;
+ payerVpa: string;
+ payerName: string;
+ payerBranch: string;
+ payerApp: UpiApp;
+ payeeVpa: string;
+ payeeName: string;
+ payeeBank: string;
+ payeeType: 'PERSON' | 'MERCHANT';
+ txnType: UpiTxnType;
+ amount: number;
+ valueDate: string;
+ state: UpiState;
+ failureCode?: string;
+ failureReason?: string;
+ sentTimestamp: string;
+}
+
+type ReportTab = 'trends' | 'bank' | 'date' | 'pending' | 'app' | 'user';
+
+interface BankRow {
+ bank: string;
+ count: number;
+ amount: number;
+ success: number;
+ pending: number;
+ failed: number;
+}
+
+interface DateRow {
+ date: string;
+ count: number;
+ amount: number;
+ p2p: number;
+ p2m: number;
+}
+
+interface UserRow {
+ user: string;
+ count: number;
+ amount: number;
+ branch: string;
+}
+
+interface AppRow {
+ app: UpiApp;
+ count: number;
+ amount: number;
+ share: number;
+}
+
+@Component({
+ selector: 'mifosx-upi-report',
+ templateUrl: './upi-report.component.html',
+ // Reuse the NEFT/RTGS report SCSS so all three rail-reports share the same shell.
+ styleUrls: ['../neft-rtgs-report/neft-rtgs-report.component.scss'],
+ imports: [
+ ...STANDALONE_SHARED_IMPORTS,
+ FormsModule,
+ FaIconComponent,
+ MatTooltip
+ ]
+})
+export class UpiReportComponent implements OnInit {
+ activeTab: ReportTab = 'trends';
+
+ search = '';
+ typeFilter: '' | UpiTxnType = '';
+ stateFilter: '' | UpiState = '';
+
+ txns: UpiTxn[] = [];
+
+ readonly tabs: { id: ReportTab; label: string; icon: string }[] = [
+ { id: 'trends', label: 'Trends', icon: 'chart-line' },
+ { id: 'bank', label: 'Handler-Bank', icon: 'university' },
+ { id: 'date', label: 'Date-wise', icon: 'calendar-alt' },
+ { id: 'pending', label: 'Success / Pending / Failed', icon: 'tasks' },
+ { id: 'app', label: 'App-wise', icon: 'mobile-alt' },
+ { id: 'user', label: 'User-wise', icon: 'user-tie' }
+ ];
+
+ ngOnInit(): void {
+ this.txns = seedUpiTransactions();
+ }
+
+ // ───────────────────── KPIs ─────────────────────
+ get totalCount(): number {
+ return this.txns.length;
+ }
+ get totalAmount(): number {
+ return this.txns.reduce((s, t) => s + t.amount, 0);
+ }
+ get successCount(): number {
+ return this.txns.filter((t) => t.state === 'SUCCESS').length;
+ }
+ get pendingCount(): number {
+ return this.txns.filter((t) => [
+ 'INITIATED',
+ 'PENDING'
+ ].includes(t.state)).length;
+ }
+ get failedCount(): number {
+ return this.txns.filter((t) => [
+ 'FAILED',
+ 'EXPIRED',
+ 'DECLINED'
+ ].includes(t.state)).length;
+ }
+ get p2pCount(): number {
+ return this.txns.filter((t) => t.txnType === 'P2P').length;
+ }
+ get p2mCount(): number {
+ return this.txns.filter((t) => t.txnType === 'P2M').length;
+ }
+ get successRate(): number {
+ if (this.totalCount === 0) return 0;
+ return (this.successCount / this.totalCount) * 100;
+ }
+ get avgTicket(): number {
+ if (this.totalCount === 0) return 0;
+ return this.totalAmount / this.totalCount;
+ }
+
+ // ───────────────────── Filtered list ─────────────────────
+ get filteredTxns(): UpiTxn[] {
+ const q = this.search.trim().toLowerCase();
+ return this.txns.filter((t) => {
+ if (this.typeFilter && t.txnType !== this.typeFilter) return false;
+ if (this.stateFilter && t.state !== this.stateFilter) return false;
+ if (!q) return true;
+ return (
+ t.rrn.toLowerCase().includes(q) ||
+ t.upiRef.toLowerCase().includes(q) ||
+ t.payerVpa.toLowerCase().includes(q) ||
+ t.payerName.toLowerCase().includes(q) ||
+ t.payeeVpa.toLowerCase().includes(q) ||
+ t.payeeName.toLowerCase().includes(q) ||
+ t.payeeBank.toLowerCase().includes(q)
+ );
+ });
+ }
+
+ // ───────────────────── Aggregations ─────────────────────
+ get bankRows(): BankRow[] {
+ const map = new Map();
+ for (const t of this.txns) {
+ const key = t.payeeBank;
+ if (!map.has(key)) {
+ map.set(key, { bank: key, count: 0, amount: 0, success: 0, pending: 0, failed: 0 });
+ }
+ const r = map.get(key)!;
+ r.count++;
+ r.amount += t.amount;
+ if (t.state === 'SUCCESS') r.success++;
+ else if (t.state === 'FAILED' || t.state === 'EXPIRED' || t.state === 'DECLINED') r.failed++;
+ else r.pending++;
+ }
+ return Array.from(map.values()).sort((a, b) => b.amount - a.amount);
+ }
+
+ get dateRows(): DateRow[] {
+ const map = new Map();
+ for (const t of this.txns) {
+ if (!map.has(t.valueDate)) {
+ map.set(t.valueDate, { date: t.valueDate, count: 0, amount: 0, p2p: 0, p2m: 0 });
+ }
+ const r = map.get(t.valueDate)!;
+ r.count++;
+ r.amount += t.amount;
+ if (t.txnType === 'P2P') r.p2p++;
+ else if (t.txnType === 'P2M') r.p2m++;
+ }
+ return Array.from(map.values()).sort((a, b) => (a.date < b.date ? 1 : -1));
+ }
+
+ get userRows(): UserRow[] {
+ const map = new Map();
+ for (const t of this.txns) {
+ const key = `${t.payerName}|${t.payerBranch}`;
+ if (!map.has(key)) {
+ map.set(key, { user: t.payerName, branch: t.payerBranch, count: 0, amount: 0 });
+ }
+ const r = map.get(key)!;
+ r.count++;
+ r.amount += t.amount;
+ }
+ return Array.from(map.values()).sort((a, b) => b.amount - a.amount);
+ }
+
+ get appRows(): AppRow[] {
+ const total = this.totalAmount || 1;
+ const map = new Map();
+ for (const t of this.txns) {
+ if (!map.has(t.payerApp)) {
+ map.set(t.payerApp, { app: t.payerApp, count: 0, amount: 0, share: 0 });
+ }
+ const r = map.get(t.payerApp)!;
+ r.count++;
+ r.amount += t.amount;
+ }
+ map.forEach((r) => (r.share = (r.amount / total) * 100));
+ return Array.from(map.values()).sort((a, b) => b.amount - a.amount);
+ }
+
+ get trendBuckets(): DateRow[] {
+ return this.dateRows.slice(0, 7).reverse();
+ }
+
+ get trendMaxAmount(): number {
+ const buckets = this.trendBuckets;
+ if (!buckets.length) return 1;
+ return Math.max(...buckets.map((b) => b.amount));
+ }
+
+ get failureCodeRows(): { code: string; reason: string; count: number; amount: number }[] {
+ const map = new Map();
+ for (const t of this.txns) {
+ const failed = t.state === 'FAILED' || t.state === 'EXPIRED' || t.state === 'DECLINED';
+ if (!failed || !t.failureCode) continue;
+ if (!map.has(t.failureCode)) {
+ map.set(t.failureCode, {
+ code: t.failureCode,
+ reason: t.failureReason || '',
+ count: 0,
+ amount: 0
+ });
+ }
+ const r = map.get(t.failureCode)!;
+ r.count++;
+ r.amount += t.amount;
+ }
+ return Array.from(map.values()).sort((a, b) => b.count - a.count);
+ }
+
+ // ───────────────────── Actions ─────────────────────
+ showTab(tab: ReportTab): void {
+ this.activeTab = tab;
+ }
+
+ exportCsv(): void {
+ const headers = [
+ 'RRN',
+ 'UPI Ref',
+ 'Payer VPA',
+ 'Payer Name',
+ 'Payer App',
+ 'Payee VPA',
+ 'Payee Name',
+ 'Payee Bank',
+ 'Payee Type',
+ 'Txn Type',
+ 'Amount',
+ 'Value Date',
+ 'State',
+ 'Failure Code',
+ 'Sent Timestamp'
+ ];
+ const rows: string[][] = [headers];
+ for (const t of this.filteredTxns) {
+ rows.push([
+ t.rrn,
+ t.upiRef,
+ t.payerVpa,
+ t.payerName,
+ t.payerApp,
+ t.payeeVpa,
+ t.payeeName,
+ t.payeeBank,
+ t.payeeType,
+ t.txnType,
+ t.amount.toFixed(2),
+ t.valueDate,
+ t.state,
+ t.failureCode || '',
+ t.sentTimestamp
+ ]);
+ }
+ const escape = (v: string): string => {
+ const s = (v || '').replace(/"/g, '""');
+ return /[,"\n]/.test(s) ? `"${s}"` : s;
+ };
+ const csv = rows.map((r) => r.map(escape).join(',')).join('\n') + '\n';
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `UPI_Report_${new Date().toISOString().slice(0, 10)}.csv`;
+ a.click();
+ URL.revokeObjectURL(url);
+ }
+
+ clearFilters(): void {
+ this.search = '';
+ this.typeFilter = '';
+ this.stateFilter = '';
+ }
+
+ // ───────────────────── Helpers ─────────────────────
+ stateColor(state: UpiState): string {
+ switch (state) {
+ case 'SUCCESS':
+ return 'green';
+ case 'FAILED':
+ case 'EXPIRED':
+ case 'DECLINED':
+ return 'red';
+ case 'INITIATED':
+ return 'teal';
+ case 'PENDING':
+ return 'amber';
+ }
+ }
+
+ trendBarHeight(amount: number): number {
+ return Math.max(6, Math.round((amount / this.trendMaxAmount) * 100));
+ }
+}
+
+// ───────────────────── Shared mock seed (top-level export) ─────────────────────
+/**
+ * Mock UPI history. Exported so any per-sender history dialogs (or future
+ * dashboards) can render from the same dataset. Replace with an API call.
+ */
+export function seedUpiTransactions(): UpiTxn[] {
+ const today = new Date();
+ const dt = (offset: number, h = 10, m = 0): string => {
+ const d = new Date(today);
+ d.setDate(d.getDate() - offset);
+ const pad = (n: number) => String(n).padStart(2, '0');
+ return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(h) + ':' + pad(m) + ':00';
+ };
+ const vd = (offset: number): string => dt(offset).slice(0, 10);
+
+ return [
+ {
+ rrn: 'UPI52608001',
+ upiRef: 'YBL5202608111',
+ payerVpa: 'anita.desai@ybl',
+ payerName: 'Anita Desai',
+ payerBranch: 'Mumbai — BKC',
+ payerApp: 'PhonePe',
+ payeeVpa: 'amazon@apb',
+ payeeName: 'Amazon Pay India',
+ payeeBank: 'Axis Bank',
+ payeeType: 'MERCHANT',
+ txnType: 'P2M',
+ amount: 1899,
+ valueDate: vd(0),
+ state: 'SUCCESS',
+ sentTimestamp: dt(0, 9, 14)
+ },
+ {
+ rrn: 'UPI52608002',
+ upiRef: 'OKAXIS520260822',
+ payerVpa: 'anita.desai@ybl',
+ payerName: 'Anita Desai',
+ payerBranch: 'Mumbai — BKC',
+ payerApp: 'PhonePe',
+ payeeVpa: 'rohan.m@oksbi',
+ payeeName: 'Rohan S. Mehta',
+ payeeBank: 'State Bank of India',
+ payeeType: 'PERSON',
+ txnType: 'P2P',
+ amount: 2500,
+ valueDate: vd(0),
+ state: 'SUCCESS',
+ sentTimestamp: dt(0, 10, 38)
+ },
+ {
+ rrn: 'UPI52608003',
+ upiRef: 'OKHDFC520260833',
+ payerVpa: 'karthik@okhdfcbank',
+ payerName: 'Karthik Rao',
+ payerBranch: 'Bengaluru — MG Road',
+ payerApp: 'GPay',
+ payeeVpa: 'swiggy@hdfcbank',
+ payeeName: 'Swiggy',
+ payeeBank: 'HDFC Bank',
+ payeeType: 'MERCHANT',
+ txnType: 'P2M',
+ amount: 645,
+ valueDate: vd(0),
+ state: 'SUCCESS',
+ sentTimestamp: dt(0, 13, 22)
+ },
+ {
+ rrn: 'UPI52608004',
+ upiRef: 'BHIM5202608044',
+ payerVpa: 'sushma.i@upi',
+ payerName: 'Sushma Iyer',
+ payerBranch: 'Delhi — Connaught',
+ payerApp: 'BHIM',
+ payeeVpa: 'uber@okhdfcbank',
+ payeeName: 'Uber India',
+ payeeBank: 'HDFC Bank',
+ payeeType: 'MERCHANT',
+ txnType: 'P2M',
+ amount: 248,
+ valueDate: vd(1),
+ state: 'FAILED',
+ failureCode: 'U28',
+ failureReason: 'Beneficiary Account is Frozen',
+ sentTimestamp: dt(1, 18, 5)
+ },
+ {
+ rrn: 'UPI52608005',
+ upiRef: 'OKICICI520260855',
+ payerVpa: 'ramesh.k@oklicici',
+ payerName: 'Ramesh Krishnan',
+ payerBranch: 'Chennai — T. Nagar',
+ payerApp: 'Paytm',
+ payeeVpa: 'padma.r@upi',
+ payeeName: 'Padma Rao',
+ payeeBank: 'Canara Bank',
+ payeeType: 'PERSON',
+ txnType: 'P2P',
+ amount: 1200,
+ valueDate: vd(1),
+ state: 'SUCCESS',
+ sentTimestamp: dt(1, 11, 27)
+ },
+ {
+ rrn: 'UPI52608006',
+ upiRef: 'OKAXIS520260866',
+ payerVpa: 'vikram.j@axl',
+ payerName: 'Vikram Joshi',
+ payerBranch: 'Pune — Koregaon Park',
+ payerApp: 'GPay',
+ payeeVpa: 'flipkart@apb',
+ payeeName: 'Flipkart',
+ payeeBank: 'Axis Bank',
+ payeeType: 'MERCHANT',
+ txnType: 'P2M',
+ amount: 4799,
+ valueDate: vd(2),
+ state: 'SUCCESS',
+ sentTimestamp: dt(2, 16, 48)
+ },
+ {
+ rrn: 'UPI52608007',
+ upiRef: 'BHIM5202608077',
+ payerVpa: 'lakshmi.n@upi',
+ payerName: 'Lakshmi Naidu',
+ payerBranch: 'Hyderabad — Banjara',
+ payerApp: 'BHIM',
+ payeeVpa: 'aarav.ent@hdfcbank',
+ payeeName: 'Aarav Enterprises',
+ payeeBank: 'Kotak Mahindra Bank',
+ payeeType: 'MERCHANT',
+ txnType: 'P2M',
+ amount: 11500,
+ valueDate: vd(2),
+ state: 'PENDING',
+ sentTimestamp: dt(2, 9, 11)
+ },
+ {
+ rrn: 'UPI52608008',
+ upiRef: 'OKWA5202608088',
+ payerVpa: 'arjun.b@oklicici',
+ payerName: 'Arjun Banerjee',
+ payerBranch: 'Kolkata — Park Street',
+ payerApp: 'WhatsApp',
+ payeeVpa: 'indrani.sen@axl',
+ payeeName: 'Indrani Sen',
+ payeeBank: 'Axis Bank',
+ payeeType: 'PERSON',
+ txnType: 'P2P',
+ amount: 800,
+ valueDate: vd(3),
+ state: 'SUCCESS',
+ sentTimestamp: dt(3, 14, 56)
+ },
+ {
+ rrn: 'UPI52608009',
+ upiRef: 'OKAMP520260899',
+ payerVpa: 'anita.desai@apl',
+ payerName: 'Anita Desai',
+ payerBranch: 'Mumbai — BKC',
+ payerApp: 'Amazon Pay',
+ payeeVpa: 'amazon@apb',
+ payeeName: 'Amazon Pay India',
+ payeeBank: 'Axis Bank',
+ payeeType: 'MERCHANT',
+ txnType: 'P2M',
+ amount: 2349,
+ valueDate: vd(3),
+ state: 'DECLINED',
+ failureCode: 'U30',
+ failureReason: 'Daily Limit Exceeded',
+ sentTimestamp: dt(3, 21, 32)
+ },
+ {
+ rrn: 'UPI52608010',
+ upiRef: 'OKPAYTM5202608010',
+ payerVpa: 'karthik@paytm',
+ payerName: 'Karthik Rao',
+ payerBranch: 'Bengaluru — MG Road',
+ payerApp: 'Paytm',
+ payeeVpa: 'zomato@paytm',
+ payeeName: 'Zomato',
+ payeeBank: 'Paytm Payments Bank',
+ payeeType: 'MERCHANT',
+ txnType: 'P2M',
+ amount: 420,
+ valueDate: vd(4),
+ state: 'SUCCESS',
+ sentTimestamp: dt(4, 20, 5)
+ },
+ {
+ rrn: 'UPI52608011',
+ upiRef: 'OKICICI520260811',
+ payerVpa: 'sushma.i@upi',
+ payerName: 'Sushma Iyer',
+ payerBranch: 'Delhi — Connaught',
+ payerApp: 'BHIM',
+ payeeVpa: 'madhavi.s@upi',
+ payeeName: 'Madhavi Singh',
+ payeeBank: 'Punjab National Bank',
+ payeeType: 'PERSON',
+ txnType: 'P2P',
+ amount: 6500,
+ valueDate: vd(4),
+ state: 'SUCCESS',
+ sentTimestamp: dt(4, 12, 14)
+ },
+ {
+ rrn: 'UPI52608012',
+ upiRef: 'OKHDFC520260812',
+ payerVpa: 'ramesh.k@okhdfcbank',
+ payerName: 'Ramesh Krishnan',
+ payerBranch: 'Chennai — T. Nagar',
+ payerApp: 'GPay',
+ payeeVpa: 'bbasket@axl',
+ payeeName: 'Big Basket',
+ payeeBank: 'Axis Bank',
+ payeeType: 'MERCHANT',
+ txnType: 'P2M',
+ amount: 1380,
+ valueDate: vd(5),
+ state: 'SUCCESS',
+ sentTimestamp: dt(5, 8, 49)
+ },
+ {
+ rrn: 'UPI52608013',
+ upiRef: 'OKAXIS520260813',
+ payerVpa: 'lakshmi.n@upi',
+ payerName: 'Lakshmi Naidu',
+ payerBranch: 'Hyderabad — Banjara',
+ payerApp: 'PhonePe',
+ payeeVpa: 'spectra.cc@axl',
+ payeeName: 'Spectra Components',
+ payeeBank: 'State Bank of India',
+ payeeType: 'MERCHANT',
+ txnType: 'P2M',
+ amount: 28999,
+ valueDate: vd(5),
+ state: 'EXPIRED',
+ failureCode: 'U23',
+ failureReason: 'Collect Request Expired',
+ sentTimestamp: dt(5, 15, 18)
+ },
+ {
+ rrn: 'UPI52608014',
+ upiRef: 'OKICICI520260814',
+ payerVpa: 'arjun.b@oklicici',
+ payerName: 'Arjun Banerjee',
+ payerBranch: 'Kolkata — Park Street',
+ payerApp: 'GPay',
+ payeeVpa: 'aircel@okhdfcbank',
+ payeeName: 'Airtel Recharge',
+ payeeBank: 'HDFC Bank',
+ payeeType: 'MERCHANT',
+ txnType: 'P2M',
+ amount: 349,
+ valueDate: vd(6),
+ state: 'SUCCESS',
+ sentTimestamp: dt(6, 10, 2)
+ },
+ {
+ rrn: 'UPI52608015',
+ upiRef: 'OKAXIS520260815',
+ payerVpa: 'vikram.j@axl',
+ payerName: 'Vikram Joshi',
+ payerBranch: 'Pune — Koregaon Park',
+ payerApp: 'WhatsApp',
+ payeeVpa: 'neha.k@upi',
+ payeeName: 'Neha Kapoor',
+ payeeBank: 'HDFC Bank',
+ payeeType: 'PERSON',
+ txnType: 'P2P',
+ amount: 1750,
+ valueDate: vd(6),
+ state: 'SUCCESS',
+ sentTimestamp: dt(6, 17, 41)
+ }
+ ];
+}
diff --git a/src/app/letter-mgmt/certificates/balance-certificate/balance-certificate.component.html b/src/app/letter-mgmt/certificates/balance-certificate/balance-certificate.component.html
new file mode 100644
index 000000000..aaff1cec8
--- /dev/null
+++ b/src/app/letter-mgmt/certificates/balance-certificate/balance-certificate.component.html
@@ -0,0 +1,152 @@
+
+
+
+
+
+
+
+
Balance Certificate
+
Generate an "as-on-date" balance certificate for any deposit / current account.
+
+
+
+
+
+
+
+
+
+
{{ filteredRecords.length | number }}
+
Accounts
+
+
+
+
+
+
₹{{ totalBalance() | number: '1.0-0' }}
+
Balance (as on {{ asOnDate | date: 'd MMM y' }})
+
+
+
+
+
+
{{ asOnDate | date: 'd MMM y' }}
+
Snapshot Date
+
+
+
+
+
+
+
+
Account / Customer / PAN
+
+
+
+
+
+
+
+ Account type
+
+ All types
+ @for (o of accountTypeOptions; track o.value) {
+ {{ o.label }}
+ }
+
+
+
+
+ Balance as on
+
+
+
+ @if (search || accountTypeFilter) {
+
Clear
+ }
+
+
+
+
+
+
+
+
+ Customer · A/C
+ Type
+ Branch
+ Opened
+ Available Bal.
+ Accrued Int.
+ Ledger Bal.
+ Actions
+
+
+
+ @for (r of filteredRecords; track r.accountNo) {
+
+
+ {{ r.customerName }}
+ {{ r.accountNo }} · {{ r.customerId }} · PAN {{ r.customerPan }}
+ @if (r.jointHolders) {
+ Jointly with {{ r.jointHolders }}
+ }
+
+
+ {{ accountTypeLabel(r.accountType) }}
+ {{ r.productName }}
+
+ {{ r.branch }}
+ {{ r.openedOn | date: 'd MMM y' }}
+
+ ₹{{ r.availableBalance | number: '1.2-2' }}
+
+
+ @if (r.accruedInterest > 0) {
+ ₹{{ r.accruedInterest | number: '1.2-2' }}
+ } @else {
+ —
+ }
+
+ ₹{{ r.ledgerBalance | number: '1.2-2' }}
+
+
+
+
+
+
+
+
+
+
+
+ }
+ @if (filteredRecords.length === 0) {
+
+
+
+ No accounts match the current filters.
+
+
+ }
+
+
+
+
+
diff --git a/src/app/letter-mgmt/certificates/balance-certificate/balance-certificate.component.ts b/src/app/letter-mgmt/certificates/balance-certificate/balance-certificate.component.ts
new file mode 100644
index 000000000..ccfd806e5
--- /dev/null
+++ b/src/app/letter-mgmt/certificates/balance-certificate/balance-certificate.component.ts
@@ -0,0 +1,234 @@
+/**
+ * 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, OnInit } 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 { MatSnackBar } from '@angular/material/snack-bar';
+import { MatTooltip } from '@angular/material/tooltip';
+import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+
+import {
+ CertificatePreviewDialogComponent,
+ CertificatePreviewDialogData
+} from '../certificate-preview-dialog/certificate-preview-dialog.component';
+import { downloadBalanceCertificatePdf } from '../certificate-pdf';
+
+export type BalanceAccountType = 'SAVINGS' | 'CURRENT' | 'FIXED_DEPOSIT' | 'RD' | 'NRE' | 'NRO';
+
+export interface BalanceCertificateRecord {
+ accountNo: string;
+ customerId: string;
+ customerName: string;
+ customerPan: string;
+ customerAddress: string;
+ branch: string;
+ accountType: BalanceAccountType;
+ productName: string;
+ openedOn: string;
+ /** Balance available as on the snapshot date. */
+ availableBalance: number;
+ /** Total ledger balance (incl. accrued interest, hold amounts). */
+ ledgerBalance: number;
+ /** Accrued but uncredited interest. */
+ accruedInterest: number;
+ /** Interest rate p.a. (for FDs / RD / interest-bearing accounts). */
+ interestRate?: number;
+ /** Joint-holder names if any. */
+ jointHolders?: string;
+}
+
+@Component({
+ selector: 'mifosx-balance-certificate',
+ templateUrl: './balance-certificate.component.html',
+ styleUrls: ['../certificates.shared.scss'],
+ imports: [
+ ...STANDALONE_SHARED_IMPORTS,
+ CommonModule,
+ FormsModule,
+ FaIconComponent,
+ MatTooltip
+ ]
+})
+export class BalanceCertificateComponent implements OnInit {
+ private dialog = inject(MatDialog);
+ private snack = inject(MatSnackBar);
+
+ // ── Filters ──
+ search = '';
+ accountTypeFilter: '' | BalanceAccountType = '';
+ /** Snapshot date for the balance "as on". */
+ asOnDate = todayIso();
+
+ records: BalanceCertificateRecord[] = [];
+
+ readonly accountTypeOptions: { value: BalanceAccountType; label: string }[] = [
+ { value: 'SAVINGS', label: 'Savings' },
+ { value: 'CURRENT', label: 'Current' },
+ { value: 'FIXED_DEPOSIT', label: 'Fixed Deposit' },
+ { value: 'RD', label: 'Recurring Deposit' },
+ { value: 'NRE', label: 'NRE Savings' },
+ { value: 'NRO', label: 'NRO Savings' }
+ ];
+
+ ngOnInit(): void {
+ this.records = seedBalanceCertificates();
+ }
+
+ get filteredRecords(): BalanceCertificateRecord[] {
+ const q = this.search.trim().toLowerCase();
+ return this.records.filter((r) => {
+ if (this.accountTypeFilter && r.accountType !== this.accountTypeFilter) return false;
+ if (!q) return true;
+ return (
+ r.accountNo.toLowerCase().includes(q) ||
+ r.customerName.toLowerCase().includes(q) ||
+ r.customerId.toLowerCase().includes(q) ||
+ r.customerPan.toLowerCase().includes(q)
+ );
+ });
+ }
+
+ accountTypeLabel(t: BalanceAccountType): string {
+ return this.accountTypeOptions.find((o) => o.value === t)?.label || t;
+ }
+
+ totalBalance(): number {
+ return this.filteredRecords.reduce((s, r) => s + r.availableBalance, 0);
+ }
+
+ clearFilters(): void {
+ this.search = '';
+ this.accountTypeFilter = '';
+ this.asOnDate = todayIso();
+ }
+
+ viewCertificate(r: BalanceCertificateRecord): void {
+ const data: CertificatePreviewDialogData = {
+ kind: 'balance',
+ balance: { record: r, asOnDate: this.asOnDate }
+ };
+ this.dialog.open(CertificatePreviewDialogComponent, {
+ width: '880px',
+ maxWidth: '96vw',
+ maxHeight: '92vh',
+ autoFocus: false,
+ panelClass: 'cert-preview-dialog',
+ data
+ });
+ }
+
+ downloadCertificate(r: BalanceCertificateRecord): void {
+ downloadBalanceCertificatePdf(r, this.asOnDate);
+ this.snack.open(`Balance Certificate (${r.accountNo}) downloaded.`, 'OK', { duration: 2400 });
+ }
+}
+
+function todayIso(): string {
+ const d = new Date();
+ const pad = (n: number) => String(n).padStart(2, '0');
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
+}
+
+// ───────────────────── Mock seed (top-level export) ─────────────────────
+export function seedBalanceCertificates(): BalanceCertificateRecord[] {
+ return [
+ {
+ accountNo: 'SB10029384',
+ customerId: 'CUST112233',
+ customerName: 'Rohan S. Mehta',
+ customerPan: 'ABCPM1234E',
+ customerAddress: 'Flat 902, Skyline Apt, Andheri (E), Mumbai 400069',
+ branch: 'Mumbai — Andheri (E)',
+ accountType: 'SAVINGS',
+ productName: 'Savings — Premium',
+ openedOn: '2018-04-22',
+ availableBalance: 482500,
+ ledgerBalance: 482500,
+ accruedInterest: 1820,
+ interestRate: 3.5,
+ jointHolders: 'Priya Mehta'
+ },
+ {
+ accountNo: 'CA20034112',
+ customerId: 'CUST334455',
+ customerName: 'Anita Verma',
+ customerPan: 'EFGHV5678A',
+ customerAddress: 'B-12, Connaught Pl, New Delhi 110001',
+ branch: 'New Delhi — Connaught Place',
+ accountType: 'CURRENT',
+ productName: 'Current — Business',
+ openedOn: '2019-08-11',
+ availableBalance: 1245300,
+ ledgerBalance: 1245300,
+ accruedInterest: 0
+ },
+ {
+ accountNo: 'FD50200077',
+ customerId: 'CUST556677',
+ customerName: 'Karthik Rao',
+ customerPan: 'PQRSR2233Z',
+ customerAddress: '24, MG Road, Bengaluru 560001',
+ branch: 'Bengaluru — MG Road',
+ accountType: 'FIXED_DEPOSIT',
+ productName: 'FD — Senior 18 months',
+ openedOn: '2024-02-15',
+ availableBalance: 500000,
+ ledgerBalance: 526500,
+ accruedInterest: 26500,
+ interestRate: 7.25
+ },
+ {
+ accountNo: 'RD60012345',
+ customerId: 'CUST667788',
+ customerName: 'Lakshmi Naidu',
+ customerPan: 'XYZAN7788Q',
+ customerAddress: 'Plot 14, Road No. 3, Banjara Hills, Hyderabad 500034',
+ branch: 'Hyderabad — Banjara Hills',
+ accountType: 'RD',
+ productName: 'Recurring Deposit — 24m',
+ openedOn: '2023-08-18',
+ availableBalance: 180000,
+ ledgerBalance: 188750,
+ accruedInterest: 8750,
+ interestRate: 6.85
+ },
+ {
+ accountNo: 'NRE70010023',
+ customerId: 'CUST889900',
+ customerName: 'Vikram Joshi',
+ customerPan: 'LMNOJ9988X',
+ customerAddress: 'Office 3B, Koregaon Park Road, Pune 411001',
+ branch: 'Pune — Koregaon Park',
+ accountType: 'NRE',
+ productName: 'NRE Savings — Premium',
+ openedOn: '2020-11-05',
+ availableBalance: 3850000,
+ ledgerBalance: 3850000,
+ accruedInterest: 12400,
+ interestRate: 3.5
+ },
+ {
+ accountNo: 'SB88990022',
+ customerId: 'CUST990011',
+ customerName: 'Arjun Banerjee',
+ customerPan: 'TUVWB4455F',
+ customerAddress: '42, Park Street, Kolkata 700016',
+ branch: 'Kolkata — Park Street',
+ accountType: 'SAVINGS',
+ productName: 'Savings — Regular',
+ openedOn: '2021-09-08',
+ availableBalance: 92300,
+ ledgerBalance: 92300,
+ accruedInterest: 450,
+ interestRate: 3.0
+ }
+ ];
+}
diff --git a/src/app/letter-mgmt/certificates/certificate-pdf.ts b/src/app/letter-mgmt/certificates/certificate-pdf.ts
new file mode 100644
index 000000000..cbdcba191
--- /dev/null
+++ b/src/app/letter-mgmt/certificates/certificate-pdf.ts
@@ -0,0 +1,1058 @@
+/**
+ * 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/.
+ */
+
+/*
+ * Shared jsPDF builders for the certificate pages — Loan Interest + Share.
+ * Output mirrors the on-screen preview: serif body, coloured accent strip,
+ * framed inner border, summary table with accent-coloured total row, and a
+ * circular "common seal" stamp in the sign-off block.
+ */
+
+import { jsPDF } from 'jspdf';
+
+import { FyInterestBreakup, LoanInterestRecord } from './loan-interest/loan-interest.component';
+import { ShareCertificateRecord } from './share-certificate/share-certificate.component';
+import { BalanceCertificateRecord } from './balance-certificate/balance-certificate.component';
+import { Form60Record } from './form-60/form-60.component';
+import { Form15Kind, Form15ghRecord } from './form-15gh/form-15gh.component';
+import { HypothecationRecord } from './hypothecation/hypothecation.component';
+import { NocRecord } from './noc/noc.component';
+
+const BANK_NAME = 'Nexora Co-operative Bank Ltd.';
+const BANK_ADDRESS = 'Head Office, 4th Floor, Nexora Tower, Mumbai 400051';
+const BANK_REG = 'RBI Lic. No. NX-2018/MH/482';
+const BANK_CONTACT = 'info@nexorabank.in · 022 6789 0000';
+
+const C_TEXT: [number, number, number] = [
+ 15,
+ 23,
+ 42
+];
+const C_MUTED: [number, number, number] = [
+ 100,
+ 116,
+ 139
+];
+const C_BORDER: [number, number, number] = [
+ 226,
+ 232,
+ 240
+];
+const C_LABEL_BG: [number, number, number] = [
+ 248,
+ 250,
+ 252
+];
+
+const ACCENT_BLUE: [number, number, number] = [
+ 30,
+ 64,
+ 175
+];
+const ACCENT_AMBER: [number, number, number] = [
+ 217,
+ 119,
+ 6
+];
+const ACCENT_TEAL: [number, number, number] = [
+ 13,
+ 148,
+ 136
+];
+const ACCENT_RED: [number, number, number] = [
+ 185,
+ 28,
+ 28
+];
+const ACCENT_GREEN: [number, number, number] = [
+ 21,
+ 128,
+ 61
+];
+
+function tint(rgb: [number, number, number]): [number, number, number] {
+ return [
+ Math.round(255 - (255 - rgb[0]) * 0.12),
+ Math.round(255 - (255 - rgb[1]) * 0.12),
+ Math.round(255 - (255 - rgb[2]) * 0.12)
+ ];
+}
+
+function formatLongDate(iso: string): string {
+ if (!iso) return '';
+ const d = new Date(iso);
+ if (isNaN(d.getTime())) return iso;
+ return d.toLocaleDateString('en-IN', { day: 'numeric', month: 'long', year: 'numeric' });
+}
+
+function today(): string {
+ const d = new Date();
+ const pad = (n: number) => String(n).padStart(2, '0');
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
+}
+
+interface PdfCtx {
+ doc: jsPDF;
+ pageW: number;
+ pageH: number;
+ margin: number;
+ contentW: number;
+ accent: [number, number, number];
+ accentTint: [number, number, number];
+}
+
+function setText(c: PdfCtx, rgb: [number, number, number]): void {
+ c.doc.setTextColor(rgb[0], rgb[1], rgb[2]);
+}
+function setFill(c: PdfCtx, rgb: [number, number, number]): void {
+ c.doc.setFillColor(rgb[0], rgb[1], rgb[2]);
+}
+function setDraw(c: PdfCtx, rgb: [number, number, number]): void {
+ c.doc.setDrawColor(rgb[0], rgb[1], rgb[2]);
+}
+
+/** Common letterhead + title pill + meta row + decorative inner-frame border. */
+function paintLetterhead(
+ c: PdfCtx,
+ titleText: string,
+ refLabel: string,
+ refValue: string,
+ rightLabel: string,
+ rightValue: string
+): number {
+ // Accent top stripe
+ setFill(c, c.accent);
+ c.doc.rect(0, 0, c.pageW, 4, 'F');
+
+ // Decorative inner border (mirrors the page-frame in the preview dialog)
+ setDraw(c, C_BORDER);
+ c.doc.setLineWidth(0.3);
+ c.doc.rect(c.margin - 4, 14, c.pageW - (c.margin - 4) * 2, c.pageH - 14 - 16);
+
+ // Letterhead text
+ let y = 24;
+ c.doc.setFont('times', 'bold');
+ c.doc.setFontSize(19);
+ setText(c, c.accent);
+ c.doc.text(BANK_NAME, c.pageW / 2, y, { align: 'center' });
+ y += 5.5;
+ c.doc.setFont('helvetica', 'normal');
+ c.doc.setFontSize(9);
+ setText(c, C_MUTED);
+ c.doc.text(BANK_ADDRESS, c.pageW / 2, y, { align: 'center' });
+ y += 4;
+ c.doc.text(`${BANK_REG} · ${BANK_CONTACT}`, c.pageW / 2, y, { align: 'center' });
+ y += 5;
+ setDraw(c, C_BORDER);
+ c.doc.setLineWidth(0.3);
+ c.doc.line(c.margin, y, c.pageW - c.margin, y);
+ y += 7;
+
+ // Title pill (rounded rect, centered)
+ c.doc.setFont('helvetica', 'bold');
+ c.doc.setFontSize(11.5);
+ const pillTxt = titleText.toUpperCase();
+ const pillW = c.doc.getTextWidth(pillTxt) + 14;
+ const pillH = 8;
+ const pillX = (c.pageW - pillW) / 2;
+ setFill(c, c.accentTint);
+ c.doc.roundedRect(pillX, y - 5, pillW, pillH, pillH / 2, pillH / 2, 'F');
+ setText(c, c.accent);
+ c.doc.text(pillTxt, c.pageW / 2, y + 0.5, { align: 'center' });
+ y += 11;
+
+ // Meta strip
+ const colW = c.contentW / 3;
+ c.doc.setFont('helvetica', 'bold');
+ c.doc.setFontSize(8);
+ setText(c, C_MUTED);
+ c.doc.text('CERTIFICATE REFERENCE', c.margin, y);
+ c.doc.text('DATE OF ISSUE', c.margin + colW, y);
+ c.doc.text(rightLabel.toUpperCase(), c.margin + colW * 2, y);
+ y += 5;
+ setText(c, C_TEXT);
+ c.doc.setFont('courier', 'bold');
+ c.doc.setFontSize(10.5);
+ c.doc.text(refValue, c.margin, y + 0.5);
+ c.doc.setFont('helvetica', 'bold');
+ c.doc.setFontSize(10.5);
+ c.doc.text(formatLongDate(today()), c.margin + colW, y + 0.5);
+
+ // Right column gets a tinted pill (FY or Folio)
+ const rPillTxt = rightValue;
+ c.doc.setFont('helvetica', 'bold');
+ c.doc.setFontSize(9);
+ const rPillW = c.doc.getTextWidth(rPillTxt) + 10;
+ const rPillH = 6;
+ const rPillX = c.margin + colW * 2;
+ const rPillY = y - 4;
+ setFill(c, c.accentTint);
+ c.doc.roundedRect(rPillX, rPillY, rPillW, rPillH, rPillH / 2, rPillH / 2, 'F');
+ setText(c, c.accent);
+ c.doc.text(rPillTxt, rPillX + rPillW / 2, rPillY + rPillH / 2 + 1.3, { align: 'center' });
+
+ y += 7;
+ setDraw(c, C_BORDER);
+ c.doc.setLineDashPattern(
+ [
+ 0.8,
+ 0.8
+ ],
+ 0
+ );
+ c.doc.line(c.margin, y, c.pageW - c.margin, y);
+ c.doc.setLineDashPattern([], 0);
+ return y + 6;
+}
+
+/** Two-column addressee block: small "To," label on the left, customer info on the right. */
+function paintAddressee(
+ c: PdfCtx,
+ y: number,
+ toLabel: string,
+ name: string,
+ customerId: string,
+ pan: string,
+ address: string
+): number {
+ c.doc.setFont('helvetica', 'bold');
+ c.doc.setFontSize(11);
+ setText(c, C_TEXT);
+ c.doc.text(toLabel, c.margin, y);
+
+ const x = c.margin + 22;
+ c.doc.setFont('helvetica', 'bold');
+ c.doc.setFontSize(11);
+ c.doc.text(name, x, y);
+ y += 5;
+
+ c.doc.setFont('helvetica', 'normal');
+ c.doc.setFontSize(10);
+ setText(c, C_TEXT);
+ c.doc.text(`Customer ID: ${customerId} · PAN: ${pan}`, x, y);
+ y += 4.5;
+
+ const addrLines = c.doc.splitTextToSize(address, c.contentW - 22);
+ c.doc.text(addrLines, x, y);
+ y += addrLines.length * 4.5 + 4;
+ return y;
+}
+
+/** Body paragraph helper (Times, justified). */
+function paintPara(c: PdfCtx, y: number, text: string): number {
+ c.doc.setFont('times', 'normal');
+ c.doc.setFontSize(11);
+ setText(c, C_TEXT);
+ const lines = c.doc.splitTextToSize(text, c.contentW);
+ c.doc.text(lines, c.margin, y, { align: 'justify', maxWidth: c.contentW });
+ return y + lines.length * 5 + 4;
+}
+
+/** 2x4 summary grid with accent-coloured total row. Rows are an array of
+ * [[labelL, valueL, labelR, valueR], ...] tuples plus an optional total. */
+function paintSummary(
+ c: PdfCtx,
+ y: number,
+ rows: [string, string, string, string][],
+ total: { label: string; value: string }
+): number {
+ const rowH = 8;
+ const colHalf = c.contentW / 2;
+ const labelW = 44;
+ const valueW = colHalf - labelW;
+ const drawCell = (x: number, w: number, txt: string, isLabel: boolean): void => {
+ if (isLabel) {
+ setFill(c, C_LABEL_BG);
+ c.doc.rect(x, y, w, rowH, 'F');
+ c.doc.setFont('helvetica', 'bold');
+ c.doc.setFontSize(7.5);
+ setText(c, C_MUTED);
+ c.doc.text(txt, x + 2.5, y + rowH / 2 + 1.3);
+ } else {
+ c.doc.setFont('helvetica', 'normal');
+ c.doc.setFontSize(10);
+ setText(c, C_TEXT);
+ c.doc.text(txt, x + 2.5, y + rowH / 2 + 1.3);
+ }
+ setDraw(c, C_BORDER);
+ c.doc.setLineWidth(0.2);
+ c.doc.rect(x, y, w, rowH);
+ };
+ for (const r of rows) {
+ drawCell(c.margin, labelW, r[0], true);
+ drawCell(c.margin + labelW, valueW, r[1], false);
+ drawCell(c.margin + colHalf, labelW, r[2], true);
+ drawCell(c.margin + colHalf + labelW, valueW, r[3], false);
+ y += rowH;
+ }
+ // Total row
+ setFill(c, c.accent);
+ c.doc.rect(c.margin, y, c.contentW, rowH + 1, 'F');
+ c.doc.setFont('helvetica', 'bold');
+ c.doc.setFontSize(10);
+ setText(
+ c,
+ [
+ 255,
+ 255,
+ 255
+ ]
+ );
+ c.doc.text(total.label, c.margin + 3, y + rowH / 2 + 2);
+ c.doc.text(total.value, c.margin + c.contentW - 3, y + rowH / 2 + 2, { align: 'right' });
+ return y + rowH + 6;
+}
+
+/** Sign-off + decorative circular seal on the right. */
+function paintSignoff(c: PdfCtx, y: number, branch: string): number {
+ if (y > c.pageH - c.margin - 50) {
+ c.doc.addPage();
+ setFill(c, c.accent);
+ c.doc.rect(0, 0, c.pageW, 4, 'F');
+ y = c.margin;
+ }
+ // Signature line on the left
+ setDraw(c, C_MUTED);
+ c.doc.setLineWidth(0.3);
+ c.doc.line(c.margin, y + 14, c.margin + 70, y + 14);
+ c.doc.setFont('helvetica', 'bold');
+ c.doc.setFontSize(10);
+ setText(c, C_TEXT);
+ c.doc.text('Authorised Signatory', c.margin, y + 18);
+ c.doc.setFont('helvetica', 'normal');
+ c.doc.setFontSize(8.5);
+ setText(c, C_MUTED);
+ c.doc.text(branch, c.margin, y + 22.5);
+
+ // Decorative seal on the right (concentric circles)
+ const cx = c.pageW - c.margin - 16;
+ const cy = y + 10;
+ setDraw(c, c.accent);
+ c.doc.setLineWidth(0.6);
+ c.doc.circle(cx, cy, 14);
+ c.doc.setLineWidth(0.3);
+ c.doc.setLineDashPattern(
+ [
+ 1,
+ 1
+ ],
+ 0
+ );
+ c.doc.circle(cx, cy, 11);
+ c.doc.setLineDashPattern([], 0);
+ c.doc.setFont('helvetica', 'bold');
+ c.doc.setFontSize(7);
+ setText(c, c.accent);
+ c.doc.text('NEXORA', cx, cy - 1, { align: 'center' });
+ c.doc.setFontSize(5);
+ setText(c, C_MUTED);
+ c.doc.text('CO-OPERATIVE', cx, cy + 3, { align: 'center' });
+ c.doc.text('BANK', cx, cy + 5.5, { align: 'center' });
+
+ return y + 28;
+}
+
+/** Footer line at the bottom of the cert. */
+function paintFooter(c: PdfCtx, y: number): void {
+ setDraw(c, C_BORDER);
+ c.doc.setLineDashPattern(
+ [
+ 0.8,
+ 0.8
+ ],
+ 0
+ );
+ c.doc.line(c.margin, y, c.pageW - c.margin, y);
+ c.doc.setLineDashPattern([], 0);
+ y += 4.5;
+ c.doc.setFont('helvetica', 'normal');
+ c.doc.setFontSize(8);
+ setText(c, C_MUTED);
+ c.doc.text(
+ `Issued by ${BANK_NAME} on ${formatLongDate(today())}. Computer generated — does not require physical signature for record purposes.`,
+ c.pageW / 2,
+ y,
+ { align: 'center' }
+ );
+}
+
+function newCtx(accent: [number, number, number]): PdfCtx {
+ const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
+ return {
+ doc,
+ pageW: doc.internal.pageSize.getWidth(),
+ pageH: doc.internal.pageSize.getHeight(),
+ margin: 20,
+ contentW: doc.internal.pageSize.getWidth() - 40,
+ accent,
+ accentTint: tint(accent)
+ };
+}
+
+// ───────────────────── Loan Interest Certificate ─────────────────────
+export function buildLoanInterestPdf(record: LoanInterestRecord, fy: FyInterestBreakup): jsPDF {
+ const c = newCtx(ACCENT_BLUE);
+
+ let y = paintLetterhead(
+ c,
+ 'Interest-Paid Certificate',
+ 'CERTIFICATE REFERENCE',
+ `INT-${record.accountNo}-${fy.fy.slice(3)}`,
+ 'Financial Year',
+ fy.fy
+ );
+
+ y = paintAddressee(c, y, 'To,', record.customerName, record.customerId, record.customerPan, record.customerAddress);
+
+ y = paintPara(
+ c,
+ y,
+ `This is to certify that ${record.customerName} (Customer ID ${record.customerId}) is availing the following loan facility from our bank, and the interest paid by the above customer during the financial year ${fy.fy} is as detailed below.`
+ );
+
+ y = paintSummary(
+ c,
+ y,
+ [
+ [
+ 'LOAN ACCOUNT NO.',
+ record.accountNo,
+ 'BRANCH',
+ record.branch
+ ],
+ [
+ 'LOAN TYPE',
+ record.loanType,
+ 'DISBURSED ON',
+ formatLongDate(record.disbursementDate)
+ ],
+ [
+ 'SANCTIONED AMOUNT',
+ `Rs. ${record.sanctionedAmount.toLocaleString('en-IN')}`,
+ 'INTEREST RATE',
+ `${record.interestRate}% p.a.`
+ ],
+ [
+ 'OPENING BALANCE',
+ `Rs. ${fy.openingBalance.toLocaleString('en-IN')}`,
+ 'CLOSING BALANCE',
+ `Rs. ${fy.closingBalance.toLocaleString('en-IN')}`
+ ],
+ [
+ 'PRINCIPAL REPAID',
+ `Rs. ${fy.principalPaid.toLocaleString('en-IN')}`,
+ 'INTEREST PAID',
+ `Rs. ${fy.interestPaid.toLocaleString('en-IN')}`
+ ]
+ ],
+ {
+ label: `TOTAL INTEREST PAID DURING ${fy.fy}`,
+ value: `Rs. ${fy.interestPaid.toLocaleString('en-IN')}`
+ }
+ );
+
+ y = paintPara(
+ c,
+ y,
+ 'This certificate is issued at the request of the borrower for income-tax / financial record purposes and reflects the interest credited to the captioned loan account during the said financial year as per our books.'
+ );
+
+ y = paintSignoff(c, y + 6, record.branch);
+ paintFooter(c, y + 4);
+ return c.doc;
+}
+
+export function downloadLoanInterestPdf(record: LoanInterestRecord, fy: FyInterestBreakup): void {
+ const doc = buildLoanInterestPdf(record, fy);
+ doc.save(`Interest-Certificate-${record.accountNo}-${fy.fy.replace(/\s+/g, '')}.pdf`);
+}
+
+// ───────────────────── Share Certificate ─────────────────────
+export function buildShareCertificatePdf(r: ShareCertificateRecord): jsPDF {
+ const c = newCtx(ACCENT_AMBER);
+
+ const classLabel = r.shareClass === 'PREFERENCE' ? 'Preference' : 'Ordinary';
+
+ let y = paintLetterhead(c, 'Share Certificate', 'CERTIFICATE REFERENCE', r.certificateNo, 'Folio No.', r.folioNo);
+
+ y = paintAddressee(c, y, 'Issued To,', r.memberName, r.memberId, r.memberPan, r.memberAddress);
+
+ // Centered, dramatic body paragraph (mirrors the highlighted "centered" style in the preview)
+ if (y > c.pageH - c.margin - 80) {
+ c.doc.addPage();
+ 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(
+ c,
+ y,
+ [
+ [
+ 'CERTIFICATE NO.',
+ r.certificateNo,
+ 'FOLIO NO.',
+ r.folioNo
+ ],
+ [
+ 'BRANCH',
+ r.branch,
+ 'MEMBER SINCE',
+ formatLongDate(r.joiningDate)
+ ],
+ [
+ 'SHARE CLASS',
+ classLabel,
+ 'DATE OF ISSUE',
+ formatLongDate(r.issueDate)
+ ],
+ [
+ 'SHARE NUMBERS',
+ `${r.shareNoFrom.toLocaleString('en-IN')} – ${r.shareNoTo.toLocaleString('en-IN')}`,
+ 'DIVIDEND PAID',
+ `Rs. ${r.dividendYtd.toLocaleString('en-IN')}`
+ ]
+ ],
+ {
+ label: 'TOTAL FACE VALUE',
+ value: `Rs. ${(r.shareCount * r.faceValue).toLocaleString('en-IN')}`
+ }
+ );
+
+ y = paintPara(
+ c,
+ y,
+ `Given under the common seal of the bank this ${new Date().toLocaleDateString('en-IN', {
+ day: 'numeric',
+ month: 'long',
+ year: 'numeric'
+ })}.`
+ );
+
+ y = paintSignoff(c, y + 6, r.branch);
+ paintFooter(c, y + 4);
+ return c.doc;
+}
+
+export function downloadShareCertificatePdf(r: ShareCertificateRecord): void {
+ const doc = buildShareCertificatePdf(r);
+ doc.save(`Share-Certificate-${r.certificateNo}.pdf`);
+}
+
+// ───────────────────── Balance Certificate ─────────────────────
+export function buildBalanceCertificatePdf(r: BalanceCertificateRecord, asOnDate: string): jsPDF {
+ const c = newCtx(ACCENT_BLUE);
+
+ const typeLabel = ((): string => {
+ switch (r.accountType) {
+ case 'SAVINGS':
+ return 'Savings';
+ case 'CURRENT':
+ return 'Current';
+ case 'FIXED_DEPOSIT':
+ return 'Fixed Deposit';
+ case 'RD':
+ return 'Recurring Deposit';
+ case 'NRE':
+ return 'NRE Savings';
+ case 'NRO':
+ return 'NRO Savings';
+ }
+ })();
+
+ let y = paintLetterhead(
+ c,
+ 'Balance Certificate',
+ 'CERTIFICATE REFERENCE',
+ `BAL-${r.accountNo}-${asOnDate.replace(/-/g, '')}`,
+ 'As-on Date',
+ formatLongDate(asOnDate)
+ );
+
+ y = paintAddressee(c, y, 'To,', r.customerName, r.customerId, r.customerPan, r.customerAddress);
+
+ y = paintPara(
+ c,
+ y,
+ `This is to certify that ${r.customerName} (Customer ID ${r.customerId}) maintains the following account with our bank, and the balance available in the said account as on ${formatLongDate(asOnDate)} is as detailed below.`
+ );
+
+ y = paintSummary(
+ c,
+ y,
+ [
+ [
+ 'ACCOUNT NO.',
+ r.accountNo,
+ 'BRANCH',
+ r.branch
+ ],
+ [
+ 'ACCOUNT TYPE',
+ typeLabel,
+ 'PRODUCT',
+ r.productName
+ ],
+ [
+ 'ACCOUNT OPENED',
+ formatLongDate(r.openedOn),
+ 'INTEREST RATE',
+ r.interestRate ? `${r.interestRate}% p.a.` : '—'
+ ],
+ [
+ 'AVAILABLE BALANCE',
+ `Rs. ${r.availableBalance.toLocaleString('en-IN')}`,
+ 'ACCRUED INTEREST',
+ `Rs. ${r.accruedInterest.toLocaleString('en-IN')}`
+ ]
+ ],
+ {
+ label: `TOTAL LEDGER BALANCE AS ON ${formatLongDate(asOnDate)}`,
+ value: `Rs. ${r.ledgerBalance.toLocaleString('en-IN')}`
+ }
+ );
+
+ if (r.jointHolders) {
+ y = paintPara(c, y, `Joint holder(s): ${r.jointHolders}.`);
+ }
+
+ y = paintPara(
+ c,
+ y,
+ 'This certificate is issued at the request of the customer for record purposes and reflects the balance in the said account as per our books as on the date mentioned above.'
+ );
+
+ y = paintSignoff(c, y + 6, r.branch);
+ paintFooter(c, y + 4);
+ return c.doc;
+}
+
+export function downloadBalanceCertificatePdf(r: BalanceCertificateRecord, asOnDate: string): void {
+ const doc = buildBalanceCertificatePdf(r, asOnDate);
+ doc.save(`Balance-Certificate-${r.accountNo}-${asOnDate}.pdf`);
+}
+
+// ───────────────────── Form 60 ─────────────────────
+const ID_PROOF_LABEL: Record = {
+ AADHAAR: 'Aadhaar Card',
+ VOTER_ID: 'Voter Identity Card',
+ DRIVING_LICENSE: 'Driving License',
+ PASSPORT: 'Passport',
+ RATION: 'Ration Card'
+};
+
+export function buildForm60Pdf(r: Form60Record): jsPDF {
+ const c = newCtx(ACCENT_AMBER);
+
+ let y = paintLetterhead(
+ c,
+ 'Form 60 Declaration',
+ 'CERTIFICATE REFERENCE',
+ `F60-${r.customerId}`,
+ 'PAN Status',
+ 'NOT ALLOTTED'
+ );
+
+ y = paintPara(
+ c,
+ y,
+ 'Declaration under Section 114B of the Income-tax Rules, 1962, to be made by a person who does not have a Permanent Account Number (PAN) and who enters into any transaction specified in rule 114B.'
+ );
+
+ // 14-row form structure rendered as a 2-col grid via paintSummary
+ y = paintSummary(
+ c,
+ y,
+ [
+ [
+ '1. FULL NAME',
+ r.customerName,
+ "2. FATHER'S / SPOUSE'S NAME",
+ r.fatherName
+ ],
+ [
+ '3. DATE OF BIRTH',
+ formatLongDate(r.dob),
+ '4. CUSTOMER ID',
+ r.customerId
+ ],
+ [
+ '5. ADDRESS',
+ r.customerAddress.length > 60 ? r.customerAddress.slice(0, 57) + '…' : r.customerAddress,
+ '6. BRANCH',
+ r.branch
+ ],
+ [
+ '7. ID PROOF',
+ ID_PROOF_LABEL[r.idProofType] || r.idProofType,
+ '8. ID DOC NO.',
+ r.idProofNumber
+ ],
+ [
+ '9. TRANSACTION TYPE',
+ r.transactionType.length > 50 ? r.transactionType.slice(0, 47) + '…' : r.transactionType,
+ '10. TRANSACTION DATE',
+ r.transactionDate ? formatLongDate(r.transactionDate) : '—'
+ ],
+ [
+ '11. TRANSACTION AMOUNT',
+ `Rs. ${r.transactionAmount.toLocaleString('en-IN')}`,
+ '12. AGRI. INCOME',
+ `Rs. ${r.agriIncome.toLocaleString('en-IN')}`
+ ]
+ ],
+ {
+ label: 'ESTIMATED TOTAL INCOME (CURRENT FY)',
+ value: `Rs. ${r.estimatedIncome.toLocaleString('en-IN')}`
+ }
+ );
+
+ y = paintPara(
+ c,
+ y,
+ `I, ${r.customerName}, do hereby declare that the particulars given above are true and correct to the best of my knowledge and belief. I further declare that I have neither been assessed nor am liable to be assessed under the Income-tax Act, 1961, and that I have applied / not applied for a Permanent Account Number with the Income-tax Department.`
+ );
+
+ y = paintSignoff(c, y + 6, r.branch);
+ paintFooter(c, y + 4);
+ return c.doc;
+}
+
+export function downloadForm60Pdf(r: Form60Record): void {
+ const doc = buildForm60Pdf(r);
+ doc.save(`Form-60-${r.customerId}.pdf`);
+}
+
+// ───────────────────── Form 15G / 15H ─────────────────────
+export function buildForm15ghPdf(r: Form15ghRecord, kind: Form15Kind): jsPDF {
+ const c = newCtx(ACCENT_TEAL);
+
+ const todayDob = new Date();
+ const birth = new Date(r.dob);
+ let age = todayDob.getFullYear() - birth.getFullYear();
+ const m = todayDob.getMonth() - birth.getMonth();
+ if (m < 0 || (m === 0 && todayDob.getDate() < birth.getDate())) age--;
+
+ let y = paintLetterhead(
+ c,
+ `Form ${kind} Declaration`,
+ 'CERTIFICATE REFERENCE',
+ `${kind}-${r.customerId}-${r.fy.replace(/\s+/g, '')}`,
+ 'Form',
+ kind
+ );
+
+ y = paintPara(
+ c,
+ y,
+ `Declaration under Section ${
+ kind === '15H' ? '197A(1C)' : '197A(1) / 197A(1A)'
+ } of the Income-tax Act, 1961, for non-deduction of tax at source on certain incomes — ${
+ kind === '15H'
+ ? 'applicable to resident individuals aged 60 years or above'
+ : 'applicable to resident individuals (other than companies / firms) below 60 years'
+ }.`
+ );
+
+ y = paintSummary(
+ c,
+ y,
+ [
+ [
+ '1. NAME OF ASSESSEE',
+ r.customerName,
+ '2. PAN OF ASSESSEE',
+ r.customerPan
+ ],
+ [
+ '3. STATUS',
+ 'Individual',
+ '4. AGE',
+ `${age} years`
+ ],
+ [
+ '5. PREVIOUS YEAR',
+ r.fy,
+ '6. CUSTOMER ID',
+ r.customerId
+ ],
+ [
+ '7. ADDRESS',
+ r.customerAddress.length > 60 ? r.customerAddress.slice(0, 57) + '…' : r.customerAddress,
+ '8. BRANCH',
+ r.branch
+ ],
+ [
+ '9. DEPOSIT PRINCIPAL',
+ `Rs. ${r.depositPrincipal.toLocaleString('en-IN')}`,
+ '10. ESTIMATED INTEREST',
+ `Rs. ${r.estimatedInterest.toLocaleString('en-IN')}`
+ ],
+ [
+ '11. EST. TOTAL INCOME',
+ `Rs. ${r.estimatedIncome.toLocaleString('en-IN')}`,
+ '12. PREVIOUSLY ASSESSED?',
+ r.assessed ? 'Yes' : 'No'
+ ]
+ ],
+ {
+ label: 'TAX PAYABLE ON ESTIMATED INCOME',
+ value: 'NIL'
+ }
+ );
+
+ y = paintPara(
+ c,
+ y,
+ `I, ${r.customerName}, do hereby declare that the particulars given above are true and correct, and that the tax on my estimated total income for the previous year ${r.fy} will be NIL. I therefore request that no tax be deducted at source on the interest paid to me by the bank on the captioned deposits during the said previous year.`
+ );
+
+ y = paintSignoff(c, y + 6, r.branch);
+ paintFooter(c, y + 4);
+ return c.doc;
+}
+
+export function downloadForm15ghPdf(r: Form15ghRecord, kind: Form15Kind): void {
+ const doc = buildForm15ghPdf(r, kind);
+ doc.save(`Form-${kind}-${r.customerId}-${r.fy.replace(/\s+/g, '')}.pdf`);
+}
+
+// ───────────────────── Hypothecation Certificate ─────────────────────
+const ASSET_LABEL: Record = {
+ VEHICLE: 'Vehicle',
+ PLANT_MACHINERY: 'Plant & Machinery',
+ STOCK_DEBTORS: 'Stock / Debtors',
+ GOLD: 'Gold / Bullion',
+ EQUIPMENT: 'Equipment'
+};
+
+export function buildHypothecationPdf(r: HypothecationRecord): jsPDF {
+ const c = newCtx(ACCENT_RED);
+
+ let y = paintLetterhead(
+ c,
+ 'Hypothecation Certificate',
+ 'CERTIFICATE REFERENCE',
+ r.certificateNo,
+ 'Status',
+ r.status === 'NOC_ISSUED' ? 'NOC ISSUED' : r.status
+ );
+
+ y = paintAddressee(c, y, 'Issued To,', r.customerName, r.customerId, r.customerPan, r.customerAddress);
+
+ y = paintPara(
+ c,
+ y,
+ `This is to certify that the asset described below stands hypothecated to ${BANK_NAME} as security for the captioned loan facility extended to ${r.customerName}, and the bank holds the first and exclusive charge over the said asset until the loan is fully discharged.`
+ );
+
+ const rows: [string, string, string, string][] = [
+ [
+ 'CERTIFICATE NO.',
+ r.certificateNo,
+ 'LOAN ACCOUNT NO.',
+ r.accountNo
+ ],
+ [
+ 'LOAN TYPE',
+ r.loanType,
+ 'BRANCH',
+ r.branch
+ ],
+ [
+ 'SANCTIONED AMOUNT',
+ `Rs. ${r.sanctionedAmount.toLocaleString('en-IN')}`,
+ 'DISBURSED ON',
+ formatLongDate(r.disbursementDate)
+ ],
+ [
+ 'ASSET TYPE',
+ ASSET_LABEL[r.assetType] || r.assetType,
+ 'IDENTIFIER',
+ r.assetIdentifier
+ ],
+ [
+ 'ASSET DESCRIPTION',
+ r.assetDescription.length > 38 ? r.assetDescription.slice(0, 35) + '…' : r.assetDescription,
+ 'STATUS',
+ r.status === 'NOC_ISSUED' ? 'NOC ISSUED' : r.status
+ ]
+ ];
+
+ if (r.insuredValue > 0) {
+ rows.push([
+ 'INSURED VALUE',
+ `Rs. ${r.insuredValue.toLocaleString('en-IN')}`,
+ 'INSURANCE VALID TILL',
+ formatLongDate(r.insuranceValidTill)
+ ]);
+ }
+
+ y = paintSummary(c, y, rows, {
+ label: 'OUTSTANDING PRINCIPAL',
+ value: `Rs. ${r.outstandingPrincipal.toLocaleString('en-IN')}`
+ });
+
+ y = paintPara(
+ c,
+ y,
+ 'The lien on the said asset shall stand released only upon the bank issuing a No-Objection Certificate (NOC) after the entire outstanding under the captioned facility is paid in full, together with all interest, charges, and incidental costs.'
+ );
+
+ y = paintSignoff(c, y + 6, r.branch);
+ paintFooter(c, y + 4);
+ return c.doc;
+}
+
+export function downloadHypothecationPdf(r: HypothecationRecord): void {
+ const doc = buildHypothecationPdf(r);
+ doc.save(`Hypothecation-${r.certificateNo}.pdf`);
+}
+
+// ───────────────────── No-Objection Certificate (NOC) ─────────────────────
+const NOC_LOAN_TYPE_LABEL: Record = {
+ AUTO: 'Auto Loan',
+ HOUSING: 'Housing Loan',
+ PERSONAL: 'Personal Loan',
+ EDUCATION: 'Education Loan',
+ GOLD: 'Gold Loan',
+ BUSINESS: 'Business Loan',
+ CC: 'Cash Credit',
+ OD: 'Overdraft'
+};
+
+const NOC_ASSET_LABEL: Record = {
+ VEHICLE: 'Vehicle',
+ PROPERTY: 'Property',
+ GOLD: 'Gold / Bullion',
+ EQUIPMENT: 'Equipment',
+ STOCK_DEBTORS: 'Stock / Debtors',
+ NONE: 'Unsecured'
+};
+
+export function buildNocPdf(r: NocRecord): jsPDF {
+ const c = newCtx(ACCENT_GREEN);
+
+ const statusLabel = r.status === 'READY_TO_ISSUE' ? 'READY TO ISSUE' : r.status;
+
+ let y = paintLetterhead(c, 'No-Objection Certificate', 'NOC REFERENCE', r.nocNo, 'Status', statusLabel);
+
+ y = paintAddressee(c, y, 'Issued To,', r.customerName, r.customerId, r.customerPan, r.customerAddress);
+
+ y = paintPara(
+ c,
+ y,
+ `This is to certify that the captioned loan account in the name of ${r.customerName} has been duly closed, and the bank has NO OBJECTION to the release of the lien hereinafter recorded over the asset described below. All dues under the said facility, including principal, interest, and incidental charges, have been received by the bank as on the closure date.`
+ );
+
+ const rows: [string, string, string, string][] = [
+ [
+ 'NOC NO.',
+ r.nocNo,
+ 'LOAN ACCOUNT NO.',
+ r.accountNo
+ ],
+ [
+ 'LOAN TYPE',
+ NOC_LOAN_TYPE_LABEL[r.loanType] || r.loanType,
+ 'BRANCH',
+ r.branch
+ ],
+ [
+ 'SANCTIONED AMOUNT',
+ `Rs. ${r.sanctionedAmount.toLocaleString('en-IN')}`,
+ 'DISBURSED ON',
+ formatLongDate(r.disbursementDate)
+ ]
+ ];
+
+ if (r.closureDate) {
+ rows.push([
+ 'CLOSURE DATE',
+ formatLongDate(r.closureDate),
+ 'TOTAL PAID',
+ `Rs. ${r.totalPaid.toLocaleString('en-IN')}`
+ ]);
+ }
+
+ if (r.assetType !== 'NONE') {
+ rows.push([
+ 'ASSET TYPE',
+ NOC_ASSET_LABEL[r.assetType] || r.assetType,
+ 'IDENTIFIER',
+ r.assetIdentifier
+ ]);
+ rows.push([
+ 'ASSET DESCRIPTION',
+ r.assetDescription.length > 38 ? r.assetDescription.slice(0, 35) + '…' : r.assetDescription,
+ 'FORWARD TO',
+ r.forwardTo
+ ? r.forwardTo.length > 38
+ ? r.forwardTo.slice(0, 35) + '…'
+ : r.forwardTo
+ : '—'
+ ]);
+ }
+
+ y = paintSummary(c, y, rows, {
+ label: r.assetType === 'NONE' ? 'ACCOUNT CLOSED — NO DUES OUTSTANDING' : 'LIEN RELEASED — NO OBJECTION TO TRANSFER',
+ value: 'NIL'
+ });
+
+ // Asset-type-specific closing paragraph
+ let closing: string;
+ switch (r.assetType) {
+ case 'VEHICLE':
+ closing =
+ 'The Regional Transport Authority is hereby requested to delete the hypothecation endorsement of the bank from the registration certificate of the captioned vehicle.';
+ break;
+ case 'PROPERTY':
+ closing =
+ "The Sub-Registrar's office is hereby requested to delete the equitable / registered mortgage of the bank from the property records of the captioned property.";
+ break;
+ case 'GOLD':
+ closing =
+ 'The pledged gold ornaments described above may be released to the customer / authorised representative after due verification at the branch counter.';
+ break;
+ case 'NONE':
+ closing =
+ 'This certificate is issued at the request of the customer for record purposes and confirms that no dues are outstanding under the captioned facility.';
+ break;
+ default:
+ closing =
+ "The bank's lien over the captioned asset stands released with effect from the closure date, and the customer is free to deal with the said asset.";
+ break;
+ }
+ y = paintPara(c, y, closing);
+
+ if (r.status === 'ISSUED' && r.issuedOn) {
+ y = paintPara(c, y, `Issued on: ${formatLongDate(r.issuedOn)}${r.issuedBy ? ' · By: ' + r.issuedBy : ''}.`);
+ }
+
+ y = paintSignoff(c, y + 6, r.branch);
+ paintFooter(c, y + 4);
+ return c.doc;
+}
+
+export function downloadNocPdf(r: NocRecord): void {
+ const doc = buildNocPdf(r);
+ doc.save(`NOC-${r.nocNo}.pdf`);
+}
diff --git a/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.html b/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.html
new file mode 100644
index 000000000..dc4970a06
--- /dev/null
+++ b/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.html
@@ -0,0 +1,703 @@
+
+
+
+
+
+
+
+
+
+
+
{{ bankName }}
+
{{ bankAddress }}
+
{{ bankRegNo }} · {{ bankContact }}
+
+
+
+
+ {{ title }}
+
+
+
+
+
+
+ @if (isInterest && interest) {
+
+
To,
+
+
+ {{ interest.record.customerName }}
+
+
+ Customer ID: {{ interest.record.customerId }} · PAN:
+ {{ interest.record.customerPan }}
+
+
{{ interest.record.customerAddress }}
+
+
+
+
+ This is to certify that {{ interest.record.customerName }} (Customer ID
+ {{ interest.record.customerId }} ) is availing the following loan facility from our bank, and the interest paid by the above customer during the
+ financial year {{ interest.fy.fy }} is as detailed below.
+
+
+
+
+
+ Loan Account No.
+
+ {{ interest.record.accountNo }}
+
+ Branch
+ {{ interest.record.branch }}
+
+
+ Loan Type
+ {{ interest.record.loanType }}
+ Disbursed On
+ {{ interest.record.disbursementDate | date: 'd MMM y' }}
+
+
+ Sanctioned Amount
+ ₹{{ interest.record.sanctionedAmount | number: '1.2-2' }}
+ Interest Rate
+ {{ interest.record.interestRate }}% p.a.
+
+
+ Opening Balance ({{ interest.fy.fy | slice: 0 : 8 }})
+ ₹{{ interest.fy.openingBalance | number: '1.2-2' }}
+ Closing Balance
+ ₹{{ interest.fy.closingBalance | number: '1.2-2' }}
+
+
+ Principal Repaid
+ ₹{{ interest.fy.principalPaid | number: '1.2-2' }}
+ Interest Paid
+
+ ₹{{ interest.fy.interestPaid | number: '1.2-2' }}
+
+
+
+ TOTAL INTEREST PAID DURING {{ interest.fy.fy }}
+
+ ₹{{ interest.fy.interestPaid | number: '1.2-2' }}
+
+
+
+
+
+
+ This certificate is issued at the request of the borrower for income-tax / financial record purposes and
+ reflects the interest credited to the captioned loan account during the said financial year as per our books.
+
+ }
+
+
+ @if (isShare && share) {
+
+
Issued To,
+
+
+ {{ share.memberName }}
+
+
+ Member ID: {{ share.memberId }} · PAN:
+ {{ share.memberPan }}
+
+
{{ share.memberAddress }}
+
+
+
+
+ This is to certify that {{ share.memberName }} is the registered holder of
+ {{ share.shareCount | number }} ({{ classLabelText() }}) shares of
+ ₹{{ share.faceValue | number: '1.0-0' }} each, fully paid up, in {{ bankName }} , subject to the bye-laws of the bank.
+
+
+
+
+
+ Certificate No.
+
+ {{ share.certificateNo }}
+
+ Folio No.
+ {{ share.folioNo }}
+
+
+ Branch
+ {{ share.branch }}
+ Member Since
+ {{ share.joiningDate | date: 'd MMM y' }}
+
+
+ Share Class
+ {{ classLabelText() }}
+ Date of Issue
+ {{ share.issueDate | date: 'd MMM y' }}
+
+
+ Share Numbers
+ {{ share.shareNoFrom | number }} – {{ share.shareNoTo | number }}
+ Dividend Paid (Cumulative)
+ ₹{{ share.dividendYtd | number: '1.0-0' }}
+
+
+ TOTAL FACE VALUE
+
+ ₹{{ share.shareCount * share.faceValue | number: '1.2-2' }}
+
+
+
+
+
+
+ Given under the common seal of the bank this {{ issuedOn | date: 'd' }} day of {{ issuedOn | date: 'MMMM y' }}.
+
+ }
+
+
+ @if (isBalance && balance) {
+
+
To Whom It May Concern,
+
+
+ {{ balance.record.customerName }}
+
+
+ Customer ID: {{ balance.record.customerId }} · PAN:
+ {{ balance.record.customerPan }}
+
+
{{ balance.record.customerAddress }}
+ @if (balance.record.jointHolders) {
+
Jointly with: {{ balance.record.jointHolders }}
+ }
+
+
+
+
+ This is to certify that {{ balance.record.customerName }} (Customer ID
+ {{ balance.record.customerId }} ) maintains the following account with our bank, and the balance available in the said account as on
+ {{ balance.asOnDate | date: 'd MMMM y' }} is as detailed below.
+
+
+
+
+
+ Account No.
+
+ {{ balance.record.accountNo }}
+
+ Branch
+ {{ balance.record.branch }}
+
+
+ Account Type
+ {{ accountTypeLabelText() }}
+ Product
+ {{ balance.record.productName }}
+
+
+ Account Opened
+ {{ balance.record.openedOn | date: 'd MMM y' }}
+ Interest Rate
+
+ @if (balance.record.interestRate) {
+ {{ balance.record.interestRate }}% p.a.
+ } @else {
+ —
+ }
+
+
+
+ Available Balance
+ ₹{{ balance.record.availableBalance | number: '1.2-2' }}
+ Accrued Interest
+ ₹{{ balance.record.accruedInterest | number: '1.2-2' }}
+
+
+ TOTAL LEDGER BALANCE AS ON {{ balance.asOnDate | date: 'd MMM y' }}
+
+ ₹{{ balance.record.ledgerBalance | number: '1.2-2' }}
+
+
+
+
+
+
+ This certificate is issued at the request of the customer for record purposes and reflects the balance available
+ in the said account as per our books as on the date mentioned above.
+
+ }
+
+
+ @if (isForm60 && form60) {
+
+ Declaration under Section 114B of the Income-tax Rules, 1962, to be made by a person who does
+ not have a Permanent Account Number (PAN) and who enters into any transaction specified in rule 114B.
+
+
+
+
+
+ 1. Full Name (in Block Letters)
+
+ {{ form60.customerName }}
+
+
+
+ 2. Father's / Spouse's Name
+ {{ form60.fatherName }}
+
+
+ 3. Date of Birth
+ {{ form60.dob | date: 'd MMM y' }}
+ 4. Customer ID
+ {{ form60.customerId }}
+
+
+ 5. Address
+ {{ form60.customerAddress }}
+
+
+ 6. ID Proof Submitted
+ {{ idProofLabelText() }}
+ 7. ID Document No.
+ {{ form60.idProofNumber }}
+
+
+ 8. Nature of Transaction
+ {{ form60.transactionType }}
+
+
+ 9. Transaction Amount
+ ₹{{ form60.transactionAmount | number: '1.2-2' }}
+ 10. Transaction Date
+ {{ form60.transactionDate | date: 'd MMM y' }}
+
+
+ 11. Estimated Total Income (Current FY)
+ ₹{{ form60.estimatedIncome | number: '1.0-0' }}
+ 12. Agricultural Income
+ ₹{{ form60.agriIncome | number: '1.0-0' }}
+
+
+ PAN STATUS
+
+ NOT ALLOTTED — Form 60 declared
+
+
+
+
+
+
+ I, {{ form60.customerName }} , do hereby declare that the particulars given above are true and correct to the best of my knowledge and
+ belief. I further declare that I have applied / not applied for a Permanent Account Number with the Income-tax
+ department, and that I have neither been assessed nor am liable to be assessed under the Income-tax Act, 1961.
+
+ }
+
+
+ @if (isForm15gh && form15gh) {
+
+ Declaration under Section {{ form15gh.kind === '15H' ? '197A(1C)' : '197A(1) / 197A(1A)' }}
+ of the Income-tax Act, 1961, for non-deduction of tax at source on certain incomes —
+ {{
+ form15gh.kind === '15H'
+ ? 'applicable to resident individuals aged 60 years or above'
+ : 'applicable to resident individuals (other than companies / firms) below 60 years'
+ }}.
+
+
+
+
+
+ 1. Name of Assessee
+
+ {{ form15gh.record.customerName }}
+
+
+
+ 2. PAN of Assessee
+ {{ form15gh.record.customerPan }}
+ 3. Status
+ Individual
+
+
+ 4. Previous Year
+ {{ form15gh.record.fy }}
+ 5. Age (years)
+ {{ form15gh.age }}
+
+
+ 6. Address
+ {{ form15gh.record.customerAddress }}
+
+
+ 7. Branch
+ {{ form15gh.record.branch }}
+ 8. Customer ID
+ {{ form15gh.record.customerId }}
+
+
+ 9. Deposit Principal
+ ₹{{ form15gh.record.depositPrincipal | number: '1.0-0' }}
+ 10. Estimated Interest ({{ form15gh.record.fy }})
+
+ ₹{{ form15gh.record.estimatedInterest | number: '1.0-0' }}
+
+
+
+ 11. Estimated Total Income
+ ₹{{ form15gh.record.estimatedIncome | number: '1.0-0' }}
+ 12. Previously Assessed?
+ {{ form15gh.record.assessed ? 'Yes' : 'No' }}
+
+
+ TAX PAYABLE ON ESTIMATED INCOME
+ NIL
+
+
+
+
+
+ I, {{ form15gh.record.customerName }} , do hereby declare that the particulars given above are true and correct, and that the tax on my estimated
+ total income for the previous year {{ form15gh.record.fy }} will be NIL. I therefore request that no tax be
+ deducted at source on the interest paid to me by the bank on the captioned deposits during the said previous
+ year.
+
+ }
+
+
+ @if (isHypothecation && hypothecation) {
+
+
Issued To,
+
+
+ {{ hypothecation.customerName }}
+
+
+ Customer ID: {{ hypothecation.customerId }} · PAN:
+ {{ hypothecation.customerPan }}
+
+
{{ hypothecation.customerAddress }}
+
+
+
+
+ This is to certify that the asset described below stands hypothecated to
+ {{ bankName }} as security for the captioned loan facility extended to
+ {{ hypothecation.customerName }} , and the bank holds the first and exclusive charge over the said asset until the loan is fully discharged.
+
+
+
+
+
+ Certificate No.
+
+ {{ hypothecation.certificateNo }}
+
+ Loan Account No.
+ {{ hypothecation.accountNo }}
+
+
+ Loan Type
+ {{ hypothecation.loanType }}
+ Branch
+ {{ hypothecation.branch }}
+
+
+ Sanctioned Amount
+ ₹{{ hypothecation.sanctionedAmount | number: '1.0-0' }}
+ Disbursed On
+ {{ hypothecation.disbursementDate | date: 'd MMM y' }}
+
+
+ Asset Type
+ {{ assetLabelText() }}
+ Identifier
+ {{ hypothecation.assetIdentifier }}
+
+
+ Asset Description
+ {{ hypothecation.assetDescription }}
+
+ @if (hypothecation.insuredValue > 0) {
+
+ Insured Value
+ ₹{{ hypothecation.insuredValue | number: '1.0-0' }}
+ Insurance Valid Till
+ {{ hypothecation.insuranceValidTill | date: 'd MMM y' }}
+
+ }
+
+ OUTSTANDING PRINCIPAL
+
+ ₹{{ hypothecation.outstandingPrincipal | number: '1.2-2' }}
+
+
+
+
+
+
+ The lien on the said asset shall stand released only upon the bank issuing a No-Objection Certificate (NOC)
+ after the entire outstanding under the captioned facility is paid in full, together with all interest, charges,
+ and incidental costs.
+
+ }
+
+
+ @if (isNoc && noc) {
+
+
Issued To,
+
+
+ {{ noc.customerName }}
+
+
+ Customer ID: {{ noc.customerId }} · PAN:
+ {{ noc.customerPan }}
+
+
{{ noc.customerAddress }}
+
+
+
+
+ This is to certify that the captioned loan account in the name of
+ {{ noc.customerName }}
+ has been
+ @if (noc.status === 'ISSUED') {
+ duly closed and the bank
+ } @else {
+ closed and the bank
+ }
+ has NO OBJECTION to the release of the lien hereinafter recorded over the asset described
+ below. All dues under the said facility, including principal, interest, and incidental charges, have been
+ received by the bank as on the closure date.
+
+
+
+
+
+ NOC No.
+
+ {{ noc.nocNo }}
+
+ Loan Account No.
+ {{ noc.accountNo }}
+
+
+ Loan Type
+ {{ nocLoanTypeLabel() }}
+ Branch
+ {{ noc.branch }}
+
+
+ Sanctioned Amount
+ ₹{{ noc.sanctionedAmount | number: '1.0-0' }}
+ Disbursed On
+ {{ noc.disbursementDate | date: 'd MMM y' }}
+
+ @if (noc.closureDate) {
+
+ Account Closed On
+
+ {{ noc.closureDate | date: 'd MMM y' }}
+
+ Total Paid
+ ₹{{ noc.totalPaid | number: '1.2-2' }}
+
+ }
+ @if (noc.assetType !== 'NONE') {
+
+ Asset Type
+ {{ nocAssetLabel() }}
+ Identifier
+ {{ noc.assetIdentifier }}
+
+
+ Asset Description
+ {{ noc.assetDescription }}
+
+ }
+ @if (noc.forwardTo) {
+
+ Forward To
+ {{ noc.forwardTo }}
+
+ }
+
+
+ @if (noc.assetType === 'NONE') {
+ ACCOUNT CLOSED — NO DUES OUTSTANDING
+ } @else {
+ LIEN RELEASED — NO OBJECTION TO TRANSFER
+ }
+
+ NIL
+
+
+
+
+
+ @if (noc.assetType === 'VEHICLE') {
+ The Regional Transport Authority is hereby requested to delete the hypothecation endorsement of the bank from
+ the registration certificate of the captioned vehicle.
+ } @else if (noc.assetType === 'PROPERTY') {
+ The Sub-Registrar's office is hereby requested to delete the equitable / registered mortgage of the bank from
+ the property records of the captioned property.
+ } @else if (noc.assetType === 'GOLD') {
+ The pledged gold ornaments described above may be released to the customer / authorised representative after
+ due verification at the branch counter.
+ } @else if (noc.assetType !== 'NONE') {
+ The bank's lien over the captioned asset stands released with effect from the closure date, and the customer
+ is free to deal with the said asset.
+ } @else {
+ This certificate is issued at the request of the customer for record purposes and confirms that no dues are
+ outstanding under the captioned facility.
+ }
+
+
+ @if (noc.status === 'ISSUED' && noc.issuedOn) {
+
+ Issued on: {{ noc.issuedOn | date: 'd MMM y' }}
+ @if (noc.issuedBy) {
+ · By: {{ noc.issuedBy }}
+ }
+
+ }
+ }
+
+
+
+
+
+
Authorised Signatory
+ @if (isInterest && interest) {
+
{{ interest.record.branch }}
+ }
+ @if (isShare && share) {
+
{{ share.branch }}
+ }
+ @if (isBalance && balance) {
+
{{ balance.record.branch }}
+ }
+ @if (isForm60 && form60) {
+
{{ form60.branch }}
+ }
+ @if (isForm15gh && form15gh) {
+
{{ form15gh.record.branch }}
+ }
+ @if (isHypothecation && hypothecation) {
+
{{ hypothecation.branch }}
+ }
+ @if (isNoc && noc) {
+
{{ noc.branch }}
+ }
+
+
+
+
+
NEXORA
+
+
CO-OPERATIVE BANK
+
+
+
+
+
+
+
+
+
diff --git a/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.scss b/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.scss
new file mode 100644
index 000000000..f099d25a1
--- /dev/null
+++ b/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.scss
@@ -0,0 +1,607 @@
+/**
+ * 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/.
+ */
+
+:host {
+ --cpd-blue: #1d4ed8;
+ --cpd-blue-d: #1e40af;
+ --cpd-blue-l: #eff6ff;
+ --cpd-blue-tint: #dbeafe;
+ --cpd-amber: #d97706;
+ --cpd-amber-l: #fef3c7;
+ --cpd-amber-tint: #fde68a;
+ --cpd-red: #b91c1c;
+ --cpd-red-l: #fee2e2;
+ --cpd-red-tint: #fecaca;
+ --cpd-green: #15803d;
+ --cpd-green-l: #dcfce7;
+ --cpd-purple: #6d28d9;
+ --cpd-g50: #f8fafc;
+ --cpd-g100: #f1f5f9;
+ --cpd-g200: #e2e8f0;
+ --cpd-g300: #cbd5e1;
+ --cpd-g500: #64748b;
+ --cpd-g700: #334155;
+ --cpd-g900: #0f172a;
+
+ display: block;
+}
+
+.cpd-root {
+ background: var(--cpd-g100);
+ width: 100%;
+ max-height: 90vh;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+/* ───────── Toolbar (hidden on print) ───────── */
+.cpd-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 14px;
+ padding: 12px 16px;
+ background: #fff;
+ border-bottom: 1px solid var(--cpd-g200);
+ flex-shrink: 0;
+}
+
+.cpd-toolbar-l {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.cpd-toolbar-ic {
+ width: 36px;
+ height: 36px;
+ border-radius: 10px;
+ background: var(--cpd-blue-d);
+ color: #fff;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 15px;
+}
+
+.cpd-toolbar-t {
+ font-size: 14px;
+ font-weight: 800;
+ color: var(--cpd-g900);
+}
+
+.cpd-toolbar-s {
+ font-size: 11.5px;
+ color: var(--cpd-g500);
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+}
+
+.cpd-toolbar-r {
+ display: inline-flex;
+ gap: 8px;
+}
+
+.cpd-tb-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 7px 12px;
+ border: 1px solid var(--cpd-g200);
+ background: #fff;
+ color: var(--cpd-g700);
+ font-size: 12.5px;
+ font-weight: 700;
+ border-radius: 8px;
+ cursor: pointer;
+ transition: all 0.15s ease;
+
+ fa-icon {
+ font-size: 12px;
+ color: var(--cpd-blue);
+ }
+
+ &:hover {
+ background: var(--cpd-blue-l);
+ border-color: var(--cpd-blue-tint);
+ color: var(--cpd-blue-d);
+ }
+
+ &.cpd-tb-x {
+ width: 34px;
+ padding: 0;
+ justify-content: center;
+
+ &:hover {
+ background: var(--cpd-red-l);
+ color: var(--cpd-red);
+ border-color: var(--cpd-red-tint);
+
+ fa-icon {
+ color: var(--cpd-red);
+ }
+ }
+ }
+}
+
+/* ───────── Certificate page (A4 feel) ───────── */
+.cpd-page {
+ background: #fff;
+ border: 1px solid var(--cpd-g200);
+ padding: 38px 48px 24px;
+ margin: 18px auto;
+ width: min(720px, 94%);
+ border-top: 6px solid var(--cpd-blue-d);
+ font-family: Georgia, 'Times New Roman', Times, serif;
+ color: var(--cpd-g900);
+ line-height: 1.6;
+ overflow: auto;
+ position: relative;
+
+ &.cpd-page--share,
+ &.cpd-page--amber {
+ border-top-color: var(--cpd-amber);
+ }
+
+ &.cpd-page--teal {
+ border-top-color: #0d9488;
+ }
+
+ &.cpd-page--red {
+ border-top-color: var(--cpd-red);
+ }
+
+ &.cpd-page--green {
+ border-top-color: var(--cpd-green);
+ }
+
+ /* Subtle decorative border inside the page edge to give the cert a "framed" feel. */
+ &::before {
+ content: '';
+ position: absolute;
+ inset: 18px;
+ border: 1px solid var(--cpd-g200);
+ pointer-events: none;
+ border-radius: 4px;
+ }
+
+ &.cpd-page--share::before,
+ &.cpd-page--amber::before {
+ border-color: var(--cpd-amber-tint);
+ }
+
+ &.cpd-page--teal::before {
+ border-color: #99f6e4;
+ }
+
+ &.cpd-page--red::before {
+ border-color: var(--cpd-red-tint);
+ }
+
+ &.cpd-page--green::before {
+ border-color: #bbf7d0;
+ }
+}
+
+/* ───────── Letterhead ───────── */
+.cpd-letterhead {
+ text-align: center;
+ padding-bottom: 12px;
+ margin-bottom: 14px;
+ border-bottom: 1px solid var(--cpd-g200);
+}
+
+.cpd-bank-name {
+ font-size: 19px;
+ font-weight: 800;
+ letter-spacing: 0.02em;
+ color: var(--cpd-blue-d);
+
+ .cpd-page--share &,
+ .cpd-page--amber & {
+ color: var(--cpd-amber);
+ }
+
+ .cpd-page--teal & {
+ color: #0d9488;
+ }
+
+ .cpd-page--red & {
+ color: var(--cpd-red);
+ }
+
+ .cpd-page--green & {
+ color: var(--cpd-green);
+ }
+}
+
+.cpd-bank-sub {
+ font-size: 11.5px;
+ color: var(--cpd-g500);
+ margin-top: 2px;
+ font-family:
+ ui-sans-serif,
+ -apple-system,
+ BlinkMacSystemFont,
+ 'Segoe UI',
+ sans-serif;
+}
+
+/* ───────── Title strip ───────── */
+.cpd-title-strip {
+ text-align: center;
+ margin: 6px 0 14px;
+ font-family:
+ ui-sans-serif,
+ -apple-system,
+ BlinkMacSystemFont,
+ 'Segoe UI',
+ sans-serif;
+}
+
+.cpd-title-l {
+ display: inline-block;
+ padding: 5px 18px;
+ border-radius: 999px;
+ font-size: 11.5px;
+ font-weight: 800;
+ letter-spacing: 0.15em;
+ text-transform: uppercase;
+ background: var(--cpd-blue-l);
+ color: var(--cpd-blue-d);
+ border: 1px solid var(--cpd-blue-tint);
+
+ .cpd-page--share &,
+ .cpd-page--amber & {
+ background: var(--cpd-amber-l);
+ color: var(--cpd-amber);
+ border-color: var(--cpd-amber-tint);
+ }
+
+ .cpd-page--teal & {
+ background: #ccfbf1;
+ color: #0d9488;
+ border-color: #99f6e4;
+ }
+
+ .cpd-page--red & {
+ background: var(--cpd-red-l);
+ color: var(--cpd-red);
+ border-color: var(--cpd-red-tint);
+ }
+
+ .cpd-page--green & {
+ background: var(--cpd-green-l);
+ color: var(--cpd-green);
+ border-color: #bbf7d0;
+ }
+}
+
+.cpd-stage-pill--teal {
+ background: #ccfbf1;
+ color: #0d9488;
+}
+
+.cpd-stage-pill--red {
+ background: var(--cpd-red-l);
+ color: var(--cpd-red);
+}
+
+.cpd-stage-pill--green {
+ background: var(--cpd-green-l);
+ color: var(--cpd-green);
+}
+
+/* ───────── Meta row ───────── */
+.cpd-meta-row {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ font-family:
+ ui-sans-serif,
+ -apple-system,
+ BlinkMacSystemFont,
+ 'Segoe UI',
+ sans-serif;
+ font-size: 12px;
+ margin-bottom: 16px;
+ padding-bottom: 14px;
+ border-bottom: 1px dashed var(--cpd-g200);
+
+ > div {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ }
+}
+
+.cpd-meta-l {
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--cpd-g500);
+}
+
+.cpd-meta-v {
+ font-size: 12.5px;
+ color: var(--cpd-g900);
+ font-weight: 700;
+}
+
+.cpd-stage-pill {
+ display: inline-flex;
+ align-items: center;
+ padding: 3px 10px;
+ border-radius: 999px;
+ font-size: 10.5px;
+ font-weight: 800;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ background: var(--cpd-blue-l);
+ color: var(--cpd-blue-d);
+
+ &--amber {
+ background: var(--cpd-amber-l);
+ color: var(--cpd-amber);
+ }
+}
+
+/* ───────── Addressee + body ───────── */
+.cpd-block {
+ display: flex;
+ gap: 8px;
+ font-family:
+ ui-sans-serif,
+ -apple-system,
+ BlinkMacSystemFont,
+ 'Segoe UI',
+ sans-serif;
+ font-size: 13px;
+ margin-bottom: 14px;
+}
+
+.cpd-block-l {
+ font-weight: 700;
+ color: var(--cpd-g700);
+ width: 70px;
+ flex-shrink: 0;
+}
+
+.cpd-block-v {
+ flex: 1;
+ color: var(--cpd-g900);
+ line-height: 1.5;
+}
+
+.cpd-para {
+ margin: 12px 0;
+ font-size: 13px;
+ text-align: justify;
+ color: var(--cpd-g900);
+
+ &--centered {
+ text-align: center;
+ font-size: 13.5px;
+ padding: 6px 12px;
+ background: var(--cpd-amber-l);
+ border-left: 4px solid var(--cpd-amber);
+ border-radius: 0 6px 6px 0;
+ }
+}
+
+.cpd-amount {
+ color: var(--cpd-blue-d);
+ font-variant-numeric: tabular-nums;
+}
+
+/* ───────── Summary table ───────── */
+.cpd-summary {
+ width: 100%;
+ margin: 14px 0;
+ border-collapse: collapse;
+ font-family:
+ ui-sans-serif,
+ -apple-system,
+ BlinkMacSystemFont,
+ 'Segoe UI',
+ sans-serif;
+ font-size: 12px;
+
+ td {
+ padding: 8px 10px;
+ border: 1px solid var(--cpd-g200);
+ vertical-align: top;
+ color: var(--cpd-g900);
+ }
+
+ td:nth-child(odd) {
+ background: var(--cpd-g50);
+ color: var(--cpd-g500);
+ font-weight: 700;
+ font-size: 10.5px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ width: 22%;
+ }
+
+ .mono {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ }
+
+ .cpd-summary-total td {
+ background: var(--cpd-blue-l);
+ color: var(--cpd-blue-d);
+ font-weight: 800;
+ font-size: 13px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+
+ &:last-child {
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+ }
+ }
+
+ .cpd-page--share & .cpd-summary-total td {
+ background: var(--cpd-amber-l);
+ color: var(--cpd-amber);
+ }
+}
+
+/* ───────── Sign-off + bank seal ───────── */
+.cpd-signoff {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-end;
+ gap: 24px;
+ margin-top: 26px;
+}
+
+.cpd-sign-block {
+ font-family:
+ ui-sans-serif,
+ -apple-system,
+ BlinkMacSystemFont,
+ 'Segoe UI',
+ sans-serif;
+}
+
+.cpd-sig-line {
+ width: 220px;
+ border-top: 1px solid var(--cpd-g500);
+ margin-bottom: 5px;
+}
+
+.cpd-sig-l {
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--cpd-g900);
+}
+
+.cpd-sig-sub {
+ font-size: 11px;
+ color: var(--cpd-g500);
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+}
+
+/* Decorative round "common seal" for the share certificate (also looks good on
+ the interest certificate). Pure CSS, prints fine. */
+.cpd-seal-ring {
+ width: 96px;
+ height: 96px;
+ border-radius: 50%;
+ border: 2px solid var(--cpd-amber);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+ background: #fff;
+
+ .cpd-page--interest & {
+ border-color: var(--cpd-blue);
+ }
+
+ &::after {
+ content: '';
+ position: absolute;
+ inset: 5px;
+ border-radius: 50%;
+ border: 1px dashed var(--cpd-amber);
+
+ .cpd-page--interest & {
+ border-color: var(--cpd-blue);
+ }
+ }
+}
+
+.cpd-seal-inner {
+ text-align: center;
+ position: relative;
+ z-index: 1;
+}
+
+.cpd-seal-bank {
+ font-size: 12px;
+ font-weight: 800;
+ letter-spacing: 0.18em;
+ color: var(--cpd-amber);
+
+ .cpd-page--interest & {
+ color: var(--cpd-blue);
+ }
+}
+
+.cpd-seal-line {
+ width: 36px;
+ border-top: 1px solid currentcolor;
+ margin: 3px auto;
+ color: var(--cpd-amber);
+
+ .cpd-page--interest & {
+ color: var(--cpd-blue);
+ }
+}
+
+.cpd-seal-sub {
+ font-size: 7.5px;
+ font-weight: 800;
+ letter-spacing: 0.12em;
+ color: var(--cpd-g500);
+}
+
+/* ───────── Footer ───────── */
+.cpd-footer {
+ margin-top: 24px;
+ padding-top: 10px;
+ border-top: 1px dashed var(--cpd-g200);
+ font-family:
+ ui-sans-serif,
+ -apple-system,
+ BlinkMacSystemFont,
+ 'Segoe UI',
+ sans-serif;
+ font-size: 10.5px;
+ color: var(--cpd-g500);
+ text-align: center;
+}
+
+.mono {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+}
+
+/* ───────── Print — only the page, no toolbar / dialog chrome ───────── */
+@media print {
+ :host {
+ background: #fff;
+ }
+
+ .no-print {
+ display: none !important;
+ }
+
+ .cpd-root {
+ background: #fff;
+ max-height: none;
+ overflow: visible;
+ height: auto;
+ }
+
+ .cpd-page {
+ border: 0;
+ margin: 0;
+ padding: 24px 32px;
+ width: 100%;
+ box-shadow: none;
+ border-top: 4px solid var(--cpd-blue-d);
+
+ &.cpd-page--share {
+ border-top-color: var(--cpd-amber);
+ }
+ }
+}
diff --git a/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.ts b/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.ts
new file mode 100644
index 000000000..43bd6ce5d
--- /dev/null
+++ b/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.ts
@@ -0,0 +1,322 @@
+/**
+ * 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 { CommonModule } from '@angular/common';
+import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
+import { MatTooltip } from '@angular/material/tooltip';
+import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+
+import { FyInterestBreakup, LoanInterestRecord } from '../loan-interest/loan-interest.component';
+import { ShareCertificateRecord } from '../share-certificate/share-certificate.component';
+import { BalanceCertificateRecord } from '../balance-certificate/balance-certificate.component';
+import { Form60Record } from '../form-60/form-60.component';
+import { Form15Kind, Form15ghRecord } from '../form-15gh/form-15gh.component';
+import { HypothecationRecord } from '../hypothecation/hypothecation.component';
+import { NocRecord } from '../noc/noc.component';
+import {
+ downloadBalanceCertificatePdf,
+ downloadForm15ghPdf,
+ downloadForm60Pdf,
+ downloadHypothecationPdf,
+ downloadLoanInterestPdf,
+ downloadNocPdf,
+ downloadShareCertificatePdf
+} from '../certificate-pdf';
+
+export interface InterestPreviewPayload {
+ record: LoanInterestRecord;
+ fy: FyInterestBreakup;
+}
+
+export interface BalancePreviewPayload {
+ record: BalanceCertificateRecord;
+ asOnDate: string;
+}
+
+export interface Form15ghPreviewPayload {
+ record: Form15ghRecord;
+ kind: Form15Kind;
+ age: number;
+}
+
+export type CertificateKind = 'interest' | 'share' | 'balance' | 'form60' | 'form15gh' | 'hypothecation' | 'noc';
+
+export interface CertificatePreviewDialogData {
+ kind: CertificateKind;
+ loanInterest?: InterestPreviewPayload;
+ shareCertificate?: ShareCertificateRecord;
+ balance?: BalancePreviewPayload;
+ form60?: Form60Record;
+ form15gh?: Form15ghPreviewPayload;
+ hypothecation?: HypothecationRecord;
+ noc?: NocRecord;
+}
+
+@Component({
+ selector: 'mifosx-certificate-preview-dialog',
+ templateUrl: './certificate-preview-dialog.component.html',
+ styleUrls: ['./certificate-preview-dialog.component.scss'],
+ imports: [
+ ...STANDALONE_SHARED_IMPORTS,
+ CommonModule,
+ FaIconComponent,
+ MatTooltip
+ ]
+})
+export class CertificatePreviewDialogComponent {
+ private dialogRef = inject(MatDialogRef);
+ data: CertificatePreviewDialogData = inject(MAT_DIALOG_DATA);
+
+ readonly bankName = 'Nexora Co-operative Bank Ltd.';
+ readonly bankAddress = 'Head Office, 4th Floor, Nexora Tower, Mumbai 400051';
+ readonly bankRegNo = 'RBI Lic. No. NX-2018/MH/482';
+ readonly bankContact = 'info@nexorabank.in · 022 6789 0000';
+
+ get isInterest(): boolean {
+ return this.data.kind === 'interest';
+ }
+ get isShare(): boolean {
+ return this.data.kind === 'share';
+ }
+ get isBalance(): boolean {
+ return this.data.kind === 'balance';
+ }
+ get isForm60(): boolean {
+ return this.data.kind === 'form60';
+ }
+ get isForm15gh(): boolean {
+ return this.data.kind === 'form15gh';
+ }
+ get isHypothecation(): boolean {
+ return this.data.kind === 'hypothecation';
+ }
+ get isNoc(): boolean {
+ return this.data.kind === 'noc';
+ }
+
+ get interest(): InterestPreviewPayload | undefined {
+ return this.data.loanInterest;
+ }
+ get share(): ShareCertificateRecord | undefined {
+ return this.data.shareCertificate;
+ }
+ get balance(): BalancePreviewPayload | undefined {
+ return this.data.balance;
+ }
+ get form60(): Form60Record | undefined {
+ return this.data.form60;
+ }
+ get form15gh(): Form15ghPreviewPayload | undefined {
+ return this.data.form15gh;
+ }
+ get hypothecation(): HypothecationRecord | undefined {
+ return this.data.hypothecation;
+ }
+ get noc(): NocRecord | undefined {
+ return this.data.noc;
+ }
+
+ /** Tone class on `.cpd-page` — drives the accent stripe + seal + pill colour. */
+ get pageToneClass(): string {
+ switch (this.data.kind) {
+ case 'share':
+ case 'form60':
+ return 'cpd-page--amber';
+ case 'form15gh':
+ return 'cpd-page--teal';
+ case 'hypothecation':
+ return 'cpd-page--red';
+ case 'noc':
+ return 'cpd-page--green';
+ case 'balance':
+ case 'interest':
+ default:
+ return '';
+ }
+ }
+
+ get title(): string {
+ switch (this.data.kind) {
+ case 'interest':
+ return 'Interest-Paid Certificate';
+ case 'share':
+ return 'Share Certificate';
+ case 'balance':
+ return 'Balance Certificate';
+ case 'form60':
+ return 'Form 60 Declaration';
+ case 'form15gh':
+ return this.form15gh?.kind === '15H' ? 'Form 15H Declaration' : 'Form 15G Declaration';
+ case 'hypothecation':
+ return 'Hypothecation Certificate';
+ case 'noc':
+ return 'No-Objection Certificate';
+ }
+ }
+
+ get refLine(): string {
+ if (this.isInterest && this.interest) {
+ return `${this.interest.record.accountNo} · ${this.interest.fy.fy}`;
+ }
+ if (this.isShare && this.share) {
+ return `${this.share.certificateNo} · Folio ${this.share.folioNo}`;
+ }
+ if (this.isBalance && this.balance) {
+ return `${this.balance.record.accountNo} · as on ${this.balance.asOnDate}`;
+ }
+ if (this.isForm60 && this.form60) {
+ return `${this.form60.customerId} · ${this.form60.transactionType}`;
+ }
+ if (this.isForm15gh && this.form15gh) {
+ return `${this.form15gh.record.customerId} · ${this.form15gh.kind} · ${this.form15gh.record.fy}`;
+ }
+ if (this.isHypothecation && this.hypothecation) {
+ return `${this.hypothecation.certificateNo} · ${this.hypothecation.accountNo}`;
+ }
+ if (this.isNoc && this.noc) {
+ return `${this.noc.nocNo} · ${this.noc.accountNo}`;
+ }
+ return '';
+ }
+
+ get issuedOn(): string {
+ return new Date().toISOString().slice(0, 10);
+ }
+
+ close(): void {
+ this.dialogRef.close();
+ }
+
+ printCertificate(): void {
+ window.print();
+ }
+
+ downloadPdf(): void {
+ if (this.isInterest && this.interest) {
+ downloadLoanInterestPdf(this.interest.record, this.interest.fy);
+ } else if (this.isShare && this.share) {
+ downloadShareCertificatePdf(this.share);
+ } else if (this.isBalance && this.balance) {
+ downloadBalanceCertificatePdf(this.balance.record, this.balance.asOnDate);
+ } else if (this.isForm60 && this.form60) {
+ downloadForm60Pdf(this.form60);
+ } else if (this.isForm15gh && this.form15gh) {
+ downloadForm15ghPdf(this.form15gh.record, this.form15gh.kind);
+ } else if (this.isHypothecation && this.hypothecation) {
+ downloadHypothecationPdf(this.hypothecation);
+ } else if (this.isNoc && this.noc) {
+ downloadNocPdf(this.noc);
+ }
+ }
+
+ nocLoanTypeLabel(): string {
+ const t = this.noc?.loanType;
+ switch (t) {
+ case 'AUTO':
+ return 'Auto Loan';
+ case 'HOUSING':
+ return 'Housing Loan';
+ case 'PERSONAL':
+ return 'Personal Loan';
+ case 'EDUCATION':
+ return 'Education Loan';
+ case 'GOLD':
+ return 'Gold Loan';
+ case 'BUSINESS':
+ return 'Business Loan';
+ case 'CC':
+ return 'Cash Credit';
+ case 'OD':
+ return 'Overdraft';
+ default:
+ return '';
+ }
+ }
+
+ nocAssetLabel(): string {
+ const t = this.noc?.assetType;
+ switch (t) {
+ case 'VEHICLE':
+ return 'Vehicle';
+ case 'PROPERTY':
+ return 'Property';
+ case 'GOLD':
+ return 'Gold / Bullion';
+ case 'EQUIPMENT':
+ return 'Equipment';
+ case 'STOCK_DEBTORS':
+ return 'Stock / Debtors';
+ case 'NONE':
+ return 'Unsecured';
+ default:
+ return '';
+ }
+ }
+
+ classLabelText(): string {
+ return this.share?.shareClass === 'PREFERENCE' ? 'Preference' : 'Ordinary';
+ }
+
+ accountTypeLabelText(): string {
+ const t = this.balance?.record.accountType;
+ switch (t) {
+ case 'SAVINGS':
+ return 'Savings';
+ case 'CURRENT':
+ return 'Current';
+ case 'FIXED_DEPOSIT':
+ return 'Fixed Deposit';
+ case 'RD':
+ return 'Recurring Deposit';
+ case 'NRE':
+ return 'NRE Savings';
+ case 'NRO':
+ return 'NRO Savings';
+ default:
+ return '';
+ }
+ }
+
+ idProofLabelText(): string {
+ const t = this.form60?.idProofType;
+ switch (t) {
+ case 'AADHAAR':
+ return 'Aadhaar Card';
+ case 'VOTER_ID':
+ return 'Voter Identity Card';
+ case 'DRIVING_LICENSE':
+ return 'Driving License';
+ case 'PASSPORT':
+ return 'Passport';
+ case 'RATION':
+ return 'Ration Card';
+ default:
+ return '';
+ }
+ }
+
+ assetLabelText(): string {
+ const t = this.hypothecation?.assetType;
+ switch (t) {
+ case 'VEHICLE':
+ return 'Vehicle';
+ case 'PLANT_MACHINERY':
+ return 'Plant & Machinery';
+ case 'STOCK_DEBTORS':
+ return 'Stock / Debtors';
+ case 'GOLD':
+ return 'Gold / Bullion';
+ case 'EQUIPMENT':
+ return 'Equipment';
+ default:
+ return '';
+ }
+ }
+}
diff --git a/src/app/letter-mgmt/certificates/certificates.shared.scss b/src/app/letter-mgmt/certificates/certificates.shared.scss
new file mode 100644
index 000000000..c93a0bdc7
--- /dev/null
+++ b/src/app/letter-mgmt/certificates/certificates.shared.scss
@@ -0,0 +1,470 @@
+/**
+ * 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/.
+ */
+
+/*
+ * Shared list-page styling for the Letter-Mgmt certificate pages
+ * (Loan Interest, Share Certificate, etc.). Mirrors the visual language of
+ * the notice list (header + filters + table) so the module feels cohesive.
+ */
+
+:host {
+ --cert-blue: #1d4ed8;
+ --cert-blue-d: #1e40af;
+ --cert-blue-l: #eff6ff;
+ --cert-blue-tint: #dbeafe;
+ --cert-teal: #0d9488;
+ --cert-teal-l: #ccfbf1;
+ --cert-green: #15803d;
+ --cert-green-l: #dcfce7;
+ --cert-amber: #d97706;
+ --cert-amber-l: #fef3c7;
+ --cert-red: #b91c1c;
+ --cert-red-l: #fee2e2;
+ --cert-red-tint: #fecaca;
+ --cert-purple: #6d28d9;
+ --cert-purple-l: #ede9fe;
+ --cert-g50: #f8fafc;
+ --cert-g100: #f1f5f9;
+ --cert-g200: #e2e8f0;
+ --cert-g300: #cbd5e1;
+ --cert-g500: #64748b;
+ --cert-g700: #334155;
+ --cert-g900: #0f172a;
+
+ display: block;
+}
+
+.cert-root {
+ padding: 20px 24px 32px;
+ max-width: 1340px;
+ margin: 0 auto;
+}
+
+/* ───────── Header banner ───────── */
+.cert-head {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 18px 22px;
+ border-radius: 14px;
+ background: linear-gradient(135deg, var(--cert-blue-l) 0%, #fff 65%);
+ border: 1px solid var(--cert-blue-tint);
+ margin-bottom: 16px;
+}
+
+.cert-head-l {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+}
+
+.cert-head-ic {
+ width: 48px;
+ height: 48px;
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 14px;
+ background: var(--cert-blue-d);
+ color: #fff;
+ font-size: 20px;
+}
+
+.cert-head-t {
+ margin: 0 0 3px;
+ font-size: 17px;
+ font-weight: 800;
+ color: var(--cert-g900);
+}
+
+.cert-head-s {
+ font-size: 12.5px;
+ color: var(--cert-g500);
+ line-height: 1.5;
+}
+
+/* ───────── KPI strip ───────── */
+.cert-kpis {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 10px;
+ margin-bottom: 14px;
+
+ @media (width <= 900px) {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+.cert-kpi {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 14px;
+ border-radius: 12px;
+ background: #fff;
+ border: 1px solid var(--cert-g200);
+
+ &--blue {
+ background: var(--cert-blue-l);
+ border-color: var(--cert-blue-tint);
+ }
+
+ &--green {
+ background: var(--cert-green-l);
+ border-color: #bbf7d0;
+ }
+
+ &--teal {
+ background: var(--cert-teal-l);
+ border-color: #99f6e4;
+ }
+
+ &--amber {
+ background: var(--cert-amber-l);
+ border-color: #fde68a;
+ }
+}
+
+.cert-kpi-ic {
+ width: 36px;
+ height: 36px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 10px;
+ background: rgb(255 255 255 / 70%);
+ font-size: 16px;
+
+ .cert-kpi--blue & {
+ color: var(--cert-blue);
+ }
+
+ .cert-kpi--green & {
+ color: var(--cert-green);
+ }
+
+ .cert-kpi--teal & {
+ color: var(--cert-teal);
+ }
+
+ .cert-kpi--amber & {
+ color: var(--cert-amber);
+ }
+}
+
+.cert-kpi-n {
+ font-size: 17px;
+ font-weight: 800;
+ color: var(--cert-g900);
+ font-variant-numeric: tabular-nums;
+ line-height: 1.15;
+}
+
+.cert-kpi-l {
+ font-size: 10.5px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: var(--cert-g500);
+ margin-top: 2px;
+}
+
+/* ───────── Filters ───────── */
+.cert-filters {
+ display: flex;
+ align-items: flex-end;
+ gap: 12px;
+ padding: 14px;
+ background: #fff;
+ border: 1px solid var(--cert-g200);
+ border-radius: 12px;
+ margin-bottom: 12px;
+ flex-wrap: wrap;
+}
+
+.cert-filter {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ flex: 1 1 0;
+ min-width: 220px;
+ position: relative;
+
+ label {
+ font-size: 10.5px;
+ font-weight: 700;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ color: var(--cert-g500);
+ }
+
+ input,
+ select {
+ box-sizing: border-box;
+ width: 100%;
+ height: 36px;
+ padding: 0 12px;
+ border: 1px solid var(--cert-g200);
+ border-radius: 8px;
+ background: #fff;
+ font-size: 13px;
+ color: var(--cert-g900);
+ outline: none;
+ transition: border-color 0.15s ease;
+ appearance: none;
+
+ &:focus {
+ border-color: var(--cert-blue);
+ }
+ }
+
+ select {
+ padding-right: 34px;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8' fill='none' stroke='%2364748b' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='1 2 6 7 11 2'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 12px center;
+ background-size: 12px 8px;
+ cursor: pointer;
+ }
+}
+
+.cert-input-ic {
+ position: relative;
+ width: 100%;
+
+ fa-icon {
+ position: absolute;
+ left: 11px;
+ top: 50%;
+ transform: translateY(-50%);
+ color: var(--cert-g500);
+ font-size: 12px;
+ pointer-events: none;
+ z-index: 1;
+ }
+
+ input {
+ padding-left: 32px;
+ }
+}
+
+.cert-clear {
+ height: 36px;
+ padding: 0 14px;
+ border: 1px solid var(--cert-g200);
+ border-radius: 8px;
+ background: #fff;
+ color: var(--cert-g700);
+ font-size: 12px;
+ font-weight: 700;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+
+ &:hover {
+ background: var(--cert-red-l);
+ color: var(--cert-red);
+ border-color: var(--cert-red-tint);
+ }
+}
+
+/* ───────── Table ───────── */
+.cert-card {
+ background: #fff;
+ border: 1px solid var(--cert-g200);
+ border-radius: 12px;
+ overflow: hidden;
+}
+
+.cert-table-wrap {
+ overflow-x: auto;
+}
+
+.cert-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12.5px;
+
+ th {
+ text-align: left;
+ padding: 12px;
+ background: var(--cert-g50);
+ color: var(--cert-g500);
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ font-size: 10.5px;
+ border-bottom: 1px solid var(--cert-g200);
+ white-space: nowrap;
+ }
+
+ td {
+ padding: 12px;
+ border-bottom: 1px solid var(--cert-g100);
+ vertical-align: top;
+ color: var(--cert-g900);
+ }
+
+ tr:last-child td {
+ border-bottom: 0;
+ }
+
+ .num {
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+ }
+
+ .mono {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ }
+
+ .small {
+ font-size: 11.5px;
+ }
+
+ .muted {
+ color: var(--cert-g500);
+ }
+
+ .col-actions {
+ width: 88px;
+ text-align: right;
+ white-space: nowrap;
+ }
+}
+
+.cert-cust-name {
+ font-weight: 700;
+ color: var(--cert-g900);
+}
+
+.cert-interest {
+ color: var(--cert-green);
+ font-weight: 800;
+}
+
+/* Pills */
+.cert-pill {
+ display: inline-flex;
+ align-items: center;
+ padding: 3px 10px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ background: var(--cert-g100);
+ color: var(--cert-g700);
+ white-space: nowrap;
+
+ &--type {
+ background: var(--cert-purple-l);
+ color: var(--cert-purple);
+ }
+
+ &--green {
+ background: var(--cert-green-l);
+ color: var(--cert-green);
+ }
+
+ &--blue {
+ background: var(--cert-blue-l);
+ color: var(--cert-blue-d);
+ }
+
+ &--amber {
+ background: var(--cert-amber-l);
+ color: var(--cert-amber);
+ }
+}
+
+.cert-rate-pill {
+ display: inline-flex;
+ align-items: center;
+ padding: 3px 10px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 700;
+ background: var(--cert-blue-l);
+ color: var(--cert-blue-d);
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+
+/* Row action icon buttons */
+.cert-row-actions {
+ display: inline-flex;
+ gap: 4px;
+}
+
+.cert-icon-btn {
+ width: 30px;
+ height: 30px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid var(--cert-g200);
+ background: #fff;
+ color: var(--cert-g500);
+ border-radius: 8px;
+ cursor: pointer;
+ font-size: 11.5px;
+ transition:
+ background 0.15s,
+ color 0.15s,
+ border-color 0.15s;
+
+ &:hover:not(:disabled) {
+ background: var(--cert-blue-l);
+ color: var(--cert-blue-d);
+ border-color: var(--cert-blue-tint);
+ }
+
+ &:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+ }
+
+ &.primary {
+ background: var(--cert-blue-d);
+ color: #fff;
+ border-color: var(--cert-blue-d);
+
+ &:hover:not(:disabled) {
+ background: var(--cert-blue);
+ border-color: var(--cert-blue);
+ }
+ }
+}
+
+.cert-empty {
+ text-align: center;
+ padding: 28px 16px;
+ color: var(--cert-g500);
+ font-size: 13px;
+
+ fa-icon {
+ margin-right: 6px;
+ color: var(--cert-blue);
+ }
+}
+
+/* Share-certificate specific cells (used by share-certificate.component.html) */
+.cert-shares {
+ font-weight: 800;
+ color: var(--cert-blue-d);
+ font-variant-numeric: tabular-nums;
+}
+
+.cert-folio {
+ font-weight: 700;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ color: var(--cert-g900);
+}
diff --git a/src/app/letter-mgmt/certificates/form-15gh/form-15gh.component.html b/src/app/letter-mgmt/certificates/form-15gh/form-15gh.component.html
new file mode 100644
index 000000000..be1dfe5de
--- /dev/null
+++ b/src/app/letter-mgmt/certificates/form-15gh/form-15gh.component.html
@@ -0,0 +1,171 @@
+
+
+
+
+
+
+
+
Form 15G & 15H
+
+ TDS-waiver declarations for FD / RD interest income —
+ 15G for individuals below 60 years, 15H for senior citizens (60+).
+
+
+
+
+
+
+
+
+
+
+
{{ count15G() | number }}
+
Form 15G (under 60)
+
+
+
+
+
+
{{ count15H() | number }}
+
Form 15H (60+)
+
+
+
+
+
+
{{ countFiled() | number }}
+
Filed this FY
+
+
+
+
+
+
₹{{ totalInterest() | number: '1.0-0' }}
+
Projected Interest
+
+
+
+
+
+
+
+
Customer / Customer ID / PAN
+
+
+
+
+
+
+
+ Form
+
+ Both forms
+ 15G — Under 60
+ 15H — Senior citizens (60+)
+
+
+
+
+ Status
+
+ All statuses
+ Filed
+ Pending
+
+
+
+ @if (search || kindFilter || statusFilter) {
+
Clear
+ }
+
+
+
+
+
+
+
+
+ Customer · PAN
+ Form
+ Age
+ Branch
+ Deposit Principal
+ Est. Interest ({{ records[0]?.fy }})
+ Status
+ Actions
+
+
+
+ @for (r of filteredRecords; track r.customerId) {
+
+
+ {{ r.customerName }}
+ {{ r.customerId }} · PAN {{ r.customerPan }}
+ DOB {{ r.dob | date: 'd MMM y' }}
+
+
+
+ Form {{ formKind(r) }}
+
+
+ {{ age(r.dob) }} yrs
+ {{ r.branch }}
+ ₹{{ r.depositPrincipal | number: '1.0-0' }}
+
+ ₹{{ r.estimatedInterest | number: '1.0-0' }}
+
+
+ @if (r.status === 'FILED') {
+ Filed
+ @if (r.filedOn) {
+ {{ r.filedOn | date: 'd MMM y' }}
+ }
+ } @else {
+ Pending
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ @if (filteredRecords.length === 0) {
+
+
+
+ No declarations match the current filters.
+
+
+ }
+
+
+
+
+
diff --git a/src/app/letter-mgmt/certificates/form-15gh/form-15gh.component.ts b/src/app/letter-mgmt/certificates/form-15gh/form-15gh.component.ts
new file mode 100644
index 000000000..ac158ee14
--- /dev/null
+++ b/src/app/letter-mgmt/certificates/form-15gh/form-15gh.component.ts
@@ -0,0 +1,241 @@
+/**
+ * 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, OnInit } 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 { MatSnackBar } from '@angular/material/snack-bar';
+import { MatTooltip } from '@angular/material/tooltip';
+import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+
+import {
+ CertificatePreviewDialogComponent,
+ CertificatePreviewDialogData
+} from '../certificate-preview-dialog/certificate-preview-dialog.component';
+import { downloadForm15ghPdf } from '../certificate-pdf';
+
+export type Form15Kind = '15G' | '15H';
+export type Form15Status = 'FILED' | 'PENDING';
+
+export interface Form15ghRecord {
+ customerId: string;
+ customerName: string;
+ customerPan: string;
+ customerAddress: string;
+ dob: string;
+ branch: string;
+ /** Aggregate interest the bank will pay this FY. */
+ estimatedInterest: number;
+ /** Customer's total estimated income for the FY. */
+ estimatedIncome: number;
+ /** Sum of FD / RD principals tied to the declaration. */
+ depositPrincipal: number;
+ fy: string;
+ status: Form15Status;
+ filedOn?: string;
+ /** "Yes / No" for: has any income tax been assessed for the customer
+ under the IT Act in the past six years? */
+ assessed: boolean;
+}
+
+@Component({
+ selector: 'mifosx-form-15gh',
+ templateUrl: './form-15gh.component.html',
+ styleUrls: ['../certificates.shared.scss'],
+ imports: [
+ ...STANDALONE_SHARED_IMPORTS,
+ CommonModule,
+ FormsModule,
+ FaIconComponent,
+ MatTooltip
+ ]
+})
+export class Form15ghComponent implements OnInit {
+ private dialog = inject(MatDialog);
+ private snack = inject(MatSnackBar);
+
+ // ── Filters ──
+ search = '';
+ /** Which form. Empty = both. */
+ kindFilter: '' | Form15Kind = '';
+ statusFilter: '' | Form15Status = '';
+
+ records: Form15ghRecord[] = [];
+
+ ngOnInit(): void {
+ this.records = seedForm15gh();
+ }
+
+ /** Compute age in years from DoB. */
+ age(dob: string): number {
+ if (!dob) return 0;
+ const today = new Date();
+ const birth = new Date(dob);
+ let age = today.getFullYear() - birth.getFullYear();
+ const m = today.getMonth() - birth.getMonth();
+ if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) age--;
+ return age;
+ }
+
+ /** 15H once 60+, 15G otherwise. */
+ formKind(r: Form15ghRecord): Form15Kind {
+ return this.age(r.dob) >= 60 ? '15H' : '15G';
+ }
+
+ get filteredRecords(): Form15ghRecord[] {
+ const q = this.search.trim().toLowerCase();
+ return this.records.filter((r) => {
+ if (this.kindFilter && this.formKind(r) !== this.kindFilter) return false;
+ if (this.statusFilter && r.status !== this.statusFilter) return false;
+ if (!q) return true;
+ return (
+ r.customerName.toLowerCase().includes(q) ||
+ r.customerId.toLowerCase().includes(q) ||
+ r.customerPan.toLowerCase().includes(q)
+ );
+ });
+ }
+
+ count15G(): number {
+ return this.records.filter((r) => this.formKind(r) === '15G').length;
+ }
+
+ count15H(): number {
+ return this.records.filter((r) => this.formKind(r) === '15H').length;
+ }
+
+ countFiled(): number {
+ return this.records.filter((r) => r.status === 'FILED').length;
+ }
+
+ totalInterest(): number {
+ return this.filteredRecords.reduce((s, r) => s + r.estimatedInterest, 0);
+ }
+
+ clearFilters(): void {
+ this.search = '';
+ this.kindFilter = '';
+ this.statusFilter = '';
+ }
+
+ viewCertificate(r: Form15ghRecord): void {
+ const data: CertificatePreviewDialogData = {
+ kind: 'form15gh',
+ form15gh: { record: r, kind: this.formKind(r), age: this.age(r.dob) }
+ };
+ this.dialog.open(CertificatePreviewDialogComponent, {
+ width: '880px',
+ maxWidth: '96vw',
+ maxHeight: '92vh',
+ autoFocus: false,
+ panelClass: 'cert-preview-dialog',
+ data
+ });
+ }
+
+ downloadCertificate(r: Form15ghRecord): void {
+ downloadForm15ghPdf(r, this.formKind(r));
+ this.snack.open(`Form ${this.formKind(r)} for ${r.customerName} downloaded.`, 'OK', { duration: 2400 });
+ }
+}
+
+// ───────────────────── Mock seed (top-level export) ─────────────────────
+export function seedForm15gh(): Form15ghRecord[] {
+ return [
+ {
+ customerId: 'CUST112233',
+ customerName: 'Rohan S. Mehta',
+ customerPan: 'ABCPM1234E',
+ customerAddress: 'Flat 902, Skyline Apt, Andheri (E), Mumbai 400069',
+ dob: '1985-02-14',
+ branch: 'Mumbai — Andheri (E)',
+ estimatedInterest: 38500,
+ estimatedIncome: 850000,
+ depositPrincipal: 500000,
+ fy: 'FY 2025-26',
+ status: 'FILED',
+ filedOn: '2025-04-10',
+ assessed: true
+ },
+ {
+ customerId: 'CUST556677',
+ customerName: 'Karthik Rao',
+ customerPan: 'PQRSR2233Z',
+ customerAddress: '24, MG Road, Bengaluru 560001',
+ dob: '1972-09-04',
+ branch: 'Bengaluru — MG Road',
+ estimatedInterest: 52800,
+ estimatedIncome: 320000,
+ depositPrincipal: 700000,
+ fy: 'FY 2025-26',
+ status: 'PENDING',
+ assessed: false
+ },
+ {
+ customerId: 'CUST667788',
+ customerName: 'Lakshmi Naidu',
+ customerPan: 'XYZAN7788Q',
+ customerAddress: 'Plot 14, Road No. 3, Banjara Hills, Hyderabad 500034',
+ dob: '1958-06-22',
+ branch: 'Hyderabad — Banjara Hills',
+ estimatedInterest: 88200,
+ estimatedIncome: 410000,
+ depositPrincipal: 1200000,
+ fy: 'FY 2025-26',
+ status: 'FILED',
+ filedOn: '2025-04-15',
+ assessed: true
+ },
+ {
+ customerId: 'CUST334455',
+ customerName: 'Anita Verma',
+ customerPan: 'EFGHV5678A',
+ customerAddress: 'B-12, Connaught Pl, New Delhi 110001',
+ dob: '1962-11-30',
+ branch: 'New Delhi — Connaught Place',
+ estimatedInterest: 64500,
+ estimatedIncome: 280000,
+ depositPrincipal: 900000,
+ fy: 'FY 2025-26',
+ status: 'FILED',
+ filedOn: '2025-04-05',
+ assessed: false
+ },
+ {
+ customerId: 'CUST990011',
+ customerName: 'Arjun Banerjee',
+ customerPan: 'TUVWB4455F',
+ customerAddress: '42, Park Street, Kolkata 700016',
+ dob: '1991-07-19',
+ branch: 'Kolkata — Park Street',
+ estimatedInterest: 14200,
+ estimatedIncome: 620000,
+ depositPrincipal: 200000,
+ fy: 'FY 2025-26',
+ status: 'PENDING',
+ assessed: true
+ },
+ {
+ customerId: 'CUST330044',
+ customerName: 'Madhavi Singh',
+ customerPan: 'TUVWS3344K',
+ customerAddress: 'D-5, Greater Kailash, New Delhi 110048',
+ dob: '1955-03-08',
+ branch: 'New Delhi — Connaught Place',
+ estimatedInterest: 145000,
+ estimatedIncome: 320000,
+ depositPrincipal: 2000000,
+ fy: 'FY 2025-26',
+ status: 'FILED',
+ filedOn: '2025-04-02',
+ assessed: false
+ }
+ ];
+}
diff --git a/src/app/letter-mgmt/certificates/form-60/form-60.component.html b/src/app/letter-mgmt/certificates/form-60/form-60.component.html
new file mode 100644
index 000000000..c22787b84
--- /dev/null
+++ b/src/app/letter-mgmt/certificates/form-60/form-60.component.html
@@ -0,0 +1,178 @@
+
+
+
+
+
+
+
+
Form 60 Declaration
+
+ Income Tax Rule 114B — required for any customer transacting above the threshold who does
+ not have a PAN on file.
+
+
+
+
+
+
+
+
+
+
+
{{ totalNoPan() | number }}
+
Customers without PAN
+
+
+
+
+
+
{{ totalFiled() | number }}
+
Form 60 Filed
+
+
+
+
+
+
{{ totalPending() | number }}
+
Filing Pending
+
+
+
+
+
+
+
+
Customer / Customer ID / ID No.
+
+
+
+
+
+
+
+ PAN status
+
+ Without PAN (Form 60 applicable)
+ With PAN on file
+ All customers
+
+
+
+
+ ID proof used
+
+ All ID proofs
+ @for (o of idProofOptions; track o.value) {
+ {{ o.label }}
+ }
+
+
+
+ @if (search || idProofFilter || panFilter !== 'MISSING') {
+
Clear
+ }
+
+
+
+
+
+
+
+
+ Customer · ID
+ ID Proof
+ Branch
+ Trigger Transaction
+ Amount
+ PAN Status
+ Form 60
+ Actions
+
+
+
+ @for (r of filteredRecords; track r.customerId) {
+
+
+ {{ r.customerName }}
+ {{ r.customerId }} · DOB {{ r.dob | date: 'd MMM y' }}
+ S/o, D/o, W/o: {{ r.fatherName }}
+
+
+ {{ idProofLabel(r.idProofType) }}
+ {{ r.idProofNumber }}
+
+ {{ r.branch }}
+
+ {{ r.transactionType }}
+ @if (r.transactionDate) {
+ {{ r.transactionDate | date: 'd MMM y' }}
+ }
+
+
+ @if (r.transactionAmount > 0) {
+ ₹{{ r.transactionAmount | number: '1.0-0' }}
+ } @else {
+ —
+ }
+
+
+ @if (r.hasPan) {
+ PAN on file
+ } @else {
+
+ No PAN
+
+ }
+
+
+ @if (r.hasPan) {
+ N/A
+ } @else if (r.filedOn) {
+ Filed · {{ r.filedOn | date: 'd MMM' }}
+ } @else {
+ Pending
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ @if (filteredRecords.length === 0) {
+
+
+
+ No customers match the current filters.
+
+
+ }
+
+
+
+
+
diff --git a/src/app/letter-mgmt/certificates/form-60/form-60.component.ts b/src/app/letter-mgmt/certificates/form-60/form-60.component.ts
new file mode 100644
index 000000000..335cdc762
--- /dev/null
+++ b/src/app/letter-mgmt/certificates/form-60/form-60.component.ts
@@ -0,0 +1,236 @@
+/**
+ * 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, OnInit } 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 { MatSnackBar } from '@angular/material/snack-bar';
+import { MatTooltip } from '@angular/material/tooltip';
+import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+
+import {
+ CertificatePreviewDialogComponent,
+ CertificatePreviewDialogData
+} from '../certificate-preview-dialog/certificate-preview-dialog.component';
+import { downloadForm60Pdf } from '../certificate-pdf';
+
+export type Form60IdProofType = 'AADHAAR' | 'VOTER_ID' | 'DRIVING_LICENSE' | 'PASSPORT' | 'RATION';
+
+export interface Form60Record {
+ customerId: string;
+ customerName: string;
+ customerAddress: string;
+ fatherName: string;
+ dob: string;
+ branch: string;
+ /** True only when there's no PAN on file — Form 60 is required. */
+ hasPan: boolean;
+ /** ID document used in lieu of PAN. */
+ idProofType: Form60IdProofType;
+ idProofNumber: string;
+ /** Transaction reason that triggered Form 60 (cash >50k etc.). */
+ transactionType: string;
+ transactionAmount: number;
+ transactionDate: string;
+ estimatedIncome: number;
+ agriIncome: number;
+ filedOn?: string;
+}
+
+@Component({
+ selector: 'mifosx-form-60',
+ templateUrl: './form-60.component.html',
+ styleUrls: ['../certificates.shared.scss'],
+ imports: [
+ ...STANDALONE_SHARED_IMPORTS,
+ CommonModule,
+ FormsModule,
+ FaIconComponent,
+ MatTooltip
+ ]
+})
+export class Form60Component implements OnInit {
+ private dialog = inject(MatDialog);
+ private snack = inject(MatSnackBar);
+
+ // ── Filters ──
+ search = '';
+ idProofFilter: '' | Form60IdProofType = '';
+ /** Default: only show customers without PAN (since Form 60 only applies to them). */
+ panFilter: '' | 'MISSING' | 'ON_FILE' = 'MISSING';
+
+ records: Form60Record[] = [];
+
+ readonly idProofOptions: { value: Form60IdProofType; label: string }[] = [
+ { value: 'AADHAAR', label: 'Aadhaar Card' },
+ { value: 'VOTER_ID', label: 'Voter ID' },
+ { value: 'DRIVING_LICENSE', label: 'Driving License' },
+ { value: 'PASSPORT', label: 'Passport' },
+ { value: 'RATION', label: 'Ration Card' }
+ ];
+
+ ngOnInit(): void {
+ this.records = seedForm60();
+ }
+
+ get filteredRecords(): Form60Record[] {
+ const q = this.search.trim().toLowerCase();
+ return this.records.filter((r) => {
+ if (this.panFilter === 'MISSING' && r.hasPan) return false;
+ if (this.panFilter === 'ON_FILE' && !r.hasPan) return false;
+ if (this.idProofFilter && r.idProofType !== this.idProofFilter) return false;
+ if (!q) return true;
+ return (
+ r.customerName.toLowerCase().includes(q) ||
+ r.customerId.toLowerCase().includes(q) ||
+ r.idProofNumber.toLowerCase().includes(q)
+ );
+ });
+ }
+
+ idProofLabel(t: Form60IdProofType): string {
+ return this.idProofOptions.find((o) => o.value === t)?.label || t;
+ }
+
+ totalNoPan(): number {
+ return this.records.filter((r) => !r.hasPan).length;
+ }
+
+ totalFiled(): number {
+ return this.records.filter((r) => !r.hasPan && !!r.filedOn).length;
+ }
+
+ totalPending(): number {
+ return this.records.filter((r) => !r.hasPan && !r.filedOn).length;
+ }
+
+ clearFilters(): void {
+ this.search = '';
+ this.idProofFilter = '';
+ this.panFilter = 'MISSING';
+ }
+
+ viewCertificate(r: Form60Record): void {
+ if (r.hasPan) {
+ this.snack.open(`${r.customerName} already has a PAN on file — Form 60 is not applicable.`, 'OK', {
+ duration: 3200
+ });
+ return;
+ }
+ const data: CertificatePreviewDialogData = { kind: 'form60', form60: r };
+ this.dialog.open(CertificatePreviewDialogComponent, {
+ width: '880px',
+ maxWidth: '96vw',
+ maxHeight: '92vh',
+ autoFocus: false,
+ panelClass: 'cert-preview-dialog',
+ data
+ });
+ }
+
+ downloadCertificate(r: Form60Record): void {
+ if (r.hasPan) {
+ this.snack.open(`${r.customerName} already has a PAN on file — Form 60 is not applicable.`, 'OK', {
+ duration: 3200
+ });
+ return;
+ }
+ downloadForm60Pdf(r);
+ this.snack.open(`Form 60 for ${r.customerName} downloaded.`, 'OK', { duration: 2400 });
+ }
+}
+
+// ───────────────────── Mock seed (top-level export) ─────────────────────
+export function seedForm60(): Form60Record[] {
+ return [
+ {
+ customerId: 'CUST220033',
+ customerName: 'Bluestone Trading Co. (Prop. Harish Iyer)',
+ customerAddress: '12, Brigade Road, Bengaluru 560025',
+ fatherName: 'K. R. Iyer',
+ dob: '1972-03-18',
+ branch: 'Bengaluru — MG Road',
+ hasPan: false,
+ idProofType: 'AADHAAR',
+ idProofNumber: 'XXXX-XXXX-7891',
+ transactionType: 'Cash deposit > ₹50,000',
+ transactionAmount: 75000,
+ transactionDate: '2025-05-10',
+ estimatedIncome: 850000,
+ agriIncome: 0,
+ filedOn: '2025-05-10'
+ },
+ {
+ customerId: 'CUST120544',
+ customerName: 'Sundar Pillai',
+ customerAddress: '14, Pondy Bazaar, T. Nagar, Chennai 600017',
+ fatherName: 'M. Pillai',
+ dob: '1968-11-04',
+ branch: 'Chennai — T. Nagar',
+ hasPan: false,
+ idProofType: 'VOTER_ID',
+ idProofNumber: 'TNV1234567',
+ transactionType: 'NEFT credit',
+ transactionAmount: 250000,
+ transactionDate: '2025-04-22',
+ estimatedIncome: 320000,
+ agriIncome: 180000
+ },
+ {
+ customerId: 'CUST998812',
+ customerName: 'Kamla Devi',
+ customerAddress: 'Plot 22, Sec 14, Rohini, New Delhi 110085',
+ fatherName: 'Late Hari Lal',
+ dob: '1955-06-12',
+ branch: 'New Delhi — Connaught Place',
+ hasPan: false,
+ idProofType: 'AADHAAR',
+ idProofNumber: 'XXXX-XXXX-4421',
+ transactionType: 'FD booking > ₹50,000',
+ transactionAmount: 200000,
+ transactionDate: '2025-05-02',
+ estimatedIncome: 145000,
+ agriIncome: 0,
+ filedOn: '2025-05-02'
+ },
+ {
+ customerId: 'CUST551177',
+ customerName: 'Ramesh Yadav',
+ customerAddress: 'Hno 122, Saket Nagar, Indore 452018',
+ fatherName: 'Banwari Yadav',
+ dob: '1980-08-25',
+ branch: 'Pune — Koregaon Park',
+ hasPan: false,
+ idProofType: 'DRIVING_LICENSE',
+ idProofNumber: 'MP09 20180012345',
+ transactionType: 'Cash deposit > ₹50,000',
+ transactionAmount: 85000,
+ transactionDate: '2025-05-15',
+ estimatedIncome: 410000,
+ agriIncome: 0
+ },
+ {
+ customerId: 'CUST112233',
+ customerName: 'Rohan S. Mehta',
+ customerAddress: 'Flat 902, Skyline Apt, Andheri (E), Mumbai 400069',
+ fatherName: 'Suresh Mehta',
+ dob: '1985-02-14',
+ branch: 'Mumbai — Andheri (E)',
+ hasPan: true,
+ idProofType: 'AADHAAR',
+ idProofNumber: 'XXXX-XXXX-9090',
+ transactionType: 'PAN on file — Form 60 not applicable',
+ transactionAmount: 0,
+ transactionDate: '',
+ estimatedIncome: 0,
+ agriIncome: 0
+ }
+ ];
+}
diff --git a/src/app/letter-mgmt/certificates/hypothecation/hypothecation.component.html b/src/app/letter-mgmt/certificates/hypothecation/hypothecation.component.html
new file mode 100644
index 000000000..32516934b
--- /dev/null
+++ b/src/app/letter-mgmt/certificates/hypothecation/hypothecation.component.html
@@ -0,0 +1,169 @@
+
+
+
+
+
+
+
+
Hypothecation Certificate
+
+ Lien certificate for any loan where movable assets are hypothecated to the bank — vehicles, plant &
+ machinery, stock, gold, equipment.
+
+
+
+
+
+
+
+
+
+
+
{{ countActive() | number }}
+
Active Hypothecations
+
+
+
+
+
+
{{ countNoc() | number }}
+
NOCs Issued
+
+
+
+
+
+
₹{{ totalOutstanding() | number: '1.0-0' }}
+
Outstanding Principal
+
+
+
+
+
+
+
+
Certificate / A/C / Customer / Asset
+
+
+
+
+
+
+
+ Asset type
+
+ All assets
+ @for (o of assetOptions; track o.value) {
+ {{ o.label }}
+ }
+
+
+
+
+ Status
+
+ Active
+ Closed
+ NOC Issued
+ All statuses
+
+
+
+ @if (search || assetFilter || statusFilter !== 'ACTIVE') {
+
Clear
+ }
+
+
+
+
+
+
+
+
+ Customer · Loan A/C
+ Certificate No.
+ Asset
+ Identifier
+ Sanctioned
+ Outstanding
+ Insurance
+ Status
+ Actions
+
+
+
+ @for (r of filteredRecords; track r.certificateNo) {
+
+
+ {{ r.customerName }}
+ {{ r.accountNo }} · {{ r.customerId }} · PAN {{ r.customerPan }}
+ {{ r.loanType }}
+
+ {{ r.certificateNo }}
+
+ {{ assetLabel(r.assetType) }}
+ {{ r.assetDescription }}
+
+ {{ r.assetIdentifier }}
+ ₹{{ r.sanctionedAmount | number: '1.0-0' }}
+
+ ₹{{ r.outstandingPrincipal | number: '1.0-0' }}
+
+
+ @if (r.insuredValue > 0) {
+ ₹{{ r.insuredValue | number: '1.0-0' }}
+ valid till {{ r.insuranceValidTill | date: 'd MMM y' }}
+ } @else {
+ —
+ }
+
+
+ @if (r.status === 'ACTIVE') {
+ Active
+ } @else if (r.status === 'NOC_ISSUED') {
+ NOC Issued
+ } @else {
+ Closed
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ @if (filteredRecords.length === 0) {
+
+
+
+ No hypothecation records match the current filters.
+
+
+ }
+
+
+
+
+
diff --git a/src/app/letter-mgmt/certificates/hypothecation/hypothecation.component.ts b/src/app/letter-mgmt/certificates/hypothecation/hypothecation.component.ts
new file mode 100644
index 000000000..28f664fcf
--- /dev/null
+++ b/src/app/letter-mgmt/certificates/hypothecation/hypothecation.component.ts
@@ -0,0 +1,242 @@
+/**
+ * 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, OnInit } 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 { MatSnackBar } from '@angular/material/snack-bar';
+import { MatTooltip } from '@angular/material/tooltip';
+import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+
+import {
+ CertificatePreviewDialogComponent,
+ CertificatePreviewDialogData
+} from '../certificate-preview-dialog/certificate-preview-dialog.component';
+import { downloadHypothecationPdf } from '../certificate-pdf';
+
+export type HypoAssetType = 'VEHICLE' | 'PLANT_MACHINERY' | 'STOCK_DEBTORS' | 'GOLD' | 'EQUIPMENT';
+export type HypoStatus = 'ACTIVE' | 'CLOSED' | 'NOC_ISSUED';
+
+export interface HypothecationRecord {
+ certificateNo: string;
+ accountNo: string;
+ customerId: string;
+ customerName: string;
+ customerPan: string;
+ customerAddress: string;
+ branch: string;
+ loanType: string;
+ sanctionedAmount: number;
+ outstandingPrincipal: number;
+ disbursementDate: string;
+ /** Asset hypothecated to the bank. */
+ assetType: HypoAssetType;
+ /** Free-text asset description (Maruti Swift VDi · 2022 · etc.). */
+ assetDescription: string;
+ /** Vehicle registration, machine serial, etc. */
+ assetIdentifier: string;
+ /** Insured up to ₹ (asset insurance for replacement). */
+ insuredValue: number;
+ insuranceValidTill: string;
+ status: HypoStatus;
+}
+
+@Component({
+ selector: 'mifosx-hypothecation',
+ templateUrl: './hypothecation.component.html',
+ styleUrls: ['../certificates.shared.scss'],
+ imports: [
+ ...STANDALONE_SHARED_IMPORTS,
+ CommonModule,
+ FormsModule,
+ FaIconComponent,
+ MatTooltip
+ ]
+})
+export class HypothecationCertificateComponent implements OnInit {
+ private dialog = inject(MatDialog);
+ private snack = inject(MatSnackBar);
+
+ search = '';
+ assetFilter: '' | HypoAssetType = '';
+ statusFilter: '' | HypoStatus = 'ACTIVE';
+
+ records: HypothecationRecord[] = [];
+
+ readonly assetOptions: { value: HypoAssetType; label: string }[] = [
+ { value: 'VEHICLE', label: 'Vehicle' },
+ { value: 'PLANT_MACHINERY', label: 'Plant & Machinery' },
+ { value: 'STOCK_DEBTORS', label: 'Stock / Debtors' },
+ { value: 'GOLD', label: 'Gold / Bullion' },
+ { value: 'EQUIPMENT', label: 'Equipment' }
+ ];
+
+ ngOnInit(): void {
+ this.records = seedHypothecations();
+ }
+
+ get filteredRecords(): HypothecationRecord[] {
+ const q = this.search.trim().toLowerCase();
+ return this.records.filter((r) => {
+ if (this.assetFilter && r.assetType !== this.assetFilter) return false;
+ if (this.statusFilter && r.status !== this.statusFilter) return false;
+ if (!q) return true;
+ return (
+ r.certificateNo.toLowerCase().includes(q) ||
+ r.accountNo.toLowerCase().includes(q) ||
+ r.customerName.toLowerCase().includes(q) ||
+ r.assetIdentifier.toLowerCase().includes(q) ||
+ r.assetDescription.toLowerCase().includes(q)
+ );
+ });
+ }
+
+ assetLabel(t: HypoAssetType): string {
+ return this.assetOptions.find((o) => o.value === t)?.label || t;
+ }
+
+ countActive(): number {
+ return this.records.filter((r) => r.status === 'ACTIVE').length;
+ }
+
+ countNoc(): number {
+ return this.records.filter((r) => r.status === 'NOC_ISSUED').length;
+ }
+
+ totalOutstanding(): number {
+ return this.filteredRecords.reduce((s, r) => s + r.outstandingPrincipal, 0);
+ }
+
+ clearFilters(): void {
+ this.search = '';
+ this.assetFilter = '';
+ this.statusFilter = 'ACTIVE';
+ }
+
+ viewCertificate(r: HypothecationRecord): void {
+ const data: CertificatePreviewDialogData = { kind: 'hypothecation', hypothecation: r };
+ this.dialog.open(CertificatePreviewDialogComponent, {
+ width: '880px',
+ maxWidth: '96vw',
+ maxHeight: '92vh',
+ autoFocus: false,
+ panelClass: 'cert-preview-dialog',
+ data
+ });
+ }
+
+ downloadCertificate(r: HypothecationRecord): void {
+ downloadHypothecationPdf(r);
+ this.snack.open(`Hypothecation Certificate ${r.certificateNo} downloaded.`, 'OK', {
+ duration: 2400
+ });
+ }
+}
+
+// ───────────────────── Mock seed (top-level export) ─────────────────────
+export function seedHypothecations(): HypothecationRecord[] {
+ return [
+ {
+ certificateNo: 'HYP-2024-001023',
+ accountNo: 'LN50200077',
+ customerId: 'CUST556677',
+ customerName: 'Karthik Rao',
+ customerPan: 'PQRSR2233Z',
+ customerAddress: '24, MG Road, Bengaluru 560001',
+ branch: 'Bengaluru — MG Road',
+ loanType: 'Auto Loan',
+ sanctionedAmount: 850000,
+ outstandingPrincipal: 480000,
+ disbursementDate: '2023-01-20',
+ assetType: 'VEHICLE',
+ assetDescription: 'Maruti Suzuki Swift VXi · Manual · Petrol',
+ assetIdentifier: 'KA-01-MZ-4421',
+ insuredValue: 950000,
+ insuranceValidTill: '2026-01-19',
+ status: 'ACTIVE'
+ },
+ {
+ certificateNo: 'HYP-2024-001024',
+ accountNo: 'LN70010023',
+ customerId: 'CUST889900',
+ customerName: 'Vikram Joshi',
+ customerPan: 'LMNOJ9988X',
+ customerAddress: 'Office 3B, Koregaon Park Road, Pune 411001',
+ branch: 'Pune — Koregaon Park',
+ loanType: 'Business Loan',
+ sanctionedAmount: 5500000,
+ outstandingPrincipal: 3950000,
+ disbursementDate: '2020-11-05',
+ assetType: 'PLANT_MACHINERY',
+ assetDescription: 'CNC milling machine · Haas VF-2SS · 2020',
+ assetIdentifier: 'SN-VF2SS-2020-114782',
+ insuredValue: 7200000,
+ insuranceValidTill: '2026-03-30',
+ status: 'ACTIVE'
+ },
+ {
+ certificateNo: 'HYP-2024-001025',
+ accountNo: 'LN50200099',
+ customerId: 'CUST667788',
+ customerName: 'Lakshmi Naidu',
+ customerPan: 'XYZAN7788Q',
+ customerAddress: 'Plot 14, Road No. 3, Banjara Hills, Hyderabad 500034',
+ branch: 'Hyderabad — Banjara Hills',
+ loanType: 'Gold Loan',
+ sanctionedAmount: 450000,
+ outstandingPrincipal: 180000,
+ disbursementDate: '2023-08-18',
+ assetType: 'GOLD',
+ assetDescription: '22 KT Gold Jewellery · Total 92.5 grams',
+ assetIdentifier: 'GL-PKT-7891 · Locker S-22',
+ insuredValue: 580000,
+ insuranceValidTill: '2026-08-17',
+ status: 'ACTIVE'
+ },
+ {
+ certificateNo: 'HYP-2024-001026',
+ accountNo: 'LN20034112',
+ customerId: 'CUST334455',
+ customerName: 'Anita Verma',
+ customerPan: 'EFGHV5678A',
+ customerAddress: 'B-12, Connaught Pl, New Delhi 110001',
+ branch: 'New Delhi — Connaught Place',
+ loanType: 'Personal Loan',
+ sanctionedAmount: 600000,
+ outstandingPrincipal: 0,
+ disbursementDate: '2022-09-10',
+ assetType: 'STOCK_DEBTORS',
+ assetDescription: 'Trade receivables — invoices > 60 days',
+ assetIdentifier: 'POOL-AV-2022-001',
+ insuredValue: 0,
+ insuranceValidTill: '',
+ status: 'NOC_ISSUED'
+ },
+ {
+ certificateNo: 'HYP-2024-001027',
+ accountNo: 'LN50100445',
+ customerId: 'CUST110022',
+ customerName: 'Spectra Components Pvt Ltd',
+ customerPan: 'AABCS1122F',
+ customerAddress: 'Plot 9, MIDC, Andheri (E), Mumbai 400093',
+ branch: 'Mumbai — Andheri (E)',
+ loanType: 'Cash Credit',
+ sanctionedAmount: 2500000,
+ outstandingPrincipal: 1820000,
+ disbursementDate: '2021-07-12',
+ assetType: 'STOCK_DEBTORS',
+ assetDescription: 'Stock-in-trade + book debts (paripassu)',
+ assetIdentifier: 'POOL-SCPL-2021-A',
+ insuredValue: 4500000,
+ insuranceValidTill: '2026-06-30',
+ status: 'ACTIVE'
+ }
+ ];
+}
diff --git a/src/app/letter-mgmt/certificates/loan-interest/loan-interest.component.html b/src/app/letter-mgmt/certificates/loan-interest/loan-interest.component.html
new file mode 100644
index 000000000..664320b0e
--- /dev/null
+++ b/src/app/letter-mgmt/certificates/loan-interest/loan-interest.component.html
@@ -0,0 +1,155 @@
+
+
+
+
+
+
+
+
+
Loan Interest Certificate
+
+ Generate and download interest-paid certificates per loan account, per financial year.
+
+
+
+
+
+
+
+
+
+
+
{{ filteredLoans.length | number }}
+
Loan Accounts
+
+
+
+
+
+
₹{{ totalInterestForFy() | number: '1.0-0' }}
+
Interest Paid ({{ fyFilter }})
+
+
+
+
+
+
₹{{ totalPrincipalForFy() | number: '1.0-0' }}
+
Principal Paid ({{ fyFilter }})
+
+
+
+
+
+
+
+
Account number / Customer / PAN
+
+
+
+
+
+
+
+ Financial year
+
+ @for (f of fyOptions; track f) {
+ {{ f }}
+ }
+
+
+
+
+ Loan type
+
+ All types
+ @for (o of loanTypeOptions; track o.value) {
+ {{ o.label }}
+ }
+
+
+
+ @if (search || loanTypeFilter) {
+
Clear
+ }
+
+
+
+
+
+
+
+
+ Customer · Loan A/C
+ Type
+ Branch
+ Sanctioned
+ Outstanding
+ Interest ({{ fyFilter }})
+ Principal ({{ fyFilter }})
+ Rate
+ Actions
+
+
+
+ @for (l of filteredLoans; track l.accountNo) {
+
+
+ {{ l.customerName }}
+ {{ l.accountNo }} · {{ l.customerId }} · PAN {{ l.customerPan }}
+
+
+ {{ loanTypeLabel(l.loanType) }}
+
+ {{ l.branch }}
+ ₹{{ l.sanctionedAmount | number: '1.0-0' }}
+
+ ₹{{ outstandingPrincipal(l) | number: '1.0-0' }}
+
+
+ ₹{{ selectedFyData(l)?.interestPaid | number: '1.0-0' }}
+
+ ₹{{ selectedFyData(l)?.principalPaid | number: '1.0-0' }}
+
+ {{ l.interestRate }}% p.a.
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ @if (filteredLoans.length === 0) {
+
+
+
+ No loan accounts match the current filters.
+
+
+ }
+
+
+
+
+
diff --git a/src/app/letter-mgmt/certificates/loan-interest/loan-interest.component.ts b/src/app/letter-mgmt/certificates/loan-interest/loan-interest.component.ts
new file mode 100644
index 000000000..d3af41347
--- /dev/null
+++ b/src/app/letter-mgmt/certificates/loan-interest/loan-interest.component.ts
@@ -0,0 +1,336 @@
+/**
+ * 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, OnInit } 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 { MatSnackBar } from '@angular/material/snack-bar';
+import { MatTooltip } from '@angular/material/tooltip';
+import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+
+import {
+ CertificatePreviewDialogComponent,
+ CertificatePreviewDialogData
+} from '../certificate-preview-dialog/certificate-preview-dialog.component';
+import { downloadLoanInterestPdf } from '../certificate-pdf';
+
+export type LoanProductType = 'HOUSING' | 'PERSONAL' | 'AUTO' | 'EDUCATION' | 'GOLD' | 'BUSINESS';
+
+export interface FyInterestBreakup {
+ /** Financial year label e.g. "FY 2024-25". */
+ fy: string;
+ openingBalance: number;
+ closingBalance: number;
+ principalPaid: number;
+ interestPaid: number;
+}
+
+export interface LoanInterestRecord {
+ accountNo: string;
+ customerId: string;
+ customerName: string;
+ customerPan: string;
+ customerAddress: string;
+ branch: string;
+ loanType: LoanProductType;
+ sanctionedAmount: number;
+ disbursementDate: string;
+ /** Interest rate p.a. (e.g. 8.65) */
+ interestRate: number;
+ fy: FyInterestBreakup[];
+}
+
+@Component({
+ selector: 'mifosx-loan-interest-certificate',
+ templateUrl: './loan-interest.component.html',
+ styleUrls: ['../certificates.shared.scss'],
+ imports: [
+ ...STANDALONE_SHARED_IMPORTS,
+ CommonModule,
+ FormsModule,
+ FaIconComponent,
+ MatTooltip
+ ]
+})
+export class LoanInterestCertificateComponent implements OnInit {
+ private dialog = inject(MatDialog);
+ private snack = inject(MatSnackBar);
+
+ // ── Filters ──
+ search = '';
+ fyFilter = '';
+ loanTypeFilter: '' | LoanProductType = '';
+
+ loans: LoanInterestRecord[] = [];
+
+ /** All financial years present across the dataset (newest first). */
+ fyOptions: string[] = [];
+
+ readonly loanTypeOptions: { value: LoanProductType; label: string }[] = [
+ { value: 'HOUSING', label: 'Housing Loan' },
+ { value: 'PERSONAL', label: 'Personal Loan' },
+ { value: 'AUTO', label: 'Auto Loan' },
+ { value: 'EDUCATION', label: 'Education Loan' },
+ { value: 'GOLD', label: 'Gold Loan' },
+ { value: 'BUSINESS', label: 'Business Loan' }
+ ];
+
+ ngOnInit(): void {
+ this.loans = seedLoanInterestRecords();
+ this.fyOptions = Array.from(new Set(this.loans.flatMap((l) => l.fy.map((f) => f.fy))))
+ .sort()
+ .reverse();
+ // Default to most-recent FY
+ if (this.fyOptions.length) {
+ this.fyFilter = this.fyOptions[0];
+ }
+ }
+
+ // ── Derived list ──
+ get filteredLoans(): LoanInterestRecord[] {
+ const q = this.search.trim().toLowerCase();
+ return this.loans.filter((l) => {
+ if (this.loanTypeFilter && l.loanType !== this.loanTypeFilter) return false;
+ if (!q) return true;
+ return (
+ l.accountNo.toLowerCase().includes(q) ||
+ l.customerName.toLowerCase().includes(q) ||
+ l.customerId.toLowerCase().includes(q) ||
+ l.customerPan.toLowerCase().includes(q)
+ );
+ });
+ }
+
+ loanTypeLabel(t: LoanProductType): string {
+ return this.loanTypeOptions.find((o) => o.value === t)?.label || t;
+ }
+
+ selectedFyData(l: LoanInterestRecord): FyInterestBreakup | undefined {
+ return l.fy.find((f) => f.fy === this.fyFilter) || l.fy[0];
+ }
+
+ totalInterestForFy(): number {
+ return this.filteredLoans.reduce((s, l) => s + (this.selectedFyData(l)?.interestPaid || 0), 0);
+ }
+
+ totalPrincipalForFy(): number {
+ return this.filteredLoans.reduce((s, l) => s + (this.selectedFyData(l)?.principalPaid || 0), 0);
+ }
+
+ outstandingPrincipal(l: LoanInterestRecord): number {
+ return this.selectedFyData(l)?.closingBalance ?? 0;
+ }
+
+ // ── Actions ──
+ clearFilters(): void {
+ this.search = '';
+ this.loanTypeFilter = '';
+ if (this.fyOptions.length) this.fyFilter = this.fyOptions[0];
+ }
+
+ viewCertificate(l: LoanInterestRecord): void {
+ const fy = this.selectedFyData(l);
+ if (!fy) {
+ this.snack.open('No interest data on file for the selected financial year.', 'OK', { duration: 2500 });
+ return;
+ }
+ const data: CertificatePreviewDialogData = {
+ kind: 'interest',
+ loanInterest: { record: l, fy }
+ };
+ this.dialog.open(CertificatePreviewDialogComponent, {
+ width: '880px',
+ maxWidth: '96vw',
+ maxHeight: '92vh',
+ autoFocus: false,
+ panelClass: 'cert-preview-dialog',
+ data
+ });
+ }
+
+ downloadCertificate(l: LoanInterestRecord): void {
+ const fy = this.selectedFyData(l);
+ if (!fy) {
+ this.snack.open('No interest data on file for the selected financial year.', 'OK', { duration: 2500 });
+ return;
+ }
+ downloadLoanInterestPdf(l, fy);
+ this.snack.open(`Interest Certificate (${fy.fy}) downloaded.`, 'OK', { duration: 2400 });
+ }
+}
+
+// ───────────────────── Mock seed (top-level export) ─────────────────────
+export function seedLoanInterestRecords(): LoanInterestRecord[] {
+ return [
+ {
+ accountNo: 'LN10029384',
+ customerId: 'CUST112233',
+ customerName: 'Rohan S. Mehta',
+ customerPan: 'ABCPM1234E',
+ customerAddress: 'Flat 902, Skyline Apt, Andheri (E), Mumbai 400069',
+ branch: 'Mumbai — Andheri (E)',
+ loanType: 'HOUSING',
+ sanctionedAmount: 4500000,
+ disbursementDate: '2021-06-15',
+ interestRate: 8.65,
+ fy: [
+ {
+ fy: 'FY 2024-25',
+ openingBalance: 3620000,
+ closingBalance: 3380000,
+ principalPaid: 240000,
+ interestPaid: 312500
+ },
+ {
+ fy: 'FY 2023-24',
+ openingBalance: 3840000,
+ closingBalance: 3620000,
+ principalPaid: 220000,
+ interestPaid: 328200
+ },
+ {
+ fy: 'FY 2022-23',
+ openingBalance: 4050000,
+ closingBalance: 3840000,
+ principalPaid: 210000,
+ interestPaid: 342800
+ }
+ ]
+ },
+ {
+ accountNo: 'LN20034112',
+ customerId: 'CUST334455',
+ customerName: 'Anita Verma',
+ customerPan: 'EFGHV5678A',
+ customerAddress: 'B-12, Connaught Pl, New Delhi 110001',
+ branch: 'New Delhi — Connaught Place',
+ loanType: 'PERSONAL',
+ sanctionedAmount: 600000,
+ disbursementDate: '2022-09-10',
+ interestRate: 12.5,
+ fy: [
+ {
+ fy: 'FY 2024-25',
+ openingBalance: 380000,
+ closingBalance: 220000,
+ principalPaid: 160000,
+ interestPaid: 41200
+ },
+ { fy: 'FY 2023-24', openingBalance: 520000, closingBalance: 380000, principalPaid: 140000, interestPaid: 56800 }
+ ]
+ },
+ {
+ accountNo: 'LN50200077',
+ customerId: 'CUST556677',
+ customerName: 'Karthik Rao',
+ customerPan: 'PQRSR2233Z',
+ customerAddress: '24, MG Road, Bengaluru 560001',
+ branch: 'Bengaluru — MG Road',
+ loanType: 'AUTO',
+ sanctionedAmount: 850000,
+ disbursementDate: '2023-01-20',
+ interestRate: 9.4,
+ fy: [
+ {
+ fy: 'FY 2024-25',
+ openingBalance: 620000,
+ closingBalance: 480000,
+ principalPaid: 140000,
+ interestPaid: 58800
+ },
+ { fy: 'FY 2023-24', openingBalance: 780000, closingBalance: 620000, principalPaid: 160000, interestPaid: 71200 }
+ ]
+ },
+ {
+ accountNo: 'LN70010023',
+ customerId: 'CUST889900',
+ customerName: 'Vikram Joshi',
+ customerPan: 'LMNOJ9988X',
+ customerAddress: 'Office 3B, Koregaon Park Road, Pune 411001',
+ branch: 'Pune — Koregaon Park',
+ loanType: 'BUSINESS',
+ sanctionedAmount: 5500000,
+ disbursementDate: '2020-11-05',
+ interestRate: 11.0,
+ fy: [
+ {
+ fy: 'FY 2024-25',
+ openingBalance: 4200000,
+ closingBalance: 3950000,
+ principalPaid: 250000,
+ interestPaid: 464800
+ },
+ {
+ fy: 'FY 2023-24',
+ openingBalance: 4480000,
+ closingBalance: 4200000,
+ principalPaid: 280000,
+ interestPaid: 488600
+ },
+ {
+ fy: 'FY 2022-23',
+ openingBalance: 4760000,
+ closingBalance: 4480000,
+ principalPaid: 280000,
+ interestPaid: 522300
+ }
+ ]
+ },
+ {
+ accountNo: 'LN88990022',
+ customerId: 'CUST990011',
+ customerName: 'Arjun Banerjee',
+ customerPan: 'TUVWB4455F',
+ customerAddress: '42, Park Street, Kolkata 700016',
+ branch: 'Kolkata — Park Street',
+ loanType: 'EDUCATION',
+ sanctionedAmount: 1200000,
+ disbursementDate: '2022-04-22',
+ interestRate: 9.85,
+ fy: [
+ {
+ fy: 'FY 2024-25',
+ openingBalance: 920000,
+ closingBalance: 790000,
+ principalPaid: 130000,
+ interestPaid: 93500
+ },
+ {
+ fy: 'FY 2023-24',
+ openingBalance: 1080000,
+ closingBalance: 920000,
+ principalPaid: 160000,
+ interestPaid: 102200
+ }
+ ]
+ },
+ {
+ accountNo: 'LN50200099',
+ customerId: 'CUST667788',
+ customerName: 'Lakshmi Naidu',
+ customerPan: 'XYZAN7788Q',
+ customerAddress: 'Plot 14, Road No. 3, Banjara Hills, Hyderabad 500034',
+ branch: 'Hyderabad — Banjara Hills',
+ loanType: 'GOLD',
+ sanctionedAmount: 450000,
+ disbursementDate: '2023-08-18',
+ interestRate: 10.25,
+ fy: [
+ {
+ fy: 'FY 2024-25',
+ openingBalance: 320000,
+ closingBalance: 180000,
+ principalPaid: 140000,
+ interestPaid: 28400
+ }
+ ]
+ }
+ ];
+}
diff --git a/src/app/letter-mgmt/certificates/noc/noc.component.html b/src/app/letter-mgmt/certificates/noc/noc.component.html
new file mode 100644
index 000000000..d6883b408
--- /dev/null
+++ b/src/app/letter-mgmt/certificates/noc/noc.component.html
@@ -0,0 +1,179 @@
+
+
+
+
+
+
+
+
No-Objection Certificate (NOC)
+
+ Issue NOCs for closed loan accounts — releases the bank's lien on hypothecated assets (vehicles, property,
+ gold) or confirms unsecured-loan closure.
+
+
+
+
+
+
+
+
+
+
+
{{ countIssued() | number }}
+
NOCs Issued
+
+
+
+
+
+
{{ countReady() | number }}
+
Ready to Issue
+
+
+
+
+
+
{{ countPending() | number }}
+
Pending Closure
+
+
+
+
+
+
+
+
NOC / A/C / Customer / Asset
+
+
+
+
+
+
+
+ Loan type
+
+ All types
+ @for (o of loanTypeOptions; track o.value) {
+ {{ o.label }}
+ }
+
+
+
+
+ Status
+
+ All statuses
+ Issued
+ Ready to issue
+ Pending closure
+
+
+
+ @if (search || loanTypeFilter || statusFilter) {
+
Clear
+ }
+
+
+
+
+
+
+
+
+ Customer · Loan A/C
+ NOC No.
+ Loan Type
+ Asset / Identifier
+ Total Paid
+ Closure
+ Status
+ Actions
+
+
+
+ @for (r of filteredRecords; track r.nocNo) {
+
+
+ {{ r.customerName }}
+ {{ r.accountNo }} · {{ r.customerId }} · PAN {{ r.customerPan }}
+
+ {{ r.nocNo }}
+
+ {{ loanTypeLabel(r.loanType) }}
+ Sanctioned ₹{{ r.sanctionedAmount | number: '1.0-0' }}
+
+
+ @if (r.assetType !== 'NONE') {
+ {{ r.assetDescription }}
+ {{ r.assetIdentifier }}
+ } @else {
+ Unsecured · no asset
+ }
+
+
+ ₹{{ r.totalPaid | number: '1.0-0' }}
+
+
+ @if (r.closureDate) {
+ {{ r.closureDate | date: 'd MMM y' }}
+ } @else {
+ —
+ }
+
+
+ @if (r.status === 'ISSUED') {
+ Issued
+ @if (r.issuedOn) {
+ {{ r.issuedOn | date: 'd MMM y' }}
+ }
+ } @else if (r.status === 'READY_TO_ISSUE') {
+ Ready to issue
+ } @else {
+
+ Pending closure
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ @if (filteredRecords.length === 0) {
+
+
+
+ No NOCs match the current filters.
+
+
+ }
+
+
+
+
+
diff --git a/src/app/letter-mgmt/certificates/noc/noc.component.ts b/src/app/letter-mgmt/certificates/noc/noc.component.ts
new file mode 100644
index 000000000..7221e6d04
--- /dev/null
+++ b/src/app/letter-mgmt/certificates/noc/noc.component.ts
@@ -0,0 +1,306 @@
+/**
+ * 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, OnInit } 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 { MatSnackBar } from '@angular/material/snack-bar';
+import { MatTooltip } from '@angular/material/tooltip';
+import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+
+import {
+ CertificatePreviewDialogComponent,
+ CertificatePreviewDialogData
+} from '../certificate-preview-dialog/certificate-preview-dialog.component';
+import { downloadNocPdf } from '../certificate-pdf';
+
+export type NocLoanType = 'AUTO' | 'HOUSING' | 'PERSONAL' | 'EDUCATION' | 'GOLD' | 'BUSINESS' | 'CC' | 'OD';
+
+export type NocStatus = 'ISSUED' | 'PENDING' | 'READY_TO_ISSUE';
+
+export type NocAssetType = 'VEHICLE' | 'PROPERTY' | 'GOLD' | 'EQUIPMENT' | 'STOCK_DEBTORS' | 'NONE';
+
+export interface NocRecord {
+ nocNo: string;
+ accountNo: string;
+ customerId: string;
+ customerName: string;
+ customerPan: string;
+ customerAddress: string;
+ branch: string;
+ loanType: NocLoanType;
+ sanctionedAmount: number;
+ disbursementDate: string;
+ /** Date the loan was fully closed / settled. */
+ closureDate: string;
+ totalPaid: number;
+ /** Asset whose lien is being released. */
+ assetType: NocAssetType;
+ assetDescription: string;
+ assetIdentifier: string;
+ /** Where to forward the NOC (RTO Mumbai, Sub-Registrar etc.) */
+ forwardTo?: string;
+ status: NocStatus;
+ issuedOn?: string;
+ issuedBy?: string;
+}
+
+@Component({
+ selector: 'mifosx-noc',
+ templateUrl: './noc.component.html',
+ styleUrls: ['../certificates.shared.scss'],
+ imports: [
+ ...STANDALONE_SHARED_IMPORTS,
+ CommonModule,
+ FormsModule,
+ FaIconComponent,
+ MatTooltip
+ ]
+})
+export class NocCertificateComponent implements OnInit {
+ private dialog = inject(MatDialog);
+ private snack = inject(MatSnackBar);
+
+ // ── Filters ──
+ search = '';
+ loanTypeFilter: '' | NocLoanType = '';
+ statusFilter: '' | NocStatus = '';
+
+ records: NocRecord[] = [];
+
+ readonly loanTypeOptions: { value: NocLoanType; label: string }[] = [
+ { value: 'AUTO', label: 'Auto Loan' },
+ { value: 'HOUSING', label: 'Housing Loan' },
+ { value: 'PERSONAL', label: 'Personal Loan' },
+ { value: 'EDUCATION', label: 'Education Loan' },
+ { value: 'GOLD', label: 'Gold Loan' },
+ { value: 'BUSINESS', label: 'Business Loan' },
+ { value: 'CC', label: 'Cash Credit (CC)' },
+ { value: 'OD', label: 'Overdraft (OD)' }
+ ];
+
+ ngOnInit(): void {
+ this.records = seedNocRecords();
+ }
+
+ // ── Derived list ──
+ get filteredRecords(): NocRecord[] {
+ const q = this.search.trim().toLowerCase();
+ return this.records.filter((r) => {
+ if (this.loanTypeFilter && r.loanType !== this.loanTypeFilter) return false;
+ if (this.statusFilter && r.status !== this.statusFilter) return false;
+ if (!q) return true;
+ return (
+ r.nocNo.toLowerCase().includes(q) ||
+ r.accountNo.toLowerCase().includes(q) ||
+ r.customerName.toLowerCase().includes(q) ||
+ r.customerId.toLowerCase().includes(q) ||
+ r.assetIdentifier.toLowerCase().includes(q)
+ );
+ });
+ }
+
+ loanTypeLabel(t: NocLoanType): string {
+ return this.loanTypeOptions.find((o) => o.value === t)?.label || t;
+ }
+
+ countIssued(): number {
+ return this.records.filter((r) => r.status === 'ISSUED').length;
+ }
+
+ countReady(): number {
+ return this.records.filter((r) => r.status === 'READY_TO_ISSUE').length;
+ }
+
+ countPending(): number {
+ return this.records.filter((r) => r.status === 'PENDING').length;
+ }
+
+ clearFilters(): void {
+ this.search = '';
+ this.loanTypeFilter = '';
+ this.statusFilter = '';
+ }
+
+ viewCertificate(r: NocRecord): void {
+ if (r.status === 'PENDING') {
+ this.snack.open(
+ `${r.customerName}'s loan ${r.accountNo} is still pending closure — NOC cannot be previewed yet.`,
+ 'OK',
+ { duration: 3500 }
+ );
+ return;
+ }
+ const data: CertificatePreviewDialogData = { kind: 'noc', noc: r };
+ this.dialog.open(CertificatePreviewDialogComponent, {
+ width: '880px',
+ maxWidth: '96vw',
+ maxHeight: '92vh',
+ autoFocus: false,
+ panelClass: 'cert-preview-dialog',
+ data
+ });
+ }
+
+ downloadCertificate(r: NocRecord): void {
+ if (r.status === 'PENDING') {
+ this.snack.open(
+ `${r.customerName}'s loan ${r.accountNo} is still pending closure — NOC cannot be downloaded yet.`,
+ 'OK',
+ { duration: 3500 }
+ );
+ return;
+ }
+ downloadNocPdf(r);
+ this.snack.open(`NOC ${r.nocNo} downloaded.`, 'OK', { duration: 2400 });
+ }
+}
+
+// ───────────────────── Mock seed (top-level export) ─────────────────────
+export function seedNocRecords(): NocRecord[] {
+ return [
+ {
+ nocNo: 'NOC-2024-005012',
+ accountNo: 'LN50200077',
+ customerId: 'CUST556677',
+ customerName: 'Karthik Rao',
+ customerPan: 'PQRSR2233Z',
+ customerAddress: '24, MG Road, Bengaluru 560001',
+ branch: 'Bengaluru — MG Road',
+ loanType: 'AUTO',
+ sanctionedAmount: 850000,
+ disbursementDate: '2023-01-20',
+ closureDate: '2026-01-15',
+ totalPaid: 988600,
+ assetType: 'VEHICLE',
+ assetDescription: 'Maruti Suzuki Swift VXi · Manual · Petrol',
+ assetIdentifier: 'KA-01-MZ-4421',
+ forwardTo: 'RTO Bengaluru, Koramangala',
+ status: 'ISSUED',
+ issuedOn: '2026-01-18',
+ issuedBy: 'Anita Desai'
+ },
+ {
+ nocNo: 'NOC-2024-005028',
+ accountNo: 'LN20034112',
+ customerId: 'CUST334455',
+ customerName: 'Anita Verma',
+ customerPan: 'EFGHV5678A',
+ customerAddress: 'B-12, Connaught Pl, New Delhi 110001',
+ branch: 'New Delhi — Connaught Place',
+ loanType: 'PERSONAL',
+ sanctionedAmount: 600000,
+ disbursementDate: '2022-09-10',
+ closureDate: '2025-09-09',
+ totalPaid: 698000,
+ assetType: 'NONE',
+ assetDescription: 'Unsecured personal loan',
+ assetIdentifier: '—',
+ status: 'ISSUED',
+ issuedOn: '2025-09-12',
+ issuedBy: 'Sushma Iyer'
+ },
+ {
+ nocNo: 'NOC-2024-005034',
+ accountNo: 'LN50200099',
+ customerId: 'CUST667788',
+ customerName: 'Lakshmi Naidu',
+ customerPan: 'XYZAN7788Q',
+ customerAddress: 'Plot 14, Road No. 3, Banjara Hills, Hyderabad 500034',
+ branch: 'Hyderabad — Banjara Hills',
+ loanType: 'GOLD',
+ sanctionedAmount: 450000,
+ disbursementDate: '2023-08-18',
+ closureDate: '2026-02-28',
+ totalPaid: 502800,
+ assetType: 'GOLD',
+ assetDescription: '22 KT Gold Jewellery · Total 92.5 grams',
+ assetIdentifier: 'GL-PKT-7891 · Locker S-22',
+ forwardTo: 'Branch Locker — return of pledged ornaments',
+ status: 'READY_TO_ISSUE'
+ },
+ {
+ nocNo: 'NOC-2024-005041',
+ accountNo: 'LN88990022',
+ customerId: 'CUST990011',
+ customerName: 'Arjun Banerjee',
+ customerPan: 'TUVWB4455F',
+ customerAddress: '42, Park Street, Kolkata 700016',
+ branch: 'Kolkata — Park Street',
+ loanType: 'EDUCATION',
+ sanctionedAmount: 1200000,
+ disbursementDate: '2022-04-22',
+ closureDate: '2026-03-10',
+ totalPaid: 1389500,
+ assetType: 'NONE',
+ assetDescription: 'Education loan (no asset hypothecated)',
+ assetIdentifier: '—',
+ status: 'READY_TO_ISSUE'
+ },
+ {
+ nocNo: 'NOC-2024-005055',
+ accountNo: 'LN10029384',
+ customerId: 'CUST112233',
+ customerName: 'Rohan S. Mehta',
+ customerPan: 'ABCPM1234E',
+ customerAddress: 'Flat 902, Skyline Apt, Andheri (E), Mumbai 400069',
+ branch: 'Mumbai — Andheri (E)',
+ loanType: 'HOUSING',
+ sanctionedAmount: 4500000,
+ disbursementDate: '2021-06-15',
+ closureDate: '',
+ totalPaid: 1310000,
+ assetType: 'PROPERTY',
+ assetDescription: 'Flat 902, Skyline Apt, Andheri (E), Mumbai 400069',
+ assetIdentifier: 'Reg No. AND/2021/00482',
+ forwardTo: 'Sub-Registrar, Andheri',
+ status: 'PENDING'
+ },
+ {
+ nocNo: 'NOC-2024-005062',
+ accountNo: 'LN70010023',
+ customerId: 'CUST889900',
+ customerName: 'Vikram Joshi',
+ customerPan: 'LMNOJ9988X',
+ customerAddress: 'Office 3B, Koregaon Park Road, Pune 411001',
+ branch: 'Pune — Koregaon Park',
+ loanType: 'BUSINESS',
+ sanctionedAmount: 5500000,
+ disbursementDate: '2020-11-05',
+ closureDate: '',
+ totalPaid: 1820000,
+ assetType: 'EQUIPMENT',
+ assetDescription: 'CNC milling machine · Haas VF-2SS · 2020',
+ assetIdentifier: 'SN-VF2SS-2020-114782',
+ status: 'PENDING'
+ },
+ {
+ nocNo: 'NOC-2024-005078',
+ accountNo: 'LN30049827',
+ customerId: 'CUST771199',
+ customerName: 'Suresh Pillai',
+ customerPan: 'RSTUP4400J',
+ customerAddress: '17, Pondy Bazaar, T. Nagar, Chennai 600017',
+ branch: 'Chennai — T. Nagar',
+ loanType: 'AUTO',
+ sanctionedAmount: 1250000,
+ disbursementDate: '2022-06-30',
+ closureDate: '2025-12-20',
+ totalPaid: 1480200,
+ assetType: 'VEHICLE',
+ assetDescription: 'Honda City ZX · CVT · Petrol',
+ assetIdentifier: 'TN-09-CB-8821',
+ forwardTo: 'RTO Chennai Central',
+ status: 'ISSUED',
+ issuedOn: '2025-12-24',
+ issuedBy: 'Ramesh Krishnan'
+ }
+ ];
+}
diff --git a/src/app/letter-mgmt/certificates/share-certificate/share-certificate.component.html b/src/app/letter-mgmt/certificates/share-certificate/share-certificate.component.html
new file mode 100644
index 000000000..a5e1818ae
--- /dev/null
+++ b/src/app/letter-mgmt/certificates/share-certificate/share-certificate.component.html
@@ -0,0 +1,164 @@
+
+
+
+
+
+
+
+
+
Share Certificate
+
+ Issue, preview and download member share certificates with folio + share-number range.
+
+
+
+
+
+
+
+
+
+
+
{{ filteredRecords.length | number }}
+
Certificates
+
+
+
+
+
+
{{ totalShares() | number }}
+
Shares Held
+
+
+
+
+
+
₹{{ totalShareValue() | number: '1.0-0' }}
+
Total Face Value
+
+
+
+
+
+
+
+
Certificate / Folio / Member / PAN
+
+
+
+
+
+
+
+ Share class
+
+ All classes
+ @for (o of classOptions; track o.value) {
+ {{ o.label }}
+ }
+
+
+
+
+ Branch
+
+ All branches
+ @for (b of branchOptions; track b) {
+ {{ b }}
+ }
+
+
+
+ @if (search || classFilter || branchFilter) {
+
Clear
+ }
+
+
+
+
+
+
+
+
+ Member · Folio
+ Certificate No.
+ Class
+ Branch
+ Shares
+ Face Value
+ Total
+ Share Nos.
+ Issued
+ Actions
+
+
+
+ @for (r of filteredRecords; track r.certificateNo) {
+
+
+ {{ r.memberName }}
+
+ {{ r.memberId }} · Folio {{ r.folioNo }} · PAN {{ r.memberPan }}
+
+
+ {{ r.certificateNo }}
+
+
+ {{ classLabel(r.shareClass) }}
+
+
+ {{ r.branch }}
+
+ {{ r.shareCount | number }}
+
+ ₹{{ r.faceValue | number: '1.0-0' }}
+
+ ₹{{ r.shareCount * r.faceValue | number: '1.0-0' }}
+
+ {{ r.shareNoFrom }}–{{ r.shareNoTo }}
+ {{ r.issueDate | date: 'd MMM y' }}
+
+
+
+
+
+
+
+
+
+
+
+ }
+ @if (filteredRecords.length === 0) {
+
+
+
+ No share certificates match the current filters.
+
+
+ }
+
+
+
+
+
diff --git a/src/app/letter-mgmt/certificates/share-certificate/share-certificate.component.ts b/src/app/letter-mgmt/certificates/share-certificate/share-certificate.component.ts
new file mode 100644
index 000000000..bccc5c0fc
--- /dev/null
+++ b/src/app/letter-mgmt/certificates/share-certificate/share-certificate.component.ts
@@ -0,0 +1,263 @@
+/**
+ * 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, OnInit } 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 { MatSnackBar } from '@angular/material/snack-bar';
+import { MatTooltip } from '@angular/material/tooltip';
+import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+
+import {
+ CertificatePreviewDialogComponent,
+ CertificatePreviewDialogData
+} from '../certificate-preview-dialog/certificate-preview-dialog.component';
+import { downloadShareCertificatePdf } from '../certificate-pdf';
+
+export type ShareClass = 'ORDINARY' | 'PREFERENCE';
+
+export interface ShareCertificateRecord {
+ certificateNo: string;
+ folioNo: string;
+ memberId: string;
+ memberName: string;
+ memberPan: string;
+ memberAddress: string;
+ branch: string;
+ joiningDate: string;
+ shareClass: ShareClass;
+ shareCount: number;
+ faceValue: number;
+ issueDate: string;
+ /** Distinct numeric range of share numbers held (e.g. 1001–1100). */
+ shareNoFrom: number;
+ shareNoTo: number;
+ /** Total dividend received since joining. */
+ dividendYtd: number;
+}
+
+@Component({
+ selector: 'mifosx-share-certificate',
+ templateUrl: './share-certificate.component.html',
+ styleUrls: ['../certificates.shared.scss'],
+ imports: [
+ ...STANDALONE_SHARED_IMPORTS,
+ CommonModule,
+ FormsModule,
+ FaIconComponent,
+ MatTooltip
+ ]
+})
+export class ShareCertificateComponent implements OnInit {
+ private dialog = inject(MatDialog);
+ private snack = inject(MatSnackBar);
+
+ // ── Filters ──
+ search = '';
+ classFilter: '' | ShareClass = '';
+ branchFilter = '';
+
+ records: ShareCertificateRecord[] = [];
+
+ readonly classOptions: { value: ShareClass; label: string }[] = [
+ { value: 'ORDINARY', label: 'Ordinary' },
+ { value: 'PREFERENCE', label: 'Preference' }
+ ];
+
+ ngOnInit(): void {
+ this.records = seedShareCertificates();
+ }
+
+ // ── Derived list ──
+ get filteredRecords(): ShareCertificateRecord[] {
+ const q = this.search.trim().toLowerCase();
+ const branch = this.branchFilter.trim().toLowerCase();
+ return this.records.filter((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.records.map((r) => r.branch))).sort();
+ }
+
+ classLabel(c: ShareClass): string {
+ return this.classOptions.find((o) => o.value === c)?.label || c;
+ }
+
+ totalShares(): number {
+ return this.filteredRecords.reduce((s, r) => s + r.shareCount, 0);
+ }
+
+ totalShareValue(): number {
+ return this.filteredRecords.reduce((s, r) => s + r.shareCount * r.faceValue, 0);
+ }
+
+ // ── Actions ──
+ clearFilters(): void {
+ this.search = '';
+ this.classFilter = '';
+ this.branchFilter = '';
+ }
+
+ viewCertificate(r: ShareCertificateRecord): void {
+ const data: CertificatePreviewDialogData = {
+ kind: 'share',
+ shareCertificate: r
+ };
+ this.dialog.open(CertificatePreviewDialogComponent, {
+ width: '880px',
+ maxWidth: '96vw',
+ maxHeight: '92vh',
+ autoFocus: false,
+ panelClass: 'cert-preview-dialog',
+ data
+ });
+ }
+
+ downloadCertificate(r: ShareCertificateRecord): void {
+ downloadShareCertificatePdf(r);
+ this.snack.open(`Share Certificate ${r.certificateNo} downloaded.`, 'OK', { duration: 2400 });
+ }
+}
+
+// ───────────────────── Mock seed (top-level export) ─────────────────────
+export function seedShareCertificates(): ShareCertificateRecord[] {
+ return [
+ {
+ certificateNo: 'SC-2024-000123',
+ folioNo: 'F-00482',
+ memberId: 'MBR000482',
+ memberName: 'Rohan S. Mehta',
+ memberPan: 'ABCPM1234E',
+ memberAddress: 'Flat 902, Skyline Apt, Andheri (E), Mumbai 400069',
+ branch: 'Mumbai — Andheri (E)',
+ joiningDate: '2018-04-22',
+ shareClass: 'ORDINARY',
+ shareCount: 100,
+ faceValue: 100,
+ issueDate: '2018-04-22',
+ shareNoFrom: 48201,
+ shareNoTo: 48300,
+ dividendYtd: 7200
+ },
+ {
+ certificateNo: 'SC-2024-000124',
+ folioNo: 'F-00713',
+ memberId: 'MBR000713',
+ memberName: 'Anita Verma',
+ memberPan: 'EFGHV5678A',
+ memberAddress: 'B-12, Connaught Pl, New Delhi 110001',
+ branch: 'New Delhi — Connaught Place',
+ joiningDate: '2019-08-11',
+ shareClass: 'ORDINARY',
+ shareCount: 50,
+ faceValue: 100,
+ issueDate: '2019-08-11',
+ shareNoFrom: 71301,
+ shareNoTo: 71350,
+ dividendYtd: 3000
+ },
+ {
+ certificateNo: 'SC-2024-000125',
+ folioNo: 'F-01102',
+ memberId: 'MBR001102',
+ memberName: 'Karthik Rao',
+ memberPan: 'PQRSR2233Z',
+ memberAddress: '24, MG Road, Bengaluru 560001',
+ branch: 'Bengaluru — MG Road',
+ joiningDate: '2020-02-04',
+ shareClass: 'ORDINARY',
+ shareCount: 200,
+ faceValue: 100,
+ issueDate: '2020-02-04',
+ shareNoFrom: 110201,
+ shareNoTo: 110400,
+ dividendYtd: 14400
+ },
+ {
+ certificateNo: 'SC-2024-000126',
+ folioNo: 'F-01277',
+ memberId: 'MBR001277',
+ memberName: 'Madhavi Singh',
+ memberPan: 'TUVWS3344K',
+ memberAddress: 'D-5, Greater Kailash, New Delhi 110048',
+ branch: 'New Delhi — Connaught Place',
+ joiningDate: '2017-11-19',
+ shareClass: 'PREFERENCE',
+ shareCount: 75,
+ faceValue: 1000,
+ issueDate: '2017-11-19',
+ shareNoFrom: 12721,
+ shareNoTo: 12795,
+ dividendYtd: 67500
+ },
+ {
+ certificateNo: 'SC-2024-000127',
+ folioNo: 'F-01498',
+ memberId: 'MBR001498',
+ memberName: 'Vikram Joshi',
+ memberPan: 'LMNOJ9988X',
+ memberAddress: 'Office 3B, Koregaon Park Road, Pune 411001',
+ branch: 'Pune — Koregaon Park',
+ joiningDate: '2016-06-30',
+ shareClass: 'PREFERENCE',
+ shareCount: 250,
+ faceValue: 1000,
+ issueDate: '2016-06-30',
+ shareNoFrom: 14981,
+ shareNoTo: 15230,
+ dividendYtd: 225000
+ },
+ {
+ certificateNo: 'SC-2024-000128',
+ folioNo: 'F-01902',
+ memberId: 'MBR001902',
+ memberName: 'Lakshmi Naidu',
+ memberPan: 'XYZAN7788Q',
+ memberAddress: 'Plot 14, Road No. 3, Banjara Hills, Hyderabad 500034',
+ branch: 'Hyderabad — Banjara Hills',
+ joiningDate: '2021-03-15',
+ shareClass: 'ORDINARY',
+ shareCount: 30,
+ faceValue: 100,
+ issueDate: '2021-03-15',
+ shareNoFrom: 190201,
+ shareNoTo: 190230,
+ dividendYtd: 1800
+ },
+ {
+ certificateNo: 'SC-2024-000129',
+ folioNo: 'F-02011',
+ memberId: 'MBR002011',
+ memberName: 'Arjun Banerjee',
+ memberPan: 'TUVWB4455F',
+ memberAddress: '42, Park Street, Kolkata 700016',
+ branch: 'Kolkata — Park Street',
+ joiningDate: '2021-09-08',
+ shareClass: 'ORDINARY',
+ shareCount: 40,
+ faceValue: 100,
+ issueDate: '2021-09-08',
+ shareNoFrom: 201101,
+ shareNoTo: 201140,
+ dividendYtd: 2400
+ }
+ ];
+}
diff --git a/src/app/letter-mgmt/letter-mgmt-routing.module.ts b/src/app/letter-mgmt/letter-mgmt-routing.module.ts
new file mode 100644
index 000000000..b3df465f6
--- /dev/null
+++ b/src/app/letter-mgmt/letter-mgmt-routing.module.ts
@@ -0,0 +1,71 @@
+/**
+ * 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 { NgModule } from '@angular/core';
+import { Routes, RouterModule } from '@angular/router';
+
+import { Route } from '../core/route/route.service';
+import { NoticeComponent } from './notice/notice.component';
+import { LoanInterestCertificateComponent } from './certificates/loan-interest/loan-interest.component';
+import { ShareCertificateComponent } from './certificates/share-certificate/share-certificate.component';
+import { BalanceCertificateComponent } from './certificates/balance-certificate/balance-certificate.component';
+import { Form60Component } from './certificates/form-60/form-60.component';
+import { Form15ghComponent } from './certificates/form-15gh/form-15gh.component';
+import { HypothecationCertificateComponent } from './certificates/hypothecation/hypothecation.component';
+import { NocCertificateComponent } from './certificates/noc/noc.component';
+
+const routes: Routes = [
+ Route.withShell([
+ {
+ path: 'letter-mgmt/notice',
+ component: NoticeComponent,
+ data: { title: 'Loan Overdue Notices', breadcrumb: 'Notice' }
+ },
+ {
+ path: 'letter-mgmt/loan-interest-certificate',
+ component: LoanInterestCertificateComponent,
+ data: { title: 'Loan Interest Certificate', breadcrumb: 'Loan Interest Certificate' }
+ },
+ {
+ path: 'letter-mgmt/share-certificate',
+ component: ShareCertificateComponent,
+ data: { title: 'Share Certificate', breadcrumb: 'Share Certificate' }
+ },
+ {
+ path: 'letter-mgmt/balance-certificate',
+ component: BalanceCertificateComponent,
+ data: { title: 'Balance Certificate', breadcrumb: 'Balance Certificate' }
+ },
+ {
+ path: 'letter-mgmt/form-60',
+ component: Form60Component,
+ data: { title: 'Form 60', breadcrumb: 'Form 60' }
+ },
+ {
+ path: 'letter-mgmt/form-15gh',
+ component: Form15ghComponent,
+ data: { title: 'Form 15G & 15H', breadcrumb: 'Form 15G/H' }
+ },
+ {
+ path: 'letter-mgmt/hypothecation-certificate',
+ component: HypothecationCertificateComponent,
+ data: { title: 'Hypothecation Certificate', breadcrumb: 'Hypothecation Certificate' }
+ },
+ {
+ path: 'letter-mgmt/noc',
+ component: NocCertificateComponent,
+ data: { title: 'No-Objection Certificate (NOC)', breadcrumb: 'NOC' }
+ }
+ ])
+];
+
+@NgModule({
+ imports: [RouterModule.forChild(routes)],
+ exports: [RouterModule]
+})
+export class LetterMgmtRoutingModule {}
diff --git a/src/app/letter-mgmt/letter-mgmt.module.ts b/src/app/letter-mgmt/letter-mgmt.module.ts
new file mode 100644
index 000000000..7ffbc06e6
--- /dev/null
+++ b/src/app/letter-mgmt/letter-mgmt.module.ts
@@ -0,0 +1,46 @@
+/**
+ * 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 { NgModule } from '@angular/core';
+
+import { SharedModule } from '../shared/shared.module';
+import { LetterMgmtRoutingModule } from './letter-mgmt-routing.module';
+import { NoticeComponent } from './notice/notice.component';
+import { NoticeHistoryDialogComponent } from './notice/notice-history-dialog/notice-history-dialog.component';
+import { NoticePreviewDialogComponent } from './notice/notice-preview-dialog/notice-preview-dialog.component';
+import { CallContactDialogComponent } from './notice/call-contact-dialog/call-contact-dialog.component';
+import { SpeedPostDialogComponent } from './notice/speed-post-dialog/speed-post-dialog.component';
+import { LoanInterestCertificateComponent } from './certificates/loan-interest/loan-interest.component';
+import { ShareCertificateComponent } from './certificates/share-certificate/share-certificate.component';
+import { BalanceCertificateComponent } from './certificates/balance-certificate/balance-certificate.component';
+import { Form60Component } from './certificates/form-60/form-60.component';
+import { Form15ghComponent } from './certificates/form-15gh/form-15gh.component';
+import { HypothecationCertificateComponent } from './certificates/hypothecation/hypothecation.component';
+import { NocCertificateComponent } from './certificates/noc/noc.component';
+import { CertificatePreviewDialogComponent } from './certificates/certificate-preview-dialog/certificate-preview-dialog.component';
+
+@NgModule({
+ imports: [
+ SharedModule,
+ LetterMgmtRoutingModule,
+ NoticeComponent,
+ NoticeHistoryDialogComponent,
+ NoticePreviewDialogComponent,
+ CallContactDialogComponent,
+ SpeedPostDialogComponent,
+ LoanInterestCertificateComponent,
+ ShareCertificateComponent,
+ BalanceCertificateComponent,
+ Form60Component,
+ Form15ghComponent,
+ HypothecationCertificateComponent,
+ NocCertificateComponent,
+ CertificatePreviewDialogComponent
+ ]
+})
+export class LetterMgmtModule {}
diff --git a/src/app/letter-mgmt/notice/call-contact-dialog/call-contact-dialog.component.html b/src/app/letter-mgmt/notice/call-contact-dialog/call-contact-dialog.component.html
new file mode 100644
index 000000000..4ee908682
--- /dev/null
+++ b/src/app/letter-mgmt/notice/call-contact-dialog/call-contact-dialog.component.html
@@ -0,0 +1,209 @@
+
+
+
+
+
+
+
+
+
Reach {{ data.loan.customerName }}
+
+ {{ data.loan.accountNo }} · {{ data.loan.customerId }} ·
+ {{ data.loan.branch }}
+
+
+
+
+
+
+
+
+
+
+ @for (c of contacts; track c.kind) {
+
+
+
+
+
+
+
+
+
+
{{ c.label }}
+ @if (c.name) {
+
{{ c.name }}
+ }
+
+91 {{ formatMobile(c.mobile) }}
+
+ @if (latestLog(c); as log) {
+
+
+ {{ outcomeLabel(log.outcome) }}
+ · {{ log.calledAt.slice(0, 16) }}
+ @if (log.promiseAmount && log.promiseDate) {
+
+ PTP ₹{{ log.promiseAmount | number: '1.0-0' }} by {{ log.promiseDate | date: 'd MMM' }}
+
+ }
+
+ }
+
+
+
+
+
+ Call
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ @if (isLogging(c)) {
+
+
+
+ Log call outcome
+ {{ c.name || c.label }} · +91 {{ formatMobile(c.mobile) }}
+
+
+
Pick an outcome
+
+ @for (o of outcomeOptions; track o.value) {
+
+
+ {{ o.label }}
+
+ }
+
+
+ @if (isPromiseToPay) {
+
+ }
+
+
+ Notes (optional)
+
+
+
+
+
+ Cancel
+
+
+
+ Save call log
+
+
+
+ }
+
+ }
+
+ @if (contacts.length === 0) {
+
+
+
+
No contact numbers on file.
+
Update the customer profile to add mobile, family or nominee contacts.
+
+
+ }
+
+
+ @if (allLogs.length > 0) {
+
+
+
+ Recent calls
+ {{ allLogs.length }}
+
+
+ @for (log of allLogs; track log.id) {
+
+
+
+
+
+ {{ outcomeLabel(log.outcome) }}
+
+
+ {{ contactKindLabel(log.contactKind) }}
+ @if (log.contactName) {
+ · {{ log.contactName }}
+ }
+
+ +91 {{ formatMobile(log.contactNumber) }}
+ {{ log.calledAt.slice(0, 16) }} · {{ log.calledBy }}
+
+ @if (log.promiseAmount && log.promiseDate) {
+
+
+ Promise ₹{{ log.promiseAmount | number: '1.0-0' }} by
+ {{ log.promiseDate | date: 'd MMM y' }}
+
+ }
+ @if (log.notes) {
+ "{{ log.notes }}"
+ }
+
+ }
+
+
+ }
+
+
diff --git a/src/app/letter-mgmt/notice/call-contact-dialog/call-contact-dialog.component.scss b/src/app/letter-mgmt/notice/call-contact-dialog/call-contact-dialog.component.scss
new file mode 100644
index 000000000..777c6baf4
--- /dev/null
+++ b/src/app/letter-mgmt/notice/call-contact-dialog/call-contact-dialog.component.scss
@@ -0,0 +1,765 @@
+/**
+ * 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/.
+ */
+
+:host {
+ --ccd-blue: #1d4ed8;
+ --ccd-blue-d: #1e40af;
+ --ccd-blue-l: #eff6ff;
+ --ccd-blue-tint: #dbeafe;
+ --ccd-teal: #0d9488;
+ --ccd-teal-l: #ccfbf1;
+ --ccd-amber: #d97706;
+ --ccd-amber-l: #fef3c7;
+ --ccd-red: #b91c1c;
+ --ccd-red-l: #fee2e2;
+ --ccd-red-tint: #fecaca;
+ --ccd-green: #15803d;
+ --ccd-green-l: #dcfce7;
+ --ccd-purple: #6d28d9;
+ --ccd-purple-l: #ede9fe;
+ --ccd-g50: #f8fafc;
+ --ccd-g100: #f1f5f9;
+ --ccd-g200: #e2e8f0;
+ --ccd-g300: #cbd5e1;
+ --ccd-g500: #64748b;
+ --ccd-g700: #334155;
+ --ccd-g900: #0f172a;
+
+ display: block;
+}
+
+.ccd-root {
+ background: #fff;
+ padding: 14px 18px 18px;
+ width: 100%;
+ max-height: 86vh;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+/* ───────── Header ───────── */
+.ccd-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 14px;
+ padding-bottom: 12px;
+ margin-bottom: 12px;
+ border-bottom: 1px solid var(--ccd-g200);
+}
+
+.ccd-head-l {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.ccd-head-ic {
+ width: 38px;
+ height: 38px;
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 12px;
+ background: var(--ccd-green);
+ color: #fff;
+ font-size: 15px;
+}
+
+.ccd-head-t {
+ margin: 0;
+ font-size: 16px;
+ font-weight: 800;
+ color: var(--ccd-g900);
+ line-height: 1.2;
+}
+
+.ccd-head-s {
+ margin-top: 3px;
+ font-size: 11.5px;
+ color: var(--ccd-g500);
+}
+
+.ccd-x {
+ width: 32px;
+ height: 32px;
+ border-radius: 999px;
+ border: 1px solid var(--ccd-g200);
+ background: #fff;
+ color: var(--ccd-g500);
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 13px;
+ flex-shrink: 0;
+ transition: all 0.12s ease;
+
+ &:hover {
+ background: var(--ccd-red-l);
+ color: var(--ccd-red);
+ border-color: var(--ccd-red-tint);
+ }
+}
+
+.mono {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+}
+
+/* ───────── Body ───────── */
+.ccd-body {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ overflow-y: auto;
+ padding-right: 2px;
+}
+
+/* ───────── Contact row ───────── */
+.ccd-row {
+ position: relative;
+ display: grid;
+ grid-template-columns: 44px 1fr auto;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 14px 12px 18px;
+ background: #fff;
+ border: 1px solid var(--ccd-g200);
+ border-radius: 12px;
+ transition:
+ border-color 0.15s ease,
+ box-shadow 0.15s ease;
+ overflow: hidden;
+
+ &:hover {
+ border-color: var(--ccd-blue-tint);
+ box-shadow: 0 4px 14px -8px rgb(15 23 42 / 18%);
+ }
+}
+
+.ccd-row-strip {
+ position: absolute;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ width: 4px;
+
+ .accent-blue & {
+ background: var(--ccd-blue);
+ }
+
+ .accent-teal & {
+ background: var(--ccd-teal);
+ }
+
+ .accent-amber & {
+ background: var(--ccd-amber);
+ }
+
+ .accent-purple & {
+ background: var(--ccd-purple);
+ }
+}
+
+.ccd-row-icon {
+ width: 38px;
+ height: 38px;
+ border-radius: 50%;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 14px;
+ flex-shrink: 0;
+
+ .accent-blue & {
+ background: var(--ccd-blue-l);
+ color: var(--ccd-blue-d);
+ }
+
+ .accent-teal & {
+ background: var(--ccd-teal-l);
+ color: var(--ccd-teal);
+ }
+
+ .accent-amber & {
+ background: var(--ccd-amber-l);
+ color: var(--ccd-amber);
+ }
+
+ .accent-purple & {
+ background: var(--ccd-purple-l);
+ color: var(--ccd-purple);
+ }
+}
+
+.ccd-row-text {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ line-height: 1.25;
+}
+
+.ccd-row-label {
+ font-size: 9.5px;
+ font-weight: 800;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--ccd-g500);
+}
+
+.ccd-row-name {
+ font-size: 13px;
+ font-weight: 700;
+ color: var(--ccd-g900);
+}
+
+.ccd-row-mobile {
+ font-size: 12.5px;
+ font-weight: 600;
+ color: var(--ccd-g700);
+ font-variant-numeric: tabular-nums;
+ letter-spacing: 0.02em;
+}
+
+/* ───────── Row actions ───────── */
+.ccd-row-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ flex-shrink: 0;
+}
+
+.ccd-act {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 34px;
+ padding: 0 11px;
+ border-radius: 8px;
+ border: 1px solid var(--ccd-g200);
+ background: #fff;
+ color: var(--ccd-g700);
+ font-size: 12.5px;
+ font-weight: 700;
+ cursor: pointer;
+ transition: all 0.12s ease;
+
+ fa-icon {
+ font-size: 12px;
+ color: var(--ccd-g500);
+ }
+
+ span {
+ letter-spacing: 0.02em;
+ }
+
+ &:hover {
+ background: var(--ccd-blue-l);
+ border-color: var(--ccd-blue-tint);
+ color: var(--ccd-blue-d);
+
+ fa-icon {
+ color: var(--ccd-blue-d);
+ }
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--ccd-blue);
+ outline-offset: 2px;
+ }
+}
+
+/* Primary "Call" button → solid green so it pops as the recommended action. */
+.ccd-act.ccd-act--primary {
+ background: var(--ccd-green);
+ color: #fff;
+ border-color: var(--ccd-green);
+
+ fa-icon {
+ color: #fff;
+ }
+
+ &:hover {
+ background: #14532d;
+ border-color: #14532d;
+ color: #fff;
+
+ fa-icon {
+ color: #fff;
+ }
+ }
+}
+
+/* "Log call outcome" toggle button. */
+.ccd-act.ccd-act--log {
+ width: 34px;
+ padding: 0;
+ justify-content: center;
+
+ &.is-on {
+ background: var(--ccd-blue-d);
+ border-color: var(--ccd-blue-d);
+ color: #fff;
+
+ fa-icon {
+ color: #fff;
+ }
+ }
+}
+
+/* "Save call log" — primary blue when enabled. */
+.ccd-act.ccd-act--save {
+ background: var(--ccd-blue-d);
+ color: #fff;
+ border-color: var(--ccd-blue-d);
+
+ fa-icon {
+ color: #fff;
+ }
+
+ &:hover:not(:disabled) {
+ background: var(--ccd-blue);
+ border-color: var(--ccd-blue);
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+}
+
+/* ───────── Latest-log strip on the contact row ───────── */
+.ccd-row-group {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+}
+
+.ccd-row.is-logging {
+ border-color: var(--ccd-blue-tint);
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+}
+
+.ccd-row-lastlog {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ margin-top: 4px;
+ padding: 3px 9px;
+ border-radius: 999px;
+ font-size: 10.5px;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ align-self: flex-start;
+
+ fa-icon {
+ font-size: 10px;
+ }
+
+ &.tone-green {
+ background: var(--ccd-green-l);
+ color: var(--ccd-green);
+ }
+
+ &.tone-blue {
+ background: var(--ccd-blue-l);
+ color: var(--ccd-blue-d);
+ }
+
+ &.tone-amber {
+ background: var(--ccd-amber-l);
+ color: var(--ccd-amber);
+ }
+
+ &.tone-red {
+ background: var(--ccd-red-l);
+ color: var(--ccd-red);
+ }
+}
+
+.ccd-lastlog-m {
+ font-weight: 500;
+ color: inherit;
+ opacity: 0.85;
+}
+
+.ccd-lastlog-ptp {
+ margin-left: 6px;
+ padding-left: 6px;
+ border-left: 1px solid currentcolor;
+ opacity: 0.85;
+}
+
+/* ───────── Inline disposition picker ───────── */
+.ccd-log {
+ border: 1px solid var(--ccd-blue-tint);
+ border-top: 0;
+ border-bottom-left-radius: 12px;
+ border-bottom-right-radius: 12px;
+ background: linear-gradient(180deg, #f8fafc 0%, #fff 100%);
+ padding: 12px 14px 14px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ margin-bottom: 8px;
+}
+
+.ccd-log-h {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12.5px;
+ font-weight: 800;
+ color: var(--ccd-g900);
+ padding-bottom: 8px;
+ border-bottom: 1px dashed var(--ccd-g200);
+}
+
+.ccd-log-h-ic {
+ width: 22px;
+ height: 22px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 7px;
+ background: var(--ccd-blue-d);
+ color: #fff;
+ font-size: 11px;
+}
+
+.ccd-log-h-sub {
+ margin-left: auto;
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--ccd-g500);
+ letter-spacing: 0.01em;
+}
+
+.ccd-log-l {
+ font-size: 10px;
+ font-weight: 800;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--ccd-g500);
+}
+
+/* Outcome chip group */
+.ccd-chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.ccd-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 5px 11px;
+ border-radius: 999px;
+ border: 1px solid var(--ccd-g200);
+ background: #fff;
+ color: var(--ccd-g700);
+ font-size: 11.5px;
+ font-weight: 700;
+ letter-spacing: 0.01em;
+ cursor: pointer;
+ transition: all 0.12s ease;
+
+ fa-icon {
+ font-size: 11px;
+ color: var(--ccd-g500);
+ }
+
+ &:hover:not(.is-on) {
+ background: var(--ccd-g50);
+ border-color: var(--ccd-g300);
+ }
+
+ &.is-on {
+ color: #fff;
+
+ fa-icon {
+ color: #fff;
+ }
+
+ &.tone-green {
+ background: var(--ccd-green);
+ border-color: var(--ccd-green);
+ }
+
+ &.tone-blue {
+ background: var(--ccd-blue-d);
+ border-color: var(--ccd-blue-d);
+ }
+
+ &.tone-amber {
+ background: var(--ccd-amber);
+ border-color: var(--ccd-amber);
+ }
+
+ &.tone-red {
+ background: var(--ccd-red);
+ border-color: var(--ccd-red);
+ }
+ }
+}
+
+/* Promise-to-pay fields */
+.ccd-ptp {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px;
+ padding: 10px 12px;
+ background: var(--ccd-green-l);
+ border: 1px solid #bbf7d0;
+ border-radius: 10px;
+}
+
+.ccd-field {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+
+ label {
+ font-size: 10px;
+ font-weight: 800;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--ccd-g500);
+ }
+
+ input,
+ textarea {
+ box-sizing: border-box;
+ width: 100%;
+ border: 1px solid var(--ccd-g200);
+ border-radius: 8px;
+ padding: 8px 11px;
+ background: #fff;
+ font-size: 12.5px;
+ color: var(--ccd-g900);
+ font-family: inherit;
+ outline: none;
+ transition: border-color 0.15s ease;
+ resize: vertical;
+
+ &:focus {
+ border-color: var(--ccd-blue);
+ }
+ }
+
+ textarea {
+ min-height: 50px;
+ }
+}
+
+.ccd-log-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ padding-top: 4px;
+}
+
+/* ───────── Recent-calls history list ───────── */
+.ccd-history {
+ margin-top: 14px;
+ padding-top: 14px;
+ border-top: 1px dashed var(--ccd-g200);
+}
+
+.ccd-history-h {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 10px;
+ font-weight: 800;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--ccd-g500);
+ margin-bottom: 8px;
+
+ fa-icon {
+ font-size: 11px;
+ color: var(--ccd-blue);
+ }
+}
+
+.ccd-history-c {
+ margin-left: 4px;
+ padding: 2px 8px;
+ border-radius: 999px;
+ background: var(--ccd-g100);
+ color: var(--ccd-g500);
+ font-size: 9.5px;
+ font-weight: 800;
+}
+
+.ccd-history-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.ccd-history-item {
+ padding: 8px 10px 8px 14px;
+ border: 1px solid var(--ccd-g200);
+ border-radius: 9px;
+ background: #fff;
+ position: relative;
+
+ &.tone-green {
+ border-left: 4px solid var(--ccd-green);
+ }
+
+ &.tone-blue {
+ border-left: 4px solid var(--ccd-blue);
+ }
+
+ &.tone-amber {
+ border-left: 4px solid var(--ccd-amber);
+ }
+
+ &.tone-red {
+ border-left: 4px solid var(--ccd-red);
+ }
+}
+
+.ccd-history-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+ font-size: 11.5px;
+}
+
+.ccd-history-outcome {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ font-weight: 800;
+ color: var(--ccd-g900);
+
+ fa-icon {
+ font-size: 10.5px;
+ }
+
+ .tone-green & fa-icon {
+ color: var(--ccd-green);
+ }
+
+ .tone-blue & fa-icon {
+ color: var(--ccd-blue-d);
+ }
+
+ .tone-amber & fa-icon {
+ color: var(--ccd-amber);
+ }
+
+ .tone-red & fa-icon {
+ color: var(--ccd-red);
+ }
+}
+
+.ccd-history-kind {
+ font-size: 10.5px;
+ font-weight: 600;
+ color: var(--ccd-g500);
+}
+
+.ccd-history-num {
+ font-size: 11.5px;
+ font-weight: 600;
+ color: var(--ccd-g700);
+ font-variant-numeric: tabular-nums;
+}
+
+.ccd-history-meta {
+ margin-left: auto;
+ font-size: 11px;
+ color: var(--ccd-g500);
+}
+
+.ccd-history-ptp {
+ margin-top: 6px;
+ padding: 4px 9px;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 11px;
+ font-weight: 700;
+ color: var(--ccd-green);
+ background: var(--ccd-green-l);
+ border-radius: 8px;
+
+ fa-icon {
+ font-size: 10px;
+ }
+}
+
+.ccd-history-notes {
+ margin-top: 6px;
+ font-size: 11.5px;
+ font-style: italic;
+ color: var(--ccd-g500);
+ line-height: 1.45;
+}
+
+/* ───────── Empty state ───────── */
+.ccd-empty {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ padding: 18px 16px;
+ border: 1px dashed var(--ccd-g300);
+ border-radius: 10px;
+ background: var(--ccd-g50);
+ color: var(--ccd-g700);
+
+ fa-icon {
+ color: var(--ccd-blue);
+ font-size: 18px;
+ margin-top: 2px;
+ }
+
+ p {
+ margin: 4px 0 0;
+ font-size: 12.5px;
+ color: var(--ccd-g500);
+ }
+}
+
+@media (width <= 540px) {
+ .ccd-row {
+ grid-template-columns: 38px 1fr;
+ grid-template-areas:
+ 'icon text'
+ 'actions actions';
+ row-gap: 8px;
+ }
+
+ .ccd-row-icon {
+ grid-area: icon;
+ }
+
+ .ccd-row-text {
+ grid-area: text;
+ }
+
+ .ccd-row-actions {
+ grid-area: actions;
+ justify-content: flex-end;
+ }
+}
diff --git a/src/app/letter-mgmt/notice/call-contact-dialog/call-contact-dialog.component.ts b/src/app/letter-mgmt/notice/call-contact-dialog/call-contact-dialog.component.ts
new file mode 100644
index 000000000..2001b7c30
--- /dev/null
+++ b/src/app/letter-mgmt/notice/call-contact-dialog/call-contact-dialog.component.ts
@@ -0,0 +1,306 @@
+/**
+ * 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 { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
+import { MatTooltip } from '@angular/material/tooltip';
+import { MatSnackBar } from '@angular/material/snack-bar';
+import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+
+import { CallContactKind, CallLog, CallOutcome, OverdueLoan } from '../notice.component';
+
+export interface CallContactDialogData {
+ loan: OverdueLoan;
+}
+
+export type ContactKind = CallContactKind;
+
+export interface ContactEntry {
+ kind: ContactKind;
+ /** Display label e.g. "Customer", "Alternative", "Family — Spouse", "Nominee" */
+ label: string;
+ /** Person's name, when applicable (customer / family / nominee). */
+ name?: string;
+ /** Just the 10-digit India mobile (no +91). */
+ mobile: string;
+ /** Icon name (already registered) */
+ icon: string;
+ /** Color accent for the row strip. */
+ accent: 'blue' | 'teal' | 'purple' | 'amber';
+}
+
+interface OutcomeOption {
+ value: CallOutcome;
+ label: string;
+ /** Pill color theme — green = good, amber = neutral, red = bad. */
+ tone: 'green' | 'blue' | 'amber' | 'red';
+ icon: string;
+}
+
+interface LogDraft {
+ outcome: CallOutcome | null;
+ notes: string;
+ promiseAmount: number | null;
+ promiseDate: string;
+}
+
+@Component({
+ selector: 'mifosx-call-contact-dialog',
+ templateUrl: './call-contact-dialog.component.html',
+ styleUrls: ['./call-contact-dialog.component.scss'],
+ imports: [
+ ...STANDALONE_SHARED_IMPORTS,
+ CommonModule,
+ FormsModule,
+ FaIconComponent,
+ MatTooltip
+ ]
+})
+export class CallContactDialogComponent {
+ private dialogRef = inject(MatDialogRef);
+ private snack = inject(MatSnackBar);
+ data: CallContactDialogData = inject(MAT_DIALOG_DATA);
+
+ /** Tracks which row is currently in disposition-logging mode (one at a time). */
+ loggingKind: ContactKind | null = null;
+ /** Per-session draft state for the inline disposition picker. */
+ draft: LogDraft = this.emptyDraft();
+
+ readonly outcomeOptions: OutcomeOption[] = [
+ { value: 'PROMISE_TO_PAY', label: 'Promise to Pay', tone: 'green', icon: 'check-circle' },
+ { value: 'SETTLED', label: 'Settled / Paid', tone: 'green', icon: 'check-double' },
+ { value: 'CALLBACK_REQUESTED', label: 'Callback Requested', tone: 'blue', icon: 'history' },
+ { value: 'NOT_REACHABLE', label: 'Not Reachable', tone: 'amber', icon: 'phone' },
+ { value: 'SWITCHED_OFF', label: 'Switched Off', tone: 'amber', icon: 'mobile-alt' },
+ { value: 'RNR', label: 'Ring No Response', tone: 'amber', icon: 'bell' },
+ { value: 'BUSY', label: 'Busy', tone: 'amber', icon: 'hourglass-half' },
+ { value: 'WRONG_NUMBER', label: 'Wrong Number', tone: 'red', icon: 'exclamation-triangle' },
+ { value: 'REFUSED', label: 'Refused / Abusive', tone: 'red', icon: 'ban' }
+ ];
+
+ /** All reachable contacts for the loan — only the ones that actually have a number. */
+ get contacts(): ContactEntry[] {
+ const l = this.data.loan;
+ const out: ContactEntry[] = [];
+
+ out.push({
+ kind: 'PRIMARY',
+ label: 'Customer (primary)',
+ name: l.customerName,
+ mobile: l.mobile,
+ icon: 'user',
+ accent: 'blue'
+ });
+
+ if (l.mobileAlt) {
+ out.push({
+ kind: 'ALTERNATIVE',
+ label: 'Alternative number',
+ name: l.customerName,
+ mobile: l.mobileAlt,
+ icon: 'mobile-alt',
+ accent: 'teal'
+ });
+ }
+
+ if (l.familyMobile) {
+ out.push({
+ kind: 'FAMILY',
+ label: l.familyRelation ? `Family — ${l.familyRelation}` : 'Family',
+ name: l.familyName,
+ mobile: l.familyMobile,
+ icon: 'users',
+ accent: 'amber'
+ });
+ }
+
+ if (l.nomineeMobile) {
+ out.push({
+ kind: 'NOMINEE',
+ label: 'Nominee',
+ name: l.nomineeName,
+ mobile: l.nomineeMobile,
+ icon: 'shield-alt',
+ accent: 'purple'
+ });
+ }
+
+ return out;
+ }
+
+ /** "9818877665" → "98188 77665" — easier to read in the row. */
+ formatMobile(m: string): string {
+ if (!m) return '';
+ const trimmed = m.replace(/\D/g, '').slice(-10);
+ return trimmed.length === 10 ? `${trimmed.slice(0, 5)} ${trimmed.slice(5)}` : m;
+ }
+
+ call(c: ContactEntry): void {
+ window.location.href = `tel:+91${c.mobile}`;
+ this.snack.open(`Calling ${c.name || 'contact'} · +91 ${this.formatMobile(c.mobile)}…`, 'OK', {
+ duration: 2500
+ });
+ }
+
+ whatsapp(c: ContactEntry): void {
+ const msg = `Dear ${c.name || 'Sir/Madam'}, regarding your loan account ${this.data.loan.accountNo} with our bank — kindly contact our Recovery & Collections team.`;
+ const url = `https://wa.me/91${c.mobile}?text=${encodeURIComponent(msg)}`;
+ window.open(url, '_blank', 'noopener');
+ this.snack.open(`Opening WhatsApp for ${c.name || 'contact'}…`, 'OK', { duration: 2000 });
+ }
+
+ copy(c: ContactEntry): void {
+ const number = `+91 ${this.formatMobile(c.mobile)}`;
+ const fallback = (): void => {
+ const ta = document.createElement('textarea');
+ ta.value = number;
+ ta.style.position = 'fixed';
+ ta.style.opacity = '0';
+ document.body.appendChild(ta);
+ ta.select();
+ try {
+ document.execCommand('copy');
+ } catch {
+ /* ignore */
+ }
+ document.body.removeChild(ta);
+ };
+ if (navigator?.clipboard?.writeText) {
+ navigator.clipboard.writeText(number).catch(fallback);
+ } else {
+ fallback();
+ }
+ this.snack.open(`Copied ${number} to clipboard.`, 'OK', { duration: 2000 });
+ }
+
+ // ───────── Disposition logging ─────────
+ isLogging(c: ContactEntry): boolean {
+ return this.loggingKind === c.kind;
+ }
+
+ startLogging(c: ContactEntry): void {
+ this.loggingKind = c.kind;
+ this.draft = this.emptyDraft();
+ }
+
+ cancelLogging(): void {
+ this.loggingKind = null;
+ this.draft = this.emptyDraft();
+ }
+
+ pickOutcome(o: CallOutcome): void {
+ this.draft.outcome = this.draft.outcome === o ? null : o;
+ }
+
+ isOutcomePicked(o: CallOutcome): boolean {
+ return this.draft.outcome === o;
+ }
+
+ get isPromiseToPay(): boolean {
+ return this.draft.outcome === 'PROMISE_TO_PAY';
+ }
+
+ /** True once an outcome is picked and (for PTP) the promise fields are filled. */
+ get canSaveLog(): boolean {
+ if (!this.draft.outcome) return false;
+ if (this.isPromiseToPay) {
+ if (!this.draft.promiseAmount || this.draft.promiseAmount <= 0) return false;
+ if (!this.draft.promiseDate) return false;
+ }
+ return true;
+ }
+
+ saveLog(c: ContactEntry): void {
+ if (!this.canSaveLog || !this.draft.outcome) return;
+ const log: CallLog = {
+ id: 'CL' + Date.now().toString().slice(-9),
+ contactKind: c.kind,
+ contactNumber: c.mobile,
+ contactName: c.name,
+ calledAt: this.nowStr(),
+ calledBy: 'Current User',
+ outcome: this.draft.outcome,
+ promiseAmount: this.isPromiseToPay ? Number(this.draft.promiseAmount) : undefined,
+ promiseDate: this.isPromiseToPay ? this.draft.promiseDate : undefined,
+ notes: (this.draft.notes || '').trim() || undefined
+ };
+ this.data.loan.callLogs = [
+ log,
+ ...(this.data.loan.callLogs || [])
+ ];
+ const label = this.outcomeLabel(log.outcome);
+ this.snack.open(`Call disposition saved · ${label}`, 'OK', { duration: 2400 });
+ this.cancelLogging();
+ }
+
+ // ───────── Display helpers ─────────
+ /** Most-recent call disposition for a particular contact number (or null). */
+ latestLog(c: ContactEntry): CallLog | null {
+ const logs = this.data.loan.callLogs || [];
+ return logs.find((l) => l.contactNumber === c.mobile) || null;
+ }
+
+ /** All logs sorted newest first — drives the "Recent calls" footer list. */
+ get allLogs(): CallLog[] {
+ return this.data.loan.callLogs || [];
+ }
+
+ outcomeLabel(o: CallOutcome): string {
+ return this.outcomeOptions.find((x) => x.value === o)?.label || o;
+ }
+
+ outcomeTone(o: CallOutcome): 'green' | 'blue' | 'amber' | 'red' {
+ return this.outcomeOptions.find((x) => x.value === o)?.tone || 'amber';
+ }
+
+ outcomeIcon(o: CallOutcome): string {
+ return this.outcomeOptions.find((x) => x.value === o)?.icon || 'phone';
+ }
+
+ contactKindLabel(k: ContactKind): string {
+ switch (k) {
+ case 'PRIMARY':
+ return 'Primary';
+ case 'ALTERNATIVE':
+ return 'Alternative';
+ case 'FAMILY':
+ return 'Family';
+ case 'NOMINEE':
+ return 'Nominee';
+ }
+ }
+
+ close(): void {
+ this.dialogRef.close();
+ }
+
+ private emptyDraft(): LogDraft {
+ return { outcome: null, notes: '', promiseAmount: null, promiseDate: '' };
+ }
+
+ private nowStr(): string {
+ const d = new Date();
+ const pad = (n: number) => String(n).padStart(2, '0');
+ return (
+ d.getFullYear() +
+ '-' +
+ pad(d.getMonth() + 1) +
+ '-' +
+ pad(d.getDate()) +
+ ' ' +
+ pad(d.getHours()) +
+ ':' +
+ pad(d.getMinutes()) +
+ ':' +
+ pad(d.getSeconds())
+ );
+ }
+}
diff --git a/src/app/letter-mgmt/notice/notice-history-dialog/notice-history-dialog.component.html b/src/app/letter-mgmt/notice/notice-history-dialog/notice-history-dialog.component.html
new file mode 100644
index 000000000..1a2ae8d85
--- /dev/null
+++ b/src/app/letter-mgmt/notice/notice-history-dialog/notice-history-dialog.component.html
@@ -0,0 +1,148 @@
+
+
+
+
+
+
+
+
+
+ {{ data.loan.customerName }} · {{ data.loan.accountNo }}
+
+
+ {{ data.loan.notices.length }} notice(s) generated · Overdue
+ ₹{{ data.loan.overdueTotal | number: '1.0-0' }} · DPD {{ data.loan.daysOverdue }}
+
+
+
+
+
+
+
+
+
+
+ @if (data.loan.notices.length === 0) {
+
+
+
+
No notices have been generated yet.
+
Click the Generate icon on the row to issue a first-stage notice.
+
+
+ } @else {
+
+ @for (n of data.loan.notices; track n.id) {
+
+
+
+
+
+
+
+ {{ stageLabel(n.stage) }}
+
+
+ Generated {{ n.generatedAt }} by {{ n.generatedBy }}
+
+ {{ n.id }}
+
+
+
+
+
+
+
+
+
+
+ @if (n.sends.length === 0) {
+
+
+ Generated but not yet dispatched.
+
+ } @else {
+
+
+
+ Channel
+ Destination
+ Sent
+ By
+ Status
+
+
+
+ @for (s of n.sends; track s.sentAt + s.channel) {
+
+
+
+
+ {{ channelLabel(s.channel) }}
+
+
+ {{ s.destination }}
+ {{ s.sentAt }}
+ {{ s.sentBy }}
+
+
+ {{ s.status }}
+
+
+
+ @if (s.channel === 'POST' && (s.consignmentNo || s.postOffice)) {
+
+
+
+ @if (s.consignmentNo) {
+
+
+ Consignment
+ {{ s.consignmentNo }}
+
+ }
+ @if (s.postOffice) {
+
+
+ Post office
+ {{ s.postOffice }}
+
+ }
+ @if (s.bookedDate) {
+
+ Booked
+ {{ s.bookedDate | date: 'd MMM y' }}
+
+ }
+ @if (s.postage) {
+
+ Postage
+ ₹{{ s.postage | number: '1.0-0' }}
+
+ }
+
+
+
+ }
+ }
+
+
+ }
+
+
+ }
+
+ }
+
+
diff --git a/src/app/letter-mgmt/notice/notice-history-dialog/notice-history-dialog.component.scss b/src/app/letter-mgmt/notice/notice-history-dialog/notice-history-dialog.component.scss
new file mode 100644
index 000000000..844da4334
--- /dev/null
+++ b/src/app/letter-mgmt/notice/notice-history-dialog/notice-history-dialog.component.scss
@@ -0,0 +1,435 @@
+/**
+ * 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/.
+ */
+
+:host {
+ --nhd-blue: #1d4ed8;
+ --nhd-blue-d: #1e40af;
+ --nhd-blue-l: #eff6ff;
+ --nhd-blue-tint: #dbeafe;
+ --nhd-green: #15803d;
+ --nhd-green-l: #dcfce7;
+ --nhd-amber: #d97706;
+ --nhd-amber-l: #fef3c7;
+ --nhd-red: #b91c1c;
+ --nhd-red-l: #fee2e2;
+ --nhd-red-tint: #fecaca;
+ --nhd-purple: #6d28d9;
+ --nhd-purple-l: #ede9fe;
+ --nhd-purple-tint: #ddd6fe;
+ --nhd-g50: #f8fafc;
+ --nhd-g100: #f1f5f9;
+ --nhd-g200: #e2e8f0;
+ --nhd-g300: #cbd5e1;
+ --nhd-g500: #64748b;
+ --nhd-g700: #334155;
+ --nhd-g900: #0f172a;
+
+ display: block;
+}
+
+.nhd-root {
+ background: #fff;
+ padding: 16px 20px 22px;
+ width: 100%;
+ max-height: 84vh;
+ display: flex;
+ flex-direction: column;
+}
+
+/* ───────── Header ───────── */
+.nhd-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 14px;
+ padding-bottom: 12px;
+ margin-bottom: 14px;
+ border-bottom: 1px solid var(--nhd-g200);
+}
+
+.nhd-head-l {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.nhd-head-ic {
+ width: 40px;
+ height: 40px;
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 12px;
+ background: var(--nhd-blue-d);
+ color: #fff;
+ font-size: 17px;
+}
+
+.nhd-head-t {
+ margin: 0;
+ font-size: 16px;
+ font-weight: 800;
+ color: var(--nhd-g900);
+
+ small {
+ font-weight: 500;
+ color: var(--nhd-g500);
+ font-size: 12.5px;
+ margin-left: 4px;
+ }
+}
+
+.nhd-head-s {
+ margin-top: 3px;
+ font-size: 12px;
+ color: var(--nhd-g500);
+
+ strong {
+ color: var(--nhd-g900);
+ }
+}
+
+.nhd-x {
+ width: 32px;
+ height: 32px;
+ border-radius: 999px;
+ border: 1px solid var(--nhd-g200);
+ background: #fff;
+ color: var(--nhd-g500);
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 13px;
+ flex-shrink: 0;
+ transition:
+ background 0.12s,
+ color 0.12s,
+ border-color 0.12s;
+
+ &:hover {
+ background: var(--nhd-red-l);
+ color: var(--nhd-red);
+ border-color: var(--nhd-red-tint);
+ }
+}
+
+/* ───────── Body / Empty ───────── */
+.nhd-body {
+ flex: 1;
+ overflow: auto;
+}
+
+.nhd-empty {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ padding: 18px 16px;
+ border: 1px dashed var(--nhd-g300);
+ border-radius: 10px;
+ background: var(--nhd-g50);
+ color: var(--nhd-g700);
+
+ fa-icon {
+ color: var(--nhd-blue);
+ font-size: 18px;
+ margin-top: 2px;
+ }
+
+ p {
+ margin: 4px 0 0;
+ font-size: 12.5px;
+ color: var(--nhd-g500);
+ }
+}
+
+/* ───────── Timeline ───────── */
+.nhd-timeline {
+ list-style: none;
+ margin: 0;
+ padding: 6px 0 0 28px;
+ position: relative;
+
+ &::before {
+ content: '';
+ position: absolute;
+ left: 11px;
+ top: 0;
+ bottom: 8px;
+ width: 2px;
+ background: var(--nhd-g200);
+ }
+}
+
+.nhd-tl-item {
+ position: relative;
+ padding-bottom: 16px;
+}
+
+.nhd-tl-dot {
+ position: absolute;
+ left: -28px;
+ top: 4px;
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 11px;
+ color: #fff;
+ background: var(--nhd-blue-d);
+ border: 3px solid #fff;
+ box-shadow: 0 0 0 2px var(--nhd-blue-tint);
+
+ .accent-amber & {
+ background: var(--nhd-amber);
+ box-shadow: 0 0 0 2px #fde68a;
+ }
+
+ .accent-red & {
+ background: var(--nhd-red);
+ box-shadow: 0 0 0 2px var(--nhd-red-tint);
+ }
+
+ .accent-purple & {
+ background: var(--nhd-purple);
+ box-shadow: 0 0 0 2px var(--nhd-purple-tint);
+ }
+}
+
+.nhd-tl-card {
+ border: 1px solid var(--nhd-g200);
+ border-radius: 12px;
+ background: #fff;
+ padding: 12px 14px;
+}
+
+.nhd-tl-head {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+ margin-bottom: 10px;
+ padding-bottom: 10px;
+ border-bottom: 1px dashed var(--nhd-g200);
+}
+
+.nhd-stage-pill {
+ display: inline-flex;
+ align-items: center;
+ padding: 3px 10px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 800;
+ letter-spacing: 0.02em;
+ text-transform: uppercase;
+
+ &--blue {
+ background: var(--nhd-blue-l);
+ color: var(--nhd-blue-d);
+ }
+
+ &--amber {
+ background: var(--nhd-amber-l);
+ color: var(--nhd-amber);
+ }
+
+ &--red {
+ background: var(--nhd-red-l);
+ color: var(--nhd-red);
+ }
+
+ &--purple {
+ background: var(--nhd-purple-l);
+ color: var(--nhd-purple);
+ }
+}
+
+.nhd-tl-meta {
+ font-size: 11.5px;
+ color: var(--nhd-g500);
+
+ strong {
+ color: var(--nhd-g900);
+ }
+}
+
+.nhd-tl-id {
+ margin-left: auto;
+ font-size: 11px;
+ color: var(--nhd-g500);
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+}
+
+.nhd-tl-actions {
+ display: inline-flex;
+ gap: 4px;
+}
+
+.nhd-action-btn {
+ width: 28px;
+ height: 28px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid var(--nhd-g200);
+ background: #fff;
+ color: var(--nhd-g500);
+ border-radius: 7px;
+ cursor: pointer;
+ font-size: 11px;
+ transition:
+ background 0.15s,
+ color 0.15s,
+ border-color 0.15s;
+
+ &:hover {
+ background: var(--nhd-blue-l);
+ color: var(--nhd-blue-d);
+ border-color: var(--nhd-blue-tint);
+ }
+}
+
+.nhd-no-sends {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12.5px;
+ color: var(--nhd-amber);
+
+ fa-icon {
+ font-size: 12px;
+ }
+}
+
+/* ───────── Send table ───────── */
+.nhd-send-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12px;
+
+ th {
+ text-align: left;
+ padding: 6px 10px;
+ background: var(--nhd-g50);
+ color: var(--nhd-g500);
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ font-size: 10px;
+ border-bottom: 1px solid var(--nhd-g200);
+ }
+
+ td {
+ padding: 8px 10px;
+ border-bottom: 1px solid var(--nhd-g100);
+ color: var(--nhd-g900);
+ vertical-align: top;
+ }
+
+ tr:last-child td {
+ border-bottom: 0;
+ }
+
+ .small {
+ font-size: 11.5px;
+ }
+
+ .mono {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ color: var(--nhd-g700);
+ }
+}
+
+.nhd-channel {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-weight: 700;
+ color: var(--nhd-g900);
+
+ fa-icon {
+ color: var(--nhd-blue);
+ font-size: 12px;
+ }
+}
+
+.nhd-status-pill {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 8px;
+ border-radius: 999px;
+ font-size: 10.5px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+
+ &--green {
+ background: var(--nhd-green-l);
+ color: var(--nhd-green);
+ }
+
+ &--amber {
+ background: var(--nhd-amber-l);
+ color: var(--nhd-amber);
+ }
+
+ &--red {
+ background: var(--nhd-red-l);
+ color: var(--nhd-red);
+ }
+}
+
+/* ───────── Postal-dispatch detail strip (under POST send rows) ───────── */
+.nhd-post-row td {
+ padding: 6px 10px 10px;
+ background: #fff7ed;
+ border-bottom: 1px solid var(--nhd-g100);
+}
+
+.nhd-post-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.nhd-post-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 3px 9px;
+ border-radius: 999px;
+ background: #fff;
+ border: 1px solid #fed7aa;
+ font-size: 11px;
+ color: var(--nhd-g900);
+
+ fa-icon {
+ font-size: 10px;
+ color: #c2410c;
+ }
+}
+
+.nhd-post-l {
+ font-size: 9.5px;
+ font-weight: 800;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--nhd-g500);
+}
+
+.nhd-post-v {
+ font-size: 11.5px;
+ font-weight: 700;
+ color: var(--nhd-g900);
+
+ &.mono {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ }
+}
diff --git a/src/app/letter-mgmt/notice/notice-history-dialog/notice-history-dialog.component.ts b/src/app/letter-mgmt/notice/notice-history-dialog/notice-history-dialog.component.ts
new file mode 100644
index 000000000..633f83582
--- /dev/null
+++ b/src/app/letter-mgmt/notice/notice-history-dialog/notice-history-dialog.component.ts
@@ -0,0 +1,133 @@
+/**
+ * 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 { CommonModule } from '@angular/common';
+import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog';
+import { MatTooltip } from '@angular/material/tooltip';
+import { MatSnackBar } from '@angular/material/snack-bar';
+import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+
+import {
+ downloadNoticePdf,
+ NoticeChannel,
+ NoticeEvent,
+ NoticeStage,
+ OverdueLoan,
+ SendStatus
+} from '../notice.component';
+import {
+ NoticePreviewDialogComponent,
+ NoticePreviewDialogData
+} from '../notice-preview-dialog/notice-preview-dialog.component';
+
+export interface NoticeHistoryDialogData {
+ loan: OverdueLoan;
+}
+
+@Component({
+ selector: 'mifosx-notice-history-dialog',
+ templateUrl: './notice-history-dialog.component.html',
+ styleUrls: ['./notice-history-dialog.component.scss'],
+ imports: [
+ ...STANDALONE_SHARED_IMPORTS,
+ CommonModule,
+ FaIconComponent,
+ MatTooltip
+ ]
+})
+export class NoticeHistoryDialogComponent {
+ private dialogRef = inject(MatDialogRef);
+ private dialog = inject(MatDialog);
+ private snack = inject(MatSnackBar);
+ data: NoticeHistoryDialogData = inject(MAT_DIALOG_DATA);
+
+ close(): void {
+ this.dialogRef.close();
+ }
+
+ /** Opens the printable preview for a specific notice in the timeline. */
+ viewNotice(notice: NoticeEvent): void {
+ const data: NoticePreviewDialogData = { loan: this.data.loan, notice };
+ this.dialog.open(NoticePreviewDialogComponent, {
+ width: '880px',
+ maxWidth: '96vw',
+ maxHeight: '92vh',
+ autoFocus: false,
+ panelClass: 'ntc-preview-dialog',
+ data
+ });
+ }
+
+ /** Builds and downloads the PDF version of a specific historical notice. */
+ downloadNotice(notice: NoticeEvent): void {
+ downloadNoticePdf(this.data.loan, notice);
+ this.snack.open(`Notice ${notice.id} downloaded as PDF.`, 'OK', { duration: 2200 });
+ }
+
+ stageLabel(s: NoticeStage): string {
+ return s === 'FIRST'
+ ? '1st Notice'
+ : s === 'SECOND'
+ ? '2nd Notice'
+ : s === 'FINAL'
+ ? 'Final Notice'
+ : 'Legal Notice';
+ }
+
+ stageColor(s: NoticeStage): string {
+ return s === 'FIRST' ? 'blue' : s === 'SECOND' ? 'amber' : s === 'FINAL' ? 'red' : 'purple';
+ }
+
+ channelLabel(c: NoticeChannel): string {
+ switch (c) {
+ case 'EMAIL':
+ return 'Email';
+ case 'POST':
+ return 'Speed Post';
+ case 'COURIER':
+ return 'Courier';
+ case 'SMS':
+ return 'SMS';
+ case 'WHATSAPP':
+ return 'WhatsApp';
+ case 'IN_PERSON':
+ return 'In-Person';
+ }
+ }
+
+ channelIcon(c: NoticeChannel): string {
+ switch (c) {
+ case 'EMAIL':
+ return 'envelope';
+ case 'POST':
+ return 'stamp';
+ case 'COURIER':
+ return 'paper-plane';
+ case 'SMS':
+ return 'mobile-alt';
+ case 'WHATSAPP':
+ return 'comment-alt';
+ case 'IN_PERSON':
+ return 'user-tie';
+ }
+ }
+
+ statusColor(s: SendStatus): string {
+ switch (s) {
+ case 'DELIVERED':
+ return 'green';
+ case 'PENDING':
+ return 'amber';
+ case 'FAILED':
+ case 'BOUNCED':
+ return 'red';
+ }
+ }
+}
diff --git a/src/app/letter-mgmt/notice/notice-preview-dialog/notice-preview-dialog.component.html b/src/app/letter-mgmt/notice/notice-preview-dialog/notice-preview-dialog.component.html
new file mode 100644
index 000000000..bcca421e7
--- /dev/null
+++ b/src/app/letter-mgmt/notice/notice-preview-dialog/notice-preview-dialog.component.html
@@ -0,0 +1,150 @@
+
+
+
+
+
+
+
+
+
+
+
{{ bankName }}
+
{{ bankAddress }}
+
{{ bankRegNo }} · {{ bankContact }}
+
+
+
+
+
+
+
+
To,
+
+
+ {{ data.loan.customerName }}
+
+
+ Customer ID: {{ data.loan.customerId }}
+
+
{{ data.loan.address }}
+
Mob: +91 {{ data.loan.mobile }} · {{ data.loan.email }}
+
+
+
+
+
+ Subject:
+ {{ letter.subject }}
+
+
+
+
{{ letter.salutation }}
+
{{ letter.intro }}
+ @for (p of letter.body; track p) {
+
{{ p }}
+ }
+
+
+
+
+
+ Loan Account No.
+
+ {{ data.loan.accountNo }}
+
+ Branch
+ {{ data.loan.branch }}
+
+
+ Days Past Due
+
+ {{ data.loan.daysOverdue }}
+
+ Last Paid Date
+ {{ data.loan.lastPaidDate | date: 'd MMM y' }}
+
+
+ Overdue Principal
+ ₹{{ data.loan.overduePrincipal | number: '1.2-2' }}
+ Overdue Interest
+ ₹{{ data.loan.overdueInterest | number: '1.2-2' }}
+
+
+ TOTAL DUES OUTSTANDING
+
+ ₹{{ data.loan.overdueTotal | number: '1.2-2' }}
+
+
+
+
+
+
+
{{ letter.callToAction }}
+
+
+
{{ letter.consequences }}
+
+
+
{{ letter.signOff }}
+
+
+
+
+ For {{ bankName }}
+
+
Authorised Signatory — Recovery & Collections
+
{{ data.loan.branch }}
+
+
+
+
+
+
+
diff --git a/src/app/letter-mgmt/notice/notice-preview-dialog/notice-preview-dialog.component.scss b/src/app/letter-mgmt/notice/notice-preview-dialog/notice-preview-dialog.component.scss
new file mode 100644
index 000000000..f7ea5b786
--- /dev/null
+++ b/src/app/letter-mgmt/notice/notice-preview-dialog/notice-preview-dialog.component.scss
@@ -0,0 +1,501 @@
+/**
+ * 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/.
+ */
+
+:host {
+ --npd-blue: #1d4ed8;
+ --npd-blue-d: #1e40af;
+ --npd-blue-l: #eff6ff;
+ --npd-blue-tint: #dbeafe;
+ --npd-amber: #d97706;
+ --npd-amber-l: #fef3c7;
+ --npd-red: #b91c1c;
+ --npd-red-l: #fee2e2;
+ --npd-red-tint: #fecaca;
+ --npd-purple: #6d28d9;
+ --npd-purple-l: #ede9fe;
+ --npd-g50: #f8fafc;
+ --npd-g100: #f1f5f9;
+ --npd-g200: #e2e8f0;
+ --npd-g300: #cbd5e1;
+ --npd-g500: #64748b;
+ --npd-g700: #334155;
+ --npd-g900: #0f172a;
+
+ display: block;
+}
+
+.npd-root {
+ background: var(--npd-g100);
+ width: 100%;
+ max-height: 90vh;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+/* ───────── Toolbar (hidden on print) ───────── */
+.npd-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 14px;
+ padding: 12px 16px;
+ background: #fff;
+ border-bottom: 1px solid var(--npd-g200);
+ flex-shrink: 0;
+}
+
+.npd-toolbar-l {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.npd-toolbar-ic {
+ width: 36px;
+ height: 36px;
+ border-radius: 10px;
+ background: var(--npd-blue-d);
+ color: #fff;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 15px;
+}
+
+.npd-toolbar-t {
+ font-size: 14px;
+ font-weight: 800;
+ color: var(--npd-g900);
+}
+
+.npd-toolbar-s {
+ font-size: 11.5px;
+ color: var(--npd-g500);
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+}
+
+.npd-toolbar-r {
+ display: inline-flex;
+ gap: 8px;
+}
+
+.npd-tb-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 7px 12px;
+ border: 1px solid var(--npd-g200);
+ background: #fff;
+ color: var(--npd-g700);
+ font-size: 12.5px;
+ font-weight: 700;
+ border-radius: 8px;
+ cursor: pointer;
+ transition:
+ background 0.15s,
+ color 0.15s,
+ border-color 0.15s;
+
+ fa-icon {
+ font-size: 12px;
+ color: var(--npd-blue);
+ }
+
+ &:hover {
+ background: var(--npd-blue-l);
+ border-color: var(--npd-blue-tint);
+ color: var(--npd-blue-d);
+ }
+
+ &.npd-tb-x {
+ width: 34px;
+ padding: 0;
+ justify-content: center;
+
+ &:hover {
+ background: var(--npd-red-l);
+ color: var(--npd-red);
+ border-color: var(--npd-red-tint);
+
+ fa-icon {
+ color: var(--npd-red);
+ }
+ }
+ }
+}
+
+/* ───────── Letter page (A4 feel) ───────── */
+.npd-page {
+ background: #fff;
+ border: 1px solid var(--npd-g200);
+ padding: 38px 48px 32px;
+ margin: 18px auto;
+ width: min(720px, 94%);
+ border-top: 6px solid var(--npd-blue-d);
+ font-family: Georgia, 'Times New Roman', Times, serif;
+ color: var(--npd-g900);
+ line-height: 1.6;
+ overflow: auto;
+
+ &.tone-firm {
+ border-top-color: var(--npd-amber);
+ }
+
+ &.tone-urgent {
+ border-top-color: var(--npd-red);
+ }
+
+ &.tone-legal {
+ border-top-color: var(--npd-purple);
+ }
+}
+
+/* ───────── Letterhead ───────── */
+.npd-letterhead {
+ text-align: center;
+ padding-bottom: 14px;
+ margin-bottom: 18px;
+ border-bottom: 1px solid var(--npd-g200);
+}
+
+.npd-bank-name {
+ font-size: 19px;
+ font-weight: 800;
+ letter-spacing: 0.02em;
+ color: var(--npd-blue-d);
+
+ .tone-firm & {
+ color: var(--npd-amber);
+ }
+
+ .tone-urgent & {
+ color: var(--npd-red);
+ }
+
+ .tone-legal & {
+ color: var(--npd-purple);
+ }
+}
+
+.npd-bank-sub {
+ font-size: 11.5px;
+ color: var(--npd-g500);
+ margin-top: 2px;
+ font-family:
+ ui-sans-serif,
+ -apple-system,
+ BlinkMacSystemFont,
+ 'Segoe UI',
+ sans-serif;
+}
+
+/* ───────── Meta row ───────── */
+.npd-meta-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 16px;
+ font-family:
+ ui-sans-serif,
+ -apple-system,
+ BlinkMacSystemFont,
+ 'Segoe UI',
+ sans-serif;
+ font-size: 12px;
+ margin-bottom: 16px;
+ padding-bottom: 14px;
+ border-bottom: 1px dashed var(--npd-g200);
+
+ > div {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ }
+}
+
+.npd-meta-l {
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--npd-g500);
+}
+
+.npd-meta-v {
+ font-size: 12.5px;
+ color: var(--npd-g900);
+ font-weight: 700;
+}
+
+.npd-stage-pill {
+ display: inline-flex;
+ align-items: center;
+ padding: 3px 10px;
+ border-radius: 999px;
+ font-size: 10.5px;
+ font-weight: 800;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+
+ &--polite {
+ background: var(--npd-blue-l);
+ color: var(--npd-blue-d);
+ }
+
+ &--firm {
+ background: var(--npd-amber-l);
+ color: var(--npd-amber);
+ }
+
+ &--urgent {
+ background: var(--npd-red-l);
+ color: var(--npd-red);
+ }
+
+ &--legal {
+ background: var(--npd-purple-l);
+ color: var(--npd-purple);
+ }
+}
+
+/* ───────── Addressee + subject ───────── */
+.npd-block {
+ display: flex;
+ gap: 8px;
+ font-family:
+ ui-sans-serif,
+ -apple-system,
+ BlinkMacSystemFont,
+ 'Segoe UI',
+ sans-serif;
+ font-size: 13px;
+ margin-bottom: 14px;
+}
+
+.npd-block-l {
+ font-weight: 700;
+ color: var(--npd-g700);
+ width: 36px;
+ flex-shrink: 0;
+}
+
+.npd-block-v {
+ flex: 1;
+ color: var(--npd-g900);
+ line-height: 1.5;
+}
+
+.npd-subject {
+ margin: 14px 0;
+ padding: 8px 0;
+ border-top: 1px solid var(--npd-g200);
+ border-bottom: 1px solid var(--npd-g200);
+ font-size: 13px;
+
+ .npd-subject-l {
+ font-weight: 800;
+ color: var(--npd-g700);
+ margin-right: 6px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ font-size: 11px;
+ }
+
+ .npd-subject-v {
+ font-weight: 700;
+ color: var(--npd-g900);
+ }
+}
+
+/* ───────── Body paragraphs ───────── */
+.npd-salutation {
+ font-size: 13.5px;
+ margin: 16px 0 8px;
+ font-weight: 700;
+ color: var(--npd-g900);
+}
+
+.npd-para {
+ margin: 10px 0;
+ font-size: 13px;
+ text-align: justify;
+ color: var(--npd-g900);
+}
+
+.npd-cta {
+ background: var(--npd-blue-l);
+ border-left: 4px solid var(--npd-blue-d);
+ padding: 10px 14px;
+ font-weight: 600;
+ border-radius: 0 6px 6px 0;
+
+ .tone-firm & {
+ background: var(--npd-amber-l);
+ border-left-color: var(--npd-amber);
+ }
+
+ .tone-urgent & {
+ background: var(--npd-red-l);
+ border-left-color: var(--npd-red);
+ }
+
+ .tone-legal & {
+ background: var(--npd-purple-l);
+ border-left-color: var(--npd-purple);
+ }
+}
+
+.npd-consequences {
+ font-size: 12px;
+ color: var(--npd-g700);
+ font-style: italic;
+}
+
+/* ───────── Summary table ───────── */
+.npd-summary {
+ width: 100%;
+ margin: 16px 0;
+ border-collapse: collapse;
+ font-family:
+ ui-sans-serif,
+ -apple-system,
+ BlinkMacSystemFont,
+ 'Segoe UI',
+ sans-serif;
+ font-size: 12px;
+
+ td {
+ padding: 8px 10px;
+ border: 1px solid var(--npd-g200);
+ vertical-align: top;
+ color: var(--npd-g900);
+ }
+
+ td:nth-child(odd) {
+ background: var(--npd-g50);
+ color: var(--npd-g500);
+ font-weight: 700;
+ font-size: 10.5px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ width: 22%;
+ }
+
+ .mono {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ }
+
+ .npd-summary-total td {
+ background: var(--npd-blue-l);
+ color: var(--npd-blue-d);
+ font-weight: 800;
+ font-size: 13px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+
+ &:last-child {
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+ }
+ }
+
+ .tone-urgent &,
+ .tone-legal & {
+ .npd-summary-total td {
+ background: var(--npd-red-l);
+ color: var(--npd-red);
+ }
+ }
+}
+
+/* ───────── Sign-off ───────── */
+.npd-signoff {
+ margin-top: 24px;
+ font-size: 13px;
+ color: var(--npd-g900);
+}
+
+.npd-signature {
+ display: flex;
+ margin-top: 38px;
+}
+
+.npd-sig-line {
+ width: 220px;
+ border-top: 1px solid var(--npd-g500);
+ margin-bottom: 6px;
+}
+
+.npd-sig-name {
+ font-size: 12.5px;
+ color: var(--npd-g900);
+}
+
+.npd-sig-role {
+ font-size: 11px;
+ color: var(--npd-g500);
+ font-family:
+ ui-sans-serif,
+ -apple-system,
+ BlinkMacSystemFont,
+ 'Segoe UI',
+ sans-serif;
+}
+
+.npd-footer {
+ margin-top: 30px;
+ padding-top: 12px;
+ border-top: 1px dashed var(--npd-g200);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-family:
+ ui-sans-serif,
+ -apple-system,
+ BlinkMacSystemFont,
+ 'Segoe UI',
+ sans-serif;
+ font-size: 10.5px;
+ color: var(--npd-g500);
+
+ .mono {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ }
+}
+
+.mono {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+}
+
+/* ───────── Print: show only the letter, no toolbar, no dialog chrome ───────── */
+@media print {
+ :host {
+ background: #fff;
+ }
+
+ .no-print {
+ display: none !important;
+ }
+
+ .npd-root {
+ background: #fff;
+ max-height: none;
+ overflow: visible;
+ height: auto;
+ }
+
+ .npd-page {
+ border: 0;
+ border-top: 4px solid var(--npd-blue-d);
+ margin: 0;
+ padding: 24px 32px;
+ width: 100%;
+ box-shadow: none;
+ }
+}
diff --git a/src/app/letter-mgmt/notice/notice-preview-dialog/notice-preview-dialog.component.ts b/src/app/letter-mgmt/notice/notice-preview-dialog/notice-preview-dialog.component.ts
new file mode 100644
index 000000000..3641b8920
--- /dev/null
+++ b/src/app/letter-mgmt/notice/notice-preview-dialog/notice-preview-dialog.component.ts
@@ -0,0 +1,159 @@
+/**
+ * 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 { CommonModule } from '@angular/common';
+import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
+import { MatTooltip } from '@angular/material/tooltip';
+import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+
+import { downloadNoticePdf, NoticeEvent, NoticeStage, OverdueLoan } from '../notice.component';
+
+export interface NoticePreviewDialogData {
+ loan: OverdueLoan;
+ notice: NoticeEvent;
+}
+
+interface LetterCopy {
+ subject: string;
+ salutation: string;
+ intro: string;
+ body: string[];
+ callToAction: string;
+ consequences: string;
+ signOff: string;
+ tone: 'polite' | 'firm' | 'urgent' | 'legal';
+}
+
+@Component({
+ selector: 'mifosx-notice-preview-dialog',
+ templateUrl: './notice-preview-dialog.component.html',
+ styleUrls: ['./notice-preview-dialog.component.scss'],
+ imports: [
+ ...STANDALONE_SHARED_IMPORTS,
+ CommonModule,
+ FaIconComponent,
+ MatTooltip
+ ]
+})
+export class NoticePreviewDialogComponent {
+ private dialogRef = inject(MatDialogRef);
+ data: NoticePreviewDialogData = inject(MAT_DIALOG_DATA);
+
+ readonly bankName = 'Nexora Co-operative Bank Ltd.';
+ readonly bankAddress = 'Head Office, 4th Floor, Nexora Tower, Mumbai 400051';
+ readonly bankRegNo = 'RBI Lic. No. NX-2018/MH/482';
+ readonly bankContact = 'recovery@nexorabank.in · 022 6789 0000';
+
+ /** The letter body adapts to the notice stage — polite reminder up to legal demand. */
+ get letter(): LetterCopy {
+ return buildLetterCopy(this.data.loan, this.data.notice);
+ }
+
+ close(): void {
+ this.dialogRef.close();
+ }
+
+ printNotice(): void {
+ window.print();
+ }
+
+ /** Builds and saves the notice as a PDF using the shared jsPDF generator. */
+ downloadPdf(): void {
+ downloadNoticePdf(this.data.loan, this.data.notice);
+ }
+
+ stageLabel = stageLabel;
+}
+
+function stageLabel(s: NoticeStage): string {
+ return s === 'FIRST'
+ ? '1st Reminder Notice'
+ : s === 'SECOND'
+ ? '2nd Reminder Notice'
+ : s === 'FINAL'
+ ? 'Final Demand Notice'
+ : 'Legal Notice (Pre-Litigation)';
+}
+
+function buildLetterCopy(loan: OverdueLoan, notice: NoticeEvent): LetterCopy {
+ const overdue = `Rs. ${loan.overdueTotal.toLocaleString('en-IN')}`;
+ const lastPaid = loan.lastPaidDate;
+
+ switch (notice.stage) {
+ case 'FIRST':
+ return {
+ tone: 'polite',
+ subject: `Reminder — Overdue dues on Loan A/c ${loan.accountNo}`,
+ salutation: `Dear ${loan.customerName},`,
+ intro:
+ 'We refer to your captioned loan account with our bank. Our records indicate that your account has fallen into arrears as detailed below.',
+ body: [
+ `This is a gentle reminder requesting you to regularise the account at the earliest. The overdue amount of ${overdue} (as on date) has remained unpaid since your last payment on ${lastPaid}.`,
+ 'Maintaining a healthy repayment record helps preserve your credit score and avoids additional penal interest. If you have already made the payment in the last 24 hours, kindly disregard this notice.'
+ ],
+ callToAction: `Action Requested: Please remit the total dues of ${overdue} within 7 days from the date of this notice through any of our accepted channels — branch deposit, NEFT/RTGS, IMPS, or UPI.`,
+ consequences:
+ 'Failure to clear the dues may attract additional penal interest charges in accordance with the sanction terms and may lead to escalation to the next stage of recovery action.',
+ signOff: 'Yours sincerely,'
+ };
+
+ case 'SECOND':
+ return {
+ tone: 'firm',
+ subject: `Second Reminder — Overdue dues on Loan A/c ${loan.accountNo}`,
+ salutation: `Dear ${loan.customerName},`,
+ intro:
+ 'Despite our earlier reminder, our records show that the dues in your captioned loan account remain unsettled.',
+ body: [
+ `The overdue amount of ${overdue} continues to remain outstanding. Your last payment was received on ${lastPaid}, after which no further repayment has been credited to the account.`,
+ 'We urge you to treat this matter with utmost urgency and clear the dues immediately to avoid further action on the account.'
+ ],
+ callToAction: `Action Required: Kindly regularise the account by paying ${overdue} within 7 days from this notice. If you have already made the payment, please share the UTR / transaction reference with our recovery team so we can update the records.`,
+ consequences:
+ 'Continued non-payment may result in your account being classified as a Special Mention Account (SMA) per RBI guidelines, reported to credit bureaus (CIBIL/Experian/Equifax/CRIF), and escalated for further recovery action including issuance of a final demand notice.',
+ signOff: 'Regards,'
+ };
+
+ case 'FINAL':
+ return {
+ tone: 'urgent',
+ subject: `FINAL DEMAND NOTICE — Loan A/c ${loan.accountNo}`,
+ salutation: `Dear ${loan.customerName},`,
+ intro:
+ 'This communication serves as the FINAL DEMAND NOTICE in respect of your captioned loan account, which continues to remain in default despite our prior reminders.',
+ body: [
+ `As on date, an amount of ${overdue} is overdue and payable. Your last payment was received on ${lastPaid}. No payments have been credited since then.`,
+ 'You are hereby called upon to clear the entire overdue amount along with applicable penal interest within 15 days from the date of this notice.'
+ ],
+ callToAction: `Action Required: Payment of ${overdue} (plus applicable penal interest accrued up to the date of payment) must reach the bank within 15 days from receipt of this notice.`,
+ consequences:
+ 'Failure to comply within the stipulated period shall constrain the bank, without further reference to you, to (i) classify your account as a Non-Performing Asset (NPA) per RBI norms, (ii) report the same to all credit information companies, and (iii) initiate appropriate legal and recovery proceedings, including action under the SARFAESI Act, 2002 / RDDB Act, 1993 where applicable, with all costs and consequences thereof being recoverable from you.',
+ signOff: 'For and on behalf of the bank,'
+ };
+
+ case 'LEGAL':
+ default:
+ return {
+ tone: 'legal',
+ subject: `LEGAL NOTICE — Loan A/c ${loan.accountNo} — Demand for Payment`,
+ salutation: `To ${loan.customerName},`,
+ intro:
+ 'Take notice that the bank, through its undersigned authorised officer, hereby serves upon you this Legal Notice in respect of the captioned loan account.',
+ body: [
+ `That you availed credit facilities from the bank vide Loan Account No. ${loan.accountNo}. That as on the date of this notice, a sum of ${overdue} is outstanding and payable by you to the bank, having remained unpaid since ${lastPaid}, in spite of repeated reminders and demands made by the bank.`,
+ 'That your continued default constitutes a breach of the sanction terms and loan agreement entered into between you and the bank, and the entire outstanding has become recallable forthwith.'
+ ],
+ callToAction: `You are hereby called upon to pay to the bank a sum of ${overdue} together with further interest, penal charges, and costs, within 15 days from receipt of this notice, failing which the bank shall be constrained to initiate appropriate legal proceedings against you, including but not limited to proceedings under the SARFAESI Act, 2002, the Negotiable Instruments Act, 1881, and / or recovery proceedings before the Debts Recovery Tribunal (DRT), entirely at your risk, cost, and consequences.`,
+ consequences:
+ 'Please treat this notice with the seriousness it deserves. The bank reserves the right to take any and all such steps as may be advised in law, without any further reference to you, in the event of your continued default. A copy of this notice is being retained for legal record.',
+ signOff: 'Authorised Officer,'
+ };
+ }
+}
diff --git a/src/app/letter-mgmt/notice/notice.component.html b/src/app/letter-mgmt/notice/notice.component.html
new file mode 100644
index 000000000..7819067b2
--- /dev/null
+++ b/src/app/letter-mgmt/notice/notice.component.html
@@ -0,0 +1,371 @@
+
+
+
+
+
+
+
+
+
Loan Overdue Notices
+
+ Generate, dispatch, and audit notices for accounts with overdue dues. Bulk operations supported.
+
+
+
+
+
+
+
+
+
+
+
{{ totalOverdueAccounts | number }}
+
Overdue Accounts
+
+
+
+
+
+
₹{{ totalOverdueAmount | number: '1.0-0' }}
+
Total Overdue Value
+
+
+
+
+
+
{{ noticesGeneratedCount | number }}
+
Notices Generated
+
+
+
+
+
+
{{ noticesPendingGenCount | number }}
+
Pending Generation
+
+
+
+
+
+
+
+
Account number / Customer
+
+
+
+
+
+
+ Account type
+
+ All types
+ @for (o of accountTypeOptions; track o.value) {
+ {{ o.label }}
+ }
+
+
+
+
+
+ @if (search || accountTypeFilter || startDate || endDate || asOnDate) {
+
Clear
+ }
+
+
+
+ @if (selectedCount > 0) {
+
+
+
+ {{ selectedCount }} selected
+
+
+
+ Generate notices
+
+
+ Bulk email
+
+
+ Bulk post
+
+
+ Email + Post
+
+
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Customer · A/C
+ Type
+ Branch
+ Overdue
+ DPD
+ Last Paid
+ Notice Status
+ Last Sent
+ Actions
+
+
+
+ @for (loan of filteredLoans; track loan.accountNo) {
+
+
+
+
+
+ {{ loan.customerName }}
+ {{ loan.accountNo }} · {{ loan.customerId }}
+
+
+ {{ accountTypeLabel(loan.accountType) }}
+
+ {{ loan.branch }}
+
+ ₹{{ loan.overdueTotal | number: '1.0-0' }}
+
+ P ₹{{ loan.overduePrincipal | number: '1.0-0' }} · I ₹{{ loan.overdueInterest | number: '1.0-0' }}
+
+
+
+
+ {{ loan.daysOverdue }} d
+
+
+ {{ loan.lastPaidDate | date: 'd MMM y' }}
+
+ @switch (noticeStatus(loan)) {
+ @case ('NONE') {
+
+ Not Generated
+
+ }
+ @case ('GENERATED') {
+
+
+ {{ stageLabel(latestNotice(loan)!.stage) }} · Awaiting Send
+
+ }
+ @case ('SENT') {
+
+
+ {{ stageLabel(latestNotice(loan)!.stage) }} · Sent ({{ totalSendCount(loan) }})
+
+ }
+ }
+
+
+ @if (latestNotice(loan) && latestNotice(loan)!.sends.length > 0) {
+
+
+ {{ channelLabel(latestNotice(loan)!.sends[0].channel) }}
+
+ {{ latestNotice(loan)!.sends[0].sentAt }}
+ } @else {
+ —
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ @if (filteredLoans.length === 0) {
+
+
+
+ No overdue accounts match the current filters.
+
+
+ }
+
+
+
+
+
diff --git a/src/app/letter-mgmt/notice/notice.component.scss b/src/app/letter-mgmt/notice/notice.component.scss
new file mode 100644
index 000000000..527dc7c1b
--- /dev/null
+++ b/src/app/letter-mgmt/notice/notice.component.scss
@@ -0,0 +1,1028 @@
+/**
+ * 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/.
+ */
+
+:host {
+ --ntc-blue: #1d4ed8;
+ --ntc-blue-d: #1e40af;
+ --ntc-blue-l: #eff6ff;
+ --ntc-blue-tint: #dbeafe;
+ --ntc-teal: #0d9488;
+ --ntc-teal-l: #ccfbf1;
+ --ntc-green: #15803d;
+ --ntc-green-d: #166534;
+ --ntc-green-l: #dcfce7;
+ --ntc-amber: #d97706;
+ --ntc-amber-l: #fef3c7;
+ --ntc-red: #b91c1c;
+ --ntc-red-l: #fee2e2;
+ --ntc-red-tint: #fecaca;
+ --ntc-purple: #6d28d9;
+ --ntc-purple-l: #ede9fe;
+ --ntc-g50: #f8fafc;
+ --ntc-g100: #f1f5f9;
+ --ntc-g200: #e2e8f0;
+ --ntc-g300: #cbd5e1;
+ --ntc-g500: #64748b;
+ --ntc-g700: #334155;
+ --ntc-g900: #0f172a;
+
+ display: block;
+}
+
+.ntc-root {
+ padding: 20px 24px 32px;
+ max-width: 1340px;
+ margin: 0 auto;
+}
+
+/* ───────── Header ───────── */
+.ntc-head {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 18px 22px;
+ border-radius: 14px;
+ background: linear-gradient(135deg, var(--ntc-blue-l) 0%, #fff 65%);
+ border: 1px solid var(--ntc-blue-tint);
+ margin-bottom: 16px;
+}
+
+.ntc-head-l {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+}
+
+.ntc-head-ic {
+ width: 48px;
+ height: 48px;
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 14px;
+ background: var(--ntc-blue-d);
+ color: #fff;
+ font-size: 20px;
+}
+
+.ntc-head-t {
+ margin: 0 0 3px;
+ font-size: 17px;
+ font-weight: 800;
+ color: var(--ntc-g900);
+}
+
+.ntc-head-s {
+ font-size: 12.5px;
+ color: var(--ntc-g500);
+ line-height: 1.5;
+}
+
+/* ───────── KPI strip ───────── */
+.ntc-kpis {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 10px;
+ margin-bottom: 14px;
+
+ @media (width <= 900px) {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+.ntc-kpi {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 14px;
+ border-radius: 12px;
+ background: #fff;
+ border: 1px solid var(--ntc-g200);
+
+ &--blue {
+ background: var(--ntc-blue-l);
+ border-color: var(--ntc-blue-tint);
+ }
+
+ &--red {
+ background: var(--ntc-red-l);
+ border-color: var(--ntc-red-tint);
+ }
+
+ &--green {
+ background: var(--ntc-green-l);
+ border-color: #bbf7d0;
+ }
+
+ &--amber {
+ background: var(--ntc-amber-l);
+ border-color: #fde68a;
+ }
+}
+
+.ntc-kpi-ic {
+ width: 36px;
+ height: 36px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 10px;
+ background: rgb(255 255 255 / 70%);
+ color: var(--ntc-g900);
+ font-size: 16px;
+
+ .ntc-kpi--blue & {
+ color: var(--ntc-blue);
+ }
+
+ .ntc-kpi--red & {
+ color: var(--ntc-red);
+ }
+
+ .ntc-kpi--green & {
+ color: var(--ntc-green-d);
+ }
+
+ .ntc-kpi--amber & {
+ color: var(--ntc-amber);
+ }
+}
+
+.ntc-kpi-n {
+ font-size: 17px;
+ font-weight: 800;
+ color: var(--ntc-g900);
+ font-variant-numeric: tabular-nums;
+ line-height: 1.15;
+}
+
+.ntc-kpi-l {
+ font-size: 10.5px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: var(--ntc-g500);
+ margin-top: 2px;
+}
+
+/* ───────── Filters ───────── */
+.ntc-filters {
+ display: flex;
+ align-items: flex-end;
+ gap: 12px;
+ padding: 14px;
+ background: #fff;
+ border: 1px solid var(--ntc-g200);
+ border-radius: 12px;
+ margin-bottom: 12px;
+ flex-wrap: wrap;
+}
+
+.ntc-filter {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ flex: 1 1 0;
+ min-width: 220px;
+ min-height: 0;
+
+ /* Each filter column is its own positioning context — keeps the search icon
+ (absolutely positioned inside .ntc-input-ic) firmly inside its own column. */
+ position: relative;
+ overflow: visible;
+
+ label {
+ font-size: 10.5px;
+ font-weight: 700;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ color: var(--ntc-g500);
+ }
+
+ select,
+ input {
+ box-sizing: border-box;
+ width: 100%;
+ height: 36px;
+ padding: 0 12px;
+ border: 1px solid var(--ntc-g200);
+ border-radius: 8px;
+ background: #fff;
+ font-size: 13px;
+ color: var(--ntc-g900);
+ outline: none;
+ transition: border-color 0.15s ease;
+ appearance: none;
+
+ &:focus {
+ border-color: var(--ntc-blue);
+ }
+ }
+
+ /* Custom chevron for selects — the native one wasn't reliably padded, which
+ caused option text to be visually clipped behind it on some Chrome builds. */
+ select {
+ padding-right: 34px;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8' fill='none' stroke='%2364748b' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='1 2 6 7 11 2'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 12px center;
+ background-size: 12px 8px;
+ cursor: pointer;
+ }
+}
+
+.ntc-input-ic {
+ position: relative;
+ display: block;
+ width: 100%;
+
+ fa-icon {
+ position: absolute;
+ left: 11px;
+ top: 50%;
+ transform: translateY(-50%);
+ color: var(--ntc-g500);
+ font-size: 12px;
+ pointer-events: none;
+ z-index: 1;
+ }
+
+ input {
+ width: 100%;
+ padding-left: 32px;
+ }
+}
+
+.ntc-filter--date {
+ flex: 1.4;
+ min-width: 300px;
+}
+
+/* Header row: column label on the left, mode toggle on the right.
+ Same vertical footprint as other filters' label row → no taller column,
+ no toggle leaking into adjacent columns. */
+.ntc-date-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ min-height: 16px;
+
+ label {
+ font-size: 10.5px;
+ font-weight: 700;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ color: var(--ntc-g500);
+ margin: 0;
+ }
+}
+
+.ntc-date-toggle {
+ display: inline-flex;
+ background: var(--ntc-g100);
+ border: 1px solid var(--ntc-g200);
+ border-radius: 999px;
+ padding: 2px;
+
+ button {
+ border: 0;
+ background: transparent;
+ padding: 2px 10px;
+ font-size: 10.5px;
+ font-weight: 700;
+ letter-spacing: 0.03em;
+ color: var(--ntc-g500);
+ border-radius: 999px;
+ cursor: pointer;
+ transition:
+ background 0.15s,
+ color 0.15s;
+
+ &.is-on {
+ background: #fff;
+ color: var(--ntc-blue-d);
+ box-shadow: 0 1px 3px rgb(15 23 42 / 12%);
+ }
+ }
+}
+
+.ntc-date-row {
+ display: flex;
+ gap: 8px;
+
+ > input {
+ flex: 1;
+ min-width: 0;
+ }
+}
+
+.ntc-clear {
+ height: 36px;
+ padding: 0 14px;
+ border: 1px solid var(--ntc-g200);
+ border-radius: 8px;
+ background: #fff;
+ color: var(--ntc-g700);
+ font-size: 12px;
+ font-weight: 700;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+
+ &:hover {
+ background: var(--ntc-red-l);
+ color: var(--ntc-red);
+ border-color: var(--ntc-red-tint);
+ }
+}
+
+/* ───────── Bulk action bar ───────── */
+.ntc-bulk-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 14px;
+ padding: 10px 16px;
+ background: var(--ntc-blue-d);
+ color: #fff;
+ border-radius: 12px;
+ margin-bottom: 12px;
+ flex-wrap: wrap;
+}
+
+.ntc-bulk-l {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 13px;
+ font-weight: 600;
+
+ strong {
+ font-size: 15px;
+ }
+}
+
+.ntc-bulk-r {
+ display: inline-flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.ntc-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 7px 14px;
+ border-radius: 8px;
+ border: 1px solid transparent;
+ font-size: 12.5px;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ cursor: pointer;
+ transition:
+ background 0.15s,
+ color 0.15s,
+ border-color 0.15s;
+
+ fa-icon {
+ font-size: 12px;
+ }
+}
+
+.ntc-btn-primary {
+ background: #fff;
+ color: var(--ntc-blue-d);
+
+ &:hover {
+ background: var(--ntc-blue-l);
+ }
+}
+
+.ntc-btn-secondary {
+ background: rgb(255 255 255 / 14%);
+ color: #fff;
+ border-color: rgb(255 255 255 / 24%);
+
+ &:hover {
+ background: rgb(255 255 255 / 22%);
+ }
+}
+
+/* ───────── Table card ───────── */
+.ntc-card {
+ background: #fff;
+ border: 1px solid var(--ntc-g200);
+ border-radius: 12px;
+ overflow: hidden;
+}
+
+.ntc-table-wrap {
+ overflow-x: visible;
+}
+
+.ntc-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12.5px;
+
+ /* Default to wrapping content — only force nowrap on specific columns below
+ so the table fits the viewport without a horizontal scrollbar. */
+ white-space: normal;
+
+ th {
+ text-align: left;
+ padding: 12px;
+ background: var(--ntc-g50);
+ color: var(--ntc-g500);
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ font-size: 10.5px;
+ border-bottom: 1px solid var(--ntc-g200);
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ }
+
+ td {
+ padding: 12px;
+ border-bottom: 1px solid var(--ntc-g100);
+ vertical-align: top;
+ color: var(--ntc-g900);
+ }
+
+ tr:last-child td {
+ border-bottom: 0;
+ }
+
+ tr.is-selected td {
+ background: var(--ntc-blue-l);
+ }
+
+ .num {
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+ }
+
+ .mono {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ }
+
+ .small {
+ font-size: 11.5px;
+ }
+
+ .muted {
+ color: var(--ntc-g500);
+ }
+
+ .col-sel {
+ width: 36px;
+ text-align: center;
+ white-space: nowrap;
+ }
+
+ .col-actions {
+ width: 84px;
+ white-space: nowrap;
+ text-align: right;
+ }
+
+ /* Keep monospace IDs, currency, DPD pills, and dates on one line — these are
+ short cells that look odd if they wrap. */
+ .mono,
+ .num {
+ white-space: nowrap;
+ }
+
+ // stylelint-disable-next-line selector-no-qualifying-type
+ input[type='checkbox'] {
+ width: 14px;
+ height: 14px;
+ accent-color: var(--ntc-blue);
+ cursor: pointer;
+ }
+}
+
+.ntc-cust-name {
+ font-weight: 700;
+ color: var(--ntc-g900);
+}
+
+.ntc-amount {
+ font-weight: 800;
+ color: var(--ntc-red);
+}
+
+/* Pills */
+.ntc-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 3px 10px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ background: var(--ntc-g100);
+ color: var(--ntc-g700);
+ white-space: nowrap;
+
+ fa-icon {
+ font-size: 10.5px;
+ }
+
+ &--type {
+ background: var(--ntc-purple-l);
+ color: var(--ntc-purple);
+ }
+
+ &--blue {
+ background: var(--ntc-blue-l);
+ color: var(--ntc-blue-d);
+ }
+
+ &--green {
+ background: var(--ntc-green-l);
+ color: var(--ntc-green-d);
+ }
+
+ &--amber {
+ background: var(--ntc-amber-l);
+ color: var(--ntc-amber);
+ }
+
+ &--red {
+ background: var(--ntc-red-l);
+ color: var(--ntc-red);
+ }
+}
+
+.ntc-dpd-pill {
+ display: inline-block;
+ padding: 3px 10px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 800;
+ letter-spacing: 0.02em;
+ font-variant-numeric: tabular-nums;
+
+ &--mild {
+ background: var(--ntc-amber-l);
+ color: var(--ntc-amber);
+ }
+
+ &--severe {
+ background: #ffedd5;
+ color: #c2410c;
+ }
+
+ &--critical {
+ background: var(--ntc-red-l);
+ color: var(--ntc-red);
+ }
+}
+
+.ntc-last-send {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-weight: 700;
+ color: var(--ntc-g700);
+
+ fa-icon {
+ color: var(--ntc-blue);
+ }
+}
+
+/* Row action icon buttons */
+.ntc-row-actions {
+ display: inline-flex;
+ gap: 4px;
+}
+
+/* The kebab "More actions" trigger — slightly narrower than the icon buttons
+ beside it since the 3-dot glyph is already vertical. */
+.ntc-more-btn {
+ width: 26px;
+
+ fa-icon {
+ font-size: 14px;
+ }
+
+ &[aria-expanded='true'] {
+ background: var(--ntc-blue-l);
+ color: var(--ntc-blue-d);
+ border-color: var(--ntc-blue-tint);
+ }
+}
+
+.ntc-icon-btn {
+ width: 30px;
+ height: 30px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid var(--ntc-g200);
+ background: #fff;
+ color: var(--ntc-g500);
+ border-radius: 8px;
+ cursor: pointer;
+ font-size: 11.5px;
+ transition:
+ background 0.15s,
+ color 0.15s,
+ border-color 0.15s;
+
+ &:hover:not(:disabled) {
+ background: var(--ntc-blue-l);
+ color: var(--ntc-blue-d);
+ border-color: var(--ntc-blue-tint);
+ }
+
+ &:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+ }
+
+ &.primary {
+ background: var(--ntc-blue-d);
+ color: #fff;
+ border-color: var(--ntc-blue-d);
+
+ &:hover:not(:disabled) {
+ background: var(--ntc-blue);
+ border-color: var(--ntc-blue);
+ }
+ }
+}
+
+.ntc-empty {
+ text-align: center;
+ padding: 28px 16px;
+ color: var(--ntc-g500);
+ font-size: 13px;
+
+ fa-icon {
+ margin-right: 6px;
+ color: var(--ntc-blue);
+ }
+}
+
+/* ───────── Row "More actions" menu (mat-menu overlay) ─────────
+ mat-menu renders into the CDK overlay outside this component's view
+ encapsulation, so every selector for the menu interior is scoped via
+ `::ng-deep .ntc-actions-menu` to make sure the styles actually land.
+*/
+::ng-deep .ntc-actions-menu.mat-mdc-menu-panel {
+ min-width: 480px;
+ max-width: 520px;
+ border-radius: 14px !important;
+ overflow: hidden;
+ box-shadow:
+ 0 14px 32px -10px rgb(15 23 42 / 28%),
+ 0 4px 12px -4px rgb(15 23 42 / 14%) !important;
+}
+
+::ng-deep .ntc-actions-menu .mat-mdc-menu-content {
+ padding: 0 !important;
+ max-height: none;
+}
+
+/* ── Header block (identity / facts / hero) — slimmed down ── */
+::ng-deep .ntc-actions-menu .nmh {
+ padding: 12px 14px 10px;
+ background: linear-gradient(180deg, #f8fafc 0%, #fff 100%);
+ border-bottom: 1px solid #e2e8f0;
+ cursor: default;
+ font-family: var(--mat-app-body-large-font, inherit);
+}
+
+/* identity row: avatar + name + type pill */
+::ng-deep .ntc-actions-menu .nmh-id {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 8px;
+}
+
+::ng-deep .ntc-actions-menu .nmh-avatar {
+ width: 38px;
+ height: 38px;
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ font-size: 15px;
+ font-weight: 800;
+ letter-spacing: -0.02em;
+ color: #fff;
+ background: #1e40af;
+ box-shadow: 0 0 0 3px rgb(30 64 175 / 14%);
+
+ /* avatar tint = DPD severity (mild blue → severe amber → critical red) */
+ &.nmh-avatar--mild {
+ background: #1e40af;
+ box-shadow: 0 0 0 3px rgb(30 64 175 / 14%);
+ }
+
+ &.nmh-avatar--severe {
+ background: #c2410c;
+ box-shadow: 0 0 0 3px rgb(194 65 12 / 14%);
+ }
+
+ &.nmh-avatar--critical {
+ background: #b91c1c;
+ box-shadow: 0 0 0 3px rgb(185 28 28 / 16%);
+ }
+}
+
+::ng-deep .ntc-actions-menu .nmh-id-text {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ line-height: 1.2;
+}
+
+::ng-deep .ntc-actions-menu .nmh-name {
+ font-size: 14px;
+ font-weight: 800;
+ color: #0f172a;
+ word-break: break-word;
+}
+
+::ng-deep .ntc-actions-menu .nmh-acc {
+ font-size: 11.5px;
+ font-weight: 600;
+ color: #475569;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ letter-spacing: 0.02em;
+}
+
+::ng-deep .ntc-actions-menu .nmh-type {
+ flex-shrink: 0;
+ align-self: flex-start;
+ padding: 3px 9px;
+ border-radius: 999px;
+ font-size: 10px;
+ font-weight: 800;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ background: #ede9fe;
+ color: #6d28d9;
+ white-space: nowrap;
+}
+
+/* facts grid (Customer ID + Branch) */
+::ng-deep .ntc-actions-menu .nmh-facts {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 2px 14px;
+ margin-bottom: 8px;
+}
+
+::ng-deep .ntc-actions-menu .nmh-fact {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ min-width: 0;
+}
+
+::ng-deep .ntc-actions-menu .nmh-fact-l {
+ font-size: 9.5px;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: #64748b;
+}
+
+::ng-deep .ntc-actions-menu .nmh-fact-v {
+ font-size: 11.5px;
+ font-weight: 600;
+ color: #0f172a;
+ line-height: 1.25;
+ word-break: break-word;
+
+ &.mono {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ }
+}
+
+/* Hero strip: prominent overdue value + DPD badge */
+::ng-deep .ntc-actions-menu .nmh-hero {
+ display: flex;
+ align-items: stretch;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 8px 12px;
+ border-radius: 10px;
+ border: 1px solid;
+
+ &.nmh-hero--mild {
+ background: #eff6ff;
+ border-color: #dbeafe;
+ }
+
+ &.nmh-hero--severe {
+ background: #fff7ed;
+ border-color: #fed7aa;
+ }
+
+ &.nmh-hero--critical {
+ background: #fef2f2;
+ border-color: #fecaca;
+ }
+}
+
+::ng-deep .ntc-actions-menu .nmh-overdue {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ flex: 1;
+ min-width: 0;
+}
+
+::ng-deep .ntc-actions-menu .nmh-overdue-l {
+ font-size: 9.5px;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: #64748b;
+}
+
+::ng-deep .ntc-actions-menu .nmh-overdue-v {
+ font-size: 17px;
+ font-weight: 800;
+ color: #b91c1c;
+ font-variant-numeric: tabular-nums;
+ line-height: 1.1;
+ letter-spacing: -0.01em;
+
+ .nmh-hero--mild & {
+ color: #1e40af;
+ }
+
+ .nmh-hero--severe & {
+ color: #c2410c;
+ }
+}
+
+::ng-deep .ntc-actions-menu .nmh-dpd {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 4px 12px;
+ border-radius: 8px;
+ background: rgb(255 255 255 / 72%);
+ border: 1px solid rgb(255 255 255 / 90%);
+ min-width: 64px;
+
+ .nmh-hero--mild & {
+ background: #fff;
+ border-color: #c7d2fe;
+ }
+
+ .nmh-hero--severe & {
+ background: #fff;
+ border-color: #fed7aa;
+ }
+
+ .nmh-hero--critical & {
+ background: #fff;
+ border-color: #fecaca;
+ }
+}
+
+::ng-deep .ntc-actions-menu .nmh-dpd-v {
+ font-size: 18px;
+ font-weight: 800;
+ line-height: 1;
+ font-variant-numeric: tabular-nums;
+ letter-spacing: -0.02em;
+
+ .nmh-hero--mild & {
+ color: #1e40af;
+ }
+
+ .nmh-hero--severe & {
+ color: #c2410c;
+ }
+
+ .nmh-hero--critical & {
+ color: #b91c1c;
+ }
+}
+
+::ng-deep .ntc-actions-menu .nmh-dpd-l {
+ font-size: 8.5px;
+ font-weight: 700;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ color: #64748b;
+ margin-top: 1px;
+}
+
+/* ── Action grid sections ── */
+::ng-deep .ntc-actions-menu .nma-section {
+ padding: 8px 12px 0;
+
+ &:last-child {
+ padding-bottom: 10px;
+ }
+}
+
+::ng-deep .ntc-actions-menu .nma-section-l {
+ padding: 4px 4px 6px;
+ font-size: 9.5px;
+ font-weight: 800;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: #94a3b8;
+ cursor: default;
+ user-select: none;
+}
+
+::ng-deep .ntc-actions-menu .nma-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 6px;
+}
+
+::ng-deep .ntc-actions-menu .nma-tile {
+ display: inline-flex;
+ align-items: center;
+ gap: 9px;
+ padding: 8px 11px;
+ border: 1px solid #e2e8f0;
+ border-radius: 9px;
+ background: #fff;
+ color: #334155;
+ font-size: 12.5px;
+ font-weight: 600;
+ text-align: left;
+ cursor: pointer;
+ width: 100%;
+ min-height: 38px;
+ transition:
+ background 0.15s ease,
+ border-color 0.15s ease,
+ color 0.15s ease,
+ transform 0.12s ease;
+
+ &:hover:not(:disabled) {
+ background: #eff6ff;
+ border-color: #c7d2fe;
+ color: #1e40af;
+ transform: translateY(-1px);
+
+ .nma-tile-ic {
+ color: #1e40af;
+ }
+ }
+
+ &:active:not(:disabled) {
+ transform: translateY(0);
+ }
+
+ &:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+
+ .nma-tile-ic {
+ color: #cbd5e1;
+ }
+ }
+
+ &:focus-visible {
+ outline: 2px solid #1d4ed8;
+ outline-offset: 2px;
+ }
+}
+
+::ng-deep .ntc-actions-menu .nma-tile-ic {
+ width: 16px;
+ font-size: 13px;
+ color: #1d4ed8;
+ flex-shrink: 0;
+ text-align: center;
+ transition: color 0.15s ease;
+}
+
+::ng-deep .ntc-actions-menu .nma-tile-l {
+ flex: 1;
+ min-width: 0;
+ line-height: 1.2;
+ letter-spacing: 0.01em;
+}
diff --git a/src/app/letter-mgmt/notice/notice.component.ts b/src/app/letter-mgmt/notice/notice.component.ts
new file mode 100644
index 000000000..65d899b67
--- /dev/null
+++ b/src/app/letter-mgmt/notice/notice.component.ts
@@ -0,0 +1,1526 @@
+/**
+ * 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, OnInit } 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 { MatMenu, MatMenuContent, MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
+import { MatSnackBar } from '@angular/material/snack-bar';
+import { MatTooltip } from '@angular/material/tooltip';
+import { jsPDF } from 'jspdf';
+import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+
+import {
+ NoticeHistoryDialogComponent,
+ NoticeHistoryDialogData
+} from './notice-history-dialog/notice-history-dialog.component';
+import {
+ NoticePreviewDialogComponent,
+ NoticePreviewDialogData
+} from './notice-preview-dialog/notice-preview-dialog.component';
+import { CallContactDialogComponent, CallContactDialogData } from './call-contact-dialog/call-contact-dialog.component';
+import {
+ SpeedPostDialogComponent,
+ SpeedPostDialogData,
+ SpeedPostDialogResult
+} from './speed-post-dialog/speed-post-dialog.component';
+
+export type LoanAccountType = 'HOUSING' | 'PERSONAL' | 'AUTO' | 'GOLD' | 'EDUCATION' | 'BUSINESS' | 'CC' | 'OD';
+
+export type NoticeChannel = 'EMAIL' | 'POST' | 'COURIER' | 'SMS' | 'WHATSAPP' | 'IN_PERSON';
+export type NoticeStage = 'FIRST' | 'SECOND' | 'FINAL' | 'LEGAL';
+export type SendStatus = 'DELIVERED' | 'PENDING' | 'FAILED' | 'BOUNCED';
+
+export interface SendEvent {
+ channel: NoticeChannel;
+ sentAt: string; // YYYY-MM-DD HH:mm:ss
+ sentBy: string;
+ status: SendStatus;
+ destination: string;
+ /** India Post / courier consignment / tracking number — POST + COURIER only. */
+ consignmentNo?: string;
+ /** Originating post office or courier branch — POST + COURIER only. */
+ postOffice?: string;
+ /** Booking date — may differ from sentAt if a counter-booking was made
+ and entered into the system the next day. YYYY-MM-DD. */
+ bookedDate?: string;
+ /** Postage paid in ₹ — POST + COURIER only. */
+ postage?: number;
+}
+
+export interface NoticeEvent {
+ id: string;
+ stage: NoticeStage;
+ generatedAt: string;
+ generatedBy: string;
+ sends: SendEvent[];
+}
+
+export type CallOutcome =
+ | 'PROMISE_TO_PAY'
+ | 'NOT_REACHABLE'
+ | 'SWITCHED_OFF'
+ | 'RNR'
+ | 'BUSY'
+ | 'WRONG_NUMBER'
+ | 'CALLBACK_REQUESTED'
+ | 'REFUSED'
+ | 'SETTLED';
+
+export type CallContactKind = 'PRIMARY' | 'ALTERNATIVE' | 'FAMILY' | 'NOMINEE';
+
+export interface CallLog {
+ id: string;
+ contactKind: CallContactKind;
+ contactNumber: string;
+ contactName?: string;
+ /** YYYY-MM-DD HH:mm:ss */
+ calledAt: string;
+ calledBy: string;
+ outcome: CallOutcome;
+ /** Set only when outcome === 'PROMISE_TO_PAY'. */
+ promiseAmount?: number;
+ promiseDate?: string;
+ notes?: string;
+}
+
+export interface OverdueLoan {
+ accountNo: string;
+ accountType: LoanAccountType;
+ customerId: string;
+ customerName: string;
+ branch: string;
+ email: string;
+ mobile: string;
+ /** Secondary number on file (work / spouse handset / etc.). */
+ mobileAlt?: string;
+ /** Family member contact captured during onboarding (KYC). */
+ familyName?: string;
+ familyRelation?: string;
+ familyMobile?: string;
+ /** Nominee captured for the loan account. */
+ nomineeName?: string;
+ nomineeMobile?: string;
+ address: string;
+ emiAmount: number;
+ overduePrincipal: number;
+ overdueInterest: number;
+ overdueTotal: number;
+ daysOverdue: number;
+ lastPaidDate: string;
+ /** Most recent notice events first. */
+ notices: NoticeEvent[];
+ /** Recovery-call dispositions logged by collections operators. Newest first. */
+ callLogs?: CallLog[];
+}
+
+@Component({
+ selector: 'mifosx-notice',
+ templateUrl: './notice.component.html',
+ styleUrls: ['./notice.component.scss'],
+ imports: [
+ ...STANDALONE_SHARED_IMPORTS,
+ CommonModule,
+ FormsModule,
+ FaIconComponent,
+ MatTooltip,
+ MatMenu,
+ MatMenuItem,
+ MatMenuTrigger,
+ MatMenuContent
+ ]
+})
+export class NoticeComponent implements OnInit {
+ private snack = inject(MatSnackBar);
+ private dialog = inject(MatDialog);
+
+ // ─── Filters ───
+ search = '';
+ accountTypeFilter: '' | LoanAccountType = '';
+ filterMode: 'range' | 'ason' = 'range';
+ startDate = '';
+ endDate = '';
+ asOnDate = '';
+
+ loans: OverdueLoan[] = [];
+ selected = new Set();
+
+ readonly accountTypeOptions: { value: LoanAccountType; label: string }[] = [
+ { value: 'HOUSING', label: 'Housing Loan' },
+ { value: 'PERSONAL', label: 'Personal Loan' },
+ { value: 'AUTO', label: 'Auto Loan' },
+ { value: 'GOLD', label: 'Gold Loan' },
+ { value: 'EDUCATION', label: 'Education Loan' },
+ { value: 'BUSINESS', label: 'Business Loan' },
+ { value: 'CC', label: 'Cash Credit (CC)' },
+ { value: 'OD', label: 'Overdraft (OD)' }
+ ];
+
+ ngOnInit(): void {
+ this.loans = seedOverdueLoans();
+ }
+
+ // ───────────────────── Derived state ─────────────────────
+ get filteredLoans(): OverdueLoan[] {
+ const q = this.search.trim().toLowerCase();
+ return this.loans.filter((l) => {
+ if (this.accountTypeFilter && l.accountType !== this.accountTypeFilter) return false;
+ if (!this.matchesDateFilter(l)) return false;
+ if (!q) return true;
+ return (
+ l.accountNo.toLowerCase().includes(q) ||
+ l.customerName.toLowerCase().includes(q) ||
+ l.customerId.toLowerCase().includes(q) ||
+ l.branch.toLowerCase().includes(q)
+ );
+ });
+ }
+
+ private matchesDateFilter(l: OverdueLoan): boolean {
+ if (this.filterMode === 'ason') {
+ if (!this.asOnDate) return true;
+ // "As on date" — include loans whose last-paid-date is on or before that date.
+ return l.lastPaidDate <= this.asOnDate;
+ }
+ // Range mode — include loans whose last-paid-date falls inside the range.
+ if (this.startDate && l.lastPaidDate < this.startDate) return false;
+ if (this.endDate && l.lastPaidDate > this.endDate) return false;
+ return true;
+ }
+
+ get totalOverdueAccounts(): number {
+ return this.filteredLoans.length;
+ }
+ get totalOverdueAmount(): number {
+ return this.filteredLoans.reduce((s, l) => s + l.overdueTotal, 0);
+ }
+ get noticesGeneratedCount(): number {
+ return this.filteredLoans.filter((l) => l.notices.length > 0).length;
+ }
+ get noticesPendingGenCount(): number {
+ return this.filteredLoans.filter((l) => l.notices.length === 0).length;
+ }
+
+ /** Loans never noticed = quick "pending generation" bucket. */
+ get pendingGenerationLoans(): OverdueLoan[] {
+ return this.filteredLoans.filter((l) => l.notices.length === 0);
+ }
+
+ // ───────────────────── Selection ─────────────────────
+ toggleSelect(accountNo: string, checked: boolean): void {
+ if (checked) this.selected.add(accountNo);
+ else this.selected.delete(accountNo);
+ }
+
+ isSelected(accountNo: string): boolean {
+ return this.selected.has(accountNo);
+ }
+
+ toggleSelectAll(checked: boolean): void {
+ if (checked) {
+ this.filteredLoans.forEach((l) => this.selected.add(l.accountNo));
+ } else {
+ this.filteredLoans.forEach((l) => this.selected.delete(l.accountNo));
+ }
+ }
+
+ get allOnPageSelected(): boolean {
+ const visible = this.filteredLoans;
+ return visible.length > 0 && visible.every((l) => this.selected.has(l.accountNo));
+ }
+
+ get someOnPageSelected(): boolean {
+ const visible = this.filteredLoans;
+ return visible.some((l) => this.selected.has(l.accountNo)) && !this.allOnPageSelected;
+ }
+
+ get selectedCount(): number {
+ return this.selected.size;
+ }
+
+ // ───────────────────── Actions ─────────────────────
+ clearFilters(): void {
+ this.search = '';
+ this.accountTypeFilter = '';
+ this.startDate = '';
+ this.endDate = '';
+ this.asOnDate = '';
+ }
+
+ setFilterMode(mode: 'range' | 'ason'): void {
+ this.filterMode = mode;
+ }
+
+ /**
+ * Stamp a new generated-notice event onto the given loan. Stage is chosen
+ * automatically based on prior history (1st → 2nd → Final → Legal).
+ */
+ generateNotice(loan: OverdueLoan): void {
+ const stage = this.nextStage(loan);
+ const now = this.nowStr();
+ loan.notices = [
+ {
+ id: 'NTC' + Date.now().toString().slice(-9),
+ stage,
+ generatedAt: now,
+ generatedBy: 'Current User',
+ sends: []
+ },
+ ...loan.notices
+ ];
+ this.snack.open(`${this.stageLabel(stage)} notice generated for ${loan.accountNo}`, 'OK', { duration: 2800 });
+ }
+
+ /** Generate a notice for every selected loan in one click. */
+ bulkGenerate(): void {
+ if (this.selectedCount === 0) return;
+ let made = 0;
+ for (const acc of this.selected) {
+ const loan = this.loans.find((l) => l.accountNo === acc);
+ if (loan) {
+ this.generateNoticeQuiet(loan);
+ made++;
+ }
+ }
+ this.snack.open(`Generated ${made} notices.`, 'OK', { duration: 2800 });
+ this.selected.clear();
+ }
+
+ private generateNoticeQuiet(loan: OverdueLoan): void {
+ const stage = this.nextStage(loan);
+ loan.notices = [
+ {
+ id: 'NTC' + Date.now().toString().slice(-9) + Math.floor(Math.random() * 99),
+ stage,
+ generatedAt: this.nowStr(),
+ generatedBy: 'Current User',
+ sends: []
+ },
+ ...loan.notices
+ ];
+ }
+
+ /** Send the most recent generated notice via the chosen channel. */
+ sendNotice(loan: OverdueLoan, channel: NoticeChannel): void {
+ const latest = loan.notices[0];
+ if (!latest) {
+ this.snack.open('Generate a notice first before sending.', 'OK', { duration: 2500 });
+ return;
+ }
+
+ // Speed Post requires consignment + post office details before dispatch.
+ if (channel === 'POST') {
+ this.dispatchBySpeedPost(loan, latest);
+ return;
+ }
+
+ const dest = this.destinationFor(loan, channel);
+ latest.sends = [
+ {
+ channel,
+ sentAt: this.nowStr(),
+ sentBy: 'Current User',
+ status: 'DELIVERED',
+ destination: dest
+ },
+ ...latest.sends
+ ];
+ this.snack.open(`${this.channelLabel(channel)} sent for ${loan.accountNo}`, 'OK', { duration: 2500 });
+ }
+
+ /** Opens the Speed Post dispatch dialog. On confirm, stamps a send event with
+ the consignment number, originating post office, booking date, and postage. */
+ private dispatchBySpeedPost(loan: OverdueLoan, notice: NoticeEvent): void {
+ const data: SpeedPostDialogData = { loan, notice };
+ const ref = this.dialog.open(
+ SpeedPostDialogComponent,
+ {
+ width: '620px',
+ maxWidth: '94vw',
+ maxHeight: '88vh',
+ autoFocus: false,
+ panelClass: 'spd-dialog',
+ data
+ }
+ );
+ ref.afterClosed().subscribe((result) => {
+ if (!result?.confirmed) return;
+ notice.sends = [
+ {
+ channel: 'POST',
+ sentAt: this.nowStr(),
+ sentBy: 'Current User',
+ status: 'DELIVERED',
+ destination: loan.address,
+ consignmentNo: result.consignmentNo,
+ postOffice: result.postOffice,
+ bookedDate: result.bookedDate,
+ postage: result.postage
+ },
+ ...notice.sends
+ ];
+ this.snack.open(`Speed Post booked · ${result.consignmentNo} (${result.postOffice})`, 'OK', { duration: 3200 });
+ });
+ }
+
+ /** Send the most-recent notice for every selected loan via Email + Post. */
+ bulkSend(channels: NoticeChannel[] = [
+ 'EMAIL',
+ 'POST'
+ ]): void {
+ if (this.selectedCount === 0) return;
+ let sent = 0;
+ for (const acc of this.selected) {
+ const loan = this.loans.find((l) => l.accountNo === acc);
+ const latest = loan?.notices[0];
+ if (!loan || !latest) continue;
+ for (const channel of channels) {
+ latest.sends = [
+ {
+ channel,
+ sentAt: this.nowStr(),
+ sentBy: 'Current User',
+ status: 'DELIVERED',
+ destination: this.destinationFor(loan, channel)
+ },
+ ...latest.sends
+ ];
+ }
+ sent++;
+ }
+ this.snack.open(
+ `Dispatched notices for ${sent} accounts via ${channels.map((c) => this.channelLabel(c)).join(' + ')}.`,
+ 'OK',
+ {
+ duration: 3200
+ }
+ );
+ this.selected.clear();
+ }
+
+ openHistory(loan: OverdueLoan): void {
+ const data: NoticeHistoryDialogData = { loan };
+ this.dialog.open(NoticeHistoryDialogComponent, {
+ width: '780px',
+ maxWidth: '94vw',
+ maxHeight: '88vh',
+ autoFocus: false,
+ panelClass: 'ntc-history-dialog',
+ data
+ });
+ }
+
+ /** Opens the printable preview of the most-recently generated notice. */
+ viewLatestNotice(loan: OverdueLoan): void {
+ const latest = this.latestNotice(loan);
+ if (!latest) {
+ this.snack.open('Generate a notice first to preview it.', 'OK', { duration: 2500 });
+ return;
+ }
+ this.openPreview(loan, latest);
+ }
+
+ /** Opens the printable preview for any specific notice (used from history dialog too). */
+ openPreview(loan: OverdueLoan, notice: NoticeEvent): void {
+ const data: NoticePreviewDialogData = { loan, notice };
+ this.dialog.open(NoticePreviewDialogComponent, {
+ width: '880px',
+ maxWidth: '96vw',
+ maxHeight: '92vh',
+ autoFocus: false,
+ panelClass: 'ntc-preview-dialog',
+ data
+ });
+ }
+
+ /** Downloads the most-recent notice as a PDF. */
+ downloadLatestNotice(loan: OverdueLoan): void {
+ const latest = this.latestNotice(loan);
+ if (!latest) {
+ this.snack.open('Generate a notice first to download it.', 'OK', { duration: 2500 });
+ return;
+ }
+ this.downloadNoticeFile(loan, latest);
+ }
+
+ /** Generates and downloads the notice PDF using jsPDF. */
+ downloadNoticeFile(loan: OverdueLoan, notice: NoticeEvent): void {
+ downloadNoticePdf(loan, notice);
+ this.snack.open(`Notice ${notice.id} downloaded as PDF.`, 'OK', { duration: 2200 });
+ }
+
+ /**
+ * Opens the Call Contact dialog with every reachable number on file:
+ * primary mobile · alternative · family member · nominee. The operator
+ * picks which one to dial / WhatsApp / copy.
+ */
+ callCustomer(loan: OverdueLoan): void {
+ const data: CallContactDialogData = { loan };
+ this.dialog.open(CallContactDialogComponent, {
+ width: '560px',
+ maxWidth: '94vw',
+ maxHeight: '88vh',
+ autoFocus: false,
+ panelClass: 'ccd-dialog',
+ data
+ });
+ }
+
+ /** Copies the loan account number to the OS clipboard. */
+ copyAccountNo(loan: OverdueLoan): void {
+ const fallback = (): void => {
+ const ta = document.createElement('textarea');
+ ta.value = loan.accountNo;
+ ta.style.position = 'fixed';
+ ta.style.opacity = '0';
+ document.body.appendChild(ta);
+ ta.select();
+ try {
+ document.execCommand('copy');
+ } catch {
+ /* ignore */
+ }
+ document.body.removeChild(ta);
+ };
+ if (navigator?.clipboard?.writeText) {
+ navigator.clipboard.writeText(loan.accountNo).catch(fallback);
+ } else {
+ fallback();
+ }
+ this.snack.open(`Copied ${loan.accountNo} to clipboard.`, 'OK', { duration: 2000 });
+ }
+
+ /** Stub — would deep-link to the loan view in a real deployment. */
+ openLoanProfile(loan: OverdueLoan): void {
+ this.snack.open(`Opening loan profile for ${loan.accountNo}…`, 'OK', { duration: 2200 });
+ }
+
+ // ───────────────────── Display helpers ─────────────────────
+ accountTypeLabel(t: LoanAccountType): string {
+ return this.accountTypeOptions.find((o) => o.value === t)?.label || t;
+ }
+
+ stageLabel(s: NoticeStage): string {
+ return s === 'FIRST' ? '1st' : s === 'SECOND' ? '2nd' : s === 'FINAL' ? 'Final' : 'Legal';
+ }
+
+ channelLabel(c: NoticeChannel): string {
+ switch (c) {
+ case 'EMAIL':
+ return 'Email';
+ case 'POST':
+ return 'Speed Post';
+ case 'COURIER':
+ return 'Courier';
+ case 'SMS':
+ return 'SMS';
+ case 'WHATSAPP':
+ return 'WhatsApp';
+ case 'IN_PERSON':
+ return 'In-Person';
+ }
+ }
+
+ channelIcon(c: NoticeChannel): string {
+ switch (c) {
+ case 'EMAIL':
+ return 'envelope';
+ case 'POST':
+ return 'stamp';
+ case 'COURIER':
+ return 'paper-plane';
+ case 'SMS':
+ return 'mobile-alt';
+ case 'WHATSAPP':
+ return 'comment-alt';
+ case 'IN_PERSON':
+ return 'user-tie';
+ }
+ }
+
+ dpdBucket(d: number): 'mild' | 'severe' | 'critical' {
+ if (d <= 30) return 'mild';
+ if (d <= 90) return 'severe';
+ return 'critical';
+ }
+
+ noticeStatus(loan: OverdueLoan): 'NONE' | 'GENERATED' | 'SENT' {
+ if (loan.notices.length === 0) return 'NONE';
+ return loan.notices[0].sends.length > 0 ? 'SENT' : 'GENERATED';
+ }
+
+ latestNotice(loan: OverdueLoan): NoticeEvent | null {
+ return loan.notices[0] || null;
+ }
+
+ totalSendCount(loan: OverdueLoan): number {
+ return loan.notices.reduce((s, n) => s + n.sends.length, 0);
+ }
+
+ private nextStage(loan: OverdueLoan): NoticeStage {
+ const used = new Set(loan.notices.map((n) => n.stage));
+ if (!used.has('FIRST')) return 'FIRST';
+ if (!used.has('SECOND')) return 'SECOND';
+ if (!used.has('FINAL')) return 'FINAL';
+ return 'LEGAL';
+ }
+
+ private destinationFor(loan: OverdueLoan, channel: NoticeChannel): string {
+ switch (channel) {
+ case 'EMAIL':
+ return loan.email;
+ case 'SMS':
+ case 'WHATSAPP':
+ return '+91 ' + loan.mobile;
+ case 'POST':
+ case 'COURIER':
+ return loan.address;
+ case 'IN_PERSON':
+ return loan.branch + ' counter';
+ }
+ }
+
+ private nowStr(): string {
+ const d = new Date();
+ const pad = (n: number) => String(n).padStart(2, '0');
+ return (
+ d.getFullYear() +
+ '-' +
+ pad(d.getMonth() + 1) +
+ '-' +
+ pad(d.getDate()) +
+ ' ' +
+ pad(d.getHours()) +
+ ':' +
+ pad(d.getMinutes()) +
+ ':' +
+ pad(d.getSeconds())
+ );
+ }
+}
+
+// ───────────────────── PDF rendering ─────────────────────
+interface StageCopy {
+ title: string;
+ subject: string;
+ salutation: string;
+ body: string[];
+ cta: string;
+ consequences: string;
+ signOff: string;
+ accent: [number, number, number];
+}
+
+function stageCopy(loan: OverdueLoan, notice: NoticeEvent): StageCopy {
+ const overdue = `Rs. ${loan.overdueTotal.toLocaleString('en-IN')}`;
+ const lastPaid = loan.lastPaidDate;
+
+ switch (notice.stage) {
+ case 'FIRST':
+ return {
+ title: '1st Reminder Notice',
+ subject: `Reminder — Overdue dues on Loan A/c ${loan.accountNo}`,
+ salutation: `Dear ${loan.customerName},`,
+ body: [
+ 'We refer to your captioned loan account with our bank. Our records indicate that your account has fallen into arrears as detailed below.',
+ `This is a gentle reminder requesting you to regularise the account at the earliest. The overdue amount of ${overdue} (as on date) has remained unpaid since your last payment on ${lastPaid}.`,
+ 'Maintaining a healthy repayment record helps preserve your credit score and avoids additional penal interest. If you have already made the payment in the last 24 hours, kindly disregard this notice.'
+ ],
+ cta: `Please remit the total dues of ${overdue} within 7 days from the date of this notice through any of our accepted channels — branch deposit, NEFT/RTGS, IMPS, or UPI.`,
+ consequences:
+ 'Failure to clear the dues may attract additional penal interest charges and may lead to escalation to the next stage of recovery action.',
+ signOff: 'Yours sincerely,',
+ accent: [
+ 29,
+ 78,
+ 216
+ ]
+ };
+ case 'SECOND':
+ return {
+ title: '2nd Reminder Notice',
+ subject: `Second Reminder — Overdue dues on Loan A/c ${loan.accountNo}`,
+ salutation: `Dear ${loan.customerName},`,
+ body: [
+ 'Despite our earlier reminder, our records show that the dues in your captioned loan account remain unsettled.',
+ `The overdue amount of ${overdue} continues to remain outstanding. Your last payment was received on ${lastPaid}, after which no further repayment has been credited to the account.`,
+ 'We urge you to treat this matter with utmost urgency and clear the dues immediately to avoid further action on the account.'
+ ],
+ cta: `Kindly regularise the account by paying ${overdue} within 7 days from this notice. If you have already made the payment, please share the UTR / transaction reference with our recovery team so we can update the records.`,
+ consequences:
+ 'Continued non-payment may result in classification as a Special Mention Account (SMA) per RBI guidelines, reporting to credit bureaus (CIBIL/Experian/Equifax/CRIF), and escalation for further recovery action including issuance of a final demand notice.',
+ signOff: 'Regards,',
+ accent: [
+ 217,
+ 119,
+ 6
+ ]
+ };
+ case 'FINAL':
+ return {
+ title: 'Final Demand Notice',
+ subject: `FINAL DEMAND NOTICE — Loan A/c ${loan.accountNo}`,
+ salutation: `Dear ${loan.customerName},`,
+ body: [
+ 'This communication serves as the FINAL DEMAND NOTICE in respect of your captioned loan account, which continues to remain in default despite our prior reminders.',
+ `As on date, an amount of ${overdue} is overdue and payable. Your last payment was received on ${lastPaid}. No payments have been credited since then.`,
+ 'You are hereby called upon to clear the entire overdue amount along with applicable penal interest within 15 days from the date of this notice.'
+ ],
+ cta: `Payment of ${overdue} (plus applicable penal interest accrued up to the date of payment) must reach the bank within 15 days from receipt of this notice.`,
+ consequences:
+ 'Failure to comply within the stipulated period shall constrain the bank, without further reference to you, to classify your account as a Non-Performing Asset (NPA) per RBI norms, report the same to all credit information companies, and initiate appropriate legal and recovery proceedings, including action under the SARFAESI Act, 2002 / RDDB Act, 1993 where applicable.',
+ signOff: 'For and on behalf of the bank,',
+ accent: [
+ 185,
+ 28,
+ 28
+ ]
+ };
+ case 'LEGAL':
+ default:
+ return {
+ title: 'Legal Notice (Pre-Litigation)',
+ subject: `LEGAL NOTICE — Loan A/c ${loan.accountNo} — Demand for Payment`,
+ salutation: `To ${loan.customerName},`,
+ body: [
+ 'Take notice that the bank, through its undersigned authorised officer, hereby serves upon you this Legal Notice in respect of the captioned loan account.',
+ `That you availed credit facilities from the bank vide Loan Account No. ${loan.accountNo}. That as on the date of this notice, a sum of ${overdue} is outstanding and payable by you to the bank, having remained unpaid since ${lastPaid}, in spite of repeated reminders and demands made by the bank.`,
+ 'That your continued default constitutes a breach of the sanction terms and loan agreement entered into between you and the bank, and the entire outstanding has become recallable forthwith.'
+ ],
+ cta: `You are hereby called upon to pay to the bank a sum of ${overdue} together with further interest, penal charges, and costs, within 15 days from receipt of this notice, failing which the bank shall be constrained to initiate appropriate legal proceedings against you, including but not limited to proceedings under the SARFAESI Act, 2002, the Negotiable Instruments Act, 1881, and / or recovery proceedings before the Debts Recovery Tribunal (DRT), entirely at your risk, cost, and consequences.`,
+ consequences:
+ 'Please treat this notice with the seriousness it deserves. The bank reserves the right to take any and all such steps as may be advised in law, without any further reference to you, in the event of your continued default.',
+ signOff: 'Authorised Officer,',
+ accent: [
+ 109,
+ 40,
+ 217
+ ]
+ };
+ }
+}
+
+/**
+ * Build a printable PDF that visually mirrors the on-screen preview dialog:
+ * - serif (Times) body, sans-serif (Helvetica) for labels / meta
+ * - centered letterhead with accent-coloured bank name
+ * - three-column "Notice Reference / Date of Issue / Stage" meta strip
+ * - "To," addressee block + subject row with hairline dividers
+ * - justified serif body
+ * - 2x4 loan-summary grid with accent-coloured TOTAL DUES row
+ * - accent left-bar CTA, italic consequences, signature line
+ */
+export function buildNoticePdf(loan: OverdueLoan, notice: NoticeEvent): jsPDF {
+ 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 - margin * 2;
+ const copy = stageCopy(loan, notice);
+
+ // Helper: lighter tint of an [r,g,b] colour (~ alpha .14 over white).
+ const tint = ([
+ r,
+ g,
+ b
+ ]: [
+ number,
+ number,
+ number
+ ]): [number, number, number] => [
+ Math.round(255 - (255 - r) * 0.12),
+ Math.round(255 - (255 - g) * 0.12),
+ Math.round(255 - (255 - b) * 0.12)
+ ];
+ const accent = copy.accent as [number, number, number];
+ const accentTint = tint(accent);
+
+ // Palette (matches the preview dialog SCSS)
+ const C_TEXT: [number, number, number] = [
+ 15,
+ 23,
+ 42
+ ]; // g900
+ const C_MUTED: [number, number, number] = [
+ 100,
+ 116,
+ 139
+ ]; // g500
+ const C_BORDER: [number, number, number] = [
+ 226,
+ 232,
+ 240
+ ]; // g200
+ const C_LABEL_BG: [number, number, number] = [
+ 248,
+ 250,
+ 252
+ ]; // g50
+
+ const setText = (rgb: [number, number, number]): void => {
+ doc.setTextColor(rgb[0], rgb[1], rgb[2]);
+ };
+ const setFill = (rgb: [number, number, number]): void => {
+ doc.setFillColor(rgb[0], rgb[1], rgb[2]);
+ };
+ const setDraw = (rgb: [number, number, number]): void => {
+ doc.setDrawColor(rgb[0], rgb[1], rgb[2]);
+ };
+
+ // ── Accent top bar (matches `.npd-page` border-top: 6px) ──
+ setFill(accent);
+ doc.rect(0, 0, pageW, 4, 'F');
+
+ // ── Letterhead ──
+ let y = 22;
+ doc.setFont('times', 'bold');
+ doc.setFontSize(20);
+ setText(accent);
+ doc.text('Nexora Co-operative Bank Ltd.', pageW / 2, y, { align: 'center' });
+ y += 6;
+ doc.setFont('helvetica', 'normal');
+ doc.setFontSize(9);
+ setText(C_MUTED);
+ doc.text('Head Office, 4th Floor, Nexora Tower, Mumbai 400051', pageW / 2, y, { align: 'center' });
+ y += 4;
+ doc.text('RBI Lic. No. NX-2018/MH/482 · recovery@nexorabank.in · 022 6789 0000', pageW / 2, y, {
+ align: 'center'
+ });
+ y += 4;
+ setDraw(C_BORDER);
+ doc.setLineWidth(0.3);
+ doc.line(margin, y, pageW - margin, y);
+ y += 8;
+
+ // ── Notice meta row (3 columns) ──
+ const colW = contentW / 3;
+ doc.setFont('helvetica', 'bold');
+ doc.setFontSize(8);
+ setText(C_MUTED);
+ doc.text('NOTICE REFERENCE', margin, y);
+ doc.text('DATE OF ISSUE', margin + colW, y);
+ doc.text('STAGE', margin + colW * 2, y);
+ y += 5;
+
+ // Values row
+ setText(C_TEXT);
+ doc.setFont('courier', 'bold');
+ doc.setFontSize(11);
+ doc.text(notice.id, margin, y + 1);
+
+ doc.setFont('helvetica', 'bold');
+ doc.setFontSize(11);
+ // Date in "8 April 2026" style
+ const issueDate = formatLongDate(notice.generatedAt.slice(0, 10));
+ doc.text(issueDate, margin + colW, y + 1);
+
+ // Stage pill
+ const stagePillX = margin + colW * 2;
+ doc.setFont('helvetica', 'bold');
+ doc.setFontSize(9);
+ const pillW = doc.getTextWidth(copy.title.toUpperCase()) + 8;
+ const pillH = 6;
+ const pillY = y - 3.5;
+ setFill(accentTint);
+ doc.roundedRect(stagePillX, pillY, pillW, pillH, pillH / 2, pillH / 2, 'F');
+ setText(accent);
+ doc.text(copy.title.toUpperCase(), stagePillX + pillW / 2, pillY + pillH / 2 + 1.4, { align: 'center' });
+
+ y += 6;
+ setDraw(C_BORDER);
+ doc.setLineDashPattern(
+ [
+ 0.8,
+ 0.8
+ ],
+ 0
+ );
+ doc.line(margin, y, pageW - margin, y);
+ doc.setLineDashPattern([], 0);
+ y += 8;
+
+ // ── Addressee block ──
+ doc.setFont('helvetica', 'bold');
+ doc.setFontSize(11);
+ setText(C_TEXT);
+ doc.text('To,', margin, y);
+
+ const addrX = margin + 14;
+ doc.setFont('helvetica', 'bold');
+ doc.setFontSize(11);
+ doc.text(loan.customerName, addrX, y);
+ y += 5;
+
+ doc.setFont('helvetica', 'normal');
+ doc.setFontSize(10);
+ setText(C_TEXT);
+ doc.text(`Customer ID: ${loan.customerId}`, addrX, y);
+ y += 4.5;
+
+ const addrLines = doc.splitTextToSize(loan.address, contentW - 14);
+ doc.text(addrLines, addrX, y);
+ y += addrLines.length * 4.5;
+
+ doc.text(`Mob: +91 ${loan.mobile} · ${loan.email}`, addrX, y);
+ y += 8;
+
+ // ── Subject (divider above + below) ──
+ setDraw(C_BORDER);
+ doc.setLineWidth(0.3);
+ doc.line(margin, y, pageW - margin, y);
+ y += 6;
+ doc.setFont('helvetica', 'bold');
+ doc.setFontSize(8);
+ setText(C_MUTED);
+ doc.text('SUBJECT:', margin, y);
+
+ doc.setFont('helvetica', 'bold');
+ doc.setFontSize(11);
+ setText(C_TEXT);
+ const subjLines = doc.splitTextToSize(copy.subject, contentW - 26);
+ doc.text(subjLines, margin + 24, y);
+ y += subjLines.length * 5 + 1;
+ doc.line(margin, y, pageW - margin, y);
+ y += 8;
+
+ // ── Salutation ──
+ doc.setFont('times', 'bold');
+ doc.setFontSize(12);
+ setText(C_TEXT);
+ doc.text(copy.salutation, margin, y);
+ y += 7;
+
+ // ── Body paragraphs (Times, justified) ──
+ doc.setFont('times', 'normal');
+ doc.setFontSize(11);
+ setText(C_TEXT);
+ const writePara = (text: string): void => {
+ const lines = doc.splitTextToSize(text, contentW);
+ if (y + lines.length * 5 > pageH - margin - 55) {
+ doc.addPage();
+ setFill(accent);
+ doc.rect(0, 0, pageW, 4, 'F');
+ y = margin;
+ }
+ doc.text(lines, margin, y, { align: 'justify', maxWidth: contentW });
+ y += lines.length * 5 + 3;
+ };
+ for (const p of copy.body) {
+ writePara(p);
+ }
+
+ // ── Loan summary table (2x4 grid + accent total row) ──
+ y += 2;
+ if (y > pageH - margin - 70) {
+ doc.addPage();
+ setFill(accent);
+ doc.rect(0, 0, pageW, 4, 'F');
+ y = margin;
+ }
+ const rowH = 8;
+ const colHalf = contentW / 2;
+ const labelW = 40;
+ const valueW = colHalf - labelW;
+ const drawCell = (x: number, w: number, text: string, isLabel: boolean): void => {
+ if (isLabel) {
+ setFill(C_LABEL_BG);
+ doc.rect(x, y, w, rowH, 'F');
+ doc.setFont('helvetica', 'bold');
+ doc.setFontSize(7.5);
+ setText(C_MUTED);
+ doc.text(text, x + 2.5, y + rowH / 2 + 1.3);
+ } else {
+ doc.setFont('helvetica', 'normal');
+ doc.setFontSize(10);
+ setText(C_TEXT);
+ doc.text(text, x + 2.5, y + rowH / 2 + 1.3);
+ }
+ setDraw(C_BORDER);
+ doc.setLineWidth(0.2);
+ doc.rect(x, y, w, rowH);
+ };
+
+ // Row 1
+ drawCell(margin, labelW, 'LOAN ACCOUNT NO.', true);
+ doc.setFont('courier', 'bold');
+ doc.setFontSize(10);
+ setText(C_TEXT);
+ doc.text(loan.accountNo, margin + labelW + 2.5, y + rowH / 2 + 1.3);
+ setDraw(C_BORDER);
+ doc.rect(margin + labelW, y, valueW, rowH);
+ drawCell(margin + colHalf, labelW, 'BRANCH', true);
+ drawCell(margin + colHalf + labelW, valueW, loan.branch, false);
+ y += rowH;
+
+ // Row 2
+ drawCell(margin, labelW, 'DAYS PAST DUE', true);
+ drawCell(margin + labelW, valueW, String(loan.daysOverdue), false);
+ drawCell(margin + colHalf, labelW, 'LAST PAID DATE', true);
+ drawCell(margin + colHalf + labelW, valueW, formatLongDate(loan.lastPaidDate), false);
+ y += rowH;
+
+ // Row 3
+ drawCell(margin, labelW, 'OVERDUE PRINCIPAL', true);
+ drawCell(margin + labelW, valueW, `Rs. ${loan.overduePrincipal.toLocaleString('en-IN')}`, false);
+ drawCell(margin + colHalf, labelW, 'OVERDUE INTEREST', true);
+ drawCell(margin + colHalf + labelW, valueW, `Rs. ${loan.overdueInterest.toLocaleString('en-IN')}`, false);
+ y += rowH;
+
+ // Total row (accent background, white text)
+ setFill(accent);
+ doc.rect(margin, y, contentW, rowH + 1, 'F');
+ doc.setFont('helvetica', 'bold');
+ doc.setFontSize(10);
+ setText([
+ 255,
+ 255,
+ 255
+ ]);
+ doc.text('TOTAL DUES OUTSTANDING', margin + 3, y + rowH / 2 + 2);
+ doc.text(`Rs. ${loan.overdueTotal.toLocaleString('en-IN')}`, margin + contentW - 3, y + rowH / 2 + 2, {
+ align: 'right'
+ });
+ y += rowH + 8;
+
+ // ── CTA (accent left bar + tinted background) ──
+ if (y > pageH - margin - 55) {
+ doc.addPage();
+ setFill(accent);
+ doc.rect(0, 0, pageW, 4, 'F');
+ y = margin;
+ }
+ doc.setFont('times', 'bold');
+ doc.setFontSize(11);
+ setText(C_TEXT);
+ const ctaLines = doc.splitTextToSize(copy.cta, contentW - 10);
+ const ctaH = ctaLines.length * 5 + 6;
+ setFill(accentTint);
+ doc.rect(margin, y, contentW, ctaH, 'F');
+ setFill(accent);
+ doc.rect(margin, y, 1.8, ctaH, 'F');
+ doc.text(ctaLines, margin + 6, y + 5, { align: 'justify', maxWidth: contentW - 10 });
+ y += ctaH + 6;
+
+ // ── Consequences (italic, muted) ──
+ if (y > pageH - margin - 40) {
+ doc.addPage();
+ setFill(accent);
+ doc.rect(0, 0, pageW, 4, 'F');
+ y = margin;
+ }
+ doc.setFont('times', 'italic');
+ doc.setFontSize(9.5);
+ setText(C_MUTED);
+ const conLines = doc.splitTextToSize(copy.consequences, contentW);
+ doc.text(conLines, margin, y, { align: 'justify', maxWidth: contentW });
+ y += conLines.length * 4.5 + 8;
+
+ // ── Sign-off ──
+ if (y > pageH - margin - 35) {
+ doc.addPage();
+ setFill(accent);
+ doc.rect(0, 0, pageW, 4, 'F');
+ y = margin;
+ }
+ doc.setFont('times', 'normal');
+ doc.setFontSize(11);
+ setText(C_TEXT);
+ doc.text(copy.signOff, margin, y);
+ y += 16;
+ setDraw(C_MUTED);
+ doc.setLineWidth(0.3);
+ doc.line(margin, y, margin + 70, y);
+ y += 5;
+ doc.setFont('helvetica', 'normal');
+ doc.setFontSize(10);
+ doc.text('For ', margin, y);
+ doc.setFont('helvetica', 'bold');
+ doc.text('Nexora Co-operative Bank Ltd.', margin + 7, y);
+ y += 4.5;
+ doc.setFont('helvetica', 'normal');
+ doc.setFontSize(9);
+ setText(C_MUTED);
+ doc.text(`Authorised Signatory — Recovery & Collections`, margin, y);
+ y += 4;
+ doc.setFont('courier', 'normal');
+ doc.text(loan.branch, margin, y);
+ y += 8;
+
+ // ── Footer ──
+ setDraw(C_BORDER);
+ doc.setLineDashPattern(
+ [
+ 0.8,
+ 0.8
+ ],
+ 0
+ );
+ doc.line(margin, y, pageW - margin, y);
+ doc.setLineDashPattern([], 0);
+ y += 4.5;
+ doc.setFont('helvetica', 'normal');
+ doc.setFontSize(8);
+ setText(C_MUTED);
+ doc.text(`Notice generated by ${notice.generatedBy} on ${notice.generatedAt}.`, margin, y);
+ doc.setFont('courier', 'normal');
+ doc.text(notice.id, pageW - margin, y, { align: 'right' });
+
+ return doc;
+}
+
+/** "2026-04-08" → "8 April 2026" — matches the date format in the preview header. */
+function formatLongDate(iso: string): string {
+ if (!iso) return '';
+ const d = new Date(iso);
+ if (isNaN(d.getTime())) return iso;
+ return d.toLocaleDateString('en-IN', { day: 'numeric', month: 'long', year: 'numeric' });
+}
+
+/** Convenience: build the PDF and trigger a download with a sensible filename. */
+export function downloadNoticePdf(loan: OverdueLoan, notice: NoticeEvent): void {
+ const doc = buildNoticePdf(loan, notice);
+ doc.save(`${notice.id}-${loan.accountNo}-Notice.pdf`);
+}
+
+// ───────────────────── Mock seed (top-level export) ─────────────────────
+/**
+ * Mock list of loan accounts with overdue dues. Replace with an API call
+ * (e.g., GET /loans?overdue=true&branch=...) once the back-end is wired.
+ */
+export function seedOverdueLoans(): OverdueLoan[] {
+ const daysAgo = (n: number): string => {
+ const d = new Date();
+ d.setDate(d.getDate() - n);
+ const pad = (x: number) => String(x).padStart(2, '0');
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
+ };
+ const ts = (offset: number, h = 10, m = 0): string => {
+ const d = new Date();
+ d.setDate(d.getDate() - offset);
+ const pad = (x: number) => String(x).padStart(2, '0');
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(h)}:${pad(m)}:00`;
+ };
+
+ return [
+ {
+ accountNo: 'LN10029384',
+ accountType: 'HOUSING',
+ customerId: 'CUST112233',
+ customerName: 'Rohan S. Mehta',
+ branch: 'Mumbai — Andheri (E)',
+ email: 'rohan.mehta@example.com',
+ mobile: '9821234567',
+ mobileAlt: '9870011234',
+ familyName: 'Priya Mehta',
+ familyRelation: 'Spouse',
+ familyMobile: '9821987654',
+ nomineeName: 'Aarav Mehta',
+ nomineeMobile: '9819900112',
+ address: 'Flat 902, Skyline Apt, Andheri (E), Mumbai 400069',
+ emiAmount: 38500,
+ overduePrincipal: 65000,
+ overdueInterest: 12000,
+ overdueTotal: 77000,
+ daysOverdue: 22,
+ lastPaidDate: daysAgo(22),
+ notices: []
+ },
+ {
+ accountNo: 'LN20034112',
+ accountType: 'PERSONAL',
+ customerId: 'CUST334455',
+ customerName: 'Anita Verma',
+ branch: 'New Delhi — Connaught Place',
+ email: 'anita.verma@example.com',
+ mobile: '9845112233',
+ mobileAlt: '9810044556',
+ familyName: 'Suresh Verma',
+ familyRelation: 'Father',
+ familyMobile: '9845998877',
+ nomineeName: 'Kavita Verma',
+ nomineeMobile: '9899012233',
+ address: 'B-12, Connaught Pl, New Delhi 110001',
+ emiAmount: 14500,
+ overduePrincipal: 28000,
+ overdueInterest: 4200,
+ overdueTotal: 32200,
+ daysOverdue: 45,
+ lastPaidDate: daysAgo(45),
+ notices: [
+ {
+ id: 'NTC700001234',
+ stage: 'FIRST',
+ generatedAt: ts(15, 10, 20),
+ generatedBy: 'Sushma Iyer',
+ sends: [
+ {
+ channel: 'EMAIL',
+ sentAt: ts(15, 11, 5),
+ sentBy: 'Sushma Iyer',
+ status: 'DELIVERED',
+ destination: 'anita.verma@example.com'
+ },
+ {
+ channel: 'POST',
+ sentAt: ts(14, 14, 30),
+ sentBy: 'Sushma Iyer',
+ status: 'DELIVERED',
+ destination: 'B-12, Connaught Pl, New Delhi 110001',
+ consignmentNo: 'EM123456789IN',
+ postOffice: 'New Delhi GPO',
+ bookedDate: ts(14, 14, 30).slice(0, 10),
+ postage: 45
+ }
+ ]
+ }
+ ]
+ },
+ {
+ accountNo: 'LN50200077',
+ accountType: 'AUTO',
+ customerId: 'CUST556677',
+ customerName: 'Karthik Rao',
+ branch: 'Bengaluru — MG Road',
+ email: 'karthik.rao@example.com',
+ mobile: '9900112233',
+ mobileAlt: '9845001122',
+ familyName: 'Lalitha Rao',
+ familyRelation: 'Mother',
+ familyMobile: '9900998877',
+ nomineeName: 'Shreya Rao',
+ nomineeMobile: '9844551133',
+ address: '24, MG Road, Bengaluru 560001',
+ emiAmount: 22000,
+ overduePrincipal: 18000,
+ overdueInterest: 2400,
+ overdueTotal: 20400,
+ daysOverdue: 12,
+ lastPaidDate: daysAgo(12),
+ notices: []
+ },
+ {
+ accountNo: 'LN50200099',
+ accountType: 'GOLD',
+ customerId: 'CUST667788',
+ customerName: 'Lakshmi Naidu',
+ branch: 'Hyderabad — Banjara Hills',
+ email: 'lakshmi.n@example.com',
+ mobile: '9849988776',
+ mobileAlt: '9849111223',
+ familyName: 'Sundar Naidu',
+ familyRelation: 'Husband',
+ familyMobile: '9849776655',
+ nomineeName: 'Meera Naidu',
+ nomineeMobile: '9848220011',
+ address: 'Plot 14, Road No. 3, Banjara Hills, Hyderabad 500034',
+ emiAmount: 8500,
+ overduePrincipal: 9200,
+ overdueInterest: 1100,
+ overdueTotal: 10300,
+ daysOverdue: 18,
+ lastPaidDate: daysAgo(18),
+ notices: [
+ {
+ id: 'NTC700002345',
+ stage: 'FIRST',
+ generatedAt: ts(6, 9, 45),
+ generatedBy: 'Lakshmi Naidu',
+ sends: [
+ {
+ channel: 'EMAIL',
+ sentAt: ts(6, 10, 0),
+ sentBy: 'Lakshmi Naidu',
+ status: 'DELIVERED',
+ destination: 'lakshmi.n@example.com'
+ }
+ ]
+ }
+ ]
+ },
+ {
+ accountNo: 'LN70010023',
+ accountType: 'BUSINESS',
+ customerId: 'CUST889900',
+ customerName: 'Vikram Joshi',
+ branch: 'Pune — Koregaon Park',
+ email: 'vikram.j@example.com',
+ mobile: '9890065432',
+ mobileAlt: '9876543210',
+ familyName: 'Aruna Joshi',
+ familyRelation: 'Spouse',
+ familyMobile: '9890112233',
+ nomineeName: 'Aditya Joshi',
+ nomineeMobile: '9823344556',
+ address: 'Office 3B, Koregaon Park Road, Pune 411001',
+ emiAmount: 145000,
+ overduePrincipal: 320000,
+ overdueInterest: 48000,
+ overdueTotal: 368000,
+ daysOverdue: 95,
+ lastPaidDate: daysAgo(95),
+ notices: [
+ {
+ id: 'NTC700003456',
+ stage: 'FIRST',
+ generatedAt: ts(60, 11, 0),
+ generatedBy: 'Sushma Iyer',
+ sends: [
+ {
+ channel: 'EMAIL',
+ sentAt: ts(60, 11, 30),
+ sentBy: 'Sushma Iyer',
+ status: 'DELIVERED',
+ destination: 'vikram.j@example.com'
+ },
+ {
+ channel: 'POST',
+ sentAt: ts(59, 15, 12),
+ sentBy: 'Sushma Iyer',
+ status: 'DELIVERED',
+ destination: 'Office 3B, Koregaon Park Road, Pune 411001',
+ consignmentNo: 'EM987654321IN',
+ postOffice: 'Pune City HPO',
+ bookedDate: ts(59, 15, 12).slice(0, 10),
+ postage: 55
+ }
+ ]
+ },
+ {
+ id: 'NTC700003457',
+ stage: 'SECOND',
+ generatedAt: ts(30, 10, 15),
+ generatedBy: 'Sushma Iyer',
+ sends: [
+ {
+ channel: 'EMAIL',
+ sentAt: ts(30, 10, 45),
+ sentBy: 'Sushma Iyer',
+ status: 'DELIVERED',
+ destination: 'vikram.j@example.com'
+ },
+ {
+ channel: 'COURIER',
+ sentAt: ts(29, 17, 22),
+ sentBy: 'Sushma Iyer',
+ status: 'DELIVERED',
+ destination: 'Office 3B, Koregaon Park Road, Pune 411001'
+ },
+ {
+ channel: 'WHATSAPP',
+ sentAt: ts(28, 9, 5),
+ sentBy: 'Sushma Iyer',
+ status: 'DELIVERED',
+ destination: '+91 9890065432'
+ }
+ ]
+ }
+ ]
+ },
+ {
+ accountNo: 'LN88990022',
+ accountType: 'EDUCATION',
+ customerId: 'CUST990011',
+ customerName: 'Arjun Banerjee',
+ branch: 'Kolkata — Park Street',
+ email: 'arjun.banerjee@example.com',
+ mobile: '9831100225',
+ mobileAlt: '9831566778',
+ familyName: 'Subir Banerjee',
+ familyRelation: 'Father',
+ familyMobile: '9830012345',
+ nomineeName: 'Madhumita Banerjee',
+ nomineeMobile: '9830998877',
+ address: '42, Park Street, Kolkata 700016',
+ emiAmount: 18500,
+ overduePrincipal: 22000,
+ overdueInterest: 3300,
+ overdueTotal: 25300,
+ daysOverdue: 8,
+ lastPaidDate: daysAgo(8),
+ notices: []
+ },
+ {
+ accountNo: 'LN50100445',
+ accountType: 'CC',
+ customerId: 'CUST110022',
+ customerName: 'Spectra Components Pvt Ltd',
+ branch: 'Mumbai — Andheri (E)',
+ email: 'finance@spectracomponents.in',
+ mobile: '9821100110',
+ mobileAlt: '9821100220',
+ familyName: 'Nitin Shah',
+ familyRelation: 'Managing Director',
+ familyMobile: '9820011988',
+ nomineeName: 'Priti Shah',
+ nomineeMobile: '9820011990',
+ address: 'Plot 9, MIDC, Andheri (E), Mumbai 400093',
+ emiAmount: 0,
+ overduePrincipal: 0,
+ overdueInterest: 142000,
+ overdueTotal: 142000,
+ daysOverdue: 60,
+ lastPaidDate: daysAgo(60),
+ notices: [
+ {
+ id: 'NTC700004567',
+ stage: 'FIRST',
+ generatedAt: ts(40, 10, 0),
+ generatedBy: 'Anita Desai',
+ sends: [
+ {
+ channel: 'EMAIL',
+ sentAt: ts(40, 10, 15),
+ sentBy: 'Anita Desai',
+ status: 'DELIVERED',
+ destination: 'finance@spectracomponents.in'
+ }
+ ]
+ }
+ ]
+ },
+ {
+ accountNo: 'LN50100887',
+ accountType: 'OD',
+ customerId: 'CUST220033',
+ customerName: 'Bluestone Trading Co.',
+ branch: 'Bengaluru — MG Road',
+ email: 'ops@bluestonetrading.in',
+ mobile: '9845009900',
+ mobileAlt: '9845009911',
+ familyName: 'Harish Iyer',
+ familyRelation: 'Proprietor',
+ familyMobile: '9844456677',
+ nomineeName: 'Nandita Iyer',
+ nomineeMobile: '9844456680',
+ address: '12, Brigade Road, Bengaluru 560025',
+ emiAmount: 0,
+ overduePrincipal: 0,
+ overdueInterest: 88500,
+ overdueTotal: 88500,
+ daysOverdue: 35,
+ lastPaidDate: daysAgo(35),
+ notices: []
+ },
+ {
+ accountNo: 'LN50100332',
+ accountType: 'HOUSING',
+ customerId: 'CUST330044',
+ customerName: 'Madhavi Singh',
+ branch: 'New Delhi — Connaught Place',
+ email: 'madhavi.singh@example.com',
+ mobile: '9818877665',
+ mobileAlt: '9818800220',
+ familyName: 'Rajesh Singh',
+ familyRelation: 'Spouse',
+ familyMobile: '9810022334',
+ nomineeName: 'Pooja Singh',
+ nomineeMobile: '9818099887',
+ address: 'D-5, Greater Kailash, New Delhi 110048',
+ emiAmount: 42000,
+ overduePrincipal: 78000,
+ overdueInterest: 13500,
+ overdueTotal: 91500,
+ daysOverdue: 145,
+ lastPaidDate: daysAgo(145),
+ notices: [
+ {
+ id: 'NTC700005678',
+ stage: 'FIRST',
+ generatedAt: ts(120, 9, 30),
+ generatedBy: 'Sushma Iyer',
+ sends: [
+ {
+ channel: 'EMAIL',
+ sentAt: ts(120, 10, 0),
+ sentBy: 'Sushma Iyer',
+ status: 'DELIVERED',
+ destination: 'madhavi.singh@example.com'
+ },
+ {
+ channel: 'POST',
+ sentAt: ts(119, 14, 5),
+ sentBy: 'Sushma Iyer',
+ status: 'DELIVERED',
+ destination: 'D-5, Greater Kailash, New Delhi 110048',
+ consignmentNo: 'EM200334455IN',
+ postOffice: 'Greater Kailash SO',
+ bookedDate: ts(119, 14, 5).slice(0, 10),
+ postage: 45
+ }
+ ]
+ },
+ {
+ id: 'NTC700005679',
+ stage: 'SECOND',
+ generatedAt: ts(75, 10, 30),
+ generatedBy: 'Sushma Iyer',
+ sends: [
+ {
+ channel: 'POST',
+ sentAt: ts(75, 16, 22),
+ sentBy: 'Sushma Iyer',
+ status: 'DELIVERED',
+ destination: 'D-5, Greater Kailash, New Delhi 110048',
+ consignmentNo: 'EM200445566IN',
+ postOffice: 'Greater Kailash SO',
+ bookedDate: ts(75, 16, 22).slice(0, 10),
+ postage: 50
+ }
+ ]
+ },
+ {
+ id: 'NTC700005680',
+ stage: 'FINAL',
+ generatedAt: ts(20, 11, 0),
+ generatedBy: 'Sushma Iyer',
+ sends: [
+ {
+ channel: 'COURIER',
+ sentAt: ts(20, 18, 12),
+ sentBy: 'Sushma Iyer',
+ status: 'DELIVERED',
+ destination: 'D-5, Greater Kailash, New Delhi 110048'
+ },
+ {
+ channel: 'EMAIL',
+ sentAt: ts(20, 11, 5),
+ sentBy: 'Sushma Iyer',
+ status: 'BOUNCED',
+ destination: 'madhavi.singh@example.com'
+ }
+ ]
+ }
+ ]
+ },
+ {
+ accountNo: 'LN70020045',
+ accountType: 'PERSONAL',
+ customerId: 'CUST440055',
+ customerName: 'Indrani Sen',
+ branch: 'Kolkata — Park Street',
+ email: 'indrani.sen@example.com',
+ mobile: '9831200115',
+ mobileAlt: '9831200220',
+ familyName: 'Pranab Sen',
+ familyRelation: 'Brother',
+ familyMobile: '9830445566',
+ nomineeName: 'Rohini Sen',
+ nomineeMobile: '9830445599',
+ address: '18, Camac Street, Kolkata 700017',
+ emiAmount: 9800,
+ overduePrincipal: 12500,
+ overdueInterest: 1850,
+ overdueTotal: 14350,
+ daysOverdue: 5,
+ lastPaidDate: daysAgo(5),
+ notices: []
+ }
+ ];
+}
diff --git a/src/app/letter-mgmt/notice/speed-post-dialog/speed-post-dialog.component.html b/src/app/letter-mgmt/notice/speed-post-dialog/speed-post-dialog.component.html
new file mode 100644
index 000000000..283aee49a
--- /dev/null
+++ b/src/app/letter-mgmt/notice/speed-post-dialog/speed-post-dialog.component.html
@@ -0,0 +1,97 @@
+
+
+
+
+
+
+
+
+
Record Speed Post dispatch
+
+ {{ data.loan.customerName }} · {{ data.loan.accountNo }} · Notice
+ {{ data.notice.id }}
+
+
+
+
+
+
+
+
+
+
+
+
+
Dispatch address
+
{{ data.loan.address }}
+
+
+
+
+
+
+
+
+
+ Cancel
+
+
+
+ Confirm dispatch
+
+
+
diff --git a/src/app/letter-mgmt/notice/speed-post-dialog/speed-post-dialog.component.scss b/src/app/letter-mgmt/notice/speed-post-dialog/speed-post-dialog.component.scss
new file mode 100644
index 000000000..aa2c6da61
--- /dev/null
+++ b/src/app/letter-mgmt/notice/speed-post-dialog/speed-post-dialog.component.scss
@@ -0,0 +1,282 @@
+/**
+ * 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/.
+ */
+
+:host {
+ --spd-blue: #1d4ed8;
+ --spd-blue-d: #1e40af;
+ --spd-blue-l: #eff6ff;
+ --spd-blue-tint: #dbeafe;
+ --spd-amber: #d97706;
+ --spd-amber-l: #fef3c7;
+ --spd-red: #b91c1c;
+ --spd-red-l: #fee2e2;
+ --spd-red-tint: #fecaca;
+ --spd-green: #15803d;
+ --spd-green-l: #dcfce7;
+ --spd-orange: #c2410c;
+ --spd-orange-l: #ffedd5;
+ --spd-g50: #f8fafc;
+ --spd-g100: #f1f5f9;
+ --spd-g200: #e2e8f0;
+ --spd-g300: #cbd5e1;
+ --spd-g500: #64748b;
+ --spd-g700: #334155;
+ --spd-g900: #0f172a;
+
+ display: block;
+}
+
+.spd-root {
+ background: #fff;
+ padding: 16px 20px 18px;
+ width: 100%;
+ max-height: 90vh;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ overflow: hidden;
+}
+
+/* ───────── Header ───────── */
+.spd-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 14px;
+ padding-bottom: 12px;
+ border-bottom: 1px solid var(--spd-g200);
+}
+
+.spd-head-l {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.spd-head-ic {
+ width: 40px;
+ height: 40px;
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 12px;
+ background: var(--spd-orange);
+ color: #fff;
+ font-size: 16px;
+}
+
+.spd-head-t {
+ margin: 0;
+ font-size: 16px;
+ font-weight: 800;
+ color: var(--spd-g900);
+ line-height: 1.2;
+}
+
+.spd-head-s {
+ margin-top: 3px;
+ font-size: 11.5px;
+ color: var(--spd-g500);
+}
+
+.spd-x {
+ width: 32px;
+ height: 32px;
+ border-radius: 999px;
+ border: 1px solid var(--spd-g200);
+ background: #fff;
+ color: var(--spd-g500);
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 13px;
+ flex-shrink: 0;
+ transition: all 0.12s ease;
+
+ &:hover {
+ background: var(--spd-red-l);
+ color: var(--spd-red);
+ border-color: var(--spd-red-tint);
+ }
+}
+
+.mono {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+}
+
+/* ───────── Delivery address card ───────── */
+.spd-addr {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ padding: 10px 14px;
+ background: var(--spd-orange-l);
+ border: 1px solid #fed7aa;
+ border-radius: 10px;
+}
+
+.spd-addr-ic {
+ color: var(--spd-orange);
+ font-size: 14px;
+ margin-top: 2px;
+ flex-shrink: 0;
+}
+
+.spd-addr-l {
+ font-size: 9.5px;
+ font-weight: 800;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--spd-g500);
+}
+
+.spd-addr-v {
+ font-size: 12.5px;
+ font-weight: 600;
+ color: var(--spd-g900);
+ line-height: 1.4;
+ margin-top: 2px;
+}
+
+/* ───────── Form ───────── */
+.spd-form {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.spd-row {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 12px;
+}
+
+.spd-field {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ min-width: 0;
+
+ label {
+ font-size: 10px;
+ font-weight: 800;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--spd-g500);
+ }
+
+ input,
+ textarea {
+ box-sizing: border-box;
+ width: 100%;
+ height: 38px;
+ border: 1px solid var(--spd-g200);
+ border-radius: 8px;
+ padding: 0 11px;
+ background: #fff;
+ font-size: 13px;
+ color: var(--spd-g900);
+ font-family: inherit;
+ outline: none;
+ transition: border-color 0.15s ease;
+
+ &:focus {
+ border-color: var(--spd-blue);
+ }
+
+ &::placeholder {
+ color: var(--spd-g300);
+ }
+ }
+
+ &--empty {
+ visibility: hidden;
+ }
+}
+
+.spd-req {
+ color: var(--spd-red);
+ font-weight: 800;
+}
+
+.spd-hint {
+ font-size: 11px;
+ font-weight: 500;
+ color: var(--spd-g500);
+ line-height: 1.4;
+}
+
+.spd-err {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ font-size: 11px;
+ font-weight: 700;
+ color: var(--spd-red);
+
+ fa-icon {
+ font-size: 10.5px;
+ }
+}
+
+/* ───────── Footer actions ───────── */
+.spd-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ padding-top: 8px;
+ border-top: 1px solid var(--spd-g200);
+}
+
+.spd-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ height: 38px;
+ padding: 0 16px;
+ border-radius: 9px;
+ border: 1px solid var(--spd-g200);
+ background: #fff;
+ color: var(--spd-g700);
+ font-size: 13px;
+ font-weight: 700;
+ cursor: pointer;
+ transition: all 0.12s ease;
+
+ fa-icon {
+ font-size: 12px;
+ }
+
+ &:hover {
+ background: var(--spd-g50);
+ border-color: var(--spd-g300);
+ }
+
+ &--primary {
+ background: var(--spd-orange);
+ border-color: var(--spd-orange);
+ color: #fff;
+
+ fa-icon {
+ color: #fff;
+ }
+
+ &:hover:not(:disabled) {
+ background: #9a3412;
+ border-color: #9a3412;
+ color: #fff;
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+ }
+}
diff --git a/src/app/letter-mgmt/notice/speed-post-dialog/speed-post-dialog.component.ts b/src/app/letter-mgmt/notice/speed-post-dialog/speed-post-dialog.component.ts
new file mode 100644
index 000000000..877b66511
--- /dev/null
+++ b/src/app/letter-mgmt/notice/speed-post-dialog/speed-post-dialog.component.ts
@@ -0,0 +1,91 @@
+/**
+ * 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 { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
+import { MatTooltip } from '@angular/material/tooltip';
+import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+
+import { NoticeEvent, OverdueLoan } from '../notice.component';
+
+export interface SpeedPostDialogData {
+ loan: OverdueLoan;
+ notice: NoticeEvent;
+}
+
+export interface SpeedPostDialogResult {
+ confirmed: boolean;
+ consignmentNo?: string;
+ postOffice?: string;
+ bookedDate?: string;
+ postage?: number;
+}
+
+@Component({
+ selector: 'mifosx-speed-post-dialog',
+ templateUrl: './speed-post-dialog.component.html',
+ styleUrls: ['./speed-post-dialog.component.scss'],
+ imports: [
+ ...STANDALONE_SHARED_IMPORTS,
+ CommonModule,
+ FormsModule,
+ FaIconComponent,
+ MatTooltip
+ ]
+})
+export class SpeedPostDialogComponent {
+ private dialogRef = inject(MatDialogRef);
+ data: SpeedPostDialogData = inject(MAT_DIALOG_DATA);
+
+ consignmentNo = '';
+ postOffice = '';
+ bookedDate = todayIso();
+ postage: number | null = 45;
+
+ /** Suggested India-post Speed Post 13-char article ID format: 2 letters + 9 digits + 2 letters
+ (e.g. "EM123456789IN"). Loose pattern — operator can paste any tracking ID. */
+ readonly consignmentPattern = /^[A-Z]{2}\d{9}[A-Z]{2}$|^[A-Za-z0-9]{6,20}$/;
+
+ get isConsignmentValid(): boolean {
+ const c = (this.consignmentNo || '').trim();
+ return c.length >= 6 && this.consignmentPattern.test(c);
+ }
+
+ get isFormValid(): boolean {
+ return this.isConsignmentValid && (this.postOffice || '').trim().length >= 2 && !!this.bookedDate;
+ }
+
+ /** Force the consignment number to uppercase for the India-Post-style ID. */
+ upperCaseConsignment(value: string): void {
+ this.consignmentNo = (value || '').toUpperCase();
+ }
+
+ confirm(): void {
+ if (!this.isFormValid) return;
+ this.dialogRef.close({
+ confirmed: true,
+ consignmentNo: this.consignmentNo.trim(),
+ postOffice: this.postOffice.trim(),
+ bookedDate: this.bookedDate,
+ postage: this.postage != null ? Number(this.postage) : undefined
+ });
+ }
+
+ cancel(): void {
+ this.dialogRef.close({ confirmed: false });
+ }
+}
+
+function todayIso(): string {
+ const d = new Date();
+ const pad = (n: number) => String(n).padStart(2, '0');
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
+}
diff --git a/src/app/products/current-products/current-products.component.html b/src/app/products/current-products/current-products.component.html
index 4ff29d6b3..f9f46dcb6 100644
--- a/src/app/products/current-products/current-products.component.html
+++ b/src/app/products/current-products/current-products.component.html
@@ -1,37 +1,163 @@
-
-
-
-
- {{ 'labels.text.Create Current Product' | translate }}
-
+
+
+
+
-
-
-
-
-
- {{ 'labels.inputs.Filter' | translate }}
-
-
-
+
+
+
+
+
+
{{ totalCount | number }}
+
Total Current Products
+
+
+
+
+
+
{{ currencyCount | number }}
+
{{ currencyCount === 1 ? 'Currency' : 'Currencies' }} in use
+
+
+
+
+
+
{{ filteredCount | number }}
+
Showing after filter
+
+
-
-
-
- {{ 'labels.inputs.Name' | translate }}
- {{ product.name }}
-
+
+
+
+ @if (totalCount === 0) {
+
+
+
No current-account products yet
+
Create your first current-account product to enable transactional account onboarding.
+
+
+ {{ 'labels.text.Create Current Product' | translate }}
+
+
+ } @else {
+
+
+
+ {{ 'labels.inputs.Name' | translate }}
+
+
+
+
+
+
+ {{ 'labels.inputs.Short Name' | translate }}
+
+ {{ p.shortName }}
+
+
+
+
+ Currency
+
+ @if (p.currency?.code) {
+ {{ p.currency.code }}
+ } @else {
+ —
+ }
+
+
+
+
+ Actions
+
+
+ View
+
+
+ Edit
+
+
+
-
- {{ 'labels.inputs.Short Name' | translate }}
- {{ product.shortName }}
-
+
+
+
-
-
-
+ @if (filteredCount === 0 && totalCount > 0) {
+
+
+ No products match your filter.
+
+ }
-
+
+
+ }
diff --git a/src/app/products/current-products/current-products.component.scss b/src/app/products/current-products/current-products.component.scss
index 24edb63bb..b16b7bdf3 100644
--- a/src/app/products/current-products/current-products.component.scss
+++ b/src/app/products/current-products/current-products.component.scss
@@ -1,7 +1,17 @@
-table {
- width: 100%;
+/**
+ * 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/.
+ */
- .select-row:hover {
- cursor: pointer;
- }
+@use 'assets/styles/product-list' as pl;
+
+@include pl.product-list-styles;
+
+/* Current-account-specific green accent */
+:host {
+ --pl-accent: #15803d;
+ --pl-accent-l: #dcfce7;
}
diff --git a/src/app/products/current-products/current-products.component.ts b/src/app/products/current-products/current-products.component.ts
index b2eb80a43..52c0e5f05 100644
--- a/src/app/products/current-products/current-products.component.ts
+++ b/src/app/products/current-products/current-products.component.ts
@@ -1,3 +1,11 @@
+/**
+ * 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, ViewChild, inject } from '@angular/core';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort, MatSortHeader } from '@angular/material/sort';
@@ -16,6 +24,7 @@ import {
} from '@angular/material/table';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+import { MatTooltip } from '@angular/material/tooltip';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({
@@ -37,35 +46,54 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatHeaderRow,
MatRowDef,
MatRow,
- MatPaginator
+ MatPaginator,
+ MatTooltip
]
})
export class CurrentProductsComponent implements OnInit {
private route = inject(ActivatedRoute);
- currentProductsData: any;
+ currentProductsData: any[] = [];
displayedColumns: string[] = [
'name',
- 'shortName'
+ 'shortName',
+ 'currency',
+ 'actions'
];
dataSource: MatTableDataSource
;
+ filteredCount = 0;
+
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
@ViewChild(MatSort, { static: true }) sort: MatSort;
constructor() {
this.route.data.subscribe((data: { currentProducts: any }) => {
- this.currentProductsData = data.currentProducts;
+ this.currentProductsData = data.currentProducts || [];
});
}
+ get totalCount(): number {
+ return this.currentProductsData?.length || 0;
+ }
+
+ get currencyCount(): number {
+ const set = new Set();
+ for (const p of this.currentProductsData || []) {
+ if (p?.currency?.code) set.add(p.currency.code);
+ }
+ return set.size;
+ }
+
ngOnInit() {
this.dataSource = new MatTableDataSource(this.currentProductsData);
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
+ this.filteredCount = this.totalCount;
+ this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length));
}
applyFilter(filterValue: string) {
- this.dataSource.filter = filterValue.trim().toLowerCase();
+ this.dataSource.filter = (filterValue || '').trim().toLowerCase();
}
}
diff --git a/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.html b/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.html
index 0a68dd9d7..b1159bcaf 100644
--- a/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.html
+++ b/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.html
@@ -6,623 +6,637 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
-
-
- {{ 'labels.buttons.Edit' | translate }}
-
-
-
-
-
{{ currentProduct.name }}
-
-
-
{{ 'labels.inputs.Details' | translate }}
-
-
-
-
{{ 'labels.inputs.Short Name' | translate }}:
-
{{ currentProduct.shortName }}
+
+
+
+
+
+
+
Current-Account Product
+
{{ currentProduct.name }}
+
+ {{ currentProduct.shortName }}
+ @if (currentProduct.currency?.code) {
+ {{ currentProduct.currency.code }}
+ }
+
+ @if (currentProduct.description) {
+
{{ currentProduct.description }}
+ }
+
+
+
+ {{ 'labels.buttons.Edit' | translate }}
+
- @if (currentProduct.description) {
-
- {{ 'labels.inputs.Description' | translate }}:
- {{ currentProduct.description }}
-
- }
+
+
{{ 'labels.inputs.Details' | translate }}
- @if (
- currentProduct.accountType ||
- currentProduct.customerSegment ||
- currentProduct.glAccountId ||
- currentProduct.effectiveDate ||
- currentProduct.effectiveTo ||
- currentProduct.endDate ||
- currentProduct.introducerRequired !== undefined ||
- currentProduct.shareLinkingEnabled !== undefined
- ) {
-
Product Classification
+
+
+ {{ 'labels.inputs.Short Name' | translate }}:
+ {{ currentProduct.shortName }}
+
+
+ @if (currentProduct.description) {
+
+ {{ 'labels.inputs.Description' | translate }}:
+ {{ currentProduct.description }}
+
+ }
+
+ @if (
+ currentProduct.accountType ||
+ currentProduct.customerSegment ||
+ currentProduct.glAccountId ||
+ currentProduct.effectiveDate ||
+ currentProduct.effectiveTo ||
+ currentProduct.endDate ||
+ currentProduct.introducerRequired !== undefined ||
+ currentProduct.shareLinkingEnabled !== undefined
+ ) {
+
Product Classification
+
+
+
+ @if (currentProduct.accountType) {
+
+ Account Type:
+ {{ currentProduct.accountType }}
+
+ }
+
+ @if (currentProduct.customerSegment) {
+
+ Customer Segment:
+ {{ currentProduct.customerSegment }}
+
+ }
+
+ @if (currentProduct.glAccountId) {
+
+ GL Account (Liability):
+ {{ currentProduct.glAccountName || currentProduct.glAccountId }}
+
+ }
+
+ @if (currentProduct.effectiveDate) {
+
+ Effective Date:
+ {{ currentProduct.effectiveDate | dateFormat }}
+
+ }
+
+ @if (currentProduct.effectiveTo) {
+
+ Effective To:
+ {{ currentProduct.effectiveTo | dateFormat }}
+
+ }
+
+ @if (currentProduct.endDate) {
+
+ End Date:
+ {{ currentProduct.endDate | dateFormat }}
+
+ }
+
+ @if (currentProduct.introducerRequired !== undefined) {
+
+ Introducer Required:
+ {{
+ currentProduct.introducerRequired === true || currentProduct.introducerRequired === 'Yes' ? 'Yes' : 'No'
+ }}
+
+ }
+
+ @if (currentProduct.shareLinkingEnabled !== undefined) {
+
+ Share Linking Enabled:
+ {{ currentProduct.shareLinkingEnabled | yesNo }}
+
+ }
+ }
+
+
{{ 'labels.inputs.Currency' | translate }}
- @if (currentProduct.accountType) {
+
+ {{ 'labels.inputs.Currency' | translate }}:
+ {{ currentProduct.currency.name }}
+
+
+
+ {{ 'labels.inputs.Decimal Places' | translate }}:
+ {{ currentProduct.currency.decimalPlaces }}
+
+
+ @if (currentProduct.currency.inMultiplesOf !== undefined && currentProduct.currency.inMultiplesOf !== null) {
- Account Type:
- {{ currentProduct.accountType }}
+ {{ 'labels.inputs.Currency in multiples of' | translate }}:
+ {{ currentProduct.currency.inMultiplesOf }}
}
- @if (currentProduct.customerSegment) {
+ @if (currentProduct.dormancyAlert !== undefined) {
- Customer Segment:
- {{ currentProduct.customerSegment }}
+ Dormancy Alert:
+ {{ currentProduct.dormancyAlert | yesNo }}
}
- @if (currentProduct.glAccountId) {
+ @if (currentProduct.nominationFacility !== undefined) {
- GL Account (Liability):
- {{ currentProduct.glAccountName || currentProduct.glAccountId }}
+ Nomination Facility:
+ {{ currentProduct.nominationFacility | yesNo }}
}
- @if (currentProduct.effectiveDate) {
+ @if (currentProduct.balanceSwapOption !== undefined) {
- Effective Date:
- {{ currentProduct.effectiveDate | dateFormat }}
+ Balance Swap Option:
+ {{ currentProduct.balanceSwapOption | yesNo }}
}
- @if (currentProduct.effectiveTo) {
+ @if (currentProduct.msme !== undefined) {
- Effective To:
- {{ currentProduct.effectiveTo | dateFormat }}
+ MSME:
+ {{ currentProduct.msme | yesNo }}
}
- @if (currentProduct.endDate) {
+ @if (currentProduct.autoDebitFacility !== undefined) {
- End Date:
- {{ currentProduct.endDate | dateFormat }}
+ Auto Debit Facility:
+ {{ currentProduct.autoDebitFacility | yesNo }}
}
- @if (currentProduct.introducerRequired !== undefined) {
+
Interest On OD
+
+
+
+ @if (currentProduct.interestPostingPeriodType?.value) {
- Introducer Required:
+ {{ 'labels.inputs.Interest Posting Period' | translate }}:
+ {{ currentProduct.interestPostingPeriodType.value | translateKey: 'catalogs' }}
+
+ }
+
+ @if (currentProduct.interestCalculateSelector) {
+
+ Interest Calculate:
+ {{ currentProduct.interestCalculateSelector }}
+
+ }
+
+ @if (currentProduct.averagePeriod) {
+
+ Average Period:
+ {{ currentProduct.averagePeriod }}
+
+ }
+
+ @if (currentProduct.autoPost !== undefined) {
+
+ Auto Post:
+ {{ currentProduct.autoPost | yesNo }}
+
+ }
+
+ @if (currentProduct.frequencyFixDay) {
+
+ Frequency Fix Day:
+ {{ currentProduct.frequencyFixDay }}
+
+ }
+
+ @if (currentProduct.lastInterestDate) {
+
+ Last Interest Date:
+ {{ currentProduct.lastInterestDate | dateFormat }}
+
+ }
+
+
{{ 'labels.inputs.Settings' | translate }}
+
+
+
+ @if (currentProduct.minRequiredOpeningBalance) {
+
+ {{ 'labels.inputs.Minimum Opening Balance' | translate }}:
+ {{ currentProduct.minRequiredOpeningBalance | formatNumber }}
+
+ }
+
+ @if (currentProduct.lockinPeriodFrequency) {
+
+ {{ 'labels.inputs.Lock-in Period' | translate }}:
{{
- currentProduct.introducerRequired === true || currentProduct.introducerRequired === 'Yes' ? 'Yes' : 'No'
+ currentProduct.lockinPeriodFrequency +
+ ' ' +
+ (currentProduct.lockinPeriodFrequencyType?.value || currentProduct.lockinPeriodFrequencyType?.code || '')
}}
}
- @if (currentProduct.shareLinkingEnabled !== undefined) {
+
+ {{ 'labels.inputs.Apply Withdrawal Fee for Transfers' | translate }}:
+ {{ currentProduct.withdrawalFeeForTransfers | yesNo }}
+
+
+ @if (currentProduct.balanceFrequency) {
- Share Linking Enabled:
- {{ currentProduct.shareLinkingEnabled | yesNo }}
-
- }
- }
-
-
{{ 'labels.inputs.Currency' | translate }}
-
-
-
-
- {{ 'labels.inputs.Currency' | translate }}:
- {{ currentProduct.currency.name }}
-
-
-
- {{ 'labels.inputs.Decimal Places' | translate }}:
- {{ currentProduct.currency.decimalPlaces }}
-
-
- @if (currentProduct.currency.inMultiplesOf !== undefined && currentProduct.currency.inMultiplesOf !== null) {
-
- {{ 'labels.inputs.Currency in multiples of' | translate }}:
- {{ currentProduct.currency.inMultiplesOf }}
-
- }
-
- @if (currentProduct.dormancyAlert !== undefined) {
-
- Dormancy Alert:
- {{ currentProduct.dormancyAlert | yesNo }}
-
- }
-
- @if (currentProduct.nominationFacility !== undefined) {
-
- Nomination Facility:
- {{ currentProduct.nominationFacility | yesNo }}
-
- }
-
- @if (currentProduct.balanceSwapOption !== undefined) {
-
- Balance Swap Option:
- {{ currentProduct.balanceSwapOption | yesNo }}
-
- }
-
- @if (currentProduct.msme !== undefined) {
-
- MSME:
- {{ currentProduct.msme | yesNo }}
-
- }
-
- @if (currentProduct.autoDebitFacility !== undefined) {
-
- Auto Debit Facility:
- {{ currentProduct.autoDebitFacility | yesNo }}
-
- }
-
-
Interest On OD
-
-
-
- @if (currentProduct.interestPostingPeriodType?.value) {
-
- {{ 'labels.inputs.Interest Posting Period' | translate }}:
- {{ currentProduct.interestPostingPeriodType.value | translateKey: 'catalogs' }}
-
- }
-
- @if (currentProduct.interestCalculateSelector) {
-
- Interest Calculate:
- {{ currentProduct.interestCalculateSelector }}
-
- }
-
- @if (currentProduct.averagePeriod) {
-
- Average Period:
- {{ currentProduct.averagePeriod }}
-
- }
-
- @if (currentProduct.autoPost !== undefined) {
-
- Auto Post:
- {{ currentProduct.autoPost | yesNo }}
-
- }
-
- @if (currentProduct.frequencyFixDay) {
-
- Frequency Fix Day:
- {{ currentProduct.frequencyFixDay }}
-
- }
-
- @if (currentProduct.lastInterestDate) {
-
- Last Interest Date:
- {{ currentProduct.lastInterestDate | dateFormat }}
-
- }
-
-
{{ 'labels.inputs.Settings' | translate }}
-
-
-
- @if (currentProduct.minRequiredOpeningBalance) {
-
- {{ 'labels.inputs.Minimum Opening Balance' | translate }}:
- {{ currentProduct.minRequiredOpeningBalance | formatNumber }}
-
- }
-
- @if (currentProduct.lockinPeriodFrequency) {
-
- {{ 'labels.inputs.Lock-in Period' | translate }}:
- {{
- currentProduct.lockinPeriodFrequency +
- ' ' +
- (currentProduct.lockinPeriodFrequencyType?.value || currentProduct.lockinPeriodFrequencyType?.code || '')
- }}
-
- }
-
-
- {{ 'labels.inputs.Apply Withdrawal Fee for Transfers' | translate }}:
- {{ currentProduct.withdrawalFeeForTransfers | yesNo }}
-
-
- @if (currentProduct.balanceFrequency) {
-
- Balance Frequency:
- {{ currentProduct.balanceFrequency }}
-
- }
-
-
- Enable Lock-in Period:
- {{ currentProduct.enableLockinPeriod | yesNo }}
-
-
- @if (currentProduct.minBalanceForInterestCalculation) {
-
- {{ 'labels.inputs.Balance Required for Interest Calculation' | translate }}:
- {{ currentProduct.minBalanceForInterestCalculation | formatNumber }}
-
- }
-
-
- {{ 'labels.inputs.Enforce Minimum Balance' | translate }}:
- {{ currentProduct.enforceMinRequiredBalance | yesNo }}
-
-
- @if (currentProduct.minRequiredBalance) {
-
- {{ 'labels.inputs.Minimum Balance' | translate }}:
- {{ currentProduct.minRequiredBalance | formatNumber }}
-
- }
-
-
- {{ 'labels.inputs.Withhold Tax is Applicable' | translate }}:
- {{ currentProduct.withHoldTax | yesNo }}
-
-
- @if (currentProduct.withHoldTax) {
-
- {{ 'labels.inputs.Tax Group' | translate }}:
- {{ currentProduct.taxGroup.name }}
-
- }
-
-
- {{ 'labels.inputs.Is Overdraft Allowed' | translate }}:
- {{ currentProduct.allowOverdraft | yesNo }}
-
-
-
- Account Closed Zero Balance in Day Closing:
- {{ currentProduct.accountClosedZeroBalanceInDayClosing | yesNo }}
-
-
- @if (currentProduct.minOverdraftForInterestCalculation) {
-
- {{ 'labels.inputs.Minimum Overdraft Required for Interest Calculation' | translate }}:
- {{ currentProduct.minOverdraftForInterestCalculation | formatNumber }}
-
- }
-
- @if (currentProduct.nominalAnnualInterestRateOverdraft) {
-
- {{ 'labels.inputs.Nominal Annual Interest for Overdraft' | translate }}:
- {{ currentProduct.nominalAnnualInterestRateOverdraft | formatNumber }} %
-
- }
-
- @if (currentProduct.overdraftLimit) {
-
- {{ 'labels.inputs.Maximum Overdraft Amount Limit' | translate }}:
- {{ currentProduct.overdraftLimit | formatNumber }}
-
- }
-
- @if (currentProduct.allowOverdraft) {
- @if (currentProduct.avgBalanceCalcPeriod) {
-
- Average Balance Calculation Period:
- {{ currentProduct.avgBalanceCalcPeriod }}
+ Balance Frequency:
+ {{ currentProduct.balanceFrequency }}
}
- @if (currentProduct.validForPeriod) {
-
- Valid for Period:
- {{ currentProduct.validForPeriod }}
-
- }
-
- @if (currentProduct.odAccountClosedZeroBalanceInDayClosing !== undefined) {
-
- OD Account Closed with Zero Balance in Day Closing:
- {{ currentProduct.odAccountClosedZeroBalanceInDayClosing | yesNo }}
-
- }
- }
-
-
- {{ 'labels.inputs.Enable Dormancy Tracking' | translate }}:
- {{ currentProduct.isDormancyTrackingActive | yesNo }}
-
-
- @if (currentProduct.isDormancyTrackingActive) {
-
-
{{ 'labels.inputs.Number of Days to Inactive sub-status' | translate }}:
-
{{ currentProduct.daysToInactive }}
-
{{ 'labels.inputs.Number of Days to Dormant sub-status' | translate }}:
-
{{ currentProduct.daysToDormancy }}
-
{{ 'labels.inputs.Number of Days to Escheat' | translate }}:
-
{{ currentProduct.daysToEscheat }}
+
+ Enable Lock-in Period:
+ {{ currentProduct.enableLockinPeriod | yesNo }}
- }
-
Account Facilities
+ @if (currentProduct.minBalanceForInterestCalculation) {
+
+ {{ 'labels.inputs.Balance Required for Interest Calculation' | translate }}:
+ {{ currentProduct.minBalanceForInterestCalculation | formatNumber }}
+
+ }
-
- @if (currentProduct.accountFacilityConfig) {
-
- @for (feature of accountFacilityFeatureItems; track feature.key) {
- @if (currentProduct.accountFacilityConfig[feature.key] !== undefined) {
-
- {{ feature.label }}:
- {{ currentProduct.accountFacilityConfig[feature.key] | yesNo }}
-
+
+ {{ 'labels.inputs.Enforce Minimum Balance' | translate }}:
+ {{ currentProduct.enforceMinRequiredBalance | yesNo }}
+
+
+ @if (currentProduct.minRequiredBalance) {
+
+ {{ 'labels.inputs.Minimum Balance' | translate }}:
+ {{ currentProduct.minRequiredBalance | formatNumber }}
+
+ }
+
+
+ {{ 'labels.inputs.Withhold Tax is Applicable' | translate }}:
+ {{ currentProduct.withHoldTax | yesNo }}
+
+
+ @if (currentProduct.withHoldTax) {
+
+ {{ 'labels.inputs.Tax Group' | translate }}:
+ {{ currentProduct.taxGroup.name }}
+
+ }
+
+
+ {{ 'labels.inputs.Is Overdraft Allowed' | translate }}:
+ {{ currentProduct.allowOverdraft | yesNo }}
+
+
+
+ Account Closed Zero Balance in Day Closing:
+ {{ currentProduct.accountClosedZeroBalanceInDayClosing | yesNo }}
+
+
+ @if (currentProduct.minOverdraftForInterestCalculation) {
+
+ {{ 'labels.inputs.Minimum Overdraft Required for Interest Calculation' | translate }}:
+ {{ currentProduct.minOverdraftForInterestCalculation | formatNumber }}
+
+ }
+
+ @if (currentProduct.nominalAnnualInterestRateOverdraft) {
+
+ {{ 'labels.inputs.Nominal Annual Interest for Overdraft' | translate }}:
+ {{ currentProduct.nominalAnnualInterestRateOverdraft | formatNumber }} %
+
+ }
+
+ @if (currentProduct.overdraftLimit) {
+
+ {{ 'labels.inputs.Maximum Overdraft Amount Limit' | translate }}:
+ {{ currentProduct.overdraftLimit | formatNumber }}
+
+ }
+
+ @if (currentProduct.allowOverdraft) {
+ @if (currentProduct.avgBalanceCalcPeriod) {
+
+ Average Balance Calculation Period:
+ {{ currentProduct.avgBalanceCalcPeriod }}
+
+ }
+
+ @if (currentProduct.validForPeriod) {
+
+ Valid for Period:
+ {{ currentProduct.validForPeriod }}
+
+ }
+
+ @if (currentProduct.odAccountClosedZeroBalanceInDayClosing !== undefined) {
+
+ OD Account Closed with Zero Balance in Day Closing:
+ {{ currentProduct.odAccountClosedZeroBalanceInDayClosing | yesNo }}
+
+ }
+ }
+
+
+ {{ 'labels.inputs.Enable Dormancy Tracking' | translate }}:
+ {{ currentProduct.isDormancyTrackingActive | yesNo }}
+
+
+ @if (currentProduct.isDormancyTrackingActive) {
+
+ {{ 'labels.inputs.Number of Days to Inactive sub-status' | translate }}:
+ {{ currentProduct.daysToInactive }}
+ {{ 'labels.inputs.Number of Days to Dormant sub-status' | translate }}:
+ {{ currentProduct.daysToDormancy }}
+ {{ 'labels.inputs.Number of Days to Escheat' | translate }}:
+ {{ currentProduct.daysToEscheat }}
+
+ }
+
+
Account Facilities
+
+
+ @if (currentProduct.accountFacilityConfig) {
+
+ @for (feature of accountFacilityFeatureItems; track feature.key) {
+ @if (currentProduct.accountFacilityConfig[feature.key] !== undefined) {
+
+ {{ feature.label }}:
+ {{ currentProduct.accountFacilityConfig[feature.key] | yesNo }}
+
+ }
}
- }
-
- }
-
- @if (currentProduct.accountFacilityLimits?.length) {
-
-
Account Facility Limits
-
-
-
- Limit Type
- {{ row.limitType }}
-
-
- General Limit
- {{ row.generalLimit }}
-
-
- Minor Account Limit
- {{ row.minorAccountLimit }}
-
-
- Small Saving Limit
- {{ row.smallSavingLimit }}
-
-
-
-
-
- }
-
- @if (currentProduct.depositLimitKyc) {
-
-
Deposit Limit (KYC)
-
- General:
- {{ currentProduct.depositLimitKyc.general.kycType }} -
- {{ currentProduct.depositLimitKyc.general.limit }}
-
-
Minor:
-
{{ currentProduct.depositLimitKyc.minor.kycType }} - {{ currentProduct.depositLimitKyc.minor.limit }}
+ }
+
+ @if (currentProduct.accountFacilityLimits?.length) {
+
+
Account Facility Limits
+
+
+
+ Limit Type
+ {{ row.limitType }}
+
+
+ General Limit
+ {{ row.generalLimit }}
+
+
+ Minor Account Limit
+ {{ row.minorAccountLimit }}
+
+
+ Small Saving Limit
+ {{ row.smallSavingLimit }}
+
+
+
+
-
- Small Saving:
- {{ currentProduct.depositLimitKyc.smallSaving.kycType }} -
- {{ currentProduct.depositLimitKyc.smallSaving.limit }}
-
-
- }
+ }
- @if (currentProduct.charges.length) {
-
-
{{ 'labels.heading.Charges' | translate }}
-
-
-
- {{ 'labels.inputs.Name' | translate }}
-
- {{ charge.name + ', ' + charge.currency.displaySymbol }}
-
-
-
- {{ 'labels.inputs.Type' | translate }}
-
- {{ charge.chargeCalculationType.value | translateKey: 'catalogs' }}
-
-
-
- {{ 'labels.inputs.Amount' | translate }}
-
- {{ charge.amount | formatNumber }}
-
-
-
- {{ 'labels.inputs.Collected On' | translate }}
-
- {{ charge.chargeTimeType.value | translateKey: 'catalogs' }}
-
-
-
-
-
-
- }
-
-
{{ 'labels.heading.Accounting' | translate }}
-
-
-
-
-
-
- @if (currentProduct.otherGlMappingsMap) {
-
-
Other GL Mappings
-
-
- @if (currentProduct.otherGlMappingsMap.assets?.length) {
-
Assets
-
-
- Account Name
- {{ row.accountName }}
-
-
- GL Account
- {{ row.glCode || row.glAccountId }}
-
-
-
-
- }
-
- @if (currentProduct.otherGlMappingsMap.liabilities?.length) {
-
Liabilities
-
-
- Account Name
- {{ row.accountName }}
-
-
- GL Account
- {{ row.glCode || row.glAccountId }}
-
-
-
-
- }
-
- @if (currentProduct.otherGlMappingsMap.expenses?.length) {
-
Expenses
-
-
- Account Name
- {{ row.accountName }}
-
-
- GL Account
- {{ row.glCode || row.glAccountId }}
-
-
-
-
- }
-
- @if (currentProduct.otherGlMappingsMap.income?.length) {
-
Income
-
-
- Account Name
- {{ row.accountName }}
-
-
- GL Account
- {{ row.glCode || row.glAccountId }}
-
-
-
-
- }
-
- }
-
- @if (
- currentProduct.paymentChannelToFundSourceMappings?.length ||
- currentProduct.feeToIncomeAccountMappings?.length ||
- currentProduct.penaltyToIncomeAccountMappings?.length
- ) {
-
-
{{ 'labels.inputs.Advanced Accounting Rules' | translate }}
-
- @if (currentProduct.paymentChannelToFundSourceMappings?.length) {
-
-
{{ 'labels.inputs.Fund Sources for Payment Channels' | translate }}
-
+ Deposit Limit (KYC)
+
+ General:
+ {{ currentProduct.depositLimitKyc.general.kycType }} -
+ {{ currentProduct.depositLimitKyc.general.limit }}
-
- {{ 'labels.inputs.Payment Type' | translate }}
-
- {{ paymentFundSource.paymentType.name }}
-
-
-
- {{ 'labels.inputs.Fund Source' | translate }}
-
- ({{ paymentFundSource.fundSourceAccount.glCode }}) {{ paymentFundSource.fundSourceAccount.name }}
-
-
-
-
-
- }
- @if (currentProduct.feeToIncomeAccountMappings?.length) {
-
-
{{ 'labels.inputs.Fees to Specific Income Accounts' | translate }}
-
-
- {{ 'labels.inputs.Fee/Charge' | translate }}
-
- {{ fee.charge.name }}
-
-
-
- {{ 'labels.inputs.Income Account' | translate }}
- ({{ fee.incomeAccount.glCode }}) {{ fee.incomeAccount.name }}
-
-
-
-
+
+ Minor:
+ {{ currentProduct.depositLimitKyc.minor.kycType }} - {{ currentProduct.depositLimitKyc.minor.limit }}
- }
- @if (currentProduct.penaltyToIncomeAccountMappings?.length) {
-
-
{{ 'labels.inputs.Penalties to Specific Income Accounts' | translate }}
+
+ Small Saving:
+ {{ currentProduct.depositLimitKyc.smallSaving.kycType }} -
+ {{ currentProduct.depositLimitKyc.smallSaving.limit }}
+
+
+ }
+
+ @if (currentProduct.charges.length) {
+
+
{{ 'labels.heading.Charges' | translate }}
+
+
+
+ {{ 'labels.inputs.Name' | translate }}
+
+ {{ charge.name + ', ' + charge.currency.displaySymbol }}
+
+
+
+ {{ 'labels.inputs.Type' | translate }}
+
+ {{ charge.chargeCalculationType.value | translateKey: 'catalogs' }}
+
+
+
+ {{ 'labels.inputs.Amount' | translate }}
+
+ {{ charge.amount | formatNumber }}
+
+
+
+ {{ 'labels.inputs.Collected On' | translate }}
+
+ {{ charge.chargeTimeType.value | translateKey: 'catalogs' }}
+
+
+
+
+
+
+ }
+
+
{{ 'labels.heading.Accounting' | translate }}
+
+
+
+
+
+
+ @if (currentProduct.otherGlMappingsMap) {
+
+
Other GL Mappings
+
+
+ @if (currentProduct.otherGlMappingsMap.assets?.length) {
+
Assets
+
+
+ Account Name
+ {{ row.accountName }}
+
+
+ GL Account
+ {{ row.glCode || row.glAccountId }}
+
+
+
+
+ }
+
+ @if (currentProduct.otherGlMappingsMap.liabilities?.length) {
+
Liabilities
-
- {{ 'labels.inputs.Penalty' | translate }}
-
- {{ penalty.charge.name }}
-
+
+ Account Name
+ {{ row.accountName }}
-
- {{ 'labels.inputs.Income Account' | translate }}
-
- ({{ penalty.incomeAccount.glCode }}) {{ penalty.incomeAccount.name }}
-
+
+ GL Account
+ {{ row.glCode || row.glAccountId }}
-
-
+
+
-
- }
-
- }
+ }
+
+ @if (currentProduct.otherGlMappingsMap.expenses?.length) {
+
Expenses
+
+
+ Account Name
+ {{ row.accountName }}
+
+
+ GL Account
+ {{ row.glCode || row.glAccountId }}
+
+
+
+
+ }
+
+ @if (currentProduct.otherGlMappingsMap.income?.length) {
+
Income
+
+
+ Account Name
+ {{ row.accountName }}
+
+
+ GL Account
+ {{ row.glCode || row.glAccountId }}
+
+
+
+
+ }
+
+ }
+
+ @if (
+ currentProduct.paymentChannelToFundSourceMappings?.length ||
+ currentProduct.feeToIncomeAccountMappings?.length ||
+ currentProduct.penaltyToIncomeAccountMappings?.length
+ ) {
+
+
{{ 'labels.inputs.Advanced Accounting Rules' | translate }}
+
+ @if (currentProduct.paymentChannelToFundSourceMappings?.length) {
+
+
{{ 'labels.inputs.Fund Sources for Payment Channels' | translate }}
+
+
+ {{ 'labels.inputs.Payment Type' | translate }}
+
+ {{ paymentFundSource.paymentType.name }}
+
+
+
+ {{ 'labels.inputs.Fund Source' | translate }}
+
+ ({{ paymentFundSource.fundSourceAccount.glCode }}) {{ paymentFundSource.fundSourceAccount.name }}
+
+
+
+
+
+
+ }
+ @if (currentProduct.feeToIncomeAccountMappings?.length) {
+
+
{{ 'labels.inputs.Fees to Specific Income Accounts' | translate }}
+
+
+ {{ 'labels.inputs.Fee/Charge' | translate }}
+
+ {{ fee.charge.name }}
+
+
+
+ {{ 'labels.inputs.Income Account' | translate }}
+ ({{ fee.incomeAccount.glCode }}) {{ fee.incomeAccount.name }}
+
+
+
+
+
+ }
+ @if (currentProduct.penaltyToIncomeAccountMappings?.length) {
+
+
{{ 'labels.inputs.Penalties to Specific Income Accounts' | translate }}
+
+
+ {{ 'labels.inputs.Penalty' | translate }}
+
+ {{ penalty.charge.name }}
+
+
+
+ {{ 'labels.inputs.Income Account' | translate }}
+
+ ({{ penalty.incomeAccount.glCode }}) {{ penalty.incomeAccount.name }}
+
+
+
+
+
+
+ }
+
+ }
+
diff --git a/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.scss b/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.scss
index b4c371673..367f0cc1d 100644
--- a/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.scss
+++ b/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.scss
@@ -1,5 +1,22 @@
+/**
+ * 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/.
+ */
+
@use 'assets/styles/helper';
@use 'assets/styles/colours' as *;
+@use 'assets/styles/product-view' as pv;
+
+@include pv.product-view-styles;
+
+/* Current-account-specific green accent */
+:host {
+ --pv-accent: #15803d;
+ --pv-accent-l: #dcfce7;
+}
.tab-container {
padding: 1%;
diff --git a/src/app/products/fixed-deposit-products/fixed-deposit-products.component.html b/src/app/products/fixed-deposit-products/fixed-deposit-products.component.html
index 0443d6386..6659f570b 100644
--- a/src/app/products/fixed-deposit-products/fixed-deposit-products.component.html
+++ b/src/app/products/fixed-deposit-products/fixed-deposit-products.component.html
@@ -6,52 +6,167 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
-
-
-
-
- {{ 'labels.buttons.Create Fixed Deposit Product' | translate }}
-
-
-
-
-
-
-
- {{ 'labels.inputs.Filter' | translate }}
-
-
-
+
+
-
-
-
- {{ 'labels.inputs.Name' | translate }}
- {{ fixedDepositProduct.name }}
-
+
+
+
+
+
{{ totalCount | number }}
+
Total FD Products
+
+
+
+
+
+
{{ currencyCount | number }}
+
{{ currencyCount === 1 ? 'Currency' : 'Currencies' }} in use
+
+
+
+
+
+
{{ filteredCount | number }}
+
Showing after filter
+
+
+
-
- {{ 'labels.inputs.Short Name' | translate }}
- {{ fixedDepositProduct.shortName }}
-
+
+ @if (totalCount === 0) {
+
+
+
No fixed-deposit products yet
+
Create your first FD product to enable term-deposit account onboarding.
+
+
+ {{ 'labels.buttons.Create Fixed Deposit Product' | translate }}
+
+
+ } @else {
+
+
+
+ {{ 'labels.inputs.Name' | translate }}
+
+
+
+
-
+
+ {{ 'labels.inputs.Short Name' | translate }}
+
+ {{ p.shortName }}
+
+
+
+
+ Currency
+
+ @if (p.currency?.code) {
+ {{ p.currency.code }}
+ } @else {
+ —
+ }
+
+
+
+
+ Actions
+
+
+ View
+
+
+ Edit
+
+
+
+
+
+
+
+
+ @if (filteredCount === 0 && totalCount > 0) {
+
+
+ No products match your filter.
+
+ }
+
+
+
+ }
- {{ 'labels.buttons.Create Fixed Deposit Product' | translate }}
- {{ 'labels.text.Allows you to create new fixed deposit product' | translate }}
+ {{ 'labels.heading.Create Fixed Deposit Product' | translate }}
+ {{ 'labels.text.This option allows you to create new fixed deposit product.' | translate }}
{{ 'labels.buttons.Close' | translate }}
@@ -70,7 +185,7 @@
- {{ 'labels.text.Search bar to filter fixed deposit products.' | translate }}
+ {{ 'labels.heading.Search bar to filter fixed deposit products.' | translate }}
{{ 'labels.buttons.Close' | translate }}
@@ -93,12 +208,7 @@
-
+ {{ 'labels.heading.List of fixed deposit products in the organization' | translate }}
{{ 'labels.buttons.Close' | translate }}
diff --git a/src/app/products/fixed-deposit-products/fixed-deposit-products.component.scss b/src/app/products/fixed-deposit-products/fixed-deposit-products.component.scss
index 567dcb2f8..59dbd3032 100644
--- a/src/app/products/fixed-deposit-products/fixed-deposit-products.component.scss
+++ b/src/app/products/fixed-deposit-products/fixed-deposit-products.component.scss
@@ -6,10 +6,12 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
-table {
- width: 100%;
+@use 'assets/styles/product-list' as pl;
- .select-row:hover {
- cursor: pointer;
- }
+@include pl.product-list-styles;
+
+/* FD-specific teal accent */
+:host {
+ --pl-accent: #0e7e6b;
+ --pl-accent-l: #ccfbf1;
}
diff --git a/src/app/products/fixed-deposit-products/fixed-deposit-products.component.ts b/src/app/products/fixed-deposit-products/fixed-deposit-products.component.ts
index 9d15848b7..d22851c34 100644
--- a/src/app/products/fixed-deposit-products/fixed-deposit-products.component.ts
+++ b/src/app/products/fixed-deposit-products/fixed-deposit-products.component.ts
@@ -32,6 +32,7 @@ import { of } from 'rxjs';
import { PopoverService } from '../../configuration-wizard/popover/popover.service';
import { ConfigurationWizardService } from '../../configuration-wizard/configuration-wizard.service';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+import { MatTooltip } from '@angular/material/tooltip';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
@@ -56,7 +57,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatHeaderRow,
MatRowDef,
MatRow,
- MatPaginator
+ MatPaginator,
+ MatTooltip
]
})
export class FixedDepositProductsComponent implements OnInit, AfterViewInit {
@@ -66,15 +68,31 @@ export class FixedDepositProductsComponent implements OnInit, AfterViewInit {
private popoverService = inject(PopoverService);
/** Fixed deposit products data. */
- fixedDepositProductData: any;
+ fixedDepositProductData: any[] = [];
/** Columns to be displayed in fixed deposit products table. */
displayedColumns: string[] = [
'name',
- 'shortName'
+ 'shortName',
+ 'currency',
+ 'actions'
];
/** Data source for fixed deposit products table. */
dataSource: MatTableDataSource;
+ filteredCount = 0;
+
+ get totalCount(): number {
+ return this.fixedDepositProductData?.length || 0;
+ }
+
+ get currencyCount(): number {
+ const set = new Set();
+ for (const p of this.fixedDepositProductData || []) {
+ if (p?.currency?.code) set.add(p.currency.code);
+ }
+ return set.size;
+ }
+
/** Paginator for fixed deposit products table. */
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
/** Sorter for fixed deposit products table. */
@@ -124,6 +142,8 @@ export class FixedDepositProductsComponent implements OnInit, AfterViewInit {
this.dataSource = new MatTableDataSource(this.fixedDepositProductData);
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
+ this.filteredCount = this.totalCount;
+ this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length));
}
/**
diff --git a/src/app/products/fixed-deposit-products/view-fixed-deposit-product/fixed-deposit-general-tab/fixed-deposit-general-tab.component.html b/src/app/products/fixed-deposit-products/view-fixed-deposit-product/fixed-deposit-general-tab/fixed-deposit-general-tab.component.html
index 7afb66932..85726f0f4 100644
--- a/src/app/products/fixed-deposit-products/view-fixed-deposit-product/fixed-deposit-general-tab/fixed-deposit-general-tab.component.html
+++ b/src/app/products/fixed-deposit-products/view-fixed-deposit-product/fixed-deposit-general-tab/fixed-deposit-general-tab.component.html
@@ -6,531 +6,524 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
-
-
-
- {{ 'labels.buttons.Edit' | translate }}
-
-
-
-
-
-
-
{{ fixedDepositProductData.name }}
-
-
-
-
{{ 'labels.inputs.Details' | translate }}
-
-
-
-
-
{{ 'labels.inputs.Short Name' | translate }}:
-
{{ fixedDepositProductData.shortName }}
+
+
+
+
+
+
+
Fixed Deposit Product
+
{{ fixedDepositProductData.name }}
+
+ {{ fixedDepositProductData.shortName }}
+ @if (fixedDepositProductData.currency?.code) {
+ {{ fixedDepositProductData.currency.code }}
+ }
-
@if (fixedDepositProductData.description) {
-
- {{ 'labels.inputs.Description' | translate }}:
- {{ fixedDepositProductData.description }}
-
+
{{ fixedDepositProductData.description }}
}
+
+
+
+ {{ 'labels.buttons.Edit' | translate }}
+
+
-
{{ 'labels.inputs.Currency' | translate }}
+
+
{{ 'labels.inputs.Details' | translate }}
-
+
-
-
{{ 'labels.heading.Currency' | translate }}:
+
+ {{ 'labels.inputs.Short Name' | translate }}:
+ {{ fixedDepositProductData.shortName }}
+
+
+ @if (fixedDepositProductData.description) {
+
+ {{ 'labels.inputs.Description' | translate }}:
+ {{ fixedDepositProductData.description }}
+
+ }
+
+
{{ 'labels.inputs.Currency' | translate }}
+
+
+
+
+ {{ 'labels.heading.Currency' | translate }}:
+ ({{ fixedDepositProductData.currency.code }}) {{ fixedDepositProductData.currency.name }}
+
+
+
+ {{ 'labels.inputs.Decimal Places' | translate }}:
+ {{ fixedDepositProductData.currency.decimalPlaces }}
+
+
+
+ {{ 'labels.inputs.Currency in multiples of' | translate }}:
+ {{ fixedDepositProductData.currency.inMultiplesOf | formatNumber }}
+
+
+
{{ 'labels.heading.Terms' | translate }}
+
+
+
+
+ {{ 'labels.inputs.Deposit Amount' | translate }}:
+ {{
+ fixedDepositProductData.depositAmount +
+ ' (Min: ' +
+ (fixedDepositProductData.minDepositAmount
+ ? fixedDepositProductData.minDepositAmount
+ : fixedDepositProductData.depositAmount) +
+ ', Max: ' +
+ (fixedDepositProductData.maxDepositAmount
+ ? fixedDepositProductData.maxDepositAmount
+ : fixedDepositProductData.depositAmount) +
+ ')'
+ }}
+
+
+
+ {{ 'labels.inputs.Interest Compounding Period' | translate }}:
+ {{ fixedDepositProductData.interestCompoundingPeriodType.value }}
+
+
+
+ {{ 'labels.inputs.Interest Posting Period' | translate }}:
+ {{ fixedDepositProductData.interestPostingPeriodType.value }}
+
+
+
+ {{ 'labels.inputs.Interest Calculated Using' | translate }}:
+ {{ fixedDepositProductData.interestCalculationType.value }}
+
+
+
+ {{ 'labels.inputs.Days in Year' | translate }}:
+ {{ fixedDepositProductData.interestCalculationDaysInYearType.value }}
+
+
+
{{ 'labels.heading.Settings' | translate }}
+
+
+
+ @if (fixedDepositProductData.lockinPeriodFrequency) {
+
+ {{ 'labels.inputs.Lock-in Period' | translate }}:
+
+ {{ fixedDepositProductData.lockinPeriodFrequency }}
+ {{ fixedDepositProductData.lockinPeriodFrequencyType.value }}
+
+ }
+
+ @if (fixedDepositProductData.minDepositTerm) {
+
+ {{ 'labels.inputs.Minimum Deposit Term' | translate }}:
+ @if (fixedDepositProductData.minDepositTerm !== undefined) {
({{ fixedDepositProductData.currency.code }}) {{ fixedDepositProductData.currency.name }} {{ fixedDepositProductData.minDepositTerm }}
+
+ {{ fixedDepositProductData.minDepositTermType.value }}
-
+ }
+
+ }
-
- {{ 'labels.inputs.Decimal Places' | translate }}:
- {{ fixedDepositProductData.currency.decimalPlaces }}
-
+ @if (fixedDepositProductData.maxDepositTerm) {
+
+ {{ 'labels.inputs.Maximum Deposit Term' | translate }}:
+ @if (fixedDepositProductData.maxDepositTerm !== undefined) {
+ {{ fixedDepositProductData.maxDepositTerm }}
+
+ {{ fixedDepositProductData.maxDepositTermType.value }}
+ }
+
+ }
-
- {{ 'labels.inputs.Currency in multiples of' | translate }}:
- {{ fixedDepositProductData.currency.inMultiplesOf | formatNumber }}
-
+ @if (fixedDepositProductData.inMultiplesOfDepositTerm) {
+
+ {{ 'labels.inputs.Deposit Term In multiple of' | translate }}:
+ {{ fixedDepositProductData.inMultiplesOfDepositTerm }}
+ -
+ {{ fixedDepositProductData.inMultiplesOfDepositTermType.value }}
+
+ }
-
{{ 'labels.heading.Terms' | translate }}
+
+ {{ 'labels.inputs.Apply Penal Interest (less)' | translate }}:
+ {{ fixedDepositProductData.preClosurePenalApplicable | yesNo }}
+
+ @if (fixedDepositProductData.preClosurePenalApplicable) {
+
+ {{ 'labels.inputs.Penal Interest' | translate }} (%):
+ {{ fixedDepositProductData.preClosurePenalInterest }} % On
+ {{ fixedDepositProductData.preClosurePenalInterestOnType.value }}
+
+ }
+
+ @if (fixedDepositProductData.minBalanceForInterestCalculation) {
+
+ {{ 'labels.inputs.Balance Required for Interest Calculation' | translate }}:
+ {{ fixedDepositProductData.minBalanceForInterestCalculation | formatNumber }}
+
+ }
+
+
+ {{ 'labels.inputs.Withhold Tax is Applicable' | translate }}:
+ {{ fixedDepositProductData.withHoldTax | yesNo }}
+
+
+ @if (fixedDepositProductData.withHoldTax) {
+
+ {{ 'labels.inputs.Withhold Tax group' | translate }}:
+ {{ fixedDepositProductData.taxGroup.name }}
+
+ }
+
+ @if (fixedDepositProductData.activeChart) {
+
+
{{ 'labels.heading.Interest Rate Chart' | translate }}
-
-
- {{ 'labels.inputs.Deposit Amount' | translate }}:
- {{
- fixedDepositProductData.depositAmount +
- ' (Min: ' +
- (fixedDepositProductData.minDepositAmount
- ? fixedDepositProductData.minDepositAmount
- : fixedDepositProductData.depositAmount) +
- ', Max: ' +
- (fixedDepositProductData.maxDepositAmount
- ? fixedDepositProductData.maxDepositAmount
- : fixedDepositProductData.depositAmount) +
- ')'
- }}
-
-
-
- {{ 'labels.inputs.Interest Compounding Period' | translate }}:
- {{ fixedDepositProductData.interestCompoundingPeriodType.value }}
-
-
-
- {{ 'labels.inputs.Interest Posting Period' | translate }}:
- {{ fixedDepositProductData.interestPostingPeriodType.value }}
-
-
-
- {{ 'labels.inputs.Interest Calculated Using' | translate }}:
- {{ fixedDepositProductData.interestCalculationType.value }}
-
-
-
- {{ 'labels.inputs.Days in Year' | translate }}:
- {{ fixedDepositProductData.interestCalculationDaysInYearType.value }}
-
-
-
{{ 'labels.heading.Settings' | translate }}
-
-
-
- @if (fixedDepositProductData.lockinPeriodFrequency) {
-
- {{ 'labels.inputs.Lock-in Period' | translate }}:
-
- {{ fixedDepositProductData.lockinPeriodFrequency }}
- {{ fixedDepositProductData.lockinPeriodFrequencyType.value }}
-
- }
-
- @if (fixedDepositProductData.minDepositTerm) {
-
- {{ 'labels.inputs.Minimum Deposit Term' | translate }}:
- @if (fixedDepositProductData.minDepositTerm !== undefined) {
- {{ fixedDepositProductData.minDepositTerm }}
-
- {{ fixedDepositProductData.minDepositTermType.value }}
- }
-
- }
-
- @if (fixedDepositProductData.maxDepositTerm) {
-
- {{ 'labels.inputs.Maximum Deposit Term' | translate }}:
- @if (fixedDepositProductData.maxDepositTerm !== undefined) {
- {{ fixedDepositProductData.maxDepositTerm }}
-
- {{ fixedDepositProductData.maxDepositTermType.value }}
- }
-
- }
-
- @if (fixedDepositProductData.inMultiplesOfDepositTerm) {
-
- {{ 'labels.inputs.Deposit Term In multiple of' | translate }}:
- {{ fixedDepositProductData.inMultiplesOfDepositTerm }}
- -
- {{ fixedDepositProductData.inMultiplesOfDepositTermType.value }}
-
- }
-
-
- {{ 'labels.inputs.Apply Penal Interest (less)' | translate }}:
- {{ fixedDepositProductData.preClosurePenalApplicable | yesNo }}
-
-
- @if (fixedDepositProductData.preClosurePenalApplicable) {
-
- {{ 'labels.inputs.Penal Interest' | translate }} (%):
- {{ fixedDepositProductData.preClosurePenalInterest }} % On
- {{ fixedDepositProductData.preClosurePenalInterestOnType.value }}
-
- }
-
- @if (fixedDepositProductData.minBalanceForInterestCalculation) {
-
- {{ 'labels.inputs.Balance Required for Interest Calculation' | translate }}:
- {{ fixedDepositProductData.minBalanceForInterestCalculation | formatNumber }}
-
- }
-
-
- {{ 'labels.inputs.Withhold Tax is Applicable' | translate }}:
- {{ fixedDepositProductData.withHoldTax | yesNo }}
-
-
- @if (fixedDepositProductData.withHoldTax) {
-
- {{ 'labels.inputs.Withhold Tax group' | translate }}:
- {{ fixedDepositProductData.taxGroup.name }}
-
- }
-
- @if (fixedDepositProductData.activeChart) {
-
-
{{ 'labels.heading.Interest Rate Chart' | translate }}
-
-
- @if (fixedDepositProductData.activeChart.name) {
-
- {{ 'labels.inputs.Name' | translate }}:
- {{ fixedDepositProductData.activeChart.name }}
-
- }
- @if (fixedDepositProductData.activeChart.description) {
-
- {{ 'labels.inputs.Description' | translate }}:
- {{ fixedDepositProductData.activeChart.description }}
-
- }
-
- {{ 'labels.inputs.From Date' | translate }}:
- {{ fixedDepositProductData.activeChart.fromDate | dateFormat }}
-
- @if (fixedDepositProductData.activeChart.endDate) {
-
- {{ 'labels.inputs.End Date' | translate }}:
- {{ fixedDepositProductData.activeChart.endDate | dateFormat }}
-
- }
-
- {{ 'labels.inputs.Primary Grouping by Amount' | translate }}:
- {{ fixedDepositProductData.activeChart.isPrimaryGroupingByAmount | yesNo }}
-
-
-
- {{ 'labels.inputs.Period' | translate }}
-
- @if (chartSlab.fromPeriod !== undefined) {
- {{ chartSlab.fromPeriod }} - {{ chartSlab.toPeriod }} {{ chartSlab.periodType.value }}
- }
- @if (chartSlab.fromPeriod === undefined) {
-
- }
-
-
-
- {{ 'labels.inputs.Amount Range' | translate }}
-
- @if (chartSlab.amountRangeFrom !== undefined) {
- {{ chartSlab.amountRangeFrom }} - {{ chartSlab.amountRangeTo }}
- }
- @if (chartSlab.amountRangeFrom === undefined) {
-
- }
-
-
-
- {{ 'labels.inputs.Interest' | translate }}
- {{ chartSlab.annualInterestRate }} %
-
-
- {{ 'labels.inputs.Description' | translate }}
-
- {{ chartSlab.description }}
-
-
-
- {{ 'labels.inputs.Actions' | translate }}
-
-
- @if (expandChartSlabIndex !== chartSlabIndex) {
-
-
- {{ 'labels.buttons.View Incentives' | translate }}
-
- }
- @if (expandChartSlabIndex === chartSlabIndex) {
-
-
- {{ 'labels.buttons.Hide Incentives' | translate }}
-
- }
-
-
-
-
-
-
-
-
- {{ 'labels.heading.Incentives' | translate }}
-
-
-
- {{ 'labels.inputs.Entity Type' | translate }}
-
- {{
- incentive.entityType
- | find: fixedDepositProductsTemplate.chartTemplate.entityTypeOptions : 'id' : 'value'
- }}
-
-
-
- {{ 'labels.inputs.Attribute Name' | translate }}
-
- {{
- incentive.attributeName
- | find
- : fixedDepositProductsTemplate.chartTemplate.attributeNameOptions
- : 'id'
- : 'value'
- }}
-
-
-
- {{ 'labels.inputs.Condition Type' | translate }}
-
- {{
- incentive.conditionType
- | find
- : fixedDepositProductsTemplate.chartTemplate.conditionTypeOptions
- : 'id'
- : 'value'
- }}
-
-
-
- {{ 'labels.inputs.Attribute Value' | translate }}
-
- @switch (incentive.attributeName) {
- @case (2) {
- {{
- incentive.attributeValue
- | find: fixedDepositProductsTemplate.chartTemplate.genderOptions : 'id' : 'name'
- }}
- }
- @case (3) {
- {{ incentive.attributeValue }}
- }
- @case (4) {
- {{
- incentive.attributeValue
- | find
- : fixedDepositProductsTemplate.chartTemplate.clientTypeOptions
- : 'id'
- : 'name'
- }}
- }
- @case (5) {
- {{
- incentive.attributeValue
- | find
- : fixedDepositProductsTemplate.chartTemplate.clientClassificationOptions
- : 'id'
- : 'name'
- }}
- }
- }
-
-
-
- {{ 'labels.inputs.Incentive Type' | translate }}
-
- {{
- incentive.incentiveType
- | find
- : fixedDepositProductsTemplate.chartTemplate.incentiveTypeOptions
- : 'id'
- : 'value'
- }}
-
-
-
- {{ 'labels.inputs.Amount' | translate }}
-
- {{ incentive.amount | formatNumber }}
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+ @if (fixedDepositProductData.activeChart.name) {
+
+ {{ 'labels.inputs.Name' | translate }}:
+ {{ fixedDepositProductData.activeChart.name }}
+ }
+ @if (fixedDepositProductData.activeChart.description) {
+
+ {{ 'labels.inputs.Description' | translate }}:
+ {{ fixedDepositProductData.activeChart.description }}
+
+ }
+
+ {{ 'labels.inputs.From Date' | translate }}:
+ {{ fixedDepositProductData.activeChart.fromDate | dateFormat }}
- }
+ @if (fixedDepositProductData.activeChart.endDate) {
+
+ {{ 'labels.inputs.End Date' | translate }}:
+ {{ fixedDepositProductData.activeChart.endDate | dateFormat }}
+
+ }
+
+ {{ 'labels.inputs.Primary Grouping by Amount' | translate }}:
+ {{ fixedDepositProductData.activeChart.isPrimaryGroupingByAmount | yesNo }}
+
+
+
+ {{ 'labels.inputs.Period' | translate }}
+
+ @if (chartSlab.fromPeriod !== undefined) {
+ {{ chartSlab.fromPeriod }} - {{ chartSlab.toPeriod }} {{ chartSlab.periodType.value }}
+ }
+ @if (chartSlab.fromPeriod === undefined) {
+
+ }
+
+
+
+ {{ 'labels.inputs.Amount Range' | translate }}
+
+ @if (chartSlab.amountRangeFrom !== undefined) {
+ {{ chartSlab.amountRangeFrom }} - {{ chartSlab.amountRangeTo }}
+ }
+ @if (chartSlab.amountRangeFrom === undefined) {
+
+ }
+
+
+
+ {{ 'labels.inputs.Interest' | translate }}
+ {{ chartSlab.annualInterestRate }} %
+
+
+ {{ 'labels.inputs.Description' | translate }}
+
+ {{ chartSlab.description }}
+
+
+
+ {{ 'labels.inputs.Actions' | translate }}
+
+
+ @if (expandChartSlabIndex !== chartSlabIndex) {
+
+
+ {{ 'labels.buttons.View Incentives' | translate }}
+
+ }
+ @if (expandChartSlabIndex === chartSlabIndex) {
+
+
+ {{ 'labels.buttons.Hide Incentives' | translate }}
+
+ }
+
+
+
+
+
+
+
+
+ {{ 'labels.heading.Incentives' | translate }}
+
+
+
+ {{ 'labels.inputs.Entity Type' | translate }}
+
+ {{
+ incentive.entityType
+ | find: fixedDepositProductsTemplate.chartTemplate.entityTypeOptions : 'id' : 'value'
+ }}
+
+
+
+ {{ 'labels.inputs.Attribute Name' | translate }}
+
+ {{
+ incentive.attributeName
+ | find: fixedDepositProductsTemplate.chartTemplate.attributeNameOptions : 'id' : 'value'
+ }}
+
+
+
+ {{ 'labels.inputs.Condition Type' | translate }}
+
+ {{
+ incentive.conditionType
+ | find: fixedDepositProductsTemplate.chartTemplate.conditionTypeOptions : 'id' : 'value'
+ }}
+
+
+
+ {{ 'labels.inputs.Attribute Value' | translate }}
+
+ @switch (incentive.attributeName) {
+ @case (2) {
+ {{
+ incentive.attributeValue
+ | find: fixedDepositProductsTemplate.chartTemplate.genderOptions : 'id' : 'name'
+ }}
+ }
+ @case (3) {
+ {{ incentive.attributeValue }}
+ }
+ @case (4) {
+ {{
+ incentive.attributeValue
+ | find: fixedDepositProductsTemplate.chartTemplate.clientTypeOptions : 'id' : 'name'
+ }}
+ }
+ @case (5) {
+ {{
+ incentive.attributeValue
+ | find
+ : fixedDepositProductsTemplate.chartTemplate.clientClassificationOptions
+ : 'id'
+ : 'name'
+ }}
+ }
+ }
+
+
+
+ {{ 'labels.inputs.Incentive Type' | translate }}
+
+ {{
+ incentive.incentiveType
+ | find: fixedDepositProductsTemplate.chartTemplate.incentiveTypeOptions : 'id' : 'value'
+ }}
+
+
+
+ {{ 'labels.inputs.Amount' | translate }}
+
+ {{ incentive.amount | formatNumber }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
- @if (fixedDepositProductData.charges.length) {
-
-
{{ 'labels.inputs.Charges' | translate }}
-
-
-
- {{ 'labels.inputs.Name' | translate }}
-
- {{ charge.name }}
+ @if (fixedDepositProductData.charges.length) {
+
+
{{ 'labels.inputs.Charges' | translate }}
+
+
+
+ {{ 'labels.inputs.Name' | translate }}
+
+ {{ charge.name }}
+
+
+
+ {{ 'labels.inputs.Type' | translate }}
+
+ {{ charge.chargeCalculationType.value }}
+
+
+
+ {{ 'labels.inputs.Amount' | translate }}
+
+ {{ charge.amount | formatNumber }}
+
+
+
+ {{ 'labels.inputs.Collected On' | translate }}
+
+ {{ charge.chargeTimeType.value }}
+
+
+
+
+
+
+ }
+
+ {{ 'labels.heading.Accounting' | translate }}
+
+
+
+
+
+
+ @if (
+ fixedDepositProductData.advancedAccountingRules &&
+ (fixedDepositProductData.paymentChannelToFundSourceMappings?.length ||
+ fixedDepositProductData.feeToIncomeAccountMappings?.length ||
+ fixedDepositProductData.penaltyToIncomeAccountMappings?.length)
+ ) {
+
+
{{ 'labels.heading.Advanced Accounting Rules' | translate }}
+
+
+ @if (fixedDepositProductData.paymentChannelToFundSourceMappings?.length) {
+
+
+ {{ 'labels.inputs.Configure Fund Sources for Payment Channels' | translate }}
+
+
+
+ {{ 'labels.inputs.Payment Type' | translate }}
+
+ {{ paymentFundSource.paymentType.name }}
-
- {{ 'labels.inputs.Type' | translate }}
-
- {{ charge.chargeCalculationType.value }}
+
+ {{ 'labels.inputs.Fund Source' | translate }}
+
+ {{ paymentFundSource.fundSourceAccount.name }}
-
- {{ 'labels.inputs.Amount' | translate }}
-
- {{ charge.amount | formatNumber }}
-
-
-
- {{ 'labels.inputs.Collected On' | translate }}
-
- {{ charge.chargeTimeType.value }}
-
-
-
-
+
+
}
-
-
{{ 'labels.heading.Accounting' | translate }}
-
-
-
-
-
-
- @if (
- fixedDepositProductData.advancedAccountingRules &&
- (fixedDepositProductData.paymentChannelToFundSourceMappings?.length ||
- fixedDepositProductData.feeToIncomeAccountMappings?.length ||
- fixedDepositProductData.penaltyToIncomeAccountMappings?.length)
- ) {
-
-
{{ 'labels.heading.Advanced Accounting Rules' | translate }}
-
-
- @if (fixedDepositProductData.paymentChannelToFundSourceMappings?.length) {
-
-
- {{ 'labels.inputs.Configure Fund Sources for Payment Channels' | translate }}
-
-
-
- {{ 'labels.inputs.Payment Type' | translate }}
-
- {{ paymentFundSource.paymentType.name }}
-
-
-
- {{ 'labels.inputs.Fund Source' | translate }}
-
- {{ paymentFundSource.fundSourceAccount.name }}
-
-
-
-
-
-
- }
- @if (fixedDepositProductData.feeToIncomeAccountMappings?.length) {
-
-
{{ 'labels.inputs.Fees to Specific Income Accounts' | translate }}
-
-
- {{ 'labels.inputs.Fees' | translate }}
-
- {{ feesIncome.charge.name }}
-
-
-
- {{ 'labels.inputs.Income Account' | translate }}
-
- {{ feesIncome.incomeAccount.name }}
-
-
-
-
-
-
- }
- @if (fixedDepositProductData.penaltyToIncomeAccountMappings?.length) {
-
-
- {{ 'labels.heading.Penalties to Specific Income Accounts' | translate }}
-
-
-
- {{ 'labels.inputs.Penalty' | translate }}
-
- {{ penaltyIncome.charge.name }}
-
-
-
- {{ 'labels.inputs.Income Account' | translate }}
-
- {{ penaltyIncome.incomeAccount.name }}
-
-
-
-
-
-
- }
+ @if (fixedDepositProductData.feeToIncomeAccountMappings?.length) {
+
+
{{ 'labels.inputs.Fees to Specific Income Accounts' | translate }}
+
+
+ {{ 'labels.inputs.Fees' | translate }}
+
+ {{ feesIncome.charge.name }}
+
+
+
+ {{ 'labels.inputs.Income Account' | translate }}
+
+ {{ feesIncome.incomeAccount.name }}
+
+
+
+
+
+
+ }
+ @if (fixedDepositProductData.penaltyToIncomeAccountMappings?.length) {
+
+
+ {{ 'labels.heading.Penalties to Specific Income Accounts' | translate }}
+
+
+
+ {{ 'labels.inputs.Penalty' | translate }}
+
+ {{ penaltyIncome.charge.name }}
+
+
+
+ {{ 'labels.inputs.Income Account' | translate }}
+
+ {{ penaltyIncome.incomeAccount.name }}
+
+
+
+
+
}
-
-
+ }
+
diff --git a/src/app/products/fixed-deposit-products/view-fixed-deposit-product/fixed-deposit-general-tab/fixed-deposit-general-tab.component.scss b/src/app/products/fixed-deposit-products/view-fixed-deposit-product/fixed-deposit-general-tab/fixed-deposit-general-tab.component.scss
index 83a6fded5..8402cc126 100644
--- a/src/app/products/fixed-deposit-products/view-fixed-deposit-product/fixed-deposit-general-tab/fixed-deposit-general-tab.component.scss
+++ b/src/app/products/fixed-deposit-products/view-fixed-deposit-product/fixed-deposit-general-tab/fixed-deposit-general-tab.component.scss
@@ -8,6 +8,15 @@
@use 'assets/styles/helper';
@use 'assets/styles/colours' as *;
+@use 'assets/styles/product-view' as pv;
+
+@include pv.product-view-styles;
+
+/* FD-specific teal accent */
+:host {
+ --pv-accent: #0e7e6b;
+ --pv-accent-l: #ccfbf1;
+}
.container {
width: 100%;
diff --git a/src/app/products/loan-products/loan-products.component.html b/src/app/products/loan-products/loan-products.component.html
index 0c1887f6f..676738f06 100644
--- a/src/app/products/loan-products/loan-products.component.html
+++ b/src/app/products/loan-products/loan-products.component.html
@@ -6,73 +6,195 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
-
-
-
-
- {{ 'labels.buttons.Create Loan Product' | translate }}
-
-
-
-
-
- {{ 'labels.buttons.Import' | translate }}
-
-
-
-
-
-
-
- {{ 'labels.inputs.Filter' | translate }}
-
-
-
+
+
+
-
-
-
- {{ 'labels.inputs.Name' | translate }}
- {{ loanProduct.name }}
-
+
+
+
+
+
+
{{ totalCount | number }}
+
Total Loan Products
+
+
+
+
+
+
{{ activeCount | number }}
+
Active
+
+
+
+
+
+
{{ filteredCount | number }}
+
Showing after filter
+
+
+
-
- {{ 'labels.inputs.Short Name' | translate }}
- {{ loanProduct.shortName }}
-
+
+
+
-
- {{ 'labels.inputs.Expiry Date' | translate }}
- {{ loanProduct.closeDate | dateFormat }}
-
+ @if (totalCount === 0) {
+
+
+
No loan products yet
+
Create your first loan product to enable loan account onboarding for clients.
+
+
+ {{ 'labels.buttons.Create Loan Product' | translate }}
+
+
+ } @else {
+
+
+
+ {{ 'labels.inputs.Name' | translate }}
+
+
+
+
-
- {{ 'labels.inputs.Status' | translate }}
-
-
-
+
+ {{ 'labels.inputs.Short Name' | translate }}
+
+ {{ p.shortName }}
+
+
+
+
+ {{ 'labels.inputs.Expiry Date' | translate }}
+
+ @if (p.closeDate) {
+ {{ p.closeDate | dateFormat }}
+ } @else {
+ —
+ }
+
+
+
+
+ {{ 'labels.inputs.Status' | translate }}
+
+
+
+ {{
+ p.status === 'loanProduct.active'
+ ? ('labels.inputs.Active' | translate)
+ : ('labels.catalogs.Inactive' | translate)
+ }}
+
+
+
+
+
+ Actions
+
+
+ View
+
+
+ Edit
+
+
+
+
+
+
+
+
+ @if (filteredCount === 0 && totalCount > 0) {
+
+
+ No products match your filter.
-
-
+ }
-
-
-
-
-
+
+
+ }
+
{{ 'labels.heading.Create loan product' | translate }}
{{ 'labels.text.This option allows you to create new loan product.' | translate }}
diff --git a/src/app/products/loan-products/loan-products.component.scss b/src/app/products/loan-products/loan-products.component.scss
index 567dcb2f8..e15399058 100644
--- a/src/app/products/loan-products/loan-products.component.scss
+++ b/src/app/products/loan-products/loan-products.component.scss
@@ -6,10 +6,12 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
-table {
- width: 100%;
+@use 'assets/styles/product-list' as pl;
- .select-row:hover {
- cursor: pointer;
- }
+@include pl.product-list-styles;
+
+/* Loan-specific accent tint (amber/orange) */
+:host {
+ --pl-accent: #b45309;
+ --pl-accent-l: #fef3c7;
}
diff --git a/src/app/products/loan-products/loan-products.component.ts b/src/app/products/loan-products/loan-products.component.ts
index c3733f68b..f16a3bff1 100644
--- a/src/app/products/loan-products/loan-products.component.ts
+++ b/src/app/products/loan-products/loan-products.component.ts
@@ -78,15 +78,30 @@ export class LoanProductsComponent implements OnInit, AfterViewInit {
private settingsService = inject(SettingsService);
private errorHandler = inject(ErrorHandlerService);
- loanProductsData: any;
+ loanProductsData: any[] = [];
displayedColumns: string[] = [
'name',
'shortName',
'closeDate',
- 'status'
+ 'status',
+ 'actions'
];
dataSource: MatTableDataSource;
+ filteredCount = 0;
+
+ get totalCount(): number {
+ return this.loanProductsData?.length || 0;
+ }
+
+ get activeCount(): number {
+ return (this.loanProductsData || []).filter((p) => p?.status === 'loanProduct.active').length;
+ }
+
+ get inactiveCount(): number {
+ return this.totalCount - this.activeCount;
+ }
+
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
@ViewChild(MatSort, { static: true }) sort: MatSort;
@@ -115,10 +130,12 @@ export class LoanProductsComponent implements OnInit, AfterViewInit {
this.dataSource = new MatTableDataSource(this.loanProductsData);
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
+ this.filteredCount = this.totalCount;
+ this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length));
}
applyFilter(filterValue: string) {
- this.dataSource.filter = filterValue.trim().toLowerCase();
+ this.dataSource.filter = (filterValue || '').trim().toLowerCase();
}
/**
diff --git a/src/app/products/loan-products/view-loan-product/general-tab/general-tab.component.html b/src/app/products/loan-products/view-loan-product/general-tab/general-tab.component.html
index 941a1855f..daadc5f00 100644
--- a/src/app/products/loan-products/view-loan-product/general-tab/general-tab.component.html
+++ b/src/app/products/loan-products/view-loan-product/general-tab/general-tab.component.html
@@ -6,26 +6,58 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
-
-
-
- {{ 'labels.buttons.Export' | translate }}
-
-
- {{ 'labels.buttons.Edit' | translate }}
-
-
+
+
+
+
+
+
+
Loan Product
+
{{ loanProduct.name }}
+
+ {{ loanProduct.shortName }}
+ @if (loanProduct.currency?.code) {
+ {{ loanProduct.currency.code }}
+ }
+
+
+ {{ loanProduct.status === 'loanProduct.active' ? 'Active' : 'Inactive' }}
+
+
+ @if (loanProduct.description) {
+
{{ loanProduct.description }}
+ }
+
+
+
+
+ {{ 'labels.buttons.Export' | translate }}
+
+
+ {{ 'labels.buttons.Edit' | translate }}
+
+
+
-
-
+
+
+
+
+
+
diff --git a/src/app/products/loan-products/view-loan-product/general-tab/general-tab.component.scss b/src/app/products/loan-products/view-loan-product/general-tab/general-tab.component.scss
index af9f4c075..9f020ced7 100644
--- a/src/app/products/loan-products/view-loan-product/general-tab/general-tab.component.scss
+++ b/src/app/products/loan-products/view-loan-product/general-tab/general-tab.component.scss
@@ -5,3 +5,13 @@
* 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/.
*/
+
+@use 'assets/styles/product-view' as pv;
+
+@include pv.product-view-styles;
+
+/* Loan-specific amber accent */
+:host {
+ --pv-accent: #b45309;
+ --pv-accent-l: #fef3c7;
+}
diff --git a/src/app/products/recurring-deposit-products/recurring-deposit-products.component.html b/src/app/products/recurring-deposit-products/recurring-deposit-products.component.html
index a2e463e62..7616d87e5 100644
--- a/src/app/products/recurring-deposit-products/recurring-deposit-products.component.html
+++ b/src/app/products/recurring-deposit-products/recurring-deposit-products.component.html
@@ -6,71 +6,177 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
-
-
-
-
- {{ 'labels.buttons.Create Recurring Deposit Product' | translate }}
-
-
-
-
-
- CBS Base Chart
-
-
-
-
-
-
-
- {{ 'labels.inputs.Filter' | translate }}
-
-
-
+
+
-
-
-
- {{ 'labels.inputs.Name' | translate }}
- {{ recurringDepositProduct.name }}
-
+
+
+
+
+
{{ totalCount | number }}
+
Total RD Products
+
+
+
+
+
+
{{ currencyCount | number }}
+
{{ currencyCount === 1 ? 'Currency' : 'Currencies' }} in use
+
+
+
+
+
+
{{ filteredCount | number }}
+
Showing after filter
+
+
+
-
- {{ 'labels.inputs.Short Name' | translate }}
- {{ recurringDepositProduct.shortName }}
-
+
+ @if (totalCount === 0) {
+
+
+
No recurring-deposit products yet
+
Create your first RD product to enable instalment-savings onboarding.
+
+
+ {{ 'labels.buttons.Create Recurring Deposit Product' | translate }}
+
+
+ } @else {
+
+
+
+ {{ 'labels.inputs.Name' | translate }}
+
+
+
+
-
+
+ {{ 'labels.inputs.Short Name' | translate }}
+
+ {{ p.shortName }}
+
+
+
+
+ Currency
+
+ @if (p.currency?.code) {
+ {{ p.currency.code }}
+ } @else {
+ —
+ }
+
+
+
+
+ Actions
+
+
+ View
+
+
+ Edit
+
+
+
+
+
+
+
+
+ @if (filteredCount === 0 && totalCount > 0) {
+
+
+ No products match your filter.
+
+ }
+
+
+
+ }
- {{ 'labels.heading.Create recurring deposit product' | translate }}
- {{ 'labels.text.This option allows you to create new recurring product.' | translate }}
+ {{ 'labels.heading.Create Recurring Deposit Product' | translate }}
+ {{ 'labels.text.This option allows you to create new recurring deposit product.' | translate }}
{{ 'labels.buttons.Close' | translate }}
@@ -114,12 +220,7 @@
-
+ {{ 'labels.heading.List of recurring deposit products in the organization' | translate }}
{{ 'labels.buttons.Close' | translate }}
diff --git a/src/app/products/recurring-deposit-products/recurring-deposit-products.component.scss b/src/app/products/recurring-deposit-products/recurring-deposit-products.component.scss
index 567dcb2f8..66a07a338 100644
--- a/src/app/products/recurring-deposit-products/recurring-deposit-products.component.scss
+++ b/src/app/products/recurring-deposit-products/recurring-deposit-products.component.scss
@@ -6,10 +6,12 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
-table {
- width: 100%;
+@use 'assets/styles/product-list' as pl;
- .select-row:hover {
- cursor: pointer;
- }
+@include pl.product-list-styles;
+
+/* RD-specific purple accent */
+:host {
+ --pl-accent: #6d28d9;
+ --pl-accent-l: #ede9fe;
}
diff --git a/src/app/products/recurring-deposit-products/recurring-deposit-products.component.ts b/src/app/products/recurring-deposit-products/recurring-deposit-products.component.ts
index e2b18181d..dd13b0517 100644
--- a/src/app/products/recurring-deposit-products/recurring-deposit-products.component.ts
+++ b/src/app/products/recurring-deposit-products/recurring-deposit-products.component.ts
@@ -73,15 +73,31 @@ export class RecurringDepositProductsComponent implements OnInit, AfterViewInit
private popoverService = inject(PopoverService);
/** Data table data. */
- recurringDepositProductData: any;
+ recurringDepositProductData: any[] = [];
/** Columns to be displayed in recurring deposit products table. */
displayedColumns: string[] = [
'name',
- 'shortName'
+ 'shortName',
+ 'currency',
+ 'actions'
];
/** Data source for recurring deposit products table. */
dataSource: MatTableDataSource;
+ filteredCount = 0;
+
+ get totalCount(): number {
+ return this.recurringDepositProductData?.length || 0;
+ }
+
+ get currencyCount(): number {
+ const set = new Set();
+ for (const p of this.recurringDepositProductData || []) {
+ if (p?.currency?.code) set.add(p.currency.code);
+ }
+ return set.size;
+ }
+
/** Paginator for recurring deposit products table. */
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
/** Sorter for recurring deposit products table. */
@@ -131,6 +147,8 @@ export class RecurringDepositProductsComponent implements OnInit, AfterViewInit
this.dataSource = new MatTableDataSource(this.recurringDepositProductData);
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
+ this.filteredCount = this.totalCount;
+ this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length));
}
/**
diff --git a/src/app/products/recurring-deposit-products/view-recurring-deposit-product/recurring-deposit-general-tab/recurring-deposit-general-tab.component.html b/src/app/products/recurring-deposit-products/view-recurring-deposit-product/recurring-deposit-general-tab/recurring-deposit-general-tab.component.html
index 1105b7f68..176eae34f 100644
--- a/src/app/products/recurring-deposit-products/view-recurring-deposit-product/recurring-deposit-general-tab/recurring-deposit-general-tab.component.html
+++ b/src/app/products/recurring-deposit-products/view-recurring-deposit-product/recurring-deposit-general-tab/recurring-deposit-general-tab.component.html
@@ -6,629 +6,656 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
-
-
- {{ 'labels.buttons.Edit' | translate }}
-
-
-
-
-
{{ recurringDepositProduct.name }}
-
-
-
-
{{ 'labels.inputs.Details' | translate }}
-
-
-
- {{ 'labels.inputs.Short Name' | translate }}:
- {{ recurringDepositProduct.shortName }}
-
-
- @if (recurringDepositProduct.description) {
-
- {{ 'labels.inputs.Description' | translate }}:
- {{ recurringDepositProduct.description }}
-
- }
-
-
-
Product Classification
-
-
- @if (recurringDepositProduct.accountType) {
-
- Account Type:
- {{ recurringDepositProduct.accountType }}
-
- }
- @if (recurringDepositProduct.customerSegment) {
-
- Customer Segment:
- {{ recurringDepositProduct.customerSegment }}
-
- }
- @if (recurringDepositProduct.glAccountId) {
-
- GL Account (Liability):
- {{ recurringDepositProduct.glAccountName || recurringDepositProduct.glAccountId }}
-
- }
- @if (recurringDepositProduct.effectiveTo) {
-
- Effective To:
- {{ recurringDepositProduct.effectiveTo | dateFormat }}
-
- }
- @if (recurringDepositProduct.endDate) {
-
- End Date:
- {{ recurringDepositProduct.endDate | dateFormat }}
-
- }
-
- Introducer Required:
- {{ recurringDepositProduct.introducerRequired | yesNo }}
-
-
- Deposit Account Link:
- {{ recurringDepositProduct.shareLinkingEnabled | yesNo }}
-
-
-
-
{{ 'labels.inputs.Currency' | translate }}
-
-
-
- {{ 'labels.inputs.Currency' | translate }}:
- ({{ recurringDepositProduct.currency.code }}) {{ recurringDepositProduct.currency.name }}
-
-
- {{ 'labels.inputs.Decimal Places' | translate }}:
- {{ recurringDepositProduct.currency.decimalPlaces }}
-
-
- Dormancy Alert:
- {{ recurringDepositProduct.dormancyAlert | yesNo }}
-
-
- Balance Swap Option:
- {{ recurringDepositProduct.balanceSwapOption | yesNo }}
-
-
- MSME:
- {{ recurringDepositProduct.msme | yesNo }}
-
-
- Nomination Facility:
- {{ recurringDepositProduct.nominationFacility | yesNo }}
-
-
- Auto Debit Facility:
- {{ recurringDepositProduct.autoDebitFacility | yesNo }}
-
-
-
-
{{ 'labels.inputs.Terms' | translate }}
-
-
-
- {{ 'labels.inputs.Deposit Amount' | translate }}:
- {{
- recurringDepositProduct.depositAmount +
- ' (Min: ' +
- (recurringDepositProduct.minDepositAmount || recurringDepositProduct.depositAmount) +
- ', Max: ' +
- (recurringDepositProduct.maxDepositAmount || recurringDepositProduct.depositAmount) +
- ')'
- }}
-
-
- {{ 'labels.inputs.Nominal Annual Interest' | translate }}:
- {{ recurringDepositProduct.nominalAnnualInterestRate | formatNumber }} %
-
- @if (recurringDepositProduct.effectiveDate) {
-
- Effective Date:
- {{ recurringDepositProduct.effectiveDate | dateFormat }}
-
- }
- @if (recurringDepositProduct.interestCalculationTypeName) {
-
- {{ 'labels.inputs.Interest Calculation Type' | translate }}:
- {{ recurringDepositProduct.interestCalculationTypeName }}
-
- }
-
- Interest Calculate:
- {{ recurringDepositProduct.interestCalculationType?.value }}
-
- @if (recurringDepositProduct.averagePeriod) {
-
- Average Period:
- {{ recurringDepositProduct.averagePeriod }}
-
- }
-
- {{ 'labels.inputs.Interest Posting Period' | translate }}:
- {{ recurringDepositProduct.interestPostingPeriodType.value }}
-
-
- {{ 'labels.inputs.Interest Compounding Period' | translate }}:
- {{ recurringDepositProduct.interestCompoundingPeriodType.value }}
-
-
- {{ 'labels.inputs.Days in Year' | translate }}:
- {{ recurringDepositProduct.interestCalculationDaysInYearType.value }}
-
-
- Auto Post:
- {{ recurringDepositProduct.autoPost | yesNo }}
-
- @if (recurringDepositProduct.frequencyFixDay) {
-
- Frequency (Fix Day):
- {{ recurringDepositProduct.frequencyFixDay }}
-
- }
- @if (recurringDepositProduct.lastInterestDate) {
-
- Last Interest Date:
- {{ recurringDepositProduct.lastInterestDate | dateFormat }}
-
- }
-
-
-
{{ 'labels.inputs.Settings' | translate }}
-
-
-
-
Premature Option
-
- Loan Facility:
- {{ recurringDepositProduct.loanFacility | yesNo }}
-
-
- Premature Withdrawal Option:
- {{ recurringDepositProduct.prematureWithdrawalOption | yesNo }}
-
-
- Tenure Flexibility:
- {{ recurringDepositProduct.tenureFlexibility | yesNo }}
-
-
- Lien Marked:
- {{ recurringDepositProduct.lienMarked | yesNo }}
-
-
- {{ 'labels.inputs.Apply Withdrawal Fee for Transfers' | translate }}:
- {{ recurringDepositProduct.withdrawalFeeForTransfers | yesNo }}
-
-
-
-
{{ 'labels.inputs.Lock-in Period' | translate }}
-
- Enable Lock-in Period:
- {{ recurringDepositProduct.enableLockinPeriod | yesNo }}
-
- @if (recurringDepositProduct.lockinPeriodFrequency) {
-
- {{ 'labels.inputs.Lock-in Period' | translate }}:
- {{ recurringDepositProduct.lockinPeriodFrequency }}
- {{ recurringDepositProduct.lockinPeriodFrequencyType?.value }}
-
- }
-
-
-
Maturity Instructions
-
- Maturity Instructions:
- {{ recurringDepositProduct.maturityInstructions | yesNo }}
-
-
- Credit Maturity Proceeds:
- {{ recurringDepositProduct.creditMaturityProceeds | yesNo }}
-
-
- Penalty Grace Period (Missed Installments):
- {{ recurringDepositProduct.penaltyGracePeriodMissedInstallments | yesNo }}
-
-
- Panel Request Applicable:
- {{ recurringDepositProduct.panelRequestApplicable | yesNo }}
-
-
- No Penalty (Nominee / Heir Closure):
- {{ recurringDepositProduct.noPenaltyNomineeHeirClosure | yesNo }}
-
-
- Deceased Settlement:
- {{ recurringDepositProduct.deceasedSettlement | yesNo }}
-
-
- Allow Debit Txn After Due Date:
- {{ recurringDepositProduct.allowDebitTxnAfterDueDate | yesNo }}
-
-
-
-
Additional Rules
-
- Account Closed Zero Balance in Day Closing:
- {{ recurringDepositProduct.accountClosedZeroBalanceInDayClosing | yesNo }}
-
- @if (recurringDepositProduct.minBalanceForInterestCalculation) {
-
- {{ 'labels.inputs.Balance Required for Interest Calculation' | translate }}:
- {{ recurringDepositProduct.minBalanceForInterestCalculation | formatNumber }}
-
- }
-
- @if (recurringDepositProduct.minDepositTerm) {
-
- {{ 'labels.inputs.Minimum Deposit Term' | translate }}:
- {{ recurringDepositProduct.minDepositTerm }} {{ recurringDepositProduct.minDepositTermType?.value }}
-
- }
- @if (recurringDepositProduct.maxDepositTerm) {
-
- {{ 'labels.inputs.Maximum Deposit Term' | translate }}:
- {{ recurringDepositProduct.maxDepositTerm }} {{ recurringDepositProduct.maxDepositTermType?.value }}
-
- }
- @if (recurringDepositProduct.inMultipleOfDepositTerm) {
-
- {{ 'labels.inputs.Deposit Term In multiple of' | translate }}:
- {{ recurringDepositProduct.inMultipleOfDepositTerm }} -
- {{ recurringDepositProduct.inMultipleOfDepositTermType?.value }}
-
- }
- @if (recurringDepositProduct.isMandatoryDeposit !== undefined) {
-
- {{ 'labels.inputs.Recurring Deposit Type' | translate }}:
- {{
- recurringDepositProduct.isMandatoryDeposit
- ? ('labels.text.Mandatory' | translate)
- : ('labels.text.Voluntary' | translate)
- }}
-
- }
- @if (recurringDepositProduct.preClosurePenalInterest) {
-
- {{ 'labels.inputs.Apply penal interest (less)' | translate }}:
- {{ recurringDepositProduct.preClosurePenalInterest }}% On
- {{ recurringDepositProduct.preClosurePenalInterestOnType?.value }}
-
- }
-
- {{ 'labels.inputs.Withhold Tax is Applicable' | translate }}:
- {{ recurringDepositProduct.withHoldTax | yesNo }}
-
- @if (recurringDepositProduct.withHoldTax) {
-
- {{ 'labels.inputs.Tax Group' | translate }}:
- {{ recurringDepositProduct.taxGroup?.name }}
-
- }
-
-
- @if (recurringDepositProduct.activeChart) {
-
-
{{ 'labels.heading.Interest Rate Charts' | translate }}
-
-
- @if (recurringDepositProduct.activeChart.name) {
-
- {{ 'labels.inputs.Name' | translate }}:
- {{ recurringDepositProduct.activeChart.name }}
-
- }
-
-
{{ 'labels.inputs.Valid from Date' | translate }}:
-
{{ recurringDepositProduct.activeChart.fromDate | dateFormat }}
+
+
+
+
+
+
+
Recurring Deposit Product
+
{{ recurringDepositProduct.name }}
+
+ {{ recurringDepositProduct.shortName }}
+ @if (recurringDepositProduct.currency?.code) {
+ {{ recurringDepositProduct.currency.code }}
+ }
- @if (recurringDepositProduct.activeChart.endDate) {
-
- {{ 'labels.inputs.End Date' | translate }}:
- {{ recurringDepositProduct.activeChart.endDate | dateFormat }}
-
+ @if (recurringDepositProduct.description) {
+
{{ recurringDepositProduct.description }}
}
- @if (recurringDepositProduct.activeChart.description) {
-
- {{ 'labels.inputs.Description' | translate }}:
- {{ recurringDepositProduct.activeChart.description }}
-
- }
-
- {{ 'labels.inputs.Primary Grouping by Amount' | translate }}:
- {{ recurringDepositProduct.activeChart.isPrimaryGroupingByAmount | yesNo }}
-
-
-
- {{ 'labels.inputs.Period' | translate }}
-
- @if (chartSlab.fromPeriod !== undefined) {
- {{ chartSlab.fromPeriod }} - {{ chartSlab.toPeriod }} {{ chartSlab.periodType.value }}
- }
- @if (chartSlab.fromPeriod === undefined) {
-
- }
-
-
-
- {{ 'labels.inputs.Amount Range' | translate }}
-
- @if (chartSlab.amountRangeFrom !== undefined) {
- {{ chartSlab.amountRangeFrom }} - {{ chartSlab.amountRangeTo }}
- }
- @if (chartSlab.amountRangeFrom === undefined) {
-
- }
-
-
-
- {{ 'labels.inputs.Interest' | translate }}
- {{ chartSlab.annualInterestRate }}
-
-
- {{ 'labels.inputs.Description' | translate }}
- {{ chartSlab.description }}
-
-
- {{ 'labels.inputs.Actions' | translate }}
-
-
- @if (expandChartSlabIndex !== chartSlabIndex) {
- {{ 'labels.buttons.View Incentives' | translate }}
- }
- @if (expandChartSlabIndex === chartSlabIndex) {
- {{ 'labels.buttons.Hide Incentives' | translate }}
- }
-
-
-
-
-
-
-
- {{ 'labels.heading.Incentives' | translate }}
-
-
- {{ 'labels.inputs.Entity Type' | translate }}
-
- {{
- incentive.entityType
- | find: recurringDepositProductTemplate.chartTemplate.entityTypeOptions : 'id' : 'value'
- }}
-
-
-
- {{ 'labels.inputs.Attribute Name' | translate }}
-
- {{
- incentive.attributeName
- | find: recurringDepositProductTemplate.chartTemplate.attributeNameOptions : 'id' : 'value'
- }}
-
-
-
- {{ 'labels.inputs.Condition Type' | translate }}
-
- {{
- incentive.conditionType
- | find: recurringDepositProductTemplate.chartTemplate.conditionTypeOptions : 'id' : 'value'
- }}
-
-
-
- {{ 'labels.inputs.Attribute Value' | translate }}
-
- @switch (incentive.attributeName) {
- @case (2) {
- {{
- incentive.attributeValue
- | find: recurringDepositProductTemplate.chartTemplate.genderOptions : 'id' : 'name'
- }}
- }
- @case (3) {
- {{ incentive.attributeValue }}
- }
- @case (4) {
- {{
- incentive.attributeValue
- | find: recurringDepositProductTemplate.chartTemplate.clientTypeOptions : 'id' : 'name'
- }}
- }
- @case (5) {
- {{
- incentive.attributeValue
- | find
- : recurringDepositProductTemplate.chartTemplate.clientClassificationOptions
- : 'id'
- : 'name'
- }}
- }
- }
-
-
-
- {{ 'labels.inputs.Incentive Type' | translate }}
-
- {{
- incentive.incentiveType
- | find: recurringDepositProductTemplate.chartTemplate.incentiveTypeOptions : 'id' : 'value'
- }}
-
-
-
- {{ 'labels.inputs.Amount' | translate }}
- {{ incentive.amount }}
-
-
-
-
-
-
-
-
-
-
-
-
-
- }
+
+ {{ 'labels.buttons.Edit' | translate }}
+
+
-
- @if (recurringDepositProduct.charges.length) {
-
-
{{ 'labels.heading.Charges' | translate }}
-
-
-
- {{ 'labels.inputs.Name' | translate }}
- {{ charge.name }}
-
-
- {{ 'labels.inputs.Type' | translate }}
- {{ charge.chargeCalculationType.value }}
-
-
- {{ 'labels.inputs.Amount' | translate }}
- {{ charge.amount | formatNumber }}
-
-
- {{ 'labels.inputs.Collected On' | translate }}
- {{ charge.chargeTimeType.value }}
-
-
-
-
+
+
+
{{ 'labels.inputs.Details' | translate }}
+
+
+
+ {{ 'labels.inputs.Short Name' | translate }}:
+ {{ recurringDepositProduct.shortName }}
- }
-
-
{{ 'labels.heading.Accounting' | translate }}
-
+ @if (recurringDepositProduct.description) {
+
+ {{ 'labels.inputs.Description' | translate }}:
+ {{ recurringDepositProduct.description }}
+
+ }
-
-
+
+
Product Classification
+
- @if (
- recurringDepositProduct.paymentChannelToFundSourceMappings?.length ||
- recurringDepositProduct.feeToIncomeAccountMappings?.length ||
- recurringDepositProduct.penaltyToIncomeAccountMappings?.length
- ) {
-
-
{{ 'labels.inputs.Advanced Accounting Rules' | translate }}
-
- @if (recurringDepositProduct.paymentChannelToFundSourceMappings?.length) {
-
-
{{ 'labels.inputs.Fund Sources for Payment Channels' | translate }}
+ @if (recurringDepositProduct.accountType) {
+
+ Account Type:
+ {{ recurringDepositProduct.accountType }}
+
+ }
+ @if (recurringDepositProduct.customerSegment) {
+
+ Customer Segment:
+ {{ recurringDepositProduct.customerSegment }}
+
+ }
+ @if (recurringDepositProduct.glAccountId) {
+
+ GL Account (Liability):
+ {{ recurringDepositProduct.glAccountName || recurringDepositProduct.glAccountId }}
+
+ }
+ @if (recurringDepositProduct.effectiveTo) {
+
+ Effective To:
+ {{ recurringDepositProduct.effectiveTo | dateFormat }}
+
+ }
+ @if (recurringDepositProduct.endDate) {
+
+ End Date:
+ {{ recurringDepositProduct.endDate | dateFormat }}
+
+ }
+
+ Introducer Required:
+ {{ recurringDepositProduct.introducerRequired | yesNo }}
+
+
+ Deposit Account Link:
+ {{ recurringDepositProduct.shareLinkingEnabled | yesNo }}
+
+
+
+
{{ 'labels.inputs.Currency' | translate }}
+
+
+
+ {{ 'labels.inputs.Currency' | translate }}:
+ ({{ recurringDepositProduct.currency.code }}) {{ recurringDepositProduct.currency.name }}
+
+
+ {{ 'labels.inputs.Decimal Places' | translate }}:
+ {{ recurringDepositProduct.currency.decimalPlaces }}
+
+
+ Dormancy Alert:
+ {{ recurringDepositProduct.dormancyAlert | yesNo }}
+
+
+ Balance Swap Option:
+ {{ recurringDepositProduct.balanceSwapOption | yesNo }}
+
+
+ MSME:
+ {{ recurringDepositProduct.msme | yesNo }}
+
+
+ Nomination Facility:
+ {{ recurringDepositProduct.nominationFacility | yesNo }}
+
+
+ Auto Debit Facility:
+ {{ recurringDepositProduct.autoDebitFacility | yesNo }}
+
+
+
+
{{ 'labels.inputs.Terms' | translate }}
+
+
+
+ {{ 'labels.inputs.Deposit Amount' | translate }}:
+ {{
+ recurringDepositProduct.depositAmount +
+ ' (Min: ' +
+ (recurringDepositProduct.minDepositAmount || recurringDepositProduct.depositAmount) +
+ ', Max: ' +
+ (recurringDepositProduct.maxDepositAmount || recurringDepositProduct.depositAmount) +
+ ')'
+ }}
+
+
+ {{ 'labels.inputs.Nominal Annual Interest' | translate }}:
+ {{ recurringDepositProduct.nominalAnnualInterestRate | formatNumber }} %
+
+ @if (recurringDepositProduct.effectiveDate) {
+
+ Effective Date:
+ {{ recurringDepositProduct.effectiveDate | dateFormat }}
+
+ }
+ @if (recurringDepositProduct.interestCalculationTypeName) {
+
+ {{ 'labels.inputs.Interest Calculation Type' | translate }}:
+ {{ recurringDepositProduct.interestCalculationTypeName }}
+
+ }
+
+ Interest Calculate:
+ {{ recurringDepositProduct.interestCalculationType?.value }}
+
+ @if (recurringDepositProduct.averagePeriod) {
+
+ Average Period:
+ {{ recurringDepositProduct.averagePeriod }}
+
+ }
+
+ {{ 'labels.inputs.Interest Posting Period' | translate }}:
+ {{ recurringDepositProduct.interestPostingPeriodType.value }}
+
+
+ {{ 'labels.inputs.Interest Compounding Period' | translate }}:
+ {{ recurringDepositProduct.interestCompoundingPeriodType.value }}
+
+
+ {{ 'labels.inputs.Days in Year' | translate }}:
+ {{ recurringDepositProduct.interestCalculationDaysInYearType.value }}
+
+
+ Auto Post:
+ {{ recurringDepositProduct.autoPost | yesNo }}
+
+ @if (recurringDepositProduct.frequencyFixDay) {
+
+ Frequency (Fix Day):
+ {{ recurringDepositProduct.frequencyFixDay }}
+
+ }
+ @if (recurringDepositProduct.lastInterestDate) {
+
+ Last Interest Date:
+ {{ recurringDepositProduct.lastInterestDate | dateFormat }}
+
+ }
+
+
+
{{ 'labels.inputs.Settings' | translate }}
+
+
+
+
Premature Option
+
+ Loan Facility:
+ {{ recurringDepositProduct.loanFacility | yesNo }}
+
+
+ Premature Withdrawal Option:
+ {{ recurringDepositProduct.prematureWithdrawalOption | yesNo }}
+
+
+ Tenure Flexibility:
+ {{ recurringDepositProduct.tenureFlexibility | yesNo }}
+
+
+ Lien Marked:
+ {{ recurringDepositProduct.lienMarked | yesNo }}
+
+
+ {{ 'labels.inputs.Apply Withdrawal Fee for Transfers' | translate }}:
+ {{ recurringDepositProduct.withdrawalFeeForTransfers | yesNo }}
+
+
+
+
{{ 'labels.inputs.Lock-in Period' | translate }}
+
+ Enable Lock-in Period:
+ {{ recurringDepositProduct.enableLockinPeriod | yesNo }}
+
+ @if (recurringDepositProduct.lockinPeriodFrequency) {
+
+ {{ 'labels.inputs.Lock-in Period' | translate }}:
+ {{ recurringDepositProduct.lockinPeriodFrequency }}
+ {{ recurringDepositProduct.lockinPeriodFrequencyType?.value }}
+
+ }
+
+
+
Maturity Instructions
+
+ Maturity Instructions:
+ {{ recurringDepositProduct.maturityInstructions | yesNo }}
+
+
+ Credit Maturity Proceeds:
+ {{ recurringDepositProduct.creditMaturityProceeds | yesNo }}
+
+
+ Penalty Grace Period (Missed Installments):
+ {{ recurringDepositProduct.penaltyGracePeriodMissedInstallments | yesNo }}
+
+
+ Panel Request Applicable:
+ {{ recurringDepositProduct.panelRequestApplicable | yesNo }}
+
+
+ No Penalty (Nominee / Heir Closure):
+ {{ recurringDepositProduct.noPenaltyNomineeHeirClosure | yesNo }}
+
+
+ Deceased Settlement:
+ {{ recurringDepositProduct.deceasedSettlement | yesNo }}
+
+
+ Allow Debit Txn After Due Date:
+ {{ recurringDepositProduct.allowDebitTxnAfterDueDate | yesNo }}
+
+
+
+
Additional Rules
+
+ Account Closed Zero Balance in Day Closing:
+ {{ recurringDepositProduct.accountClosedZeroBalanceInDayClosing | yesNo }}
+
+ @if (recurringDepositProduct.minBalanceForInterestCalculation) {
+
+ {{ 'labels.inputs.Balance Required for Interest Calculation' | translate }}:
+ {{ recurringDepositProduct.minBalanceForInterestCalculation | formatNumber }}
+
+ }
+
+ @if (recurringDepositProduct.minDepositTerm) {
+
+ {{ 'labels.inputs.Minimum Deposit Term' | translate }}:
+ {{ recurringDepositProduct.minDepositTerm }} {{ recurringDepositProduct.minDepositTermType?.value }}
+
+ }
+ @if (recurringDepositProduct.maxDepositTerm) {
+
+ {{ 'labels.inputs.Maximum Deposit Term' | translate }}:
+ {{ recurringDepositProduct.maxDepositTerm }} {{ recurringDepositProduct.maxDepositTermType?.value }}
+
+ }
+ @if (recurringDepositProduct.inMultipleOfDepositTerm) {
+
+ {{ 'labels.inputs.Deposit Term In multiple of' | translate }}:
+ {{ recurringDepositProduct.inMultipleOfDepositTerm }} -
+ {{ recurringDepositProduct.inMultipleOfDepositTermType?.value }}
+
+ }
+ @if (recurringDepositProduct.isMandatoryDeposit !== undefined) {
+
+ {{ 'labels.inputs.Recurring Deposit Type' | translate }}:
+ {{
+ recurringDepositProduct.isMandatoryDeposit
+ ? ('labels.text.Mandatory' | translate)
+ : ('labels.text.Voluntary' | translate)
+ }}
+
+ }
+ @if (recurringDepositProduct.preClosurePenalInterest) {
+
+ {{ 'labels.inputs.Apply penal interest (less)' | translate }}:
+ {{ recurringDepositProduct.preClosurePenalInterest }}% On
+ {{ recurringDepositProduct.preClosurePenalInterestOnType?.value }}
+
+ }
+
+ {{ 'labels.inputs.Withhold Tax is Applicable' | translate }}:
+ {{ recurringDepositProduct.withHoldTax | yesNo }}
+
+ @if (recurringDepositProduct.withHoldTax) {
+
+ {{ 'labels.inputs.Tax Group' | translate }}:
+ {{ recurringDepositProduct.taxGroup?.name }}
+
+ }
+
+
+ @if (recurringDepositProduct.activeChart) {
+
+
{{ 'labels.heading.Interest Rate Charts' | translate }}
+
+
+ @if (recurringDepositProduct.activeChart.name) {
+
+ {{ 'labels.inputs.Name' | translate }}:
+ {{ recurringDepositProduct.activeChart.name }}
+
+ }
+
+ {{ 'labels.inputs.Valid from Date' | translate }}:
+ {{ recurringDepositProduct.activeChart.fromDate | dateFormat }}
+
+ @if (recurringDepositProduct.activeChart.endDate) {
+
+ {{ 'labels.inputs.End Date' | translate }}:
+ {{ recurringDepositProduct.activeChart.endDate | dateFormat }}
+
+ }
+ @if (recurringDepositProduct.activeChart.description) {
+
+ {{ 'labels.inputs.Description' | translate }}:
+ {{ recurringDepositProduct.activeChart.description }}
+
+ }
+
+ {{ 'labels.inputs.Primary Grouping by Amount' | translate }}:
+ {{ recurringDepositProduct.activeChart.isPrimaryGroupingByAmount | yesNo }}
+
-
- {{ 'labels.inputs.Payment Type' | translate }}
- {{ paymentFundSource.paymentType.name }}
+
+ {{ 'labels.inputs.Period' | translate }}
+
+ @if (chartSlab.fromPeriod !== undefined) {
+ {{ chartSlab.fromPeriod }} - {{ chartSlab.toPeriod }} {{ chartSlab.periodType.value }}
+ }
+ @if (chartSlab.fromPeriod === undefined) {
+
+ }
+
-
- {{ 'labels.inputs.Fund Source' | translate }}
- {{ paymentFundSource.fundSourceAccount.name }}
+
+ {{ 'labels.inputs.Amount Range' | translate }}
+
+ @if (chartSlab.amountRangeFrom !== undefined) {
+ {{ chartSlab.amountRangeFrom }} - {{ chartSlab.amountRangeTo }}
+ }
+ @if (chartSlab.amountRangeFrom === undefined) {
+
+ }
+
-
-
+
+ {{ 'labels.inputs.Interest' | translate }}
+ {{ chartSlab.annualInterestRate }}
+
+
+ {{ 'labels.inputs.Description' | translate }}
+ {{ chartSlab.description }}
+
+
+ {{ 'labels.inputs.Actions' | translate }}
+
+
+ @if (expandChartSlabIndex !== chartSlabIndex) {
+ {{ 'labels.buttons.View Incentives' | translate }}
+ }
+ @if (expandChartSlabIndex === chartSlabIndex) {
+ {{ 'labels.buttons.Hide Incentives' | translate }}
+ }
+
+
+
+
+
+
+
+ {{ 'labels.heading.Incentives' | translate }}
+
+
+ {{ 'labels.inputs.Entity Type' | translate }}
+
+ {{
+ incentive.entityType
+ | find: recurringDepositProductTemplate.chartTemplate.entityTypeOptions : 'id' : 'value'
+ }}
+
+
+
+ {{ 'labels.inputs.Attribute Name' | translate }}
+
+ {{
+ incentive.attributeName
+ | find
+ : recurringDepositProductTemplate.chartTemplate.attributeNameOptions
+ : 'id'
+ : 'value'
+ }}
+
+
+
+ {{ 'labels.inputs.Condition Type' | translate }}
+
+ {{
+ incentive.conditionType
+ | find
+ : recurringDepositProductTemplate.chartTemplate.conditionTypeOptions
+ : 'id'
+ : 'value'
+ }}
+
+
+
+ {{ 'labels.inputs.Attribute Value' | translate }}
+
+ @switch (incentive.attributeName) {
+ @case (2) {
+ {{
+ incentive.attributeValue
+ | find: recurringDepositProductTemplate.chartTemplate.genderOptions : 'id' : 'name'
+ }}
+ }
+ @case (3) {
+ {{ incentive.attributeValue }}
+ }
+ @case (4) {
+ {{
+ incentive.attributeValue
+ | find
+ : recurringDepositProductTemplate.chartTemplate.clientTypeOptions
+ : 'id'
+ : 'name'
+ }}
+ }
+ @case (5) {
+ {{
+ incentive.attributeValue
+ | find
+ : recurringDepositProductTemplate.chartTemplate.clientClassificationOptions
+ : 'id'
+ : 'name'
+ }}
+ }
+ }
+
+
+
+ {{ 'labels.inputs.Incentive Type' | translate }}
+
+ {{
+ incentive.incentiveType
+ | find
+ : recurringDepositProductTemplate.chartTemplate.incentiveTypeOptions
+ : 'id'
+ : 'value'
+ }}
+
+
+
+ {{ 'labels.inputs.Amount' | translate }}
+ {{ incentive.amount }}
+
+
+
+
+
+
+
+
+
+
+
+
- }
- @if (recurringDepositProduct.feeToIncomeAccountMappings?.length) {
-
-
{{ 'labels.inputs.Fees to Specific Income Accounts' | translate }}
-
-
- {{ 'labels.inputs.Fee/Charge' | translate }}
- {{ feesIncome.charge.name }}
-
-
- {{ 'labels.inputs.Income Account' | translate }}
- {{ feesIncome.incomeAccount.name }}
-
-
-
-
-
- }
- @if (recurringDepositProduct.penaltyToIncomeAccountMappings?.length) {
-
-
{{ 'labels.inputs.Penalties to Specific Income Accounts' | translate }}
-
-
- {{ 'labels.inputs.Penalty' | translate }}
- {{ penaltyIncome.charge.name }}
-
-
- {{ 'labels.inputs.Income Account' | translate }}
- {{ penaltyIncome.incomeAccount.name }}
-
-
-
-
-
- }
-
- }
+
+ }
+
+
+ @if (recurringDepositProduct.charges.length) {
+
+
{{ 'labels.heading.Charges' | translate }}
+
+
+
+ {{ 'labels.inputs.Name' | translate }}
+ {{ charge.name }}
+
+
+ {{ 'labels.inputs.Type' | translate }}
+ {{ charge.chargeCalculationType.value }}
+
+
+ {{ 'labels.inputs.Amount' | translate }}
+ {{ charge.amount | formatNumber }}
+
+
+ {{ 'labels.inputs.Collected On' | translate }}
+ {{ charge.chargeTimeType.value }}
+
+
+
+
+
+ }
+
+
+
{{ 'labels.heading.Accounting' | translate }}
+
+
+
+
+
+ @if (
+ recurringDepositProduct.paymentChannelToFundSourceMappings?.length ||
+ recurringDepositProduct.feeToIncomeAccountMappings?.length ||
+ recurringDepositProduct.penaltyToIncomeAccountMappings?.length
+ ) {
+
+
{{ 'labels.inputs.Advanced Accounting Rules' | translate }}
+
+ @if (recurringDepositProduct.paymentChannelToFundSourceMappings?.length) {
+
+
{{ 'labels.inputs.Fund Sources for Payment Channels' | translate }}
+
+
+ {{ 'labels.inputs.Payment Type' | translate }}
+ {{ paymentFundSource.paymentType.name }}
+
+
+ {{ 'labels.inputs.Fund Source' | translate }}
+ {{ paymentFundSource.fundSourceAccount.name }}
+
+
+
+
+
+ }
+ @if (recurringDepositProduct.feeToIncomeAccountMappings?.length) {
+
+
{{ 'labels.inputs.Fees to Specific Income Accounts' | translate }}
+
+
+ {{ 'labels.inputs.Fee/Charge' | translate }}
+ {{ feesIncome.charge.name }}
+
+
+ {{ 'labels.inputs.Income Account' | translate }}
+ {{ feesIncome.incomeAccount.name }}
+
+
+
+
+
+ }
+ @if (recurringDepositProduct.penaltyToIncomeAccountMappings?.length) {
+
+
{{ 'labels.inputs.Penalties to Specific Income Accounts' | translate }}
+
+
+ {{ 'labels.inputs.Penalty' | translate }}
+ {{ penaltyIncome.charge.name }}
+
+
+ {{ 'labels.inputs.Income Account' | translate }}
+ {{ penaltyIncome.incomeAccount.name }}
+
+
+
+
+
+ }
+
+ }
+
diff --git a/src/app/products/recurring-deposit-products/view-recurring-deposit-product/recurring-deposit-general-tab/recurring-deposit-general-tab.component.scss b/src/app/products/recurring-deposit-products/view-recurring-deposit-product/recurring-deposit-general-tab/recurring-deposit-general-tab.component.scss
index 15b7c6e8b..cdee63517 100644
--- a/src/app/products/recurring-deposit-products/view-recurring-deposit-product/recurring-deposit-general-tab/recurring-deposit-general-tab.component.scss
+++ b/src/app/products/recurring-deposit-products/view-recurring-deposit-product/recurring-deposit-general-tab/recurring-deposit-general-tab.component.scss
@@ -8,6 +8,15 @@
@use 'assets/styles/helper';
@use 'assets/styles/colours' as *;
+@use 'assets/styles/product-view' as pv;
+
+@include pv.product-view-styles;
+
+/* RD-specific purple accent */
+:host {
+ --pv-accent: #6d28d9;
+ --pv-accent-l: #ede9fe;
+}
.container {
width: 100%;
diff --git a/src/app/products/saving-products/saving-products.component.html b/src/app/products/saving-products/saving-products.component.html
index 874d9891d..2ec3107a5 100644
--- a/src/app/products/saving-products/saving-products.component.html
+++ b/src/app/products/saving-products/saving-products.component.html
@@ -6,44 +6,174 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
-
-
-
-
- {{ 'labels.text.Create Savings Product' | translate }}
-
+
+
+
-
-
-
-
-
- {{ 'labels.inputs.Filter' | translate }}
-
-
-
+
+
+
+
+
+
+
{{ totalCount | number }}
+
Total Savings Products
+
+
+
+
+
+
{{ currencyCount | number }}
+
{{ currencyCount === 1 ? 'Currency' : 'Currencies' }} in use
+
+
+
+
+
+
{{ filteredCount | number }}
+
Showing after filter
+
+
-
-
-
- {{ 'labels.inputs.Name' | translate }}
- {{ savingProduct.name }}
-
+
+
+
+
+ @if (totalCount === 0) {
+
+
+
No savings products yet
+
Create your first savings product to make it available for client accounts.
+
+
+ {{ 'labels.text.Create Savings Product' | translate }}
+
+
+ } @else {
+
+
+
+ {{ 'labels.inputs.Name' | translate }}
+
+
+
+
+
+
+ {{ 'labels.inputs.Short Name' | translate }}
+
+ {{ p.shortName }}
+
+
+
+
+ Currency
+
+ @if (p.currency?.code) {
+ {{ p.currency.code }}
+ } @else {
+ —
+ }
+
+
+
+
+ Actions
+
+
+
+ View
+
+
+
+ Edit
+
+
+
-
- {{ 'labels.inputs.Short Name' | translate }}
- {{ savingProduct.shortName }}
-
+
+
+
-
-
-
+ @if (filteredCount === 0 && totalCount > 0) {
+
+
+ No products match your filter.
+
+ }
-
+
+
+ }
+
{{ 'labels.text.Create Savings Product' | translate }}
{{ 'labels.text.This option allows you to create new savings product.' | translate }}
diff --git a/src/app/products/saving-products/saving-products.component.scss b/src/app/products/saving-products/saving-products.component.scss
index 567dcb2f8..a379b31c9 100644
--- a/src/app/products/saving-products/saving-products.component.scss
+++ b/src/app/products/saving-products/saving-products.component.scss
@@ -6,10 +6,540 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
-table {
- width: 100%;
+:host {
+ --sp-navy: #0b1e3d;
+ --sp-blue: #1d4ed8;
+ --sp-blue-l: #eff6ff;
+ --sp-teal: #0e7e6b;
+ --sp-teal-l: #ccfbf1;
+ --sp-purple: #6d28d9;
+ --sp-purple-l: #ede9fe;
+ --sp-amber: #b45309;
+ --sp-amber-l: #fef3c7;
+ --sp-green: #15803d;
+ --sp-green-l: #dcfce7;
+ --sp-red: #c0392b;
+ --sp-red-l: #fee2e2;
+ --sp-g50: #f8fafc;
+ --sp-g100: #f1f5f9;
+ --sp-g200: #e2e8f0;
+ --sp-g500: #64748b;
+ --sp-g700: #334155;
+ --sp-g900: #0f172a;
- .select-row:hover {
- cursor: pointer;
+ display: block;
+ font-family:
+ 'DM Sans',
+ system-ui,
+ -apple-system,
+ sans-serif;
+ background: var(--sp-g50);
+ color: var(--sp-g900);
+ font-size: 13.5px;
+}
+
+.sp-root {
+ padding: 18px 22px;
+ max-width: 1400px;
+ margin: 0 auto;
+}
+
+/* ───────── HEADER ───────── */
+.sp-header {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ flex-wrap: wrap;
+ margin-bottom: 14px;
+}
+
+.sp-header-left {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ flex: 1;
+ min-width: 320px;
+}
+
+.sp-header-ic {
+ width: 48px;
+ height: 48px;
+ border-radius: 14px;
+ background: linear-gradient(135deg, var(--sp-blue) 0%, var(--sp-navy) 100%);
+ color: #fff;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 20px;
+ flex-shrink: 0;
+ box-shadow:
+ 0 6px 16px rgb(29 78 216 / 25%),
+ inset 0 1px 0 rgb(255 255 255 / 22%);
+}
+
+.sp-header-text {
+ h2 {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 700;
+ color: var(--sp-g900);
+ letter-spacing: 0.01em;
+ }
+
+ p {
+ margin: 3px 0 0;
+ font-size: 12px;
+ color: var(--sp-g500);
+ line-height: 1.4;
}
}
+
+.sp-header-right {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.sp-count-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ padding: 6px 14px 6px 12px;
+ border-radius: 999px;
+ background: var(--sp-blue-l);
+ color: var(--sp-blue);
+ border: 1px solid #dbeafe;
+ font-size: 12px;
+ font-weight: 600;
+
+ strong {
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--sp-navy);
+ }
+
+ span {
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ font-size: 10.5px;
+ }
+}
+
+.sp-count-chip-ic {
+ font-size: 11px;
+}
+
+.sp-create-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ height: 38px;
+ padding: 0 16px;
+ border-radius: 10px;
+ background: linear-gradient(135deg, var(--sp-blue) 0%, var(--sp-navy) 100%);
+ color: #fff;
+ border: 0;
+ font-size: 13px;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ cursor: pointer;
+ text-decoration: none;
+ white-space: nowrap;
+ box-shadow:
+ 0 6px 16px rgb(29 78 216 / 30%),
+ inset 0 1px 0 rgb(255 255 255 / 18%);
+ transition: all 0.16s ease;
+ font-family: inherit;
+
+ fa-icon {
+ font-size: 12px;
+ }
+
+ &:hover {
+ transform: translateY(-1px);
+ box-shadow:
+ 0 10px 22px rgb(29 78 216 / 38%),
+ inset 0 1px 0 rgb(255 255 255 / 22%);
+ }
+
+ &:active {
+ transform: translateY(0);
+ }
+}
+
+/* ───────── KPI STRIP ───────── */
+.sp-kpis {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 12px;
+ margin-bottom: 14px;
+}
+
+@media (width <= 720px) {
+ .sp-kpis {
+ grid-template-columns: 1fr;
+ }
+}
+
+.sp-kpi {
+ background: #fff;
+ border: 1px solid var(--sp-g200);
+ border-radius: 12px;
+ padding: 12px 16px;
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
+ border-left-width: 4px;
+}
+
+.sp-kpi-ic {
+ width: 40px;
+ height: 40px;
+ border-radius: 10px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 16px;
+ flex-shrink: 0;
+}
+
+.sp-kpi-n {
+ font-size: 20px;
+ font-weight: 700;
+ color: var(--sp-g900);
+ line-height: 1.1;
+}
+
+.sp-kpi-l {
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--sp-g500);
+ margin-top: 4px;
+}
+
+.sp-kpi--blue {
+ border-left-color: var(--sp-blue);
+
+ .sp-kpi-ic {
+ background: var(--sp-blue-l);
+ color: var(--sp-blue);
+ }
+}
+
+.sp-kpi--teal {
+ border-left-color: var(--sp-teal);
+
+ .sp-kpi-ic {
+ background: var(--sp-teal-l);
+ color: var(--sp-teal);
+ }
+}
+
+.sp-kpi--purple {
+ border-left-color: var(--sp-purple);
+
+ .sp-kpi-ic {
+ background: var(--sp-purple-l);
+ color: var(--sp-purple);
+ }
+}
+
+/* ───────── CARD + TOOLBAR ───────── */
+.sp-card {
+ background: #fff;
+ border: 1px solid var(--sp-g200);
+ border-radius: 14px;
+ overflow: hidden;
+ box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
+}
+
+.sp-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 14px 16px;
+ background: #fff;
+ border-bottom: 1px solid var(--sp-g200);
+}
+
+.sp-search {
+ position: relative;
+ flex: 1;
+ max-width: 480px;
+
+ input {
+ width: 100%;
+ box-sizing: border-box;
+ padding: 9px 14px 9px 36px;
+ border: 1px solid var(--sp-g200);
+ border-radius: 10px;
+ background: var(--sp-g50);
+ font-size: 13px;
+ color: var(--sp-g900);
+ font-family: inherit;
+
+ &::placeholder {
+ color: #94a3b8;
+ }
+
+ &:focus {
+ outline: none;
+ border-color: var(--sp-blue);
+ background: #fff;
+ box-shadow: 0 0 0 3px rgb(29 78 216 / 14%);
+ }
+ }
+}
+
+.sp-search-ic {
+ position: absolute;
+ top: 50%;
+ left: 13px;
+ transform: translateY(-50%);
+ color: #94a3b8;
+ font-size: 13px;
+ pointer-events: none;
+}
+
+/* ───────── TABLE ───────── */
+.sp-table-wrap {
+ overflow-x: auto;
+}
+
+.sp-table {
+ width: 100%;
+ background: #fff;
+
+ th.mat-mdc-header-cell {
+ background: var(--sp-g50);
+ color: var(--sp-g700);
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--sp-g200);
+ white-space: nowrap;
+
+ &.num {
+ text-align: right;
+ }
+ }
+
+ td.mat-mdc-cell {
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--sp-g100);
+ font-size: 13px;
+ color: var(--sp-g900);
+ vertical-align: middle;
+
+ &.num {
+ text-align: right;
+ }
+ }
+}
+
+.sp-row {
+ cursor: pointer;
+ transition: background 0.14s ease;
+
+ &:hover {
+ background: var(--sp-blue-l);
+ }
+}
+
+/* Product Name cell */
+.sp-product-cell {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.sp-product-icon {
+ width: 36px;
+ height: 36px;
+ border-radius: 10px;
+ background: var(--sp-blue-l);
+ color: var(--sp-blue);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 14px;
+ flex-shrink: 0;
+ transition: all 0.14s ease;
+}
+
+.sp-row:hover .sp-product-icon {
+ background: var(--sp-blue);
+ color: #fff;
+ transform: scale(1.04);
+}
+
+.sp-product-meta {
+ display: flex;
+ flex-direction: column;
+ line-height: 1.25;
+}
+
+.sp-product-name {
+ font-size: 13.5px;
+ font-weight: 600;
+ color: var(--sp-g900);
+}
+
+.sp-product-sub {
+ font-size: 11px;
+ color: var(--sp-g500);
+ margin-top: 2px;
+
+ span {
+ margin: 0 4px;
+ }
+}
+
+/* Short Name pill */
+.sp-tag {
+ display: inline-flex;
+ align-items: center;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ padding: 3px 9px;
+ border-radius: 999px;
+ background: var(--sp-g100);
+ color: var(--sp-g700);
+ font-family: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace;
+}
+
+/* Currency pill */
+.sp-currency-pill {
+ display: inline-flex;
+ align-items: center;
+ font-size: 11px;
+ font-weight: 700;
+ padding: 3px 9px;
+ border-radius: 999px;
+ background: var(--sp-teal-l);
+ color: var(--sp-teal);
+ border: 1px solid #99f6e4;
+ font-family: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace;
+}
+
+.muted {
+ color: var(--sp-g500);
+}
+
+/* Action buttons */
+.sp-action-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ height: 30px;
+ padding: 0 12px;
+ border-radius: 8px;
+ border: 1px solid;
+ font-size: 11.5px;
+ font-weight: 600;
+ cursor: pointer;
+ text-decoration: none;
+ font-family: inherit;
+ margin-left: 6px;
+ transition: all 0.14s ease;
+
+ fa-icon {
+ font-size: 11px;
+ }
+
+ &:first-child {
+ margin-left: 0;
+ }
+
+ &--view {
+ background: var(--sp-blue-l);
+ color: var(--sp-blue);
+ border-color: #dbeafe;
+
+ &:hover {
+ background: var(--sp-blue);
+ color: #fff;
+ border-color: var(--sp-blue);
+ }
+ }
+
+ &--edit {
+ background: var(--sp-amber-l);
+ color: var(--sp-amber);
+ border-color: #fde68a;
+
+ &:hover {
+ background: var(--sp-amber);
+ color: #fff;
+ border-color: var(--sp-amber);
+ }
+ }
+}
+
+/* No match strip */
+.sp-no-match {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ padding: 28px 16px;
+ background: var(--sp-g50);
+ color: var(--sp-g500);
+ font-size: 13px;
+ font-weight: 500;
+ border-top: 1px dashed var(--sp-g200);
+
+ fa-icon {
+ font-size: 13px;
+ }
+}
+
+/* ───────── EMPTY STATE ───────── */
+.sp-empty {
+ text-align: center;
+ padding: 50px 24px;
+}
+
+.sp-empty-ic {
+ width: 64px;
+ height: 64px;
+ margin: 0 auto 16px;
+ border-radius: 16px;
+ background: var(--sp-blue-l);
+ color: var(--sp-blue);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 26px;
+}
+
+.sp-empty h3 {
+ margin: 0 0 8px;
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--sp-g900);
+}
+
+.sp-empty p {
+ margin: 0 0 20px;
+ font-size: 13px;
+ color: var(--sp-g500);
+ line-height: 1.5;
+ max-width: 420px;
+ margin-left: auto;
+ margin-right: auto;
+}
+
+/* ───────── Paginator polish ───────── */
+::ng-deep .sp-table-wrap .mat-mdc-paginator {
+ background: var(--sp-g50);
+ border-top: 1px solid var(--sp-g200);
+ font-family: inherit;
+}
diff --git a/src/app/products/saving-products/saving-products.component.ts b/src/app/products/saving-products/saving-products.component.ts
index 2a69b19ce..8c0afbbb0 100644
--- a/src/app/products/saving-products/saving-products.component.ts
+++ b/src/app/products/saving-products/saving-products.component.ts
@@ -24,6 +24,7 @@ import {
MatRow
} from '@angular/material/table';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
+import { MatTooltip } from '@angular/material/tooltip';
/* Custom Services */
import { PopoverService } from '../../configuration-wizard/popover/popover.service';
@@ -50,7 +51,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatHeaderRow,
MatRowDef,
MatRow,
- MatPaginator
+ MatPaginator,
+ MatTooltip
]
})
export class SavingProductsComponent implements OnInit, AfterViewInit {
@@ -59,13 +61,33 @@ export class SavingProductsComponent implements OnInit, AfterViewInit {
private configurationWizardService = inject(ConfigurationWizardService);
private popoverService = inject(PopoverService);
- savingProductsData: any;
+ savingProductsData: any[] = [];
displayedColumns: string[] = [
'name',
- 'shortName'
+ 'shortName',
+ 'currency',
+ 'actions'
];
dataSource: MatTableDataSource;
+ /** Cached count of rows after the current filter is applied. */
+ filteredCount = 0;
+
+ /** Total products as returned by the resolver (filter-independent). */
+ get totalCount(): number {
+ return this.savingProductsData?.length || 0;
+ }
+
+ /** Number of distinct currencies in the product list. */
+ get currencyCount(): number {
+ const set = new Set();
+ for (const p of this.savingProductsData || []) {
+ const code = p?.currency?.code;
+ if (code) set.add(code);
+ }
+ return set.size;
+ }
+
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
@ViewChild(MatSort, { static: true }) sort: MatSort;
@@ -94,10 +116,13 @@ export class SavingProductsComponent implements OnInit, AfterViewInit {
this.dataSource = new MatTableDataSource(this.savingProductsData);
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
+ this.filteredCount = this.totalCount;
+ // Keep the filtered-row count chip live as the user types.
+ this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length));
}
applyFilter(filterValue: string) {
- this.dataSource.filter = filterValue.trim().toLowerCase();
+ this.dataSource.filter = (filterValue || '').trim().toLowerCase();
}
/**
diff --git a/src/app/products/saving-products/view-saving-product/saving-product-general-tab/saving-product-general-tab.component.html b/src/app/products/saving-products/view-saving-product/saving-product-general-tab/saving-product-general-tab.component.html
index a36665ab5..b54d8f010 100644
--- a/src/app/products/saving-products/view-saving-product/saving-product-general-tab/saving-product-general-tab.component.html
+++ b/src/app/products/saving-products/view-saving-product/saving-product-general-tab/saving-product-general-tab.component.html
@@ -1,4 +1,4 @@
-
-
-
- {{ 'labels.buttons.Edit' | translate }}
-
-
-
-
-
{{ savingProduct.name }}
-
-
-
{{ 'labels.inputs.Details' | translate }}
-
-
-
-
{{ 'labels.inputs.Short Name' | translate }}:
-
{{ savingProduct.shortName }}
+
+
+
+
+
+
+
Savings Product
+
{{ savingProduct.name }}
+
+ {{ savingProduct.shortName }}
+ @if (savingProduct.currency?.code) {
+ {{ savingProduct.currency.code }}
+ }
+ @if (savingProduct.accountType) {
+ {{ savingProduct.accountType }}
+ }
+
+
+ {{ savingProduct.endDate ? 'Inactive' : 'Active' }}
+
+
+ @if (savingProduct.description) {
+
{{ savingProduct.description }}
+ }
+
+
+
+
+ {{ 'labels.buttons.Edit' | translate }}
+
- @if (savingProduct.description) {
-
-
{{ 'labels.inputs.Description' | translate }}:
-
{{ savingProduct.description }}
+
+
+
+
+
{{ 'labels.inputs.Details' | translate }}
- }
+
+
+ {{ 'labels.inputs.Short Name' | translate }}
+ {{ savingProduct.shortName }}
+
+ @if (savingProduct.description) {
+
+ {{ 'labels.inputs.Description' | translate }}
+ {{ savingProduct.description }}
+
+ }
+
+
+
@if (
savingProduct.accountType ||
savingProduct.customerSegment ||
@@ -47,513 +80,599 @@
savingProduct.introducerRequired !== undefined ||
savingProduct.shareLinkingEnabled !== undefined
) {
-
Product Classification
-
-
-
- @if (savingProduct.accountType) {
-
-
Account Type:
-
{{ savingProduct.accountType }}
+
+
+
+
Product Classification
- }
-
- @if (savingProduct.customerSegment) {
-
- Customer Segment:
- {{ savingProduct.customerSegment }}
-
- }
-
- @if (savingProduct.glAccountId) {
-
- GL Account (Liability):
- {{ savingProduct.glAccountName || savingProduct.glAccountId }}
-
- }
-
- @if (savingProduct.effectiveDate) {
-
- Effective Date:
- {{ savingProduct.effectiveDate | dateFormat }}
-
- }
-
- @if (savingProduct.effectiveTo) {
-
- Effective To:
- {{ savingProduct.effectiveTo | dateFormat }}
-
- }
-
- @if (savingProduct.endDate) {
-
- End Date:
- {{ savingProduct.endDate | dateFormat }}
-
- }
-
- @if (savingProduct.introducerRequired !== undefined) {
-
- Introducer Required:
- {{
- savingProduct.introducerRequired === true || savingProduct.introducerRequired === 'Yes' ? 'Yes' : 'No'
- }}
-
- }
-
- @if (savingProduct.shareLinkingEnabled !== undefined) {
-
- Share Linking Enabled:
- {{ savingProduct.shareLinkingEnabled | yesNo }}
-
- }
- }
-
- {{ 'labels.inputs.Currency' | translate }}
-
-
-
-
- {{ 'labels.inputs.Currency' | translate }}:
- {{ savingProduct.currency.name }}
-
-
-
- {{ 'labels.inputs.Decimal Places' | translate }}:
- {{ savingProduct.currency.decimalPlaces }}
-
-
- @if (savingProduct.currency.inMultiplesOf !== undefined && savingProduct.currency.inMultiplesOf !== null) {
-
- {{ 'labels.inputs.Currency in multiples of' | translate }}:
- {{ savingProduct.currency.inMultiplesOf }}
-
- }
-
- @if (savingProduct.dormancyAlert !== undefined) {
-
- Dormancy Alert:
- {{ savingProduct.dormancyAlert | yesNo }}
-
- }
-
- @if (savingProduct.nominationFacility !== undefined) {
-
- Nomination Facility:
- {{ savingProduct.nominationFacility | yesNo }}
-
- }
-
- {{ 'labels.inputs.Terms' | translate }}
-
-
-
-
- {{ 'labels.inputs.Nominal Annual Interest' | translate }}:
- {{ savingProduct.nominalAnnualInterestRate | formatNumber }} %
-
-
-
- {{ 'labels.inputs.Interest Compounding Period' | translate }}:
- {{ savingProduct.interestCompoundingPeriodType.value | translateKey: 'catalogs' }}
-
-
-
- {{ 'labels.inputs.Interest Posting Period' | translate }}:
- {{ savingProduct.interestPostingPeriodType.value | translateKey: 'catalogs' }}
-
-
- @if (savingProduct.interestCalculationTypeName) {
-
- {{ 'labels.inputs.Interest Calculation Type' | translate }}:
- {{ savingProduct.interestCalculationTypeName }}
-
- }
-
-
- {{ 'labels.inputs.Interest Calculated using' | translate }}:
- {{ savingProduct.interestCalculationType.value | translateKey: 'catalogs' }}
-
-
-
- {{ 'labels.inputs.Days in Year' | translate }}:
- {{ savingProduct.interestCalculationDaysInYearType.value | translateKey: 'catalogs' }}
-
-
- @if (savingProduct.effectiveDate) {
-
- Effective Date:
- {{ savingProduct.effectiveDate | dateFormat }}
-
- }
-
-
- Interest Calculate:
- {{ savingProduct.interestCalculationType?.value | translateKey: 'catalogs' }}
-
-
- @if (savingProduct.averagePeriod) {
-
- Average Period:
- {{ savingProduct.averagePeriod }}
-
- }
-
-
- Auto Post:
- {{ savingProduct.autoPost | yesNo }}
-
-
- @if (savingProduct.frequencyFixDay) {
-
- Frequency Fix Day:
- {{ savingProduct.frequencyFixDay }}
-
- }
-
- @if (savingProduct.lastInterestDate) {
-
- Last Interest Date:
- {{ savingProduct.lastInterestDate | dateFormat }}
-
- }
-
- {{ 'labels.inputs.Settings' | translate }}
-
-
-
- @if (savingProduct.minRequiredOpeningBalance) {
-
- {{ 'labels.inputs.Minimum Opening Balance' | translate }}:
- {{ savingProduct.minRequiredOpeningBalance | formatNumber }}
-
- }
-
- @if (savingProduct.lockinPeriodFrequency) {
-
- {{ 'labels.inputs.Lock-in Period' | translate }}:
- {{
- savingProduct.lockinPeriodFrequency + ' ' + savingProduct.lockinPeriodFrequencyType.value
- }}
-
- }
-
-
- {{ 'labels.inputs.Apply Withdrawal Fee for Transfers' | translate }}:
- {{ savingProduct.withdrawalFeeForTransfers | yesNo }}
-
-
- @if (savingProduct.balanceFrequency) {
-
- Balance Frequency:
- {{ savingProduct.balanceFrequency }}
-
- }
-
-
- Enable Lock-in Period:
- {{ savingProduct.enableLockinPeriod | yesNo }}
-
-
- @if (savingProduct.minBalanceForInterestCalculation) {
-
- {{ 'labels.inputs.Balance Required for Interest Calculation' | translate }}:
- {{ savingProduct.minBalanceForInterestCalculation | formatNumber }}
-
- }
-
-
- {{ 'labels.inputs.Enforce Minimum Balance' | translate }}:
- {{ savingProduct.enforceMinRequiredBalance | yesNo }}
-
-
- @if (savingProduct.minRequiredBalance) {
-
- {{ 'labels.inputs.Minimum Balance' | translate }}:
- {{ savingProduct.minRequiredBalance | formatNumber }}
-
- }
-
-
- {{ 'labels.inputs.Withhold Tax is Applicable' | translate }}:
- {{ savingProduct.withHoldTax | yesNo }}
-
-
- @if (savingProduct.withHoldTax) {
-
- {{ 'labels.inputs.Tax Group' | translate }}:
- {{ savingProduct.taxGroup.name }}
-
- }
-
-
- {{ 'labels.inputs.Is Overdraft Allowed' | translate }}:
- {{ savingProduct.allowOverdraft | yesNo }}
-
-
-
- Account Closed Zero Balance in Day Closing:
- {{ savingProduct.accountClosedZeroBalanceInDayClosing | yesNo }}
-
-
- @if (savingProduct.minOverdraftForInterestCalculation) {
-
- {{ 'labels.inputs.Minimum Overdraft Required for Interest Calculation' | translate }}:
- {{ savingProduct.minOverdraftForInterestCalculation | formatNumber }}
-
- }
-
- @if (savingProduct.nominalAnnualInterestRateOverdraft) {
-
- {{ 'labels.inputs.Nominal Annual Interest for Overdraft' | translate }}:
- {{ savingProduct.nominalAnnualInterestRateOverdraft | formatNumber }} %
-
- }
-
- @if (savingProduct.overdraftLimit) {
-
- {{ 'labels.inputs.Maximum Overdraft Amount Limit' | translate }}:
- {{ savingProduct.overdraftLimit | formatNumber }}
-
- }
-
-
- {{ 'labels.inputs.Enable Dormancy Tracking' | translate }}:
- {{ savingProduct.isDormancyTrackingActive | yesNo }}
-
-
- @if (savingProduct.isDormancyTrackingActive) {
-
- {{ 'labels.inputs.Number of Days to Inactive sub-status' | translate }}:
- {{ savingProduct.daysToInactive }}
- {{ 'labels.inputs.Number of Days to Dormant sub-status' | translate }}:
- {{ savingProduct.daysToDormancy }}
- {{ 'labels.inputs.Number of Days to Escheat' | translate }}:
- {{ savingProduct.daysToEscheat }}
-
- }
-
- Account Facilities
-
-
- @if (savingProduct.accountFacilityConfig) {
-
- @for (feature of accountFacilityFeatureItems; track feature.key) {
- @if (savingProduct.accountFacilityConfig[feature.key] !== undefined) {
-
-
{{ feature.label }}:
-
{{ savingProduct.accountFacilityConfig[feature.key] | yesNo }}
+
+ @if (savingProduct.accountType) {
+
+ Account Type
+ {{ savingProduct.accountType }}
}
+ @if (savingProduct.customerSegment) {
+
+ Customer Segment
+ {{ savingProduct.customerSegment }}
+
+ }
+ @if (savingProduct.glAccountId) {
+
+ GL Account (Liability)
+ {{ savingProduct.glAccountName || savingProduct.glAccountId }}
+
+ }
+ @if (savingProduct.effectiveDate) {
+
+ Effective Date
+ {{ savingProduct.effectiveDate | dateFormat }}
+
+ }
+ @if (savingProduct.effectiveTo) {
+
+ Effective To
+ {{ savingProduct.effectiveTo | dateFormat }}
+
+ }
+ @if (savingProduct.endDate) {
+
+ End Date
+ {{ savingProduct.endDate | dateFormat }}
+
+ }
+ @if (savingProduct.introducerRequired !== undefined) {
+
+ Introducer Required
+
+
+ {{
+ savingProduct.introducerRequired === true || savingProduct.introducerRequired === 'Yes' ? 'Yes' : 'No'
+ }}
+
+
+
+ }
+ @if (savingProduct.shareLinkingEnabled !== undefined) {
+
+ Share Linking Enabled
+
+
+ {{ savingProduct.shareLinkingEnabled | yesNo }}
+
+
+
+ }
+
+
+ }
+
+
+
+
+
+
{{ 'labels.inputs.Currency' | translate }}
+
+
+
+ {{ 'labels.inputs.Currency' | translate }}
+ {{ savingProduct.currency.name }}
+
+
+ {{ 'labels.inputs.Decimal Places' | translate }}
+ {{ savingProduct.currency.decimalPlaces }}
+
+ @if (savingProduct.currency.inMultiplesOf !== undefined && savingProduct.currency.inMultiplesOf !== null) {
+
+ {{ 'labels.inputs.Currency in multiples of' | translate }}
+ {{ savingProduct.currency.inMultiplesOf }}
+
+ }
+ @if (savingProduct.dormancyAlert !== undefined) {
+
+ Dormancy Alert
+ {{ savingProduct.dormancyAlert | yesNo }}
+
+ }
+ @if (savingProduct.nominationFacility !== undefined) {
+
+ Nomination Facility
+ {{ savingProduct.nominationFacility | yesNo }}
+
}
- }
+
- @if (savingProduct.accountFacilityLimits?.length) {
-
-
Account Facility Limits
-
-
-
- Limit Type
- {{ row.limitType }}
-
-
- General Limit
- {{ row.generalLimit }}
-
-
- Minor Account Limit
- {{ row.minorAccountLimit }}
-
-
- Small Saving Limit
- {{ row.smallSavingLimit }}
-
-
-
-
+
+
+
+
+
{{ 'labels.inputs.Terms' | translate }}
- }
-
- @if (savingProduct.depositLimitKyc) {
-
-
Deposit Limit (KYC)
-
-
General:
-
{{ savingProduct.depositLimitKyc.general.kycType }} - {{ savingProduct.depositLimitKyc.general.limit }}
+
+ {{ 'labels.inputs.Nominal Annual Interest' | translate }}
+ {{ savingProduct.nominalAnnualInterestRate | formatNumber }}%
-
-
Minor:
-
{{ savingProduct.depositLimitKyc.minor.kycType }} - {{ savingProduct.depositLimitKyc.minor.limit }}
+
+ {{ 'labels.inputs.Interest Compounding Period' | translate }}
+ {{ savingProduct.interestCompoundingPeriodType.value | translateKey: 'catalogs' }}
-
-
Small Saving:
-
{{ savingProduct.depositLimitKyc.smallSaving.kycType }} -
- {{ savingProduct.depositLimitKyc.smallSaving.limit }}
+
+ {{ 'labels.inputs.Interest Posting Period' | translate }}
+ {{ savingProduct.interestPostingPeriodType.value | translateKey: 'catalogs' }}
+ @if (savingProduct.interestCalculationTypeName) {
+
+ {{ 'labels.inputs.Interest Calculation Type' | translate }}
+ {{ savingProduct.interestCalculationTypeName }}
+
+ }
+
+ {{ 'labels.inputs.Interest Calculated using' | translate }}
+ {{ savingProduct.interestCalculationType.value | translateKey: 'catalogs' }}
+
+
+ {{ 'labels.inputs.Days in Year' | translate }}
+ {{
+ savingProduct.interestCalculationDaysInYearType.value | translateKey: 'catalogs'
+ }}
+
+ @if (savingProduct.effectiveDate) {
+
+ Effective Date
+ {{ savingProduct.effectiveDate | dateFormat }}
+
+ }
+ @if (savingProduct.averagePeriod) {
+
+ Average Period
+ {{ savingProduct.averagePeriod }}
+
+ }
+
+ Auto Post
+
+
+ {{ savingProduct.autoPost | yesNo }}
+
+
+
+ @if (savingProduct.frequencyFixDay) {
+
+ Frequency Fix Day
+ {{ savingProduct.frequencyFixDay }}
+
+ }
+ @if (savingProduct.lastInterestDate) {
+
+ Last Interest Date
+ {{ savingProduct.lastInterestDate | dateFormat }}
+
+ }
+
+
+
+
+
+
+
{{ 'labels.inputs.Settings' | translate }}
+
+
+ @if (savingProduct.minRequiredOpeningBalance) {
+
+ {{ 'labels.inputs.Minimum Opening Balance' | translate }}
+ {{ savingProduct.minRequiredOpeningBalance | formatNumber }}
+
+ }
+ @if (savingProduct.lockinPeriodFrequency) {
+
+ {{ 'labels.inputs.Lock-in Period' | translate }}
+ {{
+ savingProduct.lockinPeriodFrequency + ' ' + savingProduct.lockinPeriodFrequencyType.value
+ }}
+
+ }
+
+ {{ 'labels.inputs.Apply Withdrawal Fee for Transfers' | translate }}
+
+
+ {{ savingProduct.withdrawalFeeForTransfers | yesNo }}
+
+
+
+ @if (savingProduct.balanceFrequency) {
+
+ Balance Frequency
+ {{ savingProduct.balanceFrequency }}
+
+ }
+
+ Enable Lock-in Period
+
+
+ {{ savingProduct.enableLockinPeriod | yesNo }}
+
+
+
+ @if (savingProduct.minBalanceForInterestCalculation) {
+
+ {{ 'labels.inputs.Balance Required for Interest Calculation' | translate }}
+ {{ savingProduct.minBalanceForInterestCalculation | formatNumber }}
+
+ }
+
+ {{ 'labels.inputs.Enforce Minimum Balance' | translate }}
+
+
+ {{ savingProduct.enforceMinRequiredBalance | yesNo }}
+
+
+
+ @if (savingProduct.minRequiredBalance) {
+
+ {{ 'labels.inputs.Minimum Balance' | translate }}
+ {{ savingProduct.minRequiredBalance | formatNumber }}
+
+ }
+
+ {{ 'labels.inputs.Withhold Tax is Applicable' | translate }}
+
+
+ {{ savingProduct.withHoldTax | yesNo }}
+
+
+
+ @if (savingProduct.withHoldTax) {
+
+ {{ 'labels.inputs.Tax Group' | translate }}
+ {{ savingProduct.taxGroup.name }}
+
+ }
+
+ {{ 'labels.inputs.Is Overdraft Allowed' | translate }}
+
+
+ {{ savingProduct.allowOverdraft | yesNo }}
+
+
+
+
+ Account Closed Zero Balance in Day Closing
+
+
+ {{ savingProduct.accountClosedZeroBalanceInDayClosing | yesNo }}
+
+
+
+ @if (savingProduct.minOverdraftForInterestCalculation) {
+
+ {{
+ 'labels.inputs.Minimum Overdraft Required for Interest Calculation' | translate
+ }}
+ {{ savingProduct.minOverdraftForInterestCalculation | formatNumber }}
+
+ }
+ @if (savingProduct.nominalAnnualInterestRateOverdraft) {
+
+ {{ 'labels.inputs.Nominal Annual Interest for Overdraft' | translate }}
+ {{ savingProduct.nominalAnnualInterestRateOverdraft | formatNumber }} %
+
+ }
+ @if (savingProduct.overdraftLimit) {
+
+ {{ 'labels.inputs.Maximum Overdraft Amount Limit' | translate }}
+ {{ savingProduct.overdraftLimit | formatNumber }}
+
+ }
+
+ {{ 'labels.inputs.Enable Dormancy Tracking' | translate }}
+
+
+ {{ savingProduct.isDormancyTrackingActive | yesNo }}
+
+
+
+ @if (savingProduct.isDormancyTrackingActive) {
+
+ {{ 'labels.inputs.Number of Days to Inactive sub-status' | translate }}
+ {{ savingProduct.daysToInactive }}
+
+
+ {{ 'labels.inputs.Number of Days to Dormant sub-status' | translate }}
+ {{ savingProduct.daysToDormancy }}
+
+
+ {{ 'labels.inputs.Number of Days to Escheat' | translate }}
+ {{ savingProduct.daysToEscheat }}
+
+ }
+
+
+
+
+ @if (
+ savingProduct.accountFacilityConfig || savingProduct.accountFacilityLimits?.length || savingProduct.depositLimitKyc
+ ) {
+
+
+
+ @if (savingProduct.accountFacilityConfig) {
+
+ @for (feature of accountFacilityFeatureItems; track feature.key) {
+ @if (savingProduct.accountFacilityConfig[feature.key] !== undefined) {
+
+
+ {{ feature.label }}
+
+ }
+ }
+
+ }
+
+ @if (savingProduct.accountFacilityLimits?.length) {
+ Account Facility Limits
+
+
+
+ Limit Type
+ {{ row.limitType }}
+
+
+ General Limit
+ {{ row.generalLimit }}
+
+
+ Minor Account Limit
+ {{ row.minorAccountLimit }}
+
+
+ Small Saving Limit
+ {{ row.smallSavingLimit }}
+
+
+
+
+
+ }
+
+ @if (savingProduct.depositLimitKyc) {
+ Deposit Limit (KYC)
+
+
+ General
+ {{ savingProduct.depositLimitKyc.general.kycType }} ·
+ {{ savingProduct.depositLimitKyc.general.limit }}
+
+
+ Minor
+ {{ savingProduct.depositLimitKyc.minor.kycType }} · {{ savingProduct.depositLimitKyc.minor.limit }}
+
+
+ Small Saving
+ {{ savingProduct.depositLimitKyc.smallSaving.kycType }} ·
+ {{ savingProduct.depositLimitKyc.smallSaving.limit }}
+
+
+ }
+
}
+
@if (savingProduct.charges.length) {
-
-
{{ 'labels.heading.Charges' | translate }}
-
-
-
- {{ 'labels.inputs.Name' | translate }}
-
- {{ charge.name + ', ' + charge.currency.displaySymbol }}
-
-
-
- {{ 'labels.inputs.Type' | translate }}
-
- {{ charge.chargeCalculationType.value | translateKey: 'catalogs' }}
-
-
-
- {{ 'labels.inputs.Amount' | translate }}
-
- {{ charge.amount | formatNumber }}
-
-
-
- {{ 'labels.inputs.Collected On' | translate }}
-
- {{ charge.chargeTimeType.value | translateKey: 'catalogs' }}
-
-
-
-
-
-
+
+
+
+
{{ 'labels.heading.Charges' | translate }}
+
+
+
+
+ {{ 'labels.inputs.Name' | translate }}
+ {{ charge.name + ', ' + charge.currency.displaySymbol }}
+
+
+ {{ 'labels.inputs.Type' | translate }}
+
+ {{ charge.chargeCalculationType.value | translateKey: 'catalogs' }}
+
+
+
+ {{ 'labels.inputs.Amount' | translate }}
+ {{ charge.amount | formatNumber }}
+
+
+ {{ 'labels.inputs.Collected On' | translate }}
+ {{ charge.chargeTimeType.value | translateKey: 'catalogs' }}
+
+
+
+
+
+
}
-
{{ 'labels.heading.Accounting' | translate }}
-
-
-
-
-
+
+
+
+
+
{{ 'labels.heading.Accounting' | translate }}
+
+
+
+
@if (savingProduct.otherGlMappingsMap) {
-
-
Other GL Mappings
-
+
+
@if (savingProduct.otherGlMappingsMap.assets?.length) {
- Assets
-
-
- Account Name
- {{ row.accountName }}
-
-
- GL Account
- {{ row.glCode || row.glAccountId }}
-
-
-
-
+ Assets
+
+
+
+ Account Name
+ {{ row.accountName }}
+
+
+ GL Account
+ {{ row.glCode || row.glAccountId }}
+
+
+
+
+
}
-
@if (savingProduct.otherGlMappingsMap.liabilities?.length) {
- Liabilities
-
-
- Account Name
- {{ row.accountName }}
-
-
- GL Account
- {{ row.glCode || row.glAccountId }}
-
-
-
-
+ Liabilities
+
+
+
+ Account Name
+ {{ row.accountName }}
+
+
+ GL Account
+ {{ row.glCode || row.glAccountId }}
+
+
+
+
+
}
-
@if (savingProduct.otherGlMappingsMap.expenses?.length) {
- Expenses
-
-
- Account Name
- {{ row.accountName }}
-
-
- GL Account
- {{ row.glCode || row.glAccountId }}
-
-
-
-
+ Expenses
+
+
+
+ Account Name
+ {{ row.accountName }}
+
+
+ GL Account
+ {{ row.glCode || row.glAccountId }}
+
+
+
+
+
}
-
@if (savingProduct.otherGlMappingsMap.income?.length) {
- Income
-
-
- Account Name
- {{ row.accountName }}
-
-
- GL Account
- {{ row.glCode || row.glAccountId }}
-
-
-
-
+ Income
+
+
+
+ Account Name
+ {{ row.accountName }}
+
+
+ GL Account
+ {{ row.glCode || row.glAccountId }}
+
+
+
+
+
}
-
+
}
+
@if (
savingProduct.paymentChannelToFundSourceMappings?.length ||
savingProduct.feeToIncomeAccountMappings?.length ||
savingProduct.penaltyToIncomeAccountMappings?.length
) {
-
-
{{ 'labels.inputs.Advanced Accounting Rules' | translate }}
-
+
+
+
+
{{ 'labels.inputs.Advanced Accounting Rules' | translate }}
+
+
@if (savingProduct.paymentChannelToFundSourceMappings?.length) {
-
-
{{ 'labels.inputs.Fund Sources for Payment Channels' | translate }}
-
+ {{ 'labels.inputs.Fund Sources for Payment Channels' | translate }}
+
+
{{ 'labels.inputs.Payment Type' | translate }}
-
- {{ paymentFundSource.paymentType.name }}
-
+ {{ p.paymentType.name }}
{{ 'labels.inputs.Fund Source' | translate }}
-
- ({{ paymentFundSource.fundSourceAccount.glCode }}) {{ paymentFundSource.fundSourceAccount.name }}
-
+ ({{ p.fundSourceAccount.glCode }}) {{ p.fundSourceAccount.name }}
@@ -561,18 +680,16 @@
}
@if (savingProduct.feeToIncomeAccountMappings?.length) {
-
-
{{ 'labels.inputs.Fees to Specific Income Accounts' | translate }}
-
+ {{ 'labels.inputs.Fees to Specific Income Accounts' | translate }}
+
+
{{ 'labels.inputs.Fee/Charge' | translate }}
-
- {{ fee.charge.name }}
-
+ {{ f.charge.name }}
{{ 'labels.inputs.Income Account' | translate }}
- ({{ fee.incomeAccount.glCode }}) {{ fee.incomeAccount.name }}
+ ({{ f.incomeAccount.glCode }}) {{ f.incomeAccount.name }}
@@ -580,30 +697,22 @@
}
@if (savingProduct.penaltyToIncomeAccountMappings?.length) {
-
-
{{ 'labels.inputs.Penalties to Specific Income Accounts' | translate }}
-
+ {{ 'labels.inputs.Penalties to Specific Income Accounts' | translate }}
+
+
{{ 'labels.inputs.Penalty' | translate }}
-
- {{ penalty.charge.name }}
-
+ {{ p.charge.name }}
{{ 'labels.inputs.Income Account' | translate }}
-
- ({{ penalty.incomeAccount.glCode }}) {{ penalty.incomeAccount.name }}
-
+ ({{ p.incomeAccount.glCode }}) {{ p.incomeAccount.name }}
}
-
+
}
diff --git a/src/app/products/saving-products/view-saving-product/saving-product-general-tab/saving-product-general-tab.component.scss b/src/app/products/saving-products/view-saving-product/saving-product-general-tab/saving-product-general-tab.component.scss
index 84df78f65..87d33be07 100644
--- a/src/app/products/saving-products/view-saving-product/saving-product-general-tab/saving-product-general-tab.component.scss
+++ b/src/app/products/saving-products/view-saving-product/saving-product-general-tab/saving-product-general-tab.component.scss
@@ -6,160 +6,443 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
-@use 'assets/styles/helper';
-@use 'assets/styles/colours' as *;
+:host {
+ --vsp-navy: #0b1e3d;
+ --vsp-blue: #1d4ed8;
+ --vsp-blue-l: #eff6ff;
+ --vsp-teal: #0e7e6b;
+ --vsp-teal-l: #ccfbf1;
+ --vsp-purple: #6d28d9;
+ --vsp-purple-l: #ede9fe;
+ --vsp-indigo: #3730a3;
+ --vsp-indigo-l: #e0e7ff;
+ --vsp-amber: #b45309;
+ --vsp-amber-l: #fef3c7;
+ --vsp-green: #15803d;
+ --vsp-green-l: #dcfce7;
+ --vsp-red: #c0392b;
+ --vsp-red-l: #fee2e2;
+ --vsp-pink: #be185d;
+ --vsp-pink-l: #fce7f3;
+ --vsp-g50: #f8fafc;
+ --vsp-g100: #f1f5f9;
+ --vsp-g200: #e2e8f0;
+ --vsp-g500: #64748b;
+ --vsp-g700: #334155;
+ --vsp-g900: #0f172a;
-.tab-container {
- padding: 1%;
- margin: 1%;
+ display: block;
+ font-family:
+ 'DM Sans',
+ system-ui,
+ -apple-system,
+ sans-serif;
+ color: var(--vsp-g900);
+ font-size: 13.5px;
}
-.mat-h3 {
- margin-top: 10px !important;
- font-weight: 300;
- color: $status-approved;
-}
-
-.interestCarts {
- float: right;
-}
-
-h2,
-h3,
-h4 {
- margin: 0;
- font-weight: 500;
-}
-
-table {
- width: 100%;
-
- tr.incentives-row {
- height: 0;
-
- > td {
- padding: 0;
- }
-
- .incentives {
- overflow: hidden;
- }
-
- .mat-card {
- border-radius: 0;
-
- > h4 {
- font-weight: 500;
- margin: 0.5em 0 0;
- }
- }
- }
-}
-
-.mat-elevation-z1 {
- margin: 1em 0 1.5em;
-}
-
-span {
- margin: 0.5em 0;
-}
-
-mat-divider {
- margin: 0 0 0.5em;
-}
-
-.margin-t {
- margin-top: 1em;
-}
-
-.spa-gl-summary,
-.spa-other-gl-summary {
+.vsp-root {
+ padding: 4px 4px 24px;
display: flex;
flex-direction: column;
- gap: 0.9rem;
- margin-top: 1.25rem;
+ gap: 14px;
}
-.spa-gl-summary__header {
+/* ───────── HERO ───────── */
+.vsp-hero {
+ background: linear-gradient(135deg, var(--vsp-navy) 0%, var(--vsp-blue) 100%);
+ border-radius: 14px;
+ padding: 22px 22px 22px 18px;
display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 0.25rem 0 0.5rem;
-}
-
-.spa-gl-section {
- display: flex;
- flex-direction: column;
- gap: 0.75rem;
- padding: 1rem 1.15rem;
- border: 1px solid #e3e7f2;
- border-radius: 12px;
- background: #fff;
-}
-
-.spa-gl-section h5 {
- font-size: 0.9rem;
- font-weight: 700;
- letter-spacing: 0.08em;
- text-transform: uppercase;
- color: #91a0bc;
-}
-
-.spa-gl-row {
- display: grid;
- grid-template-columns: minmax(220px, 40%) 1fr;
- gap: 1rem;
- align-items: baseline;
- padding: 0.2rem 0;
-}
-
-.spa-gl-row__label {
- color: #1f2f4a;
- font-weight: 500;
-}
-
-.spa-gl-row__value {
- color: #101828;
- font-weight: 600;
- word-break: break-word;
-}
-
-.spa-other-gl-table {
- margin-top: 0.5rem;
- margin-bottom: 0.75rem;
- table-layout: fixed;
- width: 100%;
-}
-
-.spa-other-gl-table:first-of-type {
- margin-top: 0.25rem;
-}
-
-.spa-other-gl-summary .mat-elevation-z1 {
- border-radius: 10px;
+ align-items: flex-start;
+ gap: 18px;
+ box-shadow:
+ 0 10px 24px rgb(11 30 61 / 18%),
+ 0 4px 10px rgb(11 30 61 / 12%);
+ color: #fff;
+ position: relative;
overflow: hidden;
}
-.spa-other-gl-table th.mat-mdc-header-cell,
-.spa-other-gl-table td.mat-mdc-cell {
- padding-left: 1.25rem;
- padding-right: 1.25rem;
+.vsp-hero::before {
+ content: '';
+ position: absolute;
+ top: -60px;
+ right: -60px;
+ width: 240px;
+ height: 240px;
+ border-radius: 50%;
+ background: radial-gradient(circle, rgb(255 255 255 / 8%) 0%, transparent 70%);
+ pointer-events: none;
}
-.spa-other-gl-table th.mat-mdc-header-cell:first-child,
-.spa-other-gl-table td.mat-mdc-cell:first-child {
- width: 38%;
+.vsp-hero-left {
+ display: flex;
+ align-items: flex-start;
+ gap: 16px;
+ flex: 1;
+ min-width: 0;
}
-.spa-other-gl-table th.mat-mdc-header-cell:last-child,
-.spa-other-gl-table td.mat-mdc-cell:last-child {
- width: 62%;
- word-break: break-word;
+.vsp-hero-icon {
+ width: 56px;
+ height: 56px;
+ border-radius: 14px;
+ background: rgb(255 255 255 / 16%);
+ border: 1px solid rgb(255 255 255 / 22%);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 24px;
+ flex-shrink: 0;
}
-@media (width <= 768px) {
- .spa-gl-row {
+.vsp-hero-text {
+ flex: 1;
+ min-width: 0;
+}
+
+.vsp-hero-eyebrow {
+ font-size: 10.5px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ color: rgb(255 255 255 / 70%);
+ margin-bottom: 4px;
+}
+
+.vsp-hero-title {
+ margin: 0 0 10px;
+ font-size: 22px;
+ font-weight: 700;
+ letter-spacing: 0.01em;
+ color: #fff;
+ line-height: 1.2;
+}
+
+.vsp-hero-meta {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.vsp-hero-desc {
+ margin: 12px 0 0;
+ font-size: 13px;
+ color: rgb(255 255 255 / 82%);
+ line-height: 1.5;
+ max-width: 720px;
+}
+
+.vsp-edit-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ height: 38px;
+ padding: 0 16px;
+ border-radius: 10px;
+ background: rgb(255 255 255 / 14%);
+ color: #fff;
+ border: 1px solid rgb(255 255 255 / 28%);
+ font-size: 13px;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ cursor: pointer;
+ text-decoration: none;
+ white-space: nowrap;
+ font-family: inherit;
+ flex-shrink: 0;
+ transition: all 0.16s ease;
+
+ fa-icon {
+ font-size: 12px;
+ }
+
+ &:hover {
+ background: rgb(255 255 255 / 24%);
+ border-color: rgb(255 255 255 / 50%);
+ }
+}
+
+/* ───────── PILLS ───────── */
+.vsp-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ padding: 4px 10px;
+ border-radius: 999px;
+ font-family: inherit;
+
+ fa-icon {
+ font-size: 9.5px;
+ }
+
+ &--code {
+ background: rgb(255 255 255 / 18%);
+ color: #fff;
+ border: 1px solid rgb(255 255 255 / 24%);
+ font-family: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace;
+ }
+
+ &--teal {
+ background: var(--vsp-teal-l);
+ color: var(--vsp-teal);
+ border: 1px solid #99f6e4;
+ }
+
+ &--blue {
+ background: var(--vsp-blue-l);
+ color: var(--vsp-blue);
+ border: 1px solid #dbeafe;
+ }
+
+ &--green {
+ background: var(--vsp-green-l);
+ color: var(--vsp-green);
+ border: 1px solid #bbf7d0;
+ }
+
+ &--amber {
+ background: var(--vsp-amber-l);
+ color: var(--vsp-amber);
+ border: 1px solid #fde68a;
+ }
+
+ &--red {
+ background: var(--vsp-red-l);
+ color: var(--vsp-red);
+ border: 1px solid #fecaca;
+ }
+
+ &--gray {
+ background: var(--vsp-g100);
+ color: var(--vsp-g700);
+ border: 1px solid var(--vsp-g200);
+ }
+}
+
+/* ───────── SECTION ───────── */
+.vsp-section {
+ background: #fff;
+ border: 1px solid var(--vsp-g200);
+ border-radius: 12px;
+ padding: 16px 20px;
+ box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
+}
+
+.vsp-section-h {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 14px;
+ padding-bottom: 12px;
+ border-bottom: 1px dashed var(--vsp-g200);
+
+ h3 {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--vsp-g900);
+ letter-spacing: 0.02em;
+ }
+}
+
+.vsp-section-ic {
+ width: 36px;
+ height: 36px;
+ border-radius: 10px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 14px;
+ flex-shrink: 0;
+
+ &--blue {
+ background: var(--vsp-blue-l);
+ color: var(--vsp-blue);
+ }
+
+ &--teal {
+ background: var(--vsp-teal-l);
+ color: var(--vsp-teal);
+ }
+
+ &--purple {
+ background: var(--vsp-purple-l);
+ color: var(--vsp-purple);
+ }
+
+ &--indigo {
+ background: var(--vsp-indigo-l);
+ color: var(--vsp-indigo);
+ }
+
+ &--amber {
+ background: var(--vsp-amber-l);
+ color: var(--vsp-amber);
+ }
+
+ &--green {
+ background: var(--vsp-green-l);
+ color: var(--vsp-green);
+ }
+
+ &--red {
+ background: var(--vsp-red-l);
+ color: var(--vsp-red);
+ }
+
+ &--pink {
+ background: var(--vsp-pink-l);
+ color: var(--vsp-pink);
+ }
+}
+
+.vsp-sub-h {
+ margin: 16px 0 10px;
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--vsp-g700);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
+/* ───────── KEY/VALUE GRID ───────── */
+.vsp-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px 24px;
+}
+
+.vsp-grid--3 {
+ grid-template-columns: repeat(3, 1fr);
+}
+
+@media (width <= 900px) {
+ .vsp-grid,
+ .vsp-grid--3 {
+ grid-template-columns: 1fr 1fr;
+ }
+}
+
+@media (width <= 600px) {
+ .vsp-grid,
+ .vsp-grid--3 {
grid-template-columns: 1fr;
- gap: 0.35rem;
+ }
+}
+
+.vsp-kv {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ padding: 8px 0;
+ border-bottom: 1px dashed var(--vsp-g100);
+
+ &--span {
+ grid-column: 1 / -1;
+ }
+}
+
+.vsp-k {
+ font-size: 10.5px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--vsp-g500);
+}
+
+.vsp-v {
+ font-size: 13.5px;
+ color: var(--vsp-g900);
+ font-weight: 500;
+ word-break: break-word;
+
+ strong {
+ font-weight: 700;
+ }
+}
+
+.vsp-v--accent {
+ font-size: 18px;
+ font-weight: 700;
+ color: var(--vsp-blue);
+ letter-spacing: 0.01em;
+}
+
+.vsp-suffix {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--vsp-g500);
+ margin-left: 4px;
+}
+
+/* ───────── FEATURE GRID (account facilities) ───────── */
+.vsp-feature {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 12px;
+ border-radius: 8px;
+ background: var(--vsp-g50);
+ border: 1px solid var(--vsp-g200);
+ font-size: 12.5px;
+ font-weight: 600;
+ color: var(--vsp-g700);
+
+ fa-icon {
+ font-size: 12px;
+ color: var(--vsp-g500);
+ }
+
+ &.is-on {
+ background: var(--vsp-green-l);
+ border-color: #bbf7d0;
+ color: var(--vsp-green);
+
+ fa-icon {
+ color: var(--vsp-green);
+ }
+ }
+}
+
+/* ───────── TABLES ───────── */
+.vsp-table-wrap {
+ overflow-x: auto;
+ border: 1px solid var(--vsp-g200);
+ border-radius: 10px;
+ margin-top: 4px;
+}
+
+.vsp-table {
+ width: 100%;
+ background: #fff;
+
+ th.mat-mdc-header-cell {
+ background: var(--vsp-g50);
+ color: var(--vsp-g700);
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ padding: 11px 14px;
+ border-bottom: 1px solid var(--vsp-g200);
+ white-space: nowrap;
+ }
+
+ td.mat-mdc-cell {
+ padding: 10px 14px;
+ border-bottom: 1px solid var(--vsp-g100);
+ font-size: 12.5px;
+ color: var(--vsp-g900);
+ }
+
+ tbody tr:last-child td.mat-mdc-cell {
+ border-bottom: 0;
}
}
diff --git a/src/app/products/saving-products/view-saving-product/saving-product-general-tab/saving-product-general-tab.component.ts b/src/app/products/saving-products/view-saving-product/saving-product-general-tab/saving-product-general-tab.component.ts
index 8e7b1efa1..f74717eb9 100644
--- a/src/app/products/saving-products/view-saving-product/saving-product-general-tab/saving-product-general-tab.component.ts
+++ b/src/app/products/saving-products/view-saving-product/saving-product-general-tab/saving-product-general-tab.component.ts
@@ -10,7 +10,6 @@ import { Component, inject } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { Accounting } from 'app/core/utils/accounting';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
-import { MatDivider } from '@angular/material/divider';
import {
MatTable,
MatColumnDef,
@@ -36,7 +35,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
- MatDivider,
MatTable,
MatColumnDef,
MatHeaderCellDef,
diff --git a/src/app/products/share-products/share-products.component.html b/src/app/products/share-products/share-products.component.html
index 11730928b..c44b89651 100644
--- a/src/app/products/share-products/share-products.component.html
+++ b/src/app/products/share-products/share-products.component.html
@@ -6,51 +6,164 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
-
-
-
-
- {{ 'labels.buttons.Create Share Product' | translate }}
-
-
-
-
-
-
-
- {{ 'labels.inputs.Filter' | translate }}
-
-
-
+
+
-
-
-
- {{ 'labels.inputs.Name' | translate }}
- {{ shareProduct.name }}
-
+
+
+
+
+
{{ totalCount | number }}
+
Total Share Products
+
+
+
+
+
+
{{ totalSharesAggregate | number }}
+
Total Shares (all products)
+
+
+
+
+
+
{{ filteredCount | number }}
+
Showing after filter
+
+
+
-
- {{ 'labels.inputs.Short Name' | translate }}
- {{ shareProduct.shortName }}
-
+
+
-
- {{ 'labels.inputs.Total Shares' | translate }}
- {{ shareProduct.totalShares }}
-
+ @if (totalCount === 0) {
+
+
+
No share products yet
+
Create your first share product to enable share-capital subscriptions.
+
+
+ {{ 'labels.buttons.Create Share Product' | translate }}
+
+
+ } @else {
+
+
+
+ {{ 'labels.inputs.Name' | translate }}
+
+
+
+
-
-
-
+
+ {{ 'labels.inputs.Short Name' | translate }}
+
+ {{ p.shortName }}
+
+
-
+
+
+ {{ 'labels.inputs.Total Shares' | translate }}
+
+
+ {{ p.totalShares | number }}
+
+
+
+
+ Actions
+
+
+ View
+
+
+ Edit
+
+
+
+
+
+
+
+
+ @if (filteredCount === 0 && totalCount > 0) {
+
+
+ No products match your filter.
+
+ }
+
+
+
+ }
- {{ 'labels.buttons.Create Share Product' | translate }}
+ {{ 'labels.heading.Create Share Product' | translate }}
{{ 'labels.text.This option allows you to create new share product.' | translate }}
@@ -93,12 +206,7 @@
-
+ {{ 'labels.heading.List of share products in the organization' | translate }}
{{ 'labels.buttons.Close' | translate }}
diff --git a/src/app/products/share-products/share-products.component.scss b/src/app/products/share-products/share-products.component.scss
index 567dcb2f8..7efa1dee4 100644
--- a/src/app/products/share-products/share-products.component.scss
+++ b/src/app/products/share-products/share-products.component.scss
@@ -6,10 +6,12 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
-table {
- width: 100%;
+@use 'assets/styles/product-list' as pl;
- .select-row:hover {
- cursor: pointer;
- }
+@include pl.product-list-styles;
+
+/* Share-specific pink accent */
+:host {
+ --pl-accent: #be185d;
+ --pl-accent-l: #fce7f3;
}
diff --git a/src/app/products/share-products/share-products.component.ts b/src/app/products/share-products/share-products.component.ts
index 8402b3374..b01a42ab1 100644
--- a/src/app/products/share-products/share-products.component.ts
+++ b/src/app/products/share-products/share-products.component.ts
@@ -29,6 +29,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { PopoverService } from '../../configuration-wizard/popover/popover.service';
import { ConfigurationWizardService } from '../../configuration-wizard/configuration-wizard.service';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+import { MatTooltip } from '@angular/material/tooltip';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({
@@ -50,7 +51,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatHeaderRow,
MatRowDef,
MatRow,
- MatPaginator
+ MatPaginator,
+ MatTooltip
]
})
export class ShareProductsComponent implements OnInit, AfterViewInit {
@@ -59,14 +61,25 @@ export class ShareProductsComponent implements OnInit, AfterViewInit {
private configurationWizardService = inject(ConfigurationWizardService);
private popoverService = inject(PopoverService);
- shareProductsData: any;
+ shareProductsData: any[] = [];
displayedColumns: string[] = [
'name',
'shortName',
- 'totalShares'
+ 'totalShares',
+ 'actions'
];
dataSource: MatTableDataSource;
+ filteredCount = 0;
+
+ get totalCount(): number {
+ return this.shareProductsData?.length || 0;
+ }
+
+ get totalSharesAggregate(): number {
+ return (this.shareProductsData || []).reduce((s, p) => s + (Number(p?.totalShares) || 0), 0);
+ }
+
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
@ViewChild(MatSort, { static: true }) sort: MatSort;
@@ -95,10 +108,12 @@ export class ShareProductsComponent implements OnInit, AfterViewInit {
this.dataSource = new MatTableDataSource(this.shareProductsData);
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
+ this.filteredCount = this.totalCount;
+ this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length));
}
applyFilter(filterValue: string) {
- this.dataSource.filter = filterValue.trim().toLowerCase();
+ this.dataSource.filter = (filterValue || '').trim().toLowerCase();
}
/**
diff --git a/src/app/products/share-products/view-share-product/share-product-general-tab/share-product-general-tab.component.html b/src/app/products/share-products/view-share-product/share-product-general-tab/share-product-general-tab.component.html
index 624ecb60b..d1f16779a 100644
--- a/src/app/products/share-products/view-share-product/share-product-general-tab/share-product-general-tab.component.html
+++ b/src/app/products/share-products/view-share-product/share-product-general-tab/share-product-general-tab.component.html
@@ -6,243 +6,255 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
-
-
-
- {{ 'labels.buttons.Edit' | translate }}
-
-
-
- {{ 'labels.buttons.Dividends' | translate }}
-
-
-
-
-
-
-
{{ shareProduct.name }}
-
-
-
-
{{ 'labels.inputs.Details' | translate }}
-
-
-
-
- {{ 'labels.inputs.Short Name' | translate }}:
- {{ shareProduct.shortName }}
-
-
- @if (shareProduct.description) {
-
-
{{ 'labels.inputs.Description' | translate }}:
-
{{ shareProduct.description }}
+
+
+
+
+
+
+
Share Product
+
{{ shareProduct.name }}
+
+ {{ shareProduct.shortName }}
+ @if (shareProduct.currency?.code) {
+ {{ shareProduct.currency.code }}
+ }
+ @if (shareProduct.totalShares !== undefined && shareProduct.totalShares !== null) {
+ {{ shareProduct.totalShares | number }} shares
+ }
+
+ @if (shareProduct.description) {
+
{{ shareProduct.description }}
+ }
+
- }
-
-
{{ 'labels.heading.Currency' | translate }}
-
-
-
-
- {{ 'labels.inputs.Currency' | translate }}:
- {{ shareProduct.currency.name }}
-
-
-
- {{ 'labels.inputs.Decimal Places' | translate }}:
- {{ shareProduct.currency.decimalPlaces }}
-
-
-
- {{ 'labels.inputs.Currency in multiples of' | translate }}:
- {{ shareProduct.currency.inMultiplesOf }}
-
-
-
{{ 'labels.heading.Terms' | translate }}
-
-
-
-
- {{ 'labels.inputs.Total Number of Shares' | translate }}:
- {{ shareProduct.totalShares | formatNumber }}
-
-
-
- {{ 'labels.inputs.Shares to be Issued' | translate }}:
- {{ shareProduct.totalSharesIssued | formatNumber }}
-
-
-
- {{ 'labels.inputs.Nominal/Unit Price' | translate }}:
- {{ shareProduct.unitPrice | formatNumber }}
-
-
-
- {{ 'labels.inputs.Capital Value' | translate }}:
- {{ shareProduct.shareCapital | formatNumber }}
-
-
-
{{ 'labels.heading.Settings' | translate }}
-
-
-
-
- {{ 'labels.inputs.Shares per Client' | translate }}:
- {{
- shareProduct.nominalShares +
- ' (Min: ' +
- (shareProduct.minimumShares ? shareProduct.minimumShares : shareProduct.nominalShares) +
- ', Max: ' +
- (shareProduct.maximumShares ? shareProduct.maximumShares : shareProduct.nominalShares) +
- ')'
- }}
-
-
- @if (shareProduct.minimumActivePeriod) {
-
- {{ 'labels.inputs.Minimum Active Period' | translate }}:
- {{ shareProduct.minimumActivePeriod }}
- {{ shareProduct.minimumActivePeriodForDividendsTypeEnum.value | translateKey: 'catalogs' }}
+
+ {{ 'labels.buttons.Dividends' | translate }}
+
+
+ {{ 'labels.buttons.Edit' | translate }}
+
- }
+
+
+
+
{{ 'labels.inputs.Details' | translate }}
+
+
- @if (shareProduct.lockinPeriod) {
- {{ 'labels.inputs.Lock-in Period' | translate }}:
- {{ shareProduct.lockinPeriod }} {{ shareProduct.lockPeriodTypeEnum.value | translateKey: 'catalogs' }}
+ {{ 'labels.inputs.Short Name' | translate }}:
+ {{ shareProduct.shortName }}
- }
-
-
{{ 'labels.inputs.Allow dividends for inactive clients' | translate }}:
-
{{ shareProduct.allowDividendCalculationForInactiveClients | yesNo }}
+ @if (shareProduct.description) {
+
+ {{ 'labels.inputs.Description' | translate }}:
+ {{ shareProduct.description }}
+
+ }
+
+
{{ 'labels.heading.Currency' | translate }}
+
+
+
+
+ {{ 'labels.inputs.Currency' | translate }}:
+ {{ shareProduct.currency.name }}
+
+
+
+ {{ 'labels.inputs.Decimal Places' | translate }}:
+ {{ shareProduct.currency.decimalPlaces }}
+
+
+
+ {{ 'labels.inputs.Currency in multiples of' | translate }}:
+ {{ shareProduct.currency.inMultiplesOf }}
+
+
+
{{ 'labels.heading.Terms' | translate }}
+
+
+
+
+ {{ 'labels.inputs.Total Number of Shares' | translate }}:
+ {{ shareProduct.totalShares | formatNumber }}
+
+
+
+ {{ 'labels.inputs.Shares to be Issued' | translate }}:
+ {{ shareProduct.totalSharesIssued | formatNumber }}
+
+
+
+ {{ 'labels.inputs.Nominal/Unit Price' | translate }}:
+ {{ shareProduct.unitPrice | formatNumber }}
+
+
+
+ {{ 'labels.inputs.Capital Value' | translate }}:
+ {{ shareProduct.shareCapital | formatNumber }}
+
+
+
{{ 'labels.heading.Settings' | translate }}
+
+
+
+
+ {{ 'labels.inputs.Shares per Client' | translate }}:
+ {{
+ shareProduct.nominalShares +
+ ' (Min: ' +
+ (shareProduct.minimumShares ? shareProduct.minimumShares : shareProduct.nominalShares) +
+ ', Max: ' +
+ (shareProduct.maximumShares ? shareProduct.maximumShares : shareProduct.nominalShares) +
+ ')'
+ }}
+
+
+ @if (shareProduct.minimumActivePeriod) {
+
+ {{ 'labels.inputs.Minimum Active Period' | translate }}:
+ {{ shareProduct.minimumActivePeriod }}
+ {{ shareProduct.minimumActivePeriodForDividendsTypeEnum.value | translateKey: 'catalogs' }}
+
+ }
+
+ @if (shareProduct.lockinPeriod) {
+
+ {{ 'labels.inputs.Lock-in Period' | translate }}:
+ {{ shareProduct.lockinPeriod }} {{ shareProduct.lockPeriodTypeEnum.value | translateKey: 'catalogs' }}
+
+ }
+
+
+ {{ 'labels.inputs.Allow dividends for inactive clients' | translate }}:
+ {{ shareProduct.allowDividendCalculationForInactiveClients | yesNo }}
+
+
+ @if (shareProduct.marketPrice?.length) {
+
+
{{ 'labels.inputs.Market Price Period' | translate }}
+
+
+
+ {{ 'labels.inputs.From Date' | translate }}
+
+ {{ marketPrice.fromDate | dateFormat }}
+
+
+
+ {{ 'labels.inputs.Nominal/Unit Price' | translate }}
+
+ {{ marketPrice.shareValue | formatNumber }}
+
+
+
+
+
+
+ }
+
+ @if (shareProduct.charges.length) {
+
+
{{ 'labels.heading.Charges' | translate }}
+
+
+
+ {{ 'labels.inputs.Name' | translate }}
+
+ {{ charge.name + ', ' + charge.currency.displaySymbol }}
+
+
+
+ {{ 'labels.inputs.Type' | translate }}
+
+ {{ charge.chargeCalculationType.value | translateKey: 'catalogs' }}
+
+
+
+ {{ 'labels.inputs.Amount' | translate }}
+
+ {{ charge.amount | numberFormat }}
+
+
+
+ {{ 'labels.inputs.Collected On' | translate }}
+
+ {{ charge.chargeTimeType.value | translateKey: 'catalogs' }}
+
+
+
+
+
+
+ }
+
+
{{ 'labels.heading.Accounting' | translate }}
+
+
+
+
+ {{ 'labels.inputs.Type' | translate }}:
+ {{
+ 'labels.accounting.' + getAccountingRuleName(shareProduct.accountingRule.value) | translate
+ }}
+
+
+ @if (shareProduct.accountingRule.id === 2) {
+
+
{{ 'labels.heading.Assets' | translate }}
+
+
+
+
+
{{ 'labels.heading.Liabilities' | translate }}
+
+
+
+
+
{{ 'labels.heading.Share Equity' | translate }}
+
+
+
+
+
{{ 'labels.heading.Income' | translate }}
+
+
+
+
+
+ }
-
- @if (shareProduct.marketPrice?.length) {
-
-
{{ 'labels.inputs.Market Price Period' | translate }}
-
-
-
- {{ 'labels.inputs.From Date' | translate }}
-
- {{ marketPrice.fromDate | dateFormat }}
-
-
-
- {{ 'labels.inputs.Nominal/Unit Price' | translate }}
-
- {{ marketPrice.shareValue | formatNumber }}
-
-
-
-
-
-
- }
-
- @if (shareProduct.charges.length) {
-
-
{{ 'labels.heading.Charges' | translate }}
-
-
-
- {{ 'labels.inputs.Name' | translate }}
-
- {{ charge.name + ', ' + charge.currency.displaySymbol }}
-
-
-
- {{ 'labels.inputs.Type' | translate }}
-
- {{ charge.chargeCalculationType.value | translateKey: 'catalogs' }}
-
-
-
- {{ 'labels.inputs.Amount' | translate }}
-
- {{ charge.amount | numberFormat }}
-
-
-
- {{ 'labels.inputs.Collected On' | translate }}
-
- {{ charge.chargeTimeType.value | translateKey: 'catalogs' }}
-
-
-
-
-
-
- }
-
-
{{ 'labels.heading.Accounting' | translate }}
-
-
-
-
- {{ 'labels.inputs.Type' | translate }}:
- {{
- 'labels.accounting.' + getAccountingRuleName(shareProduct.accountingRule.value) | translate
- }}
-
-
- @if (shareProduct.accountingRule.id === 2) {
-
-
{{ 'labels.heading.Assets' | translate }}
-
-
-
-
-
{{ 'labels.heading.Liabilities' | translate }}
-
-
-
-
-
{{ 'labels.heading.Share Equity' | translate }}
-
-
-
-
-
{{ 'labels.heading.Income' | translate }}
-
-
-
-
-
- }
-
-
diff --git a/src/app/products/share-products/view-share-product/share-product-general-tab/share-product-general-tab.component.scss b/src/app/products/share-products/view-share-product/share-product-general-tab/share-product-general-tab.component.scss
index b927a75f6..c6be20032 100644
--- a/src/app/products/share-products/view-share-product/share-product-general-tab/share-product-general-tab.component.scss
+++ b/src/app/products/share-products/view-share-product/share-product-general-tab/share-product-general-tab.component.scss
@@ -8,6 +8,15 @@
@use 'assets/styles/helper';
@use 'assets/styles/colours' as *;
+@use 'assets/styles/product-view' as pv;
+
+@include pv.product-view-styles;
+
+/* Share-specific pink accent */
+:host {
+ --pv-accent: #be185d;
+ --pv-accent-l: #fce7f3;
+}
.tab-container {
padding: 1%;
diff --git a/src/app/shared/icons.module.ts b/src/app/shared/icons.module.ts
index 85e048995..84225dd03 100644
--- a/src/app/shared/icons.module.ts
+++ b/src/app/shared/icons.module.ts
@@ -176,6 +176,11 @@ import {
faStamp,
faSpinner,
faPhone,
+ faTruck,
+ faTruckFast,
+ faCommentDots,
+ faEllipsisV,
+ faEllipsisH,
faHashtag,
faMapMarkerAlt,
faHourglassHalf,
@@ -193,7 +198,10 @@ import {
faRobot,
faMicrochip,
faMagic,
- faQrcode
+ faQrcode,
+ faRedo,
+ faSignature,
+ faPercentage
} from '@fortawesome/free-solid-svg-icons';
/**
@@ -376,6 +384,11 @@ export class IconsModule {
faStamp,
faSpinner,
faPhone,
+ faTruck,
+ faTruckFast,
+ faCommentDots,
+ faEllipsisV,
+ faEllipsisH,
faHashtag,
faMapMarkerAlt,
faHourglassHalf,
@@ -393,7 +406,10 @@ export class IconsModule {
faRobot,
faMicrochip,
faMagic,
- faQrcode
+ faQrcode,
+ faRedo,
+ faSignature,
+ faPercentage
);
}
}
diff --git a/src/assets/styles/_client-action.scss b/src/assets/styles/_client-action.scss
new file mode 100644
index 000000000..95478681c
--- /dev/null
+++ b/src/assets/styles/_client-action.scss
@@ -0,0 +1,266 @@
+/**
+ * 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/.
+ */
+
+/*
+ * Shared "client action" page styling — used by Activate / Close / Withdraw /
+ * Reject / Reactivate etc. screens. Consumer overrides --ca-accent on :host
+ * to colour the hero card per action.
+ */
+
+@mixin client-action-styles {
+ :host {
+ --ca-navy: #0b1e3d;
+ --ca-blue: #1d4ed8;
+ --ca-blue-l: #eff6ff;
+ --ca-green: #15803d;
+ --ca-green-l: #dcfce7;
+ --ca-amber: #b45309;
+ --ca-amber-l: #fef3c7;
+ --ca-red: #c0392b;
+ --ca-red-l: #fee2e2;
+ --ca-g50: #f8fafc;
+ --ca-g100: #f1f5f9;
+ --ca-g200: #e2e8f0;
+ --ca-g500: #64748b;
+ --ca-g700: #334155;
+ --ca-g900: #0f172a;
+ --ca-accent: var(--ca-blue);
+ --ca-accent-l: var(--ca-blue-l);
+ --ca-accent-fg: #fff;
+
+ display: block;
+ font-family:
+ 'DM Sans',
+ system-ui,
+ -apple-system,
+ sans-serif;
+ color: var(--ca-g900);
+ font-size: 13.5px;
+ }
+
+ .ca-root {
+ padding: 18px 22px;
+ max-width: 720px;
+ margin: 0 auto;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ }
+
+ /* ───────── HERO ───────── */
+ .ca-hero {
+ background: linear-gradient(135deg, var(--ca-navy) 0%, var(--ca-accent) 100%);
+ border-radius: 14px;
+ padding: 22px 24px;
+ display: flex;
+ align-items: center;
+ gap: 18px;
+ box-shadow:
+ 0 10px 24px rgb(11 30 61 / 18%),
+ 0 4px 10px rgb(11 30 61 / 12%);
+ color: #fff;
+ position: relative;
+ overflow: hidden;
+ }
+
+ .ca-hero::before {
+ content: '';
+ position: absolute;
+ top: -60px;
+ right: -60px;
+ width: 220px;
+ height: 220px;
+ border-radius: 50%;
+ background: radial-gradient(circle, rgb(255 255 255 / 8%) 0%, transparent 70%);
+ pointer-events: none;
+ }
+
+ .ca-hero-icon {
+ width: 56px;
+ height: 56px;
+ border-radius: 14px;
+ background: rgb(255 255 255 / 16%);
+ border: 1px solid rgb(255 255 255 / 22%);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 26px;
+ flex-shrink: 0;
+ }
+
+ .ca-hero-text {
+ flex: 1;
+ min-width: 0;
+ }
+
+ .ca-hero-eyebrow {
+ font-size: 10.5px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ color: rgb(255 255 255 / 70%);
+ margin-bottom: 4px;
+ }
+
+ .ca-hero-title {
+ margin: 0;
+ font-size: 20px;
+ font-weight: 700;
+ letter-spacing: 0.01em;
+ color: #fff;
+ line-height: 1.2;
+ }
+
+ .ca-hero-sub {
+ margin: 6px 0 0;
+ font-size: 13px;
+ color: rgb(255 255 255 / 82%);
+ line-height: 1.5;
+ max-width: 540px;
+ }
+
+ /* ───────── CARD ───────── */
+ .ca-card {
+ background: #fff;
+ border: 1px solid var(--ca-g200);
+ border-radius: 14px;
+ overflow: hidden;
+ box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
+ }
+
+ .ca-card-body {
+ padding: 22px 24px;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ }
+
+ .ca-form-row {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+
+ mat-form-field {
+ width: 100%;
+ }
+ }
+
+ .ca-row-label {
+ font-size: 10.5px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--ca-g500);
+ margin-bottom: 2px;
+ }
+
+ /* Notice / warning strip below form for destructive actions */
+ .ca-notice {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ padding: 12px 14px;
+ border-radius: 10px;
+ background: var(--ca-accent-l);
+ border: 1px solid;
+ border-color: color-mix(in srgb, var(--ca-accent) 30%, transparent);
+ color: var(--ca-g700);
+ font-size: 12.5px;
+ line-height: 1.5;
+
+ fa-icon {
+ color: var(--ca-accent);
+ font-size: 14px;
+ margin-top: 1px;
+ flex-shrink: 0;
+ }
+
+ strong {
+ color: var(--ca-accent);
+ font-weight: 700;
+ }
+ }
+
+ /* ───────── FOOTER ACTIONS ───────── */
+ .ca-actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 10px;
+ padding: 14px 24px;
+ background: var(--ca-g50);
+ border-top: 1px solid var(--ca-g200);
+ }
+
+ .ca-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ height: 38px;
+ padding: 0 18px;
+ border-radius: 10px;
+ font-size: 13px;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ cursor: pointer;
+ text-decoration: none;
+ white-space: nowrap;
+ font-family: inherit;
+ transition: all 0.16s ease;
+ border: 1px solid transparent;
+
+ fa-icon {
+ font-size: 12px;
+ }
+
+ &:disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+ }
+ }
+
+ .ca-btn--cancel {
+ background: #fff;
+ color: var(--ca-g700);
+ border-color: var(--ca-g200);
+
+ &:hover {
+ background: var(--ca-g100);
+ border-color: var(--ca-g500);
+ }
+ }
+
+ .ca-btn--confirm {
+ background: linear-gradient(135deg, var(--ca-accent) 0%, var(--ca-navy) 100%);
+ color: var(--ca-accent-fg);
+ box-shadow:
+ 0 6px 16px color-mix(in srgb, var(--ca-accent) 30%, transparent),
+ inset 0 1px 0 rgb(255 255 255 / 18%);
+
+ &:hover:not(:disabled) {
+ transform: translateY(-1px);
+ box-shadow:
+ 0 10px 22px color-mix(in srgb, var(--ca-accent) 40%, transparent),
+ inset 0 1px 0 rgb(255 255 255 / 22%);
+ }
+
+ &:active {
+ transform: translateY(0);
+ }
+ }
+
+ /* Material form-field polish */
+ ::ng-deep .ca-card mat-form-field {
+ width: 100%;
+
+ .mat-mdc-form-field-infix {
+ padding-top: 10px;
+ padding-bottom: 10px;
+ }
+ }
+}
diff --git a/src/assets/styles/_product-list.scss b/src/assets/styles/_product-list.scss
new file mode 100644
index 000000000..4706b574b
--- /dev/null
+++ b/src/assets/styles/_product-list.scss
@@ -0,0 +1,605 @@
+/**
+ * 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/.
+ */
+
+/*
+ * Shared "product list" page styling — used by Saving / Loan / FD / RD / Share /
+ * Current Account product list screens. Components @use this partial so the
+ * card / KPI / search / table / action-button look is identical across all
+ * product types.
+ *
+ * Theming hook: each consumer may override `--pl-accent` and `--pl-accent-l`
+ * on the host element to tint the header/icon accent per product type.
+ */
+
+@mixin product-list-styles {
+ :host {
+ --pl-navy: #0b1e3d;
+ --pl-blue: #1d4ed8;
+ --pl-blue-l: #eff6ff;
+ --pl-teal: #0e7e6b;
+ --pl-teal-l: #ccfbf1;
+ --pl-purple: #6d28d9;
+ --pl-purple-l: #ede9fe;
+ --pl-amber: #b45309;
+ --pl-amber-l: #fef3c7;
+ --pl-green: #15803d;
+ --pl-green-l: #dcfce7;
+ --pl-red: #c0392b;
+ --pl-red-l: #fee2e2;
+ --pl-g50: #f8fafc;
+ --pl-g100: #f1f5f9;
+ --pl-g200: #e2e8f0;
+ --pl-g500: #64748b;
+ --pl-g700: #334155;
+ --pl-g900: #0f172a;
+
+ /* Default accent — consumer can override on :host */
+ --pl-accent: var(--pl-blue);
+ --pl-accent-l: var(--pl-blue-l);
+
+ display: block;
+ font-family:
+ 'DM Sans',
+ system-ui,
+ -apple-system,
+ sans-serif;
+ background: var(--pl-g50);
+ color: var(--pl-g900);
+ font-size: 13.5px;
+ }
+
+ .pl-root {
+ padding: 18px 22px;
+ max-width: 1400px;
+ margin: 0 auto;
+ }
+
+ /* ───────── HEADER ───────── */
+ .pl-header {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ flex-wrap: wrap;
+ margin-bottom: 14px;
+ }
+
+ .pl-header-left {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ flex: 1;
+ min-width: 320px;
+ }
+
+ .pl-header-ic {
+ width: 48px;
+ height: 48px;
+ border-radius: 14px;
+ background: linear-gradient(135deg, var(--pl-accent) 0%, var(--pl-navy) 100%);
+ color: #fff;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 20px;
+ flex-shrink: 0;
+ box-shadow:
+ 0 6px 16px rgb(29 78 216 / 25%),
+ inset 0 1px 0 rgb(255 255 255 / 22%);
+ }
+
+ .pl-header-text {
+ h2 {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 700;
+ color: var(--pl-g900);
+ letter-spacing: 0.01em;
+ }
+
+ p {
+ margin: 3px 0 0;
+ font-size: 12px;
+ color: var(--pl-g500);
+ line-height: 1.4;
+ }
+ }
+
+ .pl-header-right {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex-wrap: wrap;
+ }
+
+ .pl-count-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ padding: 6px 14px 6px 12px;
+ border-radius: 999px;
+ background: var(--pl-accent-l);
+ color: var(--pl-accent);
+ border: 1px solid #dbeafe;
+ font-size: 12px;
+ font-weight: 600;
+
+ strong {
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--pl-navy);
+ }
+
+ span {
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ font-size: 10.5px;
+ }
+
+ fa-icon {
+ font-size: 11px;
+ }
+ }
+
+ .pl-create-btn,
+ .pl-secondary-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ height: 38px;
+ padding: 0 16px;
+ border-radius: 10px;
+ font-size: 13px;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ cursor: pointer;
+ text-decoration: none;
+ white-space: nowrap;
+ font-family: inherit;
+ transition: all 0.16s ease;
+ border: 0;
+
+ fa-icon {
+ font-size: 12px;
+ }
+ }
+
+ .pl-create-btn {
+ background: linear-gradient(135deg, var(--pl-accent) 0%, var(--pl-navy) 100%);
+ color: #fff;
+ box-shadow:
+ 0 6px 16px rgb(29 78 216 / 30%),
+ inset 0 1px 0 rgb(255 255 255 / 18%);
+
+ &:hover {
+ transform: translateY(-1px);
+ box-shadow:
+ 0 10px 22px rgb(29 78 216 / 38%),
+ inset 0 1px 0 rgb(255 255 255 / 22%);
+ }
+
+ &:active {
+ transform: translateY(0);
+ }
+ }
+
+ .pl-secondary-btn {
+ background: #fff;
+ color: var(--pl-accent);
+ border: 1px solid var(--pl-g200);
+
+ &:hover {
+ background: var(--pl-accent-l);
+ border-color: var(--pl-accent);
+ }
+ }
+
+ /* ───────── KPI STRIP ───────── */
+ .pl-kpis {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 12px;
+ margin-bottom: 14px;
+ }
+
+ @media (width <= 720px) {
+ .pl-kpis {
+ grid-template-columns: 1fr;
+ }
+ }
+
+ .pl-kpi {
+ background: #fff;
+ border: 1px solid var(--pl-g200);
+ border-radius: 12px;
+ padding: 12px 16px;
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
+ border-left-width: 4px;
+ }
+
+ .pl-kpi-ic {
+ width: 40px;
+ height: 40px;
+ border-radius: 10px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 16px;
+ flex-shrink: 0;
+ }
+
+ .pl-kpi-n {
+ font-size: 20px;
+ font-weight: 700;
+ color: var(--pl-g900);
+ line-height: 1.1;
+ }
+
+ .pl-kpi-l {
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--pl-g500);
+ margin-top: 4px;
+ }
+
+ .pl-kpi--blue {
+ border-left-color: var(--pl-blue);
+
+ .pl-kpi-ic {
+ background: var(--pl-blue-l);
+ color: var(--pl-blue);
+ }
+ }
+
+ .pl-kpi--teal {
+ border-left-color: var(--pl-teal);
+
+ .pl-kpi-ic {
+ background: var(--pl-teal-l);
+ color: var(--pl-teal);
+ }
+ }
+
+ .pl-kpi--purple {
+ border-left-color: var(--pl-purple);
+
+ .pl-kpi-ic {
+ background: var(--pl-purple-l);
+ color: var(--pl-purple);
+ }
+ }
+
+ /* ───────── CARD + TOOLBAR ───────── */
+ .pl-card {
+ background: #fff;
+ border: 1px solid var(--pl-g200);
+ border-radius: 14px;
+ overflow: hidden;
+ box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
+ }
+
+ .pl-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 14px 16px;
+ background: #fff;
+ border-bottom: 1px solid var(--pl-g200);
+ }
+
+ .pl-search {
+ position: relative;
+ flex: 1;
+ max-width: 480px;
+
+ input {
+ width: 100%;
+ box-sizing: border-box;
+ padding: 9px 14px 9px 36px;
+ border: 1px solid var(--pl-g200);
+ border-radius: 10px;
+ background: var(--pl-g50);
+ font-size: 13px;
+ color: var(--pl-g900);
+ font-family: inherit;
+
+ &::placeholder {
+ color: #94a3b8;
+ }
+
+ &:focus {
+ outline: none;
+ border-color: var(--pl-accent);
+ background: #fff;
+ box-shadow: 0 0 0 3px rgb(29 78 216 / 14%);
+ }
+ }
+ }
+
+ .pl-search-ic {
+ position: absolute;
+ top: 50%;
+ left: 13px;
+ transform: translateY(-50%);
+ color: #94a3b8;
+ font-size: 13px;
+ pointer-events: none;
+ }
+
+ /* ───────── TABLE ───────── */
+ .pl-table-wrap {
+ overflow-x: auto;
+ }
+
+ .pl-table {
+ width: 100%;
+ background: #fff;
+
+ th.mat-mdc-header-cell {
+ background: var(--pl-g50);
+ color: var(--pl-g700);
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--pl-g200);
+ white-space: nowrap;
+
+ &.num {
+ text-align: right;
+ }
+ }
+
+ td.mat-mdc-cell {
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--pl-g100);
+ font-size: 13px;
+ color: var(--pl-g900);
+ vertical-align: middle;
+
+ &.num {
+ text-align: right;
+ }
+ }
+ }
+
+ .pl-row {
+ cursor: pointer;
+ transition: background 0.14s ease;
+
+ &:hover {
+ background: var(--pl-accent-l);
+ }
+ }
+
+ .pl-product-cell {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ }
+
+ .pl-product-icon {
+ width: 36px;
+ height: 36px;
+ border-radius: 10px;
+ background: var(--pl-accent-l);
+ color: var(--pl-accent);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 14px;
+ flex-shrink: 0;
+ transition: all 0.14s ease;
+ }
+
+ .pl-row:hover .pl-product-icon {
+ background: var(--pl-accent);
+ color: #fff;
+ transform: scale(1.04);
+ }
+
+ .pl-product-meta {
+ display: flex;
+ flex-direction: column;
+ line-height: 1.25;
+ }
+
+ .pl-product-name {
+ font-size: 13.5px;
+ font-weight: 600;
+ color: var(--pl-g900);
+ }
+
+ .pl-product-sub {
+ font-size: 11px;
+ color: var(--pl-g500);
+ margin-top: 2px;
+
+ span {
+ margin: 0 4px;
+ }
+ }
+
+ /* Short Name pill */
+ .pl-tag {
+ display: inline-flex;
+ align-items: center;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ padding: 3px 9px;
+ border-radius: 999px;
+ background: var(--pl-g100);
+ color: var(--pl-g700);
+ font-family: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace;
+ }
+
+ /* Currency pill */
+ .pl-currency-pill {
+ display: inline-flex;
+ align-items: center;
+ font-size: 11px;
+ font-weight: 700;
+ padding: 3px 9px;
+ border-radius: 999px;
+ background: var(--pl-teal-l);
+ color: var(--pl-teal);
+ border: 1px solid #99f6e4;
+ font-family: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace;
+ }
+
+ /* Status pill */
+ .pl-status-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ padding: 3px 10px;
+ border-radius: 999px;
+
+ fa-icon {
+ font-size: 9.5px;
+ }
+
+ &--active {
+ background: var(--pl-green-l);
+ color: var(--pl-green);
+ border: 1px solid #bbf7d0;
+ }
+
+ &--inactive {
+ background: var(--pl-g100);
+ color: var(--pl-g700);
+ border: 1px solid var(--pl-g200);
+ }
+ }
+
+ .muted {
+ color: var(--pl-g500);
+ }
+
+ /* Action buttons */
+ .pl-action-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ height: 30px;
+ padding: 0 12px;
+ border-radius: 8px;
+ border: 1px solid;
+ font-size: 11.5px;
+ font-weight: 600;
+ cursor: pointer;
+ text-decoration: none;
+ font-family: inherit;
+ margin-left: 6px;
+ transition: all 0.14s ease;
+
+ fa-icon {
+ font-size: 11px;
+ }
+
+ &:first-child {
+ margin-left: 0;
+ }
+
+ &--view {
+ background: var(--pl-blue-l);
+ color: var(--pl-blue);
+ border-color: #dbeafe;
+
+ &:hover {
+ background: var(--pl-blue);
+ color: #fff;
+ border-color: var(--pl-blue);
+ }
+ }
+
+ &--edit {
+ background: var(--pl-amber-l);
+ color: var(--pl-amber);
+ border-color: #fde68a;
+
+ &:hover {
+ background: var(--pl-amber);
+ color: #fff;
+ border-color: var(--pl-amber);
+ }
+ }
+ }
+
+ /* No-match strip */
+ .pl-no-match {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ padding: 28px 16px;
+ background: var(--pl-g50);
+ color: var(--pl-g500);
+ font-size: 13px;
+ font-weight: 500;
+ border-top: 1px dashed var(--pl-g200);
+
+ fa-icon {
+ font-size: 13px;
+ }
+ }
+
+ /* ───────── EMPTY STATE ───────── */
+ .pl-empty {
+ text-align: center;
+ padding: 50px 24px;
+ }
+
+ .pl-empty-ic {
+ width: 64px;
+ height: 64px;
+ margin: 0 auto 16px;
+ border-radius: 16px;
+ background: var(--pl-accent-l);
+ color: var(--pl-accent);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 26px;
+ }
+
+ .pl-empty h3 {
+ margin: 0 0 8px;
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--pl-g900);
+ }
+
+ .pl-empty p {
+ margin: 0 0 20px;
+ font-size: 13px;
+ color: var(--pl-g500);
+ line-height: 1.5;
+ max-width: 420px;
+ margin-left: auto;
+ margin-right: auto;
+ }
+
+ /* Paginator polish */
+ ::ng-deep .pl-table-wrap .mat-mdc-paginator {
+ background: var(--pl-g50);
+ border-top: 1px solid var(--pl-g200);
+ font-family: inherit;
+ }
+}
diff --git a/src/assets/styles/_product-view.scss b/src/assets/styles/_product-view.scss
new file mode 100644
index 000000000..e6cb3780f
--- /dev/null
+++ b/src/assets/styles/_product-view.scss
@@ -0,0 +1,575 @@
+/**
+ * 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/.
+ */
+
+/*
+ * Shared "product view" styling — used by the general-tab views of
+ * Saving / Loan / FD / RD / Share / Current product types.
+ *
+ * Theming hook: each consumer may override `--pv-accent` / `--pv-accent-l`
+ * on the host to tint the hero gradient per product type.
+ */
+
+@mixin product-view-styles {
+ :host {
+ --pv-navy: #0b1e3d;
+ --pv-blue: #1d4ed8;
+ --pv-blue-l: #eff6ff;
+ --pv-teal: #0e7e6b;
+ --pv-teal-l: #ccfbf1;
+ --pv-purple: #6d28d9;
+ --pv-purple-l: #ede9fe;
+ --pv-indigo: #3730a3;
+ --pv-indigo-l: #e0e7ff;
+ --pv-amber: #b45309;
+ --pv-amber-l: #fef3c7;
+ --pv-green: #15803d;
+ --pv-green-l: #dcfce7;
+ --pv-red: #c0392b;
+ --pv-red-l: #fee2e2;
+ --pv-pink: #be185d;
+ --pv-pink-l: #fce7f3;
+ --pv-g50: #f8fafc;
+ --pv-g100: #f1f5f9;
+ --pv-g200: #e2e8f0;
+ --pv-g500: #64748b;
+ --pv-g700: #334155;
+ --pv-g900: #0f172a;
+ --pv-accent: var(--pv-blue);
+ --pv-accent-l: var(--pv-blue-l);
+
+ display: block;
+ font-family:
+ 'DM Sans',
+ system-ui,
+ -apple-system,
+ sans-serif;
+ color: var(--pv-g900);
+ font-size: 13.5px;
+ }
+
+ .pv-root {
+ padding: 4px 4px 24px;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ }
+
+ /* ───────── HERO ───────── */
+ .pv-hero {
+ background: linear-gradient(135deg, var(--pv-navy) 0%, var(--pv-accent) 100%);
+ border-radius: 14px;
+ padding: 22px 22px 22px 18px;
+ display: flex;
+ align-items: flex-start;
+ gap: 18px;
+ box-shadow:
+ 0 10px 24px rgb(11 30 61 / 18%),
+ 0 4px 10px rgb(11 30 61 / 12%);
+ color: #fff;
+ position: relative;
+ overflow: hidden;
+ }
+
+ .pv-hero::before {
+ content: '';
+ position: absolute;
+ top: -60px;
+ right: -60px;
+ width: 240px;
+ height: 240px;
+ border-radius: 50%;
+ background: radial-gradient(circle, rgb(255 255 255 / 8%) 0%, transparent 70%);
+ pointer-events: none;
+ }
+
+ .pv-hero-left {
+ display: flex;
+ align-items: flex-start;
+ gap: 16px;
+ flex: 1;
+ min-width: 0;
+ }
+
+ .pv-hero-icon {
+ width: 56px;
+ height: 56px;
+ border-radius: 14px;
+ background: rgb(255 255 255 / 16%);
+ border: 1px solid rgb(255 255 255 / 22%);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 24px;
+ flex-shrink: 0;
+ }
+
+ .pv-hero-text {
+ flex: 1;
+ min-width: 0;
+ }
+
+ .pv-hero-eyebrow {
+ font-size: 10.5px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ color: rgb(255 255 255 / 70%);
+ margin-bottom: 4px;
+ }
+
+ .pv-hero-title {
+ margin: 0 0 10px;
+ font-size: 22px;
+ font-weight: 700;
+ letter-spacing: 0.01em;
+ color: #fff;
+ line-height: 1.2;
+ }
+
+ .pv-hero-meta {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+ }
+
+ .pv-hero-desc {
+ margin: 12px 0 0;
+ font-size: 13px;
+ color: rgb(255 255 255 / 82%);
+ line-height: 1.5;
+ max-width: 720px;
+ }
+
+ .pv-edit-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ height: 38px;
+ padding: 0 16px;
+ border-radius: 10px;
+ background: rgb(255 255 255 / 14%);
+ color: #fff;
+ border: 1px solid rgb(255 255 255 / 28%);
+ font-size: 13px;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ cursor: pointer;
+ text-decoration: none;
+ white-space: nowrap;
+ font-family: inherit;
+ flex-shrink: 0;
+ transition: all 0.16s ease;
+
+ fa-icon {
+ font-size: 12px;
+ }
+
+ &:hover {
+ background: rgb(255 255 255 / 24%);
+ border-color: rgb(255 255 255 / 50%);
+ }
+ }
+
+ /* ───────── PILLS ───────── */
+ .pv-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ padding: 4px 10px;
+ border-radius: 999px;
+ font-family: inherit;
+
+ fa-icon {
+ font-size: 9.5px;
+ }
+
+ &--code {
+ background: rgb(255 255 255 / 18%);
+ color: #fff;
+ border: 1px solid rgb(255 255 255 / 24%);
+ font-family: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace;
+ }
+
+ &--teal {
+ background: var(--pv-teal-l);
+ color: var(--pv-teal);
+ border: 1px solid #99f6e4;
+ }
+
+ &--blue {
+ background: var(--pv-blue-l);
+ color: var(--pv-blue);
+ border: 1px solid #dbeafe;
+ }
+
+ &--green {
+ background: var(--pv-green-l);
+ color: var(--pv-green);
+ border: 1px solid #bbf7d0;
+ }
+
+ &--amber {
+ background: var(--pv-amber-l);
+ color: var(--pv-amber);
+ border: 1px solid #fde68a;
+ }
+
+ &--red {
+ background: var(--pv-red-l);
+ color: var(--pv-red);
+ border: 1px solid #fecaca;
+ }
+
+ &--gray {
+ background: var(--pv-g100);
+ color: var(--pv-g700);
+ border: 1px solid var(--pv-g200);
+ }
+ }
+
+ /* ───────── SECTION ───────── */
+ .pv-section {
+ background: #fff;
+ border: 1px solid var(--pv-g200);
+ border-radius: 12px;
+ padding: 16px 20px;
+ box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
+ }
+
+ .pv-section-h {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 14px;
+ padding-bottom: 12px;
+ border-bottom: 1px dashed var(--pv-g200);
+
+ h3 {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--pv-g900);
+ letter-spacing: 0.02em;
+ }
+ }
+
+ .pv-section-ic {
+ width: 36px;
+ height: 36px;
+ border-radius: 10px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 14px;
+ flex-shrink: 0;
+
+ &--blue {
+ background: var(--pv-blue-l);
+ color: var(--pv-blue);
+ }
+
+ &--teal {
+ background: var(--pv-teal-l);
+ color: var(--pv-teal);
+ }
+
+ &--purple {
+ background: var(--pv-purple-l);
+ color: var(--pv-purple);
+ }
+
+ &--indigo {
+ background: var(--pv-indigo-l);
+ color: var(--pv-indigo);
+ }
+
+ &--amber {
+ background: var(--pv-amber-l);
+ color: var(--pv-amber);
+ }
+
+ &--green {
+ background: var(--pv-green-l);
+ color: var(--pv-green);
+ }
+
+ &--red {
+ background: var(--pv-red-l);
+ color: var(--pv-red);
+ }
+
+ &--pink {
+ background: var(--pv-pink-l);
+ color: var(--pv-pink);
+ }
+ }
+
+ .pv-sub-h {
+ margin: 16px 0 10px;
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--pv-g700);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ }
+
+ /* ───────── KEY/VALUE GRID ───────── */
+ .pv-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px 24px;
+ }
+
+ .pv-grid--3 {
+ grid-template-columns: repeat(3, 1fr);
+ }
+
+ @media (width <= 900px) {
+ .pv-grid,
+ .pv-grid--3 {
+ grid-template-columns: 1fr 1fr;
+ }
+ }
+
+ @media (width <= 600px) {
+ .pv-grid,
+ .pv-grid--3 {
+ grid-template-columns: 1fr;
+ }
+ }
+
+ .pv-kv {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ padding: 8px 0;
+ border-bottom: 1px dashed var(--pv-g100);
+
+ &--span {
+ grid-column: 1 / -1;
+ }
+ }
+
+ .pv-k {
+ font-size: 10.5px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--pv-g500);
+ }
+
+ .pv-v {
+ font-size: 13.5px;
+ color: var(--pv-g900);
+ font-weight: 500;
+ word-break: break-word;
+
+ strong {
+ font-weight: 700;
+ }
+ }
+
+ .pv-v--accent {
+ font-size: 18px;
+ font-weight: 700;
+ color: var(--pv-accent);
+ letter-spacing: 0.01em;
+ }
+
+ .pv-suffix {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--pv-g500);
+ margin-left: 4px;
+ }
+
+ /* ───────── FEATURE GRID ───────── */
+ .pv-feature {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 12px;
+ border-radius: 8px;
+ background: var(--pv-g50);
+ border: 1px solid var(--pv-g200);
+ font-size: 12.5px;
+ font-weight: 600;
+ color: var(--pv-g700);
+
+ fa-icon {
+ font-size: 12px;
+ color: var(--pv-g500);
+ }
+
+ &.is-on {
+ background: var(--pv-green-l);
+ border-color: #bbf7d0;
+ color: var(--pv-green);
+
+ fa-icon {
+ color: var(--pv-green);
+ }
+ }
+ }
+
+ /* ───────── LEGACY-MARKUP POLISH ─────────
+ Polishes the existing Mifos label/value layout (.flex-100.layout-row >
+ .flex-40 + .flex-60) and section headings (.mat-h3) when wrapped in
+ .pv-legacy, so view templates can be retro-fitted with the new look
+ without rewriting every row. */
+ .pv-legacy {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+
+ > h2.mat-h2,
+ .pv-section > h2.mat-h2 {
+ margin: 0 0 12px;
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--pv-g900);
+ }
+
+ /* Section heading -> behaves like .pv-section-h */
+ .mat-h3 {
+ margin: 0 0 12px;
+ padding: 10px 16px;
+ font-size: 13px;
+ font-weight: 700;
+ color: var(--pv-accent);
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ background: var(--pv-accent-l);
+ border-left: 3px solid var(--pv-accent);
+ border-radius: 8px;
+ }
+
+ .mat-h4 {
+ margin: 12px 0 8px;
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--pv-g700);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ }
+
+ /* Section card wrapper: every consecutive group of (h3 + rows) is
+ essentially a section. We wrap each whole content block in a card-like
+ container by styling .responsive-column / .layout-row-wrap.flex-100 */
+ .layout-row-wrap.flex-100,
+ > .flex-100.layout-row-wrap,
+ > .responsive-column {
+ background: #fff;
+ border: 1px solid var(--pv-g200);
+ border-radius: 12px;
+ padding: 14px 18px;
+ box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
+ }
+
+ /* Label / value row */
+ .flex-100.layout-row,
+ .layout-row.flex-100 {
+ display: grid;
+ grid-template-columns: minmax(220px, 42%) 1fr;
+ gap: 14px;
+ align-items: baseline;
+ padding: 8px 0;
+ border-bottom: 1px dashed var(--pv-g100);
+
+ &:last-child {
+ border-bottom: 0;
+ }
+
+ > .flex-40 {
+ font-size: 10.5px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--pv-g500);
+ margin: 0;
+ flex: none;
+ }
+
+ > .flex-60 {
+ font-size: 13.5px;
+ color: var(--pv-g900);
+ font-weight: 500;
+ word-break: break-word;
+ margin: 0;
+ flex: none;
+
+ strong {
+ font-weight: 700;
+ }
+ }
+ }
+
+ /* Dividers between sections can be removed since cards already separate */
+ mat-divider {
+ display: none;
+ }
+
+ /* Inline tables in legacy content */
+ .mat-elevation-z1 {
+ border-radius: 10px;
+ overflow: hidden;
+ margin-top: 6px;
+ box-shadow: none;
+ border: 1px solid var(--pv-g200);
+ }
+
+ @media (width <= 600px) {
+ .flex-100.layout-row,
+ .layout-row.flex-100 {
+ grid-template-columns: 1fr;
+ gap: 4px;
+ }
+ }
+ }
+
+ /* ───────── TABLES ───────── */
+ .pv-table-wrap {
+ overflow-x: auto;
+ border: 1px solid var(--pv-g200);
+ border-radius: 10px;
+ margin-top: 4px;
+ }
+
+ .pv-table {
+ width: 100%;
+ background: #fff;
+
+ th.mat-mdc-header-cell {
+ background: var(--pv-g50);
+ color: var(--pv-g700);
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ padding: 11px 14px;
+ border-bottom: 1px solid var(--pv-g200);
+ white-space: nowrap;
+ }
+
+ td.mat-mdc-cell {
+ padding: 10px 14px;
+ border-bottom: 1px solid var(--pv-g100);
+ font-size: 12.5px;
+ color: var(--pv-g900);
+ }
+
+ tbody tr:last-child td.mat-mdc-cell {
+ border-bottom: 0;
+ }
+ }
+}
diff --git a/src/environments/.env.ts b/src/environments/.env.ts
index 21b460bcf..cbb635d0c 100644
--- a/src/environments/.env.ts
+++ b/src/environments/.env.ts
@@ -9,8 +9,8 @@
/* tslint:disable */
export default {
'mifos_x': {
- 'version': '260515',
- 'hash': 'g595054b0b'
+ 'version': '260518',
+ 'hash': 'g4322f37c6'
},
'allow_switching_backend_instance': true
};
diff --git a/src/index.html b/src/index.html
index 6eaa4572e..b56579749 100644
--- a/src/index.html
+++ b/src/index.html
@@ -27,6 +27,7 @@
rel="stylesheet"
/>
+