Files
cbsnexora/src/app/organization/dds-batch-run-preview/dds-batch-run-preview.component.ts
T
niteshandClaude Opus 5.5 1f87ad475c feat(dds): agent-first Party Master, account actions menu, FD-style statuses
- Party Master opens on agents (paged, from the server summary), then one
  agent's dashboard: accounts, maturity, money and collection cards, each
  with a View that filters the accounts table. The page title follows the
  view, and Back from an account returns to its agent.
- DDS account page: an actions menu with Edit, Premature Closure, and
  Maturity Payout & Close. The manual Close Account buttons are gone, since
  payout and premature closure close the account themselves.
- Statuses Active / Matured / Closed / Premature Closed are shown and
  filtered on every DDS screen through a shared dds-status helper.
- Premature closure dialog redesigned: term progress, numbered steps, and a
  payout calculation for each choice using the server's formulas.
- Agent & Slab Approval tab: slab approvals moved in beside agents, with
  toggle buttons and labels named for the action; richer slab review.
- Batch processing: calculation runs are reported as applied, and an empty
  run is reported as nothing to do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 18:33:15 +05:30

402 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 this.jobType.endsWith('_CALCULATION') ? 'Calculated' : '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 is sent to a checker for approval first. 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();
}
});
}
}