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>
</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">
<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"
>{{ debitLines.length }} account{{ debitLines.length !== 1 ? 's' : '' }}</span
>
</div>
<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"
>{{ creditLines.length }} account{{ creditLines.length !== 1 ? 's' : '' }}</span
>
@@ -547,7 +560,10 @@
<div class="dri-card">
<div class="dri-section-title">
<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>
<table class="dri-cr-table">
<thead>
@@ -585,7 +601,10 @@
<div class="dri-card">
<div class="dri-section-title">
<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>
<table class="dri-cr-table">
<thead>
@@ -189,6 +189,15 @@ export class DdsApprovalReviewComponent implements OnInit {
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. */
/**
* 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 {
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
</p>
</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>
<form [formGroup]="batchForm">
@@ -174,170 +182,48 @@
<div class="bp-action-bar">
<button mat-stroked-button type="button" (click)="exit()">Exit</button>
<span class="bp-action-bar__spacer"></span>
@if (generatedRun) {
<button mat-stroked-button type="button" (click)="discard()"><i class="fas fa-times"></i> Discard</button>
}
<button mat-stroked-button type="button" [disabled]="batchForm.invalid || generating" (click)="generate()">
<!-- Generate opens the run on a page of its own; Post and Discard are there, beside the figures. -->
<button
mat-flat-button
color="primary"
type="button"
[disabled]="batchForm.invalid || generating"
(click)="generate()"
>
@if (generating) {
<i class="fas fa-circle-notch fa-spin"></i> Generating…
} @else {
<i class="fas fa-calculator"></i> Generate
}
</button>
<button
mat-flat-button
color="primary"
type="button"
[disabled]="!generatedRun || previewRows.length === 0"
(click)="post()"
>
<i class="fas fa-check"></i> Post
</button>
</div>
</div>
<!-- ── 3. Preview ─────────────────────────────────────────────────────────────────────────── -->
@if (generatedRun) {
<div class="bp-card">
<div class="bp-step-head">
<span class="bp-step-no">3</span>
<!--
A run generated and left unposted is still there to go back to. Each is one button, so the page a
clerk closed by mistake is a click away - not a Generate again.
-->
@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>
<h3>{{ labelOf(generatedRun.jobType) }} — preview</h3>
<p>Run #{{ generatedRun.id }} · nothing applied yet — check the figures, then Post.</p>
<h3>Generated, not posted yet</h3>
<p>Open a run to check its figures, then Post or Discard it.</p>
</div>
</div>
@if (previewRows.length === 0) {
<p class="bp-empty bp-empty--box">
<i class="fas fa-inbox"></i>
Nothing to apply. Either this period has already been processed, or there was no activity in it.
</p>
} @else {
<div class="bp-kpis">
<div class="bp-kpi">
<span>Agents</span><strong>{{ agentGroups.length }}</strong>
</div>
<div class="bp-kpi">
<span>{{ isCommissionJob ? 'Entries' : 'Accounts' }}</span
><strong>{{ previewRows.length }}</strong>
</div>
<div class="bp-kpi bp-kpi--accent">
<span>{{ showsCommissionBreakdown ? 'Total payable' : 'Total' }}</span>
<strong>₹ {{ money(previewTotal) }}</strong>
</div>
<div class="bp-kpi">
<span>Date</span><strong>{{ generatedRun.jobDate | dateFormat }}</strong>
</div>
</div>
<div class="bp-tools">
<span class="bp-tools__hint">
{{
isCommissionJob
? 'One line per agent - View shows how their commission was worked out.'
: 'Grouped by collecting agent - View shows the DDS accounts under each.'
}}
</span>
<span class="bp-tools__spacer"></span>
<button mat-stroked-button type="button" (click)="exportPreview()">
<i class="fas fa-file-export"></i> Export all details
<div class="bp-previews__list">
@for (run of pendingPreviews; track run.id) {
<button type="button" class="bp-preview-btn" (click)="openRun(run.id)">
<span class="bp-preview-btn__job">{{ labelOf(run.jobType) }}</span>
<span class="bp-preview-btn__meta">
Run #{{ run.id }} · {{ run.totalRows }} {{ run.totalRows === 1 ? 'entry' : 'entries' }} · ₹
{{ money(run.totalAmount) }}
</span>
<span class="bp-preview-btn__go">Open <i class="fas fa-arrow-right"></i></span>
</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>
</div>
}
</form>
@@ -347,7 +233,7 @@
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.
-->
@if (postingRunId) {
@if (runStats.running > 0) {
<div class="bp-running">
<i class="fas fa-circle-notch fa-spin"></i>
<span>
@@ -357,220 +243,227 @@
</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. -->
<div class="bp-card">
<div class="bp-runs-head">
<div class="bp-step-head bp-step-head--plain">
<span class="bp-step-no bp-step-no--muted"><i class="fas fa-history"></i></span>
<div>
<!--
Every run and whether it was applied - 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.
In a popup so the page itself stays one screen: the list can run to hundreds of runs, filtered and
paged here, with each run's full detail opening under its row.
-->
<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>
<p>Every run, and whether it was applied.</p>
</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>
@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="bp-skeleton">
@for (row of [1, 2, 3, 4]; track row) {
<span class="bp-skeleton__bar"></span>
}
</div>
} @else if (recentRuns.length === 0) {
<p class="bp-empty bp-empty--box"><i class="fas fa-inbox"></i> No batch has been run yet.</p>
} @else {
<div class="bp-table-scroll">
<table class="bp-table bp-table--runs">
<thead>
<tr>
<th>#</th>
<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 class="bpr-body">
<!-- A status chip says how many runs are in it and filters to them; the chips combine. -->
<div class="bpr-statuses">
@for (status of runStatuses; track status.code) {
<button
type="button"
class="bpr-chip bpr-chip--{{ status.code.toLowerCase() }}"
[class.bpr-chip--on]="runFilter.statuses.has(status.code)"
(click)="toggleRunStatus(status.code)"
>
{{ status.label }} <b>{{ runCount(status.code) }}</b>
</button>
}
<span class="bpr-total">{{ recentRuns.length }} runs in all</span>
</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>
<div class="bpr-filters">
<label class="bpr-field bpr-field--search">
<span>Search</span>
<input
type="search"
class="bpd-search"
placeholder="Search party code or name"
[value]="agentSearch"
(input)="agentSearch = $any($event.target).value"
placeholder="Run number or job"
[value]="runFilter.search"
(input)="setRunSearch($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>
</label>
<label class="bpr-field">
<span>Job date from</span>
<input type="date" [value]="runFilter.from" (change)="setRunDate('from', $any($event.target).value)" />
</label>
<label class="bpr-field">
<span>to</span>
<input type="date" [value]="runFilter.to" (change)="setRunDate('to', $any($event.target).value)" />
</label>
@if (hasRunFilters) {
<button type="button" class="bpr-clear" (click)="clearRunFilters()">
<i class="fas fa-times"></i> Clear filters
</button>
}
</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 class="bpr-jobs">
<span class="bpr-jobs__label">Job</span>
@for (job of runJobOptions; track job.code) {
<button
type="button"
class="bpr-chip"
[class.bpr-chip--on]="runFilter.jobs.has(job.code)"
(click)="toggleRunJob(job.code)"
>
{{ job.label }}
</button>
}
</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 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>
</div>
@@ -33,6 +33,10 @@ $red: #b91c1c;
gap: 14px;
margin-bottom: 16px;
&__spacer {
flex: 1;
}
&__ic {
display: grid;
place-items: center;
@@ -360,7 +364,7 @@ $red: #b91c1c;
// ── 3. Preview ────────────────────────────────────────────────────────────────────────────────────────
.bp-kpis {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 10px;
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 */
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 { Router } from '@angular/router';
import { MatDialog, MatDialogClose } from '@angular/material/dialog';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
import { MatTooltip } from '@angular/material/tooltip';
import { DateAdapter, MAT_DATE_FORMATS } from '@angular/material/core';
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';
/** Custom Services */
@@ -22,17 +22,7 @@ import { OrganizationService } from 'app/organization/organization.service';
import { AlertService } from 'app/core/alert/alert.service';
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;
}
import { csvAmount, dateText, formatMoney, jobLabel } from './dds-batch.shared';
/**
* DDS Interest & Commission batch processing — the legacy Interest Part menu.
@@ -53,7 +43,8 @@ export interface AgentGroup {
...STANDALONE_SHARED_IMPORTS,
// The reason a run failed is too long to sit in the column, and too useful to leave out.
MatTooltip,
MatDialogClose
MatDialogClose,
MatPaginator
],
// 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.
@@ -62,7 +53,7 @@ export interface AgentGroup {
{ provide: MAT_DATE_FORMATS, useValue: DDS_DATE_FORMATS }
]
})
export class DdsBatchProcessingComponent implements OnInit {
export class DdsBatchProcessingComponent implements OnInit, OnDestroy {
private formBuilder = inject(UntypedFormBuilder);
private router = inject(Router);
private organizationService = inject(OrganizationService);
@@ -137,243 +128,192 @@ export class DdsBatchProcessingComponent implements OnInit {
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. */
selectedGroup: AgentGroup | null = null;
agentSearch = '';
/** The statuses a run can be in, as the screen names them, in the order a run moves through them. */
readonly runStatuses: Array<{ code: string; label: string }> = [
{ code: 'CALCULATED', label: 'Not posted' },
{ code: 'POSTING', label: 'Running' },
{ code: 'POSTED', label: 'Posted' },
{ code: 'FAILED', label: 'Failed' }
];
private groupsFor: any[] | null = null;
private groups: AgentGroup[] = [];
/** Every filter at once: what is typed, the jobs and statuses picked (any number), and a job date range. */
runFilter = {
search: '',
jobs: new Set<string>(),
statuses: new Set<string>(),
from: '',
to: ''
};
/**
* The preview as agents with their accounts under them. A run can list a thousand accounts; read
* agent by agent it is a handful of lines, and each agent's accounts open in a popup on View.
* Built once per Generate, not on every change detection pass.
*/
get agentGroups(): AgentGroup[] {
if (this.groupsFor !== this.previewRows) {
const byAgent = new Map<string, AgentGroup>();
for (const row of this.previewRows) {
const key = String(row.agentId ?? row.agentCode ?? '');
let group = byAgent.get(key);
if (!group) {
const name = String(row.agentName ?? '');
group = {
key,
agentCode: row.agentCode ?? '',
agentName: name,
initials: name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((word) => word[0].toUpperCase())
.join(''),
rows: [],
total: 0
};
byAgent.set(key, group);
}
group.rows.push(row);
group.total += Number(row.amount || 0);
filteredRuns: any[] = [];
runsPageIndex = 0;
runsPageSize = 10;
/** The run whose full detail is open under its row. */
expandedRunId: number | null = null;
/** The jobs the list actually holds - so a retired job (OD interest) still filters, and an unused one does not clutter. */
get runJobOptions(): Array<{ code: string; label: string }> {
const codes = Array.from(new Set(this.recentRuns.map((run) => run.jobType)));
return codes.map((code) => ({ code, label: this.labelOf(code) }));
}
get hasRunFilters(): boolean {
const f = this.runFilter;
return !!(f.search.trim() || f.jobs.size || f.statuses.size || f.from || f.to);
}
get pagedRuns(): any[] {
const start = this.runsPageIndex * this.runsPageSize;
return this.filteredRuns.slice(start, start + this.runsPageSize);
}
/** What the filtered list adds up to, so the footer says it without anyone counting rows. */
get filteredRunsTotal(): number {
return this.filteredRuns.reduce((total, run) => total + Number(run.totalAmount || 0), 0);
}
statusLabel(code: string): string {
return this.runStatuses.find((status) => status.code === code)?.label ?? code;
}
/** 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;
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 (f.jobs.size && !f.jobs.has(run.jobType)) {
return false;
}
if (withRunTotal) {
lines.push(
[
'Total',
'',
'',
'',
'',
'',
'',
'',
'',
this.csvAmount(groups.reduce((t, g) => t + g.total, 0))
]
.map(quote)
.join(',')
);
if (f.statuses.size && !f.statuses.has(run.status)) {
return false;
}
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 {
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(',')
);
}
set.add(value);
}
this.applyRunFilters();
}
// 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'}`;
clearRunFilters(): void {
this.runFilter = { search: '', jobs: new Set<string>(), statuses: new Set<string>(), from: '', to: '' };
this.applyRunFilters();
}
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');
link.href = URL.createObjectURL(blob);
link.download = `${name.replace(/\s+/g, '-').toLowerCase()}.csv`;
link.download = 'dds-batch-runs.csv';
link.click();
URL.revokeObjectURL(link.href);
}
/** True while Generate is working out the preview. */
/** True while Generate is working out the run. */
generating = false;
/** True while the list of previous runs is being fetched. */
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[] = [];
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[] = [];
batchForm: UntypedFormGroup = this.formBuilder.group({
@@ -459,19 +390,6 @@ export class DdsBatchProcessingComponent implements OnInit {
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 {
this.organizationService.getOffices().subscribe((offices: any[]) => {
this.offices = offices || [];
@@ -480,22 +398,11 @@ export class DdsBatchProcessingComponent implements OnInit {
}
money(value: any): string {
if (value === null || value === undefined || value === '') {
return '—';
}
return DdsBatchProcessingComponent.MONEY.format(Number(value));
return formatMoney(value);
}
/** Every job a past run can have been, including those no longer offered to run. */
private static readonly RUN_LABELS: Record<string, string> = {
OD_INTEREST_CALCULATION: 'OD Interest Calculation',
OD_INTEREST_POSTING: 'OD Interest Posting'
};
labelOf(code: string): string {
return (
this.jobTypes.find((type) => type.code === code)?.label ?? DdsBatchProcessingComponent.RUN_LABELS[code] ?? code
);
return jobLabel(code);
}
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
* 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.organizationService.getDdsBatchRuns().subscribe({
next: (runs: any[]) => {
this.recentRuns = runs || [];
this.runsLoading = false;
this.applyRunFilters();
this.watchRunning();
},
error: () => (this.runsLoading = false)
});
@@ -556,181 +479,68 @@ export class DdsBatchProcessingComponent implements OnInit {
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.`
});
}
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 {
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);
}
// ── Runs that are going on in the background ──────────────────────────────────────────────────────
private static readonly STATUS_CHECK_INTERVAL_MS = 4000;
/** Twenty minutes of checking, which is longer than any run measured here has taken. */
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". */
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()
});
/**
* A crediting run is done by the server in the background, so the list is read again until none is
* still running, and the clerk is told how each one ended - posted, or failed with the reason the
* server recorded. Polled rather than pushed: a batch that takes minutes needs no faster news, and
* a poll costs one query. It gives up after a while - the run is on the list either way.
*/
private watchRunning(): void {
const nowRunning = new Set<number>(this.recentRuns.filter((run) => run.status === 'POSTING').map((run) => run.id));
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 { ViewAgentComponent } from './agent-master/view-agent/view-agent.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 { CreateEmployeeComponent } from './employees/create-employee/create-employee.component';
import { ViewEmployeeComponent } from './employees/view-employee/view-employee.component';
@@ -268,8 +269,19 @@ const routes: Routes = [
},
{
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