feat: KYC re-verify UX, family/nominee tab gating, savings dialogs, cfcfrms shell
- KYC tab: fix Verify (PAN) and Send OTP (Aadhaar) being disabled in edit mode; unmask Aadhaar input and show on-file masked value as hint; resolve kycId from id/kycId/resourceId fallbacks. - Client stepper/view: add familyAllowed / nomineeAllowed predicates + CanActivate route guards. Family tab hidden for non-individuals, Nominee tab hidden for non-proprietor entities. Family-members step accepts a nomineeOnly input for proprietorship clients. Add/Edit/Delete buttons inside family-members and nominee tabs gated by canAdd. - cfcfrms: add cfcfrms-shell wrapper, restyle complaint/fraud/mule/escalate pages and dashboard. - Savings: identity-verify and transaction-confirm dialogs, manage / activate / add-charge flows, account preview styling. - Tasks, core shell, account-nominees, icons: minor style and behaviour updates. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
bf3e26a0d0
commit
b149fef72e
+128
@@ -0,0 +1,128 @@
|
||||
<!--
|
||||
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="idv">
|
||||
<!-- Header — single-line teal strip matching the withdrawal hero. -->
|
||||
<div class="idv__head">
|
||||
<fa-icon icon="id-card" class="idv__head-ico"></fa-icon>
|
||||
<h2 class="idv__title">Verify Customer Identity</h2>
|
||||
<span class="idv__head-spacer"></span>
|
||||
<span class="idv__head-tag">
|
||||
<fa-icon icon="shield-alt"></fa-icon>
|
||||
KYC Check
|
||||
</span>
|
||||
<button type="button" class="idv__head-close" (click)="close()" aria-label="Close">
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<mat-dialog-content class="idv__content">
|
||||
<div class="idv__row">
|
||||
<!-- Profile photo (click to zoom) -->
|
||||
<div
|
||||
class="idv__photo"
|
||||
[class.idv__photo--zoomable]="!!profileImage"
|
||||
(click)="zoomImage(profileImage, 'Profile Photo')"
|
||||
[attr.role]="profileImage ? 'button' : null"
|
||||
[attr.tabindex]="profileImage ? 0 : null"
|
||||
>
|
||||
@if (profileImage) {
|
||||
<img [src]="profileImage" alt="Profile" />
|
||||
<span class="idv__zoom-hint" aria-hidden="true">
|
||||
<fa-icon icon="search"></fa-icon>
|
||||
</span>
|
||||
} @else {
|
||||
<fa-icon icon="user-circle"></fa-icon>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="idv__meta">
|
||||
<div class="idv__meta-row">
|
||||
<span class="idv__meta-label">Client</span>
|
||||
<span class="idv__meta-value">{{ displayName || data.clientName || '—' }}</span>
|
||||
</div>
|
||||
<div class="idv__meta-row">
|
||||
<span class="idv__meta-label">CIF Number</span>
|
||||
<span class="idv__meta-value idv__meta-value--mono">{{ cifNumber || '—' }}</span>
|
||||
</div>
|
||||
<div class="idv__meta-row">
|
||||
<span class="idv__meta-label">Account No</span>
|
||||
<span class="idv__meta-value idv__meta-value--mono">{{ data.accountNo || '—' }}</span>
|
||||
</div>
|
||||
@if (mobileNo) {
|
||||
<div class="idv__meta-row">
|
||||
<span class="idv__meta-label">Mobile</span>
|
||||
<span class="idv__meta-value idv__meta-value--mono">{{ mobileNo }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (emailAddress) {
|
||||
<div class="idv__meta-row">
|
||||
<span class="idv__meta-label">Email</span>
|
||||
<span class="idv__meta-value">{{ emailAddress }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KYC -->
|
||||
<div class="idv__section">
|
||||
<span class="idv__section-title">KYC Identifiers</span>
|
||||
<div class="idv__kyc">
|
||||
<div class="idv__kyc-row">
|
||||
<span class="idv__kyc-label">PAN</span>
|
||||
<span class="idv__kyc-value idv__meta-value--mono">{{ panNumber || '—' }}</span>
|
||||
@if (panNumber) {
|
||||
<span class="idv__pill" [class.idv__pill--ok]="panVerified" [class.idv__pill--warn]="!panVerified">
|
||||
<fa-icon [icon]="panVerified ? 'check-circle' : 'exclamation-triangle'"></fa-icon>
|
||||
{{ panVerified ? 'Verified' : 'Unverified' }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="idv__kyc-row">
|
||||
<span class="idv__kyc-label">Aadhaar</span>
|
||||
<span class="idv__kyc-value idv__meta-value--mono">{{ aadhaarMasked || '—' }}</span>
|
||||
@if (aadhaarMasked) {
|
||||
<span class="idv__pill" [class.idv__pill--ok]="aadhaarVerified" [class.idv__pill--warn]="!aadhaarVerified">
|
||||
<fa-icon [icon]="aadhaarVerified ? 'check-circle' : 'exclamation-triangle'"></fa-icon>
|
||||
{{ aadhaarVerified ? 'Verified' : 'Unverified' }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Signature (click to zoom) -->
|
||||
<div class="idv__section">
|
||||
<span class="idv__section-title">Specimen Signature</span>
|
||||
<div
|
||||
class="idv__sig"
|
||||
[class.idv__sig--zoomable]="!!signatureImage"
|
||||
(click)="zoomImage(signatureImage, 'Specimen Signature')"
|
||||
[attr.role]="signatureImage ? 'button' : null"
|
||||
[attr.tabindex]="signatureImage ? 0 : null"
|
||||
>
|
||||
@if (signatureImage) {
|
||||
<img [src]="signatureImage" alt="Signature" />
|
||||
<span class="idv__zoom-hint idv__zoom-hint--sig" aria-hidden="true">
|
||||
<fa-icon icon="search"></fa-icon> Zoom
|
||||
</span>
|
||||
} @else if (loading) {
|
||||
<span class="idv__sig-empty">Loading…</span>
|
||||
} @else {
|
||||
<span class="idv__sig-empty">No signature on file</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions class="idv__actions">
|
||||
<button mat-flat-button type="button" class="idv__close" (click)="close()">
|
||||
<fa-icon icon="check" class="m-r-6"></fa-icon> Done
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</div>
|
||||
+404
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
$navy: #0d1b2a;
|
||||
$slate: #475569;
|
||||
$muted: #64748b;
|
||||
$border: #e2e8f0;
|
||||
$soft: #f8fafc;
|
||||
$teal: #0f766e;
|
||||
$teal-d: #134e4a;
|
||||
$green: #047857;
|
||||
$amber: #b45309;
|
||||
|
||||
.idv {
|
||||
width: 480px;
|
||||
max-width: 92vw;
|
||||
}
|
||||
|
||||
/* Single-line teal strip — icon · title · KYC badge · close. Compact,
|
||||
reaches edge-to-edge, no subtitle clutter. */
|
||||
.idv__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 14px;
|
||||
height: 44px;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, $teal-d 0%, #115e59 45%, $teal 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at top right, rgb(255 255 255 / 14%), transparent 55%);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.idv__head-ico {
|
||||
font-size: 14px;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.idv__title {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.01em;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.idv__head-spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
/* KYC badge — communicates the purpose of the dialog without needing
|
||||
subtitle copy. */
|
||||
.idv__head-tag {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 22px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 16%);
|
||||
border: 1px solid rgb(255 255 255 / 30%);
|
||||
font-size: 10.5px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
|
||||
fa-icon {
|
||||
font-size: 10px;
|
||||
opacity: 0.95;
|
||||
}
|
||||
}
|
||||
|
||||
/* Close (×) button — clean white circle, right-anchored. */
|
||||
.idv__head-close {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgb(255 255 255 / 30%);
|
||||
background: rgb(255 255 255 / 12%);
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
transform 0.12s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgb(255 255 255 / 26%);
|
||||
border-color: rgb(255 255 255 / 46%);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
}
|
||||
|
||||
.idv__content {
|
||||
padding: 16px 18px 8px !important;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* Profile photo + identity meta */
|
||||
.idv__row {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px dashed $border;
|
||||
}
|
||||
|
||||
.idv__photo {
|
||||
position: relative;
|
||||
width: 92px;
|
||||
height: 92px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: $soft;
|
||||
border: 1px solid $border;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: #cbd5e1;
|
||||
font-size: 42px;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
|
||||
&--zoomable {
|
||||
cursor: zoom-in;
|
||||
|
||||
&:hover img {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
&:hover .idv__zoom-hint {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid $teal;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tiny magnifier badge in the corner of zoomable surfaces. */
|
||||
.idv__zoom-hint {
|
||||
position: absolute;
|
||||
bottom: 6px;
|
||||
right: 6px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: rgb(15 23 42 / 78%);
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
opacity: 0.85;
|
||||
transition: opacity 0.15s ease;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 2px 6px -2px rgb(0 0 0 / 35%);
|
||||
|
||||
&--sig {
|
||||
width: auto;
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
|
||||
fa-icon {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.idv__meta {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.idv__meta-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.idv__meta-label {
|
||||
color: $slate;
|
||||
font-size: 10.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.idv__meta-value {
|
||||
color: $navy;
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
word-break: break-word;
|
||||
|
||||
&--mono {
|
||||
font-family: 'JetBrains Mono', 'Courier New', monospace;
|
||||
font-size: 11.5px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
}
|
||||
|
||||
/* Sections (KYC / Signature) */
|
||||
.idv__section {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.idv__section-title {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: $slate;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.idv__kyc {
|
||||
background: $soft;
|
||||
border: 1px solid $border;
|
||||
border-radius: 10px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.idv__kyc-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 5px 0;
|
||||
font-size: 12.5px;
|
||||
|
||||
& + & {
|
||||
border-top: 1px dashed #eef2f7;
|
||||
}
|
||||
}
|
||||
|
||||
.idv__kyc-label {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: $slate;
|
||||
min-width: 64px;
|
||||
}
|
||||
|
||||
.idv__kyc-value {
|
||||
flex: 1 1 auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.idv__pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 20px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
|
||||
fa-icon {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
&--ok {
|
||||
background: #ecfdf5;
|
||||
color: $green;
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
|
||||
&--warn {
|
||||
background: #fffbeb;
|
||||
color: $amber;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
}
|
||||
|
||||
/* Signature */
|
||||
.idv__sig {
|
||||
position: relative;
|
||||
background: $soft;
|
||||
border: 1px solid $border;
|
||||
border-radius: 10px;
|
||||
height: 110px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
img {
|
||||
max-width: 92%;
|
||||
max-height: 92%;
|
||||
object-fit: contain;
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
|
||||
&--zoomable {
|
||||
cursor: zoom-in;
|
||||
|
||||
&:hover {
|
||||
border-color: $teal;
|
||||
}
|
||||
|
||||
&:hover img {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
&:hover .idv__zoom-hint {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid $teal;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.idv__sig-empty {
|
||||
font-size: 12px;
|
||||
color: $muted;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.idv__actions {
|
||||
padding: 12px 18px 16px !important;
|
||||
justify-content: flex-end;
|
||||
background: #fff;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.idv__close {
|
||||
height: 36px !important;
|
||||
border-radius: 9px !important;
|
||||
font-weight: 700 !important;
|
||||
padding: 0 22px !important;
|
||||
background: linear-gradient(135deg, $teal 0%, $teal-d 100%) !important;
|
||||
color: #fff !important;
|
||||
box-shadow: 0 3px 10px -2px rgb(15 118 110 / 35%) !important;
|
||||
}
|
||||
|
||||
.m-r-6 {
|
||||
margin-right: 6px;
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* 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 {
|
||||
MatDialog,
|
||||
MatDialogRef,
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
} from '@angular/material/dialog';
|
||||
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
|
||||
import { ClientsService } from 'app/clients/clients.service';
|
||||
import { ImageZoomDialogComponent } from 'app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
export interface IdentityVerifyData {
|
||||
clientId: number;
|
||||
/** Optional — we already know these from the parent screen, used as fallback
|
||||
* while the API calls are still in flight. */
|
||||
clientName: string | null;
|
||||
accountNo: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity-verify pop-up shown on the withdrawal screen. Loads the
|
||||
* client's CIF, profile picture, signature, PAN and Aadhaar so the
|
||||
* cashier can match the customer against the system before paying out
|
||||
* cash.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-identity-verify-dialog',
|
||||
standalone: true,
|
||||
templateUrl: './identity-verify-dialog.component.html',
|
||||
styleUrls: ['./identity-verify-dialog.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class IdentityVerifyDialogComponent implements OnInit {
|
||||
dialogRef = inject<MatDialogRef<IdentityVerifyDialogComponent>>(MatDialogRef);
|
||||
data = inject<IdentityVerifyData>(MAT_DIALOG_DATA);
|
||||
private clientsService = inject(ClientsService);
|
||||
private sanitizer = inject(DomSanitizer);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
loading = true;
|
||||
/** Best-effort population — any failing fetch leaves the corresponding
|
||||
* field null and the template falls back to "—". */
|
||||
cifNumber: string | null = null;
|
||||
displayName: string | null = null;
|
||||
mobileNo: string | null = null;
|
||||
emailAddress: string | null = null;
|
||||
panNumber: string | null = null;
|
||||
panVerified = false;
|
||||
aadhaarMasked: string | null = null;
|
||||
aadhaarVerified = false;
|
||||
profileImage: SafeUrl | null = null;
|
||||
signatureImage: SafeUrl | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
if (!this.data?.clientId) {
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
const id = String(this.data.clientId);
|
||||
let pending = 3;
|
||||
const done = () => {
|
||||
pending--;
|
||||
if (pending <= 0) this.loading = false;
|
||||
};
|
||||
|
||||
// Client profile (CIF, name, mobile, email)
|
||||
this.clientsService.getClientData(id).subscribe({
|
||||
next: (client: any) => {
|
||||
this.cifNumber = client?.cifNumber ?? client?.cifNo ?? null;
|
||||
this.displayName = client?.displayName ?? client?.fullname ?? this.data.clientName;
|
||||
this.mobileNo = client?.mobileNo ?? null;
|
||||
this.emailAddress = client?.emailAddress ?? client?.email ?? null;
|
||||
done();
|
||||
},
|
||||
error: () => done()
|
||||
});
|
||||
|
||||
// KYC (PAN, Aadhaar)
|
||||
this.clientsService.getClientKyc(id).subscribe({
|
||||
next: (kyc: any) => {
|
||||
this.panNumber = kyc?.panNumber ?? null;
|
||||
this.panVerified = !!kyc?.panVerified;
|
||||
this.aadhaarMasked = kyc?.aadhaarNumberMasked ?? kyc?.aadhaarNumber ?? null;
|
||||
this.aadhaarVerified = !!kyc?.aadhaarVerified;
|
||||
done();
|
||||
},
|
||||
error: () => done()
|
||||
});
|
||||
|
||||
// Profile picture
|
||||
this.clientsService.getClientProfileImage(id).subscribe({
|
||||
next: (img: any) => {
|
||||
if (img) this.profileImage = this.sanitizer.bypassSecurityTrustResourceUrl(img);
|
||||
done();
|
||||
},
|
||||
error: () => done()
|
||||
});
|
||||
|
||||
// Signature — finds the doc named/described 'signature' then pulls
|
||||
// the blob. Counted separately because not all clients have one.
|
||||
this.clientsService.getClientDocuments(id).subscribe({
|
||||
next: (docs: any[]) => {
|
||||
const sigDoc = docs?.find(
|
||||
(d: any) =>
|
||||
d?.name?.toLowerCase()?.includes('signature') || d?.description?.toLowerCase()?.includes('signature')
|
||||
);
|
||||
if (sigDoc?.id != null) {
|
||||
this.clientsService.getClientSignatureImage(id, String(sigDoc.id)).subscribe({
|
||||
next: (blob: Blob) => {
|
||||
this.signatureImage = this.sanitizer.bypassSecurityTrustUrl(URL.createObjectURL(blob));
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pop the image up in the shared full-screen zoom viewer so the
|
||||
* cashier can match minute details (signature loops, photo features)
|
||||
* against the customer at the counter.
|
||||
*/
|
||||
zoomImage(imageSrc: SafeUrl | null, title: string): void {
|
||||
if (!imageSrc) return;
|
||||
this.dialog.open(ImageZoomDialogComponent, {
|
||||
data: { imageSrc, title },
|
||||
panelClass: 'image-zoom-panel',
|
||||
maxWidth: '90vw',
|
||||
maxHeight: '90vh'
|
||||
});
|
||||
}
|
||||
}
|
||||
+334
-97
@@ -6,127 +6,364 @@
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="container mat-elevation-z8">
|
||||
<mat-card>
|
||||
@if (transactionType.withdrawal) {
|
||||
<mat-card-title class="form-heading">
|
||||
{{ 'labels.heading.Withdraw Money From Saving Account' | translate }}
|
||||
</mat-card-title>
|
||||
}
|
||||
@if (transactionType.deposit) {
|
||||
<mat-card-title class="form-heading">
|
||||
{{ 'labels.heading.Deposit Money To Saving Account' | translate }}
|
||||
</mat-card-title>
|
||||
}
|
||||
<div class="sat" [class.sat--deposit]="transactionType.deposit" [class.sat--withdrawal]="transactionType.withdrawal">
|
||||
<!-- Hero header -->
|
||||
<div class="sat__hero">
|
||||
<div class="sat__hero-top">
|
||||
<button type="button" mat-stroked-button class="sat__hero-back" [routerLink]="['../../transactions']">
|
||||
<fa-icon icon="arrow-left" class="m-r-6"></fa-icon>
|
||||
{{ 'labels.buttons.Back' | translate }}
|
||||
</button>
|
||||
<div class="sat__hero-ico">
|
||||
<fa-icon [icon]="transactionType.deposit ? 'arrow-down' : 'arrow-up'"></fa-icon>
|
||||
</div>
|
||||
<div class="sat__hero-title-wrap">
|
||||
<h2 class="sat__title">
|
||||
{{
|
||||
transactionType.withdrawal
|
||||
? ('labels.heading.Withdraw Money From Saving Account' | translate)
|
||||
: ('labels.heading.Deposit Money To Saving Account' | translate)
|
||||
}}
|
||||
</h2>
|
||||
<p class="sat__sub">
|
||||
{{
|
||||
transactionType.deposit
|
||||
? 'Count the cash by denomination, then confirm the payment details.'
|
||||
: 'Disburse cash by denomination and confirm the payment details.'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
@if (transactionType.withdrawal && clientId) {
|
||||
<button
|
||||
type="button"
|
||||
mat-flat-button
|
||||
class="sat__hero-verify"
|
||||
[class.sat__hero-verify--pulse]="!identityVerified"
|
||||
(click)="openIdentityVerify()"
|
||||
aria-label="Verify customer identity"
|
||||
>
|
||||
<fa-icon icon="id-card" class="m-r-6"></fa-icon>
|
||||
Verify Identity
|
||||
</button>
|
||||
}
|
||||
<div class="sat__hero-meta">
|
||||
<span class="sat__hero-meta-label">Counted Total</span>
|
||||
<span class="sat__hero-meta-value"> ₹{{ denominationTotal | number: '1.2-2' }} </span>
|
||||
@if (totalPieces > 0) {
|
||||
<span class="sat__hero-meta-sub">{{ totalPieces }} piece(s)</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="savingAccountTransactionForm" (ngSubmit)="submit()">
|
||||
<mat-card-content>
|
||||
<div class="layout-column">
|
||||
<mat-form-field (click)="dueDatePicker.open()">
|
||||
<mat-label>{{ 'labels.inputs.Transaction Date' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[min]="minDate"
|
||||
[max]="maxDate"
|
||||
[matDatepicker]="dueDatePicker"
|
||||
formControlName="transactionDate"
|
||||
required
|
||||
/>
|
||||
<mat-datepicker-toggle matSuffix [for]="dueDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #dueDatePicker></mat-datepicker>
|
||||
@if (savingAccountTransactionForm.controls.transactionDate.hasError('required')) {
|
||||
<mat-error>
|
||||
{{ 'labels.inputs.Transaction Date' | translate }} {{ 'labels.commons.is' | translate }}
|
||||
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
<!-- Context strip: client / account / KYC / risk / same-day NEFT-RTGS -->
|
||||
<div class="sat__ctx">
|
||||
<div class="sat__ctx-item">
|
||||
<fa-icon icon="user" class="sat__ctx-ico"></fa-icon>
|
||||
<div class="sat__ctx-text">
|
||||
<span class="sat__ctx-label">Client</span>
|
||||
<span class="sat__ctx-value">{{ clientName || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mifosx-input-amount
|
||||
[currency]="currency"
|
||||
[isRequired]="true"
|
||||
[inputFormControl]="savingAccountTransactionForm.controls.transactionAmount"
|
||||
[inputLabel]="'Transaction Amount'"
|
||||
<div class="sat__ctx-divider"></div>
|
||||
|
||||
<div class="sat__ctx-item">
|
||||
<fa-icon icon="hashtag" class="sat__ctx-ico"></fa-icon>
|
||||
<div class="sat__ctx-text">
|
||||
<span class="sat__ctx-label">Account No</span>
|
||||
<span class="sat__ctx-value sat__ctx-value--mono">{{ accountNo || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sat__ctx-divider"></div>
|
||||
|
||||
<div class="sat__ctx-item sat__ctx-item--balance">
|
||||
<fa-icon icon="wallet" class="sat__ctx-ico"></fa-icon>
|
||||
<div class="sat__ctx-text">
|
||||
<span class="sat__ctx-label">Available Balance</span>
|
||||
<span class="sat__ctx-value sat__ctx-value--amount sat__ctx-value--lg">
|
||||
₹{{ availableBalance | number: '1.2-2' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sat__ctx-divider"></div>
|
||||
|
||||
<div class="sat__ctx-item">
|
||||
<fa-icon icon="id-card" class="sat__ctx-ico"></fa-icon>
|
||||
<div class="sat__ctx-text">
|
||||
<span class="sat__ctx-label">KYC</span>
|
||||
<span
|
||||
class="sat__ctx-badge"
|
||||
[class.sat__ctx-badge--ok]="kycStatus === 'Verified'"
|
||||
[class.sat__ctx-badge--warn]="kycStatus === 'Partial'"
|
||||
[class.sat__ctx-badge--danger]="kycStatus === 'Pending'"
|
||||
>
|
||||
</mifosx-input-amount>
|
||||
<fa-icon
|
||||
[icon]="
|
||||
kycStatus === 'Verified'
|
||||
? 'check-circle'
|
||||
: kycStatus === 'Partial'
|
||||
? 'exclamation-triangle'
|
||||
: 'times-circle'
|
||||
"
|
||||
></fa-icon>
|
||||
{{ kycStatus }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label>{{ 'labels.inputs.Payment Type' | translate }}</mat-label>
|
||||
<mat-select required formControlName="paymentTypeId">
|
||||
@for (paymentType of paymentTypeOptions; track paymentType) {
|
||||
<mat-option [value]="paymentType.id">
|
||||
{{ paymentType.name }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
@if (savingAccountTransactionForm.controls.paymentTypeId.hasError('required')) {
|
||||
<mat-error>
|
||||
{{ 'labels.inputs.Payment Type' | translate }} {{ 'labels.commons.is' | translate }}
|
||||
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
<div class="sat__ctx-divider"></div>
|
||||
|
||||
<div class="flex-fill">
|
||||
<span class="expandcollapsebutton m-l-10 m-t-40 flex-75" (click)="addPaymentDetails()">
|
||||
<mat-slide-toggle>
|
||||
<div [className]="addPaymentDetailsFlag ? 'enabled' : 'disabled'">
|
||||
<span class="m-l-10">{{ 'labels.inputs.Show Payment Details' | translate }}</span>
|
||||
</div>
|
||||
</mat-slide-toggle>
|
||||
<div class="sat__ctx-item">
|
||||
<fa-icon icon="exchange-alt" class="sat__ctx-ico"></fa-icon>
|
||||
<div class="sat__ctx-text">
|
||||
<span class="sat__ctx-label">Today's NEFT/RTGS</span>
|
||||
<span class="sat__ctx-value sat__ctx-value--amount"> ₹{{ sameDayNeftRtgs | number: '1.2-2' }} </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sat__ctx-divider"></div>
|
||||
|
||||
<div class="sat__ctx-item">
|
||||
<fa-icon icon="money-check-alt" class="sat__ctx-ico"></fa-icon>
|
||||
<div class="sat__ctx-text">
|
||||
<span class="sat__ctx-label">Cheque In Clearing</span>
|
||||
<span class="sat__ctx-value sat__ctx-value--amount"> ₹{{ sameDayChequeClearing | number: '1.2-2' }} </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="savingAccountTransactionForm" (ngSubmit)="submit()" class="sat__form">
|
||||
<!-- ─── LEFT COLUMN: denomination breakdown ────────────────────── -->
|
||||
<section class="sat__panel sat__panel--denom">
|
||||
<header class="sat__panel-head">
|
||||
<fa-icon icon="money-bill-wave" class="sat__panel-ico"></fa-icon>
|
||||
<h3 class="sat__panel-title">Cash Denomination Breakdown</h3>
|
||||
<span class="sat__panel-spacer"></span>
|
||||
<button type="button" mat-stroked-button class="sat__panel-action" (click)="clearDenominations()">
|
||||
<fa-icon icon="undo" class="m-r-6"></fa-icon> Clear
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Banknotes -->
|
||||
<div class="sat__denom-group">
|
||||
<div class="sat__denom-group-head">
|
||||
<span class="sat__denom-group-title">Banknotes</span>
|
||||
<span class="sat__denom-group-total">₹{{ notesTotal | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="sat__denom-grid">
|
||||
@for (d of notes; track d.value) {
|
||||
<div class="sat__denom" [class.sat__denom--active]="countFor(d) > 0">
|
||||
<div class="sat__denom-top">
|
||||
<span class="sat__denom-chip sat__denom-chip--note">{{ d.label }}</span>
|
||||
<span class="sat__denom-amount">₹{{ subtotalFor(d) | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="sat__denom-stepper">
|
||||
<button
|
||||
type="button"
|
||||
class="sat__denom-step"
|
||||
(click)="bumpDenom(d, -1)"
|
||||
[disabled]="countFor(d) === 0"
|
||||
aria-label="Decrease"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
[value]="countFor(d)"
|
||||
(input)="onDenomChange(d, $any($event.target).value)"
|
||||
inputmode="numeric"
|
||||
class="sat__denom-count"
|
||||
/>
|
||||
<button type="button" class="sat__denom-step" (click)="bumpDenom(d, 1)" aria-label="Increase">+</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Coins -->
|
||||
<div class="sat__denom-group">
|
||||
<div class="sat__denom-group-head">
|
||||
<span class="sat__denom-group-title">Coins</span>
|
||||
<span class="sat__denom-group-total">₹{{ coinsTotal | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="sat__denom-grid">
|
||||
@for (d of coins; track d.value) {
|
||||
<div class="sat__denom" [class.sat__denom--active]="countFor(d) > 0">
|
||||
<div class="sat__denom-top">
|
||||
<span class="sat__denom-chip sat__denom-chip--coin">{{ d.label }}</span>
|
||||
<span class="sat__denom-amount">₹{{ subtotalFor(d) | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="sat__denom-stepper">
|
||||
<button
|
||||
type="button"
|
||||
class="sat__denom-step"
|
||||
(click)="bumpDenom(d, -1)"
|
||||
[disabled]="countFor(d) === 0"
|
||||
aria-label="Decrease"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
[value]="countFor(d)"
|
||||
(input)="onDenomChange(d, $any($event.target).value)"
|
||||
inputmode="numeric"
|
||||
class="sat__denom-count"
|
||||
/>
|
||||
<button type="button" class="sat__denom-step" (click)="bumpDenom(d, 1)" aria-label="Increase">+</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tally + apply -->
|
||||
<div class="sat__tally">
|
||||
<div class="sat__tally-row">
|
||||
<span class="sat__tally-label">Banknotes</span>
|
||||
<span class="sat__tally-value">₹{{ notesTotal | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="sat__tally-row">
|
||||
<span class="sat__tally-label">Coins</span>
|
||||
<span class="sat__tally-value">₹{{ coinsTotal | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="sat__tally-row sat__tally-row--grand">
|
||||
<span class="sat__tally-label">Grand Total</span>
|
||||
<span class="sat__tally-value">₹{{ denominationTotal | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
mat-flat-button
|
||||
class="sat__apply"
|
||||
[disabled]="denominationTotal === 0"
|
||||
(click)="applyDenominationTotal()"
|
||||
>
|
||||
<fa-icon icon="arrow-right" class="m-r-6"></fa-icon>
|
||||
Use ₹{{ denominationTotal | number: '1.2-2' }} as Transaction Amount
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ─── RIGHT COLUMN: transaction + payment details ────────────── -->
|
||||
<section class="sat__panel sat__panel--side">
|
||||
<header class="sat__panel-head">
|
||||
<fa-icon icon="info-circle" class="sat__panel-ico"></fa-icon>
|
||||
<h3 class="sat__panel-title">Transaction Details</h3>
|
||||
</header>
|
||||
|
||||
<div class="sat__panel-body">
|
||||
<mat-form-field appearance="outline" class="sat__field" (click)="dueDatePicker.open()">
|
||||
<mat-label>{{ 'labels.inputs.Transaction Date' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[min]="minDate"
|
||||
[max]="maxDate"
|
||||
[matDatepicker]="dueDatePicker"
|
||||
formControlName="transactionDate"
|
||||
required
|
||||
/>
|
||||
<mat-datepicker-toggle matSuffix [for]="dueDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #dueDatePicker></mat-datepicker>
|
||||
@if (savingAccountTransactionForm.controls.transactionDate.hasError('required')) {
|
||||
<mat-error>
|
||||
{{ 'labels.inputs.Transaction Date' | translate }} {{ 'labels.commons.is' | translate }}
|
||||
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mifosx-input-amount
|
||||
[currency]="currency"
|
||||
[isRequired]="true"
|
||||
[inputFormControl]="savingAccountTransactionForm.controls.transactionAmount"
|
||||
[inputLabel]="'Transaction Amount'"
|
||||
>
|
||||
</mifosx-input-amount>
|
||||
|
||||
@if (denominationTotal > 0 && !denominationMatchesForm) {
|
||||
<div class="sat__mismatch">
|
||||
<fa-icon icon="exclamation-triangle" class="sat__mismatch-ico"></fa-icon>
|
||||
<span>
|
||||
Counted cash <strong>₹{{ denominationTotal | number: '1.2-2' }}</strong> doesn't match the transaction
|
||||
amount. Click <strong>Use ₹{{ denominationTotal | number: '1.2-2' }}</strong> to sync.
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@if (denominationMatchesForm) {
|
||||
<div class="sat__match">
|
||||
<fa-icon icon="check-circle" class="sat__match-ico"></fa-icon>
|
||||
Cash count matches transaction amount.
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (addPaymentDetailsFlag) {
|
||||
<mat-form-field>
|
||||
<mat-form-field appearance="outline" class="sat__field">
|
||||
<mat-label>{{ 'labels.inputs.Payment Type' | translate }}</mat-label>
|
||||
<mat-select required formControlName="paymentTypeId">
|
||||
@for (paymentType of paymentTypeOptions; track paymentType) {
|
||||
<mat-option [value]="paymentType.id">{{ paymentType.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
@if (savingAccountTransactionForm.controls.paymentTypeId.hasError('required')) {
|
||||
<mat-error>
|
||||
{{ 'labels.inputs.Payment Type' | translate }} {{ 'labels.commons.is' | translate }}
|
||||
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<div class="sat__toggle-row" (click)="addPaymentDetails()">
|
||||
<mat-slide-toggle [checked]="!!addPaymentDetailsFlag">
|
||||
<span class="sat__toggle-label">{{ 'labels.inputs.Show Payment Details' | translate }}</span>
|
||||
</mat-slide-toggle>
|
||||
</div>
|
||||
|
||||
@if (addPaymentDetailsFlag) {
|
||||
<div class="sat__sub-grid">
|
||||
<mat-form-field appearance="outline" class="sat__field">
|
||||
<mat-label>{{ 'labels.inputs.Account Number' | translate }}</mat-label>
|
||||
<input type="number" formControlName="accountNumber" matInput />
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
@if (addPaymentDetailsFlag) {
|
||||
<mat-form-field>
|
||||
<mat-form-field appearance="outline" class="sat__field">
|
||||
<mat-label>{{ 'labels.inputs.Cheque' | translate }}</mat-label>
|
||||
<input type="number" formControlName="checkNumber" matInput />
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
@if (addPaymentDetailsFlag) {
|
||||
<mat-form-field>
|
||||
<mat-form-field appearance="outline" class="sat__field">
|
||||
<mat-label>{{ 'labels.inputs.Routing Code' | translate }}</mat-label>
|
||||
<input formControlName="routingCode" matInput />
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
@if (addPaymentDetailsFlag) {
|
||||
<mat-form-field>
|
||||
<mat-form-field appearance="outline" class="sat__field">
|
||||
<mat-label>{{ 'labels.inputs.Receipt Number' | translate }}</mat-label>
|
||||
<input formControlName="receiptNumber" matInput />
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
@if (addPaymentDetailsFlag) {
|
||||
<mat-form-field>
|
||||
<mat-form-field appearance="outline" class="sat__field sat__field--wide">
|
||||
<mat-label>{{ 'labels.inputs.Bank' | translate }}</mat-label>
|
||||
<input formControlName="bankNumber" matInput />
|
||||
</mat-form-field>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label>{{ 'labels.inputs.Notes' | translate }}</mat-label>
|
||||
<textarea formControlName="note" matInput cdkTextareaAutosize cdkAutosizeMinRows="2"></textarea>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="sat__field">
|
||||
<mat-label>{{ 'labels.inputs.Notes' | translate }}</mat-label>
|
||||
<textarea formControlName="note" matInput cdkTextareaAutosize cdkAutosizeMinRows="2"></textarea>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-card-actions class="layout-row layout-xs-column layout-align-center gap-5px">
|
||||
<button type="button" mat-raised-button [routerLink]="['../../transactions']">
|
||||
{{ 'labels.buttons.Cancel' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="primary" [disabled]="!savingAccountTransactionForm.valid">
|
||||
{{ 'labels.buttons.Submit' | translate }}
|
||||
</button>
|
||||
</mat-card-actions>
|
||||
<div class="sat__actions">
|
||||
<button type="button" mat-stroked-button class="sat__cancel" [routerLink]="['../../transactions']">
|
||||
<fa-icon icon="times" class="m-r-6"></fa-icon>
|
||||
{{ 'labels.buttons.Cancel' | translate }}
|
||||
</button>
|
||||
<button mat-flat-button type="submit" class="sat__submit" [disabled]="!savingAccountTransactionForm.valid">
|
||||
<fa-icon icon="check" class="m-r-6"></fa-icon>
|
||||
{{ 'labels.buttons.Submit' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</form>
|
||||
</mat-card>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
+858
-10
@@ -6,24 +6,872 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
.expandcollapsebutton {
|
||||
margin-top: -7px;
|
||||
/* Banking-grade transaction-entry screen. Two-column layout: cash
|
||||
denomination breakdown on the left (the cashier's primary workspace),
|
||||
transaction & payment details on the right. Hero header is green for
|
||||
deposits / amber for withdrawals. */
|
||||
|
||||
$navy: #0d1b2a;
|
||||
$slate: #475569;
|
||||
$muted: #64748b;
|
||||
$border: #e2e8f0;
|
||||
$soft: #f8fafc;
|
||||
$blue: #1e40af;
|
||||
$green: #059669;
|
||||
$green-d: #047857;
|
||||
$amber: #d97706;
|
||||
$amber-d: #b45309;
|
||||
$red: #b91c1c;
|
||||
|
||||
.sat {
|
||||
width: 100%;
|
||||
padding: 10px 16px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 37rem;
|
||||
/* ─── Hero ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Deep navy-blue banking gradient — formal, high-contrast, sits well
|
||||
with KYC / Risk / NEFT-RTGS / Cheque badges that follow. */
|
||||
.sat__hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 10px 16px 12px;
|
||||
border-radius: 12px;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #0f172a 0%, #1e3a8a 45%, #1e40af 100%);
|
||||
box-shadow: 0 6px 18px -10px rgb(15 23 42 / 50%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at top right, rgb(255 255 255 / 12%), transparent 55%);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.right-input {
|
||||
.sat__hero-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Back button — sits at the top-left of the hero on a translucent
|
||||
surface so it reads clearly against the dark gradient. */
|
||||
.sat__hero-back {
|
||||
height: 30px !important;
|
||||
border-radius: 8px !important;
|
||||
padding: 0 11px !important;
|
||||
font-weight: 700 !important;
|
||||
font-size: 12px !important;
|
||||
letter-spacing: 0.01em;
|
||||
background: rgb(255 255 255 / 14%) !important;
|
||||
color: #fff !important;
|
||||
border: 1px solid rgb(255 255 255 / 28%) !important;
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgb(255 255 255 / 26%) !important;
|
||||
border-color: rgb(255 255 255 / 42%) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.sat__hero-title-wrap {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Deep teal banking gradient for withdrawals — clearly distinct from the
|
||||
navy-blue deposit hero without resorting to a loud red. */
|
||||
.sat--withdrawal .sat__hero {
|
||||
background: linear-gradient(135deg, #134e4a 0%, #115e59 45%, #0f766e 100%);
|
||||
box-shadow: 0 6px 18px -10px rgb(15 118 110 / 50%);
|
||||
}
|
||||
|
||||
.sat__hero-ico {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 9px;
|
||||
background: rgb(255 255 255 / 18%);
|
||||
border: 1px solid rgb(255 255 255 / 28%);
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 15px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sat__title {
|
||||
margin: 0;
|
||||
font-size: 14.5px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.01em;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.sat__sub {
|
||||
margin: 1px 0 0;
|
||||
font-size: 11.5px;
|
||||
color: rgb(255 255 255 / 80%);
|
||||
}
|
||||
|
||||
/* Withdrawal-only "Verify Identity" pill — sits next to the Counted
|
||||
Total tile and opens the identity-verify dialog.
|
||||
|
||||
Adds a subtle outer pulse-ring (via ::before) until the cashier has
|
||||
opened the dialog once — drives attention to the compliance step
|
||||
without changing the button's resting colour. */
|
||||
.sat__hero-verify {
|
||||
position: relative;
|
||||
height: 30px !important;
|
||||
border-radius: 8px !important;
|
||||
padding: 0 12px !important;
|
||||
font-weight: 700 !important;
|
||||
font-size: 12px !important;
|
||||
letter-spacing: 0.01em;
|
||||
color: #fff !important;
|
||||
background: rgb(255 255 255 / 16%) !important;
|
||||
border: 1px solid rgb(255 255 255 / 30%) !important;
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
overflow: visible !important;
|
||||
|
||||
&:hover {
|
||||
background: rgb(255 255 255 / 28%) !important;
|
||||
border-color: rgb(255 255 255 / 42%) !important;
|
||||
}
|
||||
|
||||
/* Pulse ring lives just outside the pill so it doesn't clip; sits on
|
||||
a separate paint layer so it doesn't trigger layout. */
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
border-radius: 10px;
|
||||
border: 2px solid rgb(255 255 255 / 70%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
&--pulse::before {
|
||||
animation: sat-verify-pulse 1.6s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
/* Pause the pulse on hover so the hover state isn't fighting the
|
||||
animation — gives the cashier a calm target to click. */
|
||||
&--pulse:hover::before {
|
||||
animation-play-state: paused;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sat-verify-pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
70% {
|
||||
transform: scale(1.18);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1.18);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sat__hero-verify--pulse::before {
|
||||
animation: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.sat__hero-meta {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
padding: 5px 12px;
|
||||
border-radius: 8px;
|
||||
background: rgb(255 255 255 / 18%);
|
||||
border: 1px solid rgb(255 255 255 / 30%);
|
||||
backdrop-filter: blur(8px);
|
||||
min-width: 130px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sat__hero-meta-label {
|
||||
font-size: 9.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: rgb(255 255 255 / 78%);
|
||||
}
|
||||
|
||||
.sat__hero-meta-value {
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.sat__hero-meta-sub {
|
||||
font-size: 10px;
|
||||
color: rgb(255 255 255 / 75%);
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* ─── Context strip (client / account / KYC / risk / NEFT-RTGS) ──── */
|
||||
.sat__ctx {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
padding: 5px 8px;
|
||||
border-radius: 8px;
|
||||
background: rgb(255 255 255 / 12%);
|
||||
border: 1px solid rgb(255 255 255 / 22%);
|
||||
backdrop-filter: blur(8px);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.sat__ctx-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
/* Tight flex-basis (110px) so all chips pack on a single row at typical
|
||||
desktop widths — items shrink down to their content when space is
|
||||
constrained instead of wrapping. */
|
||||
flex: 1 1 110px;
|
||||
padding: 2px 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sat__ctx-ico {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
background: rgb(255 255 255 / 18%);
|
||||
border: 1px solid rgb(255 255 255 / 25%);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sat__ctx-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.sat__ctx-label {
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: rgb(255 255 255 / 70%);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sat__ctx-value {
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&--mono {
|
||||
font-family: 'JetBrains Mono', 'Courier New', monospace;
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
&--amount {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&--lg {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Highlight the live balance — the cashier's anchor figure. */
|
||||
.sat__ctx-item--balance {
|
||||
background: rgb(255 255 255 / 12%);
|
||||
border: 1px solid rgb(255 255 255 / 22%);
|
||||
border-radius: 7px;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
.sat__ctx-divider {
|
||||
width: 1px;
|
||||
background: rgb(255 255 255 / 18%);
|
||||
margin: 4px 0;
|
||||
flex-shrink: 0;
|
||||
|
||||
/* Vertical divider — hidden when items wrap to multiple rows */
|
||||
@media (width <= 720px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sat__ctx-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 22px;
|
||||
padding: 0 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
background: rgb(255 255 255 / 18%);
|
||||
border: 1px solid rgb(255 255 255 / 30%);
|
||||
color: #fff;
|
||||
align-self: flex-start;
|
||||
width: fit-content;
|
||||
|
||||
fa-icon {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
&--ok {
|
||||
background: rgb(16 185 129 / 36%);
|
||||
border-color: rgb(110 231 183 / 60%);
|
||||
color: #d1fae5;
|
||||
}
|
||||
|
||||
&--warn {
|
||||
background: rgb(245 158 11 / 36%);
|
||||
border-color: rgb(252 211 77 / 60%);
|
||||
color: #fef3c7;
|
||||
}
|
||||
|
||||
&--danger {
|
||||
background: rgb(239 68 68 / 36%);
|
||||
border-color: rgb(252 165 165 / 60%);
|
||||
color: #fee2e2;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Two-column layout ───────────────────────────────────────────── */
|
||||
.sat__form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
|
||||
@media (width <= 1100px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.sat__panel {
|
||||
background: #fff;
|
||||
border: 1px solid $border;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sat__panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
background: $soft;
|
||||
border-bottom: 1px solid $border;
|
||||
}
|
||||
|
||||
.sat__panel-ico {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 7px;
|
||||
background: linear-gradient(135deg, $green 0%, $green-d 100%);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.sat--withdrawal .sat__panel-ico {
|
||||
background: linear-gradient(135deg, $amber 0%, $amber-d 100%);
|
||||
}
|
||||
|
||||
.sat__panel-title {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: $navy;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.sat__panel-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sat__panel-action {
|
||||
height: 26px !important;
|
||||
border-radius: 7px !important;
|
||||
font-size: 11.5px !important;
|
||||
font-weight: 600 !important;
|
||||
padding: 0 10px !important;
|
||||
}
|
||||
|
||||
.sat__panel-body {
|
||||
padding: 12px 14px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
/* ─── Denomination groups ─────────────────────────────────────────── */
|
||||
.sat__denom-group {
|
||||
padding: 8px 14px 4px;
|
||||
|
||||
& + & {
|
||||
border-top: 1px solid #f1f5f9;
|
||||
}
|
||||
}
|
||||
|
||||
.sat__denom-group-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.sat__denom-group-title {
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: $slate;
|
||||
}
|
||||
|
||||
.sat__denom-group-total {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: $navy;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.sat__denom-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ── Single denomination tile ──
|
||||
Two-row layout: top has the denomination chip + running subtotal,
|
||||
bottom has a full-width stepper (− / count / +). Easier to read,
|
||||
gives the cashier obvious tap targets, and never clips on narrow
|
||||
columns. */
|
||||
.sat__denom {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
padding: 6px 9px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
border: 1px solid $border;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
min-width: 0;
|
||||
|
||||
&--active {
|
||||
border-color: #86efac;
|
||||
background: #f0fdf4;
|
||||
box-shadow: 0 1px 3px -1px rgb(4 120 87 / 18%);
|
||||
}
|
||||
}
|
||||
|
||||
.sat__denom-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sat__denom-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 48px;
|
||||
height: 22px;
|
||||
padding: 0 9px;
|
||||
border-radius: 5px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
|
||||
&--note {
|
||||
background: linear-gradient(135deg, #ecfeff 0%, #cffafe 100%);
|
||||
color: #155e75;
|
||||
border: 1px solid #a5f3fc;
|
||||
}
|
||||
|
||||
&--coin {
|
||||
background: linear-gradient(135deg, #fef9c3 0%, #fde68a 100%);
|
||||
color: #854d0e;
|
||||
border: 1px solid #fcd34d;
|
||||
}
|
||||
}
|
||||
|
||||
.sat__denom-amount {
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
color: $navy;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.right-label {
|
||||
padding-right: 25px !important;
|
||||
/* Stepper — full-width row beneath the chip. */
|
||||
.sat__denom-stepper {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid $border;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.form-heading {
|
||||
font-size: 1.25rem;
|
||||
.sat__denom-count {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
text-align: center;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: $navy;
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding: 0 4px;
|
||||
|
||||
/* Hide the native number spinner — the explicit + / − buttons replace it. */
|
||||
appearance: textfield;
|
||||
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.sat__denom-step {
|
||||
flex: 0 0 auto;
|
||||
width: 28px;
|
||||
background: $soft;
|
||||
border: none;
|
||||
border-right: 1px solid $border;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: $slate;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.12s ease,
|
||||
color 0.12s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
border-left: 1px solid $border;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #e2e8f0;
|
||||
color: $navy;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background: #cbd5e1;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Tally / apply ───────────────────────────────────────────────── */
|
||||
.sat__tally {
|
||||
margin: 8px 14px 14px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
border: 1px solid $border;
|
||||
}
|
||||
|
||||
.sat__tally-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 3px 0;
|
||||
font-size: 12px;
|
||||
color: $slate;
|
||||
|
||||
&--grand {
|
||||
margin-top: 4px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed #cbd5e1;
|
||||
|
||||
.sat__tally-label {
|
||||
font-weight: 800;
|
||||
color: $navy;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.sat__tally-value {
|
||||
font-size: 15.5px;
|
||||
font-weight: 800;
|
||||
color: $green-d;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sat--withdrawal .sat__tally-row--grand .sat__tally-value {
|
||||
color: $amber-d;
|
||||
}
|
||||
|
||||
.sat__tally-value {
|
||||
font-weight: 700;
|
||||
color: $navy;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.sat__apply {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
height: 34px !important;
|
||||
border-radius: 8px !important;
|
||||
background: linear-gradient(135deg, $green 0%, $green-d 100%) !important;
|
||||
color: #fff !important;
|
||||
font-weight: 700 !important;
|
||||
font-size: 12.5px !important;
|
||||
letter-spacing: 0.01em;
|
||||
box-shadow: 0 3px 10px -2px rgb(4 120 87 / 35%) !important;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
filter: grayscale(0.4);
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.sat--withdrawal .sat__apply {
|
||||
background: linear-gradient(135deg, $amber 0%, $amber-d 100%) !important;
|
||||
box-shadow: 0 4px 12px -2px rgb(180 83 9 / 35%) !important;
|
||||
}
|
||||
|
||||
/* ─── Form fields (right panel) ───────────────────────────────────── */
|
||||
.sat__field {
|
||||
width: 100%;
|
||||
|
||||
::ng-deep .mat-mdc-text-field-wrapper {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-form-field-infix {
|
||||
min-height: 48px;
|
||||
padding-top: 12px !important;
|
||||
padding-bottom: 12px !important;
|
||||
}
|
||||
|
||||
::ng-deep .mdc-notched-outline__leading,
|
||||
::ng-deep .mdc-notched-outline__notch,
|
||||
::ng-deep .mdc-notched-outline__trailing {
|
||||
border-color: #cbd5e1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.sat__sub-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px 12px;
|
||||
|
||||
.sat__field--wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.sat__toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 4px 10px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
.sat__toggle-label {
|
||||
margin-left: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: $slate;
|
||||
}
|
||||
}
|
||||
|
||||
/* Match / mismatch banners */
|
||||
.sat__mismatch,
|
||||
.sat__match {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
margin: 2px 0 8px;
|
||||
}
|
||||
|
||||
.sat__mismatch {
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
color: #92400e;
|
||||
|
||||
&-ico {
|
||||
color: $amber;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 800;
|
||||
}
|
||||
}
|
||||
|
||||
.sat__match {
|
||||
background: #ecfdf5;
|
||||
border: 1px solid #a7f3d0;
|
||||
color: #065f46;
|
||||
|
||||
&-ico {
|
||||
color: $green;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Actions ─────────────────────────────────────────────────────── */
|
||||
.sat__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
|
||||
button {
|
||||
height: 42px !important;
|
||||
border-radius: 10px !important;
|
||||
font-weight: 700 !important;
|
||||
padding: 0 22px !important;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
}
|
||||
|
||||
.sat__cancel {
|
||||
color: $slate !important;
|
||||
border-color: $border !important;
|
||||
}
|
||||
|
||||
.sat__submit {
|
||||
background: linear-gradient(135deg, $green 0%, $green-d 100%) !important;
|
||||
color: #fff !important;
|
||||
box-shadow: 0 4px 12px -2px rgb(4 120 87 / 35%) !important;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
filter: grayscale(0.3);
|
||||
background: linear-gradient(135deg, #9ca3af 0%, #6b7280 100%) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.sat--withdrawal .sat__submit {
|
||||
background: linear-gradient(135deg, $amber 0%, $amber-d 100%) !important;
|
||||
box-shadow: 0 4px 12px -2px rgb(180 83 9 / 35%) !important;
|
||||
|
||||
&:disabled {
|
||||
background: linear-gradient(135deg, #9ca3af 0%, #6b7280 100%) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.m-r-6 {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
/* ─── Responsive ──────────────────────────────────────────────────── */
|
||||
@media (width <= 720px) {
|
||||
.sat__hero-top {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sat__hero-meta {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sat__ctx-item {
|
||||
flex: 1 1 calc(50% - 14px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 480px) {
|
||||
.sat__ctx-item {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.sat__actions {
|
||||
flex-direction: column-reverse;
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+297
-16
@@ -19,16 +19,33 @@ import { Router, ActivatedRoute, RouterLink } from '@angular/router';
|
||||
|
||||
/** Custom Services */
|
||||
import { SavingsService } from '../../savings.service';
|
||||
import { ClientsService } from 'app/clients/clients.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
import { Currency } from 'app/shared/models/general.model';
|
||||
import { InputAmountComponent } from '../../../shared/input-amount/input-amount.component';
|
||||
import { MatSlideToggle } from '@angular/material/slide-toggle';
|
||||
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { TransactionConfirmDialogComponent } from './transaction-confirm-dialog/transaction-confirm-dialog.component';
|
||||
import { IdentityVerifyDialogComponent } from './identity-verify-dialog/identity-verify-dialog.component';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/** Single Indian denomination row (note or coin). */
|
||||
interface Denomination {
|
||||
value: number;
|
||||
kind: 'note' | 'coin';
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create savings account transactions component.
|
||||
* Create savings account transactions component (Deposit / Withdraw).
|
||||
*
|
||||
* Banking-grade redesign: gradient hero, denomination breakdown grid
|
||||
* (Indian notes ₹500/₹200/₹100/₹50/₹20/₹10 + coins ₹20/₹10/₹5/₹2/Re 1)
|
||||
* with live total + auto-sync into transactionAmount, payment & note
|
||||
* panel below.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-savings-transactions',
|
||||
@@ -38,7 +55,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
InputAmountComponent,
|
||||
MatSlideToggle,
|
||||
CdkTextareaAutosize
|
||||
CdkTextareaAutosize,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class SavingsAccountTransactionsComponent implements OnInit {
|
||||
@@ -47,7 +65,9 @@ export class SavingsAccountTransactionsComponent implements OnInit {
|
||||
private router = inject(Router);
|
||||
private dateUtils = inject(Dates);
|
||||
private savingsService = inject(SavingsService);
|
||||
private clientsService = inject(ClientsService);
|
||||
private settingsService = inject(SettingsService);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
/** Minimum Due Date allowed. */
|
||||
minDate = new Date(2000, 0, 1);
|
||||
@@ -73,15 +93,50 @@ export class SavingsAccountTransactionsComponent implements OnInit {
|
||||
savingAccountId: string;
|
||||
currency: Currency | null = null;
|
||||
|
||||
/**
|
||||
* Retrieves the Saving Account transaction template data from `resolve`.
|
||||
* @param {FormBuilder} formBuilder Form Builder.
|
||||
* @param {SavingsService} savingsService Savings Service.
|
||||
* @param {ActivatedRoute} route Activated Route.
|
||||
* @param {Dates} dateUtils Date Utils.
|
||||
* @param {Router} router Router for navigation.
|
||||
* @param {SettingsService} settingsService Settings Service
|
||||
*/
|
||||
/** Indian banknote denominations (descending). */
|
||||
readonly notes: Denomination[] = [
|
||||
{ value: 500, kind: 'note', label: '₹500' },
|
||||
{ value: 200, kind: 'note', label: '₹200' },
|
||||
{ value: 100, kind: 'note', label: '₹100' },
|
||||
{ value: 50, kind: 'note', label: '₹50' },
|
||||
{ value: 20, kind: 'note', label: '₹20' },
|
||||
{ value: 10, kind: 'note', label: '₹10' }
|
||||
];
|
||||
|
||||
/** Indian coin denominations (descending). */
|
||||
readonly coins: Denomination[] = [
|
||||
{ value: 20, kind: 'coin', label: '₹20' },
|
||||
{ value: 10, kind: 'coin', label: '₹10' },
|
||||
{ value: 5, kind: 'coin', label: '₹5' },
|
||||
{ value: 2, kind: 'coin', label: '₹2' },
|
||||
{ value: 1, kind: 'coin', label: 'Re 1' }
|
||||
];
|
||||
|
||||
/** Quantity per denomination — keyed by `${kind}:${value}`. */
|
||||
denominationCounts: Record<string, number> = {};
|
||||
|
||||
// ── Context strip data (populated async) ───────────────────────────
|
||||
/** Account number of the savings account. */
|
||||
accountNo: string | null = null;
|
||||
/** Client / group display name. */
|
||||
clientName: string | null = null;
|
||||
/** Client id (if this savings account is owned by a client, not a group)
|
||||
* — used to open the identity-verify dialog on the withdrawal screen. */
|
||||
clientId: number | null = null;
|
||||
/** Best-effort KYC status: 'Verified' | 'Partial' | 'Pending'. */
|
||||
kycStatus: 'Verified' | 'Partial' | 'Pending' = 'Pending';
|
||||
/** Sum of NEFT/RTGS transactions on the current business day for this
|
||||
* account — used as an AML hint for the cashier. */
|
||||
sameDayNeftRtgs = 0;
|
||||
/** Sum of same-day cheque transactions that are still awaiting
|
||||
* clearing for this account — pre-empts duplicate cash credit. */
|
||||
sameDayChequeClearing = 0;
|
||||
/** Live available balance of the savings account. */
|
||||
availableBalance = 0;
|
||||
/** Tracks whether the cashier has opened the identity-verify pop-up
|
||||
* at least once on this transaction; toggles off the attention pulse. */
|
||||
identityVerified = false;
|
||||
|
||||
constructor() {
|
||||
this.route.data.subscribe((data: { savingsAccountActionData: any }) => {
|
||||
this.paymentTypeOptions = data.savingsAccountActionData.paymentTypeOptions;
|
||||
@@ -94,17 +149,118 @@ export class SavingsAccountTransactionsComponent implements OnInit {
|
||||
this.savingAccountId = this.route.snapshot.params['savingAccountId'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the Saving account transaction form when component loads.
|
||||
*/
|
||||
ngOnInit() {
|
||||
this.maxDate = this.settingsService.businessDate;
|
||||
this.createSavingAccountTransactionForm();
|
||||
this.loadContextStrip();
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to create the Saving Account Transaction Form.
|
||||
* Pull the savings account + client to populate the cashier's context
|
||||
* strip (account number, client name, KYC status, risk level, same-day
|
||||
* NEFT/RTGS aggregate). Each fetch fails silently — the strip just
|
||||
* shows '—' for whatever wasn't returned.
|
||||
*/
|
||||
private loadContextStrip(): void {
|
||||
if (!this.savingAccountId) return;
|
||||
this.savingsService.getSavingsAccountData(this.savingAccountId).subscribe({
|
||||
next: (account: any) => {
|
||||
this.accountNo = account?.accountNo ?? null;
|
||||
this.clientName = account?.clientName ?? account?.groupName ?? null;
|
||||
this.clientId = account?.clientId ?? null;
|
||||
if (!this.currency && account?.currency) this.currency = account.currency;
|
||||
this.availableBalance = Number(account?.summary?.availableBalance ?? account?.summary?.accountBalance ?? 0);
|
||||
this.computeSameDayNeftRtgs(account?.transactions);
|
||||
this.computeSameDayChequeClearing(account?.transactions);
|
||||
if (account?.clientId) {
|
||||
this.fetchClientKyc(String(account.clientId));
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
// Leave defaults in place — strip falls back to '—'.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private fetchClientKyc(clientId: string): void {
|
||||
this.clientsService.getClientKyc(clientId).subscribe({
|
||||
next: (kyc: any) => {
|
||||
const pan = !!kyc?.panVerified;
|
||||
const aadhaar = !!kyc?.aadhaarVerified;
|
||||
this.kycStatus = pan && aadhaar ? 'Verified' : pan || aadhaar ? 'Partial' : 'Pending';
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter `transactions` to those on today's business date whose payment
|
||||
* type or transaction-type label mentions NEFT or RTGS, and sum them.
|
||||
* The list will be undefined for accounts loaded without the
|
||||
* `transactions` association — in that case we just stay at 0.
|
||||
*/
|
||||
private computeSameDayNeftRtgs(transactions: any[] | undefined): void {
|
||||
if (!Array.isArray(transactions) || transactions.length === 0) {
|
||||
this.sameDayNeftRtgs = 0;
|
||||
return;
|
||||
}
|
||||
const today = this.settingsService.businessDate ?? new Date();
|
||||
const sameDay = (d: any) => {
|
||||
if (!d) return false;
|
||||
const t = Array.isArray(d) && d.length >= 3 ? new Date(d[0], d[1] - 1, d[2]) : new Date(d);
|
||||
return (
|
||||
t.getFullYear() === today.getFullYear() && t.getMonth() === today.getMonth() && t.getDate() === today.getDate()
|
||||
);
|
||||
};
|
||||
const isNeftRtgs = (t: any) => {
|
||||
const txnType = (t?.transactionType?.value ?? t?.transactionType?.code ?? '').toUpperCase();
|
||||
const payType = (t?.paymentDetailData?.paymentType?.name ?? '').toUpperCase();
|
||||
return /NEFT|RTGS/.test(txnType) || /NEFT|RTGS/.test(payType);
|
||||
};
|
||||
this.sameDayNeftRtgs = transactions
|
||||
.filter((t: any) => !t.reversed && sameDay(t.date) && isNeftRtgs(t))
|
||||
.reduce((sum, t: any) => sum + (Number(t.amount) || 0), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum same-day cheque transactions that are still in clearing for this
|
||||
* account. A row is treated as a cheque-clearing entry when either:
|
||||
* • paymentDetailData carries a cheque/check number, OR
|
||||
* • the transaction type / payment type label mentions "CHEQUE".
|
||||
* Posted (cleared) entries are excluded so we only show the pending
|
||||
* float — that's the AML-relevant figure for the cashier.
|
||||
*/
|
||||
private computeSameDayChequeClearing(transactions: any[] | undefined): void {
|
||||
if (!Array.isArray(transactions) || transactions.length === 0) {
|
||||
this.sameDayChequeClearing = 0;
|
||||
return;
|
||||
}
|
||||
const today = this.settingsService.businessDate ?? new Date();
|
||||
const sameDay = (d: any) => {
|
||||
if (!d) return false;
|
||||
const t = Array.isArray(d) && d.length >= 3 ? new Date(d[0], d[1] - 1, d[2]) : new Date(d);
|
||||
return (
|
||||
t.getFullYear() === today.getFullYear() && t.getMonth() === today.getMonth() && t.getDate() === today.getDate()
|
||||
);
|
||||
};
|
||||
const isCheque = (t: any) => {
|
||||
const txnType = (t?.transactionType?.value ?? t?.transactionType?.code ?? '').toUpperCase();
|
||||
const payType = (t?.paymentDetailData?.paymentType?.name ?? '').toUpperCase();
|
||||
const hasCheckNo = !!t?.paymentDetailData?.checkNumber;
|
||||
return hasCheckNo || /CHEQUE|CHECK/.test(txnType) || /CHEQUE|CHECK/.test(payType);
|
||||
};
|
||||
const isPending = (t: any) => {
|
||||
const cleared = t?.transactionType?.bankTransfer === true || t?.transactionType?.deposit === true;
|
||||
// Treat anything not yet posted as in-clearing — backends differ on
|
||||
// the exact flag, so we look at `cleared` as a heuristic and exclude
|
||||
// it from the float.
|
||||
return !cleared;
|
||||
};
|
||||
this.sameDayChequeClearing = transactions
|
||||
.filter((t: any) => !t.reversed && sameDay(t.date) && isCheque(t) && isPending(t))
|
||||
.reduce((sum, t: any) => sum + (Number(t.amount) || 0), 0);
|
||||
}
|
||||
|
||||
createSavingAccountTransactionForm() {
|
||||
this.savingAccountTransactionForm = this.formBuilder.group({
|
||||
transactionDate: [
|
||||
@@ -123,6 +279,83 @@ export class SavingsAccountTransactionsComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Denomination helpers ────────────────────────────────────────────
|
||||
denomKey(d: Denomination): string {
|
||||
return `${d.kind}:${d.value}`;
|
||||
}
|
||||
|
||||
countFor(d: Denomination): number {
|
||||
return this.denominationCounts[this.denomKey(d)] || 0;
|
||||
}
|
||||
|
||||
subtotalFor(d: Denomination): number {
|
||||
return this.countFor(d) * d.value;
|
||||
}
|
||||
|
||||
onDenomChange(d: Denomination, raw: any): void {
|
||||
const n = Math.max(0, Math.floor(Number(raw) || 0));
|
||||
this.denominationCounts[this.denomKey(d)] = n;
|
||||
}
|
||||
|
||||
bumpDenom(d: Denomination, delta: number): void {
|
||||
const next = Math.max(0, this.countFor(d) + delta);
|
||||
this.denominationCounts[this.denomKey(d)] = next;
|
||||
}
|
||||
|
||||
get notesTotal(): number {
|
||||
return this.notes.reduce((s, d) => s + this.subtotalFor(d), 0);
|
||||
}
|
||||
|
||||
get coinsTotal(): number {
|
||||
return this.coins.reduce((s, d) => s + this.subtotalFor(d), 0);
|
||||
}
|
||||
|
||||
get denominationTotal(): number {
|
||||
return this.notesTotal + this.coinsTotal;
|
||||
}
|
||||
|
||||
get totalPieces(): number {
|
||||
return Object.values(this.denominationCounts).reduce((s, n) => s + (n || 0), 0);
|
||||
}
|
||||
|
||||
get denominationMatchesForm(): boolean {
|
||||
const formAmt = Number(this.savingAccountTransactionForm?.controls?.['transactionAmount']?.value || 0);
|
||||
return this.denominationTotal > 0 && formAmt === this.denominationTotal;
|
||||
}
|
||||
|
||||
/** Push the counted total into the form's transactionAmount field. */
|
||||
applyDenominationTotal(): void {
|
||||
this.savingAccountTransactionForm.controls['transactionAmount'].setValue(this.denominationTotal);
|
||||
this.savingAccountTransactionForm.controls['transactionAmount'].markAsDirty();
|
||||
}
|
||||
|
||||
clearDenominations(): void {
|
||||
this.denominationCounts = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the identity-verify pop-up. Only meaningful for withdrawals
|
||||
* (where the cashier needs to match the customer at the counter
|
||||
* against the on-file profile before paying out cash).
|
||||
*/
|
||||
openIdentityVerify(): void {
|
||||
if (!this.clientId) return;
|
||||
// Calm the attention pulse after the first open — the cashier has
|
||||
// acknowledged the verification step.
|
||||
this.identityVerified = true;
|
||||
this.dialog.open(IdentityVerifyDialogComponent, {
|
||||
width: '500px',
|
||||
maxWidth: '92vw',
|
||||
panelClass: 'idv-panel',
|
||||
autoFocus: false,
|
||||
data: {
|
||||
clientId: this.clientId,
|
||||
clientName: this.clientName,
|
||||
accountNo: this.accountNo
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to add payment detail fields to the UI.
|
||||
*/
|
||||
@@ -145,8 +378,39 @@ export class SavingsAccountTransactionsComponent implements OnInit {
|
||||
|
||||
/**
|
||||
* Method to submit the transaction details.
|
||||
*
|
||||
* Opens a pre-commit confirmation pop-up showing the current balance,
|
||||
* the transaction amount, and the resulting balance. The actual API
|
||||
* call only fires after the cashier confirms.
|
||||
*/
|
||||
submit() {
|
||||
const formAmount = Number(this.savingAccountTransactionForm.value.transactionAmount) || 0;
|
||||
const paymentTypeId = this.savingAccountTransactionForm.value.paymentTypeId;
|
||||
const paymentTypeLabel = this.paymentTypeOptions?.find((p) => p.id === paymentTypeId)?.name ?? null;
|
||||
|
||||
const ref = this.dialog.open(TransactionConfirmDialogComponent, {
|
||||
width: '480px',
|
||||
maxWidth: '92vw',
|
||||
panelClass: 'tcd-panel',
|
||||
autoFocus: false,
|
||||
data: {
|
||||
kind: this.transactionType.deposit ? 'deposit' : 'withdrawal',
|
||||
clientName: this.clientName,
|
||||
accountNo: this.accountNo,
|
||||
currentBalance: this.availableBalance,
|
||||
transactionAmount: formAmount,
|
||||
paymentTypeLabel
|
||||
}
|
||||
});
|
||||
ref.afterClosed().subscribe((result: any) => {
|
||||
if (result?.confirm) {
|
||||
this.postTransaction();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Actual API call — only invoked after the cashier confirms the dialog. */
|
||||
private postTransaction(): void {
|
||||
const savingAccountTransactionFormData = this.savingAccountTransactionForm.value;
|
||||
const locale = this.settingsService.language.code;
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
@@ -160,9 +424,26 @@ export class SavingsAccountTransactionsComponent implements OnInit {
|
||||
locale
|
||||
};
|
||||
data['transactionAmount'] = data['transactionAmount'] * 1;
|
||||
// Surface the denomination breakdown on the wire — backend can ignore
|
||||
// unknown fields, but if a cash-handling extension is enabled it can
|
||||
// record the cashier's count for audit.
|
||||
if (this.denominationTotal > 0) {
|
||||
const breakdown = [
|
||||
...this.notes,
|
||||
...this.coins
|
||||
]
|
||||
.map((d) => ({
|
||||
denomination: d.value,
|
||||
kind: d.kind,
|
||||
count: this.countFor(d),
|
||||
subtotal: this.subtotalFor(d)
|
||||
}))
|
||||
.filter((row) => row.count > 0);
|
||||
data['denominationBreakdown'] = breakdown;
|
||||
}
|
||||
this.savingsService
|
||||
.executeSavingsAccountTransactionsCommand(this.savingAccountId, this.transactionCommand, data)
|
||||
.subscribe((res) => {
|
||||
.subscribe(() => {
|
||||
this.router.navigate(['../../transactions'], { relativeTo: this.route });
|
||||
});
|
||||
}
|
||||
|
||||
+82
@@ -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/.
|
||||
-->
|
||||
|
||||
<div class="tcd" [class.tcd--deposit]="isDeposit" [class.tcd--withdrawal]="!isDeposit">
|
||||
<!-- Header -->
|
||||
<div class="tcd__head">
|
||||
<div class="tcd__head-ico">
|
||||
<fa-icon [icon]="isDeposit ? 'arrow-down' : 'arrow-up'"></fa-icon>
|
||||
</div>
|
||||
<div class="tcd__head-text">
|
||||
<h2 class="tcd__title">Confirm {{ isDeposit ? 'Deposit' : 'Withdrawal' }}</h2>
|
||||
<p class="tcd__sub">Review the figures below — once confirmed the entry will post to the account ledger.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-dialog-content class="tcd__content">
|
||||
<!-- Client / account meta -->
|
||||
<div class="tcd__meta">
|
||||
<div class="tcd__meta-item">
|
||||
<span class="tcd__meta-label">Client</span>
|
||||
<span class="tcd__meta-value">{{ data.clientName || '—' }}</span>
|
||||
</div>
|
||||
<div class="tcd__meta-item">
|
||||
<span class="tcd__meta-label">Account No</span>
|
||||
<span class="tcd__meta-value tcd__meta-value--mono">{{ data.accountNo || '—' }}</span>
|
||||
</div>
|
||||
@if (data.paymentTypeLabel) {
|
||||
<div class="tcd__meta-item">
|
||||
<span class="tcd__meta-label">Payment Type</span>
|
||||
<span class="tcd__meta-value">{{ data.paymentTypeLabel }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Balance breakdown -->
|
||||
<div class="tcd__breakdown">
|
||||
<div class="tcd__brk-row">
|
||||
<span class="tcd__brk-label">Current Balance</span>
|
||||
<span class="tcd__brk-value">₹{{ data.currentBalance | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="tcd__brk-row tcd__brk-row--txn">
|
||||
<span class="tcd__brk-label">
|
||||
{{ isDeposit ? 'Deposit Amount' : 'Withdrawal Amount' }}
|
||||
</span>
|
||||
<span
|
||||
class="tcd__brk-value"
|
||||
[class.tcd__brk-value--positive]="isDeposit"
|
||||
[class.tcd__brk-value--negative]="!isDeposit"
|
||||
>
|
||||
{{ isDeposit ? '+' : '−' }} ₹{{ data.transactionAmount | number: '1.2-2' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="tcd__brk-row tcd__brk-row--total">
|
||||
<span class="tcd__brk-label">New Balance</span>
|
||||
<span class="tcd__brk-value">₹{{ newBalance | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overdraft warning -->
|
||||
@if (overdraft) {
|
||||
<div class="tcd__warn">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
Resulting balance is negative — confirm the account permits overdraft before proceeding.
|
||||
</div>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions class="tcd__actions">
|
||||
<button mat-stroked-button type="button" class="tcd__cancel" (click)="cancel()">
|
||||
<fa-icon icon="times" class="m-r-6"></fa-icon> Cancel
|
||||
</button>
|
||||
<button mat-flat-button type="button" class="tcd__confirm" (click)="confirm()">
|
||||
<fa-icon icon="check" class="m-r-6"></fa-icon>
|
||||
Confirm {{ isDeposit ? 'Deposit' : 'Withdrawal' }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</div>
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
$navy: #0d1b2a;
|
||||
$slate: #475569;
|
||||
$muted: #64748b;
|
||||
$border: #e2e8f0;
|
||||
$soft: #f8fafc;
|
||||
$green: #059669;
|
||||
$green-d: #047857;
|
||||
$teal: #0f766e;
|
||||
$teal-d: #134e4a;
|
||||
$red: #b91c1c;
|
||||
$amber: #b45309;
|
||||
|
||||
.tcd {
|
||||
width: 460px;
|
||||
max-width: 92vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ─── Header ──────────────────────────────────────────────────────── */
|
||||
.tcd__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 18px 22px;
|
||||
color: #fff;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at top right, rgb(255 255 255 / 14%), transparent 55%);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.tcd--deposit .tcd__head {
|
||||
background: linear-gradient(135deg, #0f172a 0%, #1e3a8a 45%, #1e40af 100%);
|
||||
}
|
||||
|
||||
.tcd--withdrawal .tcd__head {
|
||||
background: linear-gradient(135deg, $teal-d 0%, #115e59 45%, $teal 100%);
|
||||
}
|
||||
|
||||
.tcd__head-ico {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
background: rgb(255 255 255 / 18%);
|
||||
border: 1px solid rgb(255 255 255 / 28%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tcd__head-text {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tcd__title {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.tcd__sub {
|
||||
margin: 3px 0 0;
|
||||
font-size: 12px;
|
||||
color: rgb(255 255 255 / 82%);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ─── Content ─────────────────────────────────────────────────────── */
|
||||
.tcd__content {
|
||||
padding: 18px 22px 6px !important;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.tcd__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px dashed $border;
|
||||
}
|
||||
|
||||
.tcd__meta-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tcd__meta-label {
|
||||
color: $slate;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tcd__meta-value {
|
||||
color: $navy;
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
word-break: break-word;
|
||||
|
||||
&--mono {
|
||||
font-family: 'JetBrains Mono', 'Courier New', monospace;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Breakdown card ──────────────────────────────────────────────── */
|
||||
.tcd__breakdown {
|
||||
background: $soft;
|
||||
border: 1px solid $border;
|
||||
border-radius: 12px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.tcd__brk-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
font-size: 13px;
|
||||
color: $slate;
|
||||
|
||||
& + & {
|
||||
border-top: 1px dashed #eef2f7;
|
||||
}
|
||||
|
||||
&--total {
|
||||
padding-top: 12px;
|
||||
margin-top: 4px;
|
||||
border-top: 1px solid #cbd5e1 !important;
|
||||
|
||||
.tcd__brk-label {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: $navy;
|
||||
}
|
||||
|
||||
.tcd__brk-value {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: $navy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tcd__brk-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tcd__brk-value {
|
||||
font-weight: 700;
|
||||
color: $navy;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
&--positive {
|
||||
color: $green-d;
|
||||
}
|
||||
|
||||
&--negative {
|
||||
color: $red;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Overdraft warning ───────────────────────────────────────────── */
|
||||
.tcd__warn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
color: #92400e;
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
|
||||
fa-icon {
|
||||
color: $amber;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Actions ─────────────────────────────────────────────────────── */
|
||||
.tcd__actions {
|
||||
padding: 14px 22px 18px !important;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
background: #fff;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
margin: 0 !important;
|
||||
|
||||
button {
|
||||
height: 40px !important;
|
||||
border-radius: 10px !important;
|
||||
font-weight: 700 !important;
|
||||
padding: 0 18px !important;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
}
|
||||
|
||||
.tcd__cancel {
|
||||
color: $slate !important;
|
||||
border-color: $border !important;
|
||||
}
|
||||
|
||||
.tcd__confirm {
|
||||
color: #fff !important;
|
||||
box-shadow: 0 4px 12px -2px rgb(4 120 87 / 35%) !important;
|
||||
}
|
||||
|
||||
.tcd--deposit .tcd__confirm {
|
||||
background: linear-gradient(135deg, $green 0%, $green-d 100%) !important;
|
||||
}
|
||||
|
||||
.tcd--withdrawal .tcd__confirm {
|
||||
background: linear-gradient(135deg, $teal 0%, $teal-d 100%) !important;
|
||||
box-shadow: 0 4px 12px -2px rgb(15 118 110 / 35%) !important;
|
||||
}
|
||||
|
||||
.m-r-6 {
|
||||
margin-right: 6px;
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, inject } from '@angular/core';
|
||||
import {
|
||||
MatDialogRef,
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
} from '@angular/material/dialog';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
export interface TransactionConfirmData {
|
||||
/** 'deposit' | 'withdrawal' — drives the colour palette + sign. */
|
||||
kind: 'deposit' | 'withdrawal';
|
||||
clientName: string | null;
|
||||
accountNo: string | null;
|
||||
currentBalance: number;
|
||||
transactionAmount: number;
|
||||
paymentTypeLabel: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-commit confirmation pop-up showing the cashier exactly how the
|
||||
* deposit / withdrawal will affect the account: current balance, the
|
||||
* transaction amount, and the post-transaction balance.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-transaction-confirm-dialog',
|
||||
standalone: true,
|
||||
templateUrl: './transaction-confirm-dialog.component.html',
|
||||
styleUrls: ['./transaction-confirm-dialog.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class TransactionConfirmDialogComponent {
|
||||
dialogRef = inject<MatDialogRef<TransactionConfirmDialogComponent>>(MatDialogRef);
|
||||
data = inject<TransactionConfirmData>(MAT_DIALOG_DATA);
|
||||
|
||||
get isDeposit(): boolean {
|
||||
return this.data.kind === 'deposit';
|
||||
}
|
||||
|
||||
get signedAmount(): number {
|
||||
return this.isDeposit ? this.data.transactionAmount : -this.data.transactionAmount;
|
||||
}
|
||||
|
||||
get newBalance(): number {
|
||||
return this.data.currentBalance + this.signedAmount;
|
||||
}
|
||||
|
||||
get overdraft(): boolean {
|
||||
return !this.isDeposit && this.newBalance < 0;
|
||||
}
|
||||
|
||||
confirm(): void {
|
||||
this.dialogRef.close({ confirm: true });
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.dialogRef.close({ confirm: false });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user