Merge branch 'feature/account-charges' of https://git.zeptic.in/CBSNexora/Angular-UI-CBS-Main into feature/account-charges

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-05-21 21:19:03 +05:30
co-authored by Claude Sonnet 4.6
98 changed files with 8573 additions and 2572 deletions
@@ -6,100 +6,129 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container">
<mat-stepper
orientation="vertical"
[linear]="false"
class="mat-elevation-z8"
labelPosition="bottom"
#savingProductStepper
<div class="csa-container">
<!-- Hero -->
<div class="csa-header">
<fa-icon icon="piggy-bank" class="csa-header__icon"></fa-icon>
<div>
<h2 class="csa-header__title">Create Savings Account</h2>
<div class="csa-header__sub">
Pick a product, confirm currency, set digital channels and review before submit.
</div>
</div>
</div>
<!-- Wizard tabs -->
<mat-tab-group
class="csa-tabs"
animationDuration="200ms"
[preserveContent]="true"
[(selectedIndex)]="selectedTabIndex"
>
<ng-template matStepperIcon="number">
<fa-icon icon="pencil-alt" size="sm"></fa-icon>
</ng-template>
<ng-template matStepperIcon="edit">
<fa-icon icon="pencil-alt" size="sm"></fa-icon>
</ng-template>
<ng-template matStepperIcon="done">
<fa-icon icon="check" size="sm"></fa-icon>
</ng-template>
<ng-template matStepperIcon="error">
<fa-icon icon="exclamation-triangle" size="lg"></fa-icon>
</ng-template>
<ng-template matStepperIcon="preview">
<fa-icon icon="eye" size="sm"></fa-icon>
</ng-template>
<!-- Step 1: Product Classification + Details -->
<mat-step>
<ng-template matStepLabel>Product Classification</ng-template>
<mifosx-savings-account-details-step
[savingsAccountTemplate]="savingsAccountTemplate"
(savingsAccountProductTemplate)="setTemplate($event)"
>
</mifosx-savings-account-details-step>
</mat-step>
@if (savingsAccountProductTemplate) {
<!-- Step 2: Currency -->
<mat-step>
<ng-template matStepLabel>Currency</ng-template>
<mifosx-savings-account-currency-step
[savingsAccountProductTemplate]="savingsAccountProductTemplate"
[primaryClientId]="savingsAccountTemplate?.clientId ?? null"
></mifosx-savings-account-currency-step>
<div class="layout-row align-center gap-2percent margin-t responsive-column">
<button mat-raised-button matStepperPrevious>
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Previous' | translate }}
</button>
<button mat-raised-button matStepperNext>
{{ 'labels.buttons.Next' | translate }}
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
</button>
</div>
</mat-step>
<!-- Step 3: Digital -->
<mat-step>
<ng-template matStepLabel>Digital</ng-template>
<mifosx-savings-account-digital-step [savingsAccountProductTemplate]="savingsAccountProductTemplate">
</mifosx-savings-account-digital-step>
<div class="layout-row align-center gap-2percent margin-t responsive-column">
<button mat-raised-button matStepperPrevious>
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Previous' | translate }}
</button>
<button mat-raised-button matStepperNext>
{{ 'labels.buttons.Next' | translate }}
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
</button>
</div>
</mat-step>
<!-- Step 4: Preview -->
<mat-step completed>
<ng-template matStepLabel>{{ 'labels.inputs.PREVIEW' | translate }}</ng-template>
<mifosx-savings-account-preview-step
[savingsAccountProductTemplate]="savingsAccountProductTemplate"
<!-- Tab 1: Product Classification + Details -->
<mat-tab>
<ng-template mat-tab-label>
<fa-icon icon="check-square" class="csa-tab__icon"></fa-icon>
Product
</ng-template>
<div class="csa-tab-body">
<mifosx-savings-account-details-step
[savingsAccountTemplate]="savingsAccountTemplate"
[savingsAccountTermsForm]="savingsAccountTermsForm"
[savingsAccount]="savingsAccount"
[savingsAccountDigital]="savingsAccountDigital"
[savingsAccountNominees]="savingsAccountCurrencyStep?.nominees ?? []"
(submitEvent)="submit()"
(savingsAccountProductTemplate)="setTemplate($event)"
>
</mifosx-savings-account-preview-step>
</mat-step>
}
</mat-stepper>
</mifosx-savings-account-details-step>
<div class="csa-actions">
<span class="csa-actions__spacer"></span>
<button mat-raised-button color="primary" [disabled]="!savingsAccountProductTemplate" (click)="next()">
{{ 'labels.buttons.Next' | translate }}
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
</button>
</div>
</div>
</mat-tab>
<!-- Tab 2: Currency -->
<mat-tab [disabled]="!savingsAccountProductTemplate">
<ng-template mat-tab-label>
<fa-icon icon="dollar-sign" class="csa-tab__icon"></fa-icon>
Currency
</ng-template>
<div class="csa-tab-body">
@if (savingsAccountProductTemplate) {
<mifosx-savings-account-currency-step
[savingsAccountProductTemplate]="savingsAccountProductTemplate"
[primaryClientId]="savingsAccountTemplate?.clientId ?? null"
></mifosx-savings-account-currency-step>
}
<div class="csa-actions">
<button mat-stroked-button (click)="prev()">
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Previous' | translate }}
</button>
<span class="csa-actions__spacer"></span>
<button mat-raised-button color="primary" (click)="next()">
{{ 'labels.buttons.Next' | translate }}
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
</button>
</div>
</div>
</mat-tab>
<!-- Tab 3: Digital -->
<mat-tab [disabled]="!savingsAccountProductTemplate">
<ng-template mat-tab-label>
<fa-icon icon="mobile-alt" class="csa-tab__icon"></fa-icon>
Digital
</ng-template>
<div class="csa-tab-body">
@if (savingsAccountProductTemplate) {
<mifosx-savings-account-digital-step [savingsAccountProductTemplate]="savingsAccountProductTemplate">
</mifosx-savings-account-digital-step>
}
<div class="csa-actions">
<button mat-stroked-button (click)="prev()">
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Previous' | translate }}
</button>
<span class="csa-actions__spacer"></span>
<button mat-raised-button color="primary" (click)="next()">
{{ 'labels.buttons.Next' | translate }}
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
</button>
</div>
</div>
</mat-tab>
<!-- Tab 4: Preview / Submit -->
<mat-tab [disabled]="!savingsAccountProductTemplate">
<ng-template mat-tab-label>
<fa-icon icon="eye" class="csa-tab__icon"></fa-icon>
{{ 'labels.inputs.PREVIEW' | translate }}
</ng-template>
<div class="csa-tab-body">
@if (savingsAccountProductTemplate) {
<mifosx-savings-account-preview-step
[savingsAccountProductTemplate]="savingsAccountProductTemplate"
[savingsAccountTemplate]="savingsAccountTemplate"
[savingsAccountTermsForm]="savingsAccountTermsForm"
[savingsAccount]="savingsAccount"
[savingsAccountDigital]="savingsAccountDigital"
[savingsAccountNominees]="savingsAccountCurrencyStep?.nominees ?? []"
(submitEvent)="submit()"
>
</mifosx-savings-account-preview-step>
}
<div class="csa-actions">
<button mat-stroked-button (click)="prev()">
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Previous' | translate }}
</button>
</div>
</div>
</mat-tab>
</mat-tab-group>
</div>
@@ -6,75 +6,153 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
// Container styling with proper spacing and margins
.container {
margin: 1.5rem auto;
max-width: 75rem;
width: 100%;
padding: 0 1.5rem;
box-sizing: border-box;
$navy: #0d1b2a;
$blue: #1e88e5;
$blue-d: #1e40af;
$indigo: #4f46e5;
$border: #e2e8f0;
.csa-container {
max-width: 1180px;
margin: 0 auto;
padding: 20px 24px;
}
// Material Stepper styling with proper spacing
:host ::ng-deep mat-stepper {
background: transparent;
/* ─── Hero header (matches Nexora pages) ────────────────────────────── */
.csa-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
.mat-step-header {
padding: 1rem 1.5rem;
margin-bottom: 0.5rem;
&__icon {
width: 44px;
height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 12px;
background: linear-gradient(135deg, $blue 0%, $indigo 100%);
color: #fff;
font-size: 18px;
box-shadow: 0 2px 8px rgb(29 78 216 / 25%);
flex-shrink: 0;
}
.mat-step-content {
padding: 1.5rem;
margin: 1rem 0;
overflow: visible !important;
&__title {
margin: 0;
font-size: 20px;
font-weight: 800;
color: $navy;
line-height: 1.1;
letter-spacing: 0.02em;
}
&__sub {
margin-top: 2px;
font-size: 12.5px;
color: #64748b;
}
}
// Form field spacing within steps
:host ::ng-deep mat-step-content {
overflow: visible !important;
/* ─── Wizard tab strip ──────────────────────────────────────────────── */
.csa-tabs {
background: #fff;
border: 1px solid $border;
border-radius: 14px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
overflow: hidden;
mat-form-field {
margin-bottom: 1rem;
width: 100%;
}
.layout-row-wrap {
gap: 1rem;
margin-bottom: 1rem;
}
}
// Responsive design
@media (width <= 768px) {
.container {
padding: 0 1rem;
margin: 1rem auto;
}
:host ::ng-deep mat-stepper {
.mat-step-content {
padding: 1rem;
margin: 0.75rem 0;
::ng-deep {
.mat-mdc-tab-header {
background: #f8fafc;
border-bottom: 1px solid $border;
}
.mat-step-header {
padding: 0.75rem 1rem;
.mat-mdc-tab {
min-width: 140px;
height: 50px;
padding: 0 22px;
font-size: 13px !important;
font-weight: 600 !important;
letter-spacing: 0.01em;
color: #475569 !important;
opacity: 1 !important;
}
.mat-mdc-tab:hover:not(.mdc-tab--active, .mat-mdc-tab-disabled) {
background: rgb(30 64 175 / 5%);
color: $navy !important;
}
.mat-mdc-tab.mdc-tab--active {
.mdc-tab__text-label {
color: $blue-d !important;
font-weight: 700 !important;
}
}
/* Active-tab underline indicator — slightly bolder than Material's default. */
.mdc-tab-indicator__content--underline {
border-color: $blue-d !important;
border-top-width: 3px !important;
}
.mat-mdc-tab.mat-mdc-tab-disabled {
opacity: 0.45 !important;
cursor: not-allowed;
}
.mat-mdc-tab-body-wrapper {
min-height: 320px;
}
.mat-mdc-tab-body,
.mat-mdc-tab-body-content {
overflow: visible !important;
}
}
}
@media (width <= 480px) {
.container {
padding: 0 0.75rem;
margin: 0.75rem auto;
}
.csa-tab__icon {
margin-right: 8px;
font-size: 13px;
}
:host ::ng-deep mat-stepper {
.mat-step-content {
padding: 0.75rem;
margin: 0.5rem 0;
}
/* ─── Tab body + action bar ─────────────────────────────────────────── */
.csa-tab-body {
padding: 24px;
@media (width <= 768px) {
padding: 16px;
}
}
.csa-actions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid #f1f5f9;
&__spacer {
flex: 1;
}
button {
height: 40px !important;
border-radius: 10px !important;
font-weight: 600 !important;
padding: 0 18px !important;
}
}
/* Utility helpers preserved from the prior stepper template. */
.m-l-10 {
margin-left: 8px;
}
.m-r-10 {
margin-right: 8px;
}
@@ -15,27 +15,24 @@ import { FormGroup } from '@angular/forms';
import { SavingsAccountDetailsStepComponent } from '../savings-account-stepper/savings-account-details-step/savings-account-details-step.component';
import { SavingsAccountCurrencyStepComponent } from '../savings-account-stepper/savings-account-currency-step/savings-account-currency-step.component';
import { SavingsAccountDigitalStepComponent } from '../savings-account-stepper/savings-account-digital-step/savings-account-digital-step.component';
import { SavingsAccountSettingsStepComponent } from '../savings-account-stepper/savings-account-settings-step/savings-account-settings-step.component';
/** Custom Services */
import { SavingsService } from '../savings.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service';
import {
MatStepper,
MatStepperIcon,
MatStep,
MatStepLabel,
MatStepperNext,
MatStepperPrevious
} from '@angular/material/stepper';
import { MatTabsModule } from '@angular/material/tabs';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { SavingsAccountPreviewStepComponent } from '../savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Create Savings Account Component
*
* Wizard-tab UI: Product → Currency → Digital → Preview. Tabs 2-4 are
* disabled until a product is picked on Tab 1 (matches the prior stepper
* gate that only rendered later steps once `savingsAccountProductTemplate`
* was set).
*/
@Component({
selector: 'mifosx-create-savings-account',
@@ -43,13 +40,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
styleUrls: ['./create-savings-account.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatStepper,
MatStepperIcon,
MatStepperNext,
MatStepperPrevious,
MatTabsModule,
FaIconComponent,
MatStep,
MatStepLabel,
SavingsAccountDetailsStepComponent,
SavingsAccountCurrencyStepComponent,
SavingsAccountDigitalStepComponent,
@@ -68,6 +60,10 @@ export class CreateSavingsAccountComponent {
savingsAccountTemplate: any;
/** Savings Account Product Template */
savingsAccountProductTemplate: any;
/** Currently visible wizard tab (0-indexed). */
selectedTabIndex = 0;
/** Total wizard tabs — Product, Currency, Digital, Preview. */
private readonly totalTabs = 4;
@ViewChild(SavingsAccountDetailsStepComponent, { static: true })
savingsAccountDetailsStep: SavingsAccountDetailsStepComponent;
@@ -88,6 +84,18 @@ export class CreateSavingsAccountComponent {
this.savingsAccountProductTemplate = $event;
}
next(): void {
if (this.selectedTabIndex < this.totalTabs - 1) {
this.selectedTabIndex += 1;
}
}
prev(): void {
if (this.selectedTabIndex > 0) {
this.selectedTabIndex -= 1;
}
}
get savingsAccountDetailsForm() {
return this.savingsAccountDetailsStep.savingsAccountDetailsForm;
}
@@ -6,53 +6,73 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container mat-elevation-z8">
<mat-card>
<form [formGroup]="activateSavingsAccountForm" (ngSubmit)="submit()">
<mat-card-content>
<div class="layout-column">
<mat-form-field class="flex-fill" (click)="activatedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Activated On Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="activatedOnDatePicker"
required
formControlName="activatedOnDate"
/>
<mat-datepicker-toggle matSuffix [for]="activatedOnDatePicker"></mat-datepicker-toggle>
<mat-datepicker #activatedOnDatePicker></mat-datepicker>
@if (activateSavingsAccountForm.controls.activatedOnDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Activated On Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<div class="act-savings">
<!-- Hero -->
<div class="act-savings__hero">
<div class="act-savings__hero-icon">
<fa-icon icon="check-circle"></fa-icon>
</div>
<div>
<h2 class="act-savings__title">Activate Savings Account</h2>
<p class="act-savings__sub">Set the activation date and opening balance to bring this account live.</p>
</div>
</div>
<mat-form-field class="flex-fill">
<mat-label>Opening Balance</mat-label>
<input matInput type="number" [min]="minRequiredOpeningBalance ?? 0" formControlName="openingBalance" />
<span matPrefix>₹&nbsp;</span>
@if (minRequiredOpeningBalance) {
<mat-hint>Minimum required: ₹{{ minRequiredOpeningBalance }}</mat-hint>
}
@if (activateSavingsAccountForm.controls.openingBalance.hasError('min')) {
<mat-error>Opening balance must be at least ₹{{ minRequiredOpeningBalance ?? 0 }}</mat-error>
}
</mat-form-field>
</div>
</mat-card-content>
<!-- Form card -->
<form class="act-savings__card" [formGroup]="activateSavingsAccountForm" (ngSubmit)="submit()">
<div class="act-savings__card-head">
<fa-icon icon="info-circle" class="act-savings__card-ico"></fa-icon>
<h3 class="act-savings__card-title">Activation Details</h3>
</div>
<mat-card-actions class="layout-row align-center gap-5px responsive-column">
<button type="button" mat-raised-button [routerLink]="['../../transactions']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" [disabled]="!activateSavingsAccountForm.valid">
{{ 'labels.buttons.Confirm' | translate }}
</button>
</mat-card-actions>
</form>
</mat-card>
<div class="act-savings__card-body">
<mat-form-field appearance="outline" class="act-savings__field" (click)="activatedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Activated On Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="activatedOnDatePicker"
required
formControlName="activatedOnDate"
/>
<mat-datepicker-toggle matSuffix [for]="activatedOnDatePicker"></mat-datepicker-toggle>
<mat-datepicker #activatedOnDatePicker></mat-datepicker>
@if (activateSavingsAccountForm.controls.activatedOnDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Activated On Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="act-savings__field">
<mat-label>Opening Balance</mat-label>
<input matInput type="number" [min]="minRequiredOpeningBalance ?? 0" formControlName="openingBalance" />
<span matPrefix class="act-savings__prefix">₹&nbsp;</span>
@if (minRequiredOpeningBalance) {
<mat-hint>Minimum required: ₹{{ minRequiredOpeningBalance }}</mat-hint>
}
@if (activateSavingsAccountForm.controls.openingBalance.hasError('min')) {
<mat-error>Opening balance must be at least ₹{{ minRequiredOpeningBalance ?? 0 }}</mat-error>
}
</mat-form-field>
</div>
<div class="act-savings__actions">
<button type="button" mat-stroked-button class="act-savings__cancel" [routerLink]="['../../transactions']">
<fa-icon icon="times" class="m-r-6"></fa-icon>
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button
mat-raised-button
type="submit"
class="act-savings__confirm"
[disabled]="!activateSavingsAccountForm.valid"
>
<fa-icon icon="check" class="m-r-6"></fa-icon>
{{ 'labels.buttons.Confirm' | translate }}
</button>
</div>
</form>
</div>
@@ -6,6 +6,190 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.container {
max-width: 37rem;
$navy: #0d1b2a;
$blue: #1e88e5;
$blue-d: #1e40af;
$green: #10b981;
$green-d: #047857;
$border: #e2e8f0;
$soft: #f8fafc;
.act-savings {
max-width: 720px;
margin: 0 auto;
padding: 20px 24px;
/* ─── Hero ───────────────────────────────────────────────────────── */
&__hero {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 18px;
}
&__hero-icon {
width: 46px;
height: 46px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 12px;
background: linear-gradient(135deg, $green 0%, $green-d 100%);
color: #fff;
font-size: 18px;
box-shadow: 0 4px 14px -4px rgb(4 120 87 / 45%);
flex-shrink: 0;
}
&__title {
margin: 0;
font-size: 20px;
font-weight: 800;
color: $navy;
line-height: 1.15;
letter-spacing: 0.01em;
}
&__sub {
margin: 4px 0 0;
font-size: 13px;
color: #64748b;
max-width: 540px;
}
/* ─── Form card ─────────────────────────────────────────────────── */
&__card {
background: #fff;
border: 1px solid $border;
border-radius: 14px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
overflow: hidden;
}
&__card-head {
display: flex;
align-items: center;
gap: 10px;
padding: 13px 20px;
background: $soft;
border-bottom: 1px solid $border;
}
&__card-ico {
width: 26px;
height: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: linear-gradient(135deg, $blue 0%, $blue-d 100%);
color: #fff;
font-size: 12px;
}
&__card-title {
margin: 0;
font-size: 13.5px;
font-weight: 700;
color: $navy;
letter-spacing: 0.01em;
}
&__card-body {
padding: 22px 22px 4px;
display: grid;
gap: 6px;
}
/* Form field tweaks — slightly more compact density. */
&__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;
}
::ng-deep &.mat-focused {
.mdc-notched-outline__leading,
.mdc-notched-outline__notch,
.mdc-notched-outline__trailing {
border-color: $blue-d !important;
border-width: 2px !important;
}
}
}
&__prefix {
color: #64748b;
font-weight: 600;
margin-right: 4px;
}
/* ─── Action row ────────────────────────────────────────────────── */
&__actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
padding: 14px 20px 18px;
border-top: 1px solid #f1f5f9;
background: #fff;
button {
height: 42px !important;
border-radius: 10px !important;
font-weight: 600 !important;
padding: 0 22px !important;
letter-spacing: 0.01em;
}
}
&__cancel {
color: #475569 !important;
border-color: $border !important;
}
&__confirm {
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;
}
}
}
.m-r-6 {
margin-right: 6px;
}
@media (width <= 600px) {
.act-savings {
padding: 14px;
&__actions {
flex-direction: column-reverse;
button {
width: 100%;
}
}
}
}
@@ -11,6 +11,7 @@ import { Component, OnInit, Input, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Dates } from 'app/core/utils/dates';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
/** Custom Services */
import { SavingsService } from 'app/savings/savings.service';
@@ -19,13 +20,17 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Activate Savings Account Component
*
* Nexora redesign: gradient green hero + section card with form fields and
* a primary/secondary action row.
*/
@Component({
selector: 'mifosx-activate-savings-account',
templateUrl: './activate-savings-account.component.html',
styleUrls: ['./activate-savings-account.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS
...STANDALONE_SHARED_IMPORTS,
FaIconComponent
]
})
export class ActivateSavingsAccountComponent implements OnInit {
@@ -6,121 +6,153 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container mat-elevation-z8">
<mat-card>
<form [formGroup]="savingsChargeForm" (ngSubmit)="submit()">
<mat-card-content>
<div class="layout-column">
<mat-form-field>
<mat-label>{{ 'labels.inputs.Charge' | translate }}</mat-label>
<mat-select required formControlName="chargeId">
@for (savingsCharge of savingsChargeOptions; track savingsCharge) {
<mat-option [value]="savingsCharge.id">
{{ savingsCharge.name + ' (' + savingsCharge.currency.name + ')' }}
</mat-option>
}
</mat-select>
@if (savingsChargeForm.controls.chargeId.hasError('required')) {
<div class="acs">
<!-- Hero -->
<div class="acs__hero">
<button type="button" mat-stroked-button class="acs__back" [routerLink]="['../../transactions']">
<fa-icon icon="arrow-left"></fa-icon>
</button>
<div class="acs__hero-ico">
<fa-icon icon="plus-circle"></fa-icon>
</div>
<div class="acs__hero-text">
<h2 class="acs__title">Add Charge to Savings Account</h2>
<p class="acs__sub">
Attach a fee or penalty from the product's catalogue. Amount and timing are pre-filled from the selected charge
— override only when needed.
</p>
</div>
<span class="acs__hero-pill">
<fa-icon icon="receipt"></fa-icon>
Charge
</span>
</div>
<!-- Form card -->
<form class="acs__card" [formGroup]="savingsChargeForm" (ngSubmit)="submit()">
<div class="acs__card-head">
<fa-icon icon="info-circle" class="acs__card-ico"></fa-icon>
<h3 class="acs__card-title">Charge Details</h3>
</div>
<div class="acs__card-body">
<mat-form-field appearance="outline" class="acs__field">
<mat-label>{{ 'labels.inputs.Charge' | translate }}</mat-label>
<mat-select required formControlName="chargeId">
@for (savingsCharge of savingsChargeOptions; track savingsCharge.id) {
<mat-option [value]="savingsCharge.id">
{{ savingsCharge.name + ' (' + savingsCharge.currency.name + ')' }}
</mat-option>
}
</mat-select>
@if (savingsChargeForm.controls.chargeId.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Charge' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
@if (chargeDetails) {
<div class="acs__field-grid">
<mat-form-field appearance="outline" class="acs__field">
<mat-label>{{ 'labels.inputs.Amount' | translate }}</mat-label>
<input type="number" required matInput formControlName="amount" />
@if (savingsChargeForm.controls.amount.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Charge' | translate }} {{ 'labels.commons.is' | translate }}
{{ 'labels.inputs.Amount' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
@if (chargeDetails) {
<div class="layout-column">
<mat-form-field>
<mat-label>{{ 'labels.inputs.Amount' | translate }}</mat-label>
<input type="number" required matInput formControlName="amount" />
@if (savingsChargeForm.controls.amount.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Amount' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<mat-form-field>
<mat-label>{{ 'labels.inputs.Charge Calculation' | translate }}</mat-label>
<mat-select formControlName="chargeCalculationType">
@for (chargeCalculation of chargeDetails.chargeCalculationTypeOptions; track chargeCalculation) {
<mat-option [value]="chargeCalculation.id">
{{ chargeCalculation.value }}
</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label>{{ 'labels.inputs.Charge time type' | translate }}</mat-label>
<mat-select formControlName="chargeTimeType">
@for (chargeTime of chargeDetails.chargeTimeTypeOptions; track chargeTime) {
<mat-option [value]="chargeTime.id">
{{ chargeTime.value }}
</mat-option>
}
</mat-select>
</mat-form-field>
@if (savingsChargeForm.contains('dueDate')) {
<mat-form-field (click)="dueDatePicker.open()">
<mat-label>{{ 'labels.inputs.Due for collection on' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="dueDatePicker"
required
formControlName="dueDate"
/>
<mat-datepicker-toggle matSuffix [for]="dueDatePicker"></mat-datepicker-toggle>
<mat-datepicker #dueDatePicker></mat-datepicker>
@if (savingsChargeForm.controls.dueDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Due for collection on' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="acs__field">
<mat-label>{{ 'labels.inputs.Charge Calculation' | translate }}</mat-label>
<mat-select formControlName="chargeCalculationType">
@for (chargeCalculation of chargeDetails.chargeCalculationTypeOptions; track chargeCalculation.id) {
<mat-option [value]="chargeCalculation.id">{{ chargeCalculation.value }}</mat-option>
}
@if (savingsChargeForm.contains('feeOnMonthDay')) {
<mat-form-field (click)="feeOnMonthDayPicker.open()">
<mat-label>{{ 'labels.inputs.Due On' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="feeOnMonthDayPicker"
required
formControlName="feeOnMonthDay"
/>
<mat-datepicker-toggle matSuffix [for]="feeOnMonthDayPicker"></mat-datepicker-toggle>
<mat-datepicker #feeOnMonthDayPicker></mat-datepicker>
@if (savingsChargeForm.controls.feeOnMonthDay.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Due Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="acs__field">
<mat-label>{{ 'labels.inputs.Charge time type' | translate }}</mat-label>
<mat-select formControlName="chargeTimeType">
@for (chargeTime of chargeDetails.chargeTimeTypeOptions; track chargeTime.id) {
<mat-option [value]="chargeTime.id">{{ chargeTime.value }}</mat-option>
}
@if (savingsChargeForm.contains('feeInterval')) {
<mat-form-field>
<mat-label>{{ 'labels.inputs.Repeats Every' | translate }}</mat-label>
<input matInput formControlName="feeInterval" />
</mat-form-field>
</mat-select>
</mat-form-field>
@if (savingsChargeForm.contains('dueDate')) {
<mat-form-field appearance="outline" class="acs__field" (click)="dueDatePicker.open()">
<mat-label>{{ 'labels.inputs.Due for collection on' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="dueDatePicker"
required
formControlName="dueDate"
/>
<mat-datepicker-toggle matSuffix [for]="dueDatePicker"></mat-datepicker-toggle>
<mat-datepicker #dueDatePicker></mat-datepicker>
@if (savingsChargeForm.controls.dueDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Due for collection on' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</div>
</mat-form-field>
}
@if (savingsChargeForm.contains('feeOnMonthDay')) {
<mat-form-field appearance="outline" class="acs__field" (click)="feeOnMonthDayPicker.open()">
<mat-label>{{ 'labels.inputs.Due On' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="feeOnMonthDayPicker"
required
formControlName="feeOnMonthDay"
/>
<mat-datepicker-toggle matSuffix [for]="feeOnMonthDayPicker"></mat-datepicker-toggle>
<mat-datepicker #feeOnMonthDayPicker></mat-datepicker>
@if (savingsChargeForm.controls.feeOnMonthDay.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Due Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
}
@if (savingsChargeForm.contains('feeInterval')) {
<mat-form-field appearance="outline" class="acs__field">
<mat-label>{{ 'labels.inputs.Repeats Every' | translate }}</mat-label>
<input matInput formControlName="feeInterval" />
</mat-form-field>
}
</div>
} @else {
<div class="acs__hint">
<fa-icon icon="info-circle"></fa-icon>
Pick a charge above to load its amount, calculation type and timing.
</div>
}
</div>
<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]="!savingsChargeForm.valid">
{{ 'labels.buttons.Submit' | translate }}
</button>
</mat-card-actions>
</mat-card-content>
</form>
</mat-card>
<div class="acs__actions">
<button type="button" mat-stroked-button class="acs__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="acs__submit" [disabled]="!savingsChargeForm.valid">
<fa-icon icon="check" class="m-r-6"></fa-icon>
{{ 'labels.buttons.Submit' | translate }}
</button>
</div>
</form>
</div>
@@ -6,6 +6,273 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.container {
width: 37rem;
$navy: #0d1b2a;
$slate: #475569;
$muted: #64748b;
$border: #e2e8f0;
$soft: #f8fafc;
$violet: #6d28d9;
$violet-d: #4c1d95;
.acs {
max-width: 820px;
margin: 0 auto;
padding: 14px 18px 22px;
display: flex;
flex-direction: column;
gap: 12px;
}
/* ─── Hero ─────────────────────────────────────────────────────────── */
.acs__hero {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 18px;
border-radius: 12px;
color: #fff;
background: linear-gradient(135deg, #1e1b4b 0%, $violet-d 45%, $violet 100%);
box-shadow: 0 6px 18px -10px rgb(76 29 149 / 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;
}
}
.acs__back {
height: 30px !important;
min-width: 30px !important;
width: 30px !important;
padding: 0 !important;
border-radius: 50% !important;
background: rgb(255 255 255 / 14%) !important;
color: #fff !important;
border: 1px solid rgb(255 255 255 / 28%) !important;
font-size: 12px !important;
flex-shrink: 0;
position: relative;
z-index: 1;
&:hover {
background: rgb(255 255 255 / 26%) !important;
}
}
.acs__hero-ico {
width: 36px;
height: 36px;
border-radius: 9px;
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: 15px;
flex-shrink: 0;
position: relative;
z-index: 1;
}
.acs__hero-text {
flex: 1 1 auto;
min-width: 0;
position: relative;
z-index: 1;
}
.acs__title {
margin: 0;
font-size: 14.5px;
font-weight: 800;
letter-spacing: 0.01em;
line-height: 1.15;
}
.acs__sub {
margin: 2px 0 0;
font-size: 11.5px;
color: rgb(255 255 255 / 82%);
line-height: 1.4;
}
.acs__hero-pill {
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;
position: relative;
z-index: 1;
fa-icon {
font-size: 10px;
opacity: 0.95;
}
}
/* ─── Form card ────────────────────────────────────────────────────── */
.acs__card {
background: #fff;
border: 1px solid $border;
border-radius: 12px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
overflow: hidden;
}
.acs__card-head {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 14px;
background: $soft;
border-bottom: 1px solid $border;
}
.acs__card-ico {
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 7px;
background: linear-gradient(135deg, $violet 0%, $violet-d 100%);
color: #fff;
font-size: 11px;
}
.acs__card-title {
margin: 0;
font-size: 12.5px;
font-weight: 700;
color: $navy;
letter-spacing: 0.01em;
}
.acs__card-body {
padding: 16px 18px 6px;
}
.acs__field {
width: 100%;
::ng-deep .mat-mdc-text-field-wrapper {
background: #fff;
border-radius: 10px;
}
::ng-deep .mat-mdc-form-field-infix {
min-height: 46px;
padding-top: 11px !important;
padding-bottom: 11px !important;
}
::ng-deep .mdc-notched-outline__leading,
::ng-deep .mdc-notched-outline__notch,
::ng-deep .mdc-notched-outline__trailing {
border-color: #cbd5e1 !important;
}
}
/* Two-column grid for the charge-detail fields when a charge is selected. */
.acs__field-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px 12px;
margin-top: 4px;
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
/* Hint shown before a charge is picked. */
.acs__hint {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 14px;
margin-top: 4px;
border-radius: 10px;
background: #eef2ff;
border: 1px dashed #c7d2fe;
color: #3730a3;
font-size: 12.5px;
font-weight: 500;
fa-icon {
color: $violet;
flex-shrink: 0;
}
}
/* ─── Actions ─────────────────────────────────────────────────────── */
.acs__actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 12px 18px 14px;
border-top: 1px solid #f1f5f9;
button {
height: 38px !important;
border-radius: 9px !important;
font-weight: 700 !important;
padding: 0 18px !important;
letter-spacing: 0.01em;
}
}
.acs__cancel {
color: $slate !important;
border-color: $border !important;
}
.acs__submit {
background: linear-gradient(135deg, $violet 0%, $violet-d 100%) !important;
color: #fff !important;
box-shadow: 0 3px 10px -2px rgb(76 29 149 / 35%) !important;
&:disabled {
opacity: 0.55;
filter: grayscale(0.3);
background: linear-gradient(135deg, #9ca3af 0%, #6b7280 100%) !important;
box-shadow: none !important;
}
}
.m-r-6 {
margin-right: 6px;
}
@media (width <= 600px) {
.acs {
padding: 12px;
}
.acs__hero {
flex-wrap: wrap;
}
.acs__actions {
flex-direction: column-reverse;
button {
width: 100%;
}
}
}
@@ -21,6 +21,7 @@ import { Router, ActivatedRoute, RouterLink } from '@angular/router';
import { SavingsService } from '../../savings.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
@@ -31,7 +32,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
templateUrl: './add-charge-savings-account.component.html',
styleUrls: ['./add-charge-savings-account.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS
...STANDALONE_SHARED_IMPORTS,
FaIconComponent
]
})
export class AddChargeSavingsAccountComponent implements OnInit {
@@ -6,36 +6,92 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container mat-elevation-z8">
<mat-card>
@if (transactionType.holdamount) {
<mat-card-title>{{ 'labels.heading.Hold Amount' | translate }}</mat-card-title>
}
@if (transactionType.blockaccount) {
<mat-card-title>{{ 'labels.heading.Block Savings Account' | translate }}</mat-card-title>
}
<div
class="msa"
[class.msa--hold]="transactionType.holdamount"
[class.msa--block-account]="transactionType.blockaccount"
[class.msa--block-deposit]="transactionType.blockdeposit"
[class.msa--block-withdrawal]="transactionType.blockwithdrawal"
>
<!-- Hero -->
<div class="msa__hero">
<button type="button" mat-stroked-button class="msa__back" [routerLink]="['../../transactions']">
<fa-icon icon="arrow-left"></fa-icon>
</button>
<form [formGroup]="manageSavingsAccountForm" (ngSubmit)="submit()">
<mat-card-content class="layout-row">
<mat-form-field class="flex-100">
<mat-label>{{ 'labels.inputs.Reason' | translate }}</mat-label>
<mat-select required formControlName="reasonForBlock">
@for (codeValue of reasonOptions; track codeValue) {
<mat-option [value]="codeValue.id">
{{ codeValue.name }}
</mat-option>
}
</mat-select>
@if (manageSavingsAccountForm.controls.reasonForBlock.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Reason' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<div class="msa__hero-ico">
<fa-icon
[icon]="
transactionType.holdamount
? 'pause'
: transactionType.blockaccount
? 'lock'
: transactionType.blockdeposit
? 'lock'
: transactionType.blockwithdrawal
? 'lock'
: 'lock'
"
></fa-icon>
</div>
<div class="msa__hero-text">
<h2 class="msa__title">
@if (transactionType.holdamount) {
<mat-form-field class="flex-100" (click)="transactionDatePicker.open()">
{{ 'labels.heading.Hold Amount' | translate }}
} @else if (transactionType.blockaccount) {
Block Savings Account
} @else if (transactionType.blockdeposit) {
Block Deposits
} @else if (transactionType.blockwithdrawal) {
Block Withdrawals
}
</h2>
<p class="msa__sub">
@if (transactionType.holdamount) {
Place a hold on a portion of the available balance with a reason and amount.
} @else if (transactionType.blockaccount) {
Freezes all account activity. Select a reason to record the action in the audit log.
} @else if (transactionType.blockdeposit) {
Stops incoming credits to this account. Existing withdrawals continue to work.
} @else if (transactionType.blockwithdrawal) {
Stops outgoing debits from this account. Deposits continue to be accepted.
}
</p>
</div>
<span class="msa__hero-pill">
<fa-icon icon="exclamation-triangle"></fa-icon>
Action recorded
</span>
</div>
<!-- Form card -->
<form class="msa__card" [formGroup]="manageSavingsAccountForm" (ngSubmit)="submit()">
<div class="msa__card-head">
<fa-icon icon="info-circle" class="msa__card-ico"></fa-icon>
<h3 class="msa__card-title">Action Details</h3>
</div>
<div class="msa__card-body">
<mat-form-field appearance="outline" class="msa__field">
<mat-label>{{ 'labels.inputs.Reason' | translate }}</mat-label>
<mat-select required formControlName="reasonForBlock">
@for (codeValue of reasonOptions; track codeValue.id) {
<mat-option [value]="codeValue.id">{{ codeValue.name }}</mat-option>
}
</mat-select>
@if (manageSavingsAccountForm.controls.reasonForBlock.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Reason' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
@if (transactionType.holdamount) {
<div class="msa__field-row">
<mat-form-field appearance="outline" class="msa__field" (click)="transactionDatePicker.open()">
<mat-label>{{ 'labels.inputs.Transaction Date' | translate }}</mat-label>
<input
matInput
@@ -54,28 +110,28 @@
</mat-error>
}
</mat-form-field>
}
@if (transactionType.holdamount) {
<mifosx-input-amount
class="flex-100"
class="msa__field"
[currency]="currency"
[isRequired]="true"
[inputFormControl]="manageSavingsAccountForm.controls.transactionAmount"
[inputLabel]="'Transaction Amount'"
>
</mifosx-input-amount>
}
</mat-card-content>
</div>
}
</div>
<mat-card-actions class="layout-row align-center gap-5px responsive-column">
<button type="button" mat-raised-button [routerLink]="['../../transactions']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" [disabled]="!manageSavingsAccountForm.valid">
{{ 'labels.buttons.Confirm' | translate }}
</button>
</mat-card-actions>
</form>
</mat-card>
<div class="msa__actions">
<button type="button" mat-stroked-button class="msa__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="msa__confirm" [disabled]="!manageSavingsAccountForm.valid">
<fa-icon icon="check" class="m-r-6"></fa-icon>
{{ 'labels.buttons.Confirm' | translate }}
</button>
</div>
</form>
</div>
@@ -6,6 +6,305 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.container {
width: 37rem;
/* Manage-Savings-Account screen — drives four flows: Hold Amount (blue),
Block Deposit (orange), Block Withdrawal (orange), Block Account (red).
Hero gradient + Confirm button tint shift per modifier class. */
$navy: #0d1b2a;
$slate: #475569;
$muted: #64748b;
$border: #e2e8f0;
$soft: #f8fafc;
$blue: #1e88e5;
$blue-d: #1e40af;
$amber: #d97706;
$amber-d: #b45309;
$red: #b91c1c;
$red-d: #7f1d1d;
.msa {
max-width: 720px;
margin: 0 auto;
padding: 14px 18px 22px;
display: flex;
flex-direction: column;
gap: 12px;
}
/* ─── Hero ─────────────────────────────────────────────────────────── */
.msa__hero {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 18px;
border-radius: 12px;
color: #fff;
position: relative;
overflow: hidden;
/* Default (Hold Amount) — calm blue. Modifier classes override below. */
background: linear-gradient(135deg, #0f172a 0%, $blue-d 45%, $blue 100%);
box-shadow: 0 6px 18px -10px rgb(30 64 175 / 50%);
&::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle at top right, rgb(255 255 255 / 12%), transparent 55%);
pointer-events: none;
}
}
/* Hero gradient variants */
.msa--block-deposit .msa__hero,
.msa--block-withdrawal .msa__hero {
background: linear-gradient(135deg, #7c2d12 0%, $amber-d 45%, $amber 100%);
box-shadow: 0 6px 18px -10px rgb(180 83 9 / 50%);
}
.msa--block-account .msa__hero {
background: linear-gradient(135deg, $red-d 0%, $red 45%, #dc2626 100%);
box-shadow: 0 6px 18px -10px rgb(153 27 27 / 55%);
}
.msa__back {
height: 30px !important;
min-width: 30px !important;
width: 30px !important;
padding: 0 !important;
border-radius: 50% !important;
background: rgb(255 255 255 / 14%) !important;
color: #fff !important;
border: 1px solid rgb(255 255 255 / 28%) !important;
font-size: 12px !important;
flex-shrink: 0;
position: relative;
z-index: 1;
&:hover {
background: rgb(255 255 255 / 26%) !important;
}
}
.msa__hero-ico {
width: 36px;
height: 36px;
border-radius: 9px;
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: 15px;
flex-shrink: 0;
position: relative;
z-index: 1;
}
.msa__hero-text {
flex: 1 1 auto;
min-width: 0;
position: relative;
z-index: 1;
}
.msa__title {
margin: 0;
font-size: 14.5px;
font-weight: 800;
letter-spacing: 0.01em;
line-height: 1.15;
}
.msa__sub {
margin: 2px 0 0;
font-size: 11.5px;
color: rgb(255 255 255 / 82%);
line-height: 1.4;
}
.msa__hero-pill {
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;
position: relative;
z-index: 1;
fa-icon {
font-size: 10px;
opacity: 0.95;
}
}
/* ─── Form card ────────────────────────────────────────────────────── */
.msa__card {
background: #fff;
border: 1px solid $border;
border-radius: 12px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
overflow: hidden;
}
.msa__card-head {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 14px;
background: $soft;
border-bottom: 1px solid $border;
}
.msa__card-ico {
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 7px;
background: linear-gradient(135deg, $blue 0%, $blue-d 100%);
color: #fff;
font-size: 11px;
}
.msa--block-deposit .msa__card-ico,
.msa--block-withdrawal .msa__card-ico {
background: linear-gradient(135deg, $amber 0%, $amber-d 100%);
}
.msa--block-account .msa__card-ico {
background: linear-gradient(135deg, $red 0%, $red-d 100%);
}
.msa__card-title {
margin: 0;
font-size: 12.5px;
font-weight: 700;
color: $navy;
letter-spacing: 0.01em;
}
.msa__card-body {
padding: 16px 18px 4px;
display: flex;
flex-direction: column;
gap: 4px;
}
.msa__field {
width: 100%;
::ng-deep .mat-mdc-text-field-wrapper {
background: #fff;
border-radius: 10px;
}
::ng-deep .mat-mdc-form-field-infix {
min-height: 46px;
padding-top: 11px !important;
padding-bottom: 11px !important;
}
::ng-deep .mdc-notched-outline__leading,
::ng-deep .mdc-notched-outline__notch,
::ng-deep .mdc-notched-outline__trailing {
border-color: #cbd5e1 !important;
}
}
.msa__field-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
/* ─── Actions ─────────────────────────────────────────────────────── */
.msa__actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 12px 18px 14px;
border-top: 1px solid #f1f5f9;
button {
height: 38px !important;
border-radius: 9px !important;
font-weight: 700 !important;
padding: 0 18px !important;
letter-spacing: 0.01em;
}
}
.msa__cancel {
color: $slate !important;
border-color: $border !important;
}
.msa__confirm {
/* Default — blue (Hold Amount) */
background: linear-gradient(135deg, $blue 0%, $blue-d 100%) !important;
color: #fff !important;
box-shadow: 0 3px 10px -2px rgb(30 64 175 / 35%) !important;
&:disabled {
opacity: 0.55;
filter: grayscale(0.3);
background: linear-gradient(135deg, #9ca3af 0%, #6b7280 100%) !important;
box-shadow: none !important;
}
}
.msa--block-deposit .msa__confirm,
.msa--block-withdrawal .msa__confirm {
background: linear-gradient(135deg, $amber 0%, $amber-d 100%) !important;
box-shadow: 0 3px 10px -2px rgb(180 83 9 / 35%) !important;
&:disabled {
background: linear-gradient(135deg, #9ca3af 0%, #6b7280 100%) !important;
}
}
.msa--block-account .msa__confirm {
background: linear-gradient(135deg, $red 0%, $red-d 100%) !important;
box-shadow: 0 3px 10px -2px rgb(153 27 27 / 40%) !important;
&:disabled {
background: linear-gradient(135deg, #9ca3af 0%, #6b7280 100%) !important;
}
}
.m-r-6 {
margin-right: 6px;
}
@media (width <= 600px) {
.msa {
padding: 12px;
}
.msa__hero {
flex-wrap: wrap;
}
.msa__actions {
flex-direction: column-reverse;
button {
width: 100%;
}
}
}
@@ -14,8 +14,10 @@ import { SavingsService } from 'app/savings/savings.service';
import { SettingsService } from 'app/settings/settings.service';
import { Currency } from 'app/shared/models/general.model';
import { SystemService } from 'app/system/system.service';
import { MatCard, MatCardTitle, MatCardContent, MatCardActions } from '@angular/material/card';
import { MatDialog } from '@angular/material/dialog';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { InputAmountComponent } from '../../../shared/input-amount/input-amount.component';
import { SuccessDialogComponent } from 'app/shared/success-dialog/success-dialog.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
type TransactionCommandType = 'holdamount' | 'blockaccount' | 'blockdeposit' | 'blockwithdrawal';
@@ -33,7 +35,7 @@ interface TransactionType {
styleUrls: ['./manage-savings-account.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatCardTitle,
FaIconComponent,
InputAmountComponent
]
})
@@ -45,6 +47,7 @@ export class ManageSavingsAccountComponent implements OnInit {
private router = inject(Router);
private systemService = inject(SystemService);
private settingsService = inject(SettingsService);
private dialog = inject(MatDialog);
@Input() currency: Currency;
/** Minimum date allowed. */
@@ -170,9 +173,7 @@ export class ManageSavingsAccountComponent implements OnInit {
this.savingsService
.executeSavingsAccountTransactionsCommand(this.savingAccountId, command, payload)
.subscribe((response: any) => {
this.router.navigate(['../../transactions'], { relativeTo: this.route });
});
.subscribe(() => this.showSuccessAndExit());
} else {
payload = {
...this.manageSavingsAccountForm.value
@@ -186,9 +187,39 @@ export class ManageSavingsAccountComponent implements OnInit {
this.savingsService
.executeSavingsAccountCommand(this.savingAccountId, command, payload)
.subscribe((response: any) => {
this.router.navigate(['../../transactions'], { relativeTo: this.route });
});
.subscribe(() => this.showSuccessAndExit());
}
}
/**
* Shows the shared success pop-up with copy tuned to the current
* transactionType, then navigates back to the transactions tab when
* the cashier dismisses it.
*/
private showSuccessAndExit(): void {
let title = 'Action Completed';
let message = 'The action has been recorded on the account.';
if (this.transactionType.holdamount) {
title = 'Amount Held Successfully';
message = 'The selected amount has been placed on hold on this savings account.';
} else if (this.transactionType.blockaccount) {
title = 'Account Blocked Successfully';
message = 'All activity on this savings account has been frozen.';
} else if (this.transactionType.blockdeposit) {
title = 'Deposits Blocked Successfully';
message = 'Incoming credits to this savings account have been blocked.';
} else if (this.transactionType.blockwithdrawal) {
title = 'Withdrawals Blocked Successfully';
message = 'Outgoing debits from this savings account have been blocked.';
}
const ref = this.dialog.open(SuccessDialogComponent, {
width: '420px',
maxWidth: '92vw',
autoFocus: false,
data: { title, message, buttonLabel: 'Done' }
});
ref.afterClosed().subscribe(() => {
this.router.navigate(['../../transactions'], { relativeTo: this.route });
});
}
}
@@ -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>
@@ -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;
}
@@ -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'
});
}
}
@@ -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>
@@ -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%;
}
}
}
@@ -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 });
});
}
@@ -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>
@@ -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;
}
@@ -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 });
}
}
@@ -57,13 +57,6 @@
</mat-select>
</mat-form-field>
}
@if (savingsProductSelected) {
<mat-form-field appearance="outline" class="sad-field">
<mat-label>{{ 'labels.inputs.External ID' | translate }}</mat-label>
<input matInput formControlName="externalId" />
</mat-form-field>
}
</div>
</div>
@@ -82,6 +82,10 @@ export class SavingsAccountDetailsStepComponent implements OnInit {
primaryClientCif = '';
primaryClientId: number | null = null;
primaryHolderShare = 0;
/** Best-effort string label of the client's entity type
* (e.g. "Individual", "Corporate", "MSME") — used to auto-fill the
* Customer Segment dropdown when the product template lands. */
private clientEntityType: string | null = null;
// ── Status of Account (RBI) ────────────────────────────────────────────
readonly accountStatusOptions = [
@@ -167,8 +171,7 @@ export class SavingsAccountDetailsStepComponent implements OnInit {
productId: this.savingsAccountTemplate.savingsProductId,
submittedOnDate:
this.savingsAccountTemplate.timeline.submittedOnDate &&
new Date(this.savingsAccountTemplate.timeline.submittedOnDate),
externalId: this.savingsAccountTemplate.externalId
new Date(this.savingsAccountTemplate.timeline.submittedOnDate)
});
} else {
this.savingsAccountDetailsForm.patchValue({
@@ -193,11 +196,49 @@ export class SavingsAccountDetailsStepComponent implements OnInit {
this.autoDetectAccountStatus();
}
}
this.clientEntityType = this.deriveEntityType(client);
// Product template may have already arrived before the client did —
// try the segment-apply both here and when the template lands.
this.applyInferredCustomerSegment();
this.cdr.detectChanges();
});
}
}
/**
* Build a best-effort label for the client's entity / customer type.
* Tries `clientType` first (the explicit master), then falls back to
* `clientClassification` and finally `legalForm`.
*/
private deriveEntityType(client: any): string | null {
if (!client) return null;
const direct =
client.clientType?.value ??
client.clientType?.name ??
client.clientClassification?.value ??
client.clientClassification?.name;
if (direct && typeof direct === 'string') return direct;
const legalForm = client.legalForm?.value ?? client.legalForm?.code ?? client.legalForm?.name;
return typeof legalForm === 'string' ? legalForm : null;
}
/**
* If `clientEntityType` and `customerSegmentOptions` are both ready, try
* to match an option (exact then case-insensitive substring) and set it
* as the selected Customer Segment. Falls through silently otherwise so
* the existing product default stays in place.
*/
private applyInferredCustomerSegment(): void {
if (!this.clientEntityType || !this.customerSegmentOptions?.length) return;
const needle = this.clientEntityType.trim().toLowerCase();
const match =
this.customerSegmentOptions.find((o) => o.toLowerCase() === needle) ||
this.customerSegmentOptions.find((o) => o.toLowerCase().includes(needle) || needle.includes(o.toLowerCase()));
if (match) {
this.selectedCustomerSegment = match;
}
}
/** Parse DOB from backend — handles array [yyyy, mm, dd] or string */
private parseClientDob(dob: any): Date | null {
if (!dob) return null;
@@ -263,8 +304,7 @@ export class SavingsAccountDetailsStepComponent implements OnInit {
'',
Validators.required
],
fieldOfficerId: [''],
externalId: ['']
fieldOfficerId: ['']
});
}
@@ -279,6 +319,9 @@ export class SavingsAccountDetailsStepComponent implements OnInit {
this.customerSegmentOptions = this.extractOptions(response.customerSegmentOptions, response.customerSegment);
this.selectedAccountType = response.accountType ?? null;
this.selectedCustomerSegment = response.customerSegment ?? null;
// Override the product's default segment with the one inferred
// from the client's entity type when an option matches.
this.applyInferredCustomerSegment();
this.savingsAccountProductTemplate.emit(response);
this.fieldOfficerData = response.fieldOfficerOptions;
this.savingsProductSelected = true;
@@ -575,15 +575,18 @@
}
</div>
<div class="layout-row responsive-column align-center gap-2px margin-t">
<button mat-raised-button matStepperPrevious>
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Previous' | translate }}
</button>
<button mat-raised-button [routerLink]="['../..']">
<!--
Submit & Cancel — Previous lives in the parent wizard's tab action bar
now that this component renders inside a mat-tab-group rather than a
mat-stepper. Re-introducing matStepperPrevious here would require a
CdkStepper ancestor and would crash the view on init.
-->
<div class="preview-submit-row">
<button mat-stroked-button [routerLink]="['../..']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" (click)="submitEvent.emit()">
<button mat-raised-button color="primary" class="preview-submit-row__primary" (click)="submitEvent.emit()">
<fa-icon icon="check" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Submit' | translate }}
</button>
</div>
@@ -13,6 +13,31 @@
padding: 0.5rem 0;
}
/* Submit + Cancel row at the bottom of the preview. Previous lives in the
parent wizard's tab action bar. */
.preview-submit-row {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 12px;
margin-top: 18px;
padding-top: 16px;
border-top: 1px solid #f1f5f9;
button {
height: 42px !important;
border-radius: 10px !important;
font-weight: 600 !important;
padding: 0 22px !important;
}
&__primary {
background: linear-gradient(135deg, #10b981 0%, #047857 100%) !important;
color: #fff !important;
box-shadow: 0 4px 12px -2px rgb(4 120 87 / 35%) !important;
}
}
.preview-section {
border: 1px solid #e2e8f0;
border-radius: 10px;
@@ -23,7 +23,6 @@ import {
} from '@angular/material/table';
import { MatDivider } from '@angular/material/divider';
import { ExternalIdentifierComponent } from '../../../shared/external-identifier/external-identifier.component';
import { MatStepperPrevious } from '@angular/material/stepper';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { FindPipe } from '../../../pipes/find.pipe';
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
@@ -52,7 +51,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatHeaderRow,
MatRowDef,
MatRow,
MatStepperPrevious,
FaIconComponent,
FindPipe,
DateFormatPipe,
@@ -6,25 +6,22 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="tab-container mat-typography">
<div class="layout-row align-start">
<div class="m-b-10">
<h3>{{ 'labels.inputs.Charges' | translate }}</h3>
</div>
@if (this.chargesData) {
<div class="action-button m-b-20">
<button mat-raised-button color="primary" (click)="toggleCharges()">
<div class="chg-tab">
<div class="chg-tab__card">
<div class="chg-tab__toolbar">
<h3 class="chg-tab__title">{{ 'labels.inputs.Charges' | translate }}</h3>
<span class="chg-tab__spacer"></span>
@if (this.chargesData) {
<button mat-flat-button class="chg-tab__toggle" (click)="toggleCharges()">
{{
showInactiveCharges
? viewAllChargeButtons('View Active Charges')
: viewAllChargeButtons('View Inactive Charges')
}}
</button>
</div>
}
</div>
}
</div>
<div class="mat-elevation-z1 m-b-25">
<table mat-table #chargesTable [dataSource]="dataSource">
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Name' | translate }}</th>
@@ -6,30 +6,107 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.tab-container {
padding: 1%;
margin: 1%;
$navy: #0d1b2a;
$slate: #475569;
$muted: #64748b;
$border: #e2e8f0;
$soft: #f8fafc;
$blue: #1e40af;
h3 {
margin: 1% auto;
.chg-tab {
width: 100%;
}
.chg-tab__card {
background: #fff;
border: 1px solid $border;
border-radius: 14px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
overflow: hidden;
}
.chg-tab__toolbar {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 18px;
background: $soft;
border-bottom: 1px solid $border;
}
.chg-tab__title {
margin: 0;
font-size: 14px;
font-weight: 700;
color: $navy;
letter-spacing: 0.01em;
}
.chg-tab__spacer {
flex: 1;
}
.chg-tab__toggle {
height: 34px !important;
border-radius: 8px !important;
font-weight: 600 !important;
font-size: 13px !important;
padding: 0 14px !important;
background: linear-gradient(135deg, #1e88e5 0%, $blue 100%) !important;
color: #fff !important;
}
table {
width: 100%;
}
::ng-deep .chg-tab__card {
.mat-mdc-header-row {
background: $soft;
}
.action-button {
margin-left: auto;
.mat-mdc-header-cell {
font-size: 11.5px;
font-weight: 800;
color: $slate;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 12px 14px;
border-bottom: 1px solid $border;
}
table {
width: 100%;
.mat-mdc-cell {
font-size: 13px;
color: $navy;
padding: 11px 14px;
border-bottom: 1px solid #f1f5f9;
}
.mat-mdc-row:hover {
background: #f8fafc;
cursor: pointer;
}
/* Right-align monetary columns. */
.mat-column-due,
.mat-column-paid,
.mat-column-waived,
.mat-column-outstanding {
text-align: right;
font-variant-numeric: tabular-nums;
}
.mat-column-actions {
text-align: center;
width: 140px;
.account-action-button {
min-width: 26px;
padding: 0 6px;
margin: 4px;
line-height: 25px;
}
.select-row:hover {
cursor: pointer;
min-width: 30px !important;
padding: 0 8px !important;
margin: 0 3px !important;
height: 30px !important;
line-height: 30px !important;
border-radius: 7px !important;
}
}
}
@@ -13,355 +13,378 @@
}
@if (!isLoading) {
<div class="savings-account-tables layout-row gap-2percent">
<div class="flex-49 layout-column gap-4percent">
@if (!savingsAccountData.status.rejected && !savingsAccountData.status.submittedAndPendingApproval) {
<div>
<h4 class="table-headers">{{ 'labels.heading.Savings Details' | translate }}</h4>
<table>
<tbody>
<tr>
<td>{{ 'labels.inputs.External Id' | translate }}</td>
@if (savingsAccountData.externalId) {
<td>
<mifosx-external-identifier
externalId="{{ savingsAccountData.externalId }}"
></mifosx-external-identifier>
</td>
}
@if (!savingsAccountData.externalId) {
<td>{{ 'labels.inputs.Not Provided' | translate }}</td>
}
</tr>
<tr>
<td>{{ 'labels.inputs.Activated On' | translate }}</td>
<td>
{{
savingsAccountData.timeline.activatedOnDate
? (savingsAccountData.timeline.activatedOnDate | dateFormat)
: 'Not Activated'
}}
</td>
</tr>
<tr>
<td>{{ 'labels.inputs.Field Officer' | translate }}</td>
<td>{{ savingsAccountData.fieldOfficerName ? savingsAccountData.fieldOfficerName : 'Unassigned' }}</td>
</tr>
<tr>
<td>{{ 'labels.inputs.Currency' | translate }}</td>
<td>{{ savingsAccountData.currency.name }} [{{ savingsAccountData.currency.code }}]</td>
</tr>
<tr>
<td>{{ 'labels.inputs.Nominal Interest Rate' | translate }}</td>
<td class="r-amount">
@if (savingsAccountData.nominalAnnualInterestRate) {
<span>{{ savingsAccountData.nominalAnnualInterestRate | formatNumber }} %</span>
}
@if (!savingsAccountData.nominalAnnualInterestRate) {
<span>0 %</span>
}
</td>
</tr>
</tbody>
</table>
</div>
} @else {
<div>
<h4 class="table-headers">{{ 'labels.heading.Savings Details' | translate }}</h4>
<table>
<tbody>
<tr>
<td>{{ 'labels.inputs.Field Officer' | translate }}</td>
<td>{{ savingsAccountData.fieldOfficerName ? savingsAccountData.fieldOfficerName : 'Unassigned' }}</td>
</tr>
<tr>
<td>{{ 'labels.inputs.Balance' | translate }}</td>
<td class="r-amount">
{{ savingsAccountData.summary.accountBalance | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</td>
</tr>
</tbody>
</table>
</div>
}
@if (!savingsAccountData.status.rejected && !savingsAccountData.status.submittedAndPendingApproval) {
<div>
<h4 class="table-headers">{{ 'labels.heading.Performance History' | translate }}</h4>
<table>
<tbody>
@if (savingsAccountData.summary.totalDeposits) {
<tr>
<td>{{ 'labels.inputs.Total Deposits' | translate }}</td>
<td class="r-amount">
{{ savingsAccountData.summary.totalDeposits | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</td>
</tr>
}
@if (savingsAccountData.summary.totalInterestEarned >= 0) {
<tr>
<td>{{ 'labels.inputs.Total Interest Earned' | translate }}</td>
<td class="r-amount">
{{
savingsAccountData.summary.totalInterestEarned
| currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
@if (isActive) {
<div class="flex-49">
<h4 class="table-headers">{{ 'labels.heading.Account Summary' | translate }}</h4>
<table>
<tbody>
@if (savingsAccountData.summary.totalWithdrawals) {
<tr>
<td>{{ 'labels.inputs.Total Withdrawls' | translate }}</td>
<td class="r-amount">
{{
savingsAccountData.summary.totalWithdrawals | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</td>
</tr>
}
@if (savingsAccountData.summary.totalWithdrawalFees) {
<tr>
<td>{{ 'labels.inputs.Withdrawals Fees' | translate }}</td>
<td class="r-amount">
{{
savingsAccountData.summary.totalWithdrawalFees | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</td>
</tr>
}
@if (savingsAccountData.summary.totalAnnualFees) {
<tr>
<td>{{ 'labels.inputs.Annual Fees' | translate }}</td>
<td class="r-amount">
{{ savingsAccountData.summary.totalAnnualFees | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</td>
</tr>
}
@if (savingsAccountData.summary.totalInterestEarned >= 0) {
<tr>
<td>{{ 'labels.inputs.Interest Earned' | translate }}</td>
<td class="r-amount">
{{
savingsAccountData.summary.totalInterestEarned | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</td>
</tr>
}
@if (savingsAccountData.summary.totalInterestPosted) {
<tr>
<td>{{ 'labels.inputs.Interest Posted' | translate }}</td>
<td class="r-amount">
{{
savingsAccountData.summary.totalInterestPosted | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</td>
</tr>
}
@if (savingsAccountData.summary.interestNotPosted >= 0) {
<tr>
<td>{{ 'labels.inputs.Interest Earned Not Posted' | translate }}</td>
<td class="r-amount">
{{
savingsAccountData.summary.interestNotPosted | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</td>
</tr>
}
@if (savingsAccountData.summary.totalOverdraftInterestDerived) {
<tr>
<td>{{ 'labels.inputs.Interest On Overdraft' | translate }}</td>
<td class="r-amount">
{{
savingsAccountData.summary.totalOverdraftInterestDerived
| currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</td>
</tr>
}
@if (savingsAccountData.summary.interestNotPosted < 0) {
<tr>
<td>{{ 'labels.inputs.Overdraft Interest Not Posted' | translate }}</td>
<td class="r-amount">
{{
savingsAccountData.summary.interestNotPosted | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</td>
</tr>
}
@if (savingsAccountData.nominalAnnualInterestRate) {
<tr>
<td>{{ 'labels.inputs.Nominal Interest Rate' | translate }}</td>
<td class="r-amount">{{ savingsAccountData.nominalAnnualInterestRate | formatNumber }} %</td>
</tr>
}
<tr>
<td>{{ 'labels.inputs.Interest Compounding Period' | translate }}</td>
<td>{{ savingsAccountData.interestCompoundingPeriodType.value | translateKey: 'catalogs' }}</td>
</tr>
<tr>
<td>{{ 'labels.inputs.Interest Posting Period' | translate }}</td>
<td>{{ savingsAccountData.interestPostingPeriodType.value | translateKey: 'catalogs' }}</td>
</tr>
<tr>
<td>{{ 'labels.inputs.Interest Calculated Using' | translate }}</td>
<td>{{ savingsAccountData.interestCalculationType.value | translateKey: 'catalogs' }}</td>
</tr>
<tr>
<td>{{ 'labels.inputs.Days in Year' | translate }}</td>
<td>{{ savingsAccountData.interestCalculationDaysInYearType.value | translateKey: 'catalogs' }}</td>
</tr>
@if (savingsAccountData.withdrawalFee) {
<tr>
<td>{{ 'labels.inputs.Withdrawal Fee' | translate }}</td>
<td class="r-amount">
{{ savingsAccountData.withdrawalFee.amount | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</td>
</tr>
}
@if (savingsAccountData.lastActiveTransactionDate) {
<tr>
<td>{{ 'labels.inputs.Last Active Transaction Date' | translate }}</td>
<td>{{ savingsAccountData.lastActiveTransactionDate | dateFormat }}</td>
</tr>
}
@if (!(savingsAccountData.subStatus.id === 0)) {
<tr>
<td>{{ 'labels.inputs.Substatus' | translate }}</td>
<td>{{ savingsAccountData.subStatus.value }}</td>
</tr>
}
@if (savingsAccountData.daysToInactive) {
<tr>
<td>{{ 'labels.inputs.Days to Inactive' | translate }}</td>
<td>{{ savingsAccountData.daysToInactive }}</td>
</tr>
}
@if (savingsAccountData.daysToDormancy) {
<tr>
<td>{{ 'labels.inputs.Days to Dormancy' | translate }}</td>
<td>{{ savingsAccountData.daysToDormancy }}</td>
</tr>
}
@if (savingsAccountData.daysToEscheat) {
<tr>
<td>{{ 'labels.inputs.Days to Escheat' | translate }}</td>
<td>{{ savingsAccountData.daysToEscheat }}</td>
</tr>
}
@if (savingsAccountData.annualFee) {
<tr>
<td>{{ 'labels.inputs.Annual Fee' | translate }}</td>
<td class="r-amount">
{{ savingsAccountData.annualFee.amount | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</td>
</tr>
}
@if (savingsAccountData.allowOverdraft) {
<tr>
<td>{{ 'labels.inputs.Overdraft Limit' | translate }}</td>
<td class="r-amount">
{{ savingsAccountData.overdraftLimit | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</td>
</tr>
}
@if (savingsAccountData.allowOverdraft) {
<tr>
<td>{{ 'labels.inputs.Minimum Overdraft Required for Interest Calculation' | translate }}</td>
<td>
{{
savingsAccountData.minOverdraftForInterestCalculation
| currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</td>
</tr>
}
@if (savingsAccountData.minBalanceForInterestCalculation) {
<tr>
<td>{{ 'labels.inputs.Min Balance Required for Interest Calculation' | translate }}</td>
<td>{{ savingsAccountData.minBalanceForInterestCalculation | formatNumber }}</td>
</tr>
}
@if (savingsAccountData.minRequiredBalance) {
<tr>
<td>{{ 'labels.inputs.Minimum Required Balance' | translate }}</td>
<td>{{ savingsAccountData.minRequiredBalance | formatNumber }}</td>
</tr>
}
@if (savingsAccountData.enforceMinRequiredBalance) {
<tr>
<td>{{ 'labels.inputs.Enforce Minimum Required Balance' | translate }}</td>
<td>{{ savingsAccountData.enforceMinRequiredBalance | yesNo }}</td>
</tr>
}
@if (savingsAccountData.summary.lastInterestCalculationDate) {
<tr>
<td>{{ 'labels.inputs.Interest Recalculation Date' | translate }}</td>
<td>{{ savingsAccountData.summary.lastInterestCalculationDate | dateFormat }}</td>
</tr>
}
@if (savingsAccountData.onHoldFunds) {
<tr>
<td>{{ 'labels.inputs.On Hold Funds' | translate }}</td>
<td>
<a *mifosxHasPermission="'READ_SAVINGSACCOUNT'">{{
savingsAccountData.onHoldFunds | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}</a>
</td>
</tr>
}
@if (savingsAccountData.withHoldTax) {
<tr>
<td>{{ 'labels.inputs.Withhold Tax Group' | translate }}</td>
<td>{{ savingsAccountData.taxGroup.name }}</td>
</tr>
}
</tbody>
</table>
</div>
} @else {
<div class="flex-49">
<h4 class="table-headers">{{ 'labels.heading.Account Summary' | translate }}</h4>
<table>
<tbody>
<tr>
<td>{{ 'labels.inputs.External Id' | translate }}</td>
@if (savingsAccountData.externalId) {
<td>
<mifosx-external-identifier
externalId="{{ savingsAccountData.externalId }}"
></mifosx-external-identifier>
</td>
}
@if (!savingsAccountData.externalId) {
<td>{{ 'labels.inputs.Not Provided' | translate }}</td>
}
</tr>
<tr>
<td>{{ 'labels.inputs.Activated On' | translate }}</td>
<td>
<div class="gt-grid">
<!-- ─── LEFT COLUMN ──────────────────────────────────────────── -->
<div class="gt-col">
<!-- Savings Details -->
<section class="nx-card">
<header class="nx-card__head">
<fa-icon icon="info-circle" class="nx-card__ico"></fa-icon>
<h3 class="nx-card__title">{{ 'labels.heading.Savings Details' | translate }}</h3>
</header>
<div class="nx-card__body">
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Account Number' | translate }}</span>
<span class="nx-row__value nx-mono">{{ savingsAccountData.accountNo }}</span>
</div>
<div class="nx-row">
<span class="nx-row__label"
>{{ 'labels.text.' + entityType | translate }} {{ 'labels.inputs.name' | translate }}</span
>
<span class="nx-row__value">
{{ savingsAccountData.clientName || savingsAccountData.groupName || '—' }}
</span>
</div>
@if (!savingsAccountData.status.rejected && !savingsAccountData.status.submittedAndPendingApproval) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Activated On' | translate }}</span>
<span class="nx-row__value">
{{
savingsAccountData.timeline.activatedOnDate
? (savingsAccountData.timeline.activatedOnDate | dateFormat)
: 'Not Activated'
}}
</td>
</tr>
<tr>
<td>{{ 'labels.inputs.Currency' | translate }}</td>
<td>{{ savingsAccountData.currency.name }} [{{ savingsAccountData.currency.code }}]</td>
</tr>
</tbody>
</table>
</div>
}
</span>
</div>
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Field Officer' | translate }}</span>
<span class="nx-row__value">
{{ savingsAccountData.fieldOfficerName ? savingsAccountData.fieldOfficerName : 'Unassigned' }}
</span>
</div>
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Currency' | translate }}</span>
<span class="nx-row__value">
{{ savingsAccountData.currency.name }}
<span class="nx-mono">[{{ savingsAccountData.currency.code }}]</span>
</span>
</div>
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Nominal Interest Rate' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
@if (savingsAccountData.nominalAnnualInterestRate) {
{{ savingsAccountData.nominalAnnualInterestRate | formatNumber }} %
} @else {
0 %
}
</span>
</div>
} @else {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Field Officer' | translate }}</span>
<span class="nx-row__value">
{{ savingsAccountData.fieldOfficerName ? savingsAccountData.fieldOfficerName : 'Unassigned' }}
</span>
</div>
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Balance' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{ savingsAccountData.summary.accountBalance | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</span>
</div>
}
</div>
</section>
<!-- Performance History -->
@if (!savingsAccountData.status.rejected && !savingsAccountData.status.submittedAndPendingApproval) {
<section class="nx-card">
<header class="nx-card__head">
<fa-icon icon="chart-line" class="nx-card__ico"></fa-icon>
<h3 class="nx-card__title">{{ 'labels.heading.Performance History' | translate }}</h3>
</header>
<div class="nx-card__body">
@if (savingsAccountData.summary.totalDeposits) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Total Deposits' | translate }}</span>
<span class="nx-row__value nx-row__value--amount nx-row__value--positive">
{{ savingsAccountData.summary.totalDeposits | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</span>
</div>
}
@if (savingsAccountData.summary.totalInterestEarned >= 0) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Total Interest Earned' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{
savingsAccountData.summary.totalInterestEarned | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
@if (!savingsAccountData.summary.totalDeposits && !(savingsAccountData.summary.totalInterestEarned >= 0)) {
<div class="nx-empty">
<fa-icon icon="inbox" class="nx-empty__ico"></fa-icon>
<span>No activity yet.</span>
</div>
}
</div>
</section>
}
</div>
<!-- ─── RIGHT COLUMN ─────────────────────────────────────────── -->
<div class="gt-col">
@if (isActive) {
<section class="nx-card">
<header class="nx-card__head">
<fa-icon icon="file-invoice" class="nx-card__ico"></fa-icon>
<h3 class="nx-card__title">{{ 'labels.heading.Account Summary' | translate }}</h3>
</header>
<div class="nx-card__body">
@if (savingsAccountData.summary.totalWithdrawals) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Total Withdrawls' | translate }}</span>
<span class="nx-row__value nx-row__value--amount nx-row__value--negative">
{{
savingsAccountData.summary.totalWithdrawals | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
@if (savingsAccountData.summary.totalWithdrawalFees) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Withdrawals Fees' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{
savingsAccountData.summary.totalWithdrawalFees | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
@if (savingsAccountData.summary.totalAnnualFees) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Annual Fees' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{ savingsAccountData.summary.totalAnnualFees | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</span>
</div>
}
@if (savingsAccountData.summary.totalInterestEarned >= 0) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Interest Earned' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{
savingsAccountData.summary.totalInterestEarned | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
@if (savingsAccountData.summary.totalInterestPosted) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Interest Posted' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{
savingsAccountData.summary.totalInterestPosted | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
@if (savingsAccountData.summary.interestNotPosted >= 0) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Interest Earned Not Posted' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{
savingsAccountData.summary.interestNotPosted | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
@if (savingsAccountData.summary.totalOverdraftInterestDerived) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Interest On Overdraft' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{
savingsAccountData.summary.totalOverdraftInterestDerived
| currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
@if (savingsAccountData.summary.interestNotPosted < 0) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Overdraft Interest Not Posted' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{
savingsAccountData.summary.interestNotPosted | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
@if (savingsAccountData.nominalAnnualInterestRate) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Nominal Interest Rate' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{ savingsAccountData.nominalAnnualInterestRate | formatNumber }} %
</span>
</div>
}
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Interest Compounding Period' | translate }}</span>
<span class="nx-row__value">
{{ savingsAccountData.interestCompoundingPeriodType.value | translateKey: 'catalogs' }}
</span>
</div>
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Interest Posting Period' | translate }}</span>
<span class="nx-row__value">
{{ savingsAccountData.interestPostingPeriodType.value | translateKey: 'catalogs' }}
</span>
</div>
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Interest Calculated Using' | translate }}</span>
<span class="nx-row__value">
{{ savingsAccountData.interestCalculationType.value | translateKey: 'catalogs' }}
</span>
</div>
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Days in Year' | translate }}</span>
<span class="nx-row__value">
{{ savingsAccountData.interestCalculationDaysInYearType.value | translateKey: 'catalogs' }}
</span>
</div>
@if (savingsAccountData.withdrawalFee) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Withdrawal Fee' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{ savingsAccountData.withdrawalFee.amount | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</span>
</div>
}
@if (savingsAccountData.lastActiveTransactionDate) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Last Active Transaction Date' | translate }}</span>
<span class="nx-row__value">{{ savingsAccountData.lastActiveTransactionDate | dateFormat }}</span>
</div>
}
@if (!(savingsAccountData.subStatus.id === 0)) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Substatus' | translate }}</span>
<span class="nx-row__value">{{ savingsAccountData.subStatus.value }}</span>
</div>
}
@if (savingsAccountData.daysToInactive) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Days to Inactive' | translate }}</span>
<span class="nx-row__value">{{ savingsAccountData.daysToInactive }}</span>
</div>
}
@if (savingsAccountData.daysToDormancy) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Days to Dormancy' | translate }}</span>
<span class="nx-row__value">{{ savingsAccountData.daysToDormancy }}</span>
</div>
}
@if (savingsAccountData.daysToEscheat) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Days to Escheat' | translate }}</span>
<span class="nx-row__value">{{ savingsAccountData.daysToEscheat }}</span>
</div>
}
@if (savingsAccountData.annualFee) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Annual Fee' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{ savingsAccountData.annualFee.amount | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</span>
</div>
}
@if (savingsAccountData.allowOverdraft) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Overdraft Limit' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{ savingsAccountData.overdraftLimit | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</span>
</div>
}
@if (savingsAccountData.allowOverdraft) {
<div class="nx-row">
<span class="nx-row__label">
{{ 'labels.inputs.Minimum Overdraft Required for Interest Calculation' | translate }}
</span>
<span class="nx-row__value">
{{
savingsAccountData.minOverdraftForInterestCalculation
| currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
@if (savingsAccountData.minBalanceForInterestCalculation) {
<div class="nx-row">
<span class="nx-row__label">
{{ 'labels.inputs.Min Balance Required for Interest Calculation' | translate }}
</span>
<span class="nx-row__value">
{{ savingsAccountData.minBalanceForInterestCalculation | formatNumber }}
</span>
</div>
}
@if (savingsAccountData.minRequiredBalance) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Minimum Required Balance' | translate }}</span>
<span class="nx-row__value">{{ savingsAccountData.minRequiredBalance | formatNumber }}</span>
</div>
}
@if (savingsAccountData.enforceMinRequiredBalance) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Enforce Minimum Required Balance' | translate }}</span>
<span class="nx-row__value">{{ savingsAccountData.enforceMinRequiredBalance | yesNo }}</span>
</div>
}
@if (savingsAccountData.summary.lastInterestCalculationDate) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Interest Recalculation Date' | translate }}</span>
<span class="nx-row__value">
{{ savingsAccountData.summary.lastInterestCalculationDate | dateFormat }}
</span>
</div>
}
@if (savingsAccountData.onHoldFunds) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.On Hold Funds' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
<a *mifosxHasPermission="'READ_SAVINGSACCOUNT'">
{{ savingsAccountData.onHoldFunds | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</a>
</span>
</div>
}
@if (savingsAccountData.withHoldTax) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Withhold Tax Group' | translate }}</span>
<span class="nx-row__value">{{ savingsAccountData.taxGroup.name }}</span>
</div>
}
</div>
</section>
} @else {
<section class="nx-card">
<header class="nx-card__head">
<fa-icon icon="file-invoice" class="nx-card__ico"></fa-icon>
<h3 class="nx-card__title">{{ 'labels.heading.Account Summary' | translate }}</h3>
</header>
<div class="nx-card__body">
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Activated On' | translate }}</span>
<span class="nx-row__value">
{{
savingsAccountData.timeline.activatedOnDate
? (savingsAccountData.timeline.activatedOnDate | dateFormat)
: 'Not Activated'
}}
</span>
</div>
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Currency' | translate }}</span>
<span class="nx-row__value">
{{ savingsAccountData.currency.name }}
<span class="nx-mono">[{{ savingsAccountData.currency.code }}]</span>
</span>
</div>
</div>
</section>
}
</div>
</div>
}
@@ -6,9 +6,15 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
$navy: #0d1b2a;
$slate: #475569;
$muted: #64748b;
$border: #e2e8f0;
$soft: #f8fafc;
/* ─── Loader keyframes (legacy, untouched) ──────────────────────────── */
$bar: 160px;
$foo: $bar * 8;
$color: #e2e4ec;
.loader {
margin-bottom: 39px;
@@ -79,164 +85,136 @@ $color: #e2e4ec;
}
}
.general-tab-container {
padding: 16px 20px;
width: 100%;
box-sizing: border-box;
position: relative;
background-color: var(--background-color, #f8f9fa);
color: var(--text-color, #333);
transition:
background-color 0.3s ease,
color 0.3s ease;
/* ─── Two-column grid ───────────────────────────────────────────────── */
.gt-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 16px;
.page-header {
margin-bottom: 24px;
padding: 16px 0;
position: relative;
border-bottom: 1px solid var(--border-color, #e9ecef);
@media (width <= 1024px) {
grid-template-columns: 1fr;
}
}
.header-title {
font-size: 1.75rem;
font-weight: 600;
margin: 0 0 8px;
line-height: 1.2;
color: var(--primary-color, #007bff);
position: relative;
}
.gt-col {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
.header-subtitle {
font-size: 0.95rem;
margin: 0;
line-height: 1.4;
color: var(--text-secondary, #6c757d);
position: relative;
}
/* ─── Nexora section card ───────────────────────────────────────────── */
.nx-card {
background: #fff;
border: 1px solid $border;
border-radius: 14px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
overflow: hidden;
&__head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 18px;
background: $soft;
border-bottom: 1px solid $border;
}
.content-wrapper {
position: relative;
width: 100%;
}
.account-info-card {
background: var(--card-background, #fff);
&__ico {
width: 26px;
height: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
box-shadow: 0 2px 4px rgb(0 0 0 / 10%);
overflow: hidden;
position: relative;
width: 100%;
max-width: 600px;
transition:
box-shadow 0.3s ease,
background-color 0.3s ease;
background: linear-gradient(135deg, #3b82f6 0%, #1e40af 100%);
color: #fff;
font-size: 12px;
}
.card-header {
padding: 16px 20px;
background: var(--card-header-bg, #f8f9fa);
border-bottom: 1px solid var(--border-color, #e9ecef);
position: relative;
&__title {
margin: 0;
font-size: 13.5px;
font-weight: 700;
color: $navy;
letter-spacing: 0.01em;
}
h3 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
line-height: 1.3;
color: var(--text-color, #333);
}
&__body {
padding: 4px 18px 10px;
}
}
/* ─── Key/Value row ─────────────────────────────────────────────────── */
.nx-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
min-height: 38px;
padding: 8px 0;
border-bottom: 1px dashed #eef2f7;
font-size: 13px;
&:last-child {
border-bottom: none;
}
&__label {
color: $slate;
font-weight: 500;
}
&__value {
color: $navy;
font-weight: 600;
text-align: right;
word-break: break-word;
&--amount {
font-variant-numeric: tabular-nums;
font-weight: 700;
}
.card-content {
padding: 20px;
position: relative;
&--positive {
color: #047857;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 10px 0;
border-bottom: 1px solid var(--border-light, #f1f3f4);
position: relative;
min-height: 24px;
&:last-child {
border-bottom: none;
}
.label {
font-weight: 500;
color: var(--text-secondary, #6c757d);
flex: 0 0 auto;
margin-right: 16px;
white-space: nowrap;
line-height: 1.4;
}
.value {
font-weight: 600;
color: var(--text-color, #333);
text-align: right;
flex: 1 1 auto;
line-height: 1.4;
&.currency {
color: var(--success-color, #28a745);
font-size: 1.05rem;
}
}
}
&--negative {
color: #b91c1c;
}
}
}
// Dark mode styles
[data-theme='dark'] .general-tab-container,
.dark-theme .general-tab-container {
--background-color: #1a1a1a;
--text-color: #e9ecef;
--text-secondary: #adb5bd;
--primary-color: #4dabf7;
--card-background: #2d3748;
--card-header-bg: #374151;
--border-color: #4a5568;
--border-light: #2d3748;
--success-color: #68d391;
.nx-muted {
color: $muted;
font-weight: 500;
font-style: italic;
}
// Responsive adjustments for mobile
@media (width <= 768px) {
.general-tab-container {
padding: 12px 16px;
.nx-mono {
font-family: 'JetBrains Mono', 'Courier New', monospace;
font-size: 12.5px;
color: $muted;
margin-left: 4px;
}
.page-header {
margin-bottom: 20px;
padding: 12px 0;
/* ─── Empty state inside a card ─────────────────────────────────────── */
.nx-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 24px 16px;
color: $muted;
.header-title {
font-size: 1.5rem;
}
&__ico {
font-size: 28px;
color: #cbd5e1;
}
.header-subtitle {
font-size: 0.9rem;
}
}
.account-info-card {
.card-header {
padding: 14px 16px;
}
.card-content {
padding: 16px;
.info-row {
padding: 8px 0;
.label {
margin-right: 12px;
}
}
}
}
span {
font-size: 13px;
font-weight: 500;
}
}
@@ -10,7 +10,6 @@ import { Component, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { Currency } from 'app/shared/models/general.model';
import { CurrencyPipe } from '@angular/common';
import { ExternalIdentifierComponent } from '../../../shared/external-identifier/external-identifier.component';
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
import { FormatNumberPipe } from '../../../pipes/format-number.pipe';
import { YesnoPipe } from '../../../pipes/yesno.pipe';
@@ -22,7 +21,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
styleUrls: ['./general-tab.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
ExternalIdentifierComponent,
CurrencyPipe,
DateFormatPipe,
FormatNumberPipe,
@@ -6,239 +6,373 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<mat-card class="account-card">
<mat-card-header class="header layout-column">
<mat-card-title-group class="header-title-group">
<div class="profile-image-container">
<div>
<img
mat-card-md-image
class="profile-image"
matTooltip="{{ 'tooltips.Savings Account' | translate }}"
[src]="'assets/images/savings_account_placeholder.png'"
/>
</div>
<div class="sav-view">
<!-- ─── Gradient hero header ─────────────────────────────────────── -->
<header class="sav-hero">
<div class="sav-hero__left">
<div class="sav-hero__avatar">
<fa-icon icon="piggy-bank"></fa-icon>
</div>
<div class="mat-typography account-card-title">
<mat-card-title class="layout-row layout-lt-md-column">
<div class="flex-60">
<h3>
@if (!savingsAccountData.subStatus.block) {
<i
class="fa fa-stop"
[ngClass]="savingsAccountData.status.code | statusLookup"
[matTooltip]="savingsAccountData.status.value"
></i>
}
@if (savingsAccountData.subStatus.block) {
<i
class="fa fa-stop"
[ngClass]="savingsAccountData.subStatus.value | statusLookup"
[matTooltip]="savingsAccountData.subStatus.value"
></i>
}
</h3>
<!-- Product Name Row -->
<span class="product-row">
<span class="m-r-5">{{ 'labels.inputs.Savings Product' | translate }} :</span>
<span class="m-r-5">
<mifosx-long-text textValue="{{ savingsAccountData.savingsProductName }}"></mifosx-long-text>
</span>
<div class="sav-hero__info">
<!-- Status pill (account status, or sub-status if blocked) -->
<div class="sav-hero__status-row">
@if (!savingsAccountData.subStatus.block) {
<span
class="sav-status-pill"
[ngClass]="savingsAccountData.status.code | statusLookup"
[matTooltip]="savingsAccountData.status.value"
>
<span class="sav-status-pill__dot"></span>
{{ savingsAccountData.status.value }}
</span>
<!-- Account Number Row -->
<span class="account-number-row">
<span class="m-r-5">{{ 'labels.inputs.Account Number' | translate }} :</span>
<span class="m-r-5">
<mifosx-account-number
accountNo="{{ savingsAccountData.accountNo }}"
display="left"
></mifosx-account-number>
</span>
} @else {
<span
class="sav-status-pill sav-status-pill--warn"
[ngClass]="savingsAccountData.subStatus.value | statusLookup"
[matTooltip]="savingsAccountData.subStatus.value"
>
<span class="sav-status-pill__dot"></span>
{{ savingsAccountData.subStatus.value }}
</span>
<!-- External ID Row -->
@if (savingsAccountData.externalId) {
<span class="external-id-row">
<span class="m-r-5">{{ 'labels.inputs.External Id' | translate }} :</span>
<span>{{ savingsAccountData.externalId }}</span>
</span>
}
<span class="account-overview">
{{ 'labels.text.' + entityType | translate }} {{ 'labels.inputs.name' | translate }}:
}
</div>
<h2 class="sav-hero__title">
<mifosx-long-text textValue="{{ savingsAccountData.savingsProductName }}"></mifosx-long-text>
</h2>
<div class="sav-hero__meta">
<span class="sav-hero__meta-item">
<span class="sav-hero__meta-label">{{ 'labels.inputs.Account Number' | translate }}</span>
<mifosx-account-number
accountNo="{{ savingsAccountData.accountNo }}"
display="left"
></mifosx-account-number>
</span>
<span class="sav-hero__meta-item">
<span class="sav-hero__meta-label">
{{ 'labels.text.' + entityType | translate }} {{ 'labels.inputs.name' | translate }}
</span>
<span class="sav-hero__meta-value">
{{ savingsAccountData.clientName || savingsAccountData.groupName }}
@if (savingsAccountData.clientAccountNo) {
<span class="m-l-10">
<mifosx-account-number accountNo="{{ savingsAccountData.clientAccountNo }}"></mifosx-account-number>
</span>
<mifosx-account-number accountNo="{{ savingsAccountData.clientAccountNo }}"></mifosx-account-number>
}
</span>
</div>
@if (!savingsAccountData.status.rejected && !savingsAccountData.status.submittedAndPendingApproval) {
<div class="flex-30">
<h3>{{ 'labels.heading.Account Overview' | translate }}</h3>
<table class="account-overview">
<tbody>
<tr>
<td>{{ 'labels.inputs.Current Balance' | translate }}</td>
<td class="r-amount">
{{
savingsAccountData.summary.accountBalance | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</td>
</tr>
<tr>
<td>{{ 'labels.inputs.Available Balance' | translate }}</td>
<td class="r-amount">
{{
savingsAccountData.summary.availableBalance
| currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</td>
</tr>
</tbody>
</table>
</div>
}
<div class="flex-5">
<button
mat-icon-button
[matMenuTriggerFor]="accountMenu"
aria-label="Loan account actions"
yPosition="below"
>
<mat-icon matListIcon class="actions-menu">
<fa-icon icon="bars" size="sm"></fa-icon>
</mat-icon>
</button>
</div>
</mat-card-title>
</span>
</div>
</div>
</div>
<mat-menu #accountMenu="matMenu">
@for (item of buttonConfig.singleButtons; track item) {
<button mat-menu-item *mifosxHasPermission="item.taskPermissionName" (click)="doAction(item.name)">
<mat-icon matListIcon>
<fa-icon icon="{{ item.icon }}" size="sm"></fa-icon>
</mat-icon>
<span> {{ 'labels.menus.' + item.name | translate }} </span>
</button>
}
@if (buttonConfig.options && !savingsAccountData.subStatus.block) {
<button mat-menu-item [matMenuTriggerFor]="More">{{ 'labels.menus.More' | translate }}</button>
<mat-menu #More="matMenu">
@for (option of buttonConfig.options; track option) {
<span>
<button mat-menu-item *mifosxHasPermission="option.taskPermissionName" (click)="doAction(option.name)">
{{ 'labels.menus.' + option.name | translate }}
</button>
</span>
}
</mat-menu>
}
</mat-menu>
</mat-card-title-group>
</mat-card-header>
<mat-card-content class="content">
@if (isActive) {
<nav mat-tab-nav-bar class="navigation-tabs" [tabPanel]="tabPanel">
<a
mat-tab-link
[routerLink]="['./general']"
routerLinkActive
#general="routerLinkActive"
[active]="general.isActive"
>
{{ 'labels.inputs.General' | translate }}
</a>
<a
mat-tab-link
[routerLink]="['./transactions']"
routerLinkActive
#transactions="routerLinkActive"
[active]="transactions.isActive"
>
{{ 'labels.inputs.Transactions' | translate }}
</a>
<a
mat-tab-link
[routerLink]="['./passbook']"
routerLinkActive
#passbook="routerLinkActive"
[active]="passbook.isActive"
>
{{ 'labels.inputs.Passbook' | translate }}
</a>
<a
mat-tab-link
[routerLink]="['./charges']"
routerLinkActive
#charges="routerLinkActive"
[active]="charges.isActive"
>
{{ 'labels.inputs.Charges' | translate }}
</a>
<a
mat-tab-link
[routerLink]="['./documents']"
routerLinkActive
#savingsDocuments="routerLinkActive"
[active]="savingsDocuments.isActive"
>
{{ 'labels.inputs.Documents' | translate }}
</a>
<a
mat-tab-link
*mifosxHasPermission="'READ_SAVINGSNOTE'"
[routerLink]="['./notes']"
routerLinkActive
#notes="routerLinkActive"
[active]="notes.isActive"
>
{{ 'labels.inputs.Notes' | translate }}
</a>
<ng-container *mifosxHasPermission="'READ_SAVINGSACCOUNTCHARGE'">
@if (savingsAccountData.clientId) {
<a
mat-tab-link
[routerLink]="['./standing-instructions']"
routerLinkActive
#standingInstructions="routerLinkActive"
[active]="standingInstructions.isActive"
>
{{ 'labels.inputs.Standing Instructions' | translate }}
</a>
}
</ng-container>
<a
mat-tab-link
[routerLink]="['./nominees']"
routerLinkActive
#nominees="routerLinkActive"
[active]="nominees.isActive"
>
Nominees
</a>
@for (savingsDatatable of savingsDatatables; track savingsDatatable) {
<a
mat-tab-link
*mifosxHasPermission="'READ_' + savingsDatatable.registeredTableName"
[routerLink]="['./datatables', savingsDatatable.registeredTableName]"
routerLinkActive
#datatable="routerLinkActive"
[active]="datatable.isActive"
>
{{ savingsDatatable.registeredTableName }}
</a>
}
</nav>
<!-- Balance KPI strip (only for approved / active accounts) -->
@if (!savingsAccountData.status.rejected && !savingsAccountData.status.submittedAndPendingApproval) {
<div class="sav-hero__kpis">
<div class="sav-kpi">
<span class="sav-kpi__label">{{ 'labels.inputs.Current Balance' | translate }}</span>
<span class="sav-kpi__value">
{{ savingsAccountData.summary.accountBalance | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</span>
</div>
<div class="sav-kpi sav-kpi--available">
<span class="sav-kpi__label">{{ 'labels.inputs.Available Balance' | translate }}</span>
<span class="sav-kpi__value">
{{ savingsAccountData.summary.availableBalance | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</span>
</div>
</div>
}
<mat-tab-nav-panel #tabPanel>
<!-- Action menu (kebab) -->
<div class="sav-hero__actions">
<button mat-icon-button [matMenuTriggerFor]="accountMenu" aria-label="Account actions" yPosition="below">
<fa-icon icon="ellipsis-v"></fa-icon>
</button>
</div>
<!-- Primary actions menu (Nexora-styled). The grouped sections come
from `kindOf(name)` in the component — Transact, Restrict (block),
Interest, Admin, Danger. -->
<mat-menu #accountMenu="matMenu" class="sav-action-menu" xPosition="before">
<div class="sav-menu" (click)="$event.stopPropagation()">
<header class="sav-menu__head">
<fa-icon icon="bolt" class="sav-menu__head-ico"></fa-icon>
<div>
<div class="sav-menu__head-title">Account Actions</div>
<div class="sav-menu__head-sub">Manage deposits, blocks, interest &amp; more</div>
</div>
</header>
<!-- Cash movement (Deposit / Withdrawal / Hold Amount) -->
<div class="sav-menu__group">
<div class="sav-menu__group-title">Transact</div>
@for (item of buttonConfig.singleButtons; track item.name) {
@if (kindOf(item.name) === 'transact') {
<button
mat-menu-item
class="sav-menu__item sav-menu__item--transact"
*mifosxHasPermission="item.taskPermissionName"
(click)="doAction(item.name)"
>
<span class="sav-menu__ico">
<fa-icon [icon]="iconOf(item.name, item.icon)"></fa-icon>
</span>
<span class="sav-menu__label">{{ 'labels.menus.' + item.name | translate }}</span>
</button>
}
}
</div>
<!-- Restrictions (Block / Unblock variants) -->
<div class="sav-menu__group">
<div class="sav-menu__group-title">Restrict</div>
@for (item of buttonConfig.singleButtons; track item.name) {
@if (kindOf(item.name) === 'block') {
<button
mat-menu-item
class="sav-menu__item sav-menu__item--block"
*mifosxHasPermission="item.taskPermissionName"
(click)="doAction(item.name)"
>
<span class="sav-menu__ico">
<fa-icon [icon]="iconOf(item.name, item.icon)"></fa-icon>
</span>
<span class="sav-menu__label">{{ 'labels.menus.' + item.name | translate }}</span>
</button>
}
}
</div>
<!-- Interest -->
<div class="sav-menu__group">
<div class="sav-menu__group-title">Interest</div>
@for (item of buttonConfig.singleButtons; track item.name) {
@if (kindOf(item.name) === 'interest') {
<button
mat-menu-item
class="sav-menu__item sav-menu__item--interest"
*mifosxHasPermission="item.taskPermissionName"
(click)="doAction(item.name)"
>
<span class="sav-menu__ico">
<fa-icon [icon]="iconOf(item.name, item.icon)"></fa-icon>
</span>
<span class="sav-menu__label">{{ 'labels.menus.' + item.name | translate }}</span>
</button>
}
}
</div>
<!-- Admin (approval/staff/charges) — drawn from BOTH primary
buttons and the historical "More" options. -->
@if (buttonConfig.options && !savingsAccountData.subStatus.block) {
<div class="sav-menu__group">
<div class="sav-menu__group-title">Admin</div>
@for (item of buttonConfig.singleButtons; track item.name) {
@if (kindOf(item.name) === 'admin') {
<button
mat-menu-item
class="sav-menu__item sav-menu__item--admin"
*mifosxHasPermission="item.taskPermissionName"
(click)="doAction(item.name)"
>
<span class="sav-menu__ico">
<fa-icon [icon]="iconOf(item.name, item.icon)"></fa-icon>
</span>
<span class="sav-menu__label">{{ 'labels.menus.' + item.name | translate }}</span>
</button>
}
}
@for (option of buttonConfig.options; track option.name) {
@if (kindOf(option.name) === 'admin') {
<button
mat-menu-item
class="sav-menu__item sav-menu__item--admin"
*mifosxHasPermission="option.taskPermissionName"
(click)="doAction(option.name)"
>
<span class="sav-menu__ico">
<fa-icon [icon]="iconOf(option.name)"></fa-icon>
</span>
<span class="sav-menu__label">{{ 'labels.menus.' + option.name | translate }}</span>
</button>
}
}
@for (option of buttonConfig.options; track option.name) {
@if (kindOf(option.name) === 'interest') {
<button
mat-menu-item
class="sav-menu__item sav-menu__item--interest"
*mifosxHasPermission="option.taskPermissionName"
(click)="doAction(option.name)"
>
<span class="sav-menu__ico">
<fa-icon [icon]="iconOf(option.name)"></fa-icon>
</span>
<span class="sav-menu__label">{{ 'labels.menus.' + option.name | translate }}</span>
</button>
}
}
@for (option of buttonConfig.options; track option.name) {
@if (kindOf(option.name) === 'transact') {
<button
mat-menu-item
class="sav-menu__item sav-menu__item--transact"
*mifosxHasPermission="option.taskPermissionName"
(click)="doAction(option.name)"
>
<span class="sav-menu__ico">
<fa-icon [icon]="iconOf(option.name)"></fa-icon>
</span>
<span class="sav-menu__label">{{ 'labels.menus.' + option.name | translate }}</span>
</button>
}
}
</div>
}
<!-- Danger (Close / Reject / Delete) -->
@if (buttonConfig.options && !savingsAccountData.subStatus.block) {
<div class="sav-menu__group sav-menu__group--danger">
<div class="sav-menu__group-title">Danger zone</div>
@for (option of buttonConfig.options; track option.name) {
@if (kindOf(option.name) === 'danger') {
<button
mat-menu-item
class="sav-menu__item sav-menu__item--danger"
*mifosxHasPermission="option.taskPermissionName"
(click)="doAction(option.name)"
>
<span class="sav-menu__ico">
<fa-icon [icon]="iconOf(option.name)"></fa-icon>
</span>
<span class="sav-menu__label">{{ 'labels.menus.' + option.name | translate }}</span>
</button>
}
}
</div>
}
</div>
</mat-menu>
</header>
<!-- ─── Pill-style tab navigation ─────────────────────────────────── -->
@if (isActive) {
<nav mat-tab-nav-bar class="sav-tabs" [tabPanel]="tabPanel">
<a
mat-tab-link
class="sav-tab"
[routerLink]="['./general']"
routerLinkActive
#general="routerLinkActive"
[active]="general.isActive"
>
<fa-icon icon="info-circle" class="sav-tab__ic"></fa-icon>
<span>{{ 'labels.inputs.General' | translate }}</span>
</a>
<a
mat-tab-link
class="sav-tab"
[routerLink]="['./transactions']"
routerLinkActive
#transactions="routerLinkActive"
[active]="transactions.isActive"
>
<fa-icon icon="exchange-alt" class="sav-tab__ic"></fa-icon>
<span>{{ 'labels.inputs.Transactions' | translate }}</span>
</a>
<a
mat-tab-link
class="sav-tab"
[routerLink]="['./passbook']"
routerLinkActive
#passbook="routerLinkActive"
[active]="passbook.isActive"
>
<fa-icon icon="book" class="sav-tab__ic"></fa-icon>
<span>{{ 'labels.inputs.Passbook' | translate }}</span>
</a>
<a
mat-tab-link
class="sav-tab"
[routerLink]="['./charges']"
routerLinkActive
#charges="routerLinkActive"
[active]="charges.isActive"
>
<fa-icon icon="receipt" class="sav-tab__ic"></fa-icon>
<span>{{ 'labels.inputs.Charges' | translate }}</span>
</a>
<a
mat-tab-link
class="sav-tab"
[routerLink]="['./documents']"
routerLinkActive
#savingsDocuments="routerLinkActive"
[active]="savingsDocuments.isActive"
>
<fa-icon icon="folder-open" class="sav-tab__ic"></fa-icon>
<span>{{ 'labels.inputs.Documents' | translate }}</span>
</a>
<a
mat-tab-link
class="sav-tab"
*mifosxHasPermission="'READ_SAVINGSNOTE'"
[routerLink]="['./notes']"
routerLinkActive
#notes="routerLinkActive"
[active]="notes.isActive"
>
<fa-icon icon="sticky-note" class="sav-tab__ic"></fa-icon>
<span>{{ 'labels.inputs.Notes' | translate }}</span>
</a>
<ng-container *mifosxHasPermission="'READ_SAVINGSACCOUNTCHARGE'">
@if (savingsAccountData.clientId) {
<a
mat-tab-link
class="sav-tab"
[routerLink]="['./standing-instructions']"
routerLinkActive
#standingInstructions="routerLinkActive"
[active]="standingInstructions.isActive"
>
<fa-icon icon="calendar-check" class="sav-tab__ic"></fa-icon>
<span>{{ 'labels.inputs.Standing Instructions' | translate }}</span>
</a>
}
</ng-container>
<a
mat-tab-link
class="sav-tab"
[routerLink]="['./nominees']"
routerLinkActive
#nominees="routerLinkActive"
[active]="nominees.isActive"
>
<fa-icon icon="user-shield" class="sav-tab__ic"></fa-icon>
<span>Nominees</span>
</a>
@for (savingsDatatable of savingsDatatables; track savingsDatatable) {
<a
mat-tab-link
class="sav-tab"
*mifosxHasPermission="'READ_' + savingsDatatable.registeredTableName"
[routerLink]="['./datatables', savingsDatatable.registeredTableName]"
routerLinkActive
#datatable="routerLinkActive"
[active]="datatable.isActive"
>
<fa-icon icon="table" class="sav-tab__ic"></fa-icon>
<span>{{ savingsDatatable.registeredTableName }}</span>
</a>
}
</nav>
}
<mat-tab-nav-panel #tabPanel>
<div class="sav-body">
<router-outlet></router-outlet>
</mat-tab-nav-panel>
</mat-card-content>
</mat-card>
</div>
</mat-tab-nav-panel>
</div>
@@ -6,12 +6,397 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
mat-card-title {
display: flex;
$navy: #0d1b2a;
$blue: #1e88e5;
$blue-d: #1e40af;
$purple: #7c3aed;
$border: #e2e8f0;
$soft: #f8fafc;
.sav-view {
max-width: 1440px;
margin: 0 auto;
padding: 12px 20px 22px;
}
.product-row,
.account-number-row,
.external-id-row {
display: block;
/* ─── Gradient hero header ──────────────────────────────────────────── */
.sav-hero {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: stretch;
gap: 14px;
padding: 12px 18px;
border-radius: 12px;
color: #fff;
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 45%, #4338ca 100%);
box-shadow: 0 6px 18px -10px rgb(15 23 42 / 35%);
overflow: hidden;
/* Subtle radial flare top-right for depth. */
&::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle at top right, rgb(255 255 255 / 12%) 0%, transparent 55%);
pointer-events: none;
}
&__left {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
position: relative;
z-index: 1;
}
&__avatar {
width: 40px;
height: 40px;
border-radius: 10px;
background: linear-gradient(135deg, rgb(255 255 255 / 22%) 0%, rgb(255 255 255 / 10%) 100%);
border: 1px solid rgb(255 255 255 / 28%);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 16px;
flex-shrink: 0;
box-shadow: 0 3px 10px -3px rgb(0 0 0 / 25%);
}
&__info {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
&__status-row {
display: flex;
align-items: center;
gap: 6px;
}
&__title {
margin: 0;
font-size: 15px;
font-weight: 800;
letter-spacing: 0.01em;
color: #fff;
line-height: 1.15;
::ng-deep mifosx-long-text {
color: #fff;
}
}
&__meta {
display: flex;
flex-wrap: wrap;
gap: 2px 18px;
margin-top: 1px;
}
&__meta-item {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: rgb(255 255 255 / 90%);
}
&__meta-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.07em;
color: rgb(255 255 255 / 60%);
font-weight: 600;
}
&__meta-value {
font-weight: 600;
color: #fff;
}
&__kpis {
display: flex;
gap: 8px;
align-items: stretch;
position: relative;
z-index: 1;
}
&__actions {
display: flex;
align-items: center;
position: relative;
z-index: 1;
button {
width: 32px !important;
height: 32px !important;
min-width: 32px !important;
padding: 0 !important;
border-radius: 50% !important;
color: #fff !important;
background: rgb(255 255 255 / 10%) !important;
border: 1px solid rgb(255 255 255 / 22%) !important;
display: inline-flex !important;
align-items: center;
justify-content: center;
line-height: 1;
transition:
background 0.15s ease,
border-color 0.15s ease,
transform 0.12s ease;
/* Center the FA icon — mat-icon-button can offset child glyphs. */
::ng-deep fa-icon {
font-size: 14px;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
}
/* Strip Material's ripple/state surface that paints a square. */
::ng-deep .mat-mdc-button-persistent-ripple,
::ng-deep .mat-mdc-button-touch-target {
border-radius: 50% !important;
}
}
button:hover {
background: rgb(255 255 255 / 22%) !important;
border-color: rgb(255 255 255 / 38%) !important;
}
button:active {
transform: scale(0.94);
}
}
}
/* Balance KPI tiles inside the hero */
.sav-kpi {
display: flex;
flex-direction: column;
justify-content: center;
min-width: 130px;
padding: 7px 14px;
border-radius: 9px;
background: rgb(255 255 255 / 14%);
border: 1px solid rgb(255 255 255 / 22%);
backdrop-filter: blur(8px);
&__label {
font-size: 9.5px;
text-transform: uppercase;
letter-spacing: 0.07em;
font-weight: 700;
color: rgb(255 255 255 / 75%);
}
&__value {
margin-top: 2px;
font-size: 15px;
font-weight: 800;
color: #fff;
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
}
&--available {
background: rgb(16 185 129 / 22%);
border-color: rgb(16 185 129 / 35%);
}
}
/* Status pill (Active / Pending / Closed / Blocked) */
.sav-status-pill {
display: inline-flex;
align-items: center;
gap: 5px;
height: 18px;
padding: 0 8px;
border-radius: 999px;
background: rgb(16 185 129 / 24%);
border: 1px solid rgb(16 185 129 / 40%);
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #d1fae5;
&__dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: #10b981;
box-shadow: 0 0 0 2px rgb(16 185 129 / 30%);
}
&--warn {
background: rgb(245 158 11 / 24%);
border-color: rgb(245 158 11 / 40%);
color: #fef3c7;
.sav-status-pill__dot {
background: #f59e0b;
box-shadow: 0 0 0 3px rgb(245 158 11 / 30%);
}
}
/* Theme variants driven by `statusLookup` pipe — keeps colour coding
consistent with the rest of the app. */
&.statussubmitted,
&.statuspending {
background: rgb(245 158 11 / 24%);
border-color: rgb(245 158 11 / 40%);
color: #fef3c7;
.sav-status-pill__dot {
background: #f59e0b;
}
}
&.statusclosed,
&.statusrejected,
&.statuswithdrawn {
background: rgb(239 68 68 / 24%);
border-color: rgb(239 68 68 / 40%);
color: #fee2e2;
.sav-status-pill__dot {
background: #ef4444;
}
}
}
/* ─── Pill-style tab navigation ─────────────────────────────────────── */
.sav-tabs {
margin-top: 18px;
background: #fff;
border: 1px solid $border;
border-radius: 14px;
padding: 6px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
overflow: hidden;
::ng-deep {
.mat-mdc-tab-link-container {
border-bottom: none !important;
}
.mat-mdc-tab-links {
gap: 4px;
flex-wrap: wrap;
}
.mdc-tab-indicator__content--underline,
.mat-mdc-tab-header-pagination-controls-enabled .mdc-tab-indicator__content--underline {
display: none !important;
}
}
}
.sav-tab {
display: inline-flex !important;
align-items: center;
gap: 8px;
height: 38px !important;
min-width: 0 !important;
padding: 0 16px !important;
border-radius: 10px !important;
font-family: Inter, 'DM Sans', Roboto, sans-serif !important;
font-size: 13px !important;
font-weight: 600 !important;
letter-spacing: 0.01em !important;
color: #64748b !important;
background: transparent !important;
transition: all 0.15s ease;
white-space: nowrap;
border: 1px solid transparent !important;
opacity: 1 !important;
&__ic {
font-size: 12px;
color: inherit;
}
&:hover:not(.mdc-tab--active) {
background: #f1f5f9 !important;
color: $navy !important;
}
&.mdc-tab--active,
&.mat-mdc-tab-link-active {
background: linear-gradient(135deg, $blue 0%, $blue-d 100%) !important;
color: #fff !important;
box-shadow: 0 2px 6px -1px rgb(30 64 175 / 35%);
border-color: $blue-d !important;
::ng-deep .mdc-tab__text-label,
::ng-deep .mat-mdc-tab-link-content,
::ng-deep span,
.sav-tab__ic {
color: #fff !important;
opacity: 1 !important;
}
}
}
.sav-body {
margin-top: 16px;
}
/* ─── Responsive ────────────────────────────────────────────────────── */
@media (width <= 1100px) {
.sav-hero {
grid-template-columns: 1fr;
&__kpis {
width: 100%;
flex-wrap: wrap;
}
&__actions {
position: absolute;
top: 18px;
right: 18px;
}
}
.sav-kpi {
flex: 1 1 auto;
min-width: 0;
}
}
@media (width <= 700px) {
.sav-view {
padding: 12px;
}
.sav-hero {
padding: 18px;
&__title {
font-size: 18px;
}
&__avatar {
width: 52px;
height: 52px;
font-size: 22px;
border-radius: 12px;
}
}
.sav-tab {
padding: 0 12px !important;
font-size: 12px !important;
}
}
@@ -13,6 +13,7 @@ import { MatDialog } from '@angular/material/dialog';
/** Custom Dialogs */
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
import { SuccessDialogComponent } from 'app/shared/success-dialog/success-dialog.component';
import { CalculateInterestDialogComponent } from './custom-dialogs/calculate-interest-dialog/calculate-interest-dialog.component';
import { PostInterestDialogComponent } from './custom-dialogs/post-interest-dialog/post-interest-dialog.component';
import { ToggleWithholdTaxDialogComponent } from './custom-dialogs/toggle-withhold-tax-dialog/toggle-withhold-tax-dialog.component';
@@ -26,14 +27,6 @@ import { TranslateService } from '@ngx-translate/core';
/** Environment Configuration */
import { environment } from '../../../environments/environment';
import {
MatCard,
MatCardHeader,
MatCardTitleGroup,
MatCardMdImage,
MatCardTitle,
MatCardContent
} from '@angular/material/card';
import { MatTooltip } from '@angular/material/tooltip';
import { NgClass, CurrencyPipe } from '@angular/common';
import { LongTextComponent } from '../../shared/long-text/long-text.component';
@@ -48,6 +41,9 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Savings Account View Component
*
* Nexora redesign: gradient hero card with balance KPIs + pill-style tab
* navigation. No more `mat-card` chrome — pure custom layout.
*/
@Component({
selector: 'mifosx-savings-account-view',
@@ -55,11 +51,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
styleUrls: ['./savings-account-view.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatCardHeader,
MatCardTitleGroup,
MatCardMdImage,
MatTooltip,
MatCardTitle,
NgClass,
LongTextComponent,
AccountNumberComponent,
@@ -97,6 +89,87 @@ export class SavingsAccountViewComponent implements OnInit {
isActive = false;
currency: Currency;
/**
* Categorisation map used by the action menu — every action name is
* bucketed into one of: 'transact' (cash movement), 'block' (restrict
* access), 'interest' (interest postings), 'admin' (approvals, charges,
* staff), 'danger' (close / reject / delete). Drives the section split
* and the colour of each icon.
*/
private readonly ACTION_KIND: Record<string, 'transact' | 'block' | 'interest' | 'admin' | 'danger'> = {
Deposit: 'transact',
Withdrawal: 'transact',
'Hold Amount': 'transact',
'Transfer Funds': 'transact',
'Interbank Transfer': 'transact',
'Block Deposit': 'block',
'Unblock Deposit': 'block',
'Block Withdrawal': 'block',
'Unblock Withdrawal': 'block',
'Block Account': 'block',
'Unblock Account': 'block',
'Calculate Interest': 'interest',
'Post Interest': 'interest',
'Post Interest As On': 'interest',
'Enable Withhold Tax': 'interest',
'Disable Withhold Tax': 'interest',
'Add Charge': 'admin',
'Apply Annual Fees': 'admin',
'Modify Application': 'admin',
'Assign Staff': 'admin',
'Unassign Staff': 'admin',
Approve: 'admin',
Activate: 'admin',
'Undo Approval': 'admin',
Close: 'danger',
Reject: 'danger',
Delete: 'danger',
'Withdrawn by Client': 'danger'
};
/** Lightweight icon fallback for actions whose buttonConfig doesn't
* carry an icon (the `options` sub-menu items historically didn't). */
private readonly ACTION_ICON: Record<string, string> = {
Deposit: 'arrow-down',
Withdrawal: 'arrow-up',
'Hold Amount': 'pause',
'Transfer Funds': 'exchange-alt',
'Interbank Transfer': 'random',
'Block Deposit': 'lock',
'Unblock Deposit': 'lock-open',
'Block Withdrawal': 'lock',
'Unblock Withdrawal': 'lock-open',
'Block Account': 'lock',
'Unblock Account': 'lock-open',
'Calculate Interest': 'calculator',
'Post Interest': 'percentage',
'Post Interest As On': 'calendar-day',
'Enable Withhold Tax': 'percentage',
'Disable Withhold Tax': 'percentage',
'Add Charge': 'plus-circle',
'Apply Annual Fees': 'receipt',
'Modify Application': 'edit',
'Assign Staff': 'user-plus',
'Unassign Staff': 'user-slash',
Approve: 'check-circle',
Activate: 'check',
'Undo Approval': 'undo',
Close: 'times-circle',
Reject: 'ban',
Delete: 'trash',
'Withdrawn by Client': 'user-slash'
};
/** Returns the colour kind for a given action name (used to tint the
* icon and hover state in the dropdown). */
kindOf(name: string): 'transact' | 'block' | 'interest' | 'admin' | 'danger' {
return this.ACTION_KIND[name] ?? 'admin';
}
iconOf(name: string, fallback?: string): string {
return this.ACTION_ICON[name] ?? fallback ?? 'circle';
}
/**
* Fetches savings account data from `resolve`
* @param {ActivatedRoute} route Activated Route
@@ -373,9 +446,33 @@ export class SavingsAccountViewComponent implements OnInit {
unblockSavingsAccountDialogRef.afterClosed().subscribe((response: { confirm: any }) => {
if (response.confirm) {
this.savingsService.executeSavingsAccountCommand(this.savingsAccountData.id, command, {}).subscribe(() => {
this.reload();
this.showUnblockSuccess(action);
});
}
});
}
/**
* Pops the shared success dialog with copy tuned to the unblock variant
* the cashier just performed, then reloads the savings view so the
* account substatus shows as unblocked.
*/
private showUnblockSuccess(action: string): void {
let title = 'Account Unblocked Successfully';
let message = 'Account activity has been restored.';
if (action === 'Unblock Deposit') {
title = 'Deposits Unblocked Successfully';
message = 'Incoming credits to this savings account are accepted again.';
} else if (action === 'Unblock Withdrawal') {
title = 'Withdrawals Unblocked Successfully';
message = 'Outgoing debits from this savings account are accepted again.';
}
const ref = this.dialog.open(SuccessDialogComponent, {
width: '420px',
maxWidth: '92vw',
autoFocus: false,
data: { title, message, buttonLabel: 'Done' }
});
ref.afterClosed().subscribe(() => this.reload());
}
}
@@ -6,23 +6,22 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container">
<div class="mat-elevation-z1 m-b-25" [hidden]="!accountWithTransactions">
<div class="layout-row align-end">
@if (checkStatus()) {
<div class="action-button m-b-20 layout-row gap-20px">
<mat-checkbox [formControl]="hideReversedParam" (click)="hideReversed()" class="accruals">
{{ 'labels.inputs.Hide Reversed' | translate }}</mat-checkbox
>
<mat-checkbox [formControl]="hideAccrualsParam" (click)="hideAccruals()" class="accruals">
{{ 'labels.inputs.Hide Accruals' | translate }}</mat-checkbox
>
<button mat-raised-button color="primary" [routerLink]="['export']">
{{ 'labels.buttons.Export' | translate }}
</button>
</div>
}
</div>
<div class="txn-tab">
<div class="txn-tab__card" [hidden]="!accountWithTransactions">
@if (checkStatus()) {
<div class="txn-tab__toolbar">
<mat-checkbox [formControl]="hideReversedParam" (click)="hideReversed()" class="txn-tab__check">
{{ 'labels.inputs.Hide Reversed' | translate }}
</mat-checkbox>
<mat-checkbox [formControl]="hideAccrualsParam" (click)="hideAccruals()" class="txn-tab__check">
{{ 'labels.inputs.Hide Accruals' | translate }}
</mat-checkbox>
<span class="txn-tab__spacer"></span>
<button mat-flat-button class="txn-tab__export" [routerLink]="['export']">
{{ 'labels.buttons.Export' | translate }}
</button>
</div>
}
<table mat-table [dataSource]="dataSource" matSort>
<ng-container matColumnDef="row">
@@ -59,13 +58,6 @@
</td>
</ng-container>
<ng-container matColumnDef="externalId">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.External Id' | translate }}</th>
<td mat-cell *matCellDef="let transaction" [ngClass]="savingsTransactionColor(transaction)">
<mifosx-external-identifier externalId="{{ transaction.externalId }}"></mifosx-external-identifier>
</td>
</ng-container>
<ng-container matColumnDef="transactionType">
<th mat-header-cell class="center" *matHeaderCellDef>{{ 'labels.inputs.Transaction Type' | translate }}</th>
<td
@@ -176,10 +168,11 @@
<mat-paginator [pageSize]="50" [pageSizeOptions]="[50, 100, 200]" showFirstLastButtons></mat-paginator>
</div>
<div class="alert" [hidden]="accountWithTransactions">
<div class="message">
<i class="fa fa-exclamation-circle alert-check"></i>
<div class="txn-tab__empty" [hidden]="accountWithTransactions">
<fa-icon icon="inbox" class="txn-tab__empty-ico"></fa-icon>
<div class="txn-tab__empty-title">
{{ 'labels.text.No transaction was found' | translate }}
</div>
<div class="txn-tab__empty-sub">Posted transactions will appear here.</div>
</div>
</div>
@@ -6,37 +6,153 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
@use 'assets/styles/helper';
$navy: #0d1b2a;
$slate: #475569;
$muted: #64748b;
$border: #e2e8f0;
$soft: #f8fafc;
$blue: #1e40af;
.tab-container {
padding: 1%;
margin: 1%;
.txn-tab {
/* Wrapper hero (savings-account-view) already provides the page padding;
this tab sits flush so the table can use the full width. */
width: 100%;
}
h3 {
margin: 1% auto;
.txn-tab__card {
background: #fff;
border: 1px solid $border;
border-radius: 14px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
overflow: hidden;
}
/* ─── Toolbar ─────────────────────────────────────────────────────── */
.txn-tab__toolbar {
display: flex;
align-items: center;
gap: 18px;
padding: 12px 18px;
background: $soft;
border-bottom: 1px solid $border;
flex-wrap: wrap;
}
.txn-tab__check {
font-size: 13px;
color: $slate;
font-weight: 500;
}
.txn-tab__spacer {
flex: 1;
}
.txn-tab__export {
height: 36px !important;
border-radius: 8px !important;
font-weight: 600 !important;
font-size: 13px !important;
padding: 0 16px !important;
background: linear-gradient(135deg, #1e88e5 0%, $blue 100%) !important;
color: #fff !important;
}
/* ─── Table ───────────────────────────────────────────────────────── */
table {
width: 100%;
}
::ng-deep .txn-tab__card {
.mat-mdc-header-row {
background: $soft;
}
.action-button {
margin-left: auto;
.mat-mdc-header-cell {
font-size: 11.5px;
font-weight: 800;
color: $slate;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 12px 14px;
border-bottom: 1px solid $border;
}
table {
width: 100%;
.mat-mdc-cell {
font-size: 13px;
color: $navy;
padding: 11px 14px;
border-bottom: 1px solid #f1f5f9;
}
.account-action-button {
min-width: 26px;
padding: 0 0 3px;
margin: 0 10%;
line-height: 25px;
.mat-mdc-row:hover {
background: #f8fafc;
cursor: pointer;
}
.accruals {
padding-top: 1%;
}
}
/* Tabular numerics on the money columns (Debit / Credit / Balance) +
right-align them. */
.mat-column-debit,
.mat-column-credit,
.mat-column-balance {
text-align: right;
font-variant-numeric: tabular-nums;
}
.mat-column-actions {
text-align: center;
width: 80px;
}
/* Compact paginator */
.mat-mdc-paginator {
border-top: 1px solid $border;
background: $soft;
min-height: 48px;
}
/* Row tint variants — strike-through reversed, dim accrual, blue transfer */
.strike td {
text-decoration: line-through;
color: $muted;
}
.accrual td {
color: $muted;
font-style: italic;
}
.transfer td {
color: #6d28d9;
}
}
.select-row:hover {
cursor: pointer;
font-weight: 800;
/* ─── Empty state ─────────────────────────────────────────────────── */
.txn-tab__empty {
background: #fff;
border: 1px solid $border;
border-radius: 14px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
padding: 56px 24px;
text-align: center;
color: $muted;
}
.txn-tab__empty-ico {
font-size: 34px;
color: #cbd5e1;
margin-bottom: 8px;
}
.txn-tab__empty-title {
font-size: 14.5px;
font-weight: 700;
color: $slate;
margin-top: 6px;
}
.txn-tab__empty-sub {
font-size: 12.5px;
color: $muted;
margin-top: 2px;
}
@@ -38,7 +38,6 @@ import { MatDialog } from '@angular/material/dialog';
import { Currency } from 'app/shared/models/general.model';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatButton, MatIconButton } from '@angular/material/button';
import { ExternalIdentifierComponent } from '../../../shared/external-identifier/external-identifier.component';
import { MatMenuTrigger, MatMenu, MatMenuItem } from '@angular/material/menu';
import { MatIcon } from '@angular/material/icon';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
@@ -64,7 +63,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatCellDef,
MatCell,
NgClass,
ExternalIdentifierComponent,
MatIconButton,
MatMenuTrigger,
MatIcon,
@@ -102,7 +100,6 @@ export class TransactionsTabComponent implements OnInit {
'row',
'id',
'date',
'externalId',
'transactionType',
'debit',
'credit',
@@ -6,123 +6,133 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container transaction-buttons m-b-20 align-end-center gap-2percent" align="end">
@if (
!(
transactionData.interestedPostedAsOn === false &&
(transactionData.transactionType.id === 17 || transactionData.transactionType.id === 3)
)
) {
<span>
<span *mifosxHasPermission="'ADJUSTTRANSACTION_SAVINGSACCOUNT'">
@if (
transactionData.transactionType.value === 'Transfer' ||
transactionData.reversed === 'true' ||
transactionData.transactionType.id === 3 ||
transactionData.transactionType.id === 17
) {
<button mat-raised-button color="primary" [routerLink]="'edit'">
<fa-icon icon="edit" class="m-r-10"></fa-icon>{{ 'labels.buttons.Edit' | translate }}
</button>
}
<div class="txn">
<!-- ─── Compact toolbar strip ─────────────────────────────────────── -->
<div class="txn__toolbar">
<button mat-stroked-button class="txn__back" type="button" [routerLink]="['../../']" matTooltip="Back">
<fa-icon icon="arrow-left"></fa-icon>
</button>
<div class="txn__title-block">
<span class="txn__id">Transaction #{{ transactionData.id }}</span>
<span class="txn__type">{{ transactionData.transactionType.value | translateKey: 'catalogs' }}</span>
</div>
@if (transactionData.reversed) {
<span class="txn__pill txn__pill--reversed"><span class="txn__pill-dot"></span> Reversed</span>
} @else if (transactionData.transactionType.accrual) {
<span class="txn__pill txn__pill--accrual"><span class="txn__pill-dot"></span> Accrual</span>
} @else if (transactionData.transfer) {
<span class="txn__pill txn__pill--transfer"><span class="txn__pill-dot"></span> Transfer</span>
} @else {
<span class="txn__pill"><span class="txn__pill-dot"></span> Posted</span>
}
<div class="txn__amount" [class.txn__amount--debit]="isDebit()">
<span class="txn__amount-label">{{ isDebit() ? 'Debit' : 'Credit' }}</span>
<span class="txn__amount-value">
{{ isDebit() ? '−' : '+' }}
{{ transactionData.amount | currency: transactionData.currency.code : 'symbol-narrow' : '1.2-2' }}
</span>
</span>
}
@if (!transactionData.reversed && transactionData.transactionType.amountHold) {
<span>
<button
mat-raised-button
color="primary"
*mifosxHasPermission="'RELEASEAMOUNT_SAVINGSACCOUNT'"
(click)="releaseAmount()"
>
<fa-icon icon="lock-open" class="m-r-10"></fa-icon>{{ 'labels.buttons.Release Amount' | translate }}
</button>
</span>
}
@if (allowUndo()) {
<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>
</span>
}
</div>
</div>
<div class="container">
<mat-card>
<mat-card-content>
<div class="layout-row-wrap card-content">
<div class="flex-100" [ngClass]="transactionColor()"></div>
<div class="flex-50 mat-body-strong">
{{ 'labels.inputs.Id' | translate }}
</div>
<div class="flex-50">
{{ transactionData.id }}
</div>
<div class="flex-50 mat-body-strong">
{{ 'labels.inputs.Transaction Type' | translate }}
</div>
<div class="flex-50">
{{ transactionData.transactionType.value | translateKey: 'catalogs' }}
</div>
<div class="flex-50 mat-body-strong">
{{ 'labels.inputs.Transaction Date' | translate }}
</div>
<div class="flex-50">
{{ transactionData.date | dateFormat }}
</div>
<div class="flex-50 mat-body-strong">
{{ 'labels.inputs.Currency' | translate }}
</div>
<div class="flex-50">
{{ transactionData.currency.displayLabel }}
</div>
<div class="flex-50 mat-body-strong">
{{ 'labels.inputs.Amount' | translate }}
</div>
<div class="flex-50">
{{ transactionData.amount | currency: transactionData.currency.code : 'symbol-narrow' : '1.2-2' }}
</div>
@if (transactionData.note) {
<div class="flex-50 mat-body-strong">
{{ 'labels.inputs.Note' | translate }}
</div>
}
@if (transactionData.note) {
<div class="flex-50">
{{ transactionData.note }}
</div>
}
@if (transactionData.paymentDetailData) {
<mifosx-transaction-payment-detail [paymentDetailData]="transactionData.paymentDetailData">
</mifosx-transaction-payment-detail>
}
</div>
<div class="layout-row layout-align-center gap-2percent column-on-mobile">
<button type="button" mat-raised-button color="primary" [routerLink]="['../../']">
{{ 'labels.buttons.Back' | translate }}
<div class="txn__actions">
@if (
!(
transactionData.interestedPostedAsOn === false &&
(transactionData.transactionType.id === 17 || transactionData.transactionType.id === 3)
)
) {
<span *mifosxHasPermission="'ADJUSTTRANSACTION_SAVINGSACCOUNT'">
@if (
transactionData.transactionType.value === 'Transfer' ||
transactionData.reversed === 'true' ||
transactionData.transactionType.id === 3 ||
transactionData.transactionType.id === 17
) {
<button mat-stroked-button class="txn__btn" [routerLink]="'edit'">
<fa-icon icon="edit" class="m-r-6"></fa-icon>{{ 'labels.buttons.Edit' | translate }}
</button>
}
</span>
}
@if (!transactionData.reversed && transactionData.transactionType.amountHold) {
<button
mat-stroked-button
class="txn__btn"
*mifosxHasPermission="'RELEASEAMOUNT_SAVINGSACCOUNT'"
(click)="releaseAmount()"
>
<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>
<!-- ─── Details panel ─────────────────────────────────────────────── -->
<section class="txn__panel">
<div class="txn__panel-grid">
<div class="txn__cell">
<span class="txn__cell-label">{{ 'labels.inputs.Id' | translate }}</span>
<span class="txn__cell-value txn__cell-value--mono">{{ transactionData.id }}</span>
</div>
</mat-card-content>
</mat-card>
@if (accountNo) {
<div class="txn__cell">
<span class="txn__cell-label">Account Number</span>
<span class="txn__cell-value txn__cell-value--mono">{{ accountNo }}</span>
</div>
}
@if (clientName) {
<div class="txn__cell">
<span class="txn__cell-label">Client Name</span>
<span class="txn__cell-value">{{ clientName }}</span>
</div>
}
<div class="txn__cell">
<span class="txn__cell-label">{{ 'labels.inputs.Transaction Type' | translate }}</span>
<span class="txn__cell-value">{{ transactionData.transactionType.value | translateKey: 'catalogs' }}</span>
</div>
<div class="txn__cell">
<span class="txn__cell-label">{{ 'labels.inputs.Transaction Date' | translate }}</span>
<span class="txn__cell-value">{{ transactionData.date | dateFormat }}</span>
</div>
<div class="txn__cell">
<span class="txn__cell-label">{{ 'labels.inputs.Currency' | translate }}</span>
<span class="txn__cell-value">{{ transactionData.currency.displayLabel }}</span>
</div>
<div class="txn__cell">
<span class="txn__cell-label">{{ 'labels.inputs.Amount' | translate }}</span>
<span
class="txn__cell-value txn__cell-value--amount"
[class.txn__cell-value--negative]="isDebit()"
[class.txn__cell-value--positive]="!isDebit()"
>
{{ transactionData.amount | currency: transactionData.currency.code : 'symbol-narrow' : '1.2-2' }}
</span>
</div>
@if (transactionData.note) {
<div class="txn__cell txn__cell--wide">
<span class="txn__cell-label">{{ 'labels.inputs.Note' | translate }}</span>
<span class="txn__cell-value">{{ transactionData.note }}</span>
</div>
}
</div>
@if (transactionData.paymentDetailData) {
<div class="txn__payment-detail">
<mifosx-transaction-payment-detail [paymentDetailData]="transactionData.paymentDetailData">
</mifosx-transaction-payment-detail>
</div>
}
</section>
</div>
@@ -6,40 +6,303 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
@use 'assets/styles/helper';
@use 'assets/styles/colours' as *;
/* Compact, banking-grade transaction view: single toolbar strip on top,
single wide grid panel below. Designed to fit on one screen on a normal
desktop without scrolling, no big gradient hero, no centered narrow
column — the layout uses full available width. */
.container {
max-width: 37rem;
$navy: #0d1b2a;
$slate: #475569;
$muted: #64748b;
$border: #e2e8f0;
$soft: #f8fafc;
$blue: #1e40af;
$green: #047857;
$red: #b91c1c;
$amber: #b45309;
$purple: #6d28d9;
.card-content {
div {
margin: 1rem 0;
word-wrap: break-word;
.txn {
width: 100%;
padding: 12px 16px 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
/* ─── Toolbar (back · title · status · amount · actions) ──────────── */
.txn__toolbar {
display: flex;
align-items: center;
gap: 14px;
background: #fff;
border: 1px solid $border;
border-radius: 12px;
padding: 10px 14px;
box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
flex-wrap: wrap;
}
.txn__back {
width: 36px;
height: 36px;
min-width: 36px !important;
padding: 0 !important;
border-radius: 8px !important;
color: $slate !important;
border-color: $border !important;
display: inline-flex !important;
align-items: center;
justify-content: center;
}
.txn__title-block {
display: flex;
flex-direction: column;
min-width: 0;
flex: 0 1 auto;
}
.txn__id {
font-size: 15px;
font-weight: 800;
color: $navy;
line-height: 1.1;
letter-spacing: 0.01em;
}
.txn__type {
margin-top: 2px;
font-size: 11px;
color: $muted;
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 700;
}
.txn__pill {
display: inline-flex;
align-items: center;
gap: 6px;
height: 22px;
padding: 0 9px;
border-radius: 999px;
background: #ecfdf5;
color: $green;
border: 1px solid #a7f3d0;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
&-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: $green;
}
&--reversed {
background: #fef2f2;
color: $red;
border-color: #fecaca;
.txn__pill-dot {
background: $red;
}
}
mifosx-transaction-payment-detail {
width: 100%;
margin-bottom: 20px;
&--accrual {
background: #fffbeb;
color: $amber;
border-color: #fde68a;
.txn__pill-dot {
background: $amber;
}
}
&--transfer {
background: #f5f3ff;
color: $purple;
border-color: #ddd6fe;
.txn__pill-dot {
background: $purple;
}
}
}
.transaction-buttons {
margin-top: 3%;
/* Amount mini-tile inside the toolbar — Credit (green) / Debit (red). */
.txn__amount {
display: inline-flex;
flex-direction: column;
align-items: flex-end;
margin-left: auto;
padding: 6px 14px;
border-radius: 10px;
background: #ecfdf5;
border: 1px solid #a7f3d0;
&--debit {
background: #fef2f2;
border-color: #fecaca;
.txn__amount-value {
color: $red;
}
.txn__amount-label {
color: $red;
}
}
}
.active {
background-color: $status-active;
height: 4px;
.txn__amount-label {
font-size: 10px;
font-weight: 700;
color: $green;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.linked {
background-color: $status-approved;
height: 4px;
.txn__amount-value {
font-size: 17px;
font-weight: 800;
color: $green;
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
}
.undo {
background-color: $status-active-overdue;
height: 4px;
.txn__actions {
display: inline-flex;
align-items: center;
gap: 8px;
.txn__btn {
height: 36px !important;
border-radius: 8px !important;
font-weight: 600 !important;
font-size: 13px !important;
padding: 0 14px !important;
letter-spacing: 0.01em;
}
.txn__btn--undo {
background: $red !important;
color: #fff !important;
&:hover {
background: #991b1b !important;
}
}
}
/* ─── Wide details panel ──────────────────────────────────────────── */
.txn__panel {
background: #fff;
border: 1px solid $border;
border-radius: 12px;
box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
padding: 16px 18px;
}
/* Multi-column grid — each cell is one label/value pair. Uses the full
panel width so wide screens get more columns side-by-side. */
.txn__panel-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 4px 24px;
}
.txn__cell {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
padding: 10px 0;
border-bottom: 1px dashed #eef2f7;
font-size: 13px;
min-width: 0;
&--wide {
grid-column: 1 / -1;
}
}
.txn__cell-label {
color: $slate;
font-weight: 500;
flex-shrink: 0;
}
.txn__cell-value {
color: $navy;
font-weight: 600;
text-align: right;
word-break: break-word;
min-width: 0;
&--mono {
font-family: 'JetBrains Mono', 'Courier New', monospace;
font-size: 12.5px;
letter-spacing: 0.02em;
}
&--amount {
font-variant-numeric: tabular-nums;
font-weight: 800;
}
&--positive {
color: $green;
}
&--negative {
color: $red;
}
}
.txn__payment-detail {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #f1f5f9;
::ng-deep mat-card,
::ng-deep .mat-mdc-card {
border: none !important;
box-shadow: none !important;
padding: 0 !important;
background: transparent !important;
}
}
.m-r-6 {
margin-right: 6px;
}
/* ─── Responsive ──────────────────────────────────────────────────── */
@media (width <= 900px) {
.txn__toolbar {
row-gap: 10px;
}
.txn__amount {
margin-left: 0;
}
}
@media (width <= 600px) {
.txn__title-block {
flex: 1 1 auto;
}
.txn__actions {
width: 100%;
justify-content: flex-end;
.txn__btn {
flex: 1 1 auto;
}
}
}
@@ -45,11 +45,36 @@ export class SavingsTransactionGeneralTabComponent {
transactionId: string;
transactionData: any;
/** Parent savings account context — fetched on init so we can show the
* account number and client/group name in the hero. */
accountNo: string | null = null;
clientName: string | null = null;
constructor() {
this.route.data.subscribe((data: { savingsAccountTransaction: any }) => {
this.accountId = this.route.parent.snapshot.params['savingAccountId'];
this.transactionData = data.savingsAccountTransaction;
});
if (this.accountId) {
this.savingsService.getSavingsAccountData(this.accountId).subscribe({
next: (account: any) => {
this.accountNo = account?.accountNo || null;
this.clientName = account?.clientName || account?.groupName || null;
},
error: () => {
this.accountNo = null;
this.clientName = null;
}
});
}
}
/** Returns true for transactions that move money out (withdrawal, fees,
* overdraft interest, withhold-tax). Used to colour the Amount chip. */
isDebit(): boolean {
const t = this.transactionData?.transactionType;
if (!t) return false;
return t.withdrawal === true || t.feeDeduction === true || t.overdraftInterest === true || t.withholdTax === true;
}
allowUndo(): boolean {