feat(cashier, sponsor-dd): GL settlement and DD funding entry points, DD registry redesign

Transaction Entry (clerk):
- Route a two-row Fund Transfer of Dr SHR / Cr GL to the share-withdrawal
  request with settlementMode GL, so a clerk can settle a share withdrawal
  into any GL account instead of the member's linked savings account.
- Offer GL as an account type for Demand Draft funding, and submit it as a
  GL debit (no client or savings/current account is involved).

My Work:
- Add a voucher detail dialog so a clerk can open a raised voucher from the
  request list and see its GL lines.

Closing Cash Report:
- Fix the total row so it reflects the corrected opening balance.

DD Leaflet Registry and DD Inventory:
- Redesign both pages onto the shared usr-* visual language: gradient header
  badge, status filter chips with counts, card-wrapped sticky-header table,
  and one primary action per row with the rest behind an overflow menu.
  Filtering, pagination and every action condition are unchanged.
- Register faHandPaper, needed by the new Stop Payment menu item.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-16 17:17:22 +05:30
co-authored by Claude Opus 5
parent d18bd9cf37
commit b71619c402
15 changed files with 2382 additions and 614 deletions
+7
View File
@@ -178,6 +178,13 @@ export class CashierService {
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
* stood then, and what it holds now.
@@ -174,19 +174,24 @@ export class ClosingCashReportComponent implements OnInit {
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 {
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.
*
* Was deposits minus withdrawals, which is the counter's movement rather than the drawer's
* 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.
* 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
* row's own `netAmount` rather than re-deriving it from today's legs.
*/
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[] {
return this.rows.filter((r) => r.kind === 'coin');
+16 -4
View File
@@ -152,9 +152,21 @@
<tbody>
@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
is when the heading has scrolled away. -->
<tr [class]="'mw-row mw-row--' + r.myRole">
<td class="mw-vno">{{ r.voucherNo }}</td>
is when the heading has scrolled away. Clicking anywhere on the row opens the full
slip -- every line, not just the first debit and first credit this table has room
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>
<span class="mw-badge" [class]="'mw-badge--' + (r.side || 'JV')">{{ sideLabel(r.side) }}</span>
</td>
@@ -171,7 +183,7 @@
<span class="mw-acct">{{ r.creditAccount }}</span>
}
@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 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
tally and a checker's are each readable without adding rows up. */
.mw-group {
+35 -2
View File
@@ -10,8 +10,10 @@ import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
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 { VoucherDetailDialogComponent } from './voucher-detail-dialog.component';
/** What the signed-in user's part in a voucher was. */
export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED';
@@ -72,11 +74,17 @@ interface MyWorkReport {
imports: [
CommonModule,
FormsModule,
MatIconModule
MatIconModule,
MatDialogModule
]
})
export class MyWorkComponent implements OnInit {
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. */
period: 'DAY' | 'MONTH' = 'DAY';
@@ -323,6 +331,31 @@ export class MyWorkComponent implements OnInit {
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 {
const d = new Date();
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;
}
@@ -166,22 +166,30 @@
</div>
<div class="ws-f ws-f--actyp">
<label class="ws-f__lbl" title="Account Type">AC. TP.</label>
<select
class="ws-f__ctl"
[(ngModel)]="row.acTyp"
(click)="$event.stopPropagation()"
(change)="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer' &&
(row.dbCr = 'Cr')
"
>
@for (a of acTypOptionsFor(row); track a) {
<option [value]="a">{{ a }}</option>
}
</select>
@if (acTypLockedByToken && row === selectedRow) {
<span
class="ws-f__ctl ws-f__ctl--locked"
title="Locked — filled from a token-driven request, not a normal account entry"
>{{ acTypLockedLabel }}</span
>
} @else {
<select
class="ws-f__ctl"
[(ngModel)]="row.acTyp"
(click)="$event.stopPropagation()"
(change)="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer' &&
(row.dbCr = 'Cr')
"
>
@for (a of acTypOptionsFor(row); track a) {
<option [value]="a">{{ a }}</option>
}
</select>
}
</div>
</div>
</div>
@@ -206,14 +214,20 @@
? ddGlMapping?.ddPayableGlCode || 'Auto GL'
: 'Account number'
"
[readonly]="row.typ === 'DD' && !isDdAccountDebitRow(row)"
[class.ws-f__ctl--readonly]="row.typ === 'DD' && !isDdAccountDebitRow(row)"
[readonly]="
(row.typ === 'DD' && !isDdAccountDebitRow(row)) || (acTypLockedByToken && row === selectedRow)
"
[class.ws-f__ctl--readonly]="
(row.typ === 'DD' && !isDdAccountDebitRow(row)) || (acTypLockedByToken && row === selectedRow)
"
[title]="
isDdAccountDebitRow(row)
? 'Member account the draft is funded from'
: 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'
: isDdAccountDebitRow(row)
? 'Member account the draft is funded from'
: row.typ === 'DD'
? 'Configured Sponsor DD Payable GL (Auto-selected)'
: 'Account number'
"
/>
</div>
@@ -1523,22 +1537,30 @@
</div>
<div class="ws-f ws-f--actyp">
<label class="ws-f__lbl">AC. TP.</label>
<select
class="ws-f__ctl"
[(ngModel)]="row.acTyp"
(click)="$event.stopPropagation()"
(change)="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer' &&
(row.dbCr = 'Cr')
"
>
@for (a of acTypOptionsFor(row); track a) {
<option [value]="a">{{ a }}</option>
}
</select>
@if (acTypLockedByToken && row === selectedRow) {
<span
class="ws-f__ctl ws-f__ctl--locked"
title="Locked — filled from a token-driven request, not a normal account entry"
>{{ acTypLockedLabel }}</span
>
} @else {
<select
class="ws-f__ctl"
[(ngModel)]="row.acTyp"
(click)="$event.stopPropagation()"
(change)="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer' &&
(row.dbCr = 'Cr')
"
>
@for (a of acTypOptionsFor(row); track a) {
<option [value]="a">{{ a }}</option>
}
</select>
}
</div>
</div>
</div>
@@ -1556,10 +1578,16 @@
[placeholder]="
row.typ === 'DD' ? ddGlMapping?.ddPayableGlCode || 'Auto GL' : 'Account number'
"
[readonly]="row.typ === 'DD'"
[class.ws-f__ctl--readonly]="row.typ === 'DD'"
[readonly]="row.typ === 'DD' || (acTypLockedByToken && row === selectedRow)"
[class.ws-f__ctl--readonly]="
row.typ === 'DD' || (acTypLockedByToken && row === selectedRow)
"
[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>
@@ -916,6 +916,20 @@ input.ws-f__ctl--readonly {
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 {
background: #fef9c3;
color: #713f12;
@@ -1703,15 +1703,18 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
/**
* 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
* accounts a member actually holds money in. GL was offered here too, and picking it quietly
* switched the row onto the cash path — the one that asks for a walk-in purchaser's name and a
* note-and-coin count, neither of which a clerk debiting an account has. The option is removed
* rather than validated afterwards, so the wrong path cannot be reached at all.
* A clerk raises a draft against a member's own balance (SD/CA) or, now, against a GL account
* directly for an institutional/holding-account draft (see isDdAccountDebitRow()) — both are
* governed, maker-checker submissions. GL used to be offered here too in a different sense: picking
* it quietly switched the row onto the CASHIER's walk-in cash path instead (the one that asks for a
* 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 = [
'SD',
'CA'
'CA',
'GL'
];
/**
@@ -1726,6 +1729,33 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
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 = [
'Dr',
'Cr'
@@ -2321,14 +2351,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
* would let a draft be raised against an account with no cash to reconcile it against — and the
* clerk flow is the one that carries a maker-checker step over the member's balance.
* Clerk-only. A cashier's counter takes cash and nothing else, so offering them an account/GL debit
* would let a draft be raised with no cash to reconcile it against — and the clerk flow is the one
* 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 {
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')
);
}
/**
@@ -2408,19 +2445,24 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// 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
// 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 fundingClientId: number | null = null;
if (accountDebit) {
const acNo = (row.acNo || '').trim();
if (!acNo) {
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;
}
fundingAccountId = this.rowAccountIds[acNo] ?? null;
fundingClientId = this.rowClientIds[acNo] ?? null;
if (!fundingAccountId || !fundingClientId) {
fundingClientId = isGlDebit ? null : (this.rowClientIds[acNo] ?? null);
if (!fundingAccountId || (!isGlDebit && !fundingClientId)) {
this.showAlert('error', 'Account Not Found', [
`Account ${acNo} could not be resolved.`,
'Check the account number and wait for its details to load before raising the draft.'
@@ -2510,29 +2552,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
* account is only touched when a checker approves, which is also when the balance is re-checked —
* so a draft raised against a sufficient balance can still be refused if the balance has since
* gone. No denominations are sent, because no cash is taken.
* Clerk-only, and a maker-checker request like the cash flow: nothing is debited here. The
* account/GL is only touched when a checker approves, which for a member account is also when the
* balance is re-checked — so a draft raised against a sufficient balance can still be refused if
* 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
* exactly one, since which table the account lives in decides which control GL is debited.
* The funding account goes in exactly one of savingsAccountId, currentAccountId or glAccountId: the
* server insists on exactly one, since which one decides what gets debited on approval.
*/
private submitAccountDebitDd(
row: TxnRow,
leaflet: any,
beneficiaryData: any,
fundingAccountId: number,
fundingClientId: number
fundingClientId: number | null
): void {
const d = new Date();
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 isGl = row.acTyp === 'GL';
const payload = {
clientId: fundingClientId,
clientId: isGl ? undefined : (fundingClientId ?? undefined),
beneficiaryName: beneficiaryData.beneficiaryName,
payableAtBranch: beneficiaryData.payableAtBranch,
payableAtCity: beneficiaryData.payableAtCity,
@@ -2540,9 +2585,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
chargesAmount: Number(row.ddCharges) || 0,
issuingBranchId: row.br,
issuedDate,
paymentMethod: 'ACCOUNT_DEBIT' as const,
savingsAccountId: isCurrent ? undefined : fundingAccountId,
currentAccountId: isCurrent ? fundingAccountId : undefined
paymentMethod: (isGl ? 'GL' : 'ACCOUNT_DEBIT') as 'GL' | 'ACCOUNT_DEBIT',
savingsAccountId: isGl || isCurrent ? undefined : fundingAccountId,
currentAccountId: isGl || !isCurrent ? undefined : fundingAccountId,
glAccountId: isGl ? fundingAccountId : undefined
};
this.saving = true;
@@ -3044,6 +3090,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(
payload: Record<string, unknown>,
shareRow: TxnRow,
@@ -3103,37 +3311,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
.pipe(
catchError((err: any) => {
// 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
// cash-claim pool, then the account-closure pool, before giving up: all three share
// the same TOK. NO. field at this counter.
// which are legitimate matches with their own specific error) — try the account-closure
// pool before giving up: both share the same TOK. NO. field at this counter. A dividend
// 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) {
return this.dividendClaimRequestService.getByToken(token).pipe(
catchError((divErr: 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))
);
}
return this.closureRequestService.getByToken(token).pipe(
catchError((closeErr: any) => {
this.loadingWithdrawal = false;
const msg: string =
divErr?.error?.message ??
divErr?.error?.errors?.[0]?.defaultUserMessage ??
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);
}),
// Only tag a result that actually came from the dividend pool — a closure hit
// recovered by the inner fallback above is already tagged and must stay that way.
map((divReq: any) => (divReq && !divReq.__closureRequest ? { __dividendClaim: true, ...divReq } : divReq))
map((closeReq: any) => (closeReq ? { __closureRequest: true, ...closeReq } : null))
);
}
this.loadingWithdrawal = false;
@@ -3159,7 +3353,16 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.clientDetails = null;
this.accountError = '';
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';
// 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.dbCr = 'Dr';
this.selectedRow = row;
@@ -3181,7 +3384,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.clientDetails = null;
this.accountError = '';
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.typ = 'Payment';
row.amount = Number(req.cashier_disbursed_amount) || null;
row.dbCr = 'Dr';
// A closure request stores payment_type_id as NULL, meaning "Cash (Default)" — resolve the
@@ -3194,15 +3400,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
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.acTyp = 'SD';
row.acTyp = acTyp;
row.typ = 'Payment';
row.amount = Number(req.amount) || null;
row.dbCr = 'Dr';
row.paymentTypeId = req.payment_type_id ?? null;
row.description = req.description ?? '';
this.selectedRow = row;
if (req.account_no) {
this.loadAccountDetails(req.account_no.trim(), 'SD');
this.loadAccountDetails(req.account_no.trim(), acTyp);
}
});
}
@@ -3229,10 +3443,24 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
onAcNoBlur(row: TxnRow, index: number): void {
if (!row.acNo.trim()) return;
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);
}
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;
this.lastLoadedAcNo = '';
this.acNoInput$.next({ row, index, value });
@@ -3341,8 +3569,15 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
const match = exactMatch ?? partialMatch ?? idMatch ?? null;
if (match?.id) {
const matchedGlCode = match[f] ?? acNo;
if (this.selectedRow && this.selectedRow.typ === 'DD' && match.glCode) {
this.selectedRow.acNo = match.glCode;
// Any row resolved via the partial/endsWith/id fallback above (not an exact code match)
// 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;
this.fetchAllDetails(match.id, matchedGlCode, cfg, token, acTyp, clientIdFromSearch);
@@ -4519,6 +4754,26 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// "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
// 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 =
this.rows.every((r) => r.typ === 'Fund Transfer') && this.rows.some((r) => r.acTyp === 'GL');
if (
+2
View File
@@ -56,6 +56,7 @@ import {
faGavel,
faGlobe,
faHandHoldingUsd,
faHandPaper,
faHome,
faKey,
faLink,
@@ -303,6 +304,7 @@ export class IconsModule {
faHandHoldingHand,
faHandHoldingHeart,
faHandHoldingUsd,
faHandPaper,
faHeart,
faHome,
faKey,
@@ -5,124 +5,244 @@
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="inventory-container">
<div class="header-row">
<div class="title-area">
<h2>Sponsor Bank DD Inventory</h2>
<p>Manage allocated ranges of physical Demand Draft leaflets received from Sponsor Banks.</p>
<!--
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="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>
<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>
<!-- Says why the clerk is here when they arrived from a dividend claim rather than from the menu.
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. -->
<!-- Dividend Context Banner -->
@if (dividendContext) {
<div class="dividend-banner">
<div class="dividend-banner__text">
<strong>Issuing a dividend Demand Draft</strong>
<div class="inv-dividend-banner">
<div class="inv-dividend-banner__ic">
<fa-icon icon="hand-holding-usd"></fa-icon>
</div>
<div class="inv-dividend-banner__text">
<strong>Issuing Dividend Demand Draft</strong>
<span>
{{ dividendContext.beneficiary }} ·
{{ dividendContext.amount | number: '1.2-2' }}
Beneficiary: <strong>{{ dividendContext.beneficiary }}</strong> · Amount:
<strong>₹{{ dividendContext.amount | number: '1.2-2' }}</strong>
@if (dividendContext.financialYear) {
· FY {{ dividendContext.financialYear }}
}
</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>
<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>
}
<mat-card>
<table mat-table [dataSource]="dataSource">
<!-- ID Column -->
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef>Batch ID</th>
<td mat-cell *matCellDef="let element">{{ 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">{{ 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.
<!-- KPI Metrics Bar -->
<div class="inv-kpi-grid">
<div class="inv-kpi-card">
<div class="inv-kpi-card__icon blue">
<fa-icon icon="layer-group"></fa-icon>
</div>
<div class="inv-kpi-card__info">
<span class="inv-kpi-card__label">Total Series Batches</span>
<span class="inv-kpi-card__val">{{ totalBatches }}</span>
</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>
@@ -22,6 +22,8 @@ import { MatNativeDateModule } from '@angular/material/core';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { HttpClient } from '@angular/common/http';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { SponsorDDService } from '../sponsor-dd.service';
@Component({
@@ -30,106 +32,482 @@ import { SponsorDDService } from '../sponsor-dd.service';
imports: [
CommonModule,
RouterModule,
FormsModule,
ReactiveFormsModule,
MatCardModule,
MatTableModule,
MatButtonModule,
MatDialogModule,
MatSnackBarModule
MatSnackBarModule,
MatFormFieldModule,
MatInputModule,
MatIconModule,
MatTooltipModule,
FaIconComponent
],
templateUrl: './dd-inventory-list.component.html',
styles: [
`
.inventory-container {
.inv-container {
padding: 24px;
max-width: 1200px;
max-width: 1360px;
margin: 0 auto;
}
.header-row {
/* Hero Header */
.inv-hero {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
gap: 16px;
flex-wrap: wrap;
}
.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;
}
.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 {
.inv-hero__left {
display: flex;
align-items: center;
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;
flex-direction: column;
gap: 2px;
font-size: 13px;
font-size: 13.5px;
color: #312e81;
}
.dividend-banner__text strong {
font-size: 13.5px;
}
.dividend-banner__hint {
color: #4338ca;
.inv-dividend-banner__hint {
font-size: 12px;
color: #4338ca;
}
.dividend-banner button {
.inv-dividend-banner button {
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 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;
searchText = '';
selectedStatusFilter = 'ALL';
displayedColumns: string[] = [
'id',
@@ -168,13 +541,6 @@ export class DdInventoryListComponent implements OnInit {
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 {
const params = this.route.snapshot.queryParams;
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> {
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 {
this.router.navigate([
'/clerk',
@@ -212,7 +576,7 @@ export class DdInventoryListComponent implements OnInit {
loadInventories(): void {
this.sponsorDDService.getInventories().subscribe({
next: (data) => {
this.dataSource = data;
this.dataSource = data || [];
},
error: (err) => {
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 {
const dialogRef = this.dialog.open(AddInventoryDialogComponent, {
width: '500px',
width: '520px',
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
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="leaflet-container">
<div class="header-row">
<div class="title-area">
<button mat-button routerLink="/sponsor-dd/inventory" style="margin-bottom: 8px; color: #64748b; padding-left: 0">
&larr; Back to Inventories
<div class="ddreg-container">
<!-- Hero header -->
<div class="ddreg-header">
<div class="ddreg-header__left">
<button class="ddreg-back" routerLink="/sponsor-dd/inventory" matTooltip="Back to Inventories">
<fa-icon icon="arrow-left"></fa-icon>
</button>
<h2>DD Leaflet Registry</h2>
<p *ngIf="inventoryDetails">
Sponsor Bank: <strong>{{ inventoryDetails.sponsorBankName }}</strong> | Series Range:
<strong>{{ inventoryDetails.seriesStart }} - {{ inventoryDetails.seriesEnd }}</strong>
</p>
<fa-icon icon="money-check-alt" class="ddreg-header__icon"></fa-icon>
<div>
<h2 class="ddreg-header__title">DD Leaflet Registry</h2>
<div class="ddreg-header__sub" *ngIf="inventoryDetails">
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>
<!-- 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. -->
<div class="dividend-banner" *ngIf="dividendContext">
<div class="dividend-banner__text">
<div class="ddreg-dividend" *ngIf="dividendContext">
<fa-icon icon="hand-holding-usd" class="ddreg-dividend__ic"></fa-icon>
<div class="ddreg-dividend__text">
<strong>Issuing a dividend Demand Draft</strong>
<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>
</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>
<button mat-button class="action-btn" (click)="cancelDividendClaim()">Cancel</button>
<button class="ddreg-btn ghost small" (click)="cancelDividendClaim()">Cancel</button>
</div>
<mat-form-field appearance="outline" class="search-field">
<mat-label>Search by DD Leaflet No.</mat-label>
<input matInput [value]="searchText" (input)="onSearchChange($event.target.value)" placeholder="e.g. 3005" />
</mat-form-field>
<div class="status-filter-row">
<button mat-stroked-button color="accent" [class.filter-active]="todayOnly" (click)="toggleTodayOnly()">
<!-- Toolbar: search + date scope -->
<div class="ddreg-toolbar">
<div class="ddreg-toolbar__search">
<fa-icon icon="search" class="ddreg-toolbar__search-ic"></fa-icon>
<input
type="text"
[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' }}
</button>
</div>
<!-- Status filter chips — doubles as an at-a-glance count of every leaflet state -->
<div class="ddreg-chips">
<button
mat-stroked-button
*ngFor="let status of statusOptions"
[color]="selectedStatus === status ? 'primary' : undefined"
[class.filter-active]="selectedStatus === status"
class="ddreg-chip"
[ngClass]="'s-' + status.toLowerCase()"
[class.active]="selectedStatus === status"
(click)="filterByStatus(status)"
>
{{ status === 'ALL' ? 'All' : status }}
<span class="filter-count">({{ statusCount(status) }})</span>
<span class="ddreg-chip__dot"></span>
{{ status === 'ALL' ? 'All' : (status | titlecase) }}
<span class="ddreg-chip__count">{{ statusCount(status) }}</span>
</button>
</div>
<p class="hint-text" *ngIf="todayOnly">
Only DDs actually ISSUED today are shown - AVAILABLE, CANCELLED, STOPPED and CLEARED are hidden. Click "Showing All
<p class="ddreg-hint" *ngIf="todayOnly">
<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.
</p>
<mat-card>
<table mat-table [dataSource]="dataSource">
<!-- DD Number Column -->
<ng-container matColumnDef="ddNumber">
<th mat-header-cell *matHeaderCellDef>DD Leaflet No.</th>
<td mat-cell *matCellDef="let element">
<strong>{{ element.ddNumber }}</strong>
</td>
</ng-container>
<!-- Leaflets table -->
<div class="ddreg-card">
<div class="ddreg-card__head">
<div class="ddreg-card__title">
<fa-icon icon="list" class="ddreg-card__title-ic"></fa-icon>
Leaflets
<span class="ddreg-card__count">{{ dataSource.filteredData.length }} of {{ dataSource.data.length }}</span>
</div>
</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 -->
<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>
<!-- 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 }})
<!-- Status Column -->
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let element">
<span class="ddreg-badge" [ngClass]="element.status.toLowerCase()">
<span class="ddreg-badge__dot"></span>
{{ element.status | titlecase }}
</span>
</td>
</ng-container>
<!-- The leaflet is ruined and cannot be handed over. Offered beside Request Reprint
because the two look alike from the counter: reprint when the paper survived, revoke
when it did not. -->
<button
mat-stroked-button
color="warn"
size="small"
*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>
<!-- Face Value Column -->
<ng-container matColumnDef="faceValue">
<th mat-header-cell *matHeaderCellDef class="ddreg-num">Face Value</th>
<td mat-cell *matCellDef="let element" class="ddreg-num ddreg-mono">
{{ element.faceValue > 0 ? '₹' + (element.faceValue | number: '1.2-2') : '—' }}
</td>
</ng-container>
<!-- 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="pending-badge" *ngIf="element.revokedReplacedByDdNumber">
Revoked → replaced by DD {{ element.revokedReplacedByDdNumber }}
</span>
<span class="pending-badge" *ngIf="element.revokedReplacementOfDdNumber">
Replaces spoiled DD {{ element.revokedReplacementOfDdNumber }}
</span>
</div>
</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>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
<!-- Beneficiary Column -->
<ng-container matColumnDef="beneficiaryName">
<th mat-header-cell *matHeaderCellDef>Beneficiary</th>
<td mat-cell *matCellDef="let element">{{ element.beneficiaryName || '—' }}</td>
</ng-container>
<div
*ngIf="dataSource.filteredData.length === 0"
class="no-records-row"
style="text-align: center; padding: 32px; color: #64748b"
>
{{ dataSource.data.length === 0 ? 'Loading leaflets...' : 'No leaflets match the selected filter.' }}
<!-- Issued Date Column -->
<ng-container matColumnDef="issuedDate">
<th mat-header-cell *matHeaderCellDef>Issued Date</th>
<td mat-cell *matCellDef="let element" class="ddreg-mono">{{ 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" 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>
<mat-paginator [pageSizeOptions]="[25, 50, 100, 250]" [pageSize]="25" showFirstLastButtons></mat-paginator>
</mat-card>
</div>
</div>
+577 -95
View File
@@ -21,6 +21,9 @@ import { MatSelectModule } from '@angular/material/select';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatNativeDateModule } from '@angular/material/core';
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 { HttpClient } from '@angular/common/http';
import { debounceTime } from 'rxjs/operators';
@@ -40,145 +43,624 @@ import { DividendClaimRequestService } from '../../clerk/dividend-claim-request.
MatDialogModule,
MatSnackBarModule,
MatFormFieldModule,
MatInputModule
MatInputModule,
MatMenuModule,
MatIconModule,
MatTooltipModule,
FaIconComponent
],
templateUrl: './dd-list.component.html',
styles: [
`
.leaflet-container {
padding: 24px;
max-width: 1200px;
/* Same visual language as users.component.scss (gradient header icon, KPI-style chips, card
table with sticky head + row-hover, icon-button/overflow-menu actions) so this registry
reads as part of the same app rather than a one-off style. */
.ddreg-container {
padding: 20px 24px;
max-width: 1320px;
margin: 0 auto;
}
.dividend-banner {
/* ─── Header ─── */
.ddreg-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
margin-bottom: 16px;
}
.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;
border: 1px solid #c7d2fe;
border-left: 4px solid #4f46e5;
border-radius: 8px;
background-color: #eef2ff;
border-radius: 10px;
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;
flex-direction: column;
gap: 2px;
font-size: 13px;
color: #312e81;
}
.dividend-banner__hint {
.ddreg-dividend__hint {
color: #4338ca;
font-size: 12px;
}
.dividend-banner button {
.ddreg-dividend .ddreg-btn {
margin-left: auto;
}
.header-row {
display: flex;
justify-content: space-between;
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 {
/* ─── Buttons ─── */
.ddreg-btn {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 9999px;
gap: 8px;
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-weight: 500;
}
.status-badge.available {
background-color: #dcfce7;
color: #15803d;
}
.status-badge.issued {
background-color: #dbeafe;
color: #1d4ed8;
}
.status-badge.cleared {
background-color: #f1f5f9;
.ddreg-btn.ghost {
border-color: #d0d5dd;
color: #475569;
}
.status-badge.cancelled {
background-color: #fee2e2;
.ddreg-btn.ghost:hover {
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;
}
.status-badge.stopped {
background-color: #fef3c7;
.ddreg-chip.s-stopped {
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;
}
/* 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. */
.status-badge.revoked {
background-color: #e2e8f0;
.ddreg-badge.revoked {
background: #e2e8f0;
color: #475569;
}
.action-btn-group {
display: flex;
gap: 8px;
/* ─── Actions ─── */
.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;
align-items: center;
padding: 4px 10px;
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 {
justify-content: center;
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;
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;
issuingBranchId: number;
issuedDate: string; // yyyy-MM-dd
paymentMethod: 'ACCOUNT_DEBIT' | 'CHEQUE' | 'CASH';
paymentMethod: 'ACCOUNT_DEBIT' | 'CHEQUE' | 'CASH' | 'GL';
savingsAccountId?: number;
currentAccountId?: number;
chequeNumber?: string;
chequeBankName?: string;
cashierDepositRequestId?: number;
denominationData?: string;
glAccountId?: number;
}
): Observable<any> {
return this.http.post<any>(`/sponsordd/leaflets/${leafletId}/issue-request`, data);