Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6890584b16 | ||
|
|
d584af8e54 | ||
|
|
e1b33827ea | ||
|
|
8ed8f92b00 | ||
|
|
c9feeae3cb | ||
|
|
0404197c1b | ||
|
|
8a6becff86 | ||
|
|
c803e20628 | ||
|
|
4e3b49a96a | ||
|
|
915deddb74 | ||
|
|
6a04a92113 | ||
|
|
88f34f58e3 | ||
|
|
88c4242692 | ||
|
|
ca01e9b65b | ||
|
|
a4e0a22205 |
@@ -7,16 +7,6 @@
|
||||
-->
|
||||
|
||||
<div class="container">
|
||||
<div class="container m-b-20 align-end gap-2px">
|
||||
@if (!viewAccountTransferData.reversed) {
|
||||
<span>
|
||||
<button mat-raised-button color="warn" *mifosxHasPermission="'ADJUST_ACCOUNTTRANSFER'">
|
||||
<fa-icon icon="undo" class="m-r-10"></fa-icon>{{ 'labels.buttons.Undo' | translate }}
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<mat-card>
|
||||
<mat-card-content>
|
||||
<div class="transfer-container">
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<!--
|
||||
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 class="pg-head">
|
||||
<div>
|
||||
<div class="pg-eyebrow">BRANCH OPERATIONS · BRANCH CASH POSITION</div>
|
||||
<h1 class="pg-title">Branch Cash Position</h1>
|
||||
<p class="pg-sub">
|
||||
The notes and coins the branch holds, denomination by denomination: the vault plus every cashier's drawer. Cash
|
||||
moving between the vault and a cashier nets out; deposits, withdrawals and exchanges change it.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="pg-toolbar pg-toolbar--mat">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>As of Date</mat-label>
|
||||
<input matInput type="date" [value]="asOfDate" (change)="onDateChange($any($event.target).value)" />
|
||||
</mat-form-field>
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
|
||||
<mat-icon>refresh</mat-icon> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (errorMsg) {
|
||||
<div class="pg-empty bcp-error">{{ errorMsg }}</div>
|
||||
}
|
||||
@if (loading) {
|
||||
<div class="pg-empty">Loading…</div>
|
||||
}
|
||||
|
||||
@if (position; as p) {
|
||||
<div class="bcp-totals">
|
||||
<div class="bcp-total">
|
||||
<span>Branch</span><strong>{{ p.officeName }}</strong>
|
||||
</div>
|
||||
<div class="bcp-total">
|
||||
<span>Vault</span><strong>₹{{ p.vaultAmount | number: '1.2-2' }}</strong>
|
||||
</div>
|
||||
<div class="bcp-total">
|
||||
<span>Cashiers' drawers</span><strong>₹{{ p.cashiersAmount | number: '1.2-2' }}</strong>
|
||||
</div>
|
||||
<div class="bcp-total bcp-total--main">
|
||||
<span>Bank total</span><strong>₹{{ p.totalAmount | number: '1.2-2' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!p.vaultHasBreakdown) {
|
||||
<div class="pg-empty bcp-note">
|
||||
No vault denomination breakdown was recorded on or before {{ p.asOfDate }}, so the vault column reads 0. Open
|
||||
the business day with a note/coin count to start tracking it.
|
||||
</div>
|
||||
}
|
||||
|
||||
<ng-template #table let-rows let-label="label" let-coin="coin">
|
||||
@if (rows.length > 0) {
|
||||
<div class="pos-section-label" [class.pos-section-label--coin]="coin">{{ label }}</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Denomination</th>
|
||||
<th class="num">Vault</th>
|
||||
<th class="num">Cashiers</th>
|
||||
<th class="num">Bank total (count)</th>
|
||||
<th class="num">Bank total (₹)</th>
|
||||
<th class="num">Exchange today</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (d of rows; track d.denominationId) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="pg-chip" [class.pg-chip--coin]="coin">₹{{ d.denominationValue }}</span>
|
||||
</td>
|
||||
<td class="num">{{ d.vaultCount }}</td>
|
||||
<td class="num">{{ d.cashierCount }}</td>
|
||||
<td class="num bcp-strong">{{ d.totalCount }}</td>
|
||||
<td class="num">₹{{ d.totalAmount | number: '1.2-2' }}</td>
|
||||
<td
|
||||
class="num bcp-move"
|
||||
[class.bcp-move--in]="d.exchangeNet > 0"
|
||||
[class.bcp-move--out]="d.exchangeNet < 0"
|
||||
>
|
||||
{{ movement(d.exchangeNet) }}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<div class="pg-card bcp-card">
|
||||
<ng-container
|
||||
*ngTemplateOutlet="table; context: { $implicit: notes(), label: 'Notes', coin: false }"
|
||||
></ng-container>
|
||||
<ng-container
|
||||
*ngTemplateOutlet="table; context: { $implicit: coins(), label: 'Coins', coin: true }"
|
||||
></ng-container>
|
||||
</div>
|
||||
|
||||
<p class="bcp-foot">
|
||||
<strong>Exchange today</strong> is the notes taken in less the notes given out by cash exchanges on
|
||||
{{ p.asOfDate }}. It is already part of the Cashiers column; it is shown beside the total so a change in the mix
|
||||
can be seen.
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.bcp-error {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.bcp-note {
|
||||
margin-bottom: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.bcp-totals {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.bcp-total {
|
||||
flex: 1 1 180px;
|
||||
padding: 12px 14px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
&--main {
|
||||
background: #e6f4ee;
|
||||
border-color: #1f7a4d;
|
||||
|
||||
strong {
|
||||
color: #0f4f3c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bcp-card {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.bcp-strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bcp-move {
|
||||
font-weight: 700;
|
||||
color: #94a3b8;
|
||||
|
||||
&--in {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
&--out {
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
.bcp-foot {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 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 { MatIconModule } from '@angular/material/icon';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { CashierService } from '../cashier.service';
|
||||
import { businessToday } from 'app/core/utils/business-today';
|
||||
|
||||
interface BranchDenomRow {
|
||||
denominationId: number;
|
||||
denominationValue: number;
|
||||
kind: string;
|
||||
vaultCount: number;
|
||||
cashierCount: number;
|
||||
totalCount: number;
|
||||
totalAmount: number;
|
||||
/** Notes taken in less notes given out by exchanges on the day. */
|
||||
exchangeNet: number;
|
||||
}
|
||||
|
||||
interface BranchPosition {
|
||||
officeId: number;
|
||||
officeName: string;
|
||||
asOfDate: string;
|
||||
vaultHasBreakdown: boolean;
|
||||
vaultAmount: number;
|
||||
cashiersAmount: number;
|
||||
totalAmount: number;
|
||||
denominations: BranchDenomRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* What the branch holds, denomination by denomination: the vault plus every cashier's drawer. An exchange
|
||||
* (a customer hands in 10 x ₹500 and leaves with 50 x ₹100) leaves the vault alone and changes a drawer, so
|
||||
* it only shows up in this total -- +10 on ₹500, -50 on ₹100.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-branch-cash-position',
|
||||
templateUrl: './branch-cash-position.component.html',
|
||||
styleUrls: [
|
||||
'../cashier-shared.scss',
|
||||
'../cashier-position/cashier-position.component.scss',
|
||||
'./branch-cash-position.component.scss'
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatIconModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule
|
||||
]
|
||||
})
|
||||
export class BranchCashPositionComponent implements OnInit {
|
||||
private service = inject(CashierService);
|
||||
|
||||
asOfDate = '';
|
||||
loading = false;
|
||||
errorMsg = '';
|
||||
position: BranchPosition | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
const d = businessToday();
|
||||
this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.errorMsg = '';
|
||||
this.position = null;
|
||||
this.service
|
||||
.branchCashPosition(this.asOfDate)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((data: BranchPosition | null) => {
|
||||
this.loading = false;
|
||||
if (!data) {
|
||||
this.errorMsg = 'Could not load the branch cash position.';
|
||||
return;
|
||||
}
|
||||
this.position = data;
|
||||
});
|
||||
}
|
||||
|
||||
onDateChange(value: string): void {
|
||||
this.asOfDate = value;
|
||||
this.load();
|
||||
}
|
||||
|
||||
notes(): BranchDenomRow[] {
|
||||
return (this.position?.denominations || []).filter((d) => d.kind !== 'coin');
|
||||
}
|
||||
|
||||
coins(): BranchDenomRow[] {
|
||||
return (this.position?.denominations || []).filter((d) => d.kind === 'coin');
|
||||
}
|
||||
|
||||
/** "+10", "-50", or a dash when the day's exchanges did not touch the denomination. */
|
||||
movement(net: number): string {
|
||||
if (!net) return '—';
|
||||
return net > 0 ? `+${net}` : `${net}`;
|
||||
}
|
||||
}
|
||||
@@ -386,6 +386,38 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!-- Investment interest vouchers that are due but not posted. A reminder only: unlike the interest
|
||||
above it never stops the close (Jitendra Sir); the voucher can still be posted afterwards. -->
|
||||
@if (preview?.investmentInterestPending?.length) {
|
||||
<div class="bd-warn">
|
||||
<mat-icon>notifications_active</mat-icon>
|
||||
<div>
|
||||
<strong>Reminder: investment interest vouchers are pending.</strong>
|
||||
@for (t of preview.investmentInterestPending; track t.purchaseId) {
|
||||
<div>
|
||||
{{ t.lineType === 'COUPON' ? 'Coupon' : 'Provision' }} · {{ t.scriptDesc }} · deal {{ t.dealNo }} ·
|
||||
{{ t.intDate }} · ₹{{ (t.lineType === 'COUPON' ? t.grossInterest : t.amount) | number: '1.2-2' }}
|
||||
</div>
|
||||
}
|
||||
Post them under Investment → Govt Security / Bond → 03 Interest Entry. You can still close the day.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (preview?.investmentFdPending?.length) {
|
||||
<div class="bd-warn">
|
||||
<mat-icon>notifications_active</mat-icon>
|
||||
<div>
|
||||
<strong>Reminder: FD investment entries are pending.</strong>
|
||||
@for (t of preview.investmentFdPending; track t.fdId) {
|
||||
<div>
|
||||
{{ t.lineType === 'MATURITY' ? 'Maturity' : t.lineType === 'PAYOUT' ? 'Interest payout' : 'Provision' }}
|
||||
· {{ t.partyName }} · FD {{ t.dealNo }} · {{ t.intDate }} · ₹{{ t.amount | number: '1.2-2' }}
|
||||
</div>
|
||||
}
|
||||
Post them under Investment → FD Investment. You can still close the day.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (preview?.savingsInterestPending?.length) {
|
||||
<div class="bd-warn">
|
||||
<mat-icon>warning</mat-icon>
|
||||
|
||||
@@ -22,50 +22,255 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ── Customer Info Bar ── -->
|
||||
<div class="cx-customer-bar">
|
||||
<div
|
||||
class="cx-customer-toggle"
|
||||
[class.cx-customer-toggle--active]="isAccountHolder"
|
||||
(click)="toggleAccountHolder()"
|
||||
>
|
||||
<mat-icon>{{ isAccountHolder ? 'check_box' : 'check_box_outline_blank' }}</mat-icon>
|
||||
<span>Account Holder</span>
|
||||
</div>
|
||||
|
||||
@if (isAccountHolder) {
|
||||
<label class="cx-field cx-field--inline">
|
||||
<span>Account No.</span>
|
||||
<input
|
||||
type="text"
|
||||
[value]="accountNo"
|
||||
(input)="accountNo = $any($event.target).value"
|
||||
placeholder="e.g. 000101234567"
|
||||
/>
|
||||
</label>
|
||||
} @else {
|
||||
<span class="cx-walk-in"><mat-icon>person_outline</mat-icon> Walk-in Customer</span>
|
||||
}
|
||||
|
||||
@if (needsIdProof) {
|
||||
<div class="cx-pan-required">
|
||||
<mat-icon>warning</mat-icon>
|
||||
<span>Amount ≥ ₹50,000 — Customer PAN / Aadhaar required (RBI)</span>
|
||||
<!-- ── Cashier: whose drawer takes the customer's notes and gives the change ── -->
|
||||
@if (cashier) {
|
||||
<section class="cx-cashier">
|
||||
<div class="cx-avatar cx-avatar--sm">{{ cashierInitials }}</div>
|
||||
<div class="cx-cashier__id">
|
||||
<div class="cx-cashier__label">Cashier at the counter</div>
|
||||
<div class="cx-cashier__name">{{ cashier.name || '—' }}</div>
|
||||
<div class="cx-cashier__flow">
|
||||
<span><mat-icon>south</mat-icon> Receives from customer</span>
|
||||
<span><mat-icon>north</mat-icon> Gives to customer</span>
|
||||
</div>
|
||||
</div>
|
||||
<label class="cx-field cx-field--inline cx-field--pan">
|
||||
<span>PAN / Aadhaar *</span>
|
||||
<input
|
||||
type="text"
|
||||
[value]="customerIdProof"
|
||||
(input)="customerIdProof = $any($event.target).value"
|
||||
placeholder="ABCDE1234F"
|
||||
maxlength="20"
|
||||
/>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
<div class="cx-cashier__stats">
|
||||
<div>
|
||||
<span>Counter</span><strong>{{ cashier.tellerName || '—' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Branch</span><strong>{{ cashier.officeName || '—' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Allocated today</span
|
||||
><strong>{{
|
||||
cashier.allocatedAmount === null ? '—' : '₹' + (cashier.allocatedAmount | number: '1.2-2')
|
||||
}}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Cash in hand</span><strong>₹{{ cashier.totalAmount | number: '1.2-2' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
} @else if (noCashier) {
|
||||
<section class="cx-cashier cx-cashier--none">
|
||||
<mat-icon>person_off</mat-icon>
|
||||
<div>
|
||||
<div class="cx-cashier__name">Your login is not linked to a cashier</div>
|
||||
<div class="cx-cashier__label">
|
||||
A cash exchange moves notes in and out of a cashier's drawer, so it can only be recorded by a cashier. Log in
|
||||
as a cashier to continue.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- ── Customer: an existing client (details from their record) or a walk-in identified at the counter ── -->
|
||||
<section class="cx-cust">
|
||||
<header class="cx-cust__head">
|
||||
<div class="cx-cust__title">
|
||||
<span class="cx-cust__title-icon"><mat-icon>groups</mat-icon></span>
|
||||
<div>
|
||||
<div class="cx-cust__title-text">Customer</div>
|
||||
<div class="cx-cust__title-sub">Who is exchanging cash at the counter</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cx-cust__modes" role="tablist" aria-label="Customer type">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="cx-cust__mode"
|
||||
[class.cx-cust__mode--active]="customerMode === 'existing'"
|
||||
[attr.aria-selected]="customerMode === 'existing'"
|
||||
(click)="setCustomerMode('existing')"
|
||||
>
|
||||
<mat-icon>badge</mat-icon> Existing Customer
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="cx-cust__mode"
|
||||
[class.cx-cust__mode--active]="customerMode === 'walkin'"
|
||||
[attr.aria-selected]="customerMode === 'walkin'"
|
||||
(click)="setCustomerMode('walkin')"
|
||||
>
|
||||
<mat-icon>person_outline</mat-icon> Walk-in Customer
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="cx-cust__body">
|
||||
<!-- Who -->
|
||||
<div class="cx-cust__who">
|
||||
@if (customerMode === 'existing') {
|
||||
@if (loadingCustomer) {
|
||||
<div class="cx-cust__empty">
|
||||
<mat-icon class="cx-cust__empty-icon cx-spin">autorenew</mat-icon>
|
||||
<div class="cx-cust__empty-title">Loading customer details…</div>
|
||||
</div>
|
||||
} @else if (!selectedCustomer) {
|
||||
<div class="cx-cust__empty">
|
||||
<mat-icon class="cx-cust__empty-icon">person_search</mat-icon>
|
||||
<div class="cx-cust__empty-title">No customer selected</div>
|
||||
<div class="cx-cust__empty-sub">Search by name, account no., PAN or Aadhaar</div>
|
||||
<button type="button" class="cx-btn cx-btn--primary" (click)="openCustomerSearch()">
|
||||
<mat-icon>search</mat-icon> Search Customer
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="cx-cust__card">
|
||||
<div class="cx-cust__card-top">
|
||||
<div class="cx-avatar">{{ customerInitials }}</div>
|
||||
<div class="cx-cust__card-id">
|
||||
<div class="cx-cust__name">{{ selectedCustomer.displayName }}</div>
|
||||
<div class="cx-cust__badges">
|
||||
<span
|
||||
class="cx-kyc"
|
||||
[class.cx-kyc--ok]="selectedCustomer.kycStatus === 'Verified'"
|
||||
[class.cx-kyc--warn]="selectedCustomer.kycStatus !== 'Verified'"
|
||||
>
|
||||
<mat-icon>{{
|
||||
selectedCustomer.kycStatus === 'Verified' ? 'verified_user' : 'gpp_maybe'
|
||||
}}</mat-icon>
|
||||
KYC: {{ selectedCustomer.kycStatus }}
|
||||
</span>
|
||||
@if (selectedCustomer.accountType) {
|
||||
<span class="cx-chip-tag">{{ selectedCustomer.accountType }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="cx-cust__card-actions">
|
||||
<button type="button" class="cx-link-btn" (click)="openCustomerSearch()">
|
||||
<mat-icon>swap_horiz</mat-icon> Change
|
||||
</button>
|
||||
<button type="button" class="cx-link-btn cx-link-btn--danger" (click)="clearCustomer()">
|
||||
<mat-icon>close</mat-icon> Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cx-cust__grid">
|
||||
<div>
|
||||
<span>CIF No.</span><strong>{{ selectedCustomer.cifNumber || '—' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Client ID</span><strong>{{ selectedCustomer.clientId }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Mobile</span><strong>{{ selectedCustomer.mobileNo || '—' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Branch</span><strong>{{ selectedCustomer.officeName || '—' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Account</span><strong>{{ selectedCustomer.accountNo || 'No account' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>PAN on record</span>
|
||||
<strong>
|
||||
{{ selectedCustomer.kycPan || 'Not on record' }}
|
||||
@if (selectedCustomer.kycPan) {
|
||||
<span
|
||||
class="cx-kyc cx-kyc--sm"
|
||||
[class.cx-kyc--ok]="selectedCustomer.panVerified"
|
||||
[class.cx-kyc--warn]="!selectedCustomer.panVerified"
|
||||
>{{ selectedCustomer.panVerified ? 'Verified' : 'Not verified' }}</span
|
||||
>
|
||||
}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="cx-cust__walkin">
|
||||
<label class="cx-field">
|
||||
<span>Customer Name{{ needsPan ? ' *' : '' }}</span>
|
||||
<input
|
||||
type="text"
|
||||
[value]="walkInName"
|
||||
(input)="walkInName = $any($event.target).value"
|
||||
placeholder="Full name as on ID"
|
||||
maxlength="150"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
<label class="cx-field">
|
||||
<span>Mobile</span>
|
||||
<input
|
||||
type="tel"
|
||||
inputmode="numeric"
|
||||
[value]="walkInMobile"
|
||||
(input)="walkInMobile = $any($event.target).value"
|
||||
placeholder="10-digit mobile number"
|
||||
maxlength="15"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- PAN -->
|
||||
<div
|
||||
class="cx-pan"
|
||||
[class.cx-pan--required]="panMissing"
|
||||
[class.cx-pan--invalid]="panFormatInvalid"
|
||||
[class.cx-pan--valid]="panValue && !panFormatInvalid"
|
||||
>
|
||||
<div class="cx-pan__head">
|
||||
<mat-icon>credit_card</mat-icon>
|
||||
<span class="cx-pan__label">PAN Card</span>
|
||||
<span class="cx-pan__tag" [class.cx-pan__tag--on]="needsPan">{{ needsPan ? 'Mandatory' : 'Optional' }}</span>
|
||||
</div>
|
||||
<div class="cx-pan__input">
|
||||
<input
|
||||
type="text"
|
||||
[value]="panNumber"
|
||||
(input)="panNumber = $any($event.target).value.toUpperCase()"
|
||||
placeholder="ABCDE1234F"
|
||||
maxlength="10"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
aria-label="PAN number"
|
||||
/>
|
||||
@if (panValue && !panFormatInvalid) {
|
||||
<mat-icon class="cx-pan__state cx-pan__state--ok">check_circle</mat-icon>
|
||||
} @else if (panFormatInvalid) {
|
||||
<mat-icon class="cx-pan__state cx-pan__state--bad">error</mat-icon>
|
||||
}
|
||||
</div>
|
||||
<div class="cx-pan__hint">
|
||||
@if (panFormatInvalid) {
|
||||
PAN is 5 letters, 4 digits, 1 letter (e.g. ABCDE1234F).
|
||||
} @else if (panFromKyc) {
|
||||
<mat-icon>link</mat-icon> Fetched from the customer's KYC record.
|
||||
} @else if (panMissing) {
|
||||
Required: this exchange is above ₹50,000 (Income Tax Rule 114B / RBI).
|
||||
} @else {
|
||||
Mandatory for exchanges above ₹50,000.
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="cx-cols">
|
||||
<!-- The cashier's count of a denomination and what this exchange does to it, updated as counts are typed. -->
|
||||
<ng-template #drawerCell let-d="d">
|
||||
@if (availableFor(d) === null) {
|
||||
—
|
||||
} @else {
|
||||
In drawer <strong>{{ availableFor(d) }}</strong>
|
||||
@if (netFor(d) !== 0) {
|
||||
<span class="cx-after">
|
||||
<mat-icon>{{ netFor(d) > 0 ? 'trending_up' : 'trending_down' }}</mat-icon>
|
||||
<strong>{{ drawerAfter(d) }}</strong>
|
||||
<small>({{ netFor(d) > 0 ? '+' : '' }}{{ netFor(d) }})</small>
|
||||
</span>
|
||||
}
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<!-- RECEIVED -->
|
||||
<section class="cx-col cx-col--received">
|
||||
<header class="cx-col-head">
|
||||
@@ -78,7 +283,7 @@
|
||||
<div class="cx-subhead">Bank Notes</div>
|
||||
}
|
||||
@for (d of notes; track d.id) {
|
||||
<div class="cx-row">
|
||||
<div class="cx-row cx-row--stock">
|
||||
<span class="cx-chip">{{ d.label }}</span>
|
||||
<div class="cx-step">
|
||||
<button type="button" (click)="bump('r', d, -1)" [disabled]="countFor('r', d) === 0">−</button>
|
||||
@@ -90,6 +295,9 @@
|
||||
/>
|
||||
<button type="button" (click)="bump('r', d, 1)">+</button>
|
||||
</div>
|
||||
<span class="cx-avail" [class.cx-avail--up]="netFor(d) > 0" [class.cx-avail--down]="netFor(d) < 0">
|
||||
<ng-container *ngTemplateOutlet="drawerCell; context: { d: d }"></ng-container>
|
||||
</span>
|
||||
<span class="cx-amt" [class.cx-amt--zero]="countFor('r', d) === 0"
|
||||
>₹{{ subtotal('r', d) | number: '1.2-2' }}</span
|
||||
>
|
||||
@@ -99,7 +307,7 @@
|
||||
<div class="cx-subhead">Coins</div>
|
||||
}
|
||||
@for (d of coins; track d.id) {
|
||||
<div class="cx-row">
|
||||
<div class="cx-row cx-row--stock">
|
||||
<span class="cx-chip cx-chip--coin">{{ d.label }}</span>
|
||||
<div class="cx-step">
|
||||
<button type="button" (click)="bump('r', d, -1)" [disabled]="countFor('r', d) === 0">−</button>
|
||||
@@ -111,6 +319,9 @@
|
||||
/>
|
||||
<button type="button" (click)="bump('r', d, 1)">+</button>
|
||||
</div>
|
||||
<span class="cx-avail" [class.cx-avail--up]="netFor(d) > 0" [class.cx-avail--down]="netFor(d) < 0">
|
||||
<ng-container *ngTemplateOutlet="drawerCell; context: { d: d }"></ng-container>
|
||||
</span>
|
||||
<span class="cx-amt" [class.cx-amt--zero]="countFor('r', d) === 0"
|
||||
>₹{{ subtotal('r', d) | number: '1.2-2' }}</span
|
||||
>
|
||||
@@ -131,7 +342,7 @@
|
||||
<div class="cx-subhead">Bank Notes</div>
|
||||
}
|
||||
@for (d of notes; track d.id) {
|
||||
<div class="cx-row">
|
||||
<div class="cx-row cx-row--stock" [class.cx-row--over]="exceedsAvailable(d)">
|
||||
<span class="cx-chip">{{ d.label }}</span>
|
||||
<div class="cx-step">
|
||||
<button type="button" (click)="bump('g', d, -1)" [disabled]="countFor('g', d) === 0">−</button>
|
||||
@@ -141,8 +352,16 @@
|
||||
[value]="countFor('g', d)"
|
||||
(input)="onChange('g', d, $any($event.target).value)"
|
||||
/>
|
||||
<button type="button" (click)="bump('g', d, 1)">+</button>
|
||||
<button type="button" (click)="bump('g', d, 1)" [disabled]="!canGiveMore(d)">+</button>
|
||||
</div>
|
||||
<span
|
||||
class="cx-avail"
|
||||
[class.cx-avail--up]="netFor(d) > 0"
|
||||
[class.cx-avail--down]="netFor(d) < 0"
|
||||
[class.cx-avail--over]="exceedsAvailable(d)"
|
||||
>
|
||||
<ng-container *ngTemplateOutlet="drawerCell; context: { d: d }"></ng-container>
|
||||
</span>
|
||||
<span class="cx-amt" [class.cx-amt--zero]="countFor('g', d) === 0"
|
||||
>₹{{ subtotal('g', d) | number: '1.2-2' }}</span
|
||||
>
|
||||
@@ -152,7 +371,7 @@
|
||||
<div class="cx-subhead">Coins</div>
|
||||
}
|
||||
@for (d of coins; track d.id) {
|
||||
<div class="cx-row">
|
||||
<div class="cx-row cx-row--stock" [class.cx-row--over]="exceedsAvailable(d)">
|
||||
<span class="cx-chip cx-chip--coin">{{ d.label }}</span>
|
||||
<div class="cx-step">
|
||||
<button type="button" (click)="bump('g', d, -1)" [disabled]="countFor('g', d) === 0">−</button>
|
||||
@@ -162,8 +381,16 @@
|
||||
[value]="countFor('g', d)"
|
||||
(input)="onChange('g', d, $any($event.target).value)"
|
||||
/>
|
||||
<button type="button" (click)="bump('g', d, 1)">+</button>
|
||||
<button type="button" (click)="bump('g', d, 1)" [disabled]="!canGiveMore(d)">+</button>
|
||||
</div>
|
||||
<span
|
||||
class="cx-avail"
|
||||
[class.cx-avail--up]="netFor(d) > 0"
|
||||
[class.cx-avail--down]="netFor(d) < 0"
|
||||
[class.cx-avail--over]="exceedsAvailable(d)"
|
||||
>
|
||||
<ng-container *ngTemplateOutlet="drawerCell; context: { d: d }"></ng-container>
|
||||
</span>
|
||||
<span class="cx-amt" [class.cx-amt--zero]="countFor('g', d) === 0"
|
||||
>₹{{ subtotal('g', d) | number: '1.2-2' }}</span
|
||||
>
|
||||
@@ -173,6 +400,30 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- What this exchange does to the cashier's drawer, denomination by denomination. -->
|
||||
@if (impactRows.length) {
|
||||
<div class="cx-impact">
|
||||
<div class="cx-impact__title"><mat-icon>inventory_2</mat-icon> Drawer after this exchange</div>
|
||||
<div class="cx-impact__chips">
|
||||
@for (r of impactRows; track r.id) {
|
||||
<span
|
||||
class="cx-impact__chip"
|
||||
[class.cx-impact__chip--up]="r.net > 0"
|
||||
[class.cx-impact__chip--down]="r.net < 0"
|
||||
>
|
||||
<b>{{ r.label }}</b>
|
||||
{{ r.before }} → {{ r.after }}
|
||||
<em>({{ r.net > 0 ? '+' : '' }}{{ r.net }})</em>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="cx-impact__note">
|
||||
The branch's total for each denomination moves by the same amount; the vault is not touched (Branch Ops → Branch
|
||||
Cash Position).
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="cx-summary">
|
||||
<div class="cx-sum">
|
||||
<span>Received</span><strong>₹{{ receivedTotal | number: '1.2-2' }}</strong>
|
||||
@@ -213,9 +464,15 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (needsIdProof && !customerIdProof.trim()) {
|
||||
@if (panMissing) {
|
||||
<div class="cx-warn cx-warn--pan">
|
||||
<mat-icon>badge</mat-icon> Enter customer PAN / Aadhaar to proceed (amount ≥ ₹50,000).
|
||||
<mat-icon>badge</mat-icon> Enter the customer's PAN to proceed (amount above ₹50,000).
|
||||
</div>
|
||||
}
|
||||
@if (!customerIdentified && !loadingCustomer) {
|
||||
<div class="cx-warn cx-warn--pan">
|
||||
<mat-icon>person_search</mat-icon>
|
||||
{{ customerMode === 'existing' ? 'Search and select the customer.' : "Enter the walk-in customer's name." }}
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
@@ -73,99 +73,584 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Customer info bar ── */
|
||||
.cx-customer-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
background: #f8fafc;
|
||||
/* ── Customer: existing client or walk-in, plus PAN ── */
|
||||
.cx-cust {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
|
||||
overflow: hidden;
|
||||
|
||||
.cx-customer-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: linear-gradient(180deg, #f8fafc, #f1f5f9);
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
&__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
&__title-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
background: #e6f4ee;
|
||||
color: #0f4f3c;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
&__title-text {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
&__title-sub {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&__modes {
|
||||
display: inline-flex;
|
||||
padding: 3px;
|
||||
background: #e2e8f0;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
&__mode {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
color 0.15s ease;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
&--active,
|
||||
&--active:hover {
|
||||
background: #fff;
|
||||
color: #0f4f3c;
|
||||
box-shadow: 0 1px 3px rgb(15 23 42 / 15%);
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.7fr) minmax(260px, 1fr);
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
&__who {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Nothing picked yet, or loading */
|
||||
&__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
min-height: 150px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
background: #f8fafc;
|
||||
border: 1.5px dashed #cbd5e1;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
&__empty-icon {
|
||||
font-size: 36px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
&--active {
|
||||
color: #1e40af;
|
||||
&__empty-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
&__empty-sub {
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* A picked client */
|
||||
&__card {
|
||||
padding: 14px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
&__card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__card-id {
|
||||
flex: 1 1 180px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
&__badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
&__card-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 12px 16px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed #e2e8f0;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
}
|
||||
|
||||
/* A walk-in, identified at the counter */
|
||||
&__walkin {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
|
||||
.cx-field {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cx-field input {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
.cx-field input:focus {
|
||||
outline: none;
|
||||
border-color: #1f7a4d;
|
||||
box-shadow: 0 0 0 3px rgb(31 122 77 / 15%);
|
||||
}
|
||||
}
|
||||
|
||||
.cx-avatar {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #1f7a4d, #0f4f3c);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
color: #fff;
|
||||
|
||||
&--sm {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* The cashier at the counter, or why there is none */
|
||||
.cx-cashier {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px 20px;
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-left: 4px solid #1f7a4d;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
|
||||
|
||||
&__id {
|
||||
flex: 1 1 220px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
&__flow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 14px;
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
|
||||
span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
color: #1e40af;
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
&__stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 28px;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
}
|
||||
|
||||
&--none {
|
||||
background: #fffbeb;
|
||||
border-color: #fcd34d;
|
||||
border-left-color: #d97706;
|
||||
|
||||
mat-icon {
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.cx-cashier__label {
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
color: #92400e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cx-walk-in {
|
||||
display: flex;
|
||||
.cx-kyc {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
&--ok {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
&--warn {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
&--sm {
|
||||
margin-left: 6px;
|
||||
padding: 1px 6px;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.cx-pan-required {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fcd34d;
|
||||
.cx-chip-tag {
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
background: #eef2ff;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #3730a3;
|
||||
}
|
||||
|
||||
.cx-link-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #92400e;
|
||||
color: #1e40af;
|
||||
cursor: pointer;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
color: #d97706;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
&--danger {
|
||||
color: #b91c1c;
|
||||
|
||||
&:hover {
|
||||
background: #fef2f2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cx-field--inline {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
/* PAN: neutral until needed, amber when required and missing, red when malformed, green when valid */
|
||||
.cx-pan {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-self: start;
|
||||
gap: 8px;
|
||||
padding: 14px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
|
||||
span {
|
||||
white-space: nowrap;
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #475569;
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
width: 180px;
|
||||
&__label {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
&__tag {
|
||||
margin-left: auto;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: #e2e8f0;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
|
||||
&--on {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
}
|
||||
|
||||
&__input {
|
||||
position: relative;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 40px 0 12px;
|
||||
background: #fff;
|
||||
border: 1.5px solid #cbd5e1;
|
||||
border-radius: 10px;
|
||||
font-family: ui-monospace, sfmono-regular, menlo, consolas, monospace;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: #0f172a;
|
||||
|
||||
&::placeholder {
|
||||
font-weight: 500;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #1f7a4d;
|
||||
box-shadow: 0 0 0 3px rgb(31 122 77 / 15%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__state {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 10px;
|
||||
transform: translateY(-50%);
|
||||
font-size: 22px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
|
||||
&--ok {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
&--bad {
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
&__hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
&--required {
|
||||
background: #fffbeb;
|
||||
border-color: #fcd34d;
|
||||
|
||||
.cx-pan__input input {
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
.cx-pan__hint {
|
||||
font-weight: 600;
|
||||
color: #92400e;
|
||||
}
|
||||
}
|
||||
|
||||
&--invalid {
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
|
||||
.cx-pan__input input {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.cx-pan__hint {
|
||||
font-weight: 600;
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
&--valid {
|
||||
background: #f0fdf4;
|
||||
border-color: #bbf7d0;
|
||||
|
||||
.cx-pan__input input {
|
||||
border-color: #22c55e;
|
||||
}
|
||||
|
||||
.cx-pan__hint {
|
||||
color: #166534;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cx-field--pan input {
|
||||
width: 160px;
|
||||
border-color: #fcd34d;
|
||||
.cx-spin {
|
||||
animation: cx-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #d97706;
|
||||
@keyframes cx-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 900px) {
|
||||
.cx-cust__body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.cx-cust__walkin {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,6 +830,129 @@
|
||||
border-bottom: 1px dotted #eef2f7;
|
||||
}
|
||||
|
||||
/* Both sides: the cashier's count of each denomination sits between the counter and the amount. */
|
||||
.cx-row--stock {
|
||||
grid-template-columns: 64px 120px 1fr 96px;
|
||||
}
|
||||
|
||||
.cx-row--over {
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.cx-avail {
|
||||
padding-left: 6px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
|
||||
strong {
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
/* the count after this exchange: green when it goes up, red when it goes down */
|
||||
.cx-after {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
margin-left: 6px;
|
||||
font-weight: 600;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
&--up .cx-after,
|
||||
&--up .cx-after strong {
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
&--down .cx-after,
|
||||
&--down .cx-after strong {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
&--over,
|
||||
&--over strong,
|
||||
&--over .cx-after,
|
||||
&--over .cx-after strong {
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
/* The drawer after the whole exchange, one chip per denomination it changes */
|
||||
.cx-impact {
|
||||
padding: 12px 14px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
|
||||
&__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: #475569;
|
||||
}
|
||||
}
|
||||
|
||||
&__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 8px;
|
||||
background: #f1f5f9;
|
||||
font-size: 12px;
|
||||
color: #334155;
|
||||
|
||||
b {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
em {
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&--up {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
&--down {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
}
|
||||
|
||||
&__note {
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.cx-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -11,7 +11,15 @@ import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { CashierService } from '../cashier.service';
|
||||
import { ClientsService } from 'app/clients/clients.service';
|
||||
import {
|
||||
CustomerSearchDialogComponent,
|
||||
CustomerSearchResult
|
||||
} from 'app/shared/customer-search-dialog/customer-search-dialog.component';
|
||||
import { businessToday } from 'app/core/utils/business-today';
|
||||
|
||||
interface Denom {
|
||||
@@ -21,6 +29,47 @@ interface Denom {
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** An existing client picked through Customer Search, with what the counter needs to identify them. */
|
||||
interface SelectedCustomer {
|
||||
clientId: number;
|
||||
displayName: string;
|
||||
cifNumber: string | null;
|
||||
mobileNo: string | null;
|
||||
officeName: string | null;
|
||||
accountNo: string | null;
|
||||
accountType: string | null;
|
||||
/** PAN on the client's KYC record, if any. */
|
||||
kycPan: string | null;
|
||||
panVerified: boolean;
|
||||
kycStatus: string;
|
||||
}
|
||||
|
||||
/** The cashier at the counter: the one who takes the customer's notes and hands over the change. */
|
||||
interface CashierInfo {
|
||||
cashierId: number;
|
||||
name: string;
|
||||
tellerName: string | null;
|
||||
officeName: string | null;
|
||||
/** Cash the vault allocated to them for the day, once known. */
|
||||
allocatedAmount: number | null;
|
||||
/** Cash they hold now. */
|
||||
totalAmount: number;
|
||||
}
|
||||
|
||||
/** Same rule as ClientKycApiConstants.PAN_REGEX on the server: 5 letters, 4 digits, 1 letter. */
|
||||
const PAN_PATTERN = /^[A-Z]{5}[0-9]{4}[A-Z]$/;
|
||||
|
||||
/** Income Tax Rule 114B / RBI: a cash exchange above this needs the customer's PAN. */
|
||||
const PAN_MANDATORY_ABOVE = 50000;
|
||||
|
||||
const KYC_STATUS: Record<number, string> = {
|
||||
100: 'Pending',
|
||||
200: 'In Progress',
|
||||
300: 'Verified',
|
||||
400: 'Rejected',
|
||||
500: 'Expired'
|
||||
};
|
||||
|
||||
/** Cashier cash-exchange entry: customer gives some denominations and takes equal-value change (net 0). */
|
||||
@Component({
|
||||
selector: 'mifosx-cash-exchange-entry',
|
||||
@@ -31,11 +80,14 @@ interface Denom {
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatIconModule,
|
||||
MatSnackBarModule
|
||||
MatSnackBarModule,
|
||||
MatDialogModule
|
||||
]
|
||||
})
|
||||
export class CashExchangeEntryComponent implements OnInit {
|
||||
private service = inject(CashierService);
|
||||
private clientsService = inject(ClientsService);
|
||||
private dialog = inject(MatDialog);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
notes: Denom[] = [];
|
||||
@@ -47,11 +99,17 @@ export class CashExchangeEntryComponent implements OnInit {
|
||||
saving = false;
|
||||
|
||||
// ── Customer info ──
|
||||
isAccountHolder = false;
|
||||
accountNo = '';
|
||||
customerIdProof = '';
|
||||
/** An existing client found through Customer Search, or a walk-in identified at the counter. */
|
||||
customerMode: 'existing' | 'walkin' = 'walkin';
|
||||
selectedCustomer: SelectedCustomer | null = null;
|
||||
loadingCustomer = false;
|
||||
walkInName = '';
|
||||
walkInMobile = '';
|
||||
panNumber = '';
|
||||
|
||||
// ── Cashier position (for denomination overdraft check) ──
|
||||
// ── Cashier at the counter, and their position (for the denomination overdraft check) ──
|
||||
/** The cashier linked to the logged-in user; null when the login is not a cashier's. */
|
||||
cashier: CashierInfo | null = null;
|
||||
private availableMap = new Map<number, number>();
|
||||
positionLoaded = false;
|
||||
|
||||
@@ -88,7 +146,19 @@ export class CashExchangeEntryComponent implements OnInit {
|
||||
((res?.denominations || []) as any[]).forEach((d: any) => {
|
||||
if (d.denominationId) this.availableMap.set(Number(d.denominationId), Number(d.count ?? 0));
|
||||
});
|
||||
const cashierId = res?.cashierId ? Number(res.cashierId) : null;
|
||||
this.cashier = cashierId
|
||||
? {
|
||||
cashierId,
|
||||
name: res.cashierName || '',
|
||||
tellerName: null,
|
||||
officeName: null,
|
||||
allocatedAmount: null,
|
||||
totalAmount: Number(res.totalAmount ?? 0)
|
||||
}
|
||||
: null;
|
||||
this.positionLoaded = true;
|
||||
if (cashierId) this.loadCashierDetails(cashierId);
|
||||
},
|
||||
error: () => {
|
||||
this.positionLoaded = true;
|
||||
@@ -96,6 +166,31 @@ export class CashExchangeEntryComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
/** The counter, branch and today's allocation; the card still shows name and cash in hand without them. */
|
||||
private loadCashierDetails(cashierId: number): void {
|
||||
this.service
|
||||
.getInterCashierCashierSummary(cashierId, this.exchangeDate)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((r: any) => {
|
||||
if (!r || this.cashier?.cashierId !== cashierId) return;
|
||||
this.cashier = {
|
||||
...this.cashier,
|
||||
tellerName: r.tellerName || null,
|
||||
officeName: r.officeName || null,
|
||||
allocatedAmount: Number(r.allocatedAmount ?? 0)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Up to two letters for the cashier's avatar. */
|
||||
get cashierInitials(): string {
|
||||
const words = (this.cashier?.name || '').trim().split(/\s+/).filter(Boolean);
|
||||
if (words.length === 0) return '?';
|
||||
const first = words[0].charAt(0);
|
||||
const last = words.length > 1 ? words[words.length - 1].charAt(0) : '';
|
||||
return (first + last).toUpperCase();
|
||||
}
|
||||
|
||||
countFor(side: 'r' | 'g', d: Denom): number {
|
||||
return (side === 'r' ? this.receivedCounts : this.givenCounts)[d.id] || 0;
|
||||
}
|
||||
@@ -129,14 +224,102 @@ export class CashExchangeEntryComponent implements OnInit {
|
||||
return this.receivedTotal > 0 && this.receivedTotal === this.givenTotal;
|
||||
}
|
||||
|
||||
/** Above ₹50,000 PAN/ID is mandatory per RBI guideline */
|
||||
get needsIdProof(): boolean {
|
||||
return this.receivedTotal >= 50000;
|
||||
/** Above ₹50,000 the customer's PAN is mandatory (Income Tax Rule 114B, followed under RBI guidelines). */
|
||||
get needsPan(): boolean {
|
||||
return this.receivedTotal > PAN_MANDATORY_ABOVE;
|
||||
}
|
||||
|
||||
/** Up to two letters for the avatar: first and last word of the customer's name. */
|
||||
get customerInitials(): string {
|
||||
const words = (this.selectedCustomer?.displayName || '').trim().split(/\s+/).filter(Boolean);
|
||||
if (words.length === 0) return '?';
|
||||
const first = words[0].charAt(0);
|
||||
const last = words.length > 1 ? words[words.length - 1].charAt(0) : '';
|
||||
return (first + last).toUpperCase();
|
||||
}
|
||||
|
||||
get panValue(): string {
|
||||
return this.panNumber.trim().toUpperCase();
|
||||
}
|
||||
|
||||
/** A PAN that is entered must be well-formed, required or not. */
|
||||
get panFormatInvalid(): boolean {
|
||||
return !!this.panValue && !PAN_PATTERN.test(this.panValue);
|
||||
}
|
||||
|
||||
get panMissing(): boolean {
|
||||
return this.needsPan && !this.panValue;
|
||||
}
|
||||
|
||||
/** The PAN comes from the client's KYC record, so the cashier confirms it rather than types it. */
|
||||
get panFromKyc(): boolean {
|
||||
return !!this.selectedCustomer?.kycPan && this.panValue === this.selectedCustomer.kycPan.toUpperCase();
|
||||
}
|
||||
|
||||
/** Who the customer is has to be known: a picked client, or a walk-in's name once PAN is required. */
|
||||
get customerIdentified(): boolean {
|
||||
if (this.customerMode === 'existing') return !!this.selectedCustomer;
|
||||
return !this.needsPan || this.walkInName.trim().length > 0;
|
||||
}
|
||||
|
||||
/** A login with no cashier behind it has no drawer to take notes into or give change from. */
|
||||
get noCashier(): boolean {
|
||||
return this.positionLoaded && !this.cashier;
|
||||
}
|
||||
|
||||
/** How many of the denomination the cashier holds, or null while that is not known (loading, or no cashier). */
|
||||
availableFor(d: Denom): number | null {
|
||||
if (!this.positionLoaded || !this.cashier) return null;
|
||||
return this.availableMap.get(d.id) ?? 0;
|
||||
}
|
||||
|
||||
/** The cashier is giving more of this denomination than they hold. */
|
||||
exceedsAvailable(d: Denom): boolean {
|
||||
const available = this.availableFor(d);
|
||||
return available !== null && this.countFor('g', d) > available;
|
||||
}
|
||||
|
||||
/** The + on the given side stops at what the cashier holds; a larger typed number is still caught and shown. */
|
||||
canGiveMore(d: Denom): boolean {
|
||||
const available = this.availableFor(d);
|
||||
return available === null || this.countFor('g', d) < available;
|
||||
}
|
||||
|
||||
/** What this exchange does to the cashier's count of the denomination: notes taken in less notes given out. */
|
||||
netFor(d: Denom): number {
|
||||
return this.countFor('r', d) - this.countFor('g', d);
|
||||
}
|
||||
|
||||
/** The cashier's count of the denomination once this exchange is recorded, or null while it is not known. */
|
||||
drawerAfter(d: Denom): number | null {
|
||||
const available = this.availableFor(d);
|
||||
return available === null ? null : available + this.netFor(d);
|
||||
}
|
||||
|
||||
/**
|
||||
* The denominations this exchange changes, with the drawer's count before and after, so the effect shows
|
||||
* while the counts are being typed. The branch's total for each denomination moves by the same amount, as
|
||||
* the customer's notes go into the drawer and the change comes out of it; the vault is not involved.
|
||||
*/
|
||||
get impactRows(): Array<{ id: number; label: string; kind: string; net: number; before: number; after: number }> {
|
||||
if (!this.positionLoaded || !this.cashier) return [];
|
||||
return this.denoms
|
||||
.filter((d) => this.netFor(d) !== 0)
|
||||
.map((d) => ({
|
||||
id: d.id,
|
||||
label: d.label,
|
||||
kind: d.kind,
|
||||
net: this.netFor(d),
|
||||
before: this.availableFor(d) ?? 0,
|
||||
after: this.drawerAfter(d) ?? 0
|
||||
}));
|
||||
}
|
||||
|
||||
/** Denominations where cashier is giving more than currently available. */
|
||||
get givenOverdraftItems(): Array<{ label: string; available: number; requested: number }> {
|
||||
if (!this.positionLoaded) return [];
|
||||
// Without a cashier every denomination would read as "0 available"; the missing cashier is the
|
||||
// thing to tell the user, not a stock shortage.
|
||||
if (!this.positionLoaded || !this.cashier) return [];
|
||||
return this.denoms
|
||||
.map((d) => ({ label: d.label, available: this.availableMap.get(d.id) ?? 0, requested: this.countFor('g', d) }))
|
||||
.filter((item) => item.requested > 0 && item.requested > item.available);
|
||||
@@ -147,21 +330,77 @@ export class CashExchangeEntryComponent implements OnInit {
|
||||
}
|
||||
|
||||
get valid(): boolean {
|
||||
return this.matches && (!this.needsIdProof || this.customerIdProof.trim().length > 0) && !this.hasGivenOverdraft;
|
||||
return (
|
||||
this.matches &&
|
||||
!!this.cashier &&
|
||||
!this.hasGivenOverdraft &&
|
||||
!this.panFormatInvalid &&
|
||||
!this.panMissing &&
|
||||
this.customerIdentified &&
|
||||
!this.loadingCustomer
|
||||
);
|
||||
}
|
||||
|
||||
toggleAccountHolder(): void {
|
||||
this.isAccountHolder = !this.isAccountHolder;
|
||||
if (!this.isAccountHolder) this.accountNo = '';
|
||||
setCustomerMode(mode: 'existing' | 'walkin'): void {
|
||||
if (this.customerMode === mode) return;
|
||||
this.customerMode = mode;
|
||||
this.selectedCustomer = null;
|
||||
this.walkInName = '';
|
||||
this.walkInMobile = '';
|
||||
this.panNumber = '';
|
||||
}
|
||||
|
||||
/** Pick an existing client through Customer Search, then load their details and KYC (PAN). */
|
||||
openCustomerSearch(): void {
|
||||
this.dialog
|
||||
// An existing customer without an account is still a known customer, so the account is not required.
|
||||
.open(CustomerSearchDialogComponent, { width: '960px', maxWidth: '95vw', data: { requireAccount: false } })
|
||||
.afterClosed()
|
||||
.subscribe((result: CustomerSearchResult | undefined) => {
|
||||
if (!result?.clientId) return;
|
||||
this.loadCustomer(result);
|
||||
});
|
||||
}
|
||||
|
||||
private loadCustomer(result: CustomerSearchResult): void {
|
||||
this.loadingCustomer = true;
|
||||
const id = String(result.clientId);
|
||||
forkJoin({
|
||||
client: this.clientsService.getClientData(id, true).pipe(catchError(() => of(null))),
|
||||
kyc: this.clientsService.getClientKyc(id, true).pipe(catchError(() => of(null)))
|
||||
}).subscribe(({ client, kyc }: { client: any; kyc: any }) => {
|
||||
const kycPan: string | null = kyc?.panNumber || result.panNumber || null;
|
||||
this.selectedCustomer = {
|
||||
clientId: result.clientId,
|
||||
displayName: client?.displayName || result.displayName,
|
||||
cifNumber: client?.cifNumber || null,
|
||||
mobileNo: client?.mobileNo || result.mobileNo || null,
|
||||
officeName: client?.officeName || result.officeName || null,
|
||||
accountNo: result.accountNo || null,
|
||||
accountType: result.accountType || null,
|
||||
kycPan,
|
||||
panVerified: !!kyc?.panVerified,
|
||||
kycStatus: KYC_STATUS[Number(kyc?.kycStatus?.id ?? kyc?.kycStatus)] || 'Not on record'
|
||||
};
|
||||
this.panNumber = kycPan ? kycPan.toUpperCase() : '';
|
||||
this.loadingCustomer = false;
|
||||
});
|
||||
}
|
||||
|
||||
clearCustomer(): void {
|
||||
this.selectedCustomer = null;
|
||||
this.panNumber = '';
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.receivedCounts = {};
|
||||
this.givenCounts = {};
|
||||
this.note = '';
|
||||
this.accountNo = '';
|
||||
this.customerIdProof = '';
|
||||
this.isAccountHolder = false;
|
||||
this.customerMode = 'walkin';
|
||||
this.selectedCustomer = null;
|
||||
this.walkInName = '';
|
||||
this.walkInMobile = '';
|
||||
this.panNumber = '';
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
@@ -171,11 +410,17 @@ export class CashExchangeEntryComponent implements OnInit {
|
||||
this.denoms
|
||||
.filter((d) => this.countFor(side, d) > 0)
|
||||
.map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(side, d) }));
|
||||
const customer = this.customerMode === 'existing' ? this.selectedCustomer : null;
|
||||
const pan = this.panValue || null;
|
||||
const payload: any = {
|
||||
exchangeDate: this.exchangeDate,
|
||||
note: this.note || null,
|
||||
customerIdProof: this.customerIdProof.trim() || null,
|
||||
accountNo: this.isAccountHolder && this.accountNo.trim() ? this.accountNo.trim() : null,
|
||||
clientId: customer?.clientId ?? null,
|
||||
accountNo: customer?.accountNo ?? null,
|
||||
customerName: customer ? customer.displayName : this.walkInName.trim() || null,
|
||||
customerMobile: customer ? customer.mobileNo : this.walkInMobile.trim() || null,
|
||||
panNumber: pan,
|
||||
customerIdProof: pan,
|
||||
received: build('r'),
|
||||
given: build('g')
|
||||
};
|
||||
|
||||
@@ -11,25 +11,11 @@
|
||||
<div>
|
||||
<div class="pg-eyebrow">CASHIER · HISTORY</div>
|
||||
<h1 class="pg-title">Cash Movement History</h1>
|
||||
<p class="pg-sub">All transfers, settlements, and inter-cashier movements. Reverse any active transaction.</p>
|
||||
<p class="pg-sub">All transfers, settlements, inter-cashier movements and cash exchanges.</p>
|
||||
</div>
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="load()"><mat-icon>refresh</mat-icon> Refresh</button>
|
||||
</header>
|
||||
|
||||
<!-- Reversal confirm panel -->
|
||||
@if (reversingId !== null) {
|
||||
<div class="reverse-panel">
|
||||
<mat-icon class="reverse-icon">warning</mat-icon>
|
||||
<span
|
||||
>Reverse <strong>#{{ reversingId }}</strong
|
||||
>? This cannot be undone.</span
|
||||
>
|
||||
<input class="reverse-reason" type="text" [(ngModel)]="reverseReason" placeholder="Reason (optional)" />
|
||||
<button type="button" class="pg-btn pg-btn--danger" (click)="confirmReverse()">Confirm Reversal</button>
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="cancelReverse()">Cancel</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (loading) {
|
||||
<div class="pg-empty">Loading…</div>
|
||||
}
|
||||
@@ -134,11 +120,6 @@
|
||||
>
|
||||
<mat-icon>visibility</mat-icon>
|
||||
</button>
|
||||
@if (!r.isReversed) {
|
||||
<button type="button" class="pg-btn pg-btn--ghost btn-sm" title="Reverse" (click)="startReverse(r)">
|
||||
<mat-icon>undo</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -187,6 +168,12 @@
|
||||
<span class="detail-meta__label">Date</span>
|
||||
<span class="detail-meta__val">{{ detail.row.date }}</span>
|
||||
</div>
|
||||
@if (detail.row.type === 'exchange') {
|
||||
<div class="detail-meta__item">
|
||||
<span class="detail-meta__label">Customer PAN</span>
|
||||
<span class="detail-meta__val">{{ detail.row.panNumber || '—' }}</span>
|
||||
</div>
|
||||
}
|
||||
<div class="detail-meta__item">
|
||||
<span class="detail-meta__label">Total Amount</span>
|
||||
<span class="detail-meta__val detail-meta__val--amount">₹{{ detail.row.amount | number: '1.2-2' }}</span>
|
||||
|
||||
@@ -47,38 +47,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.reverse-panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fcd34d;
|
||||
border-radius: 10px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 13px;
|
||||
color: #92400e;
|
||||
|
||||
.reverse-icon {
|
||||
color: #f59e0b;
|
||||
font-size: 20px;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.reverse-reason {
|
||||
flex: 1 1 200px;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #fcd34d;
|
||||
border-radius: 7px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
background: #fff;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Action buttons cell ── */
|
||||
.action-btns {
|
||||
display: flex;
|
||||
|
||||
@@ -8,9 +8,7 @@
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
@@ -31,6 +29,8 @@ interface TxnRow {
|
||||
reversedBy: number | null;
|
||||
reversedAt: string | null;
|
||||
reverseReason: string | null;
|
||||
/** Customer PAN recorded on a cash exchange. */
|
||||
panNumber?: string | null;
|
||||
type: 'transfer' | 'settlement' | 'inter' | 'exchange';
|
||||
}
|
||||
|
||||
@@ -52,7 +52,10 @@ interface DetailView {
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
/** Unified history of all cash movements with reversal and detail view support. */
|
||||
/**
|
||||
* Unified history of all cash movements with a detail view. Read-only: cash movements cannot be reversed
|
||||
* from here (an earlier reversal still shows as Reversed, with who, when and why).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-cash-history',
|
||||
templateUrl: './cash-history.component.html',
|
||||
@@ -63,16 +66,13 @@ interface DetailView {
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatIconModule,
|
||||
MatSnackBarModule,
|
||||
MatTabsModule,
|
||||
MatPaginatorModule
|
||||
]
|
||||
})
|
||||
export class CashHistoryComponent implements OnInit {
|
||||
private service = inject(CashierService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
transfers: TxnRow[] = [];
|
||||
settlements: TxnRow[] = [];
|
||||
@@ -99,10 +99,6 @@ export class CashHistoryComponent implements OnInit {
|
||||
|
||||
private denomKindMap = new Map<number, 'note' | 'coin'>();
|
||||
|
||||
reversingId: number | null = null;
|
||||
reversingType: string | null = null;
|
||||
reverseReason = '';
|
||||
|
||||
detail: DetailView | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -212,7 +208,8 @@ export class CashHistoryComponent implements OnInit {
|
||||
this.exchanges = (exchangeItems as any[]).map((e) => ({
|
||||
id: e.id,
|
||||
date: e.exchangeDate,
|
||||
fromParty: e.accountNo ? `Account ${e.accountNo}` : 'Walk-in Customer',
|
||||
fromParty: this.exchangeParty(e),
|
||||
panNumber: e.panNumber ?? null,
|
||||
toParty: e.performedByUsername || '—',
|
||||
amount: e.exchangeAmount,
|
||||
note: e.note,
|
||||
@@ -310,7 +307,8 @@ export class CashHistoryComponent implements OnInit {
|
||||
this.exchanges = (res.pageItems ?? []).map((e: any) => ({
|
||||
id: e.id,
|
||||
date: e.exchangeDate,
|
||||
fromParty: e.accountNo ? `Account ${e.accountNo}` : 'Walk-in Customer',
|
||||
fromParty: this.exchangeParty(e),
|
||||
panNumber: e.panNumber ?? null,
|
||||
toParty: e.performedByUsername || '—',
|
||||
amount: e.exchangeAmount,
|
||||
note: e.note,
|
||||
@@ -392,39 +390,12 @@ export class CashHistoryComponent implements OnInit {
|
||||
this.detail = null;
|
||||
}
|
||||
|
||||
startReverse(row: TxnRow): void {
|
||||
this.reversingId = row.id;
|
||||
this.reversingType = row.type;
|
||||
this.reverseReason = '';
|
||||
}
|
||||
|
||||
cancelReverse(): void {
|
||||
this.reversingId = null;
|
||||
this.reversingType = null;
|
||||
this.reverseReason = '';
|
||||
}
|
||||
|
||||
confirmReverse(): void {
|
||||
if (!this.reversingId || !this.reversingType) return;
|
||||
const id = this.reversingId;
|
||||
const type = this.reversingType;
|
||||
const reason = this.reverseReason;
|
||||
this.cancelReverse();
|
||||
|
||||
let obs$;
|
||||
if (type === 'transfer') obs$ = this.service.reverseCashTransfer(id, reason);
|
||||
else if (type === 'settlement') obs$ = this.service.reverseCashSettlement(id, reason);
|
||||
else if (type === 'exchange') obs$ = this.service.reverseCashExchange(id, reason);
|
||||
else obs$ = this.service.reverseInterCashierTransfer(id, reason);
|
||||
|
||||
obs$.pipe(catchError(() => of(null))).subscribe((res: any) => {
|
||||
if (res?.reversed) {
|
||||
this.snackBar.open('Transaction reversed successfully', 'Close', { duration: 3000 });
|
||||
this.load();
|
||||
} else {
|
||||
this.snackBar.open('Reversal failed', 'Close', { duration: 3000 });
|
||||
}
|
||||
});
|
||||
/** Who the exchange was with: the customer's name (and account, if one), else the account, else a walk-in. */
|
||||
private exchangeParty(e: any): string {
|
||||
if (e.customerName) {
|
||||
return e.accountNo ? `${e.customerName} (A/C ${e.accountNo})` : e.customerName;
|
||||
}
|
||||
return e.accountNo ? `Account ${e.accountNo}` : 'Walk-in Customer';
|
||||
}
|
||||
|
||||
typeLabel(type: string): string {
|
||||
|
||||
@@ -23,6 +23,7 @@ import { DayBookComponent } from './day-book/day-book.component';
|
||||
import { VoucherReportComponent } from './voucher-report/voucher-report.component';
|
||||
import { BranchDayComponent } from './branch-day/branch-day.component';
|
||||
import { VaultPositionComponent } from './vault-position/vault-position.component';
|
||||
import { BranchCashPositionComponent } from './branch-cash-position/branch-cash-position.component';
|
||||
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';
|
||||
@@ -225,6 +226,13 @@ const routes: Routes = [
|
||||
component: VaultPositionComponent,
|
||||
data: { title: 'Vault Position', breadcrumb: 'Vault Position' }
|
||||
},
|
||||
{
|
||||
// What the whole branch holds per denomination: the vault plus every cashier's drawer, so an
|
||||
// exchange (which leaves the vault alone) is visible in the bank's total.
|
||||
path: 'branch-position',
|
||||
component: BranchCashPositionComponent,
|
||||
data: { title: 'Branch Cash Position', breadcrumb: 'Branch Cash Position' }
|
||||
},
|
||||
{
|
||||
// The same day book the clerk and cashier read, reached from where a principal officer
|
||||
// signing the day off actually looks. It carries the Officer Verification Sheet — each
|
||||
|
||||
@@ -32,9 +32,6 @@ export class CashierService {
|
||||
getCashExchange(id: number): Observable<any> {
|
||||
return this.http.get(`/cashexchange/${id}`);
|
||||
}
|
||||
reverseCashExchange(id: number, reason: string): Observable<any> {
|
||||
return this.http.post(`/cashexchange/${id}/reverse`, { reason });
|
||||
}
|
||||
|
||||
// ── Tellers / Cashiers ──
|
||||
getTellers(officeId?: number): Observable<any[]> {
|
||||
@@ -60,9 +57,6 @@ export class CashierService {
|
||||
getCashTransferById(id: number): Observable<any> {
|
||||
return this.http.get(`/cashtransfer/${id}`);
|
||||
}
|
||||
reverseCashTransfer(id: number, reason: string): Observable<any> {
|
||||
return this.http.post(`/cashtransfer/${id}/reverse`, { reason });
|
||||
}
|
||||
|
||||
// ── Cash Settlement ──
|
||||
createCashSettlement(data: any): Observable<any> {
|
||||
@@ -75,9 +69,6 @@ export class CashierService {
|
||||
getCashSettlementById(id: number): Observable<any> {
|
||||
return this.http.get(`/cashsettlement/${id}`);
|
||||
}
|
||||
reverseCashSettlement(id: number, reason: string): Observable<any> {
|
||||
return this.http.post(`/cashsettlement/${id}/reverse`, { reason });
|
||||
}
|
||||
|
||||
// ── Inter-cashier Transfer ──
|
||||
createInterCashierTransfer(data: any): Observable<any> {
|
||||
@@ -90,8 +81,10 @@ export class CashierService {
|
||||
getInterCashierById(id: number): Observable<any> {
|
||||
return this.http.get(`/cashintercashier/${id}`);
|
||||
}
|
||||
reverseInterCashierTransfer(id: number, reason: string): Observable<any> {
|
||||
return this.http.post(`/cashintercashier/${id}/reverse`, { reason });
|
||||
/** One cashier's details, today's vault allocation and denomination-wise cash, for either side of a transfer. */
|
||||
getInterCashierCashierSummary(cashierId: number, date: string): Observable<any> {
|
||||
const params = new HttpParams().set('cashierId', cashierId).set('date', date);
|
||||
return this.http.get<any>('/cashintercashier/cashier-summary', { params });
|
||||
}
|
||||
|
||||
// ── Reconciliation ──
|
||||
@@ -318,6 +311,16 @@ export class CashierService {
|
||||
if (asOfDate) params = params.set('asOfDate', asOfDate);
|
||||
return this.http.get('/cashreports/vault-position', { params });
|
||||
}
|
||||
/**
|
||||
* Denomination-wise cash the whole branch holds: its vault(s) plus every cashier's drawer, with the day's
|
||||
* exchange movement beside it. Not for a cashier -- the server refuses it.
|
||||
*/
|
||||
branchCashPosition(asOfDate?: string, officeId?: number): Observable<any> {
|
||||
let params = new HttpParams();
|
||||
if (asOfDate) params = params.set('asOfDate', asOfDate);
|
||||
if (officeId) params = params.set('officeId', String(officeId));
|
||||
return this.http.get('/cashreports/branch-position', { params });
|
||||
}
|
||||
excessTransactionReport(threshold: number, fromDate?: string, toDate?: string): Observable<any[]> {
|
||||
let params = new HttpParams().set('threshold', String(threshold));
|
||||
if (fromDate) params = params.set('fromDate', fromDate);
|
||||
|
||||
@@ -11,41 +11,29 @@
|
||||
<div>
|
||||
<div class="pg-eyebrow">CASHIER · INTER-CASHIER TRANSFER</div>
|
||||
<h1 class="pg-title">Inter-Cashier Transfer</h1>
|
||||
<p class="pg-sub">Move denomination-wise cash between two cashiers within the same teller.</p>
|
||||
<p class="pg-sub">Hand over denomination-wise cash from your drawer to another cashier at your counter.</p>
|
||||
</div>
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="clear()"><mat-icon>undo</mat-icon> Clear</button>
|
||||
</header>
|
||||
|
||||
<div class="pg-toolbar pg-toolbar--mat">
|
||||
@if (tellers.length === 1) {
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Teller</mat-label>
|
||||
<input matInput type="text" [value]="tellers[0].name" readonly />
|
||||
</mat-form-field>
|
||||
} @else {
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Teller</mat-label>
|
||||
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
|
||||
@for (t of tellers; track t.id) {
|
||||
<mat-option [value]="t.id">{{ t.name }}</mat-option>
|
||||
}
|
||||
@if (tellers.length === 0) {
|
||||
<mat-option [value]="null" disabled>No tellers configured</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (ownCashierMissing) {
|
||||
<div class="ict-alert">
|
||||
<mat-icon>person_off</mat-icon>
|
||||
Your login is not linked to a cashier. An inter-cashier transfer is made by the cashier handing the cash over, so
|
||||
log in as that cashier.
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="pg-toolbar pg-toolbar--mat">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>From (Cashier)</mat-label>
|
||||
<mat-select [value]="fromCashierId" (selectionChange)="onFromSelect($event.value)" [disabled]="!selectedTellerId">
|
||||
@for (c of availableFrom; track c.id) {
|
||||
<mat-option [value]="c.id">{{ c.label }}</mat-option>
|
||||
}
|
||||
@if (selectedTellerId && allCashiers.length === 0 && !loadingCashiers) {
|
||||
<mat-option [value]="null" disabled>No cashiers</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
<mat-label>Teller</mat-label>
|
||||
<input matInput type="text" [value]="tellerName || '—'" readonly />
|
||||
</mat-form-field>
|
||||
|
||||
<!-- The sender is always the cashier who is logged in; nothing to pick. -->
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>From (You)</mat-label>
|
||||
<input matInput type="text" [value]="fromParty || '—'" readonly />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
@@ -54,7 +42,7 @@
|
||||
@for (c of availableTo; track c.id) {
|
||||
<mat-option [value]="c.id">{{ c.label }}</mat-option>
|
||||
}
|
||||
@if (fromCashierId && availableTo.length === 0) {
|
||||
@if (fromCashierId && !loadingCashiers && availableTo.length === 0) {
|
||||
<mat-option [value]="null" disabled>No other cashiers</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
@@ -62,7 +50,7 @@
|
||||
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Date</mat-label>
|
||||
<input matInput type="date" [value]="transferDate" (change)="transferDate = $any($event.target).value" />
|
||||
<input matInput type="date" [value]="transferDate" (change)="onDateChange($any($event.target).value)" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="pg-note-field">
|
||||
@@ -71,26 +59,102 @@
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!-- Both sides of the transfer: who they are, whether the vault has allocated them cash today,
|
||||
and what they hold now. -->
|
||||
@if (fromCashierId || toCashierId) {
|
||||
<div class="ict-parties">
|
||||
<ng-container
|
||||
*ngTemplateOutlet="
|
||||
partyCard;
|
||||
context: { title: 'From (Sending Cashier)', s: fromSummary, loading: loadingFrom, picked: !!fromCashierId }
|
||||
"
|
||||
></ng-container>
|
||||
<mat-icon class="ict-parties__arrow">arrow_forward</mat-icon>
|
||||
<ng-container
|
||||
*ngTemplateOutlet="
|
||||
partyCard;
|
||||
context: { title: 'To (Receiving Cashier)', s: toSummary, loading: loadingTo, picked: !!toCashierId }
|
||||
"
|
||||
></ng-container>
|
||||
</div>
|
||||
}
|
||||
|
||||
<ng-template #partyCard let-title="title" let-s="s" let-loading="loading" let-picked="picked">
|
||||
<div class="ict-party" [class.ict-party--blocked]="s && !s.cashAllocated">
|
||||
<div class="ict-party__title">{{ title }}</div>
|
||||
@if (!picked) {
|
||||
<div class="ict-party__empty">Select a cashier</div>
|
||||
} @else if (loading) {
|
||||
<div class="ict-party__empty">Loading…</div>
|
||||
} @else if (!s) {
|
||||
<div class="ict-party__empty">Could not load this cashier's details.</div>
|
||||
} @else {
|
||||
<div class="ict-party__name">{{ s.cashierName || '—' }}</div>
|
||||
<div class="ict-party__meta">
|
||||
<span><mat-icon>point_of_sale</mat-icon> {{ s.tellerName || '—' }}</span>
|
||||
<span><mat-icon>account_balance</mat-icon> {{ s.officeName || '—' }}</span>
|
||||
</div>
|
||||
<div class="ict-party__figures">
|
||||
<div>
|
||||
<div class="ict-party__label">Allocated today</div>
|
||||
<div class="ict-party__value">₹{{ s.allocatedAmount | number: '1.2-2' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="ict-party__label">Cash in hand</div>
|
||||
<div class="ict-party__value">₹{{ s.totalAmount | number: '1.2-2' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@if (!s.cashAllocated) {
|
||||
<div class="ict-party__warn">
|
||||
<mat-icon>block</mat-icon> No cash allocated from the vault for this date — inter-cashier transfer is not
|
||||
allowed.
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
@if (fromSummary && toSummary && !bothAllocated) {
|
||||
<div class="ict-alert">
|
||||
<mat-icon>block</mat-icon>
|
||||
Both cashiers must first be allocated cash from the vault (Cash Transfer) for {{ transferDate }} before an
|
||||
inter-cashier transfer.
|
||||
</div>
|
||||
}
|
||||
@if (anyExceedsHeld) {
|
||||
<div class="ict-alert">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
The sending cashier does not hold enough of the highlighted denominations.
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="pg-card">
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Denomination</th>
|
||||
<th class="num">From holds</th>
|
||||
<th style="text-align: center">Count</th>
|
||||
<th class="num">Amount</th>
|
||||
<th class="num">To holds</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (notes.length) {
|
||||
<tr class="pg-section">
|
||||
<td colspan="3">Bank Notes</td>
|
||||
<td colspan="5">Bank Notes</td>
|
||||
</tr>
|
||||
}
|
||||
@for (d of notes; track d.id) {
|
||||
<tr>
|
||||
<tr [class.ict-row--over]="exceedsHeld(d)">
|
||||
<td>
|
||||
<span class="pg-chip">{{ d.label }}</span>
|
||||
</td>
|
||||
<td class="num" [class.ict-held--over]="exceedsHeld(d)">
|
||||
<ng-container
|
||||
*ngTemplateOutlet="heldCell; context: { n: heldBy(fromSummary, d), delta: -countFor(d) }"
|
||||
></ng-container>
|
||||
</td>
|
||||
<td style="display: flex; justify-content: center">
|
||||
<div class="pg-step">
|
||||
<button type="button" (click)="bump(d, -1)" [disabled]="countFor(d) === 0">−</button>
|
||||
@@ -99,18 +163,28 @@
|
||||
</div>
|
||||
</td>
|
||||
<td class="num">₹{{ subtotal(d) | number: '1.2-2' }}</td>
|
||||
<td class="num">
|
||||
<ng-container
|
||||
*ngTemplateOutlet="heldCell; context: { n: heldBy(toSummary, d), delta: countFor(d) }"
|
||||
></ng-container>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (coins.length) {
|
||||
<tr class="pg-section">
|
||||
<td colspan="3">Coins</td>
|
||||
<td colspan="5">Coins</td>
|
||||
</tr>
|
||||
}
|
||||
@for (d of coins; track d.id) {
|
||||
<tr>
|
||||
<tr [class.ict-row--over]="exceedsHeld(d)">
|
||||
<td>
|
||||
<span class="pg-chip pg-chip--coin">{{ d.label }}</span>
|
||||
</td>
|
||||
<td class="num" [class.ict-held--over]="exceedsHeld(d)">
|
||||
<ng-container
|
||||
*ngTemplateOutlet="heldCell; context: { n: heldBy(fromSummary, d), delta: -countFor(d) }"
|
||||
></ng-container>
|
||||
</td>
|
||||
<td style="display: flex; justify-content: center">
|
||||
<div class="pg-step">
|
||||
<button type="button" (click)="bump(d, -1)" [disabled]="countFor(d) === 0">−</button>
|
||||
@@ -119,17 +193,34 @@
|
||||
</div>
|
||||
</td>
|
||||
<td class="num">₹{{ subtotal(d) | number: '1.2-2' }}</td>
|
||||
<td class="num">
|
||||
<ng-container
|
||||
*ngTemplateOutlet="heldCell; context: { n: heldBy(toSummary, d), delta: countFor(d) }"
|
||||
></ng-container>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (denoms.length === 0) {
|
||||
<tr>
|
||||
<td colspan="3" class="pg-empty">No active denominations. Add some in Cash Denomination.</td>
|
||||
<td colspan="5" class="pg-empty">No active denominations. Add some in Cash Denomination.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- A held count, with what it becomes once this transfer is recorded. -->
|
||||
<ng-template #heldCell let-n="n" let-delta="delta">
|
||||
@if (n === null) {
|
||||
—
|
||||
} @else {
|
||||
{{ n }}
|
||||
@if (delta !== 0) {
|
||||
<span class="ict-after">→ {{ n + delta }}</span>
|
||||
}
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<div class="pg-total-bar">
|
||||
Total to transfer: <strong>₹{{ total | number: '1.2-2' }}</strong>
|
||||
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!valid || saving" (click)="submit()">
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
.ict-parties {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
&__arrow {
|
||||
align-self: center;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
@media (width <= 760px) {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
&__arrow {
|
||||
justify-self: center;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ict-party {
|
||||
padding: 12px 14px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
|
||||
&--blocked {
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&__empty {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
&__name {
|
||||
margin-top: 4px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
|
||||
span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
&__figures {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
&__warn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #991b1b;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ict-alert {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
padding: 8px 14px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: #991b1b;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.ict-row--over {
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.ict-held--over {
|
||||
color: #b91c1c;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ict-after {
|
||||
margin-left: 4px;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
@@ -29,12 +29,27 @@ interface Cashier {
|
||||
id: number;
|
||||
label: string;
|
||||
}
|
||||
/** One side of the transfer, from GET /cashintercashier/cashier-summary. */
|
||||
interface CashierSummary {
|
||||
cashierId: number;
|
||||
cashierName: string;
|
||||
tellerName: string;
|
||||
officeName: string;
|
||||
cashAllocated: boolean;
|
||||
allocatedAmount: number;
|
||||
totalAmount: number;
|
||||
/** Count held per denomination id. */
|
||||
held: Record<number, number>;
|
||||
}
|
||||
|
||||
/** Move cash denomination-wise between two cashiers under the same teller. */
|
||||
@Component({
|
||||
selector: 'mifosx-inter-cashier-transfer',
|
||||
templateUrl: './inter-cashier-transfer.component.html',
|
||||
styleUrls: ['../cashier-shared.scss'],
|
||||
styleUrls: [
|
||||
'../cashier-shared.scss',
|
||||
'./inter-cashier-transfer.component.scss'
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
@@ -57,7 +72,12 @@ export class InterCashierTransferComponent implements OnInit {
|
||||
note = '';
|
||||
saving = false;
|
||||
|
||||
tellers: { id: number; name: string }[] = [];
|
||||
/**
|
||||
* The sending cashier is always the one logged in: cash is handed over from their own drawer, so there is
|
||||
* no one else to pick on that side. Only the receiving cashier is chosen.
|
||||
*/
|
||||
ownCashierMissing = false;
|
||||
tellerName = '';
|
||||
allCashiers: Cashier[] = [];
|
||||
selectedTellerId: number | null = null;
|
||||
fromCashierId: number | null = null;
|
||||
@@ -66,15 +86,19 @@ export class InterCashierTransferComponent implements OnInit {
|
||||
toParty = '';
|
||||
loadingCashiers = false;
|
||||
|
||||
/** Details and denomination-wise cash of each side, so both are seen before the transfer is recorded. */
|
||||
fromSummary: CashierSummary | null = null;
|
||||
toSummary: CashierSummary | null = null;
|
||||
loadingFrom = false;
|
||||
loadingTo = false;
|
||||
|
||||
get denoms(): Denom[] {
|
||||
return [
|
||||
...this.notes,
|
||||
...this.coins
|
||||
];
|
||||
}
|
||||
get availableFrom(): Cashier[] {
|
||||
return this.allCashiers;
|
||||
}
|
||||
/** Everyone under the same teller except the sender. */
|
||||
get availableTo(): Cashier[] {
|
||||
return this.allCashiers.filter((c) => c.id !== this.fromCashierId);
|
||||
}
|
||||
@@ -98,25 +122,20 @@ export class InterCashierTransferComponent implements OnInit {
|
||||
this.coins = [];
|
||||
}
|
||||
});
|
||||
this.service
|
||||
.getTellers()
|
||||
.pipe(catchError(() => of([] as any[])))
|
||||
.subscribe((list) => {
|
||||
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
|
||||
if (this.tellers.length === 1) {
|
||||
this.onTellerSelect(this.tellers[0].id);
|
||||
}
|
||||
});
|
||||
// The sender is the cashier behind this login; their counter then gives the receivers to pick from.
|
||||
this.service.getCurrentCashierInfo().subscribe((info) => {
|
||||
if (!info) {
|
||||
this.ownCashierMissing = true;
|
||||
return;
|
||||
}
|
||||
this.fromCashierId = info.cashierId;
|
||||
this.fromParty = info.cashierName;
|
||||
this.loadSummary('from');
|
||||
});
|
||||
}
|
||||
|
||||
onTellerSelect(tellerId: number): void {
|
||||
this.selectedTellerId = tellerId;
|
||||
this.fromCashierId = null;
|
||||
this.toCashierId = null;
|
||||
this.fromParty = '';
|
||||
this.toParty = '';
|
||||
this.allCashiers = [];
|
||||
if (!tellerId) return;
|
||||
/** The other cashiers at this counter, one of whom receives the cash. */
|
||||
private loadReceivers(tellerId: number): void {
|
||||
this.loadingCashiers = true;
|
||||
this.service
|
||||
.getCashiersForTeller(tellerId)
|
||||
@@ -127,20 +146,90 @@ export class InterCashierTransferComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
onFromSelect(cashierId: number): void {
|
||||
this.fromCashierId = cashierId;
|
||||
const c = this.allCashiers.find((x) => x.id === cashierId);
|
||||
this.fromParty = c ? c.label : '';
|
||||
if (this.toCashierId === cashierId) {
|
||||
this.toCashierId = null;
|
||||
this.toParty = '';
|
||||
}
|
||||
}
|
||||
|
||||
onToSelect(cashierId: number): void {
|
||||
this.toCashierId = cashierId;
|
||||
const c = this.allCashiers.find((x) => x.id === cashierId);
|
||||
this.toParty = c ? c.label : '';
|
||||
this.loadSummary('to');
|
||||
}
|
||||
|
||||
onDateChange(value: string): void {
|
||||
this.transferDate = value;
|
||||
this.loadSummary('from');
|
||||
this.loadSummary('to');
|
||||
}
|
||||
|
||||
/** Fetch one side's details, vault allocation and denomination-wise cash for the transfer date. */
|
||||
private loadSummary(side: 'from' | 'to'): void {
|
||||
const cashierId = side === 'from' ? this.fromCashierId : this.toCashierId;
|
||||
if (side === 'from') {
|
||||
this.fromSummary = null;
|
||||
} else {
|
||||
this.toSummary = null;
|
||||
}
|
||||
if (!cashierId || !this.transferDate) return;
|
||||
this.setLoading(side, true);
|
||||
this.service
|
||||
.getInterCashierCashierSummary(cashierId, this.transferDate)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((r: any) => {
|
||||
this.setLoading(side, false);
|
||||
// A late answer for a cashier no longer selected on that side is dropped.
|
||||
const current = side === 'from' ? this.fromCashierId : this.toCashierId;
|
||||
if (!r || current !== cashierId) return;
|
||||
// The sender's summary also tells which counter they are at, hence who they can hand cash to.
|
||||
if (side === 'from' && r.tellerId && this.selectedTellerId !== Number(r.tellerId)) {
|
||||
this.selectedTellerId = Number(r.tellerId);
|
||||
this.tellerName = r.tellerName || '';
|
||||
this.loadReceivers(this.selectedTellerId);
|
||||
}
|
||||
const held: Record<number, number> = {};
|
||||
for (const d of r.denominations || []) {
|
||||
held[d.denominationId] = (held[d.denominationId] || 0) + Number(d.count || 0);
|
||||
}
|
||||
const summary: CashierSummary = {
|
||||
cashierId: r.cashierId,
|
||||
cashierName: r.cashierName,
|
||||
tellerName: r.tellerName,
|
||||
officeName: r.officeName,
|
||||
cashAllocated: !!r.cashAllocated,
|
||||
allocatedAmount: Number(r.allocatedAmount || 0),
|
||||
totalAmount: Number(r.totalAmount || 0),
|
||||
held
|
||||
};
|
||||
if (side === 'from') {
|
||||
this.fromSummary = summary;
|
||||
} else {
|
||||
this.toSummary = summary;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private setLoading(side: 'from' | 'to', loading: boolean): void {
|
||||
if (side === 'from') {
|
||||
this.loadingFrom = loading;
|
||||
} else {
|
||||
this.loadingTo = loading;
|
||||
}
|
||||
}
|
||||
|
||||
/** Count of the denomination the cashier holds, or null while their figures are not loaded. */
|
||||
heldBy(summary: CashierSummary | null, d: Denom): number | null {
|
||||
return summary ? summary.held[d.id] || 0 : null;
|
||||
}
|
||||
|
||||
/** The sending cashier is asked to hand on more of this denomination than they hold. */
|
||||
exceedsHeld(d: Denom): boolean {
|
||||
return !!this.fromSummary && this.countFor(d) > (this.fromSummary.held[d.id] || 0);
|
||||
}
|
||||
|
||||
get anyExceedsHeld(): boolean {
|
||||
return this.denoms.some((d) => this.exceedsHeld(d));
|
||||
}
|
||||
|
||||
/** Both sides must have been allocated cash from the vault for the day before they can transfer. */
|
||||
get bothAllocated(): boolean {
|
||||
return !!this.fromSummary?.cashAllocated && !!this.toSummary?.cashAllocated;
|
||||
}
|
||||
|
||||
countFor(d: Denom): number {
|
||||
@@ -160,7 +249,14 @@ export class InterCashierTransferComponent implements OnInit {
|
||||
return this.denoms.reduce((s, d) => s + this.subtotal(d), 0);
|
||||
}
|
||||
get valid(): boolean {
|
||||
return this.total > 0 && !!this.selectedTellerId && !!this.fromCashierId && !!this.toCashierId;
|
||||
return (
|
||||
this.total > 0 &&
|
||||
!!this.selectedTellerId &&
|
||||
!!this.fromCashierId &&
|
||||
!!this.toCashierId &&
|
||||
this.bothAllocated &&
|
||||
!this.anyExceedsHeld
|
||||
);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
@@ -187,10 +283,12 @@ export class InterCashierTransferComponent implements OnInit {
|
||||
this.snackBar.open('Inter-cashier transfer recorded', 'Close', { duration: 3000 });
|
||||
this.clear();
|
||||
this.note = '';
|
||||
this.fromCashierId = null;
|
||||
// The sender stays the logged-in cashier; the receiver is picked afresh, and the sender's holdings
|
||||
// are read again now that part of them has gone.
|
||||
this.toCashierId = null;
|
||||
this.fromParty = '';
|
||||
this.toParty = '';
|
||||
this.toSummary = null;
|
||||
this.loadSummary('from');
|
||||
this.saving = false;
|
||||
},
|
||||
error: () => {
|
||||
|
||||
@@ -19,34 +19,21 @@
|
||||
<!-- ── Entry Tab ── -->
|
||||
<mat-tab label="New Reconciliation">
|
||||
<div class="pg-tab-body">
|
||||
@if (ownCashierMissing) {
|
||||
<div class="pg-empty">
|
||||
Your login is not linked to a cashier, so there is no cash drawer to reconcile. Ask an administrator to link
|
||||
your user to a cashier.
|
||||
</div>
|
||||
}
|
||||
<div class="pg-toolbar pg-toolbar--mat">
|
||||
@if (tellers.length === 1) {
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Teller</mat-label>
|
||||
<input matInput type="text" [value]="tellers[0].name" readonly />
|
||||
</mat-form-field>
|
||||
} @else {
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Teller</mat-label>
|
||||
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
|
||||
@for (t of tellers; track t.id) {
|
||||
<mat-option [value]="t.id">{{ t.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
<!-- A reconciliation is the cashier's own count of their own drawer: nothing to pick. -->
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Cashier</mat-label>
|
||||
<mat-select
|
||||
[value]="selectedCashierId"
|
||||
(selectionChange)="onCashierSelect($event.value)"
|
||||
[disabled]="!selectedTellerId"
|
||||
>
|
||||
@for (c of cashiers; track c.id) {
|
||||
<mat-option [value]="c.id">{{ c.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
<mat-label>Teller</mat-label>
|
||||
<input matInput type="text" [value]="ownTellerName || '—'" readonly />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Cashier (You)</mat-label>
|
||||
<input matInput type="text" [value]="cashierName || '—'" readonly />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
|
||||
@@ -12,7 +12,6 @@ import { FormsModule } from '@angular/forms';
|
||||
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 { MatTabsModule } from '@angular/material/tabs';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
@@ -58,7 +57,6 @@ interface ReconRecord {
|
||||
MatIconModule,
|
||||
MatSnackBarModule,
|
||||
MatFormFieldModule,
|
||||
MatSelectModule,
|
||||
MatInputModule,
|
||||
MatTabsModule
|
||||
]
|
||||
@@ -67,9 +65,14 @@ export class ReconciliationComponent implements OnInit {
|
||||
private service = inject(CashierService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
/**
|
||||
* A reconciliation is the cashier's own count of their own drawer, so nobody else makes one and there is
|
||||
* no cashier to pick: the screen is fixed to the logged-in cashier. The server refuses anything else.
|
||||
*/
|
||||
ownCashierMissing = false;
|
||||
ownTellerName = '';
|
||||
|
||||
// ── Entry form ──
|
||||
tellers: { id: number; name: string }[] = [];
|
||||
cashiers: { id: number; label: string }[] = [];
|
||||
selectedTellerId: number | null = null;
|
||||
selectedCashierId: number | null = null;
|
||||
cashierName = '';
|
||||
@@ -88,42 +91,28 @@ export class ReconciliationComponent implements OnInit {
|
||||
ngOnInit(): void {
|
||||
const d = businessToday();
|
||||
this.reconDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
this.service
|
||||
.getTellers()
|
||||
.pipe(catchError(() => of([] as any[])))
|
||||
.subscribe((list) => {
|
||||
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
|
||||
if (this.tellers.length === 1) {
|
||||
this.onTellerSelect(this.tellers[0].id);
|
||||
}
|
||||
});
|
||||
this.lockToOwnCashier();
|
||||
this.loadHistory();
|
||||
}
|
||||
|
||||
onTellerSelect(tellerId: number): void {
|
||||
this.selectedTellerId = tellerId;
|
||||
this.selectedCashierId = null;
|
||||
this.cashierName = '';
|
||||
this.cashiers = [];
|
||||
this.rows = [];
|
||||
if (!tellerId) return;
|
||||
this.service
|
||||
.getCashiersForTeller(tellerId)
|
||||
.pipe(catchError(() => of({ cashiers: [] })))
|
||||
.subscribe((r: any) => {
|
||||
this.cashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName }));
|
||||
if (this.cashiers.length === 1) {
|
||||
this.onCashierSelect(this.cashiers[0].id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onCashierSelect(cashierId: number): void {
|
||||
this.selectedCashierId = cashierId;
|
||||
const c = this.cashiers.find((x) => x.id === cashierId);
|
||||
this.cashierName = c ? c.label : '';
|
||||
this.rows = [];
|
||||
this.fetchExpected();
|
||||
/** Take the logged-in cashier as the one being reconciled, with their counter for the record. */
|
||||
private lockToOwnCashier(): void {
|
||||
this.service.getCurrentCashierInfo().subscribe((info) => {
|
||||
if (!info) {
|
||||
this.ownCashierMissing = true;
|
||||
return;
|
||||
}
|
||||
this.selectedCashierId = info.cashierId;
|
||||
this.cashierName = info.cashierName;
|
||||
this.service
|
||||
.getInterCashierCashierSummary(info.cashierId, this.reconDate)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((r: any) => {
|
||||
this.selectedTellerId = r?.tellerId ?? null;
|
||||
this.ownTellerName = r?.tellerName || '';
|
||||
});
|
||||
this.fetchExpected();
|
||||
});
|
||||
}
|
||||
|
||||
fetchExpected(): void {
|
||||
|
||||
@@ -256,13 +256,6 @@
|
||||
>
|
||||
<fa-icon icon="exchange"></fa-icon> Cash Exchange
|
||||
</a>
|
||||
<a
|
||||
class="cashier-nav__item"
|
||||
[routerLink]="['/cashier/exchange-detail']"
|
||||
routerLinkActive="cashier-nav__item--active"
|
||||
>
|
||||
<fa-icon icon="globe"></fa-icon> Exchange Detail
|
||||
</a>
|
||||
<a
|
||||
class="cashier-nav__item"
|
||||
[routerLink]="['/cashier/inter-cashier']"
|
||||
@@ -270,13 +263,6 @@
|
||||
>
|
||||
<fa-icon icon="exchange-alt"></fa-icon> Inter-Cashier
|
||||
</a>
|
||||
<a
|
||||
class="cashier-nav__item"
|
||||
[routerLink]="['/cashier/excess-report']"
|
||||
routerLinkActive="cashier-nav__item--active"
|
||||
>
|
||||
<fa-icon icon="exclamation-circle"></fa-icon> Excess Report
|
||||
</a>
|
||||
<a
|
||||
class="cashier-nav__item"
|
||||
[routerLink]="['/cashier/cashier-position']"
|
||||
@@ -284,6 +270,13 @@
|
||||
>
|
||||
<fa-icon icon="wallet"></fa-icon> Position
|
||||
</a>
|
||||
<a
|
||||
class="cashier-nav__item"
|
||||
[routerLink]="['/cashier/reconciliation']"
|
||||
routerLinkActive="cashier-nav__item--active"
|
||||
>
|
||||
<fa-icon icon="balance-scale"></fa-icon> Reconciliation
|
||||
</a>
|
||||
<a
|
||||
class="cashier-nav__item"
|
||||
[routerLink]="['/cashier/cash-history']"
|
||||
@@ -308,13 +301,6 @@
|
||||
>
|
||||
<fa-icon icon="list"></fa-icon> Payment
|
||||
</a>
|
||||
<a
|
||||
class="cashier-nav__item"
|
||||
[routerLink]="['/cashier/closing-report']"
|
||||
routerLinkActive="cashier-nav__item--active"
|
||||
>
|
||||
<fa-icon icon="file-alt"></fa-icon> Closing Report
|
||||
</a>
|
||||
</nav>
|
||||
}
|
||||
}
|
||||
@@ -1094,6 +1080,9 @@
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/vault-position']">
|
||||
<fa-icon icon="lock" class="menu-item-icon"></fa-icon> Vault Position
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/branch-position']">
|
||||
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Branch Cash Position
|
||||
</button>
|
||||
</div>
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">Verification & Reports</div>
|
||||
@@ -1166,12 +1155,9 @@
|
||||
<!-- Cash Entries & Exchange -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">Cash & Counter</div>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-exchange']">
|
||||
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> Cash Exchange Entry
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/exchange-detail']">
|
||||
<fa-icon icon="globe" class="menu-item-icon"></fa-icon> Currency Exchange Detail
|
||||
</button>
|
||||
<!-- Cash Exchange, Currency Exchange Detail, Inter-Cashier Transfer and Reconciliation are not
|
||||
here: they move or count notes in one cashier's own drawer, so only the cashier who is logged
|
||||
in does them, from the cashier screen. -->
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/transaction-entry']">
|
||||
<fa-icon icon="keyboard" class="menu-item-icon"></fa-icon> Transaction Entry
|
||||
</button>
|
||||
@@ -1186,9 +1172,6 @@
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-transfer']">
|
||||
<fa-icon icon="random" class="menu-item-icon"></fa-icon> Cash Transfer
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/inter-cashier']">
|
||||
<fa-icon icon="exchange-alt" class="menu-item-icon"></fa-icon> Inter-Cashier Transfer
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-settlement']">
|
||||
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Cash Settlement
|
||||
</button>
|
||||
@@ -1209,9 +1192,6 @@
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/excess-report']">
|
||||
<fa-icon icon="exclamation-circle" class="menu-item-icon"></fa-icon> Excess Transaction Report
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/reconciliation']">
|
||||
<fa-icon icon="balance-scale" class="menu-item-icon"></fa-icon> Reconciliation
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-history']">
|
||||
<fa-icon icon="history" class="menu-item-icon"></fa-icon> Cash History
|
||||
</button>
|
||||
|
||||
@@ -244,6 +244,44 @@ $color: #e2e4ec;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* The cashier's bar is meant to be one row at a normal window size: it carries about eleven items, and at
|
||||
* the usual padding the last one wrapped onto a second row. The items are tightened so they fit, and spread
|
||||
* across the row. As the window narrows they tighten further, then drop their icons, and only wrap in a very
|
||||
* narrow window. The clerk's bar (cashier-nav--clerk) has its own dropdowns and is left as it was.
|
||||
*/
|
||||
.cashier-nav:not(.cashier-nav--clerk) {
|
||||
flex-wrap: nowrap;
|
||||
justify-content: space-between;
|
||||
gap: 0;
|
||||
padding: 4px 8px;
|
||||
|
||||
.cashier-nav__item {
|
||||
flex: 0 0 auto;
|
||||
gap: 4px;
|
||||
padding: 5px 8px;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
@media (width <= 1240px) {
|
||||
.cashier-nav__item {
|
||||
padding: 5px 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 1120px) {
|
||||
.cashier-nav__item fa-icon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 900px) {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.sidenav {
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
|
||||
-12
@@ -135,18 +135,6 @@
|
||||
<mat-icon><fa-icon icon="eye" size="sm"></fa-icon></mat-icon>
|
||||
<span>{{ 'labels.text.View Transaction' | translate }}</span>
|
||||
</button>
|
||||
@if (!transaction.reversed) {
|
||||
<span>
|
||||
<button
|
||||
mat-menu-item
|
||||
*mifosxHasPermission="'ADJUSTTRANSACTION_CURRENTACCOUNT'"
|
||||
(click)="undoTransaction(transaction, $event)"
|
||||
>
|
||||
<mat-icon><fa-icon icon="undo" size="sm"></fa-icon></mat-icon>
|
||||
<span>{{ 'tooltips.Undo Transaction' | translate }}</span>
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
<button
|
||||
mat-menu-item
|
||||
class="account-action-button"
|
||||
|
||||
-12
@@ -40,18 +40,6 @@
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
@if (allowUndo()) {
|
||||
<span>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="warn"
|
||||
*mifosxHasPermission="'UNDOTRANSACTION_CURRENTACCOUNT'"
|
||||
(click)="undoTransaction()"
|
||||
>
|
||||
<fa-icon icon="undo" class="m-r-10"></fa-icon>{{ 'labels.buttons.Undo' | translate }}
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
|
||||
-6
@@ -130,12 +130,6 @@
|
||||
<mat-icon><fa-icon icon="eye" size="sm"></fa-icon></mat-icon>
|
||||
<span>{{ 'labels.text.View Transaction' | translate }}</span>
|
||||
</button>
|
||||
@if (!isFirstTransaction(idx)) {
|
||||
<button mat-menu-item (click)="undoTransaction(transaction)">
|
||||
<mat-icon><fa-icon icon="undo" size="sm"></fa-icon></mat-icon>
|
||||
<span>{{ 'tooltips.Undo Transaction' | translate }}</span>
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="routeEdit($event)"
|
||||
|
||||
+1
-14
@@ -6,20 +6,7 @@
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="container m-b-20 align-end-center gap-2percent">
|
||||
@if (allowUndo()) {
|
||||
<span>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="warn"
|
||||
*mifosxHasPermission="'UNDOTRANSACTION_SAVINGSACCOUNT'"
|
||||
(click)="undoTransaction()"
|
||||
>
|
||||
<fa-icon icon="undo" class="m-r-10"></fa-icon>Undo
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="container m-b-20 align-end-center gap-2percent"></div>
|
||||
<div class="container">
|
||||
<mat-card>
|
||||
<mat-card-content>
|
||||
|
||||
-8
@@ -15,14 +15,6 @@
|
||||
</button>
|
||||
}
|
||||
</span>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="warn"
|
||||
*mifosxHasPermission="'UNDOTRANSACTION_SAVINGSACCOUNT'"
|
||||
(click)="undoTransaction()"
|
||||
>
|
||||
<fa-icon icon="undo" class="m-r-10"></fa-icon>{{ 'labels.buttons.Undo' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,65 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pending investment entries (GOI / bond interest, FDs with other banks): one line each, details on demand -->
|
||||
@if (pendingInvestmentInterest.length || pendingFd.length) {
|
||||
<div class="investment-interest-alert">
|
||||
<span class="iia-icon">🔔</span>
|
||||
<div class="iia-text">
|
||||
<strong>Investment entries pending</strong>
|
||||
@if (pendingInvestmentInterest.length) {
|
||||
<div class="iia-row">
|
||||
<span class="iia-summary"
|
||||
>GOI / Bonds: {{ pendingInvestmentInterest.length }} interest voucher(s){{
|
||||
overdueNote(pendingInvestmentInterest)
|
||||
}}</span
|
||||
>
|
||||
<button type="button" class="iia-toggle" (click)="showInterestDetails = !showInterestDetails">
|
||||
{{ showInterestDetails ? 'Hide' : 'Details' }}
|
||||
</button>
|
||||
<a class="iia-action" routerLink="/investment/bonds" [queryParams]="{ tab: 'interest' }"
|
||||
>Open Interest Entry →</a
|
||||
>
|
||||
</div>
|
||||
@if (showInterestDetails) {
|
||||
@for (t of pendingInvestmentInterest.slice(0, detailLimit); track t.purchaseId) {
|
||||
<a class="iia-item" routerLink="/investment/bonds" [queryParams]="{ tab: 'interest' }">
|
||||
{{ t.lineType === 'COUPON' ? 'Coupon' : 'Provision' }} · {{ t.scriptDesc }} · deal {{ t.dealNo }} ·
|
||||
{{ t.overdue ? 'overdue since ' + t.intDate : t.postable ? 'due today' : 'due ' + t.intDate }}
|
||||
</a>
|
||||
}
|
||||
@if (pendingInvestmentInterest.length > detailLimit) {
|
||||
<span class="iia-more">+{{ pendingInvestmentInterest.length - detailLimit }} more in Interest Entry</span>
|
||||
}
|
||||
}
|
||||
}
|
||||
@if (pendingFd.length) {
|
||||
<div class="iia-row">
|
||||
<span class="iia-summary"
|
||||
>FD with banks: {{ pendingFd.length }} entry(ies){{ overdueNote(pendingFd) }}</span
|
||||
>
|
||||
<button type="button" class="iia-toggle" (click)="showFdDetails = !showFdDetails">
|
||||
{{ showFdDetails ? 'Hide' : 'Details' }}
|
||||
</button>
|
||||
<a class="iia-action" routerLink="/investment/fd">Open FD Investment →</a>
|
||||
</div>
|
||||
@if (showFdDetails) {
|
||||
@for (t of pendingFd.slice(0, detailLimit); track t.fdId + t.lineType + t.intDate) {
|
||||
<a class="iia-item" routerLink="/investment/fd" [queryParams]="{ tab: 'interest', fd: t.fdId }">
|
||||
{{ t.lineType === 'MATURITY' ? 'Maturity' : t.lineType === 'PAYOUT' ? 'Interest payout' : 'Provision' }}
|
||||
· {{ t.partyName }} · FD {{ t.dealNo }} ·
|
||||
{{ t.overdue ? 'overdue since ' + t.intDate : t.postable ? 'due today' : 'due ' + t.intDate }}
|
||||
</a>
|
||||
}
|
||||
@if (pendingFd.length > detailLimit) {
|
||||
<span class="iia-more">+{{ pendingFd.length - detailLimit }} more in FD Investment</span>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Quick Action Cards -->
|
||||
<div class="quick-actions">
|
||||
<a class="qa-card" [routerLink]="['/dashboard']" #buttonDashboard>
|
||||
|
||||
@@ -424,3 +424,78 @@ $accent: #0ea5e9;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.investment-interest-alert {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin: 12px 0;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #f5c26b;
|
||||
border-left: 4px solid #e08e0b;
|
||||
border-radius: 8px;
|
||||
background: #fff8e6;
|
||||
color: #5c3d00;
|
||||
text-decoration: none;
|
||||
|
||||
.iia-icon {
|
||||
font-size: 20px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.iia-text {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.iia-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.iia-summary {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.iia-toggle {
|
||||
padding: 2px 10px;
|
||||
border: 1px solid #e0a43a;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.iia-item {
|
||||
padding-left: 12px;
|
||||
font-size: 13px;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.iia-more {
|
||||
padding-left: 12px;
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
a.iia-item:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.iia-action {
|
||||
align-self: center;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import { PopoverService } from '../configuration-wizard/popover/popover.service'
|
||||
import { ConfigurationWizardService } from '../configuration-wizard/configuration-wizard.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { DscService } from 'app/users/dsc/dsc.service';
|
||||
import { InvestmentFdDueDto, InvestmentInterestDueDto, InvestmentService } from 'app/investment/investment.service';
|
||||
|
||||
/** Custom Components */
|
||||
import { NextStepDialogComponent } from '../configuration-wizard/next-step-dialog/next-step-dialog.component';
|
||||
@@ -109,6 +110,7 @@ export class HomeComponent implements OnInit, AfterViewInit {
|
||||
this.tenant = this.tenantIdentifier();
|
||||
this.setFilteredActivities();
|
||||
this.loadDscStatus();
|
||||
this.loadPendingInvestmentInterest();
|
||||
if (!this.authenticationService.hasDialogBeenShown()) {
|
||||
this.dialog.open(WarningDialogComponent, {
|
||||
disableClose: false,
|
||||
@@ -118,6 +120,39 @@ export class HomeComponent implements OnInit, AfterViewInit {
|
||||
}
|
||||
}
|
||||
|
||||
/** Investment interest lines due now or within two days, shown on the home screen so nobody has to look for them. */
|
||||
pendingInvestmentInterest: InvestmentInterestDueDto[] = [];
|
||||
/** FDs placed with other banks: interest vouchers and maturities due now or within two days. */
|
||||
pendingFd: InvestmentFdDueDto[] = [];
|
||||
/** The lists stay folded to one line each; the user opens them when needed. */
|
||||
showInterestDetails = false;
|
||||
showFdDetails = false;
|
||||
readonly detailLimit = 5;
|
||||
private readonly investmentService = inject(InvestmentService);
|
||||
|
||||
/** " · 3 overdue, oldest 14-08-2025" for a list of due entries (dates DD-MM-YYYY); empty when none is overdue. */
|
||||
overdueNote(items: Array<{ overdue?: boolean; intDate: string }>): string {
|
||||
const late = items.filter((t) => t.overdue);
|
||||
if (!late.length) {
|
||||
return '';
|
||||
}
|
||||
const key = (d: string) => d.split('-').reverse().join('');
|
||||
const oldest = late.map((t) => t.intDate).sort((a, b) => key(a).localeCompare(key(b)))[0];
|
||||
return ` · ${late.length} overdue, oldest ${oldest}`;
|
||||
}
|
||||
|
||||
/** Silent when the user cannot see investments or nothing is due — the strip simply does not render. */
|
||||
private loadPendingInvestmentInterest(): void {
|
||||
this.investmentService.getDueInterest().subscribe({
|
||||
next: (due) => (this.pendingInvestmentInterest = due ?? []),
|
||||
error: () => (this.pendingInvestmentInterest = [])
|
||||
});
|
||||
this.investmentService.getFdDue().subscribe({
|
||||
next: (due) => (this.pendingFd = due ?? []),
|
||||
error: () => (this.pendingFd = [])
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface a small "DSC verified" badge in the welcome banner when the
|
||||
* signed-in user has at least one ACTIVE DSC certificate. Silent on
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
|
||||
export type InstrumentType = 'FD' | 'GOVT_SECURITY' | 'NON_SLR' | 'BOND' | 'MUTUAL_FUND' | 'SHARE';
|
||||
export type HoldingStatus = 'ACTIVE' | 'MATURED' | 'SOLD' | 'WRITTEN_OFF';
|
||||
export type HoldingStatus = 'PENDING_SETTLEMENT' | 'ACTIVE' | 'MATURED' | 'SOLD' | 'WRITTEN_OFF';
|
||||
export type InterestFreq = 'MONTHLY' | 'QUARTERLY' | 'HALF_YEARLY' | 'YEARLY' | 'CUMULATIVE';
|
||||
export type TxnType =
|
||||
| 'PURCHASE'
|
||||
@@ -28,6 +28,7 @@ export type TxnType =
|
||||
| 'REDEEM'
|
||||
| 'WRITE_OFF'
|
||||
| 'AMORTIZE'
|
||||
| 'TRANSFER'
|
||||
| 'PART_PAYMENT';
|
||||
|
||||
export const INSTRUMENT_LABELS: Record<InstrumentType, string> = {
|
||||
@@ -123,6 +124,22 @@ export interface InvestmentHolding {
|
||||
* never re-counts days up to this date, so re-running a Calculation & Post for an overlapping
|
||||
* or repeated period always yields 0 instead of double-posting the same interest. */
|
||||
interestPostedTillDate?: string;
|
||||
/** RBI investment category chosen at purchase — HTM / AFS / HFT. */
|
||||
category?: string;
|
||||
/** SGL / CSGL account the security is held in (custody, not an accounting GL). */
|
||||
sglAccountNo?: string;
|
||||
/** Backend id / deal number / party code once the purchase is saved (absent on sample rows). */
|
||||
id?: number;
|
||||
dealNo?: string;
|
||||
partyCode?: string;
|
||||
bookValue?: number;
|
||||
settlementAmount?: number;
|
||||
/** Settlement date of the deal (deal date + T+n) — it can be settled on or after this day. */
|
||||
settlementDate?: string;
|
||||
/** Last coupon date before the purchase; coupons fall every six months from it. */
|
||||
lastIntDate?: string;
|
||||
/** Journal entry transaction posted when the deal was settled. */
|
||||
glTransactionId?: string;
|
||||
}
|
||||
|
||||
export interface InvestmentTxn {
|
||||
@@ -248,6 +265,80 @@ export function accruedInterestBetween(h: InvestmentHolding, fromStr: string, to
|
||||
return Math.round(h.faceValue * (h.interestRate / 100) * (days / 365.25));
|
||||
}
|
||||
|
||||
/** Days between two DD-MM-YYYY dates on a 30/360 basis (every month 30 days), as the bank's existing
|
||||
* CBS counts them — e.g. 10-08 → 30-09 = 50 days, 10-08 → 07-10 = 57 days. */
|
||||
export function days360(fromStr: string, toStr: string): number {
|
||||
const a = parseDDMMYYYY(fromStr);
|
||||
const b = parseDDMMYYYY(toStr);
|
||||
if (isNaN(a.getTime()) || isNaN(b.getTime()) || b <= a) {
|
||||
return 0;
|
||||
}
|
||||
const d1 = Math.min(a.getDate(), 30);
|
||||
const d2 = Math.min(b.getDate(), 30);
|
||||
return (b.getFullYear() - a.getFullYear()) * 360 + (b.getMonth() - a.getMonth()) * 30 + (d2 - d1);
|
||||
}
|
||||
|
||||
/** Most recent half-yearly coupon date on or before `onStr`, derived from the maturity date (coupons
|
||||
* fall on the maturity day/month and six months either side of it). */
|
||||
export function lastHalfYearlyCouponDate(maturityStr: string, onStr: string): string {
|
||||
const mat = parseDDMMYYYY(maturityStr);
|
||||
const on = parseDDMMYYYY(onStr);
|
||||
if (isNaN(mat.getTime()) || isNaN(on.getTime())) {
|
||||
return '';
|
||||
}
|
||||
const day = mat.getDate();
|
||||
const couponOn = (year: number, month: number): Date => {
|
||||
const lastDay = new Date(year, month + 1, 0).getDate();
|
||||
return new Date(year, month, Math.min(day, lastDay));
|
||||
};
|
||||
const months = [
|
||||
mat.getMonth() % 6,
|
||||
(mat.getMonth() % 6) + 6
|
||||
];
|
||||
let best: Date | undefined;
|
||||
for (const year of [
|
||||
on.getFullYear() - 1,
|
||||
on.getFullYear()
|
||||
]) {
|
||||
for (const m of months) {
|
||||
const c = couponOn(year, m);
|
||||
if (c <= on && (!best || c > best)) {
|
||||
best = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
return best ? formatDDMMYYYY(best) : '';
|
||||
}
|
||||
|
||||
/** First half-yearly coupon date after `onStr` (coupons fall on the maturity day/month and six months
|
||||
* either side), never later than maturity. */
|
||||
export function nextHalfYearlyCouponDate(maturityStr: string, onStr: string): string {
|
||||
const last = lastHalfYearlyCouponDate(maturityStr, onStr);
|
||||
const mat = parseDDMMYYYY(maturityStr);
|
||||
if (!last || isNaN(mat.getTime())) {
|
||||
return '';
|
||||
}
|
||||
const l = parseDDMMYYYY(last);
|
||||
const year = l.getFullYear() + (l.getMonth() >= 6 ? 1 : 0);
|
||||
const month = (l.getMonth() + 6) % 12;
|
||||
const next = new Date(year, month, Math.min(mat.getDate(), new Date(year, month + 1, 0).getDate()));
|
||||
return formatDDMMYYYY(next > mat ? mat : next);
|
||||
}
|
||||
|
||||
/** The two coupon days of a half-yearly security, e.g. "14 Feb / 14 Aug". */
|
||||
export function couponDatesLabel(maturityStr: string): string {
|
||||
const mat = parseDDMMYYYY(maturityStr);
|
||||
if (isNaN(mat.getTime())) {
|
||||
return '';
|
||||
}
|
||||
const label = (month: number): string => {
|
||||
const day = Math.min(mat.getDate(), new Date(2001, month + 1, 0).getDate());
|
||||
return `${day} ${new Date(2001, month, 1).toLocaleString('en-US', { month: 'short' })}`;
|
||||
};
|
||||
const first = mat.getMonth() % 6;
|
||||
return `${label(first)} / ${label(first + 6)}`;
|
||||
}
|
||||
|
||||
export function formatDDMMYYYY(d: Date): string {
|
||||
return `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`;
|
||||
}
|
||||
@@ -1404,99 +1495,333 @@ export const CASH_BANK_GL_MASTER: InvestmentGlOption[] = [
|
||||
{ glCode: '110030', glName: 'HDFC BANK CURRENT ACCOUNT' }
|
||||
];
|
||||
|
||||
/** Investment GLs a script / party can be mapped to. PROPOSED codes awaiting approval — the
|
||||
* backend will take these from the chart of accounts, never hard-coded. */
|
||||
export const INVESTMENT_GL_OPTIONS: InvestmentGlOption[] = [
|
||||
{ glCode: '100701', glName: 'INVESTMENT IN CENTRAL GOVT SECURITIES' },
|
||||
{ glCode: '100702', glName: 'INVESTMENT IN STATE GOVT SECURITIES' },
|
||||
{ glCode: '100703', glName: 'INVESTMENT IN BONDS / DEBENTURES' },
|
||||
{ glCode: '100704', glName: 'FIXED DEPOSITS WITH OTHER BANKS' },
|
||||
{ glCode: '100705', glName: 'INVESTMENT IN MUTUAL FUNDS' },
|
||||
{ glCode: '100706', glName: 'INVESTMENT IN SHARES' }
|
||||
];
|
||||
|
||||
/** Fallback investment GL when a script has none mapped. */
|
||||
export const INVESTMENT_GL_BY_TYPE: Record<InstrumentType, string> = {
|
||||
GOVT_SECURITY: '100701',
|
||||
NON_SLR: '100703',
|
||||
BOND: '100703',
|
||||
FD: '100704',
|
||||
MUTUAL_FUND: '100705',
|
||||
SHARE: '100706'
|
||||
};
|
||||
|
||||
/** Broken-period interest paid to the seller at purchase — an expense, not part of cost (RBI UCB Investment
|
||||
* Directions para 152). */
|
||||
export const BROKEN_PERIOD_INTEREST_GL: InvestmentGlOption = {
|
||||
glCode: '500606',
|
||||
glName: 'BROKEN PERIOD INTEREST PAID ON INVESTMENTS'
|
||||
};
|
||||
|
||||
/** Charges on an investment deal — expenses on their own GLs, not part of the book value (Jitendra Sir). */
|
||||
export const STAMP_DUTY_GL: InvestmentGlOption = { glCode: '500601', glName: 'STAMP DUTY ON INVESTMENTS' };
|
||||
export const BROKERAGE_GL: InvestmentGlOption = { glCode: '500602', glName: 'BROKERAGE ON INVESTMENTS' };
|
||||
export const OTHER_CHARGES_GL: InvestmentGlOption = { glCode: '500603', glName: 'OTHER CHARGES ON INVESTMENTS' };
|
||||
/** Premium paid over face value, held until amortised to maturity (Jitendra Sir). */
|
||||
export const PREMIUM_ON_INVESTMENTS_GL: InvestmentGlOption = { glCode: '100707', glName: 'PREMIUM ON INVESTMENTS' };
|
||||
|
||||
/** Interest receivable (the senior's "Provision A/c") — debited for interest bought from the seller. */
|
||||
export const INTEREST_RECEIVABLE_GL: InvestmentGlOption = {
|
||||
glCode: '100603',
|
||||
glName: 'INTEREST RECEIVABLE ON INVESTMENTS (PROVISION)'
|
||||
};
|
||||
|
||||
/** Bank accounts a purchase can be paid from — these GLs already exist in the chart of accounts. */
|
||||
export const SETTLEMENT_BANK_GL_OPTIONS: InvestmentGlOption[] = [
|
||||
{ glCode: '100201', glName: 'IDBI BANK' },
|
||||
{ glCode: '100202', glName: 'YES BANK' }
|
||||
];
|
||||
|
||||
/** Every GL the Investment screens can show by name. */
|
||||
export const INVESTMENT_GL_LOOKUP: InvestmentGlOption[] = [
|
||||
...INVESTMENT_GL_OPTIONS,
|
||||
INTEREST_RECEIVABLE_GL,
|
||||
...SETTLEMENT_BANK_GL_OPTIONS
|
||||
];
|
||||
|
||||
/** SLR classification on the Script Master, as in the bank's existing CBS ("GOI" = Central Govt). */
|
||||
export type SlrType = 'GOI' | 'STATE_GOVT' | 'TREASURY' | 'NON_SLR';
|
||||
|
||||
export const SLR_TYPE_OPTIONS: Array<{ value: SlrType; label: string }> = [
|
||||
{ value: 'GOI', label: 'GOI' },
|
||||
{ value: 'STATE_GOVT', label: 'STATE GOVT' },
|
||||
{ value: 'TREASURY', label: 'TREASURY' },
|
||||
{ value: 'NON_SLR', label: 'NON SLR' }
|
||||
];
|
||||
|
||||
export interface ScriptMaster {
|
||||
/** Backend id once the script is saved (absent on the sample rows). */
|
||||
id?: number;
|
||||
scriptCode: string;
|
||||
scriptName: string;
|
||||
instrumentType: InstrumentType;
|
||||
isin: string;
|
||||
faceValuePerUnit: number;
|
||||
// Fields below follow the bank's existing CBS Script Master; a purchase picks them up instead
|
||||
// of the dealer re-typing them on every deal.
|
||||
slrType?: SlrType;
|
||||
/** Investment GL this script is booked to. */
|
||||
glAccountId?: number;
|
||||
glCode?: string;
|
||||
glName?: string;
|
||||
active?: boolean;
|
||||
/** Interest (coupon) rate % per annum. */
|
||||
couponRate?: number;
|
||||
issueDate?: string;
|
||||
maturityDate?: string;
|
||||
// Issuer details — used for Bond / Mutual Fund scripts.
|
||||
companyName?: string;
|
||||
address1?: string;
|
||||
address2?: string;
|
||||
address3?: string;
|
||||
city?: string;
|
||||
pincode?: string;
|
||||
phone?: string;
|
||||
fax?: string;
|
||||
corpCode?: string;
|
||||
}
|
||||
|
||||
/** Period between two DD-MM-YYYY dates as years / months / days, as shown on the Script Master. */
|
||||
export function periodBetween(fromStr?: string, toStr?: string): { years: number; months: number; days: number } {
|
||||
const none = { years: 0, months: 0, days: 0 };
|
||||
if (!fromStr || !toStr) {
|
||||
return none;
|
||||
}
|
||||
const from = parseDDMMYYYY(fromStr);
|
||||
const to = parseDDMMYYYY(toStr);
|
||||
if (isNaN(from.getTime()) || isNaN(to.getTime()) || to < from) {
|
||||
return none;
|
||||
}
|
||||
let years = to.getFullYear() - from.getFullYear();
|
||||
let months = to.getMonth() - from.getMonth();
|
||||
let days = to.getDate() - from.getDate();
|
||||
if (days < 0) {
|
||||
months -= 1;
|
||||
days += new Date(to.getFullYear(), to.getMonth(), 0).getDate();
|
||||
}
|
||||
if (months < 0) {
|
||||
years -= 1;
|
||||
months += 12;
|
||||
}
|
||||
return { years, months, days };
|
||||
}
|
||||
|
||||
export const SCRIPT_MASTER: ScriptMaster[] = [
|
||||
{ scriptCode: 'FD-CUM', scriptName: 'Cumulative Fixed Deposit', instrumentType: 'FD', isin: '', faceValuePerUnit: 1 },
|
||||
{
|
||||
scriptCode: 'FD-CUM',
|
||||
scriptName: 'Cumulative Fixed Deposit',
|
||||
instrumentType: 'FD',
|
||||
isin: '',
|
||||
faceValuePerUnit: 1,
|
||||
slrType: 'NON_SLR',
|
||||
glCode: '100704'
|
||||
},
|
||||
{
|
||||
scriptCode: 'FD-MIP',
|
||||
scriptName: 'Monthly Interest Payout FD',
|
||||
instrumentType: 'FD',
|
||||
isin: '',
|
||||
faceValuePerUnit: 1
|
||||
faceValuePerUnit: 1,
|
||||
slrType: 'NON_SLR',
|
||||
glCode: '100704'
|
||||
},
|
||||
{
|
||||
scriptCode: 'IN0020230012',
|
||||
scriptName: '7.26% GOI 2033',
|
||||
instrumentType: 'GOVT_SECURITY',
|
||||
isin: 'IN0020230012',
|
||||
faceValuePerUnit: 100
|
||||
faceValuePerUnit: 100,
|
||||
slrType: 'GOI',
|
||||
glCode: '100701',
|
||||
couponRate: 7.26,
|
||||
issueDate: '15-01-2023',
|
||||
maturityDate: '15-01-2033'
|
||||
},
|
||||
{
|
||||
scriptCode: 'IN0020220089',
|
||||
scriptName: '7.10% GOI 2029',
|
||||
instrumentType: 'GOVT_SECURITY',
|
||||
isin: 'IN0020220089',
|
||||
faceValuePerUnit: 100
|
||||
faceValuePerUnit: 100,
|
||||
slrType: 'GOI',
|
||||
glCode: '100701',
|
||||
couponRate: 7.1,
|
||||
issueDate: '10-03-2022',
|
||||
maturityDate: '10-03-2029'
|
||||
},
|
||||
{
|
||||
scriptCode: 'IN0020210056',
|
||||
scriptName: '6.79% GOI 2027',
|
||||
instrumentType: 'GOVT_SECURITY',
|
||||
isin: 'IN0020210056',
|
||||
faceValuePerUnit: 100,
|
||||
slrType: 'GOI',
|
||||
glCode: '100701',
|
||||
couponRate: 6.79,
|
||||
issueDate: '05-06-2021',
|
||||
maturityDate: '05-06-2027'
|
||||
},
|
||||
{
|
||||
scriptCode: 'IN0020240034',
|
||||
scriptName: '7.18% GOI 2037',
|
||||
instrumentType: 'GOVT_SECURITY',
|
||||
isin: 'IN0020240034',
|
||||
faceValuePerUnit: 100,
|
||||
slrType: 'GOI',
|
||||
glCode: '100701',
|
||||
couponRate: 7.18,
|
||||
issueDate: '22-04-2024',
|
||||
maturityDate: '22-04-2037'
|
||||
},
|
||||
{
|
||||
scriptCode: 'MH-SDL-2031',
|
||||
scriptName: '8.05% Maharashtra SDL 2031',
|
||||
instrumentType: 'GOVT_SECURITY',
|
||||
isin: '',
|
||||
faceValuePerUnit: 100,
|
||||
slrType: 'STATE_GOVT',
|
||||
glCode: '100702',
|
||||
couponRate: 8.05,
|
||||
issueDate: '15-02-2021',
|
||||
maturityDate: '15-02-2031'
|
||||
},
|
||||
{
|
||||
scriptCode: 'GJ-SDL-2026',
|
||||
scriptName: '7.85% Gujarat SDL 2026',
|
||||
instrumentType: 'GOVT_SECURITY',
|
||||
isin: '',
|
||||
faceValuePerUnit: 100,
|
||||
slrType: 'STATE_GOVT',
|
||||
glCode: '100702',
|
||||
couponRate: 7.85,
|
||||
issueDate: '18-09-2016',
|
||||
maturityDate: '18-09-2026'
|
||||
},
|
||||
{
|
||||
scriptCode: 'TBILL-364D',
|
||||
scriptName: '364 Days Treasury Bill',
|
||||
instrumentType: 'GOVT_SECURITY',
|
||||
isin: '',
|
||||
faceValuePerUnit: 100
|
||||
faceValuePerUnit: 100,
|
||||
slrType: 'TREASURY',
|
||||
glCode: '100701',
|
||||
couponRate: 0,
|
||||
issueDate: '02-07-2026',
|
||||
maturityDate: '02-07-2027'
|
||||
},
|
||||
{
|
||||
scriptCode: 'TBILL-91D',
|
||||
scriptName: '91 Days Treasury Bill',
|
||||
instrumentType: 'GOVT_SECURITY',
|
||||
isin: '',
|
||||
faceValuePerUnit: 100,
|
||||
slrType: 'TREASURY',
|
||||
glCode: '100701',
|
||||
couponRate: 0,
|
||||
issueDate: '15-05-2026',
|
||||
maturityDate: '13-08-2026'
|
||||
},
|
||||
{
|
||||
scriptCode: 'REC-7.5-2031',
|
||||
scriptName: 'REC Ltd Bond 7.50% 2031',
|
||||
instrumentType: 'NON_SLR',
|
||||
isin: 'INE020B08JN0',
|
||||
faceValuePerUnit: 100
|
||||
faceValuePerUnit: 100,
|
||||
slrType: 'NON_SLR',
|
||||
glCode: '100703',
|
||||
couponRate: 7.5,
|
||||
issueDate: '11-08-2021',
|
||||
maturityDate: '11-08-2031',
|
||||
companyName: 'REC Limited',
|
||||
city: 'New Delhi'
|
||||
},
|
||||
{
|
||||
scriptCode: 'NABARD-7.35-2030',
|
||||
scriptName: 'NABARD Bond 7.35% 2030',
|
||||
instrumentType: 'NON_SLR',
|
||||
isin: 'INE261F09EU1',
|
||||
faceValuePerUnit: 100
|
||||
faceValuePerUnit: 100,
|
||||
slrType: 'NON_SLR',
|
||||
glCode: '100703',
|
||||
couponRate: 7.35,
|
||||
issueDate: '20-09-2020',
|
||||
maturityDate: '20-09-2030',
|
||||
companyName: 'NABARD',
|
||||
city: 'Mumbai'
|
||||
},
|
||||
{
|
||||
scriptCode: 'LTF-8.1-2028',
|
||||
scriptName: 'L&T Finance NCD 8.10% 2028',
|
||||
instrumentType: 'BOND',
|
||||
isin: 'INE027E07BR5',
|
||||
faceValuePerUnit: 100
|
||||
faceValuePerUnit: 100,
|
||||
slrType: 'NON_SLR',
|
||||
glCode: '100703',
|
||||
couponRate: 8.1,
|
||||
companyName: 'L&T Finance Ltd',
|
||||
city: 'Mumbai'
|
||||
},
|
||||
{
|
||||
scriptCode: 'BAF-7.95-2027',
|
||||
scriptName: 'Bajaj Finance NCD 7.95% 2027',
|
||||
instrumentType: 'BOND',
|
||||
isin: 'INE296A07SP0',
|
||||
faceValuePerUnit: 100
|
||||
faceValuePerUnit: 100,
|
||||
slrType: 'NON_SLR',
|
||||
glCode: '100703',
|
||||
couponRate: 7.95,
|
||||
companyName: 'Bajaj Finance Ltd',
|
||||
city: 'Pune'
|
||||
},
|
||||
{
|
||||
scriptCode: 'SBI-OVN',
|
||||
scriptName: 'SBI Overnight Fund',
|
||||
instrumentType: 'MUTUAL_FUND',
|
||||
isin: 'INF200KA1Q40',
|
||||
faceValuePerUnit: 1000
|
||||
faceValuePerUnit: 1000,
|
||||
slrType: 'NON_SLR',
|
||||
glCode: '100705',
|
||||
companyName: 'SBI Mutual Fund',
|
||||
city: 'Mumbai'
|
||||
},
|
||||
{
|
||||
scriptCode: 'HDFC-LIQ',
|
||||
scriptName: 'HDFC Liquid Fund',
|
||||
instrumentType: 'MUTUAL_FUND',
|
||||
isin: 'INF179KA1WR9',
|
||||
faceValuePerUnit: 1000
|
||||
faceValuePerUnit: 1000,
|
||||
slrType: 'NON_SLR',
|
||||
glCode: '100705',
|
||||
companyName: 'HDFC Mutual Fund',
|
||||
city: 'Mumbai'
|
||||
},
|
||||
{
|
||||
scriptCode: 'SBIN',
|
||||
scriptName: 'State Bank of India',
|
||||
instrumentType: 'SHARE',
|
||||
isin: 'INE062A01020',
|
||||
faceValuePerUnit: 1
|
||||
faceValuePerUnit: 1,
|
||||
slrType: 'NON_SLR',
|
||||
glCode: '100706'
|
||||
},
|
||||
{
|
||||
scriptCode: 'HDFCBANK',
|
||||
scriptName: 'HDFC Bank Ltd',
|
||||
instrumentType: 'SHARE',
|
||||
isin: 'INE040A01034',
|
||||
faceValuePerUnit: 1
|
||||
faceValuePerUnit: 1,
|
||||
slrType: 'NON_SLR',
|
||||
glCode: '100706'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -1561,23 +1886,157 @@ export const BROKER_MASTER: BrokerMaster[] = [
|
||||
];
|
||||
|
||||
export interface PartyMaster {
|
||||
/** Backend id once the party is saved (absent on the sample rows). */
|
||||
id?: number;
|
||||
partyCode: string;
|
||||
partyName: string;
|
||||
partyType: 'BANK' | 'CORPORATE' | 'GOVT' | 'AMC' | 'EXCHANGE' | 'COOP_SOCIETY';
|
||||
panNo: string;
|
||||
panNo?: string;
|
||||
// Fields below follow the bank's existing CBS Bank / Party Master.
|
||||
bondType?: InstrumentType;
|
||||
address1?: string;
|
||||
address2?: string;
|
||||
address3?: string;
|
||||
city?: string;
|
||||
pincode?: string;
|
||||
phone?: string;
|
||||
fax?: string;
|
||||
bankType?: string;
|
||||
/** GL this party is mapped to. */
|
||||
glAccountId?: number;
|
||||
glCode?: string;
|
||||
glName?: string;
|
||||
/** Interest income GL of the FDs placed with this bank (Bond Type FD). */
|
||||
interestGlAccountId?: number;
|
||||
interestGlCode?: string;
|
||||
interestGlName?: string;
|
||||
/** Whether this bank deducts TDS from FD interest (some do, some do not), and at what rate (%). */
|
||||
tdsApplicable?: boolean;
|
||||
tdsRate?: number | null;
|
||||
rbiCode?: string;
|
||||
/** Exposure limit for this party (₹). */
|
||||
limit?: number | null;
|
||||
leiNo?: string;
|
||||
// Counterparty's bank details
|
||||
cpBankName?: string;
|
||||
cpBranchName?: string;
|
||||
cpAccountNo?: string;
|
||||
cpIfsc?: string;
|
||||
/** Absent means ACTIVE. An INACTIVE party is not offered for new deals. */
|
||||
status?: 'ACTIVE' | 'INACTIVE';
|
||||
}
|
||||
|
||||
/** Sample Bank Type options until the bank confirms its list. */
|
||||
export const BANK_TYPE_OPTIONS = [
|
||||
'SCHEDULED COMMERCIAL BANK',
|
||||
'STATE CO-OPERATIVE BANK',
|
||||
'DISTRICT CENTRAL CO-OPERATIVE BANK',
|
||||
'URBAN CO-OPERATIVE BANK',
|
||||
'RBI / GOVERNMENT',
|
||||
'OTHER'
|
||||
];
|
||||
|
||||
export const PARTY_MASTER: PartyMaster[] = [
|
||||
{ partyCode: 'PTY-001', partyName: 'Jila Sahakari Kendriya Bank', partyType: 'BANK', panNo: 'AABCJ1234K' },
|
||||
{ partyCode: 'PTY-002', partyName: 'State Cooperative Bank Ltd', partyType: 'BANK', panNo: 'AABCS5678L' },
|
||||
{ partyCode: 'PTY-003', partyName: 'RBI - SGL', partyType: 'GOVT', panNo: 'AAACR4849R' },
|
||||
{ partyCode: 'PTY-004', partyName: 'REC Limited', partyType: 'CORPORATE', panNo: 'AAACR0641G' },
|
||||
{ partyCode: 'PTY-005', partyName: 'NABARD', partyType: 'CORPORATE', panNo: 'AAACN0272B' },
|
||||
{ partyCode: 'PTY-006', partyName: 'L&T Finance Ltd', partyType: 'CORPORATE', panNo: 'AAACL0140P' },
|
||||
{ partyCode: 'PTY-007', partyName: 'SBI Mutual Fund', partyType: 'AMC', panNo: 'AAATS4325L' },
|
||||
{ partyCode: 'PTY-008', partyName: 'HDFC Mutual Fund', partyType: 'AMC', panNo: 'AAATH1234M' },
|
||||
{ partyCode: 'PTY-009', partyName: 'NSE/BSE', partyType: 'EXCHANGE', panNo: 'AAACN0028G' },
|
||||
{ partyCode: 'PTY-010', partyName: 'District Cooperative Bank', partyType: 'COOP_SOCIETY', panNo: 'AABCD9988K' }
|
||||
{
|
||||
partyCode: 'PTY-001',
|
||||
partyName: 'Jila Sahakari Kendriya Bank',
|
||||
partyType: 'BANK',
|
||||
bondType: 'FD',
|
||||
glCode: '100704',
|
||||
city: 'Vidisha',
|
||||
bankType: 'DISTRICT CENTRAL CO-OPERATIVE BANK',
|
||||
status: 'ACTIVE'
|
||||
},
|
||||
{
|
||||
partyCode: 'PTY-002',
|
||||
partyName: 'State Cooperative Bank Ltd',
|
||||
partyType: 'BANK',
|
||||
bondType: 'FD',
|
||||
glCode: '100704',
|
||||
city: 'Bhopal',
|
||||
bankType: 'STATE CO-OPERATIVE BANK',
|
||||
status: 'ACTIVE'
|
||||
},
|
||||
{
|
||||
partyCode: 'PTY-003',
|
||||
partyName: 'RBI - SGL',
|
||||
partyType: 'GOVT',
|
||||
bondType: 'GOVT_SECURITY',
|
||||
glCode: '100701',
|
||||
city: 'Mumbai',
|
||||
bankType: 'RBI / GOVERNMENT',
|
||||
status: 'ACTIVE'
|
||||
},
|
||||
{
|
||||
partyCode: 'PTY-004',
|
||||
partyName: 'REC Limited',
|
||||
partyType: 'CORPORATE',
|
||||
bondType: 'NON_SLR',
|
||||
glCode: '100703',
|
||||
city: 'New Delhi',
|
||||
bankType: 'OTHER',
|
||||
status: 'ACTIVE'
|
||||
},
|
||||
{
|
||||
partyCode: 'PTY-005',
|
||||
partyName: 'NABARD',
|
||||
partyType: 'CORPORATE',
|
||||
bondType: 'NON_SLR',
|
||||
glCode: '100703',
|
||||
city: 'Mumbai',
|
||||
bankType: 'OTHER',
|
||||
status: 'ACTIVE'
|
||||
},
|
||||
{
|
||||
partyCode: 'PTY-006',
|
||||
partyName: 'L&T Finance Ltd',
|
||||
partyType: 'CORPORATE',
|
||||
bondType: 'BOND',
|
||||
glCode: '100703',
|
||||
city: 'Mumbai',
|
||||
bankType: 'OTHER',
|
||||
status: 'ACTIVE'
|
||||
},
|
||||
{
|
||||
partyCode: 'PTY-007',
|
||||
partyName: 'SBI Mutual Fund',
|
||||
partyType: 'AMC',
|
||||
bondType: 'MUTUAL_FUND',
|
||||
glCode: '100705',
|
||||
city: 'Mumbai',
|
||||
bankType: 'OTHER',
|
||||
status: 'ACTIVE'
|
||||
},
|
||||
{
|
||||
partyCode: 'PTY-008',
|
||||
partyName: 'HDFC Mutual Fund',
|
||||
partyType: 'AMC',
|
||||
bondType: 'MUTUAL_FUND',
|
||||
glCode: '100705',
|
||||
city: 'Mumbai',
|
||||
bankType: 'OTHER',
|
||||
status: 'ACTIVE'
|
||||
},
|
||||
{
|
||||
partyCode: 'PTY-009',
|
||||
partyName: 'NSE/BSE',
|
||||
partyType: 'EXCHANGE',
|
||||
bondType: 'SHARE',
|
||||
glCode: '100706',
|
||||
city: 'Mumbai',
|
||||
bankType: 'OTHER',
|
||||
status: 'ACTIVE'
|
||||
},
|
||||
{
|
||||
partyCode: 'PTY-010',
|
||||
partyName: 'District Cooperative Bank',
|
||||
partyType: 'COOP_SOCIETY',
|
||||
bondType: 'SHARE',
|
||||
glCode: '100706',
|
||||
city: 'Vidisha',
|
||||
bankType: 'DISTRICT CENTRAL CO-OPERATIVE BANK',
|
||||
status: 'ACTIVE'
|
||||
}
|
||||
];
|
||||
|
||||
export interface SubTypeMaster {
|
||||
@@ -1725,13 +2184,15 @@ export const LIEN_REASON_MASTER: LienReason[] = [
|
||||
/** Daily Script Master (legacy menu "09") — the day's quoted rate per script, entered/imported
|
||||
* every working day. Distinct from Script Market Detail ("13") which also carries YTM/valuation. */
|
||||
export interface DailyScriptRate {
|
||||
/** Backend id once saved. */
|
||||
id?: number;
|
||||
rateDate: string;
|
||||
scriptCode: string;
|
||||
scriptName: string;
|
||||
openRate: number;
|
||||
openRate: number | null;
|
||||
closeRate: number;
|
||||
highRate: number;
|
||||
lowRate: number;
|
||||
highRate: number | null;
|
||||
lowRate: number | null;
|
||||
}
|
||||
|
||||
export const DAILY_SCRIPT_RATES: DailyScriptRate[] = [
|
||||
|
||||
@@ -106,38 +106,136 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- PARTY MASTER -->
|
||||
<!-- PARTY MASTER (layout follows the bank's existing CBS Bank / Party Master) -->
|
||||
@if (activeTab === 'party') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Add Bank / Party</div>
|
||||
<div class="pos-section-label">Bank / Party Master — Entry</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field"
|
||||
><span>Party Name *</span><input type="text" [(ngModel)]="newParty.partyName"
|
||||
/></label>
|
||||
<label class="inv-form-field">
|
||||
<span>Party Type *</span>
|
||||
<select [(ngModel)]="newParty.partyType">
|
||||
<span>* Bond Type</span>
|
||||
<select [(ngModel)]="partyForm.bondType">
|
||||
<option [ngValue]="undefined">-- Select --</option>
|
||||
<option value="BANK">BANK</option>
|
||||
<option value="CORPORATE">CORPORATE</option>
|
||||
<option value="GOVT">GOVT</option>
|
||||
<option value="AMC">AMC</option>
|
||||
<option value="EXCHANGE">EXCHANGE</option>
|
||||
<option value="COOP_SOCIETY">COOP SOCIETY</option>
|
||||
@for (t of instrumentTypes; track t) {
|
||||
<option [value]="t">{{ t }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field"><span>PAN No</span><input type="text" [(ngModel)]="newParty.panNo" /></label>
|
||||
<label class="inv-form-field"
|
||||
><span>* Party Code</span><input type="text" [value]="partyForm.partyCode || nextPartyCode" readonly
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>* Party Name</span><input type="text" [(ngModel)]="partyForm.partyName"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Address Line 1 (Building / Flat No.)</span
|
||||
><input type="text" placeholder="e.g. Shop No. 12, Shanti Complex" [(ngModel)]="partyForm.address1"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Address Line 2 (Street / Area)</span
|
||||
><input type="text" placeholder="e.g. MG Road, Near Bus Stand" [(ngModel)]="partyForm.address2"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Address Line 3 (Locality / Landmark)</span
|
||||
><input type="text" placeholder="e.g. Arera Colony" [(ngModel)]="partyForm.address3"
|
||||
/></label>
|
||||
<label class="inv-form-field"><span>City</span><input type="text" [(ngModel)]="partyForm.city" /></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Pincode</span><input type="text" maxlength="6" [(ngModel)]="partyForm.pincode"
|
||||
/></label>
|
||||
<label class="inv-form-field"><span>Phone</span><input type="text" [(ngModel)]="partyForm.phone" /></label>
|
||||
<label class="inv-form-field"><span>Fax</span><input type="text" [(ngModel)]="partyForm.fax" /></label>
|
||||
<label class="inv-form-field">
|
||||
<span>Bank Type</span>
|
||||
<select [(ngModel)]="partyForm.bankType">
|
||||
<option [ngValue]="undefined">-- Select --</option>
|
||||
@for (b of bankTypeOptions; track b) {
|
||||
<option [value]="b">{{ b }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>* GL A/c No.</span>
|
||||
<select [(ngModel)]="partyForm.glAccountId">
|
||||
<option [ngValue]="undefined">-- Select --</option>
|
||||
@for (g of partyGlOptions; track g.id) {
|
||||
<option [ngValue]="g.id">{{ g.glCode }} — {{ g.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
@if (partyForm.bondType === 'FD') {
|
||||
<label class="inv-form-field">
|
||||
<span>* Interest GL</span>
|
||||
<select [(ngModel)]="partyForm.interestGlAccountId">
|
||||
<option [ngValue]="undefined">-- Select --</option>
|
||||
@for (g of interestGlOptions; track g.id) {
|
||||
<option [ngValue]="g.id">{{ g.glCode }} — {{ g.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Bank deducts TDS on FD interest?</span>
|
||||
<select [ngModel]="partyForm.tdsApplicable ?? false" (ngModelChange)="partyForm.tdsApplicable = $event">
|
||||
<option [ngValue]="false">No</option>
|
||||
<option [ngValue]="true">Yes</option>
|
||||
</select>
|
||||
</label>
|
||||
@if (partyForm.tdsApplicable) {
|
||||
<label class="inv-form-field"
|
||||
><span>* TDS rate (%)</span
|
||||
><input type="number" min="0" max="99" step="0.01" [(ngModel)]="partyForm.tdsRate" placeholder="e.g. 10"
|
||||
/></label>
|
||||
}
|
||||
}
|
||||
<label class="inv-form-field"><span>RBI Code</span><input type="text" [(ngModel)]="partyForm.rbiCode" /></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Limit (₹)</span><input type="number" min="0" [(ngModel)]="partyForm.limit"
|
||||
/></label>
|
||||
<label class="inv-form-field"><span>LEI No</span><input type="text" [(ngModel)]="partyForm.leiNo" /></label>
|
||||
<label class="inv-form-field">
|
||||
<span>Status</span>
|
||||
<select [(ngModel)]="partyForm.status">
|
||||
<option [ngValue]="undefined">ACTIVE</option>
|
||||
<option value="INACTIVE">INACTIVE</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="pos-section-label">Counterparty's Bank Details</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field"
|
||||
><span>Name of Bank</span><input type="text" [(ngModel)]="partyForm.cpBankName"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Name of Branch</span><input type="text" [(ngModel)]="partyForm.cpBranchName"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Account No.</span><input type="text" [(ngModel)]="partyForm.cpAccountNo"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>IFSC</span><input type="text" maxlength="11" [(ngModel)]="partyForm.cpIfsc"
|
||||
/></label>
|
||||
</div>
|
||||
<div class="inv-form-actions">
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="addParty()">Save</button>
|
||||
<label class="inv-form-field"
|
||||
><span>Search Party Code</span><input type="text" placeholder="e.g. PTY-001" [(ngModel)]="partySearchCode"
|
||||
/></label>
|
||||
<button type="button" class="pg-btn pg-btn--primary" [disabled]="saving" (click)="saveParty()">Save</button>
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="searchParty()">Search</button>
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="cancelParty()">Cancel</button>
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="dropParty()">Drop</button>
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="cancelParty()">Exit</button>
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Party Code</th>
|
||||
<th>Bond Type</th>
|
||||
<th>Party Name</th>
|
||||
<th>Type</th>
|
||||
<th>PAN No</th>
|
||||
<th>City</th>
|
||||
<th>Bank Type</th>
|
||||
<th>GL A/c</th>
|
||||
<th class="num">Limit</th>
|
||||
<th>Counterparty Bank / IFSC</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -146,9 +244,14 @@
|
||||
<td>
|
||||
<span class="pg-chip">{{ p.partyCode }}</span>
|
||||
</td>
|
||||
<td>{{ p.bondType || '—' }}</td>
|
||||
<td>{{ p.partyName }}</td>
|
||||
<td>{{ p.partyType }}</td>
|
||||
<td>{{ p.panNo }}</td>
|
||||
<td>{{ p.city || '—' }}</td>
|
||||
<td>{{ p.bankType || '—' }}</td>
|
||||
<td>{{ p.glCode ? p.glCode + ' — ' + glName(p.glCode) : '—' }}</td>
|
||||
<td class="num">{{ (p.limit ?? null) !== null ? '₹' + (p.limit | number: '1.0-0') : '—' }}</td>
|
||||
<td>{{ p.cpBankName ? p.cpBankName + (p.cpIfsc ? ' / ' + p.cpIfsc : '') : '—' }}</td>
|
||||
<td>{{ p.status ?? 'ACTIVE' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
@@ -156,42 +259,118 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- SCRIPT MASTER -->
|
||||
<!-- SCRIPT MASTER (layout follows the bank's existing CBS Script Master) -->
|
||||
@if (activeTab === 'script') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Add Script</div>
|
||||
<div class="pos-section-label">Script Master — Entry</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field"
|
||||
><span>Script Code</span><input type="text" [(ngModel)]="newScript.scriptCode"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Script Name *</span><input type="text" [(ngModel)]="newScript.scriptName"
|
||||
/></label>
|
||||
<label class="inv-form-field">
|
||||
<span>Instrument Type *</span>
|
||||
<select [(ngModel)]="newScript.instrumentType">
|
||||
<span>* Bond Type</span>
|
||||
<select [(ngModel)]="scriptForm.instrumentType" [disabled]="!!scriptForm.scriptCode">
|
||||
<option [ngValue]="undefined">-- Select --</option>
|
||||
@for (t of instrumentTypes; track t) {
|
||||
<option [value]="t">{{ t }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field"><span>ISIN</span><input type="text" [(ngModel)]="newScript.isin" /></label>
|
||||
<label class="inv-form-field">
|
||||
<span>SLR</span>
|
||||
<select [(ngModel)]="scriptForm.slrType">
|
||||
<option [ngValue]="undefined">-- Select --</option>
|
||||
@for (o of slrTypeOptions; track o.value) {
|
||||
<option [value]="o.value">{{ o.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field"
|
||||
><span>Face Value / Unit</span><input type="number" [(ngModel)]="newScript.faceValuePerUnit"
|
||||
><span>* Script Code</span><input type="text" [value]="scriptForm.scriptCode || nextScriptCode" readonly
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>* Script Desc</span
|
||||
><input type="text" placeholder="e.g. 7.50%GOI2034" [(ngModel)]="scriptForm.scriptName"
|
||||
/></label>
|
||||
<label class="inv-form-field">
|
||||
<span>* GL A/c No.</span>
|
||||
<select [(ngModel)]="scriptForm.glAccountId">
|
||||
<option [ngValue]="undefined">-- Select --</option>
|
||||
@for (g of scriptGlOptions; track g.id) {
|
||||
<option [ngValue]="g.id">{{ g.glCode }} — {{ g.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field"
|
||||
><span>{{ scriptIsDebtSecurity ? '* ' : '' }}ISIN</span><input type="text" [(ngModel)]="scriptForm.isin"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>{{ scriptIsDebtSecurity ? '* ' : '' }}Int Rate %</span
|
||||
><input type="number" step="0.01" min="0" [(ngModel)]="scriptForm.couponRate"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>{{ scriptIsDebtSecurity ? '* ' : '' }}Issue Date</span
|
||||
><mifosx-date-ddmmyyyy [(ngModel)]="scriptForm.issueDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"
|
||||
><span>{{ scriptIsDebtSecurity ? '* ' : '' }}Redum / Mat Date</span
|
||||
><mifosx-date-ddmmyyyy [(ngModel)]="scriptForm.maturityDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Period (Year)</span><input type="text" [value]="scriptPeriod.years" readonly
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Period (Month)</span><input type="text" [value]="scriptPeriod.months" readonly
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Period (Days)</span><input type="text" [value]="scriptPeriod.days" readonly
|
||||
/></label>
|
||||
</div>
|
||||
<div class="pos-section-label">Company Details (Bond / Mutual Fund)</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field"
|
||||
><span>Company Name</span><input type="text" [(ngModel)]="scriptForm.companyName"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Address Line 1 (Building / Flat No.)</span
|
||||
><input type="text" placeholder="e.g. Shop No. 12, Shanti Complex" [(ngModel)]="scriptForm.address1"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Address Line 2 (Street / Area)</span
|
||||
><input type="text" placeholder="e.g. MG Road, Near Bus Stand" [(ngModel)]="scriptForm.address2"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Address Line 3 (Locality / Landmark)</span
|
||||
><input type="text" placeholder="e.g. Arera Colony" [(ngModel)]="scriptForm.address3"
|
||||
/></label>
|
||||
<label class="inv-form-field"><span>City</span><input type="text" [(ngModel)]="scriptForm.city" /></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Pincode</span><input type="text" maxlength="6" [(ngModel)]="scriptForm.pincode"
|
||||
/></label>
|
||||
<label class="inv-form-field"><span>Phone</span><input type="text" [(ngModel)]="scriptForm.phone" /></label>
|
||||
<label class="inv-form-field"><span>Fax</span><input type="text" [(ngModel)]="scriptForm.fax" /></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Corp. Code</span><input type="text" [(ngModel)]="scriptForm.corpCode"
|
||||
/></label>
|
||||
</div>
|
||||
<div class="inv-form-actions">
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="addScript()">Save</button>
|
||||
<label class="inv-form-field"
|
||||
><span>Search Script Code</span><input type="text" placeholder="e.g. G00001" [(ngModel)]="scriptSearchCode"
|
||||
/></label>
|
||||
<button type="button" class="pg-btn pg-btn--primary" [disabled]="saving" (click)="saveScript()">Save</button>
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="searchScript()">Search</button>
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="cancelScript()">Cancel</button>
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="cancelScript()">Exit</button>
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Script Code</th>
|
||||
<th>Script Name</th>
|
||||
<th>Instrument</th>
|
||||
<th>Bond Type</th>
|
||||
<th>SLR</th>
|
||||
<th>Script Desc</th>
|
||||
<th>GL A/c</th>
|
||||
<th>ISIN</th>
|
||||
<th class="num">Face Value/Unit</th>
|
||||
<th class="num">Int Rate %</th>
|
||||
<th>Issue Date</th>
|
||||
<th>Maturity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -200,10 +379,14 @@
|
||||
<td>
|
||||
<span class="pg-chip">{{ s.scriptCode }}</span>
|
||||
</td>
|
||||
<td>{{ s.scriptName }}</td>
|
||||
<td>{{ s.instrumentType }}</td>
|
||||
<td>{{ s.slrType || '—' }}</td>
|
||||
<td>{{ s.scriptName }}</td>
|
||||
<td>{{ s.glCode || '—' }}</td>
|
||||
<td>{{ s.isin || '—' }}</td>
|
||||
<td class="num">{{ s.faceValuePerUnit }}</td>
|
||||
<td class="num">{{ s.couponRate ?? '—' }}</td>
|
||||
<td>{{ s.issueDate || '—' }}</td>
|
||||
<td>{{ s.maturityDate || '—' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
@@ -386,10 +569,11 @@
|
||||
<th class="num">High</th>
|
||||
<th class="num">Low</th>
|
||||
<th class="num">Close</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of dailyScriptRates; track r.rateDate + r.scriptCode) {
|
||||
@for (r of dailyScriptRates; track r.id ?? r.rateDate + r.scriptCode) {
|
||||
<tr>
|
||||
<td>{{ r.rateDate }}</td>
|
||||
<td>
|
||||
@@ -400,6 +584,13 @@
|
||||
<td class="num">{{ r.highRate | number: '1.2-2' }}</td>
|
||||
<td class="num">{{ r.lowRate | number: '1.2-2' }}</td>
|
||||
<td class="num">{{ r.closeRate | number: '1.2-2' }}</td>
|
||||
<td>
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="dropDailyRate(r)">Drop</button>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="8" class="pg-empty">No rates entered yet.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
|
||||
@@ -6,20 +6,17 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
|
||||
import {
|
||||
SCRIPT_MASTER,
|
||||
BROKER_MASTER,
|
||||
PARTY_MASTER,
|
||||
SUBTYPE_MASTER,
|
||||
CONTROL_PARAMETERS,
|
||||
MUTUAL_FUND_PRODUCTS,
|
||||
SGL_BANK_MASTER,
|
||||
SCRIPT_MARKET_DETAIL,
|
||||
DAILY_SCRIPT_RATES,
|
||||
LIEN_REASON_MASTER,
|
||||
ScriptMaster,
|
||||
BrokerMaster,
|
||||
@@ -31,9 +28,14 @@ import {
|
||||
ScriptMarketDetail,
|
||||
DailyScriptRate,
|
||||
LienReason,
|
||||
InstrumentType
|
||||
InstrumentType,
|
||||
BANK_TYPE_OPTIONS,
|
||||
SLR_TYPE_OPTIONS,
|
||||
INVESTMENT_GL_LOOKUP,
|
||||
periodBetween
|
||||
} from '../investment-data';
|
||||
import { exportWorkbook, ExportSheetSpec } from '../investment-export';
|
||||
import { InvestmentGlAccount, InvestmentService, apiErrorMessage, toDailyScriptRate } from '../investment.service';
|
||||
|
||||
type MasterTab =
|
||||
| 'party'
|
||||
@@ -51,6 +53,10 @@ type MasterTab =
|
||||
* Investment master-data screens (legacy "04-13" Entry submenu masters) — 9 simple dummy
|
||||
* lists, each with an inline Add-row form so the demo stays interactive without a backend.
|
||||
*/
|
||||
/** Detail GLs under the 100700 INVESTMENTS head; 100707 holds premium only, never a party's or script's GL. */
|
||||
const INVESTMENT_GL_HEAD_PREFIX = '1007';
|
||||
const PREMIUM_ON_INVESTMENTS_GL_CODE = '100707';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-investment-masters',
|
||||
templateUrl: './investment-masters.component.html',
|
||||
@@ -65,18 +71,19 @@ type MasterTab =
|
||||
DateDdmmyyyyPickerComponent
|
||||
]
|
||||
})
|
||||
export class InvestmentMastersComponent {
|
||||
export class InvestmentMastersComponent implements OnInit {
|
||||
activeTab: MasterTab = 'party';
|
||||
|
||||
scripts: ScriptMaster[] = [...SCRIPT_MASTER];
|
||||
scripts: ScriptMaster[] = [];
|
||||
brokers: BrokerMaster[] = [...BROKER_MASTER];
|
||||
parties: PartyMaster[] = [...PARTY_MASTER];
|
||||
parties: PartyMaster[] = [];
|
||||
subTypes: SubTypeMaster[] = [...SUBTYPE_MASTER];
|
||||
controlParams: ControlParameter[] = [...CONTROL_PARAMETERS];
|
||||
mfProducts: MutualFundProductMaster[] = [...MUTUAL_FUND_PRODUCTS];
|
||||
sglBanks: SglBankMaster[] = [...SGL_BANK_MASTER];
|
||||
marketDetails: ScriptMarketDetail[] = [...SCRIPT_MARKET_DETAIL];
|
||||
dailyScriptRates: DailyScriptRate[] = [...DAILY_SCRIPT_RATES];
|
||||
/** Loaded from the backend (Daily Script Master) in ngOnInit. */
|
||||
dailyScriptRates: DailyScriptRate[] = [];
|
||||
lienReasons: LienReason[] = [...LIEN_REASON_MASTER];
|
||||
|
||||
readonly instrumentTypes: InstrumentType[] = [
|
||||
@@ -106,8 +113,21 @@ export class InvestmentMastersComponent {
|
||||
toastVisible = false;
|
||||
private toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
newParty: Partial<PartyMaster> = {};
|
||||
newScript: Partial<ScriptMaster> = {};
|
||||
// Bank / Party Master and Script Master follow the bank's existing CBS screens (one entry form
|
||||
// with Save / Search / Cancel / Drop / Exit) and are stored in the backend through
|
||||
// InvestmentService; the other master tabs are still sample data.
|
||||
private readonly investmentService = inject(InvestmentService);
|
||||
partyForm: Partial<PartyMaster> = {};
|
||||
partySearchCode = '';
|
||||
scriptForm: Partial<ScriptMaster> = {};
|
||||
scriptSearchCode = '';
|
||||
saving = false;
|
||||
readonly bankTypeOptions = BANK_TYPE_OPTIONS;
|
||||
readonly slrTypeOptions = SLR_TYPE_OPTIONS;
|
||||
/** Investment GLs (under the 100700 INVESTMENTS head) a party or script can be mapped to — not loans, banks or test GLs. */
|
||||
investmentGlOptions: InvestmentGlAccount[] = [];
|
||||
/** Income GLs under 400400 INCOME FROM INVESTMENTS, for an FD bank's interest. */
|
||||
interestGlOptions: InvestmentGlAccount[] = [];
|
||||
newSubType: Partial<SubTypeMaster> = {};
|
||||
newBroker: Partial<BrokerMaster> = {};
|
||||
newControlParam: Partial<ControlParameter> = {};
|
||||
@@ -116,43 +136,252 @@ export class InvestmentMastersComponent {
|
||||
newSglBank: Partial<SglBankMaster> = {};
|
||||
newDailyRate: Partial<DailyScriptRate> = {};
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadDailyRates();
|
||||
this.investmentService.getAssetGlAccounts().subscribe({
|
||||
next: (gls) =>
|
||||
(this.investmentGlOptions = gls.filter(
|
||||
(g) => g.glCode.startsWith(INVESTMENT_GL_HEAD_PREFIX) && g.glCode !== PREMIUM_ON_INVESTMENTS_GL_CODE
|
||||
)),
|
||||
error: (err) => this.toast(`Could not load GL accounts: ${apiErrorMessage(err)}`)
|
||||
});
|
||||
this.investmentService.getIncomeGlAccounts().subscribe({
|
||||
next: (gls) => (this.interestGlOptions = gls.filter((g) => g.glCode.startsWith('4004'))),
|
||||
error: (err) => this.toast(`Could not load income GLs: ${apiErrorMessage(err)}`)
|
||||
});
|
||||
this.reloadMasters();
|
||||
}
|
||||
|
||||
setTab(tab: MasterTab): void {
|
||||
this.activeTab = tab;
|
||||
}
|
||||
|
||||
addParty(): void {
|
||||
if (!this.newParty.partyName || !this.newParty.partyType) {
|
||||
this.toast('Enter Party Name and Type.');
|
||||
return;
|
||||
}
|
||||
this.parties = [
|
||||
{
|
||||
partyCode: `PTY-${String(this.parties.length + 1).padStart(3, '0')}`,
|
||||
panNo: '',
|
||||
...this.newParty
|
||||
} as PartyMaster,
|
||||
...this.parties
|
||||
];
|
||||
this.newParty = {};
|
||||
this.toast('Party/Bank master added.');
|
||||
/** Re-reads both masters from the backend (also refreshes the lists the Purchase screen uses). */
|
||||
private reloadMasters(): void {
|
||||
this.investmentService.syncSharedMasters().subscribe({
|
||||
next: ({ parties, scripts }) => {
|
||||
this.parties = parties;
|
||||
this.scripts = scripts;
|
||||
},
|
||||
error: (err) => this.toast(`Could not load masters: ${apiErrorMessage(err)}`)
|
||||
});
|
||||
}
|
||||
|
||||
addScript(): void {
|
||||
if (!this.newScript.scriptName || !this.newScript.instrumentType) {
|
||||
this.toast('Enter Script Name and Instrument Type.');
|
||||
glName(glCode?: string): string {
|
||||
return (
|
||||
this.investmentGlOptions.find((g) => g.glCode === glCode)?.name ??
|
||||
INVESTMENT_GL_LOOKUP.find((g) => g.glCode === glCode)?.glName ??
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
/** FD GLs are the bank-wise children of 100704 (codes 1007040x). */
|
||||
private static isFdGl(glCode: string): boolean {
|
||||
return glCode.startsWith('1007040');
|
||||
}
|
||||
|
||||
/** An FD bank is mapped to its own FD GL; any other party to a non-FD investment GL. */
|
||||
get partyGlOptions(): InvestmentGlAccount[] {
|
||||
const fd = this.partyForm.bondType === 'FD';
|
||||
return this.investmentGlOptions.filter((g) => InvestmentMastersComponent.isFdGl(g.glCode) === fd);
|
||||
}
|
||||
|
||||
/** Scripts are securities, never an FD with a bank. */
|
||||
get scriptGlOptions(): InvestmentGlAccount[] {
|
||||
return this.investmentGlOptions.filter((g) => !InvestmentMastersComponent.isFdGl(g.glCode));
|
||||
}
|
||||
|
||||
// -------------------- Bank / Party Master --------------------
|
||||
/** Preview of the code the backend will assign; the saved code always comes from the backend. */
|
||||
get nextPartyCode(): string {
|
||||
const nums = this.parties.map((p) => parseInt(p.partyCode.replace(/\D/g, ''), 10)).filter((n) => !isNaN(n));
|
||||
return `PTY-${String((nums.length ? Math.max(...nums) : 0) + 1).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
saveParty(): void {
|
||||
const f = this.partyForm;
|
||||
if (!f.bondType || !f.partyName || !f.glAccountId) {
|
||||
this.toast('Enter Bond Type, Party Name and GL A/c No.');
|
||||
return;
|
||||
}
|
||||
this.scripts = [
|
||||
{
|
||||
scriptCode: this.newScript.scriptCode || `SCR-${this.scripts.length + 1}`,
|
||||
isin: '',
|
||||
faceValuePerUnit: 100,
|
||||
...this.newScript
|
||||
} as ScriptMaster,
|
||||
...this.scripts
|
||||
];
|
||||
this.newScript = {};
|
||||
this.toast('Script master added.');
|
||||
if (f.bondType === 'FD' && !f.interestGlAccountId) {
|
||||
this.toast('An FD bank needs its Interest GL as well.');
|
||||
return;
|
||||
}
|
||||
const tds = f.bondType === 'FD' && !!f.tdsApplicable;
|
||||
if (tds && !(Number(f.tdsRate) > 0 && Number(f.tdsRate) < 100)) {
|
||||
this.toast('Enter the TDS rate (%) this bank deducts on FD interest.');
|
||||
return;
|
||||
}
|
||||
const body = {
|
||||
bondType: f.bondType,
|
||||
partyName: f.partyName,
|
||||
address1: f.address1 ?? '',
|
||||
address2: f.address2 ?? '',
|
||||
address3: f.address3 ?? '',
|
||||
city: f.city ?? '',
|
||||
pincode: f.pincode ?? '',
|
||||
phone: f.phone ?? '',
|
||||
fax: f.fax ?? '',
|
||||
bankType: f.bankType ?? '',
|
||||
glAccountId: Number(f.glAccountId),
|
||||
interestGlAccountId: f.bondType === 'FD' && f.interestGlAccountId ? Number(f.interestGlAccountId) : null,
|
||||
tdsApplicable: tds,
|
||||
tdsRate: tds ? Number(f.tdsRate) : null,
|
||||
rbiCode: f.rbiCode ?? '',
|
||||
limit: f.limit ?? null,
|
||||
leiNo: f.leiNo ?? '',
|
||||
cpBankName: f.cpBankName ?? '',
|
||||
cpBranchName: f.cpBranchName ?? '',
|
||||
cpAccountNo: f.cpAccountNo ?? '',
|
||||
cpIfsc: f.cpIfsc ?? '',
|
||||
active: f.status !== 'INACTIVE',
|
||||
locale: 'en'
|
||||
};
|
||||
const request = f.id ? this.investmentService.updateParty(f.id, body) : this.investmentService.createParty(body);
|
||||
this.saving = true;
|
||||
request.subscribe({
|
||||
next: (res) => {
|
||||
this.saving = false;
|
||||
this.toast(`Party ${res.resourceIdentifier ?? f.partyCode} ${f.id ? 'updated' : 'saved'}.`);
|
||||
this.partyForm = {};
|
||||
this.reloadMasters();
|
||||
},
|
||||
error: (err) => {
|
||||
this.saving = false;
|
||||
this.toast(apiErrorMessage(err));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
searchParty(): void {
|
||||
const p = this.parties.find((x) => x.partyCode.toUpperCase() === this.partySearchCode.trim().toUpperCase());
|
||||
if (!p) {
|
||||
this.toast(`Party Code ${this.partySearchCode} not found.`);
|
||||
return;
|
||||
}
|
||||
this.partyForm = { ...p };
|
||||
this.toast(`Party ${p.partyCode} loaded.`);
|
||||
}
|
||||
|
||||
cancelParty(): void {
|
||||
this.partyForm = {};
|
||||
this.partySearchCode = '';
|
||||
}
|
||||
|
||||
dropParty(): void {
|
||||
const f = this.partyForm;
|
||||
if (!f.id) {
|
||||
this.toast('Search a saved party first, then Drop.');
|
||||
return;
|
||||
}
|
||||
this.investmentService.deleteParty(f.id).subscribe({
|
||||
next: () => {
|
||||
this.toast(`Party ${f.partyCode} dropped.`);
|
||||
this.partyForm = {};
|
||||
this.reloadMasters();
|
||||
},
|
||||
error: (err) => this.toast(apiErrorMessage(err))
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------- Script Master --------------------
|
||||
/** Coupon / maturity terms only apply to debt securities. */
|
||||
get scriptIsDebtSecurity(): boolean {
|
||||
return [
|
||||
'GOVT_SECURITY',
|
||||
'NON_SLR',
|
||||
'BOND'
|
||||
].includes(this.scriptForm.instrumentType ?? '');
|
||||
}
|
||||
|
||||
/** Preview of the code the backend will assign — a type letter + running number, e.g. G00001. */
|
||||
get nextScriptCode(): string {
|
||||
const prefixByType: Record<InstrumentType, string> = {
|
||||
GOVT_SECURITY: 'G',
|
||||
NON_SLR: 'N',
|
||||
BOND: 'B',
|
||||
MUTUAL_FUND: 'M',
|
||||
FD: 'F',
|
||||
SHARE: 'S'
|
||||
};
|
||||
const type = this.scriptForm.instrumentType;
|
||||
if (!type) {
|
||||
return '';
|
||||
}
|
||||
const prefix = prefixByType[type];
|
||||
const pattern = new RegExp(`^${prefix}(\\d{5})$`);
|
||||
const nums = this.scripts
|
||||
.map((s) => pattern.exec(s.scriptCode)?.[1])
|
||||
.filter((n): n is string => !!n)
|
||||
.map((n) => parseInt(n, 10));
|
||||
return `${prefix}${String((nums.length ? Math.max(...nums) : 0) + 1).padStart(5, '0')}`;
|
||||
}
|
||||
|
||||
get scriptPeriod(): { years: number; months: number; days: number } {
|
||||
return periodBetween(this.scriptForm.issueDate, this.scriptForm.maturityDate);
|
||||
}
|
||||
|
||||
saveScript(): void {
|
||||
const f = this.scriptForm;
|
||||
if (!f.instrumentType || !f.scriptName || !f.glAccountId) {
|
||||
this.toast('Enter Bond Type, Script Desc and GL A/c No.');
|
||||
return;
|
||||
}
|
||||
if (this.scriptIsDebtSecurity && (!f.isin || f.couponRate == null || !f.issueDate || !f.maturityDate)) {
|
||||
this.toast('Enter ISIN, Int Rate, Issue Date and Maturity Date for this security.');
|
||||
return;
|
||||
}
|
||||
const body = {
|
||||
bondType: f.instrumentType,
|
||||
slrType: f.slrType ?? '',
|
||||
scriptDesc: f.scriptName,
|
||||
glAccountId: Number(f.glAccountId),
|
||||
isin: f.isin ?? '',
|
||||
intRate: f.couponRate ?? null,
|
||||
issueDate: f.issueDate ?? '',
|
||||
maturityDate: f.maturityDate ?? '',
|
||||
companyName: f.companyName ?? '',
|
||||
address1: f.address1 ?? '',
|
||||
address2: f.address2 ?? '',
|
||||
address3: f.address3 ?? '',
|
||||
city: f.city ?? '',
|
||||
pincode: f.pincode ?? '',
|
||||
phone: f.phone ?? '',
|
||||
fax: f.fax ?? '',
|
||||
corpCode: f.corpCode ?? '',
|
||||
dateFormat: 'dd-MM-yyyy',
|
||||
locale: 'en'
|
||||
};
|
||||
const request = f.id ? this.investmentService.updateScript(f.id, body) : this.investmentService.createScript(body);
|
||||
this.saving = true;
|
||||
request.subscribe({
|
||||
next: (res) => {
|
||||
this.saving = false;
|
||||
this.toast(`Script ${res.resourceIdentifier ?? f.scriptCode} ${f.id ? 'updated' : 'saved'}.`);
|
||||
this.scriptForm = {};
|
||||
this.reloadMasters();
|
||||
},
|
||||
error: (err) => {
|
||||
this.saving = false;
|
||||
this.toast(apiErrorMessage(err));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
searchScript(): void {
|
||||
const s = this.scripts.find((x) => x.scriptCode.toUpperCase() === this.scriptSearchCode.trim().toUpperCase());
|
||||
if (!s) {
|
||||
this.toast(`Script Code ${this.scriptSearchCode} not found.`);
|
||||
return;
|
||||
}
|
||||
this.scriptForm = { ...s };
|
||||
this.toast(`Script ${s.scriptCode} loaded.`);
|
||||
}
|
||||
|
||||
cancelScript(): void {
|
||||
this.scriptForm = {};
|
||||
this.scriptSearchCode = '';
|
||||
}
|
||||
|
||||
addSubType(): void {
|
||||
@@ -248,24 +477,56 @@ export class InvestmentMastersComponent {
|
||||
this.toast('SGL Bank master added.');
|
||||
}
|
||||
|
||||
private loadDailyRates(): void {
|
||||
this.investmentService.getDailyRates().subscribe({
|
||||
next: (rows) => (this.dailyScriptRates = rows.map(toDailyScriptRate)),
|
||||
error: (err) => this.toast(`Could not load daily script rates: ${apiErrorMessage(err)}`)
|
||||
});
|
||||
}
|
||||
|
||||
addDailyRate(): void {
|
||||
if (!this.newDailyRate.scriptCode || !this.newDailyRate.rateDate || this.newDailyRate.closeRate == null) {
|
||||
const r = this.newDailyRate;
|
||||
if (!r.scriptCode || !r.rateDate || r.closeRate == null) {
|
||||
this.toast('Enter Rate Date, Script Code and Close Rate.');
|
||||
return;
|
||||
}
|
||||
const script = this.scripts.find((s) => s.scriptCode === this.newDailyRate.scriptCode);
|
||||
this.dailyScriptRates = [
|
||||
{
|
||||
scriptName: script?.scriptName || '',
|
||||
openRate: this.newDailyRate.closeRate,
|
||||
highRate: this.newDailyRate.closeRate,
|
||||
lowRate: this.newDailyRate.closeRate,
|
||||
...this.newDailyRate
|
||||
} as DailyScriptRate,
|
||||
...this.dailyScriptRates
|
||||
];
|
||||
this.newDailyRate = {};
|
||||
this.toast('Daily script rate added.');
|
||||
const script = this.scripts.find((s) => s.scriptCode === r.scriptCode);
|
||||
if (!script?.id) {
|
||||
this.toast(`Script ${r.scriptCode} is not saved in Script Master.`);
|
||||
return;
|
||||
}
|
||||
const body = {
|
||||
scriptId: script.id,
|
||||
rateDate: r.rateDate,
|
||||
closeRate: r.closeRate,
|
||||
...(r.openRate != null ? { openRate: r.openRate } : {}),
|
||||
...(r.highRate != null ? { highRate: r.highRate } : {}),
|
||||
...(r.lowRate != null ? { lowRate: r.lowRate } : {}),
|
||||
dateFormat: 'dd-MM-yyyy',
|
||||
locale: 'en'
|
||||
};
|
||||
this.investmentService.createDailyRate(body).subscribe({
|
||||
next: () => {
|
||||
this.toast(`Rate for ${r.scriptCode} on ${r.rateDate} saved.`);
|
||||
this.newDailyRate = {};
|
||||
this.loadDailyRates();
|
||||
},
|
||||
error: (err) => this.toast(apiErrorMessage(err))
|
||||
});
|
||||
}
|
||||
|
||||
/** A wrong rate is corrected by dropping it and entering it again. */
|
||||
dropDailyRate(r: DailyScriptRate): void {
|
||||
if (!r.id) {
|
||||
return;
|
||||
}
|
||||
this.investmentService.deleteDailyRate(r.id).subscribe({
|
||||
next: () => {
|
||||
this.toast(`Rate for ${r.scriptCode} on ${r.rateDate} dropped.`);
|
||||
this.loadDailyRates();
|
||||
},
|
||||
error: (err) => this.toast(apiErrorMessage(err))
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------- Export to Excel --------------------
|
||||
@@ -279,46 +540,121 @@ export class InvestmentMastersComponent {
|
||||
return one(
|
||||
[
|
||||
'Party Code',
|
||||
'Bond Type',
|
||||
'Party Name',
|
||||
'Type',
|
||||
'PAN No'
|
||||
'Address',
|
||||
'City',
|
||||
'Pincode',
|
||||
'Phone',
|
||||
'Fax',
|
||||
'Bank Type',
|
||||
'GL A/c No',
|
||||
'RBI Code',
|
||||
'Limit',
|
||||
'LEI No',
|
||||
'Counterparty Bank',
|
||||
'Branch',
|
||||
'Account No',
|
||||
'IFSC',
|
||||
'Status'
|
||||
],
|
||||
[
|
||||
12,
|
||||
14,
|
||||
30,
|
||||
16,
|
||||
14
|
||||
36,
|
||||
14,
|
||||
10,
|
||||
14,
|
||||
14,
|
||||
24,
|
||||
12,
|
||||
12,
|
||||
14,
|
||||
22,
|
||||
24,
|
||||
20,
|
||||
18,
|
||||
14,
|
||||
10
|
||||
],
|
||||
this.parties.map((p) => [
|
||||
p.partyCode,
|
||||
p.bondType ?? '',
|
||||
p.partyName,
|
||||
p.partyType,
|
||||
p.panNo
|
||||
[
|
||||
p.address1,
|
||||
p.address2,
|
||||
p.address3
|
||||
]
|
||||
.filter((x) => !!x)
|
||||
.join(', '),
|
||||
p.city ?? '',
|
||||
p.pincode ?? '',
|
||||
p.phone ?? '',
|
||||
p.fax ?? '',
|
||||
p.bankType ?? '',
|
||||
p.glCode ?? '',
|
||||
p.rbiCode ?? '',
|
||||
p.limit ?? null,
|
||||
p.leiNo ?? '',
|
||||
p.cpBankName ?? '',
|
||||
p.cpBranchName ?? '',
|
||||
p.cpAccountNo ?? '',
|
||||
p.cpIfsc ?? '',
|
||||
p.status ?? 'ACTIVE'
|
||||
])
|
||||
);
|
||||
case 'script':
|
||||
return one(
|
||||
[
|
||||
'Script Code',
|
||||
'Script Name',
|
||||
'Instrument',
|
||||
'Bond Type',
|
||||
'SLR',
|
||||
'Script Desc',
|
||||
'GL A/c No',
|
||||
'ISIN',
|
||||
'Face Value/Unit'
|
||||
'Int Rate %',
|
||||
'Issue Date',
|
||||
'Maturity Date',
|
||||
'Period (Y/M/D)',
|
||||
'Face Value/Unit',
|
||||
'Company Name',
|
||||
'Corp Code'
|
||||
],
|
||||
[
|
||||
14,
|
||||
16,
|
||||
12,
|
||||
30,
|
||||
12,
|
||||
16,
|
||||
16,
|
||||
16
|
||||
10,
|
||||
14,
|
||||
14,
|
||||
14,
|
||||
14,
|
||||
24,
|
||||
12
|
||||
],
|
||||
this.scripts.map((s) => [
|
||||
s.scriptCode,
|
||||
s.scriptName,
|
||||
s.instrumentType,
|
||||
s.isin,
|
||||
s.faceValuePerUnit
|
||||
])
|
||||
this.scripts.map((s) => {
|
||||
const p = periodBetween(s.issueDate, s.maturityDate);
|
||||
return [
|
||||
s.scriptCode,
|
||||
s.instrumentType,
|
||||
s.slrType ?? '',
|
||||
s.scriptName,
|
||||
s.glCode ?? '',
|
||||
s.isin,
|
||||
s.couponRate ?? null,
|
||||
s.issueDate ?? '',
|
||||
s.maturityDate ?? '',
|
||||
s.issueDate && s.maturityDate ? `${p.years}/${p.months}/${p.days}` : '',
|
||||
s.faceValuePerUnit,
|
||||
s.companyName ?? '',
|
||||
s.corpCode ?? ''
|
||||
];
|
||||
})
|
||||
);
|
||||
case 'subtype':
|
||||
return one(
|
||||
|
||||
@@ -0,0 +1,856 @@
|
||||
/**
|
||||
* 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 { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||
import { Observable, forkJoin } from 'rxjs';
|
||||
import { map, tap } from 'rxjs/operators';
|
||||
import {
|
||||
DailyScriptRate,
|
||||
HoldingStatus,
|
||||
InstrumentType,
|
||||
InvestmentHolding,
|
||||
PARTY_MASTER,
|
||||
PartyMaster,
|
||||
SCRIPT_MASTER,
|
||||
ScriptMaster,
|
||||
SlrType
|
||||
} from './investment-data';
|
||||
|
||||
/** Investment party as returned by /investment/parties. */
|
||||
export interface InvestmentPartyDto {
|
||||
id: number;
|
||||
partyCode: string;
|
||||
bondType: InstrumentType;
|
||||
partyName: string;
|
||||
address1?: string;
|
||||
address2?: string;
|
||||
address3?: string;
|
||||
city?: string;
|
||||
pincode?: string;
|
||||
phone?: string;
|
||||
fax?: string;
|
||||
bankType?: string;
|
||||
glAccountId: number;
|
||||
glCode: string;
|
||||
glName: string;
|
||||
interestGlAccountId?: number;
|
||||
interestGlCode?: string;
|
||||
interestGlName?: string;
|
||||
tdsApplicable?: boolean;
|
||||
tdsRate?: number;
|
||||
rbiCode?: string;
|
||||
limit?: number;
|
||||
leiNo?: string;
|
||||
cpBankName?: string;
|
||||
cpBranchName?: string;
|
||||
cpAccountNo?: string;
|
||||
cpIfsc?: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/** Where an FD stands against the RBI limits on deposits with other banks (20% overall, 5% with one bank). */
|
||||
export interface InvestmentFdLimitDto {
|
||||
asOnDate: string;
|
||||
totalDeposits?: number;
|
||||
glTotalDeposits: number;
|
||||
grossLimit?: number;
|
||||
fdOutstanding: number;
|
||||
bankBalances: number;
|
||||
grossAfter: number;
|
||||
bankLimit?: number;
|
||||
partyName?: string;
|
||||
bankAfter: number;
|
||||
newAmount: number;
|
||||
withinLimits: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type FdPayout = 'MATURITY' | 'HALF_YEARLY' | 'YEARLY';
|
||||
export type FdCalcType = 'SIMPLE' | 'MONTHLY' | 'QUARTERLY' | 'HALF_YEARLY' | 'YEARLY';
|
||||
|
||||
/** An FD the bank has placed with another bank (dates DD-MM-YYYY); also the purchase preview. */
|
||||
export interface InvestmentFdDto {
|
||||
id?: number;
|
||||
dealNo?: string;
|
||||
/** TDS rate the bank deducts on interest (from its FD party); absent when it does not deduct TDS. */
|
||||
tdsRate?: number | null;
|
||||
partyId?: number;
|
||||
partyCode?: string;
|
||||
partyName?: string;
|
||||
certNo?: string;
|
||||
certDate?: string;
|
||||
dealDate?: string;
|
||||
principal?: number;
|
||||
intRate?: number;
|
||||
maturityDate?: string;
|
||||
days: number;
|
||||
payout: FdPayout;
|
||||
calcType: FdCalcType;
|
||||
expectedInterest?: number;
|
||||
expectedMaturityAmount?: number;
|
||||
periodInterest?: number;
|
||||
fdGlCode?: string;
|
||||
fdGlName?: string;
|
||||
interestGlCode?: string;
|
||||
interestGlName?: string;
|
||||
bankGlAccountId?: number;
|
||||
bankGlCode?: string;
|
||||
bankGlName?: string;
|
||||
status?: 'ACTIVE' | 'MATURED' | 'PREMATURE' | 'RENEWED';
|
||||
remarks?: string;
|
||||
glTransactionId?: string;
|
||||
voucherNo?: string;
|
||||
renewedFromDealNo?: string;
|
||||
closedOnDate?: string;
|
||||
closureType?: string;
|
||||
closureAmount?: number;
|
||||
closureInterest?: number;
|
||||
closureTds?: number;
|
||||
closurePenal?: number;
|
||||
closureVoucherNo?: string;
|
||||
closureGlTransactionId?: string;
|
||||
limit?: InvestmentFdLimitDto;
|
||||
}
|
||||
|
||||
export type FdClosureType = 'MATURITY' | 'PREMATURE' | 'RENEWAL';
|
||||
|
||||
/** One interest line of an FD: a 31 Mar / 30 Sep provision, a payout, or the maturity. */
|
||||
export interface InvestmentFdInterestRowDto {
|
||||
lineType: 'PROVISION' | 'PAYOUT' | 'MATURITY';
|
||||
intDate: string;
|
||||
fromDate: string;
|
||||
toDate: string;
|
||||
days: number;
|
||||
grossInterest: number;
|
||||
alreadyBooked?: number;
|
||||
tds?: number;
|
||||
amount: number;
|
||||
posted: boolean;
|
||||
voucherNo?: string;
|
||||
glTransactionId?: string;
|
||||
postable: boolean;
|
||||
}
|
||||
|
||||
export interface InvestmentFdScheduleDto {
|
||||
fd: InvestmentFdDto;
|
||||
provisioned: number;
|
||||
rows: InvestmentFdInterestRowDto[];
|
||||
}
|
||||
|
||||
/** The next interest voucher (or the maturity) of an FD that is due. */
|
||||
export interface InvestmentFdDueDto {
|
||||
fdId: number;
|
||||
dealNo: string;
|
||||
partyName: string;
|
||||
certNo: string;
|
||||
lineType: 'PROVISION' | 'PAYOUT' | 'MATURITY';
|
||||
intDate: string;
|
||||
amount: number;
|
||||
postable: boolean;
|
||||
overdue: boolean;
|
||||
}
|
||||
|
||||
/** What closing an FD works out to, and the voucher it will post. */
|
||||
export interface InvestmentFdClosureDto {
|
||||
fdId: number;
|
||||
dealNo: string;
|
||||
type: FdClosureType;
|
||||
closeDate: string;
|
||||
principal: number;
|
||||
expectedInterest: number;
|
||||
provisioned: number;
|
||||
receivedAmount: number;
|
||||
tds: number;
|
||||
penalAmount?: number;
|
||||
interest: number;
|
||||
income: number;
|
||||
difference: number;
|
||||
renewalAmount?: number;
|
||||
topUp?: number;
|
||||
lines: Array<{ side: 'Dr' | 'Cr'; glCode: string; glName: string; amount: number }>;
|
||||
}
|
||||
|
||||
/** Investment script as returned by /investment/scripts (dates are DD-MM-YYYY). */
|
||||
export interface InvestmentScriptDto {
|
||||
id: number;
|
||||
scriptCode: string;
|
||||
bondType: InstrumentType;
|
||||
slrType?: SlrType;
|
||||
scriptDesc: string;
|
||||
glAccountId: number;
|
||||
glCode: string;
|
||||
glName: string;
|
||||
isin?: string;
|
||||
faceValuePerUnit: number;
|
||||
intRate?: number;
|
||||
issueDate?: string;
|
||||
maturityDate?: string;
|
||||
companyName?: string;
|
||||
address1?: string;
|
||||
address2?: string;
|
||||
address3?: string;
|
||||
city?: string;
|
||||
pincode?: string;
|
||||
phone?: string;
|
||||
fax?: string;
|
||||
corpCode?: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/** Purchase entry (holding) as returned by /investment/purchases (dates are DD-MM-YYYY). */
|
||||
export interface InvestmentPurchaseDto {
|
||||
id: number;
|
||||
dealNo: string;
|
||||
certNo: string;
|
||||
bondType: InstrumentType;
|
||||
scriptId: number;
|
||||
scriptCode: string;
|
||||
scriptDesc: string;
|
||||
isin?: string;
|
||||
partyId: number;
|
||||
partyCode: string;
|
||||
partyName: string;
|
||||
dealDate: string;
|
||||
dealTime?: string;
|
||||
settlementType: string;
|
||||
settlementDate: string;
|
||||
faceValue: number;
|
||||
purchaseRate: number;
|
||||
units: number;
|
||||
balanceUnits: number;
|
||||
brokerage?: number;
|
||||
stampDuty?: number;
|
||||
otherCharges?: number;
|
||||
bookValue: number;
|
||||
premiumDiscount?: number;
|
||||
intRate?: number;
|
||||
issueDate?: string;
|
||||
maturityDate?: string;
|
||||
lastIntDate?: string;
|
||||
accruedDays?: number;
|
||||
accruedInterest?: number;
|
||||
settlementAmount: number;
|
||||
ytm?: number;
|
||||
category: string;
|
||||
subType?: string;
|
||||
investmentGlAccountId: number;
|
||||
investmentGlCode: string;
|
||||
investmentGlName: string;
|
||||
crGlAccountId: number;
|
||||
crGlCode: string;
|
||||
crGlName: string;
|
||||
otherDetails?: string;
|
||||
status: string;
|
||||
glTransactionId?: string;
|
||||
settledOnDate?: string;
|
||||
}
|
||||
|
||||
export interface InvestmentGlAccount {
|
||||
id: number;
|
||||
glCode: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backend calls for the Investment masters (Bank / Party Master and Script Master). Results are also copied into the
|
||||
* shared PARTY_MASTER / SCRIPT_MASTER lists so screens that still read those lists (Purchase Entry) offer the saved
|
||||
* masters.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class InvestmentService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
// -------------------- Bank / Party Master --------------------
|
||||
getParties(): Observable<InvestmentPartyDto[]> {
|
||||
return this.http.get<InvestmentPartyDto[]>('/investment/parties');
|
||||
}
|
||||
|
||||
createParty(body: object): Observable<{ resourceId: number; resourceIdentifier: string }> {
|
||||
return this.http.post<{ resourceId: number; resourceIdentifier: string }>('/investment/parties', body);
|
||||
}
|
||||
|
||||
updateParty(id: number, body: object): Observable<{ resourceId: number; resourceIdentifier: string }> {
|
||||
return this.http.put<{ resourceId: number; resourceIdentifier: string }>(`/investment/parties/${id}`, body);
|
||||
}
|
||||
|
||||
deleteParty(id: number): Observable<unknown> {
|
||||
return this.http.delete(`/investment/parties/${id}`);
|
||||
}
|
||||
|
||||
// -------------------- Script Master --------------------
|
||||
getScripts(): Observable<InvestmentScriptDto[]> {
|
||||
return this.http.get<InvestmentScriptDto[]>('/investment/scripts');
|
||||
}
|
||||
|
||||
createScript(body: object): Observable<{ resourceId: number; resourceIdentifier: string }> {
|
||||
return this.http.post<{ resourceId: number; resourceIdentifier: string }>('/investment/scripts', body);
|
||||
}
|
||||
|
||||
updateScript(id: number, body: object): Observable<{ resourceId: number; resourceIdentifier: string }> {
|
||||
return this.http.put<{ resourceId: number; resourceIdentifier: string }>(`/investment/scripts/${id}`, body);
|
||||
}
|
||||
|
||||
// -------------------- Purchase Entry --------------------
|
||||
getPurchases(): Observable<InvestmentPurchaseDto[]> {
|
||||
return this.http.get<InvestmentPurchaseDto[]>('/investment/purchases');
|
||||
}
|
||||
|
||||
/** Coupon and 31 Mar / 30 Sep provision lines of a holding up to maturity (calculated, not posted). */
|
||||
/** GOI Category Transfers, newest first. */
|
||||
getTransfers(): Observable<InvestmentSecurityTransferDto[]> {
|
||||
return this.http.get<InvestmentSecurityTransferDto[]>('/investment/transfers');
|
||||
}
|
||||
|
||||
/** Moves a holding to another security of the same terms; a voucher is raised only when the GL changes. */
|
||||
transferSecurity(purchaseId: number, body: object): Observable<{ resourceIdentifier?: string }> {
|
||||
return this.http.post<{ resourceIdentifier?: string }>(`/investment/purchases/${purchaseId}/transfer`, body);
|
||||
}
|
||||
|
||||
/** Sales and redemptions, newest first (pending sales included). */
|
||||
getSales(): Observable<InvestmentSaleDto[]> {
|
||||
return this.http.get<InvestmentSaleDto[]>('/investment/sales');
|
||||
}
|
||||
|
||||
/** What a sale would come to before it is saved (dealDate dd-MM-yyyy). */
|
||||
quoteSale(params: Record<string, string | number>): Observable<InvestmentSaleDto> {
|
||||
return this.http.get<InvestmentSaleDto>('/investment/sales/quote', { params });
|
||||
}
|
||||
|
||||
createSale(body: object): Observable<{ resourceId?: number; resourceIdentifier?: string }> {
|
||||
return this.http.post<{ resourceId?: number; resourceIdentifier?: string }>('/investment/sales', body);
|
||||
}
|
||||
|
||||
/** Posts a pending sale on or after its settlement date; the voucher number comes back as resourceIdentifier. */
|
||||
settleSale(saleId: number): Observable<{ resourceIdentifier?: string }> {
|
||||
return this.http.post<{ resourceIdentifier?: string }>(`/investment/sales/${saleId}/settle`, {});
|
||||
}
|
||||
|
||||
deleteSale(saleId: number): Observable<unknown> {
|
||||
return this.http.delete(`/investment/sales/${saleId}`);
|
||||
}
|
||||
|
||||
/** Premium amortisation schedule of a holding (06 Amortize Entry). */
|
||||
getAmortisation(purchaseId: number): Observable<InvestmentAmortisationDto> {
|
||||
return this.http.get<InvestmentAmortisationDto>(`/investment/amortisation/${purchaseId}`);
|
||||
}
|
||||
|
||||
/** Holdings due for amortisation on a 31 March (07 Amortize Yearly Process). */
|
||||
getAmortisationDue(fyEnd: string): Observable<InvestmentAmortisationDueDto[]> {
|
||||
return this.http.get<InvestmentAmortisationDueDto[]>(`/investment/amortisation/due?fyEnd=${fyEnd}`);
|
||||
}
|
||||
|
||||
/** Posts amortisation for a 31 March: one holding when purchaseId is given, else every holding due. */
|
||||
amortise(body: object): Observable<{ resourceIdentifier?: string }> {
|
||||
return this.http.post<{ resourceIdentifier?: string }>('/investment/amortisation', body);
|
||||
}
|
||||
|
||||
/** Writes part of a holding's book value off as a loss (Dr Loss / Cr Investment); the units stay held. */
|
||||
writeOff(purchaseId: number, body: object): Observable<{ resourceIdentifier?: string }> {
|
||||
return this.http.post<{ resourceIdentifier?: string }>(`/investment/purchases/${purchaseId}/write-off`, body);
|
||||
}
|
||||
|
||||
/** What the redemption at maturity will post; refused before maturity. */
|
||||
redemptionQuote(purchaseId: number): Observable<InvestmentSaleDto> {
|
||||
return this.http.get<InvestmentSaleDto>(`/investment/purchases/${purchaseId}/redemption-quote`);
|
||||
}
|
||||
|
||||
redeem(purchaseId: number, body: object): Observable<{ resourceIdentifier?: string }> {
|
||||
return this.http.post<{ resourceIdentifier?: string }>(`/investment/purchases/${purchaseId}/redeem`, body);
|
||||
}
|
||||
|
||||
/** Interest lines already posted (coupons and provisions), newest first. */
|
||||
getInterestPostings(): Observable<InvestmentInterestPostingDto[]> {
|
||||
return this.http.get<InvestmentInterestPostingDto[]>('/investment/interest-postings');
|
||||
}
|
||||
|
||||
/** Interest lines of all holdings that are due now or within the next few days. */
|
||||
getDueInterest(): Observable<InvestmentInterestDueDto[]> {
|
||||
return this.http.get<InvestmentInterestDueDto[]>('/investment/interest-postings/due');
|
||||
}
|
||||
|
||||
/** Posts one interest line (coupon / provision) with its JV voucher; the voucher number comes back as resourceIdentifier. */
|
||||
postInterest(purchaseId: number, body: object): Observable<{ resourceIdentifier?: string; transactionId?: string }> {
|
||||
return this.http.post<{ resourceIdentifier?: string; transactionId?: string }>(
|
||||
`/investment/purchases/${purchaseId}/interest-postings`,
|
||||
body
|
||||
);
|
||||
}
|
||||
|
||||
getInterestSchedule(purchaseId: number, asOnDate?: string): Observable<InvestmentInterestScheduleDto> {
|
||||
const params = asOnDate ? `?asOnDate=${encodeURIComponent(asOnDate)}` : '';
|
||||
return this.http.get<InvestmentInterestScheduleDto>(
|
||||
`/investment/purchases/${purchaseId}/interest-schedule${params}`
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------- Daily Script Master --------------------
|
||||
getDailyRates(scriptId?: number): Observable<InvestmentDailyRateDto[]> {
|
||||
const params = scriptId ? `?scriptId=${scriptId}` : '';
|
||||
return this.http.get<InvestmentDailyRateDto[]>(`/investment/daily-rates${params}`);
|
||||
}
|
||||
|
||||
/** Newest Daily Script Master rate of a script on or before a date; null when none has been entered. */
|
||||
getLatestDailyRate(scriptId: number, onDate: string): Observable<InvestmentDailyRateDto | null> {
|
||||
return this.http.get<InvestmentDailyRateDto | null>(
|
||||
`/investment/daily-rates/latest?scriptId=${scriptId}&onDate=${encodeURIComponent(onDate)}`
|
||||
);
|
||||
}
|
||||
|
||||
createDailyRate(body: object): Observable<{ resourceId: number }> {
|
||||
return this.http.post<{ resourceId: number }>('/investment/daily-rates', body);
|
||||
}
|
||||
|
||||
deleteDailyRate(rateId: number): Observable<unknown> {
|
||||
return this.http.delete(`/investment/daily-rates/${rateId}`);
|
||||
}
|
||||
|
||||
/** Settles a deal: posts it to GL dated its settlement date. Under maker-checker the response only says it awaits approval. */
|
||||
settlePurchase(purchaseId: number): Observable<{ transactionId?: string; rollbackTransaction?: boolean }> {
|
||||
return this.http.post<{ transactionId?: string; rollbackTransaction?: boolean }>(
|
||||
`/investment/purchases/${purchaseId}/settle`,
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
createPurchase(body: object): Observable<{ resourceId: number; resourceIdentifier: string }> {
|
||||
return this.http.post<{ resourceId: number; resourceIdentifier: string }>('/investment/purchases', body);
|
||||
}
|
||||
|
||||
deletePurchase(id: number): Observable<unknown> {
|
||||
return this.http.delete(`/investment/purchases/${id}`);
|
||||
}
|
||||
|
||||
// -------------------- FD with other banks --------------------
|
||||
getFds(): Observable<InvestmentFdDto[]> {
|
||||
return this.http.get<InvestmentFdDto[]>('/investment/fds');
|
||||
}
|
||||
|
||||
/** Interest, maturity amount and the RBI limit check for an FD before it is saved. */
|
||||
quoteFd(params: Record<string, string | number>): Observable<InvestmentFdDto> {
|
||||
return this.http.get<InvestmentFdDto>('/investment/fds/quote', { params });
|
||||
}
|
||||
|
||||
/** Places the FD: Dr the bank's FD GL / Cr our bank GL at once. */
|
||||
createFd(
|
||||
body: object
|
||||
): Observable<{ resourceIdentifier?: string; changes?: { dealNo?: string; voucherNo?: string } }> {
|
||||
return this.http.post<{ resourceIdentifier?: string; changes?: { dealNo?: string; voucherNo?: string } }>(
|
||||
'/investment/fds',
|
||||
body
|
||||
);
|
||||
}
|
||||
|
||||
closureQuoteFd(fdId: number, params: Record<string, string | number>): Observable<InvestmentFdClosureDto> {
|
||||
return this.http.get<InvestmentFdClosureDto>(`/investment/fds/${fdId}/closure-quote`, { params });
|
||||
}
|
||||
|
||||
/** Maturity, premature closure or renewal of an FD (voucher posted at once). */
|
||||
closeFd(
|
||||
fdId: number,
|
||||
type: FdClosureType,
|
||||
body: object
|
||||
): Observable<{ resourceIdentifier?: string; changes?: { dealNo?: string; voucherNo?: string; interest?: number } }> {
|
||||
const path = type === 'MATURITY' ? 'mature' : type === 'PREMATURE' ? 'premature' : 'renew';
|
||||
return this.http.post<{
|
||||
resourceIdentifier?: string;
|
||||
changes?: { dealNo?: string; voucherNo?: string; interest?: number };
|
||||
}>(`/investment/fds/${fdId}/${path}`, body);
|
||||
}
|
||||
|
||||
getFdSchedule(fdId: number): Observable<InvestmentFdScheduleDto> {
|
||||
return this.http.get<InvestmentFdScheduleDto>(`/investment/fds/${fdId}/interest-schedule`);
|
||||
}
|
||||
|
||||
/** Posts the next interest line of an FD (provision, or payout with the bank's amount and TDS). */
|
||||
postFdInterest(fdId: number, body: object): Observable<{ resourceIdentifier?: string }> {
|
||||
return this.http.post<{ resourceIdentifier?: string }>(`/investment/fds/${fdId}/interest`, body);
|
||||
}
|
||||
|
||||
/** FD interest vouchers and maturities due now or within two days. */
|
||||
getFdDue(): Observable<InvestmentFdDueDto[]> {
|
||||
return this.http.get<InvestmentFdDueDto[]>('/investment/fds/due');
|
||||
}
|
||||
|
||||
/** Records total deposits as on a 31 March, the base of the RBI limits. */
|
||||
setDepositBase(body: object): Observable<unknown> {
|
||||
return this.http.post('/investment/fds/deposit-base', body);
|
||||
}
|
||||
|
||||
/** Enabled INCOME detail GLs — an FD bank's interest GL. */
|
||||
getIncomeGlAccounts(): Observable<InvestmentGlAccount[]> {
|
||||
return this.http
|
||||
.get<
|
||||
Array<{ id: number; glCode: string; name: string }>
|
||||
>('/glaccounts', { params: { type: '4', usage: '1', disabled: 'false' } })
|
||||
.pipe(
|
||||
map((list) =>
|
||||
list
|
||||
.map((g) => ({ id: g.id, glCode: g.glCode, name: g.name }))
|
||||
.sort((a, b) => a.glCode.localeCompare(b.glCode))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------- GL accounts --------------------
|
||||
/** Enabled ASSET detail GLs — the only kind an investment party / script can be booked to. */
|
||||
getAssetGlAccounts(): Observable<InvestmentGlAccount[]> {
|
||||
return this.http
|
||||
.get<
|
||||
Array<{ id: number; glCode: string; name: string; disabled: boolean }>
|
||||
>('/glaccounts', { params: { type: '1', usage: '1', disabled: 'false' } })
|
||||
.pipe(
|
||||
map((list) =>
|
||||
list
|
||||
.map((g) => ({ id: g.id, glCode: g.glCode, name: g.name }))
|
||||
.sort((a, b) => a.glCode.localeCompare(b.glCode))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Loads both masters from the backend and refreshes the shared lists other screens read. */
|
||||
syncSharedMasters(): Observable<{ parties: PartyMaster[]; scripts: ScriptMaster[] }> {
|
||||
return forkJoin({ parties: this.getParties(), scripts: this.getScripts() }).pipe(
|
||||
map(({ parties, scripts }) => ({ parties: parties.map(toPartyMaster), scripts: scripts.map(toScriptMaster) })),
|
||||
tap(({ parties, scripts }) => {
|
||||
PARTY_MASTER.splice(0, PARTY_MASTER.length, ...parties);
|
||||
SCRIPT_MASTER.splice(0, SCRIPT_MASTER.length, ...scripts);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function toPartyMaster(p: InvestmentPartyDto): PartyMaster {
|
||||
return {
|
||||
id: p.id,
|
||||
partyCode: p.partyCode,
|
||||
partyName: p.partyName,
|
||||
partyType: 'BANK',
|
||||
bondType: p.bondType,
|
||||
address1: p.address1,
|
||||
address2: p.address2,
|
||||
address3: p.address3,
|
||||
city: p.city,
|
||||
pincode: p.pincode,
|
||||
phone: p.phone,
|
||||
fax: p.fax,
|
||||
bankType: p.bankType,
|
||||
glAccountId: p.glAccountId,
|
||||
glCode: p.glCode,
|
||||
glName: p.glName,
|
||||
interestGlAccountId: p.interestGlAccountId,
|
||||
interestGlCode: p.interestGlCode,
|
||||
interestGlName: p.interestGlName,
|
||||
tdsApplicable: p.tdsApplicable ?? false,
|
||||
tdsRate: p.tdsRate ?? null,
|
||||
rbiCode: p.rbiCode,
|
||||
limit: p.limit ?? null,
|
||||
leiNo: p.leiNo,
|
||||
cpBankName: p.cpBankName,
|
||||
cpBranchName: p.cpBranchName,
|
||||
cpAccountNo: p.cpAccountNo,
|
||||
cpIfsc: p.cpIfsc,
|
||||
status: p.active ? 'ACTIVE' : 'INACTIVE'
|
||||
};
|
||||
}
|
||||
|
||||
export function toScriptMaster(s: InvestmentScriptDto): ScriptMaster {
|
||||
return {
|
||||
id: s.id,
|
||||
scriptCode: s.scriptCode,
|
||||
scriptName: s.scriptDesc,
|
||||
instrumentType: s.bondType,
|
||||
isin: s.isin ?? '',
|
||||
faceValuePerUnit: s.faceValuePerUnit,
|
||||
slrType: s.slrType,
|
||||
glAccountId: s.glAccountId,
|
||||
glCode: s.glCode,
|
||||
glName: s.glName,
|
||||
couponRate: s.intRate,
|
||||
issueDate: s.issueDate,
|
||||
maturityDate: s.maturityDate,
|
||||
companyName: s.companyName,
|
||||
address1: s.address1,
|
||||
address2: s.address2,
|
||||
address3: s.address3,
|
||||
city: s.city,
|
||||
pincode: s.pincode,
|
||||
phone: s.phone,
|
||||
fax: s.fax,
|
||||
corpCode: s.corpCode,
|
||||
active: s.active
|
||||
};
|
||||
}
|
||||
|
||||
/** A saved purchase as a row of the Holdings register. */
|
||||
export function toHolding(p: InvestmentPurchaseDto): InvestmentHolding {
|
||||
return {
|
||||
id: p.id,
|
||||
dealNo: p.dealNo,
|
||||
certNo: p.certNo,
|
||||
instrumentType: p.bondType,
|
||||
scriptCode: p.scriptCode,
|
||||
scriptName: p.scriptDesc,
|
||||
party: p.partyName,
|
||||
partyCode: p.partyCode,
|
||||
subType: p.subType ?? '',
|
||||
// Face value still held after any part sale.
|
||||
faceValue: p.units ? Math.round((p.faceValue * p.balanceUnits * 100) / p.units) / 100 : p.faceValue,
|
||||
purchaseRate: p.purchaseRate,
|
||||
units: p.balanceUnits,
|
||||
purchaseDate: p.settlementDate,
|
||||
maturityDate: p.maturityDate || p.dealDate,
|
||||
interestRate: p.intRate ?? 0,
|
||||
interestFreq: p.bondType === 'MUTUAL_FUND' ? 'CUMULATIVE' : 'HALF_YEARLY',
|
||||
glCode: p.investmentGlCode,
|
||||
glName: p.investmentGlName,
|
||||
crToAc: p.crGlCode,
|
||||
brokerCode: '',
|
||||
status: (p.status === 'REDEEMED' ? 'MATURED' : p.status) as HoldingStatus,
|
||||
settlementDate: p.settlementDate,
|
||||
lastIntDate: p.lastIntDate,
|
||||
glTransactionId: p.glTransactionId,
|
||||
category: p.category,
|
||||
bookValue: p.bookValue,
|
||||
settlementAmount: p.settlementAmount
|
||||
};
|
||||
}
|
||||
|
||||
/** One line of a holding's interest schedule (see InvestmentInterestScheduleData in the backend). */
|
||||
export interface InvestmentInterestRowDto {
|
||||
type: 'COUPON' | 'PROVISION';
|
||||
intDate: string;
|
||||
fromDate: string;
|
||||
toDate: string;
|
||||
days: number;
|
||||
grossInterest: number;
|
||||
alreadyBooked: number;
|
||||
amount: number;
|
||||
due: boolean;
|
||||
/** Posted to GL: its JV voucher and GL transaction. */
|
||||
posted: boolean;
|
||||
voucherNo?: string;
|
||||
glTransactionId?: string;
|
||||
postedOnDate?: string;
|
||||
/** The next line to post and its date has come. */
|
||||
postable: boolean;
|
||||
/** Unposted and due within the reminder window (or past). */
|
||||
dueSoon: boolean;
|
||||
}
|
||||
|
||||
/** A holding's next interest line that is due (or due within the reminder window) — a task to post. */
|
||||
/** A sale (before maturity) or redemption (at maturity) of a holding; also the shape of a quote before saving. */
|
||||
/** One line of a holding's premium amortisation schedule (31 March, or the remainder at maturity). */
|
||||
export interface InvestmentAmortisationRowDto {
|
||||
kind: 'YEAR' | 'MATURITY';
|
||||
fyEnd: string;
|
||||
fromDate: string;
|
||||
toDate: string;
|
||||
days: number;
|
||||
amount: number;
|
||||
premiumAfter?: number;
|
||||
posted: boolean;
|
||||
voucherNo?: string;
|
||||
glTransactionId?: string;
|
||||
postable: boolean;
|
||||
}
|
||||
|
||||
export interface InvestmentAmortisationDto {
|
||||
purchaseId: number;
|
||||
dealNo: string;
|
||||
certNo: string;
|
||||
scriptCode: string;
|
||||
scriptDesc: string;
|
||||
category: string;
|
||||
settlementDate: string;
|
||||
maturityDate: string;
|
||||
faceValue: number;
|
||||
premium: number;
|
||||
unamortisedPremium: number;
|
||||
bookValue: number;
|
||||
premiumGlCode: string;
|
||||
rows: InvestmentAmortisationRowDto[];
|
||||
}
|
||||
|
||||
/** A holding whose premium amortisation falls due on a 31 March. */
|
||||
export interface InvestmentAmortisationDueDto {
|
||||
purchaseId: number;
|
||||
dealNo: string;
|
||||
certNo: string;
|
||||
scriptCode: string;
|
||||
scriptDesc: string;
|
||||
fyEnd: string;
|
||||
fromDate: string;
|
||||
days: number;
|
||||
unamortisedPremium: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface InvestmentSaleDto {
|
||||
id?: number;
|
||||
purchaseId: number;
|
||||
dealNo: string;
|
||||
certNo: string;
|
||||
bondType: string;
|
||||
scriptCode: string;
|
||||
scriptDesc: string;
|
||||
saleType: 'SELL' | 'REDEEM' | 'WRITEOFF' | 'AMORTISE';
|
||||
partyId?: number;
|
||||
partyName?: string;
|
||||
dealDate: string;
|
||||
dealTime?: string;
|
||||
settlementType?: string;
|
||||
settlementDate: string;
|
||||
units: number;
|
||||
faceValue: number;
|
||||
saleRate: number;
|
||||
consideration: number;
|
||||
accruedDays: number;
|
||||
accruedInterest: number;
|
||||
receivableCleared: number;
|
||||
interestIncome: number;
|
||||
brokerage: number;
|
||||
bookValueSold: number;
|
||||
/** Part of the book value taken off that was unamortised premium. */
|
||||
premiumCleared?: number;
|
||||
/** GL the premium share is credited to (100707, or the investment GL on older deals). */
|
||||
premiumGlCode?: string;
|
||||
profitLoss: number;
|
||||
netAmount: number;
|
||||
bankGlAccountId: number;
|
||||
bankGlCode: string;
|
||||
bankGlName: string;
|
||||
investmentGlCode: string;
|
||||
sanctionAuthority?: string;
|
||||
remarks?: string;
|
||||
status: 'PENDING_SETTLEMENT' | 'SETTLED';
|
||||
glTransactionId?: string;
|
||||
voucherNo?: string;
|
||||
settledOnDate?: string;
|
||||
}
|
||||
|
||||
/** A GOI Category Transfer: a holding moved to another security of the same terms. */
|
||||
export interface InvestmentSecurityTransferDto {
|
||||
id: number;
|
||||
purchaseId: number;
|
||||
dealNo: string;
|
||||
certNo: string;
|
||||
bondType: string;
|
||||
fromScriptCode: string;
|
||||
fromScriptDesc: string;
|
||||
toScriptCode: string;
|
||||
toScriptDesc: string;
|
||||
fromGlCode: string;
|
||||
toGlCode: string;
|
||||
toGlName: string;
|
||||
transferDate: string;
|
||||
units: number;
|
||||
bookValue: number;
|
||||
remarks?: string;
|
||||
glTransactionId?: string;
|
||||
voucherNo?: string;
|
||||
}
|
||||
|
||||
/** An interest line already posted to GL with its JV voucher. */
|
||||
export interface InvestmentInterestPostingDto {
|
||||
id: number;
|
||||
purchaseId: number;
|
||||
dealNo: string;
|
||||
certNo: string;
|
||||
bondType: string;
|
||||
scriptDesc: string;
|
||||
lineType: 'COUPON' | 'PROVISION';
|
||||
intDate: string;
|
||||
fromDate: string;
|
||||
toDate: string;
|
||||
grossInterest: number;
|
||||
alreadyBooked: number;
|
||||
amount: number;
|
||||
drGlCode: string;
|
||||
drGlName: string;
|
||||
incomeGlCode: string;
|
||||
incomeGlName: string;
|
||||
glTransactionId: string;
|
||||
voucherNo: string;
|
||||
postedOnDate: string;
|
||||
}
|
||||
|
||||
export interface InvestmentInterestDueDto {
|
||||
purchaseId: number;
|
||||
dealNo: string;
|
||||
certNo: string;
|
||||
scriptCode: string;
|
||||
scriptDesc: string;
|
||||
lineType: 'COUPON' | 'PROVISION';
|
||||
intDate: string;
|
||||
grossInterest: number;
|
||||
amount: number;
|
||||
postable: boolean;
|
||||
overdue: boolean;
|
||||
}
|
||||
|
||||
export interface InvestmentInterestScheduleDto {
|
||||
purchaseId: number;
|
||||
dealNo: string;
|
||||
certNo: string;
|
||||
scriptCode: string;
|
||||
scriptDesc: string;
|
||||
faceValue: number;
|
||||
unitValue: number;
|
||||
balanceUnits: number;
|
||||
purchaseRate: number;
|
||||
bookValue: number;
|
||||
intRate: number;
|
||||
dealDate: string;
|
||||
lastIntDate: string;
|
||||
maturityDate: string;
|
||||
purchasedAccruedInterest: number;
|
||||
couponPerHalfYear: number;
|
||||
asOnDate: string;
|
||||
interestGlCode: string;
|
||||
interestGlName: string;
|
||||
bankGlAccountId: number;
|
||||
bankGlCode: string;
|
||||
bankGlName: string;
|
||||
status: string;
|
||||
rows: InvestmentInterestRowDto[];
|
||||
}
|
||||
|
||||
/** A Daily Script Master row (see InvestmentDailyRateData in the backend). */
|
||||
export interface InvestmentDailyRateDto {
|
||||
id: number;
|
||||
scriptId: number;
|
||||
scriptCode: string;
|
||||
scriptDesc: string;
|
||||
bondType: string;
|
||||
rateDate: string;
|
||||
openRate: number | null;
|
||||
highRate: number | null;
|
||||
lowRate: number | null;
|
||||
closeRate: number;
|
||||
}
|
||||
|
||||
export function toDailyScriptRate(d: InvestmentDailyRateDto): DailyScriptRate {
|
||||
return {
|
||||
id: d.id,
|
||||
rateDate: d.rateDate,
|
||||
scriptCode: d.scriptCode,
|
||||
scriptName: d.scriptDesc,
|
||||
openRate: d.openRate,
|
||||
closeRate: d.closeRate,
|
||||
highRate: d.highRate,
|
||||
lowRate: d.lowRate
|
||||
};
|
||||
}
|
||||
|
||||
/** The first validation / domain-rule message from a Fineract error response, for a toast. */
|
||||
export function apiErrorMessage(err: unknown): string {
|
||||
const e = err as HttpErrorResponse;
|
||||
const body = e?.error;
|
||||
return body?.errors?.[0]?.defaultUserMessage || body?.defaultUserMessage || e?.message || 'Request failed.';
|
||||
}
|
||||
@@ -177,10 +177,6 @@
|
||||
}
|
||||
@if (allowUndoTransaction(transaction)) {
|
||||
<span>
|
||||
<button mat-menu-item *mifosxHasPermission="'ADJUST_LOAN'" (click)="undoTransaction(transaction, $event)">
|
||||
<mat-icon><fa-icon icon="undo" size="sm"></fa-icon></mat-icon>
|
||||
<span>{{ 'tooltips.Undo Transaction' | translate }}</span>
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
class="account-action-button"
|
||||
|
||||
@@ -12,9 +12,6 @@
|
||||
<button mat-raised-button color="primary" [routerLink]="'edit'" [disabled]="!allowEdition">
|
||||
<fa-icon icon="edit" class="m-r-10"></fa-icon>{{ 'Edit' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="warn" (click)="undoTransaction()" [disabled]="!allowUndo">
|
||||
<fa-icon icon="undo" class="m-r-10"></fa-icon>{{ 'Undo' | translate }}
|
||||
</button>
|
||||
</span>
|
||||
<span *mifosxHasPermission="'CHARGEBACK_LOAN'">
|
||||
<button mat-raised-button color="warn" [disabled]="!allowChargeback" (click)="chargebackTransaction()">
|
||||
|
||||
@@ -128,18 +128,6 @@
|
||||
<mat-icon><fa-icon icon="eye" size="sm"></fa-icon></mat-icon>
|
||||
<span>{{ 'labels.text.View Transaction' | translate }}</span>
|
||||
</button>
|
||||
@if (!transaction.reversed) {
|
||||
<span>
|
||||
<button
|
||||
mat-menu-item
|
||||
*mifosxHasPermission="'ADJUSTTRANSACTION_SAVINGSACCOUNT'"
|
||||
(click)="undoTransaction(transaction, $event)"
|
||||
>
|
||||
<mat-icon><fa-icon icon="undo" size="sm"></fa-icon></mat-icon>
|
||||
<span>{{ 'tooltips.Undo Transaction' | translate }}</span>
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
<button
|
||||
mat-menu-item
|
||||
class="account-action-button"
|
||||
|
||||
-10
@@ -66,16 +66,6 @@
|
||||
<fa-icon icon="lock-open" class="m-r-6"></fa-icon>{{ 'labels.buttons.Release Amount' | translate }}
|
||||
</button>
|
||||
}
|
||||
@if (allowUndo()) {
|
||||
<button
|
||||
mat-flat-button
|
||||
class="txn__btn txn__btn--undo"
|
||||
*mifosxHasPermission="'UNDOTRANSACTION_SAVINGSACCOUNT'"
|
||||
(click)="undoTransaction()"
|
||||
>
|
||||
<fa-icon icon="undo" class="m-r-6"></fa-icon>{{ 'labels.buttons.Undo' | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
-18
@@ -24,24 +24,6 @@
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="txn-header-bar__actions">
|
||||
@if (dataSource?.data[0].manualEntry) {
|
||||
<button
|
||||
mat-raised-button
|
||||
color="warn"
|
||||
(click)="revertTransaction()"
|
||||
*mifosxHasPermission="'REVERSE_JOURNALENTRY'"
|
||||
[disabled]="dataSource?.data[0].reversed"
|
||||
class="txn-revert-btn"
|
||||
>
|
||||
@if (!dataSource.data[0].reversed) {
|
||||
<span><fa-icon icon="undo" class="m-r-6"></fa-icon>Revert Transaction</span>
|
||||
} @else {
|
||||
<span><fa-icon icon="check-circle" class="m-r-6"></fa-icon>Reverted</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transaction Info Cards -->
|
||||
|
||||
+1
-29
@@ -8,10 +8,8 @@
|
||||
|
||||
import { Component, OnInit, ViewChild, inject } from '@angular/core';
|
||||
import { ViewJournalEntryComponent } from '../view-journal-entry/view-journal-entry.component';
|
||||
import { RevertTransactionComponent } from 'app/accounting/revert-transaction/revert-transaction.component';
|
||||
import { AccountingService } from 'app/accounting/accounting.service';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { MatSort, MatSortHeader } from '@angular/material/sort';
|
||||
import { MatPaginator } from '@angular/material/paginator';
|
||||
import {
|
||||
@@ -65,9 +63,7 @@ import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
]
|
||||
})
|
||||
export class ViewJournalEntryTransactionComponent implements OnInit {
|
||||
private accountingService = inject(AccountingService);
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
dialog = inject(MatDialog);
|
||||
private location = inject(Location);
|
||||
|
||||
@@ -147,30 +143,6 @@ export class ViewJournalEntryTransactionComponent implements OnInit {
|
||||
this.dialog.open(ViewJournalEntryComponent, { data: { journalEntry } });
|
||||
}
|
||||
|
||||
revertTransaction(transactionId?: string) {
|
||||
const revertTransactionDialogRef = this.dialog.open(RevertTransactionComponent, {
|
||||
data: { reverted: this.dataSource.data[0].reversed, transactionId }
|
||||
});
|
||||
revertTransactionDialogRef.afterClosed().subscribe((response: any) => {
|
||||
if (response.revert) {
|
||||
this.accountingService
|
||||
.revertTransaction(this.transactionId, response.comments)
|
||||
.subscribe((reversedTransaction: any) => {
|
||||
this.dataSource.data[0].reversed = true;
|
||||
this.revertTransaction(reversedTransaction.transactionId);
|
||||
});
|
||||
} else if (response.redirect) {
|
||||
this.router.navigate(
|
||||
[
|
||||
'../',
|
||||
transactionId
|
||||
],
|
||||
{ relativeTo: this.route }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
goBack(): void {
|
||||
this.location.back();
|
||||
}
|
||||
|
||||
@@ -10,12 +10,12 @@ import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup } from '@angular/forms';
|
||||
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
|
||||
export interface CustomerSearchResult {
|
||||
@@ -30,6 +30,17 @@ export interface CustomerSearchResult {
|
||||
address: string | null;
|
||||
}
|
||||
|
||||
/** Optional data for the dialog. */
|
||||
export interface CustomerSearchDialogData {
|
||||
/**
|
||||
* Whether a customer must have an account to be picked. True by default, because the account screens
|
||||
* need one; a screen that only needs the customer (cash exchange) passes false.
|
||||
*/
|
||||
requireAccount?: boolean;
|
||||
}
|
||||
|
||||
type AccountTypeKey = 'savings' | 'current' | 'fixedDeposit' | 'recurringDeposit';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-customer-search-dialog',
|
||||
standalone: true,
|
||||
@@ -41,173 +52,479 @@ export interface CustomerSearchResult {
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule,
|
||||
MatTableModule,
|
||||
MatCheckboxModule,
|
||||
MatIconModule,
|
||||
MatProgressBarModule,
|
||||
MatSnackBarModule
|
||||
],
|
||||
template: `
|
||||
<h2 mat-dialog-title>Search Customer</h2>
|
||||
<mat-dialog-content>
|
||||
<p class="hint-text">
|
||||
Fill in any one field, or combine several to narrow the search (e.g. name + account number).
|
||||
</p>
|
||||
<form [formGroup]="form" class="search-form">
|
||||
<mat-form-field appearance="outline" class="field">
|
||||
<mat-label>Name</mat-label>
|
||||
<input matInput formControlName="name" placeholder="Customer name" />
|
||||
</mat-form-field>
|
||||
<div class="csd-head">
|
||||
<div class="csd-head__icon"><mat-icon>person_search</mat-icon></div>
|
||||
<div class="csd-head__text">
|
||||
<h2 class="csd-head__title">Search Customer</h2>
|
||||
<p class="csd-head__sub">
|
||||
Fill in any one field, or combine several to narrow the search (e.g. name + account number).
|
||||
</p>
|
||||
</div>
|
||||
<button mat-icon-button type="button" class="csd-head__close" (click)="onClose()" aria-label="Close">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="field">
|
||||
<mat-label>Account Number</mat-label>
|
||||
<input matInput formControlName="accountNumber" placeholder="Savings / Current / FD / RD" />
|
||||
</mat-form-field>
|
||||
@if (loading) {
|
||||
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||
}
|
||||
|
||||
<mat-form-field appearance="outline" class="field">
|
||||
<mat-label>Aadhaar Number</mat-label>
|
||||
<input matInput formControlName="aadhaarNumber" placeholder="12-digit Aadhaar" maxlength="12" />
|
||||
</mat-form-field>
|
||||
<mat-dialog-content class="csd-content">
|
||||
<form [formGroup]="form" class="csd-form" (keydown.enter)="onEnter($event)">
|
||||
<div class="csd-grid">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="csd-field csd-field--name">
|
||||
<mat-label>Name</mat-label>
|
||||
<mat-icon matPrefix>person</mat-icon>
|
||||
<input matInput formControlName="name" placeholder="Customer name" autocomplete="off" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="field">
|
||||
<mat-label>PAN Number</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="panNumber"
|
||||
placeholder="e.g. ABCDE1234F"
|
||||
maxlength="10"
|
||||
style="text-transform: uppercase;"
|
||||
/>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="csd-field">
|
||||
<mat-label>Account Number</mat-label>
|
||||
<mat-icon matPrefix>account_balance_wallet</mat-icon>
|
||||
<input matInput formControlName="accountNumber" placeholder="Any account" autocomplete="off" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="field field--wide">
|
||||
<mat-label>Address</mat-label>
|
||||
<input matInput formControlName="address" placeholder="Street, town, city, or postal code" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="csd-field">
|
||||
<mat-label>Aadhaar Number</mat-label>
|
||||
<mat-icon matPrefix>fingerprint</mat-icon>
|
||||
<input
|
||||
matInput
|
||||
formControlName="aadhaarNumber"
|
||||
placeholder="12-digit Aadhaar"
|
||||
maxlength="12"
|
||||
inputmode="numeric"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="csd-field">
|
||||
<mat-label>PAN Number</mat-label>
|
||||
<mat-icon matPrefix>credit_card</mat-icon>
|
||||
<input
|
||||
matInput
|
||||
formControlName="panNumber"
|
||||
placeholder="ABCDE1234F"
|
||||
maxlength="10"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
(input)="upperCase('panNumber')"
|
||||
class="csd-upper"
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="csd-field csd-field--address">
|
||||
<mat-label>Address</mat-label>
|
||||
<mat-icon matPrefix>place</mat-icon>
|
||||
<input
|
||||
matInput
|
||||
formControlName="address"
|
||||
placeholder="Street, town, city, or postal code"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="csd-types">
|
||||
<span class="csd-types__label">Account Type</span>
|
||||
<div class="csd-types__pills">
|
||||
@for (t of accountTypeOptions; track t.key) {
|
||||
<button
|
||||
type="button"
|
||||
class="csd-pill"
|
||||
[class.csd-pill--on]="isTypeOn(t.key)"
|
||||
[attr.aria-pressed]="isTypeOn(t.key)"
|
||||
(click)="toggleType(t.key)"
|
||||
>
|
||||
<mat-icon>{{ isTypeOn(t.key) ? 'check' : t.icon }}</mat-icon
|
||||
>{{ t.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<span class="csd-types__hint">Leave all unselected to search every account type</span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="account-type-filter" [formGroup]="form">
|
||||
<span class="account-type-filter__label">Account Type</span>
|
||||
<mat-checkbox formControlName="savings">Savings</mat-checkbox>
|
||||
<mat-checkbox formControlName="current">Current</mat-checkbox>
|
||||
<mat-checkbox formControlName="fixedDeposit">Fixed Deposit</mat-checkbox>
|
||||
<mat-checkbox formControlName="recurringDeposit">Recurring Deposit</mat-checkbox>
|
||||
<span class="account-type-filter__hint">(leave all unchecked to search every account type)</span>
|
||||
</div>
|
||||
@if (searched) {
|
||||
<div class="csd-results">
|
||||
<div class="csd-results__head">{{ results.length }} {{ results.length === 1 ? 'result' : 'results' }}</div>
|
||||
|
||||
<div class="results" *ngIf="searched">
|
||||
<p *ngIf="!loading && results.length === 0" class="no-results">No matching customer found.</p>
|
||||
@if (!loading && results.length === 0) {
|
||||
<div class="csd-empty">
|
||||
<mat-icon>search_off</mat-icon>
|
||||
<div class="csd-empty__title">No matching customer found</div>
|
||||
<div class="csd-empty__sub">Check the spelling, or try fewer fields.</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<table mat-table [dataSource]="results" *ngIf="results.length > 0">
|
||||
<ng-container matColumnDef="displayName">
|
||||
<th mat-header-cell *matHeaderCellDef>Name</th>
|
||||
<td mat-cell *matCellDef="let r">{{ r.displayName }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="account">
|
||||
<th mat-header-cell *matHeaderCellDef>Account</th>
|
||||
<td mat-cell *matCellDef="let r">
|
||||
<span *ngIf="r.accountNo">{{ r.accountNo }} ({{ accountTypeLabel(r.accountType) }})</span>
|
||||
<span *ngIf="!r.accountNo" class="muted">No account</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="mobileNo">
|
||||
<th mat-header-cell *matHeaderCellDef>Mobile</th>
|
||||
<td mat-cell *matCellDef="let r">{{ r.mobileNo || '-' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="officeName">
|
||||
<th mat-header-cell *matHeaderCellDef>Branch</th>
|
||||
<td mat-cell *matCellDef="let r">{{ r.officeName || '-' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="panNumber">
|
||||
<th mat-header-cell *matHeaderCellDef>PAN</th>
|
||||
<td mat-cell *matCellDef="let r">{{ r.panNumber || '-' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="aadhaarNumber">
|
||||
<th mat-header-cell *matHeaderCellDef>Aadhaar</th>
|
||||
<td mat-cell *matCellDef="let r">{{ r.aadhaarNumber || '-' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="address">
|
||||
<th mat-header-cell *matHeaderCellDef>Address</th>
|
||||
<td mat-cell *matCellDef="let r">{{ r.address || '-' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="select">
|
||||
<th mat-header-cell *matHeaderCellDef></th>
|
||||
<td mat-cell *matCellDef="let r">
|
||||
<button mat-flat-button color="primary" [disabled]="!r.accountNo" (click)="onSelect(r)">Select</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
||||
</table>
|
||||
</div>
|
||||
@for (r of results; track $index) {
|
||||
<div class="csd-result" [class.csd-result--noacc]="!r.accountNo">
|
||||
<div class="csd-avatar">{{ initials(r.displayName) }}</div>
|
||||
<div class="csd-result__main">
|
||||
<div class="csd-result__name">
|
||||
{{ r.displayName }}
|
||||
@if (r.accountNo) {
|
||||
<span class="csd-badge csd-badge--type">{{ accountTypeLabel(r.accountType) }}</span>
|
||||
<span class="csd-result__acc">{{ r.accountNo }}</span>
|
||||
} @else {
|
||||
<span class="csd-badge csd-badge--muted">No account</span>
|
||||
}
|
||||
</div>
|
||||
<div class="csd-result__meta">
|
||||
<span><mat-icon>call</mat-icon>{{ r.mobileNo || '—' }}</span>
|
||||
<span><mat-icon>account_balance</mat-icon>{{ r.officeName || '—' }}</span>
|
||||
<span><mat-icon>credit_card</mat-icon>PAN {{ r.panNumber || '—' }}</span>
|
||||
<span><mat-icon>fingerprint</mat-icon>Aadhaar {{ r.aadhaarNumber || '—' }}</span>
|
||||
</div>
|
||||
@if (r.address) {
|
||||
<div class="csd-result__addr"><mat-icon>place</mat-icon>{{ r.address }}</div>
|
||||
}
|
||||
</div>
|
||||
<button mat-flat-button color="primary" [disabled]="!canSelect(r)" (click)="onSelect(r)">Select</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button (click)="onClose()">Close</button>
|
||||
<button mat-flat-button color="primary" [disabled]="loading" (click)="onSearch()">Search</button>
|
||||
|
||||
<mat-dialog-actions align="end" class="csd-actions">
|
||||
<button mat-button type="button" (click)="onClose()">Close</button>
|
||||
<button mat-stroked-button type="button" [disabled]="loading" (click)="onClear()">Clear</button>
|
||||
<button mat-flat-button color="primary" type="button" [disabled]="loading" (click)="onSearch()">
|
||||
<mat-icon>search</mat-icon> Search
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.hint-text {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
margin: 0 0 12px 0;
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.search-form {
|
||||
|
||||
.csd-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 16px;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 20px 16px 12px 24px;
|
||||
}
|
||||
.field {
|
||||
flex: 1 1 220px;
|
||||
|
||||
.csd-head__icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
background: #e6f4ee;
|
||||
color: #0f4f3c;
|
||||
}
|
||||
.field--wide {
|
||||
flex-basis: 100%;
|
||||
|
||||
.csd-head__icon mat-icon {
|
||||
font-size: 26px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
.account-type-filter {
|
||||
|
||||
.csd-head__text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.csd-head__title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.csd-head__sub {
|
||||
margin: 2px 0 0;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.csd-head__close {
|
||||
flex: 0 0 auto;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.csd-content {
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.csd-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px 14px;
|
||||
}
|
||||
|
||||
.csd-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.csd-field--name {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.csd-field--address {
|
||||
grid-column: span 3;
|
||||
}
|
||||
|
||||
.csd-upper {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.csd-types {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
margin: 4px 0 16px 0;
|
||||
gap: 8px 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.account-type-filter__label {
|
||||
|
||||
.csd-types__label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
}
|
||||
.account-type-filter__hint {
|
||||
|
||||
.csd-types__pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.csd-types__hint {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.results {
|
||||
margin-top: 16px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
th {
|
||||
font-weight: 600;
|
||||
|
||||
.csd-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 32px;
|
||||
padding: 0 14px 0 10px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #475569;
|
||||
background-color: #f8fafc;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
td {
|
||||
color: #334155;
|
||||
}
|
||||
.muted {
|
||||
|
||||
.csd-pill mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.no-results {
|
||||
text-align: center;
|
||||
|
||||
.csd-pill:hover {
|
||||
border-color: #94a3b8;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.csd-pill--on,
|
||||
.csd-pill--on:hover {
|
||||
border-color: #1f7a4d;
|
||||
background: #e6f4ee;
|
||||
font-weight: 600;
|
||||
color: #0f4f3c;
|
||||
}
|
||||
|
||||
.csd-pill--on mat-icon {
|
||||
color: #1f7a4d;
|
||||
}
|
||||
|
||||
.csd-results {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.csd-results__head {
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.csd-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 8px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.csd-result:hover {
|
||||
border-color: #1f7a4d;
|
||||
box-shadow: 0 2px 8px rgb(15 23 42 / 8%);
|
||||
}
|
||||
|
||||
.csd-result--noacc {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.csd-avatar {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #1f7a4d, #0f4f3c);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.csd-result__main {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.csd-result__name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.csd-result__acc {
|
||||
font-family: ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.csd-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.csd-badge--type {
|
||||
background: #eef2ff;
|
||||
color: #3730a3;
|
||||
}
|
||||
|
||||
.csd-badge--muted {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.csd-result__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 16px;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.csd-result__meta span,
|
||||
.csd-result__addr {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.csd-result__meta mat-icon,
|
||||
.csd-result__addr mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.csd-result__addr {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.csd-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 28px 16px;
|
||||
border: 1.5px dashed #cbd5e1;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.csd-empty mat-icon {
|
||||
font-size: 36px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.csd-empty__title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.csd-empty__sub {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.csd-actions {
|
||||
gap: 8px;
|
||||
padding: 12px 24px 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.csd-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.csd-field--address {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.csd-result {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.csd-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.csd-field--name,
|
||||
.csd-field--address {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
`
|
||||
]
|
||||
@@ -217,20 +534,18 @@ export class CustomerSearchDialogComponent implements OnInit {
|
||||
private http = inject(HttpClient);
|
||||
private dialogRef = inject(MatDialogRef<CustomerSearchDialogComponent>);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
private data = inject<CustomerSearchDialogData | null>(MAT_DIALOG_DATA, { optional: true });
|
||||
|
||||
form!: FormGroup;
|
||||
loading = false;
|
||||
searched = false;
|
||||
results: CustomerSearchResult[] = [];
|
||||
displayedColumns = [
|
||||
'displayName',
|
||||
'account',
|
||||
'mobileNo',
|
||||
'officeName',
|
||||
'panNumber',
|
||||
'aadhaarNumber',
|
||||
'address',
|
||||
'select'
|
||||
|
||||
readonly accountTypeOptions: Array<{ key: AccountTypeKey; label: string; icon: string }> = [
|
||||
{ key: 'savings', label: 'Savings', icon: 'savings' },
|
||||
{ key: 'current', label: 'Current', icon: 'account_balance_wallet' },
|
||||
{ key: 'fixedDeposit', label: 'Fixed Deposit', icon: 'lock' },
|
||||
{ key: 'recurringDeposit', label: 'Recurring Deposit', icon: 'autorenew' }
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -247,6 +562,48 @@ export class CustomerSearchDialogComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
/** The account screens need an account to open; a caller that only needs the customer opts out. */
|
||||
private get requireAccount(): boolean {
|
||||
return this.data?.requireAccount !== false;
|
||||
}
|
||||
|
||||
canSelect(r: CustomerSearchResult): boolean {
|
||||
return !this.requireAccount || !!r.accountNo;
|
||||
}
|
||||
|
||||
isTypeOn(key: AccountTypeKey): boolean {
|
||||
return !!this.form?.get(key)?.value;
|
||||
}
|
||||
|
||||
toggleType(key: AccountTypeKey): void {
|
||||
const control = this.form.get(key);
|
||||
control?.setValue(!control.value);
|
||||
}
|
||||
|
||||
upperCase(name: string): void {
|
||||
const control = this.form.get(name);
|
||||
const value = String(control?.value ?? '');
|
||||
const upper = value.toUpperCase();
|
||||
if (value !== upper) {
|
||||
control?.setValue(upper, { emitEvent: false });
|
||||
}
|
||||
}
|
||||
|
||||
/** Enter in a text box runs the search; on a button it keeps its own meaning. */
|
||||
onEnter(event: Event): void {
|
||||
if ((event.target as HTMLElement)?.tagName !== 'INPUT') return;
|
||||
event.preventDefault();
|
||||
if (!this.loading) this.onSearch();
|
||||
}
|
||||
|
||||
initials(name: string | null): string {
|
||||
const words = (name || '').trim().split(/\s+/).filter(Boolean);
|
||||
if (words.length === 0) return '?';
|
||||
const first = words[0].charAt(0);
|
||||
const last = words.length > 1 ? words[words.length - 1].charAt(0) : '';
|
||||
return (first + last).toUpperCase();
|
||||
}
|
||||
|
||||
accountTypeLabel(type: string | null): string {
|
||||
switch (type) {
|
||||
case 'SAVINGS':
|
||||
@@ -297,6 +654,22 @@ export class CustomerSearchDialogComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
onClear(): void {
|
||||
this.form.reset({
|
||||
name: '',
|
||||
accountNumber: '',
|
||||
aadhaarNumber: '',
|
||||
panNumber: '',
|
||||
address: '',
|
||||
savings: false,
|
||||
current: false,
|
||||
fixedDeposit: false,
|
||||
recurringDeposit: false
|
||||
});
|
||||
this.results = [];
|
||||
this.searched = false;
|
||||
}
|
||||
|
||||
onSelect(result: CustomerSearchResult): void {
|
||||
this.dialogRef.close(result);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user