-
{{ group.initials }}
-
-
- {{ group.agentCode }} {{ group.agentName }}
-
-
- {{ labelOf(generatedRun?.jobType) }} · Run #{{ generatedRun?.id }} ·
- {{ generatedRun?.jobDate | dateFormat }}
-
-
-
-
-
+
+
+
+ @for (status of runStatuses; track status.code) {
+
+ {{ status.label }} {{ runCount(status.code) }}
+
+ }
+ {{ recentRuns.length }} runs in all
-
- @if (isCommissionJob) {
- @for (row of group.rows; track row.id) {
-
- @if (showsCommissionBreakdown) {
-
- Collection ₹ {{ money(row.baseAmount) }}
-
-
- Slab rate {{ row.ratePercent }}%
-
-
- Commission ₹ {{ money(row.grossAmount) }}
-
-
- – TDS ₹ {{ money(row.tdsAmount) }}
-
-
- – Deductions ₹ {{ money(row.deductionAmount) }}
-
- }
-
- {{ showsCommissionBreakdown ? 'Payable to agent' : 'To be paid out' }}
- ₹ {{ money(row.amount) }}
-
-
-
- Period {{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}. The agent's collection from all
- their customers is added up and one slab rate applies to the whole figure.
-
- }
- } @else {
-
-
- Accounts {{ group.rows.length }}
-
-
- Total ₹ {{ money(group.total) }}
-
-
-
+
-
-
Close
-
-
- Export this agent
-
+
+ Job
+ @for (job of runJobOptions; track job.code) {
+
+ {{ job.label }}
+
+ }
+
+ @if (runsLoading && recentRuns.length === 0) {
+
+ @for (row of [1, 2, 3, 4, 5]; track row) {
+
+ }
+
+ } @else if (recentRuns.length === 0) {
+
No batch has been run yet.
+ } @else if (filteredRuns.length === 0) {
+
+ No run matches these filters.
+ Clear filters
+
+ } @else {
+
+
+
+ }
- }
+
+
+ Close
+
+
+ Export {{ hasRunFilters ? 'filtered' : 'all' }}
+
+
+
diff --git a/src/app/organization/dds-batch-processing/dds-batch-processing.component.scss b/src/app/organization/dds-batch-processing/dds-batch-processing.component.scss
index 50bfe1f38..87dc9b069 100644
--- a/src/app/organization/dds-batch-processing/dds-batch-processing.component.scss
+++ b/src/app/organization/dds-batch-processing/dds-batch-processing.component.scss
@@ -33,6 +33,10 @@ $red: #b91c1c;
gap: 14px;
margin-bottom: 16px;
+ &__spacer {
+ flex: 1;
+ }
+
&__ic {
display: grid;
place-items: center;
@@ -360,7 +364,7 @@ $red: #b91c1c;
// ── 3. Preview ────────────────────────────────────────────────────────────────────────────────────────
.bp-kpis {
display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
+ grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 10px;
margin-bottom: 12px;
}
@@ -818,3 +822,336 @@ $red: #b91c1c;
}
}
}
+
+// ── Previous runs button (header) and popup ──────────────────────────────────────────────────────────
+.bp-runs-btn {
+ position: relative;
+ color: $primary;
+
+ i {
+ margin-right: 6px;
+ }
+
+ &__count {
+ margin-left: 8px;
+ padding: 1px 9px;
+ border-radius: 999px;
+ background: $primary-soft;
+ font-size: 12px;
+ font-weight: 700;
+ }
+
+ &__warn {
+ position: absolute;
+ top: 6px;
+ right: 6px;
+ width: 9px;
+ height: 9px;
+ border-radius: 50%;
+ background: $red;
+ }
+}
+
+.bpr {
+ display: flex;
+ flex-direction: column;
+ max-height: 90vh;
+ color: $text;
+
+ &-head {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 16px 20px;
+ border-bottom: 1px solid $border;
+
+ &__ic {
+ display: grid;
+ place-items: center;
+ flex: none;
+ width: 38px;
+ height: 38px;
+ border-radius: 10px;
+ background: $primary-soft;
+ color: $primary;
+ }
+
+ &__text {
+ flex: 1;
+ min-width: 0;
+
+ h3 {
+ margin: 0;
+ font-size: 16px;
+ font-weight: 700;
+ }
+
+ p {
+ margin: 2px 0 0;
+ font-size: 12px;
+ color: $text-mid;
+ }
+ }
+ }
+
+ &-icon-btn {
+ border: 0;
+ background: transparent;
+ color: $text-mid;
+ font-size: 15px;
+ cursor: pointer;
+ }
+
+ &-body {
+ flex: 1;
+ min-height: 0;
+ padding: 14px 20px 6px;
+ overflow-y: auto;
+ }
+
+ &-statuses,
+ &-jobs {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 10px;
+ }
+
+ &-jobs__label,
+ &-total {
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ color: $text-soft;
+ }
+
+ &-total {
+ margin-left: auto;
+ }
+
+ &-chip {
+ padding: 4px 12px;
+ border: 1px solid $border;
+ border-radius: 999px;
+ background: #fff;
+ color: $text-mid;
+ font: inherit;
+ font-size: 12.5px;
+ font-weight: 500;
+ cursor: pointer;
+
+ b {
+ margin-left: 4px;
+ }
+
+ &:hover {
+ border-color: $primary;
+ color: $primary;
+ }
+
+ &--on {
+ border-color: $primary;
+ background: $primary;
+ color: #fff;
+
+ &:hover {
+ color: #fff;
+ }
+ }
+
+ &--posted:not(&--on) {
+ background: #f0fdf4;
+ color: $green;
+ }
+
+ &--failed:not(&--on) {
+ background: #fef2f2;
+ color: $red;
+ }
+
+ &--posting:not(&--on) {
+ background: #eff6ff;
+ color: #1d4ed8;
+ }
+ }
+
+ &-filters {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-end;
+ gap: 12px;
+ margin-bottom: 10px;
+ }
+
+ &-field {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+
+ span {
+ font-size: 11px;
+ font-weight: 600;
+ color: $text-soft;
+ }
+
+ input {
+ padding: 7px 10px;
+ border: 1px solid $border;
+ border-radius: 8px;
+ font: inherit;
+ font-size: 13px;
+ }
+
+ &--search {
+ flex: 1;
+ min-width: 200px;
+ }
+ }
+
+ &-clear {
+ border: 0;
+ background: transparent;
+ color: $primary;
+ font: inherit;
+ font-size: 12.5px;
+ font-weight: 600;
+ cursor: pointer;
+ }
+
+ &-scroll {
+ max-height: 42vh;
+ overflow-y: auto;
+ }
+
+ &-caret {
+ width: 28px;
+ color: $text-soft;
+ font-size: 11px;
+ }
+
+ &-row {
+ cursor: pointer;
+
+ &--open td {
+ background: #f0fdf9;
+ }
+ }
+
+ &-detail td {
+ padding: 0 12px 12px 40px;
+ background: #f0fdf9;
+ }
+
+ &-detail__grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
+ gap: 8px;
+
+ div {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding: 8px 12px;
+ border: 1px solid $border;
+ border-radius: 8px;
+ background: #fff;
+ }
+
+ span {
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: $text-soft;
+ }
+
+ strong {
+ font-size: 13px;
+ font-variant-numeric: tabular-nums;
+ }
+ }
+
+ &-detail__fail {
+ margin: 8px 0 0;
+ padding: 8px 12px;
+ border-radius: 8px;
+ background: #fef2f2;
+ color: $red;
+ font-size: 12.5px;
+
+ i {
+ margin-right: 6px;
+ }
+ }
+
+ &-actions {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 12px 20px;
+ border-top: 1px solid $border;
+
+ i {
+ margin-right: 6px;
+ }
+ }
+}
+
+// ── Runs generated and not yet posted: one button each, to go back to its page ──────────────────────
+.bp-previews {
+ &__list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ }
+}
+
+.bp-preview-btn {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 10px 16px;
+ border: 1.5px solid $border;
+ border-radius: 10px;
+ background: #fff;
+ color: $text;
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+
+ &:hover {
+ border-color: $primary;
+ box-shadow: 0 0 0 3px rgb(15 118 110 / 10%);
+ }
+
+ &__job {
+ font-size: 14px;
+ font-weight: 700;
+ }
+
+ &__meta {
+ font-size: 12px;
+ color: $text-mid;
+ }
+
+ &__go {
+ margin-left: auto;
+ color: $primary;
+ font-size: 12.5px;
+ font-weight: 700;
+
+ i {
+ margin-left: 4px;
+ }
+ }
+}
+
+.bpr-detail__open {
+ margin: 8px 0 0;
+
+ i {
+ margin-right: 6px;
+ }
+}
diff --git a/src/app/organization/dds-batch-processing/dds-batch-processing.component.ts b/src/app/organization/dds-batch-processing/dds-batch-processing.component.ts
index b81891f05..3c955d310 100644
--- a/src/app/organization/dds-batch-processing/dds-batch-processing.component.ts
+++ b/src/app/organization/dds-batch-processing/dds-batch-processing.component.ts
@@ -7,14 +7,14 @@
*/
/** Angular Imports */
-import { Component, OnInit, TemplateRef, ViewChild, inject } from '@angular/core';
+import { Component, OnDestroy, OnInit, TemplateRef, ViewChild, inject } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { MatDialog, MatDialogClose } from '@angular/material/dialog';
+import { MatPaginator, PageEvent } from '@angular/material/paginator';
import { MatTooltip } from '@angular/material/tooltip';
import { DateAdapter, MAT_DATE_FORMATS } from '@angular/material/core';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
-import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
import { DdsDateAdapter, DDS_DATE_FORMATS } from './dds-date-adapter';
/** Custom Services */
@@ -22,17 +22,7 @@ import { OrganizationService } from 'app/organization/organization.service';
import { AlertService } from 'app/core/alert/alert.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
-import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-approvals/dds-approval-notice';
-
-/** One agent's lines of a previewed run: an agent and the DDS accounts under them (or, for commission, their one row). */
-export interface AgentGroup {
- key: string;
- agentCode: string;
- agentName: string;
- initials: string;
- rows: any[];
- total: number;
-}
+import { csvAmount, dateText, formatMoney, jobLabel } from './dds-batch.shared';
/**
* DDS Interest & Commission batch processing — the legacy Interest Part menu.
@@ -53,7 +43,8 @@ export interface AgentGroup {
...STANDALONE_SHARED_IMPORTS,
// The reason a run failed is too long to sit in the column, and too useful to leave out.
MatTooltip,
- MatDialogClose
+ MatDialogClose,
+ MatPaginator
],
// Day-first dates, as the legacy screen showed them. Declared here rather than application-wide:
// a @Component's providers reach only this component, so no other screen's datepicker changes.
@@ -62,7 +53,7 @@ export interface AgentGroup {
{ provide: MAT_DATE_FORMATS, useValue: DDS_DATE_FORMATS }
]
})
-export class DdsBatchProcessingComponent implements OnInit {
+export class DdsBatchProcessingComponent implements OnInit, OnDestroy {
private formBuilder = inject(UntypedFormBuilder);
private router = inject(Router);
private organizationService = inject(OrganizationService);
@@ -137,243 +128,192 @@ export class DdsBatchProcessingComponent implements OnInit {
return code.endsWith('_POSTING') ? 'Post' : 'Run';
}
- @ViewChild('agentDialog') agentDialog: TemplateRef
;
+ // ── Previous runs, in a popup ─────────────────────────────────────────────────────────────────────
+ @ViewChild('runsDialog') runsDialog: TemplateRef;
- /** The agent whose accounts the popup is showing, and what is typed in its search box. */
- selectedGroup: AgentGroup | null = null;
- agentSearch = '';
+ /** The statuses a run can be in, as the screen names them, in the order a run moves through them. */
+ readonly runStatuses: Array<{ code: string; label: string }> = [
+ { code: 'CALCULATED', label: 'Not posted' },
+ { code: 'POSTING', label: 'Running' },
+ { code: 'POSTED', label: 'Posted' },
+ { code: 'FAILED', label: 'Failed' }
+ ];
- private groupsFor: any[] | null = null;
- private groups: AgentGroup[] = [];
+ /** Every filter at once: what is typed, the jobs and statuses picked (any number), and a job date range. */
+ runFilter = {
+ search: '',
+ jobs: new Set(),
+ statuses: new Set(),
+ from: '',
+ to: ''
+ };
- /**
- * The preview as agents with their accounts under them. A run can list a thousand accounts; read
- * agent by agent it is a handful of lines, and each agent's accounts open in a popup on View.
- * Built once per Generate, not on every change detection pass.
- */
- get agentGroups(): AgentGroup[] {
- if (this.groupsFor !== this.previewRows) {
- const byAgent = new Map();
- for (const row of this.previewRows) {
- const key = String(row.agentId ?? row.agentCode ?? '');
- let group = byAgent.get(key);
- if (!group) {
- const name = String(row.agentName ?? '');
- group = {
- key,
- agentCode: row.agentCode ?? '',
- agentName: name,
- initials: name
- .split(/\s+/)
- .filter(Boolean)
- .slice(0, 2)
- .map((word) => word[0].toUpperCase())
- .join(''),
- rows: [],
- total: 0
- };
- byAgent.set(key, group);
- }
- group.rows.push(row);
- group.total += Number(row.amount || 0);
+ filteredRuns: any[] = [];
+ runsPageIndex = 0;
+ runsPageSize = 10;
+ /** The run whose full detail is open under its row. */
+ expandedRunId: number | null = null;
+
+ /** The jobs the list actually holds - so a retired job (OD interest) still filters, and an unused one does not clutter. */
+ get runJobOptions(): Array<{ code: string; label: string }> {
+ const codes = Array.from(new Set(this.recentRuns.map((run) => run.jobType)));
+ return codes.map((code) => ({ code, label: this.labelOf(code) }));
+ }
+
+ get hasRunFilters(): boolean {
+ const f = this.runFilter;
+ return !!(f.search.trim() || f.jobs.size || f.statuses.size || f.from || f.to);
+ }
+
+ get pagedRuns(): any[] {
+ const start = this.runsPageIndex * this.runsPageSize;
+ return this.filteredRuns.slice(start, start + this.runsPageSize);
+ }
+
+ /** What the filtered list adds up to, so the footer says it without anyone counting rows. */
+ get filteredRunsTotal(): number {
+ return this.filteredRuns.reduce((total, run) => total + Number(run.totalAmount || 0), 0);
+ }
+
+ statusLabel(code: string): string {
+ return this.runStatuses.find((status) => status.code === code)?.label ?? code;
+ }
+
+ /** How many runs are in each status, for the numbers on the status chips (against the whole list, not the filtered one). */
+ runCount(statusCode: string): number {
+ return this.recentRuns.filter((run) => run.status === statusCode).length;
+ }
+
+ openRuns(): void {
+ // Fresh each time it is opened: a run started or posted since is on the list.
+ this.loadRecentRuns();
+ this.dialog.open(this.runsDialog, { width: '1040px', maxWidth: '96vw', autoFocus: false });
+ }
+
+ /** Filters the list and returns to its first page - a narrower list may have fewer pages than the one being read. */
+ applyRunFilters(): void {
+ const f = this.runFilter;
+ const term = f.search.trim().toLowerCase();
+ this.filteredRuns = this.recentRuns.filter((run) => {
+ if (term && !`#${run.id} ${run.id} ${this.labelOf(run.jobType)}`.toLowerCase().includes(term)) {
+ return false;
}
- this.groupsFor = this.previewRows;
- this.groups = Array.from(byAgent.values());
- }
- 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);
- }
-
- /** The agent's accounts (or commission breakdown) in a popup. */
- viewAgent(group: AgentGroup): void {
- this.selectedGroup = group;
- this.agentSearch = '';
- this.dialog.open(this.agentDialog, { width: '860px', maxWidth: '95vw', autoFocus: false });
- }
-
- /** A date as the server sent it - text, or a [year, month, day] array - for the export file. */
- private dateText(value: any): string {
- if (!value) {
- return '';
- }
- if (Array.isArray(value)) {
- return `${value[0]}-${String(value[1]).padStart(2, '0')}-${String(value[2]).padStart(2, '0')}`;
- }
- return String(value);
- }
-
- private csvAmount(value: any): string {
- return value === null || value === undefined || value === '' ? '' : Number(value).toFixed(2);
- }
-
- /**
- * Every line of the preview, agent by agent, with each agent's subtotal and the run's total - the
- * same figures the screen shows, in a file a branch can keep or hand to an auditor. An interest
- * run lists each agent's accounts; a commission run lists each agent's breakdown.
- */
- exportPreview(): void {
- if (!this.generatedRun || 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 lines: string[] = [];
-
- if (this.isCommissionJob) {
- lines.push(
- [
- 'Agent Code',
- 'Agent Name',
- 'From',
- 'To',
- 'Collection',
- 'Rate %',
- 'Commission',
- 'TDS',
- 'Deductions',
- 'Payable'
- ]
- .map(quote)
- .join(',')
- );
- for (const group of groups) {
- for (const row of group.rows) {
- lines.push(
- [
- group.agentCode,
- group.agentName,
- this.dateText(row.fromDate),
- this.dateText(row.toDate),
- this.csvAmount(row.baseAmount),
- row.ratePercent ?? '',
- this.csvAmount(row.grossAmount),
- this.csvAmount(row.tdsAmount),
- this.csvAmount(row.deductionAmount),
- this.csvAmount(row.amount)
- ]
- .map(quote)
- .join(',')
- );
- }
+ if (f.jobs.size && !f.jobs.has(run.jobType)) {
+ return false;
}
- if (withRunTotal) {
- lines.push(
- [
- 'Total',
- '',
- '',
- '',
- '',
- '',
- '',
- '',
- '',
- this.csvAmount(groups.reduce((t, g) => t + g.total, 0))
- ]
- .map(quote)
- .join(',')
- );
+ if (f.statuses.size && !f.statuses.has(run.status)) {
+ return false;
}
+ const day = dateText(run.jobDate);
+ if (f.from && day < f.from) {
+ return false;
+ }
+ return !(f.to && day > f.to);
+ });
+ this.runsPageIndex = 0;
+ }
+
+ setRunSearch(value: string): void {
+ this.runFilter.search = value;
+ this.applyRunFilters();
+ }
+
+ setRunDate(which: 'from' | 'to', value: string): void {
+ this.runFilter[which] = value;
+ this.applyRunFilters();
+ }
+
+ toggleRunJob(code: string): void {
+ this.toggleIn(this.runFilter.jobs, code);
+ }
+
+ toggleRunStatus(code: string): void {
+ this.toggleIn(this.runFilter.statuses, code);
+ }
+
+ private toggleIn(set: Set, value: string): void {
+ if (set.has(value)) {
+ set.delete(value);
} else {
- lines.push(
- [
- 'Agent Code',
- 'Agent Name',
- 'Party Code',
- 'Party Name',
- 'From',
- 'To',
- 'Rate %',
- 'Amount'
- ]
- .map(quote)
- .join(',')
- );
- for (const group of groups) {
- for (const row of group.rows) {
- lines.push(
- [
- group.agentCode,
- group.agentName,
- row.partyCode,
- row.partyName,
- this.dateText(row.fromDate),
- this.dateText(row.toDate),
- row.ratePercent ?? '',
- this.csvAmount(row.amount)
- ]
- .map(quote)
- .join(',')
- );
- }
- lines.push(
- [
- group.agentCode,
- group.agentName,
- '',
- `Agent total (${group.rows.length} accounts)`,
- '',
- '',
- '',
- this.csvAmount(group.total)
- ]
- .map(quote)
- .join(',')
- );
- }
- if (withRunTotal) {
- lines.push(
- [
- 'Run total',
- '',
- '',
- `${groups.length} agents, ${groups.reduce((t, g) => t + g.rows.length, 0)} accounts`,
- '',
- '',
- '',
- this.csvAmount(groups.reduce((t, g) => t + g.total, 0))
- ]
- .map(quote)
- .join(',')
- );
- }
+ set.add(value);
}
+ this.applyRunFilters();
+ }
- // A byte-order mark so Excel opens the rupee sign and any non-English names correctly.
- const blob = new Blob(['\ufeff' + lines.join('\r\n')], { type: 'text/csv;charset=utf-8' });
- const name = withRunTotal
- ? `${this.labelOf(run.jobType)}-run-${run.id}`
- : `${this.labelOf(run.jobType)}-run-${run.id}-${groups[0]?.agentCode ?? 'agent'}`;
+ clearRunFilters(): void {
+ this.runFilter = { search: '', jobs: new Set(), statuses: new Set(), from: '', to: '' };
+ this.applyRunFilters();
+ }
+
+ onRunsPage(event: PageEvent): void {
+ this.runsPageIndex = event.pageIndex;
+ this.runsPageSize = event.pageSize;
+ }
+
+ toggleRunDetail(id: number): void {
+ this.expandedRunId = this.expandedRunId === id ? null : id;
+ }
+
+ /** The runs the filters leave, with everything the list knows about each, as a file. */
+ exportRuns(): void {
+ const quote = (value: any) => `"${String(value ?? '').replace(/"/g, '""')}"`;
+ const header = [
+ 'Run #',
+ 'Job',
+ 'Job Date',
+ 'Status',
+ 'Entries',
+ 'Entries Posted',
+ 'Amount',
+ 'Generated On',
+ 'Posted On',
+ 'Branch',
+ 'Scheme',
+ 'Agent From',
+ 'Agent To',
+ 'Party From',
+ 'Party To',
+ 'Failure Reason'
+ ];
+ const lines = this.filteredRuns.map((run) =>
+ [
+ run.id,
+ this.labelOf(run.jobType),
+ dateText(run.jobDate),
+ this.statusLabel(run.status),
+ run.totalRows,
+ run.rowsPosted,
+ csvAmount(run.totalAmount),
+ dateText(run.generatedOnDate),
+ dateText(run.postedOnDate),
+ run.branchScope,
+ run.subType || 'All schemes',
+ run.agentFrom,
+ run.agentTo,
+ run.partyFrom,
+ run.partyTo,
+ run.failureReason
+ ]
+ .map(quote)
+ .join(',')
+ );
+ const blob = new Blob(
+ ['\ufeff' + [
+ header.map(quote).join(','),
+ ...lines
+ ].join('\r\n')],
+ {
+ type: 'text/csv;charset=utf-8'
+ }
+ );
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
- link.download = `${name.replace(/\s+/g, '-').toLowerCase()}.csv`;
+ link.download = 'dds-batch-runs.csv';
link.click();
URL.revokeObjectURL(link.href);
}
- /** True while Generate is working out the preview. */
+ /** True while Generate is working out the run. */
generating = false;
/** True while the list of previous runs is being fetched. */
runsLoading = true;
@@ -393,18 +333,9 @@ export class DdsBatchProcessingComponent implements OnInit {
};
}
- private static readonly MONEY = new Intl.NumberFormat('en-IN', {
- minimumFractionDigits: 2,
- maximumFractionDigits: 2
- });
-
offices: any[] = [];
selectedOfficeNames = new Set();
- /** The run currently previewed on screen, or null once it has been posted or discarded. */
- generatedRun: any = null;
- previewRows: any[] = [];
-
recentRuns: any[] = [];
batchForm: UntypedFormGroup = this.formBuilder.group({
@@ -459,19 +390,6 @@ export class DdsBatchProcessingComponent implements OnInit {
return this.batchForm.get('branchScope')?.value === 'Selected';
}
- /**
- * Only a commission calculation has a breakdown worth showing: it is the one job where the figure
- * applied is not the figure worked out, because TDS and the standing deductions come off it.
- */
- get showsCommissionBreakdown(): boolean {
- return this.jobType === 'COMMISSION_CALCULATION';
- }
-
- /** What Generate found, in words — the same figure the Post button is about to apply. */
- get previewTotal(): number {
- return this.previewRows.reduce((total, row) => total + Number(row.amount || 0), 0);
- }
-
ngOnInit(): void {
this.organizationService.getOffices().subscribe((offices: any[]) => {
this.offices = offices || [];
@@ -480,22 +398,11 @@ export class DdsBatchProcessingComponent implements OnInit {
}
money(value: any): string {
- if (value === null || value === undefined || value === '') {
- return '—';
- }
- return DdsBatchProcessingComponent.MONEY.format(Number(value));
+ return formatMoney(value);
}
- /** Every job a past run can have been, including those no longer offered to run. */
- private static readonly RUN_LABELS: Record = {
- OD_INTEREST_CALCULATION: 'OD Interest Calculation',
- OD_INTEREST_POSTING: 'OD Interest Posting'
- };
-
labelOf(code: string): string {
- return (
- this.jobTypes.find((type) => type.code === code)?.label ?? DdsBatchProcessingComponent.RUN_LABELS[code] ?? code
- );
+ return jobLabel(code);
}
toggleOffice(officeName: string): void {
@@ -511,12 +418,28 @@ export class DdsBatchProcessingComponent implements OnInit {
* invisible the moment the page is left, and there is no way to tell whether a month's interest
* was ever actually posted.
*/
- private loadRecentRuns(): void {
+ /** Runs generated and not yet posted - each gets a button to go back to its page. */
+ get pendingPreviews(): any[] {
+ return this.recentRuns.filter((run) => run.status === 'CALCULATED');
+ }
+
+ /** A run on a page of its own: its figures agent by agent, and Post or Discard while it is still a preview. */
+ openRun(runId: number): void {
+ this.dialog.closeAll();
+ this.router.navigate([
+ '/organization/dds-batch-processing/run',
+ runId
+ ]);
+ }
+
+ loadRecentRuns(): void {
this.runsLoading = true;
this.organizationService.getDdsBatchRuns().subscribe({
next: (runs: any[]) => {
this.recentRuns = runs || [];
this.runsLoading = false;
+ this.applyRunFilters();
+ this.watchRunning();
},
error: () => (this.runsLoading = false)
});
@@ -556,181 +479,68 @@ export class DdsBatchProcessingComponent implements OnInit {
error: () => (this.generating = false),
next: (batchRun: any) => {
this.generating = false;
- this.generatedRun = batchRun;
- this.previewRows = batchRun.rows || [];
-
- // A run that found nothing is the normal outcome of a period already processed, not a
- // failure — but it has to say so, or an empty table reads as the screen being broken.
- if (this.previewRows.length === 0) {
- this.alertService.alert({
- type: 'Information',
- message: `${this.jobTypeLabel} found nothing to apply. Either this period has already been processed, or there was no activity in it.`
- });
- } else {
- this.alertService.alert({
- type: 'Success',
- message: `${this.jobTypeLabel}: ${this.previewRows.length} ${this.previewRows.length === 1 ? 'entry' : 'entries'} totalling ${this.money(this.previewTotal)} calculated. Nothing has been applied yet — review and press Post.`
- });
- }
-
this.loadRecentRuns();
+ // The run is checked on a page of its own - agent by agent, with Post and Discard beside the
+ // figures - so this page does not grow a second screen's worth below the form.
+ this.router.navigate([
+ '/organization/dds-batch-processing/run',
+ batchRun.id
+ ]);
}
});
}
});
}
- post(): void {
- if (!this.generatedRun || this.previewRows.length === 0) {
- return;
- }
-
- const count = this.previewRows.length;
- const total = this.money(this.previewTotal);
- const isPosting = this.isPostingJob;
-
- // Post is the step that moves money, and it cannot be undone from this screen — 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. ` +
- (isPosting
- ? '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 actually happened rather than assuming it finished.
- *
- * A crediting run costs what a savings deposit costs on every account it touches, so the server
- * takes it off the request and does it in the background - the reply comes back in a moment while
- * the work is still going on. Reporting that as "posted" would tell the clerk a run had finished
- * seconds after it began, and they would go looking for credits that were still being written.
- *
- * So the run is followed until it settles. A calculation run is done by the time the reply lands
- * and says so at once; a crediting run says it is under way, and the list beneath refreshes itself
- * until the server marks it posted - or failed, with the reason the server recorded.
- */
- private applyRun(count: number, total: string): void {
- const label = this.jobTypeLabel;
- const runId = this.generatedRun.id;
- const crediting = this.isPostingJob;
-
- this.organizationService.postDdsBatchRun(runId).subscribe((response: any) => {
- this.generatedRun = null;
- this.previewRows = [];
- this.loadRecentRuns();
-
- if (heldForApproval(response)) {
- announceHeldForApproval(
- response,
- `${label} (${count} ${count === 1 ? 'entry' : 'entries'}, ${total})`,
- this.organizationService,
- this.alertService
- );
- return;
- }
-
- if (!crediting) {
- this.alertService.alert({
- type: 'Success',
- message: `${label} posted — ${count} ${count === 1 ? 'entry' : 'entries'}, ${total}.`
- });
- return;
- }
-
- this.postingRunId = runId;
- this.alertService.alert({
- type: 'Posting',
- message:
- `${label} is running — ${count} ${count === 1 ? 'entry' : 'entries'}, ${total}. ` +
- 'It continues in the background; this list will update when it is done.'
- });
- this.followRun(runId, label);
- });
- }
-
- /** The crediting run being followed, if one is. The screen reads this to show it is under way. */
- postingRunId: number | null = null;
-
- /**
- * Watches a background run until the server marks it settled.
- *
- * Polled rather than pushed: a batch that takes minutes needs no faster news than this, and a poll
- * costs nothing when the answer is one row. It gives up after a while rather than asking forever -
- * the run is still on the list either way, and a clerk can read its status there.
- */
- private followRun(runId: number, label: string): void {
- let checksLeft = DdsBatchProcessingComponent.MAX_STATUS_CHECKS;
-
- const check = () => {
- this.organizationService.getDdsBatchRuns().subscribe({
- next: (runs: any[]) => {
- const run = (runs || []).find((each: any) => each.id === runId);
- const status = run?.status;
-
- if (status === 'POSTED' || status === 'FAILED') {
- this.postingRunId = null;
- this.loadRecentRuns();
- this.alertService.alert(
- status === 'POSTED'
- ? { type: 'Success', message: `${label} posted — ${run.rowsPosted ?? run.totalRows} entries applied.` }
- : { type: 'Error', message: `${label} failed: ${run.failureReason || 'see the run for details'}` }
- );
- return;
- }
-
- if (--checksLeft > 0) {
- setTimeout(check, DdsBatchProcessingComponent.STATUS_CHECK_INTERVAL_MS);
- } else {
- // Still going after the wait. Left on the list rather than reported either way - saying
- // "failed" for a run that is merely long would be worse than saying nothing.
- this.postingRunId = null;
- this.loadRecentRuns();
- }
- },
- // A failed status check says nothing about the run itself, which carries on regardless.
- error: () => {
- if (--checksLeft > 0) {
- setTimeout(check, DdsBatchProcessingComponent.STATUS_CHECK_INTERVAL_MS);
- } else {
- this.postingRunId = null;
- }
- }
- });
- };
-
- setTimeout(check, DdsBatchProcessingComponent.STATUS_CHECK_INTERVAL_MS);
- }
-
+ // ── Runs that are going on in the background ──────────────────────────────────────────────────────
private static readonly STATUS_CHECK_INTERVAL_MS = 4000;
/** Twenty minutes of checking, which is longer than any run measured here has taken. */
private static readonly MAX_STATUS_CHECKS = 300;
+ private checksLeft = DdsBatchProcessingComponent.MAX_STATUS_CHECKS;
+ private watching: ReturnType | null = null;
+ private runningIds = new Set();
- /** Drops the preview without applying it — the safe way out of a calculation that looks wrong. */
- /** Throws the preview away - off the screen and off the server, so it does not linger as "Not posted". */
- discard(): void {
- const run = this.generatedRun;
- this.generatedRun = null;
- this.previewRows = [];
- if (run?.id) {
- this.organizationService.discardDdsBatchRun(run.id).subscribe({
- next: () => this.loadRecentRuns(),
- error: () => this.loadRecentRuns()
- });
+ /**
+ * A crediting run is done by the server in the background, so the list is read again until none is
+ * still running, and the clerk is told how each one ended - posted, or failed with the reason the
+ * server recorded. Polled rather than pushed: a batch that takes minutes needs no faster news, and
+ * a poll costs one query. It gives up after a while - the run is on the list either way.
+ */
+ private watchRunning(): void {
+ const nowRunning = new Set(this.recentRuns.filter((run) => run.status === 'POSTING').map((run) => run.id));
+
+ for (const id of this.runningIds) {
+ if (nowRunning.has(id)) {
+ continue;
+ }
+ const run = this.recentRuns.find((each) => each.id === id);
+ const label = this.labelOf(run?.jobType);
+ if (run?.status === 'POSTED') {
+ this.alertService.alert({
+ type: 'Success',
+ message: `${label} posted — ${run.rowsPosted ?? run.totalRows} entries applied.`
+ });
+ } else if (run?.status === 'FAILED') {
+ this.alertService.alert({
+ type: 'Error',
+ message: `${label} failed: ${run.failureReason || 'see the run for details'}`
+ });
+ }
+ }
+ this.runningIds = nowRunning;
+
+ if (this.watching) {
+ clearTimeout(this.watching);
+ this.watching = null;
+ }
+ if (nowRunning.size > 0 && this.checksLeft-- > 0) {
+ this.watching = setTimeout(() => this.loadRecentRuns(), DdsBatchProcessingComponent.STATUS_CHECK_INTERVAL_MS);
+ }
+ }
+
+ ngOnDestroy(): void {
+ if (this.watching) {
+ clearTimeout(this.watching);
}
}
diff --git a/src/app/organization/dds-batch-processing/dds-batch.shared.ts b/src/app/organization/dds-batch-processing/dds-batch.shared.ts
new file mode 100644
index 000000000..5804ff191
--- /dev/null
+++ b/src/app/organization/dds-batch-processing/dds-batch.shared.ts
@@ -0,0 +1,101 @@
+/**
+ * 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/.
+ */
+
+/**
+ * What the DDS batch screen and its run preview page share: how a job is named, how money and dates
+ * read, and how a run's rows are grouped under their agent.
+ */
+
+/** One agent's lines of a previewed run: an agent and the DDS accounts under them (or, for commission, their one row). */
+export interface AgentGroup {
+ key: string;
+ agentCode: string;
+ agentName: string;
+ initials: string;
+ rows: any[];
+ total: number;
+}
+
+/** Every job a run can have been, including those no longer offered to run (OD interest). */
+export const JOB_LABELS: Record = {
+ INTEREST_CALCULATION: 'Interest Calculation',
+ INTEREST_POSTING: 'Interest Posting',
+ OD_INTEREST_CALCULATION: 'OD Interest Calculation',
+ OD_INTEREST_POSTING: 'OD Interest Posting',
+ COMMISSION_CALCULATION: 'Commission Calculation',
+ COMMISSION_POSTING: 'Commission Posting',
+ MATURITY_AUTO_CLOSE: 'Maturity Auto Close'
+};
+
+export function jobLabel(code: string): string {
+ return JOB_LABELS[code] ?? code;
+}
+
+export function isCommissionJobType(code: string): boolean {
+ return (code || '').startsWith('COMMISSION');
+}
+
+export function isPostingJobType(code: string): boolean {
+ return (code || '').endsWith('_POSTING');
+}
+
+const MONEY = new Intl.NumberFormat('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
+
+export function formatMoney(value: any): string {
+ if (value === null || value === undefined || value === '') {
+ return '—';
+ }
+ return MONEY.format(Number(value));
+}
+
+/** A date as the server sent it - text, or a [year, month, day] array - for a file or a comparison. */
+export function dateText(value: any): string {
+ if (!value) {
+ return '';
+ }
+ if (Array.isArray(value)) {
+ return `${value[0]}-${String(value[1]).padStart(2, '0')}-${String(value[2]).padStart(2, '0')}`;
+ }
+ return String(value);
+}
+
+export function csvAmount(value: any): string {
+ return value === null || value === undefined || value === '' ? '' : Number(value).toFixed(2);
+}
+
+/**
+ * The rows of a run as agents with their accounts under them. A run can list a thousand accounts;
+ * read agent by agent it is a handful of lines.
+ */
+export function groupByAgent(rows: any[]): AgentGroup[] {
+ const byAgent = new Map();
+ for (const row of rows) {
+ const key = String(row.agentId ?? row.agentCode ?? '');
+ let group = byAgent.get(key);
+ if (!group) {
+ const name = String(row.agentName ?? '');
+ group = {
+ key,
+ agentCode: row.agentCode ?? '',
+ agentName: name,
+ initials: name
+ .split(/\s+/)
+ .filter(Boolean)
+ .slice(0, 2)
+ .map((word) => word[0].toUpperCase())
+ .join(''),
+ rows: [],
+ total: 0
+ };
+ byAgent.set(key, group);
+ }
+ group.rows.push(row);
+ group.total += Number(row.amount || 0);
+ }
+ return Array.from(byAgent.values());
+}
diff --git a/src/app/organization/dds-batch-run-preview/dds-batch-run-preview.component.html b/src/app/organization/dds-batch-run-preview/dds-batch-run-preview.component.html
new file mode 100644
index 000000000..7f97cfa78
--- /dev/null
+++ b/src/app/organization/dds-batch-run-preview/dds-batch-run-preview.component.html
@@ -0,0 +1,338 @@
+
+
+
+
+
+ @if (loading) {
+
+
+ @for (row of [1, 2, 3, 4, 5]; track row) {
+
+ }
+
+
+ } @else if (notFound) {
+
+
+ This run no longer exists - a new Generate or Discard may have replaced it.
+ Back
+
+
+ } @else {
+ @if (generatedRun?.status === 'FAILED') {
+
+ {{ generatedRun.failureReason || 'No reason recorded' }}
+
+ }
+
+ @if (previewRows.length === 0) {
+
+
+ Nothing to apply. Either this period has already been processed, or there was no activity in it.
+
+ } @else {
+
+
+ Agents {{ agentGroups.length }}
+
+
+ {{ isCommissionJob ? 'Entries' : 'Accounts' }} {{ previewRows.length }}
+
+
+ {{ showsCommissionBreakdown ? 'Total payable' : 'Total' }}
+ ₹ {{ money(previewTotal) }}
+
+
+ Date {{ generatedRun.jobDate | dateFormat }}
+
+
+
+
+
+ {{
+ isCommissionJob
+ ? 'One line per agent - View shows how their commission was worked out.'
+ : 'Grouped by collecting agent - View shows the DDS accounts under each.'
+ }}
+
+
+
+ Export all details
+
+
+
+
+ }
+
+ }
+
+
+
+ @if (selectedGroup; as group) {
+
+
+
{{ group.initials }}
+
+
+ {{ group.agentCode }} {{ group.agentName }}
+
+
+ {{ labelOf(generatedRun?.jobType) }} · Run #{{ generatedRun?.id }} ·
+ {{ generatedRun?.jobDate | dateFormat }}
+
+
+
+
+
+
+
+
+ @if (isCommissionJob) {
+ @for (row of group.rows; track row.id) {
+
+ @if (showsCommissionBreakdown) {
+
+ Collection ₹ {{ money(row.baseAmount) }}
+
+
+ Slab rate {{ row.ratePercent }}%
+
+
+ Commission ₹ {{ money(row.grossAmount) }}
+
+
+ – TDS ₹ {{ money(row.tdsAmount) }}
+
+
+ – Deductions ₹ {{ money(row.deductionAmount) }}
+
+ }
+
+ {{ showsCommissionBreakdown ? 'Payable to agent' : 'To be paid out' }}
+ ₹ {{ money(row.amount) }}
+
+
+
+ Period {{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}. The agent's collection from all
+ their customers is added up and one slab rate applies to the whole figure.
+
+ }
+ } @else {
+
+
+ Accounts {{ group.rows.length }}
+
+
+ Total ₹ {{ money(group.total) }}
+
+
+
+
+
+
+ }
+
+
+
+ Close
+
+
+ Export this agent
+
+
+
+ }
+
+
diff --git a/src/app/organization/dds-batch-run-preview/dds-batch-run-preview.component.scss b/src/app/organization/dds-batch-run-preview/dds-batch-run-preview.component.scss
new file mode 100644
index 000000000..e7f637390
--- /dev/null
+++ b/src/app/organization/dds-batch-run-preview/dds-batch-run-preview.component.scss
@@ -0,0 +1,36 @@
+/**
+ * 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/.
+ */
+
+// The batch screen's styles are loaded with this one; only what is particular to the run page is here.
+.bprv-back {
+ flex: none;
+ color: #0f766e;
+}
+
+.bprv-banner {
+ margin: 0 0 12px;
+}
+
+// The page itself should not scroll: the table takes what is left of the window and scrolls inside.
+.bprv-card {
+ display: flex;
+ flex-direction: column;
+ margin-bottom: 0;
+
+ .bp-table-scroll {
+ max-height: calc(100vh - 430px);
+ min-height: 200px;
+ overflow: auto;
+
+ th {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ }
+ }
+}
diff --git a/src/app/organization/dds-batch-run-preview/dds-batch-run-preview.component.ts b/src/app/organization/dds-batch-run-preview/dds-batch-run-preview.component.ts
new file mode 100644
index 000000000..66d74164f
--- /dev/null
+++ b/src/app/organization/dds-batch-run-preview/dds-batch-run-preview.component.ts
@@ -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;
+
+ /** 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();
+ }
+ });
+ }
+}
diff --git a/src/app/organization/organization-routing.module.ts b/src/app/organization/organization-routing.module.ts
index de58ad938..06f655fb8 100644
--- a/src/app/organization/organization-routing.module.ts
+++ b/src/app/organization/organization-routing.module.ts
@@ -21,6 +21,7 @@ import { AgentMasterComponent } from './agent-master/agent-master.component';
import { CreateAgentComponent } from './agent-master/create-agent/create-agent.component';
import { ViewAgentComponent } from './agent-master/view-agent/view-agent.component';
import { DdsBatchProcessingComponent } from './dds-batch-processing/dds-batch-processing.component';
+import { DdsBatchRunPreviewComponent } from './dds-batch-run-preview/dds-batch-run-preview.component';
import { EmployeesComponent } from './employees/employees.component';
import { CreateEmployeeComponent } from './employees/create-employee/create-employee.component';
import { ViewEmployeeComponent } from './employees/view-employee/view-employee.component';
@@ -268,8 +269,19 @@ const routes: Routes = [
},
{
path: 'dds-batch-processing',
- component: DdsBatchProcessingComponent,
- data: { title: 'DDS Interest & Commission Batch', breadcrumb: 'DDS Batch Processing' }
+ data: { title: 'DDS Interest & Commission Batch', breadcrumb: 'DDS Batch Processing' },
+ children: [
+ {
+ path: '',
+ component: DdsBatchProcessingComponent
+ },
+ {
+ // One generated run: its figures agent by agent, with Post and Discard while it is a preview.
+ path: 'run/:runId',
+ component: DdsBatchRunPreviewComponent,
+ data: { title: 'DDS Batch Run', breadcrumb: 'Run', routeParamBreadcrumb: false }
+ }
+ ]
},
{
// Bank's own profile — name, legal name, code, registration, address/contact. A