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:
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, ' ');
|
||||
}
|
||||
|
||||
+144
@@ -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 & 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>
|
||||
+167
@@ -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;
|
||||
}
|
||||
}
|
||||
+109
@@ -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 & Commission</h2>
|
||||
<div class="bp-header__ic"><i class="fas fa-cogs"></i></div>
|
||||
<div>
|
||||
<h2 class="bp-header__title">DDS Interest & 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+172
-18
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user