From 306af59099ed79108d2129957022b4c66f98cb50 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Wed, 28 Oct 2026 17:13:33 +0530 Subject: [PATCH] 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 --- .../dds-account-view.component.html | 1089 ++++++++++------- .../dds-account-view.component.scss | 836 +++++++++++-- .../dds-account-view.component.ts | 289 ++++- ...dds-transaction-view-dialog.component.html | 144 +++ ...dds-transaction-view-dialog.component.scss | 167 +++ .../dds-transaction-view-dialog.component.ts | 109 ++ .../create-party/create-party.component.html | 7 + .../create-party/create-party.component.scss | 16 + .../create-party/create-party.component.ts | 35 + .../commission-slab-master.component.html | 157 --- .../commission-slab-master.component.scss | 163 --- .../dds-batch-processing.component.html | 593 ++++++--- .../dds-batch-processing.component.scss | 809 ++++++++++-- .../dds-batch-processing.component.ts | 394 +++++- .../organization-routing.module.ts | 6 - .../organization/organization.component.html | 13 - src/app/organization/organization.service.ts | 5 + .../commission-slab-master.component.html | 311 +++++ .../commission-slab-master.component.scss | 493 ++++++++ .../commission-slab-master.component.ts | 190 ++- src/app/products/products-routing.module.ts | 6 + src/app/products/products.component.html | 13 + 22 files changed, 4581 insertions(+), 1264 deletions(-) create mode 100644 src/app/deposits/dds-accounts/dds-account-view/dds-transaction-view-dialog/dds-transaction-view-dialog.component.html create mode 100644 src/app/deposits/dds-accounts/dds-account-view/dds-transaction-view-dialog/dds-transaction-view-dialog.component.scss create mode 100644 src/app/deposits/dds-accounts/dds-account-view/dds-transaction-view-dialog/dds-transaction-view-dialog.component.ts delete mode 100644 src/app/organization/commission-slab-master/commission-slab-master.component.html delete mode 100644 src/app/organization/commission-slab-master/commission-slab-master.component.scss create mode 100644 src/app/products/commission-slab-master/commission-slab-master.component.html create mode 100644 src/app/products/commission-slab-master/commission-slab-master.component.scss rename src/app/{organization => products}/commission-slab-master/commission-slab-master.component.ts (56%) diff --git a/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.html b/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.html index 888501fe1..5007fde33 100644 --- a/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.html +++ b/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.html @@ -54,504 +54,701 @@ - -
- - - - + +
+ - - @if (tab === 'closure') { - @if (!closure) { -
-
Loading closure details…
-
- } @else { + + + @if (tab === 'closure') { + @if (!closure) { +
+
Loading closure details…
+
+ } @else { +
+
+
+
How It Closed
+
+ Type + + {{ closureLabels[closure.closureType] || closure.closureType }} + @if (closure.withinLockin) { + within lock-in + } + +
+
+ Closed On{{ closure.closeDate | date: 'dd MMMM yyyy' }} +
+ @if (closure.paidOutBy) { +
+ Paid Out By{{ closure.paidOutBy }} +
+ } +
+ Closed By{{ closure.closedBy || '—' }} +
+
+ Approved By{{ closure.approvedBy || '—' }} +
+ @if (closure.lockinOverrideReason) { +
+ Reason{{ closure.lockinOverrideReason }} +
+ } + @if (closure.shortfallBorneBy) { +
+ Cost Borne By + {{ + closure.shortfallBorneBy === 'AGENT' ? 'Agent — ' + closure.agentCode : 'Customer' + }} +
+ } +
+ +
+
The Money
+
+ Balance at Close₹{{ closure.balanceAtClose || 0 | formatNumber }} +
+ @if (closure.interestRecovered > 0) { +
+ Interest Recovered− ₹{{ closure.interestRecovered | formatNumber }} +
+ } + @if (closure.interestToppedUpByAgent > 0) { +
+ Interest Funded by Agent+ ₹{{ closure.interestToppedUpByAgent | formatNumber }} +
+ } +
+ Paid Out₹{{ closure.paidOut || 0 | formatNumber }} +
+ @if (closure.closureType === 'PREMATURE') { +
+ Agent Penalty ({{ closure.agentCode }})₹{{ closure.agentCommissionPenalty || 0 | formatNumber }} +
+ } +
+
+ +
+
+
Where It Went
+ @if (closure.destination === 'OWN_ACCOUNT') { +
Paid IntoCustomer's own account
+
+ Account{{ closure.creditedAccount?.accountNo }} +
+ @if (closure.creditedAccount?.productName) { +
+ Product{{ closure.creditedAccount.productName }} +
+ } + @if (closure.creditedAccount?.owner) { +
+ Holder{{ closure.creditedAccount.owner }} +
+ } + } @else if (closure.destination === 'SUNDRY') { +
+ Parked InSundry (customer has no account here) +
+
+ GL Account{{ closure.sundryGl?.glCode }} · {{ closure.sundryGl?.glName }} +
+ } @else { +
Paid OutNothing - the balance was nil
+ } + @if (closure.voucherNo) { +
+ Voucher{{ closure.voucherNo }} +
+ } + @if (closure.payoutDate) { +
+ Paid On{{ closure.payoutDate | date: 'dd MMMM yyyy' }} +
+ } + @if (closure.passbookNote) { +
+ Passbook Note{{ closure.passbookNote }} +
+ } +
+ + @if (closure.ledgerEntries?.length) { +
+
Ledger Entry
+
+ Transaction ID{{ closure.ledgerTransactionId }} +
+ @for (leg of closure.ledgerEntries; track $index) { +
+ {{ leg.glCode }} · {{ leg.glName }} + + {{ leg.side }} ₹{{ leg.amount | formatNumber }} + +
+ } +
+ } +
+
+ } + } + + + @if (tab === 'general') {
-
How It Closed
+
DDS Details
- Type + Account Number{{ account.accountNo }} +
+
+ Party Code{{ account.partyCode }} +
+
+ Client Name{{ account.clientName }} ({{ account.clientId }}) +
+
+ Agent{{ account.agentCode }} · {{ account.agentName }} +
+
+ Branch{{ account.officeName }} +
+
+ Product{{ account.subType }} +
+
+ Opened On{{ account.openingDate | date: 'dd MMMM yyyy' }} +
+
+ Matures On{{ account.maturityDate | date: 'dd MMMM yyyy' }} +
+
+ Daily Deposit₹{{ account.dailyDepositAmount || 0 | formatNumber }} +
+
+ Term{{ account.month }} months · {{ account.days }} days +
+
+ Interest Rate{{ account.interestRate }} % +
+
+ CurrencyIndian Rupee [INR] +
+
+ +
+
Performance History
+
+ Total Collected₹{{ totalOf('PARTY_TRAN') | formatNumber }} +
+
+ Total Interest Earned₹{{ interestEarned | formatNumber }} +
+
+ Total Paid Out₹{{ totalDebitsAll | formatNumber }} +
+
+
+ +
+
+
Account Summary
+
+ Collected to Date₹{{ account.collectedToDate || 0 | formatNumber }} +
+
+ Expected to Date₹{{ account.expectedToDate || 0 | formatNumber }} +
+
+ Standing - {{ closureLabels[closure.closureType] || closure.closureType }} - @if (closure.withinLockin) { - within lock-in + @if (account.advanceDays > 0) { + {{ account.advanceDays }} days ahead + } @else if (account.arrearsDays > 0) { + {{ account.arrearsDays }} days behind + } @else { + Up to date }
- Closed On{{ closure.closeDate | date: 'dd MMMM yyyy' }} -
- @if (closure.paidOutBy) { -
- Paid Out By{{ closure.paidOutBy }} -
- } -
- Closed By{{ closure.closedBy || '—' }} + Instalments Due{{ account.instalmentsDue ?? 0 }}
- Approved By{{ closure.approvedBy || '—' }} + Interest Earned₹{{ interestEarned | formatNumber }}
- @if (closure.lockinOverrideReason) { +
+ Interest Calculated, Not Posted₹{{ account.interestCalc || 0 | formatNumber }} +
+
Interest Calculated UsingDaily Balance
+
Days in Year365 Days
+
+ Last Interest Date{{ account.interestDate ? (account.interestDate | date: 'dd MMMM yyyy') : '—' }} +
+
+ Last Transaction Date{{ lastTransactionDate ? (lastTransactionDate | date: 'dd MMMM yyyy') : '—' }} +
+
+ Maturity Amount₹{{ account.maturityAmount || 0 | formatNumber }} +
+
+ Lien + {{ account.lien ? 'Yes — ₹' + (account.amountHeldUnderLien || 0) : 'No' }} +
+
+ Freeze{{ account.freezeCode || 'Normal' }} +
+ @if (account.closeDate) {
- Reason{{ closure.lockinOverrideReason }} -
- } - @if (closure.shortfallBorneBy) { -
- Cost Borne By - {{ - closure.shortfallBorneBy === 'AGENT' ? 'Agent — ' + closure.agentCode : 'Customer' - }} + Closed On{{ account.closeDate | date: 'dd MMMM yyyy' }}
}
- -
-
The Money
-
- Balance at Close₹{{ closure.balanceAtClose || 0 | formatNumber }} -
- @if (closure.interestRecovered > 0) { -
- Interest Recovered− ₹{{ closure.interestRecovered | formatNumber }} -
- } - @if (closure.interestToppedUpByAgent > 0) { -
- Interest Funded by Agent+ ₹{{ closure.interestToppedUpByAgent | formatNumber }} -
- } -
- Paid Out₹{{ closure.paidOut || 0 | formatNumber }} -
- @if (closure.closureType === 'PREMATURE') { -
- Agent Penalty ({{ closure.agentCode }})₹{{ closure.agentCommissionPenalty || 0 | formatNumber }} -
- } -
-
- -
-
-
Where It Went
- @if (closure.destination === 'OWN_ACCOUNT') { -
Paid IntoCustomer's own account
-
- Account{{ closure.creditedAccount?.accountNo }} -
- @if (closure.creditedAccount?.productName) { -
- Product{{ closure.creditedAccount.productName }} -
- } - @if (closure.creditedAccount?.owner) { -
- Holder{{ closure.creditedAccount.owner }} -
- } - } @else if (closure.destination === 'SUNDRY') { -
Parked InSundry (customer has no account here)
-
- GL Account{{ closure.sundryGl?.glCode }} · {{ closure.sundryGl?.glName }} -
- } @else { -
Paid OutNothing - the balance was nil
- } - @if (closure.voucherNo) { -
- Voucher{{ closure.voucherNo }} -
- } - @if (closure.payoutDate) { -
- Paid On{{ closure.payoutDate | date: 'dd MMMM yyyy' }} -
- } - @if (closure.passbookNote) { -
- Passbook Note{{ closure.passbookNote }} -
- } -
- - @if (closure.ledgerEntries?.length) { -
-
Ledger Entry
-
- Transaction ID{{ closure.ledgerTransactionId }} -
- @for (leg of closure.ledgerEntries; track $index) { -
- {{ leg.glCode }} · {{ leg.glName }} - - {{ leg.side }} ₹{{ leg.amount | formatNumber }} - -
- } -
- }
} - } - - @if (tab === 'general') { -
-
-
-
DDS Details
-
- Account Number{{ account.accountNo }} -
-
- Party Code{{ account.partyCode }} -
-
- Client Name{{ account.clientName }} ({{ account.clientId }}) -
-
- Agent{{ account.agentCode }} · {{ account.agentName }} -
-
- Branch{{ account.officeName }} -
-
- Product{{ account.subType }} -
-
- Opened On{{ account.openingDate | date: 'dd MMMM yyyy' }} -
-
- Matures On{{ account.maturityDate | date: 'dd MMMM yyyy' }} -
-
- Daily Deposit₹{{ account.dailyDepositAmount || 0 | formatNumber }} -
-
- Term{{ account.month }} months · {{ account.days }} days -
-
- Interest Rate{{ account.interestRate }} % -
-
- CurrencyIndian Rupee [INR] + + @if (tab === 'transactions') { +
+
+
+ + + + @if (hasDateFilters) { + + } + + Credits {{ totalCredits | currency: 'INR' : 'symbol-narrow' : '1.2-2' }} + Debits {{ totalDebits | currency: 'INR' : 'symbol-narrow' : '1.2-2' }} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
# + {{ idx + 1 }} + {{ 'labels.inputs.Id' | translate }} + {{ txn.id }} + + {{ 'labels.inputs.Transaction Date' | translate }} + + {{ txn.transactionDate | dateFormat }} + + {{ 'labels.inputs.Transaction Type' | translate }} + + {{ label(txn.transactionType) }} + {{ 'labels.inputs.Debit' | translate }} + {{ txn.direction === 'DEBIT' ? (txn.amount | currency: 'INR' : 'symbol-narrow' : '1.2-2') : 'N/A' }} + {{ 'labels.inputs.Credit' | translate }} + {{ txn.direction === 'CREDIT' ? (txn.amount | currency: 'INR' : 'symbol-narrow' : '1.2-2') : 'N/A' }} + {{ 'labels.inputs.Balance' | translate }} ↑ + {{ txn.balanceAfterTransaction || 0 | currency: 'INR' : 'symbol-narrow' : '1.2-2' }} + {{ 'labels.inputs.Actions' | translate }} + + + + + + +
+ Nothing moved on this account in these dates +
+ +
-
-
Performance History
-
- Total Collected₹{{ totalOf('PARTY_TRAN') | formatNumber }} -
-
- Total Interest Earned₹{{ interestEarned | formatNumber }} -
-
- Total Paid Out₹{{ totalDebitsAll | formatNumber }} +
+ +
+ {{ 'labels.text.No transaction was found' | translate }}
+
Collections, interest and payouts will appear here.
+ } -
-
-
Account Summary
-
- Collected to Date₹{{ account.collectedToDate || 0 | formatNumber }} + + @if (tab === 'passbook') { +
+
+
+ + {{ 'labels.inputs.From Date' | translate }} + + + + + {{ 'labels.inputs.To Date' | translate }} + + + + @if (hasDateFilters) { + + } + + + +
-
- Expected to Date₹{{ account.expectedToDate || 0 | formatNumber }} +
+ +
+ +
+
+
+ account_balance +
+
+
{{ bankName }}
+
{{ bankSubtitle }}
+
+
+
+
DDS PASSBOOK
+
As on: {{ today | dateFormat }}
+
-
- Standing - - @if (account.advanceDays > 0) { - {{ account.advanceDays }} days ahead - } @else if (account.arrearsDays > 0) { - {{ account.arrearsDays }} days behind - } @else { - Up to date + + + @if (branchName || ifscCode || micrCode) { +
+ @if (branchName) { + {{ branchName }} Branch + } + @if (branchAddress) { + {{ branchAddress }} + } + @if (ifscCode) { + IFSC: {{ ifscCode }} + } + @if (micrCode) { + MICR: {{ micrCode }} } - -
-
- Instalments Due{{ account.instalmentsDue ?? 0 }} -
-
- Interest Earned₹{{ interestEarned | formatNumber }} -
-
- Interest Calculated, Not Posted₹{{ account.interestCalc || 0 | formatNumber }} -
-
Interest Calculated UsingDaily Balance
-
Days in Year365 Days
-
- Last Interest Date{{ account.interestDate ? (account.interestDate | date: 'dd MMMM yyyy') : '—' }} -
-
- Last Transaction Date{{ lastTransactionDate ? (lastTransactionDate | date: 'dd MMMM yyyy') : '—' }} -
-
- Maturity Amount₹{{ account.maturityAmount || 0 | formatNumber }} -
-
- Lien - {{ account.lien ? 'Yes — ₹' + (account.amountHeldUnderLien || 0) : 'No' }} -
-
- Freeze{{ account.freezeCode || 'Normal' }} -
- @if (account.closeDate) { -
- Closed On{{ account.closeDate | date: 'dd MMMM yyyy' }}
} -
-
-
- } - - @if (tab === 'transactions') { -
-
-
- From - -
-
- To - -
- - @if (hasDateFilters) { - - } -
- Credits ₹{{ totalCredits | formatNumber }} - Debits ₹{{ totalDebits | formatNumber }} -
- -
+ + - @if (shownTransactions.length === 0) { -
- - {{ - hasDateFilters - ? 'Nothing moved on this account in these dates' - : 'Nothing has been credited to this account yet' - }} -
- } @else { -
- - - - - - - - - - - - - - - - @for (txn of shownTransactions; track txn.id; let i = $index) { + +
+
+
Current Balance
+
+ {{ account.currentBalance || 0 | currency: 'INR' : 'symbol-narrow' : '1.2-2' }} +
+
+
+
+
Maturity Amount
+
+ {{ account.maturityAmount || 0 | currency: 'INR' : 'symbol-narrow' : '1.2-2' }} +
+
+
+
+
Total Transactions
+
{{ passbookEntries.length }}
+
+
+ + + @if (passbookEntries.length > 0) { +
#IDTransaction DateTransaction TypeVoucherDescriptionDebitCreditBalance
+ - - - - - - - + + + + + + + + + + @for (txn of passbookEntries; track txn.id; let i = $index) { + + + + + + + + + + } + + + + + - - - } - -
{{ i + 1 }}{{ txn.id }}{{ txn.transactionDate | date: 'dd MMMM yyyy' }}{{ label(txn.transactionType) }}{{ txn.voucherNo || '—' }}{{ txn.description }} - {{ txn.direction === 'DEBIT' ? '₹' + (txn.amount | formatNumber) : 'N/A' }} + #DateDescriptionRef No.Debit (Dr)Credit (Cr)Balance (₹)
{{ i + 1 }}{{ txn.transactionDate | dateFormat }} + {{ label(txn.transactionType) }} + {{ txn.refNo || txn.id }} + @if (txn.direction === 'DEBIT') { + {{ txn.amount | currency: 'INR' : 'symbol-narrow' : '1.2-2' }} + } + + @if (txn.direction === 'CREDIT') { + {{ txn.amount | currency: 'INR' : 'symbol-narrow' : '1.2-2' }} + } + + {{ txn.balanceAfterTransaction || 0 | currency: 'INR' : 'symbol-narrow' : '1.2-2' }} +
Total / Closing Balance + {{ totalDebits | currency: 'INR' : 'symbol-narrow' : '1.2-2' }} - {{ txn.direction === 'CREDIT' ? '₹' + (txn.amount | formatNumber) : 'N/A' }} + + {{ totalCredits | currency: 'INR' : 'symbol-narrow' : '1.2-2' }} - ₹{{ txn.balanceAfterTransaction || 0 | formatNumber }} + + {{ passbookClosingBalance | currency: 'INR' : 'symbol-narrow' : '1.2-2' }}
-
- } -
- } + + + } @else { +
+ inbox +

{{ hasDateFilters ? 'No transactions found for the selected date range.' : 'No entries yet.' }}

+
+ } - - @if (tab === 'passbook') { -
-
-
- From - + +
-
- To - -
- @if (hasDateFilters) { - - } -
+ } -
-
-
-
DDS Passbook
-
{{ account.officeName }}
-
-
- Account No{{ account.accountNo }} Party Code{{ account.partyCode }} Name{{ account.clientName || account.partyName }} Product{{ account.subType }} · {{ account.interestRate }} % Opened On{{ account.openingDate | date: 'dd/MM/yyyy' }} Agent{{ account.agentCode }} -
-
- - @if (passbookEntries.length === 0) { + + @if (tab === 'nominees') { +
+ @if (!account.nominees || account.nominees.length === 0) {
- {{ hasDateFilters ? 'No entries in these dates' : 'No entries yet' }} + + No nominee on this account
} @else { - - - - - - - - - - - - - @for (txn of passbookEntries; track txn.id) { +
+
DateVoucherParticularsWithdrawalsDepositsBalance
+ - - - - - - + + + + + + + + + - } - - - - - - - - - -
{{ txn.transactionDate | date: 'dd/MM/yyyy' }}{{ txn.voucherNo || '' }} - {{ label(txn.transactionType) }} - @if (txn.description) { - {{ txn.description }} - } - {{ txn.direction === 'DEBIT' ? (txn.amount | formatNumber) : '' }}{{ txn.direction === 'CREDIT' ? (txn.amount | formatNumber) : '' }} - {{ txn.balanceAfterTransaction || 0 | formatNumber }} - #NameRelationGenderMobileBirth DateNominated OnGuardian{{ account.nomineeSplitMode === 'Sequence' ? 'Order' : 'Share %' }}
Total - {{ totalDebits | formatNumber }} - - {{ totalCredits | formatNumber }} - - {{ passbookClosingBalance | formatNumber }} -
+ + + @for (nominee of account.nominees; track nominee.id; let i = $index) { + + {{ i + 1 }} + + {{ nominee.name }} + + {{ nominee.relation }} + {{ nominee.gender }} + {{ nominee.mobileNo || '—' }} + {{ nominee.birthDate ? (nominee.birthDate | date: 'dd MMM yyyy') : '—' }} + {{ nominee.nominationDate ? (nominee.nominationDate | date: 'dd MMM yyyy') : '—' }} + {{ nominee.guardianName || '—' }} + + {{ + account.nomineeSplitMode === 'Sequence' ? nominee.sequenceNo : (nominee.sharePercent ?? '—') + }} + + + } + + +
}
-
- } - - - @if (tab === 'nominees') { -
- @if (!account.nominees || account.nominees.length === 0) { -
- - No nominee on this account -
- } @else { -
- - - - - - - - - - - - - - - - @for (nominee of account.nominees; track nominee.id; let i = $index) { - - - - - - - - - - - - } - -
#NameRelationGenderMobileBirth DateNominated OnGuardian{{ account.nomineeSplitMode === 'Sequence' ? 'Order' : 'Share %' }}
{{ i + 1 }} - {{ nominee.name }} - {{ nominee.relation }}{{ nominee.gender }}{{ nominee.mobileNo || '—' }}{{ nominee.birthDate ? (nominee.birthDate | date: 'dd MMM yyyy') : '—' }}{{ nominee.nominationDate ? (nominee.nominationDate | date: 'dd MMM yyyy') : '—' }}{{ nominee.guardianName || '—' }} - {{ account.nomineeSplitMode === 'Sequence' ? nominee.sequenceNo : (nominee.sharePercent ?? '—') }} -
-
- } -
- } + } + }
diff --git a/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.scss b/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.scss index d0da71aa8..4b5f16eae 100644 --- a/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.scss +++ b/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.scss @@ -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; + } +} diff --git a/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.ts b/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.ts index 33c3c817b..5ecaab665 100644 --- a/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.ts +++ b/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.ts @@ -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 = { 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([]); + + /** 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(`${this.account?.accountNo || 'DDS'} Passbook${passbook.innerHTML}`); + 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(` + ${this.account?.accountNo || 'DDS'} Passbook + + ${clone.outerHTML}`); 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 = ` + `; + + /** 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, ' '); } diff --git a/src/app/deposits/dds-accounts/dds-account-view/dds-transaction-view-dialog/dds-transaction-view-dialog.component.html b/src/app/deposits/dds-accounts/dds-account-view/dds-transaction-view-dialog/dds-transaction-view-dialog.component.html new file mode 100644 index 000000000..b8273db1c --- /dev/null +++ b/src/app/deposits/dds-accounts/dds-account-view/dds-transaction-view-dialog/dds-transaction-view-dialog.component.html @@ -0,0 +1,144 @@ + + +
+
+
+
+

{{ data.typeLabel }}

+

Transaction #{{ txn.id }} · {{ txn.refNo || '—' }}

+
+
+ {{ isCredit ? 'Credit' : 'Debit' }} + {{ txn.amount | currency: currency : 'symbol-narrow' : '1.2-2' }} +
+ +
+ +
+ +
+

DDS Transaction Receipt

+
+ {{ account.partyName }} · A/c {{ account.accountNo }} · {{ txn.officeName || account.officeName || '' }} +
+
+ {{ isCredit ? 'Credit' : 'Debit' }} {{ txn.amount | currency: currency : 'symbol-narrow' : '1.2-2' }} +
+
+ +
+

General

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Id{{ txn.id }}
Reference No.{{ txn.refNo || '—' }}
Transaction Type{{ data.typeLabel }} ({{ txn.direction }})
Transaction Date{{ txn.transactionDate | date: 'dd MMM yyyy' }}
Submitted On{{ txn.submittedOnDate ? (txn.submittedOnDate | date: 'dd MMM yyyy') : '—' }}
Currency{{ currency }}
Amount{{ txn.amount | currency: currency : 'symbol-narrow' : '1.2-2' }}
Running Balance + {{ txn.balanceAfterTransaction || 0 | currency: currency : 'symbol-narrow' : '1.2-2' }} +
Note{{ txn.description || '—' }}
+
+ +
+

Payment Details

+ + + + + + + + + + + + + + + + + +
Mode{{ txn.transactionMode || '—' }}
Account Type / No.{{ txn.accountType || '—' }}{{ txn.accountNo ? ' · ' + txn.accountNo : '' }}
Cheque No.{{ txn.chequeNo || '—' }}
Token No.{{ txn.tokenNo || '—' }}
+
+ +
+

Accounting & Audit

+ + + + + + + + + + + + + + + + + + + + + + + + + +
Voucher No.{{ txn.voucherNo || '—' }}
Journal Entry{{ txn.journalTransactionId || 'None - nothing moved on the books' }}
Branch{{ txn.officeName || account.officeName || '—' }}
Agent{{ txn.agentCode }}{{ txn.agentName ? ' · ' + txn.agentName : '' }}
Created By{{ txn.createdByUsername || '—' }}
Created On{{ txn.createdDate ? (txn.createdDate | date: 'dd MMM yyyy, hh:mm a') : '—' }}
+
+
+ +
+ + + + +
+
diff --git a/src/app/deposits/dds-accounts/dds-account-view/dds-transaction-view-dialog/dds-transaction-view-dialog.component.scss b/src/app/deposits/dds-accounts/dds-account-view/dds-transaction-view-dialog/dds-transaction-view-dialog.component.scss new file mode 100644 index 000000000..4dbddccc1 --- /dev/null +++ b/src/app/deposits/dds-accounts/dds-account-view/dds-transaction-view-dialog/dds-transaction-view-dialog.component.scss @@ -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; + } +} diff --git a/src/app/deposits/dds-accounts/dds-account-view/dds-transaction-view-dialog/dds-transaction-view-dialog.component.ts b/src/app/deposits/dds-accounts/dds-account-view/dds-transaction-view-dialog/dds-transaction-view-dialog.component.ts new file mode 100644 index 000000000..0b7ad0e12 --- /dev/null +++ b/src/app/deposits/dds-accounts/dds-account-view/dds-transaction-view-dialog/dds-transaction-view-dialog.component.ts @@ -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); + private router = inject(Router); + data = inject(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(`${this.txn.refNo || 'DDS'} Receipt${receipt.innerHTML} +
CustomerAuthorised Signatory
+ `); + popup.document.close(); + popup.focus(); + popup.print(); + } + + close(): void { + this.dialogRef.close(); + } +} diff --git a/src/app/deposits/party-master/create-party/create-party.component.html b/src/app/deposits/party-master/create-party/create-party.component.html index 4c90eb218..a33a05b3f 100644 --- a/src/app/deposits/party-master/create-party/create-party.component.html +++ b/src/app/deposits/party-master/create-party/create-party.component.html @@ -24,6 +24,13 @@
@if (isClosed) { Closed — view only + } @else if (termsLocked) { + + Active — scheme terms locked + }
diff --git a/src/app/deposits/party-master/create-party/create-party.component.scss b/src/app/deposits/party-master/create-party/create-party.component.scss index 93db161d4..087e4668a 100644 --- a/src/app/deposits/party-master/create-party/create-party.component.scss +++ b/src/app/deposits/party-master/create-party/create-party.component.scss @@ -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; diff --git a/src/app/deposits/party-master/create-party/create-party.component.ts b/src/app/deposits/party-master/create-party/create-party.component.ts index 02ee6ebcc..85440a825 100644 --- a/src/app/deposits/party-master/create-party/create-party.component.ts +++ b/src/app/deposits/party-master/create-party/create-party.component.ts @@ -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 }); + } } } } diff --git a/src/app/organization/commission-slab-master/commission-slab-master.component.html b/src/app/organization/commission-slab-master/commission-slab-master.component.html deleted file mode 100644 index 2a56de10a..000000000 --- a/src/app/organization/commission-slab-master/commission-slab-master.component.html +++ /dev/null @@ -1,157 +0,0 @@ - - -
-
- -

Commission Slab Master

-
- -
- - - - - - - - - - - - @for (slab of slabs; track slab; let i = $index) { - @if (editingIndex === i) { - - - - - - - - - } @else { - - - - - - - - } - } - - - - - - - - - -
Slab TypeSlab FromSlab ToComm Per
- - - @for (type of slabTypes; track type) { - {{ type }} - } - - - - - - - - - - - - - - - - - -
{{ slab.slabType }}{{ slab.slabFrom }}{{ slab.slabTo }}{{ slab.commissionPercent }}% - @if (slab.id) { - - - } @else { - - - } -
- - - @for (type of slabTypes; track type) { - {{ type }} - } - - - - - - - - - - - - - - - - - -
- - @if (slabs.length === 0) { -

No commission slabs defined yet — add one using the row above.

- } - - @if (coverage.length > 0) { -
- @for (entry of coverage; track entry.slabType) { -
- {{ entry.slabType }} - covers {{ entry.from | number }} to {{ entry.to | number }} - @if (entry.gaps.length > 0) { - - nothing set for {{ entry.gaps.join(', ') }} — an agent landing there earns no commission - - } -
- } -
- } - -
- - -
-
-
diff --git a/src/app/organization/commission-slab-master/commission-slab-master.component.scss b/src/app/organization/commission-slab-master/commission-slab-master.component.scss deleted file mode 100644 index 33b830b27..000000000 --- a/src/app/organization/commission-slab-master/commission-slab-master.component.scss +++ /dev/null @@ -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; - } -} diff --git a/src/app/organization/dds-batch-processing/dds-batch-processing.component.html b/src/app/organization/dds-batch-processing/dds-batch-processing.component.html index 7570ffad9..3273ce465 100644 --- a/src/app/organization/dds-batch-processing/dds-batch-processing.component.html +++ b/src/app/organization/dds-batch-processing/dds-batch-processing.component.html @@ -11,13 +11,74 @@ -

DDS Interest & Commission

+
+
+

DDS Interest & Commission

+

+ Calculate, check and post DDS interest and agent commission, and close matured accounts +

+
-
-
+ + +
+
+ 1 +
+

Choose the job

+

Each month: Calculate first, check the preview, then Post.

+
+
+ +
+ @for (group of jobGroups; track group.title) { +
+
+ +
+ {{ group.title }} + {{ group.hint }} +
+
+
+ @for (code of group.steps; track code; let last = $last) { + + @if (!last) { + + } + } +
+
+ } +
+
+ + +
+
+ 2 +
+

+ {{ jobTypeLabel }} + {{ + isPostingJob ? 'Moves money' : 'Calculation only' + }} +

+

Which branches, agents and accounts, and as of which date.

+
+
+
- + Job Type @for (type of jobTypes; track type.code) { @@ -26,7 +87,14 @@ - + + {{ isPostingJob ? 'Posting Date' : 'Interest Date' }} + + + + + + Branch @for (scope of branchScopes; track scope) { @@ -37,51 +105,59 @@ @if (isBranchSelected) {
- @for (office of offices; track office.id) { - - {{ office.name }} - - } - @if (offices.length === 0) { - Loading branches... - } + Branches ({{ selectedOfficeNames.size }} selected) +
+ @for (office of offices; track office.id) { + + {{ office.name }} + + } + @if (offices.length === 0) { + Loading branches... + } +
} - - {{ isPostingJob ? 'Posting Date' : 'Interest Date' }} - - - - +
+
+ Agents +
+ + From + + + to + + To + + +
+
- - Agent From - - - - - Agent To - - - - - @if (!isCommissionJob) { - - Party From - - - - - Party To - - - } + + @if (!isCommissionJob) { +
+ Parties +
+ + From + + + to + + To + + +
+
+ } +
-
- +
+ @if (isPostingJob) { {{ jobTypeLabel }} moves what has already been calculated to where it belongs — into the @@ -95,113 +171,21 @@
- @if (generatedRun) { -
-
- -

{{ labelOf(generatedRun.jobType) }} — preview

- Run #{{ generatedRun.id }} · nothing applied yet -
- - @if (previewRows.length === 0) { -

- Nothing to apply. Either this period has already been processed, or there was no activity in it. -

- } @else { -
- @if (showsCommissionBreakdown) { - - - - - - - - - - - - - - - @for (row of previewRows; track row.id) { - - - - - - - - - - - } - - - - - - - -
AgentPeriodCollectionRateCommissionTDSDeductionsPayable
- {{ row.agentCode }} - {{ row.agentName }} - {{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}{{ money(row.baseAmount) }}{{ row.ratePercent }}%{{ money(row.grossAmount) }}{{ money(row.tdsAmount) }}{{ money(row.deductionAmount) }}{{ money(row.amount) }}
Total payable{{ money(previewTotal) }}
- } @else { - - - - - @if (!isCommissionJob) { - - } - - - - - - @for (row of previewRows; track row.id) { - - - @if (!isCommissionJob) { - - } - - - - } - - - - - - - -
{{ isCommissionJob ? 'Agent' : 'Party' }}AgentPeriodAmount
- @if (isCommissionJob) { - {{ row.agentCode }} - {{ row.agentName }} - } @else { - {{ row.partyCode }} - {{ row.partyName }} - } - {{ row.agentCode }}{{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}{{ money(row.amount) }}
Total{{ money(previewTotal) }}
- } -
- } -
- } -
- + + @if (generatedRun) { - + } -
- -
+
+ + + @if (generatedRun) { +
+
+ 3 +
+

{{ labelOf(generatedRun.jobType) }} — preview

+

Run #{{ generatedRun.id }} · nothing applied yet — check the figures, then Post.

+
+
+ + @if (previewRows.length === 0) { +

+ + Nothing to apply. Either this period has already been processed, or there was no activity in it. +

+ } @else { +
+
+ Agents{{ agentGroups.length }} +
+
+ {{ isCommissionJob ? 'Entries' : 'Accounts' }}{{ previewRows.length }} +
+
+ {{ showsCommissionBreakdown ? 'Total payable' : 'Total' }} + ₹ {{ money(previewTotal) }} +
+
+ Date{{ generatedRun.jobDate | dateFormat }} +
+
+ +
+ + {{ + isCommissionJob + ? 'One line per agent - View shows how their commission was worked out.' + : 'Grouped by collecting agent - View shows the DDS accounts under each.' + }} + + + +
+ +
+ @if (isCommissionJob) { + + + + + + @if (showsCommissionBreakdown) { + + + + + + } + + + + + + @for (group of agentGroups; track group.key) { + @for (row of group.rows; track row.id) { + + + + @if (showsCommissionBreakdown) { + + + + + + } + + + + } + } + + + + + + + + +
AgentPeriodCollectionRateCommissionTDSDeductions{{ showsCommissionBreakdown ? 'Payable' : 'Amount' }}
+ {{ row.agentCode }} + {{ row.agentName }} + {{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}{{ money(row.baseAmount) }} + {{ row.ratePercent }}% + {{ money(row.grossAmount) }}{{ money(row.tdsAmount) }}{{ money(row.deductionAmount) }}{{ money(row.amount) }} + +
Total{{ money(previewTotal) }}
+ } @else { + + + + + + + + + + + @for (group of agentGroups; track group.key) { + + + + + + + } + + + + + + + + + +
AgentAccountsAmount
+ {{ group.initials }} + {{ group.agentCode }} + {{ group.agentName }} + {{ group.rows.length }}{{ money(group.total) }} + +
Total{{ previewRows.length }}{{ money(previewTotal) }}
+ } +
+ } +
+ } +
-
- -

Previous runs

+
+
+ +
+

Previous runs

+

Every run, and whether it was applied.

+
+
+ @if (!runsLoading && recentRuns.length > 0) { +
+ {{ runStats.total }} runs + {{ runStats.posted }} posted + @if (runStats.pending) { + {{ runStats.pending }} not posted + } + @if (runStats.running) { + {{ runStats.running }} running + } + @if (runStats.failed) { + {{ runStats.failed }} failed + } +
+ }
- @if (recentRuns.length === 0) { -

No batch has been run yet.

+ @if (runsLoading) { +
+ @for (row of [1, 2, 3, 4]; track row) { + + } +
+ } @else if (recentRuns.length === 0) { +

No batch has been run yet.

} @else {
@@ -255,8 +410,10 @@ @for (run of recentRuns; track run.id) { - - + + @@ -268,7 +425,7 @@ @@ -296,4 +453,124 @@ } + + + + @if (selectedGroup; as group) { +
+
+ {{ group.initials }} +
+

+ {{ group.agentCode }} {{ group.agentName }} +

+

+ {{ labelOf(generatedRun?.jobType) }} · Run #{{ generatedRun?.id }} · + {{ generatedRun?.jobDate | dateFormat }} +

+
+ +
+ +
+ @if (isCommissionJob) { + @for (row of group.rows; track row.id) { +
+ @if (showsCommissionBreakdown) { +
+ Collection₹ {{ money(row.baseAmount) }} +
+
+ Slab rate{{ row.ratePercent }}% +
+
+ Commission₹ {{ money(row.grossAmount) }} +
+
+ – TDS₹ {{ money(row.tdsAmount) }} +
+
+ – Deductions₹ {{ money(row.deductionAmount) }} +
+ } +
+ {{ showsCommissionBreakdown ? 'Payable to agent' : 'To be paid out' }} + ₹ {{ money(row.amount) }} +
+
+

+ 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. +

+ } + } @else { +
+
+ Accounts{{ group.rows.length }} +
+
+ Total₹ {{ money(group.total) }} +
+
+ + + +
+
{{ run.id }}{{ labelOf(run.jobType) }}#{{ run.id }} + {{ labelOf(run.jobType) }} + {{ run.jobDate | dateFormat }} {{ run.totalRows }} {{ money(run.totalAmount) }} @switch (run.status) { @case ('POSTED') { - Posted + Posted } @case ('POSTING') { @@ -280,11 +437,11 @@ class="bp-status bp-status--failed" [matTooltip]="run.failureReason || 'No reason recorded'" > - Failed + Failed } @default { - Not posted + Not posted } }
+ + + + + + + + + + + @for (row of popupRows; track row.id; let i = $index) { + + + + + + + + } @empty { + + + + } + + + + + + + +
#PartyPeriodRateAmount
{{ i + 1 }} + {{ row.partyCode }} + {{ row.partyName }} + {{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}{{ row.ratePercent ? row.ratePercent + '%' : '—' }}{{ money(row.amount) }}
No account matches the search.
{{ agentSearch ? 'Total of the accounts shown' : 'Total' }}{{ money(popupTotal) }}
+
+ } +
+ +
+ + + +
+
+ } +
diff --git a/src/app/organization/dds-batch-processing/dds-batch-processing.component.scss b/src/app/organization/dds-batch-processing/dds-batch-processing.component.scss index 26dbefd94..50bfe1f38 100644 --- a/src/app/organization/dds-batch-processing/dds-batch-processing.component.scss +++ b/src/app/organization/dds-batch-processing/dds-batch-processing.component.scss @@ -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; + } } } diff --git a/src/app/organization/dds-batch-processing/dds-batch-processing.component.ts b/src/app/organization/dds-batch-processing/dds-batch-processing.component.ts index 37a2b5ee5..b81891f05 100644 --- a/src/app/organization/dds-batch-processing/dds-batch-processing.component.ts +++ b/src/app/organization/dds-batch-processing/dds-batch-processing.component.ts @@ -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; + + /** 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(); + 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 = { + 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 { diff --git a/src/app/organization/organization-routing.module.ts b/src/app/organization/organization-routing.module.ts index 6dfb6f46f..de58ad938 100644 --- a/src/app/organization/organization-routing.module.ts +++ b/src/app/organization/organization-routing.module.ts @@ -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, diff --git a/src/app/organization/organization.component.html b/src/app/organization/organization.component.html index 9715445f4..9f2114490 100644 --- a/src/app/organization/organization.component.html +++ b/src/app/organization/organization.component.html @@ -225,19 +225,6 @@
- -
diff --git a/src/app/organization/organization.service.ts b/src/app/organization/organization.service.ts index 50550df61..9a4080b8d 100644 --- a/src/app/organization/organization.service.ts +++ b/src/app/organization/organization.service.ts @@ -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 { + 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. diff --git a/src/app/products/commission-slab-master/commission-slab-master.component.html b/src/app/products/commission-slab-master/commission-slab-master.component.html new file mode 100644 index 000000000..32330aa4e --- /dev/null +++ b/src/app/products/commission-slab-master/commission-slab-master.component.html @@ -0,0 +1,311 @@ + + +
+
+ +
+
+

Commission Slab Master

+

The DDS agent commission chart - a percentage on each agent's collection volume

+
+ + +
+ + +
+
+ 1 + The commission run adds up each agent's collections not yet counted. +
+
+ 2 + It finds the Account slab where From ≤ collection ≤ To. +
+
+ 3 + Commission = collection × slab % . TDS and the agent's deductions come off that. +
+
+ ! + A collection with no slab (a gap, below the first or above the last) stops the run. Start each slab + 0.01 after the last one ends. +
+
+ +
+
+

Slabs

+ @if (loading) { + Loading slabs… + } @else { + {{ slabs.length }} {{ slabs.length === 1 ? 'slab' : 'slabs' }} + } +
+ + + + + + + + + + + + + + @if (loading) { + + @for (row of skeletonRows; track row) { + + + + + + + + + } + } + @for (slab of slabs; track slab; let i = $index) { + @if (editingIndex === i) { + + + + + + + + + + } @else { + + + + + + + + + } + } + + + + + + + + + + +
Slab TypeSlab From (₹)Slab To (₹)Comm %Commission at "To"
+ + + @for (type of slabTypes; track type) { + {{ typeInfo(type).title }} + } + + + + + + + + + + + + + + % + + + + +
+ {{ + slab.slabType + }} + @if (!typeInfo(slab.slabType).used) { + Not applied + } + @if (!slab.id) { + Unsaved + } + {{ slab.slabFrom | number: '1.2-2' }}{{ slab.slabTo | number: '1.2-2' }} + {{ slab.commissionPercent | number: '1.0-4' }}% + ₹ {{ commissionAtTop(slab) | number: '1.2-2' }} + @if (slab.id) { + + + } @else { + + + } +
+ + + @for (type of slabTypes; track type) { + {{ typeInfo(type).title }} + } + + + + + + + + + + + + + + % + + + {{ newSlabForm.get('slabType')?.value ? typeInfo(newSlabForm.get('slabType')?.value).hint : 'Pick a type' }} + + + +
+ + @if (loadFailed) { +

+ The commission slabs could not be loaded. + +

+ } @else if (!loading && slabs.length === 0) { +

No commission slabs defined yet — add one using the row above.

+ } + + @if (!loading && coverage.length > 0) { +
+ @for (entry of coverage; track entry.slabType) { +
+ {{ entry.slabType }} + + covers ₹ {{ entry.from | number: '1.2-2' }} to ₹ {{ entry.to | number: '1.2-2' }} + + @if (typeInfo(entry.slabType).used && entry.from > 0) { + + below ₹ {{ entry.from | number: '1.2-2' }} has no slab + + } + @if (entry.gaps.length > 0) { + + no slab for {{ entry.gaps.join(', ') }} — the commission run + stops for an agent landing there + + } + @if (!typeInfo(entry.slabType).used) { + {{ typeInfo(entry.slabType).hint }} + } +
+ } +
+ } + +
+ + +
+
+ + + +
+
+
+

Commission Calculator

+ Uses the saved Account slabs - the same arithmetic as the commission run +
+ +
+
+ + Agent's collection (₹) + + + + TDS % + + + + Deductions (₹) + + +
+ + @if (calculation; as c) { + @if (c.slab) { +
+
+ Slab + ₹ {{ c.slab.slabFrom | number: '1.2-2' }} – {{ c.slab.slabTo | number: '1.2-2' }} +
+
+ Rate{{ c.rate | number: '1.0-4' }}% +
+
+ Gross₹ {{ c.gross | number: '1.2-2' }} +
+
+ – TDS₹ {{ c.tds | number: '1.2-2' }} +
+
+ – Deductions₹ {{ c.deductions | number: '1.2-2' }} +
+
+ Net to agent₹ {{ (c.net > 0 ? c.net : 0) | number: '1.2-2' }} +
+
+ @if (c.net <= 0) { +

The deductions swallow this commission - the run pays this agent nothing.

+ } + } @else { +

+ No saved Account slab covers this amount — the commission + run would stop with "no commission slab" for this agent. +

+ } + } +
+
+
diff --git a/src/app/products/commission-slab-master/commission-slab-master.component.scss b/src/app/products/commission-slab-master/commission-slab-master.component.scss new file mode 100644 index 000000000..ba8b41051 --- /dev/null +++ b/src/app/products/commission-slab-master/commission-slab-master.component.scss @@ -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; + } + } +} diff --git a/src/app/organization/commission-slab-master/commission-slab-master.component.ts b/src/app/products/commission-slab-master/commission-slab-master.component.ts similarity index 56% rename from src/app/organization/commission-slab-master/commission-slab-master.component.ts rename to src/app/products/commission-slab-master/commission-slab-master.component.ts index 6519a1193..39d114ccf 100644 --- a/src/app/organization/commission-slab-master/commission-slab-master.component.ts +++ b/src/app/products/commission-slab-master/commission-slab-master.component.ts @@ -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; + + /** 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 = { 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 = { + 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 { diff --git a/src/app/products/products-routing.module.ts b/src/app/products/products-routing.module.ts index 435578663..c4b83a29f 100644 --- a/src/app/products/products-routing.module.ts +++ b/src/app/products/products-routing.module.ts @@ -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' }, diff --git a/src/app/products/products.component.html b/src/app/products/products.component.html index 9476f53db..b84e1a0a9 100644 --- a/src/app/products/products.component.html +++ b/src/app/products/products.component.html @@ -153,6 +153,19 @@
+ +