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>
|
</span>
|
||||||
</div>
|
</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">
|
<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"
|
<span class="dri-detail-value"
|
||||||
>{{ debitLines.length }} account{{ debitLines.length !== 1 ? 's' : '' }}</span
|
>{{ debitLines.length }} account{{ debitLines.length !== 1 ? 's' : '' }}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="dri-detail-item">
|
<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"
|
<span class="dri-detail-value"
|
||||||
>{{ creditLines.length }} account{{ creditLines.length !== 1 ? 's' : '' }}</span
|
>{{ creditLines.length }} account{{ creditLines.length !== 1 ? 's' : '' }}</span
|
||||||
>
|
>
|
||||||
@@ -547,7 +560,10 @@
|
|||||||
<div class="dri-card">
|
<div class="dri-card">
|
||||||
<div class="dri-section-title">
|
<div class="dri-section-title">
|
||||||
<mat-icon>arrow_upward</mat-icon>
|
<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>
|
</div>
|
||||||
<table class="dri-cr-table">
|
<table class="dri-cr-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -585,7 +601,10 @@
|
|||||||
<div class="dri-card">
|
<div class="dri-card">
|
||||||
<div class="dri-section-title">
|
<div class="dri-section-title">
|
||||||
<mat-icon>arrow_downward</mat-icon>
|
<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>
|
</div>
|
||||||
<table class="dri-cr-table">
|
<table class="dri-cr-table">
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
@@ -189,6 +189,15 @@ export class DdsApprovalReviewComponent implements OnInit {
|
|||||||
private groups: { name: string; icon: string; changed: number; fields: any[] }[] = [];
|
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. */
|
/** 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 {
|
get movesMoney(): boolean {
|
||||||
return (this.entry?.amount !== null && this.entry?.amount !== undefined) || !!this.detail?.lines?.length;
|
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
|
Calculate, check and post DDS interest and agent commission, and close matured accounts
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<form [formGroup]="batchForm">
|
<form [formGroup]="batchForm">
|
||||||
@@ -174,170 +182,48 @@
|
|||||||
<div class="bp-action-bar">
|
<div class="bp-action-bar">
|
||||||
<button mat-stroked-button type="button" (click)="exit()">Exit</button>
|
<button mat-stroked-button type="button" (click)="exit()">Exit</button>
|
||||||
<span class="bp-action-bar__spacer"></span>
|
<span class="bp-action-bar__spacer"></span>
|
||||||
@if (generatedRun) {
|
<!-- Generate opens the run on a page of its own; Post and Discard are there, beside the figures. -->
|
||||||
<button mat-stroked-button type="button" (click)="discard()"><i class="fas fa-times"></i> Discard</button>
|
<button
|
||||||
}
|
mat-flat-button
|
||||||
<button mat-stroked-button type="button" [disabled]="batchForm.invalid || generating" (click)="generate()">
|
color="primary"
|
||||||
|
type="button"
|
||||||
|
[disabled]="batchForm.invalid || generating"
|
||||||
|
(click)="generate()"
|
||||||
|
>
|
||||||
@if (generating) {
|
@if (generating) {
|
||||||
<i class="fas fa-circle-notch fa-spin"></i> Generating…
|
<i class="fas fa-circle-notch fa-spin"></i> Generating…
|
||||||
} @else {
|
} @else {
|
||||||
<i class="fas fa-calculator"></i> Generate
|
<i class="fas fa-calculator"></i> Generate
|
||||||
}
|
}
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── 3. Preview ─────────────────────────────────────────────────────────────────────────── -->
|
<!--
|
||||||
@if (generatedRun) {
|
A run generated and left unposted is still there to go back to. Each is one button, so the page a
|
||||||
<div class="bp-card">
|
clerk closed by mistake is a click away - not a Generate again.
|
||||||
<div class="bp-step-head">
|
-->
|
||||||
<span class="bp-step-no">3</span>
|
@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>
|
<div>
|
||||||
<h3>{{ labelOf(generatedRun.jobType) }} — preview</h3>
|
<h3>Generated, not posted yet</h3>
|
||||||
<p>Run #{{ generatedRun.id }} · nothing applied yet — check the figures, then Post.</p>
|
<p>Open a run to check its figures, then Post or Discard it.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="bp-previews__list">
|
||||||
@if (previewRows.length === 0) {
|
@for (run of pendingPreviews; track run.id) {
|
||||||
<p class="bp-empty bp-empty--box">
|
<button type="button" class="bp-preview-btn" (click)="openRun(run.id)">
|
||||||
<i class="fas fa-inbox"></i>
|
<span class="bp-preview-btn__job">{{ labelOf(run.jobType) }}</span>
|
||||||
Nothing to apply. Either this period has already been processed, or there was no activity in it.
|
<span class="bp-preview-btn__meta">
|
||||||
</p>
|
Run #{{ run.id }} · {{ run.totalRows }} {{ run.totalRows === 1 ? 'entry' : 'entries' }} · ₹
|
||||||
} @else {
|
{{ money(run.totalAmount) }}
|
||||||
<div class="bp-kpis">
|
</span>
|
||||||
<div class="bp-kpi">
|
<span class="bp-preview-btn__go">Open <i class="fas fa-arrow-right"></i></span>
|
||||||
<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>
|
</button>
|
||||||
</div>
|
}
|
||||||
|
</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>
|
</form>
|
||||||
@@ -347,7 +233,7 @@
|
|||||||
it in the background and answers at once. Without this the screen would report a run as posted
|
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.
|
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">
|
<div class="bp-running">
|
||||||
<i class="fas fa-circle-notch fa-spin"></i>
|
<i class="fas fa-circle-notch fa-spin"></i>
|
||||||
<span>
|
<span>
|
||||||
@@ -357,220 +243,227 @@
|
|||||||
</div>
|
</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. -->
|
Every run and whether it was applied - without this a generated-but-unposted run vanishes the moment
|
||||||
<div class="bp-card">
|
the page is left, and there is no way to tell whether a month's interest was ever actually posted.
|
||||||
<div class="bp-runs-head">
|
In a popup so the page itself stays one screen: the list can run to hundreds of runs, filtered and
|
||||||
<div class="bp-step-head bp-step-head--plain">
|
paged here, with each run's full detail opening under its row.
|
||||||
<span class="bp-step-no bp-step-no--muted"><i class="fas fa-history"></i></span>
|
-->
|
||||||
<div>
|
<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>
|
<h3>Previous runs</h3>
|
||||||
<p>Every run, and whether it was applied.</p>
|
<p>Every run, and whether it was applied.</p>
|
||||||
</div>
|
</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>
|
</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="bpr-body">
|
||||||
<div class="bp-skeleton">
|
<!-- A status chip says how many runs are in it and filters to them; the chips combine. -->
|
||||||
@for (row of [1, 2, 3, 4]; track row) {
|
<div class="bpr-statuses">
|
||||||
<span class="bp-skeleton__bar"></span>
|
@for (status of runStatuses; track status.code) {
|
||||||
}
|
<button
|
||||||
</div>
|
type="button"
|
||||||
} @else if (recentRuns.length === 0) {
|
class="bpr-chip bpr-chip--{{ status.code.toLowerCase() }}"
|
||||||
<p class="bp-empty bp-empty--box"><i class="fas fa-inbox"></i> No batch has been run yet.</p>
|
[class.bpr-chip--on]="runFilter.statuses.has(status.code)"
|
||||||
} @else {
|
(click)="toggleRunStatus(status.code)"
|
||||||
<div class="bp-table-scroll">
|
>
|
||||||
<table class="bp-table bp-table--runs">
|
{{ status.label }} <b>{{ runCount(status.code) }}</b>
|
||||||
<thead>
|
</button>
|
||||||
<tr>
|
}
|
||||||
<th>#</th>
|
<span class="bpr-total">{{ recentRuns.length }} runs in all</span>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<div class="bpd-body">
|
<div class="bpr-filters">
|
||||||
@if (isCommissionJob) {
|
<label class="bpr-field bpr-field--search">
|
||||||
@for (row of group.rows; track row.id) {
|
<span>Search</span>
|
||||||
<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
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
class="bpd-search"
|
placeholder="Run number or job"
|
||||||
placeholder="Search party code or name"
|
[value]="runFilter.search"
|
||||||
[value]="agentSearch"
|
(input)="setRunSearch($any($event.target).value)"
|
||||||
(input)="agentSearch = $any($event.target).value"
|
|
||||||
/>
|
/>
|
||||||
|
</label>
|
||||||
<div class="bp-table-scroll bpd-scroll">
|
<label class="bpr-field">
|
||||||
<table class="bp-table">
|
<span>Job date from</span>
|
||||||
<thead>
|
<input type="date" [value]="runFilter.from" (change)="setRunDate('from', $any($event.target).value)" />
|
||||||
<tr>
|
</label>
|
||||||
<th>#</th>
|
<label class="bpr-field">
|
||||||
<th>Party</th>
|
<span>to</span>
|
||||||
<th>Period</th>
|
<input type="date" [value]="runFilter.to" (change)="setRunDate('to', $any($event.target).value)" />
|
||||||
<th class="bp-num">Rate</th>
|
</label>
|
||||||
<th class="bp-num">Amount</th>
|
@if (hasRunFilters) {
|
||||||
</tr>
|
<button type="button" class="bpr-clear" (click)="clearRunFilters()">
|
||||||
</thead>
|
<i class="fas fa-times"></i> Clear filters
|
||||||
<tbody>
|
</button>
|
||||||
@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>
|
||||||
|
|
||||||
<div class="bpd-actions">
|
<div class="bpr-jobs">
|
||||||
<button mat-stroked-button type="button" mat-dialog-close>Close</button>
|
<span class="bpr-jobs__label">Job</span>
|
||||||
<span class="bp-action-bar__spacer"></span>
|
@for (job of runJobOptions; track job.code) {
|
||||||
<button mat-flat-button color="primary" type="button" (click)="exportAgent(group)">
|
<button
|
||||||
<i class="fas fa-file-export"></i> Export this agent
|
type="button"
|
||||||
</button>
|
class="bpr-chip"
|
||||||
|
[class.bpr-chip--on]="runFilter.jobs.has(job.code)"
|
||||||
|
(click)="toggleRunJob(job.code)"
|
||||||
|
>
|
||||||
|
{{ job.label }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</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>
|
||||||
}
|
|
||||||
|
<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>
|
</ng-template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ $red: #b91c1c;
|
|||||||
gap: 14px;
|
gap: 14px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
&__spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
&__ic {
|
&__ic {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
@@ -360,7 +364,7 @@ $red: #b91c1c;
|
|||||||
// ── 3. Preview ────────────────────────────────────────────────────────────────────────────────────────
|
// ── 3. Preview ────────────────────────────────────────────────────────────────────────────────────────
|
||||||
.bp-kpis {
|
.bp-kpis {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-bottom: 12px;
|
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 */
|
/** 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 { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { MatDialog, MatDialogClose } from '@angular/material/dialog';
|
import { MatDialog, MatDialogClose } from '@angular/material/dialog';
|
||||||
|
import { MatPaginator, PageEvent } from '@angular/material/paginator';
|
||||||
import { MatTooltip } from '@angular/material/tooltip';
|
import { MatTooltip } from '@angular/material/tooltip';
|
||||||
import { DateAdapter, MAT_DATE_FORMATS } from '@angular/material/core';
|
import { DateAdapter, MAT_DATE_FORMATS } from '@angular/material/core';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
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';
|
import { DdsDateAdapter, DDS_DATE_FORMATS } from './dds-date-adapter';
|
||||||
|
|
||||||
/** Custom Services */
|
/** Custom Services */
|
||||||
@@ -22,17 +22,7 @@ import { OrganizationService } from 'app/organization/organization.service';
|
|||||||
import { AlertService } from 'app/core/alert/alert.service';
|
import { AlertService } from 'app/core/alert/alert.service';
|
||||||
import { SettingsService } from 'app/settings/settings.service';
|
import { SettingsService } from 'app/settings/settings.service';
|
||||||
import { Dates } from 'app/core/utils/dates';
|
import { Dates } from 'app/core/utils/dates';
|
||||||
import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-approvals/dds-approval-notice';
|
import { csvAmount, dateText, formatMoney, jobLabel } from './dds-batch.shared';
|
||||||
|
|
||||||
/** 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DDS Interest & Commission batch processing — the legacy Interest Part menu.
|
* DDS Interest & Commission batch processing — the legacy Interest Part menu.
|
||||||
@@ -53,7 +43,8 @@ export interface AgentGroup {
|
|||||||
...STANDALONE_SHARED_IMPORTS,
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
// The reason a run failed is too long to sit in the column, and too useful to leave out.
|
// The reason a run failed is too long to sit in the column, and too useful to leave out.
|
||||||
MatTooltip,
|
MatTooltip,
|
||||||
MatDialogClose
|
MatDialogClose,
|
||||||
|
MatPaginator
|
||||||
],
|
],
|
||||||
// Day-first dates, as the legacy screen showed them. Declared here rather than application-wide:
|
// 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.
|
// 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 }
|
{ provide: MAT_DATE_FORMATS, useValue: DDS_DATE_FORMATS }
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class DdsBatchProcessingComponent implements OnInit {
|
export class DdsBatchProcessingComponent implements OnInit, OnDestroy {
|
||||||
private formBuilder = inject(UntypedFormBuilder);
|
private formBuilder = inject(UntypedFormBuilder);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private organizationService = inject(OrganizationService);
|
private organizationService = inject(OrganizationService);
|
||||||
@@ -137,243 +128,192 @@ export class DdsBatchProcessingComponent implements OnInit {
|
|||||||
return code.endsWith('_POSTING') ? 'Post' : 'Run';
|
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. */
|
/** The statuses a run can be in, as the screen names them, in the order a run moves through them. */
|
||||||
selectedGroup: AgentGroup | null = null;
|
readonly runStatuses: Array<{ code: string; label: string }> = [
|
||||||
agentSearch = '';
|
{ code: 'CALCULATED', label: 'Not posted' },
|
||||||
|
{ code: 'POSTING', label: 'Running' },
|
||||||
|
{ code: 'POSTED', label: 'Posted' },
|
||||||
|
{ code: 'FAILED', label: 'Failed' }
|
||||||
|
];
|
||||||
|
|
||||||
private groupsFor: any[] | null = null;
|
/** Every filter at once: what is typed, the jobs and statuses picked (any number), and a job date range. */
|
||||||
private groups: AgentGroup[] = [];
|
runFilter = {
|
||||||
|
search: '',
|
||||||
|
jobs: new Set<string>(),
|
||||||
|
statuses: new Set<string>(),
|
||||||
|
from: '',
|
||||||
|
to: ''
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
filteredRuns: any[] = [];
|
||||||
* The preview as agents with their accounts under them. A run can list a thousand accounts; read
|
runsPageIndex = 0;
|
||||||
* agent by agent it is a handful of lines, and each agent's accounts open in a popup on View.
|
runsPageSize = 10;
|
||||||
* Built once per Generate, not on every change detection pass.
|
/** The run whose full detail is open under its row. */
|
||||||
*/
|
expandedRunId: number | null = null;
|
||||||
get agentGroups(): AgentGroup[] {
|
|
||||||
if (this.groupsFor !== this.previewRows) {
|
/** The jobs the list actually holds - so a retired job (OD interest) still filters, and an unused one does not clutter. */
|
||||||
const byAgent = new Map<string, AgentGroup>();
|
get runJobOptions(): Array<{ code: string; label: string }> {
|
||||||
for (const row of this.previewRows) {
|
const codes = Array.from(new Set(this.recentRuns.map((run) => run.jobType)));
|
||||||
const key = String(row.agentId ?? row.agentCode ?? '');
|
return codes.map((code) => ({ code, label: this.labelOf(code) }));
|
||||||
let group = byAgent.get(key);
|
}
|
||||||
if (!group) {
|
|
||||||
const name = String(row.agentName ?? '');
|
get hasRunFilters(): boolean {
|
||||||
group = {
|
const f = this.runFilter;
|
||||||
key,
|
return !!(f.search.trim() || f.jobs.size || f.statuses.size || f.from || f.to);
|
||||||
agentCode: row.agentCode ?? '',
|
}
|
||||||
agentName: name,
|
|
||||||
initials: name
|
get pagedRuns(): any[] {
|
||||||
.split(/\s+/)
|
const start = this.runsPageIndex * this.runsPageSize;
|
||||||
.filter(Boolean)
|
return this.filteredRuns.slice(start, start + this.runsPageSize);
|
||||||
.slice(0, 2)
|
}
|
||||||
.map((word) => word[0].toUpperCase())
|
|
||||||
.join(''),
|
/** What the filtered list adds up to, so the footer says it without anyone counting rows. */
|
||||||
rows: [],
|
get filteredRunsTotal(): number {
|
||||||
total: 0
|
return this.filteredRuns.reduce((total, run) => total + Number(run.totalAmount || 0), 0);
|
||||||
};
|
}
|
||||||
byAgent.set(key, group);
|
|
||||||
}
|
statusLabel(code: string): string {
|
||||||
group.rows.push(row);
|
return this.runStatuses.find((status) => status.code === code)?.label ?? code;
|
||||||
group.total += Number(row.amount || 0);
|
}
|
||||||
|
|
||||||
|
/** 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;
|
if (f.jobs.size && !f.jobs.has(run.jobType)) {
|
||||||
this.groups = Array.from(byAgent.values());
|
return false;
|
||||||
}
|
|
||||||
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 (withRunTotal) {
|
if (f.statuses.size && !f.statuses.has(run.status)) {
|
||||||
lines.push(
|
return false;
|
||||||
[
|
|
||||||
'Total',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
this.csvAmount(groups.reduce((t, g) => t + g.total, 0))
|
|
||||||
]
|
|
||||||
.map(quote)
|
|
||||||
.join(',')
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
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 {
|
} else {
|
||||||
lines.push(
|
set.add(value);
|
||||||
[
|
|
||||||
'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(',')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
this.applyRunFilters();
|
||||||
|
}
|
||||||
|
|
||||||
// A byte-order mark so Excel opens the rupee sign and any non-English names correctly.
|
clearRunFilters(): void {
|
||||||
const blob = new Blob(['\ufeff' + lines.join('\r\n')], { type: 'text/csv;charset=utf-8' });
|
this.runFilter = { search: '', jobs: new Set<string>(), statuses: new Set<string>(), from: '', to: '' };
|
||||||
const name = withRunTotal
|
this.applyRunFilters();
|
||||||
? `${this.labelOf(run.jobType)}-run-${run.id}`
|
}
|
||||||
: `${this.labelOf(run.jobType)}-run-${run.id}-${groups[0]?.agentCode ?? 'agent'}`;
|
|
||||||
|
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');
|
const link = document.createElement('a');
|
||||||
link.href = URL.createObjectURL(blob);
|
link.href = URL.createObjectURL(blob);
|
||||||
link.download = `${name.replace(/\s+/g, '-').toLowerCase()}.csv`;
|
link.download = 'dds-batch-runs.csv';
|
||||||
link.click();
|
link.click();
|
||||||
URL.revokeObjectURL(link.href);
|
URL.revokeObjectURL(link.href);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True while Generate is working out the preview. */
|
/** True while Generate is working out the run. */
|
||||||
generating = false;
|
generating = false;
|
||||||
/** True while the list of previous runs is being fetched. */
|
/** True while the list of previous runs is being fetched. */
|
||||||
runsLoading = true;
|
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[] = [];
|
offices: any[] = [];
|
||||||
selectedOfficeNames = new Set<string>();
|
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[] = [];
|
recentRuns: any[] = [];
|
||||||
|
|
||||||
batchForm: UntypedFormGroup = this.formBuilder.group({
|
batchForm: UntypedFormGroup = this.formBuilder.group({
|
||||||
@@ -459,19 +390,6 @@ export class DdsBatchProcessingComponent implements OnInit {
|
|||||||
return this.batchForm.get('branchScope')?.value === 'Selected';
|
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 {
|
ngOnInit(): void {
|
||||||
this.organizationService.getOffices().subscribe((offices: any[]) => {
|
this.organizationService.getOffices().subscribe((offices: any[]) => {
|
||||||
this.offices = offices || [];
|
this.offices = offices || [];
|
||||||
@@ -480,22 +398,11 @@ export class DdsBatchProcessingComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
money(value: any): string {
|
money(value: any): string {
|
||||||
if (value === null || value === undefined || value === '') {
|
return formatMoney(value);
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
return DdsBatchProcessingComponent.MONEY.format(Number(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 {
|
labelOf(code: string): string {
|
||||||
return (
|
return jobLabel(code);
|
||||||
this.jobTypes.find((type) => type.code === code)?.label ?? DdsBatchProcessingComponent.RUN_LABELS[code] ?? code
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleOffice(officeName: string): void {
|
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
|
* invisible the moment the page is left, and there is no way to tell whether a month's interest
|
||||||
* was ever actually posted.
|
* 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.runsLoading = true;
|
||||||
this.organizationService.getDdsBatchRuns().subscribe({
|
this.organizationService.getDdsBatchRuns().subscribe({
|
||||||
next: (runs: any[]) => {
|
next: (runs: any[]) => {
|
||||||
this.recentRuns = runs || [];
|
this.recentRuns = runs || [];
|
||||||
this.runsLoading = false;
|
this.runsLoading = false;
|
||||||
|
this.applyRunFilters();
|
||||||
|
this.watchRunning();
|
||||||
},
|
},
|
||||||
error: () => (this.runsLoading = false)
|
error: () => (this.runsLoading = false)
|
||||||
});
|
});
|
||||||
@@ -556,181 +479,68 @@ export class DdsBatchProcessingComponent implements OnInit {
|
|||||||
error: () => (this.generating = false),
|
error: () => (this.generating = false),
|
||||||
next: (batchRun: any) => {
|
next: (batchRun: any) => {
|
||||||
this.generating = false;
|
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();
|
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 {
|
// ── Runs that are going on in the background ──────────────────────────────────────────────────────
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static readonly STATUS_CHECK_INTERVAL_MS = 4000;
|
private static readonly STATUS_CHECK_INTERVAL_MS = 4000;
|
||||||
/** Twenty minutes of checking, which is longer than any run measured here has taken. */
|
/** Twenty minutes of checking, which is longer than any run measured here has taken. */
|
||||||
private static readonly MAX_STATUS_CHECKS = 300;
|
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". */
|
* A crediting run is done by the server in the background, so the list is read again until none is
|
||||||
discard(): void {
|
* still running, and the clerk is told how each one ended - posted, or failed with the reason the
|
||||||
const run = this.generatedRun;
|
* server recorded. Polled rather than pushed: a batch that takes minutes needs no faster news, and
|
||||||
this.generatedRun = null;
|
* a poll costs one query. It gives up after a while - the run is on the list either way.
|
||||||
this.previewRows = [];
|
*/
|
||||||
if (run?.id) {
|
private watchRunning(): void {
|
||||||
this.organizationService.discardDdsBatchRun(run.id).subscribe({
|
const nowRunning = new Set<number>(this.recentRuns.filter((run) => run.status === 'POSTING').map((run) => run.id));
|
||||||
next: () => this.loadRecentRuns(),
|
|
||||||
error: () => this.loadRecentRuns()
|
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 { CreateAgentComponent } from './agent-master/create-agent/create-agent.component';
|
||||||
import { ViewAgentComponent } from './agent-master/view-agent/view-agent.component';
|
import { ViewAgentComponent } from './agent-master/view-agent/view-agent.component';
|
||||||
import { DdsBatchProcessingComponent } from './dds-batch-processing/dds-batch-processing.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 { EmployeesComponent } from './employees/employees.component';
|
||||||
import { CreateEmployeeComponent } from './employees/create-employee/create-employee.component';
|
import { CreateEmployeeComponent } from './employees/create-employee/create-employee.component';
|
||||||
import { ViewEmployeeComponent } from './employees/view-employee/view-employee.component';
|
import { ViewEmployeeComponent } from './employees/view-employee/view-employee.component';
|
||||||
@@ -268,8 +269,19 @@ const routes: Routes = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'dds-batch-processing',
|
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
|
// Bank's own profile — name, legal name, code, registration, address/contact. A
|
||||||
|
|||||||
Reference in New Issue
Block a user