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:
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user