feat: letter-mgmt module + digital UPI/IMPS/ATM-POS reports + header redesign

letter-mgmt (new module):
- Notice page: overdue loan list with filters, KPI strip, bulk actions,
  call disposition logging (Promise-to-Pay, Not Reachable, etc.), Speed
  Post dispatch dialog with consignment + post-office tracking,
  PDF preview + download, call-contact dialog with primary / alt /
  family / nominee numbers and per-contact disposition history.
- Certificates: Loan Interest, Share, Balance, Form 60 (no-PAN flow),
  Form 15G/H (auto age-based), Hypothecation, NOC - each with shared
  preview dialog (six tone variants) and jsPDF generator using a
  common letterhead / summary-grid / signature-seal helper.

digital:
- IMPS module: report (P2A/P2P/Aadhaar tabs) + entry form with
  MMID/mobile lookup, NPCI switch resolve-to-A/C+IFSC BANL flow,
  sender autocomplete, hide sender field for P2P/Aadhaar, charges +
  18% GST + waive checkbox, per-sender today-only history dialog.
- UPI report with app-mix tab and full transaction filtering.
- ATM/POS/ECOM hub with three ticket cards routing to today/status/cycle.
- NEFT/RTGS entry: sender-account autocomplete, charges + GST + waive,
  field reorder (sender before amount), NEFT cap / RTGS floor
  validation, History button opening per-sender today's transactions.
- NEFT/RTGS report: horizontal-bar trend leaderboard replacing tall bars.

shell + theme:
- Header redesigned with IBM Plex Sans 12/500 typography, 11-column grid
  per row, top-accent hover indicator, bordered cells via grid-gap.
- Live KPI ticker below menu (CASH, TXN TODAY, CLEARING IN/OUT, NPA,
  CASA, CD RATIO, RBI CIRC, REPO) with marquee scroll, pause-on-hover,
  brand-blue gradient, amber bottom strip, pulsing red LIVE chip.
- Sidebar 'KYC Management' -> 'KYC & ReKYC Management'.
- Letter Mgmt / certificate menu items wired with green icon hints for
  items that have a working page.

access-control:
- MFA card layout compacted; method header inlines back button + title.

icons:
- Register faTruck/TruckFast, faEllipsisV/H, faCommentDots.
- Add IBM Plex Sans Google Fonts import to index.html.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aditya
2026-05-19 10:09:06 +05:30
co-authored by Claude Opus 4.7
parent 4322f37c6d
commit 325228c514
138 changed files with 27473 additions and 3997 deletions
@@ -0,0 +1,152 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="cert-root">
<div class="cert-head">
<div class="cert-head-l">
<fa-icon icon="balance-scale" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">Balance Certificate</h2>
<div class="cert-head-s">Generate an "as-on-date" balance certificate for any deposit / current account.</div>
</div>
</div>
</div>
<!-- KPI strip -->
<div class="cert-kpis">
<div class="cert-kpi cert-kpi--blue">
<fa-icon icon="balance-scale" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ filteredRecords.length | number }}</div>
<div class="cert-kpi-l">Accounts</div>
</div>
</div>
<div class="cert-kpi cert-kpi--green">
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">₹{{ totalBalance() | number: '1.0-0' }}</div>
<div class="cert-kpi-l">Balance (as on {{ asOnDate | date: 'd MMM y' }})</div>
</div>
</div>
<div class="cert-kpi cert-kpi--teal">
<fa-icon icon="calendar-alt" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ asOnDate | date: 'd MMM y' }}</div>
<div class="cert-kpi-l">Snapshot Date</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="cert-filters">
<div class="cert-filter">
<label>Account / Customer / PAN</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="text" [(ngModel)]="search" placeholder="e.g. SB10029384 or customer name" />
</div>
</div>
<div class="cert-filter">
<label>Account type</label>
<select [(ngModel)]="accountTypeFilter">
<option value="">All types</option>
@for (o of accountTypeOptions; track o.value) {
<option [value]="o.value">{{ o.label }}</option>
}
</select>
</div>
<div class="cert-filter">
<label>Balance as on</label>
<input type="date" [(ngModel)]="asOnDate" />
</div>
@if (search || accountTypeFilter) {
<button type="button" class="cert-clear" (click)="clearFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
}
</div>
<!-- Table -->
<div class="cert-card">
<div class="cert-table-wrap">
<table class="cert-table">
<thead>
<tr>
<th>Customer · A/C</th>
<th>Type</th>
<th>Branch</th>
<th>Opened</th>
<th class="num">Available Bal.</th>
<th class="num">Accrued Int.</th>
<th class="num">Ledger Bal.</th>
<th class="col-actions">Actions</th>
</tr>
</thead>
<tbody>
@for (r of filteredRecords; track r.accountNo) {
<tr>
<td>
<div class="cert-cust-name">{{ r.customerName }}</div>
<div class="mono small muted">{{ r.accountNo }} · {{ r.customerId }} · PAN {{ r.customerPan }}</div>
@if (r.jointHolders) {
<div class="small muted">Jointly with {{ r.jointHolders }}</div>
}
</td>
<td>
<span class="cert-pill cert-pill--type">{{ accountTypeLabel(r.accountType) }}</span>
<div class="small muted">{{ r.productName }}</div>
</td>
<td class="small">{{ r.branch }}</td>
<td class="small">{{ r.openedOn | date: 'd MMM y' }}</td>
<td class="num">
<strong>₹{{ r.availableBalance | number: '1.2-2' }}</strong>
</td>
<td class="num">
@if (r.accruedInterest > 0) {
₹{{ r.accruedInterest | number: '1.2-2' }}
} @else {
<span class="muted">—</span>
}
</td>
<td class="num">₹{{ r.ledgerBalance | number: '1.2-2' }}</td>
<td class="col-actions">
<div class="cert-row-actions">
<button
type="button"
class="cert-icon-btn primary"
(click)="viewCertificate(r)"
matTooltip="View / Print certificate"
>
<fa-icon icon="eye"></fa-icon>
</button>
<button
type="button"
class="cert-icon-btn"
(click)="downloadCertificate(r)"
matTooltip="Download PDF"
>
<fa-icon icon="download"></fa-icon>
</button>
</div>
</td>
</tr>
}
@if (filteredRecords.length === 0) {
<tr>
<td colspan="8" class="cert-empty">
<fa-icon icon="info-circle"></fa-icon>
No accounts match the current filters.
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
@@ -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
}
];
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,703 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="cpd-root">
<!-- TOOLBAR -->
<div class="cpd-toolbar no-print">
<div class="cpd-toolbar-l">
<fa-icon [icon]="isInterest ? 'file-invoice' : 'certificate'" class="cpd-toolbar-ic"></fa-icon>
<div>
<div class="cpd-toolbar-t">{{ title }}</div>
<div class="cpd-toolbar-s">{{ refLine }}</div>
</div>
</div>
<div class="cpd-toolbar-r">
<button class="cpd-tb-btn" (click)="printCertificate()" matTooltip="Print or Save as PDF">
<fa-icon icon="print"></fa-icon> Print / PDF
</button>
<button class="cpd-tb-btn" (click)="downloadPdf()" matTooltip="Download as PDF">
<fa-icon icon="download"></fa-icon> Download PDF
</button>
<button class="cpd-tb-btn cpd-tb-x" (click)="close()" matTooltip="Close">
<fa-icon icon="times"></fa-icon>
</button>
</div>
</div>
<!-- CERTIFICATE PAGE -->
<div class="cpd-page" [ngClass]="pageToneClass">
<!-- Bank letterhead -->
<div class="cpd-letterhead">
<div class="cpd-bank-name">{{ bankName }}</div>
<div class="cpd-bank-sub">{{ bankAddress }}</div>
<div class="cpd-bank-sub">{{ bankRegNo }} · {{ bankContact }}</div>
</div>
<!-- Certificate title strip -->
<div class="cpd-title-strip">
<span class="cpd-title-l">{{ title }}</span>
</div>
<!-- Meta strip -->
<div class="cpd-meta-row">
<div>
<div class="cpd-meta-l">Certificate Reference</div>
<div class="cpd-meta-v mono">
@if (isInterest && interest) {
INT-{{ interest.record.accountNo }}-{{ interest.fy.fy | slice: 3 }}
} @else if (isShare && share) {
{{ share.certificateNo }}
}
</div>
</div>
<div>
<div class="cpd-meta-l">Date of Issue</div>
<div class="cpd-meta-v">{{ issuedOn | date: 'd MMMM y' }}</div>
</div>
<div>
<div class="cpd-meta-l">
@if (isInterest) {
Financial Year
} @else if (isShare) {
Folio No.
} @else if (isBalance) {
As-on Date
} @else if (isForm60) {
Trigger
} @else if (isForm15gh) {
Form
} @else if (isHypothecation) {
Status
} @else if (isNoc) {
Status
}
</div>
<div class="cpd-meta-v">
@if (isInterest && interest) {
<span class="cpd-stage-pill">{{ interest.fy.fy }}</span>
} @else if (isShare && share) {
<span class="cpd-stage-pill cpd-stage-pill--amber">{{ share.folioNo }}</span>
} @else if (isBalance && balance) {
<span class="cpd-stage-pill">{{ balance.asOnDate | date: 'd MMM y' }}</span>
} @else if (isForm60 && form60) {
<span class="cpd-stage-pill cpd-stage-pill--amber">No PAN — Form 60</span>
} @else if (isForm15gh && form15gh) {
<span class="cpd-stage-pill cpd-stage-pill--teal">Form {{ form15gh.kind }}</span>
} @else if (isHypothecation && hypothecation) {
<span class="cpd-stage-pill cpd-stage-pill--red">{{ hypothecation.status }}</span>
} @else if (isNoc && noc) {
<span class="cpd-stage-pill cpd-stage-pill--green">
{{ noc.status === 'READY_TO_ISSUE' ? 'Ready to Issue' : noc.status }}
</span>
}
</div>
</div>
</div>
<!-- ── LOAN INTEREST CERTIFICATE ── -->
@if (isInterest && interest) {
<div class="cpd-block">
<div class="cpd-block-l">To,</div>
<div class="cpd-block-v">
<div>
<strong>{{ interest.record.customerName }}</strong>
</div>
<div>
Customer ID: <span class="mono">{{ interest.record.customerId }}</span> · PAN:
<span class="mono">{{ interest.record.customerPan }}</span>
</div>
<div>{{ interest.record.customerAddress }}</div>
</div>
</div>
<p class="cpd-para">
This is to certify that <strong>{{ interest.record.customerName }}</strong> (Customer ID
<strong>{{ interest.record.customerId }}</strong
>) is availing the following loan facility from our bank, and the interest paid by the above customer during the
financial year <strong>{{ interest.fy.fy }}</strong> is as detailed below.
</p>
<table class="cpd-summary">
<tbody>
<tr>
<td>Loan Account No.</td>
<td class="mono">
<strong>{{ interest.record.accountNo }}</strong>
</td>
<td>Branch</td>
<td>{{ interest.record.branch }}</td>
</tr>
<tr>
<td>Loan Type</td>
<td>{{ interest.record.loanType }}</td>
<td>Disbursed On</td>
<td>{{ interest.record.disbursementDate | date: 'd MMM y' }}</td>
</tr>
<tr>
<td>Sanctioned Amount</td>
<td>₹{{ interest.record.sanctionedAmount | number: '1.2-2' }}</td>
<td>Interest Rate</td>
<td>{{ interest.record.interestRate }}% p.a.</td>
</tr>
<tr>
<td>Opening Balance ({{ interest.fy.fy | slice: 0 : 8 }})</td>
<td>₹{{ interest.fy.openingBalance | number: '1.2-2' }}</td>
<td>Closing Balance</td>
<td>₹{{ interest.fy.closingBalance | number: '1.2-2' }}</td>
</tr>
<tr>
<td>Principal Repaid</td>
<td>₹{{ interest.fy.principalPaid | number: '1.2-2' }}</td>
<td>Interest Paid</td>
<td>
<strong class="cpd-amount">₹{{ interest.fy.interestPaid | number: '1.2-2' }}</strong>
</td>
</tr>
<tr class="cpd-summary-total">
<td colspan="3">TOTAL INTEREST PAID DURING {{ interest.fy.fy }}</td>
<td>
<strong>₹{{ interest.fy.interestPaid | number: '1.2-2' }}</strong>
</td>
</tr>
</tbody>
</table>
<p class="cpd-para">
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.
</p>
}
<!-- ── SHARE CERTIFICATE ── -->
@if (isShare && share) {
<div class="cpd-block">
<div class="cpd-block-l">Issued To,</div>
<div class="cpd-block-v">
<div>
<strong>{{ share.memberName }}</strong>
</div>
<div>
Member ID: <span class="mono">{{ share.memberId }}</span> · PAN:
<span class="mono">{{ share.memberPan }}</span>
</div>
<div>{{ share.memberAddress }}</div>
</div>
</div>
<p class="cpd-para cpd-para--centered">
This is to certify that <strong>{{ share.memberName }}</strong> is the registered holder of
<strong>{{ share.shareCount | number }}</strong> ({{ classLabelText() }}) shares of
<strong>₹{{ share.faceValue | number: '1.0-0' }}</strong> each, fully paid up, in <strong>{{ bankName }}</strong
>, subject to the bye-laws of the bank.
</p>
<table class="cpd-summary">
<tbody>
<tr>
<td>Certificate No.</td>
<td class="mono">
<strong>{{ share.certificateNo }}</strong>
</td>
<td>Folio No.</td>
<td class="mono">{{ share.folioNo }}</td>
</tr>
<tr>
<td>Branch</td>
<td>{{ share.branch }}</td>
<td>Member Since</td>
<td>{{ share.joiningDate | date: 'd MMM y' }}</td>
</tr>
<tr>
<td>Share Class</td>
<td>{{ classLabelText() }}</td>
<td>Date of Issue</td>
<td>{{ share.issueDate | date: 'd MMM y' }}</td>
</tr>
<tr>
<td>Share Numbers</td>
<td class="mono">{{ share.shareNoFrom | number }} &ndash; {{ share.shareNoTo | number }}</td>
<td>Dividend Paid (Cumulative)</td>
<td>₹{{ share.dividendYtd | number: '1.0-0' }}</td>
</tr>
<tr class="cpd-summary-total">
<td colspan="3">TOTAL FACE VALUE</td>
<td>
<strong> ₹{{ share.shareCount * share.faceValue | number: '1.2-2' }} </strong>
</td>
</tr>
</tbody>
</table>
<p class="cpd-para">
Given under the common seal of the bank this {{ issuedOn | date: 'd' }} day of {{ issuedOn | date: 'MMMM y' }}.
</p>
}
<!-- ── BALANCE CERTIFICATE ── -->
@if (isBalance && balance) {
<div class="cpd-block">
<div class="cpd-block-l">To Whom It May Concern,</div>
<div class="cpd-block-v">
<div>
<strong>{{ balance.record.customerName }}</strong>
</div>
<div>
Customer ID: <span class="mono">{{ balance.record.customerId }}</span> · PAN:
<span class="mono">{{ balance.record.customerPan }}</span>
</div>
<div>{{ balance.record.customerAddress }}</div>
@if (balance.record.jointHolders) {
<div class="small">Jointly with: {{ balance.record.jointHolders }}</div>
}
</div>
</div>
<p class="cpd-para">
This is to certify that <strong>{{ balance.record.customerName }}</strong> (Customer ID
<strong>{{ balance.record.customerId }}</strong
>) maintains the following account with our bank, and the balance available in the said account as on
<strong>{{ balance.asOnDate | date: 'd MMMM y' }}</strong> is as detailed below.
</p>
<table class="cpd-summary">
<tbody>
<tr>
<td>Account No.</td>
<td class="mono">
<strong>{{ balance.record.accountNo }}</strong>
</td>
<td>Branch</td>
<td>{{ balance.record.branch }}</td>
</tr>
<tr>
<td>Account Type</td>
<td>{{ accountTypeLabelText() }}</td>
<td>Product</td>
<td>{{ balance.record.productName }}</td>
</tr>
<tr>
<td>Account Opened</td>
<td>{{ balance.record.openedOn | date: 'd MMM y' }}</td>
<td>Interest Rate</td>
<td>
@if (balance.record.interestRate) {
{{ balance.record.interestRate }}% p.a.
} @else {
—
}
</td>
</tr>
<tr>
<td>Available Balance</td>
<td>₹{{ balance.record.availableBalance | number: '1.2-2' }}</td>
<td>Accrued Interest</td>
<td>₹{{ balance.record.accruedInterest | number: '1.2-2' }}</td>
</tr>
<tr class="cpd-summary-total">
<td colspan="3">TOTAL LEDGER BALANCE AS ON {{ balance.asOnDate | date: 'd MMM y' }}</td>
<td>
<strong>₹{{ balance.record.ledgerBalance | number: '1.2-2' }}</strong>
</td>
</tr>
</tbody>
</table>
<p class="cpd-para">
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.
</p>
}
<!-- ── FORM 60 ── -->
@if (isForm60 && form60) {
<p class="cpd-para">
Declaration under <strong>Section 114B</strong> 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.
</p>
<table class="cpd-summary">
<tbody>
<tr>
<td>1. Full Name (in Block Letters)</td>
<td colspan="3">
<strong>{{ form60.customerName }}</strong>
</td>
</tr>
<tr>
<td>2. Father's / Spouse's Name</td>
<td colspan="3">{{ form60.fatherName }}</td>
</tr>
<tr>
<td>3. Date of Birth</td>
<td>{{ form60.dob | date: 'd MMM y' }}</td>
<td>4. Customer ID</td>
<td class="mono">{{ form60.customerId }}</td>
</tr>
<tr>
<td>5. Address</td>
<td colspan="3">{{ form60.customerAddress }}</td>
</tr>
<tr>
<td>6. ID Proof Submitted</td>
<td>{{ idProofLabelText() }}</td>
<td>7. ID Document No.</td>
<td class="mono">{{ form60.idProofNumber }}</td>
</tr>
<tr>
<td>8. Nature of Transaction</td>
<td colspan="3">{{ form60.transactionType }}</td>
</tr>
<tr>
<td>9. Transaction Amount</td>
<td>₹{{ form60.transactionAmount | number: '1.2-2' }}</td>
<td>10. Transaction Date</td>
<td>{{ form60.transactionDate | date: 'd MMM y' }}</td>
</tr>
<tr>
<td>11. Estimated Total Income (Current FY)</td>
<td>₹{{ form60.estimatedIncome | number: '1.0-0' }}</td>
<td>12. Agricultural Income</td>
<td>₹{{ form60.agriIncome | number: '1.0-0' }}</td>
</tr>
<tr class="cpd-summary-total">
<td colspan="3">PAN STATUS</td>
<td>
<strong>NOT ALLOTTED — Form 60 declared</strong>
</td>
</tr>
</tbody>
</table>
<p class="cpd-para">
I, <strong>{{ form60.customerName }}</strong
>, 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.
</p>
}
<!-- ── FORM 15G / 15H ── -->
@if (isForm15gh && form15gh) {
<p class="cpd-para">
Declaration under <strong>Section {{ form15gh.kind === '15H' ? '197A(1C)' : '197A(1) / 197A(1A)' }}</strong>
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'
}}.
</p>
<table class="cpd-summary">
<tbody>
<tr>
<td>1. Name of Assessee</td>
<td colspan="3">
<strong>{{ form15gh.record.customerName }}</strong>
</td>
</tr>
<tr>
<td>2. PAN of Assessee</td>
<td class="mono">{{ form15gh.record.customerPan }}</td>
<td>3. Status</td>
<td>Individual</td>
</tr>
<tr>
<td>4. Previous Year</td>
<td>{{ form15gh.record.fy }}</td>
<td>5. Age (years)</td>
<td>{{ form15gh.age }}</td>
</tr>
<tr>
<td>6. Address</td>
<td colspan="3">{{ form15gh.record.customerAddress }}</td>
</tr>
<tr>
<td>7. Branch</td>
<td>{{ form15gh.record.branch }}</td>
<td>8. Customer ID</td>
<td class="mono">{{ form15gh.record.customerId }}</td>
</tr>
<tr>
<td>9. Deposit Principal</td>
<td>₹{{ form15gh.record.depositPrincipal | number: '1.0-0' }}</td>
<td>10. Estimated Interest ({{ form15gh.record.fy }})</td>
<td>
<strong>₹{{ form15gh.record.estimatedInterest | number: '1.0-0' }}</strong>
</td>
</tr>
<tr>
<td>11. Estimated Total Income</td>
<td>₹{{ form15gh.record.estimatedIncome | number: '1.0-0' }}</td>
<td>12. Previously Assessed?</td>
<td>{{ form15gh.record.assessed ? 'Yes' : 'No' }}</td>
</tr>
<tr class="cpd-summary-total">
<td colspan="3">TAX PAYABLE ON ESTIMATED INCOME</td>
<td><strong>NIL</strong></td>
</tr>
</tbody>
</table>
<p class="cpd-para">
I, <strong>{{ form15gh.record.customerName }}</strong
>, 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.
</p>
}
<!-- ── HYPOTHECATION CERTIFICATE ── -->
@if (isHypothecation && hypothecation) {
<div class="cpd-block">
<div class="cpd-block-l">Issued To,</div>
<div class="cpd-block-v">
<div>
<strong>{{ hypothecation.customerName }}</strong>
</div>
<div>
Customer ID: <span class="mono">{{ hypothecation.customerId }}</span> · PAN:
<span class="mono">{{ hypothecation.customerPan }}</span>
</div>
<div>{{ hypothecation.customerAddress }}</div>
</div>
</div>
<p class="cpd-para">
This is to certify that the asset described below stands hypothecated to
<strong>{{ bankName }}</strong> as security for the captioned loan facility extended to
<strong>{{ hypothecation.customerName }}</strong
>, and the bank holds the first and exclusive charge over the said asset until the loan is fully discharged.
</p>
<table class="cpd-summary">
<tbody>
<tr>
<td>Certificate No.</td>
<td class="mono">
<strong>{{ hypothecation.certificateNo }}</strong>
</td>
<td>Loan Account No.</td>
<td class="mono">{{ hypothecation.accountNo }}</td>
</tr>
<tr>
<td>Loan Type</td>
<td>{{ hypothecation.loanType }}</td>
<td>Branch</td>
<td>{{ hypothecation.branch }}</td>
</tr>
<tr>
<td>Sanctioned Amount</td>
<td>₹{{ hypothecation.sanctionedAmount | number: '1.0-0' }}</td>
<td>Disbursed On</td>
<td>{{ hypothecation.disbursementDate | date: 'd MMM y' }}</td>
</tr>
<tr>
<td>Asset Type</td>
<td>{{ assetLabelText() }}</td>
<td>Identifier</td>
<td class="mono">{{ hypothecation.assetIdentifier }}</td>
</tr>
<tr>
<td>Asset Description</td>
<td colspan="3">{{ hypothecation.assetDescription }}</td>
</tr>
@if (hypothecation.insuredValue > 0) {
<tr>
<td>Insured Value</td>
<td>₹{{ hypothecation.insuredValue | number: '1.0-0' }}</td>
<td>Insurance Valid Till</td>
<td>{{ hypothecation.insuranceValidTill | date: 'd MMM y' }}</td>
</tr>
}
<tr class="cpd-summary-total">
<td colspan="3">OUTSTANDING PRINCIPAL</td>
<td>
<strong>₹{{ hypothecation.outstandingPrincipal | number: '1.2-2' }}</strong>
</td>
</tr>
</tbody>
</table>
<p class="cpd-para">
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.
</p>
}
<!-- ── NO-OBJECTION CERTIFICATE ── -->
@if (isNoc && noc) {
<div class="cpd-block">
<div class="cpd-block-l">Issued To,</div>
<div class="cpd-block-v">
<div>
<strong>{{ noc.customerName }}</strong>
</div>
<div>
Customer ID: <span class="mono">{{ noc.customerId }}</span> · PAN:
<span class="mono">{{ noc.customerPan }}</span>
</div>
<div>{{ noc.customerAddress }}</div>
</div>
</div>
<p class="cpd-para">
This is to certify that the captioned loan account in the name of
<strong>{{ noc.customerName }}</strong>
has been
@if (noc.status === 'ISSUED') {
duly closed and the bank
} @else {
closed and the bank
}
has <strong>NO OBJECTION</strong> 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.
</p>
<table class="cpd-summary">
<tbody>
<tr>
<td>NOC No.</td>
<td class="mono">
<strong>{{ noc.nocNo }}</strong>
</td>
<td>Loan Account No.</td>
<td class="mono">{{ noc.accountNo }}</td>
</tr>
<tr>
<td>Loan Type</td>
<td>{{ nocLoanTypeLabel() }}</td>
<td>Branch</td>
<td>{{ noc.branch }}</td>
</tr>
<tr>
<td>Sanctioned Amount</td>
<td>₹{{ noc.sanctionedAmount | number: '1.0-0' }}</td>
<td>Disbursed On</td>
<td>{{ noc.disbursementDate | date: 'd MMM y' }}</td>
</tr>
@if (noc.closureDate) {
<tr>
<td>Account Closed On</td>
<td>
<strong>{{ noc.closureDate | date: 'd MMM y' }}</strong>
</td>
<td>Total Paid</td>
<td>₹{{ noc.totalPaid | number: '1.2-2' }}</td>
</tr>
}
@if (noc.assetType !== 'NONE') {
<tr>
<td>Asset Type</td>
<td>{{ nocAssetLabel() }}</td>
<td>Identifier</td>
<td class="mono">{{ noc.assetIdentifier }}</td>
</tr>
<tr>
<td>Asset Description</td>
<td colspan="3">{{ noc.assetDescription }}</td>
</tr>
}
@if (noc.forwardTo) {
<tr>
<td>Forward To</td>
<td colspan="3">{{ noc.forwardTo }}</td>
</tr>
}
<tr class="cpd-summary-total">
<td colspan="3">
@if (noc.assetType === 'NONE') {
ACCOUNT CLOSED — NO DUES OUTSTANDING
} @else {
LIEN RELEASED — NO OBJECTION TO TRANSFER
}
</td>
<td><strong>NIL</strong></td>
</tr>
</tbody>
</table>
<p class="cpd-para">
@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.
}
</p>
@if (noc.status === 'ISSUED' && noc.issuedOn) {
<p class="cpd-para">
<strong>Issued on:</strong> {{ noc.issuedOn | date: 'd MMM y' }}
@if (noc.issuedBy) {
<strong>· By:</strong> {{ noc.issuedBy }}
}
</p>
}
}
<!-- Common sign-off -->
<div class="cpd-signoff">
<div class="cpd-sign-block">
<div class="cpd-sig-line"></div>
<div class="cpd-sig-l">Authorised Signatory</div>
@if (isInterest && interest) {
<div class="cpd-sig-sub">{{ interest.record.branch }}</div>
}
@if (isShare && share) {
<div class="cpd-sig-sub">{{ share.branch }}</div>
}
@if (isBalance && balance) {
<div class="cpd-sig-sub">{{ balance.record.branch }}</div>
}
@if (isForm60 && form60) {
<div class="cpd-sig-sub">{{ form60.branch }}</div>
}
@if (isForm15gh && form15gh) {
<div class="cpd-sig-sub">{{ form15gh.record.branch }}</div>
}
@if (isHypothecation && hypothecation) {
<div class="cpd-sig-sub">{{ hypothecation.branch }}</div>
}
@if (isNoc && noc) {
<div class="cpd-sig-sub">{{ noc.branch }}</div>
}
</div>
<div class="cpd-seal">
<div class="cpd-seal-ring">
<div class="cpd-seal-inner">
<div class="cpd-seal-bank">NEXORA</div>
<div class="cpd-seal-line"></div>
<div class="cpd-seal-sub">CO-OPERATIVE BANK</div>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="cpd-footer">
<span>
Issued by {{ bankName }} on {{ issuedOn | date: 'd MMM y' }}. Computer generated — does not require physical
signature for record purposes.
</span>
</div>
</div>
</div>
@@ -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);
}
}
}
@@ -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<CertificatePreviewDialogComponent>);
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 '';
}
}
}
@@ -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);
}
@@ -0,0 +1,171 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="cert-root">
<div class="cert-head">
<div class="cert-head-l">
<fa-icon icon="file-signature" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">Form 15G &amp; 15H</h2>
<div class="cert-head-s">
TDS-waiver declarations for FD / RD interest income —
<strong>15G</strong> for individuals below 60 years, <strong>15H</strong> for senior citizens (60+).
</div>
</div>
</div>
</div>
<!-- KPI strip -->
<div class="cert-kpis">
<div class="cert-kpi cert-kpi--blue">
<fa-icon icon="user" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ count15G() | number }}</div>
<div class="cert-kpi-l">Form 15G (under 60)</div>
</div>
</div>
<div class="cert-kpi cert-kpi--amber">
<fa-icon icon="user-tie" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ count15H() | number }}</div>
<div class="cert-kpi-l">Form 15H (60+)</div>
</div>
</div>
<div class="cert-kpi cert-kpi--green">
<fa-icon icon="check-circle" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ countFiled() | number }}</div>
<div class="cert-kpi-l">Filed this FY</div>
</div>
</div>
<div class="cert-kpi cert-kpi--teal">
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">₹{{ totalInterest() | number: '1.0-0' }}</div>
<div class="cert-kpi-l">Projected Interest</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="cert-filters">
<div class="cert-filter">
<label>Customer / Customer ID / PAN</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="text" [(ngModel)]="search" placeholder="e.g. CUST556677 or customer name" />
</div>
</div>
<div class="cert-filter">
<label>Form</label>
<select [(ngModel)]="kindFilter">
<option value="">Both forms</option>
<option value="15G">15G — Under 60</option>
<option value="15H">15H — Senior citizens (60+)</option>
</select>
</div>
<div class="cert-filter">
<label>Status</label>
<select [(ngModel)]="statusFilter">
<option value="">All statuses</option>
<option value="FILED">Filed</option>
<option value="PENDING">Pending</option>
</select>
</div>
@if (search || kindFilter || statusFilter) {
<button type="button" class="cert-clear" (click)="clearFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
}
</div>
<!-- Table -->
<div class="cert-card">
<div class="cert-table-wrap">
<table class="cert-table">
<thead>
<tr>
<th>Customer · PAN</th>
<th>Form</th>
<th>Age</th>
<th>Branch</th>
<th class="num">Deposit Principal</th>
<th class="num">Est. Interest ({{ records[0]?.fy }})</th>
<th>Status</th>
<th class="col-actions">Actions</th>
</tr>
</thead>
<tbody>
@for (r of filteredRecords; track r.customerId) {
<tr>
<td>
<div class="cert-cust-name">{{ r.customerName }}</div>
<div class="mono small muted">{{ r.customerId }} · PAN {{ r.customerPan }}</div>
<div class="small muted">DOB {{ r.dob | date: 'd MMM y' }}</div>
</td>
<td>
<span
class="cert-pill"
[class.cert-pill--blue]="formKind(r) === '15G'"
[class.cert-pill--amber]="formKind(r) === '15H'"
>
Form {{ formKind(r) }}
</span>
</td>
<td class="small">{{ age(r.dob) }} yrs</td>
<td class="small">{{ r.branch }}</td>
<td class="num">₹{{ r.depositPrincipal | number: '1.0-0' }}</td>
<td class="num">
<strong class="cert-interest">₹{{ r.estimatedInterest | number: '1.0-0' }}</strong>
</td>
<td>
@if (r.status === 'FILED') {
<span class="cert-pill cert-pill--green"> <fa-icon icon="check-circle"></fa-icon> Filed </span>
@if (r.filedOn) {
<div class="mono small muted">{{ r.filedOn | date: 'd MMM y' }}</div>
}
} @else {
<span class="cert-pill cert-pill--amber"> <fa-icon icon="hourglass-half"></fa-icon> Pending </span>
}
</td>
<td class="col-actions">
<div class="cert-row-actions">
<button
type="button"
class="cert-icon-btn primary"
(click)="viewCertificate(r)"
matTooltip="View / Print declaration"
>
<fa-icon icon="eye"></fa-icon>
</button>
<button
type="button"
class="cert-icon-btn"
(click)="downloadCertificate(r)"
matTooltip="Download PDF"
>
<fa-icon icon="download"></fa-icon>
</button>
</div>
</td>
</tr>
}
@if (filteredRecords.length === 0) {
<tr>
<td colspan="8" class="cert-empty">
<fa-icon icon="info-circle"></fa-icon>
No declarations match the current filters.
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
@@ -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
}
];
}
@@ -0,0 +1,178 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="cert-root">
<div class="cert-head">
<div class="cert-head-l">
<fa-icon icon="file-signature" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">Form 60 Declaration</h2>
<div class="cert-head-s">
Income Tax Rule 114B — required for any customer transacting above the threshold who does
<strong>not</strong> have a PAN on file.
</div>
</div>
</div>
</div>
<!-- KPI strip -->
<div class="cert-kpis">
<div class="cert-kpi cert-kpi--blue">
<fa-icon icon="users" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totalNoPan() | number }}</div>
<div class="cert-kpi-l">Customers without PAN</div>
</div>
</div>
<div class="cert-kpi cert-kpi--green">
<fa-icon icon="check-circle" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totalFiled() | number }}</div>
<div class="cert-kpi-l">Form 60 Filed</div>
</div>
</div>
<div class="cert-kpi cert-kpi--amber">
<fa-icon icon="hourglass-half" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totalPending() | number }}</div>
<div class="cert-kpi-l">Filing Pending</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="cert-filters">
<div class="cert-filter">
<label>Customer / Customer ID / ID No.</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="text" [(ngModel)]="search" placeholder="e.g. CUST220033 or customer name" />
</div>
</div>
<div class="cert-filter">
<label>PAN status</label>
<select [(ngModel)]="panFilter">
<option value="MISSING">Without PAN (Form 60 applicable)</option>
<option value="ON_FILE">With PAN on file</option>
<option value="">All customers</option>
</select>
</div>
<div class="cert-filter">
<label>ID proof used</label>
<select [(ngModel)]="idProofFilter">
<option value="">All ID proofs</option>
@for (o of idProofOptions; track o.value) {
<option [value]="o.value">{{ o.label }}</option>
}
</select>
</div>
@if (search || idProofFilter || panFilter !== 'MISSING') {
<button type="button" class="cert-clear" (click)="clearFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
}
</div>
<!-- Table -->
<div class="cert-card">
<div class="cert-table-wrap">
<table class="cert-table">
<thead>
<tr>
<th>Customer · ID</th>
<th>ID Proof</th>
<th>Branch</th>
<th>Trigger Transaction</th>
<th class="num">Amount</th>
<th>PAN Status</th>
<th>Form 60</th>
<th class="col-actions">Actions</th>
</tr>
</thead>
<tbody>
@for (r of filteredRecords; track r.customerId) {
<tr>
<td>
<div class="cert-cust-name">{{ r.customerName }}</div>
<div class="mono small muted">{{ r.customerId }} · DOB {{ r.dob | date: 'd MMM y' }}</div>
<div class="small muted">S/o, D/o, W/o: {{ r.fatherName }}</div>
</td>
<td>
<span class="cert-pill cert-pill--type">{{ idProofLabel(r.idProofType) }}</span>
<div class="mono small muted">{{ r.idProofNumber }}</div>
</td>
<td class="small">{{ r.branch }}</td>
<td class="small">
{{ r.transactionType }}
@if (r.transactionDate) {
<div class="mono small muted">{{ r.transactionDate | date: 'd MMM y' }}</div>
}
</td>
<td class="num">
@if (r.transactionAmount > 0) {
₹{{ r.transactionAmount | number: '1.0-0' }}
} @else {
<span class="muted">—</span>
}
</td>
<td>
@if (r.hasPan) {
<span class="cert-pill cert-pill--green"> <fa-icon icon="check-circle"></fa-icon> PAN on file </span>
} @else {
<span class="cert-pill cert-pill--amber">
<fa-icon icon="exclamation-triangle"></fa-icon> No PAN
</span>
}
</td>
<td>
@if (r.hasPan) {
<span class="muted small">N/A</span>
} @else if (r.filedOn) {
<span class="cert-pill cert-pill--green">Filed · {{ r.filedOn | date: 'd MMM' }}</span>
} @else {
<span class="cert-pill cert-pill--amber">Pending</span>
}
</td>
<td class="col-actions">
<div class="cert-row-actions">
<button
type="button"
class="cert-icon-btn primary"
[disabled]="r.hasPan"
(click)="viewCertificate(r)"
matTooltip="View / Print Form 60"
>
<fa-icon icon="eye"></fa-icon>
</button>
<button
type="button"
class="cert-icon-btn"
[disabled]="r.hasPan"
(click)="downloadCertificate(r)"
matTooltip="Download PDF"
>
<fa-icon icon="download"></fa-icon>
</button>
</div>
</td>
</tr>
}
@if (filteredRecords.length === 0) {
<tr>
<td colspan="8" class="cert-empty">
<fa-icon icon="info-circle"></fa-icon>
No customers match the current filters.
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
@@ -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
}
];
}
@@ -0,0 +1,169 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="cert-root">
<div class="cert-head">
<div class="cert-head-l">
<fa-icon icon="file-alt" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">Hypothecation Certificate</h2>
<div class="cert-head-s">
Lien certificate for any loan where movable assets are hypothecated to the bank — vehicles, plant &amp;
machinery, stock, gold, equipment.
</div>
</div>
</div>
</div>
<!-- KPI strip -->
<div class="cert-kpis">
<div class="cert-kpi cert-kpi--blue">
<fa-icon icon="file-alt" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ countActive() | number }}</div>
<div class="cert-kpi-l">Active Hypothecations</div>
</div>
</div>
<div class="cert-kpi cert-kpi--green">
<fa-icon icon="check-circle" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ countNoc() | number }}</div>
<div class="cert-kpi-l">NOCs Issued</div>
</div>
</div>
<div class="cert-kpi cert-kpi--amber">
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">₹{{ totalOutstanding() | number: '1.0-0' }}</div>
<div class="cert-kpi-l">Outstanding Principal</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="cert-filters">
<div class="cert-filter">
<label>Certificate / A/C / Customer / Asset</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="text" [(ngModel)]="search" placeholder="e.g. HYP-2024-001023, KA-01-MZ-4421 or customer name" />
</div>
</div>
<div class="cert-filter">
<label>Asset type</label>
<select [(ngModel)]="assetFilter">
<option value="">All assets</option>
@for (o of assetOptions; track o.value) {
<option [value]="o.value">{{ o.label }}</option>
}
</select>
</div>
<div class="cert-filter">
<label>Status</label>
<select [(ngModel)]="statusFilter">
<option value="ACTIVE">Active</option>
<option value="CLOSED">Closed</option>
<option value="NOC_ISSUED">NOC Issued</option>
<option value="">All statuses</option>
</select>
</div>
@if (search || assetFilter || statusFilter !== 'ACTIVE') {
<button type="button" class="cert-clear" (click)="clearFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
}
</div>
<!-- Table -->
<div class="cert-card">
<div class="cert-table-wrap">
<table class="cert-table">
<thead>
<tr>
<th>Customer · Loan A/C</th>
<th>Certificate No.</th>
<th>Asset</th>
<th>Identifier</th>
<th class="num">Sanctioned</th>
<th class="num">Outstanding</th>
<th>Insurance</th>
<th>Status</th>
<th class="col-actions">Actions</th>
</tr>
</thead>
<tbody>
@for (r of filteredRecords; track r.certificateNo) {
<tr>
<td>
<div class="cert-cust-name">{{ r.customerName }}</div>
<div class="mono small muted">{{ r.accountNo }} · {{ r.customerId }} · PAN {{ r.customerPan }}</div>
<div class="small muted">{{ r.loanType }}</div>
</td>
<td class="mono">{{ r.certificateNo }}</td>
<td>
<span class="cert-pill cert-pill--type">{{ assetLabel(r.assetType) }}</span>
<div class="small muted">{{ r.assetDescription }}</div>
</td>
<td class="mono small">{{ r.assetIdentifier }}</td>
<td class="num">₹{{ r.sanctionedAmount | number: '1.0-0' }}</td>
<td class="num">
<strong>₹{{ r.outstandingPrincipal | number: '1.0-0' }}</strong>
</td>
<td class="small">
@if (r.insuredValue > 0) {
₹{{ r.insuredValue | number: '1.0-0' }}
<div class="mono small muted">valid till {{ r.insuranceValidTill | date: 'd MMM y' }}</div>
} @else {
<span class="muted">—</span>
}
</td>
<td>
@if (r.status === 'ACTIVE') {
<span class="cert-pill cert-pill--blue">Active</span>
} @else if (r.status === 'NOC_ISSUED') {
<span class="cert-pill cert-pill--green">NOC Issued</span>
} @else {
<span class="cert-pill">Closed</span>
}
</td>
<td class="col-actions">
<div class="cert-row-actions">
<button
type="button"
class="cert-icon-btn primary"
(click)="viewCertificate(r)"
matTooltip="View / Print certificate"
>
<fa-icon icon="eye"></fa-icon>
</button>
<button
type="button"
class="cert-icon-btn"
(click)="downloadCertificate(r)"
matTooltip="Download PDF"
>
<fa-icon icon="download"></fa-icon>
</button>
</div>
</td>
</tr>
}
@if (filteredRecords.length === 0) {
<tr>
<td colspan="9" class="cert-empty">
<fa-icon icon="info-circle"></fa-icon>
No hypothecation records match the current filters.
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
@@ -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'
}
];
}
@@ -0,0 +1,155 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="cert-root">
<!-- HEADER -->
<div class="cert-head">
<div class="cert-head-l">
<fa-icon icon="file-invoice" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">Loan Interest Certificate</h2>
<div class="cert-head-s">
Generate and download interest-paid certificates per loan account, per financial year.
</div>
</div>
</div>
</div>
<!-- KPI STRIP -->
<div class="cert-kpis">
<div class="cert-kpi cert-kpi--blue">
<fa-icon icon="file-invoice" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ filteredLoans.length | number }}</div>
<div class="cert-kpi-l">Loan Accounts</div>
</div>
</div>
<div class="cert-kpi cert-kpi--green">
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">₹{{ totalInterestForFy() | number: '1.0-0' }}</div>
<div class="cert-kpi-l">Interest Paid ({{ fyFilter }})</div>
</div>
</div>
<div class="cert-kpi cert-kpi--teal">
<fa-icon icon="check-circle" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">₹{{ totalPrincipalForFy() | number: '1.0-0' }}</div>
<div class="cert-kpi-l">Principal Paid ({{ fyFilter }})</div>
</div>
</div>
</div>
<!-- FILTERS -->
<div class="cert-filters">
<div class="cert-filter">
<label>Account number / Customer / PAN</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="text" [(ngModel)]="search" placeholder="e.g. LN10029384, ABCPM1234E or customer name" />
</div>
</div>
<div class="cert-filter">
<label>Financial year</label>
<select [(ngModel)]="fyFilter">
@for (f of fyOptions; track f) {
<option [value]="f">{{ f }}</option>
}
</select>
</div>
<div class="cert-filter">
<label>Loan type</label>
<select [(ngModel)]="loanTypeFilter">
<option value="">All types</option>
@for (o of loanTypeOptions; track o.value) {
<option [value]="o.value">{{ o.label }}</option>
}
</select>
</div>
@if (search || loanTypeFilter) {
<button type="button" class="cert-clear" (click)="clearFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
}
</div>
<!-- TABLE -->
<div class="cert-card">
<div class="cert-table-wrap">
<table class="cert-table">
<thead>
<tr>
<th>Customer · Loan A/C</th>
<th>Type</th>
<th>Branch</th>
<th class="num">Sanctioned</th>
<th class="num">Outstanding</th>
<th class="num">Interest ({{ fyFilter }})</th>
<th class="num">Principal ({{ fyFilter }})</th>
<th>Rate</th>
<th class="col-actions">Actions</th>
</tr>
</thead>
<tbody>
@for (l of filteredLoans; track l.accountNo) {
<tr>
<td>
<div class="cert-cust-name">{{ l.customerName }}</div>
<div class="mono small muted">{{ l.accountNo }} · {{ l.customerId }} · PAN {{ l.customerPan }}</div>
</td>
<td>
<span class="cert-pill cert-pill--type">{{ loanTypeLabel(l.loanType) }}</span>
</td>
<td class="small">{{ l.branch }}</td>
<td class="num">₹{{ l.sanctionedAmount | number: '1.0-0' }}</td>
<td class="num">
<strong>₹{{ outstandingPrincipal(l) | number: '1.0-0' }}</strong>
</td>
<td class="num">
<strong class="cert-interest">₹{{ selectedFyData(l)?.interestPaid | number: '1.0-0' }}</strong>
</td>
<td class="num">₹{{ selectedFyData(l)?.principalPaid | number: '1.0-0' }}</td>
<td>
<span class="cert-rate-pill">{{ l.interestRate }}% p.a.</span>
</td>
<td class="col-actions">
<div class="cert-row-actions">
<button
type="button"
class="cert-icon-btn primary"
(click)="viewCertificate(l)"
matTooltip="View / Print certificate"
>
<fa-icon icon="eye"></fa-icon>
</button>
<button
type="button"
class="cert-icon-btn"
(click)="downloadCertificate(l)"
matTooltip="Download PDF"
>
<fa-icon icon="download"></fa-icon>
</button>
</div>
</td>
</tr>
}
@if (filteredLoans.length === 0) {
<tr>
<td colspan="9" class="cert-empty">
<fa-icon icon="info-circle"></fa-icon>
No loan accounts match the current filters.
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
@@ -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
}
]
}
];
}
@@ -0,0 +1,179 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="cert-root">
<div class="cert-head">
<div class="cert-head-l">
<fa-icon icon="id-card" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">No-Objection Certificate (NOC)</h2>
<div class="cert-head-s">
Issue NOCs for closed loan accounts — releases the bank's lien on hypothecated assets (vehicles, property,
gold) or confirms unsecured-loan closure.
</div>
</div>
</div>
</div>
<!-- KPI strip -->
<div class="cert-kpis">
<div class="cert-kpi cert-kpi--green">
<fa-icon icon="check-circle" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ countIssued() | number }}</div>
<div class="cert-kpi-l">NOCs Issued</div>
</div>
</div>
<div class="cert-kpi cert-kpi--blue">
<fa-icon icon="paper-plane" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ countReady() | number }}</div>
<div class="cert-kpi-l">Ready to Issue</div>
</div>
</div>
<div class="cert-kpi cert-kpi--amber">
<fa-icon icon="hourglass-half" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ countPending() | number }}</div>
<div class="cert-kpi-l">Pending Closure</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="cert-filters">
<div class="cert-filter">
<label>NOC / A/C / Customer / Asset</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="text" [(ngModel)]="search" placeholder="e.g. NOC-2024-005012, KA-01-MZ-4421 or customer name" />
</div>
</div>
<div class="cert-filter">
<label>Loan type</label>
<select [(ngModel)]="loanTypeFilter">
<option value="">All types</option>
@for (o of loanTypeOptions; track o.value) {
<option [value]="o.value">{{ o.label }}</option>
}
</select>
</div>
<div class="cert-filter">
<label>Status</label>
<select [(ngModel)]="statusFilter">
<option value="">All statuses</option>
<option value="ISSUED">Issued</option>
<option value="READY_TO_ISSUE">Ready to issue</option>
<option value="PENDING">Pending closure</option>
</select>
</div>
@if (search || loanTypeFilter || statusFilter) {
<button type="button" class="cert-clear" (click)="clearFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
}
</div>
<!-- Table -->
<div class="cert-card">
<div class="cert-table-wrap">
<table class="cert-table">
<thead>
<tr>
<th>Customer · Loan A/C</th>
<th>NOC No.</th>
<th>Loan Type</th>
<th>Asset / Identifier</th>
<th class="num">Total Paid</th>
<th>Closure</th>
<th>Status</th>
<th class="col-actions">Actions</th>
</tr>
</thead>
<tbody>
@for (r of filteredRecords; track r.nocNo) {
<tr>
<td>
<div class="cert-cust-name">{{ r.customerName }}</div>
<div class="mono small muted">{{ r.accountNo }} · {{ r.customerId }} · PAN {{ r.customerPan }}</div>
</td>
<td class="mono">{{ r.nocNo }}</td>
<td>
<span class="cert-pill cert-pill--type">{{ loanTypeLabel(r.loanType) }}</span>
<div class="small muted">Sanctioned ₹{{ r.sanctionedAmount | number: '1.0-0' }}</div>
</td>
<td>
@if (r.assetType !== 'NONE') {
<div>{{ r.assetDescription }}</div>
<div class="mono small muted">{{ r.assetIdentifier }}</div>
} @else {
<span class="muted small">Unsecured · no asset</span>
}
</td>
<td class="num">
<strong>₹{{ r.totalPaid | number: '1.0-0' }}</strong>
</td>
<td class="small">
@if (r.closureDate) {
{{ r.closureDate | date: 'd MMM y' }}
} @else {
<span class="muted">—</span>
}
</td>
<td>
@if (r.status === 'ISSUED') {
<span class="cert-pill cert-pill--green"> <fa-icon icon="check-circle"></fa-icon> Issued </span>
@if (r.issuedOn) {
<div class="mono small muted">{{ r.issuedOn | date: 'd MMM y' }}</div>
}
} @else if (r.status === 'READY_TO_ISSUE') {
<span class="cert-pill cert-pill--blue"> <fa-icon icon="paper-plane"></fa-icon> Ready to issue </span>
} @else {
<span class="cert-pill cert-pill--amber">
<fa-icon icon="hourglass-half"></fa-icon> Pending closure
</span>
}
</td>
<td class="col-actions">
<div class="cert-row-actions">
<button
type="button"
class="cert-icon-btn primary"
[disabled]="r.status === 'PENDING'"
(click)="viewCertificate(r)"
matTooltip="View / Print NOC"
>
<fa-icon icon="eye"></fa-icon>
</button>
<button
type="button"
class="cert-icon-btn"
[disabled]="r.status === 'PENDING'"
(click)="downloadCertificate(r)"
matTooltip="Download PDF"
>
<fa-icon icon="download"></fa-icon>
</button>
</div>
</td>
</tr>
}
@if (filteredRecords.length === 0) {
<tr>
<td colspan="8" class="cert-empty">
<fa-icon icon="info-circle"></fa-icon>
No NOCs match the current filters.
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
@@ -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'
}
];
}
@@ -0,0 +1,164 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="cert-root">
<!-- HEADER -->
<div class="cert-head">
<div class="cert-head-l">
<fa-icon icon="certificate" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">Share Certificate</h2>
<div class="cert-head-s">
Issue, preview and download member share certificates with folio + share-number range.
</div>
</div>
</div>
</div>
<!-- KPI STRIP -->
<div class="cert-kpis">
<div class="cert-kpi cert-kpi--blue">
<fa-icon icon="certificate" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ filteredRecords.length | number }}</div>
<div class="cert-kpi-l">Certificates</div>
</div>
</div>
<div class="cert-kpi cert-kpi--teal">
<fa-icon icon="cube" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totalShares() | number }}</div>
<div class="cert-kpi-l">Shares Held</div>
</div>
</div>
<div class="cert-kpi cert-kpi--green">
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">₹{{ totalShareValue() | number: '1.0-0' }}</div>
<div class="cert-kpi-l">Total Face Value</div>
</div>
</div>
</div>
<!-- FILTERS -->
<div class="cert-filters">
<div class="cert-filter">
<label>Certificate / Folio / Member / PAN</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="text" [(ngModel)]="search" placeholder="e.g. SC-2024-000123 or member name" />
</div>
</div>
<div class="cert-filter">
<label>Share class</label>
<select [(ngModel)]="classFilter">
<option value="">All classes</option>
@for (o of classOptions; track o.value) {
<option [value]="o.value">{{ o.label }}</option>
}
</select>
</div>
<div class="cert-filter">
<label>Branch</label>
<select [(ngModel)]="branchFilter">
<option value="">All branches</option>
@for (b of branchOptions; track b) {
<option [value]="b">{{ b }}</option>
}
</select>
</div>
@if (search || classFilter || branchFilter) {
<button type="button" class="cert-clear" (click)="clearFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
}
</div>
<!-- TABLE -->
<div class="cert-card">
<div class="cert-table-wrap">
<table class="cert-table">
<thead>
<tr>
<th>Member · Folio</th>
<th>Certificate No.</th>
<th>Class</th>
<th>Branch</th>
<th class="num">Shares</th>
<th class="num">Face Value</th>
<th class="num">Total</th>
<th>Share Nos.</th>
<th>Issued</th>
<th class="col-actions">Actions</th>
</tr>
</thead>
<tbody>
@for (r of filteredRecords; track r.certificateNo) {
<tr>
<td>
<div class="cert-cust-name">{{ r.memberName }}</div>
<div class="mono small muted">
{{ r.memberId }} · Folio <span class="cert-folio">{{ r.folioNo }}</span> · PAN {{ r.memberPan }}
</div>
</td>
<td class="mono">{{ r.certificateNo }}</td>
<td>
<span
class="cert-pill"
[class.cert-pill--blue]="r.shareClass === 'ORDINARY'"
[class.cert-pill--amber]="r.shareClass === 'PREFERENCE'"
>
{{ classLabel(r.shareClass) }}
</span>
</td>
<td class="small">{{ r.branch }}</td>
<td class="num">
<strong class="cert-shares">{{ r.shareCount | number }}</strong>
</td>
<td class="num">₹{{ r.faceValue | number: '1.0-0' }}</td>
<td class="num">
<strong>₹{{ r.shareCount * r.faceValue | number: '1.0-0' }}</strong>
</td>
<td class="mono small">{{ r.shareNoFrom }}&ndash;{{ r.shareNoTo }}</td>
<td class="small">{{ r.issueDate | date: 'd MMM y' }}</td>
<td class="col-actions">
<div class="cert-row-actions">
<button
type="button"
class="cert-icon-btn primary"
(click)="viewCertificate(r)"
matTooltip="View / Print certificate"
>
<fa-icon icon="eye"></fa-icon>
</button>
<button
type="button"
class="cert-icon-btn"
(click)="downloadCertificate(r)"
matTooltip="Download PDF"
>
<fa-icon icon="download"></fa-icon>
</button>
</div>
</td>
</tr>
}
@if (filteredRecords.length === 0) {
<tr>
<td colspan="10" class="cert-empty">
<fa-icon icon="info-circle"></fa-icon>
No share certificates match the current filters.
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
@@ -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
}
];
}
@@ -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 {}
+46
View File
@@ -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 {}
@@ -0,0 +1,209 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="ccd-root">
<!-- HEADER -->
<div class="ccd-head">
<div class="ccd-head-l">
<fa-icon icon="phone" class="ccd-head-ic"></fa-icon>
<div>
<h2 class="ccd-head-t">Reach {{ data.loan.customerName }}</h2>
<div class="ccd-head-s">
<span class="mono">{{ data.loan.accountNo }}</span> · <span class="mono">{{ data.loan.customerId }}</span> ·
{{ data.loan.branch }}
</div>
</div>
</div>
<button type="button" class="ccd-x" (click)="close()" matTooltip="Close">
<fa-icon icon="times"></fa-icon>
</button>
</div>
<!-- CONTACT LIST -->
<div class="ccd-body">
@for (c of contacts; track c.kind) {
<div class="ccd-row-group">
<div class="ccd-row" [ngClass]="'accent-' + c.accent" [class.is-logging]="isLogging(c)">
<span class="ccd-row-strip"></span>
<div class="ccd-row-icon">
<fa-icon [icon]="c.icon"></fa-icon>
</div>
<div class="ccd-row-text">
<div class="ccd-row-label">{{ c.label }}</div>
@if (c.name) {
<div class="ccd-row-name">{{ c.name }}</div>
}
<div class="ccd-row-mobile mono">+91 {{ formatMobile(c.mobile) }}</div>
@if (latestLog(c); as log) {
<div class="ccd-row-lastlog" [ngClass]="'tone-' + outcomeTone(log.outcome)">
<fa-icon [icon]="outcomeIcon(log.outcome)"></fa-icon>
<span class="ccd-lastlog-l">{{ outcomeLabel(log.outcome) }}</span>
<span class="ccd-lastlog-m">· {{ log.calledAt.slice(0, 16) }}</span>
@if (log.promiseAmount && log.promiseDate) {
<span class="ccd-lastlog-ptp">
PTP ₹{{ log.promiseAmount | number: '1.0-0' }} by {{ log.promiseDate | date: 'd MMM' }}
</span>
}
</div>
}
</div>
<div class="ccd-row-actions">
<button
type="button"
class="ccd-act ccd-act--primary"
(click)="call(c)"
matTooltip="Call now"
aria-label="Call"
>
<fa-icon icon="phone"></fa-icon>
<span>Call</span>
</button>
<button
type="button"
class="ccd-act ccd-act--log"
[class.is-on]="isLogging(c)"
(click)="isLogging(c) ? cancelLogging() : startLogging(c)"
matTooltip="Log call outcome"
aria-label="Log call outcome"
>
<fa-icon icon="clipboard-check"></fa-icon>
</button>
<button
type="button"
class="ccd-act"
(click)="whatsapp(c)"
matTooltip="Send a WhatsApp message"
aria-label="WhatsApp"
>
<fa-icon icon="comment-dots"></fa-icon>
</button>
<button type="button" class="ccd-act" (click)="copy(c)" matTooltip="Copy number" aria-label="Copy number">
<fa-icon icon="copy"></fa-icon>
</button>
</div>
</div>
<!-- Inline disposition picker -->
@if (isLogging(c)) {
<div class="ccd-log">
<div class="ccd-log-h">
<fa-icon icon="clipboard-check" class="ccd-log-h-ic"></fa-icon>
<span>Log call outcome</span>
<span class="ccd-log-h-sub">{{ c.name || c.label }} · +91 {{ formatMobile(c.mobile) }}</span>
</div>
<div class="ccd-log-l">Pick an outcome</div>
<div class="ccd-chips">
@for (o of outcomeOptions; track o.value) {
<button
type="button"
class="ccd-chip"
[ngClass]="'tone-' + o.tone"
[class.is-on]="isOutcomePicked(o.value)"
(click)="pickOutcome(o.value)"
>
<fa-icon [icon]="o.icon"></fa-icon>
<span>{{ o.label }}</span>
</button>
}
</div>
@if (isPromiseToPay) {
<div class="ccd-ptp">
<div class="ccd-field">
<label>Promise amount (₹)</label>
<input type="number" min="1" step="1" [(ngModel)]="draft.promiseAmount" placeholder="e.g. 50000" />
</div>
<div class="ccd-field">
<label>Promise date</label>
<input type="date" [(ngModel)]="draft.promiseDate" />
</div>
</div>
}
<div class="ccd-field">
<label>Notes (optional)</label>
<textarea
rows="2"
maxlength="240"
[(ngModel)]="draft.notes"
placeholder="Anything the next agent should know…"
></textarea>
</div>
<div class="ccd-log-actions">
<button type="button" class="ccd-act" (click)="cancelLogging()">
<span>Cancel</span>
</button>
<button type="button" class="ccd-act ccd-act--save" [disabled]="!canSaveLog" (click)="saveLog(c)">
<fa-icon icon="check-circle"></fa-icon>
<span>Save call log</span>
</button>
</div>
</div>
}
</div>
}
@if (contacts.length === 0) {
<div class="ccd-empty">
<fa-icon icon="info-circle"></fa-icon>
<div>
<strong>No contact numbers on file.</strong>
<p>Update the customer profile to add mobile, family or nominee contacts.</p>
</div>
</div>
}
<!-- Recent calls audit list -->
@if (allLogs.length > 0) {
<div class="ccd-history">
<div class="ccd-history-h">
<fa-icon icon="history"></fa-icon>
<span>Recent calls</span>
<span class="ccd-history-c">{{ allLogs.length }}</span>
</div>
<ul class="ccd-history-list">
@for (log of allLogs; track log.id) {
<li class="ccd-history-item" [ngClass]="'tone-' + outcomeTone(log.outcome)">
<div class="ccd-history-row">
<span class="ccd-history-dot"></span>
<span class="ccd-history-outcome">
<fa-icon [icon]="outcomeIcon(log.outcome)"></fa-icon>
{{ outcomeLabel(log.outcome) }}
</span>
<span class="ccd-history-kind">
{{ contactKindLabel(log.contactKind) }}
@if (log.contactName) {
· {{ log.contactName }}
}
</span>
<span class="ccd-history-num mono">+91 {{ formatMobile(log.contactNumber) }}</span>
<span class="ccd-history-meta">{{ log.calledAt.slice(0, 16) }} · {{ log.calledBy }}</span>
</div>
@if (log.promiseAmount && log.promiseDate) {
<div class="ccd-history-ptp">
<fa-icon icon="check-circle"></fa-icon>
Promise ₹{{ log.promiseAmount | number: '1.0-0' }} by
{{ log.promiseDate | date: 'd MMM y' }}
</div>
}
@if (log.notes) {
<div class="ccd-history-notes">"{{ log.notes }}"</div>
}
</li>
}
</ul>
</div>
}
</div>
</div>
@@ -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;
}
}
@@ -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<CallContactDialogComponent>);
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())
);
}
}
@@ -0,0 +1,148 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="nhd-root">
<!-- HEADER -->
<div class="nhd-head">
<div class="nhd-head-l">
<fa-icon icon="history" class="nhd-head-ic"></fa-icon>
<div>
<h2 class="nhd-head-t">
{{ data.loan.customerName }} <small>· {{ data.loan.accountNo }}</small>
</h2>
<div class="nhd-head-s">
{{ data.loan.notices.length }} notice(s) generated · Overdue
<strong>₹{{ data.loan.overdueTotal | number: '1.0-0' }}</strong> · DPD {{ data.loan.daysOverdue }}
</div>
</div>
</div>
<button type="button" class="nhd-x" (click)="close()" matTooltip="Close">
<fa-icon icon="times"></fa-icon>
</button>
</div>
<!-- TIMELINE -->
<div class="nhd-body">
@if (data.loan.notices.length === 0) {
<div class="nhd-empty">
<fa-icon icon="info-circle"></fa-icon>
<div>
<strong>No notices have been generated yet.</strong>
<p>Click the Generate icon on the row to issue a first-stage notice.</p>
</div>
</div>
} @else {
<ol class="nhd-timeline">
@for (n of data.loan.notices; track n.id) {
<li class="nhd-tl-item" [ngClass]="'accent-' + stageColor(n.stage)">
<span class="nhd-tl-dot">
<fa-icon icon="file-signature"></fa-icon>
</span>
<div class="nhd-tl-card">
<div class="nhd-tl-head">
<span class="nhd-stage-pill" [ngClass]="'nhd-stage-pill--' + stageColor(n.stage)">
{{ stageLabel(n.stage) }}
</span>
<span class="nhd-tl-meta">
Generated <strong>{{ n.generatedAt }}</strong> by {{ n.generatedBy }}
</span>
<span class="nhd-tl-id mono">{{ n.id }}</span>
<span class="nhd-tl-actions">
<button
type="button"
class="nhd-action-btn"
(click)="viewNotice(n)"
matTooltip="View / print this notice"
>
<fa-icon icon="eye"></fa-icon>
</button>
<button type="button" class="nhd-action-btn" (click)="downloadNotice(n)" matTooltip="Download PDF">
<fa-icon icon="download"></fa-icon>
</button>
</span>
</div>
@if (n.sends.length === 0) {
<div class="nhd-no-sends">
<fa-icon icon="hourglass-half"></fa-icon>
Generated but not yet dispatched.
</div>
} @else {
<table class="nhd-send-table">
<thead>
<tr>
<th>Channel</th>
<th>Destination</th>
<th>Sent</th>
<th>By</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (s of n.sends; track s.sentAt + s.channel) {
<tr>
<td>
<span class="nhd-channel">
<fa-icon [icon]="channelIcon(s.channel)"></fa-icon>
{{ channelLabel(s.channel) }}
</span>
</td>
<td class="small mono">{{ s.destination }}</td>
<td class="small mono">{{ s.sentAt }}</td>
<td class="small">{{ s.sentBy }}</td>
<td>
<span class="nhd-status-pill" [ngClass]="'nhd-status-pill--' + statusColor(s.status)">
{{ s.status }}
</span>
</td>
</tr>
@if (s.channel === 'POST' && (s.consignmentNo || s.postOffice)) {
<tr class="nhd-post-row">
<td colspan="5">
<div class="nhd-post-meta">
@if (s.consignmentNo) {
<span class="nhd-post-chip">
<fa-icon icon="truck-fast"></fa-icon>
<span class="nhd-post-l">Consignment</span>
<span class="nhd-post-v mono">{{ s.consignmentNo }}</span>
</span>
}
@if (s.postOffice) {
<span class="nhd-post-chip">
<fa-icon icon="map-marker-alt"></fa-icon>
<span class="nhd-post-l">Post office</span>
<span class="nhd-post-v">{{ s.postOffice }}</span>
</span>
}
@if (s.bookedDate) {
<span class="nhd-post-chip">
<span class="nhd-post-l">Booked</span>
<span class="nhd-post-v">{{ s.bookedDate | date: 'd MMM y' }}</span>
</span>
}
@if (s.postage) {
<span class="nhd-post-chip">
<span class="nhd-post-l">Postage</span>
<span class="nhd-post-v">₹{{ s.postage | number: '1.0-0' }}</span>
</span>
}
</div>
</td>
</tr>
}
}
</tbody>
</table>
}
</div>
</li>
}
</ol>
}
</div>
</div>
@@ -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;
}
}
@@ -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<NoticeHistoryDialogComponent>);
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';
}
}
}
@@ -0,0 +1,150 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="npd-root">
<!-- DIALOG TOOLBAR (hidden when printing) -->
<div class="npd-toolbar no-print">
<div class="npd-toolbar-l">
<fa-icon icon="file-signature" class="npd-toolbar-ic"></fa-icon>
<div>
<div class="npd-toolbar-t">{{ stageLabel(data.notice.stage) }}</div>
<div class="npd-toolbar-s">{{ data.notice.id }} · {{ data.notice.generatedAt }}</div>
</div>
</div>
<div class="npd-toolbar-r">
<button class="npd-tb-btn" (click)="printNotice()" matTooltip="Print or Save as PDF">
<fa-icon icon="print"></fa-icon> Print / PDF
</button>
<button class="npd-tb-btn" (click)="downloadPdf()" matTooltip="Download as PDF">
<fa-icon icon="download"></fa-icon> Download PDF
</button>
<button class="npd-tb-btn npd-tb-x" (click)="close()" matTooltip="Close">
<fa-icon icon="times"></fa-icon>
</button>
</div>
</div>
<!-- LETTER PAGE -->
<div class="npd-page" [ngClass]="'tone-' + letter.tone">
<!-- Bank letterhead -->
<div class="npd-letterhead">
<div class="npd-bank-name">{{ bankName }}</div>
<div class="npd-bank-sub">{{ bankAddress }}</div>
<div class="npd-bank-sub">{{ bankRegNo }} &middot; {{ bankContact }}</div>
</div>
<!-- Notice meta -->
<div class="npd-meta-row">
<div>
<div class="npd-meta-l">Notice Reference</div>
<div class="npd-meta-v mono">{{ data.notice.id }}</div>
</div>
<div>
<div class="npd-meta-l">Date of Issue</div>
<div class="npd-meta-v">{{ data.notice.generatedAt.slice(0, 10) | date: 'd MMMM y' }}</div>
</div>
<div>
<div class="npd-meta-l">Stage</div>
<div class="npd-meta-v">
<span class="npd-stage-pill" [ngClass]="'npd-stage-pill--' + letter.tone">
{{ stageLabel(data.notice.stage) }}
</span>
</div>
</div>
</div>
<!-- Addressee -->
<div class="npd-block">
<div class="npd-block-l">To,</div>
<div class="npd-block-v">
<div>
<strong>{{ data.loan.customerName }}</strong>
</div>
<div>
Customer ID: <span class="mono">{{ data.loan.customerId }}</span>
</div>
<div>{{ data.loan.address }}</div>
<div>Mob: +91 {{ data.loan.mobile }} &middot; {{ data.loan.email }}</div>
</div>
</div>
<!-- Subject -->
<div class="npd-subject">
<span class="npd-subject-l">Subject:</span>
<span class="npd-subject-v">{{ letter.subject }}</span>
</div>
<!-- Body -->
<p class="npd-salutation">{{ letter.salutation }}</p>
<p class="npd-para">{{ letter.intro }}</p>
@for (p of letter.body; track p) {
<p class="npd-para">{{ p }}</p>
}
<!-- Loan summary table -->
<table class="npd-summary">
<tbody>
<tr>
<td>Loan Account No.</td>
<td class="mono">
<strong>{{ data.loan.accountNo }}</strong>
</td>
<td>Branch</td>
<td>{{ data.loan.branch }}</td>
</tr>
<tr>
<td>Days Past Due</td>
<td>
<strong>{{ data.loan.daysOverdue }}</strong>
</td>
<td>Last Paid Date</td>
<td>{{ data.loan.lastPaidDate | date: 'd MMM y' }}</td>
</tr>
<tr>
<td>Overdue Principal</td>
<td>₹{{ data.loan.overduePrincipal | number: '1.2-2' }}</td>
<td>Overdue Interest</td>
<td>₹{{ data.loan.overdueInterest | number: '1.2-2' }}</td>
</tr>
<tr class="npd-summary-total">
<td colspan="3">TOTAL DUES OUTSTANDING</td>
<td>
<strong>₹{{ data.loan.overdueTotal | number: '1.2-2' }}</strong>
</td>
</tr>
</tbody>
</table>
<!-- Call to action -->
<p class="npd-para npd-cta">{{ letter.callToAction }}</p>
<!-- Consequences -->
<p class="npd-para npd-consequences">{{ letter.consequences }}</p>
<!-- Sign-off -->
<p class="npd-signoff">{{ letter.signOff }}</p>
<div class="npd-signature">
<div>
<div class="npd-sig-line"></div>
<div class="npd-sig-name">
For <strong>{{ bankName }}</strong>
</div>
<div class="npd-sig-role">Authorised Signatory &mdash; Recovery & Collections</div>
<div class="npd-sig-role mono">{{ data.loan.branch }}</div>
</div>
</div>
<!-- Footer -->
<div class="npd-footer">
<span
>Notice generated by <strong>{{ data.notice.generatedBy }}</strong> on {{ data.notice.generatedAt }}.</span
>
<span class="mono">{{ data.notice.id }}</span>
</div>
</div>
</div>
@@ -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;
}
}
@@ -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<NoticePreviewDialogComponent>);
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,'
};
}
}
@@ -0,0 +1,371 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="ntc-root">
<!-- HEADER -->
<div class="ntc-head">
<div class="ntc-head-l">
<fa-icon icon="file-alt" class="ntc-head-ic"></fa-icon>
<div>
<h2 class="ntc-head-t">Loan Overdue Notices</h2>
<div class="ntc-head-s">
Generate, dispatch, and audit notices for accounts with overdue dues. Bulk operations supported.
</div>
</div>
</div>
</div>
<!-- KPI STRIP -->
<div class="ntc-kpis">
<div class="ntc-kpi ntc-kpi--blue">
<fa-icon icon="exclamation-triangle" class="ntc-kpi-ic"></fa-icon>
<div>
<div class="ntc-kpi-n">{{ totalOverdueAccounts | number }}</div>
<div class="ntc-kpi-l">Overdue Accounts</div>
</div>
</div>
<div class="ntc-kpi ntc-kpi--red">
<fa-icon icon="coins" class="ntc-kpi-ic"></fa-icon>
<div>
<div class="ntc-kpi-n">₹{{ totalOverdueAmount | number: '1.0-0' }}</div>
<div class="ntc-kpi-l">Total Overdue Value</div>
</div>
</div>
<div class="ntc-kpi ntc-kpi--green">
<fa-icon icon="paper-plane" class="ntc-kpi-ic"></fa-icon>
<div>
<div class="ntc-kpi-n">{{ noticesGeneratedCount | number }}</div>
<div class="ntc-kpi-l">Notices Generated</div>
</div>
</div>
<div class="ntc-kpi ntc-kpi--amber">
<fa-icon icon="hourglass-half" class="ntc-kpi-ic"></fa-icon>
<div>
<div class="ntc-kpi-n">{{ noticesPendingGenCount | number }}</div>
<div class="ntc-kpi-l">Pending Generation</div>
</div>
</div>
</div>
<!-- FILTERS -->
<div class="ntc-filters">
<div class="ntc-filter">
<label>Account number / Customer</label>
<div class="ntc-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="text" [(ngModel)]="search" placeholder="e.g. LN10029384 or customer name" />
</div>
</div>
<div class="ntc-filter">
<label>Account type</label>
<select [(ngModel)]="accountTypeFilter">
<option value="">All types</option>
@for (o of accountTypeOptions; track o.value) {
<option [value]="o.value">{{ o.label }}</option>
}
</select>
</div>
<div class="ntc-filter ntc-filter--date">
<div class="ntc-date-header">
<label>{{ filterMode === 'range' ? 'Date range' : 'As-on date' }}</label>
<div class="ntc-date-toggle">
<button type="button" [class.is-on]="filterMode === 'range'" (click)="setFilterMode('range')">Range</button>
<button type="button" [class.is-on]="filterMode === 'ason'" (click)="setFilterMode('ason')">As-on</button>
</div>
</div>
@if (filterMode === 'range') {
<div class="ntc-date-row">
<input type="date" [(ngModel)]="startDate" aria-label="From" title="From" />
<input type="date" [(ngModel)]="endDate" aria-label="To" title="To" />
</div>
} @else {
<div class="ntc-date-row">
<input type="date" [(ngModel)]="asOnDate" aria-label="As on" title="As on" />
</div>
}
</div>
@if (search || accountTypeFilter || startDate || endDate || asOnDate) {
<button type="button" class="ntc-clear" (click)="clearFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
}
</div>
<!-- BULK ACTION BAR -->
@if (selectedCount > 0) {
<div class="ntc-bulk-bar">
<div class="ntc-bulk-l">
<fa-icon icon="check-square"></fa-icon>
<strong>{{ selectedCount }}</strong> selected
</div>
<div class="ntc-bulk-r">
<button
class="ntc-btn ntc-btn-primary"
(click)="bulkGenerate()"
matTooltip="Generate next-stage notice for all selected"
>
<fa-icon icon="file-signature"></fa-icon> Generate notices
</button>
<button
class="ntc-btn ntc-btn-secondary"
(click)="bulkSend(['EMAIL'])"
matTooltip="Email the most-recent notice for each"
>
<fa-icon icon="envelope"></fa-icon> Bulk email
</button>
<button
class="ntc-btn ntc-btn-secondary"
(click)="bulkSend(['POST'])"
matTooltip="Speed-post the most-recent notice for each"
>
<fa-icon icon="stamp"></fa-icon> Bulk post
</button>
<button
class="ntc-btn ntc-btn-secondary"
(click)="bulkSend(['EMAIL', 'POST'])"
matTooltip="Send via Email + Speed Post"
>
<fa-icon icon="paper-plane"></fa-icon> Email + Post
</button>
</div>
</div>
}
<!-- Shared "More actions" menu — single mat-menu reused for every row -->
<mat-menu #rowActionsMenu="matMenu" xPosition="before" class="ntc-actions-menu">
<ng-template matMenuContent let-loan="loan">
<!-- Context header: shows whose row this menu belongs to. Click-trapped
so tapping inside doesn't close the menu. -->
<div class="nmh" (click)="$event.stopPropagation()" tabindex="-1">
<!-- Identity row -->
<div class="nmh-id">
<div class="nmh-avatar" [ngClass]="'nmh-avatar--' + dpdBucket(loan.daysOverdue)">
{{ loan.customerName.charAt(0).toUpperCase() }}
</div>
<div class="nmh-id-text">
<div class="nmh-name">{{ loan.customerName }}</div>
<div class="nmh-acc mono">{{ loan.accountNo }}</div>
</div>
<span class="nmh-type">{{ accountTypeLabel(loan.accountType) }}</span>
</div>
<!-- Secondary facts -->
<div class="nmh-facts">
<div class="nmh-fact">
<span class="nmh-fact-l">Customer ID</span>
<span class="nmh-fact-v mono">{{ loan.customerId }}</span>
</div>
<div class="nmh-fact">
<span class="nmh-fact-l">Branch</span>
<span class="nmh-fact-v">{{ loan.branch }}</span>
</div>
</div>
<!-- Hero strip: Overdue + DPD -->
<div class="nmh-hero" [ngClass]="'nmh-hero--' + dpdBucket(loan.daysOverdue)">
<div class="nmh-overdue">
<span class="nmh-overdue-l">Overdue dues</span>
<span class="nmh-overdue-v">₹{{ loan.overdueTotal | number: '1.0-0' }}</span>
</div>
<div class="nmh-dpd">
<span class="nmh-dpd-v">{{ loan.daysOverdue }}</span>
<span class="nmh-dpd-l">days past due</span>
</div>
</div>
</div>
<!-- Action grid — 2 columns, grouped into 3 sections -->
<div class="nma-section">
<div class="nma-section-l">Notice document</div>
<div class="nma-grid">
<button type="button" class="nma-tile" [disabled]="!latestNotice(loan)" (click)="viewLatestNotice(loan)">
<fa-icon icon="eye" class="nma-tile-ic"></fa-icon>
<span class="nma-tile-l">View / Print</span>
</button>
<button type="button" class="nma-tile" [disabled]="!latestNotice(loan)" (click)="downloadLatestNotice(loan)">
<fa-icon icon="download" class="nma-tile-ic"></fa-icon>
<span class="nma-tile-l">Download PDF</span>
</button>
</div>
</div>
<div class="nma-section">
<div class="nma-section-l">Send to customer</div>
<div class="nma-grid">
<button type="button" class="nma-tile" [disabled]="!latestNotice(loan)" (click)="sendNotice(loan, 'EMAIL')">
<fa-icon icon="envelope" class="nma-tile-ic"></fa-icon>
<span class="nma-tile-l">Email</span>
</button>
<button type="button" class="nma-tile" [disabled]="!latestNotice(loan)" (click)="sendNotice(loan, 'POST')">
<fa-icon icon="truck-fast" class="nma-tile-ic"></fa-icon>
<span class="nma-tile-l">Speed Post</span>
</button>
<button type="button" class="nma-tile" [disabled]="!latestNotice(loan)" (click)="sendNotice(loan, 'SMS')">
<fa-icon icon="mobile-alt" class="nma-tile-ic"></fa-icon>
<span class="nma-tile-l">SMS</span>
</button>
<button
type="button"
class="nma-tile"
[disabled]="!latestNotice(loan)"
(click)="sendNotice(loan, 'WHATSAPP')"
>
<fa-icon icon="comment-dots" class="nma-tile-ic"></fa-icon>
<span class="nma-tile-l">WhatsApp</span>
</button>
</div>
</div>
<div class="nma-section">
<div class="nma-section-l">Customer</div>
<div class="nma-grid">
<button type="button" class="nma-tile" (click)="callCustomer(loan)">
<fa-icon icon="phone" class="nma-tile-ic"></fa-icon>
<span class="nma-tile-l">Call customer</span>
</button>
<button type="button" class="nma-tile" (click)="copyAccountNo(loan)">
<fa-icon icon="copy" class="nma-tile-ic"></fa-icon>
<span class="nma-tile-l">Copy A/C No.</span>
</button>
<button type="button" class="nma-tile" [disabled]="loan.notices.length === 0" (click)="openHistory(loan)">
<fa-icon icon="history" class="nma-tile-ic"></fa-icon>
<span class="nma-tile-l">Notice history</span>
</button>
<button type="button" class="nma-tile" (click)="openLoanProfile(loan)">
<fa-icon icon="external-link-alt" class="nma-tile-ic"></fa-icon>
<span class="nma-tile-l">Open loan</span>
</button>
</div>
</div>
</ng-template>
</mat-menu>
<!-- TABLE -->
<div class="ntc-card">
<div class="ntc-table-wrap">
<table class="ntc-table">
<thead>
<tr>
<th class="col-sel">
<input
type="checkbox"
[checked]="allOnPageSelected"
[indeterminate]="someOnPageSelected"
(change)="toggleSelectAll($any($event.target).checked)"
aria-label="Select all on page"
/>
</th>
<th>Customer · A/C</th>
<th>Type</th>
<th>Branch</th>
<th class="num">Overdue</th>
<th>DPD</th>
<th>Last Paid</th>
<th>Notice Status</th>
<th>Last Sent</th>
<th class="col-actions">Actions</th>
</tr>
</thead>
<tbody>
@for (loan of filteredLoans; track loan.accountNo) {
<tr [class.is-selected]="isSelected(loan.accountNo)">
<td class="col-sel">
<input
type="checkbox"
[checked]="isSelected(loan.accountNo)"
(change)="toggleSelect(loan.accountNo, $any($event.target).checked)"
[attr.aria-label]="'Select ' + loan.accountNo"
/>
</td>
<td>
<div class="ntc-cust-name">{{ loan.customerName }}</div>
<div class="mono small muted">{{ loan.accountNo }} · {{ loan.customerId }}</div>
</td>
<td>
<span class="ntc-pill ntc-pill--type">{{ accountTypeLabel(loan.accountType) }}</span>
</td>
<td class="small">{{ loan.branch }}</td>
<td class="num">
<div class="ntc-amount">₹{{ loan.overdueTotal | number: '1.0-0' }}</div>
<div class="small muted">
P ₹{{ loan.overduePrincipal | number: '1.0-0' }} · I ₹{{ loan.overdueInterest | number: '1.0-0' }}
</div>
</td>
<td>
<span class="ntc-dpd-pill" [ngClass]="'ntc-dpd-pill--' + dpdBucket(loan.daysOverdue)">
{{ loan.daysOverdue }} d
</span>
</td>
<td class="small">{{ loan.lastPaidDate | date: 'd MMM y' }}</td>
<td>
@switch (noticeStatus(loan)) {
@case ('NONE') {
<span class="ntc-pill ntc-pill--amber">
<fa-icon icon="exclamation-circle"></fa-icon> Not Generated
</span>
}
@case ('GENERATED') {
<span class="ntc-pill ntc-pill--blue">
<fa-icon icon="file-signature"></fa-icon>
{{ stageLabel(latestNotice(loan)!.stage) }} · Awaiting Send
</span>
}
@case ('SENT') {
<span class="ntc-pill ntc-pill--green">
<fa-icon icon="check-circle"></fa-icon>
{{ stageLabel(latestNotice(loan)!.stage) }} · Sent ({{ totalSendCount(loan) }})
</span>
}
}
</td>
<td class="small">
@if (latestNotice(loan) && latestNotice(loan)!.sends.length > 0) {
<div class="ntc-last-send">
<fa-icon [icon]="channelIcon(latestNotice(loan)!.sends[0].channel)"></fa-icon>
{{ channelLabel(latestNotice(loan)!.sends[0].channel) }}
</div>
<div class="mono small muted">{{ latestNotice(loan)!.sends[0].sentAt }}</div>
} @else {
<span class="muted">—</span>
}
</td>
<td class="col-actions">
<div class="ntc-row-actions">
<button
class="ntc-icon-btn primary"
(click)="generateNotice(loan)"
matTooltip="Generate next-stage notice"
>
<fa-icon icon="file-signature"></fa-icon>
</button>
<button
class="ntc-icon-btn ntc-more-btn"
[matMenuTriggerFor]="rowActionsMenu"
[matMenuTriggerData]="{ loan: loan }"
matTooltip="More actions"
aria-label="More actions"
>
<fa-icon icon="ellipsis-v"></fa-icon>
</button>
</div>
</td>
</tr>
}
@if (filteredLoans.length === 0) {
<tr>
<td colspan="10" class="ntc-empty">
<fa-icon icon="info-circle"></fa-icon>
No overdue accounts match the current filters.
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,97 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="spd-root">
<!-- HEADER -->
<div class="spd-head">
<div class="spd-head-l">
<fa-icon icon="truck-fast" class="spd-head-ic"></fa-icon>
<div>
<h2 class="spd-head-t">Record Speed Post dispatch</h2>
<div class="spd-head-s">
{{ data.loan.customerName }} · <span class="mono">{{ data.loan.accountNo }}</span> · Notice
<span class="mono">{{ data.notice.id }}</span>
</div>
</div>
</div>
<button type="button" class="spd-x" (click)="cancel()" matTooltip="Cancel">
<fa-icon icon="times"></fa-icon>
</button>
</div>
<!-- DELIVERY ADDRESS SUMMARY -->
<div class="spd-addr">
<fa-icon icon="map-marker-alt" class="spd-addr-ic"></fa-icon>
<div>
<div class="spd-addr-l">Dispatch address</div>
<div class="spd-addr-v">{{ data.loan.address }}</div>
</div>
</div>
<!-- FORM -->
<div class="spd-form">
<div class="spd-field">
<label> Consignment / Article No. <span class="spd-req">*</span> </label>
<input
type="text"
[ngModel]="consignmentNo"
(ngModelChange)="upperCaseConsignment($event)"
placeholder="e.g. EM123456789IN"
maxlength="20"
autocomplete="off"
/>
<small class="spd-hint">
India Post Speed Post 13-char article ID, or any private courier tracking number.
</small>
@if (consignmentNo && !isConsignmentValid) {
<small class="spd-err">
<fa-icon icon="exclamation-triangle"></fa-icon>
Use 6–20 alphanumeric characters (e.g. EM123456789IN).
</small>
}
</div>
<div class="spd-row">
<div class="spd-field">
<label> Post office / Counter <span class="spd-req">*</span> </label>
<input
type="text"
[(ngModel)]="postOffice"
placeholder="e.g. New Delhi GPO"
maxlength="80"
autocomplete="off"
/>
</div>
<div class="spd-field">
<label> Booking date <span class="spd-req">*</span> </label>
<input type="date" [(ngModel)]="bookedDate" />
</div>
</div>
<div class="spd-row">
<div class="spd-field">
<label>Postage paid (₹)</label>
<input type="number" min="0" step="1" [(ngModel)]="postage" placeholder="e.g. 45" />
<small class="spd-hint">Speed Post tariff payable at booking. Leave blank if unknown.</small>
</div>
<div class="spd-field spd-field--empty"></div>
</div>
</div>
<!-- ACTIONS -->
<div class="spd-actions">
<button type="button" class="spd-btn" (click)="cancel()">
<span>Cancel</span>
</button>
<button type="button" class="spd-btn spd-btn--primary" [disabled]="!isFormValid" (click)="confirm()">
<fa-icon icon="truck-fast"></fa-icon>
<span>Confirm dispatch</span>
</button>
</div>
</div>
@@ -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;
}
}
}
@@ -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<SpeedPostDialogComponent, SpeedPostDialogResult>);
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())}`;
}