Merge branch 'dev-latest' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev-latest

This commit is contained in:
2026-09-18 10:33:28 +05:30
34 changed files with 4533 additions and 2463 deletions
@@ -26,7 +26,6 @@ import { VaultPositionComponent } from './vault-position/vault-position.componen
import { AccountRegisterComponent } from './account-register/account-register.component'; import { AccountRegisterComponent } from './account-register/account-register.component';
import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component'; import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component';
import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component'; import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component';
import { ShareCollectionComponent } from './share-collection/share-collection.component';
const routes: Routes = [ const routes: Routes = [
Route.withShell([ Route.withShell([
@@ -345,11 +344,6 @@ const routes: Routes = [
component: ExcessTransactionReportComponent, component: ExcessTransactionReportComponent,
data: { title: 'Excess Transaction Report', breadcrumb: 'Excess Transaction Report' } data: { title: 'Excess Transaction Report', breadcrumb: 'Excess Transaction Report' }
}, },
{
path: 'share-collection',
component: ShareCollectionComponent,
data: { title: 'Share Collection', breadcrumb: 'Share Collection' }
},
{ {
path: 'transaction-entry', path: 'transaction-entry',
canActivate: [CashierOnlyGuard], canActivate: [CashierOnlyGuard],
+1 -3
View File
@@ -20,7 +20,6 @@ import { ReconciliationComponent } from './reconciliation/reconciliation.compone
import { CashHistoryComponent } from './cash-history/cash-history.component'; import { CashHistoryComponent } from './cash-history/cash-history.component';
import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component'; import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component';
import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component'; import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component';
import { ShareCollectionComponent } from './share-collection/share-collection.component';
@NgModule({ @NgModule({
imports: [ imports: [
@@ -35,8 +34,7 @@ import { ShareCollectionComponent } from './share-collection/share-collection.co
ReconciliationComponent, ReconciliationComponent,
CashHistoryComponent, CashHistoryComponent,
ClosingCashReportComponent, ClosingCashReportComponent,
ExcessTransactionReportComponent, ExcessTransactionReportComponent
ShareCollectionComponent
] ]
}) })
export class CashierModule {} export class CashierModule {}
+23 -13
View File
@@ -178,6 +178,13 @@ export class CashierService {
return this.http.get('/vouchers/my-work', { params }); return this.http.get('/vouchers/my-work', { params });
} }
/** One voucher and every debit/credit line it is made of — the full slip behind a summarised
* row (a "+2 more" badge in a register only ever shows the first side of each). */
voucherDetail(voucherNo: string): Observable<any> {
const params = new HttpParams().set('voucherNo', voucherNo);
return this.http.get('/vouchers/detail', { params });
}
/** /**
* Account register for one date: what opened and closed that day, each account's balance as it * Account register for one date: what opened and closed that day, each account's balance as it
* stood then, and what it holds now. * stood then, and what it holds now.
@@ -357,19 +364,6 @@ export class CashierService {
return this.http.get<any[]>('/paymenttypes'); return this.http.get<any[]>('/paymenttypes');
} }
// ── Share Collection ──
getShareAccountForCollection(accountId: string): Observable<any> {
return this.http.get(`/accounts/share/${accountId}`);
}
collectSharePayment(data: any): Observable<any> {
return this.http.post('/cashier/sharecollection', data);
}
getShareCollectionStatus(shareAccountId: number): Observable<any> {
return this.http.get(`/cashier/sharecollection/${shareAccountId}`);
}
/** One unapplied Share Sundry receipt, found by the voucher number the member quotes. */ /** One unapplied Share Sundry receipt, found by the voucher number the member quotes. */
getShareAdvanceByVoucher(voucherNo: string, glAccountNo: string, clientId?: number): Observable<any> { getShareAdvanceByVoucher(voucherNo: string, glAccountNo: string, clientId?: number): Observable<any> {
// The voucher travels as a query parameter, not a path segment: numbers are formatted // The voucher travels as a query parameter, not a path segment: numbers are formatted
@@ -407,6 +401,12 @@ export class CashierService {
return this.http.post(`/shareadvancecollection/${collectionId}/apply`, data); return this.http.post(`/shareadvancecollection/${collectionId}/apply`, data);
} }
/** The receipt's own real Dr/Cr GL legs — never the GL account currently picked in a search box,
* which is unrelated screen state that can name a different account than this receipt sits in. */
getShareAdvanceGlEntries(collectionId: number): Observable<any> {
return this.http.get(`/shareadvancecollection/${collectionId}/gl-entries`);
}
/** Every additional-share tranche awaiting approval, across every share account. */ /** Every additional-share tranche awaiting approval, across every share account. */
getShareAdditionRequests(): Observable<any> { getShareAdditionRequests(): Observable<any> {
return this.http.get('/shareadditionrequests'); return this.http.get('/shareadditionrequests');
@@ -419,4 +419,14 @@ export class CashierService {
getShareAdditionRequest(transactionId: number): Observable<any> { getShareAdditionRequest(transactionId: number): Observable<any> {
return this.http.get(`/shareadditionrequests/${transactionId}`); return this.http.get(`/shareadditionrequests/${transactionId}`);
} }
/**
* Entry Type options for a GL credit (General / Share Collection / DD Issue / …) — the
* "GLEntryType" code under Admin > System > Manage Codes, so an admin can add another type later
* without a deployment. Empty on failure rather than erroring, since a row missing this dropdown's
* options should still be usable for every other field.
*/
getEntryTypeOptions(): Observable<any[]> {
return this.http.get<any[]>('/codes/name/GLEntryType/codevalues').pipe(catchError(() => of([])));
}
} }
@@ -174,19 +174,24 @@ export class ClosingCashReportComponent implements OnInit {
return this.noteRows.reduce((s, r) => s + Number(r.settledOutAmount ?? 0), 0); return this.noteRows.reduce((s, r) => s + Number(r.settledOutAmount ?? 0), 0);
} }
/**
* Summed from each row's own `netCount`, not re-derived from today's legs — a row's `netCount`
* already carries forward what a cashier was holding from before today (`openingCount`), for the
* force-closed-day/late-settlement case. Recomputing here from only today's vaultIn/deposit/
* withdrawal/settledOut dropped that carry-forward and put the Total row back into negative
* numbers a cashier had already reconciled.
*/
get noteNetCount(): number { get noteNetCount(): number {
return this.noteVaultInCount + this.noteDepositCount - this.noteWithdrawalCount - this.noteSettledOutCount; return this.noteRows.reduce((s, r) => s + Number(r.netCount ?? 0), 0);
} }
/** /**
* What is left in the drawer in notes: everything that came in, less everything that went out. * What is left in the drawer in notes: everything that came in, less everything that went out —
* * including cash carried forward from before today. See `noteNetCount` for why this sums each
* Was deposits minus withdrawals, which is the counter's movement rather than the drawer's * row's own `netAmount` rather than re-deriving it from today's legs.
* contents — it left out the cash drawn from the vault, so a teller holding a ₹500 note saw a net
* of zero and had nothing to count against.
*/ */
get noteNetAmount(): number { get noteNetAmount(): number {
return this.noteVaultInAmount + this.noteDepositAmount - this.noteWithdrawalAmount - this.noteSettledOutAmount; return this.noteRows.reduce((s, r) => s + Number(r.netAmount ?? 0), 0);
} }
get coinRows(): any[] { get coinRows(): any[] {
return this.rows.filter((r) => r.kind === 'coin'); return this.rows.filter((r) => r.kind === 'coin');
+16 -4
View File
@@ -152,9 +152,21 @@
<tbody> <tbody>
@for (r of g.rows; track r.voucherNo) { @for (r of g.rows; track r.voucherNo) {
<!-- The left edge repeats the section's own colour, so a row still says which part it <!-- The left edge repeats the section's own colour, so a row still says which part it
is when the heading has scrolled away. --> is when the heading has scrolled away. Clicking anywhere on the row opens the full
<tr [class]="'mw-row mw-row--' + r.myRole"> slip -- every line, not just the first debit and first credit this table has room
<td class="mw-vno">{{ r.voucherNo }}</td> to show. -->
<tr
[class]="'mw-row mw-row--' + r.myRole"
class="mw-row--clickable"
(click)="openDetail(r.voucherNo)"
[attr.aria-busy]="loadingDetailFor === r.voucherNo"
>
<td class="mw-vno">
{{ r.voucherNo }}
@if (loadingDetailFor === r.voucherNo) {
<mat-icon class="mw-vno__spinner" inline>hourglass_empty</mat-icon>
}
</td>
<td> <td>
<span class="mw-badge" [class]="'mw-badge--' + (r.side || 'JV')">{{ sideLabel(r.side) }}</span> <span class="mw-badge" [class]="'mw-badge--' + (r.side || 'JV')">{{ sideLabel(r.side) }}</span>
</td> </td>
@@ -171,7 +183,7 @@
<span class="mw-acct">{{ r.creditAccount }}</span> <span class="mw-acct">{{ r.creditAccount }}</span>
} }
@if (r.extraLines > 0) { @if (r.extraLines > 0) {
<span class="mw-more">+{{ r.extraLines }} more</span> <span class="mw-more" title="Click the row to see every line">+{{ r.extraLines }} more</span>
} }
</td> </td>
<td class="num">₹{{ r.amount | number: '1.2-2' }}</td> <td class="num">₹{{ r.amount | number: '1.2-2' }}</td>
@@ -178,6 +178,21 @@
} }
} }
.mw-row--clickable {
cursor: pointer;
&:hover {
background: #f8fafc;
}
}
.mw-vno__spinner {
margin-left: 4px;
font-size: 14px;
vertical-align: middle;
color: #94a3b8;
}
/* One section per part played. The heading carries the section's own count and total, so a maker's /* One section per part played. The heading carries the section's own count and total, so a maker's
tally and a checker's are each readable without adding rows up. */ tally and a checker's are each readable without adding rows up. */
.mw-group { .mw-group {
+35 -2
View File
@@ -10,8 +10,10 @@ import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs'; import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { catchError, finalize, of } from 'rxjs';
import { CashierService } from '../cashier.service'; import { CashierService } from '../cashier.service';
import { VoucherDetailDialogComponent } from './voucher-detail-dialog.component';
/** What the signed-in user's part in a voucher was. */ /** What the signed-in user's part in a voucher was. */
export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED'; export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED';
@@ -72,11 +74,17 @@ interface MyWorkReport {
imports: [ imports: [
CommonModule, CommonModule,
FormsModule, FormsModule,
MatIconModule MatIconModule,
MatDialogModule
] ]
}) })
export class MyWorkComponent implements OnInit { export class MyWorkComponent implements OnInit {
private service = inject(CashierService); private service = inject(CashierService);
private dialog = inject(MatDialog);
/** Which voucher's detail is currently being fetched, so the row that was clicked can show its
* own spinner instead of a page-wide one for what is a small, single-row lookup. */
loadingDetailFor: string | null = null;
/** Day is the working default; a month is what a close or an audit asks for. */ /** Day is the working default; a month is what a close or an audit asks for. */
period: 'DAY' | 'MONTH' = 'DAY'; period: 'DAY' | 'MONTH' = 'DAY';
@@ -323,6 +331,31 @@ export class MyWorkComponent implements OnInit {
return { from: first, to: lastIso }; return { from: first, to: lastIso };
} }
/** Opens the full slip for one voucher — every debit and credit line, not just the first side of
* each a table row has room to show. */
openDetail(voucherNo: string): void {
if (this.loadingDetailFor) {
return;
}
this.loadingDetailFor = voucherNo;
this.service
.voucherDetail(voucherNo)
.pipe(
catchError(() => of(null)),
finalize(() => (this.loadingDetailFor = null))
)
.subscribe((detail: any) => {
if (!detail) {
return;
}
this.dialog.open(VoucherDetailDialogComponent, {
data: { detail },
width: '600px',
autoFocus: false
});
});
}
private todayIso(): string { private todayIso(): string {
const d = new Date(); const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
@@ -0,0 +1,320 @@
/**
* 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 { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
export interface VoucherDetailDialogData {
/** Already-loaded detail from CashierService.voucherDetail() — the caller fetches, this dialog
* only renders, so one loading state lives in the row's own click handler rather than being
* duplicated inside every dialog that could ever show a voucher. */
detail: {
voucherNo: string;
voucherType: string | null;
amount: number | null;
branchName: string | null;
createdBy: string | null;
createdAt: string | null;
totalDebit: number;
totalCredit: number;
balanced: boolean;
lines: {
lineNo: number;
entryType: 'DR' | 'CR';
partyName: string | null;
accountNo: string | null;
amount: number;
description: string | null;
}[];
/** GL entries a native platform command posted for the same underlying event, outside this
* voucher entirely — a share redemption's Dr Share Capital / Cr Share Sundry pair, posted at
* manager approval, before this voucher's own Dr Sundry / Cr Cash at Vault settlement leg even
* exists. Empty for every voucher that already is the whole transaction on its own. */
relatedLines: {
stage: string;
entryType: 'DR' | 'CR';
partyName: string | null;
accountNo: string | null;
amount: number;
description: string | null;
}[];
};
}
/** The full slip behind a summarised register row — every debit and credit line a voucher is
* made of, not just the first side of each that a table row has room for. */
@Component({
selector: 'mifosx-voucher-detail-dialog',
standalone: true,
imports: [
CommonModule,
MatDialogModule,
MatButtonModule,
MatIconModule
],
template: `
<div class="vd-shell">
<div class="vd-header">
<div class="vd-header__title">
<mat-icon class="vd-icon">receipt_long</mat-icon>
<div>
<h2>{{ detail.voucherNo }}</h2>
<p>{{ detail.voucherType || 'Voucher' }} &middot; {{ detail.branchName || 'Head Office' }}</p>
</div>
</div>
<button mat-icon-button mat-dialog-close aria-label="Close">
<mat-icon>close</mat-icon>
</button>
</div>
<div class="vd-meta">
<span
>Raised by <strong>{{ detail.createdBy || '—' }}</strong></span
>
@if (detail.createdAt) {
<span>{{ detail.createdAt | date: 'dd MMM yyyy, h:mm a' }}</span>
}
@if (!detail.balanced) {
<span class="vd-unbalanced">
<mat-icon inline>warning</mat-icon>
Debit and credit do not balance
</span>
}
</div>
<table class="vd-table">
<thead>
<tr>
<th>Side</th>
<th>Party</th>
<th>Account No.</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (line of detail.lines; track line.lineNo) {
<tr>
<td>
<span
class="vd-side"
[class.vd-side--dr]="line.entryType === 'DR'"
[class.vd-side--cr]="line.entryType === 'CR'"
>
{{ line.entryType === 'DR' ? 'Debit' : 'Credit' }}
</span>
</td>
<td>{{ line.partyName || '—' }}</td>
<td class="vd-acct">{{ line.accountNo || '—' }}</td>
<td class="num">₹{{ line.amount | number: '1.2-2' }}</td>
</tr>
@if (line.description) {
<tr class="vd-desc-row">
<td></td>
<td colspan="3" class="vd-desc">{{ line.description }}</td>
</tr>
}
} @empty {
<tr>
<td colspan="4" class="vd-empty">This voucher's individual lines were not recorded.</td>
</tr>
}
</tbody>
@if (detail.lines.length > 0) {
<tfoot>
<tr>
<td colspan="3">Total</td>
<td class="num">
₹{{ detail.totalDebit | number: '1.2-2' }} / ₹{{ detail.totalCredit | number: '1.2-2' }}
</td>
</tr>
</tfoot>
}
</table>
@if (detail.relatedLines.length > 0) {
<div class="vd-related">
<div class="vd-related__title">
<mat-icon inline>link</mat-icon>
Also posted for this transaction — not part of this voucher
</div>
<table class="vd-table vd-table--related">
<tbody>
@for (line of detail.relatedLines; track $index) {
<tr>
<td>
<span
class="vd-side"
[class.vd-side--dr]="line.entryType === 'DR'"
[class.vd-side--cr]="line.entryType === 'CR'"
>
{{ line.entryType === 'DR' ? 'Debit' : 'Credit' }}
</span>
</td>
<td>{{ line.partyName || '—' }}</td>
<td class="vd-acct">{{ line.accountNo || '—' }}</td>
<td class="num">₹{{ line.amount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
<p class="vd-related__hint">
{{ detail.relatedLines[0].stage }} — this and the lines above are the same transaction's full accounting.
</p>
</div>
}
<div class="vd-actions">
<button mat-flat-button color="primary" mat-dialog-close>Close</button>
</div>
</div>
`,
styles: [
`
.vd-shell {
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px 24px 16px;
min-width: 420px;
max-width: 560px;
}
.vd-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.vd-header__title {
display: flex;
align-items: center;
gap: 12px;
}
.vd-header__title h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.vd-header__title p {
margin: 2px 0 0;
font-size: 13px;
color: rgba(0, 0, 0, 0.6);
}
.vd-icon {
color: #1976d2;
}
.vd-meta {
display: flex;
flex-wrap: wrap;
gap: 6px 16px;
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
}
.vd-unbalanced {
display: inline-flex;
align-items: center;
gap: 4px;
color: #b91c1c;
font-weight: 600;
}
.vd-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.vd-table th {
text-align: left;
text-transform: uppercase;
font-size: 11px;
letter-spacing: 0.03em;
color: rgba(0, 0, 0, 0.5);
padding: 6px 8px;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
.vd-table td {
padding: 8px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.vd-table .num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.vd-acct {
color: rgba(0, 0, 0, 0.55);
}
.vd-side {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
}
.vd-side--dr {
background: #fee2e2;
color: #b91c1c;
}
.vd-side--cr {
background: #dcfce7;
color: #15803d;
}
.vd-desc-row td {
padding-top: 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.vd-desc {
font-size: 12px;
color: rgba(0, 0, 0, 0.5);
font-style: italic;
}
.vd-empty {
text-align: center;
color: rgba(0, 0, 0, 0.5);
padding: 16px 8px;
}
.vd-table tfoot td {
font-weight: 600;
border-bottom: none;
border-top: 1px solid rgba(0, 0, 0, 0.1);
}
.vd-related {
border-top: 1px dashed rgba(0, 0, 0, 0.15);
padding-top: 12px;
}
.vd-related__title {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #6d28d9;
margin-bottom: 8px;
}
.vd-table--related td {
background: #faf5ff;
}
.vd-related__hint {
margin: 6px 0 0;
font-size: 12px;
color: rgba(0, 0, 0, 0.5);
}
.vd-actions {
display: flex;
justify-content: flex-end;
}
`
]
})
export class VoucherDetailDialogComponent {
dialogRef = inject(MatDialogRef<VoucherDetailDialogComponent>);
private data = inject<VoucherDetailDialogData>(MAT_DIALOG_DATA);
detail = this.data.detail;
}
@@ -1,375 +0,0 @@
<!--
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="pg">
<!-- ── HEADER ── -->
<header class="pg-head">
<div>
<div class="pg-eyebrow">CASHIER · SHARE COLLECTION</div>
<h1 class="pg-title">Share Collection</h1>
<p class="pg-sub">
Collect cash from member for share purchase — amounts are held in Sundry GL until the account is activated.
</p>
</div>
</header>
<!-- ── SEARCH BAR ── -->
@if (pageState !== 'success') {
<div class="sc-search-bar">
<div class="sc-search-bar__input-wrap">
<mat-icon class="sc-search-bar__icon">search</mat-icon>
<input
class="sc-search-bar__input"
type="text"
placeholder="Enter Share Account ID or Account No."
[(ngModel)]="accountInput"
(keyup.enter)="searchAccount()"
/>
</div>
<button
type="button"
class="pg-btn pg-btn--primary"
[disabled]="pageState === 'searching' || !accountInput.trim()"
(click)="searchAccount()"
>
<mat-icon>manage_search</mat-icon>
{{ pageState === 'searching' ? 'Searching…' : 'Search' }}
</button>
</div>
}
<!-- ── ERROR ── -->
@if (errorMsg) {
<div class="sc-alert sc-alert--error">
<mat-icon>error_outline</mat-icon>
<span>{{ errorMsg }}</span>
</div>
}
<!-- ── SEARCHING SPINNER ── -->
@if (pageState === 'searching') {
<div class="pg-empty">
<mat-icon class="sc-spin">hourglass_empty</mat-icon>
<p>Looking up account…</p>
</div>
}
<!-- ── ACCOUNT LOADED ── -->
@if (pageState === 'loaded') {
<!-- Member Card -->
<div class="sc-member-card">
<div class="sc-member-card__avatar">
<mat-icon>person</mat-icon>
</div>
<div class="sc-member-card__info">
<div class="sc-member-card__name">{{ account.clientName }}</div>
<div class="sc-member-card__meta">
<span class="sc-badge sc-badge--account">{{ account.accountNo }}</span>
<span class="sc-badge sc-badge--product">{{ account.productName }}</span>
<span
class="sc-badge"
[class.sc-badge--pending]="account.status?.code?.includes('pending')"
[class.sc-badge--active]="account.status?.code?.includes('active')"
>{{ account.status?.value }}</span
>
</div>
</div>
<!-- Editable Shares + Price -->
<div class="sc-member-card__shares">
<div class="sc-shares-inputs">
<div class="sc-shares-field">
<label class="sc-shares-label">No. of Shares</label>
<input
class="sc-shares-input"
type="number"
min="0"
[(ngModel)]="requestedSharesInput"
(input)="recalc()"
placeholder="0"
/>
</div>
<div class="sc-shares-sep">×</div>
<div class="sc-shares-field">
<label class="sc-shares-label">Price / Share (₹)</label>
<input
class="sc-shares-input"
type="number"
min="0"
[(ngModel)]="pricePerShare"
(input)="recalc()"
placeholder="0.00"
/>
</div>
<div class="sc-shares-sep">=</div>
<div class="sc-shares-result">
<div class="sc-shares-label">Share Amount</div>
<div class="sc-shares-total">{{ currencySymbol }}{{ shareAmount | number: '1.2-2' }}</div>
</div>
</div>
</div>
</div>
<!-- Amount Breakdown + GL Preview -->
<div class="sc-split">
<!-- LEFT: Amount Breakdown -->
<div class="pg-card sc-breakdown">
<div class="sc-breakdown__header">
<mat-icon>receipt_long</mat-icon>
<span>Amount Breakdown</span>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Description</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
<tr>
<td>
Share Amount
<span class="sc-dim">
({{ requestedSharesInput }} shares × {{ currencySymbol }}{{ pricePerShare | number: '1.2-2' }})
</span>
</td>
<td class="num">{{ currencySymbol }}{{ shareAmount | number: '1.2-2' }}</td>
</tr>
@for (charge of charges; track charge.id) {
<tr>
<td>{{ charge.name }}</td>
<td class="num sc-charge-amount">
<input class="sc-charge-input" type="number" min="0" [(ngModel)]="charge.amount" (input)="recalc()" />
</td>
</tr>
}
@if (charges.length === 0) {
<tr>
<td colspan="2">
<span class="sc-dim">No charges applicable</span>
</td>
</tr>
}
</tbody>
<tfoot>
<tr class="sc-total-row">
<td><strong>Total Payable</strong></td>
<td class="num sc-total-amount">
<strong>{{ currencySymbol }}{{ totalAmount | number: '1.2-2' }}</strong>
</td>
</tr>
</tfoot>
</table>
</div>
<!-- RIGHT: GL Preview -->
<div class="pg-card sc-gl-preview">
<div class="sc-breakdown__header">
<mat-icon>account_tree</mat-icon>
<span>GL Entries Preview</span>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Account</th>
<th>Dr / Cr</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
<tr class="gl-row--dr">
<td class="gl-acname">Cash Vault GL</td>
<td><span class="sc-dr-badge">Dr</span></td>
<td class="num gl-amt--dr">{{ currencySymbol }}{{ totalAmount | number: '1.2-2' }}</td>
</tr>
<tr class="gl-row--cr">
<td class="gl-acname">Share Sundry GL</td>
<td><span class="sc-cr-badge">Cr</span></td>
<td class="num gl-amt--cr">{{ currencySymbol }}{{ shareAmount | number: '1.2-2' }}</td>
</tr>
@if (totalChargeAmount > 0) {
<tr class="gl-row--cr">
<td class="gl-acname">Charge Receivable GL</td>
<td><span class="sc-cr-badge">Cr</span></td>
<td class="num gl-amt--cr">{{ currencySymbol }}{{ totalChargeAmount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
<div class="sc-gl-note">
<mat-icon>info_outline</mat-icon>
Funds move to Share GL &amp; Income GL when account is <strong>activated</strong>.
</div>
</div>
</div>
<!-- ── DENOMINATION (Cash Only) ── -->
@if (isCashPayment && allDenominations.length > 0) {
<div class="pg-card sc-denom">
<div class="sc-breakdown__header">
<mat-icon>payments</mat-icon>
<span>Denomination Entry</span>
@if (denominationTotal > 0) {
<span
class="sc-denom-badge"
[class.sc-denom-badge--match]="denomMatchesTotal"
[class.sc-denom-badge--mismatch]="!denomMatchesTotal"
>
₹{{ denominationTotal | number: '1.2-2' }}
{{ denomMatchesTotal ? '✓ Matches' : '≠ Mismatch' }}
</span>
}
@if (denominationTotal === 0) {
<span class="sc-denom-required">⚠ Required for Cash</span>
}
</div>
<div class="sc-denom-body">
<table class="pg-table sc-denom-table">
<thead>
<tr>
<th>Denomination</th>
<th class="num">Count</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (d of allDenominations; track d.id) {
<tr>
<td>
<span class="sc-denom-chip" [class.sc-denom-chip--coin]="d.kind === 'coin'">{{ d.label }}</span>
</td>
<td class="num">
<div class="sc-denom-stepper">
<button type="button" (click)="bumpDenom(d, -1)" [disabled]="countFor(d) === 0">−</button>
<input
type="number"
min="0"
[value]="countFor(d) || ''"
placeholder="0"
(input)="onDenomChange(d, $any($event.target).value)"
/>
<button type="button" (click)="bumpDenom(d, 1)">+</button>
</div>
</td>
<td class="num">{{ subtotalFor(d) > 0 ? (subtotalFor(d) | number: '1.2-2') : '—' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td><strong>Total</strong></td>
<td></td>
<td class="num">
<strong>₹{{ denominationTotal | number: '1.2-2' }}</strong>
</td>
</tr>
</tfoot>
</table>
@if (!denomMatchesTotal && denominationTotal > 0) {
<div class="sc-denom-mismatch">
<mat-icon>warning</mat-icon>
Denomination ₹{{ denominationTotal | number: '1.2-2' }} must equal Total Payable ₹{{
totalAmount | number: '1.2-2'
}}.
</div>
}
@if (denominationTotal > 0) {
<div class="sc-denom-actions">
<button type="button" class="pg-btn pg-btn--ghost" (click)="clearDenominations()">
<mat-icon>clear</mat-icon> Clear
</button>
</div>
}
</div>
</div>
}
<!-- Transaction Details -->
<div class="pg-toolbar pg-toolbar--mat">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Transaction Date *</mat-label>
<input matInput type="date" [(ngModel)]="transactionDate" />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Payment Mode</mat-label>
<mat-select [(ngModel)]="paymentTypeId">
<mat-option [value]="null">— Cash (Default) —</mat-option>
@for (pt of paymentTypes; track pt.id) {
<mat-option [value]="pt.id">{{ pt.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="pg-note-field">
<mat-label>Note (Optional)</mat-label>
<input matInput type="text" [(ngModel)]="note" placeholder="e.g. Share purchase for 100 shares" />
</mat-form-field>
</div>
<!-- Footer Submit Bar -->
<div class="pg-total-bar sc-footer">
<div class="sc-footer__summary">
<span class="sc-footer__label">Total to Collect</span>
<span class="sc-footer__amount">{{ currencySymbol }}{{ totalAmount | number: '1.2-2' }}</span>
</div>
<div class="sc-footer__actions">
<button type="button" class="pg-btn pg-btn--ghost" (click)="reset()"><mat-icon>close</mat-icon> Cancel</button>
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!isValid || saving" (click)="submit()">
<mat-icon>payments</mat-icon>
{{ saving ? 'Posting…' : 'Collect & Post' }}
</button>
</div>
</div>
}
<!-- ── SUCCESS ── -->
@if (pageState === 'success' && collectionResult) {
<div class="sc-success">
<div class="sc-success__icon-wrap">
<mat-icon>check_circle</mat-icon>
</div>
<h2 class="sc-success__title">Collection Posted Successfully</h2>
<p class="sc-success__sub">
Cash of
<strong>{{ collectionResult.currencyCode }} {{ collectionResult.totalAmount | number: '1.2-2' }}</strong>
collected from <strong>{{ collectionResult.clientName }}</strong> on
<strong>{{ collectionResult.collectionDate }}</strong> by <strong>{{ collectionResult.collectedBy }}</strong
>.
</p>
<!-- Transaction reference -->
<div class="sc-success__ref">
<span class="sc-success__ref-label">Transaction ID</span>
<span class="sc-success__ref-val">{{ collectionResult.transactionId }}</span>
</div>
<!-- GL entries from backend -->
<div class="sc-success__gl">
@for (entry of collectionResult.glEntries; track entry.account) {
<div
class="sc-success__gl-row"
[class.sc-success__gl-row--dr]="entry.type === 'Dr'"
[class.sc-success__gl-row--cr]="entry.type === 'Cr'"
>
<span>{{ entry.type }}. {{ entry.account }}</span>
<span>{{ entry.currency }} {{ entry.amount | number: '1.2-2' }}</span>
</div>
}
</div>
<p class="sc-success__note">
Funds are held in Sundry GL. They will move to Share Equity GL when the account is activated.
</p>
<button type="button" class="pg-btn pg-btn--primary" (click)="reset()">
<mat-icon>add</mat-icon> New Collection
</button>
</div>
}
</div>
@@ -1,701 +0,0 @@
/**
* 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/.
*/
// ── Search Bar ───────────────────────────────────────────────────────────────
.sc-search-bar {
display: flex;
align-items: center;
gap: 10px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 10px 14px;
&__input-wrap {
display: flex;
align-items: center;
gap: 8px;
flex: 1 1 auto;
}
&__icon {
color: #94a3b8;
font-size: 20px;
width: 20px;
height: 20px;
flex-shrink: 0;
}
&__input {
flex: 1 1 auto;
border: none;
outline: none;
font-size: 14px;
font-family: inherit;
color: #0d1b2a;
background: transparent;
&::placeholder {
color: #94a3b8;
}
}
}
// ── Alert ────────────────────────────────────────────────────────────────────
.sc-alert {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-radius: 10px;
font-size: 13px;
font-weight: 500;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
flex-shrink: 0;
}
&--error {
background: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
}
}
// ── Spinner ──────────────────────────────────────────────────────────────────
.sc-spin {
animation: sc-spin 1.2s linear infinite;
display: block;
margin: 0 auto 8px;
font-size: 32px !important;
width: 32px !important;
height: 32px !important;
color: #94a3b8;
}
@keyframes sc-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
// ── Member Card ──────────────────────────────────────────────────────────────
.sc-member-card {
display: flex;
align-items: center;
gap: 16px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 16px 20px;
&__avatar {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 50%;
background: #eff6ff;
flex-shrink: 0;
mat-icon {
color: #2563eb;
font-size: 24px;
width: 24px;
height: 24px;
}
}
&__info {
flex: 1 1 auto;
min-width: 0;
}
&__name {
font-size: 16px;
font-weight: 700;
color: #1e293b;
margin-bottom: 6px;
}
&__meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
&__shares {
flex-shrink: 0;
display: flex;
align-items: center;
}
}
// ── Badges ───────────────────────────────────────────────────────────────────
.sc-badge {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 8px;
border-radius: 5px;
font-size: 11px;
font-weight: 700;
background: #f1f5f9;
color: #475569;
border: 1px solid #e2e8f0;
&--account {
background: #eff6ff;
color: #1d4ed8;
border-color: #bfdbfe;
font-family: 'Roboto Mono', monospace;
}
&--product {
background: #f0fdf4;
color: #15803d;
border-color: #bbf7d0;
}
&--pending {
background: #fffbeb;
color: #d97706;
border-color: #fde68a;
}
&--active {
background: #f0fdf4;
color: #15803d;
border-color: #bbf7d0;
}
}
// ── Editable Share Inputs ────────────────────────────────────────────────────
.sc-shares-inputs {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.sc-shares-field {
display: flex;
flex-direction: column;
gap: 3px;
}
.sc-shares-label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #94a3b8;
}
.sc-shares-input {
width: 110px;
height: 38px;
border: 1.5px solid #e2e8f0;
border-radius: 8px;
padding: 0 10px;
font-size: 14px;
font-weight: 700;
font-family: inherit;
color: #1353a0;
text-align: right;
outline: none;
background: #f8fafc;
transition: border-color 0.15s;
&:focus {
border-color: #1353a0;
background: #fff;
}
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
appearance: none;
}
}
.sc-shares-sep {
font-size: 20px;
font-weight: 300;
color: #94a3b8;
padding-top: 16px;
}
.sc-shares-result {
text-align: right;
}
.sc-shares-total {
font-size: 20px;
font-weight: 800;
color: #0f4f3c;
font-variant-numeric: tabular-nums;
}
// ── Editable Charge Amount ───────────────────────────────────────────────────
.sc-charge-amount {
padding: 4px 14px !important;
}
.sc-charge-input {
width: 100px;
height: 32px;
border: 1.5px solid #e2e8f0;
border-radius: 6px;
padding: 0 8px;
font-size: 13px;
font-weight: 700;
font-family: inherit;
color: #1353a0;
text-align: right;
outline: none;
background: #f8fafc;
transition: border-color 0.15s;
&:focus {
border-color: #1353a0;
background: #fff;
}
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
appearance: none;
}
}
// ── Split Layout ─────────────────────────────────────────────────────────────
.sc-split {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
@media (width <= 900px) {
grid-template-columns: 1fr;
}
}
// ── Breakdown Card ───────────────────────────────────────────────────────────
.sc-breakdown {
&__header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
font-size: 13px;
font-weight: 700;
color: #334155;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: #0f4f3c;
}
}
}
.sc-gl-preview {
.sc-breakdown__header {
mat-icon {
color: #1d4ed8;
}
}
}
.sc-gl-note {
display: flex;
align-items: flex-start;
gap: 6px;
padding: 10px 14px;
font-size: 12px;
color: #64748b;
background: #f8fafc;
border-top: 1px solid #e2e8f0;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
flex-shrink: 0;
margin-top: 1px;
color: #94a3b8;
}
}
// ── Table helpers ────────────────────────────────────────────────────────────
.sc-dim {
color: #94a3b8;
font-size: 12px;
}
.sc-total-row td {
background: #f8fafc;
border-top: 2px solid #e2e8f0;
font-size: 14px;
}
.sc-total-amount {
color: #0f4f3c;
}
// ── Dr / Cr badges ───────────────────────────────────────────────────────────
.sc-dr-badge,
.sc-cr-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 20px;
border-radius: 4px;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.04em;
}
.sc-dr-badge {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}
.sc-cr-badge {
background: #f0fdf4;
color: #15803d;
border: 1px solid #bbf7d0;
}
// ── Footer ───────────────────────────────────────────────────────────────────
.sc-footer {
justify-content: space-between !important;
&__summary {
display: flex;
flex-direction: column;
gap: 2px;
}
&__label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #64748b;
}
&__amount {
font-size: 22px;
font-weight: 800;
color: #0f4f3c;
font-variant-numeric: tabular-nums;
}
&__actions {
display: flex;
align-items: center;
gap: 10px;
}
}
// ── Denomination Panel ────────────────────────────────────────────────────────
.sc-denom {
padding: 0;
overflow: hidden;
}
.sc-denom-body {
padding: 14px 16px;
}
.sc-denom-table {
width: 100%;
th,
td {
padding: 7px 10px;
font-size: 13px;
}
tfoot td {
border-top: 2px solid #e2e8f0;
background: #f8fafc;
font-size: 14px;
}
}
.sc-denom-chip {
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 10px;
border-radius: 5px;
font-size: 12px;
font-weight: 700;
background: #eff6ff;
color: #1d4ed8;
border: 1px solid #bfdbfe;
&--coin {
background: #fffbeb;
color: #d97706;
border-color: #fde68a;
}
}
.sc-denom-stepper {
display: flex;
align-items: center;
gap: 4px;
justify-content: flex-end;
button {
width: 26px;
height: 26px;
border: 1.5px solid #e2e8f0;
border-radius: 5px;
background: #f8fafc;
color: #475569;
font-size: 16px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
&:hover:not(:disabled) {
background: #e0f2fe;
border-color: #7dd3fc;
color: #0369a1;
}
&:disabled {
opacity: 0.35;
cursor: not-allowed;
}
}
input {
width: 62px;
height: 28px;
border: 1.5px solid #e2e8f0;
border-radius: 6px;
padding: 0 6px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
text-align: right;
outline: none;
background: #fff;
&:focus {
border-color: #1353a0;
}
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
appearance: none;
}
}
}
.sc-denom-badge {
margin-left: auto;
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 10px;
border-radius: 5px;
font-size: 12px;
font-weight: 700;
&--match {
background: #f0fdf4;
color: #15803d;
border: 1px solid #bbf7d0;
}
&--mismatch {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}
}
.sc-denom-required {
margin-left: auto;
font-size: 12px;
font-weight: 700;
color: #d97706;
}
.sc-denom-mismatch {
display: flex;
align-items: center;
gap: 6px;
margin-top: 10px;
padding: 9px 12px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
color: #dc2626;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
flex-shrink: 0;
}
}
.sc-denom-actions {
display: flex;
justify-content: flex-end;
margin-top: 10px;
}
// ── Success ──────────────────────────────────────────────────────────────────
.sc-success {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 40px 24px;
gap: 16px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 16px;
&__icon-wrap {
width: 72px;
height: 72px;
border-radius: 50%;
background: #f0fdf4;
display: flex;
align-items: center;
justify-content: center;
mat-icon {
font-size: 40px;
width: 40px;
height: 40px;
color: #16a34a;
}
}
&__title {
font-size: 22px;
font-weight: 700;
color: #1e293b;
margin: 0;
}
&__sub {
font-size: 14px;
color: #64748b;
max-width: 480px;
margin: 0;
}
&__ref {
display: flex;
align-items: center;
gap: 10px;
background: #f1f5f9;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 8px 16px;
}
&__ref-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #94a3b8;
white-space: nowrap;
}
&__ref-val {
font-family: 'Roboto Mono', 'Courier New', monospace;
font-size: 13px;
font-weight: 600;
color: #1353a0;
}
&__note {
font-size: 12px;
color: #94a3b8;
max-width: 420px;
margin: 0;
text-align: center;
}
&__gl {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
max-width: 400px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 14px 16px;
}
&__gl-row {
display: flex;
justify-content: space-between;
font-size: 13px;
font-weight: 600;
padding: 4px 0;
border-bottom: 1px dotted #e2e8f0;
&:last-child {
border-bottom: none;
}
&--dr {
color: #dc2626;
}
&--cr {
color: #15803d;
}
}
}
@@ -1,282 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { MatInputModule } from '@angular/material/input';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { CashierService } from '../cashier.service';
type PageState = 'idle' | 'searching' | 'loaded' | 'submitting' | 'success';
interface Denomination {
id: number;
value: number;
kind: 'note' | 'coin';
label: string;
}
interface ShareCharge {
id: number;
name: string;
amount: number;
currencySymbol: string;
}
@Component({
selector: 'mifosx-share-collection',
templateUrl: './share-collection.component.html',
styleUrls: [
'../cashier-shared.scss',
'./share-collection.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule,
MatIconModule,
MatSnackBarModule,
MatFormFieldModule,
MatSelectModule,
MatInputModule
]
})
export class ShareCollectionComponent implements OnInit {
private service = inject(CashierService);
private snackBar = inject(MatSnackBar);
pageState: PageState = 'idle';
accountInput = '';
transactionDate = '';
paymentTypeId: number | null = null;
paymentTypes: any[] = [];
note = '';
errorMsg = '';
saving = false;
account: any = null;
charges: ShareCharge[] = [];
collectionResult: any = null; // filled after successful POST
// Editable fields — cashier can override if API returns 0
requestedSharesInput = 0;
pricePerShare = 0;
// ── Denomination ────────────────────────────────────────────────────────────
notes: Denomination[] = [];
coins: Denomination[] = [];
denominationCounts: Record<string, number> = {};
get shareAmount(): number {
return this.requestedSharesInput * this.pricePerShare;
}
get totalChargeAmount(): number {
return this.charges.reduce((s, c) => s + c.amount, 0);
}
get totalAmount(): number {
return this.shareAmount + this.totalChargeAmount;
}
get currencySymbol(): string {
return this.account?.currency?.displaySymbol ?? '₹';
}
get isCashPayment(): boolean {
if (this.paymentTypeId === null) return true;
const pt = this.paymentTypes.find((p: any) => p.id === this.paymentTypeId);
return !!pt?.isCashPayment;
}
get allDenominations(): Denomination[] {
const desc = (a: Denomination, b: Denomination) => b.value - a.value;
return [
...[...this.notes].sort(desc),
...[...this.coins].sort(desc)
];
}
get denominationTotal(): number {
return this.allDenominations.reduce((s, d) => s + this.subtotalFor(d), 0);
}
get denomMatchesTotal(): boolean {
return this.denominationTotal === this.totalAmount;
}
get isValid(): boolean {
if (!this.account || this.totalAmount <= 0 || !this.transactionDate) return false;
if (this.isCashPayment && this.allDenominations.length > 0) {
return this.denomMatchesTotal;
}
return true;
}
ngOnInit(): void {
const d = new Date();
this.transactionDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service
.getPaymentTypes()
.pipe(catchError(() => of([])))
.subscribe((types: any[]) => (this.paymentTypes = types || []));
this.loadDenominations();
}
private loadDenominations(): void {
this.service
.getActiveDenominations()
.pipe(catchError(() => of([])))
.subscribe((list: any[]) => {
const rows: Denomination[] = (list || []).map((d: any) => ({
id: d.id,
value: d.denomination ?? d.value,
kind: d.type === 'coin' ? 'coin' : 'note',
label: '₹' + (d.denomination ?? d.value)
}));
const order = (a: Denomination, b: Denomination) => b.value - a.value;
this.notes = rows.filter((r) => r.kind === 'note').sort(order);
this.coins = rows.filter((r) => r.kind === 'coin').sort(order);
});
}
// ── Denomination helpers ──────────────────────────────────────────────────
denomKey(d: Denomination): string {
return `${d.kind}:${d.value}`;
}
countFor(d: Denomination): number {
return this.denominationCounts[this.denomKey(d)] || 0;
}
subtotalFor(d: Denomination): number {
return this.countFor(d) * d.value;
}
onDenomChange(d: Denomination, raw: any): void {
this.denominationCounts[this.denomKey(d)] = Math.max(0, Math.floor(Number(raw) || 0));
}
bumpDenom(d: Denomination, delta: number): void {
this.denominationCounts[this.denomKey(d)] = Math.max(0, this.countFor(d) + delta);
}
clearDenominations(): void {
this.denominationCounts = {};
}
searchAccount(): void {
const query = this.accountInput.trim();
if (!query) return;
this.pageState = 'searching';
this.account = null;
this.charges = [];
this.errorMsg = '';
this.service
.getShareAccountForCollection(query)
.pipe(
catchError((err) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.developerMessage ||
'Account not found. Please check the account ID and try again.';
this.pageState = 'idle';
return of(null);
})
)
.subscribe((acc: any) => {
if (!acc) return;
this.account = acc;
// Auto-detect shares from summary or first pending purchasedShares entry
const pendingTx = (acc.purchasedShares || []).find(
(t: any) =>
t.status?.code === 'purchasedSharesStatusType.applied' && t.type?.code === 'purchasedSharesType.purchased'
);
this.requestedSharesInput = Number(
acc.summary?.totalPendingForApprovalShares || pendingTx?.numberOfShares || acc.requestedShares || 0
);
this.pricePerShare = Number(pendingTx?.purchasedPrice || acc.currentMarketPrice || acc.unitPrice || 0);
// Show ALL charges on the account — no type filtering
this.charges = (acc.charges || []).map((c: any) => ({
id: c.chargeId ?? c.id,
name: c.name,
amount: Number(c.amount ?? c.amountOutstanding ?? 0),
currencySymbol: c.currency?.displaySymbol ?? '₹'
}));
this.pageState = 'loaded';
});
}
recalc(): void {
// triggered by input changes — nothing extra needed, getters auto-recalculate
}
submit(): void {
if (!this.isValid || this.saving) return;
this.saving = true;
const payload = {
shareAccountId: this.account.id,
accountNo: this.account.accountNo,
clientName: this.account.clientName,
transactionDate: this.transactionDate,
requestedShares: this.requestedSharesInput,
pricePerShare: this.pricePerShare,
shareAmount: this.shareAmount,
chargeAmount: this.totalChargeAmount,
totalAmount: this.totalAmount,
paymentTypeId: this.paymentTypeId || null,
note: this.note || null
};
this.service.collectSharePayment(payload).subscribe({
next: (result: any) => {
this.collectionResult = result;
this.snackBar.open('Share collection posted successfully.', 'Close', { duration: 3000 });
this.pageState = 'success';
this.saving = false;
},
error: (err: any) => {
const msg =
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
'Collection failed. Please try again.';
this.snackBar.open(msg, 'Close', { duration: 8000 });
this.saving = false;
}
});
}
reset(): void {
this.pageState = 'idle';
this.accountInput = '';
this.account = null;
this.charges = [];
this.errorMsg = '';
this.note = '';
this.paymentTypeId = null;
this.requestedSharesInput = 0;
this.pricePerShare = 0;
this.collectionResult = null;
this.saving = false;
this.clearDenominations();
}
}
@@ -166,23 +166,50 @@
</div> </div>
<div class="ws-f ws-f--actyp"> <div class="ws-f ws-f--actyp">
<label class="ws-f__lbl" title="Account Type">AC. TP.</label> <label class="ws-f__lbl" title="Account Type">AC. TP.</label>
<select @if (acTypLockedByToken && row === selectedRow) {
class="ws-f__ctl" <span
[(ngModel)]="row.acTyp" class="ws-f__ctl ws-f__ctl--locked"
(click)="$event.stopPropagation()" title="Locked — filled from a token-driven request, not a normal account entry"
(change)=" >{{ acTypLockedLabel }}</span
row.acTyp === 'GL' && >
row.typ !== 'Disbursement' && } @else {
row.typ !== 'Fund Transfer' && <select
row.typ !== 'Enter Bank Transfer' && class="ws-f__ctl"
(row.dbCr = 'Cr') [(ngModel)]="row.acTyp"
" (click)="$event.stopPropagation()"
> (change)="
@for (a of acTypOptionsFor(row); track a) { row.acTyp === 'GL' &&
<option [value]="a">{{ a }}</option> row.typ !== 'Disbursement' &&
} row.typ !== 'Fund Transfer' &&
</select> row.typ !== 'Enter Bank Transfer' &&
(row.dbCr = 'Cr')
"
>
@for (a of acTypOptionsFor(row); track a) {
<option [value]="a">{{ a }}</option>
}
</select>
}
</div> </div>
@if (showsEntryType(row)) {
<div class="ws-f ws-f--paytype">
<label
class="ws-f__lbl ws-f__lbl--accent"
title="What this money was actually collected for — checked before a share application or DD issuance can adopt this voucher"
>ENTRY TYPE</label
>
<select
class="ws-f__ctl ws-f__ctl--accent"
[(ngModel)]="row.entryTypeCvId"
(click)="$event.stopPropagation()"
>
<option [ngValue]="null">Select…</option>
@for (t of entryTypeOptions; track t.id) {
<option [ngValue]="t.id">{{ t.name }}</option>
}
</select>
</div>
}
</div> </div>
</div> </div>
@@ -206,14 +233,20 @@
? ddGlMapping?.ddPayableGlCode || 'Auto GL' ? ddGlMapping?.ddPayableGlCode || 'Auto GL'
: 'Account number' : 'Account number'
" "
[readonly]="row.typ === 'DD' && !isDdAccountDebitRow(row)" [readonly]="
[class.ws-f__ctl--readonly]="row.typ === 'DD' && !isDdAccountDebitRow(row)" (row.typ === 'DD' && !isDdAccountDebitRow(row)) || (acTypLockedByToken && row === selectedRow)
"
[class.ws-f__ctl--readonly]="
(row.typ === 'DD' && !isDdAccountDebitRow(row)) || (acTypLockedByToken && row === selectedRow)
"
[title]=" [title]="
isDdAccountDebitRow(row) acTypLockedByToken && row === selectedRow
? 'Member account the draft is funded from' ? 'Locked — filled from a token-driven request, not a normal account entry'
: row.typ === 'DD' : isDdAccountDebitRow(row)
? 'Configured Sponsor DD Payable GL (Auto-selected)' ? 'Member account the draft is funded from'
: 'Account number' : row.typ === 'DD'
? 'Configured Sponsor DD Payable GL (Auto-selected)'
: 'Account number'
" "
/> />
</div> </div>
@@ -1523,23 +1556,50 @@
</div> </div>
<div class="ws-f ws-f--actyp"> <div class="ws-f ws-f--actyp">
<label class="ws-f__lbl">AC. TP.</label> <label class="ws-f__lbl">AC. TP.</label>
<select @if (acTypLockedByToken && row === selectedRow) {
class="ws-f__ctl" <span
[(ngModel)]="row.acTyp" class="ws-f__ctl ws-f__ctl--locked"
(click)="$event.stopPropagation()" title="Locked — filled from a token-driven request, not a normal account entry"
(change)=" >{{ acTypLockedLabel }}</span
row.acTyp === 'GL' && >
row.typ !== 'Disbursement' && } @else {
row.typ !== 'Fund Transfer' && <select
row.typ !== 'Enter Bank Transfer' && class="ws-f__ctl"
(row.dbCr = 'Cr') [(ngModel)]="row.acTyp"
" (click)="$event.stopPropagation()"
> (change)="
@for (a of acTypOptionsFor(row); track a) { row.acTyp === 'GL' &&
<option [value]="a">{{ a }}</option> row.typ !== 'Disbursement' &&
} row.typ !== 'Fund Transfer' &&
</select> row.typ !== 'Enter Bank Transfer' &&
(row.dbCr = 'Cr')
"
>
@for (a of acTypOptionsFor(row); track a) {
<option [value]="a">{{ a }}</option>
}
</select>
}
</div> </div>
@if (showsEntryType(row)) {
<div class="ws-f ws-f--paytype">
<label
class="ws-f__lbl ws-f__lbl--accent"
title="What this money was actually collected for — checked before a share application or DD issuance can adopt this voucher"
>ENTRY TYPE</label
>
<select
class="ws-f__ctl ws-f__ctl--accent"
[(ngModel)]="row.entryTypeCvId"
(click)="$event.stopPropagation()"
>
<option [ngValue]="null">Select…</option>
@for (t of entryTypeOptions; track t.id) {
<option [ngValue]="t.id">{{ t.name }}</option>
}
</select>
</div>
}
</div> </div>
</div> </div>
<div class="ws-vdivider"></div> <div class="ws-vdivider"></div>
@@ -1556,10 +1616,16 @@
[placeholder]=" [placeholder]="
row.typ === 'DD' ? ddGlMapping?.ddPayableGlCode || 'Auto GL' : 'Account number' row.typ === 'DD' ? ddGlMapping?.ddPayableGlCode || 'Auto GL' : 'Account number'
" "
[readonly]="row.typ === 'DD'" [readonly]="row.typ === 'DD' || (acTypLockedByToken && row === selectedRow)"
[class.ws-f__ctl--readonly]="row.typ === 'DD'" [class.ws-f__ctl--readonly]="
row.typ === 'DD' || (acTypLockedByToken && row === selectedRow)
"
[title]=" [title]="
row.typ === 'DD' ? 'Configured Sponsor DD Payable GL (Auto-selected)' : 'Account number' acTypLockedByToken && row === selectedRow
? 'Locked — filled from a token-driven request, not a normal account entry'
: row.typ === 'DD'
? 'Configured Sponsor DD Payable GL (Auto-selected)'
: 'Account number'
" "
/> />
</div> </div>
@@ -916,6 +916,20 @@ input.ws-f__ctl--readonly {
font-size: 11px; font-size: 11px;
} }
/* AC.TP shown as a locked label instead of the normal editable select, while the row is filled
from a token-driven request whose real account type row.acTyp cannot show (see
acTypLockedByToken in the component). */
span.ws-f__ctl--locked {
display: inline-flex;
align-items: center;
background: #f1f5f9;
color: #475569;
cursor: not-allowed;
user-select: none;
font-size: 11px;
font-weight: 600;
}
input.ws-amt-input--locked { input.ws-amt-input--locked {
background: #fef9c3; background: #fef9c3;
color: #713f12; color: #713f12;
@@ -53,6 +53,19 @@ interface TxnRow {
/** DD issuance charges/commission — only used when TYPE=DD, TXN TP=Cash, AC.TP=GL */ /** DD issuance charges/commission — only used when TYPE=DD, TXN TP=Cash, AC.TP=GL */
ddCharges: number | null; ddCharges: number | null;
sponsorBankId?: number | null; sponsorBankId?: number | null;
/**
* What a GL credit is actually for — the id of a code value under "GLEntryType" (General, Share
* Collection, DD Issue, or whatever an admin has added under Admin > System > Manage Codes).
*
* A cashier Receipt (or a clerk Collection) into a GL account is the counter picking, by hand,
* any account to post cash into — deliberately, so a broken product mapping can never block a
* genuine voucher. That freedom is also the hole: two unrelated flows can land in the same GL
* account with nothing on the row itself saying which one the money was collected for, and a
* manager applying a share request can pick up a voucher that was really meant to fund a DD. This
* tag is what a day-end reconciliation groups by, and what Share Advance Collection / Sponsor DD
* issuance check before adopting a row — only shown/sent for AC.TP=GL, DR./CR.=Cr rows.
*/
entryTypeCvId?: number | null;
/** Only used when this row is AC.TP=SHR inside an Enter Bank Transfer to its linked savings /** Only used when this row is AC.TP=SHR inside an Enter Bank Transfer to its linked savings
* account — governs the share redemption (see submitShareRedemptionTransfer()). */ * account — governs the share redemption (see submitShareRedemptionTransfer()). */
resolutionNumber?: string; resolutionNumber?: string;
@@ -1703,15 +1716,18 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
/** /**
* Account types a clerk may fund a demand draft from. * Account types a clerk may fund a demand draft from.
* *
* A clerk raises a draft against a member's own balance, so the only sensible sources are the * A clerk raises a draft against a member's own balance (SD/CA) or, now, against a GL account
* accounts a member actually holds money in. GL was offered here too, and picking it quietly * directly for an institutional/holding-account draft (see isDdAccountDebitRow()) — both are
* switched the row onto the cash path — the one that asks for a walk-in purchaser's name and a * governed, maker-checker submissions. GL used to be offered here too in a different sense: picking
* note-and-coin count, neither of which a clerk debiting an account has. The option is removed * it quietly switched the row onto the CASHIER's walk-in cash path instead (the one that asks for a
* rather than validated afterwards, so the wrong path cannot be reached at all. * purchaser's name and a note-and-coin count), which isDdAccountDebitRow() now disambiguates by
* role — GL means "debit this GL account" for a clerk, and still means "cash/GL purchase" for a
* cashier, who never reaches this list at all (isDdAccountDebitRow() is clerk-only).
*/ */
private static readonly DD_CLERK_ACTYPS = [ private static readonly DD_CLERK_ACTYPS = [
'SD', 'SD',
'CA' 'CA',
'GL'
]; ];
/** /**
@@ -1726,6 +1742,33 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
} }
return this.acTypOptions; return this.acTypOptions;
} }
/** True while the selected row is filled from a token-driven request whose row.acTyp is a
* technical stand-in rather than the account's real type — a dividend claim or an account closure
* both set row.acTyp = 'SD' purely to route past the SHR-specific guards elsewhere on this screen
* (see onTokNoBlur()), not because the counter is paying against a savings account. The AC.TP
* dropdown must not be left editable there: it already shows the wrong value, and a cashier
* changing it further would desync the row from the request it was auto-filled from. A plain
* withdrawal-slip token is deliberately excluded — its row.acTyp is resolved to the account's real
* type there, so the normal editable control already shows it correctly and there's nothing to
* lock. */
get acTypLockedByToken(): boolean {
return !!(this.activeDividendClaimRequest || this.activeClosureRequest);
}
/** What to show in place of the AC.TP dropdown while it's locked — the same label the account
* summary strip above already uses for each of these token pools, so the two never contradict
* each other on screen. */
get acTypLockedLabel(): string {
if (this.activeClosureRequest) {
return 'Closure';
}
if (this.activeDividendClaimRequest) {
return 'Dividend';
}
return '';
}
dbCrOptions = [ dbCrOptions = [
'Dr', 'Dr',
'Cr' 'Cr'
@@ -1834,6 +1877,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.loadCashVaultGl(); this.loadCashVaultGl();
this.loadDdGstPercentage(); this.loadDdGstPercentage();
this.loadSponsorBanks(); this.loadSponsorBanks();
this.cashierService
.getEntryTypeOptions()
.pipe(catchError(() => of([])))
.subscribe((options: any[]) => {
this.entryTypeOptions = options ?? [];
});
if (this.reviewMode) { if (this.reviewMode) {
this.loadRequestIntoForm(this.reviewRequestId!, 'review'); this.loadRequestIntoForm(this.reviewRequestId!, 'review');
@@ -2321,14 +2370,21 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
} }
/** /**
* TYPE=DD funded by debiting a member's savings/current account rather than by cash. * TYPE=DD funded by debiting a member's savings/current account, or a GL account directly, rather
* than by cash.
* *
* Clerk-only. A cashier's counter takes cash and nothing else, so offering them an account debit * Clerk-only. A cashier's counter takes cash and nothing else, so offering them an account/GL debit
* would let a draft be raised against an account with no cash to reconcile it against — and the * would let a draft be raised with no cash to reconcile it against — and the clerk flow is the one
* clerk flow is the one that carries a maker-checker step over the member's balance. * that carries a maker-checker step over the money moved. AC.TP = GL means something different for
* a cashier's own DD row (a walk-in cash/GL purchase, see isDdCashGlRow()) — this method is checked
* first there specifically so a clerk's GL row is claimed here instead.
*/ */
isDdAccountDebitRow(row: TxnRow): boolean { isDdAccountDebitRow(row: TxnRow): boolean {
return this.isClerk && row.typ === 'DD' && (row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB'); return (
this.isClerk &&
row.typ === 'DD' &&
(row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB' || row.acTyp === 'GL')
);
} }
/** /**
@@ -2342,6 +2398,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return this.isDdCashGlRow(row) || this.isDdAccountDebitRow(row); return this.isDdCashGlRow(row) || this.isDdAccountDebitRow(row);
} }
/**
* Whether this row needs an Entry Type tag — a credit into a GL account, which is exactly the
* case where the money's real purpose isn't otherwise recorded anywhere (see TxnRow.entryTypeCvId).
* A debit out of a GL account, or any entry against a savings/current/share account, carries no
* such ambiguity — the account itself says what it's for.
*/
showsEntryType(row: TxnRow): boolean {
return row.acTyp === 'GL' && row.dbCr === 'Cr';
}
/**
* Entry Type choices for a GL credit — the "GLEntryType" code (Admin > System > Manage Codes),
* fetched once in ngOnInit(). A code value, not a free-text field, so the day-end "group by type"
* this exists for stays reliable; an admin extends the list there, not in this file.
*/
entryTypeOptions: { id: number; name: string }[] = [];
/** GST on DD charges only (never on the DD face value), at the admin-configured rate. */ /** GST on DD charges only (never on the DD face value), at the admin-configured rate. */
ddGstAmountFor(row: TxnRow): number { ddGstAmountFor(row: TxnRow): number {
const charges = Number(row.ddCharges) || 0; const charges = Number(row.ddCharges) || 0;
@@ -2408,19 +2481,24 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// An account-funded draft needs the account resolved to a Fineract id before it can be sent: // An account-funded draft needs the account resolved to a Fineract id before it can be sent:
// the server takes a savings/current account id and a client id, not the typed number. Both are // the server takes a savings/current account id and a client id, not the typed number. Both are
// filled in by the A/C NO. lookup, so an unresolved account means the number was never found. // filled in by the A/C NO. lookup, so an unresolved account means the number was never found.
// A GL-funded draft is the exception: a GL account is never tied to a client at all (see
// fetchAllDetails() — GL rows never populate rowClientIds), so only the account id is required.
const isGlDebit = accountDebit && row.acTyp === 'GL';
let fundingAccountId: number | null = null; let fundingAccountId: number | null = null;
let fundingClientId: number | null = null; let fundingClientId: number | null = null;
if (accountDebit) { if (accountDebit) {
const acNo = (row.acNo || '').trim(); const acNo = (row.acNo || '').trim();
if (!acNo) { if (!acNo) {
this.showAlert('error', 'Account Required', [ this.showAlert('error', 'Account Required', [
'Enter the account number the Demand Draft is funded from.' isGlDebit
? 'Enter the GL account the Demand Draft is funded from.'
: 'Enter the account number the Demand Draft is funded from.'
]); ]);
return; return;
} }
fundingAccountId = this.rowAccountIds[acNo] ?? null; fundingAccountId = this.rowAccountIds[acNo] ?? null;
fundingClientId = this.rowClientIds[acNo] ?? null; fundingClientId = isGlDebit ? null : (this.rowClientIds[acNo] ?? null);
if (!fundingAccountId || !fundingClientId) { if (!fundingAccountId || (!isGlDebit && !fundingClientId)) {
this.showAlert('error', 'Account Not Found', [ this.showAlert('error', 'Account Not Found', [
`Account ${acNo} could not be resolved.`, `Account ${acNo} could not be resolved.`,
'Check the account number and wait for its details to load before raising the draft.' 'Check the account number and wait for its details to load before raising the draft.'
@@ -2510,29 +2588,32 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
} }
/** /**
* Raises a Demand Draft funded by debiting a member's savings or current account. * Raises a Demand Draft funded by debiting a member's savings/current account, or a GL account
* directly.
* *
* Clerk-only, and a maker-checker request like the cash flow: nothing is debited here. The member's * Clerk-only, and a maker-checker request like the cash flow: nothing is debited here. The
* account is only touched when a checker approves, which is also when the balance is re-checked — * account/GL is only touched when a checker approves, which for a member account is also when the
* so a draft raised against a sufficient balance can still be refused if the balance has since * balance is re-checked — so a draft raised against a sufficient balance can still be refused if
* gone. No denominations are sent, because no cash is taken. * the balance has since gone (a GL account carries no such balance check). No denominations are
* sent, because no cash is taken.
* *
* The account goes in savingsAccountId or currentAccountId, never both: the server insists on * The funding account goes in exactly one of savingsAccountId, currentAccountId or glAccountId: the
* exactly one, since which table the account lives in decides which control GL is debited. * server insists on exactly one, since which one decides what gets debited on approval.
*/ */
private submitAccountDebitDd( private submitAccountDebitDd(
row: TxnRow, row: TxnRow,
leaflet: any, leaflet: any,
beneficiaryData: any, beneficiaryData: any,
fundingAccountId: number, fundingAccountId: number,
fundingClientId: number fundingClientId: number | null
): void { ): void {
const d = new Date(); const d = new Date();
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB'; const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB';
const isGl = row.acTyp === 'GL';
const payload = { const payload = {
clientId: fundingClientId, clientId: isGl ? undefined : (fundingClientId ?? undefined),
beneficiaryName: beneficiaryData.beneficiaryName, beneficiaryName: beneficiaryData.beneficiaryName,
payableAtBranch: beneficiaryData.payableAtBranch, payableAtBranch: beneficiaryData.payableAtBranch,
payableAtCity: beneficiaryData.payableAtCity, payableAtCity: beneficiaryData.payableAtCity,
@@ -2540,9 +2621,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
chargesAmount: Number(row.ddCharges) || 0, chargesAmount: Number(row.ddCharges) || 0,
issuingBranchId: row.br, issuingBranchId: row.br,
issuedDate, issuedDate,
paymentMethod: 'ACCOUNT_DEBIT' as const, paymentMethod: (isGl ? 'GL' : 'ACCOUNT_DEBIT') as 'GL' | 'ACCOUNT_DEBIT',
savingsAccountId: isCurrent ? undefined : fundingAccountId, savingsAccountId: isGl || isCurrent ? undefined : fundingAccountId,
currentAccountId: isCurrent ? fundingAccountId : undefined currentAccountId: isGl || !isCurrent ? undefined : fundingAccountId,
glAccountId: isGl ? fundingAccountId : undefined
}; };
this.saving = true; this.saving = true;
@@ -2802,7 +2884,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// Without this the backend defaults every request from this screen to DISBURSEMENT — a // Without this the backend defaults every request from this screen to DISBURSEMENT — a
// Collection or Fund Transfer submitted here was being saved (and shown in the checker's // Collection or Fund Transfer submitted here was being saved (and shown in the checker's
// inbox) as a plain Disbursement. // inbox) as a plain Disbursement.
requestType: isCollectionMode ? 'COLLECTION' : isFundTransfer ? 'FUND_TRANSFER' : 'DISBURSEMENT' requestType: isCollectionMode ? 'COLLECTION' : isFundTransfer ? 'FUND_TRANSFER' : 'DISBURSEMENT',
// Same ambiguity as a cashier Receipt — a Collection credits a GL account picked by hand.
// See TxnRow.entryTypeCvId.
...(isCollectionMode ? { entryTypeCvId: crRows[0]?.entryTypeCvId || null } : {})
}; };
this.saving = true; this.saving = true;
@@ -3044,6 +3129,168 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
); );
} }
/**
* Share redemption settled to a GL account the clerk picks on the CR row, instead of the
* member's linked savings account — mirrors submitShareRedemptionTransfer() above, but the
* credit side is any GL account (e.g. a "DD Payable" holding account), not a fixed linked
* account, so there is no "must match the linked account" check here.
*/
private submitShareGlSettlementTransfer(shareRow: TxnRow, glRow: TxnRow): void {
// Belt-and-braces: the TXN TP. dropdown already hides cash payment types for AC.TP = SHR (see
// paymentTypesForRow()), but re-check here too in case the payment type was set before that
// filter applied.
const settlementPaymentType = this.paymentTypes.find((p: any) => p.id === glRow.paymentTypeId);
if (!glRow.paymentTypeId) {
this.showAlert('error', 'Settlement Payment Type Required', [
'Select a Payment Type on the CR (GL account) row — it governs how the settlement credit posts.'
]);
return;
}
if (settlementPaymentType?.isCashPayment) {
this.showAlert('error', 'Cash Not Allowed for Share Transactions', [
'Share redemption settlement is always a book-transfer, never cash.',
'Select Transfer as the Payment Type on the CR row.'
]);
return;
}
const shareAccountId = this.rowAccountIds[shareRow.acNo];
if (!shareAccountId) {
this.showAlert('error', 'Share Account Not Loaded', [`Please load account details for: ${shareRow.acNo}`]);
return;
}
const settlementGlAccountId = this.rowAccountIds[glRow.acNo];
if (!settlementGlAccountId) {
this.showAlert('error', 'GL Account Not Loaded', [`Please load account details for: ${glRow.acNo}`]);
return;
}
const shares = Number(shareRow.amount) || 0;
if (shares <= 0) {
this.showAlert('error', 'Share No. Required', ['Enter the number of shares to withdraw (greater than zero).']);
return;
}
if (!shareRow.resolutionNumber?.trim()) {
this.showAlert('error', 'Board Resolution Number Required', [
'A share withdrawal must be authorised by a Board Resolution.'
]);
return;
}
if (!shareRow.meetingDate) {
this.showAlert('error', 'Date of Meeting Required', ['Enter the Board meeting date that passed the resolution.']);
return;
}
const today = new Date();
const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
// Full snapshot of the CR (settlement) row exactly as the clerk filled it in — saved verbatim
// for audit purposes, same as the linked-savings path above.
const settlementRowSnapshot = {
branch: glRow.br,
accountType: glRow.acTyp,
accountNo: glRow.acNo,
ecsAccountNo: glRow.ecsAcNo || null,
dbCr: glRow.dbCr,
amount: glRow.amount ?? null,
chequeNo: glRow.chequeNo || null,
chequeDate: glRow.cheqDate || null,
tokenNo: glRow.tokNo || null,
description: glRow.description || null,
bankName: glRow.bankName || null,
routingCode: glRow.routingCode || null,
paymentTypeId: glRow.paymentTypeId,
paymentTypeName: settlementPaymentType?.name ?? null
};
const payload = {
shareAccountId,
accountNo: shareRow.acNo,
clientId: this.rowClientIds[shareRow.acNo] ?? null,
clientName: this.rowAccountNames[shareRow.acNo] ?? null,
requestedShares: shares,
requestedDate,
resolutionNumber: shareRow.resolutionNumber.trim(),
meetingDate: shareRow.meetingDate,
description: shareRow.description || null,
settlementPaymentTypeId: glRow.paymentTypeId,
settlementRowSnapshot,
settlementMode: 'GL',
settlementGlAccountId
};
const sharePrice = this.rowSharePrices[shareRow.acNo] ?? 0;
const currentHolding = this.rowShareHoldings[shareRow.acNo] ?? 0;
const remainingShares = currentHolding - shares;
const willCloseAccount = remainingShares <= 0;
const confirmLines = [
`Share Account: ${shareRow.acNo} · Client: ${this.rowAccountNames[shareRow.acNo] ?? '—'}`,
`Shares to Withdraw: ${shares} @ ₹${sharePrice.toLocaleString('en-IN', { minimumFractionDigits: 2 })} = ₹${(shares * sharePrice).toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
`Remaining Shares After Withdrawal: ${Math.max(remainingShares, 0)}`,
...(willCloseAccount
? ['⚠ All shares withdrawn — this account will be CLOSED automatically when the Manager approves.']
: []),
`Board Resolution No.: ${payload.resolutionNumber}`,
`Date of Meeting: ${payload.meetingDate}`,
`Settlement Credit To (GL): ${glRow.acNo}`,
`Settlement Payment Type: ${settlementPaymentType?.name ?? '—'}`,
...(settlementRowSnapshot.description ? [`CR Row Remarks: ${settlementRowSnapshot.description}`] : []),
'',
willCloseAccount
? 'Pending Manager approval — redemption, settlement and account closure all post together on approval.'
: 'Pending Manager approval — redemption and settlement both post together on approval.'
];
this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', confirmLines, 'Submit for Approval', () =>
this.doSubmitShareGlSettlementTransfer(payload, shareRow, shares, glRow.acNo)
);
}
private doSubmitShareGlSettlementTransfer(
payload: Record<string, unknown>,
shareRow: TxnRow,
shares: number,
glAcNo: string
): void {
this.saving = true;
this.clerkShareWithdrawalService
.submit(payload)
.pipe(
catchError((err: any) => {
const msg =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.developerMessage ??
err?.error?.defaultUserMessage ??
'Share withdrawal submission failed. Please try again.';
this.showAlert('error', 'Submission Failed', [msg]);
this.saving = false;
return of(null);
})
)
.subscribe((res: any) => {
this.saving = false;
if (res) {
const willCloseAccount = (this.rowShareHoldings[shareRow.acNo] ?? 0) - shares <= 0;
this.showAlert('success', 'Share Withdrawal Submitted', [
`${shares} share(s) from ${shareRow.acNo} → settlement to GL ${glAcNo} on approval.`,
willCloseAccount
? 'Pending Manager approval — this will close the share account, since all shares will be withdrawn. Track status in "My Share Requests".'
: 'Pending Manager approval. Track status in "My Share Requests".'
]);
this.rows = [];
this.rowAccountIds = {};
this.rowAccountNames = {};
this.rowSharePrices = {};
this.rowClientIds = {};
this.rowLinkedSavingsAcNo = {};
this.rowShareHoldings = {};
this.addRow();
this.selectedRow = this.rows[0];
this.accountDetails = null;
this.clientDetails = null;
}
});
}
private doSubmitShareRedemptionTransfer( private doSubmitShareRedemptionTransfer(
payload: Record<string, unknown>, payload: Record<string, unknown>,
shareRow: TxnRow, shareRow: TxnRow,
@@ -3103,37 +3350,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
.pipe( .pipe(
catchError((err: any) => { catchError((err: any) => {
// Not a withdrawal-request token at all (as opposed to PENDING/WITHDRAWN/REJECTED, // Not a withdrawal-request token at all (as opposed to PENDING/WITHDRAWN/REJECTED,
// which are legitimate matches with their own specific error) — try the dividend // which are legitimate matches with their own specific error) — try the account-closure
// cash-claim pool, then the account-closure pool, before giving up: all three share // pool before giving up: both share the same TOK. NO. field at this counter. A dividend
// the same TOK. NO. field at this counter. // claim was once a third pool here, but a dividend never actually pays out in cash over
// this counter — it only ever settles through a Demand Draft, which is its own DD
// issuance request and its own token, so looking one up here could only ever 404.
if (err?.status === 404) { if (err?.status === 404) {
return this.dividendClaimRequestService.getByToken(token).pipe( return this.closureRequestService.getByToken(token).pipe(
catchError((divErr: any) => { catchError((closeErr: any) => {
if (divErr?.status === 404) {
return this.closureRequestService.getByToken(token).pipe(
catchError((closeErr: any) => {
this.loadingWithdrawal = false;
const msg: string =
closeErr?.error?.message ??
closeErr?.error?.errors?.[0]?.defaultUserMessage ??
`Token '${token}' could not be verified. Please try again.`;
this.showAlert('error', `Token '${token}'`, [msg]);
return of(null);
}),
map((closeReq: any) => (closeReq ? { __closureRequest: true, ...closeReq } : null))
);
}
this.loadingWithdrawal = false; this.loadingWithdrawal = false;
const msg: string = const msg: string =
divErr?.error?.message ?? closeErr?.error?.message ??
divErr?.error?.errors?.[0]?.defaultUserMessage ?? closeErr?.error?.errors?.[0]?.defaultUserMessage ??
`Token '${token}' could not be verified. Please try again.`; `Token '${token}' could not be verified. Please try again.`;
this.showAlert('error', `Token '${token}'`, [msg]); this.showAlert('error', `Token '${token}'`, [msg]);
return of(null); return of(null);
}), }),
// Only tag a result that actually came from the dividend pool — a closure hit map((closeReq: any) => (closeReq ? { __closureRequest: true, ...closeReq } : null))
// recovered by the inner fallback above is already tagged and must stay that way.
map((divReq: any) => (divReq && !divReq.__closureRequest ? { __dividendClaim: true, ...divReq } : divReq))
); );
} }
this.loadingWithdrawal = false; this.loadingWithdrawal = false;
@@ -3159,7 +3392,16 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.clientDetails = null; this.clientDetails = null;
this.accountError = ''; this.accountError = '';
row.acNo = req.client_name ?? 'Dividend Claim #' + req.id; row.acNo = req.client_name ?? 'Dividend Claim #' + req.id;
// Marks this acNo as already resolved, the same way loadAccountDetails() itself would —
// without it, the next click into this row (onRowSelect) sees an acNo that was never
// "loaded" and re-searches for a real account matching this stub string, which can land
// on an unrelated real account whose number happens to share a suffix with it.
this.lastLoadedAcNo = row.acNo;
row.acTyp = 'SD'; row.acTyp = 'SD';
// Every token-driven row is cash leaving the branch to a member, which is what the
// Payment register is for — not what this row's TYPE happened to default to before the
// token was entered.
row.typ = 'Payment';
row.amount = Number(req.amount) || null; row.amount = Number(req.amount) || null;
row.dbCr = 'Dr'; row.dbCr = 'Dr';
this.selectedRow = row; this.selectedRow = row;
@@ -3181,7 +3423,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.clientDetails = null; this.clientDetails = null;
this.accountError = ''; this.accountError = '';
row.acNo = req.account_no ?? ''; row.acNo = req.account_no ?? '';
// See the same line in the dividend-claim branch above for why this is required.
this.lastLoadedAcNo = row.acNo;
row.acTyp = 'SD'; row.acTyp = 'SD';
row.typ = 'Payment';
row.amount = Number(req.cashier_disbursed_amount) || null; row.amount = Number(req.cashier_disbursed_amount) || null;
row.dbCr = 'Dr'; row.dbCr = 'Dr';
// A closure request stores payment_type_id as NULL, meaning "Cash (Default)" — resolve the // A closure request stores payment_type_id as NULL, meaning "Cash (Default)" — resolve the
@@ -3194,15 +3439,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
} }
this.activeWithdrawalRequest = req; this.activeWithdrawalRequest = req;
// A plain withdrawal-slip request carries its own real account_type (SAVINGS, CURRENT,
// FIXED_DEPOSIT or RECURRING_DEPOSIT — see ClerkWithdrawalRequestApiResource.submit()), unlike
// the closure/dividend pools above, which are never anything but a savings-shaped stub.
// Hardcoding 'SD' here searched every non-savings account on the wrong endpoint (Current
// accounts live under /currentaccounts, not /savingsaccounts) and showed it mislabelled even
// when the lookup happened to succeed.
const acTyp = TransactionEntryComponent.SEARCH_ACCOUNT_TYPE_TO_AC_TYP[req.account_type || ''] || 'SD';
row.acNo = req.account_no ?? ''; row.acNo = req.account_no ?? '';
row.acTyp = 'SD'; row.acTyp = acTyp;
row.typ = 'Payment';
row.amount = Number(req.amount) || null; row.amount = Number(req.amount) || null;
row.dbCr = 'Dr'; row.dbCr = 'Dr';
row.paymentTypeId = req.payment_type_id ?? null; row.paymentTypeId = req.payment_type_id ?? null;
row.description = req.description ?? ''; row.description = req.description ?? '';
this.selectedRow = row; this.selectedRow = row;
if (req.account_no) { if (req.account_no) {
this.loadAccountDetails(req.account_no.trim(), 'SD'); this.loadAccountDetails(req.account_no.trim(), acTyp);
} }
}); });
} }
@@ -3229,10 +3482,24 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
onAcNoBlur(row: TxnRow, index: number): void { onAcNoBlur(row: TxnRow, index: number): void {
if (!row.acNo.trim()) return; if (!row.acNo.trim()) return;
this.selectedRow = row; this.selectedRow = row;
// A token-driven row's account fields are already authoritative (see onTokNoBlur) -- merely
// clicking into and out of the A/C NO. field here must not re-search for them. Doing so used
// row.acTyp's internal stand-in value (e.g. 'SD' for a dividend claim, which is never the
// account's real type) against the account's real number, either finding nothing ("Account
// not found") or, worse, matching a completely unrelated real account that happened to share a
// number suffix.
if (this.activeWithdrawalRequest || this.activeDividendClaimRequest || this.activeClosureRequest) {
return;
}
this.loadAccountDetails(row.acNo.trim(), row.acTyp); this.loadAccountDetails(row.acNo.trim(), row.acTyp);
} }
onAcNoInput(row: TxnRow, index: number, value: string): void { onAcNoInput(row: TxnRow, index: number, value: string): void {
// Same reasoning as the guard at the top of onAcNoBlur() -- a token-driven row's account
// fields must not be edited or re-searched via this field at all.
if (this.activeWithdrawalRequest || this.activeDividendClaimRequest || this.activeClosureRequest) {
return;
}
row.acNo = value; row.acNo = value;
this.lastLoadedAcNo = ''; this.lastLoadedAcNo = '';
this.acNoInput$.next({ row, index, value }); this.acNoInput$.next({ row, index, value });
@@ -3341,8 +3608,15 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
const match = exactMatch ?? partialMatch ?? idMatch ?? null; const match = exactMatch ?? partialMatch ?? idMatch ?? null;
if (match?.id) { if (match?.id) {
const matchedGlCode = match[f] ?? acNo; const matchedGlCode = match[f] ?? acNo;
if (this.selectedRow && this.selectedRow.typ === 'DD' && match.glCode) { // Any row resolved via the partial/endsWith/id fallback above (not an exact code match)
this.selectedRow.acNo = match.glCode; // loads and displays the right account, but the row itself still carries whatever shorter
// string the cashier actually typed to search with -- and that's what gets submitted and
// shown everywhere downstream (vouchers, My Work, credit_entries), not the account's real
// full number. Correct the row to the account's actual code whenever the match wasn't
// already exact, for every account type -- not just the DD/GL cases this was previously
// limited to.
if (this.selectedRow && !exactMatch && matchedGlCode) {
this.selectedRow.acNo = matchedGlCode;
} }
const clientIdFromSearch = acTyp !== 'GL' && match.clientId != null ? String(match.clientId) : null; const clientIdFromSearch = acTyp !== 'GL' && match.clientId != null ? String(match.clientId) : null;
this.fetchAllDetails(match.id, matchedGlCode, cfg, token, acTyp, clientIdFromSearch); this.fetchAllDetails(match.id, matchedGlCode, cfg, token, acTyp, clientIdFromSearch);
@@ -4519,6 +4793,26 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// "Disbursement". 'Enter Bank Transfer' never carries a GL leg, so it always stays in this // "Disbursement". 'Enter Bank Transfer' never carries a GL leg, so it always stays in this
// branch; only skip out to the Disbursement/Collection branch below when every row is 'Fund // branch; only skip out to the Disbursement/Collection branch below when every row is 'Fund
// Transfer' and at least one of them is GL. // Transfer' and at least one of them is GL.
// Share redemption settled to a GL account instead of the linked savings account: DR=Share
// account, CR=any GL account the clerk picks (e.g. a "DD Payable" holding account, ahead of a
// second, independent manual entry that later moves the value out to an actual draft). Checked
// ahead of both branches below — the Enter Bank Transfer/Fund Transfer branch would otherwise
// treat a Fund Transfer carrying a GL leg as the Disbursement/Collection shape (see
// fundTransferRowsHaveGl below), and a Share account can never be debited by that path at all —
// no such Fineract command exists — so it would either be silently mis-posted or rejected late.
if (
this.isClerk &&
this.rows.length === 2 &&
this.rows.every((r) => r.typ === 'Fund Transfer' || r.typ === 'Enter Bank Transfer') &&
this.rows.some((r) => r.dbCr === 'Dr' && r.acTyp === 'SHR') &&
this.rows.some((r) => r.dbCr === 'Cr' && r.acTyp === 'GL')
) {
const shareRow = this.rows.find((r) => r.dbCr === 'Dr' && r.acTyp === 'SHR')!;
const glRow = this.rows.find((r) => r.dbCr === 'Cr' && r.acTyp === 'GL')!;
this.submitShareGlSettlementTransfer(shareRow, glRow);
return;
}
const fundTransferRowsHaveGl = const fundTransferRowsHaveGl =
this.rows.every((r) => r.typ === 'Fund Transfer') && this.rows.some((r) => r.acTyp === 'GL'); this.rows.every((r) => r.typ === 'Fund Transfer') && this.rows.some((r) => r.acTyp === 'GL');
if ( if (
@@ -5650,6 +5944,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
chequeNo: row.chequeNo || null, chequeNo: row.chequeNo || null,
bankName: row.bankName || null, bankName: row.bankName || null,
denominationData: isCash && this.denominationTotal > 0 ? JSON.stringify(this.denominationCounts) : null, denominationData: isCash && this.denominationTotal > 0 ? JSON.stringify(this.denominationCounts) : null,
// What this GL credit is actually for — see TxnRow.entryTypeCvId. Sent for every GL deposit
// (the backend defaults to "General" when the counter didn't pick one), so Share Advance
// Collection and Sponsor DD issuance can tell whose voucher this really is before adopting it.
...(isGl ? { entryTypeCvId: row.entryTypeCvId || null } : {}),
// DD + Cash + GL breakdown — persisted so the Sponsor DD issuance screen can read the face // DD + Cash + GL breakdown — persisted so the Sponsor DD issuance screen can read the face
// value/charges/GST back once this request is approved, instead of retyping them. // value/charges/GST back once this request is approved, instead of retyping them.
...(this.isDdCashGlRow(row) ...(this.isDdCashGlRow(row)
@@ -576,13 +576,11 @@
} }
/* ───────── Print — only the page, no toolbar / dialog chrome ───────── /* ───────── Print — only the page, no toolbar / dialog chrome ─────────
This dialog is opened with `maxHeight: '92vh'`, which Angular Material carries onto the The document-level clip (`html, body { overflow: hidden }`) is lifted globally in main.scss's
overlay pane / .mat-mdc-dialog-container as an inline max-height + overflow:auto clip. print block; what remains here is this dialog's own clip — it opens with `maxHeight: '92vh'`,
`position: absolute` still lives inside that clip and gets cut off — only the on-screen which Angular Material carries onto the overlay pane / .mat-mdc-dialog-container as an inline
scroll position prints. `position: fixed` escapes it: a fixed element's containing block is max-height plus overflow:auto. Freeing those lets the whole certificate flow onto the page in
the viewport itself, not the clipped ancestor (the classic "fixed child ignores an normal static order (a fixed/absolute hack would pin it to the first page instead). */
overflow:hidden parent" behaviour), as long as nothing between here and the viewport sets a
transform/filter — the CDK overlay chrome doesn't. */
@media print { @media print {
body > :not(.cdk-overlay-container) { body > :not(.cdk-overlay-container) {
display: none !important; display: none !important;
@@ -611,9 +609,7 @@
} }
.cpd-root { .cpd-root {
position: fixed; position: static;
left: 0;
top: 0;
width: 100% !important; width: 100% !important;
height: auto !important; height: auto !important;
background: #fff; background: #fff;
@@ -70,16 +70,9 @@ export class ShareProductAccountingStepComponent implements OnInit {
shareProductAccountingForm: UntypedFormGroup; shareProductAccountingForm: UntypedFormGroup;
accountingSectionConfigs: ShareAccountingSectionConfig[] = []; accountingSectionConfigs: ShareAccountingSectionConfig[] = [];
assetAccountData: GLAccount[] = [];
liabilityAccountData: GLAccount[] = []; liabilityAccountData: GLAccount[] = [];
incomeAccountData: GLAccount[] = []; incomeAccountData: GLAccount[] = [];
equityAccountData: GLAccount[] = []; equityAccountData: GLAccount[] = [];
/** "Share Reference" is where a redemption's credit side lands pending settlement — some banks
* model that as a receivable-like Asset (e.g. "Cash Vault", this field's original default),
* others as a payable-like Liability suspense account (e.g. an existing "Sundry" account
* already used elsewhere as Shares Suspense). Both are valid conventions, so this field accepts
* either instead of Asset-only. */
referenceAccountData: GLAccount[] = [];
constructor() { constructor() {
this.createShareProductAccountingForm(); this.createShareProductAccountingForm();
@@ -87,18 +80,15 @@ export class ShareProductAccountingStepComponent implements OnInit {
ngOnInit() { ngOnInit() {
const opts = this.shareProductsTemplate.accountingMappingOptions || {}; const opts = this.shareProductsTemplate.accountingMappingOptions || {};
this.assetAccountData = opts.assetAccountOptions || [];
this.liabilityAccountData = opts.liabilityAccountOptions || []; this.liabilityAccountData = opts.liabilityAccountOptions || [];
this.incomeAccountData = opts.incomeAccountOptions || []; this.incomeAccountData = opts.incomeAccountOptions || [];
this.equityAccountData = opts.equityAccountOptions || []; this.equityAccountData = opts.equityAccountOptions || [];
this.referenceAccountData = this.assetAccountData.concat(this.liabilityAccountData);
this.buildAccountingSectionConfigs(); this.buildAccountingSectionConfigs();
if (this.shareProductsTemplate.accountingMappings) { if (this.shareProductsTemplate.accountingMappings) {
const m = this.shareProductsTemplate.accountingMappings; const m = this.shareProductsTemplate.accountingMappings;
this.shareProductAccountingForm.patchValue({ this.shareProductAccountingForm.patchValue({
shareReferenceId: m.shareReferenceId?.id || '',
shareSuspenseId: m.shareSuspenseId?.id || '', shareSuspenseId: m.shareSuspenseId?.id || '',
incomeFromFeeAccountId: m.incomeFromFeeAccountId?.id || '', incomeFromFeeAccountId: m.incomeFromFeeAccountId?.id || '',
shareEquityId: m.shareEquityId?.id || '' shareEquityId: m.shareEquityId?.id || ''
@@ -109,10 +99,6 @@ export class ShareProductAccountingStepComponent implements OnInit {
createShareProductAccountingForm() { createShareProductAccountingForm() {
this.shareProductAccountingForm = this.formBuilder.group({ this.shareProductAccountingForm = this.formBuilder.group({
accountingRule: [2], accountingRule: [2],
shareReferenceId: [
'',
Validators.required
],
shareSuspenseId: [ shareSuspenseId: [
'', '',
Validators.required Validators.required
@@ -125,7 +111,6 @@ export class ShareProductAccountingStepComponent implements OnInit {
'', '',
Validators.required Validators.required
], ],
assetsOtherGlMappings: this.formBuilder.array([]),
liabilitiesOtherGlMappings: this.formBuilder.array([]), liabilitiesOtherGlMappings: this.formBuilder.array([]),
incomeOtherGlMappings: this.formBuilder.array([]), incomeOtherGlMappings: this.formBuilder.array([]),
equityOtherGlMappings: this.formBuilder.array([]) equityOtherGlMappings: this.formBuilder.array([])
@@ -134,24 +119,6 @@ export class ShareProductAccountingStepComponent implements OnInit {
buildAccountingSectionConfigs() { buildAccountingSectionConfigs() {
this.accountingSectionConfigs = [ this.accountingSectionConfigs = [
{
key: 'assets',
title: 'Assets',
pillLabel: 'Asset',
pillClass: 'green',
rows: [
{
key: 'shareReferenceId',
label: 'Share Reference (Cash Vault or Suspense)',
inputLabel: 'Share reference',
controlName: 'shareReferenceId',
options: this.referenceAccountData,
required: true
}
],
otherMappingsControlName: 'assetsOtherGlMappings',
otherAccountOptions: this.assetAccountData
},
{ {
key: 'liabilities', key: 'liabilities',
title: 'Liabilities', title: 'Liabilities',
@@ -239,7 +206,7 @@ export class ShareProductAccountingStepComponent implements OnInit {
} }
get shareProductAccounting() { get shareProductAccounting() {
const { assetsOtherGlMappings, liabilitiesOtherGlMappings, incomeOtherGlMappings, equityOtherGlMappings, ...rest } = const { liabilitiesOtherGlMappings, incomeOtherGlMappings, equityOtherGlMappings, ...rest } =
this.shareProductAccountingForm.value; this.shareProductAccountingForm.value;
return rest; return rest;
} }
@@ -213,14 +213,6 @@
</div> </div>
@if (shareProduct.accountingRule === 2) { @if (shareProduct.accountingRule === 2) {
<div class="shpp-sub-header">{{ 'labels.heading.Assets' | translate }}</div>
<div class="shpp-row">
<span class="shpp-label">{{ 'labels.inputs.Share reference' | translate }}:</span>
<span class="shpp-value">{{
shareProduct.shareReferenceId
| find: shareProductsTemplate.accountingMappingOptions.assetAccountOptions : 'id' : 'name'
}}</span>
</div>
<div class="shpp-sub-header">{{ 'labels.heading.Liabilities' | translate }}</div> <div class="shpp-sub-header">{{ 'labels.heading.Liabilities' | translate }}</div>
<div class="shpp-row"> <div class="shpp-row">
<span class="shpp-label">{{ 'labels.inputs.Share suspense control' | translate }}:</span> <span class="shpp-label">{{ 'labels.inputs.Share suspense control' | translate }}:</span>
@@ -208,16 +208,6 @@
@if (shareProduct.accountingRule.id === 2) { @if (shareProduct.accountingRule.id === 2) {
<div class="layout-row-wrap responsive-column flex-fill"> <div class="layout-row-wrap responsive-column flex-fill">
<h4 class="mat-h4 flex-fill">{{ 'labels.heading.Assets' | translate }}</h4>
<div class="flex-fill">
<mifosx-gl-account-display
class="flex-100"
[accountTitle]="'Share reference'"
[glAccount]="shareProduct.accountingMappings.shareReferenceId"
[withTitle]="'40%'"
>
</mifosx-gl-account-display>
</div>
<h4 class="mat-h4 flex-fill">{{ 'labels.heading.Liabilities' | translate }}</h4> <h4 class="mat-h4 flex-fill">{{ 'labels.heading.Liabilities' | translate }}</h4>
<div class="flex-fill"> <div class="flex-fill">
<mifosx-gl-account-display <mifosx-gl-account-display
+2
View File
@@ -56,6 +56,7 @@ import {
faGavel, faGavel,
faGlobe, faGlobe,
faHandHoldingUsd, faHandHoldingUsd,
faHandPaper,
faHome, faHome,
faKey, faKey,
faLink, faLink,
@@ -303,6 +304,7 @@ export class IconsModule {
faHandHoldingHand, faHandHoldingHand,
faHandHoldingHeart, faHandHoldingHeart,
faHandHoldingUsd, faHandHoldingUsd,
faHandPaper,
faHeart, faHeart,
faHome, faHome,
faKey, faKey,
@@ -6,112 +6,189 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/. file, You can obtain one at http://mozilla.org/MPL/2.0/.
--> -->
<div class="container"> <div class="container action-container">
<mat-card> <mat-card class="action-card shadow-sm">
<!-- Compact Header -->
<div class="card-header-banner">
<div class="header-icon-box">
<mat-icon>add_card</mat-icon>
</div>
<div class="header-text font-accent">
<h2>{{ 'Apply Additional Shares' | translate }}</h2>
<p>Purchase additional share tranches via cash receipts or savings debit</p>
</div>
</div>
<form [formGroup]="applySharesForm" (ngSubmit)="submit()"> <form [formGroup]="applySharesForm" (ngSubmit)="submit()">
<mat-card-content> <mat-card-content class="card-body">
<div class="layout-column"> <!-- SECTION 1: Application & Resolution Details -->
<mat-form-field (click)="requestedDatePicker.open()"> <div class="form-section">
<mat-label>{{ 'labels.inputs.Request Date' | translate }}</mat-label> <div class="section-title">
<input <mat-icon class="sec-icon">assignment</mat-icon>
matInput <span>Application & Resolution Details</span>
[min]="minDate"
[max]="maxDate"
[matDatepicker]="requestedDatePicker"
required
formControlName="requestedDate"
/>
<mat-datepicker-toggle matSuffix [for]="requestedDatePicker"></mat-datepicker-toggle>
<mat-datepicker #requestedDatePicker></mat-datepicker>
@if (applySharesForm.controls.requestedDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Request Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<!-- The board resolution authorising this tranche. Its own meeting and number, not the
one the account was opened under, so it is captured per purchase. -->
<mat-form-field>
<mat-label>{{ 'labels.inputs.Resolution Number' | translate }}</mat-label>
<input matInput required formControlName="resolutionNumber" />
@if (applySharesForm.controls.resolutionNumber.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Resolution Number' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<mat-form-field (click)="resolutionDatePicker.open()">
<mat-label>{{ 'labels.inputs.Resolution Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="resolutionDatePicker"
required
formControlName="resolutionDate"
/>
<mat-datepicker-toggle matSuffix [for]="resolutionDatePicker"></mat-datepicker-toggle>
<mat-datepicker #resolutionDatePicker></mat-datepicker>
@if (applySharesForm.controls.resolutionDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Resolution Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<mat-form-field>
<mat-label>{{ 'labels.inputs.Total No. of Shares' | translate }}</mat-label>
<input matInput type="number" min="1" required formControlName="requestedShares" />
@if (applySharesForm.controls.requestedShares.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Requested Shares' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<mat-form-field>
<mat-label>{{ 'labels.inputs.Current Price' | translate }}</mat-label>
<input matInput formControlName="unitPrice" />
</mat-form-field>
<!-- Shares x price, computed rather than entered, so the value posted to the GL is the
value the clerk read back to the member. -->
<mat-form-field>
<mat-label>{{ 'labels.inputs.Amount' | translate }}</mat-label>
<input matInput formControlName="amount" />
<mat-hint
>{{ 'labels.inputs.Total No. of Shares' | translate }} &times;
{{ 'labels.inputs.Current Price' | translate }}</mat-hint
>
</mat-form-field>
<!-- Where the money comes from. Both routes land in Share Sundry, and approval is what
moves it out to share capital. -->
<div class="payment-mode">
<div class="payment-mode-label">{{ 'labels.inputs.Payment Option' | translate }}</div>
<mat-radio-group formControlName="paymentMode" class="payment-mode-options">
<mat-radio-button value="CASH">Apply Collection (Cash)</mat-radio-button>
<mat-radio-button value="SAVINGS">Debit from Savings</mat-radio-button>
</mat-radio-group>
</div> </div>
@if (paymentMode === 'CASH') { <div class="form-grid form-grid-3">
<div class="funding-panel"> <!-- Request Date -->
<p class="funding-intro"> <mat-form-field appearance="outline" density="compact" (click)="requestedDatePicker.open()">
The member has already paid at the counter. Tick the receipts that make up this payment, or enter a <mat-label>{{ 'labels.inputs.Request Date' | translate }}</mat-label>
voucher number to find one — tick more than one where they paid in instalments. The cash is sitting in <input
Share Sundry against them, and approving this request moves it into share capital. matInput
</p> [min]="minDate"
[max]="maxDate"
[matDatepicker]="requestedDatePicker"
required
formControlName="requestedDate"
/>
<mat-datepicker-toggle matSuffix [for]="requestedDatePicker"></mat-datepicker-toggle>
<mat-datepicker #requestedDatePicker></mat-datepicker>
@if (applySharesForm.controls.requestedDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Request Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<div class="gl-select-field"> <!-- Resolution Number -->
<mat-form-field appearance="outline" density="compact">
<mat-label>{{ 'labels.inputs.Resolution Number' | translate }}</mat-label>
<input matInput required formControlName="resolutionNumber" placeholder="e.g. RES-2026-08" />
@if (applySharesForm.controls.resolutionNumber.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Resolution Number' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<!-- Resolution Date -->
<mat-form-field appearance="outline" density="compact" (click)="resolutionDatePicker.open()">
<mat-label>{{ 'labels.inputs.Resolution Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="resolutionDatePicker"
required
formControlName="resolutionDate"
/>
<mat-datepicker-toggle matSuffix [for]="resolutionDatePicker"></mat-datepicker-toggle>
<mat-datepicker #resolutionDatePicker></mat-datepicker>
@if (applySharesForm.controls.resolutionDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Resolution Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
</div>
</div>
<!-- SECTION 2: Share Purchase Calculation -->
<div class="form-section financial-section">
<div class="section-title">
<mat-icon class="sec-icon">calculate</mat-icon>
<span>Share Purchase Calculation</span>
</div>
<div class="form-grid form-grid-3 align-items-center">
<!-- Total No. of Shares -->
<mat-form-field appearance="outline" density="compact">
<mat-label>{{ 'labels.inputs.Total No. of Shares' | translate }}</mat-label>
<input
matInput
type="number"
min="1"
required
formControlName="requestedShares"
placeholder="e.g. 100"
class="shares-count-input"
/>
@if (applySharesForm.controls.requestedShares.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Requested Shares' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<!-- Current Price -->
<mat-form-field appearance="outline" density="compact" class="price-field">
<mat-label>{{ 'labels.inputs.Current Price' | translate }}</mat-label>
<input matInput formControlName="unitPrice" class="price-input" />
<span matSuffix class="currency-label">per share</span>
</mat-form-field>
<!-- Computed Amount Card -->
<div class="amount-display-card">
<span class="amount-label">{{ 'labels.inputs.Amount' | translate }}</span>
<span class="amount-value">{{ amount | number: '1.2-2' }}</span>
<span class="amount-subtext"
>{{ applySharesForm.get('requestedShares').value || 0 }} shares &times;
{{ sharesAccountData?.currentMarketPrice || 0 }}</span
>
</div>
</div>
</div>
<!-- SECTION 3: Payment / Funding Option -->
<div class="form-section">
<div class="section-title">
<mat-icon class="sec-icon">account_balance_wallet</mat-icon>
<span>{{ 'labels.inputs.Payment Option' | translate }}</span>
</div>
<div class="payment-card-group">
<div class="payment-card-option" [class.active]="paymentMode === 'CASH'" (click)="setPaymentMode('CASH')">
<div class="option-icon-box">
<mat-icon>payments</mat-icon>
</div>
<div class="option-content">
<span class="option-title">Apply Collection (Cash)</span>
<span class="option-desc">Link receipts paid at counter sitting in Share Sundry</span>
</div>
@if (paymentMode === 'CASH') {
<mat-icon class="selected-check font-success">check_circle</mat-icon>
}
</div>
<div
class="payment-card-option"
[class.active]="paymentMode === 'SAVINGS'"
(click)="setPaymentMode('SAVINGS')"
>
<div class="option-icon-box">
<mat-icon>account_balance</mat-icon>
</div>
<div class="option-content">
<span class="option-title">Debit from Savings</span>
<span class="option-desc">Direct debit from member's linked savings account</span>
</div>
@if (paymentMode === 'SAVINGS') {
<mat-icon class="selected-check font-success">check_circle</mat-icon>
}
</div>
</div>
</div>
<!-- CASH FUNDING DETAILS -->
@if (paymentMode === 'CASH') {
<div class="funding-panel cash-panel animate-fade-in">
<div class="funding-intro-banner">
<mat-icon class="banner-icon">info</mat-icon>
<span>
Select the one receipt that covers this payment, or enter a voucher number to search. Click
<mat-icon class="inline-icon">visibility</mat-icon> on any row to view full details popup.
</span>
</div>
<!-- Toolbar: GL Account Select & Voucher Lookup -->
<div class="lookup-toolbar">
<div class="gl-select-box">
<label class="toolbar-label"> <mat-icon class="mini-icon">account_tree</mat-icon> GL Account </label>
<select <select
class="gl-select" class="custom-select"
[(ngModel)]="glAccountNo" [(ngModel)]="glAccountNo"
[ngModelOptions]="{ standalone: true }" [ngModelOptions]="{ standalone: true }"
[disabled]="lookingUpVoucher || submitting || loadingGlAccounts" [disabled]="lookingUpVoucher || submitting || loadingGlAccounts"
@@ -125,173 +202,394 @@
</select> </select>
</div> </div>
<div class="voucher-lookup"> <div class="voucher-lookup-box">
<input <label class="toolbar-label">
#voucherInput <mat-icon class="mini-icon">search</mat-icon> Find Specific Voucher
type="text" </label>
class="voucher-input" <div class="lookup-input-group">
placeholder="Voucher no. (e.g. 000123)" <input
[disabled]="lookingUpVoucher || submitting" #voucherInput
(keyup.enter)="lookupVoucher(voucherInput.value)" type="text"
/> class="lookup-input font-mono"
<button placeholder="Voucher no. (e.g. 000123)"
mat-stroked-button [disabled]="lookingUpVoucher || submitting"
type="button" (keyup.enter)="lookupVoucher(voucherInput.value)"
[disabled]="!voucherInput.value.trim() || !glAccountNo.trim() || lookingUpVoucher || submitting" />
(click)="lookupVoucher(voucherInput.value)" <button
> mat-flat-button
{{ lookingUpVoucher ? 'Finding…' : 'Find' }} color="primary"
</button> type="button"
</div> class="find-btn"
[disabled]="!voucherInput.value.trim() || !glAccountNo.trim() || lookingUpVoucher || submitting"
@if (voucherError) { (click)="lookupVoucher(voucherInput.value)"
<div class="alert-error"> >
<mat-icon>error_outline</mat-icon> @if (lookingUpVoucher) {
<span>{{ voucherError }}</span> <mat-spinner diameter="14"></mat-spinner>
} @else {
<mat-icon>search</mat-icon>
<span>Find</span>
}
</button>
</div> </div>
} </div>
</div>
@if (voucherError) {
<div class="alert-box alert-error">
<mat-icon>error_outline</mat-icon>
<span>{{ voucherError }}</span>
</div>
}
<!-- Vouchers Array List / Table -->
<div class="vouchers-array-container">
@if (loadingVouchers) { @if (loadingVouchers) {
<div class="funding-loading"> <div class="funding-loading">
<mat-spinner diameter="24"></mat-spinner> <mat-spinner diameter="24"></mat-spinner>
<span>Loading the member's receipts…</span> <span>Loading member receipts…</span>
</div> </div>
} @else if (vouchers.length === 0) { } @else if (vouchers.length === 0) {
<div class="funding-empty"> <div class="funding-empty">
<mat-icon>info_outline</mat-icon> <mat-icon class="empty-icon">receipt_long</mat-icon>
<div> <div class="empty-text">
<b>No unapplied receipt for this member.</b> <strong>No unapplied receipts found for this member.</strong>
<div class="funding-empty-hint"> <p>
Collect the cash first from Cashier &rsaquo; Transaction Entry as a deposit to the Share Sundry Collect cash at <em>Cashier &rsaquo; Transaction Entry</em> as a deposit to Share Sundry GL. Once
GL. Once it is approved, the voucher appears here. posted, it will appear here.
</div> </p>
</div> </div>
</div> </div>
} @else { } @else {
<!-- Checkboxes, not radios: a member paying in instalments has several receipts <div class="vouchers-card">
against one request, and they fund it together. --> <!-- Array Header Toolbar -->
<table class="receipt-table"> <div class="vouchers-header">
<thead> <div class="vouchers-title font-accent">
<tr> <mat-icon class="v-icon">receipt</mat-icon>
<th></th> <span>Vouchers / Receipts</span>
<th class="ta-left">Voucher / Receipt</th> <span class="badge-count">{{ vouchers.length }}</span>
<th class="ta-left">Date</th> </div>
<th class="ta-right">Amount</th> </div>
</tr>
</thead> <!-- Compact Table View -->
<tbody> <div class="table-responsive">
@for (v of vouchers; track v.id) { <table class="receipt-table">
<tr [class.selected]="isVoucherSelected(v.id)" (click)="toggleVoucher(v.id)"> <thead>
<td class="ta-center"> <tr>
<input <th class="col-checkbox ta-center">#</th>
type="checkbox" <th class="ta-left">Voucher / Receipt No</th>
[checked]="isVoucherSelected(v.id)" <th class="ta-left">Date</th>
(change)="toggleVoucher(v.id)" <th class="ta-left">Description</th>
(click)="$event.stopPropagation()" <th class="ta-left">Collected By</th>
/> <th class="ta-right">Amount</th>
</td> <th class="col-actions ta-center">Detail</th>
<td class="ta-left mono">{{ v.voucher_no || v.receipt_no }}</td> </tr>
<td class="ta-left">{{ v.collection_date }}</td> </thead>
<td class="ta-right"> <tbody>
<b>{{ v.amount | number: '1.2-2' }}</b> <!-- One receipt funds one tranche — no combining instalments here. -->
</td> @for (v of vouchers; track v.id) {
</tr> <tr
} class="receipt-row"
</tbody> [class.selected]="isVoucherSelected(v.id)"
@if (selectedVoucherIds.length > 0) { (click)="toggleVoucher(v.id)"
<tfoot> >
<tr class="total-row"> <td class="col-checkbox ta-center" (click)="$event.stopPropagation()">
<td></td> <input
<td class="ta-left" colspan="2"> type="radio"
{{ selectedVoucherIds.length }} selected, against {{ amount | number: '1.2-2' }} name="voucherSelect"
</td> class="custom-checkbox"
<td class="ta-right"> [checked]="isVoucherSelected(v.id)"
<b>{{ selectedVoucherTotal | number: '1.2-2' }}</b> (change)="toggleVoucher(v.id)"
</td> />
</tr> </td>
</tfoot> <td class="ta-left">
} <span class="voucher-badge font-mono">{{ v.voucher_no || v.receipt_no }}</span>
</table> </td>
<td class="ta-left date-cell">{{ v.collection_date }}</td>
<td class="ta-left desc-cell" [title]="v.note">{{ v.note || 'Share Collection' }}</td>
<td class="ta-left user-cell">
<span class="user-chip">
<mat-icon class="chip-icon">person_outline</mat-icon>
{{ v.collected_by_name || 'System' }}
</span>
</td>
<td class="ta-right amount-cell">
<span class="val font-semibold">{{ v.amount | number: '1.2-2' }}</span>
</td>
<td class="col-actions ta-center" (click)="$event.stopPropagation()">
<button
type="button"
mat-icon-button
class="view-detail-btn"
title="View Full Details Popup"
(click)="openVoucherDetail(v, $event)"
>
<mat-icon class="eye-icon">visibility</mat-icon>
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Summary Footer Bar -->
<div class="vouchers-footer">
<div class="selected-summary">
<span class="summary-text font-accent">
<strong>{{ selectedVoucherIds.length }}</strong> of
<strong>{{ vouchers.length }}</strong> receipts selected
</span>
<div class="summary-amounts">
<span class="total-label">Selected Total:</span>
<span class="total-value font-semibold">{{ selectedVoucherTotal | number: '1.2-2' }}</span>
<span class="target-divider">/</span>
<span class="target-value">Target {{ amount | number: '1.2-2' }}</span>
</div>
</div>
<!-- Coverage Status Indicator Pill -->
<div class="coverage-status">
@if (selectedVoucherIds.length === 0) {
<span class="status-badge badge-neutral">
<mat-icon>info</mat-icon> Tick at least 1 receipt
</span>
} @else if (voucherShortfall > 0) {
<span class="status-badge badge-warning">
<mat-icon>warning</mat-icon> Shortfall {{ voucherShortfall | number: '1.2-2' }}
</span>
} @else {
<span class="status-badge badge-success"> <mat-icon>check_circle</mat-icon> Fully Funded </span>
}
</div>
</div>
</div>
<!-- Says how much is still missing rather than only that it is short, so the clerk
can tell the member what to pay without working it out. -->
@if (selectedVoucherIds.length > 0 && voucherShortfall > 0) { @if (selectedVoucherIds.length > 0 && voucherShortfall > 0) {
<div class="alert-error"> <div class="alert-box alert-warning mt-8">
<mat-icon>error_outline</mat-icon> <mat-icon>error_outline</mat-icon>
<span> <span>
The selected receipts are {{ voucherShortfall | number: '1.2-2' }} short of Selected total {{ selectedVoucherTotal | number: '1.2-2' }} is
{{ amount | number: '1.2-2' }}. Tick another receipt, or collect the balance at the counter first. <strong>{{ voucherShortfall | number: '1.2-2' }} short</strong> of tranche target
{{ amount | number: '1.2-2' }}. Tick another receipt or collect balance.
</span> </span>
</div> </div>
} }
} }
</div> </div>
} </div>
}
@if (paymentMode === 'SAVINGS') { <!-- SAVINGS FUNDING DETAILS -->
<div class="funding-panel"> @if (paymentMode === 'SAVINGS') {
<p class="funding-intro"> <div class="funding-panel savings-panel animate-fade-in">
The amount is debited from the member's linked savings into Share Sundry when this is submitted, so <div class="funding-intro-banner">
their balance drops now. Approving the request then moves it into share capital. <mat-icon class="banner-icon">info</mat-icon>
</p> <span>
The tranche amount will be automatically debited from the member's linked savings account into Share
Sundry upon submission.
</span>
</div>
@if (checkingSavings) { @if (checkingSavings) {
<div class="funding-loading"> <div class="funding-loading">
<mat-spinner diameter="24"></mat-spinner> <mat-spinner diameter="24"></mat-spinner>
<span>Checking the linked savings account…</span> <span>Checking linked savings account eligibility…</span>
</div> </div>
} @else if (savingsEligibility) { } @else if (savingsEligibility) {
<div class="funding-summary"> <div class="savings-card">
@if (savingsEligibility.savingsAccountNo) { @if (savingsEligibility.savingsAccountNo) {
<div class="funding-row"> <div class="savings-row">
<span>Savings account</span> <span class="lbl">Savings Account No.</span>
<span class="mono">{{ savingsEligibility.savingsAccountNo }}</span> <span class="val font-mono">{{ savingsEligibility.savingsAccountNo }}</span>
</div>
<div class="funding-row">
<span>Available balance</span>
<span>{{ savingsEligibility.savingsBalance | number: '1.2-2' }}</span>
</div>
}
<div class="funding-row funding-row--total">
<span>To debit</span>
<span>{{ amount | number: '1.2-2' }}</span>
</div> </div>
</div> <div class="savings-row">
<span class="lbl">Available Balance</span>
@if (!savingsEligibility.eligible) { <span class="val font-semibold">{{ savingsEligibility.savingsBalance | number: '1.2-2' }}</span>
<div class="alert-error">
<mat-icon>error_outline</mat-icon>
<span>{{ savingsEligibility.reason }}</span>
</div> </div>
} }
<div class="savings-row savings-row-total">
<span class="lbl">Amount to Debit</span>
<span class="val font-semibold text-primary">{{ amount | number: '1.2-2' }}</span>
</div>
</div>
@if (!savingsEligibility.eligible) {
<div class="alert-box alert-error mt-8">
<mat-icon>error_outline</mat-icon>
<span>{{ savingsEligibility.reason }}</span>
</div>
} }
</div> }
} </div>
}
@if (errorMsg) { @if (errorMsg) {
<div class="alert-error"> <div class="alert-box alert-error mt-12">
<mat-icon>error_outline</mat-icon> <mat-icon>error_outline</mat-icon>
<span>{{ errorMsg }}</span> <span>{{ errorMsg }}</span>
</div> </div>
} }
<!-- Said plainly, so a clerk does not tell the member their shares are issued while a <!-- PENDING APPROVAL NOTICE -->
manager still has to approve the request. --> <div class="pending-notice mt-10">
<p class="pending-hint"> <mat-icon class="notice-icon">schedule</mat-icon>
Submitting records a request for approval. The shares are issued, and the money moved out of Share Sundry, <span>
only once it is approved — until then it appears under pending, not in the account's transactions. Submitting records a request for approval. Shares are issued and funds moved out of Share Sundry only upon
</p> manager approval.
</span>
</div> </div>
</mat-card-content> </mat-card-content>
<mat-card-actions class="layout-row align-center gap-5px responsive-column"> <!-- CARD ACTIONS -->
<button type="button" mat-raised-button [routerLink]="['../../']"> <mat-card-actions class="card-actions-bar">
<button type="button" mat-stroked-button class="action-btn cancel-btn" [routerLink]="['../../']">
{{ 'labels.buttons.Cancel' | translate }} {{ 'labels.buttons.Cancel' | translate }}
</button> </button>
<button mat-raised-button color="primary" [disabled]="!canSubmit">
{{ submitting ? 'Submitting…' : ('labels.buttons.Submit' | translate) }} <button mat-flat-button color="primary" class="action-btn submit-btn" [disabled]="!canSubmit">
@if (submitting) {
<mat-spinner diameter="16"></mat-spinner>
<span>Submitting…</span>
} @else {
<mat-icon>send</mat-icon>
<span>{{ 'labels.buttons.Submit' | translate }}</span>
}
</button> </button>
</mat-card-actions> </mat-card-actions>
</form> </form>
</mat-card> </mat-card>
</div> </div>
<!-- VOUCHER FULL DETAILS POPUP MODAL -->
@if (viewingVoucherDetail) {
<div class="modal-backdrop animate-fade-in" (click)="closeVoucherDetail()">
<div class="voucher-modal-dialog" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="header-title font-accent">
<mat-icon class="m-icon">receipt_long</mat-icon>
<span>Voucher Details</span>
<span class="modal-badge font-mono">{{
viewingVoucherDetail.voucher_no || viewingVoucherDetail.receipt_no
}}</span>
</div>
<button type="button" mat-icon-button class="close-btn" (click)="closeVoucherDetail()">
<mat-icon>close</mat-icon>
</button>
</div>
<div class="modal-body">
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label">Voucher / Receipt No</span>
<span class="detail-val font-mono font-semibold">{{
viewingVoucherDetail.voucher_no || viewingVoucherDetail.receipt_no || '—'
}}</span>
</div>
<div class="detail-item">
<span class="detail-label">Receipt Reference</span>
<span class="detail-val font-mono">{{ viewingVoucherDetail.receipt_no || '—' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Client Name</span>
<span class="detail-val">{{
viewingVoucherDetail.client_name || sharesAccountData?.clientName || 'Member'
}}</span>
</div>
<div class="detail-item">
<span class="detail-label">Client ID</span>
<span class="detail-val font-mono">{{
viewingVoucherDetail.client_id || sharesAccountData?.clientId || '—'
}}</span>
</div>
<div class="detail-item">
<span class="detail-label">Collection Date</span>
<span class="detail-val">{{ viewingVoucherDetail.collection_date || '—' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Status</span>
<span class="status-pill status-unapplied">{{ viewingVoucherDetail.status || 'UNAPPLIED' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Collected By</span>
<span class="detail-val">{{ viewingVoucherDetail.collected_by_name || 'System Administrator' }}</span>
</div>
<div class="detail-item full-width highlight-amount-box">
<span class="detail-label">Receipt Amount</span>
<span class="amount-val font-mono">{{ viewingVoucherDetail.amount | number: '1.2-2' }}</span>
</div>
<div class="detail-item full-width">
<span class="detail-label">Full Note / Description</span>
<div class="note-box font-accent">
{{ viewingVoucherDetail.note || 'Share collection deposit to Share Sundry GL' }}
</div>
</div>
<!-- The receipt's own actual GL posting — never the GL Account dropdown above the
voucher search box, which is unrelated screen state and can name a completely
different account than the one this specific receipt sits in. -->
<div class="detail-item full-width">
<span class="detail-label">GL Entry (this receipt's actual posting)</span>
@if (loadingGlEntries) {
<div class="gl-entries-loading">Loading…</div>
} @else if (voucherGlEntries.length === 0) {
<div class="gl-entries-empty">No GL posting recorded for this receipt.</div>
} @else {
<table class="gl-entries-table">
<thead>
<tr>
<th class="ta-left">GL Account</th>
<th class="ta-center">Dr / Cr</th>
<th class="ta-right">Amount</th>
</tr>
</thead>
<tbody>
@for (leg of voucherGlEntries; track leg.glCode + leg.drCr) {
<tr>
<td class="ta-left font-mono">{{ leg.glCode }} — {{ leg.accountName }}</td>
<td class="ta-center">
<span
class="drcr-pill"
[class.drcr-pill--dr]="leg.drCr === 'Dr'"
[class.drcr-pill--cr]="leg.drCr === 'Cr'"
>
{{ leg.drCr }}
</span>
</td>
<td class="ta-right font-mono">{{ leg.amount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
}
</div>
</div>
</div>
<div class="modal-footer">
<button
type="button"
mat-flat-button
[color]="isVoucherSelected(viewingVoucherDetail.id) ? 'warn' : 'primary'"
class="modal-action-btn"
(click)="toggleVoucher(viewingVoucherDetail.id); closeVoucherDetail()"
>
<mat-icon>{{
isVoucherSelected(viewingVoucherDetail.id) ? 'remove_circle_outline' : 'check_circle_outline'
}}</mat-icon>
<span>{{ isVoucherSelected(viewingVoucherDetail.id) ? 'Unselect Receipt' : 'Select Receipt' }}</span>
</button>
<button type="button" mat-stroked-button (click)="closeVoucherDetail()">Close</button>
</div>
</div>
</div>
}
File diff suppressed because it is too large Load Diff
@@ -107,6 +107,15 @@ export class ApplySharesComponent implements OnInit {
glAccounts: any[] = []; glAccounts: any[] = [];
loadingGlAccounts = true; loadingGlAccounts = true;
/** Currently selected voucher for viewing in the detail popup. */
viewingVoucherDetail: any = null;
/** The receipt's own real Dr/Cr GL legs, fetched fresh per voucher — never the GL Account
* dropdown above (that is just what the by-voucher search box is currently set to, which can
* name a completely different account than the one this specific receipt actually sits in). */
voucherGlEntries: { glCode: string; accountName: string; drCr: string; amount: number }[] = [];
loadingGlEntries = false;
/** What the member's savings can cover, for the savings route. */ /** What the member's savings can cover, for the savings route. */
savingsEligibility: { savingsEligibility: {
eligible: boolean; eligible: boolean;
@@ -134,10 +143,43 @@ export class ApplySharesComponent implements OnInit {
.pipe(catchError(() => of(null))) .pipe(catchError(() => of(null)))
.subscribe((res: any) => { .subscribe((res: any) => {
this.glAccounts = res?.glAccounts || []; this.glAccounts = res?.glAccounts || [];
// Auto-picked only when there is exactly one live candidate -- the backend already narrowed
// this list to accounts that actually hold an eligible receipt today, so a single option is
// unambiguous. Two or more still means the counter chooses by hand: picking "whichever
// sorts first" among several real candidates is the same blind guess that used to land on
// Cash at Vault, just with a shorter, filtered list to guess from.
if (this.glAccounts.length === 1 && !this.glAccountNo) {
this.glAccountNo = this.glAccounts[0].gl_code;
}
this.loadingGlAccounts = false; this.loadingGlAccounts = false;
}); });
} }
setPaymentMode(mode: string): void {
this.applySharesForm.get('paymentMode')?.setValue(mode);
}
openVoucherDetail(voucher: any, event?: Event): void {
if (event) {
event.stopPropagation();
}
this.viewingVoucherDetail = voucher;
this.voucherGlEntries = [];
this.loadingGlEntries = true;
this.cashierService
.getShareAdvanceGlEntries(voucher.id)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.voucherGlEntries = res?.entries || [];
this.loadingGlEntries = false;
});
}
closeVoucherDetail(): void {
this.viewingVoucherDetail = null;
this.voucherGlEntries = [];
}
/** /**
* Creates the apply shares form. * Creates the apply shares form.
* *
@@ -254,12 +296,24 @@ export class ApplySharesComponent implements OnInit {
return this.selectedVoucherIds.includes(id); return this.selectedVoucherIds.includes(id);
} }
/**
* One receipt funds one tranche — no combining instalments here (unlike the first-application
* Apply Collection dialog, which still allows several). Picking a different receipt replaces the
* current one rather than adding to it, so {@link selectedVoucherIds} never holds more than one id.
*/
toggleVoucher(id: number): void { toggleVoucher(id: number): void {
this.selectedVoucherIds = this.isVoucherSelected(id) ? this.selectedVoucherIds.filter((x) => x !== id) : [ const wasSelected = this.isVoucherSelected(id);
...this.selectedVoucherIds, this.selectedVoucherIds = wasSelected ? [] : [id];
id
];
this.voucherError = ''; this.voucherError = '';
// The GL Account picker above follows whichever receipt is ticked — its own actual GL account
// (see gl_code on the row), not left showing an unrelated account or "Select…" once a receipt
// that plainly does have one is already chosen.
if (!wasSelected) {
const voucher = this.vouchers.find((v) => v.id === id);
if (voucher?.gl_code) {
this.glAccountNo = voucher.gl_code;
}
}
} }
/** /**
@@ -307,19 +361,13 @@ export class ApplySharesComponent implements OnInit {
this.voucherError = `Voucher ${trimmed} belongs to ${res.client_name}, not the holder of this share account.`; this.voucherError = `Voucher ${trimmed} belongs to ${res.client_name}, not the holder of this share account.`;
return; return;
} }
// Added to the selection rather than replacing it — a looked-up voucher is usually the // Replaces the selection rather than adding to it — one receipt funds one tranche here, so
// second half of a part-payment, not a change of mind about the first. No amount check // finding a voucher by number is a different way to pick THE receipt, not a second one.
// here: one instalment is not meant to cover the tranche on its own.
this.vouchers = [ this.vouchers = [
res, res,
...this.vouchers.filter((v) => v.id !== res.id) ...this.vouchers.filter((v) => v.id !== res.id)
]; ];
if (!this.isVoucherSelected(res.id)) { this.selectedVoucherIds = [res.id];
this.selectedVoucherIds = [
...this.selectedVoucherIds,
res.id
];
}
}); });
} }
@@ -168,6 +168,8 @@
<th class="col-tick"></th> <th class="col-tick"></th>
<th class="ta-left">Voucher / Receipt</th> <th class="ta-left">Voucher / Receipt</th>
<th class="ta-left">Date</th> <th class="ta-left">Date</th>
<th class="ta-left">Description</th>
<th class="ta-left">Collected By</th>
<th class="ta-right">Amount</th> <th class="ta-right">Amount</th>
</tr> </tr>
</thead> </thead>
@@ -186,6 +188,8 @@
</td> </td>
<td class="ta-left mono">{{ r.voucher_no || r.receipt_no }}</td> <td class="ta-left mono">{{ r.voucher_no || r.receipt_no }}</td>
<td class="ta-left">{{ r.collection_date }}</td> <td class="ta-left">{{ r.collection_date }}</td>
<td class="ta-left" [title]="r.note">{{ r.note || '—' }}</td>
<td class="ta-left">{{ r.collected_by_name || '—' }}</td>
<td class="ta-right amount-cell">{{ r.amount | number: '1.2-2' }}</td> <td class="ta-right amount-cell">{{ r.amount | number: '1.2-2' }}</td>
</tr> </tr>
} }
@@ -197,7 +201,7 @@
<tfoot> <tfoot>
<tr class="total-row"> <tr class="total-row">
<td class="col-tick"></td> <td class="col-tick"></td>
<td class="ta-left" colspan="2"> <td class="ta-left" colspan="4">
{{ selectedIds.length }} {{ selectedIds.length === 1 ? 'receipt' : 'receipts' }} selected {{ selectedIds.length }} {{ selectedIds.length === 1 ? 'receipt' : 'receipts' }} selected
</td> </td>
<td class="ta-right amount-cell">{{ selectedTotal | number: '1.2-2' }}</td> <td class="ta-right amount-cell">{{ selectedTotal | number: '1.2-2' }}</td>
@@ -135,6 +135,12 @@ export class ApplyCollectionDialogComponent implements OnInit {
) )
.subscribe((res: any) => { .subscribe((res: any) => {
this.glAccounts = res?.glAccounts || []; this.glAccounts = res?.glAccounts || [];
// Auto-picked only when there is exactly one live candidate -- the backend already narrowed
// this list to accounts that actually hold an eligible receipt today, so a single option is
// unambiguous. Two or more still means the counter chooses by hand.
if (this.glAccounts.length === 1 && !this.glAccountNo) {
this.glAccountNo = this.glAccounts[0].gl_code;
}
this.loadingGlAccounts = false; this.loadingGlAccounts = false;
}); });
@@ -384,18 +384,59 @@
/* Print CSS */ /* Print CSS */
@media print { @media print {
/* Hide non-overlay contents during print */ ::ng-deep html,
::ng-deep body {
height: auto !important;
overflow: visible !important;
}
/* Hide non-overlay contents during print. Deliberately NOT `::ng-deep` here: under Angular's
emulated encapsulation a bare `body` selector never carries this component's scoping
attribute, so a scoped rule can never match the real <body> and is safely inert — whereas
`::ng-deep` emits it fully unscoped into the GLOBAL stylesheet for the rest of the session
(Angular never removes a component's injected styles when it's destroyed), silently hiding
every other page's print output app-wide the moment this dialog is opened once. The app
shell (nav, toolbar, sidebar) is already hidden for every print via the global rules in
main.scss, so this dialog doesn't need its own global override to get the same effect. */
body > :not(.cdk-overlay-container) { body > :not(.cdk-overlay-container) {
display: none !important; display: none !important;
} }
/* The dialog's own scroll region clips anything past 80vh -- printing only captures what a
scrollable/max-height box shows on screen, so both constraints must be lifted. */
.dialog-content-container {
max-height: none !important;
overflow: visible !important;
}
/* CDK's overlay wrapper/backdrop/pane are all position: fixed. Unset them so the full certificate prints statically. */
::ng-deep .cdk-overlay-backdrop {
display: none !important;
}
::ng-deep .cdk-overlay-container,
::ng-deep .cdk-global-overlay-wrapper,
::ng-deep .cdk-overlay-pane,
::ng-deep .mat-mdc-dialog-container,
::ng-deep .mdc-dialog__container,
::ng-deep .mdc-dialog__surface {
position: static !important;
display: block !important;
max-height: none !important;
max-width: none !important;
height: auto !important;
width: auto !important;
overflow: visible !important;
transform: none !important;
box-shadow: none !important;
background: transparent !important;
}
/* Make certificate fill page */ /* Make certificate fill page */
#share-certificate-print { #share-certificate-print {
position: absolute; position: static !important;
left: 0;
top: 0;
width: 100% !important; width: 100% !important;
height: 100% !important; height: auto !important;
padding: 0 !important; padding: 0 !important;
margin: 0 !important; margin: 0 !important;
background-color: #fff !important; background-color: #fff !important;
@@ -411,7 +452,135 @@
display: none !important; display: none !important;
} }
.mat-mdc-dialog-container { ::ng-deep .mat-mdc-dialog-container {
padding: 0 !important; padding: 0 !important;
} }
/* Compact every spacing/font value so the whole certificate fits a single printed page */
.certificate-container {
padding: 6px !important;
}
.certificate-border-inner {
padding: 14px !important;
}
.certificate-header {
margin-bottom: 10px !important;
padding-bottom: 10px !important;
.bank-name {
font-size: 19px !important;
margin-bottom: 2px !important;
}
.bank-sub {
font-size: 10px !important;
margin-bottom: 8px !important;
}
.cert-title {
font-size: 19px !important;
letter-spacing: 2px !important;
margin-bottom: 2px !important;
}
.cert-subtitle {
font-size: 9px !important;
}
}
.cert-meta-row {
margin-bottom: 10px !important;
padding-bottom: 8px !important;
.meta-label {
font-size: 9px !important;
margin-bottom: 1px !important;
}
.meta-value {
font-size: 12px !important;
}
}
.cert-body-section {
margin-bottom: 10px !important;
.cert-main-text {
font-size: 11px !important;
line-height: 1.4 !important;
margin-bottom: 10px !important;
}
}
.issued-to-details {
padding: 8px 14px !important;
margin: 8px auto !important;
.client-name-details {
font-size: 14px !important;
}
.client-ids,
.client-address {
font-size: 10px !important;
}
}
.certificate-details-table {
margin-bottom: 12px !important;
td {
padding: 5px 10px !important;
font-size: 10px !important;
}
.lbl-col-highlight {
font-size: 12px !important;
}
.val-col-highlight {
font-size: 13px !important;
}
}
.allotment-table {
margin-bottom: 12px !important;
th {
padding: 4px 8px !important;
font-size: 8px !important;
}
td {
padding: 4px 8px !important;
font-size: 9px !important;
}
}
.cert-footer {
margin-top: 8px !important;
.seal-ring {
width: 60px !important;
height: 60px !important;
}
.seal-text {
font-size: 8px !important;
}
.closing-text {
font-size: 10px !important;
}
.signature-line {
height: 22px !important;
}
.signatory-title {
font-size: 10px !important;
}
}
} }
@@ -5,124 +5,244 @@
License, v. 2.0. If a copy of the MPL was not distributed with this 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/. file, You can obtain one at http://mozilla.org/MPL/2.0/.
--> -->
<div class="inventory-container"> <!--
<div class="header-row"> Copyright since 2025 Mifos Initiative
<div class="title-area">
<h2>Sponsor Bank DD Inventory</h2> This Source Code Form is subject to the terms of the Mozilla Public
<p>Manage allocated ranges of physical Demand Draft leaflets received from Sponsor Banks.</p> 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="inv-container">
<!-- Hero header -->
<div class="inv-hero">
<div class="inv-hero__left">
<div class="inv-hero__icon">
<fa-icon icon="university"></fa-icon>
</div>
<div>
<h1 class="inv-hero__title">Sponsor Bank DD Inventory</h1>
<p class="inv-hero__sub">
Manage allocated ranges of physical Demand Draft leaflets received from Sponsor Banks.
</p>
</div>
</div> </div>
<button mat-flat-button color="primary" (click)="openAddInventoryDialog()">Register DD Series</button> <button class="inv-btn primary" (click)="openAddInventoryDialog()">
<fa-icon icon="plus"></fa-icon>
Register DD Series
</button>
</div> </div>
<!-- Says why the clerk is here when they arrived from a dividend claim rather than from the menu. <!-- Dividend Context Banner -->
Without it, a page listing every DD series gives no clue which member is waiting at the
counter or how much the draft has to be drawn for. -->
@if (dividendContext) { @if (dividendContext) {
<div class="dividend-banner"> <div class="inv-dividend-banner">
<div class="dividend-banner__text"> <div class="inv-dividend-banner__ic">
<strong>Issuing a dividend Demand Draft</strong> <fa-icon icon="hand-holding-usd"></fa-icon>
</div>
<div class="inv-dividend-banner__text">
<strong>Issuing Dividend Demand Draft</strong>
<span> <span>
{{ dividendContext.beneficiary }} · Beneficiary: <strong>{{ dividendContext.beneficiary }}</strong> · Amount:
{{ dividendContext.amount | number: '1.2-2' }} <strong>₹{{ dividendContext.amount | number: '1.2-2' }}</strong>
@if (dividendContext.financialYear) { @if (dividendContext.financialYear) {
· FY {{ dividendContext.financialYear }} · FY {{ dividendContext.financialYear }}
} }
</span> </span>
<span class="dividend-banner__hint">Open a series below and choose an available leaflet.</span> <span class="inv-dividend-banner__hint">Select a series below to pick an available leaflet.</span>
</div> </div>
<button mat-button class="action-btn" (click)="cancelDividendClaim()">Cancel</button> <button class="inv-btn ghost small" (click)="cancelDividendClaim()">
<fa-icon icon="times-circle"></fa-icon>
Cancel Claim
</button>
</div> </div>
} }
<mat-card> <!-- KPI Metrics Bar -->
<table mat-table [dataSource]="dataSource"> <div class="inv-kpi-grid">
<!-- ID Column --> <div class="inv-kpi-card">
<ng-container matColumnDef="id"> <div class="inv-kpi-card__icon blue">
<th mat-header-cell *matHeaderCellDef>Batch ID</th> <fa-icon icon="layer-group"></fa-icon>
<td mat-cell *matCellDef="let element">{{ element.id }}</td> </div>
</ng-container> <div class="inv-kpi-card__info">
<span class="inv-kpi-card__label">Total Series Batches</span>
<!-- Sponsor Bank Column --> <span class="inv-kpi-card__val">{{ totalBatches }}</span>
<ng-container matColumnDef="sponsorBankName"> </div>
<th mat-header-cell *matHeaderCellDef>Sponsor Bank</th>
<td mat-cell *matCellDef="let element">{{ element.sponsorBankName }}</td>
</ng-container>
<!-- Branch Column -->
<ng-container matColumnDef="branchName">
<th mat-header-cell *matHeaderCellDef>Branch</th>
<td mat-cell *matCellDef="let element">{{ element.branchName }}</td>
</ng-container>
<!-- Series Start Column -->
<ng-container matColumnDef="seriesStart">
<th mat-header-cell *matHeaderCellDef>Start No.</th>
<td mat-cell *matCellDef="let element">{{ element.seriesStart }}</td>
</ng-container>
<!-- Series End Column -->
<ng-container matColumnDef="seriesEnd">
<th mat-header-cell *matHeaderCellDef>End No.</th>
<td mat-cell *matCellDef="let element">{{ element.seriesEnd }}</td>
</ng-container>
<!-- Total Leaves Column -->
<ng-container matColumnDef="totalLeaves">
<th mat-header-cell *matHeaderCellDef>Total Leaves</th>
<td mat-cell *matCellDef="let element">{{ element.totalLeaves }}</td>
</ng-container>
<!-- Available Leaves Column -->
<ng-container matColumnDef="availableLeaves">
<th mat-header-cell *matHeaderCellDef>Available Leaves</th>
<td mat-cell *matCellDef="let element">
<span [class.leaves-low]="element.availableLeaves === 0">{{ element.availableLeaves }}</span>
</td>
</ng-container>
<!-- Received Date Column -->
<ng-container matColumnDef="receivedDate">
<th mat-header-cell *matHeaderCellDef>Received Date</th>
<td mat-cell *matCellDef="let element">{{ element.receivedDate }}</td>
</ng-container>
<!-- Status Column -->
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let element">
<span class="status-badge" [ngClass]="element.status.toLowerCase()">
{{ element.status }}
</span>
</td>
</ng-container>
<!-- Actions Column -->
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let element">
<!-- The dividend context travels on, so the leaflets page knows this is a dividend
draft and not an ordinary counter sale. -->
<button
mat-button
class="action-btn"
[routerLink]="['/sponsor-dd/leaflets', element.id]"
[queryParams]="dividendQueryParams"
>
{{ dividendContext ? 'Choose Leaflet' : 'View Leaflets' }}
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
<div
*ngIf="dataSource.length === 0"
class="no-records-row"
style="text-align: center; padding: 32px; color: #64748b"
>
No DD series registered yet. Click "Register DD Series" to add stock.
</div> </div>
</mat-card>
<div class="inv-kpi-card">
<div class="inv-kpi-card__icon green">
<fa-icon icon="check-circle"></fa-icon>
</div>
<div class="inv-kpi-card__info">
<span class="inv-kpi-card__label">Active Stock Series</span>
<span class="inv-kpi-card__val">{{ activeBatchesCount }}</span>
</div>
</div>
<div class="inv-kpi-card">
<div class="inv-kpi-card__icon purple">
<fa-icon icon="money-check"></fa-icon>
</div>
<div class="inv-kpi-card__info">
<span class="inv-kpi-card__label">Total Allocated Leaves</span>
<span class="inv-kpi-card__val">{{ totalLeavesCount | number }}</span>
</div>
</div>
<div class="inv-kpi-card">
<div class="inv-kpi-card__icon emerald">
<fa-icon icon="list-ul"></fa-icon>
</div>
<div class="inv-kpi-card__info">
<span class="inv-kpi-card__label">Available Leaves</span>
<span class="inv-kpi-card__val">{{ totalAvailableLeaves | number }}</span>
</div>
</div>
</div>
<!-- Search & Filter Controls -->
<div class="inv-toolbar">
<div class="inv-search">
<fa-icon icon="search" class="inv-search__ic"></fa-icon>
<input
type="text"
[(ngModel)]="searchText"
placeholder="Search by Sponsor Bank, Branch, Batch ID, or Series No..."
/>
<button class="inv-search__clear" *ngIf="searchText" (click)="searchText = ''" matTooltip="Clear">
<fa-icon icon="times"></fa-icon>
</button>
</div>
<div class="inv-filter-chips">
<button class="inv-chip" [class.active]="selectedStatusFilter === 'ALL'" (click)="selectedStatusFilter = 'ALL'">
All Batches
<span class="inv-chip__count">{{ dataSource.length }}</span>
</button>
<button
class="inv-chip active-chip"
[class.active]="selectedStatusFilter === 'ACTIVE'"
(click)="selectedStatusFilter = 'ACTIVE'"
>
<span class="inv-chip__dot green"></span>
Active
<span class="inv-chip__count">{{ activeBatchesCount }}</span>
</button>
<button
class="inv-chip exhausted-chip"
[class.active]="selectedStatusFilter === 'EXHAUSTED'"
(click)="selectedStatusFilter = 'EXHAUSTED'"
>
<span class="inv-chip__dot grey"></span>
Exhausted
<span class="inv-chip__count">{{ dataSource.length - activeBatchesCount }}</span>
</button>
</div>
</div>
<!-- Main Card & Data Table -->
<div class="inv-card">
<div class="inv-card__head">
<div class="inv-card__title">
<fa-icon icon="table" class="inv-card__title-ic"></fa-icon>
Registered Series
<span class="inv-card__count">{{ filteredData.length }} of {{ dataSource.length }}</span>
</div>
</div>
<div class="inv-table-wrap">
<table mat-table [dataSource]="filteredData" class="inv-table">
<!-- ID Column -->
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef>Batch ID</th>
<td mat-cell *matCellDef="let element" class="inv-mono inv-strong">#{{ element.id }}</td>
</ng-container>
<!-- Sponsor Bank Column -->
<ng-container matColumnDef="sponsorBankName">
<th mat-header-cell *matHeaderCellDef>Sponsor Bank</th>
<td mat-cell *matCellDef="let element" class="inv-cell-bank">
<fa-icon icon="building" class="bank-icon"></fa-icon>
<span class="inv-strong">{{ element.sponsorBankName }}</span>
</td>
</ng-container>
<!-- Branch Column -->
<ng-container matColumnDef="branchName">
<th mat-header-cell *matHeaderCellDef>Branch</th>
<td mat-cell *matCellDef="let element">{{ element.branchName }}</td>
</ng-container>
<!-- Series Start Column -->
<ng-container matColumnDef="seriesStart">
<th mat-header-cell *matHeaderCellDef>Start No.</th>
<td mat-cell *matCellDef="let element" class="inv-mono">{{ element.seriesStart }}</td>
</ng-container>
<!-- Series End Column -->
<ng-container matColumnDef="seriesEnd">
<th mat-header-cell *matHeaderCellDef>End No.</th>
<td mat-cell *matCellDef="let element" class="inv-mono">{{ element.seriesEnd }}</td>
</ng-container>
<!-- Total Leaves Column -->
<ng-container matColumnDef="totalLeaves">
<th mat-header-cell *matHeaderCellDef class="inv-num">Total Leaves</th>
<td mat-cell *matCellDef="let element" class="inv-num inv-mono">{{ element.totalLeaves }}</td>
</ng-container>
<!-- Available Leaves Column -->
<ng-container matColumnDef="availableLeaves">
<th mat-header-cell *matHeaderCellDef class="inv-num">Available Leaves</th>
<td mat-cell *matCellDef="let element" class="inv-num">
<span class="inv-leaves-badge" [class.low]="element.availableLeaves === 0">
{{ element.availableLeaves }}
</span>
</td>
</ng-container>
<!-- Received Date Column -->
<ng-container matColumnDef="receivedDate">
<th mat-header-cell *matHeaderCellDef>Received Date</th>
<td mat-cell *matCellDef="let element" class="inv-muted inv-mono">{{ element.receivedDate }}</td>
</ng-container>
<!-- Status Column -->
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let element">
<span class="inv-badge" [ngClass]="element.status.toLowerCase()">
<span class="inv-badge__dot"></span>
{{ element.status }}
</span>
</td>
</ng-container>
<!-- Actions Column -->
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef class="inv-actions-col">Actions</th>
<td mat-cell *matCellDef="let element" class="inv-actions-col">
<button
class="inv-btn primary small"
[routerLink]="['/sponsor-dd/leaflets', element.id]"
[queryParams]="dividendQueryParams"
>
<fa-icon [icon]="dividendContext ? 'hand-holding-usd' : 'eye'"></fa-icon>
{{ dividendContext ? 'Choose Leaflet' : 'View Leaflets' }}
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
@if (filteredData.length === 0) {
<div class="inv-empty">
<fa-icon icon="inbox" class="inv-empty__ic"></fa-icon>
<p class="inv-empty__title">No DD Series Found</p>
<p class="inv-empty__sub">No registered DD stock series match your filter criteria.</p>
</div>
}
</div>
</div>
</div> </div>
@@ -22,6 +22,8 @@ import { MatNativeDateModule } from '@angular/material/core';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { SponsorDDService } from '../sponsor-dd.service'; import { SponsorDDService } from '../sponsor-dd.service';
@Component({ @Component({
@@ -30,106 +32,482 @@ import { SponsorDDService } from '../sponsor-dd.service';
imports: [ imports: [
CommonModule, CommonModule,
RouterModule, RouterModule,
FormsModule,
ReactiveFormsModule,
MatCardModule, MatCardModule,
MatTableModule, MatTableModule,
MatButtonModule, MatButtonModule,
MatDialogModule, MatDialogModule,
MatSnackBarModule MatSnackBarModule,
MatFormFieldModule,
MatInputModule,
MatIconModule,
MatTooltipModule,
FaIconComponent
], ],
templateUrl: './dd-inventory-list.component.html', templateUrl: './dd-inventory-list.component.html',
styles: [ styles: [
` `
.inventory-container { .inv-container {
padding: 24px; padding: 24px;
max-width: 1200px; max-width: 1360px;
margin: 0 auto; margin: 0 auto;
} }
.header-row {
/* Hero Header */
.inv-hero {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 24px; margin-bottom: 24px;
gap: 16px;
flex-wrap: wrap;
} }
.title-area h2 { .inv-hero__left {
margin: 0 0 4px 0;
font-size: 24px;
font-weight: 600;
color: #1e293b;
}
.title-area p {
margin: 0;
color: #64748b;
font-size: 14px;
}
.mat-mdc-card {
border-radius: 12px;
border: 1px solid #e2e8f0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
overflow: hidden;
}
table {
width: 100%;
}
th {
font-weight: 600;
color: #475569;
background-color: #f8fafc;
}
td {
color: #334155;
}
.status-badge {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 9999px;
font-size: 12px;
font-weight: 500;
}
.status-badge.active {
background-color: #dcfce7;
color: #15803d;
}
.status-badge.exhausted {
background-color: #f1f5f9;
color: #475569;
}
.action-btn {
color: #7c3aed;
font-weight: 500;
}
.leaves-low {
color: #b91c1c;
font-weight: 600;
}
.dividend-banner {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 16px;
margin-bottom: 20px;
padding: 12px 16px;
border: 1px solid #c7d2fe;
border-left: 4px solid #4f46e5;
border-radius: 8px;
background-color: #eef2ff;
} }
.dividend-banner__text { .inv-hero__icon {
width: 52px;
height: 52px;
border-radius: 14px;
background: linear-gradient(135deg, #2563eb 0%, #4f46e5 100%);
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
box-shadow: 0 4px 14px rgba(37, 99, 235, 0.25);
}
.inv-hero__title {
margin: 0;
font-size: 22px;
font-weight: 800;
color: #0f172a;
letter-spacing: -0.02em;
}
.inv-hero__sub {
margin: 2px 0 0 0;
font-size: 13.5px;
color: #64748b;
}
/* Dividend Banner */
.inv-dividend-banner {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
padding: 16px 20px;
border-radius: 12px;
border: 1px solid #c7d2fe;
background: linear-gradient(135deg, #eef2ff 0%, #f5f3ff 100%);
box-shadow: 0 2px 8px rgba(79, 70, 229, 0.08);
}
.inv-dividend-banner__ic {
width: 40px;
height: 40px;
border-radius: 10px;
background: #4f46e5;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
flex-shrink: 0;
}
.inv-dividend-banner__text {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
font-size: 13px; font-size: 13.5px;
color: #312e81; color: #312e81;
} }
.dividend-banner__text strong { .inv-dividend-banner__hint {
font-size: 13.5px;
}
.dividend-banner__hint {
color: #4338ca;
font-size: 12px; font-size: 12px;
color: #4338ca;
} }
.dividend-banner button { .inv-dividend-banner button {
margin-left: auto; margin-left: auto;
} }
/* KPI Cards */
.inv-kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.inv-kpi-card {
background: #ffffff;
border-radius: 12px;
padding: 18px 20px;
border: 1px solid #e2e8f0;
display: flex;
align-items: center;
gap: 16px;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04);
transition:
transform 0.15s ease,
box-shadow 0.15s ease;
}
.inv-kpi-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.08);
}
.inv-kpi-card__icon {
width: 46px;
height: 46px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
flex-shrink: 0;
}
.inv-kpi-card__icon.blue {
background: #eff6ff;
color: #2563eb;
}
.inv-kpi-card__icon.green {
background: #f0fdf4;
color: #16a34a;
}
.inv-kpi-card__icon.purple {
background: #faf5ff;
color: #9333ea;
}
.inv-kpi-card__icon.emerald {
background: #ecfdf5;
color: #059669;
}
.inv-kpi-card__info {
display: flex;
flex-direction: column;
}
.inv-kpi-card__label {
font-size: 12px;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.inv-kpi-card__val {
font-size: 24px;
font-weight: 800;
color: #0f172a;
margin-top: 2px;
line-height: 1;
}
/* Toolbar */
.inv-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.inv-search {
position: relative;
flex: 1;
min-width: 280px;
max-width: 460px;
}
.inv-search input {
width: 100%;
padding: 10px 14px 10px 38px;
border: 1px solid #cbd5e1;
border-radius: 10px;
font-size: 13.5px;
background: #ffffff;
color: #0f172a;
outline: none;
box-sizing: border-box;
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.inv-search input:focus {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
}
.inv-search__ic {
position: absolute;
left: 13px;
top: 50%;
transform: translateY(-50%);
color: #94a3b8;
font-size: 13px;
pointer-events: none;
}
.inv-search__clear {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
border: none;
background: transparent;
color: #94a3b8;
cursor: pointer;
padding: 4px;
}
.inv-search__clear:hover {
color: #ef4444;
}
.inv-filter-chips {
display: flex;
align-items: center;
gap: 8px;
}
.inv-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
border-radius: 999px;
border: 1px solid #e2e8f0;
background: #ffffff;
color: #475569;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
}
.inv-chip:hover {
background: #f8fafc;
border-color: #cbd5e1;
}
.inv-chip.active {
background: #1e40af;
border-color: #1e40af;
color: #ffffff !important;
}
.inv-chip.active-chip.active {
background: #16a34a;
border-color: #16a34a;
color: #ffffff !important;
}
.inv-chip.exhausted-chip.active {
background: #475569;
border-color: #475569;
color: #ffffff !important;
}
.inv-chip__dot {
width: 7px;
height: 7px;
border-radius: 50%;
}
.inv-chip__dot.green {
background: #16a34a;
}
.inv-chip__dot.grey {
background: #64748b;
}
.inv-chip.active .inv-chip__dot {
background: #ffffff;
}
.inv-chip__count {
padding: 2px 7px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.07);
font-size: 11px;
font-weight: 700;
}
.inv-chip.active .inv-chip__count {
background: rgba(255, 255, 255, 0.25);
color: #ffffff;
}
/* Card & Table */
.inv-card {
background: #ffffff;
border-radius: 14px;
border: 1px solid #e2e8f0;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.05);
overflow: hidden;
}
.inv-card__head {
padding: 16px 20px;
border-bottom: 1px solid #e2e8f0;
background: linear-gradient(180deg, #f8fafc 0%, #ffffff 100%);
}
.inv-card__title {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 700;
color: #0f172a;
}
.inv-card__title-ic {
color: #2563eb;
}
.inv-card__count {
margin-left: 6px;
padding: 2px 10px;
border-radius: 999px;
background: #eff6ff;
color: #1e40af;
font-size: 11px;
font-weight: 700;
}
.inv-table-wrap {
overflow-x: auto;
}
.inv-table {
width: 100%;
border-collapse: collapse;
}
.inv-table th {
background: #f1f5f9;
color: #475569;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 12px 16px;
border-bottom: 1px solid #e2e8f0;
white-space: nowrap;
}
.inv-table td {
padding: 12px 16px;
color: #334155;
border-bottom: 1px solid #f1f5f9;
font-size: 13.5px;
vertical-align: middle;
}
.inv-table tr:hover {
background: #f8fafc;
}
.inv-cell-bank {
display: flex;
align-items: center;
gap: 8px;
}
.bank-icon {
color: #2563eb;
font-size: 14px;
}
.inv-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.inv-strong {
font-weight: 700;
color: #0f172a;
}
.inv-muted {
color: #64748b;
}
.inv-num {
text-align: right;
}
.inv-leaves-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 6px;
background: #f0fdf4;
color: #16a34a;
font-weight: 700;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.inv-leaves-badge.low {
background: #fef2f2;
color: #dc2626;
}
.inv-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.inv-badge__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.inv-badge.active {
background: #dcfce7;
color: #15803d;
}
.inv-badge.exhausted {
background: #f1f5f9;
color: #64748b;
}
.inv-actions-col {
text-align: right;
}
/* Buttons */
.inv-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 9px 18px;
border-radius: 10px;
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
border: 1px solid transparent;
transition: all 0.15s ease;
}
.inv-btn.primary {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
color: #ffffff;
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3);
}
.inv-btn.primary:hover {
transform: translateY(-1px);
box-shadow: 0 4px 14px rgba(37, 99, 235, 0.4);
}
.inv-btn.ghost {
background: #ffffff;
border-color: #cbd5e1;
color: #475569;
}
.inv-btn.ghost:hover {
background: #f8fafc;
color: #0f172a;
}
.inv-btn.small {
padding: 6px 12px;
font-size: 12px;
border-radius: 8px;
}
.inv-empty {
text-align: center;
padding: 48px 20px;
color: #64748b;
}
.inv-empty__ic {
font-size: 36px;
color: #cbd5e1;
margin-bottom: 8px;
}
.inv-empty__title {
font-size: 16px;
font-weight: 700;
color: #334155;
margin: 0 0 4px 0;
}
.inv-empty__sub {
font-size: 13px;
margin: 0;
}
` `
] ]
}) })
@@ -140,14 +518,9 @@ export class DdInventoryListComponent implements OnInit {
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private router = inject(Router); private router = inject(Router);
/**
* The dividend this visit is drawing a draft for, when the clerk arrived from a dividend claim
* rather than from the menu.
*
* Null on an ordinary visit, which is what keeps this page's normal behaviour untouched — the
* banner, the relabelled action and the forwarded query params all hang off this being set.
*/
dividendContext: { lockId: number; amount: number; beneficiary: string; financialYear: string } | null = null; dividendContext: { lockId: number; amount: number; beneficiary: string; financialYear: string } | null = null;
searchText = '';
selectedStatusFilter = 'ALL';
displayedColumns: string[] = [ displayedColumns: string[] = [
'id', 'id',
@@ -168,13 +541,6 @@ export class DdInventoryListComponent implements OnInit {
this.loadInventories(); this.loadInventories();
} }
/**
* Picks up the dividend a claim sent us here to pay out.
*
* Read from the URL rather than handed over in a service so a refreshed or bookmarked page still
* knows which lock it is drawing against — losing it silently would issue an ordinary counter
* draft against the member instead of settling their dividend.
*/
private readDividendContext(): void { private readDividendContext(): void {
const params = this.route.snapshot.queryParams; const params = this.route.snapshot.queryParams;
const lockId = Number(params['dividendLockId']); const lockId = Number(params['dividendLockId']);
@@ -189,7 +555,6 @@ export class DdInventoryListComponent implements OnInit {
}; };
} }
/** The dividend context to carry onto the leaflets page; empty on an ordinary visit. */
get dividendQueryParams(): Record<string, string | number> { get dividendQueryParams(): Record<string, string | number> {
return this.dividendContext return this.dividendContext
? { ? {
@@ -201,7 +566,6 @@ export class DdInventoryListComponent implements OnInit {
: {}; : {};
} }
/** Abandons the claim and returns to the dividend dashboard, leaving the lock untouched. */
cancelDividendClaim(): void { cancelDividendClaim(): void {
this.router.navigate([ this.router.navigate([
'/clerk', '/clerk',
@@ -212,7 +576,7 @@ export class DdInventoryListComponent implements OnInit {
loadInventories(): void { loadInventories(): void {
this.sponsorDDService.getInventories().subscribe({ this.sponsorDDService.getInventories().subscribe({
next: (data) => { next: (data) => {
this.dataSource = data; this.dataSource = data || [];
}, },
error: (err) => { error: (err) => {
this.snackBar.open('Error loading inventories: ' + (err.error?.message || err.message), 'Close', { this.snackBar.open('Error loading inventories: ' + (err.error?.message || err.message), 'Close', {
@@ -222,9 +586,44 @@ export class DdInventoryListComponent implements OnInit {
}); });
} }
get totalBatches(): number {
return this.dataSource.length;
}
get activeBatchesCount(): number {
return this.dataSource.filter((x) => (x.status || '').toUpperCase() === 'ACTIVE').length;
}
get totalLeavesCount(): number {
return this.dataSource.reduce((acc, curr) => acc + (Number(curr.totalLeaves) || 0), 0);
}
get totalAvailableLeaves(): number {
return this.dataSource.reduce((acc, curr) => acc + (Number(curr.availableLeaves) || 0), 0);
}
get filteredData(): any[] {
let list = this.dataSource;
if (this.selectedStatusFilter !== 'ALL') {
list = list.filter((item) => (item.status || '').toUpperCase() === this.selectedStatusFilter);
}
if (this.searchText.trim()) {
const q = this.searchText.trim().toLowerCase();
list = list.filter(
(item) =>
String(item.id).toLowerCase().includes(q) ||
(item.sponsorBankName || '').toLowerCase().includes(q) ||
(item.branchName || '').toLowerCase().includes(q) ||
String(item.seriesStart).includes(q) ||
String(item.seriesEnd).includes(q)
);
}
return list;
}
openAddInventoryDialog(): void { openAddInventoryDialog(): void {
const dialogRef = this.dialog.open(AddInventoryDialogComponent, { const dialogRef = this.dialog.open(AddInventoryDialogComponent, {
width: '500px', width: '520px',
disableClose: true disableClose: true
}); });
+289 -214
View File
@@ -5,251 +5,326 @@
License, v. 2.0. If a copy of the MPL was not distributed with this 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/. file, You can obtain one at http://mozilla.org/MPL/2.0/.
--> -->
<div class="leaflet-container"> <div class="ddreg-container">
<div class="header-row"> <!-- Hero header -->
<div class="title-area"> <div class="ddreg-header">
<button mat-button routerLink="/sponsor-dd/inventory" style="margin-bottom: 8px; color: #64748b; padding-left: 0"> <div class="ddreg-header__left">
&larr; Back to Inventories <button class="ddreg-back" routerLink="/sponsor-dd/inventory" matTooltip="Back to Inventories">
<fa-icon icon="arrow-left"></fa-icon>
</button> </button>
<h2>DD Leaflet Registry</h2> <fa-icon icon="money-check-alt" class="ddreg-header__icon"></fa-icon>
<p *ngIf="inventoryDetails"> <div>
Sponsor Bank: <strong>{{ inventoryDetails.sponsorBankName }}</strong> | Series Range: <h2 class="ddreg-header__title">DD Leaflet Registry</h2>
<strong>{{ inventoryDetails.seriesStart }} - {{ inventoryDetails.seriesEnd }}</strong> <div class="ddreg-header__sub" *ngIf="inventoryDetails">
</p> Sponsor Bank <strong>{{ inventoryDetails.sponsorBankName }}</strong> · Series
<strong>{{ inventoryDetails.seriesStart }}–{{ inventoryDetails.seriesEnd }}</strong>
</div>
</div>
</div>
</div>
<!-- KPI Metrics Row -->
<div class="ddreg-kpi-grid">
<div class="ddreg-kpi-card">
<div class="ddreg-kpi-card__icon blue">
<fa-icon icon="money-check-alt"></fa-icon>
</div>
<div class="ddreg-kpi-card__info">
<span class="ddreg-kpi-card__label">Total Stock</span>
<span class="ddreg-kpi-card__val">{{ dataSource.data.length }}</span>
</div>
</div>
<div class="ddreg-kpi-card">
<div class="ddreg-kpi-card__icon green">
<fa-icon icon="check-circle"></fa-icon>
</div>
<div class="ddreg-kpi-card__info">
<span class="ddreg-kpi-card__label">Available</span>
<span class="ddreg-kpi-card__val">{{ statusCount('AVAILABLE') }}</span>
</div>
</div>
<div class="ddreg-kpi-card">
<div class="ddreg-kpi-card__icon indigo">
<fa-icon icon="file-alt"></fa-icon>
</div>
<div class="ddreg-kpi-card__info">
<span class="ddreg-kpi-card__label">Issued</span>
<span class="ddreg-kpi-card__val">{{ statusCount('ISSUED') }}</span>
</div>
</div>
<div class="ddreg-kpi-card">
<div class="ddreg-kpi-card__icon amber">
<fa-icon icon="ban"></fa-icon>
</div>
<div class="ddreg-kpi-card__info">
<span class="ddreg-kpi-card__label">Cancelled / Stopped</span>
<span class="ddreg-kpi-card__val">{{ statusCount('CANCELLED') + statusCount('STOPPED') }}</span>
</div>
</div>
<div class="ddreg-kpi-card">
<div class="ddreg-kpi-card__icon slate">
<fa-icon icon="times-circle"></fa-icon>
</div>
<div class="ddreg-kpi-card__info">
<span class="ddreg-kpi-card__label">Revoked / Spoiled</span>
<span class="ddreg-kpi-card__val">{{ statusCount('REVOKED') }}</span>
</div>
</div> </div>
</div> </div>
<!-- Says why the clerk is here when they arrived from a dividend claim, and which member is <!-- Says why the clerk is here when they arrived from a dividend claim, and which member is
waiting. Without it a registry of DD numbers gives no clue what the next click commits to. --> waiting. Without it a registry of DD numbers gives no clue what the next click commits to. -->
<div class="dividend-banner" *ngIf="dividendContext"> <div class="ddreg-dividend" *ngIf="dividendContext">
<div class="dividend-banner__text"> <fa-icon icon="hand-holding-usd" class="ddreg-dividend__ic"></fa-icon>
<div class="ddreg-dividend__text">
<strong>Issuing a dividend Demand Draft</strong> <strong>Issuing a dividend Demand Draft</strong>
<span> <span>
{{ dividendContext.beneficiary }} · {{ dividendContext.amount | number: '1.2-2' }} {{ dividendContext.beneficiary }} · ₹{{ dividendContext.amount | number: '1.2-2' }}
<ng-container *ngIf="dividendContext.financialYear"> · FY {{ dividendContext.financialYear }}</ng-container> <ng-container *ngIf="dividendContext.financialYear"> · FY {{ dividendContext.financialYear }}</ng-container>
</span> </span>
<span class="dividend-banner__hint">Pick an available leaflet below and press Issue.</span> <span class="ddreg-dividend__hint">Pick an available leaflet below and press "Draw for Dividend".</span>
</div> </div>
<button mat-button class="action-btn" (click)="cancelDividendClaim()">Cancel</button> <button class="ddreg-btn ghost small" (click)="cancelDividendClaim()">Cancel</button>
</div> </div>
<mat-form-field appearance="outline" class="search-field"> <!-- Toolbar: search + date scope -->
<mat-label>Search by DD Leaflet No.</mat-label> <div class="ddreg-toolbar">
<input matInput [value]="searchText" (input)="onSearchChange($event.target.value)" placeholder="e.g. 3005" /> <div class="ddreg-toolbar__search">
</mat-form-field> <fa-icon icon="search" class="ddreg-toolbar__search-ic"></fa-icon>
<input
<div class="status-filter-row"> type="text"
<button mat-stroked-button color="accent" [class.filter-active]="todayOnly" (click)="toggleTodayOnly()"> [value]="searchText"
(input)="onSearchChange($event.target.value)"
placeholder="Search by DD Leaflet No., e.g. 3005"
/>
<button class="ddreg-toolbar__clear" *ngIf="searchText" (click)="onSearchChange('')" matTooltip="Clear">
<fa-icon icon="times"></fa-icon>
</button>
</div>
<button class="ddreg-btn ghost" [class.active]="todayOnly" (click)="toggleTodayOnly()">
<fa-icon icon="calendar-day"></fa-icon>
{{ todayOnly ? "Today's Issued DDs Only" : 'Showing All Dates' }} {{ todayOnly ? "Today's Issued DDs Only" : 'Showing All Dates' }}
</button> </button>
</div>
<!-- Status filter chips — doubles as an at-a-glance count of every leaflet state -->
<div class="ddreg-chips">
<button <button
mat-stroked-button
*ngFor="let status of statusOptions" *ngFor="let status of statusOptions"
[color]="selectedStatus === status ? 'primary' : undefined" class="ddreg-chip"
[class.filter-active]="selectedStatus === status" [ngClass]="'s-' + status.toLowerCase()"
[class.active]="selectedStatus === status"
(click)="filterByStatus(status)" (click)="filterByStatus(status)"
> >
{{ status === 'ALL' ? 'All' : status }} <span class="ddreg-chip__dot"></span>
<span class="filter-count">({{ statusCount(status) }})</span> {{ status === 'ALL' ? 'All' : (status | titlecase) }}
<span class="ddreg-chip__count">{{ statusCount(status) }}</span>
</button> </button>
</div> </div>
<p class="hint-text" *ngIf="todayOnly"> <p class="ddreg-hint" *ngIf="todayOnly">
Only DDs actually ISSUED today are shown - AVAILABLE, CANCELLED, STOPPED and CLEARED are hidden. Click "Showing All <fa-icon icon="info-circle"></fa-icon>
Only DDs actually ISSUED today are shown — Available, Cancelled, Stopped and Cleared are hidden. Click "Showing All
Dates" above to see everything. Dates" above to see everything.
</p> </p>
<mat-card> <!-- Leaflets table -->
<table mat-table [dataSource]="dataSource"> <div class="ddreg-card">
<!-- DD Number Column --> <div class="ddreg-card__head">
<ng-container matColumnDef="ddNumber"> <div class="ddreg-card__title">
<th mat-header-cell *matHeaderCellDef>DD Leaflet No.</th> <fa-icon icon="list" class="ddreg-card__title-ic"></fa-icon>
<td mat-cell *matCellDef="let element"> Leaflets
<strong>{{ element.ddNumber }}</strong> <span class="ddreg-card__count">{{ dataSource.filteredData.length }} of {{ dataSource.data.length }}</span>
</td> </div>
</ng-container> </div>
<div class="ddreg-table-wrap">
<table mat-table [dataSource]="dataSource" class="ddreg-table">
<!-- DD Number Column -->
<ng-container matColumnDef="ddNumber">
<th mat-header-cell *matHeaderCellDef>DD Leaflet No.</th>
<td mat-cell *matCellDef="let element" class="ddreg-mono ddreg-strong">{{ element.ddNumber }}</td>
</ng-container>
<!-- Status Column --> <!-- Status Column -->
<ng-container matColumnDef="status"> <ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th> <th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let element"> <td mat-cell *matCellDef="let element">
<span class="status-badge" [ngClass]="element.status.toLowerCase()"> <span class="ddreg-badge" [ngClass]="element.status.toLowerCase()">
{{ element.status }} <span class="ddreg-badge__dot"></span>
</span> {{ element.status | titlecase }}
</td>
</ng-container>
<!-- Face Value Column -->
<ng-container matColumnDef="faceValue">
<th mat-header-cell *matHeaderCellDef>Face Value</th>
<td mat-cell *matCellDef="let element">
{{ element.faceValue > 0 ? (element.faceValue | number: '1.2-2') : '-' }}
</td>
</ng-container>
<!-- Client Name Column -->
<ng-container matColumnDef="issuedToClientName">
<th mat-header-cell *matHeaderCellDef>Purchaser / Client</th>
<!-- A dividend payout has no purchasing client: the society raised the draft out of its own
Dividend Payable to pay the member. Falling back to the walk-in purchaser name shows
that, instead of repeating the beneficiary and reading as a counter sale. -->
<td mat-cell *matCellDef="let element">
{{ element.issuedToClientName || element.walkinPurchaserName || '-' }}
</td>
</ng-container>
<!-- Beneficiary Column -->
<ng-container matColumnDef="beneficiaryName">
<th mat-header-cell *matHeaderCellDef>Beneficiary</th>
<td mat-cell *matCellDef="let element">{{ element.beneficiaryName || '-' }}</td>
</ng-container>
<!-- Issued Date Column -->
<ng-container matColumnDef="issuedDate">
<th mat-header-cell *matHeaderCellDef>Issued Date</th>
<td mat-cell *matCellDef="let element">{{ element.issuedDate || '-' }}</td>
</ng-container>
<!-- Advice Status Column -->
<ng-container matColumnDef="adviceStatus">
<th mat-header-cell *matHeaderCellDef>Advice Status</th>
<td mat-cell *matCellDef="let element">
{{ element.status === 'ISSUED' ? element.adviceStatus : '-' }}
</td>
</ng-container>
<!-- Actions Column -->
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let element">
<div class="action-btn-group">
<!-- An ordinary draft is raised from the counter, not from this registry: Transaction
Entry carries the purchaser, the funding and the denomination entry a DD needs,
while this screen is the leaflet register — what each leaflet is and what has since
happened to it.
The button stays for a dividend claim, which arrives here from the dividend
dashboard with the beneficiary and amount already settled and only needs a leaflet
picked; there is no payment to collect, so there is nothing for the counter to do. -->
<button
mat-flat-button
color="primary"
size="small"
*ngIf="dividendContext && element.status === 'AVAILABLE' && !element.pendingIssueRequestId"
(click)="openIssueDialog(element)"
>
Draw for Dividend
</button>
<span class="pending-badge" *ngIf="element.status === 'AVAILABLE' && element.pendingIssueRequestId">
Pending Approval (Req #{{ element.pendingIssueRequestId }})
</span>
<!-- Corrects the beneficiary, or the branch and city the draft is payable at. The
draft itself is unchanged until a manager approves, so a second request is not
offered while one is still pending. -->
<button
mat-stroked-button
color="primary"
size="small"
*ngIf="element.status === 'ISSUED' && !element.pendingAmendmentRequestId"
(click)="openAmendDialog(element)"
>
Edit
</button>
<span class="pending-badge" *ngIf="element.status === 'ISSUED' && element.pendingAmendmentRequestId">
Amendment Pending (Req #{{ element.pendingAmendmentRequestId }})
</span>
<button
mat-stroked-button
color="warn"
size="small"
*ngIf="element.status === 'ISSUED'"
(click)="openCancelDialog(element)"
>
Cancel Draft
</button>
<button
mat-button
color="warn"
size="small"
*ngIf="element.status === 'ISSUED'"
(click)="onStopPayment(element)"
>
Stop Payment
</button>
<button
mat-stroked-button
size="small"
*ngIf="element.status === 'ISSUED' && !element.printCount"
(click)="openPrintDialog(element)"
>
Print
</button>
<button
mat-stroked-button
color="primary"
size="small"
*ngIf="
element.status === 'ISSUED' && element.printCount > 0 && element.approvedUnconsumedReprintRequestId
"
(click)="openPrintDialog(element)"
>
Print (Reprint Approved)
</button>
<button
mat-stroked-button
size="small"
*ngIf="
element.status === 'ISSUED' &&
element.printCount > 0 &&
!element.approvedUnconsumedReprintRequestId &&
!element.pendingReprintRequestId
"
(click)="openRequestReprintDialog(element)"
>
Request Reprint
</button>
<span class="pending-badge" *ngIf="element.status === 'ISSUED' && element.pendingReprintRequestId">
Reprint Pending Approval (Req #{{ element.pendingReprintRequestId }})
</span> </span>
</td>
</ng-container>
<!-- The leaflet is ruined and cannot be handed over. Offered beside Request Reprint <!-- Face Value Column -->
because the two look alike from the counter: reprint when the paper survived, revoke <ng-container matColumnDef="faceValue">
when it did not. --> <th mat-header-cell *matHeaderCellDef class="ddreg-num">Face Value</th>
<button <td mat-cell *matCellDef="let element" class="ddreg-num ddreg-mono">
mat-stroked-button {{ element.faceValue > 0 ? '₹' + (element.faceValue | number: '1.2-2') : '—' }}
color="warn" </td>
size="small" </ng-container>
*ngIf="element.status === 'ISSUED' && !element.pendingRevokeRequestId"
(click)="openRevokeDialog(element)"
>
Revoke
</button>
<span class="pending-badge" *ngIf="element.status === 'ISSUED' && element.pendingRevokeRequestId">
Revoke Pending Approval (Req #{{ element.pendingRevokeRequestId }})
</span>
<!-- Both ends of a replacement, so the pad reconciles: a revoked leaflet says where its <!-- Client Name Column -->
draft went, and the blank that took it says what it stands in for. --> <ng-container matColumnDef="issuedToClientName">
<span class="pending-badge" *ngIf="element.revokedReplacedByDdNumber"> <th mat-header-cell *matHeaderCellDef>Purchaser / Client</th>
Revoked → replaced by DD {{ element.revokedReplacedByDdNumber }} <!-- A dividend payout has no purchasing client: the society raised the draft out of its own
</span> Dividend Payable to pay the member. Falling back to the walk-in purchaser name shows
<span class="pending-badge" *ngIf="element.revokedReplacementOfDdNumber"> that, instead of repeating the beneficiary and reading as a counter sale. -->
Replaces spoiled DD {{ element.revokedReplacementOfDdNumber }} <td mat-cell *matCellDef="let element">
</span> {{ element.issuedToClientName || element.walkinPurchaserName || '—' }}
</div> </td>
</td> </ng-container>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr> <!-- Beneficiary Column -->
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr> <ng-container matColumnDef="beneficiaryName">
</table> <th mat-header-cell *matHeaderCellDef>Beneficiary</th>
<td mat-cell *matCellDef="let element">{{ element.beneficiaryName || '—' }}</td>
</ng-container>
<div <!-- Issued Date Column -->
*ngIf="dataSource.filteredData.length === 0" <ng-container matColumnDef="issuedDate">
class="no-records-row" <th mat-header-cell *matHeaderCellDef>Issued Date</th>
style="text-align: center; padding: 32px; color: #64748b" <td mat-cell *matCellDef="let element" class="ddreg-mono">{{ element.issuedDate || '—' }}</td>
> </ng-container>
{{ dataSource.data.length === 0 ? 'Loading leaflets...' : 'No leaflets match the selected filter.' }}
<!-- Advice Status Column -->
<ng-container matColumnDef="adviceStatus">
<th mat-header-cell *matHeaderCellDef>Advice Status</th>
<td mat-cell *matCellDef="let element" class="ddreg-muted">
{{ element.status === 'ISSUED' ? (element.adviceStatus | titlecase) : '—' }}
</td>
</ng-container>
<!-- Actions Column -->
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef class="ddreg-actions-col">Actions</th>
<td mat-cell *matCellDef="let element" class="ddreg-actions-col">
<div class="ddreg-actions">
<!-- An ordinary draft is raised from the counter, not from this registry: Transaction
Entry carries the purchaser, the funding and the denomination entry a DD needs,
while this screen is the leaflet register — what each leaflet is and what has
since happened to it.
The button stays for a dividend claim, which arrives here from the dividend
dashboard with the beneficiary and amount already settled and only needs a
leaflet picked; there is no payment to collect, so there is nothing for the
counter to do. -->
<button
class="ddreg-btn primary small"
*ngIf="dividendContext && element.status === 'AVAILABLE' && !element.pendingIssueRequestId"
(click)="openIssueDialog(element)"
>
<fa-icon icon="hand-holding-usd"></fa-icon>
Draw for Dividend
</button>
<span class="ddreg-pending" *ngIf="element.status === 'AVAILABLE' && element.pendingIssueRequestId">
<fa-icon icon="clock"></fa-icon>
Pending Approval (Req #{{ element.pendingIssueRequestId }})
</span>
<!-- Print is the action a clerk reaches for almost every time an issued draft is
opened, so it stays a visible button; everything rarer (edit, reprint, revoke,
stop, cancel) is one tap away behind the ⋮ menu instead of competing for space. -->
<button
class="ddreg-btn primary small"
*ngIf="element.status === 'ISSUED' && !element.printCount"
(click)="openPrintDialog(element)"
>
<fa-icon icon="print"></fa-icon>
Print
</button>
<button
class="ddreg-btn primary small"
*ngIf="
element.status === 'ISSUED' && element.printCount > 0 && element.approvedUnconsumedReprintRequestId
"
(click)="openPrintDialog(element)"
>
<fa-icon icon="print"></fa-icon>
Print (Reprint Approved)
</button>
<ng-container *ngIf="element.status === 'ISSUED'">
<button class="ddreg-act more" [matMenuTriggerFor]="rowMenu" matTooltip="More actions">
<fa-icon icon="ellipsis-v"></fa-icon>
</button>
<mat-menu #rowMenu="matMenu" xPosition="before" class="ddreg-menu">
<!-- Corrects the beneficiary, or the branch and city the draft is payable at. The
draft itself is unchanged until a manager approves, so a second request is
not offered while one is still pending. -->
<button mat-menu-item *ngIf="!element.pendingAmendmentRequestId" (click)="openAmendDialog(element)">
<fa-icon icon="pencil-alt" class="ddreg-menu__ic"></fa-icon>
Edit
</button>
<button
mat-menu-item
*ngIf="
element.printCount > 0 &&
!element.approvedUnconsumedReprintRequestId &&
!element.pendingReprintRequestId
"
(click)="openRequestReprintDialog(element)"
>
<fa-icon icon="redo" class="ddreg-menu__ic"></fa-icon>
Request Reprint
</button>
<!-- The leaflet is ruined and cannot be handed over. Grouped beside Request Reprint
because the two look alike from the counter: reprint when the paper survived,
revoke when it did not. -->
<button mat-menu-item *ngIf="!element.pendingRevokeRequestId" (click)="openRevokeDialog(element)">
<fa-icon icon="ban" class="ddreg-menu__ic warn"></fa-icon>
Revoke
</button>
<button mat-menu-item (click)="onStopPayment(element)">
<fa-icon icon="hand-paper" class="ddreg-menu__ic warn"></fa-icon>
Stop Payment
</button>
<button mat-menu-item (click)="openCancelDialog(element)">
<fa-icon icon="times-circle" class="ddreg-menu__ic warn"></fa-icon>
Cancel Draft
</button>
</mat-menu>
</ng-container>
<span class="ddreg-pending" *ngIf="element.status === 'ISSUED' && element.pendingAmendmentRequestId">
<fa-icon icon="clock"></fa-icon>
Amendment Pending (Req #{{ element.pendingAmendmentRequestId }})
</span>
<span class="ddreg-pending" *ngIf="element.status === 'ISSUED' && element.pendingReprintRequestId">
<fa-icon icon="clock"></fa-icon>
Reprint Pending (Req #{{ element.pendingReprintRequestId }})
</span>
<span class="ddreg-pending" *ngIf="element.status === 'ISSUED' && element.pendingRevokeRequestId">
<fa-icon icon="clock"></fa-icon>
Revoke Pending (Req #{{ element.pendingRevokeRequestId }})
</span>
<!-- Both ends of a replacement, so the pad reconciles: a revoked leaflet says where
its draft went, and the blank that took it says what it stands in for. -->
<span class="ddreg-info" *ngIf="element.revokedReplacedByDdNumber">
<fa-icon icon="arrow-right"></fa-icon>
Replaced by DD {{ element.revokedReplacedByDdNumber }}
</span>
<span class="ddreg-info" *ngIf="element.revokedReplacementOfDdNumber">
<fa-icon icon="undo"></fa-icon>
Replaces spoiled DD {{ element.revokedReplacementOfDdNumber }}
</span>
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
<div class="ddreg-empty" *ngIf="dataSource.filteredData.length === 0">
<fa-icon [icon]="dataSource.data.length === 0 ? 'spinner' : 'inbox'"></fa-icon>
{{ dataSource.data.length === 0 ? 'Loading leaflets…' : 'No leaflets match the selected filter.' }}
</div>
</div> </div>
<mat-paginator [pageSizeOptions]="[25, 50, 100, 250]" [pageSize]="25" showFirstLastButtons></mat-paginator> <mat-paginator [pageSizeOptions]="[25, 50, 100, 250]" [pageSize]="25" showFirstLastButtons></mat-paginator>
</mat-card> </div>
</div> </div>
+577 -95
View File
@@ -21,6 +21,9 @@ import { MatSelectModule } from '@angular/material/select';
import { MatDatepickerModule } from '@angular/material/datepicker'; import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatNativeDateModule } from '@angular/material/core'; import { MatNativeDateModule } from '@angular/material/core';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatMenuModule } from '@angular/material/menu';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { debounceTime } from 'rxjs/operators'; import { debounceTime } from 'rxjs/operators';
@@ -40,145 +43,624 @@ import { DividendClaimRequestService } from '../../clerk/dividend-claim-request.
MatDialogModule, MatDialogModule,
MatSnackBarModule, MatSnackBarModule,
MatFormFieldModule, MatFormFieldModule,
MatInputModule MatInputModule,
MatMenuModule,
MatIconModule,
MatTooltipModule,
FaIconComponent
], ],
templateUrl: './dd-list.component.html', templateUrl: './dd-list.component.html',
styles: [ styles: [
` `
.leaflet-container { /* Same visual language as users.component.scss (gradient header icon, KPI-style chips, card
padding: 24px; table with sticky head + row-hover, icon-button/overflow-menu actions) so this registry
max-width: 1200px; reads as part of the same app rather than a one-off style. */
.ddreg-container {
padding: 20px 24px;
max-width: 1320px;
margin: 0 auto; margin: 0 auto;
} }
.dividend-banner {
/* ─── Header ─── */
.ddreg-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; margin-bottom: 16px;
margin-bottom: 20px; }
.ddreg-header__left {
display: flex;
align-items: center;
gap: 12px;
}
.ddreg-back {
width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 9px;
border: 1px solid #d0d5dd;
background: #fff;
color: #475569;
cursor: pointer;
transition: all 0.15s;
}
.ddreg-back:hover {
background: #f8fafc;
border-color: #94a3b8;
color: #1e293b;
}
.ddreg-header__icon {
width: 44px;
height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 12px;
background: linear-gradient(135deg, #1e88e5 0%, #7c3aed 100%);
color: #fff;
font-size: 18px;
box-shadow: 0 2px 8px rgb(29 78 216 / 25%);
}
.ddreg-header__title {
margin: 0;
font-size: 20px;
font-weight: 800;
color: #0d1b2a;
line-height: 1.1;
}
.ddreg-header__sub {
margin-top: 2px;
font-size: 12.5px;
color: #64748b;
}
/* ─── Dividend context banner ─── */
.ddreg-dividend {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 16px;
padding: 12px 16px; padding: 12px 16px;
border: 1px solid #c7d2fe; border: 1px solid #c7d2fe;
border-left: 4px solid #4f46e5; border-left: 4px solid #4f46e5;
border-radius: 8px; border-radius: 10px;
background-color: #eef2ff; background: linear-gradient(180deg, #eef2ff 0%, #f5f3ff 100%);
} }
.dividend-banner__text { .ddreg-dividend__ic {
width: 34px;
height: 34px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 9px;
background: #4f46e5;
color: #fff;
font-size: 14px;
}
.ddreg-dividend__text {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
font-size: 13px; font-size: 13px;
color: #312e81; color: #312e81;
} }
.dividend-banner__hint { .ddreg-dividend__hint {
color: #4338ca; color: #4338ca;
font-size: 12px; font-size: 12px;
} }
.dividend-banner button { .ddreg-dividend .ddreg-btn {
margin-left: auto; margin-left: auto;
} }
.header-row {
display: flex; /* ─── Buttons ─── */
justify-content: space-between; .ddreg-btn {
align-items: center;
margin-bottom: 24px;
}
.title-area h2 {
margin: 0 0 4px 0;
font-size: 24px;
font-weight: 600;
color: #1e293b;
}
.title-area p {
margin: 0;
color: #64748b;
font-size: 14px;
}
.search-field {
width: 320px;
display: block;
margin-bottom: 8px;
}
.mat-mdc-card {
border-radius: 12px;
border: 1px solid #e2e8f0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
overflow: hidden;
}
table {
width: 100%;
}
th {
font-weight: 600;
color: #475569;
background-color: #f8fafc;
}
td {
color: #334155;
}
.status-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
padding: 4px 8px; gap: 8px;
border-radius: 9999px; padding: 8px 16px;
border-radius: 9px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
border: 1px solid transparent;
background: #fff;
}
.ddreg-btn fa-icon {
font-size: 12px; font-size: 12px;
font-weight: 500;
} }
.status-badge.available { .ddreg-btn.ghost {
background-color: #dcfce7; border-color: #d0d5dd;
color: #15803d;
}
.status-badge.issued {
background-color: #dbeafe;
color: #1d4ed8;
}
.status-badge.cleared {
background-color: #f1f5f9;
color: #475569; color: #475569;
} }
.status-badge.cancelled { .ddreg-btn.ghost:hover {
background-color: #fee2e2; background: #f8fafc;
border-color: #94a3b8;
color: #0d1b2a;
}
.ddreg-btn.ghost.active {
background: #ede9fe;
border-color: #c4b5fd;
color: #6d28d9;
}
.ddreg-btn.primary {
background: linear-gradient(135deg, #1e88e5 0%, #1e40af 100%);
border-color: #1e40af;
color: #fff;
box-shadow: 0 2px 6px -2px rgb(30 64 175 / 35%);
}
.ddreg-btn.primary:hover {
box-shadow: 0 4px 12px -3px rgb(30 64 175 / 45%);
transform: translateY(-1px);
}
.ddreg-btn.small {
padding: 6px 12px;
font-size: 12px;
}
/* ─── Toolbar ─── */
.ddreg-toolbar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.ddreg-toolbar__search {
position: relative;
flex: 1;
min-width: 260px;
max-width: 420px;
}
.ddreg-toolbar__search input {
width: 100%;
padding: 9px 34px;
border: 1px solid #d0d5dd;
border-radius: 9px;
font-size: 13px;
background: #fff;
color: #0d1b2a;
transition: all 0.15s;
box-sizing: border-box;
}
.ddreg-toolbar__search input:focus {
outline: none;
border-color: #1e88e5;
box-shadow: 0 0 0 3px rgb(30 136 229 / 12%);
}
.ddreg-toolbar__search-ic {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #94a3b8;
font-size: 12px;
pointer-events: none;
}
.ddreg-toolbar__clear {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
width: 22px;
height: 22px;
border-radius: 50%;
border: 1px solid #e2e8f0;
background: #fff;
color: #94a3b8;
font-size: 10px;
cursor: pointer;
}
.ddreg-toolbar__clear:hover {
background: #fef2f2;
color: #ef4444;
border-color: #fecaca;
}
/* ─── KPI Cards ─── */
.ddreg-kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 14px;
margin-bottom: 20px;
}
.ddreg-kpi-card {
background: #ffffff;
border-radius: 12px;
padding: 16px 18px;
border: 1px solid #e2e8f0;
display: flex;
align-items: center;
gap: 14px;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04);
transition:
transform 0.15s ease,
box-shadow 0.15s ease;
}
.ddreg-kpi-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.08);
}
.ddreg-kpi-card__icon {
width: 42px;
height: 42px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
flex-shrink: 0;
}
.ddreg-kpi-card__icon.blue {
background: #eff6ff;
color: #2563eb;
}
.ddreg-kpi-card__icon.green {
background: #f0fdf4;
color: #16a34a;
}
.ddreg-kpi-card__icon.indigo {
background: #eef2ff;
color: #4f46e5;
}
.ddreg-kpi-card__icon.amber {
background: #fffbeb;
color: #d97706;
}
.ddreg-kpi-card__icon.slate {
background: #f8fafc;
color: #64748b;
}
.ddreg-kpi-card__info {
display: flex;
flex-direction: column;
}
.ddreg-kpi-card__label {
font-size: 11.5px;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.ddreg-kpi-card__val {
font-size: 22px;
font-weight: 800;
color: #0f172a;
margin-top: 1px;
line-height: 1;
}
/* ─── Status filter chips (doubles as the count-at-a-glance) ─── */
.ddreg-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.ddreg-chip {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 7px 12px;
border-radius: 999px;
border: 1px solid #e2e8f0;
background: #fff;
color: #475569;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
}
.ddreg-chip:hover {
border-color: #cbd5e1;
background: #f8fafc;
}
.ddreg-chip__dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
opacity: 0.6;
}
.ddreg-chip__count {
padding: 1px 7px;
border-radius: 999px;
background: rgb(0 0 0 / 6%);
font-size: 11px;
font-weight: 700;
}
.ddreg-chip.s-available {
color: #15803d;
}
.ddreg-chip.s-issued {
color: #1d4ed8;
}
.ddreg-chip.s-cancelled {
color: #b91c1c; color: #b91c1c;
} }
.status-badge.stopped { .ddreg-chip.s-stopped {
background-color: #fef3c7; color: #b45309;
}
.ddreg-chip.s-cleared,
.ddreg-chip.s-revoked {
color: #475569;
}
.ddreg-chip.s-all {
color: #4338ca;
}
.ddreg-chip.active {
background: #1e40af;
border-color: #1e40af;
color: #ffffff !important;
}
.ddreg-chip.active.s-available {
background: #16a34a;
border-color: #16a34a;
}
.ddreg-chip.active.s-issued {
background: #2563eb;
border-color: #2563eb;
}
.ddreg-chip.active.s-cancelled {
background: #dc2626;
border-color: #dc2626;
}
.ddreg-chip.active.s-stopped {
background: #d97706;
border-color: #d97706;
}
.ddreg-chip.active.s-cleared,
.ddreg-chip.active.s-revoked {
background: #475569;
border-color: #475569;
}
.ddreg-chip.active.s-all {
background: #4f46e5;
border-color: #4f46e5;
}
.ddreg-chip.active .ddreg-chip__dot {
background: #fff;
opacity: 0.9;
}
.ddreg-chip.active .ddreg-chip__count {
background: rgb(255 255 255 / 25%);
color: #fff;
}
.ddreg-hint {
display: flex;
align-items: center;
gap: 6px;
color: #64748b;
font-size: 12.5px;
margin: 0 0 16px;
}
/* ─── Card wrapper ─── */
.ddreg-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
box-shadow: 0 1px 3px rgb(15 23 42 / 6%);
overflow: hidden;
}
.ddreg-card__head {
display: flex;
align-items: center;
padding: 14px 18px;
border-bottom: 1px solid #e2e8f0;
background: linear-gradient(180deg, #f8fafc 0%, #fff 100%);
}
.ddreg-card__title {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 700;
color: #0d1b2a;
}
.ddreg-card__title-ic {
color: #1e88e5;
}
.ddreg-card__count {
margin-left: 8px;
padding: 2px 10px;
background: #eff6ff;
color: #1e40af;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
}
/* ─── Table ─── */
.ddreg-table-wrap {
overflow: auto;
}
.ddreg-table {
width: 100%;
}
.ddreg-table th {
position: sticky;
top: 0;
z-index: 1;
background: #f1f5f9;
color: #475569;
text-align: left;
padding: 11px 14px !important;
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid #e2e8f0;
}
.ddreg-table td {
padding: 10px 14px !important;
color: #334155;
border-bottom: 1px solid #f1f5f9;
}
.ddreg-table tr.mat-mdc-row:hover {
background: #f8fafc;
}
.ddreg-num {
text-align: right;
}
.ddreg-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12.5px;
}
.ddreg-strong {
font-weight: 700;
color: #0d1b2a;
}
.ddreg-muted {
color: #94a3b8;
}
/* ─── Status badge (per-row, same palette as the filter chips) ─── */
.ddreg-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 9999px;
font-size: 11.5px;
font-weight: 600;
}
.ddreg-badge__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.ddreg-badge.available {
background: #dcfce7;
color: #15803d;
}
.ddreg-badge.issued {
background: #dbeafe;
color: #1d4ed8;
}
.ddreg-badge.cleared {
background: #f1f5f9;
color: #475569;
}
.ddreg-badge.cancelled {
background: #fee2e2;
color: #b91c1c;
}
.ddreg-badge.stopped {
background: #fef3c7;
color: #b45309; color: #b45309;
} }
/* Spoiled stock. Grey rather than red: nothing went wrong with the customer's draft, only /* Spoiled stock. Grey rather than red: nothing went wrong with the customer's draft, only
with the paper it was printed on, and the draft itself is alive on another leaflet. */ with the paper it was printed on, and the draft itself is alive on another leaflet. */
.status-badge.revoked { .ddreg-badge.revoked {
background-color: #e2e8f0; background: #e2e8f0;
color: #475569; color: #475569;
} }
.action-btn-group {
display: flex; /* ─── Actions ─── */
gap: 8px; .ddreg-actions-col {
min-width: 190px;
} }
.pending-badge { .ddreg-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.ddreg-act {
width: 30px;
height: 30px;
border: 1px solid #e2e8f0;
background: #fff;
border-radius: 7px;
cursor: pointer;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
padding: 4px 10px; justify-content: center;
border-radius: 9999px;
font-size: 12px;
font-weight: 500;
background-color: #fef3c7;
color: #b45309;
}
.status-filter-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.status-filter-row button.filter-active {
background-color: #ede9fe;
}
.filter-count {
color: #64748b; color: #64748b;
margin-left: 4px; font-size: 12px;
transition: all 0.12s;
} }
.hint-text { .ddreg-act:hover {
transform: translateY(-1px);
}
.ddreg-act.more:hover {
background: #f5f3ff;
border-color: #ddd6fe;
color: #7c3aed;
}
.ddreg-menu__ic {
width: 18px;
margin-right: 10px;
color: #64748b; color: #64748b;
font-size: 13px; font-size: 13px;
margin: -8px 0 16px 0; text-align: center;
}
.ddreg-menu__ic.warn {
color: #ef4444;
}
.ddreg-pending {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 9999px;
font-size: 11px;
font-weight: 600;
background: #fef3c7;
color: #b45309;
}
.ddreg-pending fa-icon {
font-size: 10px;
}
.ddreg-info {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11.5px;
color: #64748b;
}
.ddreg-info fa-icon {
font-size: 10px;
}
.ddreg-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 48px 16px;
color: #94a3b8;
font-size: 13px;
}
.ddreg-empty fa-icon {
font-size: 22px;
opacity: 0.6;
}
/* ─── Responsive ─── */
@media (max-width: 900px) {
.ddreg-toolbar {
flex-direction: column;
align-items: stretch;
}
.ddreg-toolbar__search {
max-width: none;
}
}
@media (max-width: 600px) {
.ddreg-table th,
.ddreg-table td {
padding: 8px 10px !important;
font-size: 12px;
}
} }
` `
] ]
+2 -1
View File
@@ -108,13 +108,14 @@ export class SponsorDDService {
chargesAmount?: number; chargesAmount?: number;
issuingBranchId: number; issuingBranchId: number;
issuedDate: string; // yyyy-MM-dd issuedDate: string; // yyyy-MM-dd
paymentMethod: 'ACCOUNT_DEBIT' | 'CHEQUE' | 'CASH'; paymentMethod: 'ACCOUNT_DEBIT' | 'CHEQUE' | 'CASH' | 'GL';
savingsAccountId?: number; savingsAccountId?: number;
currentAccountId?: number; currentAccountId?: number;
chequeNumber?: string; chequeNumber?: string;
chequeBankName?: string; chequeBankName?: string;
cashierDepositRequestId?: number; cashierDepositRequestId?: number;
denominationData?: string; denominationData?: string;
glAccountId?: number;
} }
): Observable<any> { ): Observable<any> {
return this.http.post<any>(`/sponsordd/leaflets/${leafletId}/issue-request`, data); return this.http.post<any>(`/sponsordd/leaflets/${leafletId}/issue-request`, data);
@@ -68,18 +68,70 @@
<tr> <tr>
<th class="ta-left">Voucher / Receipt</th> <th class="ta-left">Voucher / Receipt</th>
<th class="ta-left">Date</th> <th class="ta-left">Date</th>
<th class="ta-left">Description</th>
<th class="ta-left">Collected by</th> <th class="ta-left">Collected by</th>
<th class="ta-right">Amount</th> <th class="ta-right">Amount</th>
<th class="ta-center">Detail</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (v of detail.vouchers; track v.id) { @for (v of detail.vouchers; track v.id) {
<tr> <tr class="sard-voucher-row" (click)="toggleVoucherGlEntries(v.id)">
<td class="ta-left mono">{{ v.voucherNo || v.receiptNo }}</td> <td class="ta-left mono">{{ v.voucherNo || v.receiptNo }}</td>
<td class="ta-left">{{ v.collectionDate }}</td> <td class="ta-left">{{ v.collectionDate }}</td>
<td class="ta-left sard-desc-cell" [title]="v.note">{{ v.note || '—' }}</td>
<td class="ta-left">{{ v.collectedByName || '—' }}</td> <td class="ta-left">{{ v.collectedByName || '—' }}</td>
<td class="ta-right amount-cell">{{ v.amount | number: '1.2-2' }}</td> <td class="ta-right amount-cell">{{ v.amount | number: '1.2-2' }}</td>
<td class="ta-center">
<button
type="button"
mat-icon-button
class="sard-detail-btn"
(click)="$event.stopPropagation(); toggleVoucherGlEntries(v.id)"
title="View this receipt's actual GL posting"
>
<mat-icon>visibility</mat-icon>
</button>
</td>
</tr> </tr>
@if (expandedVoucherId === v.id) {
<tr class="sard-gl-row">
<td colspan="6">
@if (loadingGlEntriesId === v.id) {
<div class="sard-gl-loading">Loading GL entry…</div>
} @else if (!glEntriesByVoucherId[v.id]?.length) {
<div class="sard-gl-empty">No GL posting recorded for this receipt.</div>
} @else {
<table class="sard-gl-table">
<thead>
<tr>
<th class="ta-left">GL Account</th>
<th class="ta-center">Dr / Cr</th>
<th class="ta-right">Amount</th>
</tr>
</thead>
<tbody>
@for (leg of glEntriesByVoucherId[v.id]; track leg.glCode + leg.drCr) {
<tr>
<td class="ta-left mono">{{ leg.glCode }} — {{ leg.accountName }}</td>
<td class="ta-center">
<span
class="sard-drcr-pill"
[class.sard-drcr-pill--dr]="leg.drCr === 'Dr'"
[class.sard-drcr-pill--cr]="leg.drCr === 'Cr'"
>
{{ leg.drCr }}
</span>
</td>
<td class="ta-right amount-cell">{{ leg.amount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
}
</td>
</tr>
}
} }
</tbody> </tbody>
</table> </table>
@@ -121,3 +121,93 @@ $danger: #b3261e;
font-size: 13px; font-size: 13px;
margin-top: 12px; margin-top: 12px;
} }
.ta-center {
text-align: center;
}
.sard-voucher-row {
cursor: pointer;
&:hover {
background: #f4f7fb;
}
}
.sard-desc-cell {
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sard-detail-btn {
width: 30px;
height: 30px;
line-height: 30px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: $primary;
}
}
.sard-gl-row td {
background: #fbfcfe;
border-top: none;
padding: 4px 8px 10px;
}
.sard-gl-loading,
.sard-gl-empty {
font-size: 12px;
color: $muted;
padding: 4px 0;
}
.sard-gl-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
background: #fff;
border: 1px solid $border;
border-radius: 6px;
overflow: hidden;
th {
color: $muted;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.4px;
padding: 5px 8px;
background: #f4f7fb;
border-bottom: 1px solid $border;
}
td {
padding: 5px 8px;
border-top: 1px solid #eceff4;
}
}
.sard-drcr-pill {
display: inline-block;
padding: 1px 8px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
&.sard-drcr-pill--dr {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}
&.sard-drcr-pill--cr {
background: #ecfdf5;
color: #059669;
border: 1px solid #a7f3d0;
}
}
@@ -50,6 +50,12 @@ export class ShareAdditionRequestDetailDialogComponent implements OnInit {
actioning = false; actioning = false;
detail: any = null; detail: any = null;
/** Which voucher's row is currently expanded to show its real Dr/Cr GL legs, or null if none. */
expandedVoucherId: number | null = null;
loadingGlEntriesId: number | null = null;
/** Fetched once per voucher and kept — a checker toggling the same row twice shouldn't re-fetch. */
glEntriesByVoucherId: { [id: number]: { glCode: string; accountName: string; drCr: string; amount: number }[] } = {};
public dialogRef = inject(MatDialogRef<ShareAdditionRequestDetailDialogComponent>); public dialogRef = inject(MatDialogRef<ShareAdditionRequestDetailDialogComponent>);
public data = inject<{ transactionId: number }>(MAT_DIALOG_DATA); public data = inject<{ transactionId: number }>(MAT_DIALOG_DATA);
@@ -76,6 +82,26 @@ export class ShareAdditionRequestDetailDialogComponent implements OnInit {
return this.detail?.vouchers?.length || 0; return this.detail?.vouchers?.length || 0;
} }
/** Shows or hides a voucher's real GL posting, fetching it the first time it is opened. */
toggleVoucherGlEntries(voucherId: number): void {
if (this.expandedVoucherId === voucherId) {
this.expandedVoucherId = null;
return;
}
this.expandedVoucherId = voucherId;
if (this.glEntriesByVoucherId[voucherId]) {
return;
}
this.loadingGlEntriesId = voucherId;
this.cashierService
.getShareAdvanceGlEntries(voucherId)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.glEntriesByVoucherId[voucherId] = res?.entries || [];
this.loadingGlEntriesId = null;
});
}
approve(): void { approve(): void {
this.act('approveadditionalshares'); this.act('approveadditionalshares');
} }
+102 -11
View File
@@ -982,6 +982,16 @@ mat-card {
.mat-mdc-tab-header, .mat-mdc-tab-header,
.mat-stepper-vertical-line, .mat-stepper-vertical-line,
.mat-step-header, .mat-step-header,
/* Account-view pages (Shares, Savings, Current, Deposits…) use `<nav mat-tab-nav-bar
class="navigation-tabs">` for their General/Passbook/Charges/… tab links — a DIFFERENT
Material component from mat-tab-group, so it never carries `.mat-mdc-tab-header` and slipped
past the rule above. Its own style (theme/_content.scss) gives it a grey background and
`overflow: auto`, which is exactly the grey box with a baked-in scrollbar seen in print/PDF
output — printing it is as pointless as printing the tab-group header already excluded above. */
.navigation-tabs,
[mat-tab-nav-bar],
.mat-mdc-tab-nav-bar,
.mat-mdc-tab-link-container,
/* The real 2-row nav menu (Client Master / Approvals / Cashier … and the row below it) and the /* The real 2-row nav menu (Client Master / Approvals / Cashier … and the row below it) and the
live KPI ticker live directly in shell.component.html as .main-header / .kpi-ticker, not live KPI ticker live directly in shell.component.html as .main-header / .kpi-ticker, not
inside <mifosx-toolbar> — hiding only the toolbar left this menu printing on every page. */ inside <mifosx-toolbar> — hiding only the toolbar left this menu printing on every page. */
@@ -997,17 +1007,57 @@ mat-card {
margin-left: 0 !important; margin-left: 0 !important;
} }
/* The whole app shell scrolls inside CDK's own clipped viewport — /* THE root clip: assets/styles/_overflow.scss sets `html, body { overflow: hidden }` and
.mat-drawer-container { overflow: hidden } and .mat-drawer-content { overflow: auto; height: 100% } index.html puts `h-full` (height: 100%) on both, so the document itself is locked to exactly
— so without freeing these too, print is still capped to one screen's worth of the page one viewport and everything past it is cut away. Every inner container fix below is useless
regardless of the tab-body fix below. */ while this stands — it is why printing any page produced a blank sheet with only a sliver of
whatever happened to sit at the fold. */
html,
body {
height: auto !important;
overflow: visible !important;
width: 100% !important;
background: #fff !important;
/* Keep backgrounds/colours (certificate rules, table shading) instead of dropping them. */
print-color-adjust: exact;
}
/* The shell then scrolls inside CDK's own clipped viewport —
#mifosx-shell-container { height: 100vh }, .mat-drawer-content { height: 100%; overflow: auto }
— so these have to be freed as well. */
#mifosx-shell-container, #mifosx-shell-container,
mat-sidenav-container,
mat-drawer-container,
mat-sidenav-content,
mat-drawer-content,
.mat-drawer-container, .mat-drawer-container,
.mat-drawer-content, .mat-drawer-content,
mat-sidenav-content.sidenav { .mat-sidenav-content {
position: static !important;
overflow: visible !important; overflow: visible !important;
height: auto !important; height: auto !important;
max-height: none !important;
/* Material's own theme paints a background-color on .mat-drawer-container — without
overriding it here, that grey shows behind every printed page even though html/body are
already forced white above. */
background: #fff !important;
}
/* shell.component.scss:247-271 scopes `mifosx-content { overflow-x: hidden; max-width: 100% }`
specifically to `.sidenav mifosx-content` so tables/cards shrink to fit whenever the sidebar
is open on screen — this rule has nothing to do with the sidebar being hidden for print, and
without overriding it too, the routed page content keeps clipping/scrolling horizontally to
whatever width was last measured on screen, which is exactly the narrow box with a baked-in
scrollbar seen in print/PDF output. */
mifosx-content,
.sidenav mifosx-content,
mat-tab-nav-panel,
.mat-mdc-tab-nav-panel {
overflow: visible !important;
width: auto !important;
max-width: 100% !important;
height: auto !important;
} }
/* Angular Material clips a tab's content to a JS-measured on-screen height via /* Angular Material clips a tab's content to a JS-measured on-screen height via
@@ -1023,11 +1073,6 @@ mat-card {
height: auto !important; height: auto !important;
} }
body,
html {
background: #fff !important;
}
@page { @page {
/* stylelint-disable-next-line unit-allowed-list */ /* stylelint-disable-next-line unit-allowed-list */
margin: 1.5cm; margin: 1.5cm;
@@ -1869,3 +1914,49 @@ mifosx-client-datatable-step * {
--mdc-checkbox-selected-pressed-icon-color: #059669; --mdc-checkbox-selected-pressed-icon-color: #059669;
} }
/* stylelint-enable selector-max-universal */ /* stylelint-enable selector-max-universal */
/* ==========================================================================
Global Print Styles for Material Dialogs & Certificates
========================================================================== */
@media print {
html,
body {
height: auto !important;
overflow: visible !important;
background: #fff !important;
}
/* Hide main application shell during dialog printing */
body > :not(.cdk-overlay-container) {
display: none !important;
}
/* Remove fixed positioning on CDK overlay elements so content flows naturally */
.cdk-overlay-backdrop {
display: none !important;
}
.cdk-overlay-container,
.cdk-global-overlay-wrapper,
.cdk-overlay-pane,
.mat-mdc-dialog-container,
.mdc-dialog__container,
.mdc-dialog__surface {
position: static !important;
display: block !important;
max-height: none !important;
max-width: none !important;
height: auto !important;
width: auto !important;
overflow: visible !important;
transform: none !important;
box-shadow: none !important;
background: transparent !important;
padding: 0 !important;
}
.no-print,
.mat-mdc-dialog-actions {
display: none !important;
}
}