- 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>
402 lines
12 KiB
TypeScript
402 lines
12 KiB
TypeScript
/**
|
||
* 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();
|
||
}
|
||
});
|
||
}
|
||
}
|