Merge branch 'feature/dds' into merge/dds-into-dev-latest
Brings the DDS (Daily Deposit Scheme) UI from feature/dds onto the current dev-latest: - Agent settlement (Agent Tran), Close Party account, DDS reports, Party-Savings link field - Agent collection entry screen, per-agent GL account, maturity auto close - Approvals tab, vouchers, payout & close, Sundry GL search, closure tab and fixes - Menu: DDS Party Master, DDS Accounts, DDS Collection, DDS Reports and Locker links placed in the Deposits mega panel groups; Agent Master, Commission Slab and DDS Batch Processing under Organization; DDS Approvals tab in the checker inbox No content conflicts. Nothing from dev-latest is removed. Merge adjustments: - package-lock.json: kept dev-latest's lock file. feature/dds does not add or change any dependency in package.json; its lock file was only regenerated (about 18k lines removed and 12k added), which would have changed resolved versions for packages already on dev-latest. - Dropped src/app/deposits/party-master/CBS-Backend.code-workspace, a VS Code workspace file holding local folder paths, not app code. - angular.json: bundle budget raised to 18mb warning / 20mb error, as on feature/dds. Lint fixes required by the pre-commit hook (no behaviour change): - Prettier formatting on 12 DDS files. - create-party.component.scss: the dense data-entry section deliberately re-targets the base selectors to override them, so no-duplicate-selectors is disabled for that section instead of merging rules and changing the cascade. - notice-dialog / create-dds-account-type scss: stylelint-disable-line for the "ch" width unit. - commission-slab-master.component.scss: stylelint auto-fix. Verified with tsc -p src/tsconfig.app.json --noEmit, stylelint, prettier and eslint on all merged files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+381
@@ -0,0 +1,381 @@
|
||||
<!--
|
||||
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="dialog-wrapper">
|
||||
<div class="dialog-header">
|
||||
<div class="header-icon">
|
||||
<mat-icon>account_balance_wallet</mat-icon>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h2>Apply DDS Receipt</h2>
|
||||
<p>
|
||||
Agent <strong class="mono">{{ data.agentCode }}</strong> · {{ data.agentName }}
|
||||
@if (data.glCode) {
|
||||
· GL <strong class="mono">{{ data.glCode }}</strong>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" mat-icon-button class="header-close" [mat-dialog-close]="false" aria-label="Close">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<mat-dialog-content class="dialog-body">
|
||||
@if (loading) {
|
||||
<div class="state-block">
|
||||
<mat-spinner diameter="30"></mat-spinner>
|
||||
<span>Loading entries and receipts…</span>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- What the picked vouchers have to cover, and whether they do. -->
|
||||
<div class="sc-card">
|
||||
<div class="sc-row" [class.sc-ok]="canApply" [class.sc-bad]="pickedReceipts.size > 0 && difference !== 0">
|
||||
<div class="sc-left">
|
||||
<span class="sc-label">Collections up to {{ data.entryDate | date: 'dd/MM/yyyy' }}</span>
|
||||
<span class="sc-sub"
|
||||
>{{ pickedEntryCount }} {{ pickedEntryCount === 1 ? 'entry' : 'entries' }} to settle</span
|
||||
>
|
||||
</div>
|
||||
<div class="sc-right">
|
||||
<span class="sc-val">{{ required | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="sc-row sc-sep"
|
||||
[class.sc-ok]="canApply"
|
||||
[class.sc-bad]="pickedReceipts.size > 0 && difference !== 0"
|
||||
>
|
||||
<div class="sc-left">
|
||||
<span class="sc-label">Vouchers picked</span>
|
||||
<span class="sc-sub"
|
||||
>{{ pickedReceipts.size }} {{ pickedReceipts.size === 1 ? 'voucher' : 'vouchers' }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="sc-right">
|
||||
<span
|
||||
class="sc-val"
|
||||
[class.sc-val-ok]="canApply"
|
||||
[class.sc-val-bad]="pickedReceipts.size > 0 && difference !== 0"
|
||||
>
|
||||
{{ received | number: '1.2-2' }}
|
||||
</span>
|
||||
@if (pickedReceipts.size > 0 && pickedDays.size > 0 && difference < 0) {
|
||||
<span class="sc-tag sc-tag-bad">Short {{ -difference | number: '1.2-2' }}</span>
|
||||
} @else if (pickedReceipts.size > 0 && pickedDays.size > 0 && difference > 0) {
|
||||
<span class="sc-tag sc-tag-bad">Over {{ difference | number: '1.2-2' }}</span>
|
||||
} @else if (canApply) {
|
||||
<span class="sc-tag sc-tag-ok">✓ Matches</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sc-total-row" [class.sc-total-ok]="canApply">
|
||||
<span class="sc-total-lbl">Total to Apply</span>
|
||||
<div class="sc-total-nums">
|
||||
<span class="sc-total-req mono">{{ required | number: '1.2-2' }}</span>
|
||||
<span class="sc-arrow">→</span>
|
||||
<span class="sc-total-got mono" [class.sc-total-got-ok]="canApply">{{ received | number: '1.2-2' }}</span>
|
||||
<span class="sc-status-pill" [class.sc-pill-ok]="canApply" [class.sc-pill-pending]="!canApply">
|
||||
{{ canApply ? '✓ Ready' : 'Pending' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 1: the days the agent wrote down ──────────────────────────────────── -->
|
||||
<section class="slot">
|
||||
<header class="slot-head">
|
||||
<span class="slot-step">1</span>
|
||||
<div class="slot-title">
|
||||
<h3>Outstanding up to {{ data.entryDate | date: 'dd/MM/yyyy' }}</h3>
|
||||
<p>Everything the agent wrote down and has not handed in, day by day. Open a day to see its lines.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (days.length) {
|
||||
<ul class="options">
|
||||
@for (day of days; track day.date) {
|
||||
<li class="option" (click)="toggleOpen(day, $event)">
|
||||
<div class="option-main">
|
||||
<span class="option-voucher mono">{{ day.date | date: 'dd/MM/yyyy' }}</span>
|
||||
<span class="option-meta"
|
||||
>{{ day.entries.length }} {{ day.entries.length === 1 ? 'entry' : 'entries' }}</span
|
||||
>
|
||||
</div>
|
||||
<span class="option-amount mono">{{ day.total | number: '1.2-2' }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="option-info"
|
||||
(click)="toggleOpen(day, $event)"
|
||||
aria-label="Show the day's lines"
|
||||
>
|
||||
<mat-icon>{{ openDay === day.date ? 'expand_less' : 'expand_more' }}</mat-icon>
|
||||
</button>
|
||||
</li>
|
||||
@if (openDay === day.date) {
|
||||
<li class="day-lines">
|
||||
@if (day.entries.length > 8) {
|
||||
<input
|
||||
class="day-search"
|
||||
type="text"
|
||||
[(ngModel)]="daySearch"
|
||||
placeholder="Search party code or name"
|
||||
autocomplete="off"
|
||||
/>
|
||||
}
|
||||
<div class="day-scroll">
|
||||
<table class="entries">
|
||||
<tbody>
|
||||
@for (entry of visibleEntries(day); track entry.id) {
|
||||
<tr>
|
||||
<td class="entry-code">{{ entry.partyCode }}</td>
|
||||
<td class="entry-name">{{ entry.partyName }}</td>
|
||||
<td>
|
||||
{{ entry.transactionMode }}
|
||||
@if (entry.advanceDays) {
|
||||
· {{ entry.advanceDays }}d advance
|
||||
}
|
||||
@if (entry.arrearsDays) {
|
||||
· {{ entry.arrearsDays }}d previous
|
||||
}
|
||||
</td>
|
||||
<td class="num mono">{{ entry.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="4" class="entry-name">No line matches.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<p class="slot-empty">
|
||||
{{ data.agentName }} has nothing outstanding for {{ data.entryDate | date: 'dd/MM/yyyy' }}. Change the date
|
||||
on the screen to the day the entries were written.
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
<!-- ── 2: the cashier's receipts ─────────────────────────────────────────── -->
|
||||
<section class="slot">
|
||||
<header class="slot-head">
|
||||
<span class="slot-step">2</span>
|
||||
<div class="slot-title">
|
||||
<h3>DDS Cash Receipts</h3>
|
||||
<p>Tick the vouchers. Posts Dr Agent GL / Cr DDS Customer Deposits.</p>
|
||||
</div>
|
||||
<button type="button" class="slot-search" (click)="openSearch()">
|
||||
<mat-icon>search</mat-icon>
|
||||
<span>Find by voucher</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@if (available.length) {
|
||||
<ul class="options">
|
||||
@for (option of available; track option.id) {
|
||||
<li class="option" [class.option-picked]="pickedReceipts.has(option.id)" (click)="toggleReceipt(option)">
|
||||
<mat-icon class="option-check">{{
|
||||
pickedReceipts.has(option.id) ? 'check_box' : 'check_box_outline_blank'
|
||||
}}</mat-icon>
|
||||
<div class="option-main">
|
||||
<span class="option-voucher mono">{{ option.voucherNo }}</span>
|
||||
<span class="option-meta">{{ option.txnDate }} · {{ glLabel(option) }}</span>
|
||||
@if (option.description) {
|
||||
<span class="option-desc">{{ option.description }}</span>
|
||||
}
|
||||
</div>
|
||||
<span class="option-amount mono">{{ option.amount | number: '1.2-2' }}</span>
|
||||
<button type="button" class="option-info" (click)="openDetail(option, $event)" aria-label="GL entries">
|
||||
<mat-icon>receipt_long</mat-icon>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<p class="slot-empty">
|
||||
No DDS receipt is waiting on this agent's GL account. Collect the cash at
|
||||
<em>Cashier › Transaction Entry</em> with entry type <strong>DDS</strong> and have the manager approve it,
|
||||
or find an earlier voucher by number.
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (pickedReceipts.size > 0 && pickedDays.size > 0 && difference !== 0) {
|
||||
<p class="slot-note note-bad">
|
||||
The vouchers ({{ received | number: '1.2-2' }}) have to match the day's entries ({{
|
||||
required | number: '1.2-2'
|
||||
}}) exactly, so every customer is credited what the agent wrote down.
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (errorMsg && !searchOpen) {
|
||||
<div class="alert">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>{{ errorMsg }}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions class="dialog-actions">
|
||||
<button type="button" mat-stroked-button [mat-dialog-close]="false" [disabled]="submitting">Cancel</button>
|
||||
<button type="button" mat-flat-button color="primary" [disabled]="!canApply" (click)="apply()">
|
||||
@if (submitting) {
|
||||
<mat-spinner diameter="16"></mat-spinner>
|
||||
<span>Applying…</span>
|
||||
} @else {
|
||||
<mat-icon>check</mat-icon>
|
||||
<span>Apply Receipt</span>
|
||||
}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</div>
|
||||
|
||||
<!-- ── Find a voucher the list does not carry ───────────────────────────────── -->
|
||||
@if (searchOpen) {
|
||||
<div class="overlay" (click)="closeSearch()">
|
||||
<div class="popup" (click)="$event.stopPropagation()">
|
||||
<header class="popup-head">
|
||||
<h3>Find a DDS receipt</h3>
|
||||
<button type="button" mat-icon-button (click)="closeSearch()" aria-label="Close">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="popup-body">
|
||||
<label class="field">
|
||||
<span>GL Account</span>
|
||||
<div class="field-readonly mono">{{ data.glCode || '—' }} · {{ data.agentCode }}</div>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Voucher No.</span>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="searchVoucherNo"
|
||||
placeholder="CR/24092026/0001"
|
||||
(keyup.enter)="runSearch()"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@if (errorMsg) {
|
||||
<div class="alert">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>{{ errorMsg }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<footer class="popup-foot">
|
||||
<button type="button" mat-stroked-button (click)="closeSearch()">Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
[disabled]="!searchVoucherNo.trim() || searching"
|
||||
(click)="runSearch()"
|
||||
>
|
||||
@if (searching) {
|
||||
<mat-spinner diameter="16"></mat-spinner>
|
||||
} @else {
|
||||
<span>Find & Use</span>
|
||||
}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── A receipt's own GL entries ───────────────────────────────────────────── -->
|
||||
@if (viewing) {
|
||||
<div class="overlay" (click)="closeDetail()">
|
||||
<div class="popup" (click)="$event.stopPropagation()">
|
||||
<header class="popup-head">
|
||||
<h3>
|
||||
Voucher <span class="mono">{{ viewing.voucherNo }}</span>
|
||||
</h3>
|
||||
<button type="button" mat-icon-button (click)="closeDetail()" aria-label="Close">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="popup-body">
|
||||
<dl class="detail-list">
|
||||
<div>
|
||||
<dt>Date</dt>
|
||||
<dd class="mono">{{ viewing.txnDate }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Amount</dt>
|
||||
<dd class="mono">{{ viewing.amount | number: '1.2-2' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>GL Account</dt>
|
||||
<dd>{{ glLabel(viewing) }}</dd>
|
||||
</div>
|
||||
@if (viewing.cashierName) {
|
||||
<div>
|
||||
<dt>Collected By</dt>
|
||||
<dd>{{ viewing.cashierName }}</dd>
|
||||
</div>
|
||||
}
|
||||
@if (viewing.managerName) {
|
||||
<div>
|
||||
<dt>Approved By</dt>
|
||||
<dd>{{ viewing.managerName }}</dd>
|
||||
</div>
|
||||
}
|
||||
@if (viewing.description) {
|
||||
<div>
|
||||
<dt>Description</dt>
|
||||
<dd>{{ viewing.description }}</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
|
||||
<h4 class="detail-heading">Journal Entries</h4>
|
||||
@if (loadingGlEntries) {
|
||||
<div class="state-block"><mat-spinner diameter="22"></mat-spinner></div>
|
||||
} @else if (viewingGlEntries?.length) {
|
||||
<table class="gl-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account</th>
|
||||
<th class="num">Debit</th>
|
||||
<th class="num">Credit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (entry of viewingGlEntries; track entry.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="mono">{{ entry.glCode }}</span> {{ entry.glName }}
|
||||
</td>
|
||||
<td class="num mono">{{ entry.drCr === 'DR' ? (entry.amount | number: '1.2-2') : '' }}</td>
|
||||
<td class="num mono">{{ entry.drCr === 'CR' ? (entry.amount | number: '1.2-2') : '' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
} @else {
|
||||
<p class="slot-empty">No journal entries are recorded against this receipt.</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<footer class="popup-foot">
|
||||
<button type="button" mat-stroked-button (click)="closeDetail()">Close</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 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 same look as Apply Additional Shares, on purpose: both apply a cashier's receipt to what it was
|
||||
// collected for, and the counter should meet one screen, not two. Used as it is, never changed here.
|
||||
@use '../../../shares/share-dialog.shared' as *;
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// The agent's outstanding lines - read only, what the receipt has to cover.
|
||||
.entries {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
|
||||
td {
|
||||
padding: 7px 4px;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.entry-date {
|
||||
color: #64748b;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.entry-code {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.entry-name {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
// Days and vouchers are ticked, any number of each - a check box where the share dialog has a radio.
|
||||
.option-check {
|
||||
color: #94a3b8;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.option-picked {
|
||||
border-color: #2563eb !important;
|
||||
background: #eff6ff !important;
|
||||
|
||||
.option-check {
|
||||
color: #2563eb;
|
||||
}
|
||||
}
|
||||
|
||||
// A day's own lines, opened under it. Scrolls inside itself, so a thousand customers do not make the
|
||||
// dialog a thousand lines tall.
|
||||
.day-lines {
|
||||
list-style: none;
|
||||
padding: 6px 10px 10px 42px;
|
||||
}
|
||||
|
||||
.day-search {
|
||||
width: 100%;
|
||||
margin-bottom: 6px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.day-scroll {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.field-readonly {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
+351
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { of } from 'rxjs';
|
||||
import { OrganizationService } from 'app/organization/organization.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
|
||||
/** One day of an agent's outstanding entries, as the dialog lists them. */
|
||||
export interface EntryDay {
|
||||
date: string;
|
||||
entries: any[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the cashier's DDS receipts to an agent's outstanding collections.
|
||||
*
|
||||
* The agent's cash is already in the bank: the cashier took it at the counter into the agent's own GL
|
||||
* account, tagged DDS, and the manager approved it. Applying it here hands the money on to the
|
||||
* customers the agent wrote down - Dr the agent's GL, Cr DDS Customer Deposits - and each entry is
|
||||
* settled against the application. The same screen as Apply Additional Shares.
|
||||
*
|
||||
* Everything outstanding up to the day chosen on the screen, shown a day at a time - nothing to tick,
|
||||
* because what the agent wrote down is settled whole, and an agent who comes in after several days
|
||||
* brings several days' cash. The vouchers are ticked, since a day's
|
||||
* cash can come in more than one visit. They have to agree exactly: what the agent wrote down is what
|
||||
* each customer is owed, so vouchers short of it leave somebody uncredited and vouchers over it leave
|
||||
* money nobody can place.
|
||||
*
|
||||
* The day is shown totalled and its lines opened only when asked for - an agent with a thousand
|
||||
* customers is a thousand lines, and the day's total is what the counter is checking.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-apply-dds-receipt-dialog',
|
||||
templateUrl: './apply-dds-receipt-dialog.component.html',
|
||||
styleUrls: ['./apply-dds-receipt-dialog.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule
|
||||
]
|
||||
})
|
||||
export class ApplyDdsReceiptDialogComponent implements OnInit {
|
||||
private organizationService = inject(OrganizationService);
|
||||
private settingsService = inject(SettingsService);
|
||||
|
||||
public dialogRef = inject(MatDialogRef<ApplyDdsReceiptDialogComponent>);
|
||||
public data = inject<{
|
||||
agentId: number;
|
||||
agentCode: string;
|
||||
agentName: string;
|
||||
glCode: string | null;
|
||||
entryDate: string;
|
||||
}>(MAT_DIALOG_DATA);
|
||||
|
||||
loadingEntries = true;
|
||||
loadingReceipts = true;
|
||||
submitting = false;
|
||||
errorMsg = '';
|
||||
|
||||
/** The chosen day's outstanding entries - one day, or none when nothing is outstanding on it. */
|
||||
days: EntryDay[] = [];
|
||||
|
||||
/** DDS receipts on the agent's GL account, approved and not yet applied. */
|
||||
available: any[] = [];
|
||||
|
||||
/**
|
||||
* The day is settled whole, so it is always included. The vouchers are the clerk's choice, and none
|
||||
* is picked for them even when only one is listed.
|
||||
*/
|
||||
pickedDays = new Set<string>();
|
||||
pickedReceipts = new Set<number>();
|
||||
|
||||
/** The day whose lines are open, and the search within it. */
|
||||
openDay: string | null = null;
|
||||
daySearch = '';
|
||||
|
||||
get loading(): boolean {
|
||||
return this.loadingEntries || this.loadingReceipts;
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Everything outstanding up to the chosen date, whatever day it was written - an agent's cash for
|
||||
// several days arrives in one visit, and all of it is settled together.
|
||||
this.organizationService
|
||||
.getAgentPendingCollections(this.data.agentId)
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
this.errorMsg = this.readError(err, "Could not load the agent's entries.");
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.days = groupByDay(
|
||||
(res?.entries || []).filter(
|
||||
(entry: any) => entry.status === 'PENDING' && String(entry.entryDate) <= this.data.entryDate
|
||||
)
|
||||
);
|
||||
this.pickedDays = new Set(this.days.map((day) => day.date));
|
||||
this.loadingEntries = false;
|
||||
});
|
||||
|
||||
this.organizationService
|
||||
.getAgentCashReceipts(this.data.agentId)
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
this.errorMsg = this.readError(err, 'Could not load the available receipts.');
|
||||
return of([]);
|
||||
})
|
||||
)
|
||||
.subscribe((rows: any) => {
|
||||
this.available = Array.isArray(rows) ? rows : [];
|
||||
this.loadingReceipts = false;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Picking ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
toggleReceipt(receipt: any): void {
|
||||
if (this.submitting) {
|
||||
return;
|
||||
}
|
||||
this.pickedReceipts.has(receipt.id) ? this.pickedReceipts.delete(receipt.id) : this.pickedReceipts.add(receipt.id);
|
||||
}
|
||||
|
||||
toggleOpen(day: EntryDay, event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.openDay = this.openDay === day.date ? null : day.date;
|
||||
this.daySearch = '';
|
||||
}
|
||||
|
||||
/** The open day's lines, narrowed by party code or name. */
|
||||
visibleEntries(day: EntryDay): any[] {
|
||||
const term = this.daySearch.trim().toLowerCase();
|
||||
if (!term) {
|
||||
return day.entries;
|
||||
}
|
||||
return day.entries.filter(
|
||||
(entry: any) =>
|
||||
String(entry.partyCode || '')
|
||||
.toLowerCase()
|
||||
.includes(term) ||
|
||||
String(entry.partyName || '')
|
||||
.toLowerCase()
|
||||
.includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
// ── What has to be covered ──────────────────────────────────────────────────────────────────
|
||||
|
||||
get entryCount(): number {
|
||||
return this.days.reduce((sum, day) => sum + day.entries.length, 0);
|
||||
}
|
||||
|
||||
get outstandingTotal(): number {
|
||||
return this.days.reduce((sum, day) => sum + day.total, 0);
|
||||
}
|
||||
|
||||
get pickedEntryCount(): number {
|
||||
return this.days.filter((day) => this.pickedDays.has(day.date)).reduce((sum, day) => sum + day.entries.length, 0);
|
||||
}
|
||||
|
||||
get required(): number {
|
||||
return round2(this.days.filter((day) => this.pickedDays.has(day.date)).reduce((sum, day) => sum + day.total, 0));
|
||||
}
|
||||
|
||||
get received(): number {
|
||||
return round2(
|
||||
this.available.filter((r) => this.pickedReceipts.has(r.id)).reduce((sum, r) => sum + Number(r.amount || 0), 0)
|
||||
);
|
||||
}
|
||||
|
||||
/** Vouchers less entries: below zero they are short, above zero they are over. */
|
||||
get difference(): number {
|
||||
return round2(this.received - this.required);
|
||||
}
|
||||
|
||||
get canApply(): boolean {
|
||||
return this.pickedDays.size > 0 && this.pickedReceipts.size > 0 && this.difference === 0 && !this.submitting;
|
||||
}
|
||||
|
||||
glLabel(option: any): string {
|
||||
return [
|
||||
option?.glCode,
|
||||
option?.glName
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' — ');
|
||||
}
|
||||
|
||||
// ── Voucher search, for a receipt the list does not carry ───────────────────────────────────
|
||||
|
||||
searching = false;
|
||||
searchOpen = false;
|
||||
searchVoucherNo = '';
|
||||
|
||||
openSearch(): void {
|
||||
this.searchOpen = true;
|
||||
this.searchVoucherNo = '';
|
||||
this.errorMsg = '';
|
||||
}
|
||||
|
||||
closeSearch(): void {
|
||||
this.searchOpen = false;
|
||||
this.errorMsg = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds one voucher on the agent's GL account and, when it cannot be used, says why - not taken as
|
||||
* DDS, not approved yet, or already applied - rather than simply not finding it.
|
||||
*/
|
||||
runSearch(): void {
|
||||
const voucher = this.searchVoucherNo.trim();
|
||||
if (!voucher || this.searching) {
|
||||
return;
|
||||
}
|
||||
this.searching = true;
|
||||
this.errorMsg = '';
|
||||
this.organizationService
|
||||
.getAgentCashReceipts(this.data.agentId, voucher)
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
this.errorMsg = this.readError(err, `Voucher ${voucher} could not be looked up.`);
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((rows: any) => {
|
||||
this.searching = false;
|
||||
if (rows === null) {
|
||||
return;
|
||||
}
|
||||
const found = (Array.isArray(rows) ? rows : [])[0];
|
||||
if (!found) {
|
||||
this.errorMsg = `Voucher ${voucher} is not on ${this.data.agentCode}'s GL account ${this.data.glCode || ''}.`;
|
||||
} else if (found.entryType !== 'DDS') {
|
||||
this.errorMsg = `Voucher ${voucher} was taken as "${found.entryType || 'untagged'}", not DDS. Ask the cashier to correct the entry type.`;
|
||||
} else if (found.status !== 'APPROVED') {
|
||||
this.errorMsg = `Voucher ${voucher} is ${found.status}. It can be applied once the manager approves it.`;
|
||||
} else if (found.applied) {
|
||||
this.errorMsg = `Voucher ${voucher} has already been applied.`;
|
||||
} else {
|
||||
this.available = [
|
||||
found,
|
||||
...this.available.filter((entry) => entry.id !== found.id)
|
||||
];
|
||||
this.pickedReceipts.add(found.id);
|
||||
this.searchOpen = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── A receipt's own GL entries ──────────────────────────────────────────────────────────────
|
||||
|
||||
viewing: any = null;
|
||||
viewingGlEntries: any[] | null = null;
|
||||
loadingGlEntries = false;
|
||||
|
||||
openDetail(option: any, event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.viewing = option;
|
||||
this.viewingGlEntries = null;
|
||||
this.loadingGlEntries = true;
|
||||
this.organizationService
|
||||
.getCashReceiptGlEntries(option.id)
|
||||
.pipe(catchError(() => of([])))
|
||||
.subscribe((rows: any) => {
|
||||
this.viewingGlEntries = Array.isArray(rows) ? rows : [];
|
||||
this.loadingGlEntries = false;
|
||||
});
|
||||
}
|
||||
|
||||
closeDetail(): void {
|
||||
this.viewing = null;
|
||||
this.viewingGlEntries = null;
|
||||
}
|
||||
|
||||
// ── Submission ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
apply(): void {
|
||||
if (!this.canApply) {
|
||||
return;
|
||||
}
|
||||
this.submitting = true;
|
||||
this.errorMsg = '';
|
||||
const vouchers = this.available.filter((r) => this.pickedReceipts.has(r.id)).map((r) => r.voucherNo);
|
||||
const entries = this.pickedEntryCount;
|
||||
const amount = this.required;
|
||||
this.organizationService
|
||||
.settleAgentCollections({
|
||||
agentId: Number(this.data.agentId),
|
||||
cashierDepositRequestIds: Array.from(this.pickedReceipts),
|
||||
entryDates: Array.from(this.pickedDays),
|
||||
locale: this.settingsService.language.code
|
||||
})
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
this.errorMsg = this.readError(err, 'The receipts could not be applied.');
|
||||
this.submitting = false;
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
if (res) {
|
||||
this.dialogRef.close({ vouchers, entries, amount, response: res });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private readError(err: any, fallback: string): string {
|
||||
return (
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||||
err?.error?.defaultUserMessage ??
|
||||
err?.error?.developerMessage ??
|
||||
fallback
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Entries grouped by the day they were collected, oldest first, each day totalled. */
|
||||
export function groupByDay(entries: any[]): EntryDay[] {
|
||||
const byDay = new Map<string, EntryDay>();
|
||||
for (const entry of entries) {
|
||||
const date = String(entry.entryDate);
|
||||
const day = byDay.get(date) ?? { date, entries: [], total: 0 };
|
||||
day.entries.push(entry);
|
||||
day.total = round2(day.total + Number(entry.amount || 0));
|
||||
byDay.set(date, day);
|
||||
}
|
||||
return Array.from(byDay.values()).sort((a, b) => a.date.localeCompare(b.date));
|
||||
}
|
||||
|
||||
function round2(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
<!--
|
||||
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="dte-container">
|
||||
<div class="dte-header">
|
||||
<button mat-stroked-button [routerLink]="['../']" class="dte-back">
|
||||
<i class="fas fa-arrow-left"></i> Deposits
|
||||
</button>
|
||||
<h2 class="dte-header__title">Daily Transaction Detail</h2>
|
||||
</div>
|
||||
|
||||
<div class="dte-card">
|
||||
<form [formGroup]="entryForm">
|
||||
<div class="dte-info-panels">
|
||||
<div class="dte-info-panel">
|
||||
<div class="dte-info-panel__title">Agent Information</div>
|
||||
<dl>
|
||||
<dt>Name</dt>
|
||||
<dd>{{ agentInfo.name || '—' }}</dd>
|
||||
<dt>Address</dt>
|
||||
<dd>{{ agentInfo.address || '—' }}</dd>
|
||||
<dt>City</dt>
|
||||
<dd>{{ agentInfo.city || '—' }}</dd>
|
||||
<dt>A/C</dt>
|
||||
<dd>{{ agentInfo.accountNo || '—' }}</dd>
|
||||
<dt>Guarantor</dt>
|
||||
<dd>{{ agentInfo.guarantor1 || '—' }}</dd>
|
||||
<dt>Guarantor</dt>
|
||||
<dd>{{ agentInfo.guarantor2 || '—' }}</dd>
|
||||
<dt>Curr Bal</dt>
|
||||
<dd>{{ agentInfo.currentBalance | number: '1.2-2' }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="dte-info-panel">
|
||||
<div class="dte-info-panel__title">Party Information</div>
|
||||
@if (partyInfo) {
|
||||
<dl>
|
||||
<dt>Client Id</dt>
|
||||
<dd>{{ partyInfo.clientId }}</dd>
|
||||
<dt>Name</dt>
|
||||
<dd>{{ partyInfo.partyName }}</dd>
|
||||
<dt>Address</dt>
|
||||
<dd>{{ partyInfo.address1 || '—' }}</dd>
|
||||
<dt>City</dt>
|
||||
<dd>{{ partyInfo.city || '—' }}</dd>
|
||||
<dt>Op. Date</dt>
|
||||
<dd>{{ partyInfo.openingDate ? (partyInfo.openingDate | dateFormat) : '—' }}</dd>
|
||||
<dt>Maturity</dt>
|
||||
<dd>{{ partyInfo.maturityDate ? (partyInfo.maturityDate | dateFormat) : '—' }}</dd>
|
||||
<dt>Daily Depo</dt>
|
||||
<dd>{{ partyInfo.dailyDepositAmount | number: '1.2-2' }}</dd>
|
||||
<dt>Ledger</dt>
|
||||
<dd>{{ partyInfo.ledgerNo || '—' }}</dd>
|
||||
<dt>Aadhar No</dt>
|
||||
<dd>{{ partyInfo.aadharNo || '—' }}</dd>
|
||||
<dt>Curr Bal</dt>
|
||||
<dd>{{ partyInfo.currentBalance | number: '1.2-2' }}</dd>
|
||||
<!--
|
||||
What should have been collected by today against what has been. Beside the balance
|
||||
rather than instead of it: the balance is what the customer holds, this is whether
|
||||
they have kept to the scheme.
|
||||
-->
|
||||
<dt>Due So Far</dt>
|
||||
<dd>{{ partyInfo.expectedToDate | number: '1.2-2' }}</dd>
|
||||
<dt>Collected</dt>
|
||||
<dd>{{ partyInfo.collectedToDate | number: '1.2-2' }}</dd>
|
||||
@if (schedule(partyInfo); as sched) {
|
||||
<dt [class]="'dte-sched dte-sched--' + sched.state">Schedule</dt>
|
||||
<dd [class]="'dte-sched dte-sched--' + sched.state">{{ sched.text }}</dd>
|
||||
}
|
||||
<dt class="dte-after">Bal. After This Tr</dt>
|
||||
<dd class="dte-after">{{ partyBalanceAfter | number: '1.2-2' }}</dd>
|
||||
</dl>
|
||||
} @else {
|
||||
<p class="dte-info-panel__hint">
|
||||
Pick a party in a collection row below — this panel shows whose account it is and what the balance
|
||||
becomes.
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dte-controls">
|
||||
<mat-form-field appearance="outline" class="dte-field dte-field--agent">
|
||||
<mat-label>Agent</mat-label>
|
||||
<mat-select required formControlName="agentId" (selectionChange)="loadAgent()">
|
||||
@for (agent of selectableAgents; track agent.id) {
|
||||
<mat-option [value]="agent.id">
|
||||
{{ agent.agentCode }} · {{ agent.agentNameId }}
|
||||
@if (!agent.active) {
|
||||
<span class="dte-agent-off">— not collecting</span>
|
||||
}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
@if (selectableAgents.length === 0) {
|
||||
<mat-hint class="dte-hint--missing">No active agent on file</mat-hint>
|
||||
} @else if (pickedAgentIsInactive) {
|
||||
<!-- Only reachable on a settlement: this agent no longer collects, but the cash they
|
||||
already hold still has to be able to reach a branch. -->
|
||||
<mat-hint class="dte-hint--missing">This agent no longer collects — settling held cash only</mat-hint>
|
||||
} @else if (agentInfo.name) {
|
||||
<mat-hint class="dte-hint--found">
|
||||
{{ agentParties.length }} {{ agentParties.length === 1 ? 'party' : 'parties' }} to collect from
|
||||
</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="dte-field">
|
||||
<mat-label>Date</mat-label>
|
||||
<!-- The date picks out a round, not just a posting date: a Party Tran settles whatever
|
||||
the agent wrote down for that day, so changing it has to fetch that day's list. -->
|
||||
<input
|
||||
matInput
|
||||
required
|
||||
[matDatepicker]="txnDatePicker"
|
||||
formControlName="transactionDate"
|
||||
(dateChange)="loadPendingEntries(); loadAgentMoney()"
|
||||
/>
|
||||
<mat-datepicker-toggle matIconSuffix [for]="txnDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #txnDatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="dte-field">
|
||||
<mat-label>Tran. Type</mat-label>
|
||||
<mat-select required formControlName="transactionType">
|
||||
@for (type of transactionTypes; track type.label) {
|
||||
<mat-option [value]="type.label" [disabled]="!type.supported">
|
||||
{{ type.label }}
|
||||
@if (!type.supported) {
|
||||
<span class="dte-soon"> — not available yet</span>
|
||||
}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
What the agent wrote down on their round, waiting to be counted.
|
||||
|
||||
This used to be an empty grid the clerk typed the round into, and typing it posted it: a
|
||||
customer was credited the moment a line was keyed, whether or not the cash behind it ever
|
||||
reached the branch. The agent records their own collections now, and what the clerk does here
|
||||
is agree with them - so there is nothing to type, only a list to check against a bag of cash.
|
||||
-->
|
||||
@if (isPartyTran && agentInfo.name) {
|
||||
<div class="dte-section">
|
||||
<div class="dte-section__header">
|
||||
<i class="fas fa-list"></i>
|
||||
<h3>Collections up to {{ entryForm.get('transactionDate')?.value | dateFormat }}</h3>
|
||||
<button mat-stroked-button type="button" class="dte-add-row" (click)="loadPendingEntries()">
|
||||
<i class="fas fa-sync"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (pendingLoading) {
|
||||
<p class="dte-empty">Reading this agent's entries...</p>
|
||||
} @else if (pendingEntries.length === 0) {
|
||||
<p class="dte-empty">{{ agentInfo.name }} has nothing outstanding up to this date.</p>
|
||||
} @else {
|
||||
@for (entry of pendingEntries; track entry.id) {
|
||||
<div class="dte-pending-row">
|
||||
<!-- The day the agent took it, on the line itself. All the lines carry the date
|
||||
chosen above, but a row that says so can be read, printed or shown to the agent
|
||||
on its own without the header to explain it. -->
|
||||
<span class="dte-pending-row__date">{{ entry.entryDate | dateFormat }}</span>
|
||||
<span class="dte-pending-row__code">{{ entry.partyCode }}</span>
|
||||
<span class="dte-pending-row__name">{{ entry.partyName }}</span>
|
||||
<span class="dte-pending-row__mode"
|
||||
>{{ entry.transactionMode }}
|
||||
@if (entry.advanceDays) {
|
||||
· {{ entry.advanceDays }}d advance
|
||||
}
|
||||
@if (entry.arrearsDays) {
|
||||
· {{ entry.arrearsDays }}d previous
|
||||
}
|
||||
</span>
|
||||
<span class="dte-pending-row__amount">{{ entry.amount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="dte-total">
|
||||
<span>{{ pendingEntries.length }} {{ pendingEntries.length === 1 ? 'entry' : 'entries' }}</span>
|
||||
<strong>{{ pendingTotal | number: '1.2-2' }}</strong>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
The figure the clerk counts against, said in words rather than left as a column total -
|
||||
it is the one number they are acting on, and the rest of the panel is working out.
|
||||
|
||||
It says what the agent wrote down and stops there. The money side - what is owed, what the
|
||||
cashier has taken in, and applying it - is Agent Tran, so each type has one job.
|
||||
-->
|
||||
<p class="dte-expected dte-expected--note">
|
||||
Everything {{ agentInfo.name }} wrote down and has not handed in yet, up to this date, customer by
|
||||
customer. The cash and its receipts are under Agent Tran.
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!--
|
||||
The agent's money: what they owe on everything written down, what the cashier has taken in
|
||||
against it, and what is left.
|
||||
|
||||
This used to be a form the clerk typed an amount into to take the agent's cash in here. The
|
||||
cash is taken at the cashier's counter now, into the agent's own GL account, and one way to take
|
||||
the same cash in is the only safe number of ways - so there is nothing to type. What is done here
|
||||
is applying the cashier's receipts to the customers, in Apply DDS Receipt.
|
||||
-->
|
||||
@if (isAgentTran && agentInfo.name) {
|
||||
<div class="dte-section">
|
||||
<div class="dte-section__header">
|
||||
<i class="fas fa-hand-holding-usd"></i>
|
||||
<h3>Agent's cash</h3>
|
||||
<button mat-stroked-button type="button" class="dte-add-row" (click)="loadAgentMoney()">
|
||||
<i class="fas fa-sync"></i> Refresh
|
||||
</button>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
type="button"
|
||||
class="dte-apply-receipt"
|
||||
(click)="openApplyReceipt()"
|
||||
>
|
||||
<i class="fas fa-receipt"></i> Apply DDS Receipt
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (!agentGlCode) {
|
||||
<p class="dte-expected dte-expected--note">
|
||||
{{ agentInfo.name }} has no GL account, so the cashier has nowhere to take their cash. Set one on Agent
|
||||
Master.
|
||||
</p>
|
||||
} @else {
|
||||
<!-- The GL the cashier takes this agent's cash into - the code to key at Transaction Entry. -->
|
||||
<p class="dte-expected">
|
||||
Agent GL: <strong>{{ agentGlCode }}{{ agentGlName ? ' · ' + agentGlName : '' }}</strong> — the cashier
|
||||
takes {{ agentInfo.name }}'s cash into this GL (Entry Type DDS).
|
||||
</p>
|
||||
}
|
||||
|
||||
<div class="dte-money">
|
||||
<div class="dte-money__box">
|
||||
<span class="dte-money__label">To take</span>
|
||||
<strong>{{ agentOwed | number: '1.2-2' }}</strong>
|
||||
</div>
|
||||
<div class="dte-money__box">
|
||||
<span class="dte-money__label">Received (approved)</span>
|
||||
<strong>{{ agentReceived | number: '1.2-2' }}</strong>
|
||||
</div>
|
||||
<div class="dte-money__box" [class.dte-money__box--due]="agentBalance > 0">
|
||||
<span class="dte-money__label">Still to take</span>
|
||||
<strong>{{ agentBalance | number: '1.2-2' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
@if (agentReceivedBeyondDay > 0) {
|
||||
<p class="dte-expected dte-expected--note">
|
||||
{{ agentReceivedBeyondDay | number: '1.2-2' }} received at the counter is more than everything outstanding
|
||||
up to {{ entryForm.get('transactionDate')?.value | dateFormat }}. Check the voucher was taken for the
|
||||
right agent.
|
||||
</p>
|
||||
}
|
||||
@if (agentWaiting > 0) {
|
||||
<p class="dte-expected dte-expected--note">
|
||||
{{ agentWaiting | number: '1.2-2' }} more is at the counter awaiting the manager's approval.
|
||||
</p>
|
||||
}
|
||||
|
||||
<h4 class="dte-subhead">
|
||||
Outstanding entries up to {{ entryForm.get('transactionDate')?.value | dateFormat }}
|
||||
</h4>
|
||||
@if (agentMoneyLoading) {
|
||||
<p class="dte-empty">Reading this agent's entries...</p>
|
||||
} @else if (agentDays.length === 0) {
|
||||
<p class="dte-empty">{{ agentInfo.name }} has nothing outstanding.</p>
|
||||
} @else {
|
||||
@for (day of agentDays; track day.date) {
|
||||
<div class="dte-pending-row dte-pending-row--day" (click)="toggleAgentDay(day.date)">
|
||||
<span class="dte-pending-row__date">{{ day.date | dateFormat }}</span>
|
||||
<span class="dte-pending-row__name">
|
||||
{{ day.entries.length }} {{ day.entries.length === 1 ? 'entry' : 'entries' }}
|
||||
</span>
|
||||
<span class="dte-pending-row__amount">{{ day.total | number: '1.2-2' }}</span>
|
||||
<i [class]="agentOpenDay === day.date ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
|
||||
</div>
|
||||
@if (agentOpenDay === day.date) {
|
||||
<div class="dte-day-lines">
|
||||
@for (entry of day.entries; track entry.id) {
|
||||
<div class="dte-pending-row">
|
||||
<span class="dte-pending-row__code">{{ entry.partyCode }}</span>
|
||||
<span class="dte-pending-row__name">{{ entry.partyName }}</span>
|
||||
<span class="dte-pending-row__mode"
|
||||
>{{ entry.transactionMode }}
|
||||
@if (entry.advanceDays) {
|
||||
· {{ entry.advanceDays }}d advance
|
||||
}
|
||||
@if (entry.arrearsDays) {
|
||||
· {{ entry.arrearsDays }}d previous
|
||||
}
|
||||
</span>
|
||||
<span class="dte-pending-row__amount">{{ entry.amount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
<h4 class="dte-subhead">Cashier receipts</h4>
|
||||
@if (agentReceipts.length === 0) {
|
||||
<p class="dte-empty">
|
||||
No DDS receipt on {{ agentInfo.name }}'s GL account{{ agentGlCode ? ' ' + agentGlCode : '' }} waiting to
|
||||
be applied.
|
||||
</p>
|
||||
} @else {
|
||||
@for (receipt of agentReceipts; track receipt.id) {
|
||||
<div class="dte-pending-row">
|
||||
<span class="dte-pending-row__code">{{ receipt.voucherNo }}</span>
|
||||
<span class="dte-pending-row__date">{{ receipt.txnDate | dateFormat }}</span>
|
||||
<span class="dte-pending-row__name">{{ receipt.cashierName || '—' }}</span>
|
||||
<span class="dte-status" [class.dte-status--ok]="receipt.status === 'APPROVED'">
|
||||
{{ receipt.status === 'APPROVED' ? 'Approved' : 'Awaiting manager' }}
|
||||
</span>
|
||||
<span class="dte-pending-row__amount">{{ receipt.amount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isWithdrawDeposite) {
|
||||
<div class="dte-section">
|
||||
<div class="dte-section__header">
|
||||
<i class="fas fa-exchange-alt"></i>
|
||||
<h3>Withdraw Deposite</h3>
|
||||
</div>
|
||||
<div class="dte-grid">
|
||||
<mat-form-field appearance="outline" class="dte-field">
|
||||
<mat-label>Creditor Party Code</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="creditorPartyCode"
|
||||
[matAutocomplete]="creditorAuto"
|
||||
#creditorTrigger="matAutocompleteTrigger"
|
||||
(click)="creditorTrigger.openPanel()"
|
||||
(focus)="creditorTrigger.openPanel()"
|
||||
(blur)="settleTransferParty('creditor')"
|
||||
placeholder="Search by code or name…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<mat-icon matSuffix class="dte-list-arrow" (click)="$event.stopPropagation(); creditorTrigger.openPanel()"
|
||||
>arrow_drop_down</mat-icon
|
||||
>
|
||||
<mat-autocomplete #creditorAuto="matAutocomplete" panelWidth="min(30rem, 92vw)">
|
||||
@for (party of transferPartyOptions('creditor'); track party.id) {
|
||||
<mat-option [value]="party.partyCode">
|
||||
{{ party.partyCode }} · {{ party.partyName }} · ₹{{ party.currentBalance | number: '1.2-2' }}
|
||||
</mat-option>
|
||||
}
|
||||
@if (transferPartyOptions('creditor').length === 0) {
|
||||
<mat-option disabled>No open DDS account matches</mat-option>
|
||||
}
|
||||
</mat-autocomplete>
|
||||
<mat-hint>Account the money goes into</mat-hint>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="dte-field">
|
||||
<mat-label>Creditor Party Name</mat-label>
|
||||
<input matInput formControlName="creditorPartyName" readonly />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="dte-field">
|
||||
<mat-label>Creditor Curr Bal</mat-label>
|
||||
<input matInput formControlName="creditorPartyBalance" readonly />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="dte-field">
|
||||
<mat-label>Debit Party Code</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="debitPartyCode"
|
||||
[matAutocomplete]="debitAuto"
|
||||
#debitTrigger="matAutocompleteTrigger"
|
||||
(click)="debitTrigger.openPanel()"
|
||||
(focus)="debitTrigger.openPanel()"
|
||||
(blur)="settleTransferParty('debit')"
|
||||
placeholder="Search by code or name…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<mat-icon matSuffix class="dte-list-arrow" (click)="$event.stopPropagation(); debitTrigger.openPanel()"
|
||||
>arrow_drop_down</mat-icon
|
||||
>
|
||||
<mat-autocomplete #debitAuto="matAutocomplete" panelWidth="min(30rem, 92vw)">
|
||||
@for (party of transferPartyOptions('debit'); track party.id) {
|
||||
<mat-option [value]="party.partyCode">
|
||||
{{ party.partyCode }} · {{ party.partyName }} · ₹{{ party.currentBalance | number: '1.2-2' }}
|
||||
</mat-option>
|
||||
}
|
||||
@if (transferPartyOptions('debit').length === 0) {
|
||||
<mat-option disabled>No open DDS account matches</mat-option>
|
||||
}
|
||||
</mat-autocomplete>
|
||||
@if (transferToItself) {
|
||||
<mat-hint class="dte-same-party">Pick a different account - it cannot transfer to itself</mat-hint>
|
||||
} @else {
|
||||
<mat-hint>Account the money is taken from</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="dte-field">
|
||||
<mat-label>Debit Party Name</mat-label>
|
||||
<input matInput formControlName="debitPartyName" readonly />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="dte-field">
|
||||
<mat-label>Debit Curr Bal</mat-label>
|
||||
<input matInput formControlName="debitPartyBalance" readonly />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="dte-field">
|
||||
<mat-label>Amount</mat-label>
|
||||
<input matInput type="number" formControlName="withdrawAmount" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="dte-field dte-field--wide">
|
||||
<mat-label>Description</mat-label>
|
||||
<input matInput formControlName="withdrawDescription" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!isPartyTran && !isWithdrawDeposite && !isAgentTran) {
|
||||
<div class="dte-section">
|
||||
<p class="dte-empty">
|
||||
"{{ entryForm.get('transactionType').value }}" entry screen — same shape as Party Tran, to be filled in once
|
||||
the backend distinguishes these transaction types.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="dte-action-bar">
|
||||
<button mat-raised-button type="button" (click)="exit()">Exit</button>
|
||||
<button mat-stroked-button type="button" (click)="cancel()">Cancel</button>
|
||||
<button mat-stroked-button type="button" (click)="retrieve()">Retrieve</button>
|
||||
<button mat-stroked-button type="button" (click)="viewLedger()">View Ledger</button>
|
||||
<button mat-stroked-button type="button" (click)="signature()">Signature</button>
|
||||
<!-- Party Tran and Agent Tran are read here, never saved: the agent writes the round down on
|
||||
their own screen and the cash is taken in at the cashier's counter. Agent Tran applies the
|
||||
cashier's receipts from its own Apply DDS Receipt. Only Withdraw Deposite still saves. -->
|
||||
@if (!isPartyTran && !isAgentTran) {
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
type="button"
|
||||
[disabled]="entryForm.invalid || transferToItself"
|
||||
(click)="save()"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,488 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
$primary: #0f766e;
|
||||
|
||||
.dte-container {
|
||||
// Wider than a normal form page: a collection row carries six fields side by side, and a clerk
|
||||
// keying a round should see the whole of it.
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.dte-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.dte-back {
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
.dte-card {
|
||||
background: var(--md-sys-color-surface, #fff);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
|
||||
padding: 20px 24px 24px;
|
||||
}
|
||||
|
||||
.dte-info-panels {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.dte-info-panel {
|
||||
border: 1px solid rgb(0 0 0 / 10%);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
background: rgb(15 118 110 / 4%);
|
||||
|
||||
&__title {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: $primary;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
margin: 0;
|
||||
color: rgb(0 0 0 / 50%);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 4px 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
font-weight: 500;
|
||||
color: rgb(0 0 0 / 60%);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.dte-controls {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
// A sensible floor for the fields laid out with flex in .dte-controls, where there is room to spare.
|
||||
.dte-field {
|
||||
min-width: 180px;
|
||||
|
||||
&--wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Inside the collection grid the track decides the width, not the field. Left to its own 180px
|
||||
// floor a field refuses to shrink into a narrower track and overruns the one beside it.
|
||||
.dte-collection-row .dte-field {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dte-section {
|
||||
padding: 16px 0;
|
||||
border-top: 1px solid rgb(0 0 0 / 8%);
|
||||
// A very narrow window scrolls the grid sideways rather than the whole page.
|
||||
overflow-x: auto;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
i {
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dte-add-row {
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
.dte-empty {
|
||||
color: rgb(0 0 0 / 50%);
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dte-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px 16px;
|
||||
}
|
||||
|
||||
/*
|
||||
* One collection per row. Fixed columns rather than an even split, so the row holds its shape as
|
||||
* fields come and go - Cheque No and Token No only appear for a non-cash entry - and the party
|
||||
* picker gets the width its "code · name · daily amount" needs.
|
||||
*/
|
||||
.dte-collection-row {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
minmax(0, 2.2fr) /* Party */
|
||||
minmax(0, 1fr) /* TRTP */
|
||||
minmax(0, 1fr) /* AC Type */
|
||||
minmax(0, 1.4fr) /* A/C No. */
|
||||
minmax(0, 1fr) /* Amount */
|
||||
minmax(0, 1.6fr) /* Description */
|
||||
auto; /* remove */
|
||||
|
||||
gap: 8px 12px;
|
||||
align-items: start;
|
||||
padding: 10px 0;
|
||||
border-top: 1px dashed rgb(0 0 0 / 10%);
|
||||
|
||||
&:first-of-type {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
// Cheque No and Token No push Description and the remove button onto a second line, which is
|
||||
// what a non-cash entry should look like rather than a row running off the screen.
|
||||
&:has(.dte-field--instrument) {
|
||||
grid-template-columns:
|
||||
minmax(0, 2.2fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.4fr) minmax(0, 1fr)
|
||||
minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.6fr) auto;
|
||||
}
|
||||
}
|
||||
|
||||
.dte-collection-row .dte-field--wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
@media (width <= 1200px) {
|
||||
.dte-collection-row,
|
||||
.dte-collection-row:has(.dte-field--instrument) {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr)) auto;
|
||||
}
|
||||
}
|
||||
|
||||
.dte-remove-row {
|
||||
color: #b91c1c;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.dte-action-bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgb(0 0 0 / 8%);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (width <= 1000px) {
|
||||
.dte-info-panels {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dte-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.dte-collection-row {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* Resolved name under an Agent / Party Code field — the check that the right record was found. */
|
||||
.dte-hint--found {
|
||||
color: #15803d;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dte-hint--missing {
|
||||
color: #b91c1c;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Marks an agent in the list who is no longer collecting - only offered for settlement. */
|
||||
.dte-agent-off {
|
||||
color: #b45309;
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
/* The figure the clerk actually verifies against the passbook before saving. */
|
||||
.dte-after {
|
||||
font-weight: 600;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
/* What the agent should be handing over for this round. */
|
||||
.dte-total {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: baseline;
|
||||
gap: 14px;
|
||||
padding: 10px 4px 2px;
|
||||
border-top: 1px solid rgb(0 0 0 / 10%);
|
||||
font-size: 0.88rem;
|
||||
color: rgb(0 0 0 / 60%);
|
||||
|
||||
strong {
|
||||
font-size: 1.1rem;
|
||||
color: rgb(0 0 0 / 85%);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
/* Marks a transaction type the system does not handle yet, so it reads as pending rather than broken. */
|
||||
.dte-soon {
|
||||
font-size: 0.78rem;
|
||||
font-style: italic;
|
||||
color: rgb(0 0 0 / 45%);
|
||||
}
|
||||
|
||||
/*
|
||||
* Marks a party this agent has already collected from today, on the row where it is picked.
|
||||
*
|
||||
* Amber, not red: a customer paying twice in a day is legitimate - arrears, or two days at once -
|
||||
* so this is a second look, not an error. Blocking it would stop real work; saying nothing lets a
|
||||
* whole round be credited twice.
|
||||
*/
|
||||
.dte-hint--again {
|
||||
color: #b45309;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/*
|
||||
* Where a party stands against its instalments. Three states rather than one colour, because they
|
||||
* call for three different things from the clerk: ahead means take nothing, behind means take more
|
||||
* than the daily amount, level means take the daily amount as usual.
|
||||
*
|
||||
* Behind is amber, not red - falling a few days behind is ordinary in daily collection and is the
|
||||
* agent's job to recover, not an error. Red here would cry wolf on most rows.
|
||||
*/
|
||||
.dte-hint--ahead,
|
||||
.dte-sched--ahead {
|
||||
color: #15803d;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dte-hint--behind,
|
||||
.dte-sched--behind {
|
||||
color: #b45309;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dte-hint--level,
|
||||
.dte-sched--level {
|
||||
color: rgb(0 0 0 / 55%);
|
||||
}
|
||||
|
||||
/* In the party panel the schedule line carries more weight than the fields around it. */
|
||||
.dte-sched {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Says why a party in the list cannot be picked, beside the party rather than in a tooltip. */
|
||||
.dte-covered {
|
||||
color: #15803d;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/*
|
||||
* An amount that looks like a slip. Red rather than the amber used for arrears: arrears are ordinary
|
||||
* and the agent's job to recover, while this is a figure that probably was not handed over at all,
|
||||
* and once saved it cannot be taken back. It still does not block - the clerk decides.
|
||||
*/
|
||||
.dte-hint--overpay {
|
||||
color: #b91c1c;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/*
|
||||
The agent's written-down round, as the counter reads it.
|
||||
|
||||
Laid out as lines rather than as the input grid this replaced: there is nothing to fill in here,
|
||||
only a list to check a bag of cash against, and controls would invite editing something the clerk
|
||||
has no business editing.
|
||||
*/
|
||||
.dte-pending-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
padding: 10px 2px;
|
||||
border-bottom: 1px solid rgb(0 0 0 / 8%);
|
||||
|
||||
&:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* First, and in the quieter weight: the date is context for the line, not what is being read. */
|
||||
&__date {
|
||||
min-width: 96px;
|
||||
opacity: 0.7;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
&__code {
|
||||
font-weight: 600;
|
||||
min-width: 84px;
|
||||
}
|
||||
|
||||
&__name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
&__mode {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Amounts are read down the column and compared, so the digits have to line up. */
|
||||
&__amount {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
min-width: 96px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
/* The one figure the clerk acts on, said as a sentence so it is not read as another column total. */
|
||||
.dte-expected {
|
||||
margin: 14px 2px 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
|
||||
strong {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
/* Quieter than the figure above it: a note about how the screen behaves, not part of the count. */
|
||||
.dte-expected--note {
|
||||
font-size: 13px;
|
||||
opacity: 0.65;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.dte-apply-receipt {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* Agent Tran: what the agent owes, what the cashier has taken in, and what is left. */
|
||||
.dte-money {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin: 8px 0 12px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
&__box {
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid rgb(0 0 0 / 10%);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
strong {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
&--due {
|
||||
border-color: rgb(198 40 40 / 40%);
|
||||
background: rgb(198 40 40 / 4%);
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
}
|
||||
|
||||
.dte-subhead {
|
||||
margin: 16px 0 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dte-pending-row--day {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dte-day-lines {
|
||||
margin-left: 24px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
border-left: 3px solid rgb(25 118 210 / 30%);
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.dte-status {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: rgb(237 108 2 / 12%);
|
||||
color: #b45309;
|
||||
|
||||
&--ok {
|
||||
background: rgb(46 125 50 / 12%);
|
||||
color: #2e7d32;
|
||||
}
|
||||
}
|
||||
|
||||
.dte-list-arrow {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dte-same-party {
|
||||
color: #c62828;
|
||||
font-weight: 600;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+56
@@ -0,0 +1,56 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<h2 mat-dialog-title>{{ title }}</h2>
|
||||
|
||||
<mat-dialog-content>
|
||||
<mat-form-field appearance="outline" class="dcd-field">
|
||||
<mat-label>Party</mat-label>
|
||||
<mat-select [(ngModel)]="partyCode" (selectionChange)="partyChanged()">
|
||||
@for (p of eligibleParties; track p.id) {
|
||||
<mat-option [value]="p.partyCode">
|
||||
{{ p.partyCode }} · {{ p.partyName }} · Daily {{ p.dailyDepositAmount | number: '1.0-0' }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
@if (eligibleParties.length === 0) {
|
||||
<!-- Only reachable on the arrears side: nobody is behind within the window. -->
|
||||
<mat-hint class="dcd-none">No party has recoverable days</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="dcd-field">
|
||||
<mat-label>Days</mat-label>
|
||||
<mat-select [(ngModel)]="days" [disabled]="!partyCode">
|
||||
@for (d of dayOptions; track d) {
|
||||
<mat-option [value]="d">{{ d }} {{ d === 1 ? 'day' : 'days' }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
@if (isAdvance) {
|
||||
<mat-hint>Up to 5 days</mat-hint>
|
||||
} @else if (partyCode) {
|
||||
<mat-hint>Only days still within the catch-up window</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<!--
|
||||
What it comes to, shown before it is added rather than after. The clerk quotes this to the
|
||||
customer, so it has to be the scheme's own figure and has to be visible while deciding.
|
||||
-->
|
||||
@if (amount > 0) {
|
||||
<div class="dcd-amount">
|
||||
<span class="dcd-amount__figure">{{ amount | number: '1.2-2' }}</span>
|
||||
<span class="dcd-amount__basis"> {{ days }} × {{ party?.dailyDepositAmount | number: '1.2-2' }} daily </span>
|
||||
</div>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button mat-dialog-close type="button">Cancel</button>
|
||||
<button mat-raised-button color="primary" type="button" [disabled]="!canAdd" (click)="add()">Add</button>
|
||||
</mat-dialog-actions>
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.dcd-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dcd-none {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
/*
|
||||
* The figure the clerk quotes at the counter. Larger than the fields above it because it is the one
|
||||
* thing the customer is told, and it is shown with its basis so the clerk can say where it came from
|
||||
* rather than just naming a number.
|
||||
*/
|
||||
.dcd-amount {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
margin-top: 6px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 6px;
|
||||
background: rgb(15 118 110 / 8%);
|
||||
|
||||
&__figure {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
color: #0f766e;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
&__basis {
|
||||
font-size: 0.8rem;
|
||||
color: rgb(0 0 0 / 60%);
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* 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, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
|
||||
/**
|
||||
* Collecting for days other than today - either paid ahead, or owed from days gone by.
|
||||
*
|
||||
* Both are the same act from the counter's side: the customer is paying for a number of days, and
|
||||
* what that comes to is the scheme's business, not the clerk's. So the clerk picks days and the
|
||||
* amount follows. Typing an amount instead would let the daily figure be worked around a little at a
|
||||
* time, and would quote the customer something the scheme does not charge.
|
||||
*
|
||||
* The two differ only in how many days are on offer. Paying ahead is capped by the bank's allowance.
|
||||
* Paying arrears is capped by what is still inside the catch-up window - older instalments are gone,
|
||||
* and offering them would have the clerk asking for money the bank has already written off.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-day-collection-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatSelectModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule
|
||||
],
|
||||
templateUrl: './day-collection-dialog.component.html',
|
||||
styleUrls: ['./day-collection-dialog.component.scss']
|
||||
})
|
||||
export class DayCollectionDialogComponent {
|
||||
private dialogRef = inject(MatDialogRef<DayCollectionDialogComponent>);
|
||||
/** { mode: 'advance' | 'previous', parties: [] } - the agent's parties, as the screen already has them. */
|
||||
data = inject<any>(MAT_DIALOG_DATA);
|
||||
|
||||
partyCode = '';
|
||||
days: number | '' = '';
|
||||
|
||||
get isAdvance(): boolean {
|
||||
return this.data?.mode === 'advance';
|
||||
}
|
||||
|
||||
get title(): string {
|
||||
return this.isAdvance ? 'Collect in advance' : 'Collect previous days';
|
||||
}
|
||||
|
||||
get party(): any {
|
||||
return (this.data?.parties || []).find((p: any) => String(p.partyCode) === this.partyCode) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The parties this can be used on.
|
||||
*
|
||||
* Paying ahead is offered to anyone. Paying arrears is offered only where there are arrears still
|
||||
* inside the window - on an account that is up to date there is nothing to collect for past days,
|
||||
* and listing it would invite an entry that is really an advance in disguise.
|
||||
*/
|
||||
get eligibleParties(): any[] {
|
||||
const parties = this.data?.parties || [];
|
||||
return this.isAdvance ? parties : parties.filter((p: any) => Number(p.recoverableAmount || 0) > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* How many days may be picked. Five ahead, matching the bank's allowance and the server's own
|
||||
* check; for arrears, only what has not yet fallen out of the catch-up window.
|
||||
*/
|
||||
get dayOptions(): number[] {
|
||||
const daily = Number(this.party?.dailyDepositAmount || 0);
|
||||
if (!this.party || daily <= 0) {
|
||||
return [];
|
||||
}
|
||||
const limit = this.isAdvance ? 5 : Math.floor(Number(this.party.recoverableAmount || 0) / daily);
|
||||
return Array.from({ length: Math.max(0, limit) }, (_, index) => index + 1);
|
||||
}
|
||||
|
||||
get amount(): number {
|
||||
return Number(this.party?.dailyDepositAmount || 0) * Number(this.days || 0);
|
||||
}
|
||||
|
||||
/** Clearing the days when the party changes - a count valid for one account is not for another. */
|
||||
partyChanged(): void {
|
||||
this.days = '';
|
||||
}
|
||||
|
||||
get canAdd(): boolean {
|
||||
return !!this.partyCode && Number(this.days) > 0 && this.amount > 0;
|
||||
}
|
||||
|
||||
add(): void {
|
||||
this.dialogRef.close({
|
||||
partyCode: this.partyCode,
|
||||
days: Number(this.days),
|
||||
amount: this.amount,
|
||||
// Sent as days, not just an amount: the server recomputes what they come to and checks it
|
||||
// against this account, so the figure quoted here is the one that gets posted.
|
||||
advanceDays: this.isAdvance ? Number(this.days) : undefined,
|
||||
arrearsDays: this.isAdvance ? undefined : Number(this.days)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<!--
|
||||
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="nd-head">
|
||||
<span class="nd-head__ic"><fa-icon icon="info-circle"></fa-icon></span>
|
||||
<h2 class="nd-head__title">{{ data.heading }}</h2>
|
||||
</div>
|
||||
|
||||
<mat-dialog-content>
|
||||
<p class="nd-message">{{ data.message }}</p>
|
||||
</mat-dialog-content>
|
||||
|
||||
<!-- One button, because there is nothing here to agree to or call off. -->
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-flat-button color="primary" mat-dialog-close>OK</button>
|
||||
</mat-dialog-actions>
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.nd-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 18px 24px 4px;
|
||||
|
||||
/*
|
||||
* Teal rather than the amber or red the platform's other dialogs use. Nothing has gone wrong here
|
||||
* and nothing is at risk - the clerk is being told a fact about the agent they picked, and a
|
||||
* warning colour would send them looking for a fault that is not there.
|
||||
*/
|
||||
&__ic {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 8px;
|
||||
background: rgb(15 118 110 / 10%);
|
||||
color: #0f766e;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.nd-message {
|
||||
margin: 0;
|
||||
max-width: 44ch; /* stylelint-disable-line unit-allowed-list */
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.55;
|
||||
color: rgb(0 0 0 / 70%);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 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, inject } from '@angular/core';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
|
||||
/**
|
||||
* Something the clerk needs told, with nothing to decide.
|
||||
*
|
||||
* The platform's confirmation dialog asks a question - Cancel or Confirm - and its buttons imply a
|
||||
* choice. Used for a plain statement it leaves the clerk hunting for what they are agreeing to, and
|
||||
* "Cancel" reads as though the thing being described could be undone. The error dialog has the right
|
||||
* single button but dresses the message in red, which turns "this agent has no parties" into a fault.
|
||||
*
|
||||
* So: one button, and the message stands on its own.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-notice-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
FaIconComponent
|
||||
],
|
||||
templateUrl: './notice-dialog.component.html',
|
||||
styleUrls: ['./notice-dialog.component.scss']
|
||||
})
|
||||
export class NoticeDialogComponent {
|
||||
/** { heading, message } */
|
||||
data = inject<any>(MAT_DIALOG_DATA);
|
||||
}
|
||||
Reference in New Issue
Block a user