DDS: agent collection entry screen, per-agent GL account, maturity auto close

The Daily Transaction Entry screen no longer composes a Party Tran. An agent records their own round
elsewhere; this screen reads back what they wrote down, shows what it adds up to, and settles the
day once a clerk has counted the cash against it. Nothing is typed here and nothing is edited - what
settles is what the entries say, to the rupee.

Agent Master now asks which ledger account the agent's collections pass through, one agent to an
account, and offers only the accounts that can carry them. The DDS scheme product is kept out of the
agent's own savings list for the same reason the server refuses it: debits are blocked on those
accounts, so commission paid into one would go in and never come out.

The DDS product carries a Maturity Auto Close setting, asked as Yes/No to match the Auto Interest
Post question directly above it, and Batch Processing offers the run that applies it.

Also: the report viewer stops printing two money columns ungrouped and stops totalling a column that
holds a word, and an agent's birth date cannot be picked later than eighteen years ago.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
tarunmalviya
2026-09-23 11:54:49 +05:30
co-authored by Claude Opus 5
parent 57a61b3057
commit e2eff9914f
114 changed files with 10259 additions and 15955 deletions
@@ -16,46 +16,59 @@
<div class="rv-card">
<form [formGroup]="filterForm">
<div class="rv-grid">
<mat-form-field appearance="outline" class="rv-field">
<mat-label>From Date</mat-label>
<input matInput required [matDatepicker]="fromPicker" formControlName="fromDate" />
<mat-datepicker-toggle matIconSuffix [for]="fromPicker"></mat-datepicker-toggle>
<mat-datepicker #fromPicker></mat-datepicker>
</mat-form-field>
<!-- Only the filters this report's query actually reads are offered. One that changed nothing
would read as a report ignoring what it was asked. -->
@if (!hasNoFilters) {
<div class="rv-grid">
@if (usesDate) {
<mat-form-field appearance="outline" class="rv-field">
<mat-label>{{ dateLabel }} — from</mat-label>
<input matInput required [matDatepicker]="fromPicker" formControlName="fromDate" />
<mat-datepicker-toggle matIconSuffix [for]="fromPicker"></mat-datepicker-toggle>
<mat-datepicker #fromPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="rv-field">
<mat-label>To Date</mat-label>
<input matInput required [matDatepicker]="toPicker" formControlName="toDate" />
<mat-datepicker-toggle matIconSuffix [for]="toPicker"></mat-datepicker-toggle>
<mat-datepicker #toPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="rv-field">
<mat-label>{{ dateLabel }} — to</mat-label>
<input matInput required [matDatepicker]="toPicker" formControlName="toDate" />
<mat-datepicker-toggle matIconSuffix [for]="toPicker"></mat-datepicker-toggle>
<mat-datepicker #toPicker></mat-datepicker>
</mat-form-field>
}
<mat-form-field appearance="outline" class="rv-field">
<mat-label>Agent From</mat-label>
<input matInput type="number" formControlName="agentFrom" />
</mat-form-field>
@if (usesAgent) {
<mat-form-field appearance="outline" class="rv-field">
<mat-label>Agent From</mat-label>
<input matInput type="number" formControlName="agentFrom" />
</mat-form-field>
<mat-form-field appearance="outline" class="rv-field">
<mat-label>Agent To</mat-label>
<input matInput type="number" formControlName="agentTo" />
</mat-form-field>
<mat-form-field appearance="outline" class="rv-field">
<mat-label>Agent To</mat-label>
<input matInput type="number" formControlName="agentTo" />
</mat-form-field>
}
<mat-form-field appearance="outline" class="rv-field">
<mat-label>Party From</mat-label>
<input matInput type="number" formControlName="partyFrom" />
</mat-form-field>
@if (usesParty) {
<mat-form-field appearance="outline" class="rv-field">
<mat-label>Party From</mat-label>
<input matInput type="number" formControlName="partyFrom" />
</mat-form-field>
<mat-form-field appearance="outline" class="rv-field">
<mat-label>Party To</mat-label>
<input matInput type="number" formControlName="partyTo" />
</mat-form-field>
</div>
<mat-form-field appearance="outline" class="rv-field">
<mat-label>Party To</mat-label>
<input matInput type="number" formControlName="partyTo" />
</mat-form-field>
}
</div>
}
<div class="rv-action-bar">
<button mat-raised-button type="button" (click)="exit()">Exit</button>
<!-- A report with nothing to fill in has already run itself; the button then only offers to
fetch the figures again, which is what it is called. -->
<button mat-raised-button color="primary" type="button" [disabled]="filterForm.invalid" (click)="runReport()">
<i class="fas fa-play" style="margin-right: 6px"></i> Run Report
<i class="fas" [class.fa-play]="!hasNoFilters" [class.fa-rotate-right]="hasNoFilters"></i>
{{ hasNoFilters ? 'Refresh' : 'Run Report' }}
</button>
</div>
@@ -66,25 +79,66 @@
} @else if (rows.length === 0) {
<p class="rv-empty">No rows matched these filters.</p>
} @else {
<div class="rv-table-scroll">
<table class="rv-table">
<thead>
<tr>
@for (column of columns; track column) {
<th>{{ column }}</th>
}
</tr>
</thead>
<tbody>
@for (row of rows; track row) {
<!-- Laid out as the legacy statement printed it: a heading naming the report and the
period, numbered rows, and a total under every money column. -->
<div class="rv-statement">
<div class="rv-statement__head">
<h3 class="rv-statement__title">{{ reportName }}</h3>
@if (usesDate) {
<p class="rv-statement__period">
{{ dateLabel | lowercase }} {{ ranFromDate | dateFormat }} and
{{ ranToDate | dateFormat }}
</p>
} @else {
<p class="rv-statement__period">as on {{ ranOnDate | dateFormat }}</p>
}
<button mat-stroked-button type="button" class="rv-print" (click)="print()">
<i class="fas fa-print"></i> Print
</button>
</div>
<div class="rv-table-scroll">
<table class="rv-table">
<thead>
<tr>
<th class="rv-sr">#</th>
@for (column of columns; track column) {
<td>{{ row[column] }}</td>
<th [class.rv-num]="isAmountColumn(column)">{{ column }}</th>
}
</tr>
</thead>
<tbody>
@for (row of rows; track $index) {
<tr>
<td class="rv-sr">{{ $index + 1 }}</td>
@for (column of columns; track column) {
<td [class.rv-num]="isAmountColumn(column)">{{ cell(row, column) }}</td>
}
</tr>
}
</tbody>
@if (hasTotalledColumn) {
<tfoot>
<tr>
<td class="rv-sr"></td>
@for (column of columns; track column; let first = $first) {
<td [class.rv-num]="isAmountColumn(column)">
@if (first) {
Total :
} @else if (isTotalledColumn(column)) {
{{ columnTotal(column) }}
}
</td>
}
</tr>
</tfoot>
}
</tbody>
</table>
</table>
</div>
<p class="rv-statement__count">
{{ rows.length }} {{ rows.length === 1 ? 'row' : 'rows' }}
</p>
</div>
}
</div>
@@ -94,6 +94,93 @@ $primary: #0f766e;
padding: 8px 10px;
border-bottom: 1px solid rgb(0 0 0 / 6%);
}
/* Figures line up column-wise so a wrong order of magnitude is visible at a glance. */
.rv-num {
text-align: right;
font-variant-numeric: tabular-nums;
}
/* The row number down the left, as the printed statement carried it. */
.rv-sr {
width: 1%;
color: rgb(0 0 0 / 45%);
font-style: italic;
font-variant-numeric: tabular-nums;
}
tfoot td {
border-bottom: none;
border-top: 2px solid rgb(0 0 0 / 20%);
font-weight: 600;
padding-top: 10px;
}
}
/* ---- the statement block: heading, table, footer count ---- */
.rv-action-bar i {
margin-right: 6px;
}
.rv-statement__head {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 4px 12px;
margin-bottom: 14px;
}
.rv-statement__title {
margin: 0;
font-size: 17px;
font-weight: 600;
flex: 0 0 auto;
}
.rv-statement__period {
margin: 0;
font-size: 13px;
color: rgb(0 0 0 / 55%);
}
.rv-print {
margin-left: auto;
i {
margin-right: 6px;
}
}
.rv-statement__count {
margin: 10px 0 0;
font-size: 12px;
color: rgb(0 0 0 / 45%);
text-align: right;
}
/* Printing a statement should give the statement, not the screen around it. */
@media print {
.rv-header,
.rv-grid,
.rv-action-bar,
.rv-print {
display: none !important;
}
.rv-card {
box-shadow: none;
padding: 0;
}
.rv-table-scroll {
overflow-x: visible;
}
.rv-table {
white-space: normal;
font-size: 11px;
}
}
@media (width <= 900px) {
@@ -7,7 +7,7 @@
*/
/** Angular Imports */
import { Component, inject } from '@angular/core';
import { Component, OnInit, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -37,7 +37,7 @@ import { Dates } from 'app/core/utils/dates';
styleUrls: ['./report-viewer.component.scss'],
imports: [...STANDALONE_SHARED_IMPORTS]
})
export class ReportViewerComponent {
export class ReportViewerComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private formBuilder = inject(UntypedFormBuilder);
@@ -66,6 +66,41 @@ export class ReportViewerComponent {
'Agent Commission Statement': 'AGENT_COMMISSION_STATEMENT'
};
/**
* Which filters each report actually uses, and what its date range means.
*
* Every report used to be offered the same six filters whether it read them or not — so a date
* range could be typed on the Agent Check List, which has no date in its query at all, and the
* same rows would come back however it was set. A filter that changes nothing is worse than no
* filter: it reads as a report that ignores what it was asked.
*
* Kept in step with the backend by hand; the source of truth is the switch in
* `DdsReportReadPlatformServiceImpl.runReport`.
*/
private static readonly REPORT_FILTERS: Record<string, { date?: string; agent?: boolean; party?: boolean }> = {
AGENT_CHECK_LIST: {},
PARTY_CHECK_LIST: { agent: true, party: true },
PARTY_BALANCE: { agent: true, party: true },
AGENT_WISE_BALANCE: { agent: true },
BALANCE_SUMMARY: { agent: true, party: true },
MASTER_DATA_EXPORT: { agent: true, party: true },
AGENT_WISE_MONTHLY_COLLECTION: { date: 'Collected between', agent: true },
COLLECTION_SUMMARY_AGENT_WISE: { date: 'Collected between', agent: true },
DAILY_COLLECTION_REPORT: { date: 'Collected between', agent: true },
AGENT_PARTYWISE_DAILY_COLLECTION: { date: 'Collected between', agent: true, party: true },
SUPPLEMENTARY_DDS: { date: 'Collected between', agent: true, party: true },
COMMISSION_REGISTER: { date: 'Posted between', agent: true },
AGENT_COMMISSION_STATEMENT: { date: 'Posted between' },
PARTY_INTEREST_REGISTER: { date: 'Posted between', party: true },
INTEREST_CERTIFICATE: { date: 'Interest earned between', agent: true, party: true },
TRANSACTION_REGISTER: { date: 'Transacted between', agent: true, party: true },
CLOSE_PARTY_AC_REGISTER: { date: 'Closed between', agent: true, party: true },
MATURE_DATE_WISE: { date: 'Maturing between', agent: true, party: true }
};
reportName: string = this.route.snapshot.paramMap.get('reportName') || 'Report';
hasRun = false;
@@ -74,21 +109,198 @@ export class ReportViewerComponent {
columns: string[] = [];
rows: Array<Record<string, any>> = [];
/** The range the rows on screen were actually fetched for — not whatever the filter says now. */
ranFromDate: Date | null = null;
ranToDate: Date | null = null;
/** When a report carries no date range, the moment it was run is what dates its figures. */
ranOnDate: Date | null = null;
private static readonly MONEY = new Intl.NumberFormat('en-IN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
private static readonly DATE = new Intl.DateTimeFormat('en-GB', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
/**
* One cell, rendered the way a printed statement shows it.
*
* Dates arrive from the platform as `[yyyy, m, d]` and numbers as raw decimals, so printing them
* as they come gives "2026,9,2" and "10052.6" — unreadable on a statement someone checks figures
* against. Amounts get Indian grouping and two decimals; identifiers that merely look numeric
* (account numbers, codes) are left alone, since grouping them would corrupt them.
*/
cell(row: Record<string, any>, column: string): string {
const value = row[column];
if (value === null || value === undefined || value === '') {
return '—';
}
if (Array.isArray(value) && value.length >= 3) {
return ReportViewerComponent.DATE.format(new Date(value[0], value[1] - 1, value[2]));
}
if (this.isAmountColumn(column) && typeof value === 'number') {
return ReportViewerComponent.MONEY.format(value);
}
return String(value);
}
/**
* Whether a column holds money, and so should be right-aligned with two decimals.
*
* Decided from the column's name rather than its values: a count column and an account-number
* column are both numbers, and neither reads as an amount.
*/
isAmountColumn(column: string): boolean {
const name = column.toLowerCase();
// Ruled out first, because several of these also contain a money word: a rate is a percentage,
// "Total Accounts" is a count that happens to say total, and an account number is an identifier
// that would be corrupted by grouping.
//
// "agents" is matched exactly rather than as a substring: the two count columns are named
// "Total Agents" and "Active Agents", but "Cash With Agents" and "Commission Due To Agents"
// are money, and a substring test swept those two up and printed them ungrouped.
//
// A "type" column names a kind, never a quantity — "Interest Type" holds the word "Interest",
// which is enough for the money test below to claim it and put a meaningless total under it.
if (
name.includes('a/c no') ||
name.includes('code') ||
name.includes('rate') ||
name.includes('%') ||
name.includes('count') ||
name.includes('entries') ||
name.includes('accounts') ||
name === 'total agents' ||
name === 'active agents' ||
name.includes('type') ||
name.includes('pan')
) {
return false;
}
return (
name.includes('amount') ||
name.includes('balance') ||
name.includes('collect') ||
name.includes('commission') ||
name.includes('deduction') ||
name.includes('deposit') ||
name.includes('advance') ||
name.includes('interest') ||
name.includes('tds') ||
name.includes('total') ||
name.includes('diff') ||
name.includes('cash') ||
name.includes('debit') ||
name.includes('credit') ||
name.includes('settled') ||
name.includes('shortfall') ||
name.includes('due')
);
}
/**
* Whether a total under this column would mean anything.
*
* Not every amount is worth adding up. A "Balance After" on a transaction register is the running
* balance *at that moment*, so its column holds several snapshots of the same account at different
* times — summing them produces a figure that corresponds to nothing, and a reader would sooner or
* later try to reconcile it against something.
*
* A plain balance on a listing report is different: there each row is one account's position at
* one moment, and the sum is the real "total held", which is exactly what a branch wants to see.
*/
isTotalledColumn(column: string): boolean {
if (!this.isAmountColumn(column)) {
return false;
}
return !column.toLowerCase().includes('balance after');
}
/** The figure printed under a column, as the legacy statement's "Total :" row does. */
columnTotal(column: string): string {
const sum = this.rows.reduce((running, row) => {
const value = row[column];
return running + (typeof value === 'number' ? value : 0);
}, 0);
return ReportViewerComponent.MONEY.format(sum);
}
/**
* Whether to print a Total row at all.
*
* A single-row report is already its own total - Balance Summary is one line of figures for the
* whole scheme, and repeating it underneath itself only invites the reader to look for a difference
* between the two.
*/
get hasTotalledColumn(): boolean {
return this.rows.length > 1 && this.columns.some((column) => this.isTotalledColumn(column));
}
print(): void {
window.print();
}
filterForm: UntypedFormGroup = this.formBuilder.group({
fromDate: [
'',
Validators.required
],
toDate: [
'',
Validators.required
],
fromDate: [''],
toDate: [''],
agentFrom: [1],
agentTo: [99999],
partyFrom: [1],
partyTo: [99999999]
});
private get filters(): { date?: string; agent?: boolean; party?: boolean } {
return ReportViewerComponent.REPORT_FILTERS[ReportViewerComponent.REPORT_KEYS[this.reportName]] ?? {};
}
/** True when this report's query actually reads a date range - only then is one asked for. */
get usesDate(): boolean {
return !!this.filters.date;
}
/** What this report's date range means, e.g. "Posted between" rather than a bare "From Date". */
get dateLabel(): string {
return this.filters.date ?? '';
}
get usesAgent(): boolean {
return !!this.filters.agent;
}
get usesParty(): boolean {
return !!this.filters.party;
}
/** True when this report takes no filters at all, so there is nothing for anyone to fill in. */
get hasNoFilters(): boolean {
return !this.usesDate && !this.usesAgent && !this.usesParty;
}
ngOnInit(): void {
// A date is required exactly for the reports that read one, so a report without dates can be run
// straight away instead of demanding two values it will ignore.
if (this.usesDate) {
this.filterForm.get('fromDate')?.setValidators([Validators.required]);
this.filterForm.get('toDate')?.setValidators([Validators.required]);
this.filterForm.get('fromDate')?.updateValueAndValidity();
this.filterForm.get('toDate')?.updateValueAndValidity();
}
// Nothing to narrow it by means nothing to decide, so the report simply opens showing its
// figures. Making someone press Run on an empty form asks a question with only one answer.
if (this.hasNoFilters) {
this.runReport();
}
}
runReport(): void {
const reportKey = ReportViewerComponent.REPORT_KEYS[this.reportName];
if (!reportKey) {
@@ -96,20 +308,34 @@ export class ReportViewerComponent {
}
const raw = this.filterForm.getRawValue();
// Only what this report reads is sent. The backend defaults the rest to the full range, so a
// report that ignores a filter is never handed a value that looks like it was applied.
const params: Record<string, any> = {};
if (this.usesDate) {
params['fromDate'] = this.dateUtils.getDate(raw.fromDate);
params['toDate'] = this.dateUtils.getDate(raw.toDate);
}
if (this.usesAgent) {
params['agentFrom'] = raw.agentFrom;
params['agentTo'] = raw.agentTo;
}
if (this.usesParty) {
params['partyFrom'] = raw.partyFrom;
params['partyTo'] = raw.partyTo;
}
this.organizationService
.runDdsReport(reportKey, {
fromDate: this.dateUtils.getDate(raw.fromDate),
toDate: this.dateUtils.getDate(raw.toDate),
agentFrom: raw.agentFrom,
agentTo: raw.agentTo,
partyFrom: raw.partyFrom,
partyTo: raw.partyTo
})
.runDdsReport(reportKey, params)
.subscribe((result: any) => {
this.available = result.available;
this.unavailableMessage = result.message || '';
this.columns = result.columns || [];
this.rows = result.rows || [];
// Captured here so the printed heading names the range these rows came from, and does not
// silently change if the filter is edited without re-running.
this.ranFromDate = raw.fromDate;
this.ranToDate = raw.toDate;
this.ranOnDate = new Date();
this.hasRun = true;
});
}