feat(dds): batch run preview on its own page, runs popup, projected GLs in approvals

- Generate opens the run on /organization/dds-batch-processing/run/:id with a back arrow,
  Post and Discard beside the figures, agent-wise table, View popup and CSV export; the
  table scrolls inside the page. A posted, running or failed run opens there read-only
- Batch screen lists generated-but-unposted runs as buttons to reopen them; Previous runs
  moved into a popup with status and job chips, search, date range, pagination, row
  detail and export; posting progress is followed from the run status
- Shared helpers for job names, money and agent grouping
- Approval review shows, for a calculation run, the entries its matching Posting run will
  book, marked "when posted"

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 22:04:26 +05:30
co-authored by Claude Sonnet 5.5
parent 0275a46326
commit c766a6f092
10 changed files with 1752 additions and 796 deletions
@@ -515,14 +515,27 @@
}} }}
</span> </span>
</div> </div>
@if (entriesProjected) {
<div class="dri-detail-item dri-detail-item--full">
<span class="dri-detail-label">Ledger</span>
<span class="dri-detail-value"
>No journal entry is made when this is approved - it records what has been earned. The entries below
are what the matching Posting run will book.</span
>
</div>
}
<div class="dri-detail-item"> <div class="dri-detail-item">
<span class="dri-detail-label">DR Accounts</span> <span class="dri-detail-label">{{
entriesProjected ? 'DR Accounts (when posted)' : 'DR Accounts'
}}</span>
<span class="dri-detail-value" <span class="dri-detail-value"
>{{ debitLines.length }} account{{ debitLines.length !== 1 ? 's' : '' }}</span >{{ debitLines.length }} account{{ debitLines.length !== 1 ? 's' : '' }}</span
> >
</div> </div>
<div class="dri-detail-item"> <div class="dri-detail-item">
<span class="dri-detail-label">CR Accounts</span> <span class="dri-detail-label">{{
entriesProjected ? 'CR Accounts (when posted)' : 'CR Accounts'
}}</span>
<span class="dri-detail-value" <span class="dri-detail-value"
>{{ creditLines.length }} account{{ creditLines.length !== 1 ? 's' : '' }}</span >{{ creditLines.length }} account{{ creditLines.length !== 1 ? 's' : '' }}</span
> >
@@ -547,7 +560,10 @@
<div class="dri-card"> <div class="dri-card">
<div class="dri-section-title"> <div class="dri-section-title">
<mat-icon>arrow_upward</mat-icon> <mat-icon>arrow_upward</mat-icon>
Debit Entries ({{ debitLines.length }} account{{ debitLines.length !== 1 ? 's' : '' }} — funds out) {{ entriesProjected ? 'Debit entries when posted' : 'Debit Entries' }} ({{ debitLines.length }} account{{
debitLines.length !== 1 ? 's' : ''
}}
— funds out)
</div> </div>
<table class="dri-cr-table"> <table class="dri-cr-table">
<thead> <thead>
@@ -585,7 +601,10 @@
<div class="dri-card"> <div class="dri-card">
<div class="dri-section-title"> <div class="dri-section-title">
<mat-icon>arrow_downward</mat-icon> <mat-icon>arrow_downward</mat-icon>
Credit Entries ({{ creditLines.length }} account{{ creditLines.length !== 1 ? 's' : '' }} — funds in) {{ entriesProjected ? 'Credit entries when posted' : 'Credit Entries' }} ({{ creditLines.length }} account{{
creditLines.length !== 1 ? 's' : ''
}}
— funds in)
</div> </div>
<table class="dri-cr-table"> <table class="dri-cr-table">
<thead> <thead>
@@ -189,6 +189,15 @@ export class DdsApprovalReviewComponent implements OnInit {
private groups: { name: string; icon: string; changed: number; fields: any[] }[] = []; private groups: { name: string; icon: string; changed: number; fields: any[] }[] = [];
/** Whether the entry moves money at all, and so has an amount and debit and credit sides to show. */ /** Whether the entry moves money at all, and so has an amount and debit and credit sides to show. */
/**
* A calculation run is approved to record what has been earned in DDS's own tables; it makes no journal
* entry itself. The sides shown for it are what the matching Posting run will book, marked so they are
* not read as entries made on approval.
*/
get entriesProjected(): boolean {
return (this.detail?.lines || []).some((line: any) => line.projected);
}
get movesMoney(): boolean { get movesMoney(): boolean {
return (this.entry?.amount !== null && this.entry?.amount !== undefined) || !!this.detail?.lines?.length; return (this.entry?.amount !== null && this.entry?.amount !== undefined) || !!this.detail?.lines?.length;
} }
@@ -18,6 +18,14 @@
Calculate, check and post DDS interest and agent commission, and close matured accounts Calculate, check and post DDS interest and agent commission, and close matured accounts
</p> </p>
</div> </div>
<span class="bp-header__spacer"></span>
<button mat-stroked-button type="button" class="bp-runs-btn" (click)="openRuns()">
<i class="fas fa-history"></i> Previous runs
<span class="bp-runs-btn__count">{{ runStats.total }}</span>
@if (runStats.failed) {
<span class="bp-runs-btn__warn" matTooltip="{{ runStats.failed }} failed"></span>
}
</button>
</div> </div>
<form [formGroup]="batchForm"> <form [formGroup]="batchForm">
@@ -174,170 +182,48 @@
<div class="bp-action-bar"> <div class="bp-action-bar">
<button mat-stroked-button type="button" (click)="exit()">Exit</button> <button mat-stroked-button type="button" (click)="exit()">Exit</button>
<span class="bp-action-bar__spacer"></span> <span class="bp-action-bar__spacer"></span>
@if (generatedRun) { <!-- Generate opens the run on a page of its own; Post and Discard are there, beside the figures. -->
<button mat-stroked-button type="button" (click)="discard()"><i class="fas fa-times"></i> Discard</button> <button
} mat-flat-button
<button mat-stroked-button type="button" [disabled]="batchForm.invalid || generating" (click)="generate()"> color="primary"
type="button"
[disabled]="batchForm.invalid || generating"
(click)="generate()"
>
@if (generating) { @if (generating) {
<i class="fas fa-circle-notch fa-spin"></i> Generating… <i class="fas fa-circle-notch fa-spin"></i> Generating…
} @else { } @else {
<i class="fas fa-calculator"></i> Generate <i class="fas fa-calculator"></i> Generate
} }
</button> </button>
<button
mat-flat-button
color="primary"
type="button"
[disabled]="!generatedRun || previewRows.length === 0"
(click)="post()"
>
<i class="fas fa-check"></i> Post
</button>
</div> </div>
</div> </div>
<!-- ── 3. Preview ─────────────────────────────────────────────────────────────────────────── --> <!--
@if (generatedRun) { A run generated and left unposted is still there to go back to. Each is one button, so the page a
<div class="bp-card"> clerk closed by mistake is a click away - not a Generate again.
<div class="bp-step-head"> -->
<span class="bp-step-no">3</span> @if (pendingPreviews.length > 0) {
<div class="bp-card bp-previews">
<div class="bp-step-head bp-step-head--plain">
<span class="bp-step-no bp-step-no--muted"><i class="fas fa-table"></i></span>
<div> <div>
<h3>{{ labelOf(generatedRun.jobType) }} — preview</h3> <h3>Generated, not posted yet</h3>
<p>Run #{{ generatedRun.id }} · nothing applied yet — check the figures, then Post.</p> <p>Open a run to check its figures, then Post or Discard it.</p>
</div> </div>
</div> </div>
<div class="bp-previews__list">
@if (previewRows.length === 0) { @for (run of pendingPreviews; track run.id) {
<p class="bp-empty bp-empty--box"> <button type="button" class="bp-preview-btn" (click)="openRun(run.id)">
<i class="fas fa-inbox"></i> <span class="bp-preview-btn__job">{{ labelOf(run.jobType) }}</span>
Nothing to apply. Either this period has already been processed, or there was no activity in it. <span class="bp-preview-btn__meta">
</p> Run #{{ run.id }} · {{ run.totalRows }} {{ run.totalRows === 1 ? 'entry' : 'entries' }} · ₹
} @else { {{ money(run.totalAmount) }}
<div class="bp-kpis"> </span>
<div class="bp-kpi"> <span class="bp-preview-btn__go">Open <i class="fas fa-arrow-right"></i></span>
<span>Agents</span><strong>{{ agentGroups.length }}</strong>
</div>
<div class="bp-kpi">
<span>{{ isCommissionJob ? 'Entries' : 'Accounts' }}</span
><strong>{{ previewRows.length }}</strong>
</div>
<div class="bp-kpi bp-kpi--accent">
<span>{{ showsCommissionBreakdown ? 'Total payable' : 'Total' }}</span>
<strong>₹ {{ money(previewTotal) }}</strong>
</div>
<div class="bp-kpi">
<span>Date</span><strong>{{ generatedRun.jobDate | dateFormat }}</strong>
</div>
</div>
<div class="bp-tools">
<span class="bp-tools__hint">
{{
isCommissionJob
? 'One line per agent - View shows how their commission was worked out.'
: 'Grouped by collecting agent - View shows the DDS accounts under each.'
}}
</span>
<span class="bp-tools__spacer"></span>
<button mat-stroked-button type="button" (click)="exportPreview()">
<i class="fas fa-file-export"></i> Export all details
</button> </button>
</div> }
</div>
<div class="bp-table-scroll">
@if (isCommissionJob) {
<table class="bp-table bp-table--breakdown">
<thead>
<tr>
<th>Agent</th>
<th>Period</th>
@if (showsCommissionBreakdown) {
<th class="bp-num">Collection</th>
<th class="bp-num">Rate</th>
<th class="bp-num">Commission</th>
<th class="bp-num">TDS</th>
<th class="bp-num">Deductions</th>
}
<th class="bp-num">{{ showsCommissionBreakdown ? 'Payable' : 'Amount' }}</th>
<th class="bp-view-col"></th>
</tr>
</thead>
<tbody>
@for (group of agentGroups; track group.key) {
@for (row of group.rows; track row.id) {
<tr>
<td>
<span class="bp-code">{{ row.agentCode }}</span>
<span class="bp-sub">{{ row.agentName }}</span>
</td>
<td class="bp-period">{{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}</td>
@if (showsCommissionBreakdown) {
<td class="bp-num">{{ money(row.baseAmount) }}</td>
<td class="bp-num">
<span class="bp-rate">{{ row.ratePercent }}%</span>
</td>
<td class="bp-num">{{ money(row.grossAmount) }}</td>
<td class="bp-num bp-minus">{{ money(row.tdsAmount) }}</td>
<td class="bp-num bp-minus">{{ money(row.deductionAmount) }}</td>
}
<td class="bp-num bp-total">{{ money(row.amount) }}</td>
<td class="bp-view-col">
<button mat-stroked-button type="button" class="bp-view-btn" (click)="viewAgent(group)">
<i class="fas fa-eye"></i> View
</button>
</td>
</tr>
}
}
</tbody>
<tfoot>
<tr>
<td [attr.colspan]="showsCommissionBreakdown ? 7 : 2">Total</td>
<td class="bp-num bp-total">{{ money(previewTotal) }}</td>
<td></td>
</tr>
</tfoot>
</table>
} @else {
<table class="bp-table">
<thead>
<tr>
<th>Agent</th>
<th class="bp-num">Accounts</th>
<th class="bp-num">Amount</th>
<th class="bp-view-col"></th>
</tr>
</thead>
<tbody>
@for (group of agentGroups; track group.key) {
<tr class="bp-agent-row">
<td>
<span class="bp-avatar">{{ group.initials }}</span>
<span class="bp-code bp-code--inline">{{ group.agentCode }}</span>
<span class="bp-sub bp-sub--inline">{{ group.agentName }}</span>
</td>
<td class="bp-num">{{ group.rows.length }}</td>
<td class="bp-num bp-total">{{ money(group.total) }}</td>
<td class="bp-view-col">
<button mat-stroked-button type="button" class="bp-view-btn" (click)="viewAgent(group)">
<i class="fas fa-eye"></i> View
</button>
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td>Total</td>
<td class="bp-num">{{ previewRows.length }}</td>
<td class="bp-num bp-total">{{ money(previewTotal) }}</td>
<td></td>
</tr>
</tfoot>
</table>
}
</div>
}
</div> </div>
} }
</form> </form>
@@ -347,7 +233,7 @@
it in the background and answers at once. Without this the screen would report a run as posted it in the background and answers at once. Without this the screen would report a run as posted
seconds after it began, and the clerk would go looking for credits still being written. seconds after it began, and the clerk would go looking for credits still being written.
--> -->
@if (postingRunId) { @if (runStats.running > 0) {
<div class="bp-running"> <div class="bp-running">
<i class="fas fa-circle-notch fa-spin"></i> <i class="fas fa-circle-notch fa-spin"></i>
<span> <span>
@@ -357,220 +243,227 @@
</div> </div>
} }
<!-- Without this a generated-but-unposted run vanishes the moment the page is left, and there is <!--
no way to tell whether a month's interest was ever actually posted. --> Every run and whether it was applied - without this a generated-but-unposted run vanishes the moment
<div class="bp-card"> the page is left, and there is no way to tell whether a month's interest was ever actually posted.
<div class="bp-runs-head"> In a popup so the page itself stays one screen: the list can run to hundreds of runs, filtered and
<div class="bp-step-head bp-step-head--plain"> paged here, with each run's full detail opening under its row.
<span class="bp-step-no bp-step-no--muted"><i class="fas fa-history"></i></span> -->
<div> <ng-template #runsDialog>
<div class="bpr">
<div class="bpr-head">
<span class="bpr-head__ic"><i class="fas fa-history"></i></span>
<div class="bpr-head__text">
<h3>Previous runs</h3> <h3>Previous runs</h3>
<p>Every run, and whether it was applied.</p> <p>Every run, and whether it was applied.</p>
</div> </div>
<button type="button" class="bpr-icon-btn" (click)="loadRecentRuns()" matTooltip="Refresh" aria-label="Refresh">
<i class="fas fa-sync-alt" [class.fa-spin]="runsLoading"></i>
</button>
<button type="button" class="bpr-icon-btn" mat-dialog-close aria-label="Close">
<i class="fas fa-times"></i>
</button>
</div> </div>
@if (!runsLoading && recentRuns.length > 0) {
<div class="bp-stats">
<span class="bp-stat">{{ runStats.total }} runs</span>
<span class="bp-stat bp-stat--posted">{{ runStats.posted }} posted</span>
@if (runStats.pending) {
<span class="bp-stat">{{ runStats.pending }} not posted</span>
}
@if (runStats.running) {
<span class="bp-stat bp-stat--running">{{ runStats.running }} running</span>
}
@if (runStats.failed) {
<span class="bp-stat bp-stat--failed">{{ runStats.failed }} failed</span>
}
</div>
}
</div>
@if (runsLoading) { <div class="bpr-body">
<div class="bp-skeleton"> <!-- A status chip says how many runs are in it and filters to them; the chips combine. -->
@for (row of [1, 2, 3, 4]; track row) { <div class="bpr-statuses">
<span class="bp-skeleton__bar"></span> @for (status of runStatuses; track status.code) {
} <button
</div> type="button"
} @else if (recentRuns.length === 0) { class="bpr-chip bpr-chip--{{ status.code.toLowerCase() }}"
<p class="bp-empty bp-empty--box"><i class="fas fa-inbox"></i> No batch has been run yet.</p> [class.bpr-chip--on]="runFilter.statuses.has(status.code)"
} @else { (click)="toggleRunStatus(status.code)"
<div class="bp-table-scroll"> >
<table class="bp-table bp-table--runs"> {{ status.label }} <b>{{ runCount(status.code) }}</b>
<thead> </button>
<tr> }
<th>#</th> <span class="bpr-total">{{ recentRuns.length }} runs in all</span>
<th>Job</th>
<th>Date</th>
<th class="bp-num">Entries</th>
<th class="bp-num">Amount</th>
<th>Status</th>
<th>Posted On</th>
</tr>
</thead>
<tbody>
@for (run of recentRuns; track run.id) {
<tr>
<td class="bp-runid">#{{ run.id }}</td>
<td>
<span class="bp-jobname">{{ labelOf(run.jobType) }}</span>
</td>
<td>{{ run.jobDate | dateFormat }}</td>
<td class="bp-num">{{ run.totalRows }}</td>
<td class="bp-num">{{ money(run.totalAmount) }}</td>
<!--
Four states, not two. A run that is still crediting is not "not posted" — that
reads as a run somebody forgot, and a clerk would run it again on top of the one
already going. One that failed says so, with the reason the server recorded.
-->
<td>
@switch (run.status) {
@case ('POSTED') {
<span class="bp-status bp-status--posted"><i class="fas fa-check-circle"></i> Posted</span>
}
@case ('POSTING') {
<span class="bp-status bp-status--running">
<i class="fas fa-circle-notch fa-spin"></i> Running
</span>
}
@case ('FAILED') {
<span
class="bp-status bp-status--failed"
[matTooltip]="run.failureReason || 'No reason recorded'"
>
<i class="fas fa-exclamation-circle"></i> Failed
</span>
}
@default {
<span class="bp-status"><i class="far fa-clock"></i> Not posted</span>
}
}
</td>
<td>{{ run.postedOnDate ? (run.postedOnDate | dateFormat) : '—' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<!-- One agent's accounts (or commission breakdown) in a popup, opened from View. -->
<ng-template #agentDialog>
@if (selectedGroup; as group) {
<div class="bpd">
<div class="bpd-head">
<span class="bpd-av">{{ group.initials }}</span>
<div class="bpd-head__text">
<h3>
<span class="bp-code bp-code--inline">{{ group.agentCode }}</span> {{ group.agentName }}
</h3>
<p>
{{ labelOf(generatedRun?.jobType) }} · Run #{{ generatedRun?.id }} ·
{{ generatedRun?.jobDate | dateFormat }}
</p>
</div>
<button type="button" class="bpd-close" mat-dialog-close aria-label="Close">
<i class="fas fa-times"></i>
</button>
</div> </div>
<div class="bpd-body"> <div class="bpr-filters">
@if (isCommissionJob) { <label class="bpr-field bpr-field--search">
@for (row of group.rows; track row.id) { <span>Search</span>
<div class="bpd-grid">
@if (showsCommissionBreakdown) {
<div class="bpd-cell">
<span>Collection</span><strong>₹ {{ money(row.baseAmount) }}</strong>
</div>
<div class="bpd-cell">
<span>Slab rate</span><strong>{{ row.ratePercent }}%</strong>
</div>
<div class="bpd-cell">
<span>Commission</span><strong>₹ {{ money(row.grossAmount) }}</strong>
</div>
<div class="bpd-cell">
<span>– TDS</span><strong class="bp-minus">₹ {{ money(row.tdsAmount) }}</strong>
</div>
<div class="bpd-cell">
<span>– Deductions</span><strong class="bp-minus">₹ {{ money(row.deductionAmount) }}</strong>
</div>
}
<div class="bpd-cell bpd-cell--net">
<span>{{ showsCommissionBreakdown ? 'Payable to agent' : 'To be paid out' }}</span>
<strong>₹ {{ money(row.amount) }}</strong>
</div>
</div>
<p class="bpd-note">
Period {{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}. The agent's collection from all
their customers is added up and one slab rate applies to the whole figure.
</p>
}
} @else {
<div class="bpd-kpis">
<div class="bpd-cell">
<span>Accounts</span><strong>{{ group.rows.length }}</strong>
</div>
<div class="bpd-cell bpd-cell--net">
<span>Total</span><strong>₹ {{ money(group.total) }}</strong>
</div>
</div>
<input <input
type="search" type="search"
class="bpd-search" placeholder="Run number or job"
placeholder="Search party code or name" [value]="runFilter.search"
[value]="agentSearch" (input)="setRunSearch($any($event.target).value)"
(input)="agentSearch = $any($event.target).value"
/> />
</label>
<div class="bp-table-scroll bpd-scroll"> <label class="bpr-field">
<table class="bp-table"> <span>Job date from</span>
<thead> <input type="date" [value]="runFilter.from" (change)="setRunDate('from', $any($event.target).value)" />
<tr> </label>
<th>#</th> <label class="bpr-field">
<th>Party</th> <span>to</span>
<th>Period</th> <input type="date" [value]="runFilter.to" (change)="setRunDate('to', $any($event.target).value)" />
<th class="bp-num">Rate</th> </label>
<th class="bp-num">Amount</th> @if (hasRunFilters) {
</tr> <button type="button" class="bpr-clear" (click)="clearRunFilters()">
</thead> <i class="fas fa-times"></i> Clear filters
<tbody> </button>
@for (row of popupRows; track row.id; let i = $index) {
<tr>
<td class="bp-runid">{{ i + 1 }}</td>
<td>
<span class="bp-code bp-code--inline">{{ row.partyCode }}</span>
<span class="bp-sub bp-sub--inline">{{ row.partyName }}</span>
</td>
<td class="bp-period">{{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}</td>
<td class="bp-num">{{ row.ratePercent ? row.ratePercent + '%' : '—' }}</td>
<td class="bp-num bp-total">{{ money(row.amount) }}</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="bp-empty">No account matches the search.</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="4">{{ agentSearch ? 'Total of the accounts shown' : 'Total' }}</td>
<td class="bp-num bp-total">{{ money(popupTotal) }}</td>
</tr>
</tfoot>
</table>
</div>
} }
</div> </div>
<div class="bpd-actions"> <div class="bpr-jobs">
<button mat-stroked-button type="button" mat-dialog-close>Close</button> <span class="bpr-jobs__label">Job</span>
<span class="bp-action-bar__spacer"></span> @for (job of runJobOptions; track job.code) {
<button mat-flat-button color="primary" type="button" (click)="exportAgent(group)"> <button
<i class="fas fa-file-export"></i> Export this agent type="button"
</button> class="bpr-chip"
[class.bpr-chip--on]="runFilter.jobs.has(job.code)"
(click)="toggleRunJob(job.code)"
>
{{ job.label }}
</button>
}
</div> </div>
@if (runsLoading && recentRuns.length === 0) {
<div class="bp-skeleton">
@for (row of [1, 2, 3, 4, 5]; track row) {
<span class="bp-skeleton__bar"></span>
}
</div>
} @else if (recentRuns.length === 0) {
<p class="bp-empty bp-empty--box"><i class="fas fa-inbox"></i> No batch has been run yet.</p>
} @else if (filteredRuns.length === 0) {
<p class="bp-empty bp-empty--box">
<i class="fas fa-filter"></i> No run matches these filters.
<button type="button" class="bpr-clear" (click)="clearRunFilters()">Clear filters</button>
</p>
} @else {
<div class="bp-table-scroll bpr-scroll">
<table class="bp-table bp-table--runs">
<thead>
<tr>
<th class="bpr-caret"></th>
<th>#</th>
<th>Job</th>
<th>Job date</th>
<th class="bp-num">Entries</th>
<th class="bp-num">Amount</th>
<th>Status</th>
<th>Generated</th>
<th>Posted on</th>
</tr>
</thead>
<tbody>
@for (run of pagedRuns; track run.id) {
<tr
class="bpr-row"
[class.bpr-row--open]="expandedRunId === run.id"
(click)="toggleRunDetail(run.id)"
>
<td class="bpr-caret">
<i class="fas" [ngClass]="expandedRunId === run.id ? 'fa-chevron-down' : 'fa-chevron-right'"></i>
</td>
<td class="bp-runid">#{{ run.id }}</td>
<td>
<span class="bp-jobname">{{ labelOf(run.jobType) }}</span>
</td>
<td>{{ run.jobDate | dateFormat }}</td>
<td class="bp-num">{{ run.totalRows }}</td>
<td class="bp-num">{{ money(run.totalAmount) }}</td>
<!--
Four states, not two. A run that is still crediting is not "not posted" - that
reads as a run somebody forgot, and a clerk would run it again on top of the one
already going. One that failed says so, with the reason the server recorded.
-->
<td>
@switch (run.status) {
@case ('POSTED') {
<span class="bp-status bp-status--posted"><i class="fas fa-check-circle"></i> Posted</span>
}
@case ('POSTING') {
<span class="bp-status bp-status--running">
<i class="fas fa-circle-notch fa-spin"></i> Running
</span>
}
@case ('FAILED') {
<span class="bp-status bp-status--failed">
<i class="fas fa-exclamation-circle"></i> Failed
</span>
}
@default {
<span class="bp-status"><i class="far fa-clock"></i> Not posted</span>
}
}
</td>
<td>{{ run.generatedOnDate ? (run.generatedOnDate | dateFormat) : '—' }}</td>
<td>{{ run.postedOnDate ? (run.postedOnDate | dateFormat) : '—' }}</td>
</tr>
@if (expandedRunId === run.id) {
<tr class="bpr-detail">
<td colspan="9">
<div class="bpr-detail__grid">
<div>
<span>Branch</span><strong>{{ run.branchScope || '—' }}</strong>
</div>
<div>
<span>Scheme</span><strong>{{ run.subType || 'All schemes' }}</strong>
</div>
<div>
<span>Agents</span><strong>{{ run.agentFrom }} to {{ run.agentTo }}</strong>
</div>
<div>
<span>Parties</span><strong>{{ run.partyFrom }} to {{ run.partyTo }}</strong>
</div>
<div>
<span>Entries posted</span><strong>{{ run.rowsPosted ?? 0 }} of {{ run.totalRows }}</strong>
</div>
<div>
<span>Amount</span><strong>₹ {{ money(run.totalAmount) }}</strong>
</div>
</div>
<div class="bpr-detail__open">
<button mat-stroked-button type="button" (click)="openRun(run.id)">
<i class="fas fa-external-link-alt"></i>
{{ run.status === 'CALCULATED' ? 'Open preview' : 'Open run' }}
</button>
</div>
@if (run.status === 'FAILED') {
<p class="bpr-detail__fail">
<i class="fas fa-exclamation-circle"></i>
{{ run.failureReason || 'No reason recorded' }}
</p>
}
</td>
</tr>
}
}
</tbody>
<tfoot>
<tr>
<td colspan="5">
{{ hasRunFilters ? 'Total of the ' + filteredRuns.length + ' runs shown' : 'Total' }}
</td>
<td class="bp-num">{{ money(filteredRunsTotal) }}</td>
<td colspan="3"></td>
</tr>
</tfoot>
</table>
</div>
<mat-paginator
[length]="filteredRuns.length"
[pageSize]="runsPageSize"
[pageIndex]="runsPageIndex"
[pageSizeOptions]="[10, 25, 50]"
showFirstLastButtons
(page)="onRunsPage($event)"
></mat-paginator>
}
</div> </div>
}
<div class="bpr-actions">
<button mat-stroked-button type="button" mat-dialog-close>Close</button>
<span class="bp-action-bar__spacer"></span>
<button mat-stroked-button type="button" [disabled]="filteredRuns.length === 0" (click)="exportRuns()">
<i class="fas fa-file-export"></i> Export {{ hasRunFilters ? 'filtered' : 'all' }}
</button>
</div>
</div>
</ng-template> </ng-template>
</div> </div>
@@ -33,6 +33,10 @@ $red: #b91c1c;
gap: 14px; gap: 14px;
margin-bottom: 16px; margin-bottom: 16px;
&__spacer {
flex: 1;
}
&__ic { &__ic {
display: grid; display: grid;
place-items: center; place-items: center;
@@ -360,7 +364,7 @@ $red: #b91c1c;
// ── 3. Preview ──────────────────────────────────────────────────────────────────────────────────────── // ── 3. Preview ────────────────────────────────────────────────────────────────────────────────────────
.bp-kpis { .bp-kpis {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 10px; gap: 10px;
margin-bottom: 12px; margin-bottom: 12px;
} }
@@ -818,3 +822,336 @@ $red: #b91c1c;
} }
} }
} }
// ── Previous runs button (header) and popup ──────────────────────────────────────────────────────────
.bp-runs-btn {
position: relative;
color: $primary;
i {
margin-right: 6px;
}
&__count {
margin-left: 8px;
padding: 1px 9px;
border-radius: 999px;
background: $primary-soft;
font-size: 12px;
font-weight: 700;
}
&__warn {
position: absolute;
top: 6px;
right: 6px;
width: 9px;
height: 9px;
border-radius: 50%;
background: $red;
}
}
.bpr {
display: flex;
flex-direction: column;
max-height: 90vh;
color: $text;
&-head {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid $border;
&__ic {
display: grid;
place-items: center;
flex: none;
width: 38px;
height: 38px;
border-radius: 10px;
background: $primary-soft;
color: $primary;
}
&__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;
}
}
}
&-icon-btn {
border: 0;
background: transparent;
color: $text-mid;
font-size: 15px;
cursor: pointer;
}
&-body {
flex: 1;
min-height: 0;
padding: 14px 20px 6px;
overflow-y: auto;
}
&-statuses,
&-jobs {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
&-jobs__label,
&-total {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: $text-soft;
}
&-total {
margin-left: auto;
}
&-chip {
padding: 4px 12px;
border: 1px solid $border;
border-radius: 999px;
background: #fff;
color: $text-mid;
font: inherit;
font-size: 12.5px;
font-weight: 500;
cursor: pointer;
b {
margin-left: 4px;
}
&:hover {
border-color: $primary;
color: $primary;
}
&--on {
border-color: $primary;
background: $primary;
color: #fff;
&:hover {
color: #fff;
}
}
&--posted:not(&--on) {
background: #f0fdf4;
color: $green;
}
&--failed:not(&--on) {
background: #fef2f2;
color: $red;
}
&--posting:not(&--on) {
background: #eff6ff;
color: #1d4ed8;
}
}
&-filters {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 12px;
margin-bottom: 10px;
}
&-field {
display: flex;
flex-direction: column;
gap: 3px;
span {
font-size: 11px;
font-weight: 600;
color: $text-soft;
}
input {
padding: 7px 10px;
border: 1px solid $border;
border-radius: 8px;
font: inherit;
font-size: 13px;
}
&--search {
flex: 1;
min-width: 200px;
}
}
&-clear {
border: 0;
background: transparent;
color: $primary;
font: inherit;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
}
&-scroll {
max-height: 42vh;
overflow-y: auto;
}
&-caret {
width: 28px;
color: $text-soft;
font-size: 11px;
}
&-row {
cursor: pointer;
&--open td {
background: #f0fdf9;
}
}
&-detail td {
padding: 0 12px 12px 40px;
background: #f0fdf9;
}
&-detail__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 8px;
div {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 12px;
border: 1px solid $border;
border-radius: 8px;
background: #fff;
}
span {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: $text-soft;
}
strong {
font-size: 13px;
font-variant-numeric: tabular-nums;
}
}
&-detail__fail {
margin: 8px 0 0;
padding: 8px 12px;
border-radius: 8px;
background: #fef2f2;
color: $red;
font-size: 12.5px;
i {
margin-right: 6px;
}
}
&-actions {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 20px;
border-top: 1px solid $border;
i {
margin-right: 6px;
}
}
}
// ── Runs generated and not yet posted: one button each, to go back to its page ──────────────────────
.bp-previews {
&__list {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
}
.bp-preview-btn {
display: flex;
align-items: center;
gap: 14px;
padding: 10px 16px;
border: 1.5px solid $border;
border-radius: 10px;
background: #fff;
color: $text;
font: inherit;
text-align: left;
cursor: pointer;
&:hover {
border-color: $primary;
box-shadow: 0 0 0 3px rgb(15 118 110 / 10%);
}
&__job {
font-size: 14px;
font-weight: 700;
}
&__meta {
font-size: 12px;
color: $text-mid;
}
&__go {
margin-left: auto;
color: $primary;
font-size: 12.5px;
font-weight: 700;
i {
margin-left: 4px;
}
}
}
.bpr-detail__open {
margin: 8px 0 0;
i {
margin-right: 6px;
}
}
@@ -7,14 +7,14 @@
*/ */
/** Angular Imports */ /** Angular Imports */
import { Component, OnInit, TemplateRef, ViewChild, inject } from '@angular/core'; import { Component, OnDestroy, OnInit, TemplateRef, ViewChild, inject } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { MatDialog, MatDialogClose } from '@angular/material/dialog'; import { MatDialog, MatDialogClose } from '@angular/material/dialog';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
import { MatTooltip } from '@angular/material/tooltip'; import { MatTooltip } from '@angular/material/tooltip';
import { DateAdapter, MAT_DATE_FORMATS } from '@angular/material/core'; import { DateAdapter, MAT_DATE_FORMATS } from '@angular/material/core';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
import { DdsDateAdapter, DDS_DATE_FORMATS } from './dds-date-adapter'; import { DdsDateAdapter, DDS_DATE_FORMATS } from './dds-date-adapter';
/** Custom Services */ /** Custom Services */
@@ -22,17 +22,7 @@ import { OrganizationService } from 'app/organization/organization.service';
import { AlertService } from 'app/core/alert/alert.service'; import { AlertService } from 'app/core/alert/alert.service';
import { SettingsService } from 'app/settings/settings.service'; import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates'; import { Dates } from 'app/core/utils/dates';
import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-approvals/dds-approval-notice'; import { csvAmount, dateText, formatMoney, jobLabel } from './dds-batch.shared';
/** 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. * DDS Interest & Commission batch processing — the legacy Interest Part menu.
@@ -53,7 +43,8 @@ export interface AgentGroup {
...STANDALONE_SHARED_IMPORTS, ...STANDALONE_SHARED_IMPORTS,
// The reason a run failed is too long to sit in the column, and too useful to leave out. // The reason a run failed is too long to sit in the column, and too useful to leave out.
MatTooltip, MatTooltip,
MatDialogClose MatDialogClose,
MatPaginator
], ],
// Day-first dates, as the legacy screen showed them. Declared here rather than application-wide: // 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. // a @Component's providers reach only this component, so no other screen's datepicker changes.
@@ -62,7 +53,7 @@ export interface AgentGroup {
{ provide: MAT_DATE_FORMATS, useValue: DDS_DATE_FORMATS } { provide: MAT_DATE_FORMATS, useValue: DDS_DATE_FORMATS }
] ]
}) })
export class DdsBatchProcessingComponent implements OnInit { export class DdsBatchProcessingComponent implements OnInit, OnDestroy {
private formBuilder = inject(UntypedFormBuilder); private formBuilder = inject(UntypedFormBuilder);
private router = inject(Router); private router = inject(Router);
private organizationService = inject(OrganizationService); private organizationService = inject(OrganizationService);
@@ -137,243 +128,192 @@ export class DdsBatchProcessingComponent implements OnInit {
return code.endsWith('_POSTING') ? 'Post' : 'Run'; return code.endsWith('_POSTING') ? 'Post' : 'Run';
} }
@ViewChild('agentDialog') agentDialog: TemplateRef<unknown>; // ── Previous runs, in a popup ─────────────────────────────────────────────────────────────────────
@ViewChild('runsDialog') runsDialog: TemplateRef<unknown>;
/** The agent whose accounts the popup is showing, and what is typed in its search box. */ /** The statuses a run can be in, as the screen names them, in the order a run moves through them. */
selectedGroup: AgentGroup | null = null; readonly runStatuses: Array<{ code: string; label: string }> = [
agentSearch = ''; { code: 'CALCULATED', label: 'Not posted' },
{ code: 'POSTING', label: 'Running' },
{ code: 'POSTED', label: 'Posted' },
{ code: 'FAILED', label: 'Failed' }
];
private groupsFor: any[] | null = null; /** Every filter at once: what is typed, the jobs and statuses picked (any number), and a job date range. */
private groups: AgentGroup[] = []; runFilter = {
search: '',
jobs: new Set<string>(),
statuses: new Set<string>(),
from: '',
to: ''
};
/** filteredRuns: any[] = [];
* The preview as agents with their accounts under them. A run can list a thousand accounts; read runsPageIndex = 0;
* agent by agent it is a handful of lines, and each agent's accounts open in a popup on View. runsPageSize = 10;
* Built once per Generate, not on every change detection pass. /** The run whose full detail is open under its row. */
*/ expandedRunId: number | null = null;
get agentGroups(): AgentGroup[] {
if (this.groupsFor !== this.previewRows) { /** The jobs the list actually holds - so a retired job (OD interest) still filters, and an unused one does not clutter. */
const byAgent = new Map<string, AgentGroup>(); get runJobOptions(): Array<{ code: string; label: string }> {
for (const row of this.previewRows) { const codes = Array.from(new Set(this.recentRuns.map((run) => run.jobType)));
const key = String(row.agentId ?? row.agentCode ?? ''); return codes.map((code) => ({ code, label: this.labelOf(code) }));
let group = byAgent.get(key); }
if (!group) {
const name = String(row.agentName ?? ''); get hasRunFilters(): boolean {
group = { const f = this.runFilter;
key, return !!(f.search.trim() || f.jobs.size || f.statuses.size || f.from || f.to);
agentCode: row.agentCode ?? '', }
agentName: name,
initials: name get pagedRuns(): any[] {
.split(/\s+/) const start = this.runsPageIndex * this.runsPageSize;
.filter(Boolean) return this.filteredRuns.slice(start, start + this.runsPageSize);
.slice(0, 2) }
.map((word) => word[0].toUpperCase())
.join(''), /** What the filtered list adds up to, so the footer says it without anyone counting rows. */
rows: [], get filteredRunsTotal(): number {
total: 0 return this.filteredRuns.reduce((total, run) => total + Number(run.totalAmount || 0), 0);
}; }
byAgent.set(key, group);
} statusLabel(code: string): string {
group.rows.push(row); return this.runStatuses.find((status) => status.code === code)?.label ?? code;
group.total += Number(row.amount || 0); }
/** How many runs are in each status, for the numbers on the status chips (against the whole list, not the filtered one). */
runCount(statusCode: string): number {
return this.recentRuns.filter((run) => run.status === statusCode).length;
}
openRuns(): void {
// Fresh each time it is opened: a run started or posted since is on the list.
this.loadRecentRuns();
this.dialog.open(this.runsDialog, { width: '1040px', maxWidth: '96vw', autoFocus: false });
}
/** Filters the list and returns to its first page - a narrower list may have fewer pages than the one being read. */
applyRunFilters(): void {
const f = this.runFilter;
const term = f.search.trim().toLowerCase();
this.filteredRuns = this.recentRuns.filter((run) => {
if (term && !`#${run.id} ${run.id} ${this.labelOf(run.jobType)}`.toLowerCase().includes(term)) {
return false;
} }
this.groupsFor = this.previewRows; if (f.jobs.size && !f.jobs.has(run.jobType)) {
this.groups = Array.from(byAgent.values()); return false;
}
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) { if (f.statuses.size && !f.statuses.has(run.status)) {
lines.push( return false;
[
'Total',
'',
'',
'',
'',
'',
'',
'',
'',
this.csvAmount(groups.reduce((t, g) => t + g.total, 0))
]
.map(quote)
.join(',')
);
} }
const day = dateText(run.jobDate);
if (f.from && day < f.from) {
return false;
}
return !(f.to && day > f.to);
});
this.runsPageIndex = 0;
}
setRunSearch(value: string): void {
this.runFilter.search = value;
this.applyRunFilters();
}
setRunDate(which: 'from' | 'to', value: string): void {
this.runFilter[which] = value;
this.applyRunFilters();
}
toggleRunJob(code: string): void {
this.toggleIn(this.runFilter.jobs, code);
}
toggleRunStatus(code: string): void {
this.toggleIn(this.runFilter.statuses, code);
}
private toggleIn(set: Set<string>, value: string): void {
if (set.has(value)) {
set.delete(value);
} else { } else {
lines.push( set.add(value);
[
'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(',')
);
}
} }
this.applyRunFilters();
}
// A byte-order mark so Excel opens the rupee sign and any non-English names correctly. clearRunFilters(): void {
const blob = new Blob(['\ufeff' + lines.join('\r\n')], { type: 'text/csv;charset=utf-8' }); this.runFilter = { search: '', jobs: new Set<string>(), statuses: new Set<string>(), from: '', to: '' };
const name = withRunTotal this.applyRunFilters();
? `${this.labelOf(run.jobType)}-run-${run.id}` }
: `${this.labelOf(run.jobType)}-run-${run.id}-${groups[0]?.agentCode ?? 'agent'}`;
onRunsPage(event: PageEvent): void {
this.runsPageIndex = event.pageIndex;
this.runsPageSize = event.pageSize;
}
toggleRunDetail(id: number): void {
this.expandedRunId = this.expandedRunId === id ? null : id;
}
/** The runs the filters leave, with everything the list knows about each, as a file. */
exportRuns(): void {
const quote = (value: any) => `"${String(value ?? '').replace(/"/g, '""')}"`;
const header = [
'Run #',
'Job',
'Job Date',
'Status',
'Entries',
'Entries Posted',
'Amount',
'Generated On',
'Posted On',
'Branch',
'Scheme',
'Agent From',
'Agent To',
'Party From',
'Party To',
'Failure Reason'
];
const lines = this.filteredRuns.map((run) =>
[
run.id,
this.labelOf(run.jobType),
dateText(run.jobDate),
this.statusLabel(run.status),
run.totalRows,
run.rowsPosted,
csvAmount(run.totalAmount),
dateText(run.generatedOnDate),
dateText(run.postedOnDate),
run.branchScope,
run.subType || 'All schemes',
run.agentFrom,
run.agentTo,
run.partyFrom,
run.partyTo,
run.failureReason
]
.map(quote)
.join(',')
);
const blob = new Blob(
['\ufeff' + [
header.map(quote).join(','),
...lines
].join('\r\n')],
{
type: 'text/csv;charset=utf-8'
}
);
const link = document.createElement('a'); const link = document.createElement('a');
link.href = URL.createObjectURL(blob); link.href = URL.createObjectURL(blob);
link.download = `${name.replace(/\s+/g, '-').toLowerCase()}.csv`; link.download = 'dds-batch-runs.csv';
link.click(); link.click();
URL.revokeObjectURL(link.href); URL.revokeObjectURL(link.href);
} }
/** True while Generate is working out the preview. */ /** True while Generate is working out the run. */
generating = false; generating = false;
/** True while the list of previous runs is being fetched. */ /** True while the list of previous runs is being fetched. */
runsLoading = true; runsLoading = true;
@@ -393,18 +333,9 @@ export class DdsBatchProcessingComponent implements OnInit {
}; };
} }
private static readonly MONEY = new Intl.NumberFormat('en-IN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
offices: any[] = []; offices: any[] = [];
selectedOfficeNames = new Set<string>(); selectedOfficeNames = new Set<string>();
/** The run currently previewed on screen, or null once it has been posted or discarded. */
generatedRun: any = null;
previewRows: any[] = [];
recentRuns: any[] = []; recentRuns: any[] = [];
batchForm: UntypedFormGroup = this.formBuilder.group({ batchForm: UntypedFormGroup = this.formBuilder.group({
@@ -459,19 +390,6 @@ export class DdsBatchProcessingComponent implements OnInit {
return this.batchForm.get('branchScope')?.value === 'Selected'; return this.batchForm.get('branchScope')?.value === 'Selected';
} }
/**
* Only a commission calculation has a breakdown worth showing: it is the one job where the figure
* applied is not the figure worked out, because TDS and the standing deductions come off it.
*/
get showsCommissionBreakdown(): boolean {
return this.jobType === 'COMMISSION_CALCULATION';
}
/** What Generate found, in words — the same figure the Post button is about to apply. */
get previewTotal(): number {
return this.previewRows.reduce((total, row) => total + Number(row.amount || 0), 0);
}
ngOnInit(): void { ngOnInit(): void {
this.organizationService.getOffices().subscribe((offices: any[]) => { this.organizationService.getOffices().subscribe((offices: any[]) => {
this.offices = offices || []; this.offices = offices || [];
@@ -480,22 +398,11 @@ export class DdsBatchProcessingComponent implements OnInit {
} }
money(value: any): string { money(value: any): string {
if (value === null || value === undefined || value === '') { return formatMoney(value);
return '—';
}
return DdsBatchProcessingComponent.MONEY.format(Number(value));
} }
/** Every job a past run can have been, including those no longer offered to run. */
private static readonly RUN_LABELS: Record<string, string> = {
OD_INTEREST_CALCULATION: 'OD Interest Calculation',
OD_INTEREST_POSTING: 'OD Interest Posting'
};
labelOf(code: string): string { labelOf(code: string): string {
return ( return jobLabel(code);
this.jobTypes.find((type) => type.code === code)?.label ?? DdsBatchProcessingComponent.RUN_LABELS[code] ?? code
);
} }
toggleOffice(officeName: string): void { toggleOffice(officeName: string): void {
@@ -511,12 +418,28 @@ export class DdsBatchProcessingComponent implements OnInit {
* invisible the moment the page is left, and there is no way to tell whether a month's interest * invisible the moment the page is left, and there is no way to tell whether a month's interest
* was ever actually posted. * was ever actually posted.
*/ */
private loadRecentRuns(): void { /** Runs generated and not yet posted - each gets a button to go back to its page. */
get pendingPreviews(): any[] {
return this.recentRuns.filter((run) => run.status === 'CALCULATED');
}
/** A run on a page of its own: its figures agent by agent, and Post or Discard while it is still a preview. */
openRun(runId: number): void {
this.dialog.closeAll();
this.router.navigate([
'/organization/dds-batch-processing/run',
runId
]);
}
loadRecentRuns(): void {
this.runsLoading = true; this.runsLoading = true;
this.organizationService.getDdsBatchRuns().subscribe({ this.organizationService.getDdsBatchRuns().subscribe({
next: (runs: any[]) => { next: (runs: any[]) => {
this.recentRuns = runs || []; this.recentRuns = runs || [];
this.runsLoading = false; this.runsLoading = false;
this.applyRunFilters();
this.watchRunning();
}, },
error: () => (this.runsLoading = false) error: () => (this.runsLoading = false)
}); });
@@ -556,181 +479,68 @@ export class DdsBatchProcessingComponent implements OnInit {
error: () => (this.generating = false), error: () => (this.generating = false),
next: (batchRun: any) => { next: (batchRun: any) => {
this.generating = false; 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.`
});
}
this.loadRecentRuns(); this.loadRecentRuns();
// The run is checked on a page of its own - agent by agent, with Post and Discard beside the
// figures - so this page does not grow a second screen's worth below the form.
this.router.navigate([
'/organization/dds-batch-processing/run',
batchRun.id
]);
} }
}); });
} }
}); });
} }
post(): void { // ── Runs that are going on in the background ──────────────────────────────────────────────────────
if (!this.generatedRun || this.previewRows.length === 0) {
return;
}
const count = this.previewRows.length;
const total = this.money(this.previewTotal);
const isPosting = this.isPostingJob;
// Post is the step that moves money, and it cannot be undone from this screen — so it names
// exactly what is about to change rather than asking a generic "are you sure".
this.dialog
.open(ConfirmationDialogComponent, {
data: {
type: 'warn',
heading: `Post ${this.jobTypeLabel}?`,
dialogContext:
`${count} ${count === 1 ? 'entry' : 'entries'} totalling ${total} will be applied. ` +
(isPosting
? 'This credits the customers or pays the agents, and appears on the ledger. '
: 'This records what has been earned against each account. ') +
'It cannot be undone from this screen.'
}
})
.afterClosed()
.subscribe((response: any) => {
if (response?.confirm) {
this.applyRun(count, total);
}
});
}
/**
* Applies the run, and says what actually happened rather than assuming it finished.
*
* A crediting run costs what a savings deposit costs on every account it touches, so the server
* takes it off the request and does it in the background - the reply comes back in a moment while
* the work is still going on. Reporting that as "posted" would tell the clerk a run had finished
* seconds after it began, and they would go looking for credits that were still being written.
*
* So the run is followed until it settles. A calculation run is done by the time the reply lands
* and says so at once; a crediting run says it is under way, and the list beneath refreshes itself
* until the server marks it posted - or failed, with the reason the server recorded.
*/
private applyRun(count: number, total: string): void {
const label = this.jobTypeLabel;
const runId = this.generatedRun.id;
const crediting = this.isPostingJob;
this.organizationService.postDdsBatchRun(runId).subscribe((response: any) => {
this.generatedRun = null;
this.previewRows = [];
this.loadRecentRuns();
if (heldForApproval(response)) {
announceHeldForApproval(
response,
`${label} (${count} ${count === 1 ? 'entry' : 'entries'}, ${total})`,
this.organizationService,
this.alertService
);
return;
}
if (!crediting) {
this.alertService.alert({
type: 'Success',
message: `${label} posted — ${count} ${count === 1 ? 'entry' : 'entries'}, ${total}.`
});
return;
}
this.postingRunId = runId;
this.alertService.alert({
type: 'Posting',
message:
`${label} is running — ${count} ${count === 1 ? 'entry' : 'entries'}, ${total}. ` +
'It continues in the background; this list will update when it is done.'
});
this.followRun(runId, label);
});
}
/** The crediting run being followed, if one is. The screen reads this to show it is under way. */
postingRunId: number | null = null;
/**
* Watches a background run until the server marks it settled.
*
* Polled rather than pushed: a batch that takes minutes needs no faster news than this, and a poll
* costs nothing when the answer is one row. It gives up after a while rather than asking forever -
* the run is still on the list either way, and a clerk can read its status there.
*/
private followRun(runId: number, label: string): void {
let checksLeft = DdsBatchProcessingComponent.MAX_STATUS_CHECKS;
const check = () => {
this.organizationService.getDdsBatchRuns().subscribe({
next: (runs: any[]) => {
const run = (runs || []).find((each: any) => each.id === runId);
const status = run?.status;
if (status === 'POSTED' || status === 'FAILED') {
this.postingRunId = null;
this.loadRecentRuns();
this.alertService.alert(
status === 'POSTED'
? { type: 'Success', message: `${label} posted — ${run.rowsPosted ?? run.totalRows} entries applied.` }
: { type: 'Error', message: `${label} failed: ${run.failureReason || 'see the run for details'}` }
);
return;
}
if (--checksLeft > 0) {
setTimeout(check, DdsBatchProcessingComponent.STATUS_CHECK_INTERVAL_MS);
} else {
// Still going after the wait. Left on the list rather than reported either way - saying
// "failed" for a run that is merely long would be worse than saying nothing.
this.postingRunId = null;
this.loadRecentRuns();
}
},
// A failed status check says nothing about the run itself, which carries on regardless.
error: () => {
if (--checksLeft > 0) {
setTimeout(check, DdsBatchProcessingComponent.STATUS_CHECK_INTERVAL_MS);
} else {
this.postingRunId = null;
}
}
});
};
setTimeout(check, DdsBatchProcessingComponent.STATUS_CHECK_INTERVAL_MS);
}
private static readonly STATUS_CHECK_INTERVAL_MS = 4000; private static readonly STATUS_CHECK_INTERVAL_MS = 4000;
/** Twenty minutes of checking, which is longer than any run measured here has taken. */ /** Twenty minutes of checking, which is longer than any run measured here has taken. */
private static readonly MAX_STATUS_CHECKS = 300; private static readonly MAX_STATUS_CHECKS = 300;
private checksLeft = DdsBatchProcessingComponent.MAX_STATUS_CHECKS;
private watching: ReturnType<typeof setTimeout> | null = null;
private runningIds = new Set<number>();
/** 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". */ * A crediting run is done by the server in the background, so the list is read again until none is
discard(): void { * still running, and the clerk is told how each one ended - posted, or failed with the reason the
const run = this.generatedRun; * server recorded. Polled rather than pushed: a batch that takes minutes needs no faster news, and
this.generatedRun = null; * a poll costs one query. It gives up after a while - the run is on the list either way.
this.previewRows = []; */
if (run?.id) { private watchRunning(): void {
this.organizationService.discardDdsBatchRun(run.id).subscribe({ const nowRunning = new Set<number>(this.recentRuns.filter((run) => run.status === 'POSTING').map((run) => run.id));
next: () => this.loadRecentRuns(),
error: () => this.loadRecentRuns() for (const id of this.runningIds) {
}); if (nowRunning.has(id)) {
continue;
}
const run = this.recentRuns.find((each) => each.id === id);
const label = this.labelOf(run?.jobType);
if (run?.status === 'POSTED') {
this.alertService.alert({
type: 'Success',
message: `${label} posted — ${run.rowsPosted ?? run.totalRows} entries applied.`
});
} else if (run?.status === 'FAILED') {
this.alertService.alert({
type: 'Error',
message: `${label} failed: ${run.failureReason || 'see the run for details'}`
});
}
}
this.runningIds = nowRunning;
if (this.watching) {
clearTimeout(this.watching);
this.watching = null;
}
if (nowRunning.size > 0 && this.checksLeft-- > 0) {
this.watching = setTimeout(() => this.loadRecentRuns(), DdsBatchProcessingComponent.STATUS_CHECK_INTERVAL_MS);
}
}
ngOnDestroy(): void {
if (this.watching) {
clearTimeout(this.watching);
} }
} }
@@ -0,0 +1,101 @@
/**
* 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/.
*/
/**
* What the DDS batch screen and its run preview page share: how a job is named, how money and dates
* read, and how a run's rows are grouped under their agent.
*/
/** 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;
}
/** Every job a run can have been, including those no longer offered to run (OD interest). */
export const JOB_LABELS: Record<string, string> = {
INTEREST_CALCULATION: 'Interest Calculation',
INTEREST_POSTING: 'Interest Posting',
OD_INTEREST_CALCULATION: 'OD Interest Calculation',
OD_INTEREST_POSTING: 'OD Interest Posting',
COMMISSION_CALCULATION: 'Commission Calculation',
COMMISSION_POSTING: 'Commission Posting',
MATURITY_AUTO_CLOSE: 'Maturity Auto Close'
};
export function jobLabel(code: string): string {
return JOB_LABELS[code] ?? code;
}
export function isCommissionJobType(code: string): boolean {
return (code || '').startsWith('COMMISSION');
}
export function isPostingJobType(code: string): boolean {
return (code || '').endsWith('_POSTING');
}
const MONEY = new Intl.NumberFormat('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
export function formatMoney(value: any): string {
if (value === null || value === undefined || value === '') {
return '—';
}
return MONEY.format(Number(value));
}
/** A date as the server sent it - text, or a [year, month, day] array - for a file or a comparison. */
export function 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);
}
export function csvAmount(value: any): string {
return value === null || value === undefined || value === '' ? '' : Number(value).toFixed(2);
}
/**
* The rows of a run as agents with their accounts under them. A run can list a thousand accounts;
* read agent by agent it is a handful of lines.
*/
export function groupByAgent(rows: any[]): AgentGroup[] {
const byAgent = new Map<string, AgentGroup>();
for (const row of rows) {
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);
}
return Array.from(byAgent.values());
}
@@ -0,0 +1,338 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="bp-container bprv">
<div class="bp-header">
<button
mat-icon-button
type="button"
class="bprv-back"
(click)="back()"
matTooltip="Back to DDS Interest &amp; Commission"
aria-label="Back"
>
<i class="fas fa-arrow-left"></i>
</button>
<div class="bp-header__ic"><i class="fas fa-table"></i></div>
<div>
<h2 class="bp-header__title">
{{ jobTypeLabel || 'Batch run' }} — {{ canAct ? 'preview' : 'run' }}
@if (generatedRun) {
<span
class="bp-status"
[class.bp-status--posted]="generatedRun.status === 'POSTED'"
[class.bp-status--failed]="generatedRun.status === 'FAILED'"
[class.bp-status--running]="generatedRun.status === 'POSTING'"
>{{ statusText }}</span
>
}
</h2>
<p class="bp-header__sub">
@if (generatedRun) {
Run #{{ generatedRun.id }} ·
{{
canAct
? 'nothing applied yet — check the figures, then Post.'
: 'read only — this run is ' + statusText.toLowerCase() + '.'
}}
}
</p>
</div>
<span class="bp-header__spacer"></span>
@if (canAct) {
<button mat-stroked-button type="button" [disabled]="working" (click)="discard()">
<i class="fas fa-times"></i> Discard
</button>
<button
mat-flat-button
color="primary"
type="button"
[disabled]="working || previewRows.length === 0"
(click)="post()"
>
<i class="fas fa-check"></i> Post
</button>
}
</div>
@if (loading) {
<div class="bp-card">
<div class="bp-skeleton">
@for (row of [1, 2, 3, 4, 5]; track row) {
<span class="bp-skeleton__bar"></span>
}
</div>
</div>
} @else if (notFound) {
<div class="bp-card">
<p class="bp-empty bp-empty--box">
<i class="fas fa-inbox"></i> This run no longer exists - a new Generate or Discard may have replaced it.
<button mat-stroked-button type="button" (click)="back()">Back</button>
</p>
</div>
} @else {
@if (generatedRun?.status === 'FAILED') {
<p class="bpr-detail__fail bprv-banner">
<i class="fas fa-exclamation-circle"></i> {{ generatedRun.failureReason || 'No reason recorded' }}
</p>
}
<div class="bp-card bprv-card">
@if (previewRows.length === 0) {
<p class="bp-empty bp-empty--box">
<i class="fas fa-inbox"></i>
Nothing to apply. Either this period has already been processed, or there was no activity in it.
</p>
} @else {
<div class="bp-kpis">
<div class="bp-kpi">
<span>Agents</span><strong>{{ agentGroups.length }}</strong>
</div>
<div class="bp-kpi">
<span>{{ isCommissionJob ? 'Entries' : 'Accounts' }}</span
><strong>{{ previewRows.length }}</strong>
</div>
<div class="bp-kpi bp-kpi--accent">
<span>{{ showsCommissionBreakdown ? 'Total payable' : 'Total' }}</span>
<strong>₹ {{ money(previewTotal) }}</strong>
</div>
<div class="bp-kpi">
<span>Date</span><strong>{{ generatedRun.jobDate | dateFormat }}</strong>
</div>
</div>
<div class="bp-tools">
<span class="bp-tools__hint">
{{
isCommissionJob
? 'One line per agent - View shows how their commission was worked out.'
: 'Grouped by collecting agent - View shows the DDS accounts under each.'
}}
</span>
<span class="bp-tools__spacer"></span>
<button mat-stroked-button type="button" (click)="exportPreview()">
<i class="fas fa-file-export"></i> Export all details
</button>
</div>
<div class="bp-table-scroll">
@if (isCommissionJob) {
<table class="bp-table bp-table--breakdown">
<thead>
<tr>
<th>Agent</th>
<th>Period</th>
@if (showsCommissionBreakdown) {
<th class="bp-num">Collection</th>
<th class="bp-num">Rate</th>
<th class="bp-num">Commission</th>
<th class="bp-num">TDS</th>
<th class="bp-num">Deductions</th>
}
<th class="bp-num">{{ showsCommissionBreakdown ? 'Payable' : 'Amount' }}</th>
<th class="bp-view-col"></th>
</tr>
</thead>
<tbody>
@for (group of agentGroups; track group.key) {
@for (row of group.rows; track row.id) {
<tr>
<td>
<span class="bp-code">{{ row.agentCode }}</span>
<span class="bp-sub">{{ row.agentName }}</span>
</td>
<td class="bp-period">{{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}</td>
@if (showsCommissionBreakdown) {
<td class="bp-num">{{ money(row.baseAmount) }}</td>
<td class="bp-num">
<span class="bp-rate">{{ row.ratePercent }}%</span>
</td>
<td class="bp-num">{{ money(row.grossAmount) }}</td>
<td class="bp-num bp-minus">{{ money(row.tdsAmount) }}</td>
<td class="bp-num bp-minus">{{ money(row.deductionAmount) }}</td>
}
<td class="bp-num bp-total">{{ money(row.amount) }}</td>
<td class="bp-view-col">
<button mat-stroked-button type="button" class="bp-view-btn" (click)="viewAgent(group)">
<i class="fas fa-eye"></i> View
</button>
</td>
</tr>
}
}
</tbody>
<tfoot>
<tr>
<td [attr.colspan]="showsCommissionBreakdown ? 7 : 2">Total</td>
<td class="bp-num bp-total">{{ money(previewTotal) }}</td>
<td></td>
</tr>
</tfoot>
</table>
} @else {
<table class="bp-table">
<thead>
<tr>
<th>Agent</th>
<th class="bp-num">Accounts</th>
<th class="bp-num">Amount</th>
<th class="bp-view-col"></th>
</tr>
</thead>
<tbody>
@for (group of agentGroups; track group.key) {
<tr class="bp-agent-row">
<td>
<span class="bp-avatar">{{ group.initials }}</span>
<span class="bp-code bp-code--inline">{{ group.agentCode }}</span>
<span class="bp-sub bp-sub--inline">{{ group.agentName }}</span>
</td>
<td class="bp-num">{{ group.rows.length }}</td>
<td class="bp-num bp-total">{{ money(group.total) }}</td>
<td class="bp-view-col">
<button mat-stroked-button type="button" class="bp-view-btn" (click)="viewAgent(group)">
<i class="fas fa-eye"></i> View
</button>
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td>Total</td>
<td class="bp-num">{{ previewRows.length }}</td>
<td class="bp-num bp-total">{{ money(previewTotal) }}</td>
<td></td>
</tr>
</tfoot>
</table>
}
</div>
}
</div>
}
<!-- One agent's accounts (or commission breakdown) in a popup, opened from View. -->
<ng-template #agentDialog>
@if (selectedGroup; as group) {
<div class="bpd">
<div class="bpd-head">
<span class="bpd-av">{{ group.initials }}</span>
<div class="bpd-head__text">
<h3>
<span class="bp-code bp-code--inline">{{ group.agentCode }}</span> {{ group.agentName }}
</h3>
<p>
{{ labelOf(generatedRun?.jobType) }} · Run #{{ generatedRun?.id }} ·
{{ generatedRun?.jobDate | dateFormat }}
</p>
</div>
<button type="button" class="bpd-close" mat-dialog-close aria-label="Close">
<i class="fas fa-times"></i>
</button>
</div>
<div class="bpd-body">
@if (isCommissionJob) {
@for (row of group.rows; track row.id) {
<div class="bpd-grid">
@if (showsCommissionBreakdown) {
<div class="bpd-cell">
<span>Collection</span><strong>₹ {{ money(row.baseAmount) }}</strong>
</div>
<div class="bpd-cell">
<span>Slab rate</span><strong>{{ row.ratePercent }}%</strong>
</div>
<div class="bpd-cell">
<span>Commission</span><strong>₹ {{ money(row.grossAmount) }}</strong>
</div>
<div class="bpd-cell">
<span>– TDS</span><strong class="bp-minus">₹ {{ money(row.tdsAmount) }}</strong>
</div>
<div class="bpd-cell">
<span>– Deductions</span><strong class="bp-minus">₹ {{ money(row.deductionAmount) }}</strong>
</div>
}
<div class="bpd-cell bpd-cell--net">
<span>{{ showsCommissionBreakdown ? 'Payable to agent' : 'To be paid out' }}</span>
<strong>₹ {{ money(row.amount) }}</strong>
</div>
</div>
<p class="bpd-note">
Period {{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}. The agent's collection from all
their customers is added up and one slab rate applies to the whole figure.
</p>
}
} @else {
<div class="bpd-kpis">
<div class="bpd-cell">
<span>Accounts</span><strong>{{ group.rows.length }}</strong>
</div>
<div class="bpd-cell bpd-cell--net">
<span>Total</span><strong>₹ {{ money(group.total) }}</strong>
</div>
</div>
<input
type="search"
class="bpd-search"
placeholder="Search party code or name"
[value]="agentSearch"
(input)="agentSearch = $any($event.target).value"
/>
<div class="bp-table-scroll bpd-scroll">
<table class="bp-table">
<thead>
<tr>
<th>#</th>
<th>Party</th>
<th>Period</th>
<th class="bp-num">Rate</th>
<th class="bp-num">Amount</th>
</tr>
</thead>
<tbody>
@for (row of popupRows; track row.id; let i = $index) {
<tr>
<td class="bp-runid">{{ i + 1 }}</td>
<td>
<span class="bp-code bp-code--inline">{{ row.partyCode }}</span>
<span class="bp-sub bp-sub--inline">{{ row.partyName }}</span>
</td>
<td class="bp-period">{{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}</td>
<td class="bp-num">{{ row.ratePercent ? row.ratePercent + '%' : '—' }}</td>
<td class="bp-num bp-total">{{ money(row.amount) }}</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="bp-empty">No account matches the search.</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="4">{{ agentSearch ? 'Total of the accounts shown' : 'Total' }}</td>
<td class="bp-num bp-total">{{ money(popupTotal) }}</td>
</tr>
</tfoot>
</table>
</div>
}
</div>
<div class="bpd-actions">
<button mat-stroked-button type="button" mat-dialog-close>Close</button>
<span class="bp-action-bar__spacer"></span>
<button mat-flat-button color="primary" type="button" (click)="exportAgent(group)">
<i class="fas fa-file-export"></i> Export this agent
</button>
</div>
</div>
}
</ng-template>
</div>
@@ -0,0 +1,36 @@
/**
* 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/.
*/
// The batch screen's styles are loaded with this one; only what is particular to the run page is here.
.bprv-back {
flex: none;
color: #0f766e;
}
.bprv-banner {
margin: 0 0 12px;
}
// The page itself should not scroll: the table takes what is left of the window and scrolls inside.
.bprv-card {
display: flex;
flex-direction: column;
margin-bottom: 0;
.bp-table-scroll {
max-height: calc(100vh - 430px);
min-height: 200px;
overflow: auto;
th {
position: sticky;
top: 0;
z-index: 1;
}
}
}
@@ -0,0 +1,401 @@
/**
* 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/.
*/
/** Angular Imports */
import { Component, OnInit, TemplateRef, ViewChild, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { MatDialog, MatDialogClose } from '@angular/material/dialog';
import { MatTooltip } from '@angular/material/tooltip';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
/** Custom Services */
import { OrganizationService } from 'app/organization/organization.service';
import { AlertService } from 'app/core/alert/alert.service';
import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-approvals/dds-approval-notice';
import {
AgentGroup,
csvAmount,
dateText,
formatMoney,
groupByAgent,
isCommissionJobType,
isPostingJobType,
jobLabel
} from 'app/organization/dds-batch-processing/dds-batch.shared';
/**
* One generated DDS batch run, on a page of its own: the figures Generate worked out, agent by agent,
* with Post and Discard beside them.
*
* A run is a preview until it is posted - generating applied nothing - so this page is where it is
* checked, applied or thrown away. A run already posted, running or failed opens here too, read-only,
* so any run on the list can be looked into line by line.
*/
@Component({
selector: 'mifosx-dds-batch-run-preview',
templateUrl: './dds-batch-run-preview.component.html',
// The screen's own styles are reused as they are: the tables, status badges and agent popup look the
// same on both pages, and a second copy of them would drift.
styleUrls: [
'../dds-batch-processing/dds-batch-processing.component.scss',
'./dds-batch-run-preview.component.scss'
],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatTooltip,
MatDialogClose
]
})
export class DdsBatchRunPreviewComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private organizationService = inject(OrganizationService);
private alertService = inject(AlertService);
private dialog = inject(MatDialog);
@ViewChild('agentDialog') agentDialog: TemplateRef<unknown>;
/** The run on show, and its lines. Named as on the batch screen so the agent popup reads the same. */
generatedRun: any = null;
previewRows: any[] = [];
loading = true;
notFound = false;
working = false;
selectedGroup: AgentGroup | null = null;
agentSearch = '';
private groupsFor: any[] | null = null;
private groups: AgentGroup[] = [];
ngOnInit(): void {
const runId = this.route.snapshot.paramMap.get('runId');
this.organizationService.getDdsBatchRun(runId).subscribe({
next: (run: any) => {
this.generatedRun = run;
this.previewRows = run?.rows || [];
this.loading = false;
},
error: () => {
this.loading = false;
this.notFound = true;
}
});
}
get jobType(): string {
return this.generatedRun?.jobType ?? '';
}
get jobTypeLabel(): string {
return jobLabel(this.jobType);
}
get isCommissionJob(): boolean {
return isCommissionJobType(this.jobType);
}
get isPostingJob(): boolean {
return isPostingJobType(this.jobType);
}
/** Only a commission calculation has a breakdown worth showing: TDS and deductions come off its figure. */
get showsCommissionBreakdown(): boolean {
return this.jobType === 'COMMISSION_CALCULATION';
}
/** Only a run that is still a preview can be posted or thrown away. */
get canAct(): boolean {
return this.generatedRun?.status === 'CALCULATED';
}
get statusText(): string {
switch (this.generatedRun?.status) {
case 'POSTED':
return 'Posted';
case 'POSTING':
return 'Running';
case 'FAILED':
return 'Failed';
default:
return 'Not posted';
}
}
get previewTotal(): number {
return this.previewRows.reduce((total, row) => total + Number(row.amount || 0), 0);
}
money(value: any): string {
return formatMoney(value);
}
labelOf(code: string): string {
return jobLabel(code);
}
/** The preview as agents with their accounts under them, built once per load. */
get agentGroups(): AgentGroup[] {
if (this.groupsFor !== this.previewRows) {
this.groups = groupByAgent(this.previewRows);
this.groupsFor = this.previewRows;
}
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);
}
viewAgent(group: AgentGroup): void {
this.selectedGroup = group;
this.agentSearch = '';
this.dialog.open(this.agentDialog, { width: '860px', maxWidth: '95vw', autoFocus: false });
}
back(): void {
this.router.navigate(['/organization/dds-batch-processing']);
}
/** Every line of the run, agent by agent, with subtotals and the run's total. */
exportPreview(): void {
if (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 row = (cells: any[]) => cells.map(quote).join(',');
const lines: string[] = [];
const runTotal = csvAmount(groups.reduce((t, g) => t + g.total, 0));
if (this.isCommissionJob) {
lines.push(
row([
'Agent Code',
'Agent Name',
'From',
'To',
'Collection',
'Rate %',
'Commission',
'TDS',
'Deductions',
'Payable'
])
);
for (const group of groups) {
for (const each of group.rows) {
lines.push(
row([
group.agentCode,
group.agentName,
dateText(each.fromDate),
dateText(each.toDate),
csvAmount(each.baseAmount),
each.ratePercent ?? '',
csvAmount(each.grossAmount),
csvAmount(each.tdsAmount),
csvAmount(each.deductionAmount),
csvAmount(each.amount)
])
);
}
}
if (withRunTotal) {
lines.push(
row([
'Total',
'',
'',
'',
'',
'',
'',
'',
'',
runTotal
])
);
}
} else {
lines.push(
row([
'Agent Code',
'Agent Name',
'Party Code',
'Party Name',
'From',
'To',
'Rate %',
'Amount'
])
);
for (const group of groups) {
for (const each of group.rows) {
lines.push(
row([
group.agentCode,
group.agentName,
each.partyCode,
each.partyName,
dateText(each.fromDate),
dateText(each.toDate),
each.ratePercent ?? '',
csvAmount(each.amount)
])
);
}
lines.push(
row([
group.agentCode,
group.agentName,
'',
`Agent total (${group.rows.length} accounts)`,
'',
'',
'',
csvAmount(group.total)
])
);
}
if (withRunTotal) {
lines.push(
row([
'Run total',
'',
'',
`${groups.length} agents, ${groups.reduce((t, g) => t + g.rows.length, 0)} accounts`,
'',
'',
'',
runTotal
])
);
}
}
// A byte-order mark so Excel opens the rupee sign and any non-English names correctly.
const blob = new Blob(['' + lines.join('\r\n')], { type: 'text/csv;charset=utf-8' });
const name = withRunTotal
? `${this.jobTypeLabel}-run-${run.id}`
: `${this.jobTypeLabel}-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);
}
post(): void {
if (!this.canAct || this.previewRows.length === 0) {
return;
}
const count = this.previewRows.length;
const total = this.money(this.previewTotal);
// Post is the step that moves money, and it cannot be undone from here - so it names exactly what
// is about to change rather than asking a generic "are you sure".
this.dialog
.open(ConfirmationDialogComponent, {
data: {
type: 'warn',
heading: `Post ${this.jobTypeLabel}?`,
dialogContext:
`${count} ${count === 1 ? 'entry' : 'entries'} totalling ${total} will be applied. ` +
(this.isPostingJob
? 'This credits the customers or pays the agents, and appears on the ledger. '
: 'This records what has been earned against each account. ') +
'It cannot be undone from this screen.'
}
})
.afterClosed()
.subscribe((response: any) => {
if (response?.confirm) {
this.applyRun(count, total);
}
});
}
/**
* Applies the run, and says what happened rather than assuming it finished.
*
* A crediting run takes as long as a savings deposit on every account it touches, so the server does
* it in the background and answers at once. It is reported as running, not as posted - the batch
* screen the clerk returns to follows it until the server marks it posted or failed.
*/
private applyRun(count: number, total: string): void {
const label = this.jobTypeLabel;
const crediting = this.isPostingJob;
this.working = true;
this.organizationService.postDdsBatchRun(this.generatedRun.id).subscribe({
error: () => (this.working = false),
next: (response: any) => {
this.working = false;
if (heldForApproval(response)) {
announceHeldForApproval(
response,
`${label} (${count} ${count === 1 ? 'entry' : 'entries'}, ${total})`,
this.organizationService,
this.alertService
);
} else if (crediting) {
this.alertService.alert({
type: 'Posting',
message:
`${label} is running — ${count} ${count === 1 ? 'entry' : 'entries'}, ${total}. ` +
'It continues in the background; the batch screen updates when it is done.'
});
} else {
this.alertService.alert({
type: 'Success',
message: `${label} posted — ${count} ${count === 1 ? 'entry' : 'entries'}, ${total}.`
});
}
this.back();
}
});
}
/** Throws the preview away - off the server, so it does not linger as "Not posted". */
discard(): void {
if (!this.canAct) {
return;
}
this.working = true;
this.organizationService.discardDdsBatchRun(this.generatedRun.id).subscribe({
error: () => (this.working = false),
next: () => {
this.working = false;
this.alertService.alert({ type: 'Information', message: `${this.jobTypeLabel} preview discarded.` });
this.back();
}
});
}
}
@@ -21,6 +21,7 @@ import { AgentMasterComponent } from './agent-master/agent-master.component';
import { CreateAgentComponent } from './agent-master/create-agent/create-agent.component'; import { CreateAgentComponent } from './agent-master/create-agent/create-agent.component';
import { ViewAgentComponent } from './agent-master/view-agent/view-agent.component'; import { ViewAgentComponent } from './agent-master/view-agent/view-agent.component';
import { DdsBatchProcessingComponent } from './dds-batch-processing/dds-batch-processing.component'; import { DdsBatchProcessingComponent } from './dds-batch-processing/dds-batch-processing.component';
import { DdsBatchRunPreviewComponent } from './dds-batch-run-preview/dds-batch-run-preview.component';
import { EmployeesComponent } from './employees/employees.component'; import { EmployeesComponent } from './employees/employees.component';
import { CreateEmployeeComponent } from './employees/create-employee/create-employee.component'; import { CreateEmployeeComponent } from './employees/create-employee/create-employee.component';
import { ViewEmployeeComponent } from './employees/view-employee/view-employee.component'; import { ViewEmployeeComponent } from './employees/view-employee/view-employee.component';
@@ -268,8 +269,19 @@ const routes: Routes = [
}, },
{ {
path: 'dds-batch-processing', path: 'dds-batch-processing',
component: DdsBatchProcessingComponent, data: { title: 'DDS Interest & Commission Batch', breadcrumb: 'DDS Batch Processing' },
data: { title: 'DDS Interest & Commission Batch', breadcrumb: 'DDS Batch Processing' } children: [
{
path: '',
component: DdsBatchProcessingComponent
},
{
// One generated run: its figures agent by agent, with Post and Discard while it is a preview.
path: 'run/:runId',
component: DdsBatchRunPreviewComponent,
data: { title: 'DDS Batch Run', breadcrumb: 'Run', routeParamBreadcrumb: false }
}
]
}, },
{ {
// Bank's own profile — name, legal name, code, registration, address/contact. A // Bank's own profile — name, legal name, code, registration, address/contact. A