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