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
@@ -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();
}
});
}
}