feat(dds): savings-style DDS account view, batch processing redesign, slab master in Products

- DDS account view: Transactions and Passbook tabs laid out like savings, a per-line
  View Transaction popup (general, payment details, accounting and audit, receipt print)
  with actions menu (view, receipt, journal entries)
- DDS batch processing: job tiles with calculate then post pairs, compact agent and party
  ranges, agent-wise preview with a View popup of each agent's accounts, search, and CSV
  export of all details or one agent; previous runs summary; OD interest hidden
- Discard removes the generated preview on the server
- Commission Slab Master moved from Organization to Products, with a how-to card, rates
  and commission at "To", gap warnings to the paisa, loading state and a calculator popup
- Party edit: scheme terms and agent locked on an active account, with a lock badge

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 17:13:33 +05:30
co-authored by Claude Sonnet 5.5
parent 53bb97d6ed
commit 306af59099
22 changed files with 4581 additions and 1264 deletions
File diff suppressed because it is too large Load Diff
@@ -157,45 +157,89 @@
}
}
/* ── Tabs ─────────────────────────────────────────────────────────────── */
/* ── Tabs: the savings account page's pill tabs ───────────────────────── */
$tab-navy: #0d1b2a;
$tab-blue: #1e88e5;
$tab-blue-d: #1e40af;
$tab-border: #e2e8f0;
.dav-tabs {
display: flex;
gap: 8px;
padding: 8px;
border-radius: 14px;
background: #fff;
border: 1px solid rgb(0 0 0 / 7%);
border: 1px solid $tab-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;
}
}
}
.dav-tab {
flex: 1;
display: flex;
display: inline-flex !important;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 16px;
border: 0;
border-radius: 10px;
background: transparent;
font-size: 0.95rem;
font-weight: 600;
color: #475569;
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;
cursor: pointer;
&:hover {
background: #f1f5f9;
&__ic {
font-size: 12px;
color: inherit;
}
&:hover:not(.mdc-tab--active) {
background: #f1f5f9 !important;
color: $tab-navy !important;
}
&.mdc-tab--active,
&.mat-mdc-tab-link-active {
background: linear-gradient(135deg, $tab-blue 0%, $tab-blue-d 100%) !important;
color: #fff !important;
box-shadow: 0 2px 6px -1px rgb(30 64 175 / 35%);
border-color: $tab-blue-d !important;
::ng-deep .mdc-tab__text-label,
::ng-deep .mat-mdc-tab-link-content,
::ng-deep span,
.dav-tab__ic {
color: #fff !important;
opacity: 1 !important;
}
}
}
.dav-tab--on {
color: #fff;
background: linear-gradient(90deg, #1d4ed8, #2563eb);
box-shadow: 0 4px 12px rgb(37 99 235 / 30%);
&:hover {
background: linear-gradient(90deg, #1d4ed8, #2563eb);
}
.dav-body {
display: flex;
flex-direction: column;
gap: 16px;
}
/* ── Cards ────────────────────────────────────────────────────────────── */
@@ -269,38 +313,7 @@
color: rgb(0 0 0 / 50%);
}
/* ── Transactions ─────────────────────────────────────────────────────── */
.dav-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
padding: 14px 20px;
background: #f8fafc;
border-bottom: 1px solid rgb(0 0 0 / 7%);
}
.dav-datepick {
display: flex;
flex-direction: column;
gap: 2px;
span {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: rgb(0 0 0 / 45%);
}
input {
padding: 6px 10px;
border-radius: 8px;
border: 1px solid rgb(0 0 0 / 15%);
font: inherit;
}
}
/* ── Shared tables and buttons (nominees, not-found state) ─────────── */
.dav-btn {
display: inline-flex;
@@ -319,21 +332,6 @@
}
}
.dav-btn--primary {
color: #fff;
border-color: transparent;
background: linear-gradient(90deg, #1d4ed8, #2563eb);
}
.dav-totals {
margin-left: auto;
display: flex;
gap: 16px;
font-size: 0.88rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.dav-table-wrap {
overflow-x: auto;
}
@@ -366,11 +364,6 @@
}
}
.dav-desc {
max-width: 320px;
color: rgb(0 0 0 / 65%);
}
@media (width <= 720px) {
.dav-page {
padding: 16px;
@@ -382,66 +375,11 @@
}
.dav-tab {
font-size: 0.85rem;
padding: 8px;
padding: 0 12px !important;
font-size: 12px !important;
}
}
/* ── Passbook ─────────────────────────────────────────────────────────── */
.dav-push {
margin-left: auto;
}
.dav-passbook {
padding: 20px;
}
.dav-pb-head {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px 32px;
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 2px solid #1e3a8a;
}
.dav-pb-title {
font-size: 1.2rem;
font-weight: 700;
color: #1e3a8a;
}
.dav-pb-sub {
font-size: 0.85rem;
color: rgb(0 0 0 / 55%);
}
.dav-pb-grid {
display: grid;
grid-template-columns: auto auto;
gap: 4px 16px;
font-size: 0.88rem;
span {
color: rgb(0 0 0 / 55%);
}
}
.dav-pb-table {
tfoot td {
padding: 14px 16px;
border-top: 2px solid rgb(0 0 0 / 15%);
}
}
.dav-pb-note {
display: block;
font-size: 0.78rem;
color: rgb(0 0 0 / 50%);
}
/* Closure tab */
.dav-closure-tag {
margin-left: 8px;
@@ -457,3 +395,629 @@
border-top: 1px solid rgb(0 0 0 / 10%);
font-weight: 600;
}
/* ── Transactions: the savings account's Transactions tab ─────────────── */
$txn-navy: #0d1b2a;
$txn-slate: #475569;
$txn-muted: #64748b;
$txn-border: #e2e8f0;
$txn-soft: #f8fafc;
$txn-blue: #1e40af;
.txn-tab {
width: 100%;
table {
width: 100%;
}
}
.txn-tab__card {
background: #fff;
border: 1px solid $txn-border;
border-radius: 14px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
overflow: hidden;
}
.txn-tab__toolbar {
display: flex;
align-items: center;
gap: 18px;
padding: 12px 18px;
background: $txn-soft;
border-bottom: 1px solid $txn-border;
flex-wrap: wrap;
}
.txn-tab__date {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: $txn-slate;
font-weight: 500;
input {
height: 34px;
padding: 0 10px;
border-radius: 8px;
border: 1px solid $txn-border;
background: #fff;
font: inherit;
color: $txn-navy;
}
}
.txn-tab__btn {
height: 34px !important;
border-radius: 8px !important;
font-size: 13px !important;
font-weight: 600 !important;
fa-icon {
margin-right: 4px;
}
}
.txn-tab__spacer {
flex: 1;
}
.txn-tab__total {
font-size: 13px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.txn-tab__total--cr {
color: #1a7a3f;
}
.txn-tab__total--dr {
color: #c0392b;
}
.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%, $txn-blue 100%) !important;
color: #fff !important;
&:disabled {
opacity: 0.5;
}
}
::ng-deep .txn-tab__card {
.mat-mdc-header-row {
background: $txn-soft;
}
.mat-mdc-header-cell {
font-size: 11.5px;
font-weight: 800;
color: $txn-slate;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 12px 14px;
border-bottom: 1px solid $txn-border;
}
.mat-mdc-cell {
font-size: 13px;
color: $txn-navy;
padding: 11px 14px;
border-bottom: 1px solid #f1f5f9;
}
.mat-mdc-row:hover {
background: #f8fafc;
}
.mat-column-debit,
.mat-column-credit,
.mat-column-balance {
text-align: right;
font-variant-numeric: tabular-nums;
}
.mat-mdc-paginator {
border-top: 1px solid $txn-border;
background: $txn-soft;
min-height: 48px;
}
.transfer {
color: #6d28d9;
}
.txn-tab__nodata {
padding: 32px 14px;
text-align: center;
color: $txn-muted;
}
}
.txn-tab__empty {
background: #fff;
border: 1px solid $txn-border;
border-radius: 14px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
padding: 56px 24px;
text-align: center;
color: $txn-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: $txn-slate;
margin-top: 6px;
}
.txn-tab__empty-sub {
font-size: 12.5px;
color: $txn-muted;
margin-top: 2px;
}
/* ── Passbook: the savings account's Passbook tab ─────────────────────── */
$bank-primary: #1a3a6b;
$bank-accent: #c8a951;
$debit-color: #c0392b;
$credit-color: #1a7a3f;
$balance-color: #1a3a6b;
$border-color: #d0d7e3;
$row-alt: #f4f7fb;
.passbook-container {
padding: 16px;
background: #eef1f7;
border-radius: 14px;
min-height: 100%;
}
.passbook-filter-bar {
margin-bottom: 16px;
background: #fff;
border-radius: 8px;
padding: 12px 16px;
border: 1px solid $border-color;
}
.passbook-filter-form {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.passbook-date-field {
width: 180px;
}
.passbook-filter-spacer {
flex: 1;
}
.statement-wrapper {
background: #fff;
border: 1px solid $border-color;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 12px rgb(0 0 0 / 8%);
font-family: 'Segoe UI', Arial, sans-serif;
}
.bank-header {
background: $bank-primary;
padding: 18px 24px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 4px solid $bank-accent;
}
.bank-logo-area {
display: flex;
align-items: center;
gap: 14px;
}
.bank-logo-circle {
width: 52px;
height: 52px;
border-radius: 50%;
background: $bank-accent;
display: flex;
align-items: center;
justify-content: center;
mat-icon {
color: $bank-primary;
font-size: 28px;
width: 28px;
height: 28px;
}
}
.bank-name-block {
.bank-title {
color: #fff;
font-size: 18px;
font-weight: 700;
letter-spacing: 0.3px;
}
.bank-subtitle {
color: $bank-accent;
font-size: 11px;
font-weight: 500;
letter-spacing: 1.5px;
text-transform: uppercase;
margin-top: 2px;
}
}
.statement-label {
text-align: right;
.stmt-label-text {
color: $bank-accent;
font-size: 13px;
font-weight: 700;
letter-spacing: 2px;
text-transform: uppercase;
}
.stmt-period {
color: rgb(255 255 255 / 75%);
font-size: 11px;
margin-top: 4px;
}
}
.branch-line {
background: #e8edf5;
border-bottom: 1px solid $border-color;
padding: 6px 24px;
display: flex;
flex-wrap: wrap;
gap: 4px 18px;
font-size: 11px;
color: #4a5a7a;
}
.account-details-band {
background: #f0f3f9;
border-bottom: 1px solid $border-color;
padding: 14px 24px;
}
.account-details-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px 24px;
}
.detail-block {
.detail-label {
font-size: 9px;
font-weight: 700;
color: #7a8aaa;
text-transform: uppercase;
letter-spacing: 0.8px;
margin-bottom: 3px;
}
.detail-value {
font-size: 13px;
color: #222;
&.bold {
font-weight: 600;
}
&.mono {
font-family: 'Courier New', monospace;
letter-spacing: 1px;
}
&.status-badge {
display: inline-block;
padding: 1px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
background: #eee;
color: #555;
&.active {
background: #d4edda;
color: #155724;
}
}
}
}
.balance-summary-strip {
display: flex;
align-items: stretch;
background: $bank-primary;
padding: 0;
}
.balance-box {
flex: 1;
padding: 12px 20px;
text-align: center;
.balance-box-label {
font-size: 10px;
font-weight: 600;
color: rgb(255 255 255 / 65%);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 4px;
}
.balance-box-value {
font-size: 16px;
font-weight: 700;
color: $bank-accent;
}
}
.balance-divider {
width: 1px;
background: rgb(255 255 255 / 15%);
margin: 8px 0;
}
.statement-table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
thead tr {
background: #e8edf5;
border-top: 1px solid $border-color;
border-bottom: 2px solid $bank-primary;
}
thead th {
padding: 9px 10px;
font-size: 11px;
font-weight: 700;
color: $bank-primary;
text-transform: uppercase;
letter-spacing: 0.4px;
white-space: nowrap;
}
tbody tr {
border-bottom: 1px solid #e8ecf3;
transition: background 0.1s;
&:nth-child(even) {
background: $row-alt;
}
&:hover {
background: #dce6f5;
}
}
tbody td {
padding: 7px 10px;
vertical-align: middle;
color: #333;
}
tfoot .total-row {
background: #e8edf5;
border-top: 2px solid $bank-primary;
td {
padding: 9px 10px;
}
}
}
.col-sr {
width: 38px;
}
.col-date {
width: 110px;
white-space: nowrap;
}
.col-desc {
min-width: 160px;
}
.col-ref {
width: 100px;
}
.col-amount {
width: 110px;
}
.col-balance {
width: 120px;
}
.debit-amount {
color: $debit-color;
font-weight: 500;
}
.credit-amount {
color: $credit-color;
font-weight: 500;
}
.balance-amount {
color: $balance-color;
font-weight: 600;
font-family: 'Courier New', monospace;
}
.total-label {
font-size: 12px;
font-weight: 700;
color: $bank-primary;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.closing-balance {
font-size: 14px;
font-weight: 700;
color: $bank-primary;
font-family: 'Courier New', monospace;
}
.txn-desc {
font-weight: 500;
}
.text-right {
text-align: right;
}
.text-center {
text-align: center;
}
.text-muted {
color: #999;
}
.mono {
font-family: 'Courier New', monospace;
font-size: 11px;
}
.no-data-block {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
color: #aaa;
mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
margin-bottom: 12px;
}
p {
font-size: 14px;
margin: 0;
}
}
.statement-footer {
background: #f0f3f9;
border-top: 1px solid $border-color;
padding: 12px 24px;
text-align: center;
.footer-note {
font-size: 10px;
color: #888;
margin-bottom: 4px;
font-style: italic;
}
.footer-meta {
font-size: 10px;
color: #aaa;
letter-spacing: 0.3px;
}
}
@media (width <= 768px) {
.account-details-grid {
grid-template-columns: 1fr 1fr;
}
.bank-header {
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
.statement-label {
text-align: left;
}
.balance-summary-strip {
flex-direction: column;
}
.balance-divider {
width: auto;
height: 1px;
margin: 0 20px;
}
.passbook-filter-form {
flex-direction: column;
align-items: stretch;
}
.passbook-date-field {
width: 100%;
}
.passbook-filter-spacer {
display: none;
}
}
@media (width <= 480px) {
.account-details-grid {
grid-template-columns: 1fr;
}
}
@media print {
.no-print {
display: none !important;
}
.passbook-container {
background: #fff;
padding: 0;
}
.statement-wrapper {
border: none;
box-shadow: none;
border-radius: 0;
}
.bank-header,
.balance-summary-strip {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
@@ -6,14 +6,37 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { Component, DestroyRef, OnInit, ViewChild, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router } from '@angular/router';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { NgClass } from '@angular/common';
import { CurrencyPipe, NgClass } from '@angular/common';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTabLink, MatTabNav, MatTabNavPanel } from '@angular/material/tabs';
import {
MatCell,
MatCellDef,
MatColumnDef,
MatHeaderCell,
MatHeaderCellDef,
MatHeaderRow,
MatHeaderRowDef,
MatNoDataRow,
MatRow,
MatRowDef,
MatTable,
MatTableDataSource
} from '@angular/material/table';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort, MatSortHeader } from '@angular/material/sort';
import { MatIcon } from '@angular/material/icon';
import { MatIconButton } from '@angular/material/button';
import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
import { MatDialog } from '@angular/material/dialog';
import { DdsTransactionViewDialogComponent } from './dds-transaction-view-dialog/dds-transaction-view-dialog.component';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { forkJoin, of } from 'rxjs';
import { forkJoin, merge, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FormatNumberPipe } from 'app/pipes/format-number.pipe';
@@ -52,6 +75,29 @@ const TRANSACTION_LABELS: Record<string, string> = {
FaIconComponent,
MatTooltipModule,
MatProgressSpinnerModule,
MatTabNav,
MatTabLink,
MatTabNavPanel,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
MatNoDataRow,
MatSort,
MatSortHeader,
MatPaginator,
MatIcon,
MatIconButton,
MatMenu,
MatMenuItem,
MatMenuTrigger,
CurrencyPipe,
FormatNumberPipe
]
})
@@ -59,6 +105,8 @@ export class DdsAccountViewComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private organizationService = inject(OrganizationService);
private destroyRef = inject(DestroyRef);
private dialog = inject(MatDialog);
isLoading = true;
account: any = null;
@@ -79,6 +127,40 @@ export class DdsAccountViewComponent implements OnInit {
CLOSED_NIL_BALANCE: 'Closed with nil balance'
};
/** Transactions tab table, laid out as the savings account's: oldest first, 50 to a page, sortable. */
readonly txnColumns: string[] = [
'row',
'id',
'date',
'transactionType',
'debit',
'credit',
'balance',
'actions'
];
readonly txnDataSource = new MatTableDataSource<any>([]);
/** The paginator and sort only exist while the Transactions tab is open, so they are re-attached each time. */
@ViewChild(MatPaginator) set txnPaginator(paginator: MatPaginator | undefined) {
this.txnDataSource.paginator = paginator ?? null;
}
@ViewChild(MatSort) set txnSort(sort: MatSort | undefined) {
this.txnDataSource.sort = sort ?? null;
}
/**
* Passbook letterhead - the bank from Institution Profile and the issuing branch from Branch Profile,
* as the savings passbook takes them. Falls back to a generic label when the profile was never filled in.
*/
bankName = 'Bank';
bankSubtitle = 'Account Statement';
branchName = '';
branchAddress = '';
ifscCode = '';
micrCode = '';
/** The passbook reads oldest first, as it is written up. */
get passbookEntries(): any[] {
return [...this.shownTransactions].reverse();
@@ -92,37 +174,140 @@ export class DdsAccountViewComponent implements OnInit {
/**
* Prints the passbook alone. It is copied into a window of its own with only its own styles, so the
* page's menus, banner and tabs do not come out on the paper.
* page's menus, banner and tabs do not come out on the paper - the same way the savings passbook prints.
*/
printPassbook(): void {
const passbook = document.getElementById('dds-passbook');
const popup = window.open('', '_blank', 'width=900,height=700');
if (!passbook || !popup) {
if (!passbook) {
return;
}
popup.document.write(`<html><head><title>${this.account?.accountNo || 'DDS'} Passbook</title><style>
body { font-family: Arial, sans-serif; margin: 24px; color: #111; }
.dav-pb-head { display: flex; justify-content: space-between; gap: 24px; margin-bottom: 16px; }
.dav-pb-title { font-size: 20px; font-weight: 700; }
.dav-pb-sub { color: #555; }
.dav-pb-grid { display: grid; grid-template-columns: auto auto; gap: 2px 12px; font-size: 13px; }
.dav-pb-grid span { color: #555; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { border: 1px solid #999; padding: 6px 8px; text-align: left; }
.dav-right { text-align: right; }
.dav-pb-note { display: block; font-size: 11px; color: #555; }
</style></head><body>${passbook.innerHTML}</body></html>`);
const clone = passbook.cloneNode(true) as HTMLElement;
clone.querySelectorAll('.no-print').forEach((el: Element) => el.remove());
// The icon font is not available in the popup: the bank logo becomes an inline SVG, other icons go.
clone.querySelectorAll('mat-icon, .mat-icon').forEach((el: Element) => {
if (el.closest('.bank-logo-circle')) {
el.outerHTML = DdsAccountViewComponent.BANK_LOGO_SVG;
} else {
el.remove();
}
});
const popup = window.open('', '_blank', 'width=1100,height=800');
if (!popup) {
return;
}
popup.document.write(`<html><head><meta charset="utf-8" />
<title>${this.account?.accountNo || 'DDS'} Passbook</title>
<style>${DdsAccountViewComponent.PRINT_STYLES}</style></head>
<body>${clone.outerHTML}</body></html>`);
popup.document.close();
popup.focus();
popup.print();
}
/** Material "account_balance" glyph, inlined so the print window needs no icon font. */
private static readonly BANK_LOGO_SVG = `
<svg class="bank-logo-svg" viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<path d="M4 10h3v7H4zM10.5 10h3v7h-3zM2 19h20v3H2zM17 10h3v7h-3zM12 1L2 6v2h20V6z" />
</svg>`;
/** Mirrors the passbook styles in the component's SCSS - scoped styles do not reach the popup. */
private static readonly PRINT_STYLES = `
@page { size: A4 portrait; margin: 10mm; }
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
body { font-family: 'Segoe UI', Arial, sans-serif; color: #333; background: #fff; }
.statement-wrapper { background: #fff; border: 1px solid #d0d7e3; border-radius: 6px; overflow: hidden; }
.bank-header { background: #1a3a6b; padding: 14px 20px; display: flex; justify-content: space-between;
align-items: center; border-bottom: 4px solid #c8a951; }
.bank-logo-area { display: flex; align-items: center; gap: 12px; }
.bank-logo-circle { width: 44px; height: 44px; border-radius: 50%; background: #c8a951; display: flex;
align-items: center; justify-content: center; }
.bank-logo-svg { fill: #1a3a6b; display: block; }
.bank-title { color: #fff; font-size: 16px; font-weight: 700; letter-spacing: 0.3px; }
.bank-subtitle { color: #c8a951; font-size: 10px; font-weight: 500; letter-spacing: 1.5px;
text-transform: uppercase; margin-top: 2px; }
.statement-label { text-align: right; }
.stmt-label-text { color: #c8a951; font-size: 12px; font-weight: 700; letter-spacing: 2px; text-transform: uppercase; }
.stmt-period { color: rgba(255, 255, 255, 0.75); font-size: 10px; margin-top: 3px; }
.branch-line { background: #e8edf5; border-bottom: 1px solid #d0d7e3; padding: 5px 20px; display: flex;
flex-wrap: wrap; gap: 4px 16px; font-size: 9.5px; color: #4a5a7a; }
.account-details-band { background: #f0f3f9; border-bottom: 1px solid #d0d7e3; padding: 12px 20px; }
.account-details-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px 24px; }
.detail-label { font-size: 8.5px; font-weight: 700; color: #7a8aaa; text-transform: uppercase;
letter-spacing: 0.8px; margin-bottom: 2px; }
.detail-value { font-size: 12px; color: #222; }
.detail-value.bold { font-weight: 600; }
.detail-value.mono { font-family: 'Courier New', monospace; letter-spacing: 1px; }
.detail-value.status-badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 10px;
font-weight: 600; background: #eee; color: #555; }
.detail-value.status-badge.active { background: #d4edda; color: #155724; }
.balance-summary-strip { display: flex; align-items: stretch; background: #1a3a6b; }
.balance-box { flex: 1; padding: 10px 18px; text-align: center; }
.balance-box-label { font-size: 9px; font-weight: 600; color: rgba(255, 255, 255, 0.65);
text-transform: uppercase; letter-spacing: 1px; margin-bottom: 3px; }
.balance-box-value { font-size: 15px; font-weight: 700; color: #c8a951; }
.balance-divider { width: 1px; background: rgba(255, 255, 255, 0.15); margin: 6px 0; }
.statement-table { width: 100%; border-collapse: collapse; font-size: 11px; }
.statement-table thead { display: table-header-group; }
.statement-table tfoot { display: table-row-group; }
.statement-table tr { page-break-inside: avoid; break-inside: avoid; }
.statement-table thead tr { background: #e8edf5; border-top: 1px solid #d0d7e3; border-bottom: 2px solid #1a3a6b; }
.statement-table thead th { padding: 8px 9px; font-size: 10px; font-weight: 700; color: #1a3a6b;
text-transform: uppercase; letter-spacing: 0.4px; white-space: nowrap; text-align: left; }
.statement-table tbody tr { border-bottom: 1px solid #e8ecf3; }
.statement-table tbody tr:nth-child(even) { background: #f4f7fb; }
.statement-table tbody td { padding: 6px 9px; vertical-align: middle; color: #333; }
.statement-table tfoot .total-row { background: #e8edf5; border-top: 2px solid #1a3a6b; }
.statement-table tfoot .total-row td { padding: 8px 9px; }
.col-sr { width: 34px; }
.col-date { width: 100px; white-space: nowrap; }
.col-desc { min-width: 150px; }
.col-ref { width: 90px; }
.col-amount { width: 100px; }
.col-balance { width: 110px; }
.debit-amount { color: #c0392b; font-weight: 500; }
.credit-amount { color: #1a7a3f; font-weight: 500; }
.balance-amount { color: #1a3a6b; font-weight: 600; font-family: 'Courier New', monospace; }
.total-label { font-size: 11px; font-weight: 700; color: #1a3a6b; text-transform: uppercase; letter-spacing: 0.5px; }
.closing-balance { font-size: 13px; font-weight: 700; color: #1a3a6b; font-family: 'Courier New', monospace; }
.txn-desc { font-weight: 500; }
.txn-note { display: block; font-size: 9.5px; color: #777; }
.text-right { text-align: right; }
.text-center { text-align: center; }
.text-muted { color: #999; }
.mono { font-family: 'Courier New', monospace; font-size: 10px; }
.no-data-block { padding: 40px; text-align: center; color: #aaa; font-size: 13px; }
.statement-footer { background: #f0f3f9; border-top: 1px solid #d0d7e3; padding: 10px 20px; text-align: center; }
.footer-note { font-size: 9px; color: #888; margin-bottom: 3px; font-style: italic; }
.footer-meta { font-size: 9px; color: #aaa; letter-spacing: 0.3px; }
`;
fromDateControl = new FormControl('');
toDateControl = new FormControl('');
readonly today = new Date().toISOString().split('T')[0];
ngOnInit(): void {
this.txnDataSource.sortingDataAccessor = (txn: any, column: string) => {
switch (column) {
case 'date':
return `${txn.transactionDate || ''}#${String(txn.id).padStart(12, '0')}`;
case 'transactionType':
return this.label(txn.transactionType);
case 'voucher':
return txn.voucherNo || '';
case 'debit':
case 'credit':
return Number(txn.amount || 0);
case 'balance':
return Number(txn.balanceAfterTransaction || 0);
default:
return Number(txn.id || 0);
}
};
merge(this.fromDateControl.valueChanges, this.toDateControl.valueChanges)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.refreshTransactionTable());
const partyId = this.route.snapshot.paramMap.get('partyId');
if (!partyId) {
this.isLoading = false;
@@ -144,10 +329,39 @@ export class DdsAccountViewComponent implements OnInit {
(a: any, b: any) =>
String(b.transactionDate).localeCompare(String(a.transactionDate)) || Number(b.id) - Number(a.id)
);
this.refreshTransactionTable();
this.loadLetterhead(account);
this.isLoading = false;
});
}
/** The Transactions tab reads oldest first, as the savings account's does, within the chosen dates. */
private refreshTransactionTable(): void {
this.txnDataSource.data = this.passbookEntries;
}
/** Institution Profile for the bank; the account's own office's Branch Profile, else the first on record. */
private loadLetterhead(account: any): void {
this.branchName = account?.officeName || '';
forkJoin({
institution: this.organizationService.getInstitutionProfile().pipe(catchError(() => of(null))),
branches: this.organizationService.getBranchProfiles().pipe(catchError(() => of([])))
}).subscribe(({ institution, branches }: any) => {
if (institution) {
this.bankName = institution.passbookHeaderName || institution.bankName || institution.legalName || 'Bank';
this.bankSubtitle = institution.licenseNumber ? `Lic. No. ${institution.licenseNumber}` : 'Account Statement';
}
const list: any[] = Array.isArray(branches) ? branches : [];
const branch = list.find((b: any) => account?.officeId && b.officeId === account.officeId) || list[0];
if (branch) {
this.branchName = branch.officeName || this.branchName;
this.branchAddress = branch.address || '';
this.ifscCode = branch.ifscCode || '';
this.micrCode = branch.micrCode || '';
}
});
}
/** The transactions in the chosen range - all of them when no range is set. */
get shownTransactions(): any[] {
const from = this.fromDateControl.value;
@@ -164,6 +378,11 @@ export class DdsAccountViewComponent implements OnInit {
});
}
/** Whether the account has any transactions at all, whatever the date filter says. */
get hasTransactions(): boolean {
return this.transactions.length > 0;
}
get totalCredits(): number {
return this.sumOf('CREDIT');
}
@@ -209,6 +428,11 @@ export class DdsAccountViewComponent implements OnInit {
return this.transactions.length > 0 ? this.transactions[0].transactionDate : null;
}
/** Row tint, as the savings transactions table has it - money moved by transfer shows in violet. */
txnRowClass(txn: any): string {
return txn.transactionType === 'TRANSFER' || txn.transactionType === 'WITHDRAW_DEPOSITE' ? 'transfer' : '';
}
/** The rows on screen, as a spreadsheet the branch can keep or send on. */
exportCsv(): void {
const header = [
@@ -268,6 +492,35 @@ export class DdsAccountViewComponent implements OnInit {
this.toDateControl.setValue('');
}
/**
* A line in full - what it was, when, the payment details, voucher, reference, journal, branch and
* who keyed it - as a savings account's View Transaction shows one. From the Receipt menu item it
* prints straight away.
*/
viewTransaction(txn: any, printReceipt = false): void {
this.dialog.open(DdsTransactionViewDialogComponent, {
width: '640px',
maxWidth: '95vw',
data: {
transaction: txn,
account: this.account,
typeLabel: this.label(txn.transactionType),
printOnOpen: printReceipt
}
});
}
viewJournalEntries(txn: any): void {
if (txn.journalTransactionId) {
this.router.navigate([
'/',
'journal-entry',
'view',
txn.journalTransactionId
]);
}
}
label(type: string): string {
return TRANSACTION_LABELS[type] || String(type || '').replace(/_/g, ' ');
}
@@ -0,0 +1,144 @@
<!--
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="dtv">
<header class="dtv-head" [class.dtv-head--debit]="!isCredit">
<div class="dtv-head__ic"><fa-icon [icon]="isCredit ? 'arrow-down' : 'arrow-up'"></fa-icon></div>
<div class="dtv-head__text">
<h2>{{ data.typeLabel }}</h2>
<p>Transaction #{{ txn.id }} · {{ txn.refNo || '—' }}</p>
</div>
<div class="dtv-head__amount">
<span>{{ isCredit ? 'Credit' : 'Debit' }}</span>
<strong>{{ txn.amount | currency: currency : 'symbol-narrow' : '1.2-2' }}</strong>
</div>
<button type="button" class="dtv-close" (click)="close()" aria-label="Close">
<fa-icon icon="times"></fa-icon>
</button>
</header>
<div class="dtv-body" id="dds-txn-receipt">
<!-- What the printed receipt opens with; hidden on screen, where the header above says it. -->
<div class="dtv-print-only">
<h2>DDS Transaction Receipt</h2>
<div class="muted">
{{ account.partyName }} · A/c {{ account.accountNo }} · {{ txn.officeName || account.officeName || '' }}
</div>
<div class="amount">
{{ isCredit ? 'Credit' : 'Debit' }} {{ txn.amount | currency: currency : 'symbol-narrow' : '1.2-2' }}
</div>
</div>
<section class="dtv-section">
<h3>General</h3>
<table class="dtv-kv">
<tr>
<td class="k">Id</td>
<td>{{ txn.id }}</td>
</tr>
<tr>
<td class="k">Reference No.</td>
<td class="mono">{{ txn.refNo || '—' }}</td>
</tr>
<tr>
<td class="k">Transaction Type</td>
<td>{{ data.typeLabel }} ({{ txn.direction }})</td>
</tr>
<tr>
<td class="k">Transaction Date</td>
<td>{{ txn.transactionDate | date: 'dd MMM yyyy' }}</td>
</tr>
<tr>
<td class="k">Submitted On</td>
<td>{{ txn.submittedOnDate ? (txn.submittedOnDate | date: 'dd MMM yyyy') : '—' }}</td>
</tr>
<tr>
<td class="k">Currency</td>
<td>{{ currency }}</td>
</tr>
<tr>
<td class="k">Amount</td>
<td class="strong">{{ txn.amount | currency: currency : 'symbol-narrow' : '1.2-2' }}</td>
</tr>
<tr>
<td class="k">Running Balance</td>
<td class="strong">
{{ txn.balanceAfterTransaction || 0 | currency: currency : 'symbol-narrow' : '1.2-2' }}
</td>
</tr>
<tr>
<td class="k">Note</td>
<td>{{ txn.description || '—' }}</td>
</tr>
</table>
</section>
<section class="dtv-section">
<h3>Payment Details</h3>
<table class="dtv-kv">
<tr>
<td class="k">Mode</td>
<td>{{ txn.transactionMode || '—' }}</td>
</tr>
<tr>
<td class="k">Account Type / No.</td>
<td>{{ txn.accountType || '—' }}{{ txn.accountNo ? ' · ' + txn.accountNo : '' }}</td>
</tr>
<tr>
<td class="k">Cheque No.</td>
<td>{{ txn.chequeNo || '—' }}</td>
</tr>
<tr>
<td class="k">Token No.</td>
<td>{{ txn.tokenNo || '—' }}</td>
</tr>
</table>
</section>
<section class="dtv-section">
<h3>Accounting &amp; Audit</h3>
<table class="dtv-kv">
<tr>
<td class="k">Voucher No.</td>
<td class="mono">{{ txn.voucherNo || '—' }}</td>
</tr>
<tr>
<td class="k">Journal Entry</td>
<td class="mono">{{ txn.journalTransactionId || 'None - nothing moved on the books' }}</td>
</tr>
<tr>
<td class="k">Branch</td>
<td>{{ txn.officeName || account.officeName || '—' }}</td>
</tr>
<tr>
<td class="k">Agent</td>
<td>{{ txn.agentCode }}{{ txn.agentName ? ' · ' + txn.agentName : '' }}</td>
</tr>
<tr>
<td class="k">Created By</td>
<td>{{ txn.createdByUsername || '—' }}</td>
</tr>
<tr>
<td class="k">Created On</td>
<td>{{ txn.createdDate ? (txn.createdDate | date: 'dd MMM yyyy, hh:mm a') : '—' }}</td>
</tr>
</table>
</section>
</div>
<footer class="dtv-actions">
<button mat-stroked-button type="button" (click)="close()">Close</button>
<span class="dtv-actions__spacer"></span>
<button mat-stroked-button type="button" [disabled]="!txn.journalTransactionId" (click)="viewJournalEntries()">
<fa-icon icon="table-cells"></fa-icon> View Journal Entries
</button>
<button mat-flat-button color="primary" type="button" (click)="printReceipt()">
<fa-icon icon="receipt"></fa-icon> Print Receipt
</button>
</footer>
</div>
@@ -0,0 +1,167 @@
/**
* 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/.
*/
$primary: #1353a0;
$credit: #15803d;
$debit: #b91c1c;
$border: #e2e8f0;
$text: #0f172a;
$text-mid: #475569;
:host {
display: block;
}
.dtv {
display: flex;
flex-direction: column;
max-height: 88vh;
color: $text;
font-size: 13px;
}
.dtv-head {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
background: linear-gradient(135deg, #1e40af, #2563eb);
color: #fff;
&--debit {
background: linear-gradient(135deg, #7f1d1d, #b91c1c);
}
&__ic {
display: grid;
place-items: center;
flex: none;
width: 38px;
height: 38px;
border-radius: 10px;
background: rgb(255 255 255 / 18%);
}
&__text {
flex: 1;
min-width: 0;
h2 {
margin: 0;
font-size: 16px;
font-weight: 700;
}
p {
margin: 2px 0 0;
font-size: 12px;
opacity: 0.85;
}
}
&__amount {
display: flex;
flex-direction: column;
align-items: flex-end;
span {
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
opacity: 0.85;
}
strong {
font-size: 18px;
}
}
}
.dtv-close {
border: 0;
background: transparent;
color: #fff;
font-size: 16px;
cursor: pointer;
}
.dtv-body {
flex: 1;
min-height: 0;
padding: 14px 20px;
overflow-y: auto;
}
.dtv-print-only {
display: none;
}
.dtv-section {
& + & {
margin-top: 14px;
}
h3 {
margin: 0 0 6px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: $primary;
}
}
.dtv-kv {
width: 100%;
border: 1px solid $border;
border-radius: 8px;
border-collapse: separate;
border-spacing: 0;
overflow: hidden;
td {
padding: 7px 12px;
border-bottom: 1px solid #eef2f6;
vertical-align: top;
}
tr:last-child td {
border-bottom: 0;
}
.k {
width: 38%;
background: #f8fafc;
color: $text-mid;
font-weight: 600;
}
.mono {
font-family: ui-monospace, 'Cascadia Mono', Consolas, monospace;
}
.strong {
font-weight: 700;
}
}
.dtv-actions {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
border-top: 1px solid $border;
&__spacer {
flex: 1;
}
fa-icon {
margin-right: 6px;
}
}
@@ -0,0 +1,109 @@
/**
* 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 { CurrencyPipe, DatePipe } from '@angular/common';
import { Router } from '@angular/router';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatButton } from '@angular/material/button';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** What the account view hands the dialog: the line, its account, and how to name its type. */
export interface DdsTransactionViewData {
transaction: any;
account: any;
typeLabel: string;
/** Opened from the Receipt menu item: print the receipt straight away. */
printOnOpen?: boolean;
}
/**
* One DDS passbook line in full - everything the line was saved with - the way a savings account's
* View Transaction shows one: what it was, when, how much, and who keyed it, with the voucher, its
* own reference and the journal it was booked under. Prints as a receipt.
*/
@Component({
selector: 'mifosx-dds-transaction-view-dialog',
templateUrl: './dds-transaction-view-dialog.component.html',
styleUrls: ['./dds-transaction-view-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
CurrencyPipe,
DatePipe,
MatButton,
FaIconComponent
]
})
export class DdsTransactionViewDialogComponent {
private dialogRef = inject(MatDialogRef<DdsTransactionViewDialogComponent>);
private router = inject(Router);
data = inject<DdsTransactionViewData>(MAT_DIALOG_DATA);
get txn(): any {
return this.data.transaction || {};
}
get account(): any {
return this.data.account || {};
}
get isCredit(): boolean {
return this.txn.direction === 'CREDIT';
}
get currency(): string {
return this.account.currencyCode || 'INR';
}
constructor() {
if (this.data.printOnOpen) {
// After the view has drawn, so the receipt has something in it to print.
setTimeout(() => this.printReceipt(), 300);
}
}
viewJournalEntries(): void {
if (!this.txn.journalTransactionId) {
return;
}
this.dialogRef.close();
this.router.navigate([
'/',
'journal-entry',
'view',
this.txn.journalTransactionId
]);
}
/** The receipt block alone, in a window of its own - as the savings receipt prints. */
printReceipt(): void {
const receipt = document.getElementById('dds-txn-receipt');
const popup = window.open('', '_blank', 'width=720,height=800');
if (!receipt || !popup) {
return;
}
popup.document.write(`<html><head><title>${this.txn.refNo || 'DDS'} Receipt</title><style>
body { font-family: Arial, sans-serif; font-size: 12px; color: #111; margin: 24px; }
h2 { margin: 0 0 4px; font-size: 16px; } .muted { color: #555; }
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
td { padding: 6px 8px; border-bottom: 1px solid #ddd; vertical-align: top; }
td.k { width: 38%; color: #555; } .amount { font-size: 18px; font-weight: 700; }
.sign { margin-top: 48px; display: flex; justify-content: space-between; }
</style></head><body>${receipt.innerHTML}
<div class="sign"><span>Customer</span><span>Authorised Signatory</span></div>
</body></html>`);
popup.document.close();
popup.focus();
popup.print();
}
close(): void {
this.dialogRef.close();
}
}
@@ -24,6 +24,13 @@
</div>
@if (isClosed) {
<span class="pm-closed-badge">Closed — view only</span>
} @else if (termsLocked) {
<span
class="pm-locked-badge"
matTooltip="Customer, product, agent, branch, opening date and deposit terms (daily amount, deposit, days, months, interest/pay/reopen dates) cannot be changed on an active account. Contact details, nominees, lien and freeze can."
>
<i class="fas fa-lock"></i> Active — scheme terms locked
</span>
}
</div>
@@ -197,6 +197,22 @@ $primary: #0f766e;
color: rgb(0 0 0 / 60%);
}
/* On an active account: the scheme terms are locked, the rest is editable. */
.pm-locked-badge {
margin-left: 12px;
padding: 3px 11px;
border-radius: 11px;
background: #fff7ed;
color: #9a3412;
font-size: 0.72rem;
font-weight: 600;
cursor: help;
i {
margin-right: 4px;
}
}
/* Says whose details a block of fields is for, so the screen explains itself. */
.pm-section__sub {
flex: 1 1 auto;
@@ -300,6 +300,31 @@ export class CreatePartyComponent implements OnInit {
return this.partyStatus === 'CLOSED';
}
/**
* What an active account's scheme is built on - the customer and product, its branch, its opening
* its collecting agent and its deposit terms - is shown but cannot be changed, as the server refuses
* it too. Contact details, nominees, lien and freeze stay editable.
*/
static readonly LOCKED_WHEN_ACTIVE = [
'clientId',
'subType',
'agentId',
'officeId',
'openingDate',
'dailyDepositAmount',
'depositAmount',
'days',
'month',
'interestDate',
'payDate',
'reopenDate'
];
/** An account already running: its scheme terms are locked. */
get termsLocked(): boolean {
return !!this.partyId && this.partyStatus === 'ACTIVE';
}
/** The DDS Account Type master - what the legacy screen's Sub Type dropdown was fed from. */
subTypes: any[] = [];
@@ -1754,6 +1779,12 @@ export class CreatePartyComponent implements OnInit {
};
if (this.partyId) {
// An active account's locked terms are not sent at all, so an edit never even asks to change them.
if (this.termsLocked) {
for (const name of CreatePartyComponent.LOCKED_WHEN_ACTIVE) {
delete payload[name];
}
}
this.organizationService.updateParty(this.partyId, payload).subscribe(done);
} else {
this.organizationService.createParty(payload).subscribe(done);
@@ -2046,6 +2077,10 @@ export class CreatePartyComponent implements OnInit {
// A closed party is a record to look at, not to change.
if (this.isClosed) {
this.partyForm.disable({ emitEvent: false });
} else if (this.termsLocked) {
for (const name of CreatePartyComponent.LOCKED_WHEN_ACTIVE) {
this.partyForm.get(name)?.disable({ emitEvent: false });
}
}
}
}
@@ -1,157 +0,0 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="csm-container">
<div class="csm-header">
<button mat-stroked-button [routerLink]="['../']" class="csm-back">
<i class="fas fa-arrow-left"></i> {{ 'labels.heading.Organization' | translate }}
</button>
<h2 class="csm-header__title">Commission Slab Master</h2>
</div>
<div class="csm-card">
<table class="csm-table">
<thead>
<tr>
<th>Slab Type</th>
<th>Slab From</th>
<th>Slab To</th>
<th>Comm Per</th>
<th></th>
</tr>
</thead>
<tbody>
@for (slab of slabs; track slab; let i = $index) {
@if (editingIndex === i) {
<!-- The same row turned into inputs, so a correction is made where the value is read. -->
<tr [formGroup]="editSlabForm" class="csm-new-row">
<td>
<mat-form-field appearance="outline" class="csm-inline-field">
<mat-select formControlName="slabType">
@for (type of slabTypes; track type) {
<mat-option [value]="type">{{ type }}</mat-option>
}
</mat-select>
</mat-form-field>
</td>
<td>
<mat-form-field appearance="outline" class="csm-inline-field">
<input matInput type="number" formControlName="slabFrom" />
</mat-form-field>
</td>
<td>
<mat-form-field appearance="outline" class="csm-inline-field">
<input matInput type="number" formControlName="slabTo" />
</mat-form-field>
</td>
<td>
<mat-form-field appearance="outline" class="csm-inline-field">
<input matInput type="number" formControlName="commissionPercent" />
</mat-form-field>
</td>
<td class="csm-row-actions">
<button mat-icon-button color="primary" type="button" (click)="saveEdit()" matTooltip="Save correction">
<i class="fas fa-check"></i>
</button>
<button mat-icon-button type="button" (click)="cancelEdit()" matTooltip="Cancel">
<i class="fas fa-times"></i>
</button>
</td>
</tr>
} @else {
<tr>
<td>{{ slab.slabType }}</td>
<td>{{ slab.slabFrom }}</td>
<td>{{ slab.slabTo }}</td>
<td>{{ slab.commissionPercent }}%</td>
<td class="csm-row-actions">
@if (slab.id) {
<!--
A saved slab is corrected, never removed. Deleting a range would leave a hole
that pays nothing to any agent whose volume lands in it, with nothing on screen
to say why - and there is deliberately no delete endpoint behind it.
-->
<button mat-icon-button color="primary" type="button" (click)="startEdit(i)" matTooltip="Edit slab">
<i class="fas fa-pen"></i>
</button>
} @else {
<!-- Not saved yet, so taking it back off the table costs nothing. -->
<button mat-icon-button type="button" (click)="removeSlab(i)" matTooltip="Remove before saving">
<i class="fas fa-times"></i>
</button>
}
</td>
</tr>
}
}
<tr [formGroup]="newSlabForm" class="csm-new-row">
<td>
<mat-form-field appearance="outline" class="csm-inline-field">
<mat-select required formControlName="slabType" placeholder="Slab Type">
@for (type of slabTypes; track type) {
<mat-option [value]="type">{{ type }}</mat-option>
}
</mat-select>
</mat-form-field>
</td>
<td>
<mat-form-field appearance="outline" class="csm-inline-field">
<input matInput required type="number" formControlName="slabFrom" />
</mat-form-field>
</td>
<td>
<mat-form-field appearance="outline" class="csm-inline-field">
<input matInput required type="number" formControlName="slabTo" />
</mat-form-field>
</td>
<td>
<mat-form-field appearance="outline" class="csm-inline-field">
<input matInput required type="number" formControlName="commissionPercent" />
</mat-form-field>
</td>
<td class="csm-row-actions">
<!--
Deliberately not disabled while the row is incomplete. A greyed-out button says the
screen will not accept this, but never which field it is waiting for; letting the
click through means addSlab() can say so.
-->
<button mat-icon-button color="primary" type="button" (click)="addSlab()" matTooltip="Add slab">
<i class="fas fa-plus"></i>
</button>
</td>
</tr>
</tbody>
</table>
@if (slabs.length === 0) {
<p class="csm-empty">No commission slabs defined yet — add one using the row above.</p>
}
@if (coverage.length > 0) {
<div class="csm-coverage">
@for (entry of coverage; track entry.slabType) {
<div class="csm-coverage__row">
<span class="csm-coverage__type">{{ entry.slabType }}</span>
<span class="csm-coverage__range"> covers {{ entry.from | number }} to {{ entry.to | number }} </span>
@if (entry.gaps.length > 0) {
<span class="csm-coverage__gap">
nothing set for {{ entry.gaps.join(', ') }} — an agent landing there earns no commission
</span>
}
</div>
}
</div>
}
<div class="csm-action-bar">
<button mat-raised-button type="button" (click)="exit()">Exit</button>
<button mat-raised-button color="primary" type="button" (click)="save()">Save</button>
</div>
</div>
</div>
@@ -1,163 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
$primary: #0f766e;
.csm-container {
// Four inputs sit side by side in the entry row; 760px squeezed them out of the card.
max-width: 980px;
margin: 0 auto;
padding: 24px 16px;
}
.csm-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
&__title {
margin: 0;
font-size: 22px;
font-weight: 500;
}
}
.csm-back {
color: $primary;
}
.csm-card {
background: var(--md-sys-color-surface, #fff);
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
padding: 16px 24px 24px;
}
.csm-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
// Sized rather than left to the browser, so the entry row keeps its shape whatever is typed in.
th:nth-child(1),
td:nth-child(1) {
width: 26%;
}
th:nth-child(2),
td:nth-child(2) {
width: 22%;
}
th:nth-child(3),
td:nth-child(3) {
width: 22%;
}
th:nth-child(4),
td:nth-child(4) {
width: 18%;
}
th:nth-child(5),
td:nth-child(5) {
width: 12%;
}
th {
text-align: left;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: rgb(0 0 0 / 55%);
padding: 8px 10px;
border-bottom: 2px solid rgb(0 0 0 / 10%);
}
td {
padding: 6px 10px;
border-bottom: 1px solid rgb(0 0 0 / 6%);
vertical-align: middle;
}
}
.csm-inline-field {
width: 100%;
// Without this the field holds its own 180px floor and pushes the next column out of the card.
min-width: 0;
::ng-deep .mat-mdc-text-field-wrapper {
height: 44px;
}
}
.csm-row-actions {
text-align: center;
width: 48px;
}
.csm-new-row {
background: rgb(15 118 110 / 4%);
}
.csm-empty {
color: rgb(0 0 0 / 50%);
font-size: 14px;
margin: 12px 0 0;
}
.csm-action-bar {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid rgb(0 0 0 / 8%);
}
/*
* What the slabs actually cover, and any figure between them that none of them claims.
*
* A gap is legitimate - a bank may set no commission below some floor - so this reports rather than
* warns. But it has to be said out loud: the commission run finds no slab for a volume in a gap and
* moves on, so the agent is simply paid nothing that month and nothing on screen explains it.
*/
.csm-coverage {
margin-top: 14px;
padding: 12px 16px;
border: 1px solid rgb(0 0 0 / 12%);
border-radius: 6px;
background: rgb(0 0 0 / 2.5%);
display: flex;
flex-direction: column;
gap: 6px;
&__row {
display: flex;
align-items: baseline;
gap: 10px;
flex-wrap: wrap;
font-size: 0.86rem;
}
&__type {
font-weight: 600;
min-width: 90px;
}
&__range {
color: rgb(0 0 0 / 62%);
font-variant-numeric: tabular-nums;
}
&__gap {
color: #b45309;
font-variant-numeric: tabular-nums;
}
}
@@ -11,13 +11,74 @@
<button mat-stroked-button [routerLink]="['../']" class="bp-back">
<i class="fas fa-arrow-left"></i> {{ 'labels.heading.Organization' | translate }}
</button>
<h2 class="bp-header__title">DDS Interest &amp; Commission</h2>
<div class="bp-header__ic"><i class="fas fa-cogs"></i></div>
<div>
<h2 class="bp-header__title">DDS Interest &amp; Commission</h2>
<p class="bp-header__sub">
Calculate, check and post DDS interest and agent commission, and close matured accounts
</p>
</div>
</div>
<div class="bp-card">
<form [formGroup]="batchForm">
<form [formGroup]="batchForm">
<!-- ── 1. The job ─────────────────────────────────────────────────────────────────────────── -->
<div class="bp-card">
<div class="bp-step-head">
<span class="bp-step-no">1</span>
<div>
<h3>Choose the job</h3>
<p>Each month: <b>Calculate</b> first, check the preview, then <b>Post</b>.</p>
</div>
</div>
<div class="bp-jobs">
@for (group of jobGroups; track group.title) {
<div class="bp-job" [class.bp-job--active]="group.steps.includes(jobType)">
<div class="bp-job__head">
<span class="bp-job__ic"><i class="fas" [ngClass]="group.icon"></i></span>
<div>
<span class="bp-job__title">{{ group.title }}</span>
<span class="bp-job__hint">{{ group.hint }}</span>
</div>
</div>
<div class="bp-job__steps">
@for (code of group.steps; track code; let last = $last) {
<button
type="button"
class="bp-job__step"
[class.bp-job__step--on]="jobType === code"
(click)="selectJob(code)"
>
<i class="fas" [ngClass]="code.endsWith('_POSTING') ? 'fa-check-double' : 'fa-calculator'"></i>
{{ stepLabel(code) }}
</button>
@if (!last) {
<i class="fas fa-arrow-right bp-job__arrow"></i>
}
}
</div>
</div>
}
</div>
</div>
<!-- ── 2. Scope and date ──────────────────────────────────────────────────────────────────── -->
<div class="bp-card">
<div class="bp-step-head">
<span class="bp-step-no">2</span>
<div>
<h3>
{{ jobTypeLabel }}
<span class="bp-kind" [class.bp-kind--post]="isPostingJob">{{
isPostingJob ? 'Moves money' : 'Calculation only'
}}</span>
</h3>
<p>Which branches, agents and accounts, and as of which date.</p>
</div>
</div>
<div class="bp-grid">
<mat-form-field appearance="outline" class="bp-field bp-field--wide">
<mat-form-field appearance="outline" class="bp-field" subscriptSizing="dynamic">
<mat-label>Job Type</mat-label>
<mat-select required formControlName="jobType">
@for (type of jobTypes; track type.code) {
@@ -26,7 +87,14 @@
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="bp-field">
<mat-form-field appearance="outline" class="bp-field" subscriptSizing="dynamic">
<mat-label>{{ isPostingJob ? 'Posting Date' : 'Interest Date' }}</mat-label>
<input matInput required [matDatepicker]="jobDatePicker" formControlName="jobDate" />
<mat-datepicker-toggle matIconSuffix [for]="jobDatePicker"></mat-datepicker-toggle>
<mat-datepicker #jobDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="bp-field" subscriptSizing="dynamic">
<mat-label>Branch</mat-label>
<mat-select required formControlName="branchScope">
@for (scope of branchScopes; track scope) {
@@ -37,51 +105,59 @@
@if (isBranchSelected) {
<div class="bp-field bp-field--wide bp-branch-checklist">
@for (office of offices; track office.id) {
<mat-checkbox [checked]="selectedOfficeNames.has(office.name)" (change)="toggleOffice(office.name)">
{{ office.name }}
</mat-checkbox>
}
@if (offices.length === 0) {
<span class="bp-empty">Loading branches...</span>
}
<span class="bp-group-label">Branches ({{ selectedOfficeNames.size }} selected)</span>
<div class="bp-branch-checklist__list">
@for (office of offices; track office.id) {
<mat-checkbox [checked]="selectedOfficeNames.has(office.name)" (change)="toggleOffice(office.name)">
{{ office.name }}
</mat-checkbox>
}
@if (offices.length === 0) {
<span class="bp-empty"><i class="fas fa-circle-notch fa-spin"></i> Loading branches...</span>
}
</div>
</div>
}
<mat-form-field appearance="outline" class="bp-field">
<mat-label>{{ isPostingJob ? 'Posting Date' : 'Interest Date' }}</mat-label>
<input matInput required [matDatepicker]="jobDatePicker" formControlName="jobDate" />
<mat-datepicker-toggle matIconSuffix [for]="jobDatePicker"></mat-datepicker-toggle>
<mat-datepicker #jobDatePicker></mat-datepicker>
</mat-form-field>
<div class="bp-ranges bp-field--wide">
<div class="bp-range">
<span class="bp-group-label"><i class="fas fa-user-tie"></i> Agents</span>
<div class="bp-range__fields">
<mat-form-field appearance="outline" class="bp-field" subscriptSizing="dynamic">
<mat-label>From</mat-label>
<input matInput required type="number" formControlName="agentFrom" />
</mat-form-field>
<span class="bp-range__to">to</span>
<mat-form-field appearance="outline" class="bp-field" subscriptSizing="dynamic">
<mat-label>To</mat-label>
<input matInput required type="number" formControlName="agentTo" />
</mat-form-field>
</div>
</div>
<mat-form-field appearance="outline" class="bp-field">
<mat-label>Agent From</mat-label>
<input matInput required type="number" formControlName="agentFrom" />
</mat-form-field>
<mat-form-field appearance="outline" class="bp-field">
<mat-label>Agent To</mat-label>
<input matInput required type="number" formControlName="agentTo" />
</mat-form-field>
<!-- Commission is worked out per agent on their whole collection, so a party range would
imply a filter that has no effect. -->
@if (!isCommissionJob) {
<mat-form-field appearance="outline" class="bp-field">
<mat-label>Party From</mat-label>
<input matInput required type="number" formControlName="partyFrom" />
</mat-form-field>
<mat-form-field appearance="outline" class="bp-field">
<mat-label>Party To</mat-label>
<input matInput required type="number" formControlName="partyTo" />
</mat-form-field>
}
<!-- Commission is worked out per agent on their whole collection, so a party range would
imply a filter that has no effect. -->
@if (!isCommissionJob) {
<div class="bp-range">
<span class="bp-group-label"><i class="fas fa-users"></i> Parties</span>
<div class="bp-range__fields">
<mat-form-field appearance="outline" class="bp-field" subscriptSizing="dynamic">
<mat-label>From</mat-label>
<input matInput required type="number" formControlName="partyFrom" />
</mat-form-field>
<span class="bp-range__to">to</span>
<mat-form-field appearance="outline" class="bp-field" subscriptSizing="dynamic">
<mat-label>To</mat-label>
<input matInput required type="number" formControlName="partyTo" />
</mat-form-field>
</div>
</div>
}
</div>
</div>
<div class="bp-callout">
<i class="fas fa-info-circle"></i>
<div class="bp-callout" [class.bp-callout--post]="isPostingJob">
<i class="fas" [ngClass]="isPostingJob ? 'fa-exchange-alt' : 'fa-info-circle'"></i>
<span>
@if (isPostingJob) {
<strong>{{ jobTypeLabel }}</strong> moves what has already been calculated to where it belongs — into the
@@ -95,113 +171,21 @@
</span>
</div>
@if (generatedRun) {
<div class="bp-section">
<div class="bp-section__header">
<i class="fas fa-table"></i>
<h3>{{ labelOf(generatedRun.jobType) }} — preview</h3>
<span class="bp-section__note"> Run #{{ generatedRun.id }} · nothing applied yet </span>
</div>
@if (previewRows.length === 0) {
<p class="bp-empty">
Nothing to apply. Either this period has already been processed, or there was no activity in it.
</p>
} @else {
<div class="bp-table-scroll">
@if (showsCommissionBreakdown) {
<table class="bp-table bp-table--breakdown">
<thead>
<tr>
<th>Agent</th>
<th>Period</th>
<th class="bp-num">Collection</th>
<th class="bp-num">Rate</th>
<th class="bp-num">Commission</th>
<th class="bp-num">TDS</th>
<th class="bp-num">Deductions</th>
<th class="bp-num">Payable</th>
</tr>
</thead>
<tbody>
@for (row of previewRows; track row.id) {
<tr>
<td>
<span class="bp-code">{{ row.agentCode }}</span>
<span class="bp-sub">{{ row.agentName }}</span>
</td>
<td class="bp-period">{{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}</td>
<td class="bp-num">{{ money(row.baseAmount) }}</td>
<td class="bp-num">{{ row.ratePercent }}%</td>
<td class="bp-num">{{ money(row.grossAmount) }}</td>
<td class="bp-num bp-minus">{{ money(row.tdsAmount) }}</td>
<td class="bp-num bp-minus">{{ money(row.deductionAmount) }}</td>
<td class="bp-num bp-total">{{ money(row.amount) }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="7">Total payable</td>
<td class="bp-num bp-total">{{ money(previewTotal) }}</td>
</tr>
</tfoot>
</table>
} @else {
<table class="bp-table">
<thead>
<tr>
<th>{{ isCommissionJob ? 'Agent' : 'Party' }}</th>
@if (!isCommissionJob) {
<th>Agent</th>
}
<th>Period</th>
<th class="bp-num">Amount</th>
</tr>
</thead>
<tbody>
@for (row of previewRows; track row.id) {
<tr>
<td>
@if (isCommissionJob) {
<span class="bp-code">{{ row.agentCode }}</span>
<span class="bp-sub">{{ row.agentName }}</span>
} @else {
<span class="bp-code">{{ row.partyCode }}</span>
<span class="bp-sub">{{ row.partyName }}</span>
}
</td>
@if (!isCommissionJob) {
<td>{{ row.agentCode }}</td>
}
<td class="bp-period">{{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}</td>
<td class="bp-num bp-total">{{ money(row.amount) }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td [attr.colspan]="isCommissionJob ? 2 : 3">Total</td>
<td class="bp-num bp-total">{{ money(previewTotal) }}</td>
</tr>
</tfoot>
</table>
}
</div>
}
</div>
}
<div class="bp-action-bar">
<button mat-raised-button type="button" (click)="exit()">Exit</button>
<button mat-stroked-button type="button" (click)="exit()">Exit</button>
<span class="bp-action-bar__spacer"></span>
@if (generatedRun) {
<button mat-stroked-button type="button" (click)="discard()">Discard</button>
<button mat-stroked-button type="button" (click)="discard()"><i class="fas fa-times"></i> Discard</button>
}
<button mat-stroked-button type="button" [disabled]="batchForm.invalid" (click)="generate()">
<i class="fas fa-calculator"></i> Generate
<button mat-stroked-button type="button" [disabled]="batchForm.invalid || generating" (click)="generate()">
@if (generating) {
<i class="fas fa-circle-notch fa-spin"></i> Generating…
} @else {
<i class="fas fa-calculator"></i> Generate
}
</button>
<button
mat-raised-button
mat-flat-button
color="primary"
type="button"
[disabled]="!generatedRun || previewRows.length === 0"
@@ -210,8 +194,153 @@
<i class="fas fa-check"></i> Post
</button>
</div>
</form>
</div>
</div>
<!-- ── 3. Preview ─────────────────────────────────────────────────────────────────────────── -->
@if (generatedRun) {
<div class="bp-card">
<div class="bp-step-head">
<span class="bp-step-no">3</span>
<div>
<h3>{{ labelOf(generatedRun.jobType) }} — preview</h3>
<p>Run #{{ generatedRun.id }} · nothing applied yet — check the figures, then Post.</p>
</div>
</div>
@if (previewRows.length === 0) {
<p class="bp-empty bp-empty--box">
<i class="fas fa-inbox"></i>
Nothing to apply. Either this period has already been processed, or there was no activity in it.
</p>
} @else {
<div class="bp-kpis">
<div class="bp-kpi">
<span>Agents</span><strong>{{ agentGroups.length }}</strong>
</div>
<div class="bp-kpi">
<span>{{ isCommissionJob ? 'Entries' : 'Accounts' }}</span
><strong>{{ previewRows.length }}</strong>
</div>
<div class="bp-kpi bp-kpi--accent">
<span>{{ showsCommissionBreakdown ? 'Total payable' : 'Total' }}</span>
<strong>₹ {{ money(previewTotal) }}</strong>
</div>
<div class="bp-kpi">
<span>Date</span><strong>{{ generatedRun.jobDate | dateFormat }}</strong>
</div>
</div>
<div class="bp-tools">
<span class="bp-tools__hint">
{{
isCommissionJob
? 'One line per agent - View shows how their commission was worked out.'
: 'Grouped by collecting agent - View shows the DDS accounts under each.'
}}
</span>
<span class="bp-tools__spacer"></span>
<button mat-stroked-button type="button" (click)="exportPreview()">
<i class="fas fa-file-export"></i> Export all details
</button>
</div>
<div class="bp-table-scroll">
@if (isCommissionJob) {
<table class="bp-table bp-table--breakdown">
<thead>
<tr>
<th>Agent</th>
<th>Period</th>
@if (showsCommissionBreakdown) {
<th class="bp-num">Collection</th>
<th class="bp-num">Rate</th>
<th class="bp-num">Commission</th>
<th class="bp-num">TDS</th>
<th class="bp-num">Deductions</th>
}
<th class="bp-num">{{ showsCommissionBreakdown ? 'Payable' : 'Amount' }}</th>
<th class="bp-view-col"></th>
</tr>
</thead>
<tbody>
@for (group of agentGroups; track group.key) {
@for (row of group.rows; track row.id) {
<tr>
<td>
<span class="bp-code">{{ row.agentCode }}</span>
<span class="bp-sub">{{ row.agentName }}</span>
</td>
<td class="bp-period">{{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}</td>
@if (showsCommissionBreakdown) {
<td class="bp-num">{{ money(row.baseAmount) }}</td>
<td class="bp-num">
<span class="bp-rate">{{ row.ratePercent }}%</span>
</td>
<td class="bp-num">{{ money(row.grossAmount) }}</td>
<td class="bp-num bp-minus">{{ money(row.tdsAmount) }}</td>
<td class="bp-num bp-minus">{{ money(row.deductionAmount) }}</td>
}
<td class="bp-num bp-total">{{ money(row.amount) }}</td>
<td class="bp-view-col">
<button mat-stroked-button type="button" class="bp-view-btn" (click)="viewAgent(group)">
<i class="fas fa-eye"></i> View
</button>
</td>
</tr>
}
}
</tbody>
<tfoot>
<tr>
<td [attr.colspan]="showsCommissionBreakdown ? 7 : 2">Total</td>
<td class="bp-num bp-total">{{ money(previewTotal) }}</td>
<td></td>
</tr>
</tfoot>
</table>
} @else {
<table class="bp-table">
<thead>
<tr>
<th>Agent</th>
<th class="bp-num">Accounts</th>
<th class="bp-num">Amount</th>
<th class="bp-view-col"></th>
</tr>
</thead>
<tbody>
@for (group of agentGroups; track group.key) {
<tr class="bp-agent-row">
<td>
<span class="bp-avatar">{{ group.initials }}</span>
<span class="bp-code bp-code--inline">{{ group.agentCode }}</span>
<span class="bp-sub bp-sub--inline">{{ group.agentName }}</span>
</td>
<td class="bp-num">{{ group.rows.length }}</td>
<td class="bp-num bp-total">{{ money(group.total) }}</td>
<td class="bp-view-col">
<button mat-stroked-button type="button" class="bp-view-btn" (click)="viewAgent(group)">
<i class="fas fa-eye"></i> View
</button>
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td>Total</td>
<td class="bp-num">{{ previewRows.length }}</td>
<td class="bp-num bp-total">{{ money(previewTotal) }}</td>
<td></td>
</tr>
</tfoot>
</table>
}
</div>
}
</div>
}
</form>
<!--
A crediting run costs what a savings deposit costs on every account it touches, so the server does
@@ -231,13 +360,39 @@
<!-- Without this a generated-but-unposted run vanishes the moment the page is left, and there is
no way to tell whether a month's interest was ever actually posted. -->
<div class="bp-card">
<div class="bp-section__header">
<i class="fas fa-history"></i>
<h3>Previous runs</h3>
<div class="bp-runs-head">
<div class="bp-step-head bp-step-head--plain">
<span class="bp-step-no bp-step-no--muted"><i class="fas fa-history"></i></span>
<div>
<h3>Previous runs</h3>
<p>Every run, and whether it was applied.</p>
</div>
</div>
@if (!runsLoading && recentRuns.length > 0) {
<div class="bp-stats">
<span class="bp-stat">{{ runStats.total }} runs</span>
<span class="bp-stat bp-stat--posted">{{ runStats.posted }} posted</span>
@if (runStats.pending) {
<span class="bp-stat">{{ runStats.pending }} not posted</span>
}
@if (runStats.running) {
<span class="bp-stat bp-stat--running">{{ runStats.running }} running</span>
}
@if (runStats.failed) {
<span class="bp-stat bp-stat--failed">{{ runStats.failed }} failed</span>
}
</div>
}
</div>
@if (recentRuns.length === 0) {
<p class="bp-empty">No batch has been run yet.</p>
@if (runsLoading) {
<div class="bp-skeleton">
@for (row of [1, 2, 3, 4]; track row) {
<span class="bp-skeleton__bar"></span>
}
</div>
} @else if (recentRuns.length === 0) {
<p class="bp-empty bp-empty--box"><i class="fas fa-inbox"></i> No batch has been run yet.</p>
} @else {
<div class="bp-table-scroll">
<table class="bp-table bp-table--runs">
@@ -255,8 +410,10 @@
<tbody>
@for (run of recentRuns; track run.id) {
<tr>
<td>{{ run.id }}</td>
<td>{{ labelOf(run.jobType) }}</td>
<td class="bp-runid">#{{ run.id }}</td>
<td>
<span class="bp-jobname">{{ labelOf(run.jobType) }}</span>
</td>
<td>{{ run.jobDate | dateFormat }}</td>
<td class="bp-num">{{ run.totalRows }}</td>
<td class="bp-num">{{ money(run.totalAmount) }}</td>
@@ -268,7 +425,7 @@
<td>
@switch (run.status) {
@case ('POSTED') {
<span class="bp-status bp-status--posted">Posted</span>
<span class="bp-status bp-status--posted"><i class="fas fa-check-circle"></i> Posted</span>
}
@case ('POSTING') {
<span class="bp-status bp-status--running">
@@ -280,11 +437,11 @@
class="bp-status bp-status--failed"
[matTooltip]="run.failureReason || 'No reason recorded'"
>
Failed
<i class="fas fa-exclamation-circle"></i> Failed
</span>
}
@default {
<span class="bp-status">Not posted</span>
<span class="bp-status"><i class="far fa-clock"></i> Not posted</span>
}
}
</td>
@@ -296,4 +453,124 @@
</div>
}
</div>
<!-- One agent's accounts (or commission breakdown) in a popup, opened from View. -->
<ng-template #agentDialog>
@if (selectedGroup; as group) {
<div class="bpd">
<div class="bpd-head">
<span class="bpd-av">{{ group.initials }}</span>
<div class="bpd-head__text">
<h3>
<span class="bp-code bp-code--inline">{{ group.agentCode }}</span> {{ group.agentName }}
</h3>
<p>
{{ labelOf(generatedRun?.jobType) }} · Run #{{ generatedRun?.id }} ·
{{ generatedRun?.jobDate | dateFormat }}
</p>
</div>
<button type="button" class="bpd-close" mat-dialog-close aria-label="Close">
<i class="fas fa-times"></i>
</button>
</div>
<div class="bpd-body">
@if (isCommissionJob) {
@for (row of group.rows; track row.id) {
<div class="bpd-grid">
@if (showsCommissionBreakdown) {
<div class="bpd-cell">
<span>Collection</span><strong>₹ {{ money(row.baseAmount) }}</strong>
</div>
<div class="bpd-cell">
<span>Slab rate</span><strong>{{ row.ratePercent }}%</strong>
</div>
<div class="bpd-cell">
<span>Commission</span><strong>₹ {{ money(row.grossAmount) }}</strong>
</div>
<div class="bpd-cell">
<span>– TDS</span><strong class="bp-minus">₹ {{ money(row.tdsAmount) }}</strong>
</div>
<div class="bpd-cell">
<span>– Deductions</span><strong class="bp-minus">₹ {{ money(row.deductionAmount) }}</strong>
</div>
}
<div class="bpd-cell bpd-cell--net">
<span>{{ showsCommissionBreakdown ? 'Payable to agent' : 'To be paid out' }}</span>
<strong>₹ {{ money(row.amount) }}</strong>
</div>
</div>
<p class="bpd-note">
Period {{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}. The agent's collection from all
their customers is added up and one slab rate applies to the whole figure.
</p>
}
} @else {
<div class="bpd-kpis">
<div class="bpd-cell">
<span>Accounts</span><strong>{{ group.rows.length }}</strong>
</div>
<div class="bpd-cell bpd-cell--net">
<span>Total</span><strong>₹ {{ money(group.total) }}</strong>
</div>
</div>
<input
type="search"
class="bpd-search"
placeholder="Search party code or name"
[value]="agentSearch"
(input)="agentSearch = $any($event.target).value"
/>
<div class="bp-table-scroll bpd-scroll">
<table class="bp-table">
<thead>
<tr>
<th>#</th>
<th>Party</th>
<th>Period</th>
<th class="bp-num">Rate</th>
<th class="bp-num">Amount</th>
</tr>
</thead>
<tbody>
@for (row of popupRows; track row.id; let i = $index) {
<tr>
<td class="bp-runid">{{ i + 1 }}</td>
<td>
<span class="bp-code bp-code--inline">{{ row.partyCode }}</span>
<span class="bp-sub bp-sub--inline">{{ row.partyName }}</span>
</td>
<td class="bp-period">{{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}</td>
<td class="bp-num">{{ row.ratePercent ? row.ratePercent + '%' : '—' }}</td>
<td class="bp-num bp-total">{{ money(row.amount) }}</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="bp-empty">No account matches the search.</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="4">{{ agentSearch ? 'Total of the accounts shown' : 'Total' }}</td>
<td class="bp-num bp-total">{{ money(popupTotal) }}</td>
</tr>
</tfoot>
</table>
</div>
}
</div>
<div class="bpd-actions">
<button mat-stroked-button type="button" mat-dialog-close>Close</button>
<span class="bp-action-bar__spacer"></span>
<button mat-flat-button color="primary" type="button" (click)="exportAgent(group)">
<i class="fas fa-file-export"></i> Export this agent
</button>
</div>
</div>
}
</ng-template>
</div>
@@ -6,28 +6,54 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
// The DDS screens' teal, with amber for "moves money" and the usual green/red for done/failed.
$primary: #0f766e;
$primary-dark: #134e4a;
$primary-soft: #e6f4ee;
$border: #e2e8f0;
$text: #0f172a;
$text-mid: #475569;
$text-soft: #94a3b8;
$amber: #b45309;
$amber-soft: #fffbeb;
$green: #15803d;
$red: #b91c1c;
.bp-container {
// The commission breakdown carries eight columns; anything narrower makes it scroll on every
// screen rather than only on small ones.
max-width: 1180px;
max-width: 1120px;
margin: 0 auto;
padding: 24px 16px;
display: flex;
flex-direction: column;
gap: 20px;
padding: 24px 16px 40px;
color: $text;
}
// ── Header ────────────────────────────────────────────────────────────────────────────────────────────
.bp-header {
display: flex;
align-items: center;
gap: 16px;
gap: 14px;
margin-bottom: 16px;
&__ic {
display: grid;
place-items: center;
flex: none;
width: 44px;
height: 44px;
border-radius: 12px;
background: linear-gradient(135deg, $primary, $primary-dark);
color: #fff;
}
&__title {
margin: 0;
font-size: 22px;
font-weight: 500;
font-size: 21px;
font-weight: 600;
}
&__sub {
margin: 2px 0 0;
font-size: 13px;
color: $text-mid;
}
}
@@ -35,90 +61,365 @@ $primary: #0f766e;
color: $primary;
}
// ── Cards and step headings ───────────────────────────────────────────────────────────────────────────
.bp-card {
margin-bottom: 16px;
padding: 18px 20px 20px;
border: 1px solid $border;
border-radius: 12px;
background: var(--md-sys-color-surface, #fff);
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
padding: 20px 24px 24px;
box-shadow: 0 1px 3px rgb(0 0 0 / 5%);
}
.bp-step-head {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 14px;
h3 {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin: 0;
font-size: 15px;
font-weight: 700;
}
p {
margin: 2px 0 0;
font-size: 12.5px;
color: $text-mid;
}
&--plain {
margin-bottom: 0;
}
}
.bp-step-no {
display: grid;
place-items: center;
flex: none;
width: 28px;
height: 28px;
border-radius: 50%;
background: $primary;
color: #fff;
font-size: 13px;
font-weight: 700;
&--muted {
background: $primary-soft;
color: $primary;
}
}
.bp-kind {
padding: 2px 10px;
border-radius: 999px;
background: $primary-soft;
color: $primary;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
&--post {
background: #fef3c7;
color: $amber;
}
}
// ── 1. Job tiles ──────────────────────────────────────────────────────────────────────────────────────
.bp-jobs {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
@media (width <= 980px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (width <= 560px) {
grid-template-columns: 1fr;
}
}
.bp-job {
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px;
border: 1.5px solid $border;
border-radius: 12px;
background: #fff;
transition:
border-color 0.15s,
box-shadow 0.15s;
&--active {
border-color: $primary;
box-shadow: 0 0 0 3px rgb(15 118 110 / 12%);
}
&__head {
display: flex;
align-items: flex-start;
gap: 10px;
}
&__ic {
display: grid;
place-items: center;
flex: none;
width: 34px;
height: 34px;
border-radius: 9px;
background: $primary-soft;
color: $primary;
}
&__title {
display: block;
font-size: 14px;
font-weight: 700;
}
&__hint {
display: block;
margin-top: 2px;
font-size: 11.5px;
line-height: 1.35;
color: $text-mid;
}
&__steps {
display: flex;
align-items: center;
gap: 6px;
margin-top: auto;
}
&__step {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 7px 8px;
border: 1px solid $border;
border-radius: 8px;
background: #f8fafc;
color: $text-mid;
font: inherit;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
&:hover {
border-color: $primary;
color: $primary;
}
&--on {
border-color: $primary;
background: $primary;
color: #fff;
&:hover {
color: #fff;
}
}
}
&__arrow {
font-size: 11px;
color: $text-soft;
}
}
// ── 2. Scope and date ─────────────────────────────────────────────────────────────────────────────────
.bp-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px 16px;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px 16px;
@media (width <= 860px) {
grid-template-columns: 1fr;
}
}
.bp-field {
width: 100%;
&--wide {
grid-column: span 3;
grid-column: 1 / -1;
}
}
.bp-group-label {
display: block;
margin-bottom: 6px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: $text-soft;
i {
margin-right: 4px;
}
}
.bp-ranges {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
margin-top: 4px;
@media (width <= 860px) {
grid-template-columns: 1fr;
}
}
.bp-range {
padding: 10px 12px 12px;
border: 1px solid $border;
border-radius: 10px;
background: #f8fafc;
&__fields {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 10px;
}
&__to {
font-size: 12px;
color: $text-soft;
}
}
.bp-branch-checklist {
display: flex;
flex-wrap: wrap;
gap: 8px 20px;
align-items: center;
padding: 8px 0;
padding: 10px 12px;
border: 1px solid $border;
border-radius: 10px;
background: #f8fafc;
&__list {
display: flex;
flex-wrap: wrap;
gap: 4px 18px;
}
}
.bp-callout {
display: flex;
gap: 10px;
align-items: flex-start;
background: rgb(15 118 110 / 6%);
border-left: 3px solid $primary;
border-radius: 0 8px 8px 0;
padding: 12px 16px;
margin: 16px 0;
gap: 10px;
margin-top: 16px;
padding: 11px 14px;
border: 1px solid #bae6fd;
border-radius: 10px;
background: #f0f9ff;
color: #0c4a6e;
font-size: 13px;
line-height: 1.5;
i {
color: $primary;
margin-top: 2px;
margin-top: 3px;
}
&--post {
border-color: #fde68a;
background: $amber-soft;
color: $amber;
}
}
.bp-section {
padding: 16px 0;
border-top: 1px solid rgb(0 0 0 / 8%);
.bp-action-bar {
display: flex;
align-items: center;
gap: 10px;
margin-top: 16px;
padding-top: 14px;
border-top: 1px solid $border;
&__header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
i {
color: $primary;
}
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
flex: 0 0 auto;
}
&__spacer {
flex: 1;
}
&__note {
margin-left: auto;
font-size: 12px;
color: rgb(0 0 0 / 45%);
i {
margin-right: 4px;
}
}
// ── 3. Preview ────────────────────────────────────────────────────────────────────────────────────────
.bp-kpis {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin-bottom: 12px;
}
.bp-kpi {
display: flex;
flex-direction: column;
gap: 3px;
padding: 10px 14px;
border: 1px solid $border;
border-radius: 10px;
background: #f8fafc;
span {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: $text-soft;
}
strong {
font-size: 17px;
font-variant-numeric: tabular-nums;
}
&--accent {
border-color: #a7f3d0;
background: $primary-soft;
strong {
color: $primary-dark;
}
}
}
.bp-empty {
color: rgb(0 0 0 / 50%);
font-size: 14px;
margin: 0;
margin: 6px 0;
font-size: 13px;
color: $text-mid;
&--box {
padding: 16px;
border: 1.5px dashed #cbd5e1;
border-radius: 10px;
background: #f8fafc;
text-align: center;
}
i {
margin-right: 6px;
}
}
// A wide table scrolls inside its own box rather than pushing the whole page sideways.
// ── Tables ────────────────────────────────────────────────────────────────────────────────────────────
.bp-table-scroll {
overflow-x: auto;
border: 1px solid $border;
border-radius: 10px;
}
.bp-table {
@@ -127,129 +428,393 @@ $primary: #0f766e;
font-size: 13px;
th {
padding: 9px 12px;
border-bottom: 1px solid $border;
background: #f8fafc;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
text-align: left;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: rgb(0 0 0 / 55%);
padding: 8px 10px;
border-bottom: 2px solid rgb(0 0 0 / 10%);
white-space: nowrap;
color: $text-soft;
}
td {
padding: 7px 10px;
border-bottom: 1px solid rgb(0 0 0 / 6%);
vertical-align: top;
padding: 9px 12px;
border-bottom: 1px solid #eef2f6;
vertical-align: middle;
}
tbody tr:hover {
background: #f0fdf9;
}
tbody tr:last-child td {
border-bottom: 0;
}
tfoot td {
border-bottom: none;
border-top: 2px solid rgb(0 0 0 / 10%);
font-weight: 600;
border-top: 1.5px solid $border;
background: $primary-soft;
font-weight: 700;
color: $primary-dark;
}
}
// Figures line up column-wise so a wrong order of magnitude is visible at a glance.
.bp-num {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.bp-num {
text-align: right !important;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.bp-minus {
color: #b91c1c;
}
.bp-minus {
color: $red;
}
.bp-total {
font-weight: 600;
}
.bp-total {
font-weight: 700;
}
.bp-period {
white-space: nowrap;
color: rgb(0 0 0 / 60%);
}
.bp-period {
white-space: nowrap;
color: $text-mid;
}
.bp-code {
display: block;
font-weight: 600;
font-family: ui-monospace, 'Cascadia Mono', Consolas, monospace;
font-size: 12.5px;
font-weight: 700;
color: $primary-dark;
&--muted {
color: $text-mid;
}
}
.bp-sub {
display: block;
font-size: 12px;
color: rgb(0 0 0 / 55%);
color: $text-mid;
}
.bp-status {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
.bp-rate {
padding: 1px 7px;
border-radius: 6px;
background: $primary-soft;
color: $primary;
font-weight: 700;
}
// ── Previous runs ─────────────────────────────────────────────────────────────────────────────────────
.bp-runs-head {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.bp-stats {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.bp-stat {
padding: 3px 10px;
border-radius: 999px;
background: #f1f5f9;
color: $text-mid;
font-size: 12px;
font-weight: 600;
background: rgb(180 83 9 / 12%);
color: #92400e;
&--posted {
background: rgb(15 118 110 / 12%);
color: $primary;
background: #dcfce7;
color: $green;
}
/* Still crediting. Blue rather than the amber of a run nobody has posted — one is work in hand,
the other is work forgotten, and a clerk who reads them the same way runs it twice. */
&--running {
background: rgb(22 84 143 / 12%);
color: #16548f;
display: inline-flex;
align-items: center;
gap: 5px;
background: #dbeafe;
color: #1d4ed8;
}
&--failed {
background: rgb(163 42 28 / 12%);
color: #a32a1c;
background: #fee2e2;
color: $red;
}
}
.bp-runid {
font-family: ui-monospace, 'Cascadia Mono', Consolas, monospace;
color: $text-mid;
}
.bp-jobname {
font-weight: 600;
}
.bp-status {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 10px;
border-radius: 999px;
background: #f1f5f9;
color: $text-mid;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
&--posted {
background: #dcfce7;
color: $green;
}
&--running {
background: #dbeafe;
color: #1d4ed8;
}
&--failed {
background: #fee2e2;
color: $red;
cursor: help;
}
}
/* Says a run is still going after the clerk has left the button behind. */
.bp-running {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
padding: 12px 16px;
border: 1px solid rgb(22 84 143 / 25%);
border-left: 3px solid #16548f;
border-radius: 6px;
background: rgb(22 84 143 / 6%);
color: #16548f;
border: 1px solid #bfdbfe;
border-radius: 10px;
background: #eff6ff;
color: #1e3a8a;
font-size: 13px;
}
strong {
font-weight: 600;
// Loading placeholder for the runs list.
.bp-skeleton {
display: flex;
flex-direction: column;
gap: 10px;
&__bar {
height: 14px;
border-radius: 6px;
background: linear-gradient(90deg, #eef2f6 25%, #e2e8f0 37%, #eef2f6 63%);
background-size: 400% 100%;
animation: bp-shimmer 1.2s ease-in-out infinite;
}
}
.bp-action-bar {
@keyframes bp-shimmer {
0% {
background-position: 100% 50%;
}
100% {
background-position: 0 50%;
}
}
// ── Agent-wise preview: tools row, agent lines, View button ───────────────────────────────────────────
.bp-tools {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid rgb(0 0 0 / 8%);
align-items: center;
gap: 10px;
margin-bottom: 10px;
flex-wrap: wrap;
&__hint {
font-size: 12.5px;
color: $text-mid;
}
&__spacer {
flex: 1;
}
i {
margin-right: 6px;
}
}
@media (width <= 900px) {
.bp-grid {
grid-template-columns: repeat(2, 1fr);
}
.bp-agent-row td {
padding-block: 11px;
}
.bp-field--wide {
grid-column: span 2;
.bp-avatar {
display: inline-grid;
place-items: center;
width: 30px;
height: 30px;
margin-right: 10px;
border-radius: 50%;
background: $primary-soft;
color: $primary;
font-size: 12px;
font-weight: 700;
vertical-align: middle;
}
.bp-code--inline {
display: inline;
margin-right: 8px;
}
.bp-sub--inline {
display: inline;
}
.bp-view-col {
width: 1%;
text-align: right;
white-space: nowrap;
}
.bp-view-btn {
color: $primary;
i {
margin-right: 6px;
}
}
// ── One agent's accounts, in a popup ──────────────────────────────────────────────────────────────────
.bpd {
display: flex;
flex-direction: column;
max-height: 88vh;
color: $text;
&-head {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid $border;
&__text {
flex: 1;
min-width: 0;
h3 {
margin: 0;
font-size: 16px;
font-weight: 700;
}
p {
margin: 2px 0 0;
font-size: 12px;
color: $text-mid;
}
}
}
&-av {
display: grid;
place-items: center;
flex: none;
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, $primary, $primary-dark);
color: #fff;
font-weight: 700;
}
&-close {
border: 0;
background: transparent;
color: $text-mid;
font-size: 16px;
cursor: pointer;
}
&-body {
flex: 1;
min-height: 0;
padding: 14px 20px;
overflow-y: auto;
}
&-kpis,
&-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
margin-bottom: 12px;
}
&-cell {
display: flex;
flex-direction: column;
gap: 3px;
padding: 10px 14px;
border: 1px solid $border;
border-radius: 10px;
background: #f8fafc;
span {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: $text-soft;
}
strong {
font-size: 16px;
font-variant-numeric: tabular-nums;
}
&--net {
border-color: #a7f3d0;
background: $primary-soft;
strong {
color: $primary-dark;
}
}
}
&-search {
width: 100%;
margin-bottom: 10px;
padding: 9px 12px;
border: 1px solid $border;
border-radius: 8px;
font: inherit;
font-size: 13px;
}
&-scroll {
max-height: 44vh;
overflow-y: auto;
}
&-note {
margin: 0 0 10px;
font-size: 12.5px;
color: $text-mid;
}
&-actions {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 20px;
border-top: 1px solid $border;
i {
margin-right: 6px;
}
}
}
@@ -7,10 +7,10 @@
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import { Component, OnInit, TemplateRef, ViewChild, inject } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
import { MatDialog, MatDialogClose } from '@angular/material/dialog';
import { MatTooltip } from '@angular/material/tooltip';
import { DateAdapter, MAT_DATE_FORMATS } from '@angular/material/core';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -24,6 +24,16 @@ import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-approvals/dds-approval-notice';
/** One agent's lines of a previewed run: an agent and the DDS accounts under them (or, for commission, their one row). */
export interface AgentGroup {
key: string;
agentCode: string;
agentName: string;
initials: string;
rows: any[];
total: number;
}
/**
* DDS Interest & Commission batch processing — the legacy Interest Part menu.
*
@@ -42,7 +52,8 @@ import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-appro
imports: [
...STANDALONE_SHARED_IMPORTS,
// The reason a run failed is too long to sit in the column, and too useful to leave out.
MatTooltip
MatTooltip,
MatDialogClose
],
// Day-first dates, as the legacy screen showed them. Declared here rather than application-wide:
// a @Component's providers reach only this component, so no other screen's datepicker changes.
@@ -67,8 +78,8 @@ export class DdsBatchProcessingComponent implements OnInit {
jobTypes: Array<{ label: string; code: string }> = [
{ label: 'Interest Calculation', code: 'INTEREST_CALCULATION' },
{ label: 'Interest Posting', code: 'INTEREST_POSTING' },
{ label: 'OD Interest Calculation', code: 'OD_INTEREST_CALCULATION' },
{ label: 'OD Interest Posting', code: 'OD_INTEREST_POSTING' },
// OD Interest Calculation / Posting are not offered to run. Their names stay in RUN_LABELS so a
// run of either already on the list below still reads by name.
{ label: 'Commission Calculation', code: 'COMMISSION_CALCULATION' },
{ label: 'Commission Posting', code: 'COMMISSION_POSTING' },
// Last, and on its own: the others move money onto accounts that stay open, while this one pays
@@ -82,6 +93,306 @@ export class DdsBatchProcessingComponent implements OnInit {
'Selected'
];
/**
* The job menu as tiles, each calculate/post pair side by side, so the order a month is run in
* reads off the screen: calculate, check, then post. Maturity Auto Close stands on its own.
*/
readonly jobGroups: Array<{ title: string; icon: string; hint: string; steps: string[] }> = [
{
title: 'Interest',
icon: 'fa-percent',
hint: 'Customer interest on the daily balance',
steps: [
'INTEREST_CALCULATION',
'INTEREST_POSTING'
]
},
{
title: 'Agent Commission',
icon: 'fa-user-tie',
hint: 'Agent commission on collection, from the slab chart',
steps: [
'COMMISSION_CALCULATION',
'COMMISSION_POSTING'
]
},
{
title: 'Maturity',
icon: 'fa-flag-checkered',
hint: 'Pays out and closes matured accounts',
steps: ['MATURITY_AUTO_CLOSE']
}
];
/** Picks a job from its tile - the same control the form validates. */
selectJob(code: string): void {
this.batchForm.get('jobType')?.setValue(code);
}
/** "Calculate" or "Post" (or "Run") - what a tile's button says. */
stepLabel(code: string): string {
if (code.endsWith('_CALCULATION')) {
return 'Calculate';
}
return code.endsWith('_POSTING') ? 'Post' : 'Run';
}
@ViewChild('agentDialog') agentDialog: TemplateRef<unknown>;
/** The agent whose accounts the popup is showing, and what is typed in its search box. */
selectedGroup: AgentGroup | null = null;
agentSearch = '';
private groupsFor: any[] | null = null;
private groups: AgentGroup[] = [];
/**
* The preview as agents with their accounts under them. A run can list a thousand accounts; read
* agent by agent it is a handful of lines, and each agent's accounts open in a popup on View.
* Built once per Generate, not on every change detection pass.
*/
get agentGroups(): AgentGroup[] {
if (this.groupsFor !== this.previewRows) {
const byAgent = new Map<string, AgentGroup>();
for (const row of this.previewRows) {
const key = String(row.agentId ?? row.agentCode ?? '');
let group = byAgent.get(key);
if (!group) {
const name = String(row.agentName ?? '');
group = {
key,
agentCode: row.agentCode ?? '',
agentName: name,
initials: name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((word) => word[0].toUpperCase())
.join(''),
rows: [],
total: 0
};
byAgent.set(key, group);
}
group.rows.push(row);
group.total += Number(row.amount || 0);
}
this.groupsFor = this.previewRows;
this.groups = Array.from(byAgent.values());
}
return this.groups;
}
/** The popup's accounts, narrowed by its search box (party code or name). */
get popupRows(): any[] {
const rows = this.selectedGroup?.rows ?? [];
const term = this.agentSearch.trim().toLowerCase();
if (!term) {
return rows;
}
return rows.filter((row) => `${row.partyCode ?? ''} ${row.partyName ?? ''}`.toLowerCase().includes(term));
}
get popupTotal(): number {
return this.popupRows.reduce((total, row) => total + Number(row.amount || 0), 0);
}
/** The agent's accounts (or commission breakdown) in a popup. */
viewAgent(group: AgentGroup): void {
this.selectedGroup = group;
this.agentSearch = '';
this.dialog.open(this.agentDialog, { width: '860px', maxWidth: '95vw', autoFocus: false });
}
/** A date as the server sent it - text, or a [year, month, day] array - for the export file. */
private dateText(value: any): string {
if (!value) {
return '';
}
if (Array.isArray(value)) {
return `${value[0]}-${String(value[1]).padStart(2, '0')}-${String(value[2]).padStart(2, '0')}`;
}
return String(value);
}
private csvAmount(value: any): string {
return value === null || value === undefined || value === '' ? '' : Number(value).toFixed(2);
}
/**
* Every line of the preview, agent by agent, with each agent's subtotal and the run's total - the
* same figures the screen shows, in a file a branch can keep or hand to an auditor. An interest
* run lists each agent's accounts; a commission run lists each agent's breakdown.
*/
exportPreview(): void {
if (!this.generatedRun || this.previewRows.length === 0) {
return;
}
this.downloadCsv(this.agentGroups, true);
}
/** The one agent open in the popup, with the accounts the search is showing. */
exportAgent(group: AgentGroup): void {
this.downloadCsv([{ ...group, rows: this.popupRows, total: this.popupTotal }], false);
}
private downloadCsv(groups: AgentGroup[], withRunTotal: boolean): void {
const run = this.generatedRun;
const quote = (value: any) => `"${String(value ?? '').replace(/"/g, '""')}"`;
const lines: string[] = [];
if (this.isCommissionJob) {
lines.push(
[
'Agent Code',
'Agent Name',
'From',
'To',
'Collection',
'Rate %',
'Commission',
'TDS',
'Deductions',
'Payable'
]
.map(quote)
.join(',')
);
for (const group of groups) {
for (const row of group.rows) {
lines.push(
[
group.agentCode,
group.agentName,
this.dateText(row.fromDate),
this.dateText(row.toDate),
this.csvAmount(row.baseAmount),
row.ratePercent ?? '',
this.csvAmount(row.grossAmount),
this.csvAmount(row.tdsAmount),
this.csvAmount(row.deductionAmount),
this.csvAmount(row.amount)
]
.map(quote)
.join(',')
);
}
}
if (withRunTotal) {
lines.push(
[
'Total',
'',
'',
'',
'',
'',
'',
'',
'',
this.csvAmount(groups.reduce((t, g) => t + g.total, 0))
]
.map(quote)
.join(',')
);
}
} else {
lines.push(
[
'Agent Code',
'Agent Name',
'Party Code',
'Party Name',
'From',
'To',
'Rate %',
'Amount'
]
.map(quote)
.join(',')
);
for (const group of groups) {
for (const row of group.rows) {
lines.push(
[
group.agentCode,
group.agentName,
row.partyCode,
row.partyName,
this.dateText(row.fromDate),
this.dateText(row.toDate),
row.ratePercent ?? '',
this.csvAmount(row.amount)
]
.map(quote)
.join(',')
);
}
lines.push(
[
group.agentCode,
group.agentName,
'',
`Agent total (${group.rows.length} accounts)`,
'',
'',
'',
this.csvAmount(group.total)
]
.map(quote)
.join(',')
);
}
if (withRunTotal) {
lines.push(
[
'Run total',
'',
'',
`${groups.length} agents, ${groups.reduce((t, g) => t + g.rows.length, 0)} accounts`,
'',
'',
'',
this.csvAmount(groups.reduce((t, g) => t + g.total, 0))
]
.map(quote)
.join(',')
);
}
}
// A byte-order mark so Excel opens the rupee sign and any non-English names correctly.
const blob = new Blob(['\ufeff' + lines.join('\r\n')], { type: 'text/csv;charset=utf-8' });
const name = withRunTotal
? `${this.labelOf(run.jobType)}-run-${run.id}`
: `${this.labelOf(run.jobType)}-run-${run.id}-${groups[0]?.agentCode ?? 'agent'}`;
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `${name.replace(/\s+/g, '-').toLowerCase()}.csv`;
link.click();
URL.revokeObjectURL(link.href);
}
/** True while Generate is working out the preview. */
generating = false;
/** True while the list of previous runs is being fetched. */
runsLoading = true;
/** How the runs on the list stand, for the summary above it. */
get runStats(): { total: number; posted: number; running: number; failed: number; pending: number } {
const count = (status: string) => this.recentRuns.filter((run) => run.status === status).length;
const posted = count('POSTED');
const running = count('POSTING');
const failed = count('FAILED');
return {
total: this.recentRuns.length,
posted,
running,
failed,
pending: this.recentRuns.length - posted - running - failed
};
}
private static readonly MONEY = new Intl.NumberFormat('en-IN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
@@ -175,8 +486,16 @@ export class DdsBatchProcessingComponent implements OnInit {
return DdsBatchProcessingComponent.MONEY.format(Number(value));
}
/** Every job a past run can have been, including those no longer offered to run. */
private static readonly RUN_LABELS: Record<string, string> = {
OD_INTEREST_CALCULATION: 'OD Interest Calculation',
OD_INTEREST_POSTING: 'OD Interest Posting'
};
labelOf(code: string): string {
return this.jobTypes.find((type) => type.code === code)?.label ?? code;
return (
this.jobTypes.find((type) => type.code === code)?.label ?? DdsBatchProcessingComponent.RUN_LABELS[code] ?? code
);
}
toggleOffice(officeName: string): void {
@@ -193,8 +512,13 @@ export class DdsBatchProcessingComponent implements OnInit {
* was ever actually posted.
*/
private loadRecentRuns(): void {
this.organizationService.getDdsBatchRuns().subscribe((runs: any[]) => {
this.recentRuns = runs || [];
this.runsLoading = true;
this.organizationService.getDdsBatchRuns().subscribe({
next: (runs: any[]) => {
this.recentRuns = runs || [];
this.runsLoading = false;
},
error: () => (this.runsLoading = false)
});
}
@@ -224,27 +548,35 @@ export class DdsBatchProcessingComponent implements OnInit {
payload.officeNames = Array.from(this.selectedOfficeNames);
}
this.organizationService.generateDdsBatchRun(payload).subscribe((result: any) => {
this.organizationService.getDdsBatchRun(result.resourceId).subscribe((batchRun: any) => {
this.generatedRun = batchRun;
this.previewRows = batchRun.rows || [];
this.generating = true;
this.organizationService.generateDdsBatchRun(payload).subscribe({
error: () => (this.generating = false),
next: (result: any) => {
this.organizationService.getDdsBatchRun(result.resourceId).subscribe({
error: () => (this.generating = false),
next: (batchRun: any) => {
this.generating = false;
this.generatedRun = batchRun;
this.previewRows = batchRun.rows || [];
// A run that found nothing is the normal outcome of a period already processed, not a
// failure — but it has to say so, or an empty table reads as the screen being broken.
if (this.previewRows.length === 0) {
this.alertService.alert({
type: 'Information',
message: `${this.jobTypeLabel} found nothing to apply. Either this period has already been processed, or there was no activity in it.`
});
} else {
this.alertService.alert({
type: 'Success',
message: `${this.jobTypeLabel}: ${this.previewRows.length} ${this.previewRows.length === 1 ? 'entry' : 'entries'} totalling ${this.money(this.previewTotal)} calculated. Nothing has been applied yet — review and press Post.`
});
}
// A run that found nothing is the normal outcome of a period already processed, not a
// failure — but it has to say so, or an empty table reads as the screen being broken.
if (this.previewRows.length === 0) {
this.alertService.alert({
type: 'Information',
message: `${this.jobTypeLabel} found nothing to apply. Either this period has already been processed, or there was no activity in it.`
});
} else {
this.alertService.alert({
type: 'Success',
message: `${this.jobTypeLabel}: ${this.previewRows.length} ${this.previewRows.length === 1 ? 'entry' : 'entries'} totalling ${this.money(this.previewTotal)} calculated. Nothing has been applied yet — review and press Post.`
});
}
this.loadRecentRuns();
});
this.loadRecentRuns();
}
});
}
});
}
@@ -389,9 +721,17 @@ export class DdsBatchProcessingComponent implements OnInit {
private static readonly MAX_STATUS_CHECKS = 300;
/** Drops the preview without applying it — the safe way out of a calculation that looks wrong. */
/** Throws the preview away - off the screen and off the server, so it does not linger as "Not posted". */
discard(): void {
const run = this.generatedRun;
this.generatedRun = null;
this.previewRows = [];
if (run?.id) {
this.organizationService.discardDdsBatchRun(run.id).subscribe({
next: () => this.loadRecentRuns(),
error: () => this.loadRecentRuns()
});
}
}
exit(): void {
@@ -20,7 +20,6 @@ import { OfficesComponent } from './offices/offices.component';
import { AgentMasterComponent } from './agent-master/agent-master.component';
import { CreateAgentComponent } from './agent-master/create-agent/create-agent.component';
import { ViewAgentComponent } from './agent-master/view-agent/view-agent.component';
import { CommissionSlabMasterComponent } from './commission-slab-master/commission-slab-master.component';
import { DdsBatchProcessingComponent } from './dds-batch-processing/dds-batch-processing.component';
import { EmployeesComponent } from './employees/employees.component';
import { CreateEmployeeComponent } from './employees/create-employee/create-employee.component';
@@ -267,11 +266,6 @@ const routes: Routes = [
}
]
},
{
path: 'commission-slab-master',
component: CommissionSlabMasterComponent,
data: { title: 'Commission Slab Master', breadcrumb: 'Commission Slab Master' }
},
{
path: 'dds-batch-processing',
component: DdsBatchProcessingComponent,
@@ -225,19 +225,6 @@
</a>
</div>
<div #commissionSlabMaster>
<a class="org-card" [routerLink]="['commission-slab-master']">
<div class="org-card__icon org-card__icon--teal">
<i class="fas fa-percentage"></i>
</div>
<div class="org-card__body">
<h3 class="org-card__title">Commission Slab Master</h3>
<p class="org-card__desc">Define collection-volume and override commission percentage slabs</p>
</div>
<i class="fas fa-chevron-right org-card__arrow"></i>
</a>
</div>
<div #ddsBatchProcessing>
<a class="org-card" [routerLink]="['dds-batch-processing']">
<div class="org-card__icon org-card__icon--teal">
@@ -541,6 +541,11 @@ export class OrganizationService {
return this.http.get('/dds/batchruns');
}
/** Deletes a run that was only previewed (generated, never posted). */
discardDdsBatchRun(batchRunId: string | number): Observable<any> {
return this.http.delete(`/dds/batchruns/${batchRunId}`);
}
/**
* Ends the relationship with an agent. Refused while they still have accounts assigned, hold
* collected cash, or are owed commission — distinct from deactivating, which is never refused.
@@ -0,0 +1,311 @@
<!--
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="csm-container">
<div class="csm-header">
<button mat-stroked-button [routerLink]="['../']" class="csm-back">
<i class="fas fa-arrow-left"></i> {{ 'labels.heading.Products' | translate }}
</button>
<div class="csm-header__ic"><i class="fas fa-percent"></i></div>
<div>
<h2 class="csm-header__title">Commission Slab Master</h2>
<p class="csm-header__sub">The DDS agent commission chart - a percentage on each agent's collection volume</p>
</div>
<span class="csm-header__spacer"></span>
<button mat-flat-button color="primary" type="button" class="csm-calc-btn" (click)="openCalculator()">
<i class="fas fa-calculator"></i> Commission Calculator
</button>
</div>
<!-- How the chart is read, so nobody has to guess what a slab does. -->
<div class="csm-howto">
<div class="csm-howto__step">
<span class="csm-howto__no">1</span>
<span>The commission run adds up each agent's collections not yet counted.</span>
</div>
<div class="csm-howto__step">
<span class="csm-howto__no">2</span>
<span>It finds the <b>Account</b> slab where <b>From ≤ collection ≤ To</b>.</span>
</div>
<div class="csm-howto__step">
<span class="csm-howto__no">3</span>
<span>Commission = collection × slab % . TDS and the agent's deductions come off that.</span>
</div>
<div class="csm-howto__step csm-howto__step--warn">
<span class="csm-howto__no">!</span>
<span
>A collection with no slab (a gap, below the first or above the last) stops the run. Start each slab
<b>0.01</b> after the last one ends.</span
>
</div>
</div>
<div class="csm-card">
<div class="csm-card__head">
<h3>Slabs</h3>
@if (loading) {
<span class="csm-card__count csm-card__count--loading"
><mat-spinner diameter="14" class="csm-spin"></mat-spinner> Loading slabs…</span
>
} @else {
<span class="csm-card__count">{{ slabs.length }} {{ slabs.length === 1 ? 'slab' : 'slabs' }}</span>
}
</div>
<table class="csm-table">
<thead>
<tr>
<th>Slab Type</th>
<th class="num">Slab From (₹)</th>
<th class="num">Slab To (₹)</th>
<th class="num">Comm %</th>
<th class="num">Commission at "To"</th>
<th></th>
</tr>
</thead>
<tbody>
@if (loading) {
<!-- Placeholder rows while the chart is fetched, so nobody reads an empty chart as "no slabs". -->
@for (row of skeletonRows; track row) {
<tr class="csm-skeleton">
<td><span class="csm-skeleton__bar csm-skeleton__bar--wide"></span></td>
<td class="num"><span class="csm-skeleton__bar"></span></td>
<td class="num"><span class="csm-skeleton__bar"></span></td>
<td class="num"><span class="csm-skeleton__bar csm-skeleton__bar--short"></span></td>
<td class="num"><span class="csm-skeleton__bar"></span></td>
<td></td>
</tr>
}
}
@for (slab of slabs; track slab; let i = $index) {
@if (editingIndex === i) {
<!-- The same row turned into inputs, so a correction is made where the value is read. -->
<tr [formGroup]="editSlabForm" class="csm-new-row">
<td>
<mat-form-field appearance="outline" class="csm-inline-field" subscriptSizing="dynamic">
<mat-select formControlName="slabType">
@for (type of slabTypes; track type) {
<mat-option [value]="type">{{ typeInfo(type).title }}</mat-option>
}
</mat-select>
</mat-form-field>
</td>
<td>
<mat-form-field appearance="outline" class="csm-inline-field" subscriptSizing="dynamic">
<input matInput type="number" step="0.01" formControlName="slabFrom" />
</mat-form-field>
</td>
<td>
<mat-form-field appearance="outline" class="csm-inline-field" subscriptSizing="dynamic">
<input matInput type="number" step="0.01" formControlName="slabTo" />
</mat-form-field>
</td>
<td>
<mat-form-field appearance="outline" class="csm-inline-field" subscriptSizing="dynamic">
<input matInput type="number" step="0.01" formControlName="commissionPercent" />
<span matTextSuffix>%</span>
</mat-form-field>
</td>
<td></td>
<td class="csm-row-actions">
<button mat-icon-button color="primary" type="button" (click)="saveEdit()" matTooltip="Save correction">
<i class="fas fa-check"></i>
</button>
<button mat-icon-button type="button" (click)="cancelEdit()" matTooltip="Cancel">
<i class="fas fa-times"></i>
</button>
</td>
</tr>
} @else {
<tr [class.csm-row--unsaved]="!slab.id" [class.csm-row--unused]="!typeInfo(slab.slabType).used">
<td>
<span class="csm-type" [class.csm-type--unused]="!typeInfo(slab.slabType).used">{{
slab.slabType
}}</span>
@if (!typeInfo(slab.slabType).used) {
<span class="csm-tag csm-tag--warn" [matTooltip]="typeInfo(slab.slabType).hint">Not applied</span>
}
@if (!slab.id) {
<span class="csm-tag">Unsaved</span>
}
</td>
<td class="num">{{ slab.slabFrom | number: '1.2-2' }}</td>
<td class="num">{{ slab.slabTo | number: '1.2-2' }}</td>
<td class="num">
<span class="csm-rate">{{ slab.commissionPercent | number: '1.0-4' }}%</span>
</td>
<td class="num csm-muted">₹ {{ commissionAtTop(slab) | number: '1.2-2' }}</td>
<td class="csm-row-actions">
@if (slab.id) {
<!--
A saved slab is corrected, never removed. Deleting a range would leave a hole
that pays nothing to any agent whose volume lands in it, with nothing on screen
to say why - and there is deliberately no delete endpoint behind it.
-->
<button mat-icon-button color="primary" type="button" (click)="startEdit(i)" matTooltip="Edit slab">
<i class="fas fa-pen"></i>
</button>
} @else {
<!-- Not saved yet, so taking it back off the table costs nothing. -->
<button mat-icon-button type="button" (click)="removeSlab(i)" matTooltip="Remove before saving">
<i class="fas fa-times"></i>
</button>
}
</td>
</tr>
}
}
<tr [formGroup]="newSlabForm" class="csm-new-row" [hidden]="loading || loadFailed">
<td>
<mat-form-field appearance="outline" class="csm-inline-field" subscriptSizing="dynamic">
<mat-select required formControlName="slabType" placeholder="Slab Type">
@for (type of slabTypes; track type) {
<mat-option [value]="type">{{ typeInfo(type).title }}</mat-option>
}
</mat-select>
</mat-form-field>
</td>
<td>
<mat-form-field appearance="outline" class="csm-inline-field" subscriptSizing="dynamic">
<input matInput required type="number" step="0.01" formControlName="slabFrom" placeholder="From" />
</mat-form-field>
</td>
<td>
<mat-form-field appearance="outline" class="csm-inline-field" subscriptSizing="dynamic">
<input matInput required type="number" step="0.01" formControlName="slabTo" placeholder="To" />
</mat-form-field>
</td>
<td>
<mat-form-field appearance="outline" class="csm-inline-field" subscriptSizing="dynamic">
<input matInput required type="number" step="0.01" formControlName="commissionPercent" />
<span matTextSuffix>%</span>
</mat-form-field>
</td>
<td class="csm-muted csm-hint">
{{ newSlabForm.get('slabType')?.value ? typeInfo(newSlabForm.get('slabType')?.value).hint : 'Pick a type' }}
</td>
<td class="csm-row-actions">
<!--
Deliberately not disabled while the row is incomplete. A greyed-out button says the
screen will not accept this, but never which field it is waiting for; letting the
click through means addSlab() can say so.
-->
<button mat-icon-button color="primary" type="button" (click)="addSlab()" matTooltip="Add slab">
<i class="fas fa-plus"></i>
</button>
</td>
</tr>
</tbody>
</table>
@if (loadFailed) {
<p class="csm-error">
<i class="fas fa-exclamation-triangle"></i> The commission slabs could not be loaded.
<button mat-stroked-button type="button" (click)="reload()">Try again</button>
</p>
} @else if (!loading && slabs.length === 0) {
<p class="csm-empty">No commission slabs defined yet — add one using the row above.</p>
}
@if (!loading && coverage.length > 0) {
<div class="csm-coverage">
@for (entry of coverage; track entry.slabType) {
<div class="csm-coverage__row">
<span class="csm-type" [class.csm-type--unused]="!typeInfo(entry.slabType).used">{{ entry.slabType }}</span>
<span class="csm-coverage__range">
covers ₹ {{ entry.from | number: '1.2-2' }} to ₹ {{ entry.to | number: '1.2-2' }}
</span>
@if (typeInfo(entry.slabType).used && entry.from > 0) {
<span class="csm-coverage__gap">
<i class="fas fa-exclamation-triangle"></i> below ₹ {{ entry.from | number: '1.2-2' }} has no slab
</span>
}
@if (entry.gaps.length > 0) {
<span class="csm-coverage__gap">
<i class="fas fa-exclamation-triangle"></i> no slab for {{ entry.gaps.join(', ') }} — the commission run
stops for an agent landing there
</span>
}
@if (!typeInfo(entry.slabType).used) {
<span class="csm-coverage__note">{{ typeInfo(entry.slabType).hint }}</span>
}
</div>
}
</div>
}
<div class="csm-action-bar">
<button mat-raised-button type="button" (click)="exit()">Exit</button>
<button mat-raised-button color="primary" type="button" (click)="save()">Save</button>
</div>
</div>
<!-- Try an amount against the saved chart, in a popup opened from the header. -->
<ng-template #calculatorDialog>
<div class="csm-calc" [formGroup]="calcForm">
<div class="csm-calc__head">
<div>
<h3><i class="fas fa-calculator"></i> Commission Calculator</h3>
<span class="csm-muted">Uses the saved Account slabs - the same arithmetic as the commission run</span>
</div>
<button type="button" class="csm-calc__close" mat-dialog-close aria-label="Close">
<i class="fas fa-times"></i>
</button>
</div>
<div class="csm-calc__inputs">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Agent's collection (₹)</mat-label>
<input matInput type="number" step="0.01" formControlName="volume" />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>TDS %</mat-label>
<input matInput type="number" step="0.01" formControlName="tdsPercent" />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Deductions (₹)</mat-label>
<input matInput type="number" step="0.01" formControlName="deductions" />
</mat-form-field>
</div>
@if (calculation; as c) {
@if (c.slab) {
<div class="csm-calc__result">
<div class="csm-calc__cell">
<span>Slab</span>
<strong>₹ {{ c.slab.slabFrom | number: '1.2-2' }} – {{ c.slab.slabTo | number: '1.2-2' }}</strong>
</div>
<div class="csm-calc__cell">
<span>Rate</span><strong>{{ c.rate | number: '1.0-4' }}%</strong>
</div>
<div class="csm-calc__cell">
<span>Gross</span><strong>₹ {{ c.gross | number: '1.2-2' }}</strong>
</div>
<div class="csm-calc__cell">
<span>– TDS</span><strong>₹ {{ c.tds | number: '1.2-2' }}</strong>
</div>
<div class="csm-calc__cell">
<span>– Deductions</span><strong>₹ {{ c.deductions | number: '1.2-2' }}</strong>
</div>
<div class="csm-calc__cell csm-calc__cell--net">
<span>Net to agent</span><strong>₹ {{ (c.net > 0 ? c.net : 0) | number: '1.2-2' }}</strong>
</div>
</div>
@if (c.net <= 0) {
<p class="csm-calc__note">The deductions swallow this commission - the run pays this agent nothing.</p>
}
} @else {
<p class="csm-calc__none">
<i class="fas fa-exclamation-triangle"></i> No saved <b>Account</b> slab covers this amount — the commission
run would stop with "no commission slab" for this agent.
</p>
}
}
</div>
</ng-template>
</div>
@@ -0,0 +1,493 @@
/**
* 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/.
*/
$primary: #0f766e;
$primary-dark: #134e4a;
$primary-soft: #e6f4ee;
$border: #e2e8f0;
$text: #0f172a;
$text-mid: #475569;
$text-soft: #94a3b8;
$warn: #b45309;
$warn-soft: #fffbeb;
.csm-container {
max-width: 1080px;
margin: 0 auto;
padding: 24px 16px 40px;
color: $text;
}
.csm-header {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 16px;
&__ic {
display: grid;
place-items: center;
flex: none;
width: 44px;
height: 44px;
border-radius: 12px;
background: linear-gradient(135deg, $primary, $primary-dark);
color: #fff;
}
&__title {
margin: 0;
font-size: 21px;
font-weight: 600;
}
&__sub {
margin: 2px 0 0;
font-size: 13px;
color: $text-mid;
}
&__spacer {
flex: 1;
}
}
.csm-back {
color: $primary;
}
// ── How the chart is read ─────────────────────────────────────────────────────────────────────────────
.csm-howto {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-bottom: 16px;
@media (width <= 900px) {
grid-template-columns: 1fr 1fr;
}
&__step {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px;
border: 1px solid $border;
border-radius: 10px;
background: #fff;
font-size: 12.5px;
line-height: 1.45;
color: $text-mid;
&--warn {
border-color: #fde68a;
background: $warn-soft;
color: $warn;
}
}
&__no {
display: grid;
place-items: center;
flex: none;
width: 22px;
height: 22px;
border-radius: 50%;
background: $primary-soft;
color: $primary;
font-size: 12px;
font-weight: 700;
}
&__step--warn &__no {
background: #fef3c7;
color: $warn;
}
}
// ── Cards ─────────────────────────────────────────────────────────────────────────────────────────────
.csm-card {
padding: 16px 20px 20px;
border: 1px solid $border;
border-radius: 12px;
background: var(--md-sys-color-surface, #fff);
box-shadow: 0 1px 3px rgb(0 0 0 / 5%);
& + & {
margin-top: 16px;
}
&__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
h3 {
margin: 0;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: $primary-dark;
i {
margin-right: 6px;
}
}
}
&__count {
padding: 2px 10px;
border-radius: 999px;
background: $primary-soft;
color: $primary;
font-size: 12px;
font-weight: 600;
}
}
// ── Slab table ────────────────────────────────────────────────────────────────────────────────────────
.csm-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
th:nth-child(1),
td:nth-child(1) {
width: 30%;
}
th:nth-child(2),
td:nth-child(2),
th:nth-child(3),
td:nth-child(3) {
width: 17%;
}
th:nth-child(4),
td:nth-child(4) {
width: 12%;
}
th:nth-child(5),
td:nth-child(5) {
width: 16%;
}
th:nth-child(6),
td:nth-child(6) {
width: 8%;
}
th {
padding: 9px 10px;
border-bottom: 1px solid $border;
background: #f8fafc;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-align: left;
text-transform: uppercase;
color: $text-soft;
}
td {
padding: 8px 10px;
border-bottom: 1px solid #eef2f6;
vertical-align: middle;
font-size: 13.5px;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
}
// Loading: placeholder rows with a soft shimmer, and a spinner in the count badge.
.csm-skeleton td {
padding: 13px 10px;
}
.csm-skeleton__bar {
display: inline-block;
width: 70%;
height: 12px;
border-radius: 6px;
background: linear-gradient(90deg, #eef2f6 25%, #e2e8f0 37%, #eef2f6 63%);
background-size: 400% 100%;
animation: csm-shimmer 1.2s ease-in-out infinite;
&--wide {
width: 55%;
}
&--short {
width: 40%;
}
}
@keyframes csm-shimmer {
0% {
background-position: 100% 50%;
}
100% {
background-position: 0 50%;
}
}
.csm-card__count--loading {
display: inline-flex;
align-items: center;
gap: 6px;
}
.csm-error {
display: flex;
align-items: center;
gap: 10px;
margin: 12px 0 0;
padding: 10px 12px;
border-radius: 8px;
background: #fef2f2;
color: #b91c1c;
font-size: 13px;
}
.csm-row--unsaved {
background: #f0fdf9;
}
.csm-row--unused td {
color: $text-soft;
}
.csm-type {
font-weight: 600;
color: $primary-dark;
&--unused {
color: $text-soft;
}
}
.csm-tag {
margin-left: 6px;
padding: 1px 8px;
border-radius: 999px;
background: #e0e7ff;
color: #3730a3;
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
&--warn {
background: #fef3c7;
color: $warn;
cursor: help;
}
}
.csm-rate {
padding: 2px 8px;
border-radius: 6px;
background: $primary-soft;
color: $primary;
font-weight: 700;
}
.csm-muted {
color: $text-mid;
}
.csm-hint {
font-size: 11.5px;
line-height: 1.35;
}
.csm-inline-field {
width: 100%;
min-width: 0;
}
.csm-row-actions {
text-align: center;
}
.csm-new-row {
background: rgb(15 118 110 / 4%);
}
.csm-empty {
margin: 12px 0 0;
font-size: 14px;
color: $text-mid;
}
.csm-action-bar {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid $border;
}
// What the slabs cover, and any figure none of them claims.
.csm-coverage {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 14px;
padding: 12px 16px;
border: 1px solid $border;
border-radius: 8px;
background: #f8fafc;
&__row {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 10px;
font-size: 0.86rem;
}
&__range {
color: $text-mid;
font-variant-numeric: tabular-nums;
}
&__gap {
color: $warn;
font-variant-numeric: tabular-nums;
i {
margin-right: 4px;
}
}
&__note {
font-size: 12px;
color: $text-soft;
}
}
// ── Calculator (popup) ────────────────────────────────────────────────────────────────────────────────
.csm-calc-btn i {
margin-right: 6px;
}
.csm-calc {
padding: 18px 20px 20px;
color: $text;
&__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
h3 {
margin: 0 0 2px;
font-size: 14px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: $primary-dark;
i {
margin-right: 6px;
}
}
.csm-muted {
font-size: 12px;
}
}
&__close {
border: 0;
background: transparent;
color: $text-mid;
font-size: 16px;
cursor: pointer;
}
&__inputs {
display: grid;
grid-template-columns: 2fr 1fr 1fr;
gap: 12px;
@media (width <= 720px) {
grid-template-columns: 1fr;
}
}
&__result {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 8px;
margin-top: 14px;
@media (width <= 900px) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
&__cell {
display: flex;
flex-direction: column;
gap: 3px;
padding: 10px 12px;
border: 1px solid $border;
border-radius: 8px;
background: #f8fafc;
span {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: $text-soft;
}
strong {
font-size: 14px;
font-variant-numeric: tabular-nums;
}
&--net {
border-color: #a7f3d0;
background: $primary-soft;
strong {
color: $primary-dark;
font-size: 16px;
}
}
}
&__none,
&__note {
margin: 12px 0 0;
padding: 10px 12px;
border-radius: 8px;
background: $warn-soft;
color: $warn;
font-size: 13px;
i {
margin-right: 6px;
}
}
}
@@ -7,7 +7,9 @@
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import { Component, OnInit, TemplateRef, ViewChild, inject } from '@angular/core';
import { MatDialog, MatDialogClose } from '@angular/material/dialog';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -35,13 +37,25 @@ import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-appro
selector: 'mifosx-commission-slab-master',
templateUrl: './commission-slab-master.component.html',
styleUrls: ['./commission-slab-master.component.scss'],
imports: [...STANDALONE_SHARED_IMPORTS]
imports: [
...STANDALONE_SHARED_IMPORTS,
MatDialogClose,
MatProgressSpinner
]
})
export class CommissionSlabMasterComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private router = inject(Router);
private organizationService = inject(OrganizationService);
private alertService = inject(AlertService);
private dialog = inject(MatDialog);
@ViewChild('calculatorDialog') calculatorDialog: TemplateRef<unknown>;
/** The calculator in a popup, so it is one click from the top of the page rather than a scroll away. */
openCalculator(): void {
this.dialog.open(this.calculatorDialog, { width: '820px', maxWidth: '95vw', autoFocus: 'first-tabbable' });
}
private static readonly SLAB_TYPE_CODES: Record<string, string> = {
Account: 'ACCOUNT',
@@ -57,6 +71,65 @@ export class CommissionSlabMasterComponent implements OnInit {
'Commission'
];
/**
* What each slab type is for, as the screen says it. Only ACCOUNT slabs are read by the commission
* run (BatchRunWritePlatformServiceJpaRepositoryImpl.generateCommissionRows): the agent's collection
* volume is matched to one, and its percentage is the agent's commission. COMMISSION is the legacy
* senior-agent override - saved, but nothing applies it, since there is no agent hierarchy yet.
*/
static readonly SLAB_TYPE_INFO: Record<string, { title: string; hint: string; used: boolean }> = {
Account: {
title: 'Account - collection volume',
hint: "Used by the commission run: the agent's collection is matched to the slab it falls in.",
used: true
},
Commission: {
title: 'Commission - senior-agent override',
hint: 'Saved but not applied by any run yet - there is no agent hierarchy to apply it to.',
used: false
}
};
typeInfo(type: string) {
return CommissionSlabMasterComponent.SLAB_TYPE_INFO[type] || { title: type, hint: '', used: false };
}
/** Try a collection amount against the chart: which slab, what rate, and the commission it pays. */
calcForm: UntypedFormGroup = this.formBuilder.group({
volume: [10000],
tdsPercent: [0],
deductions: [0]
});
/** The same arithmetic the commission run uses (DdsCommissionCalculation), rounded half-up to paise. */
get calculation(): {
slab: any;
rate: number;
gross: number;
tds: number;
deductions: number;
net: number;
} | null {
const volume = Number(this.calcForm.get('volume')?.value || 0);
if (!(volume > 0)) {
return null;
}
const slab = this.slabs.find(
(row) => row.id && row.slabType === 'Account' && volume >= Number(row.slabFrom) && volume <= Number(row.slabTo)
);
const rate = slab ? Number(slab.commissionPercent) : 0;
const round2 = (n: number) => Math.round((n + Number.EPSILON) * 100) / 100;
const gross = round2((volume * rate) / 100);
const tds = round2((gross * Number(this.calcForm.get('tdsPercent')?.value || 0)) / 100);
const deductions = Number(this.calcForm.get('deductions')?.value || 0);
return { slab: slab || null, rate, gross, tds, deductions, net: round2(gross - tds - deductions) };
}
/** The commission a slab pays at the top of its range - what the table shows beside each slab. */
commissionAtTop(slab: any): number {
return Math.round(Number(slab.slabTo) * Number(slab.commissionPercent)) / 100;
}
displayedColumns: string[] = [
'slabType',
'slabFrom',
@@ -88,26 +161,65 @@ export class CommissionSlabMasterComponent implements OnInit {
ngOnInit(): void {
this.loadSlabs();
// Picking a type on the entry row starts the new slab a paisa above where that type's chart ends,
// so consecutive slabs meet with no gap a rupee-and-paise collection could fall into.
this.newSlabForm.get('slabType')?.valueChanges.subscribe((type: string) => {
const from = this.newSlabForm.get('slabFrom');
if (!type || !from || Number(from.value) !== 0) {
return;
}
const tops = this.slabs.filter((slab) => slab.slabType === type).map((slab) => Number(slab.slabTo));
if (tops.length > 0) {
from.setValue(Math.round((Math.max(...tops) + 0.01) * 100) / 100);
}
});
}
/** True while the chart is being fetched - the table shows a loader rather than an empty chart. */
loading = true;
/** The fetch failed: said on screen, so an empty table is not read as "no slabs". */
loadFailed = false;
/** Placeholder rows shown while loading, so the table keeps its shape. */
readonly skeletonRows = [
1,
2,
3,
4,
5
];
private loadSlabs(): void {
this.organizationService.getCommissionSlabs().subscribe((slabs: any[]) => {
this.slabs = (slabs || []).map((slab) => ({
id: slab.id,
slabType: CommissionSlabMasterComponent.SLAB_TYPE_LABELS[slab.slabType] || slab.slabType,
slabFrom: slab.slabFrom,
slabTo: slab.slabTo,
commissionPercent: slab.commissionPercent
}));
this.loading = true;
this.loadFailed = false;
this.organizationService.getCommissionSlabs().subscribe({
next: (slabs: any[]) => {
this.slabs = (slabs || []).map((slab) => ({
id: slab.id,
slabType: CommissionSlabMasterComponent.SLAB_TYPE_LABELS[slab.slabType] || slab.slabType,
slabFrom: slab.slabFrom,
slabTo: slab.slabTo,
commissionPercent: slab.commissionPercent
}));
this.loading = false;
},
error: () => {
this.loading = false;
this.loadFailed = true;
}
});
}
reload(): void {
this.loadSlabs();
}
/**
* Where each slab type's coverage begins and ends, and any figure in between that no slab claims.
*
* A gap is not an error - a bank may well decide there is no commission below some floor - but it
* has to be visible. The commission run simply finds no slab for a volume that falls in a gap and
* moves on, so the agent is paid nothing for that month with nothing on screen to say why.
* A gap has to be visible: the commission run stops with "no commission slab" for an agent whose
* collection falls in a gap, or below the first slab or above the last, so the chart should run from
* 0 to beyond the largest collection any agent makes, with each slab starting a paisa after the last.
*/
get coverage(): Array<{ slabType: string; from: number; to: number; gaps: string[] }> {
return this.slabTypes
@@ -121,12 +233,16 @@ export class CommissionSlabMasterComponent implements OnInit {
return null;
}
// To the paisa: a collection is rupees and paise, and the run matches from <= volume <= to, so
// 1-10000 followed by 10001-20000 leaves 10000.01 to 10000.99 with no slab at all.
const paise = (n: number) => Math.round(n * 100);
const money = (p: number) => (p / 100).toFixed(2);
const gaps: string[] = [];
for (let i = 1; i < rows.length; i++) {
const previousEnd = rows[i - 1].to;
const nextStart = rows[i].from;
const previousEnd = paise(rows[i - 1].to);
const nextStart = paise(rows[i].from);
if (nextStart > previousEnd + 1) {
gaps.push(`${previousEnd + 1} - ${nextStart - 1}`);
gaps.push(`${money(previousEnd + 1)} - ${money(nextStart - 1)}`);
}
}
@@ -180,6 +296,13 @@ export class CommissionSlabMasterComponent implements OnInit {
return;
}
// The same rules the server applies to the whole chart on Save, caught here on the row being added.
const chartError = this.chartErrorFor(candidate);
if (chartError) {
this.alertService.alert({ type: 'Error', message: chartError });
return;
}
this.slabs.push(candidate);
this.newSlabForm.reset({
slabType: '',
@@ -189,6 +312,31 @@ export class CommissionSlabMasterComponent implements OnInit {
});
}
/**
* Why a new slab would break its type's chart, or null. A chart starts at 0 and each slab begins one
* paisa after the one before ends - the commission run stops for any collection no slab covers - and
* amounts are to the paisa. Slabs are added at the top of the chart, in order.
*/
private chartErrorFor(candidate: { slabType: string; slabFrom: number; slabTo: number }): string | null {
const paise = (n: any) => Math.round(Number(n) * 100);
const twoDecimals = (n: any) => Math.abs(Number(n) * 100 - paise(n)) < 1e-6;
if (!twoDecimals(candidate.slabFrom) || !twoDecimals(candidate.slabTo)) {
return 'Amounts can have at most two decimals (paise)';
}
const tops = this.slabs.filter((slab) => slab.slabType === candidate.slabType).map((slab) => paise(slab.slabTo));
const expected = tops.length === 0 ? 0 : Math.max(...tops) + 1;
const from = paise(candidate.slabFrom);
if (from !== expected) {
const at = (expected / 100).toFixed(2);
return tops.length === 0
? `The first ${candidate.slabType} slab must start at 0`
: from > expected
? `This leaves a gap: nothing covers ${at} to ${((from - 1) / 100).toFixed(2)}. Start this slab at ${at}`
: `Start this slab at ${at} - one paisa after the last ${candidate.slabType} slab ends`;
}
return null;
}
/** Only ever an unsaved row - a saved slab is corrected, not taken away. */
removeSlab(index: number): void {
this.slabs.splice(index, 1);
@@ -262,7 +410,13 @@ export class CommissionSlabMasterComponent implements OnInit {
if (heldForApproval(response)) {
announceHeldForApproval(response, 'The slab correction', this.organizationService, this.alertService);
} else {
this.alertService.alert({ type: 'Success', message: 'Slab corrected' });
const moved = response?.changes?.neighboursAdjusted;
this.alertService.alert({
type: 'Success',
message: moved
? `Slab corrected - the next/previous slab moved with it so the chart has no gap`
: 'Slab corrected'
});
}
this.editingIndex = -1;
this.loadSlabs();
@@ -282,7 +436,7 @@ export class CommissionSlabMasterComponent implements OnInit {
}
exit(): void {
this.router.navigate(['/organization']);
this.router.navigate(['/products']);
}
save(): void {
@@ -33,6 +33,7 @@ import { ViewRecurringDepositProductComponent } from './recurring-deposit-produc
import { DdsAccountTypesComponent } from './dds-account-types/dds-account-types.component';
import { CreateDdsAccountTypeComponent } from './dds-account-types/create-dds-account-type/create-dds-account-type.component';
import { ViewDdsAccountTypeComponent } from './dds-account-types/view-dds-account-type/view-dds-account-type.component';
import { CommissionSlabMasterComponent } from './commission-slab-master/commission-slab-master.component';
import { ChargesComponent } from './charges/charges.component';
import { ViewChargeComponent } from './charges/view-charge/view-charge.component';
import { CreateChargeComponent } from './charges/create-charge/create-charge.component';
@@ -740,6 +741,11 @@ const routes: Routes = [
}
]
},
{
path: 'commission-slab-master',
component: CommissionSlabMasterComponent,
data: { title: 'Commission Slab Master', breadcrumb: 'Commission Slab Master' }
},
{
path: 'dds-account-types',
data: { title: 'DDS Product', breadcrumb: 'DDS Product' },
+13
View File
@@ -153,6 +153,19 @@
</a>
</div>
<div #commissionSlabMaster>
<a class="pd-card" [routerLink]="['commission-slab-master']" *mifosxHasPermission="'READ_COMMISSIONSLAB'">
<div class="pd-card__icon pd-card__icon--teal">
<i class="fas fa-percentage"></i>
</div>
<div class="pd-card__body">
<h3 class="pd-card__title">Commission Slab Master</h3>
<p class="pd-card__desc">DDS agent commission chart - a percentage on each agent's collection volume</p>
</div>
<i class="fas fa-chevron-right pd-card__arrow"></i>
</a>
</div>
<a class="pd-card" [routerLink]="['products-mix']" *mifosxHasPermission="'READ_PRODUCTMIX'">
<div class="pd-card__icon pd-card__icon--indigo">
<i class="fas fa-random"></i>