Merge branch 'dev-latest' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev-latest
This commit is contained in:
@@ -26,7 +26,6 @@ import { VaultPositionComponent } from './vault-position/vault-position.componen
|
||||
import { AccountRegisterComponent } from './account-register/account-register.component';
|
||||
import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component';
|
||||
import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component';
|
||||
import { ShareCollectionComponent } from './share-collection/share-collection.component';
|
||||
|
||||
const routes: Routes = [
|
||||
Route.withShell([
|
||||
@@ -345,11 +344,6 @@ const routes: Routes = [
|
||||
component: ExcessTransactionReportComponent,
|
||||
data: { title: 'Excess Transaction Report', breadcrumb: 'Excess Transaction Report' }
|
||||
},
|
||||
{
|
||||
path: 'share-collection',
|
||||
component: ShareCollectionComponent,
|
||||
data: { title: 'Share Collection', breadcrumb: 'Share Collection' }
|
||||
},
|
||||
{
|
||||
path: 'transaction-entry',
|
||||
canActivate: [CashierOnlyGuard],
|
||||
|
||||
@@ -20,7 +20,6 @@ import { ReconciliationComponent } from './reconciliation/reconciliation.compone
|
||||
import { CashHistoryComponent } from './cash-history/cash-history.component';
|
||||
import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component';
|
||||
import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component';
|
||||
import { ShareCollectionComponent } from './share-collection/share-collection.component';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
@@ -35,8 +34,7 @@ import { ShareCollectionComponent } from './share-collection/share-collection.co
|
||||
ReconciliationComponent,
|
||||
CashHistoryComponent,
|
||||
ClosingCashReportComponent,
|
||||
ExcessTransactionReportComponent,
|
||||
ShareCollectionComponent
|
||||
ExcessTransactionReportComponent
|
||||
]
|
||||
})
|
||||
export class CashierModule {}
|
||||
|
||||
@@ -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.
|
||||
@@ -357,19 +364,6 @@ export class CashierService {
|
||||
return this.http.get<any[]>('/paymenttypes');
|
||||
}
|
||||
|
||||
// ── Share Collection ──
|
||||
getShareAccountForCollection(accountId: string): Observable<any> {
|
||||
return this.http.get(`/accounts/share/${accountId}`);
|
||||
}
|
||||
|
||||
collectSharePayment(data: any): Observable<any> {
|
||||
return this.http.post('/cashier/sharecollection', data);
|
||||
}
|
||||
|
||||
getShareCollectionStatus(shareAccountId: number): Observable<any> {
|
||||
return this.http.get(`/cashier/sharecollection/${shareAccountId}`);
|
||||
}
|
||||
|
||||
/** One unapplied Share Sundry receipt, found by the voucher number the member quotes. */
|
||||
getShareAdvanceByVoucher(voucherNo: string, glAccountNo: string, clientId?: number): Observable<any> {
|
||||
// The voucher travels as a query parameter, not a path segment: numbers are formatted
|
||||
@@ -407,6 +401,12 @@ export class CashierService {
|
||||
return this.http.post(`/shareadvancecollection/${collectionId}/apply`, data);
|
||||
}
|
||||
|
||||
/** The receipt's own real Dr/Cr GL legs — never the GL account currently picked in a search box,
|
||||
* which is unrelated screen state that can name a different account than this receipt sits in. */
|
||||
getShareAdvanceGlEntries(collectionId: number): Observable<any> {
|
||||
return this.http.get(`/shareadvancecollection/${collectionId}/gl-entries`);
|
||||
}
|
||||
|
||||
/** Every additional-share tranche awaiting approval, across every share account. */
|
||||
getShareAdditionRequests(): Observable<any> {
|
||||
return this.http.get('/shareadditionrequests');
|
||||
@@ -419,4 +419,14 @@ export class CashierService {
|
||||
getShareAdditionRequest(transactionId: number): Observable<any> {
|
||||
return this.http.get(`/shareadditionrequests/${transactionId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry Type options for a GL credit (General / Share Collection / DD Issue / …) — the
|
||||
* "GLEntryType" code under Admin > System > Manage Codes, so an admin can add another type later
|
||||
* without a deployment. Empty on failure rather than erroring, since a row missing this dropdown's
|
||||
* options should still be usable for every other field.
|
||||
*/
|
||||
getEntryTypeOptions(): Observable<any[]> {
|
||||
return this.http.get<any[]>('/codes/name/GLEntryType/codevalues').pipe(catchError(() => of([])));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,19 +174,24 @@ export class ClosingCashReportComponent implements OnInit {
|
||||
return this.noteRows.reduce((s, r) => s + Number(r.settledOutAmount ?? 0), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' }} · {{ detail.branchName || 'Head Office' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button mat-icon-button mat-dialog-close aria-label="Close">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="vd-meta">
|
||||
<span
|
||||
>Raised by <strong>{{ detail.createdBy || '—' }}</strong></span
|
||||
>
|
||||
@if (detail.createdAt) {
|
||||
<span>{{ detail.createdAt | date: 'dd MMM yyyy, h:mm a' }}</span>
|
||||
}
|
||||
@if (!detail.balanced) {
|
||||
<span class="vd-unbalanced">
|
||||
<mat-icon inline>warning</mat-icon>
|
||||
Debit and credit do not balance
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<table class="vd-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Side</th>
|
||||
<th>Party</th>
|
||||
<th>Account No.</th>
|
||||
<th class="num">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (line of detail.lines; track line.lineNo) {
|
||||
<tr>
|
||||
<td>
|
||||
<span
|
||||
class="vd-side"
|
||||
[class.vd-side--dr]="line.entryType === 'DR'"
|
||||
[class.vd-side--cr]="line.entryType === 'CR'"
|
||||
>
|
||||
{{ line.entryType === 'DR' ? 'Debit' : 'Credit' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ line.partyName || '—' }}</td>
|
||||
<td class="vd-acct">{{ line.accountNo || '—' }}</td>
|
||||
<td class="num">₹{{ line.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
@if (line.description) {
|
||||
<tr class="vd-desc-row">
|
||||
<td></td>
|
||||
<td colspan="3" class="vd-desc">{{ line.description }}</td>
|
||||
</tr>
|
||||
}
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="4" class="vd-empty">This voucher's individual lines were not recorded.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
@if (detail.lines.length > 0) {
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="3">Total</td>
|
||||
<td class="num">
|
||||
₹{{ detail.totalDebit | number: '1.2-2' }} / ₹{{ detail.totalCredit | number: '1.2-2' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
}
|
||||
</table>
|
||||
|
||||
@if (detail.relatedLines.length > 0) {
|
||||
<div class="vd-related">
|
||||
<div class="vd-related__title">
|
||||
<mat-icon inline>link</mat-icon>
|
||||
Also posted for this transaction — not part of this voucher
|
||||
</div>
|
||||
<table class="vd-table vd-table--related">
|
||||
<tbody>
|
||||
@for (line of detail.relatedLines; track $index) {
|
||||
<tr>
|
||||
<td>
|
||||
<span
|
||||
class="vd-side"
|
||||
[class.vd-side--dr]="line.entryType === 'DR'"
|
||||
[class.vd-side--cr]="line.entryType === 'CR'"
|
||||
>
|
||||
{{ line.entryType === 'DR' ? 'Debit' : 'Credit' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ line.partyName || '—' }}</td>
|
||||
<td class="vd-acct">{{ line.accountNo || '—' }}</td>
|
||||
<td class="num">₹{{ line.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="vd-related__hint">
|
||||
{{ detail.relatedLines[0].stage }} — this and the lines above are the same transaction's full accounting.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="vd-actions">
|
||||
<button mat-flat-button color="primary" mat-dialog-close>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.vd-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 20px 24px 16px;
|
||||
min-width: 420px;
|
||||
max-width: 560px;
|
||||
}
|
||||
.vd-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.vd-header__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.vd-header__title h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.vd-header__title p {
|
||||
margin: 2px 0 0;
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.vd-icon {
|
||||
color: #1976d2;
|
||||
}
|
||||
.vd-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 16px;
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
.vd-unbalanced {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #b91c1c;
|
||||
font-weight: 600;
|
||||
}
|
||||
.vd-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
.vd-table th {
|
||||
text-align: left;
|
||||
text-transform: uppercase;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.03em;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.vd-table td {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.vd-table .num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.vd-acct {
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.vd-side {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.vd-side--dr {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
.vd-side--cr {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
.vd-desc-row td {
|
||||
padding-top: 0;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.vd-desc {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
font-style: italic;
|
||||
}
|
||||
.vd-empty {
|
||||
text-align: center;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
padding: 16px 8px;
|
||||
}
|
||||
.vd-table tfoot td {
|
||||
font-weight: 600;
|
||||
border-bottom: none;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.vd-related {
|
||||
border-top: 1px dashed rgba(0, 0, 0, 0.15);
|
||||
padding-top: 12px;
|
||||
}
|
||||
.vd-related__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: #6d28d9;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.vd-table--related td {
|
||||
background: #faf5ff;
|
||||
}
|
||||
.vd-related__hint {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.vd-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class VoucherDetailDialogComponent {
|
||||
dialogRef = inject(MatDialogRef<VoucherDetailDialogComponent>);
|
||||
private data = inject<VoucherDetailDialogData>(MAT_DIALOG_DATA);
|
||||
|
||||
detail = this.data.detail;
|
||||
}
|
||||
@@ -1,375 +0,0 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="pg">
|
||||
<!-- ── HEADER ── -->
|
||||
<header class="pg-head">
|
||||
<div>
|
||||
<div class="pg-eyebrow">CASHIER · SHARE COLLECTION</div>
|
||||
<h1 class="pg-title">Share Collection</h1>
|
||||
<p class="pg-sub">
|
||||
Collect cash from member for share purchase — amounts are held in Sundry GL until the account is activated.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ── SEARCH BAR ── -->
|
||||
@if (pageState !== 'success') {
|
||||
<div class="sc-search-bar">
|
||||
<div class="sc-search-bar__input-wrap">
|
||||
<mat-icon class="sc-search-bar__icon">search</mat-icon>
|
||||
<input
|
||||
class="sc-search-bar__input"
|
||||
type="text"
|
||||
placeholder="Enter Share Account ID or Account No."
|
||||
[(ngModel)]="accountInput"
|
||||
(keyup.enter)="searchAccount()"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--primary"
|
||||
[disabled]="pageState === 'searching' || !accountInput.trim()"
|
||||
(click)="searchAccount()"
|
||||
>
|
||||
<mat-icon>manage_search</mat-icon>
|
||||
{{ pageState === 'searching' ? 'Searching…' : 'Search' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── ERROR ── -->
|
||||
@if (errorMsg) {
|
||||
<div class="sc-alert sc-alert--error">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>{{ errorMsg }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── SEARCHING SPINNER ── -->
|
||||
@if (pageState === 'searching') {
|
||||
<div class="pg-empty">
|
||||
<mat-icon class="sc-spin">hourglass_empty</mat-icon>
|
||||
<p>Looking up account…</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── ACCOUNT LOADED ── -->
|
||||
@if (pageState === 'loaded') {
|
||||
<!-- Member Card -->
|
||||
<div class="sc-member-card">
|
||||
<div class="sc-member-card__avatar">
|
||||
<mat-icon>person</mat-icon>
|
||||
</div>
|
||||
<div class="sc-member-card__info">
|
||||
<div class="sc-member-card__name">{{ account.clientName }}</div>
|
||||
<div class="sc-member-card__meta">
|
||||
<span class="sc-badge sc-badge--account">{{ account.accountNo }}</span>
|
||||
<span class="sc-badge sc-badge--product">{{ account.productName }}</span>
|
||||
<span
|
||||
class="sc-badge"
|
||||
[class.sc-badge--pending]="account.status?.code?.includes('pending')"
|
||||
[class.sc-badge--active]="account.status?.code?.includes('active')"
|
||||
>{{ account.status?.value }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editable Shares + Price -->
|
||||
<div class="sc-member-card__shares">
|
||||
<div class="sc-shares-inputs">
|
||||
<div class="sc-shares-field">
|
||||
<label class="sc-shares-label">No. of Shares</label>
|
||||
<input
|
||||
class="sc-shares-input"
|
||||
type="number"
|
||||
min="0"
|
||||
[(ngModel)]="requestedSharesInput"
|
||||
(input)="recalc()"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div class="sc-shares-sep">×</div>
|
||||
<div class="sc-shares-field">
|
||||
<label class="sc-shares-label">Price / Share (₹)</label>
|
||||
<input
|
||||
class="sc-shares-input"
|
||||
type="number"
|
||||
min="0"
|
||||
[(ngModel)]="pricePerShare"
|
||||
(input)="recalc()"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
<div class="sc-shares-sep">=</div>
|
||||
<div class="sc-shares-result">
|
||||
<div class="sc-shares-label">Share Amount</div>
|
||||
<div class="sc-shares-total">{{ currencySymbol }}{{ shareAmount | number: '1.2-2' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Amount Breakdown + GL Preview -->
|
||||
<div class="sc-split">
|
||||
<!-- LEFT: Amount Breakdown -->
|
||||
<div class="pg-card sc-breakdown">
|
||||
<div class="sc-breakdown__header">
|
||||
<mat-icon>receipt_long</mat-icon>
|
||||
<span>Amount Breakdown</span>
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<th class="num">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
Share Amount
|
||||
<span class="sc-dim">
|
||||
({{ requestedSharesInput }} shares × {{ currencySymbol }}{{ pricePerShare | number: '1.2-2' }})
|
||||
</span>
|
||||
</td>
|
||||
<td class="num">{{ currencySymbol }}{{ shareAmount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
@for (charge of charges; track charge.id) {
|
||||
<tr>
|
||||
<td>{{ charge.name }}</td>
|
||||
<td class="num sc-charge-amount">
|
||||
<input class="sc-charge-input" type="number" min="0" [(ngModel)]="charge.amount" (input)="recalc()" />
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (charges.length === 0) {
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<span class="sc-dim">No charges applicable</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="sc-total-row">
|
||||
<td><strong>Total Payable</strong></td>
|
||||
<td class="num sc-total-amount">
|
||||
<strong>{{ currencySymbol }}{{ totalAmount | number: '1.2-2' }}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: GL Preview -->
|
||||
<div class="pg-card sc-gl-preview">
|
||||
<div class="sc-breakdown__header">
|
||||
<mat-icon>account_tree</mat-icon>
|
||||
<span>GL Entries Preview</span>
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account</th>
|
||||
<th>Dr / Cr</th>
|
||||
<th class="num">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="gl-row--dr">
|
||||
<td class="gl-acname">Cash Vault GL</td>
|
||||
<td><span class="sc-dr-badge">Dr</span></td>
|
||||
<td class="num gl-amt--dr">{{ currencySymbol }}{{ totalAmount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
<tr class="gl-row--cr">
|
||||
<td class="gl-acname">Share Sundry GL</td>
|
||||
<td><span class="sc-cr-badge">Cr</span></td>
|
||||
<td class="num gl-amt--cr">{{ currencySymbol }}{{ shareAmount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
@if (totalChargeAmount > 0) {
|
||||
<tr class="gl-row--cr">
|
||||
<td class="gl-acname">Charge Receivable GL</td>
|
||||
<td><span class="sc-cr-badge">Cr</span></td>
|
||||
<td class="num gl-amt--cr">{{ currencySymbol }}{{ totalChargeAmount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="sc-gl-note">
|
||||
<mat-icon>info_outline</mat-icon>
|
||||
Funds move to Share GL & Income GL when account is <strong>activated</strong>.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── DENOMINATION (Cash Only) ── -->
|
||||
@if (isCashPayment && allDenominations.length > 0) {
|
||||
<div class="pg-card sc-denom">
|
||||
<div class="sc-breakdown__header">
|
||||
<mat-icon>payments</mat-icon>
|
||||
<span>Denomination Entry</span>
|
||||
@if (denominationTotal > 0) {
|
||||
<span
|
||||
class="sc-denom-badge"
|
||||
[class.sc-denom-badge--match]="denomMatchesTotal"
|
||||
[class.sc-denom-badge--mismatch]="!denomMatchesTotal"
|
||||
>
|
||||
₹{{ denominationTotal | number: '1.2-2' }}
|
||||
{{ denomMatchesTotal ? '✓ Matches' : '≠ Mismatch' }}
|
||||
</span>
|
||||
}
|
||||
@if (denominationTotal === 0) {
|
||||
<span class="sc-denom-required">⚠ Required for Cash</span>
|
||||
}
|
||||
</div>
|
||||
<div class="sc-denom-body">
|
||||
<table class="pg-table sc-denom-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Denomination</th>
|
||||
<th class="num">Count</th>
|
||||
<th class="num">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (d of allDenominations; track d.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="sc-denom-chip" [class.sc-denom-chip--coin]="d.kind === 'coin'">{{ d.label }}</span>
|
||||
</td>
|
||||
<td class="num">
|
||||
<div class="sc-denom-stepper">
|
||||
<button type="button" (click)="bumpDenom(d, -1)" [disabled]="countFor(d) === 0">−</button>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
[value]="countFor(d) || ''"
|
||||
placeholder="0"
|
||||
(input)="onDenomChange(d, $any($event.target).value)"
|
||||
/>
|
||||
<button type="button" (click)="bumpDenom(d, 1)">+</button>
|
||||
</div>
|
||||
</td>
|
||||
<td class="num">{{ subtotalFor(d) > 0 ? (subtotalFor(d) | number: '1.2-2') : '—' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td><strong>Total</strong></td>
|
||||
<td></td>
|
||||
<td class="num">
|
||||
<strong>₹{{ denominationTotal | number: '1.2-2' }}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
@if (!denomMatchesTotal && denominationTotal > 0) {
|
||||
<div class="sc-denom-mismatch">
|
||||
<mat-icon>warning</mat-icon>
|
||||
Denomination ₹{{ denominationTotal | number: '1.2-2' }} must equal Total Payable ₹{{
|
||||
totalAmount | number: '1.2-2'
|
||||
}}.
|
||||
</div>
|
||||
}
|
||||
@if (denominationTotal > 0) {
|
||||
<div class="sc-denom-actions">
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="clearDenominations()">
|
||||
<mat-icon>clear</mat-icon> Clear
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Transaction Details -->
|
||||
<div class="pg-toolbar pg-toolbar--mat">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Transaction Date *</mat-label>
|
||||
<input matInput type="date" [(ngModel)]="transactionDate" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Payment Mode</mat-label>
|
||||
<mat-select [(ngModel)]="paymentTypeId">
|
||||
<mat-option [value]="null">— Cash (Default) —</mat-option>
|
||||
@for (pt of paymentTypes; track pt.id) {
|
||||
<mat-option [value]="pt.id">{{ pt.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="pg-note-field">
|
||||
<mat-label>Note (Optional)</mat-label>
|
||||
<input matInput type="text" [(ngModel)]="note" placeholder="e.g. Share purchase for 100 shares" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!-- Footer Submit Bar -->
|
||||
<div class="pg-total-bar sc-footer">
|
||||
<div class="sc-footer__summary">
|
||||
<span class="sc-footer__label">Total to Collect</span>
|
||||
<span class="sc-footer__amount">{{ currencySymbol }}{{ totalAmount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="sc-footer__actions">
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="reset()"><mat-icon>close</mat-icon> Cancel</button>
|
||||
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!isValid || saving" (click)="submit()">
|
||||
<mat-icon>payments</mat-icon>
|
||||
{{ saving ? 'Posting…' : 'Collect & Post' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── SUCCESS ── -->
|
||||
@if (pageState === 'success' && collectionResult) {
|
||||
<div class="sc-success">
|
||||
<div class="sc-success__icon-wrap">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
</div>
|
||||
<h2 class="sc-success__title">Collection Posted Successfully</h2>
|
||||
<p class="sc-success__sub">
|
||||
Cash of
|
||||
<strong>{{ collectionResult.currencyCode }} {{ collectionResult.totalAmount | number: '1.2-2' }}</strong>
|
||||
collected from <strong>{{ collectionResult.clientName }}</strong> on
|
||||
<strong>{{ collectionResult.collectionDate }}</strong> by <strong>{{ collectionResult.collectedBy }}</strong
|
||||
>.
|
||||
</p>
|
||||
|
||||
<!-- Transaction reference -->
|
||||
<div class="sc-success__ref">
|
||||
<span class="sc-success__ref-label">Transaction ID</span>
|
||||
<span class="sc-success__ref-val">{{ collectionResult.transactionId }}</span>
|
||||
</div>
|
||||
|
||||
<!-- GL entries from backend -->
|
||||
<div class="sc-success__gl">
|
||||
@for (entry of collectionResult.glEntries; track entry.account) {
|
||||
<div
|
||||
class="sc-success__gl-row"
|
||||
[class.sc-success__gl-row--dr]="entry.type === 'Dr'"
|
||||
[class.sc-success__gl-row--cr]="entry.type === 'Cr'"
|
||||
>
|
||||
<span>{{ entry.type }}. {{ entry.account }}</span>
|
||||
<span>{{ entry.currency }} {{ entry.amount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<p class="sc-success__note">
|
||||
Funds are held in Sundry GL. They will move to Share Equity GL when the account is activated.
|
||||
</p>
|
||||
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="reset()">
|
||||
<mat-icon>add</mat-icon> New Collection
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -1,701 +0,0 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
// ── Search Bar ───────────────────────────────────────────────────────────────
|
||||
|
||||
.sc-search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 10px 14px;
|
||||
|
||||
&__input-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
color: #94a3b8;
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__input {
|
||||
flex: 1 1 auto;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
color: #0d1b2a;
|
||||
background: transparent;
|
||||
|
||||
&::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Alert ────────────────────────────────────────────────────────────────────
|
||||
|
||||
.sc-alert {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&--error {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Spinner ──────────────────────────────────────────────────────────────────
|
||||
|
||||
.sc-spin {
|
||||
animation: sc-spin 1.2s linear infinite;
|
||||
display: block;
|
||||
margin: 0 auto 8px;
|
||||
font-size: 32px !important;
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
@keyframes sc-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Member Card ──────────────────────────────────────────────────────────────
|
||||
|
||||
.sc-member-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
|
||||
&__avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: #eff6ff;
|
||||
flex-shrink: 0;
|
||||
|
||||
mat-icon {
|
||||
color: #2563eb;
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
&__info {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&__shares {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Badges ───────────────────────────────────────────────────────────────────
|
||||
|
||||
.sc-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
border-radius: 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
border: 1px solid #e2e8f0;
|
||||
|
||||
&--account {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
border-color: #bfdbfe;
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
}
|
||||
|
||||
&--product {
|
||||
background: #f0fdf4;
|
||||
color: #15803d;
|
||||
border-color: #bbf7d0;
|
||||
}
|
||||
|
||||
&--pending {
|
||||
background: #fffbeb;
|
||||
color: #d97706;
|
||||
border-color: #fde68a;
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: #f0fdf4;
|
||||
color: #15803d;
|
||||
border-color: #bbf7d0;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Editable Share Inputs ────────────────────────────────────────────────────
|
||||
|
||||
.sc-shares-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.sc-shares-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.sc-shares-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.sc-shares-input {
|
||||
width: 110px;
|
||||
height: 38px;
|
||||
border: 1.5px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 0 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
font-family: inherit;
|
||||
color: #1353a0;
|
||||
text-align: right;
|
||||
outline: none;
|
||||
background: #f8fafc;
|
||||
transition: border-color 0.15s;
|
||||
|
||||
&:focus {
|
||||
border-color: #1353a0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
appearance: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sc-shares-sep {
|
||||
font-size: 20px;
|
||||
font-weight: 300;
|
||||
color: #94a3b8;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.sc-shares-result {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.sc-shares-total {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: #0f4f3c;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
// ── Editable Charge Amount ───────────────────────────────────────────────────
|
||||
|
||||
.sc-charge-amount {
|
||||
padding: 4px 14px !important;
|
||||
}
|
||||
|
||||
.sc-charge-input {
|
||||
width: 100px;
|
||||
height: 32px;
|
||||
border: 1.5px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
padding: 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
font-family: inherit;
|
||||
color: #1353a0;
|
||||
text-align: right;
|
||||
outline: none;
|
||||
background: #f8fafc;
|
||||
transition: border-color 0.15s;
|
||||
|
||||
&:focus {
|
||||
border-color: #1353a0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
appearance: none;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Split Layout ─────────────────────────────────────────────────────────────
|
||||
|
||||
.sc-split {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
|
||||
@media (width <= 900px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Breakdown Card ───────────────────────────────────────────────────────────
|
||||
|
||||
.sc-breakdown {
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: #0f4f3c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sc-gl-preview {
|
||||
.sc-breakdown__header {
|
||||
mat-icon {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sc-gl-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
padding: 10px 14px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
background: #f8fafc;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Table helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
.sc-dim {
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sc-total-row td {
|
||||
background: #f8fafc;
|
||||
border-top: 2px solid #e2e8f0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sc-total-amount {
|
||||
color: #0f4f3c;
|
||||
}
|
||||
|
||||
// ── Dr / Cr badges ───────────────────────────────────────────────────────────
|
||||
|
||||
.sc-dr-badge,
|
||||
.sc-cr-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.sc-dr-badge {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.sc-cr-badge {
|
||||
background: #f0fdf4;
|
||||
color: #15803d;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
|
||||
// ── Footer ───────────────────────────────────────────────────────────────────
|
||||
|
||||
.sc-footer {
|
||||
justify-content: space-between !important;
|
||||
|
||||
&__summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&__amount {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: #0f4f3c;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Denomination Panel ────────────────────────────────────────────────────────
|
||||
|
||||
.sc-denom {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sc-denom-body {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.sc-denom-table {
|
||||
width: 100%;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 7px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
tfoot td {
|
||||
border-top: 2px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.sc-denom-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
padding: 0 10px;
|
||||
border-radius: 5px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
border: 1px solid #bfdbfe;
|
||||
|
||||
&--coin {
|
||||
background: #fffbeb;
|
||||
color: #d97706;
|
||||
border-color: #fde68a;
|
||||
}
|
||||
}
|
||||
|
||||
.sc-denom-stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
justify-content: flex-end;
|
||||
|
||||
button {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: 1.5px solid #e2e8f0;
|
||||
border-radius: 5px;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #e0f2fe;
|
||||
border-color: #7dd3fc;
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
width: 62px;
|
||||
height: 28px;
|
||||
border: 1.5px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
padding: 0 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
text-align: right;
|
||||
outline: none;
|
||||
background: #fff;
|
||||
|
||||
&:focus {
|
||||
border-color: #1353a0;
|
||||
}
|
||||
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
appearance: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sc-denom-badge {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
padding: 0 10px;
|
||||
border-radius: 5px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
|
||||
&--match {
|
||||
background: #f0fdf4;
|
||||
color: #15803d;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
|
||||
&--mismatch {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
}
|
||||
|
||||
.sc-denom-required {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.sc-denom-mismatch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
padding: 9px 12px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #dc2626;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.sc-denom-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
// ── Success ──────────────────────────────────────────────────────────────────
|
||||
|
||||
.sc-success {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 40px 24px;
|
||||
gap: 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 16px;
|
||||
|
||||
&__icon-wrap {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
background: #f0fdf4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
mat-icon {
|
||||
font-size: 40px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: #16a34a;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
max-width: 480px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__ref {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: #f1f5f9;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
&__ref-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: #94a3b8;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__ref-val {
|
||||
font-family: 'Roboto Mono', 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1353a0;
|
||||
}
|
||||
|
||||
&__note {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
max-width: 420px;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__gl {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
&__gl-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px dotted #e2e8f0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&--dr {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
&--cr {
|
||||
color: #15803d;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { of } from 'rxjs';
|
||||
import { CashierService } from '../cashier.service';
|
||||
|
||||
type PageState = 'idle' | 'searching' | 'loaded' | 'submitting' | 'success';
|
||||
|
||||
interface Denomination {
|
||||
id: number;
|
||||
value: number;
|
||||
kind: 'note' | 'coin';
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ShareCharge {
|
||||
id: number;
|
||||
name: string;
|
||||
amount: number;
|
||||
currencySymbol: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-share-collection',
|
||||
templateUrl: './share-collection.component.html',
|
||||
styleUrls: [
|
||||
'../cashier-shared.scss',
|
||||
'./share-collection.component.scss'
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterModule,
|
||||
MatIconModule,
|
||||
MatSnackBarModule,
|
||||
MatFormFieldModule,
|
||||
MatSelectModule,
|
||||
MatInputModule
|
||||
]
|
||||
})
|
||||
export class ShareCollectionComponent implements OnInit {
|
||||
private service = inject(CashierService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
pageState: PageState = 'idle';
|
||||
accountInput = '';
|
||||
transactionDate = '';
|
||||
paymentTypeId: number | null = null;
|
||||
paymentTypes: any[] = [];
|
||||
note = '';
|
||||
errorMsg = '';
|
||||
saving = false;
|
||||
|
||||
account: any = null;
|
||||
charges: ShareCharge[] = [];
|
||||
collectionResult: any = null; // filled after successful POST
|
||||
|
||||
// Editable fields — cashier can override if API returns 0
|
||||
requestedSharesInput = 0;
|
||||
pricePerShare = 0;
|
||||
|
||||
// ── Denomination ────────────────────────────────────────────────────────────
|
||||
notes: Denomination[] = [];
|
||||
coins: Denomination[] = [];
|
||||
denominationCounts: Record<string, number> = {};
|
||||
|
||||
get shareAmount(): number {
|
||||
return this.requestedSharesInput * this.pricePerShare;
|
||||
}
|
||||
|
||||
get totalChargeAmount(): number {
|
||||
return this.charges.reduce((s, c) => s + c.amount, 0);
|
||||
}
|
||||
|
||||
get totalAmount(): number {
|
||||
return this.shareAmount + this.totalChargeAmount;
|
||||
}
|
||||
|
||||
get currencySymbol(): string {
|
||||
return this.account?.currency?.displaySymbol ?? '₹';
|
||||
}
|
||||
|
||||
get isCashPayment(): boolean {
|
||||
if (this.paymentTypeId === null) return true;
|
||||
const pt = this.paymentTypes.find((p: any) => p.id === this.paymentTypeId);
|
||||
return !!pt?.isCashPayment;
|
||||
}
|
||||
|
||||
get allDenominations(): Denomination[] {
|
||||
const desc = (a: Denomination, b: Denomination) => b.value - a.value;
|
||||
return [
|
||||
...[...this.notes].sort(desc),
|
||||
...[...this.coins].sort(desc)
|
||||
];
|
||||
}
|
||||
|
||||
get denominationTotal(): number {
|
||||
return this.allDenominations.reduce((s, d) => s + this.subtotalFor(d), 0);
|
||||
}
|
||||
|
||||
get denomMatchesTotal(): boolean {
|
||||
return this.denominationTotal === this.totalAmount;
|
||||
}
|
||||
|
||||
get isValid(): boolean {
|
||||
if (!this.account || this.totalAmount <= 0 || !this.transactionDate) return false;
|
||||
if (this.isCashPayment && this.allDenominations.length > 0) {
|
||||
return this.denomMatchesTotal;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
const d = new Date();
|
||||
this.transactionDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
this.service
|
||||
.getPaymentTypes()
|
||||
.pipe(catchError(() => of([])))
|
||||
.subscribe((types: any[]) => (this.paymentTypes = types || []));
|
||||
this.loadDenominations();
|
||||
}
|
||||
|
||||
private loadDenominations(): void {
|
||||
this.service
|
||||
.getActiveDenominations()
|
||||
.pipe(catchError(() => of([])))
|
||||
.subscribe((list: any[]) => {
|
||||
const rows: Denomination[] = (list || []).map((d: any) => ({
|
||||
id: d.id,
|
||||
value: d.denomination ?? d.value,
|
||||
kind: d.type === 'coin' ? 'coin' : 'note',
|
||||
label: '₹' + (d.denomination ?? d.value)
|
||||
}));
|
||||
const order = (a: Denomination, b: Denomination) => b.value - a.value;
|
||||
this.notes = rows.filter((r) => r.kind === 'note').sort(order);
|
||||
this.coins = rows.filter((r) => r.kind === 'coin').sort(order);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Denomination helpers ──────────────────────────────────────────────────
|
||||
|
||||
denomKey(d: Denomination): string {
|
||||
return `${d.kind}:${d.value}`;
|
||||
}
|
||||
|
||||
countFor(d: Denomination): number {
|
||||
return this.denominationCounts[this.denomKey(d)] || 0;
|
||||
}
|
||||
|
||||
subtotalFor(d: Denomination): number {
|
||||
return this.countFor(d) * d.value;
|
||||
}
|
||||
|
||||
onDenomChange(d: Denomination, raw: any): void {
|
||||
this.denominationCounts[this.denomKey(d)] = Math.max(0, Math.floor(Number(raw) || 0));
|
||||
}
|
||||
|
||||
bumpDenom(d: Denomination, delta: number): void {
|
||||
this.denominationCounts[this.denomKey(d)] = Math.max(0, this.countFor(d) + delta);
|
||||
}
|
||||
|
||||
clearDenominations(): void {
|
||||
this.denominationCounts = {};
|
||||
}
|
||||
|
||||
searchAccount(): void {
|
||||
const query = this.accountInput.trim();
|
||||
if (!query) return;
|
||||
this.pageState = 'searching';
|
||||
this.account = null;
|
||||
this.charges = [];
|
||||
this.errorMsg = '';
|
||||
|
||||
this.service
|
||||
.getShareAccountForCollection(query)
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
this.errorMsg =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||
err?.error?.developerMessage ||
|
||||
'Account not found. Please check the account ID and try again.';
|
||||
this.pageState = 'idle';
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((acc: any) => {
|
||||
if (!acc) return;
|
||||
this.account = acc;
|
||||
|
||||
// Auto-detect shares from summary or first pending purchasedShares entry
|
||||
const pendingTx = (acc.purchasedShares || []).find(
|
||||
(t: any) =>
|
||||
t.status?.code === 'purchasedSharesStatusType.applied' && t.type?.code === 'purchasedSharesType.purchased'
|
||||
);
|
||||
this.requestedSharesInput = Number(
|
||||
acc.summary?.totalPendingForApprovalShares || pendingTx?.numberOfShares || acc.requestedShares || 0
|
||||
);
|
||||
this.pricePerShare = Number(pendingTx?.purchasedPrice || acc.currentMarketPrice || acc.unitPrice || 0);
|
||||
|
||||
// Show ALL charges on the account — no type filtering
|
||||
this.charges = (acc.charges || []).map((c: any) => ({
|
||||
id: c.chargeId ?? c.id,
|
||||
name: c.name,
|
||||
amount: Number(c.amount ?? c.amountOutstanding ?? 0),
|
||||
currencySymbol: c.currency?.displaySymbol ?? '₹'
|
||||
}));
|
||||
|
||||
this.pageState = 'loaded';
|
||||
});
|
||||
}
|
||||
|
||||
recalc(): void {
|
||||
// triggered by input changes — nothing extra needed, getters auto-recalculate
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (!this.isValid || this.saving) return;
|
||||
this.saving = true;
|
||||
const payload = {
|
||||
shareAccountId: this.account.id,
|
||||
accountNo: this.account.accountNo,
|
||||
clientName: this.account.clientName,
|
||||
transactionDate: this.transactionDate,
|
||||
requestedShares: this.requestedSharesInput,
|
||||
pricePerShare: this.pricePerShare,
|
||||
shareAmount: this.shareAmount,
|
||||
chargeAmount: this.totalChargeAmount,
|
||||
totalAmount: this.totalAmount,
|
||||
paymentTypeId: this.paymentTypeId || null,
|
||||
note: this.note || null
|
||||
};
|
||||
|
||||
this.service.collectSharePayment(payload).subscribe({
|
||||
next: (result: any) => {
|
||||
this.collectionResult = result;
|
||||
this.snackBar.open('Share collection posted successfully.', 'Close', { duration: 3000 });
|
||||
this.pageState = 'success';
|
||||
this.saving = false;
|
||||
},
|
||||
error: (err: any) => {
|
||||
const msg =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||
err?.error?.developerMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
'Collection failed. Please try again.';
|
||||
this.snackBar.open(msg, 'Close', { duration: 8000 });
|
||||
this.saving = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.pageState = 'idle';
|
||||
this.accountInput = '';
|
||||
this.account = null;
|
||||
this.charges = [];
|
||||
this.errorMsg = '';
|
||||
this.note = '';
|
||||
this.paymentTypeId = null;
|
||||
this.requestedSharesInput = 0;
|
||||
this.pricePerShare = 0;
|
||||
this.collectionResult = null;
|
||||
this.saving = false;
|
||||
this.clearDenominations();
|
||||
}
|
||||
}
|
||||
@@ -166,23 +166,50 @@
|
||||
</div>
|
||||
<div 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>
|
||||
@if (showsEntryType(row)) {
|
||||
<div class="ws-f ws-f--paytype">
|
||||
<label
|
||||
class="ws-f__lbl ws-f__lbl--accent"
|
||||
title="What this money was actually collected for — checked before a share application or DD issuance can adopt this voucher"
|
||||
>ENTRY TYPE</label
|
||||
>
|
||||
<select
|
||||
class="ws-f__ctl ws-f__ctl--accent"
|
||||
[(ngModel)]="row.entryTypeCvId"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<option [ngValue]="null">Select…</option>
|
||||
@for (t of entryTypeOptions; track t.id) {
|
||||
<option [ngValue]="t.id">{{ t.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -206,14 +233,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,23 +1556,50 @@
|
||||
</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>
|
||||
@if (showsEntryType(row)) {
|
||||
<div class="ws-f ws-f--paytype">
|
||||
<label
|
||||
class="ws-f__lbl ws-f__lbl--accent"
|
||||
title="What this money was actually collected for — checked before a share application or DD issuance can adopt this voucher"
|
||||
>ENTRY TYPE</label
|
||||
>
|
||||
<select
|
||||
class="ws-f__ctl ws-f__ctl--accent"
|
||||
[(ngModel)]="row.entryTypeCvId"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<option [ngValue]="null">Select…</option>
|
||||
@for (t of entryTypeOptions; track t.id) {
|
||||
<option [ngValue]="t.id">{{ t.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ws-vdivider"></div>
|
||||
@@ -1556,10 +1616,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;
|
||||
|
||||
@@ -53,6 +53,19 @@ interface TxnRow {
|
||||
/** DD issuance charges/commission — only used when TYPE=DD, TXN TP=Cash, AC.TP=GL */
|
||||
ddCharges: number | null;
|
||||
sponsorBankId?: number | null;
|
||||
/**
|
||||
* What a GL credit is actually for — the id of a code value under "GLEntryType" (General, Share
|
||||
* Collection, DD Issue, or whatever an admin has added under Admin > System > Manage Codes).
|
||||
*
|
||||
* A cashier Receipt (or a clerk Collection) into a GL account is the counter picking, by hand,
|
||||
* any account to post cash into — deliberately, so a broken product mapping can never block a
|
||||
* genuine voucher. That freedom is also the hole: two unrelated flows can land in the same GL
|
||||
* account with nothing on the row itself saying which one the money was collected for, and a
|
||||
* manager applying a share request can pick up a voucher that was really meant to fund a DD. This
|
||||
* tag is what a day-end reconciliation groups by, and what Share Advance Collection / Sponsor DD
|
||||
* issuance check before adopting a row — only shown/sent for AC.TP=GL, DR./CR.=Cr rows.
|
||||
*/
|
||||
entryTypeCvId?: number | null;
|
||||
/** Only used when this row is AC.TP=SHR inside an Enter Bank Transfer to its linked savings
|
||||
* account — governs the share redemption (see submitShareRedemptionTransfer()). */
|
||||
resolutionNumber?: string;
|
||||
@@ -1703,15 +1716,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 +1742,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'
|
||||
@@ -1834,6 +1877,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
this.loadCashVaultGl();
|
||||
this.loadDdGstPercentage();
|
||||
this.loadSponsorBanks();
|
||||
this.cashierService
|
||||
.getEntryTypeOptions()
|
||||
.pipe(catchError(() => of([])))
|
||||
.subscribe((options: any[]) => {
|
||||
this.entryTypeOptions = options ?? [];
|
||||
});
|
||||
|
||||
if (this.reviewMode) {
|
||||
this.loadRequestIntoForm(this.reviewRequestId!, 'review');
|
||||
@@ -2321,14 +2370,21 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
/**
|
||||
* TYPE=DD funded by debiting a member's savings/current account rather than by cash.
|
||||
* TYPE=DD funded by debiting a member's savings/current account, or a GL account directly, rather
|
||||
* than by cash.
|
||||
*
|
||||
* Clerk-only. A cashier's counter takes cash and nothing else, so offering them an account debit
|
||||
* 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')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2342,6 +2398,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return this.isDdCashGlRow(row) || this.isDdAccountDebitRow(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this row needs an Entry Type tag — a credit into a GL account, which is exactly the
|
||||
* case where the money's real purpose isn't otherwise recorded anywhere (see TxnRow.entryTypeCvId).
|
||||
* A debit out of a GL account, or any entry against a savings/current/share account, carries no
|
||||
* such ambiguity — the account itself says what it's for.
|
||||
*/
|
||||
showsEntryType(row: TxnRow): boolean {
|
||||
return row.acTyp === 'GL' && row.dbCr === 'Cr';
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry Type choices for a GL credit — the "GLEntryType" code (Admin > System > Manage Codes),
|
||||
* fetched once in ngOnInit(). A code value, not a free-text field, so the day-end "group by type"
|
||||
* this exists for stays reliable; an admin extends the list there, not in this file.
|
||||
*/
|
||||
entryTypeOptions: { id: number; name: string }[] = [];
|
||||
|
||||
/** GST on DD charges only (never on the DD face value), at the admin-configured rate. */
|
||||
ddGstAmountFor(row: TxnRow): number {
|
||||
const charges = Number(row.ddCharges) || 0;
|
||||
@@ -2408,19 +2481,24 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
// An account-funded draft needs the account resolved to a Fineract id before it can be sent:
|
||||
// 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 +2588,32 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
/**
|
||||
* Raises a Demand Draft funded by debiting a member's savings or current account.
|
||||
* Raises a Demand Draft funded by debiting a member's savings/current account, or a GL account
|
||||
* directly.
|
||||
*
|
||||
* Clerk-only, and a maker-checker request like the cash flow: nothing is debited here. The member's
|
||||
* 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 +2621,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;
|
||||
@@ -2802,7 +2884,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
// Without this the backend defaults every request from this screen to DISBURSEMENT — a
|
||||
// Collection or Fund Transfer submitted here was being saved (and shown in the checker's
|
||||
// inbox) as a plain Disbursement.
|
||||
requestType: isCollectionMode ? 'COLLECTION' : isFundTransfer ? 'FUND_TRANSFER' : 'DISBURSEMENT'
|
||||
requestType: isCollectionMode ? 'COLLECTION' : isFundTransfer ? 'FUND_TRANSFER' : 'DISBURSEMENT',
|
||||
// Same ambiguity as a cashier Receipt — a Collection credits a GL account picked by hand.
|
||||
// See TxnRow.entryTypeCvId.
|
||||
...(isCollectionMode ? { entryTypeCvId: crRows[0]?.entryTypeCvId || null } : {})
|
||||
};
|
||||
|
||||
this.saving = true;
|
||||
@@ -3044,6 +3129,168 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Share redemption settled to a GL account the clerk picks on the CR row, instead of the
|
||||
* member's linked savings account — mirrors submitShareRedemptionTransfer() above, but the
|
||||
* credit side is any GL account (e.g. a "DD Payable" holding account), not a fixed linked
|
||||
* account, so there is no "must match the linked account" check here.
|
||||
*/
|
||||
private submitShareGlSettlementTransfer(shareRow: TxnRow, glRow: TxnRow): void {
|
||||
// Belt-and-braces: the TXN TP. dropdown already hides cash payment types for AC.TP = SHR (see
|
||||
// paymentTypesForRow()), but re-check here too in case the payment type was set before that
|
||||
// filter applied.
|
||||
const settlementPaymentType = this.paymentTypes.find((p: any) => p.id === glRow.paymentTypeId);
|
||||
if (!glRow.paymentTypeId) {
|
||||
this.showAlert('error', 'Settlement Payment Type Required', [
|
||||
'Select a Payment Type on the CR (GL account) row — it governs how the settlement credit posts.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
if (settlementPaymentType?.isCashPayment) {
|
||||
this.showAlert('error', 'Cash Not Allowed for Share Transactions', [
|
||||
'Share redemption settlement is always a book-transfer, never cash.',
|
||||
'Select Transfer as the Payment Type on the CR row.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const shareAccountId = this.rowAccountIds[shareRow.acNo];
|
||||
if (!shareAccountId) {
|
||||
this.showAlert('error', 'Share Account Not Loaded', [`Please load account details for: ${shareRow.acNo}`]);
|
||||
return;
|
||||
}
|
||||
const settlementGlAccountId = this.rowAccountIds[glRow.acNo];
|
||||
if (!settlementGlAccountId) {
|
||||
this.showAlert('error', 'GL Account Not Loaded', [`Please load account details for: ${glRow.acNo}`]);
|
||||
return;
|
||||
}
|
||||
|
||||
const shares = Number(shareRow.amount) || 0;
|
||||
if (shares <= 0) {
|
||||
this.showAlert('error', 'Share No. Required', ['Enter the number of shares to withdraw (greater than zero).']);
|
||||
return;
|
||||
}
|
||||
if (!shareRow.resolutionNumber?.trim()) {
|
||||
this.showAlert('error', 'Board Resolution Number Required', [
|
||||
'A share withdrawal must be authorised by a Board Resolution.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
if (!shareRow.meetingDate) {
|
||||
this.showAlert('error', 'Date of Meeting Required', ['Enter the Board meeting date that passed the resolution.']);
|
||||
return;
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
|
||||
// Full snapshot of the CR (settlement) row exactly as the clerk filled it in — saved verbatim
|
||||
// for audit purposes, same as the linked-savings path above.
|
||||
const settlementRowSnapshot = {
|
||||
branch: glRow.br,
|
||||
accountType: glRow.acTyp,
|
||||
accountNo: glRow.acNo,
|
||||
ecsAccountNo: glRow.ecsAcNo || null,
|
||||
dbCr: glRow.dbCr,
|
||||
amount: glRow.amount ?? null,
|
||||
chequeNo: glRow.chequeNo || null,
|
||||
chequeDate: glRow.cheqDate || null,
|
||||
tokenNo: glRow.tokNo || null,
|
||||
description: glRow.description || null,
|
||||
bankName: glRow.bankName || null,
|
||||
routingCode: glRow.routingCode || null,
|
||||
paymentTypeId: glRow.paymentTypeId,
|
||||
paymentTypeName: settlementPaymentType?.name ?? null
|
||||
};
|
||||
|
||||
const payload = {
|
||||
shareAccountId,
|
||||
accountNo: shareRow.acNo,
|
||||
clientId: this.rowClientIds[shareRow.acNo] ?? null,
|
||||
clientName: this.rowAccountNames[shareRow.acNo] ?? null,
|
||||
requestedShares: shares,
|
||||
requestedDate,
|
||||
resolutionNumber: shareRow.resolutionNumber.trim(),
|
||||
meetingDate: shareRow.meetingDate,
|
||||
description: shareRow.description || null,
|
||||
settlementPaymentTypeId: glRow.paymentTypeId,
|
||||
settlementRowSnapshot,
|
||||
settlementMode: 'GL',
|
||||
settlementGlAccountId
|
||||
};
|
||||
|
||||
const sharePrice = this.rowSharePrices[shareRow.acNo] ?? 0;
|
||||
const currentHolding = this.rowShareHoldings[shareRow.acNo] ?? 0;
|
||||
const remainingShares = currentHolding - shares;
|
||||
const willCloseAccount = remainingShares <= 0;
|
||||
const confirmLines = [
|
||||
`Share Account: ${shareRow.acNo} · Client: ${this.rowAccountNames[shareRow.acNo] ?? '—'}`,
|
||||
`Shares to Withdraw: ${shares} @ ₹${sharePrice.toLocaleString('en-IN', { minimumFractionDigits: 2 })} = ₹${(shares * sharePrice).toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
|
||||
`Remaining Shares After Withdrawal: ${Math.max(remainingShares, 0)}`,
|
||||
...(willCloseAccount
|
||||
? ['⚠ All shares withdrawn — this account will be CLOSED automatically when the Manager approves.']
|
||||
: []),
|
||||
`Board Resolution No.: ${payload.resolutionNumber}`,
|
||||
`Date of Meeting: ${payload.meetingDate}`,
|
||||
`Settlement Credit To (GL): ${glRow.acNo}`,
|
||||
`Settlement Payment Type: ${settlementPaymentType?.name ?? '—'}`,
|
||||
...(settlementRowSnapshot.description ? [`CR Row Remarks: ${settlementRowSnapshot.description}`] : []),
|
||||
'',
|
||||
willCloseAccount
|
||||
? 'Pending Manager approval — redemption, settlement and account closure all post together on approval.'
|
||||
: 'Pending Manager approval — redemption and settlement both post together on approval.'
|
||||
];
|
||||
this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', confirmLines, 'Submit for Approval', () =>
|
||||
this.doSubmitShareGlSettlementTransfer(payload, shareRow, shares, glRow.acNo)
|
||||
);
|
||||
}
|
||||
|
||||
private doSubmitShareGlSettlementTransfer(
|
||||
payload: Record<string, unknown>,
|
||||
shareRow: TxnRow,
|
||||
shares: number,
|
||||
glAcNo: string
|
||||
): void {
|
||||
this.saving = true;
|
||||
this.clerkShareWithdrawalService
|
||||
.submit(payload)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
const msg =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||||
err?.error?.developerMessage ??
|
||||
err?.error?.defaultUserMessage ??
|
||||
'Share withdrawal submission failed. Please try again.';
|
||||
this.showAlert('error', 'Submission Failed', [msg]);
|
||||
this.saving = false;
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.saving = false;
|
||||
if (res) {
|
||||
const willCloseAccount = (this.rowShareHoldings[shareRow.acNo] ?? 0) - shares <= 0;
|
||||
this.showAlert('success', 'Share Withdrawal Submitted', [
|
||||
`${shares} share(s) from ${shareRow.acNo} → settlement to GL ${glAcNo} on approval.`,
|
||||
willCloseAccount
|
||||
? 'Pending Manager approval — this will close the share account, since all shares will be withdrawn. Track status in "My Share Requests".'
|
||||
: 'Pending Manager approval. Track status in "My Share Requests".'
|
||||
]);
|
||||
this.rows = [];
|
||||
this.rowAccountIds = {};
|
||||
this.rowAccountNames = {};
|
||||
this.rowSharePrices = {};
|
||||
this.rowClientIds = {};
|
||||
this.rowLinkedSavingsAcNo = {};
|
||||
this.rowShareHoldings = {};
|
||||
this.addRow();
|
||||
this.selectedRow = this.rows[0];
|
||||
this.accountDetails = null;
|
||||
this.clientDetails = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private doSubmitShareRedemptionTransfer(
|
||||
payload: Record<string, unknown>,
|
||||
shareRow: TxnRow,
|
||||
@@ -3103,37 +3350,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 +3392,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 +3423,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 +3439,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 +3482,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 +3608,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 +4793,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 (
|
||||
@@ -5650,6 +5944,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
chequeNo: row.chequeNo || null,
|
||||
bankName: row.bankName || null,
|
||||
denominationData: isCash && this.denominationTotal > 0 ? JSON.stringify(this.denominationCounts) : null,
|
||||
// What this GL credit is actually for — see TxnRow.entryTypeCvId. Sent for every GL deposit
|
||||
// (the backend defaults to "General" when the counter didn't pick one), so Share Advance
|
||||
// Collection and Sponsor DD issuance can tell whose voucher this really is before adopting it.
|
||||
...(isGl ? { entryTypeCvId: row.entryTypeCvId || null } : {}),
|
||||
// DD + Cash + GL breakdown — persisted so the Sponsor DD issuance screen can read the face
|
||||
// value/charges/GST back once this request is approved, instead of retyping them.
|
||||
...(this.isDdCashGlRow(row)
|
||||
|
||||
+6
-10
@@ -576,13 +576,11 @@
|
||||
}
|
||||
|
||||
/* ───────── Print — only the page, no toolbar / dialog chrome ─────────
|
||||
This dialog is opened with `maxHeight: '92vh'`, which Angular Material carries onto the
|
||||
overlay pane / .mat-mdc-dialog-container as an inline max-height + overflow:auto clip.
|
||||
`position: absolute` still lives inside that clip and gets cut off — only the on-screen
|
||||
scroll position prints. `position: fixed` escapes it: a fixed element's containing block is
|
||||
the viewport itself, not the clipped ancestor (the classic "fixed child ignores an
|
||||
overflow:hidden parent" behaviour), as long as nothing between here and the viewport sets a
|
||||
transform/filter — the CDK overlay chrome doesn't. */
|
||||
The document-level clip (`html, body { overflow: hidden }`) is lifted globally in main.scss's
|
||||
print block; what remains here is this dialog's own clip — it opens with `maxHeight: '92vh'`,
|
||||
which Angular Material carries onto the overlay pane / .mat-mdc-dialog-container as an inline
|
||||
max-height plus overflow:auto. Freeing those lets the whole certificate flow onto the page in
|
||||
normal static order (a fixed/absolute hack would pin it to the first page instead). */
|
||||
@media print {
|
||||
body > :not(.cdk-overlay-container) {
|
||||
display: none !important;
|
||||
@@ -611,9 +609,7 @@
|
||||
}
|
||||
|
||||
.cpd-root {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
position: static;
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
background: #fff;
|
||||
|
||||
+1
-34
@@ -70,16 +70,9 @@ export class ShareProductAccountingStepComponent implements OnInit {
|
||||
shareProductAccountingForm: UntypedFormGroup;
|
||||
accountingSectionConfigs: ShareAccountingSectionConfig[] = [];
|
||||
|
||||
assetAccountData: GLAccount[] = [];
|
||||
liabilityAccountData: GLAccount[] = [];
|
||||
incomeAccountData: GLAccount[] = [];
|
||||
equityAccountData: GLAccount[] = [];
|
||||
/** "Share Reference" is where a redemption's credit side lands pending settlement — some banks
|
||||
* model that as a receivable-like Asset (e.g. "Cash Vault", this field's original default),
|
||||
* others as a payable-like Liability suspense account (e.g. an existing "Sundry" account
|
||||
* already used elsewhere as Shares Suspense). Both are valid conventions, so this field accepts
|
||||
* either instead of Asset-only. */
|
||||
referenceAccountData: GLAccount[] = [];
|
||||
|
||||
constructor() {
|
||||
this.createShareProductAccountingForm();
|
||||
@@ -87,18 +80,15 @@ export class ShareProductAccountingStepComponent implements OnInit {
|
||||
|
||||
ngOnInit() {
|
||||
const opts = this.shareProductsTemplate.accountingMappingOptions || {};
|
||||
this.assetAccountData = opts.assetAccountOptions || [];
|
||||
this.liabilityAccountData = opts.liabilityAccountOptions || [];
|
||||
this.incomeAccountData = opts.incomeAccountOptions || [];
|
||||
this.equityAccountData = opts.equityAccountOptions || [];
|
||||
this.referenceAccountData = this.assetAccountData.concat(this.liabilityAccountData);
|
||||
|
||||
this.buildAccountingSectionConfigs();
|
||||
|
||||
if (this.shareProductsTemplate.accountingMappings) {
|
||||
const m = this.shareProductsTemplate.accountingMappings;
|
||||
this.shareProductAccountingForm.patchValue({
|
||||
shareReferenceId: m.shareReferenceId?.id || '',
|
||||
shareSuspenseId: m.shareSuspenseId?.id || '',
|
||||
incomeFromFeeAccountId: m.incomeFromFeeAccountId?.id || '',
|
||||
shareEquityId: m.shareEquityId?.id || ''
|
||||
@@ -109,10 +99,6 @@ export class ShareProductAccountingStepComponent implements OnInit {
|
||||
createShareProductAccountingForm() {
|
||||
this.shareProductAccountingForm = this.formBuilder.group({
|
||||
accountingRule: [2],
|
||||
shareReferenceId: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
shareSuspenseId: [
|
||||
'',
|
||||
Validators.required
|
||||
@@ -125,7 +111,6 @@ export class ShareProductAccountingStepComponent implements OnInit {
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
assetsOtherGlMappings: this.formBuilder.array([]),
|
||||
liabilitiesOtherGlMappings: this.formBuilder.array([]),
|
||||
incomeOtherGlMappings: this.formBuilder.array([]),
|
||||
equityOtherGlMappings: this.formBuilder.array([])
|
||||
@@ -134,24 +119,6 @@ export class ShareProductAccountingStepComponent implements OnInit {
|
||||
|
||||
buildAccountingSectionConfigs() {
|
||||
this.accountingSectionConfigs = [
|
||||
{
|
||||
key: 'assets',
|
||||
title: 'Assets',
|
||||
pillLabel: 'Asset',
|
||||
pillClass: 'green',
|
||||
rows: [
|
||||
{
|
||||
key: 'shareReferenceId',
|
||||
label: 'Share Reference (Cash Vault or Suspense)',
|
||||
inputLabel: 'Share reference',
|
||||
controlName: 'shareReferenceId',
|
||||
options: this.referenceAccountData,
|
||||
required: true
|
||||
}
|
||||
],
|
||||
otherMappingsControlName: 'assetsOtherGlMappings',
|
||||
otherAccountOptions: this.assetAccountData
|
||||
},
|
||||
{
|
||||
key: 'liabilities',
|
||||
title: 'Liabilities',
|
||||
@@ -239,7 +206,7 @@ export class ShareProductAccountingStepComponent implements OnInit {
|
||||
}
|
||||
|
||||
get shareProductAccounting() {
|
||||
const { assetsOtherGlMappings, liabilitiesOtherGlMappings, incomeOtherGlMappings, equityOtherGlMappings, ...rest } =
|
||||
const { liabilitiesOtherGlMappings, incomeOtherGlMappings, equityOtherGlMappings, ...rest } =
|
||||
this.shareProductAccountingForm.value;
|
||||
return rest;
|
||||
}
|
||||
|
||||
-8
@@ -213,14 +213,6 @@
|
||||
</div>
|
||||
|
||||
@if (shareProduct.accountingRule === 2) {
|
||||
<div class="shpp-sub-header">{{ 'labels.heading.Assets' | translate }}</div>
|
||||
<div class="shpp-row">
|
||||
<span class="shpp-label">{{ 'labels.inputs.Share reference' | translate }}:</span>
|
||||
<span class="shpp-value">{{
|
||||
shareProduct.shareReferenceId
|
||||
| find: shareProductsTemplate.accountingMappingOptions.assetAccountOptions : 'id' : 'name'
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="shpp-sub-header">{{ 'labels.heading.Liabilities' | translate }}</div>
|
||||
<div class="shpp-row">
|
||||
<span class="shpp-label">{{ 'labels.inputs.Share suspense control' | translate }}:</span>
|
||||
|
||||
-10
@@ -208,16 +208,6 @@
|
||||
|
||||
@if (shareProduct.accountingRule.id === 2) {
|
||||
<div class="layout-row-wrap responsive-column flex-fill">
|
||||
<h4 class="mat-h4 flex-fill">{{ 'labels.heading.Assets' | translate }}</h4>
|
||||
<div class="flex-fill">
|
||||
<mifosx-gl-account-display
|
||||
class="flex-100"
|
||||
[accountTitle]="'Share reference'"
|
||||
[glAccount]="shareProduct.accountingMappings.shareReferenceId"
|
||||
[withTitle]="'40%'"
|
||||
>
|
||||
</mifosx-gl-account-display>
|
||||
</div>
|
||||
<h4 class="mat-h4 flex-fill">{{ 'labels.heading.Liabilities' | translate }}</h4>
|
||||
<div class="flex-fill">
|
||||
<mifosx-gl-account-display
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,112 +6,189 @@
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="container">
|
||||
<mat-card>
|
||||
<div class="container action-container">
|
||||
<mat-card class="action-card shadow-sm">
|
||||
<!-- Compact Header -->
|
||||
<div class="card-header-banner">
|
||||
<div class="header-icon-box">
|
||||
<mat-icon>add_card</mat-icon>
|
||||
</div>
|
||||
<div class="header-text font-accent">
|
||||
<h2>{{ 'Apply Additional Shares' | translate }}</h2>
|
||||
<p>Purchase additional share tranches via cash receipts or savings debit</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="applySharesForm" (ngSubmit)="submit()">
|
||||
<mat-card-content>
|
||||
<div class="layout-column">
|
||||
<mat-form-field (click)="requestedDatePicker.open()">
|
||||
<mat-label>{{ 'labels.inputs.Request Date' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[min]="minDate"
|
||||
[max]="maxDate"
|
||||
[matDatepicker]="requestedDatePicker"
|
||||
required
|
||||
formControlName="requestedDate"
|
||||
/>
|
||||
<mat-datepicker-toggle matSuffix [for]="requestedDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #requestedDatePicker></mat-datepicker>
|
||||
@if (applySharesForm.controls.requestedDate.hasError('required')) {
|
||||
<mat-error>
|
||||
{{ 'labels.inputs.Request Date' | translate }} {{ 'labels.commons.is' | translate }}
|
||||
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<!-- The board resolution authorising this tranche. Its own meeting and number, not the
|
||||
one the account was opened under, so it is captured per purchase. -->
|
||||
<mat-form-field>
|
||||
<mat-label>{{ 'labels.inputs.Resolution Number' | translate }}</mat-label>
|
||||
<input matInput required formControlName="resolutionNumber" />
|
||||
@if (applySharesForm.controls.resolutionNumber.hasError('required')) {
|
||||
<mat-error>
|
||||
{{ 'labels.inputs.Resolution Number' | translate }} {{ 'labels.commons.is' | translate }}
|
||||
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field (click)="resolutionDatePicker.open()">
|
||||
<mat-label>{{ 'labels.inputs.Resolution Date' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[min]="minDate"
|
||||
[max]="maxDate"
|
||||
[matDatepicker]="resolutionDatePicker"
|
||||
required
|
||||
formControlName="resolutionDate"
|
||||
/>
|
||||
<mat-datepicker-toggle matSuffix [for]="resolutionDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #resolutionDatePicker></mat-datepicker>
|
||||
@if (applySharesForm.controls.resolutionDate.hasError('required')) {
|
||||
<mat-error>
|
||||
{{ 'labels.inputs.Resolution Date' | translate }} {{ 'labels.commons.is' | translate }}
|
||||
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label>{{ 'labels.inputs.Total No. of Shares' | translate }}</mat-label>
|
||||
<input matInput type="number" min="1" required formControlName="requestedShares" />
|
||||
@if (applySharesForm.controls.requestedShares.hasError('required')) {
|
||||
<mat-error>
|
||||
{{ 'labels.inputs.Requested Shares' | translate }} {{ 'labels.commons.is' | translate }}
|
||||
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label>{{ 'labels.inputs.Current Price' | translate }}</mat-label>
|
||||
<input matInput formControlName="unitPrice" />
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Shares x price, computed rather than entered, so the value posted to the GL is the
|
||||
value the clerk read back to the member. -->
|
||||
<mat-form-field>
|
||||
<mat-label>{{ 'labels.inputs.Amount' | translate }}</mat-label>
|
||||
<input matInput formControlName="amount" />
|
||||
<mat-hint
|
||||
>{{ 'labels.inputs.Total No. of Shares' | translate }} ×
|
||||
{{ 'labels.inputs.Current Price' | translate }}</mat-hint
|
||||
>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Where the money comes from. Both routes land in Share Sundry, and approval is what
|
||||
moves it out to share capital. -->
|
||||
<div class="payment-mode">
|
||||
<div class="payment-mode-label">{{ 'labels.inputs.Payment Option' | translate }}</div>
|
||||
<mat-radio-group formControlName="paymentMode" class="payment-mode-options">
|
||||
<mat-radio-button value="CASH">Apply Collection (Cash)</mat-radio-button>
|
||||
<mat-radio-button value="SAVINGS">Debit from Savings</mat-radio-button>
|
||||
</mat-radio-group>
|
||||
<mat-card-content class="card-body">
|
||||
<!-- SECTION 1: Application & Resolution Details -->
|
||||
<div class="form-section">
|
||||
<div class="section-title">
|
||||
<mat-icon class="sec-icon">assignment</mat-icon>
|
||||
<span>Application & Resolution Details</span>
|
||||
</div>
|
||||
|
||||
@if (paymentMode === 'CASH') {
|
||||
<div class="funding-panel">
|
||||
<p class="funding-intro">
|
||||
The member has already paid at the counter. Tick the receipts that make up this payment, or enter a
|
||||
voucher number to find one — tick more than one where they paid in instalments. The cash is sitting in
|
||||
Share Sundry against them, and approving this request moves it into share capital.
|
||||
</p>
|
||||
<div class="form-grid form-grid-3">
|
||||
<!-- Request Date -->
|
||||
<mat-form-field appearance="outline" density="compact" (click)="requestedDatePicker.open()">
|
||||
<mat-label>{{ 'labels.inputs.Request Date' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[min]="minDate"
|
||||
[max]="maxDate"
|
||||
[matDatepicker]="requestedDatePicker"
|
||||
required
|
||||
formControlName="requestedDate"
|
||||
/>
|
||||
<mat-datepicker-toggle matSuffix [for]="requestedDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #requestedDatePicker></mat-datepicker>
|
||||
@if (applySharesForm.controls.requestedDate.hasError('required')) {
|
||||
<mat-error>
|
||||
{{ 'labels.inputs.Request Date' | translate }} {{ 'labels.commons.is' | translate }}
|
||||
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<div class="gl-select-field">
|
||||
<!-- Resolution Number -->
|
||||
<mat-form-field appearance="outline" density="compact">
|
||||
<mat-label>{{ 'labels.inputs.Resolution Number' | translate }}</mat-label>
|
||||
<input matInput required formControlName="resolutionNumber" placeholder="e.g. RES-2026-08" />
|
||||
@if (applySharesForm.controls.resolutionNumber.hasError('required')) {
|
||||
<mat-error>
|
||||
{{ 'labels.inputs.Resolution Number' | translate }} {{ 'labels.commons.is' | translate }}
|
||||
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Resolution Date -->
|
||||
<mat-form-field appearance="outline" density="compact" (click)="resolutionDatePicker.open()">
|
||||
<mat-label>{{ 'labels.inputs.Resolution Date' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[min]="minDate"
|
||||
[max]="maxDate"
|
||||
[matDatepicker]="resolutionDatePicker"
|
||||
required
|
||||
formControlName="resolutionDate"
|
||||
/>
|
||||
<mat-datepicker-toggle matSuffix [for]="resolutionDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #resolutionDatePicker></mat-datepicker>
|
||||
@if (applySharesForm.controls.resolutionDate.hasError('required')) {
|
||||
<mat-error>
|
||||
{{ 'labels.inputs.Resolution Date' | translate }} {{ 'labels.commons.is' | translate }}
|
||||
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SECTION 2: Share Purchase Calculation -->
|
||||
<div class="form-section financial-section">
|
||||
<div class="section-title">
|
||||
<mat-icon class="sec-icon">calculate</mat-icon>
|
||||
<span>Share Purchase Calculation</span>
|
||||
</div>
|
||||
|
||||
<div class="form-grid form-grid-3 align-items-center">
|
||||
<!-- Total No. of Shares -->
|
||||
<mat-form-field appearance="outline" density="compact">
|
||||
<mat-label>{{ 'labels.inputs.Total No. of Shares' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
min="1"
|
||||
required
|
||||
formControlName="requestedShares"
|
||||
placeholder="e.g. 100"
|
||||
class="shares-count-input"
|
||||
/>
|
||||
@if (applySharesForm.controls.requestedShares.hasError('required')) {
|
||||
<mat-error>
|
||||
{{ 'labels.inputs.Requested Shares' | translate }} {{ 'labels.commons.is' | translate }}
|
||||
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Current Price -->
|
||||
<mat-form-field appearance="outline" density="compact" class="price-field">
|
||||
<mat-label>{{ 'labels.inputs.Current Price' | translate }}</mat-label>
|
||||
<input matInput formControlName="unitPrice" class="price-input" />
|
||||
<span matSuffix class="currency-label">per share</span>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Computed Amount Card -->
|
||||
<div class="amount-display-card">
|
||||
<span class="amount-label">{{ 'labels.inputs.Amount' | translate }}</span>
|
||||
<span class="amount-value">{{ amount | number: '1.2-2' }}</span>
|
||||
<span class="amount-subtext"
|
||||
>{{ applySharesForm.get('requestedShares').value || 0 }} shares ×
|
||||
{{ sharesAccountData?.currentMarketPrice || 0 }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SECTION 3: Payment / Funding Option -->
|
||||
<div class="form-section">
|
||||
<div class="section-title">
|
||||
<mat-icon class="sec-icon">account_balance_wallet</mat-icon>
|
||||
<span>{{ 'labels.inputs.Payment Option' | translate }}</span>
|
||||
</div>
|
||||
|
||||
<div class="payment-card-group">
|
||||
<div class="payment-card-option" [class.active]="paymentMode === 'CASH'" (click)="setPaymentMode('CASH')">
|
||||
<div class="option-icon-box">
|
||||
<mat-icon>payments</mat-icon>
|
||||
</div>
|
||||
<div class="option-content">
|
||||
<span class="option-title">Apply Collection (Cash)</span>
|
||||
<span class="option-desc">Link receipts paid at counter sitting in Share Sundry</span>
|
||||
</div>
|
||||
@if (paymentMode === 'CASH') {
|
||||
<mat-icon class="selected-check font-success">check_circle</mat-icon>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="payment-card-option"
|
||||
[class.active]="paymentMode === 'SAVINGS'"
|
||||
(click)="setPaymentMode('SAVINGS')"
|
||||
>
|
||||
<div class="option-icon-box">
|
||||
<mat-icon>account_balance</mat-icon>
|
||||
</div>
|
||||
<div class="option-content">
|
||||
<span class="option-title">Debit from Savings</span>
|
||||
<span class="option-desc">Direct debit from member's linked savings account</span>
|
||||
</div>
|
||||
@if (paymentMode === 'SAVINGS') {
|
||||
<mat-icon class="selected-check font-success">check_circle</mat-icon>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CASH FUNDING DETAILS -->
|
||||
@if (paymentMode === 'CASH') {
|
||||
<div class="funding-panel cash-panel animate-fade-in">
|
||||
<div class="funding-intro-banner">
|
||||
<mat-icon class="banner-icon">info</mat-icon>
|
||||
<span>
|
||||
Select the one receipt that covers this payment, or enter a voucher number to search. Click
|
||||
<mat-icon class="inline-icon">visibility</mat-icon> on any row to view full details popup.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar: GL Account Select & Voucher Lookup -->
|
||||
<div class="lookup-toolbar">
|
||||
<div class="gl-select-box">
|
||||
<label class="toolbar-label"> <mat-icon class="mini-icon">account_tree</mat-icon> GL Account </label>
|
||||
<select
|
||||
class="gl-select"
|
||||
class="custom-select"
|
||||
[(ngModel)]="glAccountNo"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
[disabled]="lookingUpVoucher || submitting || loadingGlAccounts"
|
||||
@@ -125,173 +202,394 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="voucher-lookup">
|
||||
<input
|
||||
#voucherInput
|
||||
type="text"
|
||||
class="voucher-input"
|
||||
placeholder="Voucher no. (e.g. 000123)"
|
||||
[disabled]="lookingUpVoucher || submitting"
|
||||
(keyup.enter)="lookupVoucher(voucherInput.value)"
|
||||
/>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="!voucherInput.value.trim() || !glAccountNo.trim() || lookingUpVoucher || submitting"
|
||||
(click)="lookupVoucher(voucherInput.value)"
|
||||
>
|
||||
{{ lookingUpVoucher ? 'Finding…' : 'Find' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (voucherError) {
|
||||
<div class="alert-error">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>{{ voucherError }}</span>
|
||||
<div class="voucher-lookup-box">
|
||||
<label class="toolbar-label">
|
||||
<mat-icon class="mini-icon">search</mat-icon> Find Specific Voucher
|
||||
</label>
|
||||
<div class="lookup-input-group">
|
||||
<input
|
||||
#voucherInput
|
||||
type="text"
|
||||
class="lookup-input font-mono"
|
||||
placeholder="Voucher no. (e.g. 000123)"
|
||||
[disabled]="lookingUpVoucher || submitting"
|
||||
(keyup.enter)="lookupVoucher(voucherInput.value)"
|
||||
/>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
type="button"
|
||||
class="find-btn"
|
||||
[disabled]="!voucherInput.value.trim() || !glAccountNo.trim() || lookingUpVoucher || submitting"
|
||||
(click)="lookupVoucher(voucherInput.value)"
|
||||
>
|
||||
@if (lookingUpVoucher) {
|
||||
<mat-spinner diameter="14"></mat-spinner>
|
||||
} @else {
|
||||
<mat-icon>search</mat-icon>
|
||||
<span>Find</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (voucherError) {
|
||||
<div class="alert-box alert-error">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>{{ voucherError }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Vouchers Array List / Table -->
|
||||
<div class="vouchers-array-container">
|
||||
@if (loadingVouchers) {
|
||||
<div class="funding-loading">
|
||||
<mat-spinner diameter="24"></mat-spinner>
|
||||
<span>Loading the member's receipts…</span>
|
||||
<span>Loading member receipts…</span>
|
||||
</div>
|
||||
} @else if (vouchers.length === 0) {
|
||||
<div class="funding-empty">
|
||||
<mat-icon>info_outline</mat-icon>
|
||||
<div>
|
||||
<b>No unapplied receipt for this member.</b>
|
||||
<div class="funding-empty-hint">
|
||||
Collect the cash first from Cashier › Transaction Entry as a deposit to the Share Sundry
|
||||
GL. Once it is approved, the voucher appears here.
|
||||
</div>
|
||||
<mat-icon class="empty-icon">receipt_long</mat-icon>
|
||||
<div class="empty-text">
|
||||
<strong>No unapplied receipts found for this member.</strong>
|
||||
<p>
|
||||
Collect cash at <em>Cashier › Transaction Entry</em> as a deposit to Share Sundry GL. Once
|
||||
posted, it will appear here.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- Checkboxes, not radios: a member paying in instalments has several receipts
|
||||
against one request, and they fund it together. -->
|
||||
<table class="receipt-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th class="ta-left">Voucher / Receipt</th>
|
||||
<th class="ta-left">Date</th>
|
||||
<th class="ta-right">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (v of vouchers; track v.id) {
|
||||
<tr [class.selected]="isVoucherSelected(v.id)" (click)="toggleVoucher(v.id)">
|
||||
<td class="ta-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="isVoucherSelected(v.id)"
|
||||
(change)="toggleVoucher(v.id)"
|
||||
(click)="$event.stopPropagation()"
|
||||
/>
|
||||
</td>
|
||||
<td class="ta-left mono">{{ v.voucher_no || v.receipt_no }}</td>
|
||||
<td class="ta-left">{{ v.collection_date }}</td>
|
||||
<td class="ta-right">
|
||||
<b>{{ v.amount | number: '1.2-2' }}</b>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
@if (selectedVoucherIds.length > 0) {
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td></td>
|
||||
<td class="ta-left" colspan="2">
|
||||
{{ selectedVoucherIds.length }} selected, against {{ amount | number: '1.2-2' }}
|
||||
</td>
|
||||
<td class="ta-right">
|
||||
<b>{{ selectedVoucherTotal | number: '1.2-2' }}</b>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
}
|
||||
</table>
|
||||
<div class="vouchers-card">
|
||||
<!-- Array Header Toolbar -->
|
||||
<div class="vouchers-header">
|
||||
<div class="vouchers-title font-accent">
|
||||
<mat-icon class="v-icon">receipt</mat-icon>
|
||||
<span>Vouchers / Receipts</span>
|
||||
<span class="badge-count">{{ vouchers.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Compact Table View -->
|
||||
<div class="table-responsive">
|
||||
<table class="receipt-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-checkbox ta-center">#</th>
|
||||
<th class="ta-left">Voucher / Receipt No</th>
|
||||
<th class="ta-left">Date</th>
|
||||
<th class="ta-left">Description</th>
|
||||
<th class="ta-left">Collected By</th>
|
||||
<th class="ta-right">Amount</th>
|
||||
<th class="col-actions ta-center">Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- One receipt funds one tranche — no combining instalments here. -->
|
||||
@for (v of vouchers; track v.id) {
|
||||
<tr
|
||||
class="receipt-row"
|
||||
[class.selected]="isVoucherSelected(v.id)"
|
||||
(click)="toggleVoucher(v.id)"
|
||||
>
|
||||
<td class="col-checkbox ta-center" (click)="$event.stopPropagation()">
|
||||
<input
|
||||
type="radio"
|
||||
name="voucherSelect"
|
||||
class="custom-checkbox"
|
||||
[checked]="isVoucherSelected(v.id)"
|
||||
(change)="toggleVoucher(v.id)"
|
||||
/>
|
||||
</td>
|
||||
<td class="ta-left">
|
||||
<span class="voucher-badge font-mono">{{ v.voucher_no || v.receipt_no }}</span>
|
||||
</td>
|
||||
<td class="ta-left date-cell">{{ v.collection_date }}</td>
|
||||
<td class="ta-left desc-cell" [title]="v.note">{{ v.note || 'Share Collection' }}</td>
|
||||
<td class="ta-left user-cell">
|
||||
<span class="user-chip">
|
||||
<mat-icon class="chip-icon">person_outline</mat-icon>
|
||||
{{ v.collected_by_name || 'System' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="ta-right amount-cell">
|
||||
<span class="val font-semibold">{{ v.amount | number: '1.2-2' }}</span>
|
||||
</td>
|
||||
<td class="col-actions ta-center" (click)="$event.stopPropagation()">
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="view-detail-btn"
|
||||
title="View Full Details Popup"
|
||||
(click)="openVoucherDetail(v, $event)"
|
||||
>
|
||||
<mat-icon class="eye-icon">visibility</mat-icon>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Summary Footer Bar -->
|
||||
<div class="vouchers-footer">
|
||||
<div class="selected-summary">
|
||||
<span class="summary-text font-accent">
|
||||
<strong>{{ selectedVoucherIds.length }}</strong> of
|
||||
<strong>{{ vouchers.length }}</strong> receipts selected
|
||||
</span>
|
||||
|
||||
<div class="summary-amounts">
|
||||
<span class="total-label">Selected Total:</span>
|
||||
<span class="total-value font-semibold">{{ selectedVoucherTotal | number: '1.2-2' }}</span>
|
||||
<span class="target-divider">/</span>
|
||||
<span class="target-value">Target {{ amount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Coverage Status Indicator Pill -->
|
||||
<div class="coverage-status">
|
||||
@if (selectedVoucherIds.length === 0) {
|
||||
<span class="status-badge badge-neutral">
|
||||
<mat-icon>info</mat-icon> Tick at least 1 receipt
|
||||
</span>
|
||||
} @else if (voucherShortfall > 0) {
|
||||
<span class="status-badge badge-warning">
|
||||
<mat-icon>warning</mat-icon> Shortfall {{ voucherShortfall | number: '1.2-2' }}
|
||||
</span>
|
||||
} @else {
|
||||
<span class="status-badge badge-success"> <mat-icon>check_circle</mat-icon> Fully Funded </span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Says how much is still missing rather than only that it is short, so the clerk
|
||||
can tell the member what to pay without working it out. -->
|
||||
@if (selectedVoucherIds.length > 0 && voucherShortfall > 0) {
|
||||
<div class="alert-error">
|
||||
<div class="alert-box alert-warning mt-8">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>
|
||||
The selected receipts are {{ voucherShortfall | number: '1.2-2' }} short of
|
||||
{{ amount | number: '1.2-2' }}. Tick another receipt, or collect the balance at the counter first.
|
||||
Selected total {{ selectedVoucherTotal | number: '1.2-2' }} is
|
||||
<strong>{{ voucherShortfall | number: '1.2-2' }} short</strong> of tranche target
|
||||
{{ amount | number: '1.2-2' }}. Tick another receipt or collect balance.
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (paymentMode === 'SAVINGS') {
|
||||
<div class="funding-panel">
|
||||
<p class="funding-intro">
|
||||
The amount is debited from the member's linked savings into Share Sundry when this is submitted, so
|
||||
their balance drops now. Approving the request then moves it into share capital.
|
||||
</p>
|
||||
<!-- SAVINGS FUNDING DETAILS -->
|
||||
@if (paymentMode === 'SAVINGS') {
|
||||
<div class="funding-panel savings-panel animate-fade-in">
|
||||
<div class="funding-intro-banner">
|
||||
<mat-icon class="banner-icon">info</mat-icon>
|
||||
<span>
|
||||
The tranche amount will be automatically debited from the member's linked savings account into Share
|
||||
Sundry upon submission.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if (checkingSavings) {
|
||||
<div class="funding-loading">
|
||||
<mat-spinner diameter="24"></mat-spinner>
|
||||
<span>Checking the linked savings account…</span>
|
||||
</div>
|
||||
} @else if (savingsEligibility) {
|
||||
<div class="funding-summary">
|
||||
@if (savingsEligibility.savingsAccountNo) {
|
||||
<div class="funding-row">
|
||||
<span>Savings account</span>
|
||||
<span class="mono">{{ savingsEligibility.savingsAccountNo }}</span>
|
||||
</div>
|
||||
<div class="funding-row">
|
||||
<span>Available balance</span>
|
||||
<span>{{ savingsEligibility.savingsBalance | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
}
|
||||
<div class="funding-row funding-row--total">
|
||||
<span>To debit</span>
|
||||
<span>{{ amount | number: '1.2-2' }}</span>
|
||||
@if (checkingSavings) {
|
||||
<div class="funding-loading">
|
||||
<mat-spinner diameter="24"></mat-spinner>
|
||||
<span>Checking linked savings account eligibility…</span>
|
||||
</div>
|
||||
} @else if (savingsEligibility) {
|
||||
<div class="savings-card">
|
||||
@if (savingsEligibility.savingsAccountNo) {
|
||||
<div class="savings-row">
|
||||
<span class="lbl">Savings Account No.</span>
|
||||
<span class="val font-mono">{{ savingsEligibility.savingsAccountNo }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!savingsEligibility.eligible) {
|
||||
<div class="alert-error">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>{{ savingsEligibility.reason }}</span>
|
||||
<div class="savings-row">
|
||||
<span class="lbl">Available Balance</span>
|
||||
<span class="val font-semibold">{{ savingsEligibility.savingsBalance | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
}
|
||||
<div class="savings-row savings-row-total">
|
||||
<span class="lbl">Amount to Debit</span>
|
||||
<span class="val font-semibold text-primary">{{ amount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!savingsEligibility.eligible) {
|
||||
<div class="alert-box alert-error mt-8">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>{{ savingsEligibility.reason }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (errorMsg) {
|
||||
<div class="alert-error">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>{{ errorMsg }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (errorMsg) {
|
||||
<div class="alert-box alert-error mt-12">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>{{ errorMsg }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Said plainly, so a clerk does not tell the member their shares are issued while a
|
||||
manager still has to approve the request. -->
|
||||
<p class="pending-hint">
|
||||
Submitting records a request for approval. The shares are issued, and the money moved out of Share Sundry,
|
||||
only once it is approved — until then it appears under pending, not in the account's transactions.
|
||||
</p>
|
||||
<!-- PENDING APPROVAL NOTICE -->
|
||||
<div class="pending-notice mt-10">
|
||||
<mat-icon class="notice-icon">schedule</mat-icon>
|
||||
<span>
|
||||
Submitting records a request for approval. Shares are issued and funds moved out of Share Sundry only upon
|
||||
manager approval.
|
||||
</span>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
|
||||
<mat-card-actions class="layout-row align-center gap-5px responsive-column">
|
||||
<button type="button" mat-raised-button [routerLink]="['../../']">
|
||||
<!-- CARD ACTIONS -->
|
||||
<mat-card-actions class="card-actions-bar">
|
||||
<button type="button" mat-stroked-button class="action-btn cancel-btn" [routerLink]="['../../']">
|
||||
{{ 'labels.buttons.Cancel' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="primary" [disabled]="!canSubmit">
|
||||
{{ submitting ? 'Submitting…' : ('labels.buttons.Submit' | translate) }}
|
||||
|
||||
<button mat-flat-button color="primary" class="action-btn submit-btn" [disabled]="!canSubmit">
|
||||
@if (submitting) {
|
||||
<mat-spinner diameter="16"></mat-spinner>
|
||||
<span>Submitting…</span>
|
||||
} @else {
|
||||
<mat-icon>send</mat-icon>
|
||||
<span>{{ 'labels.buttons.Submit' | translate }}</span>
|
||||
}
|
||||
</button>
|
||||
</mat-card-actions>
|
||||
</form>
|
||||
</mat-card>
|
||||
</div>
|
||||
|
||||
<!-- VOUCHER FULL DETAILS POPUP MODAL -->
|
||||
@if (viewingVoucherDetail) {
|
||||
<div class="modal-backdrop animate-fade-in" (click)="closeVoucherDetail()">
|
||||
<div class="voucher-modal-dialog" (click)="$event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<div class="header-title font-accent">
|
||||
<mat-icon class="m-icon">receipt_long</mat-icon>
|
||||
<span>Voucher Details</span>
|
||||
<span class="modal-badge font-mono">{{
|
||||
viewingVoucherDetail.voucher_no || viewingVoucherDetail.receipt_no
|
||||
}}</span>
|
||||
</div>
|
||||
<button type="button" mat-icon-button class="close-btn" (click)="closeVoucherDetail()">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Voucher / Receipt No</span>
|
||||
<span class="detail-val font-mono font-semibold">{{
|
||||
viewingVoucherDetail.voucher_no || viewingVoucherDetail.receipt_no || '—'
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Receipt Reference</span>
|
||||
<span class="detail-val font-mono">{{ viewingVoucherDetail.receipt_no || '—' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Client Name</span>
|
||||
<span class="detail-val">{{
|
||||
viewingVoucherDetail.client_name || sharesAccountData?.clientName || 'Member'
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Client ID</span>
|
||||
<span class="detail-val font-mono">{{
|
||||
viewingVoucherDetail.client_id || sharesAccountData?.clientId || '—'
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Collection Date</span>
|
||||
<span class="detail-val">{{ viewingVoucherDetail.collection_date || '—' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Status</span>
|
||||
<span class="status-pill status-unapplied">{{ viewingVoucherDetail.status || 'UNAPPLIED' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Collected By</span>
|
||||
<span class="detail-val">{{ viewingVoucherDetail.collected_by_name || 'System Administrator' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-item full-width highlight-amount-box">
|
||||
<span class="detail-label">Receipt Amount</span>
|
||||
<span class="amount-val font-mono">{{ viewingVoucherDetail.amount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-item full-width">
|
||||
<span class="detail-label">Full Note / Description</span>
|
||||
<div class="note-box font-accent">
|
||||
{{ viewingVoucherDetail.note || 'Share collection deposit to Share Sundry GL' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The receipt's own actual GL posting — never the GL Account dropdown above the
|
||||
voucher search box, which is unrelated screen state and can name a completely
|
||||
different account than the one this specific receipt sits in. -->
|
||||
<div class="detail-item full-width">
|
||||
<span class="detail-label">GL Entry (this receipt's actual posting)</span>
|
||||
@if (loadingGlEntries) {
|
||||
<div class="gl-entries-loading">Loading…</div>
|
||||
} @else if (voucherGlEntries.length === 0) {
|
||||
<div class="gl-entries-empty">No GL posting recorded for this receipt.</div>
|
||||
} @else {
|
||||
<table class="gl-entries-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="ta-left">GL Account</th>
|
||||
<th class="ta-center">Dr / Cr</th>
|
||||
<th class="ta-right">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (leg of voucherGlEntries; track leg.glCode + leg.drCr) {
|
||||
<tr>
|
||||
<td class="ta-left font-mono">{{ leg.glCode }} — {{ leg.accountName }}</td>
|
||||
<td class="ta-center">
|
||||
<span
|
||||
class="drcr-pill"
|
||||
[class.drcr-pill--dr]="leg.drCr === 'Dr'"
|
||||
[class.drcr-pill--cr]="leg.drCr === 'Cr'"
|
||||
>
|
||||
{{ leg.drCr }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="ta-right font-mono">{{ leg.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
mat-flat-button
|
||||
[color]="isVoucherSelected(viewingVoucherDetail.id) ? 'warn' : 'primary'"
|
||||
class="modal-action-btn"
|
||||
(click)="toggleVoucher(viewingVoucherDetail.id); closeVoucherDetail()"
|
||||
>
|
||||
<mat-icon>{{
|
||||
isVoucherSelected(viewingVoucherDetail.id) ? 'remove_circle_outline' : 'check_circle_outline'
|
||||
}}</mat-icon>
|
||||
<span>{{ isVoucherSelected(viewingVoucherDetail.id) ? 'Unselect Receipt' : 'Select Receipt' }}</span>
|
||||
</button>
|
||||
|
||||
<button type="button" mat-stroked-button (click)="closeVoucherDetail()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -107,6 +107,15 @@ export class ApplySharesComponent implements OnInit {
|
||||
glAccounts: any[] = [];
|
||||
loadingGlAccounts = true;
|
||||
|
||||
/** Currently selected voucher for viewing in the detail popup. */
|
||||
viewingVoucherDetail: any = null;
|
||||
|
||||
/** The receipt's own real Dr/Cr GL legs, fetched fresh per voucher — never the GL Account
|
||||
* dropdown above (that is just what the by-voucher search box is currently set to, which can
|
||||
* name a completely different account than the one this specific receipt actually sits in). */
|
||||
voucherGlEntries: { glCode: string; accountName: string; drCr: string; amount: number }[] = [];
|
||||
loadingGlEntries = false;
|
||||
|
||||
/** What the member's savings can cover, for the savings route. */
|
||||
savingsEligibility: {
|
||||
eligible: boolean;
|
||||
@@ -134,10 +143,43 @@ export class ApplySharesComponent implements OnInit {
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((res: any) => {
|
||||
this.glAccounts = res?.glAccounts || [];
|
||||
// Auto-picked only when there is exactly one live candidate -- the backend already narrowed
|
||||
// this list to accounts that actually hold an eligible receipt today, so a single option is
|
||||
// unambiguous. Two or more still means the counter chooses by hand: picking "whichever
|
||||
// sorts first" among several real candidates is the same blind guess that used to land on
|
||||
// Cash at Vault, just with a shorter, filtered list to guess from.
|
||||
if (this.glAccounts.length === 1 && !this.glAccountNo) {
|
||||
this.glAccountNo = this.glAccounts[0].gl_code;
|
||||
}
|
||||
this.loadingGlAccounts = false;
|
||||
});
|
||||
}
|
||||
|
||||
setPaymentMode(mode: string): void {
|
||||
this.applySharesForm.get('paymentMode')?.setValue(mode);
|
||||
}
|
||||
|
||||
openVoucherDetail(voucher: any, event?: Event): void {
|
||||
if (event) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
this.viewingVoucherDetail = voucher;
|
||||
this.voucherGlEntries = [];
|
||||
this.loadingGlEntries = true;
|
||||
this.cashierService
|
||||
.getShareAdvanceGlEntries(voucher.id)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((res: any) => {
|
||||
this.voucherGlEntries = res?.entries || [];
|
||||
this.loadingGlEntries = false;
|
||||
});
|
||||
}
|
||||
|
||||
closeVoucherDetail(): void {
|
||||
this.viewingVoucherDetail = null;
|
||||
this.voucherGlEntries = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the apply shares form.
|
||||
*
|
||||
@@ -254,12 +296,24 @@ export class ApplySharesComponent implements OnInit {
|
||||
return this.selectedVoucherIds.includes(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* One receipt funds one tranche — no combining instalments here (unlike the first-application
|
||||
* Apply Collection dialog, which still allows several). Picking a different receipt replaces the
|
||||
* current one rather than adding to it, so {@link selectedVoucherIds} never holds more than one id.
|
||||
*/
|
||||
toggleVoucher(id: number): void {
|
||||
this.selectedVoucherIds = this.isVoucherSelected(id) ? this.selectedVoucherIds.filter((x) => x !== id) : [
|
||||
...this.selectedVoucherIds,
|
||||
id
|
||||
];
|
||||
const wasSelected = this.isVoucherSelected(id);
|
||||
this.selectedVoucherIds = wasSelected ? [] : [id];
|
||||
this.voucherError = '';
|
||||
// The GL Account picker above follows whichever receipt is ticked — its own actual GL account
|
||||
// (see gl_code on the row), not left showing an unrelated account or "Select…" once a receipt
|
||||
// that plainly does have one is already chosen.
|
||||
if (!wasSelected) {
|
||||
const voucher = this.vouchers.find((v) => v.id === id);
|
||||
if (voucher?.gl_code) {
|
||||
this.glAccountNo = voucher.gl_code;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -307,19 +361,13 @@ export class ApplySharesComponent implements OnInit {
|
||||
this.voucherError = `Voucher ${trimmed} belongs to ${res.client_name}, not the holder of this share account.`;
|
||||
return;
|
||||
}
|
||||
// Added to the selection rather than replacing it — a looked-up voucher is usually the
|
||||
// second half of a part-payment, not a change of mind about the first. No amount check
|
||||
// here: one instalment is not meant to cover the tranche on its own.
|
||||
// Replaces the selection rather than adding to it — one receipt funds one tranche here, so
|
||||
// finding a voucher by number is a different way to pick THE receipt, not a second one.
|
||||
this.vouchers = [
|
||||
res,
|
||||
...this.vouchers.filter((v) => v.id !== res.id)
|
||||
];
|
||||
if (!this.isVoucherSelected(res.id)) {
|
||||
this.selectedVoucherIds = [
|
||||
...this.selectedVoucherIds,
|
||||
res.id
|
||||
];
|
||||
}
|
||||
this.selectedVoucherIds = [res.id];
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -168,6 +168,8 @@
|
||||
<th class="col-tick"></th>
|
||||
<th class="ta-left">Voucher / Receipt</th>
|
||||
<th class="ta-left">Date</th>
|
||||
<th class="ta-left">Description</th>
|
||||
<th class="ta-left">Collected By</th>
|
||||
<th class="ta-right">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -186,6 +188,8 @@
|
||||
</td>
|
||||
<td class="ta-left mono">{{ r.voucher_no || r.receipt_no }}</td>
|
||||
<td class="ta-left">{{ r.collection_date }}</td>
|
||||
<td class="ta-left" [title]="r.note">{{ r.note || '—' }}</td>
|
||||
<td class="ta-left">{{ r.collected_by_name || '—' }}</td>
|
||||
<td class="ta-right amount-cell">{{ r.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -197,7 +201,7 @@
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td class="col-tick"></td>
|
||||
<td class="ta-left" colspan="2">
|
||||
<td class="ta-left" colspan="4">
|
||||
{{ selectedIds.length }} {{ selectedIds.length === 1 ? 'receipt' : 'receipts' }} selected
|
||||
</td>
|
||||
<td class="ta-right amount-cell">{{ selectedTotal | number: '1.2-2' }}</td>
|
||||
|
||||
+6
@@ -135,6 +135,12 @@ export class ApplyCollectionDialogComponent implements OnInit {
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.glAccounts = res?.glAccounts || [];
|
||||
// Auto-picked only when there is exactly one live candidate -- the backend already narrowed
|
||||
// this list to accounts that actually hold an eligible receipt today, so a single option is
|
||||
// unambiguous. Two or more still means the counter chooses by hand.
|
||||
if (this.glAccounts.length === 1 && !this.glAccountNo) {
|
||||
this.glAccountNo = this.glAccounts[0].gl_code;
|
||||
}
|
||||
this.loadingGlAccounts = false;
|
||||
});
|
||||
|
||||
|
||||
+175
-6
@@ -384,18 +384,59 @@
|
||||
|
||||
/* Print CSS */
|
||||
@media print {
|
||||
/* Hide non-overlay contents during print */
|
||||
::ng-deep html,
|
||||
::ng-deep body {
|
||||
height: auto !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
/* Hide non-overlay contents during print. Deliberately NOT `::ng-deep` here: under Angular's
|
||||
emulated encapsulation a bare `body` selector never carries this component's scoping
|
||||
attribute, so a scoped rule can never match the real <body> and is safely inert — whereas
|
||||
`::ng-deep` emits it fully unscoped into the GLOBAL stylesheet for the rest of the session
|
||||
(Angular never removes a component's injected styles when it's destroyed), silently hiding
|
||||
every other page's print output app-wide the moment this dialog is opened once. The app
|
||||
shell (nav, toolbar, sidebar) is already hidden for every print via the global rules in
|
||||
main.scss, so this dialog doesn't need its own global override to get the same effect. */
|
||||
body > :not(.cdk-overlay-container) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* The dialog's own scroll region clips anything past 80vh -- printing only captures what a
|
||||
scrollable/max-height box shows on screen, so both constraints must be lifted. */
|
||||
.dialog-content-container {
|
||||
max-height: none !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
/* CDK's overlay wrapper/backdrop/pane are all position: fixed. Unset them so the full certificate prints statically. */
|
||||
::ng-deep .cdk-overlay-backdrop {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
::ng-deep .cdk-overlay-container,
|
||||
::ng-deep .cdk-global-overlay-wrapper,
|
||||
::ng-deep .cdk-overlay-pane,
|
||||
::ng-deep .mat-mdc-dialog-container,
|
||||
::ng-deep .mdc-dialog__container,
|
||||
::ng-deep .mdc-dialog__surface {
|
||||
position: static !important;
|
||||
display: block !important;
|
||||
max-height: none !important;
|
||||
max-width: none !important;
|
||||
height: auto !important;
|
||||
width: auto !important;
|
||||
overflow: visible !important;
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Make certificate fill page */
|
||||
#share-certificate-print {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
position: static !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
height: auto !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
background-color: #fff !important;
|
||||
@@ -411,7 +452,135 @@
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.mat-mdc-dialog-container {
|
||||
::ng-deep .mat-mdc-dialog-container {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* Compact every spacing/font value so the whole certificate fits a single printed page */
|
||||
.certificate-container {
|
||||
padding: 6px !important;
|
||||
}
|
||||
|
||||
.certificate-border-inner {
|
||||
padding: 14px !important;
|
||||
}
|
||||
|
||||
.certificate-header {
|
||||
margin-bottom: 10px !important;
|
||||
padding-bottom: 10px !important;
|
||||
|
||||
.bank-name {
|
||||
font-size: 19px !important;
|
||||
margin-bottom: 2px !important;
|
||||
}
|
||||
|
||||
.bank-sub {
|
||||
font-size: 10px !important;
|
||||
margin-bottom: 8px !important;
|
||||
}
|
||||
|
||||
.cert-title {
|
||||
font-size: 19px !important;
|
||||
letter-spacing: 2px !important;
|
||||
margin-bottom: 2px !important;
|
||||
}
|
||||
|
||||
.cert-subtitle {
|
||||
font-size: 9px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cert-meta-row {
|
||||
margin-bottom: 10px !important;
|
||||
padding-bottom: 8px !important;
|
||||
|
||||
.meta-label {
|
||||
font-size: 9px !important;
|
||||
margin-bottom: 1px !important;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cert-body-section {
|
||||
margin-bottom: 10px !important;
|
||||
|
||||
.cert-main-text {
|
||||
font-size: 11px !important;
|
||||
line-height: 1.4 !important;
|
||||
margin-bottom: 10px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.issued-to-details {
|
||||
padding: 8px 14px !important;
|
||||
margin: 8px auto !important;
|
||||
|
||||
.client-name-details {
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
.client-ids,
|
||||
.client-address {
|
||||
font-size: 10px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.certificate-details-table {
|
||||
margin-bottom: 12px !important;
|
||||
|
||||
td {
|
||||
padding: 5px 10px !important;
|
||||
font-size: 10px !important;
|
||||
}
|
||||
|
||||
.lbl-col-highlight {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.val-col-highlight {
|
||||
font-size: 13px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.allotment-table {
|
||||
margin-bottom: 12px !important;
|
||||
|
||||
th {
|
||||
padding: 4px 8px !important;
|
||||
font-size: 8px !important;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 4px 8px !important;
|
||||
font-size: 9px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cert-footer {
|
||||
margin-top: 8px !important;
|
||||
|
||||
.seal-ring {
|
||||
width: 60px !important;
|
||||
height: 60px !important;
|
||||
}
|
||||
|
||||
.seal-text {
|
||||
font-size: 8px !important;
|
||||
}
|
||||
|
||||
.closing-text {
|
||||
font-size: 10px !important;
|
||||
}
|
||||
|
||||
.signature-line {
|
||||
height: 22px !important;
|
||||
}
|
||||
|
||||
.signatory-title {
|
||||
font-size: 10px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,124 +5,244 @@
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
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
|
||||
});
|
||||
|
||||
|
||||
@@ -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">
|
||||
← 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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
`
|
||||
]
|
||||
|
||||
@@ -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);
|
||||
|
||||
+53
-1
@@ -68,18 +68,70 @@
|
||||
<tr>
|
||||
<th class="ta-left">Voucher / Receipt</th>
|
||||
<th class="ta-left">Date</th>
|
||||
<th class="ta-left">Description</th>
|
||||
<th class="ta-left">Collected by</th>
|
||||
<th class="ta-right">Amount</th>
|
||||
<th class="ta-center">Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (v of detail.vouchers; track v.id) {
|
||||
<tr>
|
||||
<tr class="sard-voucher-row" (click)="toggleVoucherGlEntries(v.id)">
|
||||
<td class="ta-left mono">{{ v.voucherNo || v.receiptNo }}</td>
|
||||
<td class="ta-left">{{ v.collectionDate }}</td>
|
||||
<td class="ta-left sard-desc-cell" [title]="v.note">{{ v.note || '—' }}</td>
|
||||
<td class="ta-left">{{ v.collectedByName || '—' }}</td>
|
||||
<td class="ta-right amount-cell">{{ v.amount | number: '1.2-2' }}</td>
|
||||
<td class="ta-center">
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="sard-detail-btn"
|
||||
(click)="$event.stopPropagation(); toggleVoucherGlEntries(v.id)"
|
||||
title="View this receipt's actual GL posting"
|
||||
>
|
||||
<mat-icon>visibility</mat-icon>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@if (expandedVoucherId === v.id) {
|
||||
<tr class="sard-gl-row">
|
||||
<td colspan="6">
|
||||
@if (loadingGlEntriesId === v.id) {
|
||||
<div class="sard-gl-loading">Loading GL entry…</div>
|
||||
} @else if (!glEntriesByVoucherId[v.id]?.length) {
|
||||
<div class="sard-gl-empty">No GL posting recorded for this receipt.</div>
|
||||
} @else {
|
||||
<table class="sard-gl-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="ta-left">GL Account</th>
|
||||
<th class="ta-center">Dr / Cr</th>
|
||||
<th class="ta-right">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (leg of glEntriesByVoucherId[v.id]; track leg.glCode + leg.drCr) {
|
||||
<tr>
|
||||
<td class="ta-left mono">{{ leg.glCode }} — {{ leg.accountName }}</td>
|
||||
<td class="ta-center">
|
||||
<span
|
||||
class="sard-drcr-pill"
|
||||
[class.sard-drcr-pill--dr]="leg.drCr === 'Dr'"
|
||||
[class.sard-drcr-pill--cr]="leg.drCr === 'Cr'"
|
||||
>
|
||||
{{ leg.drCr }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="ta-right amount-cell">{{ leg.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
+90
@@ -121,3 +121,93 @@ $danger: #b3261e;
|
||||
font-size: 13px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.ta-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sard-voucher-row {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #f4f7fb;
|
||||
}
|
||||
}
|
||||
|
||||
.sard-desc-cell {
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sard-detail-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: $primary;
|
||||
}
|
||||
}
|
||||
|
||||
.sard-gl-row td {
|
||||
background: #fbfcfe;
|
||||
border-top: none;
|
||||
padding: 4px 8px 10px;
|
||||
}
|
||||
|
||||
.sard-gl-loading,
|
||||
.sard-gl-empty {
|
||||
font-size: 12px;
|
||||
color: $muted;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.sard-gl-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid $border;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
|
||||
th {
|
||||
color: $muted;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
padding: 5px 8px;
|
||||
background: #f4f7fb;
|
||||
border-bottom: 1px solid $border;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 5px 8px;
|
||||
border-top: 1px solid #eceff4;
|
||||
}
|
||||
}
|
||||
|
||||
.sard-drcr-pill {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
|
||||
&.sard-drcr-pill--dr {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
&.sard-drcr-pill--cr {
|
||||
background: #ecfdf5;
|
||||
color: #059669;
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
}
|
||||
|
||||
+26
@@ -50,6 +50,12 @@ export class ShareAdditionRequestDetailDialogComponent implements OnInit {
|
||||
actioning = false;
|
||||
detail: any = null;
|
||||
|
||||
/** Which voucher's row is currently expanded to show its real Dr/Cr GL legs, or null if none. */
|
||||
expandedVoucherId: number | null = null;
|
||||
loadingGlEntriesId: number | null = null;
|
||||
/** Fetched once per voucher and kept — a checker toggling the same row twice shouldn't re-fetch. */
|
||||
glEntriesByVoucherId: { [id: number]: { glCode: string; accountName: string; drCr: string; amount: number }[] } = {};
|
||||
|
||||
public dialogRef = inject(MatDialogRef<ShareAdditionRequestDetailDialogComponent>);
|
||||
public data = inject<{ transactionId: number }>(MAT_DIALOG_DATA);
|
||||
|
||||
@@ -76,6 +82,26 @@ export class ShareAdditionRequestDetailDialogComponent implements OnInit {
|
||||
return this.detail?.vouchers?.length || 0;
|
||||
}
|
||||
|
||||
/** Shows or hides a voucher's real GL posting, fetching it the first time it is opened. */
|
||||
toggleVoucherGlEntries(voucherId: number): void {
|
||||
if (this.expandedVoucherId === voucherId) {
|
||||
this.expandedVoucherId = null;
|
||||
return;
|
||||
}
|
||||
this.expandedVoucherId = voucherId;
|
||||
if (this.glEntriesByVoucherId[voucherId]) {
|
||||
return;
|
||||
}
|
||||
this.loadingGlEntriesId = voucherId;
|
||||
this.cashierService
|
||||
.getShareAdvanceGlEntries(voucherId)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((res: any) => {
|
||||
this.glEntriesByVoucherId[voucherId] = res?.entries || [];
|
||||
this.loadingGlEntriesId = null;
|
||||
});
|
||||
}
|
||||
|
||||
approve(): void {
|
||||
this.act('approveadditionalshares');
|
||||
}
|
||||
|
||||
+102
-11
@@ -982,6 +982,16 @@ mat-card {
|
||||
.mat-mdc-tab-header,
|
||||
.mat-stepper-vertical-line,
|
||||
.mat-step-header,
|
||||
/* Account-view pages (Shares, Savings, Current, Deposits…) use `<nav mat-tab-nav-bar
|
||||
class="navigation-tabs">` for their General/Passbook/Charges/… tab links — a DIFFERENT
|
||||
Material component from mat-tab-group, so it never carries `.mat-mdc-tab-header` and slipped
|
||||
past the rule above. Its own style (theme/_content.scss) gives it a grey background and
|
||||
`overflow: auto`, which is exactly the grey box with a baked-in scrollbar seen in print/PDF
|
||||
output — printing it is as pointless as printing the tab-group header already excluded above. */
|
||||
.navigation-tabs,
|
||||
[mat-tab-nav-bar],
|
||||
.mat-mdc-tab-nav-bar,
|
||||
.mat-mdc-tab-link-container,
|
||||
/* The real 2-row nav menu (Client Master / Approvals / Cashier … and the row below it) and the
|
||||
live KPI ticker live directly in shell.component.html as .main-header / .kpi-ticker, not
|
||||
inside <mifosx-toolbar> — hiding only the toolbar left this menu printing on every page. */
|
||||
@@ -997,17 +1007,57 @@ mat-card {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
/* The whole app shell scrolls inside CDK's own clipped viewport —
|
||||
.mat-drawer-container { overflow: hidden } and .mat-drawer-content { overflow: auto; height: 100% }
|
||||
— so without freeing these too, print is still capped to one screen's worth of the page
|
||||
regardless of the tab-body fix below. */
|
||||
/* THE root clip: assets/styles/_overflow.scss sets `html, body { overflow: hidden }` and
|
||||
index.html puts `h-full` (height: 100%) on both, so the document itself is locked to exactly
|
||||
one viewport and everything past it is cut away. Every inner container fix below is useless
|
||||
while this stands — it is why printing any page produced a blank sheet with only a sliver of
|
||||
whatever happened to sit at the fold. */
|
||||
html,
|
||||
body {
|
||||
height: auto !important;
|
||||
overflow: visible !important;
|
||||
width: 100% !important;
|
||||
background: #fff !important;
|
||||
|
||||
/* Keep backgrounds/colours (certificate rules, table shading) instead of dropping them. */
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
|
||||
/* The shell then scrolls inside CDK's own clipped viewport —
|
||||
#mifosx-shell-container { height: 100vh }, .mat-drawer-content { height: 100%; overflow: auto }
|
||||
— so these have to be freed as well. */
|
||||
#mifosx-shell-container,
|
||||
mat-sidenav-container,
|
||||
mat-drawer-container,
|
||||
mat-sidenav-content,
|
||||
mat-drawer-content,
|
||||
.mat-drawer-container,
|
||||
.mat-drawer-content,
|
||||
mat-sidenav-content.sidenav {
|
||||
position: static !important;
|
||||
.mat-sidenav-content {
|
||||
overflow: visible !important;
|
||||
height: auto !important;
|
||||
max-height: none !important;
|
||||
|
||||
/* Material's own theme paints a background-color on .mat-drawer-container — without
|
||||
overriding it here, that grey shows behind every printed page even though html/body are
|
||||
already forced white above. */
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
/* shell.component.scss:247-271 scopes `mifosx-content { overflow-x: hidden; max-width: 100% }`
|
||||
specifically to `.sidenav mifosx-content` so tables/cards shrink to fit whenever the sidebar
|
||||
is open on screen — this rule has nothing to do with the sidebar being hidden for print, and
|
||||
without overriding it too, the routed page content keeps clipping/scrolling horizontally to
|
||||
whatever width was last measured on screen, which is exactly the narrow box with a baked-in
|
||||
scrollbar seen in print/PDF output. */
|
||||
mifosx-content,
|
||||
.sidenav mifosx-content,
|
||||
mat-tab-nav-panel,
|
||||
.mat-mdc-tab-nav-panel {
|
||||
overflow: visible !important;
|
||||
width: auto !important;
|
||||
max-width: 100% !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
/* Angular Material clips a tab's content to a JS-measured on-screen height via
|
||||
@@ -1023,11 +1073,6 @@ mat-card {
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
body,
|
||||
html {
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
@page {
|
||||
/* stylelint-disable-next-line unit-allowed-list */
|
||||
margin: 1.5cm;
|
||||
@@ -1869,3 +1914,49 @@ mifosx-client-datatable-step * {
|
||||
--mdc-checkbox-selected-pressed-icon-color: #059669;
|
||||
}
|
||||
/* stylelint-enable selector-max-universal */
|
||||
|
||||
/* ==========================================================================
|
||||
Global Print Styles for Material Dialogs & Certificates
|
||||
========================================================================== */
|
||||
@media print {
|
||||
html,
|
||||
body {
|
||||
height: auto !important;
|
||||
overflow: visible !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
/* Hide main application shell during dialog printing */
|
||||
body > :not(.cdk-overlay-container) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Remove fixed positioning on CDK overlay elements so content flows naturally */
|
||||
.cdk-overlay-backdrop {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.cdk-overlay-container,
|
||||
.cdk-global-overlay-wrapper,
|
||||
.cdk-overlay-pane,
|
||||
.mat-mdc-dialog-container,
|
||||
.mdc-dialog__container,
|
||||
.mdc-dialog__surface {
|
||||
position: static !important;
|
||||
display: block !important;
|
||||
max-height: none !important;
|
||||
max-width: none !important;
|
||||
height: auto !important;
|
||||
width: auto !important;
|
||||
overflow: visible !important;
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.no-print,
|
||||
.mat-mdc-dialog-actions {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user