Merge dev into demo-backup

Brings in all of dev's latest content not present on demo-backup
(Share Withdrawal/Closure feature, TDS reports, KYC exception tracking,
client-edit-email-dialog, form121 tab, search-select) while preserving
demo-backup's own unique changes (wrapping Approval Center tab nav,
Loan Origination/Investment mega-menus). 36 files that a prior "ours
wins" conflict resolution had silently dropped were restored explicitly
from dev's tip.
This commit is contained in:
2026-08-06 14:16:22 +05:30
54 changed files with 12219 additions and 6 deletions
@@ -0,0 +1,210 @@
<!--
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="passbook-container">
<!-- Filter Bar -->
<div class="passbook-filter-bar no-print">
<form [formGroup]="dateRangeForm" class="passbook-filter-form">
<mat-form-field appearance="outline" class="passbook-date-field" (click)="fromDatePicker.open()">
<mat-label>{{ 'labels.inputs.From Date' | translate }}</mat-label>
<input matInput [min]="minDate" [max]="maxDate" [matDatepicker]="fromDatePicker" formControlName="fromDate" />
<mat-datepicker-toggle matSuffix [for]="fromDatePicker"></mat-datepicker-toggle>
<mat-datepicker #fromDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="passbook-date-field" (click)="toDatePicker.open()">
<mat-label>{{ 'labels.inputs.To Date' | translate }}</mat-label>
<input matInput [min]="minDate" [max]="maxDate" [matDatepicker]="toDatePicker" formControlName="toDate" />
<mat-datepicker-toggle matSuffix [for]="toDatePicker"></mat-datepicker-toggle>
<mat-datepicker #toDatePicker></mat-datepicker>
</mat-form-field>
<button
mat-raised-button
color="primary"
type="button"
[disabled]="!dateRangeForm.valid"
(click)="filterTransactions()"
>
<mat-icon>filter_list</mat-icon>
{{ 'labels.buttons.Filter' | translate }}
</button>
@if (isFiltered) {
<button mat-stroked-button type="button" (click)="clearFilter()">
<mat-icon>clear</mat-icon>
{{ 'labels.buttons.Clear' | translate }}
</button>
}
<span class="passbook-filter-spacer"></span>
<button mat-raised-button color="accent" type="button" (click)="printPassbook()">
<mat-icon>print</mat-icon>
{{ 'labels.buttons.Print' | translate }}
</button>
</form>
</div>
<!-- Share Passbook -->
<div class="statement-wrapper" #passbookContent>
<!-- Bank Header -->
<div class="bank-header">
<div class="bank-logo-area">
<div class="bank-logo-circle">
<mat-icon>account_balance</mat-icon>
</div>
<div class="bank-name-block">
<div class="bank-title">LincPay Co-operative Bank Ltd.</div>
<div class="bank-subtitle">Share Account Passbook</div>
</div>
</div>
<div class="statement-label">
<div class="stmt-label-text">SHARE HOLDING STATEMENT</div>
<div class="stmt-period">As on: {{ maxDate | dateFormat }}</div>
</div>
</div>
<!-- Account Details -->
<div class="account-details-band">
<div class="account-details-grid">
<div class="detail-block">
<div class="detail-label">Member Name</div>
<div class="detail-value bold">{{ clientName | uppercase }}</div>
</div>
<div class="detail-block">
<div class="detail-label">Share Account No.</div>
<div class="detail-value mono bold">{{ accountNo }}</div>
</div>
<div class="detail-block">
<div class="detail-label">Product</div>
<div class="detail-value">{{ productName }}</div>
</div>
<div class="detail-block">
<div class="detail-label">Account Opening Date</div>
<div class="detail-value">{{ activatedOnDate | dateFormat }}</div>
</div>
<div class="detail-block">
<div class="detail-label">Status</div>
<div class="detail-value status-badge" [class.active]="status === 'Active'">{{ status }}</div>
</div>
<div class="detail-block">
<div class="detail-label">Linked Savings A/C No.</div>
<div class="detail-value mono">{{ savingsAccountNumber || '—' }}</div>
</div>
@if (resolutionNumber) {
<div class="detail-block">
<div class="detail-label">Resolution No.</div>
<div class="detail-value">{{ resolutionNumber }}</div>
</div>
}
</div>
</div>
<!-- Balance Summary Strip -->
<div class="balance-summary-strip">
<div class="balance-box">
<div class="balance-box-label">Approved Shares</div>
<div class="balance-box-value">{{ totalApprovedShares }}</div>
</div>
<div class="balance-divider"></div>
<div class="balance-box">
<div class="balance-box-label">Share Value</div>
<div class="balance-box-value">
{{ currentMarketPrice | currency: currency?.code : 'symbol-narrow' : '1.2-2' }}
</div>
</div>
<div class="balance-divider"></div>
<div class="balance-box">
<div class="balance-box-label">Current Value</div>
<div class="balance-box-value">{{ currentValue | currency: currency?.code : 'symbol-narrow' : '1.2-2' }}</div>
</div>
<div class="balance-divider"></div>
<div class="balance-box">
<div class="balance-box-label">Total Transactions</div>
<div class="balance-box-value">{{ filteredTransactions.length }}</div>
</div>
</div>
<!-- Transactions Table -->
@if (filteredTransactions.length > 0) {
<table class="statement-table">
<thead>
<tr>
<th class="col-sr">#</th>
<th class="col-date">Date</th>
<th class="col-desc">Description</th>
<th class="col-ref">Ref No.</th>
<th class="col-amount text-right">Shares Purchased</th>
<th class="col-amount text-right">Shares Redeemed</th>
<th class="col-balance text-right">Share Balance</th>
<th class="col-amount text-right">Amount (&#8377;)</th>
<th class="col-desc">Resolution No. / Meeting</th>
</tr>
</thead>
<tbody>
@for (txn of filteredTransactions; track txn.id; let i = $index) {
<tr [class.debit-row]="isRedeem(txn)" [class.credit-row]="!isRedeem(txn)">
<td class="col-sr text-center">{{ i + 1 }}</td>
<td class="col-date">{{ txn.purchasedDate | dateFormat }}</td>
<td class="col-desc">
<span class="txn-desc">{{ txn.type.value }} ({{ txn.status.value }})</span>
</td>
<td class="col-ref text-muted mono">{{ txn.id }}</td>
<td class="col-amount text-right credit-amount">
@if (!isRedeem(txn)) {
{{ txn.numberOfShares }}
}
</td>
<td class="col-amount text-right debit-amount">
@if (isRedeem(txn)) {
{{ txn.numberOfShares }}
}
</td>
<td class="col-balance text-right balance-amount">{{ txn.runningBalance }}</td>
<td class="col-amount text-right">
{{ txn.amount | currency: currency?.code : 'symbol-narrow' : '1.2-2' }}
</td>
<td class="col-desc">
@if (isRedeem(txn)) {
{{ txn.resolutionNumber || '—' }}
@if (txn.meetingDate) {
({{ txn.meetingDate | dateFormat }})
}
} @else {
—
}
</td>
</tr>
}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="6" class="text-right total-label">Closing Share Balance</td>
<td class="text-right closing-balance">{{ totalApprovedShares }}</td>
<td colspan="2"></td>
</tr>
</tfoot>
</table>
} @else {
<div class="no-data-block">
<mat-icon>inbox</mat-icon>
<p>No transactions found for the selected date range.</p>
</div>
}
<!-- Statement Footer -->
<div class="statement-footer">
<div class="footer-note">
This is a computer-generated statement and does not require a signature. For queries, contact your branch or
call customer care.
</div>
<div class="footer-meta">Generated on: {{ maxDate | dateFormat }} &nbsp;|&nbsp; Account No: {{ accountNo }}</div>
</div>
</div>
</div>
@@ -0,0 +1,462 @@
/**
* 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/.
*/
// ─── Variables ──────────────────────────────────────────────────────────────
$bank-primary: #1a3a6b; // deep navy
$bank-accent: #c8a951; // gold trim
$debit-color: #c0392b;
$credit-color: #1a7a3f;
$balance-color: #1a3a6b;
$border-color: #d0d7e3;
$row-alt: #f4f7fb;
// ─── Container ──────────────────────────────────────────────────────────────
.passbook-container {
padding: 16px;
background: #eef1f7;
min-height: 100%;
}
// ─── Filter Bar ─────────────────────────────────────────────────────────────
.passbook-filter-bar {
margin-bottom: 16px;
background: #fff;
border-radius: 8px;
padding: 12px 16px;
border: 1px solid $border-color;
}
.passbook-filter-form {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.passbook-date-field {
width: 180px;
}
.passbook-filter-spacer {
flex: 1;
}
// ─── Statement Wrapper ───────────────────────────────────────────────────────
.statement-wrapper {
background: #fff;
border: 1px solid $border-color;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 12px rgb(0 0 0 / 8%);
font-family: 'Segoe UI', Arial, sans-serif;
}
// ─── Bank Header ─────────────────────────────────────────────────────────────
.bank-header {
background: $bank-primary;
padding: 18px 24px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 4px solid $bank-accent;
}
.bank-logo-area {
display: flex;
align-items: center;
gap: 14px;
}
.bank-logo-circle {
width: 52px;
height: 52px;
border-radius: 50%;
background: $bank-accent;
display: flex;
align-items: center;
justify-content: center;
mat-icon {
color: $bank-primary;
font-size: 28px;
width: 28px;
height: 28px;
}
}
.bank-name-block {
.bank-title {
color: #fff;
font-size: 18px;
font-weight: 700;
letter-spacing: 0.3px;
}
.bank-subtitle {
color: $bank-accent;
font-size: 11px;
font-weight: 500;
letter-spacing: 1.5px;
text-transform: uppercase;
margin-top: 2px;
}
}
.statement-label {
text-align: right;
.stmt-label-text {
color: $bank-accent;
font-size: 13px;
font-weight: 700;
letter-spacing: 2px;
text-transform: uppercase;
}
.stmt-period {
color: rgb(255 255 255 / 75%);
font-size: 11px;
margin-top: 4px;
}
}
// ─── Account Details Band ────────────────────────────────────────────────────
.account-details-band {
background: #f0f3f9;
border-bottom: 1px solid $border-color;
padding: 14px 24px;
}
.account-details-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px 24px;
}
.detail-block {
.detail-label {
font-size: 9px;
font-weight: 700;
color: #7a8aaa;
text-transform: uppercase;
letter-spacing: 0.8px;
margin-bottom: 3px;
}
.detail-value {
font-size: 13px;
color: #222;
&.bold {
font-weight: 600;
}
&.mono {
font-family: 'Courier New', monospace;
letter-spacing: 1px;
}
&.status-badge {
display: inline-block;
padding: 1px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
background: #eee;
color: #555;
&.active {
background: #d4edda;
color: #155724;
}
}
}
}
// ─── Balance Summary Strip ───────────────────────────────────────────────────
.balance-summary-strip {
display: flex;
align-items: stretch;
background: $bank-primary;
padding: 0;
}
.balance-box {
flex: 1;
padding: 12px 20px;
text-align: center;
.balance-box-label {
font-size: 10px;
font-weight: 600;
color: rgb(255 255 255 / 65%);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 4px;
}
.balance-box-value {
font-size: 16px;
font-weight: 700;
color: $bank-accent;
}
}
.balance-divider {
width: 1px;
background: rgb(255 255 255 / 15%);
margin: 8px 0;
}
// ─── Transactions Table ───────────────────────────────────────────────────────
.statement-table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
thead tr {
background: #e8edf5;
border-top: 1px solid $border-color;
border-bottom: 2px solid $bank-primary;
}
thead th {
padding: 9px 10px;
font-size: 11px;
font-weight: 700;
color: $bank-primary;
text-transform: uppercase;
letter-spacing: 0.4px;
white-space: nowrap;
}
tbody tr {
border-bottom: 1px solid #e8ecf3;
transition: background 0.1s;
&:nth-child(even) {
background: $row-alt;
}
&:hover {
background: #dce6f5;
}
}
tbody td {
padding: 7px 10px;
vertical-align: middle;
color: #333;
}
tfoot .total-row {
background: #e8edf5;
border-top: 2px solid $bank-primary;
td {
padding: 9px 10px;
}
}
}
// ─── Column widths ────────────────────────────────────────────────────────────
.col-sr {
width: 38px;
}
.col-date {
width: 100px;
white-space: nowrap;
}
.col-desc {
min-width: 140px;
}
.col-ref {
width: 70px;
}
.col-amount {
width: 100px;
}
.col-balance {
width: 90px;
}
// ─── Amount colors ────────────────────────────────────────────────────────────
.debit-amount {
color: $debit-color;
font-weight: 500;
}
.credit-amount {
color: $credit-color;
font-weight: 500;
}
.balance-amount {
color: $balance-color;
font-weight: 600;
font-family: 'Courier New', monospace;
}
.total-label {
font-size: 12px;
font-weight: 700;
color: $bank-primary;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.closing-balance {
font-size: 14px;
font-weight: 700;
color: $bank-primary;
font-family: 'Courier New', monospace;
}
// ─── Badges & misc ────────────────────────────────────────────────────────────
.txn-desc {
font-weight: 500;
}
.text-right {
text-align: right;
}
.text-center {
text-align: center;
}
.text-muted {
color: #999;
}
.mono {
font-family: 'Courier New', monospace;
font-size: 11px;
}
// ─── No Data ──────────────────────────────────────────────────────────────────
.no-data-block {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
color: #aaa;
mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
margin-bottom: 12px;
}
p {
font-size: 14px;
margin: 0;
}
}
// ─── Statement Footer ─────────────────────────────────────────────────────────
.statement-footer {
background: #f0f3f9;
border-top: 1px solid $border-color;
padding: 12px 24px;
text-align: center;
.footer-note {
font-size: 10px;
color: #888;
margin-bottom: 4px;
font-style: italic;
}
.footer-meta {
font-size: 10px;
color: #aaa;
letter-spacing: 0.3px;
}
}
// ─── Responsive ───────────────────────────────────────────────────────────────
@media (width <= 768px) {
.account-details-grid {
grid-template-columns: 1fr 1fr;
}
.bank-header {
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
.statement-label {
text-align: left;
}
.balance-summary-strip {
flex-direction: column;
}
.balance-divider {
width: auto;
height: 1px;
margin: 0 20px;
}
.passbook-filter-form {
flex-direction: column;
align-items: stretch;
}
.passbook-date-field {
width: 100%;
}
.passbook-filter-spacer {
display: none;
}
}
@media (width <= 480px) {
.account-details-grid {
grid-template-columns: 1fr;
}
}
// ─── Print ────────────────────────────────────────────────────────────────────
@media print {
.no-print {
display: none !important;
}
.passbook-container {
background: #fff;
padding: 0;
}
.statement-wrapper {
border: none;
box-shadow: none;
border-radius: 0;
}
.bank-header {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.balance-summary-strip {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
@@ -0,0 +1,182 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnInit, ElementRef, ViewChild, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { UntypedFormBuilder, Validators } from '@angular/forms';
import { CurrencyPipe } from '@angular/common';
import { MatIcon } from '@angular/material/icon';
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Share Account Passbook — a printable, chronological (oldest-first) ledger of every
* Purchase/Redeem entry, mirroring PassbookTabComponent (Savings) but for share holdings:
* shares purchased/redeemed instead of amount deposited/withdrawn, plus the Board Resolution
* Number / Date of Meeting on redeem rows.
*/
@Component({
selector: 'mifosx-shares-passbook-tab',
templateUrl: './passbook-tab.component.html',
styleUrls: ['./passbook-tab.component.scss'],
standalone: true,
imports: [
...STANDALONE_SHARED_IMPORTS,
MatIcon,
DateFormatPipe,
CurrencyPipe
]
})
export class SharesPassbookTabComponent implements OnInit {
private route = inject(ActivatedRoute);
private formBuilder = inject(UntypedFormBuilder);
@ViewChild('passbookContent') passbookContent: ElementRef;
/** Account data */
accountNo = '';
clientName = '';
productName = '';
currency: any = null;
status = '';
activatedOnDate: any = null;
resolutionNumber = '';
savingsAccountNumber = '';
totalApprovedShares = 0;
currentMarketPrice = 0;
/** Transactions */
allTransactions: any[] = [];
filteredTransactions: any[] = [];
/** Date range form */
minDate = new Date(2000, 0, 1);
maxDate = new Date();
dateRangeForm: any;
isFiltered = false;
get currentValue(): number {
return this.totalApprovedShares * this.currentMarketPrice;
}
ngOnInit(): void {
this.route.parent.data.subscribe((data: { sharesAccountData: any }) => {
const account = data.sharesAccountData;
this.accountNo = account.accountNo || '';
this.clientName = account.clientName || '';
this.productName = account.productName || '';
this.currency = account.currency || null;
this.status = account.status?.value || '';
this.activatedOnDate = account.timeline?.activatedDate || null;
this.resolutionNumber = account.resolutionNumber || '';
this.savingsAccountNumber = account.savingsAccountNumber || '';
this.totalApprovedShares = account.summary?.totalApprovedShares || 0;
this.currentMarketPrice = account.currentMarketPrice || 0;
const transactions = (account.purchasedShares || []).filter(
(transaction: any) => transaction.type.value !== 'Charge Payment'
);
this.allTransactions = this.recalculateRunningBalances(this.sortAscending(transactions));
this.filteredTransactions = [...this.allTransactions];
});
this.dateRangeForm = this.formBuilder.group({
fromDate: [
'',
Validators.required
],
toDate: [
'',
Validators.required
]
});
}
isRedeem(transaction: any): boolean {
return transaction.type?.value === 'Redeem';
}
filterTransactions(): void {
const fromDate = this.dateRangeForm.value.fromDate;
const toDate = this.dateRangeForm.value.toDate;
if (!fromDate || !toDate) return;
const from = new Date(fromDate).getTime();
const to = new Date(toDate).getTime();
this.filteredTransactions = this.allTransactions.filter((txn: any) => {
const txnDate = this.getTransactionDateAsTime(txn.purchasedDate);
return txnDate >= from && txnDate <= to;
});
this.isFiltered = true;
}
clearFilter(): void {
this.dateRangeForm.reset();
this.filteredTransactions = [...this.allTransactions];
this.isFiltered = false;
}
private getTransactionDateAsTime(date: any): number {
if (Array.isArray(date) && date.length >= 3) {
return new Date(date[0], date[1] - 1, date[2]).getTime();
}
return new Date(date).getTime();
}
printPassbook(): void {
const content = this.passbookContent?.nativeElement?.innerHTML;
if (!content) return;
const printWindow = window.open('', 'SharePassbook', 'height=700,width=900');
printWindow.document.write(`
<html>
<head>
<title>Share Account Passbook - ${this.accountNo}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', Arial, sans-serif; padding: 20px; color: #333; }
.passbook-header { text-align: center; margin-bottom: 20px; border-bottom: 2px solid #1a3a6b; padding-bottom: 12px; }
table { width: 100%; border-collapse: collapse; font-size: 11px; }
th { background: #1a3a6b; color: #fff; padding: 6px 8px; text-align: left; font-weight: 600; }
td { padding: 5px 8px; border-bottom: 1px solid #e0e0e0; }
tr:nth-child(even) { background: #fafafa; }
.text-right { text-align: right; }
</style>
</head>
<body>
${content}
</body>
</html>
`);
printWindow.document.close();
printWindow.focus();
printWindow.print();
printWindow.close();
}
private recalculateRunningBalances(transactions: any[]): any[] {
let balance = 0;
return transactions.map((txn) => {
const shares = Number(txn.numberOfShares) || 0;
balance += this.isRedeem(txn) ? -shares : shares;
return { ...txn, runningBalance: balance };
});
}
private sortAscending(transactions: any[]): any[] {
return [...transactions].sort((a, b) => {
const dateA = this.getTransactionDateAsTime(a.purchasedDate);
const dateB = this.getTransactionDateAsTime(b.purchasedDate);
if (dateA !== dateB) return dateA - dateB;
return (a.id as number) - (b.id as number);
});
}
}
@@ -124,6 +124,13 @@
</mat-card-header>
<mat-card-content class="content">
@if (sharesAccountData.status?.value === 'Active' && (sharesAccountData.summary?.totalApprovedShares ?? 0) === 0) {
<div class="closure-eligible-banner">
<fa-icon icon="info-circle"></fa-icon>
<span>All shares have been withdrawn from this account — it is now eligible for closure.</span>
<a class="closure-eligible-banner__link" [routerLink]="['/clerk/share-closure-request']"> Request Closure </a>
</div>
}
<nav mat-tab-nav-bar class="navigation-tabs" [tabPanel]="tabPanel">
<a
mat-tab-link
@@ -14,3 +14,33 @@
min-width: 60%;
font-weight: 400;
}
.closure-eligible-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
margin: 0 0 12px;
background: #eaf2ff;
border: 1px solid rgb(52 87 213 / 25%);
border-left: 3px solid #3457d5;
border-radius: 8px;
font-size: 13px;
color: #1b3a8a;
fa-icon {
color: #3457d5;
}
&__link {
margin-left: auto;
font-weight: 700;
color: #3457d5;
text-decoration: none;
white-space: nowrap;
&:hover {
text-decoration: underline;
}
}
}
@@ -102,11 +102,11 @@ export class SharesButtonsConfiguration {
{
name: 'Share Certificate',
taskPermissionName: 'READ_SHAREACCOUNTPURCHASE'
},
{
name: 'Close',
taskPermissionName: 'CLOSE_SHAREACCOUNT'
}
// 'Close' is intentionally not offered here — closing a share account must go through
// the Clerk Dashboard maker-checker flow (Clerk Dashboard > My Share Closures), never a
// direct one-step action from this view. The native close command also force-redeems any
// remaining shares, bypassing the governed Share Withdrawal approval flow entirely.
];
break;
case 'Submitted and pending approval':