diff --git a/src/app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component.html b/src/app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component.html index 7a8ca9bbb..5c3f6fb41 100644 --- a/src/app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component.html +++ b/src/app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component.html @@ -515,14 +515,27 @@ }} + @if (entriesProjected) { +
+ Ledger + No journal entry is made when this is approved - it records what has been earned. The entries below + are what the matching Posting run will book. +
+ }
- DR Accounts + {{ + entriesProjected ? 'DR Accounts (when posted)' : 'DR Accounts' + }} {{ debitLines.length }} account{{ debitLines.length !== 1 ? 's' : '' }}
- CR Accounts + {{ + entriesProjected ? 'CR Accounts (when posted)' : 'CR Accounts' + }} {{ creditLines.length }} account{{ creditLines.length !== 1 ? 's' : '' }} @@ -547,7 +560,10 @@
arrow_upward - Debit Entries ({{ debitLines.length }} account{{ debitLines.length !== 1 ? 's' : '' }} — funds out) + {{ entriesProjected ? 'Debit entries when posted' : 'Debit Entries' }} ({{ debitLines.length }} account{{ + debitLines.length !== 1 ? 's' : '' + }} + — funds out)
@@ -585,7 +601,10 @@
arrow_downward - Credit Entries ({{ creditLines.length }} account{{ creditLines.length !== 1 ? 's' : '' }} — funds in) + {{ entriesProjected ? 'Credit entries when posted' : 'Credit Entries' }} ({{ creditLines.length }} account{{ + creditLines.length !== 1 ? 's' : '' + }} + — funds in)
diff --git a/src/app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component.ts b/src/app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component.ts index a5e1dce09..4e051daa9 100644 --- a/src/app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component.ts +++ b/src/app/deposits/dds-approvals/dds-approval-review/dds-approval-review.component.ts @@ -189,6 +189,15 @@ export class DdsApprovalReviewComponent implements OnInit { private groups: { name: string; icon: string; changed: number; fields: any[] }[] = []; /** Whether the entry moves money at all, and so has an amount and debit and credit sides to show. */ + /** + * A calculation run is approved to record what has been earned in DDS's own tables; it makes no journal + * entry itself. The sides shown for it are what the matching Posting run will book, marked so they are + * not read as entries made on approval. + */ + get entriesProjected(): boolean { + return (this.detail?.lines || []).some((line: any) => line.projected); + } + get movesMoney(): boolean { return (this.entry?.amount !== null && this.entry?.amount !== undefined) || !!this.detail?.lines?.length; } diff --git a/src/app/organization/dds-batch-processing/dds-batch-processing.component.html b/src/app/organization/dds-batch-processing/dds-batch-processing.component.html index 3273ce465..56415e012 100644 --- a/src/app/organization/dds-batch-processing/dds-batch-processing.component.html +++ b/src/app/organization/dds-batch-processing/dds-batch-processing.component.html @@ -18,6 +18,14 @@ Calculate, check and post DDS interest and agent commission, and close matured accounts

+ + @@ -174,170 +182,48 @@
- @if (generatedRun) { - - } - -
- - @if (generatedRun) { -
-
- 3 + + @if (pendingPreviews.length > 0) { +
+
+
-

{{ labelOf(generatedRun.jobType) }} — preview

-

Run #{{ generatedRun.id }} · nothing applied yet — check the figures, then Post.

+

Generated, not posted yet

+

Open a run to check its figures, then Post or Discard it.

- - @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.' - }} - - - -
- -
- @if (isCommissionJob) { -
- - - - - @if (showsCommissionBreakdown) { - - - - - - } - - - - - - @for (group of agentGroups; track group.key) { - @for (row of group.rows; track row.id) { - - - - @if (showsCommissionBreakdown) { - - - - - - } - - - - } - } - - - - - - - - -
AgentPeriodCollectionRateCommissionTDSDeductions{{ showsCommissionBreakdown ? 'Payable' : 'Amount' }}
- {{ row.agentCode }} - {{ row.agentName }} - {{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}{{ money(row.baseAmount) }} - {{ row.ratePercent }}% - {{ money(row.grossAmount) }}{{ money(row.tdsAmount) }}{{ money(row.deductionAmount) }}{{ money(row.amount) }} - -
Total{{ money(previewTotal) }}
- } @else { - - - - - - - - - - - @for (group of agentGroups; track group.key) { - - - - - - - } - - - - - - - - - -
AgentAccountsAmount
- {{ group.initials }} - {{ group.agentCode }} - {{ group.agentName }} - {{ group.rows.length }}{{ money(group.total) }} - -
Total{{ previewRows.length }}{{ money(previewTotal) }}
- } -
- } + } +
} @@ -347,7 +233,7 @@ it in the background and answers at once. Without this the screen would report a run as posted seconds after it began, and the clerk would go looking for credits still being written. --> - @if (postingRunId) { + @if (runStats.running > 0) {
@@ -357,220 +243,227 @@
} - -
-
-
- -
+ + +
+
+ +

Previous runs

Every run, and whether it was applied.

+ +
- @if (!runsLoading && recentRuns.length > 0) { -
- {{ runStats.total }} runs - {{ runStats.posted }} posted - @if (runStats.pending) { - {{ runStats.pending }} not posted - } - @if (runStats.running) { - {{ runStats.running }} running - } - @if (runStats.failed) { - {{ runStats.failed }} failed - } -
- } -
- @if (runsLoading) { -
- @for (row of [1, 2, 3, 4]; track row) { - - } -
- } @else if (recentRuns.length === 0) { -

No batch has been run yet.

- } @else { -
- - - - - - - - - - - - - - @for (run of recentRuns; track run.id) { - - - - - - - - - - - } - -
#JobDateEntriesAmountStatusPosted On
#{{ run.id }} - {{ labelOf(run.jobType) }} - {{ run.jobDate | dateFormat }}{{ run.totalRows }}{{ money(run.totalAmount) }} - @switch (run.status) { - @case ('POSTED') { - Posted - } - @case ('POSTING') { - - Running - - } - @case ('FAILED') { - - Failed - - } - @default { - Not posted - } - } - {{ run.postedOnDate ? (run.postedOnDate | dateFormat) : '—' }}
-
- } -
- - - - @if (selectedGroup; as group) { -
-
- {{ group.initials }} -
-

- {{ group.agentCode }} {{ group.agentName }} -

-

- {{ labelOf(generatedRun?.jobType) }} · Run #{{ generatedRun?.id }} · - {{ generatedRun?.jobDate | dateFormat }} -

-
- +
+ +
+ @for (status of runStatuses; track 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) }} -
-
- +
+ + + + @if (hasRunFilters) { + }
-
- - - +
+ Job + @for (job of runJobOptions; track job.code) { + + }
+ + @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. + +

+ } @else { +
+ + + + + + + + + + + + + + + + @for (run of pagedRuns; track run.id) { + + + + + + + + + + + + + @if (expandedRunId === run.id) { + + + + } + } + + + + + + + + +
#JobJob dateEntriesAmountStatusGeneratedPosted on
+ + #{{ run.id }} + {{ labelOf(run.jobType) }} + {{ run.jobDate | dateFormat }}{{ run.totalRows }}{{ money(run.totalAmount) }} + @switch (run.status) { + @case ('POSTED') { + Posted + } + @case ('POSTING') { + + Running + + } + @case ('FAILED') { + + Failed + + } + @default { + Not posted + } + } + {{ run.generatedOnDate ? (run.generatedOnDate | dateFormat) : '—' }}{{ run.postedOnDate ? (run.postedOnDate | dateFormat) : '—' }}
+
+
+ Branch{{ run.branchScope || '—' }} +
+
+ Scheme{{ run.subType || 'All schemes' }} +
+
+ Agents{{ run.agentFrom }} to {{ run.agentTo }} +
+
+ Parties{{ run.partyFrom }} to {{ run.partyTo }} +
+
+ Entries posted{{ run.rowsPosted ?? 0 }} of {{ run.totalRows }} +
+
+ Amount₹ {{ money(run.totalAmount) }} +
+
+
+ +
+ @if (run.status === 'FAILED') { +

+ + {{ run.failureReason || 'No reason recorded' }} +

+ } +
+ {{ hasRunFilters ? 'Total of the ' + filteredRuns.length + ' runs shown' : 'Total' }} + {{ money(filteredRunsTotal) }}
+
+ + + }
- } + +
+ + + +
+
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 @@ + + +
+
+ +
+
+

+ {{ jobTypeLabel || 'Batch run' }} — {{ canAct ? 'preview' : 'run' }} + @if (generatedRun) { + {{ statusText }} + } +

+

+ @if (generatedRun) { + Run #{{ generatedRun.id }} · + {{ + canAct + ? 'nothing applied yet — check the figures, then Post.' + : 'read only — this run is ' + statusText.toLowerCase() + '.' + }} + } +

+
+ + @if (canAct) { + + + } +
+ + @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. + +

+
+ } @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.' + }} + + + +
+ +
+ @if (isCommissionJob) { + + + + + + @if (showsCommissionBreakdown) { + + + + + + } + + + + + + @for (group of agentGroups; track group.key) { + @for (row of group.rows; track row.id) { + + + + @if (showsCommissionBreakdown) { + + + + + + } + + + + } + } + + + + + + + + +
AgentPeriodCollectionRateCommissionTDSDeductions{{ showsCommissionBreakdown ? 'Payable' : 'Amount' }}
+ {{ row.agentCode }} + {{ row.agentName }} + {{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}{{ money(row.baseAmount) }} + {{ row.ratePercent }}% + {{ money(row.grossAmount) }}{{ money(row.tdsAmount) }}{{ money(row.deductionAmount) }}{{ money(row.amount) }} + +
Total{{ money(previewTotal) }}
+ } @else { + + + + + + + + + + + @for (group of agentGroups; track group.key) { + + + + + + + } + + + + + + + + + +
AgentAccountsAmount
+ {{ group.initials }} + {{ group.agentCode }} + {{ group.agentName }} + {{ group.rows.length }}{{ money(group.total) }} + +
Total{{ previewRows.length }}{{ money(previewTotal) }}
+ } +
+ } +
+ } + + + + @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) }} +
+
+ + + +
+ + + + + + + + + + + + @for (row of popupRows; track row.id; let i = $index) { + + + + + + + + } @empty { + + + + } + + + + + + + +
#PartyPeriodRateAmount
{{ i + 1 }} + {{ row.partyCode }} + {{ row.partyName }} + {{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}{{ row.ratePercent ? row.ratePercent + '%' : '—' }}{{ money(row.amount) }}
No account matches the search.
{{ agentSearch ? 'Total of the accounts shown' : 'Total' }}{{ money(popupTotal) }}
+
+ } +
+ +
+ + + +
+
+ } +
+
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