feat(dds): batch run preview on its own page, runs popup, projected GLs in approvals
- Generate opens the run on /organization/dds-batch-processing/run/:id with a back arrow, Post and Discard beside the figures, agent-wise table, View popup and CSV export; the table scrolls inside the page. A posted, running or failed run opens there read-only - Batch screen lists generated-but-unposted runs as buttons to reopen them; Previous runs moved into a popup with status and job chips, search, date range, pagination, row detail and export; posting progress is followed from the run status - Shared helpers for job names, money and agent grouping - Approval review shows, for a calculation run, the entries its matching Posting run will book, marked "when posted" Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
+23
-4
@@ -515,14 +515,27 @@
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
@if (entriesProjected) {
|
||||
<div class="dri-detail-item dri-detail-item--full">
|
||||
<span class="dri-detail-label">Ledger</span>
|
||||
<span class="dri-detail-value"
|
||||
>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.</span
|
||||
>
|
||||
</div>
|
||||
}
|
||||
<div class="dri-detail-item">
|
||||
<span class="dri-detail-label">DR Accounts</span>
|
||||
<span class="dri-detail-label">{{
|
||||
entriesProjected ? 'DR Accounts (when posted)' : 'DR Accounts'
|
||||
}}</span>
|
||||
<span class="dri-detail-value"
|
||||
>{{ debitLines.length }} account{{ debitLines.length !== 1 ? 's' : '' }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="dri-detail-item">
|
||||
<span class="dri-detail-label">CR Accounts</span>
|
||||
<span class="dri-detail-label">{{
|
||||
entriesProjected ? 'CR Accounts (when posted)' : 'CR Accounts'
|
||||
}}</span>
|
||||
<span class="dri-detail-value"
|
||||
>{{ creditLines.length }} account{{ creditLines.length !== 1 ? 's' : '' }}</span
|
||||
>
|
||||
@@ -547,7 +560,10 @@
|
||||
<div class="dri-card">
|
||||
<div class="dri-section-title">
|
||||
<mat-icon>arrow_upward</mat-icon>
|
||||
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)
|
||||
</div>
|
||||
<table class="dri-cr-table">
|
||||
<thead>
|
||||
@@ -585,7 +601,10 @@
|
||||
<div class="dri-card">
|
||||
<div class="dri-section-title">
|
||||
<mat-icon>arrow_downward</mat-icon>
|
||||
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)
|
||||
</div>
|
||||
<table class="dri-cr-table">
|
||||
<thead>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,14 @@
|
||||
Calculate, check and post DDS interest and agent commission, and close matured accounts
|
||||
</p>
|
||||
</div>
|
||||
<span class="bp-header__spacer"></span>
|
||||
<button mat-stroked-button type="button" class="bp-runs-btn" (click)="openRuns()">
|
||||
<i class="fas fa-history"></i> Previous runs
|
||||
<span class="bp-runs-btn__count">{{ runStats.total }}</span>
|
||||
@if (runStats.failed) {
|
||||
<span class="bp-runs-btn__warn" matTooltip="{{ runStats.failed }} failed"></span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="batchForm">
|
||||
@@ -174,170 +182,48 @@
|
||||
<div class="bp-action-bar">
|
||||
<button mat-stroked-button type="button" (click)="exit()">Exit</button>
|
||||
<span class="bp-action-bar__spacer"></span>
|
||||
@if (generatedRun) {
|
||||
<button mat-stroked-button type="button" (click)="discard()"><i class="fas fa-times"></i> Discard</button>
|
||||
}
|
||||
<button mat-stroked-button type="button" [disabled]="batchForm.invalid || generating" (click)="generate()">
|
||||
<!-- Generate opens the run on a page of its own; Post and Discard are there, beside the figures. -->
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
type="button"
|
||||
[disabled]="batchForm.invalid || generating"
|
||||
(click)="generate()"
|
||||
>
|
||||
@if (generating) {
|
||||
<i class="fas fa-circle-notch fa-spin"></i> Generating…
|
||||
} @else {
|
||||
<i class="fas fa-calculator"></i> Generate
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
type="button"
|
||||
[disabled]="!generatedRun || previewRows.length === 0"
|
||||
(click)="post()"
|
||||
>
|
||||
<i class="fas fa-check"></i> Post
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 3. Preview ─────────────────────────────────────────────────────────────────────────── -->
|
||||
@if (generatedRun) {
|
||||
<div class="bp-card">
|
||||
<div class="bp-step-head">
|
||||
<span class="bp-step-no">3</span>
|
||||
<!--
|
||||
A run generated and left unposted is still there to go back to. Each is one button, so the page a
|
||||
clerk closed by mistake is a click away - not a Generate again.
|
||||
-->
|
||||
@if (pendingPreviews.length > 0) {
|
||||
<div class="bp-card bp-previews">
|
||||
<div class="bp-step-head bp-step-head--plain">
|
||||
<span class="bp-step-no bp-step-no--muted"><i class="fas fa-table"></i></span>
|
||||
<div>
|
||||
<h3>{{ labelOf(generatedRun.jobType) }} — preview</h3>
|
||||
<p>Run #{{ generatedRun.id }} · nothing applied yet — check the figures, then Post.</p>
|
||||
<h3>Generated, not posted yet</h3>
|
||||
<p>Open a run to check its figures, then Post or Discard it.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (previewRows.length === 0) {
|
||||
<p class="bp-empty bp-empty--box">
|
||||
<i class="fas fa-inbox"></i>
|
||||
Nothing to apply. Either this period has already been processed, or there was no activity in it.
|
||||
</p>
|
||||
} @else {
|
||||
<div class="bp-kpis">
|
||||
<div class="bp-kpi">
|
||||
<span>Agents</span><strong>{{ agentGroups.length }}</strong>
|
||||
</div>
|
||||
<div class="bp-kpi">
|
||||
<span>{{ isCommissionJob ? 'Entries' : 'Accounts' }}</span
|
||||
><strong>{{ previewRows.length }}</strong>
|
||||
</div>
|
||||
<div class="bp-kpi bp-kpi--accent">
|
||||
<span>{{ showsCommissionBreakdown ? 'Total payable' : 'Total' }}</span>
|
||||
<strong>₹ {{ money(previewTotal) }}</strong>
|
||||
</div>
|
||||
<div class="bp-kpi">
|
||||
<span>Date</span><strong>{{ generatedRun.jobDate | dateFormat }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bp-tools">
|
||||
<span class="bp-tools__hint">
|
||||
{{
|
||||
isCommissionJob
|
||||
? 'One line per agent - View shows how their commission was worked out.'
|
||||
: 'Grouped by collecting agent - View shows the DDS accounts under each.'
|
||||
}}
|
||||
</span>
|
||||
<span class="bp-tools__spacer"></span>
|
||||
<button mat-stroked-button type="button" (click)="exportPreview()">
|
||||
<i class="fas fa-file-export"></i> Export all details
|
||||
<div class="bp-previews__list">
|
||||
@for (run of pendingPreviews; track run.id) {
|
||||
<button type="button" class="bp-preview-btn" (click)="openRun(run.id)">
|
||||
<span class="bp-preview-btn__job">{{ labelOf(run.jobType) }}</span>
|
||||
<span class="bp-preview-btn__meta">
|
||||
Run #{{ run.id }} · {{ run.totalRows }} {{ run.totalRows === 1 ? 'entry' : 'entries' }} · ₹
|
||||
{{ money(run.totalAmount) }}
|
||||
</span>
|
||||
<span class="bp-preview-btn__go">Open <i class="fas fa-arrow-right"></i></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bp-table-scroll">
|
||||
@if (isCommissionJob) {
|
||||
<table class="bp-table bp-table--breakdown">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Agent</th>
|
||||
<th>Period</th>
|
||||
@if (showsCommissionBreakdown) {
|
||||
<th class="bp-num">Collection</th>
|
||||
<th class="bp-num">Rate</th>
|
||||
<th class="bp-num">Commission</th>
|
||||
<th class="bp-num">TDS</th>
|
||||
<th class="bp-num">Deductions</th>
|
||||
}
|
||||
<th class="bp-num">{{ showsCommissionBreakdown ? 'Payable' : 'Amount' }}</th>
|
||||
<th class="bp-view-col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (group of agentGroups; track group.key) {
|
||||
@for (row of group.rows; track row.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="bp-code">{{ row.agentCode }}</span>
|
||||
<span class="bp-sub">{{ row.agentName }}</span>
|
||||
</td>
|
||||
<td class="bp-period">{{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}</td>
|
||||
@if (showsCommissionBreakdown) {
|
||||
<td class="bp-num">{{ money(row.baseAmount) }}</td>
|
||||
<td class="bp-num">
|
||||
<span class="bp-rate">{{ row.ratePercent }}%</span>
|
||||
</td>
|
||||
<td class="bp-num">{{ money(row.grossAmount) }}</td>
|
||||
<td class="bp-num bp-minus">{{ money(row.tdsAmount) }}</td>
|
||||
<td class="bp-num bp-minus">{{ money(row.deductionAmount) }}</td>
|
||||
}
|
||||
<td class="bp-num bp-total">{{ money(row.amount) }}</td>
|
||||
<td class="bp-view-col">
|
||||
<button mat-stroked-button type="button" class="bp-view-btn" (click)="viewAgent(group)">
|
||||
<i class="fas fa-eye"></i> View
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td [attr.colspan]="showsCommissionBreakdown ? 7 : 2">Total</td>
|
||||
<td class="bp-num bp-total">{{ money(previewTotal) }}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
} @else {
|
||||
<table class="bp-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Agent</th>
|
||||
<th class="bp-num">Accounts</th>
|
||||
<th class="bp-num">Amount</th>
|
||||
<th class="bp-view-col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (group of agentGroups; track group.key) {
|
||||
<tr class="bp-agent-row">
|
||||
<td>
|
||||
<span class="bp-avatar">{{ group.initials }}</span>
|
||||
<span class="bp-code bp-code--inline">{{ group.agentCode }}</span>
|
||||
<span class="bp-sub bp-sub--inline">{{ group.agentName }}</span>
|
||||
</td>
|
||||
<td class="bp-num">{{ group.rows.length }}</td>
|
||||
<td class="bp-num bp-total">{{ money(group.total) }}</td>
|
||||
<td class="bp-view-col">
|
||||
<button mat-stroked-button type="button" class="bp-view-btn" (click)="viewAgent(group)">
|
||||
<i class="fas fa-eye"></i> View
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td>Total</td>
|
||||
<td class="bp-num">{{ previewRows.length }}</td>
|
||||
<td class="bp-num bp-total">{{ money(previewTotal) }}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</form>
|
||||
@@ -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) {
|
||||
<div class="bp-running">
|
||||
<i class="fas fa-circle-notch fa-spin"></i>
|
||||
<span>
|
||||
@@ -357,220 +243,227 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Without this a generated-but-unposted run vanishes the moment the page is left, and there is
|
||||
no way to tell whether a month's interest was ever actually posted. -->
|
||||
<div class="bp-card">
|
||||
<div class="bp-runs-head">
|
||||
<div class="bp-step-head bp-step-head--plain">
|
||||
<span class="bp-step-no bp-step-no--muted"><i class="fas fa-history"></i></span>
|
||||
<div>
|
||||
<!--
|
||||
Every run and whether it was applied - without this a generated-but-unposted run vanishes the moment
|
||||
the page is left, and there is no way to tell whether a month's interest was ever actually posted.
|
||||
In a popup so the page itself stays one screen: the list can run to hundreds of runs, filtered and
|
||||
paged here, with each run's full detail opening under its row.
|
||||
-->
|
||||
<ng-template #runsDialog>
|
||||
<div class="bpr">
|
||||
<div class="bpr-head">
|
||||
<span class="bpr-head__ic"><i class="fas fa-history"></i></span>
|
||||
<div class="bpr-head__text">
|
||||
<h3>Previous runs</h3>
|
||||
<p>Every run, and whether it was applied.</p>
|
||||
</div>
|
||||
<button type="button" class="bpr-icon-btn" (click)="loadRecentRuns()" matTooltip="Refresh" aria-label="Refresh">
|
||||
<i class="fas fa-sync-alt" [class.fa-spin]="runsLoading"></i>
|
||||
</button>
|
||||
<button type="button" class="bpr-icon-btn" mat-dialog-close aria-label="Close">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
@if (!runsLoading && recentRuns.length > 0) {
|
||||
<div class="bp-stats">
|
||||
<span class="bp-stat">{{ runStats.total }} runs</span>
|
||||
<span class="bp-stat bp-stat--posted">{{ runStats.posted }} posted</span>
|
||||
@if (runStats.pending) {
|
||||
<span class="bp-stat">{{ runStats.pending }} not posted</span>
|
||||
}
|
||||
@if (runStats.running) {
|
||||
<span class="bp-stat bp-stat--running">{{ runStats.running }} running</span>
|
||||
}
|
||||
@if (runStats.failed) {
|
||||
<span class="bp-stat bp-stat--failed">{{ runStats.failed }} failed</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (runsLoading) {
|
||||
<div class="bp-skeleton">
|
||||
@for (row of [1, 2, 3, 4]; track row) {
|
||||
<span class="bp-skeleton__bar"></span>
|
||||
}
|
||||
</div>
|
||||
} @else if (recentRuns.length === 0) {
|
||||
<p class="bp-empty bp-empty--box"><i class="fas fa-inbox"></i> No batch has been run yet.</p>
|
||||
} @else {
|
||||
<div class="bp-table-scroll">
|
||||
<table class="bp-table bp-table--runs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Job</th>
|
||||
<th>Date</th>
|
||||
<th class="bp-num">Entries</th>
|
||||
<th class="bp-num">Amount</th>
|
||||
<th>Status</th>
|
||||
<th>Posted On</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (run of recentRuns; track run.id) {
|
||||
<tr>
|
||||
<td class="bp-runid">#{{ run.id }}</td>
|
||||
<td>
|
||||
<span class="bp-jobname">{{ labelOf(run.jobType) }}</span>
|
||||
</td>
|
||||
<td>{{ run.jobDate | dateFormat }}</td>
|
||||
<td class="bp-num">{{ run.totalRows }}</td>
|
||||
<td class="bp-num">{{ money(run.totalAmount) }}</td>
|
||||
<!--
|
||||
Four states, not two. A run that is still crediting is not "not posted" — that
|
||||
reads as a run somebody forgot, and a clerk would run it again on top of the one
|
||||
already going. One that failed says so, with the reason the server recorded.
|
||||
-->
|
||||
<td>
|
||||
@switch (run.status) {
|
||||
@case ('POSTED') {
|
||||
<span class="bp-status bp-status--posted"><i class="fas fa-check-circle"></i> Posted</span>
|
||||
}
|
||||
@case ('POSTING') {
|
||||
<span class="bp-status bp-status--running">
|
||||
<i class="fas fa-circle-notch fa-spin"></i> Running
|
||||
</span>
|
||||
}
|
||||
@case ('FAILED') {
|
||||
<span
|
||||
class="bp-status bp-status--failed"
|
||||
[matTooltip]="run.failureReason || 'No reason recorded'"
|
||||
>
|
||||
<i class="fas fa-exclamation-circle"></i> Failed
|
||||
</span>
|
||||
}
|
||||
@default {
|
||||
<span class="bp-status"><i class="far fa-clock"></i> Not posted</span>
|
||||
}
|
||||
}
|
||||
</td>
|
||||
<td>{{ run.postedOnDate ? (run.postedOnDate | dateFormat) : '—' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- One agent's accounts (or commission breakdown) in a popup, opened from View. -->
|
||||
<ng-template #agentDialog>
|
||||
@if (selectedGroup; as group) {
|
||||
<div class="bpd">
|
||||
<div class="bpd-head">
|
||||
<span class="bpd-av">{{ group.initials }}</span>
|
||||
<div class="bpd-head__text">
|
||||
<h3>
|
||||
<span class="bp-code bp-code--inline">{{ group.agentCode }}</span> {{ group.agentName }}
|
||||
</h3>
|
||||
<p>
|
||||
{{ labelOf(generatedRun?.jobType) }} · Run #{{ generatedRun?.id }} ·
|
||||
{{ generatedRun?.jobDate | dateFormat }}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="bpd-close" mat-dialog-close aria-label="Close">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
<div class="bpr-body">
|
||||
<!-- A status chip says how many runs are in it and filters to them; the chips combine. -->
|
||||
<div class="bpr-statuses">
|
||||
@for (status of runStatuses; track status.code) {
|
||||
<button
|
||||
type="button"
|
||||
class="bpr-chip bpr-chip--{{ status.code.toLowerCase() }}"
|
||||
[class.bpr-chip--on]="runFilter.statuses.has(status.code)"
|
||||
(click)="toggleRunStatus(status.code)"
|
||||
>
|
||||
{{ status.label }} <b>{{ runCount(status.code) }}</b>
|
||||
</button>
|
||||
}
|
||||
<span class="bpr-total">{{ recentRuns.length }} runs in all</span>
|
||||
</div>
|
||||
|
||||
<div class="bpd-body">
|
||||
@if (isCommissionJob) {
|
||||
@for (row of group.rows; track row.id) {
|
||||
<div class="bpd-grid">
|
||||
@if (showsCommissionBreakdown) {
|
||||
<div class="bpd-cell">
|
||||
<span>Collection</span><strong>₹ {{ money(row.baseAmount) }}</strong>
|
||||
</div>
|
||||
<div class="bpd-cell">
|
||||
<span>Slab rate</span><strong>{{ row.ratePercent }}%</strong>
|
||||
</div>
|
||||
<div class="bpd-cell">
|
||||
<span>Commission</span><strong>₹ {{ money(row.grossAmount) }}</strong>
|
||||
</div>
|
||||
<div class="bpd-cell">
|
||||
<span>– TDS</span><strong class="bp-minus">₹ {{ money(row.tdsAmount) }}</strong>
|
||||
</div>
|
||||
<div class="bpd-cell">
|
||||
<span>– Deductions</span><strong class="bp-minus">₹ {{ money(row.deductionAmount) }}</strong>
|
||||
</div>
|
||||
}
|
||||
<div class="bpd-cell bpd-cell--net">
|
||||
<span>{{ showsCommissionBreakdown ? 'Payable to agent' : 'To be paid out' }}</span>
|
||||
<strong>₹ {{ money(row.amount) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p class="bpd-note">
|
||||
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.
|
||||
</p>
|
||||
}
|
||||
} @else {
|
||||
<div class="bpd-kpis">
|
||||
<div class="bpd-cell">
|
||||
<span>Accounts</span><strong>{{ group.rows.length }}</strong>
|
||||
</div>
|
||||
<div class="bpd-cell bpd-cell--net">
|
||||
<span>Total</span><strong>₹ {{ money(group.total) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bpr-filters">
|
||||
<label class="bpr-field bpr-field--search">
|
||||
<span>Search</span>
|
||||
<input
|
||||
type="search"
|
||||
class="bpd-search"
|
||||
placeholder="Search party code or name"
|
||||
[value]="agentSearch"
|
||||
(input)="agentSearch = $any($event.target).value"
|
||||
placeholder="Run number or job"
|
||||
[value]="runFilter.search"
|
||||
(input)="setRunSearch($any($event.target).value)"
|
||||
/>
|
||||
|
||||
<div class="bp-table-scroll bpd-scroll">
|
||||
<table class="bp-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Party</th>
|
||||
<th>Period</th>
|
||||
<th class="bp-num">Rate</th>
|
||||
<th class="bp-num">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of popupRows; track row.id; let i = $index) {
|
||||
<tr>
|
||||
<td class="bp-runid">{{ i + 1 }}</td>
|
||||
<td>
|
||||
<span class="bp-code bp-code--inline">{{ row.partyCode }}</span>
|
||||
<span class="bp-sub bp-sub--inline">{{ row.partyName }}</span>
|
||||
</td>
|
||||
<td class="bp-period">{{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}</td>
|
||||
<td class="bp-num">{{ row.ratePercent ? row.ratePercent + '%' : '—' }}</td>
|
||||
<td class="bp-num bp-total">{{ money(row.amount) }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="5" class="bp-empty">No account matches the search.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="4">{{ agentSearch ? 'Total of the accounts shown' : 'Total' }}</td>
|
||||
<td class="bp-num bp-total">{{ money(popupTotal) }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</label>
|
||||
<label class="bpr-field">
|
||||
<span>Job date from</span>
|
||||
<input type="date" [value]="runFilter.from" (change)="setRunDate('from', $any($event.target).value)" />
|
||||
</label>
|
||||
<label class="bpr-field">
|
||||
<span>to</span>
|
||||
<input type="date" [value]="runFilter.to" (change)="setRunDate('to', $any($event.target).value)" />
|
||||
</label>
|
||||
@if (hasRunFilters) {
|
||||
<button type="button" class="bpr-clear" (click)="clearRunFilters()">
|
||||
<i class="fas fa-times"></i> Clear filters
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="bpd-actions">
|
||||
<button mat-stroked-button type="button" mat-dialog-close>Close</button>
|
||||
<span class="bp-action-bar__spacer"></span>
|
||||
<button mat-flat-button color="primary" type="button" (click)="exportAgent(group)">
|
||||
<i class="fas fa-file-export"></i> Export this agent
|
||||
</button>
|
||||
<div class="bpr-jobs">
|
||||
<span class="bpr-jobs__label">Job</span>
|
||||
@for (job of runJobOptions; track job.code) {
|
||||
<button
|
||||
type="button"
|
||||
class="bpr-chip"
|
||||
[class.bpr-chip--on]="runFilter.jobs.has(job.code)"
|
||||
(click)="toggleRunJob(job.code)"
|
||||
>
|
||||
{{ job.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (runsLoading && recentRuns.length === 0) {
|
||||
<div class="bp-skeleton">
|
||||
@for (row of [1, 2, 3, 4, 5]; track row) {
|
||||
<span class="bp-skeleton__bar"></span>
|
||||
}
|
||||
</div>
|
||||
} @else if (recentRuns.length === 0) {
|
||||
<p class="bp-empty bp-empty--box"><i class="fas fa-inbox"></i> No batch has been run yet.</p>
|
||||
} @else if (filteredRuns.length === 0) {
|
||||
<p class="bp-empty bp-empty--box">
|
||||
<i class="fas fa-filter"></i> No run matches these filters.
|
||||
<button type="button" class="bpr-clear" (click)="clearRunFilters()">Clear filters</button>
|
||||
</p>
|
||||
} @else {
|
||||
<div class="bp-table-scroll bpr-scroll">
|
||||
<table class="bp-table bp-table--runs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="bpr-caret"></th>
|
||||
<th>#</th>
|
||||
<th>Job</th>
|
||||
<th>Job date</th>
|
||||
<th class="bp-num">Entries</th>
|
||||
<th class="bp-num">Amount</th>
|
||||
<th>Status</th>
|
||||
<th>Generated</th>
|
||||
<th>Posted on</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (run of pagedRuns; track run.id) {
|
||||
<tr
|
||||
class="bpr-row"
|
||||
[class.bpr-row--open]="expandedRunId === run.id"
|
||||
(click)="toggleRunDetail(run.id)"
|
||||
>
|
||||
<td class="bpr-caret">
|
||||
<i class="fas" [ngClass]="expandedRunId === run.id ? 'fa-chevron-down' : 'fa-chevron-right'"></i>
|
||||
</td>
|
||||
<td class="bp-runid">#{{ run.id }}</td>
|
||||
<td>
|
||||
<span class="bp-jobname">{{ labelOf(run.jobType) }}</span>
|
||||
</td>
|
||||
<td>{{ run.jobDate | dateFormat }}</td>
|
||||
<td class="bp-num">{{ run.totalRows }}</td>
|
||||
<td class="bp-num">{{ money(run.totalAmount) }}</td>
|
||||
<!--
|
||||
Four states, not two. A run that is still crediting is not "not posted" - that
|
||||
reads as a run somebody forgot, and a clerk would run it again on top of the one
|
||||
already going. One that failed says so, with the reason the server recorded.
|
||||
-->
|
||||
<td>
|
||||
@switch (run.status) {
|
||||
@case ('POSTED') {
|
||||
<span class="bp-status bp-status--posted"><i class="fas fa-check-circle"></i> Posted</span>
|
||||
}
|
||||
@case ('POSTING') {
|
||||
<span class="bp-status bp-status--running">
|
||||
<i class="fas fa-circle-notch fa-spin"></i> Running
|
||||
</span>
|
||||
}
|
||||
@case ('FAILED') {
|
||||
<span class="bp-status bp-status--failed">
|
||||
<i class="fas fa-exclamation-circle"></i> Failed
|
||||
</span>
|
||||
}
|
||||
@default {
|
||||
<span class="bp-status"><i class="far fa-clock"></i> Not posted</span>
|
||||
}
|
||||
}
|
||||
</td>
|
||||
<td>{{ run.generatedOnDate ? (run.generatedOnDate | dateFormat) : '—' }}</td>
|
||||
<td>{{ run.postedOnDate ? (run.postedOnDate | dateFormat) : '—' }}</td>
|
||||
</tr>
|
||||
@if (expandedRunId === run.id) {
|
||||
<tr class="bpr-detail">
|
||||
<td colspan="9">
|
||||
<div class="bpr-detail__grid">
|
||||
<div>
|
||||
<span>Branch</span><strong>{{ run.branchScope || '—' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Scheme</span><strong>{{ run.subType || 'All schemes' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Agents</span><strong>{{ run.agentFrom }} to {{ run.agentTo }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Parties</span><strong>{{ run.partyFrom }} to {{ run.partyTo }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Entries posted</span><strong>{{ run.rowsPosted ?? 0 }} of {{ run.totalRows }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Amount</span><strong>₹ {{ money(run.totalAmount) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bpr-detail__open">
|
||||
<button mat-stroked-button type="button" (click)="openRun(run.id)">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
{{ run.status === 'CALCULATED' ? 'Open preview' : 'Open run' }}
|
||||
</button>
|
||||
</div>
|
||||
@if (run.status === 'FAILED') {
|
||||
<p class="bpr-detail__fail">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
{{ run.failureReason || 'No reason recorded' }}
|
||||
</p>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="5">
|
||||
{{ hasRunFilters ? 'Total of the ' + filteredRuns.length + ' runs shown' : 'Total' }}
|
||||
</td>
|
||||
<td class="bp-num">{{ money(filteredRunsTotal) }}</td>
|
||||
<td colspan="3"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<mat-paginator
|
||||
[length]="filteredRuns.length"
|
||||
[pageSize]="runsPageSize"
|
||||
[pageIndex]="runsPageIndex"
|
||||
[pageSizeOptions]="[10, 25, 50]"
|
||||
showFirstLastButtons
|
||||
(page)="onRunsPage($event)"
|
||||
></mat-paginator>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="bpr-actions">
|
||||
<button mat-stroked-button type="button" mat-dialog-close>Close</button>
|
||||
<span class="bp-action-bar__spacer"></span>
|
||||
<button mat-stroked-button type="button" [disabled]="filteredRuns.length === 0" (click)="exportRuns()">
|
||||
<i class="fas fa-file-export"></i> Export {{ hasRunFilters ? 'filtered' : 'all' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<unknown>;
|
||||
// ── Previous runs, in a popup ─────────────────────────────────────────────────────────────────────
|
||||
@ViewChild('runsDialog') runsDialog: TemplateRef<unknown>;
|
||||
|
||||
/** 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<string>(),
|
||||
statuses: new Set<string>(),
|
||||
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<string, AgentGroup>();
|
||||
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<string>, 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<string>(), statuses: new Set<string>(), 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<string>();
|
||||
|
||||
/** 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<string, string> = {
|
||||
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<typeof setTimeout> | null = null;
|
||||
private runningIds = new Set<number>();
|
||||
|
||||
/** 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<number>(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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<string, AgentGroup>();
|
||||
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());
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<div class="bp-container bprv">
|
||||
<div class="bp-header">
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="bprv-back"
|
||||
(click)="back()"
|
||||
matTooltip="Back to DDS Interest & Commission"
|
||||
aria-label="Back"
|
||||
>
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
</button>
|
||||
<div class="bp-header__ic"><i class="fas fa-table"></i></div>
|
||||
<div>
|
||||
<h2 class="bp-header__title">
|
||||
{{ jobTypeLabel || 'Batch run' }} — {{ canAct ? 'preview' : 'run' }}
|
||||
@if (generatedRun) {
|
||||
<span
|
||||
class="bp-status"
|
||||
[class.bp-status--posted]="generatedRun.status === 'POSTED'"
|
||||
[class.bp-status--failed]="generatedRun.status === 'FAILED'"
|
||||
[class.bp-status--running]="generatedRun.status === 'POSTING'"
|
||||
>{{ statusText }}</span
|
||||
>
|
||||
}
|
||||
</h2>
|
||||
<p class="bp-header__sub">
|
||||
@if (generatedRun) {
|
||||
Run #{{ generatedRun.id }} ·
|
||||
{{
|
||||
canAct
|
||||
? 'nothing applied yet — check the figures, then Post.'
|
||||
: 'read only — this run is ' + statusText.toLowerCase() + '.'
|
||||
}}
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<span class="bp-header__spacer"></span>
|
||||
@if (canAct) {
|
||||
<button mat-stroked-button type="button" [disabled]="working" (click)="discard()">
|
||||
<i class="fas fa-times"></i> Discard
|
||||
</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
type="button"
|
||||
[disabled]="working || previewRows.length === 0"
|
||||
(click)="post()"
|
||||
>
|
||||
<i class="fas fa-check"></i> Post
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<div class="bp-card">
|
||||
<div class="bp-skeleton">
|
||||
@for (row of [1, 2, 3, 4, 5]; track row) {
|
||||
<span class="bp-skeleton__bar"></span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else if (notFound) {
|
||||
<div class="bp-card">
|
||||
<p class="bp-empty bp-empty--box">
|
||||
<i class="fas fa-inbox"></i> This run no longer exists - a new Generate or Discard may have replaced it.
|
||||
<button mat-stroked-button type="button" (click)="back()">Back</button>
|
||||
</p>
|
||||
</div>
|
||||
} @else {
|
||||
@if (generatedRun?.status === 'FAILED') {
|
||||
<p class="bpr-detail__fail bprv-banner">
|
||||
<i class="fas fa-exclamation-circle"></i> {{ generatedRun.failureReason || 'No reason recorded' }}
|
||||
</p>
|
||||
}
|
||||
<div class="bp-card bprv-card">
|
||||
@if (previewRows.length === 0) {
|
||||
<p class="bp-empty bp-empty--box">
|
||||
<i class="fas fa-inbox"></i>
|
||||
Nothing to apply. Either this period has already been processed, or there was no activity in it.
|
||||
</p>
|
||||
} @else {
|
||||
<div class="bp-kpis">
|
||||
<div class="bp-kpi">
|
||||
<span>Agents</span><strong>{{ agentGroups.length }}</strong>
|
||||
</div>
|
||||
<div class="bp-kpi">
|
||||
<span>{{ isCommissionJob ? 'Entries' : 'Accounts' }}</span
|
||||
><strong>{{ previewRows.length }}</strong>
|
||||
</div>
|
||||
<div class="bp-kpi bp-kpi--accent">
|
||||
<span>{{ showsCommissionBreakdown ? 'Total payable' : 'Total' }}</span>
|
||||
<strong>₹ {{ money(previewTotal) }}</strong>
|
||||
</div>
|
||||
<div class="bp-kpi">
|
||||
<span>Date</span><strong>{{ generatedRun.jobDate | dateFormat }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bp-tools">
|
||||
<span class="bp-tools__hint">
|
||||
{{
|
||||
isCommissionJob
|
||||
? 'One line per agent - View shows how their commission was worked out.'
|
||||
: 'Grouped by collecting agent - View shows the DDS accounts under each.'
|
||||
}}
|
||||
</span>
|
||||
<span class="bp-tools__spacer"></span>
|
||||
<button mat-stroked-button type="button" (click)="exportPreview()">
|
||||
<i class="fas fa-file-export"></i> Export all details
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bp-table-scroll">
|
||||
@if (isCommissionJob) {
|
||||
<table class="bp-table bp-table--breakdown">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Agent</th>
|
||||
<th>Period</th>
|
||||
@if (showsCommissionBreakdown) {
|
||||
<th class="bp-num">Collection</th>
|
||||
<th class="bp-num">Rate</th>
|
||||
<th class="bp-num">Commission</th>
|
||||
<th class="bp-num">TDS</th>
|
||||
<th class="bp-num">Deductions</th>
|
||||
}
|
||||
<th class="bp-num">{{ showsCommissionBreakdown ? 'Payable' : 'Amount' }}</th>
|
||||
<th class="bp-view-col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (group of agentGroups; track group.key) {
|
||||
@for (row of group.rows; track row.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="bp-code">{{ row.agentCode }}</span>
|
||||
<span class="bp-sub">{{ row.agentName }}</span>
|
||||
</td>
|
||||
<td class="bp-period">{{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}</td>
|
||||
@if (showsCommissionBreakdown) {
|
||||
<td class="bp-num">{{ money(row.baseAmount) }}</td>
|
||||
<td class="bp-num">
|
||||
<span class="bp-rate">{{ row.ratePercent }}%</span>
|
||||
</td>
|
||||
<td class="bp-num">{{ money(row.grossAmount) }}</td>
|
||||
<td class="bp-num bp-minus">{{ money(row.tdsAmount) }}</td>
|
||||
<td class="bp-num bp-minus">{{ money(row.deductionAmount) }}</td>
|
||||
}
|
||||
<td class="bp-num bp-total">{{ money(row.amount) }}</td>
|
||||
<td class="bp-view-col">
|
||||
<button mat-stroked-button type="button" class="bp-view-btn" (click)="viewAgent(group)">
|
||||
<i class="fas fa-eye"></i> View
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td [attr.colspan]="showsCommissionBreakdown ? 7 : 2">Total</td>
|
||||
<td class="bp-num bp-total">{{ money(previewTotal) }}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
} @else {
|
||||
<table class="bp-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Agent</th>
|
||||
<th class="bp-num">Accounts</th>
|
||||
<th class="bp-num">Amount</th>
|
||||
<th class="bp-view-col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (group of agentGroups; track group.key) {
|
||||
<tr class="bp-agent-row">
|
||||
<td>
|
||||
<span class="bp-avatar">{{ group.initials }}</span>
|
||||
<span class="bp-code bp-code--inline">{{ group.agentCode }}</span>
|
||||
<span class="bp-sub bp-sub--inline">{{ group.agentName }}</span>
|
||||
</td>
|
||||
<td class="bp-num">{{ group.rows.length }}</td>
|
||||
<td class="bp-num bp-total">{{ money(group.total) }}</td>
|
||||
<td class="bp-view-col">
|
||||
<button mat-stroked-button type="button" class="bp-view-btn" (click)="viewAgent(group)">
|
||||
<i class="fas fa-eye"></i> View
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td>Total</td>
|
||||
<td class="bp-num">{{ previewRows.length }}</td>
|
||||
<td class="bp-num bp-total">{{ money(previewTotal) }}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- One agent's accounts (or commission breakdown) in a popup, opened from View. -->
|
||||
<ng-template #agentDialog>
|
||||
@if (selectedGroup; as group) {
|
||||
<div class="bpd">
|
||||
<div class="bpd-head">
|
||||
<span class="bpd-av">{{ group.initials }}</span>
|
||||
<div class="bpd-head__text">
|
||||
<h3>
|
||||
<span class="bp-code bp-code--inline">{{ group.agentCode }}</span> {{ group.agentName }}
|
||||
</h3>
|
||||
<p>
|
||||
{{ labelOf(generatedRun?.jobType) }} · Run #{{ generatedRun?.id }} ·
|
||||
{{ generatedRun?.jobDate | dateFormat }}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="bpd-close" mat-dialog-close aria-label="Close">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bpd-body">
|
||||
@if (isCommissionJob) {
|
||||
@for (row of group.rows; track row.id) {
|
||||
<div class="bpd-grid">
|
||||
@if (showsCommissionBreakdown) {
|
||||
<div class="bpd-cell">
|
||||
<span>Collection</span><strong>₹ {{ money(row.baseAmount) }}</strong>
|
||||
</div>
|
||||
<div class="bpd-cell">
|
||||
<span>Slab rate</span><strong>{{ row.ratePercent }}%</strong>
|
||||
</div>
|
||||
<div class="bpd-cell">
|
||||
<span>Commission</span><strong>₹ {{ money(row.grossAmount) }}</strong>
|
||||
</div>
|
||||
<div class="bpd-cell">
|
||||
<span>– TDS</span><strong class="bp-minus">₹ {{ money(row.tdsAmount) }}</strong>
|
||||
</div>
|
||||
<div class="bpd-cell">
|
||||
<span>– Deductions</span><strong class="bp-minus">₹ {{ money(row.deductionAmount) }}</strong>
|
||||
</div>
|
||||
}
|
||||
<div class="bpd-cell bpd-cell--net">
|
||||
<span>{{ showsCommissionBreakdown ? 'Payable to agent' : 'To be paid out' }}</span>
|
||||
<strong>₹ {{ money(row.amount) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p class="bpd-note">
|
||||
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.
|
||||
</p>
|
||||
}
|
||||
} @else {
|
||||
<div class="bpd-kpis">
|
||||
<div class="bpd-cell">
|
||||
<span>Accounts</span><strong>{{ group.rows.length }}</strong>
|
||||
</div>
|
||||
<div class="bpd-cell bpd-cell--net">
|
||||
<span>Total</span><strong>₹ {{ money(group.total) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="search"
|
||||
class="bpd-search"
|
||||
placeholder="Search party code or name"
|
||||
[value]="agentSearch"
|
||||
(input)="agentSearch = $any($event.target).value"
|
||||
/>
|
||||
|
||||
<div class="bp-table-scroll bpd-scroll">
|
||||
<table class="bp-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Party</th>
|
||||
<th>Period</th>
|
||||
<th class="bp-num">Rate</th>
|
||||
<th class="bp-num">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of popupRows; track row.id; let i = $index) {
|
||||
<tr>
|
||||
<td class="bp-runid">{{ i + 1 }}</td>
|
||||
<td>
|
||||
<span class="bp-code bp-code--inline">{{ row.partyCode }}</span>
|
||||
<span class="bp-sub bp-sub--inline">{{ row.partyName }}</span>
|
||||
</td>
|
||||
<td class="bp-period">{{ row.fromDate | dateFormat }} – {{ row.toDate | dateFormat }}</td>
|
||||
<td class="bp-num">{{ row.ratePercent ? row.ratePercent + '%' : '—' }}</td>
|
||||
<td class="bp-num bp-total">{{ money(row.amount) }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="5" class="bp-empty">No account matches the search.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="4">{{ agentSearch ? 'Total of the accounts shown' : 'Total' }}</td>
|
||||
<td class="bp-num bp-total">{{ money(popupTotal) }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="bpd-actions">
|
||||
<button mat-stroked-button type="button" mat-dialog-close>Close</button>
|
||||
<span class="bp-action-bar__spacer"></span>
|
||||
<button mat-flat-button color="primary" type="button" (click)="exportAgent(group)">
|
||||
<i class="fas fa-file-export"></i> Export this agent
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
</div>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<unknown>;
|
||||
|
||||
/** The run on show, and its lines. Named as on the batch screen so the agent popup reads the same. */
|
||||
generatedRun: any = null;
|
||||
previewRows: any[] = [];
|
||||
loading = true;
|
||||
notFound = false;
|
||||
working = false;
|
||||
|
||||
selectedGroup: AgentGroup | null = null;
|
||||
agentSearch = '';
|
||||
|
||||
private groupsFor: any[] | null = null;
|
||||
private groups: AgentGroup[] = [];
|
||||
|
||||
ngOnInit(): void {
|
||||
const runId = this.route.snapshot.paramMap.get('runId');
|
||||
this.organizationService.getDdsBatchRun(runId).subscribe({
|
||||
next: (run: any) => {
|
||||
this.generatedRun = run;
|
||||
this.previewRows = run?.rows || [];
|
||||
this.loading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.loading = false;
|
||||
this.notFound = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get jobType(): string {
|
||||
return this.generatedRun?.jobType ?? '';
|
||||
}
|
||||
|
||||
get jobTypeLabel(): string {
|
||||
return jobLabel(this.jobType);
|
||||
}
|
||||
|
||||
get isCommissionJob(): boolean {
|
||||
return isCommissionJobType(this.jobType);
|
||||
}
|
||||
|
||||
get isPostingJob(): boolean {
|
||||
return isPostingJobType(this.jobType);
|
||||
}
|
||||
|
||||
/** Only a commission calculation has a breakdown worth showing: TDS and deductions come off its figure. */
|
||||
get showsCommissionBreakdown(): boolean {
|
||||
return this.jobType === 'COMMISSION_CALCULATION';
|
||||
}
|
||||
|
||||
/** Only a run that is still a preview can be posted or thrown away. */
|
||||
get canAct(): boolean {
|
||||
return this.generatedRun?.status === 'CALCULATED';
|
||||
}
|
||||
|
||||
get statusText(): string {
|
||||
switch (this.generatedRun?.status) {
|
||||
case 'POSTED':
|
||||
return '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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user