Compare commits

..
Author SHA1 Message Date
niteshandClaude Sonnet 5 244b70f926 fix(savings): show a clear message when an action is parked for checker approval
Approve, close, reject, undo-approval, withdraw-by-client, and post-interest
on a savings account all silently behaved as if nothing happened when the
backend parks the command in the Checker Inbox instead of executing it
(HTTP 200 with rollbackTransaction: true) -- misleading the maker into
thinking the action either failed or quietly succeeded. Each now shows an
explicit alert naming what was sent for approval and that the account/
application is unchanged until a checker approves it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-05 16:14:24 +05:30
379 changed files with 5814 additions and 55944 deletions
+2 -9
View File
@@ -31,8 +31,6 @@
"@angular/localize/init"
],
"assets": [
"src/favicon.ico",
"src/apple-touch-icon.png",
"src/assets/images/cbsnexora-favicon.svg",
"src/robots.txt",
"src/manifest.json",
@@ -41,11 +39,6 @@
"glob": "**/*",
"input": "node_modules/tinymce",
"output": "assets/tinymce"
},
{
"glob": "lottie.min.js",
"input": "node_modules/lottie-web/build/player",
"output": "assets/js"
}
],
"styles": [
@@ -76,8 +69,8 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "18mb",
"maximumError": "20mb"
"maximumWarning": "15mb",
"maximumError": "17mb"
},
{
"type": "anyComponentStyle",
-7
View File
@@ -41,7 +41,6 @@
"jspdf-autotable": "^5.0.2",
"lightgallery": "^2.9.0",
"lodash": "4.17.23",
"lottie-web": "^5.13.0",
"moment": "^2.29.4",
"ngx-image-cropper": "^9.1.6",
"ngx-mat-select-search": "^8.0.2",
@@ -18381,12 +18380,6 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/lottie-web": {
"version": "5.13.0",
"resolved": "https://registry.npmjs.org/lottie-web/-/lottie-web-5.13.0.tgz",
"integrity": "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==",
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
-1
View File
@@ -71,7 +71,6 @@
"jspdf-autotable": "^5.0.2",
"lightgallery": "^2.9.0",
"lodash": "4.17.23",
"lottie-web": "^5.13.0",
"moment": "^2.29.4",
"ngx-image-cropper": "^9.1.6",
"ngx-mat-select-search": "^8.0.2",
@@ -7,6 +7,16 @@
-->
<div class="container">
<div class="container m-b-20 align-end gap-2px">
@if (!viewAccountTransferData.reversed) {
<span>
<button mat-raised-button color="warn" *mifosxHasPermission="'ADJUST_ACCOUNTTRANSFER'">
<fa-icon icon="undo" class="m-r-10"></fa-icon>{{ 'labels.buttons.Undo' | translate }}
</button>
</span>
}
</div>
<mat-card>
<mat-card-content>
<div class="transfer-container">
@@ -353,7 +353,12 @@
<div class="gl-fgroup">
<label class="gl-flabel"><fa-icon icon="calendar-check"></fa-icon> Submitted From</label>
<mat-form-field appearance="outline" class="gl-ffield">
<input matInput [matDatepicker]="dpSubFrom" [min]="minDate" [formControl]="submittedOnDateFrom" />
<input
matInput
[matDatepicker]="dpSubFrom"
[min]="minDate"
[formControl]="submittedOnDateFrom"
/>
<mat-datepicker-toggle matSuffix [for]="dpSubFrom"></mat-datepicker-toggle>
<mat-datepicker #dpSubFrom></mat-datepicker>
</mat-form-field>
@@ -11,14 +11,6 @@ import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
import { AccountsListComponent } from './accounts-list/accounts-list.component';
import { PartyMasterComponent } from '../deposits/party-master/party-master.component';
import { CreatePartyComponent } from '../deposits/party-master/create-party/create-party.component';
import { DueForClosureComponent } from '../deposits/party-master/due-for-closure/due-for-closure.component';
import { DailyTransactionEntryComponent } from '../deposits/daily-transaction-entry/daily-transaction-entry.component';
import { DdsReportsComponent } from '../deposits/dds-reports/dds-reports.component';
import { ReportViewerComponent } from '../deposits/dds-reports/report-viewer/report-viewer.component';
import { DdsAccountsComponent } from '../deposits/dds-accounts/dds-accounts.component';
import { DdsAccountViewComponent } from '../deposits/dds-accounts/dds-account-view/dds-account-view.component';
const routes: Routes = [
Route.withShell([
@@ -49,59 +41,6 @@ const routes: Routes = [
title: 'Recurring Deposit Accounts',
breadcrumb: 'Recurring Deposit Accounts'
}
},
{
path: 'dds',
data: { title: 'Daily Deposit Scheme — Party Master', breadcrumb: 'Party Master' },
children: [
{ path: '', component: PartyMasterComponent },
{
path: 'create',
component: CreatePartyComponent,
data: { title: 'Create Party', breadcrumb: 'Create' }
},
{
// Ahead of the :partyId routes, so "dueforclosure" is not read as a party id.
path: 'dueforclosure',
component: DueForClosureComponent,
data: { title: 'Accounts Due for Closure', breadcrumb: 'Due for Closure' }
},
{
path: ':partyId/edit',
component: CreatePartyComponent,
data: { title: 'Edit Party', breadcrumb: 'Edit' }
}
]
},
{
// The DDS accounts listed like Savings Accounts, each opening onto its transactions.
path: 'dds-accounts',
data: { title: 'DDS Accounts', breadcrumb: 'DDS Accounts' },
children: [
{ path: '', component: DdsAccountsComponent },
{
path: ':partyId',
component: DdsAccountViewComponent,
data: { title: 'DDS Account', breadcrumb: 'Account', routeParamBreadcrumb: false }
}
]
},
{
path: 'dds-collection',
component: DailyTransactionEntryComponent,
data: { title: 'Daily Transaction Entry', breadcrumb: 'Daily Transaction Entry' }
},
{
path: 'dds-reports',
data: { title: 'DDS Reports', breadcrumb: 'DDS Reports' },
children: [
{ path: '', component: DdsReportsComponent },
{
path: 'view/:reportName',
component: ReportViewerComponent,
data: { title: 'DDS Report', breadcrumb: 'reportName', routeParamBreadcrumb: 'reportName' }
}
]
}
]
}
-5
View File
@@ -85,11 +85,6 @@ export class AccountsService {
return this.http.get('/fixeddepositaccounts/maturity-dashboard');
}
/** RD Dashboard: running RDs with their installment position, closures and penalty income for the month. */
getRecurringDepositDashboard(): Observable<any> {
return this.http.get('/recurringdepositaccounts/dashboard');
}
getRecurringDepositAccounts(params: AccountListParams = {}): Observable<any> {
let httpParams = new HttpParams()
.set('paged', 'true')
-4
View File
@@ -64,7 +64,6 @@ import { BankCalendarModule } from './bank-calendar/bank-calendar.module';
import { DeafModule } from './deaf/deaf.module';
import { SavingsDailyInterestModule } from './savings-daily-interest/savings-daily-interest.module';
import { FdDashboardModule } from './fd-dashboard/fd-dashboard.module';
import { RdDashboardModule } from './rd-dashboard/rd-dashboard.module';
import { DicgcModule } from './dicgc/dicgc.module';
import { FiuModule } from './fiu/fiu.module';
import { GlReportsModule } from './gl-reports/gl-reports.module';
@@ -97,7 +96,6 @@ import { TokenInterceptor as ZitadelTokenInterceptor } from './zitadel/token.int
import { AuthService } from './zitadel/auth.service';
import { environment } from '../environments/environment';
import { CallbackComponent } from './zitadel/callback/callback.component';
import { LoadingScreenComponent } from './shared/loading-screen/loading-screen.component';
import { OAuthModule } from 'angular-oauth2-oidc';
export class CustomMissingTranslationHandler implements MissingTranslationHandler {
@@ -180,7 +178,6 @@ export function HttpLoaderFactory(http: HttpClient) {
DeafModule,
SavingsDailyInterestModule,
FdDashboardModule,
RdDashboardModule,
DicgcModule,
FiuModule,
GlReportsModule,
@@ -198,7 +195,6 @@ export function HttpLoaderFactory(http: HttpClient) {
AppRoutingModule,
NotFoundComponent,
CallbackComponent,
LoadingScreenComponent,
OAuthModule.forRoot()
],
providers: [
@@ -44,11 +44,8 @@
<div class="cal-stat sat2nd4th">
<fa-icon icon="calendar-times" class="cal-stat__icon"></fa-icon>
<div class="cal-stat__info">
<div class="cal-stat__value">{{ overallStats.sat2nd4th + overallStats.weeklyOff }}</div>
<div class="cal-stat__label">
{{ saturdayIsWorkingDay ? '2nd & 4th Sat' : 'Saturdays'
}}{{ overallStats.weeklyOff > 0 ? ' / Weekly Off' : '' }}
</div>
<div class="cal-stat__value">{{ overallStats.sat2nd4th }}</div>
<div class="cal-stat__label">2nd &amp; 4th Sat</div>
</div>
</div>
<div class="cal-stat holidays">
@@ -72,11 +69,7 @@
<div class="cal-legend__items">
<div class="cal-legend__item"><span class="cal-legend__dot working"></span> Working Day</div>
<div class="cal-legend__item"><span class="cal-legend__dot sunday"></span> Sunday</div>
<div class="cal-legend__item">
<span class="cal-legend__dot sat2nd4th"></span>
{{ saturdayIsWorkingDay ? '2nd / 4th Saturday' : 'Saturday'
}}{{ overallStats.weeklyOff > 0 ? ' / Weekly Off' : '' }}
</div>
<div class="cal-legend__item"><span class="cal-legend__dot sat2nd4th"></span> 2nd / 4th Saturday</div>
<div class="cal-legend__item"><span class="cal-legend__dot holiday"></span> Holiday</div>
<div class="cal-legend__item"><span class="cal-legend__dot today"></span> Today</div>
</div>
@@ -326,9 +319,7 @@
[class.cal-grid__day--other]="!day.isCurrentMonth"
[class.cal-grid__day--today]="day.isToday"
[class.cal-grid__day--sunday]="day.isSunday && day.isCurrentMonth"
[class.cal-grid__day--sat2nd4th]="
(day.is2ndSaturday || day.is4thSaturday || day.isWeeklyOff) && day.isCurrentMonth
"
[class.cal-grid__day--sat2nd4th]="(day.is2ndSaturday || day.is4thSaturday) && day.isCurrentMonth"
[class.cal-grid__day--holiday]="day.isHoliday && day.isCurrentMonth"
[class.cal-grid__day--working]="day.isWorking && day.isCurrentMonth"
[matTooltip]="
@@ -337,11 +328,9 @@
? '2nd Saturday Off'
: day.is4thSaturday
? '4th Saturday Off'
: day.isWeeklyOff && day.isCurrentMonth
? weekDays[day.dayOfWeek] + ' Off (Working Days)'
: day.isSunday && day.isCurrentMonth
? 'Sunday'
: '')
: day.isSunday && day.isCurrentMonth
? 'Sunday'
: '')
"
>
{{ day.isCurrentMonth ? day.date : '' }}
@@ -354,15 +343,10 @@
@if (month.holidays > 0) {
<span class="summary-tag holiday">{{ month.holidays }} Holiday{{ month.holidays > 1 ? 's' : '' }}</span>
}
@if (month.sundays > 0) {
<span class="summary-tag sun">{{ month.sundays }} Sun</span>
}
<span class="summary-tag sun">{{ month.sundays }} Sun</span>
@if (month.secondFourthSat > 0) {
<span class="summary-tag sat">{{ month.secondFourthSat }} Sat Off</span>
}
@if (month.weeklyOff > 0) {
<span class="summary-tag sat">{{ month.weeklyOff }} Weekly Off</span>
}
</div>
</div>
}
@@ -63,8 +63,6 @@ interface CalendarDay {
isSunday: boolean;
is2ndSaturday: boolean;
is4thSaturday: boolean;
/** Off every week because Admin > Working Days leaves this weekday out (Sunday aside). */
isWeeklyOff: boolean;
holidayName?: string;
dayOfWeek: number; // 0=Sun, 6=Sat
}
@@ -78,23 +76,9 @@ interface MonthData {
nonWorkingDays: number;
holidays: number;
sundays: number;
/** Saturdays off: the 2nd and 4th when Saturday is a working day, every one when it is not. */
secondFourthSat: number;
/** Other weekdays off every week because Working Days leaves them out. */
weeklyOff: number;
}
/** Weekday codes in Date#getDay() order, as used by the /workingdays recurrence (BYDAY=MO,TU,...). */
const DAY_CODES = [
'SU',
'MO',
'TU',
'WE',
'TH',
'FR',
'SA'
];
@Component({
selector: 'mifosx-bank-calendar',
templateUrl: './bank-calendar.component.html',
@@ -134,7 +118,7 @@ export class BankCalendarComponent implements OnInit {
];
workingDaysConfig: string[] = [];
holidays: any[] = [];
overallStats = { working: 0, nonWorking: 0, holidays: 0, sundays: 0, sat2nd4th: 0, weeklyOff: 0, totalDays: 0 };
overallStats = { working: 0, nonWorking: 0, holidays: 0, sundays: 0, sat2nd4th: 0, totalDays: 0 };
// ───── Holiday list (office-wise) ─────
offices: OfficeLite[] = [];
@@ -218,8 +202,7 @@ export class BankCalendarComponent implements OnInit {
}
private loadWorkingDays() {
// Default Indian banking working days: Mon-Sat (Sun off, 2nd & 4th Sat off), used until
// Admin > Working Days answers, or if it cannot be read.
// Default Indian banking working days: Mon-Sat (Sun off, 2nd & 4th Sat off)
this.workingDaysConfig = [
'MO',
'TU',
@@ -232,19 +215,6 @@ export class BankCalendarComponent implements OnInit {
this.buildCalendar();
this.rebuildHolidayRows();
// The calendar has to agree with Admin > Working Days: a weekday unticked there is off every
// week here too.
this.organizationService.getWorkingDays().subscribe({
next: (data: any) => {
const configured = this.parseWorkingDays(data?.recurrence);
if (configured.length > 0) {
this.workingDaysConfig = configured;
this.buildCalendar();
}
},
error: () => {}
});
// Pull offices then fan-out a per-office holiday fetch so each office's
// calendar entries are surfaced in the list panel (calendar grid still
// unions everything for the year-at-a-glance view).
@@ -303,23 +273,6 @@ export class BankCalendarComponent implements OnInit {
});
}
/** Weekday codes out of a recurrence such as "FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,TU,WE,TH,FR". */
private parseWorkingDays(recurrence: string | undefined): string[] {
const byDay = /BYDAY=([^;]*)/.exec(recurrence || '');
if (!byDay) {
return [];
}
return byDay[1]
.split(',')
.map((d) => d.trim().toUpperCase())
.filter((d) => DAY_CODES.includes(d));
}
/** True when Admin > Working Days counts Saturday as a working day (so only the 2nd and 4th are off). */
get saturdayIsWorkingDay(): boolean {
return this.workingDaysConfig.includes('SA');
}
/** Flatten the holidays array into a presentable list with office labels. */
private rebuildHolidayRows(): void {
this.holidayRows = this.holidays.map((h: any) => {
@@ -558,15 +511,7 @@ export class BankCalendarComponent implements OnInit {
buildCalendar() {
this.months = [];
this.overallStats = {
working: 0,
nonWorking: 0,
holidays: 0,
sundays: 0,
sat2nd4th: 0,
weeklyOff: 0,
totalDays: 0
};
this.overallStats = { working: 0, nonWorking: 0, holidays: 0, sundays: 0, sat2nd4th: 0, totalDays: 0 };
for (let m = 0; m < 12; m++) {
const monthData = this.buildMonth(m, this.currentYear);
@@ -576,7 +521,6 @@ export class BankCalendarComponent implements OnInit {
this.overallStats.holidays += monthData.holidays;
this.overallStats.sundays += monthData.sundays;
this.overallStats.sat2nd4th += monthData.secondFourthSat;
this.overallStats.weeklyOff += monthData.weeklyOff;
}
this.overallStats.totalDays = this.overallStats.working + this.overallStats.nonWorking;
}
@@ -608,7 +552,6 @@ export class BankCalendarComponent implements OnInit {
let holidayCount = 0;
let sundayCount = 0;
let sat2nd4thCount = 0;
let weeklyOffCount = 0;
// Previous month padding
const prevMonthDays = new Date(year, month, 0).getDate();
@@ -624,7 +567,6 @@ export class BankCalendarComponent implements OnInit {
isSunday: false,
is2ndSaturday: false,
is4thSaturday: false,
isWeeklyOff: false,
dayOfWeek: (startDayOfWeek - i - 1 + 7) % 7
});
}
@@ -634,29 +576,24 @@ export class BankCalendarComponent implements OnInit {
for (let d = 1; d <= daysInMonth; d++) {
const date = new Date(year, month, d);
const dow = date.getDay();
const isSunday = dow === 0;
const isSaturday = dow === 6;
const isWorkingWeekday = this.workingDaysConfig.includes(DAY_CODES[dow]);
const isSunday = dow === 0 && !isWorkingWeekday;
if (isSaturday) saturdayCount++;
// RBI: when Saturday is a working day the 2nd and 4th are still off. When Working Days leaves
// Saturday out, every Saturday is a weekly off instead.
const is2ndSat = isSaturday && isWorkingWeekday && saturdayCount === 2;
const is4thSat = isSaturday && isWorkingWeekday && saturdayCount === 4;
const isWeeklyOff = dow !== 0 && !isWorkingWeekday;
const is2ndSat = isSaturday && saturdayCount === 2;
const is4thSat = isSaturday && saturdayCount === 4;
const isToday = date.toDateString() === today.toDateString();
// Check if holiday
const holiday = this.getHolidayForDate(year, month, d);
const isHoliday = !!holiday;
// Non-working: Sunday, 2nd Sat, 4th Sat, a weekly off from Working Days, or holiday
const isNonWorking = isSunday || is2ndSat || is4thSat || isWeeklyOff || isHoliday;
// Non-working: Sunday, 2nd Sat, 4th Sat, or holiday
const isNonWorking = isSunday || is2ndSat || is4thSat || isHoliday;
if (isSunday) sundayCount++;
if (is2ndSat || is4thSat || (isSaturday && isWeeklyOff)) sat2nd4thCount++;
if (isWeeklyOff && !isSaturday) weeklyOffCount++;
if (is2ndSat || is4thSat) sat2nd4thCount++;
if (isHoliday) holidayCount++;
if (isNonWorking) {
@@ -676,7 +613,6 @@ export class BankCalendarComponent implements OnInit {
isSunday,
is2ndSaturday: is2ndSat,
is4thSaturday: is4thSat,
isWeeklyOff,
holidayName: holiday?.name,
dayOfWeek: dow
});
@@ -696,7 +632,6 @@ export class BankCalendarComponent implements OnInit {
isSunday: false,
is2ndSaturday: false,
is4thSaturday: false,
isWeeklyOff: false,
dayOfWeek: days.length % 7
});
}
@@ -710,8 +645,7 @@ export class BankCalendarComponent implements OnInit {
nonWorkingDays: nonWorkingCount,
holidays: holidayCount,
sundays: sundayCount,
secondFourthSat: sat2nd4thCount,
weeklyOff: weeklyOffCount
secondFourthSat: sat2nd4thCount
};
}
@@ -12,7 +12,6 @@ import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface RegisterRow {
accountId: number;
@@ -84,7 +83,7 @@ export class AccountRegisterComponent implements OnInit {
/** Local date — toISOString() would shift the day in any timezone behind UTC. */
private today(): string {
const d = businessToday();
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
@@ -1,114 +0,0 @@
<!--
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="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">BRANCH OPERATIONS · BRANCH CASH POSITION</div>
<h1 class="pg-title">Branch Cash Position</h1>
<p class="pg-sub">
The notes and coins the branch holds, denomination by denomination: the vault plus every cashier's drawer. Cash
moving between the vault and a cashier nets out; deposits, withdrawals and exchanges change it.
</p>
</div>
</header>
<div class="pg-toolbar pg-toolbar--mat">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>As of Date</mat-label>
<input matInput type="date" [value]="asOfDate" (change)="onDateChange($any($event.target).value)" />
</mat-form-field>
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon> Refresh
</button>
</div>
@if (errorMsg) {
<div class="pg-empty bcp-error">{{ errorMsg }}</div>
}
@if (loading) {
<div class="pg-empty">Loading…</div>
}
@if (position; as p) {
<div class="bcp-totals">
<div class="bcp-total">
<span>Branch</span><strong>{{ p.officeName }}</strong>
</div>
<div class="bcp-total">
<span>Vault</span><strong>₹{{ p.vaultAmount | number: '1.2-2' }}</strong>
</div>
<div class="bcp-total">
<span>Cashiers' drawers</span><strong>₹{{ p.cashiersAmount | number: '1.2-2' }}</strong>
</div>
<div class="bcp-total bcp-total--main">
<span>Bank total</span><strong>₹{{ p.totalAmount | number: '1.2-2' }}</strong>
</div>
</div>
@if (!p.vaultHasBreakdown) {
<div class="pg-empty bcp-note">
No vault denomination breakdown was recorded on or before {{ p.asOfDate }}, so the vault column reads 0. Open
the business day with a note/coin count to start tracking it.
</div>
}
<ng-template #table let-rows let-label="label" let-coin="coin">
@if (rows.length > 0) {
<div class="pos-section-label" [class.pos-section-label--coin]="coin">{{ label }}</div>
<table class="pg-table">
<thead>
<tr>
<th>Denomination</th>
<th class="num">Vault</th>
<th class="num">Cashiers</th>
<th class="num">Bank total (count)</th>
<th class="num">Bank total (₹)</th>
<th class="num">Exchange today</th>
</tr>
</thead>
<tbody>
@for (d of rows; track d.denominationId) {
<tr>
<td>
<span class="pg-chip" [class.pg-chip--coin]="coin">₹{{ d.denominationValue }}</span>
</td>
<td class="num">{{ d.vaultCount }}</td>
<td class="num">{{ d.cashierCount }}</td>
<td class="num bcp-strong">{{ d.totalCount }}</td>
<td class="num">₹{{ d.totalAmount | number: '1.2-2' }}</td>
<td
class="num bcp-move"
[class.bcp-move--in]="d.exchangeNet > 0"
[class.bcp-move--out]="d.exchangeNet < 0"
>
{{ movement(d.exchangeNet) }}
</td>
</tr>
}
</tbody>
</table>
}
</ng-template>
<div class="pg-card bcp-card">
<ng-container
*ngTemplateOutlet="table; context: { $implicit: notes(), label: 'Notes', coin: false }"
></ng-container>
<ng-container
*ngTemplateOutlet="table; context: { $implicit: coins(), label: 'Coins', coin: true }"
></ng-container>
</div>
<p class="bcp-foot">
<strong>Exchange today</strong> is the notes taken in less the notes given out by cash exchanges on
{{ p.asOfDate }}. It is already part of the Cashiers column; it is shown beside the total so a change in the mix
can be seen.
</p>
}
</div>
@@ -1,82 +0,0 @@
/**
* 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/.
*/
.bcp-error {
color: #dc2626;
}
.bcp-note {
margin-bottom: 12px;
text-align: left;
}
.bcp-totals {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 14px;
}
.bcp-total {
flex: 1 1 180px;
padding: 12px 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
span {
display: block;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #64748b;
}
strong {
font-size: 18px;
font-weight: 700;
color: #0f172a;
}
&--main {
background: #e6f4ee;
border-color: #1f7a4d;
strong {
color: #0f4f3c;
}
}
}
.bcp-card {
margin-bottom: 14px;
}
.bcp-strong {
font-weight: 700;
}
.bcp-move {
font-weight: 700;
color: #94a3b8;
&--in {
color: #16a34a;
}
&--out {
color: #dc2626;
}
}
.bcp-foot {
margin: 0;
font-size: 12px;
color: #64748b;
}
@@ -1,112 +0,0 @@
/**
* 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 { MatIconModule } from '@angular/material/icon';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface BranchDenomRow {
denominationId: number;
denominationValue: number;
kind: string;
vaultCount: number;
cashierCount: number;
totalCount: number;
totalAmount: number;
/** Notes taken in less notes given out by exchanges on the day. */
exchangeNet: number;
}
interface BranchPosition {
officeId: number;
officeName: string;
asOfDate: string;
vaultHasBreakdown: boolean;
vaultAmount: number;
cashiersAmount: number;
totalAmount: number;
denominations: BranchDenomRow[];
}
/**
* What the branch holds, denomination by denomination: the vault plus every cashier's drawer. An exchange
* (a customer hands in 10 x ₹500 and leaves with 50 x ₹100) leaves the vault alone and changes a drawer, so
* it only shows up in this total -- +10 on ₹500, -50 on ₹100.
*/
@Component({
selector: 'mifosx-branch-cash-position',
templateUrl: './branch-cash-position.component.html',
styleUrls: [
'../cashier-shared.scss',
'../cashier-position/cashier-position.component.scss',
'./branch-cash-position.component.scss'
],
standalone: true,
imports: [
CommonModule,
MatIconModule,
MatFormFieldModule,
MatInputModule
]
})
export class BranchCashPositionComponent implements OnInit {
private service = inject(CashierService);
asOfDate = '';
loading = false;
errorMsg = '';
position: BranchPosition | null = null;
ngOnInit(): void {
const d = businessToday();
this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.load();
}
load(): void {
this.loading = true;
this.errorMsg = '';
this.position = null;
this.service
.branchCashPosition(this.asOfDate)
.pipe(catchError(() => of(null)))
.subscribe((data: BranchPosition | null) => {
this.loading = false;
if (!data) {
this.errorMsg = 'Could not load the branch cash position.';
return;
}
this.position = data;
});
}
onDateChange(value: string): void {
this.asOfDate = value;
this.load();
}
notes(): BranchDenomRow[] {
return (this.position?.denominations || []).filter((d) => d.kind !== 'coin');
}
coins(): BranchDenomRow[] {
return (this.position?.denominations || []).filter((d) => d.kind === 'coin');
}
/** "+10", "-50", or a dash when the day's exchanges did not touch the denomination. */
movement(net: number): string {
if (!net) return '—';
return net > 0 ? `+${net}` : `${net}`;
}
}
@@ -352,51 +352,6 @@
</div>
}
<!-- Interest waiting to be posted as this branch closes. Posting runs as part of the close, so
anything unapproved stops it: the branch sees here what it has to approve and where, rather
than finding out from a refusal after filling in the whole count. -->
@if (preview?.fdInterestDue?.length) {
<div class="bd-calc bd-calc--fd">
<div class="bd-calc__head">
<mat-icon>savings</mat-icon>
<strong>Fixed deposit interest due today</strong>
</div>
@for (d of preview.fdInterestDue; track d.productId) {
<div class="bd-calc__row">
<span>
{{ d.productName }} · {{ d.fdType }} · {{ d.accountCount }} deposit(s)
@if (d.reportStatus === 'APPROVED' || d.reportStatus === 'POSTED') {
<span class="bd-fd-ok">{{ d.reportStatus }}</span>
} @else {
<span class="bd-fd-pending">{{ d.reportStatus === 'NONE' ? 'NO REPORT' : d.reportStatus }}</span>
}
</span>
<span class="bd-calc__amt">₹{{ d.totalInterest | number: '1.2-2' }}</span>
</div>
}
</div>
}
@if (preview?.fdInterestPending?.length) {
<div class="bd-warn">
<mat-icon>warning</mat-icon>
<div>
<strong>The day cannot be closed yet.</strong> Fixed deposit interest is not approved for:
{{ preview.fdInterestPending.join(', ') }}. Approve it under Reports → FD Interest Posting Reports, then
close the day. The user who generated a report cannot approve it.
</div>
</div>
}
@if (preview?.savingsInterestPending?.length) {
<div class="bd-warn">
<mat-icon>warning</mat-icon>
<div>
<strong>The day cannot be closed yet.</strong> Savings interest is not approved for:
{{ preview.savingsInterestPending.join(', ') }}. Approve it under Reports → Interest Posting Reports, then
close the day.
</div>
</div>
}
<!-- What every cash point under this branch is actually holding right now, fetched
automatically — so closing the day doesn't mean separately checking Vault Position and
every cashier's own Cashier Position screen by hand. -->
@@ -195,75 +195,6 @@
font-size: 11px;
color: #94a3b8;
}
/* Names the block, for the interest panels that sit beside the cash arithmetic. */
&__head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
padding-bottom: 6px;
border-bottom: 1px solid #e2e8f0;
font-size: 13px;
color: #0d1b2a;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: #1d4ed8;
}
}
}
/* Interest is money leaving as the day closes, not cash counted in the drawer. */
.bd-calc--fd {
border-left: 3px solid #1d4ed8;
}
.bd-fd-ok,
.bd-fd-pending {
display: inline-block;
margin-left: 6px;
padding: 1px 7px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.02em;
}
.bd-fd-ok {
background: #dcfce7;
color: #15803d;
}
.bd-fd-pending {
background: #fef3c7;
color: #b45309;
}
/* Why the close will be refused, said before the count is filled in rather than after. */
.bd-warn {
display: flex;
align-items: flex-start;
gap: 8px;
max-width: 640px;
margin-bottom: 14px;
padding: 10px 14px;
background: #fffbeb;
border: 1px solid #fcd34d;
border-radius: 8px;
font-size: 12px;
line-height: 1.5;
color: #78350f;
mat-icon {
flex-shrink: 0;
font-size: 20px;
width: 20px;
height: 20px;
color: #b45309;
}
}
.bd-hint {
@@ -11,7 +11,6 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import { ExportSheetSpec, exportWorkbook } from '../cashier-export';
import { businessToday } from 'app/core/utils/business-today';
interface SummaryRow {
code: string;
@@ -1317,7 +1316,7 @@ export class CashClearingTransferSummaryComponent {
private dateTag = '2607';
constructor() {
const d = businessToday();
const d = new Date();
this.asOnDate = `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`;
this.dateTag = `${String(d.getFullYear()).slice(-2)}${String(d.getMonth() + 1).padStart(2, '0')}`;
@@ -22,255 +22,50 @@
</div>
</header>
<!-- ── Cashier: whose drawer takes the customer's notes and gives the change ── -->
@if (cashier) {
<section class="cx-cashier">
<div class="cx-avatar cx-avatar--sm">{{ cashierInitials }}</div>
<div class="cx-cashier__id">
<div class="cx-cashier__label">Cashier at the counter</div>
<div class="cx-cashier__name">{{ cashier.name || '—' }}</div>
<div class="cx-cashier__flow">
<span><mat-icon>south</mat-icon> Receives from customer</span>
<span><mat-icon>north</mat-icon> Gives to customer</span>
</div>
</div>
<div class="cx-cashier__stats">
<div>
<span>Counter</span><strong>{{ cashier.tellerName || '—' }}</strong>
</div>
<div>
<span>Branch</span><strong>{{ cashier.officeName || '—' }}</strong>
</div>
<div>
<span>Allocated today</span
><strong>{{
cashier.allocatedAmount === null ? '—' : '₹' + (cashier.allocatedAmount | number: '1.2-2')
}}</strong>
</div>
<div>
<span>Cash in hand</span><strong>₹{{ cashier.totalAmount | number: '1.2-2' }}</strong>
</div>
</div>
</section>
} @else if (noCashier) {
<section class="cx-cashier cx-cashier--none">
<mat-icon>person_off</mat-icon>
<div>
<div class="cx-cashier__name">Your login is not linked to a cashier</div>
<div class="cx-cashier__label">
A cash exchange moves notes in and out of a cashier's drawer, so it can only be recorded by a cashier. Log in
as a cashier to continue.
</div>
</div>
</section>
}
<!-- ── Customer: an existing client (details from their record) or a walk-in identified at the counter ── -->
<section class="cx-cust">
<header class="cx-cust__head">
<div class="cx-cust__title">
<span class="cx-cust__title-icon"><mat-icon>groups</mat-icon></span>
<div>
<div class="cx-cust__title-text">Customer</div>
<div class="cx-cust__title-sub">Who is exchanging cash at the counter</div>
</div>
</div>
<div class="cx-cust__modes" role="tablist" aria-label="Customer type">
<button
type="button"
role="tab"
class="cx-cust__mode"
[class.cx-cust__mode--active]="customerMode === 'existing'"
[attr.aria-selected]="customerMode === 'existing'"
(click)="setCustomerMode('existing')"
>
<mat-icon>badge</mat-icon> Existing Customer
</button>
<button
type="button"
role="tab"
class="cx-cust__mode"
[class.cx-cust__mode--active]="customerMode === 'walkin'"
[attr.aria-selected]="customerMode === 'walkin'"
(click)="setCustomerMode('walkin')"
>
<mat-icon>person_outline</mat-icon> Walk-in Customer
</button>
</div>
</header>
<div class="cx-cust__body">
<!-- Who -->
<div class="cx-cust__who">
@if (customerMode === 'existing') {
@if (loadingCustomer) {
<div class="cx-cust__empty">
<mat-icon class="cx-cust__empty-icon cx-spin">autorenew</mat-icon>
<div class="cx-cust__empty-title">Loading customer details…</div>
</div>
} @else if (!selectedCustomer) {
<div class="cx-cust__empty">
<mat-icon class="cx-cust__empty-icon">person_search</mat-icon>
<div class="cx-cust__empty-title">No customer selected</div>
<div class="cx-cust__empty-sub">Search by name, account no., PAN or Aadhaar</div>
<button type="button" class="cx-btn cx-btn--primary" (click)="openCustomerSearch()">
<mat-icon>search</mat-icon> Search Customer
</button>
</div>
} @else {
<div class="cx-cust__card">
<div class="cx-cust__card-top">
<div class="cx-avatar">{{ customerInitials }}</div>
<div class="cx-cust__card-id">
<div class="cx-cust__name">{{ selectedCustomer.displayName }}</div>
<div class="cx-cust__badges">
<span
class="cx-kyc"
[class.cx-kyc--ok]="selectedCustomer.kycStatus === 'Verified'"
[class.cx-kyc--warn]="selectedCustomer.kycStatus !== 'Verified'"
>
<mat-icon>{{
selectedCustomer.kycStatus === 'Verified' ? 'verified_user' : 'gpp_maybe'
}}</mat-icon>
KYC: {{ selectedCustomer.kycStatus }}
</span>
@if (selectedCustomer.accountType) {
<span class="cx-chip-tag">{{ selectedCustomer.accountType }}</span>
}
</div>
</div>
<div class="cx-cust__card-actions">
<button type="button" class="cx-link-btn" (click)="openCustomerSearch()">
<mat-icon>swap_horiz</mat-icon> Change
</button>
<button type="button" class="cx-link-btn cx-link-btn--danger" (click)="clearCustomer()">
<mat-icon>close</mat-icon> Remove
</button>
</div>
</div>
<div class="cx-cust__grid">
<div>
<span>CIF No.</span><strong>{{ selectedCustomer.cifNumber || '—' }}</strong>
</div>
<div>
<span>Client ID</span><strong>{{ selectedCustomer.clientId }}</strong>
</div>
<div>
<span>Mobile</span><strong>{{ selectedCustomer.mobileNo || '—' }}</strong>
</div>
<div>
<span>Branch</span><strong>{{ selectedCustomer.officeName || '—' }}</strong>
</div>
<div>
<span>Account</span><strong>{{ selectedCustomer.accountNo || 'No account' }}</strong>
</div>
<div>
<span>PAN on record</span>
<strong>
{{ selectedCustomer.kycPan || 'Not on record' }}
@if (selectedCustomer.kycPan) {
<span
class="cx-kyc cx-kyc--sm"
[class.cx-kyc--ok]="selectedCustomer.panVerified"
[class.cx-kyc--warn]="!selectedCustomer.panVerified"
>{{ selectedCustomer.panVerified ? 'Verified' : 'Not verified' }}</span
>
}
</strong>
</div>
</div>
</div>
}
} @else {
<div class="cx-cust__walkin">
<label class="cx-field">
<span>Customer Name{{ needsPan ? ' *' : '' }}</span>
<input
type="text"
[value]="walkInName"
(input)="walkInName = $any($event.target).value"
placeholder="Full name as on ID"
maxlength="150"
autocomplete="off"
/>
</label>
<label class="cx-field">
<span>Mobile</span>
<input
type="tel"
inputmode="numeric"
[value]="walkInMobile"
(input)="walkInMobile = $any($event.target).value"
placeholder="10-digit mobile number"
maxlength="15"
autocomplete="off"
/>
</label>
</div>
}
</div>
<!-- PAN -->
<div
class="cx-pan"
[class.cx-pan--required]="panMissing"
[class.cx-pan--invalid]="panFormatInvalid"
[class.cx-pan--valid]="panValue && !panFormatInvalid"
>
<div class="cx-pan__head">
<mat-icon>credit_card</mat-icon>
<span class="cx-pan__label">PAN Card</span>
<span class="cx-pan__tag" [class.cx-pan__tag--on]="needsPan">{{ needsPan ? 'Mandatory' : 'Optional' }}</span>
</div>
<div class="cx-pan__input">
<input
type="text"
[value]="panNumber"
(input)="panNumber = $any($event.target).value.toUpperCase()"
placeholder="ABCDE1234F"
maxlength="10"
autocomplete="off"
spellcheck="false"
aria-label="PAN number"
/>
@if (panValue && !panFormatInvalid) {
<mat-icon class="cx-pan__state cx-pan__state--ok">check_circle</mat-icon>
} @else if (panFormatInvalid) {
<mat-icon class="cx-pan__state cx-pan__state--bad">error</mat-icon>
}
</div>
<div class="cx-pan__hint">
@if (panFormatInvalid) {
PAN is 5 letters, 4 digits, 1 letter (e.g. ABCDE1234F).
} @else if (panFromKyc) {
<mat-icon>link</mat-icon> Fetched from the customer's KYC record.
} @else if (panMissing) {
Required: this exchange is above ₹50,000 (Income Tax Rule 114B / RBI).
} @else {
Mandatory for exchanges above ₹50,000.
}
</div>
</div>
<!-- ── Customer Info Bar ── -->
<div class="cx-customer-bar">
<div
class="cx-customer-toggle"
[class.cx-customer-toggle--active]="isAccountHolder"
(click)="toggleAccountHolder()"
>
<mat-icon>{{ isAccountHolder ? 'check_box' : 'check_box_outline_blank' }}</mat-icon>
<span>Account Holder</span>
</div>
</section>
@if (isAccountHolder) {
<label class="cx-field cx-field--inline">
<span>Account No.</span>
<input
type="text"
[value]="accountNo"
(input)="accountNo = $any($event.target).value"
placeholder="e.g. 000101234567"
/>
</label>
} @else {
<span class="cx-walk-in"><mat-icon>person_outline</mat-icon> Walk-in Customer</span>
}
@if (needsIdProof) {
<div class="cx-pan-required">
<mat-icon>warning</mat-icon>
<span>Amount ≥ ₹50,000 — Customer PAN / Aadhaar required (RBI)</span>
</div>
<label class="cx-field cx-field--inline cx-field--pan">
<span>PAN / Aadhaar *</span>
<input
type="text"
[value]="customerIdProof"
(input)="customerIdProof = $any($event.target).value"
placeholder="ABCDE1234F"
maxlength="20"
/>
</label>
}
</div>
<div class="cx-cols">
<!-- The cashier's count of a denomination and what this exchange does to it, updated as counts are typed. -->
<ng-template #drawerCell let-d="d">
@if (availableFor(d) === null) {
—
} @else {
In drawer <strong>{{ availableFor(d) }}</strong>
@if (netFor(d) !== 0) {
<span class="cx-after">
<mat-icon>{{ netFor(d) > 0 ? 'trending_up' : 'trending_down' }}</mat-icon>
<strong>{{ drawerAfter(d) }}</strong>
<small>({{ netFor(d) > 0 ? '+' : '' }}{{ netFor(d) }})</small>
</span>
}
}
</ng-template>
<!-- RECEIVED -->
<section class="cx-col cx-col--received">
<header class="cx-col-head">
@@ -283,7 +78,7 @@
<div class="cx-subhead">Bank Notes</div>
}
@for (d of notes; track d.id) {
<div class="cx-row cx-row--stock">
<div class="cx-row">
<span class="cx-chip">{{ d.label }}</span>
<div class="cx-step">
<button type="button" (click)="bump('r', d, -1)" [disabled]="countFor('r', d) === 0">−</button>
@@ -295,9 +90,6 @@
/>
<button type="button" (click)="bump('r', d, 1)">+</button>
</div>
<span class="cx-avail" [class.cx-avail--up]="netFor(d) > 0" [class.cx-avail--down]="netFor(d) < 0">
<ng-container *ngTemplateOutlet="drawerCell; context: { d: d }"></ng-container>
</span>
<span class="cx-amt" [class.cx-amt--zero]="countFor('r', d) === 0"
>₹{{ subtotal('r', d) | number: '1.2-2' }}</span
>
@@ -307,7 +99,7 @@
<div class="cx-subhead">Coins</div>
}
@for (d of coins; track d.id) {
<div class="cx-row cx-row--stock">
<div class="cx-row">
<span class="cx-chip cx-chip--coin">{{ d.label }}</span>
<div class="cx-step">
<button type="button" (click)="bump('r', d, -1)" [disabled]="countFor('r', d) === 0">−</button>
@@ -319,9 +111,6 @@
/>
<button type="button" (click)="bump('r', d, 1)">+</button>
</div>
<span class="cx-avail" [class.cx-avail--up]="netFor(d) > 0" [class.cx-avail--down]="netFor(d) < 0">
<ng-container *ngTemplateOutlet="drawerCell; context: { d: d }"></ng-container>
</span>
<span class="cx-amt" [class.cx-amt--zero]="countFor('r', d) === 0"
>₹{{ subtotal('r', d) | number: '1.2-2' }}</span
>
@@ -342,7 +131,7 @@
<div class="cx-subhead">Bank Notes</div>
}
@for (d of notes; track d.id) {
<div class="cx-row cx-row--stock" [class.cx-row--over]="exceedsAvailable(d)">
<div class="cx-row">
<span class="cx-chip">{{ d.label }}</span>
<div class="cx-step">
<button type="button" (click)="bump('g', d, -1)" [disabled]="countFor('g', d) === 0">−</button>
@@ -352,16 +141,8 @@
[value]="countFor('g', d)"
(input)="onChange('g', d, $any($event.target).value)"
/>
<button type="button" (click)="bump('g', d, 1)" [disabled]="!canGiveMore(d)">+</button>
<button type="button" (click)="bump('g', d, 1)">+</button>
</div>
<span
class="cx-avail"
[class.cx-avail--up]="netFor(d) > 0"
[class.cx-avail--down]="netFor(d) < 0"
[class.cx-avail--over]="exceedsAvailable(d)"
>
<ng-container *ngTemplateOutlet="drawerCell; context: { d: d }"></ng-container>
</span>
<span class="cx-amt" [class.cx-amt--zero]="countFor('g', d) === 0"
>₹{{ subtotal('g', d) | number: '1.2-2' }}</span
>
@@ -371,7 +152,7 @@
<div class="cx-subhead">Coins</div>
}
@for (d of coins; track d.id) {
<div class="cx-row cx-row--stock" [class.cx-row--over]="exceedsAvailable(d)">
<div class="cx-row">
<span class="cx-chip cx-chip--coin">{{ d.label }}</span>
<div class="cx-step">
<button type="button" (click)="bump('g', d, -1)" [disabled]="countFor('g', d) === 0">−</button>
@@ -381,16 +162,8 @@
[value]="countFor('g', d)"
(input)="onChange('g', d, $any($event.target).value)"
/>
<button type="button" (click)="bump('g', d, 1)" [disabled]="!canGiveMore(d)">+</button>
<button type="button" (click)="bump('g', d, 1)">+</button>
</div>
<span
class="cx-avail"
[class.cx-avail--up]="netFor(d) > 0"
[class.cx-avail--down]="netFor(d) < 0"
[class.cx-avail--over]="exceedsAvailable(d)"
>
<ng-container *ngTemplateOutlet="drawerCell; context: { d: d }"></ng-container>
</span>
<span class="cx-amt" [class.cx-amt--zero]="countFor('g', d) === 0"
>₹{{ subtotal('g', d) | number: '1.2-2' }}</span
>
@@ -400,30 +173,6 @@
</section>
</div>
<!-- What this exchange does to the cashier's drawer, denomination by denomination. -->
@if (impactRows.length) {
<div class="cx-impact">
<div class="cx-impact__title"><mat-icon>inventory_2</mat-icon> Drawer after this exchange</div>
<div class="cx-impact__chips">
@for (r of impactRows; track r.id) {
<span
class="cx-impact__chip"
[class.cx-impact__chip--up]="r.net > 0"
[class.cx-impact__chip--down]="r.net < 0"
>
<b>{{ r.label }}</b>
{{ r.before }} → {{ r.after }}
<em>({{ r.net > 0 ? '+' : '' }}{{ r.net }})</em>
</span>
}
</div>
<div class="cx-impact__note">
The branch's total for each denomination moves by the same amount; the vault is not touched (Branch Ops → Branch
Cash Position).
</div>
</div>
}
<div class="cx-summary">
<div class="cx-sum">
<span>Received</span><strong>₹{{ receivedTotal | number: '1.2-2' }}</strong>
@@ -464,15 +213,9 @@
</div>
</div>
}
@if (panMissing) {
@if (needsIdProof && !customerIdProof.trim()) {
<div class="cx-warn cx-warn--pan">
<mat-icon>badge</mat-icon> Enter the customer's PAN to proceed (amount above ₹50,000).
</div>
}
@if (!customerIdentified && !loadingCustomer) {
<div class="cx-warn cx-warn--pan">
<mat-icon>person_search</mat-icon>
{{ customerMode === 'existing' ? 'Search and select the customer.' : "Enter the walk-in customer's name." }}
<mat-icon>badge</mat-icon> Enter customer PAN / Aadhaar to proceed (amount ≥ ₹50,000).
</div>
}
@@ -73,584 +73,99 @@
}
}
/* ── Customer: existing client or walk-in, plus PAN ── */
.cx-cust {
background: #fff;
/* ── Customer info bar ── */
.cx-customer-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 14px;
box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
overflow: hidden;
border-radius: 10px;
flex-wrap: wrap;
}
&__head {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding: 12px 16px;
background: linear-gradient(180deg, #f8fafc, #f1f5f9);
border-bottom: 1px solid #e2e8f0;
}
.cx-customer-toggle {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: #475569;
cursor: pointer;
user-select: none;
&__title {
display: flex;
align-items: center;
gap: 10px;
}
&__title-icon {
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 10px;
background: #e6f4ee;
color: #0f4f3c;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
&__title-text {
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
&__title-sub {
font-size: 12px;
color: #64748b;
}
&__modes {
display: inline-flex;
padding: 3px;
background: #e2e8f0;
border-radius: 10px;
}
&__mode {
display: inline-flex;
align-items: center;
gap: 6px;
height: 32px;
padding: 0 14px;
border: 0;
border-radius: 8px;
background: transparent;
font-size: 13px;
font-weight: 600;
color: #475569;
cursor: pointer;
transition:
background 0.15s ease,
color 0.15s ease;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
&:hover {
color: #0f172a;
}
&--active,
&--active:hover {
background: #fff;
color: #0f4f3c;
box-shadow: 0 1px 3px rgb(15 23 42 / 15%);
}
}
&__body {
display: grid;
grid-template-columns: minmax(0, 1.7fr) minmax(260px, 1fr);
gap: 16px;
padding: 16px;
}
&__who {
min-width: 0;
}
/* Nothing picked yet, or loading */
&__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
min-height: 150px;
padding: 20px;
text-align: center;
background: #f8fafc;
border: 1.5px dashed #cbd5e1;
border-radius: 12px;
}
&__empty-icon {
font-size: 36px;
width: 36px;
height: 36px;
mat-icon {
font-size: 20px;
height: 20px;
width: 20px;
color: #94a3b8;
}
&__empty-title {
font-size: 14px;
font-weight: 600;
color: #334155;
}
&--active {
color: #1e40af;
&__empty-sub {
margin-bottom: 8px;
font-size: 12px;
color: #64748b;
}
/* A picked client */
&__card {
padding: 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
}
&__card-top {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
&__card-id {
flex: 1 1 180px;
min-width: 0;
}
&__name {
font-size: 16px;
font-weight: 700;
color: #0f172a;
overflow-wrap: anywhere;
}
&__badges {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
}
&__card-actions {
display: flex;
gap: 4px;
}
&__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 12px 16px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed #e2e8f0;
span {
display: block;
margin-bottom: 2px;
font-size: 11px;
color: #64748b;
mat-icon {
color: #1e40af;
}
strong {
font-size: 13px;
font-weight: 600;
color: #0f172a;
}
}
/* A walk-in, identified at the counter */
&__walkin {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
.cx-field {
font-size: 12px;
}
.cx-field input {
width: 100%;
height: 40px;
box-sizing: border-box;
}
}
.cx-field input:focus {
outline: none;
border-color: #1f7a4d;
box-shadow: 0 0 0 3px rgb(31 122 77 / 15%);
}
}
.cx-avatar {
display: grid;
place-items: center;
flex: 0 0 auto;
width: 44px;
height: 44px;
border-radius: 50%;
background: linear-gradient(135deg, #1f7a4d, #0f4f3c);
font-size: 15px;
font-weight: 700;
letter-spacing: 0.02em;
color: #fff;
&--sm {
width: 40px;
height: 40px;
font-size: 14px;
}
}
/* The cashier at the counter, or why there is none */
.cx-cashier {
.cx-walk-in {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 14px 20px;
padding: 12px 16px;
background: #fff;
border: 1px solid #e2e8f0;
border-left: 4px solid #1f7a4d;
border-radius: 14px;
box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
&__id {
flex: 1 1 220px;
min-width: 0;
}
&__label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #64748b;
}
&__name {
font-size: 16px;
font-weight: 700;
color: #0f172a;
}
&__flow {
display: flex;
flex-wrap: wrap;
gap: 4px 14px;
margin-top: 2px;
font-size: 12px;
color: #475569;
span {
display: inline-flex;
align-items: center;
gap: 2px;
}
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
color: #94a3b8;
}
}
&__stats {
display: flex;
flex-wrap: wrap;
gap: 10px 28px;
span {
display: block;
font-size: 11px;
color: #64748b;
}
strong {
font-size: 14px;
font-weight: 600;
color: #0f172a;
}
}
&--none {
background: #fffbeb;
border-color: #fcd34d;
border-left-color: #d97706;
mat-icon {
color: #d97706;
}
.cx-cashier__label {
font-weight: 500;
letter-spacing: 0;
text-transform: none;
color: #92400e;
}
}
}
.cx-kyc {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
font-size: 12px;
color: #64748b;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
&--ok {
background: #dcfce7;
color: #166534;
}
&--warn {
background: #fef3c7;
color: #92400e;
}
&--sm {
margin-left: 6px;
padding: 1px 6px;
font-size: 10px;
font-size: 16px;
height: 16px;
width: 16px;
}
}
.cx-chip-tag {
padding: 2px 8px;
border-radius: 6px;
background: #eef2ff;
font-size: 11px;
font-weight: 600;
color: #3730a3;
}
.cx-link-btn {
display: inline-flex;
.cx-pan-required {
display: flex;
align-items: center;
gap: 4px;
height: 30px;
padding: 0 10px;
border: 0;
border-radius: 8px;
background: transparent;
gap: 6px;
padding: 4px 10px;
background: #fffbeb;
border: 1px solid #fcd34d;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
color: #1e40af;
cursor: pointer;
color: #92400e;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
&:hover {
background: #eff6ff;
}
&--danger {
color: #b91c1c;
&:hover {
background: #fef2f2;
}
width: 16px;
color: #d97706;
}
}
/* PAN: neutral until needed, amber when required and missing, red when malformed, green when valid */
.cx-pan {
display: flex;
flex-direction: column;
align-self: start;
.cx-field--inline {
flex-direction: row;
align-items: center;
gap: 8px;
padding: 14px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
&__head {
display: flex;
align-items: center;
gap: 8px;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
color: #475569;
}
span {
white-space: nowrap;
}
&__label {
font-size: 13px;
font-weight: 700;
color: #0f172a;
}
&__tag {
margin-left: auto;
padding: 2px 8px;
border-radius: 999px;
background: #e2e8f0;
font-size: 11px;
font-weight: 600;
color: #475569;
&--on {
background: #fef3c7;
color: #92400e;
}
}
&__input {
position: relative;
input {
width: 100%;
height: 44px;
box-sizing: border-box;
padding: 0 40px 0 12px;
background: #fff;
border: 1.5px solid #cbd5e1;
border-radius: 10px;
font-family: ui-monospace, sfmono-regular, menlo, consolas, monospace;
font-size: 18px;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #0f172a;
&::placeholder {
font-weight: 500;
color: #cbd5e1;
}
&:focus {
outline: none;
border-color: #1f7a4d;
box-shadow: 0 0 0 3px rgb(31 122 77 / 15%);
}
}
}
&__state {
position: absolute;
top: 50%;
right: 10px;
transform: translateY(-50%);
font-size: 22px;
width: 22px;
height: 22px;
&--ok {
color: #16a34a;
}
&--bad {
color: #dc2626;
}
}
&__hint {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #64748b;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
}
&--required {
background: #fffbeb;
border-color: #fcd34d;
.cx-pan__input input {
border-color: #f59e0b;
}
.cx-pan__hint {
font-weight: 600;
color: #92400e;
}
}
&--invalid {
background: #fef2f2;
border-color: #fecaca;
.cx-pan__input input {
border-color: #ef4444;
}
.cx-pan__hint {
font-weight: 600;
color: #b91c1c;
}
}
&--valid {
background: #f0fdf4;
border-color: #bbf7d0;
.cx-pan__input input {
border-color: #22c55e;
}
.cx-pan__hint {
color: #166534;
}
input {
width: 180px;
}
}
.cx-spin {
animation: cx-spin 1s linear infinite;
}
.cx-field--pan input {
width: 160px;
border-color: #fcd34d;
@keyframes cx-spin {
to {
transform: rotate(360deg);
}
}
@media (width <= 900px) {
.cx-cust__body {
grid-template-columns: 1fr;
}
}
@media (width <= 600px) {
.cx-cust__walkin {
grid-template-columns: 1fr;
&:focus {
outline: none;
border-color: #d97706;
}
}
@@ -830,129 +345,6 @@
border-bottom: 1px dotted #eef2f7;
}
/* Both sides: the cashier's count of each denomination sits between the counter and the amount. */
.cx-row--stock {
grid-template-columns: 64px 120px 1fr 96px;
}
.cx-row--over {
background: #fef2f2;
}
.cx-avail {
padding-left: 6px;
font-size: 12px;
color: #64748b;
strong {
font-weight: 700;
color: #0f172a;
}
/* the count after this exchange: green when it goes up, red when it goes down */
.cx-after {
display: inline-flex;
align-items: center;
gap: 3px;
margin-left: 6px;
font-weight: 600;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
small {
font-size: 11px;
}
}
&--up .cx-after,
&--up .cx-after strong {
color: #15803d;
}
&--down .cx-after,
&--down .cx-after strong {
color: #b45309;
}
&--over,
&--over strong,
&--over .cx-after,
&--over .cx-after strong {
color: #b91c1c;
}
}
/* The drawer after the whole exchange, one chip per denomination it changes */
.cx-impact {
padding: 12px 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
&__title {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
font-size: 13px;
font-weight: 700;
color: #0f172a;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: #475569;
}
}
&__chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
&__chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border-radius: 8px;
background: #f1f5f9;
font-size: 12px;
color: #334155;
b {
font-weight: 700;
}
em {
font-style: normal;
font-weight: 700;
}
&--up {
background: #dcfce7;
color: #166534;
}
&--down {
background: #fef3c7;
color: #92400e;
}
}
&__note {
margin-top: 8px;
font-size: 11px;
color: #64748b;
}
}
.cx-chip {
display: inline-flex;
align-items: center;
@@ -11,16 +11,7 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { ClientsService } from 'app/clients/clients.service';
import {
CustomerSearchDialogComponent,
CustomerSearchResult
} from 'app/shared/customer-search-dialog/customer-search-dialog.component';
import { businessToday } from 'app/core/utils/business-today';
interface Denom {
id: number;
@@ -29,47 +20,6 @@ interface Denom {
label: string;
}
/** An existing client picked through Customer Search, with what the counter needs to identify them. */
interface SelectedCustomer {
clientId: number;
displayName: string;
cifNumber: string | null;
mobileNo: string | null;
officeName: string | null;
accountNo: string | null;
accountType: string | null;
/** PAN on the client's KYC record, if any. */
kycPan: string | null;
panVerified: boolean;
kycStatus: string;
}
/** The cashier at the counter: the one who takes the customer's notes and hands over the change. */
interface CashierInfo {
cashierId: number;
name: string;
tellerName: string | null;
officeName: string | null;
/** Cash the vault allocated to them for the day, once known. */
allocatedAmount: number | null;
/** Cash they hold now. */
totalAmount: number;
}
/** Same rule as ClientKycApiConstants.PAN_REGEX on the server: 5 letters, 4 digits, 1 letter. */
const PAN_PATTERN = /^[A-Z]{5}[0-9]{4}[A-Z]$/;
/** Income Tax Rule 114B / RBI: a cash exchange above this needs the customer's PAN. */
const PAN_MANDATORY_ABOVE = 50000;
const KYC_STATUS: Record<number, string> = {
100: 'Pending',
200: 'In Progress',
300: 'Verified',
400: 'Rejected',
500: 'Expired'
};
/** Cashier cash-exchange entry: customer gives some denominations and takes equal-value change (net 0). */
@Component({
selector: 'mifosx-cash-exchange-entry',
@@ -80,14 +30,11 @@ const KYC_STATUS: Record<number, string> = {
CommonModule,
FormsModule,
MatIconModule,
MatSnackBarModule,
MatDialogModule
MatSnackBarModule
]
})
export class CashExchangeEntryComponent implements OnInit {
private service = inject(CashierService);
private clientsService = inject(ClientsService);
private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar);
notes: Denom[] = [];
@@ -99,17 +46,11 @@ export class CashExchangeEntryComponent implements OnInit {
saving = false;
// ── Customer info ──
/** An existing client found through Customer Search, or a walk-in identified at the counter. */
customerMode: 'existing' | 'walkin' = 'walkin';
selectedCustomer: SelectedCustomer | null = null;
loadingCustomer = false;
walkInName = '';
walkInMobile = '';
panNumber = '';
isAccountHolder = false;
accountNo = '';
customerIdProof = '';
// ── Cashier at the counter, and their position (for the denomination overdraft check) ──
/** The cashier linked to the logged-in user; null when the login is not a cashier's. */
cashier: CashierInfo | null = null;
// ── Cashier position (for denomination overdraft check) ──
private availableMap = new Map<number, number>();
positionLoaded = false;
@@ -122,7 +63,7 @@ export class CashExchangeEntryComponent implements OnInit {
}
ngOnInit(): void {
const d = businessToday();
const d = new Date();
this.exchangeDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service.getActiveDenominations().subscribe({
next: (list) => {
@@ -146,19 +87,7 @@ export class CashExchangeEntryComponent implements OnInit {
((res?.denominations || []) as any[]).forEach((d: any) => {
if (d.denominationId) this.availableMap.set(Number(d.denominationId), Number(d.count ?? 0));
});
const cashierId = res?.cashierId ? Number(res.cashierId) : null;
this.cashier = cashierId
? {
cashierId,
name: res.cashierName || '',
tellerName: null,
officeName: null,
allocatedAmount: null,
totalAmount: Number(res.totalAmount ?? 0)
}
: null;
this.positionLoaded = true;
if (cashierId) this.loadCashierDetails(cashierId);
},
error: () => {
this.positionLoaded = true;
@@ -166,31 +95,6 @@ export class CashExchangeEntryComponent implements OnInit {
});
}
/** The counter, branch and today's allocation; the card still shows name and cash in hand without them. */
private loadCashierDetails(cashierId: number): void {
this.service
.getInterCashierCashierSummary(cashierId, this.exchangeDate)
.pipe(catchError(() => of(null)))
.subscribe((r: any) => {
if (!r || this.cashier?.cashierId !== cashierId) return;
this.cashier = {
...this.cashier,
tellerName: r.tellerName || null,
officeName: r.officeName || null,
allocatedAmount: Number(r.allocatedAmount ?? 0)
};
});
}
/** Up to two letters for the cashier's avatar. */
get cashierInitials(): string {
const words = (this.cashier?.name || '').trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return '?';
const first = words[0].charAt(0);
const last = words.length > 1 ? words[words.length - 1].charAt(0) : '';
return (first + last).toUpperCase();
}
countFor(side: 'r' | 'g', d: Denom): number {
return (side === 'r' ? this.receivedCounts : this.givenCounts)[d.id] || 0;
}
@@ -224,102 +128,14 @@ export class CashExchangeEntryComponent implements OnInit {
return this.receivedTotal > 0 && this.receivedTotal === this.givenTotal;
}
/** Above ₹50,000 the customer's PAN is mandatory (Income Tax Rule 114B, followed under RBI guidelines). */
get needsPan(): boolean {
return this.receivedTotal > PAN_MANDATORY_ABOVE;
}
/** Up to two letters for the avatar: first and last word of the customer's name. */
get customerInitials(): string {
const words = (this.selectedCustomer?.displayName || '').trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return '?';
const first = words[0].charAt(0);
const last = words.length > 1 ? words[words.length - 1].charAt(0) : '';
return (first + last).toUpperCase();
}
get panValue(): string {
return this.panNumber.trim().toUpperCase();
}
/** A PAN that is entered must be well-formed, required or not. */
get panFormatInvalid(): boolean {
return !!this.panValue && !PAN_PATTERN.test(this.panValue);
}
get panMissing(): boolean {
return this.needsPan && !this.panValue;
}
/** The PAN comes from the client's KYC record, so the cashier confirms it rather than types it. */
get panFromKyc(): boolean {
return !!this.selectedCustomer?.kycPan && this.panValue === this.selectedCustomer.kycPan.toUpperCase();
}
/** Who the customer is has to be known: a picked client, or a walk-in's name once PAN is required. */
get customerIdentified(): boolean {
if (this.customerMode === 'existing') return !!this.selectedCustomer;
return !this.needsPan || this.walkInName.trim().length > 0;
}
/** A login with no cashier behind it has no drawer to take notes into or give change from. */
get noCashier(): boolean {
return this.positionLoaded && !this.cashier;
}
/** How many of the denomination the cashier holds, or null while that is not known (loading, or no cashier). */
availableFor(d: Denom): number | null {
if (!this.positionLoaded || !this.cashier) return null;
return this.availableMap.get(d.id) ?? 0;
}
/** The cashier is giving more of this denomination than they hold. */
exceedsAvailable(d: Denom): boolean {
const available = this.availableFor(d);
return available !== null && this.countFor('g', d) > available;
}
/** The + on the given side stops at what the cashier holds; a larger typed number is still caught and shown. */
canGiveMore(d: Denom): boolean {
const available = this.availableFor(d);
return available === null || this.countFor('g', d) < available;
}
/** What this exchange does to the cashier's count of the denomination: notes taken in less notes given out. */
netFor(d: Denom): number {
return this.countFor('r', d) - this.countFor('g', d);
}
/** The cashier's count of the denomination once this exchange is recorded, or null while it is not known. */
drawerAfter(d: Denom): number | null {
const available = this.availableFor(d);
return available === null ? null : available + this.netFor(d);
}
/**
* The denominations this exchange changes, with the drawer's count before and after, so the effect shows
* while the counts are being typed. The branch's total for each denomination moves by the same amount, as
* the customer's notes go into the drawer and the change comes out of it; the vault is not involved.
*/
get impactRows(): Array<{ id: number; label: string; kind: string; net: number; before: number; after: number }> {
if (!this.positionLoaded || !this.cashier) return [];
return this.denoms
.filter((d) => this.netFor(d) !== 0)
.map((d) => ({
id: d.id,
label: d.label,
kind: d.kind,
net: this.netFor(d),
before: this.availableFor(d) ?? 0,
after: this.drawerAfter(d) ?? 0
}));
/** Above ₹50,000 PAN/ID is mandatory per RBI guideline */
get needsIdProof(): boolean {
return this.receivedTotal >= 50000;
}
/** Denominations where cashier is giving more than currently available. */
get givenOverdraftItems(): Array<{ label: string; available: number; requested: number }> {
// Without a cashier every denomination would read as "0 available"; the missing cashier is the
// thing to tell the user, not a stock shortage.
if (!this.positionLoaded || !this.cashier) return [];
if (!this.positionLoaded) return [];
return this.denoms
.map((d) => ({ label: d.label, available: this.availableMap.get(d.id) ?? 0, requested: this.countFor('g', d) }))
.filter((item) => item.requested > 0 && item.requested > item.available);
@@ -330,77 +146,21 @@ export class CashExchangeEntryComponent implements OnInit {
}
get valid(): boolean {
return (
this.matches &&
!!this.cashier &&
!this.hasGivenOverdraft &&
!this.panFormatInvalid &&
!this.panMissing &&
this.customerIdentified &&
!this.loadingCustomer
);
return this.matches && (!this.needsIdProof || this.customerIdProof.trim().length > 0) && !this.hasGivenOverdraft;
}
setCustomerMode(mode: 'existing' | 'walkin'): void {
if (this.customerMode === mode) return;
this.customerMode = mode;
this.selectedCustomer = null;
this.walkInName = '';
this.walkInMobile = '';
this.panNumber = '';
}
/** Pick an existing client through Customer Search, then load their details and KYC (PAN). */
openCustomerSearch(): void {
this.dialog
// An existing customer without an account is still a known customer, so the account is not required.
.open(CustomerSearchDialogComponent, { width: '960px', maxWidth: '95vw', data: { requireAccount: false } })
.afterClosed()
.subscribe((result: CustomerSearchResult | undefined) => {
if (!result?.clientId) return;
this.loadCustomer(result);
});
}
private loadCustomer(result: CustomerSearchResult): void {
this.loadingCustomer = true;
const id = String(result.clientId);
forkJoin({
client: this.clientsService.getClientData(id, true).pipe(catchError(() => of(null))),
kyc: this.clientsService.getClientKyc(id, true).pipe(catchError(() => of(null)))
}).subscribe(({ client, kyc }: { client: any; kyc: any }) => {
const kycPan: string | null = kyc?.panNumber || result.panNumber || null;
this.selectedCustomer = {
clientId: result.clientId,
displayName: client?.displayName || result.displayName,
cifNumber: client?.cifNumber || null,
mobileNo: client?.mobileNo || result.mobileNo || null,
officeName: client?.officeName || result.officeName || null,
accountNo: result.accountNo || null,
accountType: result.accountType || null,
kycPan,
panVerified: !!kyc?.panVerified,
kycStatus: KYC_STATUS[Number(kyc?.kycStatus?.id ?? kyc?.kycStatus)] || 'Not on record'
};
this.panNumber = kycPan ? kycPan.toUpperCase() : '';
this.loadingCustomer = false;
});
}
clearCustomer(): void {
this.selectedCustomer = null;
this.panNumber = '';
toggleAccountHolder(): void {
this.isAccountHolder = !this.isAccountHolder;
if (!this.isAccountHolder) this.accountNo = '';
}
clear(): void {
this.receivedCounts = {};
this.givenCounts = {};
this.note = '';
this.customerMode = 'walkin';
this.selectedCustomer = null;
this.walkInName = '';
this.walkInMobile = '';
this.panNumber = '';
this.accountNo = '';
this.customerIdProof = '';
this.isAccountHolder = false;
}
submit(): void {
@@ -410,17 +170,11 @@ export class CashExchangeEntryComponent implements OnInit {
this.denoms
.filter((d) => this.countFor(side, d) > 0)
.map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(side, d) }));
const customer = this.customerMode === 'existing' ? this.selectedCustomer : null;
const pan = this.panValue || null;
const payload: any = {
exchangeDate: this.exchangeDate,
note: this.note || null,
clientId: customer?.clientId ?? null,
accountNo: customer?.accountNo ?? null,
customerName: customer ? customer.displayName : this.walkInName.trim() || null,
customerMobile: customer ? customer.mobileNo : this.walkInMobile.trim() || null,
panNumber: pan,
customerIdProof: pan,
customerIdProof: this.customerIdProof.trim() || null,
accountNo: this.isAccountHolder && this.accountNo.trim() ? this.accountNo.trim() : null,
received: build('r'),
given: build('g')
};
@@ -11,11 +11,25 @@
<div>
<div class="pg-eyebrow">CASHIER · HISTORY</div>
<h1 class="pg-title">Cash Movement History</h1>
<p class="pg-sub">All transfers, settlements, inter-cashier movements and cash exchanges.</p>
<p class="pg-sub">All transfers, settlements, and inter-cashier movements. Reverse any active transaction.</p>
</div>
<button type="button" class="pg-btn pg-btn--ghost" (click)="load()"><mat-icon>refresh</mat-icon> Refresh</button>
</header>
<!-- Reversal confirm panel -->
@if (reversingId !== null) {
<div class="reverse-panel">
<mat-icon class="reverse-icon">warning</mat-icon>
<span
>Reverse <strong>#{{ reversingId }}</strong
>? This cannot be undone.</span
>
<input class="reverse-reason" type="text" [(ngModel)]="reverseReason" placeholder="Reason (optional)" />
<button type="button" class="pg-btn pg-btn--danger" (click)="confirmReverse()">Confirm Reversal</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="cancelReverse()">Cancel</button>
</div>
}
@if (loading) {
<div class="pg-empty">Loading…</div>
}
@@ -120,6 +134,11 @@
>
<mat-icon>visibility</mat-icon>
</button>
@if (!r.isReversed) {
<button type="button" class="pg-btn pg-btn--ghost btn-sm" title="Reverse" (click)="startReverse(r)">
<mat-icon>undo</mat-icon>
</button>
}
</div>
</td>
</tr>
@@ -168,12 +187,6 @@
<span class="detail-meta__label">Date</span>
<span class="detail-meta__val">{{ detail.row.date }}</span>
</div>
@if (detail.row.type === 'exchange') {
<div class="detail-meta__item">
<span class="detail-meta__label">Customer PAN</span>
<span class="detail-meta__val">{{ detail.row.panNumber || '—' }}</span>
</div>
}
<div class="detail-meta__item">
<span class="detail-meta__label">Total Amount</span>
<span class="detail-meta__val detail-meta__val--amount">₹{{ detail.row.amount | number: '1.2-2' }}</span>
@@ -47,6 +47,38 @@
}
}
.reverse-panel {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: #fffbeb;
border: 1px solid #fcd34d;
border-radius: 10px;
flex-wrap: wrap;
font-size: 13px;
color: #92400e;
.reverse-icon {
color: #f59e0b;
font-size: 20px;
height: 20px;
width: 20px;
}
.reverse-reason {
flex: 1 1 200px;
height: 34px;
padding: 0 10px;
border: 1px solid #fcd34d;
border-radius: 7px;
font-size: 13px;
font-family: inherit;
background: #fff;
color: #1a1a1a;
}
}
/* ── Action buttons cell ── */
.action-btns {
display: flex;
@@ -8,7 +8,9 @@
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatTabsModule } from '@angular/material/tabs';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { forkJoin, of } from 'rxjs';
@@ -29,8 +31,6 @@ interface TxnRow {
reversedBy: number | null;
reversedAt: string | null;
reverseReason: string | null;
/** Customer PAN recorded on a cash exchange. */
panNumber?: string | null;
type: 'transfer' | 'settlement' | 'inter' | 'exchange';
}
@@ -52,10 +52,7 @@ interface DetailView {
loading: boolean;
}
/**
* Unified history of all cash movements with a detail view. Read-only: cash movements cannot be reversed
* from here (an earlier reversal still shows as Reversed, with who, when and why).
*/
/** Unified history of all cash movements with reversal and detail view support. */
@Component({
selector: 'mifosx-cash-history',
templateUrl: './cash-history.component.html',
@@ -66,13 +63,16 @@ interface DetailView {
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule,
MatSnackBarModule,
MatTabsModule,
MatPaginatorModule
]
})
export class CashHistoryComponent implements OnInit {
private service = inject(CashierService);
private snackBar = inject(MatSnackBar);
transfers: TxnRow[] = [];
settlements: TxnRow[] = [];
@@ -99,6 +99,10 @@ export class CashHistoryComponent implements OnInit {
private denomKindMap = new Map<number, 'note' | 'coin'>();
reversingId: number | null = null;
reversingType: string | null = null;
reverseReason = '';
detail: DetailView | null = null;
ngOnInit(): void {
@@ -208,8 +212,7 @@ export class CashHistoryComponent implements OnInit {
this.exchanges = (exchangeItems as any[]).map((e) => ({
id: e.id,
date: e.exchangeDate,
fromParty: this.exchangeParty(e),
panNumber: e.panNumber ?? null,
fromParty: e.accountNo ? `Account ${e.accountNo}` : 'Walk-in Customer',
toParty: e.performedByUsername || '—',
amount: e.exchangeAmount,
note: e.note,
@@ -307,8 +310,7 @@ export class CashHistoryComponent implements OnInit {
this.exchanges = (res.pageItems ?? []).map((e: any) => ({
id: e.id,
date: e.exchangeDate,
fromParty: this.exchangeParty(e),
panNumber: e.panNumber ?? null,
fromParty: e.accountNo ? `Account ${e.accountNo}` : 'Walk-in Customer',
toParty: e.performedByUsername || '—',
amount: e.exchangeAmount,
note: e.note,
@@ -390,12 +392,39 @@ export class CashHistoryComponent implements OnInit {
this.detail = null;
}
/** Who the exchange was with: the customer's name (and account, if one), else the account, else a walk-in. */
private exchangeParty(e: any): string {
if (e.customerName) {
return e.accountNo ? `${e.customerName} (A/C ${e.accountNo})` : e.customerName;
}
return e.accountNo ? `Account ${e.accountNo}` : 'Walk-in Customer';
startReverse(row: TxnRow): void {
this.reversingId = row.id;
this.reversingType = row.type;
this.reverseReason = '';
}
cancelReverse(): void {
this.reversingId = null;
this.reversingType = null;
this.reverseReason = '';
}
confirmReverse(): void {
if (!this.reversingId || !this.reversingType) return;
const id = this.reversingId;
const type = this.reversingType;
const reason = this.reverseReason;
this.cancelReverse();
let obs$;
if (type === 'transfer') obs$ = this.service.reverseCashTransfer(id, reason);
else if (type === 'settlement') obs$ = this.service.reverseCashSettlement(id, reason);
else if (type === 'exchange') obs$ = this.service.reverseCashExchange(id, reason);
else obs$ = this.service.reverseInterCashierTransfer(id, reason);
obs$.pipe(catchError(() => of(null))).subscribe((res: any) => {
if (res?.reversed) {
this.snackBar.open('Transaction reversed successfully', 'Close', { duration: 3000 });
this.load();
} else {
this.snackBar.open('Reversal failed', 'Close', { duration: 3000 });
}
});
}
typeLabel(type: string): string {
@@ -18,7 +18,6 @@ import { MatInputModule } from '@angular/material/input';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
type PageState = 'loading' | 'no-cashier' | 'needs-reconciliation' | 'already-settled' | 'form';
@@ -91,7 +90,7 @@ export class CashSettlementComponent implements OnInit {
saving = false;
ngOnInit(): void {
const d = businessToday();
const d = new Date();
this.settlementDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service
.getTellers()
@@ -1,229 +0,0 @@
<!--
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="cv-tab-content cta-tab">
<!-- ── TITLE BAR ── -->
<div class="cta-action-bar">
<div class="cta-action-bar__left">
<mat-icon class="cta-action-bar__icon">swap_horiz</mat-icon>
<div>
<div class="cta-action-bar__title">Cash Transfers</div>
<div class="cta-action-bar__sub">
Approve or reject an allocation of cash from the vault to a cashier. The cash leaves the vault only when it is
approved.
</div>
</div>
</div>
<div class="cta-counts">
<span class="cta-count"
><strong>{{ all.length }}</strong> waiting</span
>
</div>
</div>
<!-- ── FILTER BAR ── -->
<div class="cta-filter-bar">
<div class="cta-search-box">
<mat-icon>search</mat-icon>
<input type="text" placeholder="Search by cashier, vault, user or note" [(ngModel)]="searchTerm" />
@if (searchTerm) {
<button type="button" class="cta-search-box__clear" (click)="searchTerm = ''">
<mat-icon>close</mat-icon>
</button>
}
</div>
<button type="button" class="cta-btn cta-btn--refresh" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon>
</button>
</div>
<!-- ── TABLE ── -->
@if (loading && !loaded) {
<div class="cta-empty"><p class="cta-empty__title">Loading…</p></div>
} @else if (rows.length === 0) {
<div class="cta-empty">
<mat-icon class="cta-empty__icon">inbox</mat-icon>
<p class="cta-empty__title">No cash transfer waiting</p>
<p class="cta-empty__text">There is no cash allocation waiting for approval.</p>
</div>
} @else {
<div class="cta-table-wrap">
<table class="cta-table">
<thead>
<tr>
<th>Date</th>
<th>From</th>
<th>To (Cashier)</th>
<th class="num">Amount</th>
<th>Note</th>
<th>Raised By</th>
<th>Raised At</th>
<th class="cta-th-action">Action</th>
</tr>
</thead>
<tbody>
@for (r of rows; track r.id) {
<tr class="cta-row">
<td class="cta-dt">{{ r.transfer_date || '—' }}</td>
<td>{{ r.from_party || '—' }}</td>
<td class="cta-strong">{{ r.to_party || '—' }}</td>
<td class="num cta-amount">₹{{ r.total_amount | number: '1.2-2' }}</td>
<td class="cta-reason" [title]="r.note || ''">{{ r.note || '—' }}</td>
<td>{{ r.maker_name || '—' }}</td>
<td class="cta-dt">{{ formatDateTime(r.maker_at) }}</td>
<td class="cta-action-cell">
<button type="button" class="cta-btn cta-btn--approve" (click)="review(r)">
<mat-icon>rate_review</mat-icon> Review
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<!-- ─── Popup: review a request, every note and coin it asks for ───────────── -->
<ng-template #reviewDialog>
@if (reviewing) {
<div class="cta-dlg">
<header class="cta-dlg__head">
<mat-icon class="cta-dlg__ico cta-dlg__ico--ok">swap_horiz</mat-icon>
<div class="cta-dlg__titles">
<div class="cta-dlg__title">Review cash transfer</div>
<div class="cta-dlg__sub">
Raised by {{ reviewing.maker_name || '—' }} · {{ formatDateTime(reviewing.maker_at) }}
</div>
</div>
<button type="button" class="cta-dlg__x" aria-label="Close" (click)="closeReview()">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="cta-dlg__body">
<div class="cta-total">
<span>Amount to release</span>
<strong>₹{{ reviewing.total_amount | number: '1.2-2' }}</strong>
</div>
<div class="cta-grid">
<div class="cta-cell">
<div class="cta-label">Transfer date</div>
<div class="cta-value">{{ reviewing.transfer_date || '—' }}</div>
</div>
<div class="cta-cell">
<div class="cta-label">From</div>
<div class="cta-value">{{ reviewing.from_party || '—' }}</div>
</div>
<div class="cta-cell">
<div class="cta-label">To (cashier)</div>
<div class="cta-value">{{ reviewing.to_party || '—' }}</div>
</div>
<div class="cta-cell">
<div class="cta-label">Notes and coins</div>
<div class="cta-value">
{{ piecesOf(reviewing) }} pieces · {{ linesOf(reviewing).length }} denominations
</div>
</div>
@if (reviewing.note) {
<div class="cta-cell cta-cell--wide">
<div class="cta-label">Note</div>
<div class="cta-value">{{ reviewing.note }}</div>
</div>
}
</div>
<div class="cta-label">Denomination breakdown</div>
<table class="cta-mini">
<thead>
<tr>
<th>Denomination</th>
<th class="num">Count</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (l of linesOf(reviewing); track l.value) {
<tr>
<td>₹{{ l.value }}</td>
<td class="num">{{ l.count }}</td>
<td class="num">₹{{ l.amount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td>Total</td>
<td class="num">{{ piecesOf(reviewing) }}</td>
<td class="num">₹{{ reviewing.total_amount | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
<footer class="cta-dlg__foot">
<button type="button" class="cta-btn cta-btn--ghost" (click)="closeReview()">Close</button>
<button
type="button"
class="cta-btn cta-btn--reject"
[disabled]="busyId === reviewing.id"
(click)="rejectFromReview(reviewing)"
>
<mat-icon>close</mat-icon> Reject
</button>
<button
type="button"
class="cta-btn cta-btn--approve"
[disabled]="busyId === reviewing.id"
(click)="approve(reviewing)"
>
<mat-icon>check</mat-icon> Approve
</button>
</footer>
</div>
}
</ng-template>
<!-- ─── Popup: reject with a reason ───────────────────────────────────────── -->
<ng-template #rejectDialog>
@if (rejecting) {
<div class="cta-dlg">
<header class="cta-dlg__head">
<mat-icon class="cta-dlg__ico">block</mat-icon>
<div class="cta-dlg__titles">
<div class="cta-dlg__title">Reject cash transfer</div>
<div class="cta-dlg__sub">₹{{ rejecting.total_amount | number: '1.2-2' }} to {{ rejecting.to_party }}</div>
</div>
<button type="button" class="cta-dlg__x" aria-label="Close" (click)="cancelReject()">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="cta-dlg__body">
<label class="cta-label" for="cta-reason">Reason (required)</label>
<textarea
id="cta-reason"
rows="3"
placeholder="Why is this being rejected?"
[(ngModel)]="rejectReason"
></textarea>
</div>
<footer class="cta-dlg__foot">
<button type="button" class="cta-btn cta-btn--ghost" (click)="cancelReject()">Cancel</button>
<button
type="button"
class="cta-btn cta-btn--reject-solid"
[disabled]="!rejectReason.trim() || busyId === rejecting.id"
(click)="confirmReject()"
>
Confirm reject
</button>
</footer>
</div>
}
</ng-template>
@@ -1,573 +0,0 @@
/**
* 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/.
*/
// Laid out like the other approval tabs (Withdrawal Requests): title bar, filter bar, one table.
:host {
display: block;
}
.cta-tab {
padding: 0;
}
.cta-action-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 10px 16px;
background: #fff;
border-bottom: 1px solid #e2e8f0;
&__left {
display: flex;
align-items: center;
gap: 12px;
}
&__icon {
font-size: 22px;
width: 22px;
height: 22px;
color: #0f4f3c;
}
&__title {
font-size: 13px;
font-weight: 700;
color: #0d1b2a;
}
&__sub {
max-width: 640px;
margin-top: 1px;
font-size: 11px;
color: #64748b;
}
}
.cta-counts {
display: flex;
gap: 8px;
}
.cta-count {
padding: 2px 10px;
border-radius: 999px;
background: #f1f5f9;
font-size: 11px;
color: #475569;
strong {
color: #0f4f3c;
}
}
.cta-filter-bar {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
padding: 6px 16px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
.cta-fb-sep {
flex-shrink: 0;
width: 1px;
height: 22px;
margin: 0 4px;
background: #e2e8f0;
}
.cta-search-box {
display: inline-flex;
align-items: center;
gap: 6px;
height: 30px;
min-width: 240px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
mat-icon {
flex-shrink: 0;
font-size: 17px;
width: 17px;
height: 17px;
color: #94a3b8;
}
&:hover {
border-color: #cbd5e1;
}
&:focus-within {
border-color: #0f4f3c;
box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
}
input {
flex: 1;
border: none;
outline: none;
background: transparent;
font-family: inherit;
font-size: 12px;
color: #0d1b2a;
&::placeholder {
color: #94a3b8;
}
}
&__clear {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 20px;
height: 20px;
padding: 0;
border: none;
border-radius: 50%;
background: none;
color: #94a3b8;
cursor: pointer;
mat-icon {
font-size: 15px;
width: 15px;
height: 15px;
color: inherit;
}
&:hover {
color: #dc2626;
background: #fef2f2;
}
}
}
.cta-filter-select {
height: 30px;
min-width: 140px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 7px;
background: #fff;
font-family: inherit;
font-size: 12px;
color: #0d1b2a;
cursor: pointer;
&:focus {
outline: none;
border-color: #0f4f3c;
}
}
// ── Buttons ─────────────────────────────────────────────────────────────────
.cta-btn {
display: inline-flex;
align-items: center;
gap: 4px;
height: 26px;
padding: 0 10px;
border: 1px solid transparent;
border-radius: 7px;
font-family: inherit;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
&--refresh {
height: 30px;
width: 30px;
justify-content: center;
padding: 0;
margin-left: auto;
border-color: #e2e8f0;
background: #fff;
color: #475569;
}
&--approve {
background: #0f4f3c;
color: #fff;
&:hover:not(:disabled) {
background: #0a3d2e;
}
}
&--reject {
border-color: #fecaca;
background: #fff;
color: #dc2626;
&:hover:not(:disabled) {
background: #fef2f2;
}
}
&--reject-solid {
background: #dc2626;
color: #fff;
}
&--ghost {
border-color: #e2e8f0;
background: #fff;
color: #475569;
}
}
// ── Table ───────────────────────────────────────────────────────────────────
.cta-table-wrap {
overflow-x: auto;
background: #fff;
}
.cta-table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
th {
padding: 7px 10px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
font-size: 9.5px;
font-weight: 800;
letter-spacing: 0.06em;
text-align: left;
text-transform: uppercase;
color: #94a3b8;
white-space: nowrap;
}
td {
padding: 5px 10px;
border-bottom: 1px solid #eef2f7;
vertical-align: middle;
color: #0d1b2a;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.cta-th-action {
text-align: right;
}
}
.cta-row:hover td {
background: #f8fafc;
}
.cta-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: 0.02em;
}
.cta-voucher {
font-weight: 700;
color: #0f4f3c;
}
.cta-strong {
font-weight: 700;
}
.cta-sub {
margin-top: 1px;
font-size: 11px;
color: #64748b;
}
.cta-none {
color: #cbd5e1;
}
.cta-amount {
font-weight: 700;
}
.cta-reason {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cta-dt {
white-space: nowrap;
color: #475569;
}
.cta-kind {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
&--unpass {
background: #fef3c7;
color: #92400e;
}
&--correction {
background: #dbeafe;
color: #1e40af;
}
}
.cta-action-cell {
display: flex;
justify-content: flex-end;
gap: 6px;
white-space: nowrap;
}
// ── Empty ───────────────────────────────────────────────────────────────────
.cta-empty {
padding: 28px 16px;
text-align: center;
color: #94a3b8;
&__icon {
display: block;
margin: 0 auto 6px;
font-size: 30px;
width: 30px;
height: 30px;
opacity: 0.6;
}
&__title {
margin: 0;
font-size: 12px;
font-weight: 700;
color: #64748b;
}
&__text {
margin: 3px 0 0;
font-size: 11px;
}
}
// ── Reject popup ────────────────────────────────────────────────────────────
.cta-dlg {
font-size: 12px;
&__head {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
}
&__ico {
flex: 0 0 32px;
height: 32px;
width: 32px;
border-radius: 9px;
background: #fef2f2;
color: #dc2626;
font-size: 18px;
line-height: 32px;
text-align: center;
}
&__titles {
flex: 1;
min-width: 0;
}
&__title {
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
&__sub {
font-size: 11px;
color: #64748b;
}
&__x {
display: inline-flex;
align-items: center;
justify-content: center;
height: 28px;
width: 28px;
border: 0;
border-radius: 50%;
background: transparent;
color: #64748b;
cursor: pointer;
&:hover {
background: #e2e8f0;
}
mat-icon {
font-size: 18px;
height: 18px;
width: 18px;
}
}
&__body {
padding: 12px 14px;
max-height: 64vh;
overflow-y: auto;
textarea {
width: 100%;
padding: 7px 10px;
border: 1px solid #cbd5e1;
border-radius: 8px;
font: inherit;
font-size: 12px;
resize: vertical;
&:focus {
outline: 0;
border-color: #0f4f3c;
box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
}
}
}
&__foot {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px 14px;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
}
}
.cta-label {
display: block;
margin-bottom: 4px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #94a3b8;
}
.cta-dlg__ico--ok {
background: #f0fdf4;
color: #1f7a4d;
}
.cta-total {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 10px;
padding: 8px 12px;
border-radius: 10px;
background: #ecfdf5;
border: 1px solid #a7f3d0;
color: #065f46;
font-size: 12px;
font-weight: 600;
strong {
font-size: 18px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
}
.cta-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 6px;
margin-bottom: 12px;
}
.cta-cell {
padding: 6px 10px;
border-radius: 8px;
background: #f8fafc;
border: 1px solid #eef2f7;
&--wide {
grid-column: 1 / -1;
}
}
.cta-value {
font-size: 12px;
font-weight: 600;
color: #0f172a;
word-break: break-word;
}
.cta-mini {
width: 100%;
margin-top: 4px;
border-collapse: collapse;
font-size: 12px;
th {
padding: 6px 10px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.05em;
text-align: left;
text-transform: uppercase;
color: #94a3b8;
}
td {
padding: 5px 10px;
border-bottom: 1px solid #eef2f7;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
tfoot td {
border-top: 2px solid #e2e8f0;
border-bottom: 0;
font-weight: 800;
background: #f8fafc;
}
}
@@ -1,190 +0,0 @@
/**
* 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, TemplateRef, ViewChild, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
/** A cash transfer request waiting for a checker, as the server keeps it (snake_case columns). */
interface CashTransferRequest {
id: number;
transfer_date: string | null;
from_party: string | null;
to_party: string | null;
note: string | null;
total_amount: number;
maker_name: string | null;
maker_at: string | null;
denominations: { denominationValue: number; count: number }[];
}
/**
* Checker side of Cash Transfer: approves or rejects an allocation of cash from the vault to a cashier. The cash
* leaves the vault only when it is approved, by someone other than the person who raised it.
*/
@Component({
selector: 'mifosx-cash-transfer-approvals',
templateUrl: './cash-transfer-approvals.component.html',
styleUrls: ['./cash-transfer-approvals.component.scss'],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatIconModule,
MatSnackBarModule
]
})
export class CashTransferApprovalsComponent implements OnInit {
private service = inject(CashierService);
private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
@ViewChild('rejectDialog') rejectDialog!: TemplateRef<unknown>;
@ViewChild('reviewDialog') reviewDialog!: TemplateRef<unknown>;
private dialogRef: MatDialogRef<unknown> | null = null;
private reviewRef: MatDialogRef<unknown> | null = null;
/** The request open in the review popup, with every note and coin it asks for. */
reviewing: CashTransferRequest | null = null;
all: CashTransferRequest[] = [];
loading = false;
loaded = false;
busyId: number | null = null;
searchTerm = '';
rejecting: CashTransferRequest | null = null;
rejectReason = '';
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.service
.getCashTransferRequests('PENDING')
.pipe(catchError(() => of([] as CashTransferRequest[])))
.subscribe((rows) => {
this.all = rows as CashTransferRequest[];
this.loading = false;
this.loaded = true;
});
}
get rows(): CashTransferRequest[] {
const term = this.searchTerm.trim().toLowerCase();
if (!term) return this.all;
return this.all.filter((r) => [
r.from_party,
r.to_party,
r.maker_name,
r.note
].some((v) => (v ?? '').toLowerCase().includes(term)));
}
/** One line per denomination asked for, largest first, with what each line adds up to. */
linesOf(r: CashTransferRequest): { value: number; count: number; amount: number }[] {
return [...(r.denominations ?? [])]
.filter((d) => d.count > 0)
.sort((a, b) => b.denominationValue - a.denominationValue)
.map((d) => ({ value: d.denominationValue, count: d.count, amount: d.denominationValue * d.count }));
}
/** How many notes and coins the request asks for in all. */
piecesOf(r: CashTransferRequest): number {
return this.linesOf(r).reduce((sum, l) => sum + l.count, 0);
}
review(r: CashTransferRequest): void {
this.reviewing = r;
this.reviewRef?.close();
this.reviewRef = this.dialog.open(this.reviewDialog, { width: '560px', maxWidth: '95vw', autoFocus: false });
this.reviewRef.afterClosed().subscribe(() => (this.reviewing = null));
}
closeReview(): void {
this.reviewRef?.close();
}
/** Rejecting from the review popup: the review closes, the reason popup opens for the same request. */
rejectFromReview(r: CashTransferRequest): void {
this.reviewRef?.close();
this.startReject(r);
}
formatDateTime(value: string | null): string {
if (!value) return '—';
const d = new Date(value);
return isNaN(d.getTime())
? value
: d.toLocaleString('en-IN', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
}
approve(r: CashTransferRequest): void {
if (this.busyId) return;
this.busyId = r.id;
this.service.approveCashTransferRequest(r.id).subscribe({
next: (res: any) => {
this.busyId = null;
this.reviewRef?.close();
this.snackBar.open(
res?.glWarning ? `Approved (GL warning: ${res.glWarning})` : 'Approved — cash allocated to the cashier',
'Close',
{ duration: 6000 }
);
this.load();
},
error: (err) => {
this.busyId = null;
this.fail(err, 'Could not approve');
}
});
}
startReject(r: CashTransferRequest): void {
this.rejecting = r;
this.rejectReason = '';
this.dialogRef = this.dialog.open(this.rejectDialog, { width: '440px', maxWidth: '95vw', autoFocus: false });
this.dialogRef.afterClosed().subscribe(() => (this.rejecting = null));
}
cancelReject(): void {
this.dialogRef?.close();
}
confirmReject(): void {
if (!this.rejecting || !this.rejectReason.trim() || this.busyId) return;
const id = this.rejecting.id;
this.busyId = id;
this.service.rejectCashTransferRequest(id, this.rejectReason.trim()).subscribe({
next: () => {
this.busyId = null;
this.dialogRef?.close();
this.snackBar.open('Rejected', 'Close', { duration: 4000 });
this.load();
},
error: (err) => {
this.busyId = null;
this.fail(err, 'Could not reject');
}
});
}
private fail(err: any, fallback: string): void {
const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? fallback;
this.snackBar.open(msg, 'Close', { duration: 8000 });
}
}
@@ -13,15 +13,7 @@
<h1 class="pg-title">Cash Transfer</h1>
<p class="pg-sub">Move cash denomination-wise from one party to another (e.g. Vault → Cashier for opening).</p>
</div>
<div class="ct-head-actions">
<button type="button" class="pg-btn pg-btn--ghost" (click)="openRequests()">
<mat-icon>pending_actions</mat-icon> My Requests
@if (pendingCount) {
<span class="ct-badge">{{ pendingCount }}</span>
}
</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="clear()"><mat-icon>undo</mat-icon> Clear</button>
</div>
<button type="button" class="pg-btn pg-btn--ghost" (click)="clear()"><mat-icon>undo</mat-icon> Clear</button>
</header>
<div class="pg-toolbar pg-toolbar--mat">
@@ -163,56 +155,7 @@
<div class="pg-total-bar">
Total to transfer: <strong>₹{{ total | number: '1.2-2' }}</strong>
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!valid || saving" (click)="submit()">
<mat-icon>send</mat-icon> Send for approval
<mat-icon>swap_horiz</mat-icon> Record Transfer
</button>
</div>
</div>
<!-- Popup: this person's own cash transfer requests, and where each stands. The cash moves only once a manager approves. -->
<ng-template #requestsDialog>
<div class="ct-dlg">
<header class="ct-dlg__head">
<mat-icon class="ct-dlg__ico">pending_actions</mat-icon>
<div class="ct-dlg__titles">
<div class="ct-dlg__title">My transfer requests</div>
<div class="ct-dlg__sub">Cash leaves the vault only after a manager approves</div>
</div>
<button type="button" class="ct-dlg__x" aria-label="Close" (click)="closeRequests()">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="ct-dlg__body">
@if (myRequests.length) {
<table class="pg-table">
<thead>
<tr>
<th>Date</th>
<th>To</th>
<th class="num">Amount</th>
<th>Status</th>
<th>Approved by</th>
</tr>
</thead>
<tbody>
@for (r of myRequests; track r.id) {
<tr>
<td>{{ r.transfer_date || '—' }}</td>
<td>{{ r.to_party || '—' }}</td>
<td class="num">₹{{ r.total_amount | number: '1.2-2' }}</td>
<td>
<span class="ct-status" [class]="'ct-status ct-status--' + r.status">{{ r.status }}</span>
@if (r.rejection_reason) {
<div class="ct-requests__note">{{ r.rejection_reason }}</div>
}
</td>
<td>{{ r.checker_name || '—' }}</td>
</tr>
}
</tbody>
</table>
} @else {
<div class="pg-empty">You have not raised any cash transfer request yet.</div>
}
</div>
</div>
</ng-template>
@@ -49,131 +49,3 @@
font-size: 12px;
color: #991b1b;
}
.ct-requests {
padding: 0;
&__title {
padding: 10px 14px;
border-bottom: 1px solid #e2e8f0;
font-size: 13px;
font-weight: 700;
color: #0d1b2a;
}
&__note {
margin-top: 2px;
font-size: 11px;
color: #64748b;
}
}
.ct-status {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
background: #e2e8f0;
font-size: 11px;
font-weight: 700;
&--PENDING {
background: #fef3c7;
color: #92400e;
}
&--APPROVED {
background: #d1fae5;
color: #065f46;
}
&--REJECTED {
background: #fee2e2;
color: #991b1b;
}
}
.ct-head-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.ct-badge {
min-width: 18px;
padding: 0 6px;
border-radius: 999px;
background: #f59e0b;
color: #fff;
font-size: 11px;
font-weight: 700;
text-align: center;
}
.ct-dlg {
font-size: 12px;
&__head {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
}
&__ico {
flex: 0 0 32px;
height: 32px;
width: 32px;
border-radius: 9px;
background: #f0fdf4;
color: #1f7a4d;
font-size: 18px;
line-height: 32px;
text-align: center;
}
&__titles {
flex: 1;
min-width: 0;
}
&__title {
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
&__sub {
font-size: 11px;
color: #64748b;
}
&__x {
display: inline-flex;
align-items: center;
justify-content: center;
height: 28px;
width: 28px;
border: 0;
border-radius: 50%;
background: transparent;
color: #64748b;
cursor: pointer;
&:hover {
background: #e2e8f0;
}
mat-icon {
font-size: 18px;
height: 18px;
width: 18px;
}
}
&__body {
max-height: 60vh;
overflow-y: auto;
}
}
@@ -6,10 +6,9 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, TemplateRef, ViewChild, inject } from '@angular/core';
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatFormFieldModule } from '@angular/material/form-field';
@@ -18,7 +17,6 @@ import { MatInputModule } from '@angular/material/input';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface Denom {
id: number;
@@ -40,7 +38,6 @@ interface Denom {
CommonModule,
FormsModule,
MatIconModule,
MatDialogModule,
MatSnackBarModule,
MatFormFieldModule,
MatSelectModule,
@@ -50,10 +47,6 @@ interface Denom {
export class CashTransferComponent implements OnInit {
private service = inject(CashierService);
private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
@ViewChild('requestsDialog') requestsDialog!: TemplateRef<unknown>;
private requestsRef: MatDialogRef<unknown> | null = null;
notes: Denom[] = [];
coins: Denom[] = [];
@@ -88,35 +81,8 @@ export class CashTransferComponent implements OnInit {
vaultHasBreakdown = true;
loadingVault = false;
/** The caller's own transfer requests, newest first, so a request can be followed to its approval. */
myRequests: any[] = [];
loadMyRequests(): void {
this.service
.getCashTransferRequests(undefined, true)
.pipe(catchError(() => of([] as any[])))
.subscribe((rows) => (this.myRequests = rows.slice(0, 20)));
}
/** How many of this person's requests are still waiting for a manager. */
get pendingCount(): number {
return this.myRequests.filter((r) => r.status === 'PENDING').length;
}
/** Opens the list of this person's requests, freshly read, so a request can be followed to its approval. */
openRequests(): void {
this.loadMyRequests();
this.requestsRef?.close();
this.requestsRef = this.dialog.open(this.requestsDialog, { width: '720px', maxWidth: '95vw', autoFocus: false });
}
closeRequests(): void {
this.requestsRef?.close();
}
ngOnInit(): void {
this.loadMyRequests();
const d = businessToday();
const d = new Date();
this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service.getActiveDenominations().subscribe({
next: (list) => {
@@ -278,11 +244,16 @@ export class CashTransferComponent implements OnInit {
.filter((d) => this.countFor(d) > 0)
.map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(d) }))
};
this.service.createCashTransferRequest(payload).subscribe({
next: () => {
// Nothing has moved yet: a manager approves the allocation first, and only then does the cash leave the vault.
this.snackBar.open('Cash transfer sent for manager approval', 'Close', { duration: 5000 });
this.loadMyRequests();
this.service.createCashTransfer(payload).subscribe({
next: (res: any) => {
if (res?.glWarning) {
this.snackBar.open(`Transfer saved (GL warning: ${res.glWarning})`, 'Close', {
duration: 8000,
panelClass: ['snack-warn']
});
} else {
this.snackBar.open('Cash transfer recorded', 'Close', { duration: 3000 });
}
this.clear();
this.note = '';
this.fromParty = '';
@@ -17,7 +17,6 @@ import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import { businessToday } from 'app/core/utils/business-today';
interface DenomRow {
denominationId: number;
@@ -72,7 +71,7 @@ export class CashierPositionComponent implements OnInit {
isCashierMode = false;
ngOnInit(): void {
const d = businessToday();
const d = new Date();
this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.isCashierMode = this.authService.isCashierUser();
-21
View File
@@ -23,7 +23,6 @@ import { DayBookComponent } from './day-book/day-book.component';
import { VoucherReportComponent } from './voucher-report/voucher-report.component';
import { BranchDayComponent } from './branch-day/branch-day.component';
import { VaultPositionComponent } from './vault-position/vault-position.component';
import { BranchCashPositionComponent } from './branch-cash-position/branch-cash-position.component';
import { AccountRegisterComponent } from './account-register/account-register.component';
import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component';
import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component';
@@ -83,13 +82,6 @@ const routes: Routes = [
),
data: { title: 'My Share Requests', breadcrumb: 'My Share Requests' }
},
{
// The same page as the cashier's, reached from the clerk dashboard: a clerk unpasses vouchers here.
path: 'voucher-correction',
loadComponent: () =>
import('./voucher-correction/voucher-correction.component').then((m) => m.VoucherCorrectionComponent),
data: { title: 'Voucher Correction', breadcrumb: 'Voucher Correction' }
},
{
path: 'closure-requests',
loadComponent: () =>
@@ -233,13 +225,6 @@ const routes: Routes = [
component: VaultPositionComponent,
data: { title: 'Vault Position', breadcrumb: 'Vault Position' }
},
{
// What the whole branch holds per denomination: the vault plus every cashier's drawer, so an
// exchange (which leaves the vault alone) is visible in the bank's total.
path: 'branch-position',
component: BranchCashPositionComponent,
data: { title: 'Branch Cash Position', breadcrumb: 'Branch Cash Position' }
},
{
// The same day book the clerk and cashier read, reached from where a principal officer
// signing the day off actually looks. It carries the Officer Verification Sheet — each
@@ -298,12 +283,6 @@ const routes: Routes = [
component: ReconciliationComponent,
data: { title: 'Reconciliation', breadcrumb: 'Reconciliation' }
},
{
path: 'voucher-correction',
loadComponent: () =>
import('./voucher-correction/voucher-correction.component').then((m) => m.VoucherCorrectionComponent),
data: { title: 'Voucher Correction', breadcrumb: 'Voucher Correction' }
},
{
path: 'cash-history',
component: CashHistoryComponent,
+13 -29
View File
@@ -32,6 +32,9 @@ export class CashierService {
getCashExchange(id: number): Observable<any> {
return this.http.get(`/cashexchange/${id}`);
}
reverseCashExchange(id: number, reason: string): Observable<any> {
return this.http.post(`/cashexchange/${id}/reverse`, { reason });
}
// ── Tellers / Cashiers ──
getTellers(officeId?: number): Observable<any[]> {
@@ -47,21 +50,8 @@ export class CashierService {
}
// ── Cash Transfer ──
/** Cash leaves the vault only when a different person approves, so the screen raises a request, not a transfer. */
createCashTransferRequest(data: any): Observable<any> {
return this.http.post('/cashtransfer/requests', data);
}
/** Requests by status (comma separated); mine narrows them to the ones the caller raised. */
getCashTransferRequests(status?: string, mine = false): Observable<any[]> {
let params = new HttpParams().set('mine', String(mine));
if (status) params = params.set('status', status);
return this.http.get<any[]>('/cashtransfer/requests', { params });
}
approveCashTransferRequest(id: number): Observable<any> {
return this.http.post(`/cashtransfer/requests/${id}/approve`, {});
}
rejectCashTransferRequest(id: number, reason: string): Observable<any> {
return this.http.post(`/cashtransfer/requests/${id}/reject`, { reason });
createCashTransfer(data: any): Observable<any> {
return this.http.post('/cashtransfer', data);
}
getCashTransfers(offset = 0, limit = 20): Observable<any> {
const params = new HttpParams().set('offset', offset).set('limit', limit);
@@ -70,6 +60,9 @@ export class CashierService {
getCashTransferById(id: number): Observable<any> {
return this.http.get(`/cashtransfer/${id}`);
}
reverseCashTransfer(id: number, reason: string): Observable<any> {
return this.http.post(`/cashtransfer/${id}/reverse`, { reason });
}
// ── Cash Settlement ──
createCashSettlement(data: any): Observable<any> {
@@ -82,6 +75,9 @@ export class CashierService {
getCashSettlementById(id: number): Observable<any> {
return this.http.get(`/cashsettlement/${id}`);
}
reverseCashSettlement(id: number, reason: string): Observable<any> {
return this.http.post(`/cashsettlement/${id}/reverse`, { reason });
}
// ── Inter-cashier Transfer ──
createInterCashierTransfer(data: any): Observable<any> {
@@ -94,10 +90,8 @@ export class CashierService {
getInterCashierById(id: number): Observable<any> {
return this.http.get(`/cashintercashier/${id}`);
}
/** One cashier's details, today's vault allocation and denomination-wise cash, for either side of a transfer. */
getInterCashierCashierSummary(cashierId: number, date: string): Observable<any> {
const params = new HttpParams().set('cashierId', cashierId).set('date', date);
return this.http.get<any>('/cashintercashier/cashier-summary', { params });
reverseInterCashierTransfer(id: number, reason: string): Observable<any> {
return this.http.post(`/cashintercashier/${id}/reverse`, { reason });
}
// ── Reconciliation ──
@@ -324,16 +318,6 @@ export class CashierService {
if (asOfDate) params = params.set('asOfDate', asOfDate);
return this.http.get('/cashreports/vault-position', { params });
}
/**
* Denomination-wise cash the whole branch holds: its vault(s) plus every cashier's drawer, with the day's
* exchange movement beside it. Not for a cashier -- the server refuses it.
*/
branchCashPosition(asOfDate?: string, officeId?: number): Observable<any> {
let params = new HttpParams();
if (asOfDate) params = params.set('asOfDate', asOfDate);
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get('/cashreports/branch-position', { params });
}
excessTransactionReport(threshold: number, fromDate?: string, toDate?: string): Observable<any[]> {
let params = new HttpParams().set('threshold', String(threshold));
if (fromDate) params = params.set('fromDate', fromDate);
@@ -51,12 +51,6 @@ export class ClerkShareWithdrawalService {
return this.http.post(`/clerk/share-withdrawal-requests/${id}/validate`, {});
}
/** Runs the exact same checklist approve() will run, against a draft that hasn't been submitted
* yet — so the clerk sees it in the confirm dialog, before committing to the request. */
previewValidate(data: any): Observable<any> {
return this.http.post('/clerk/share-withdrawal-requests/preview-validate', data);
}
approve(id: number): Observable<any> {
return this.http.post(`/clerk/share-withdrawal-requests/${id}/approve`, {});
}
@@ -12,7 +12,6 @@ import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
/** Day-wise denomination-wise closing cash collected from deposits. */
@Component({
@@ -205,7 +204,7 @@ export class ClosingCashReportComponent implements OnInit {
}
ngOnInit(): void {
const d = businessToday();
const d = new Date();
this.date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.load();
this.service.getTellers().subscribe({ next: (t: any[]) => (this.tellers = t || []), error: () => {} });
@@ -202,7 +202,6 @@
<th class="num">Amount</th>
<th>Payment</th>
<th>Status</th>
<th>Correction</th>
<th>By</th>
<th>Approved By</th>
<th>Time</th>
@@ -232,18 +231,6 @@
<td>
<span [class]="statusClass(r.status)">{{ r.status }}</span>
</td>
<td>
<!-- Where the voucher stands after a correction: unpassed, revoked, closed or corrected. -->
<mifosx-voucher-status
[status]="r.voucherStatus"
[revokedBy]="r.revokedByVoucherNo"
[replaces]="r.replacesVoucherNo"
[note]="r.correctionNote"
></mifosx-voucher-status>
@if (!r.voucherStatus || r.voucherStatus === 'ACTIVE') {
<span class="db-muted">—</span>
}
</td>
<td>{{ r.handledBy ?? '—' }}</td>
<td>
{{ r.approvedBy ?? '—' }}
@@ -269,7 +256,7 @@
—
}
</td>
<td colspan="6"></td>
<td colspan="5"></td>
</tr>
</tfoot>
</table>
+2 -10
View File
@@ -13,8 +13,6 @@ import { MatIconModule } from '@angular/material/icon';
import { Router } from '@angular/router';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
/**
* Which part of the day's work a row came from. Set by the server, one per source register.
@@ -27,11 +25,6 @@ type DayBookSection = 'CASHIER' | 'CLERK' | 'SYSTEM';
interface DayBookRow {
voucherNo: string | null;
/** Where the voucher stands after a correction: active, revoked, under correction, or corrected. */
voucherStatus?: VoucherStatus;
revokedByVoucherNo?: string | null;
replacesVoucherNo?: string | null;
correctionNote?: string | null;
section: DayBookSection;
side: 'CR' | 'DR';
kind: string;
@@ -64,8 +57,7 @@ interface DayBookRow {
imports: [
CommonModule,
FormsModule,
MatIconModule,
VoucherStatusComponent
MatIconModule
]
})
export class DayBookComponent implements OnInit {
@@ -103,7 +95,7 @@ export class DayBookComponent implements OnInit {
/** Local date as yyyy-MM-dd — toISOString() would shift the day in any timezone behind UTC. */
private today(): string {
const d = businessToday();
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
@@ -11,29 +11,41 @@
<div>
<div class="pg-eyebrow">CASHIER · INTER-CASHIER TRANSFER</div>
<h1 class="pg-title">Inter-Cashier Transfer</h1>
<p class="pg-sub">Hand over denomination-wise cash from your drawer to another cashier at your counter.</p>
<p class="pg-sub">Move denomination-wise cash between two cashiers within the same teller.</p>
</div>
<button type="button" class="pg-btn pg-btn--ghost" (click)="clear()"><mat-icon>undo</mat-icon> Clear</button>
</header>
@if (ownCashierMissing) {
<div class="ict-alert">
<mat-icon>person_off</mat-icon>
Your login is not linked to a cashier. An inter-cashier transfer is made by the cashier handing the cash over, so
log in as that cashier.
</div>
}
<div class="pg-toolbar pg-toolbar--mat">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller</mat-label>
<input matInput type="text" [value]="tellerName || '—'" readonly />
</mat-form-field>
@if (tellers.length === 1) {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller</mat-label>
<input matInput type="text" [value]="tellers[0].name" readonly />
</mat-form-field>
} @else {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller</mat-label>
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
@for (t of tellers; track t.id) {
<mat-option [value]="t.id">{{ t.name }}</mat-option>
}
@if (tellers.length === 0) {
<mat-option [value]="null" disabled>No tellers configured</mat-option>
}
</mat-select>
</mat-form-field>
}
<!-- The sender is always the cashier who is logged in; nothing to pick. -->
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>From (You)</mat-label>
<input matInput type="text" [value]="fromParty || '—'" readonly />
<mat-label>From (Cashier)</mat-label>
<mat-select [value]="fromCashierId" (selectionChange)="onFromSelect($event.value)" [disabled]="!selectedTellerId">
@for (c of availableFrom; track c.id) {
<mat-option [value]="c.id">{{ c.label }}</mat-option>
}
@if (selectedTellerId && allCashiers.length === 0 && !loadingCashiers) {
<mat-option [value]="null" disabled>No cashiers</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
@@ -42,7 +54,7 @@
@for (c of availableTo; track c.id) {
<mat-option [value]="c.id">{{ c.label }}</mat-option>
}
@if (fromCashierId && !loadingCashiers && availableTo.length === 0) {
@if (fromCashierId && availableTo.length === 0) {
<mat-option [value]="null" disabled>No other cashiers</mat-option>
}
</mat-select>
@@ -50,7 +62,7 @@
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Date</mat-label>
<input matInput type="date" [value]="transferDate" (change)="onDateChange($any($event.target).value)" />
<input matInput type="date" [value]="transferDate" (change)="transferDate = $any($event.target).value" />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="pg-note-field">
@@ -59,102 +71,26 @@
</mat-form-field>
</div>
<!-- Both sides of the transfer: who they are, whether the vault has allocated them cash today,
and what they hold now. -->
@if (fromCashierId || toCashierId) {
<div class="ict-parties">
<ng-container
*ngTemplateOutlet="
partyCard;
context: { title: 'From (Sending Cashier)', s: fromSummary, loading: loadingFrom, picked: !!fromCashierId }
"
></ng-container>
<mat-icon class="ict-parties__arrow">arrow_forward</mat-icon>
<ng-container
*ngTemplateOutlet="
partyCard;
context: { title: 'To (Receiving Cashier)', s: toSummary, loading: loadingTo, picked: !!toCashierId }
"
></ng-container>
</div>
}
<ng-template #partyCard let-title="title" let-s="s" let-loading="loading" let-picked="picked">
<div class="ict-party" [class.ict-party--blocked]="s && !s.cashAllocated">
<div class="ict-party__title">{{ title }}</div>
@if (!picked) {
<div class="ict-party__empty">Select a cashier</div>
} @else if (loading) {
<div class="ict-party__empty">Loading…</div>
} @else if (!s) {
<div class="ict-party__empty">Could not load this cashier's details.</div>
} @else {
<div class="ict-party__name">{{ s.cashierName || '—' }}</div>
<div class="ict-party__meta">
<span><mat-icon>point_of_sale</mat-icon> {{ s.tellerName || '—' }}</span>
<span><mat-icon>account_balance</mat-icon> {{ s.officeName || '—' }}</span>
</div>
<div class="ict-party__figures">
<div>
<div class="ict-party__label">Allocated today</div>
<div class="ict-party__value">₹{{ s.allocatedAmount | number: '1.2-2' }}</div>
</div>
<div>
<div class="ict-party__label">Cash in hand</div>
<div class="ict-party__value">₹{{ s.totalAmount | number: '1.2-2' }}</div>
</div>
</div>
@if (!s.cashAllocated) {
<div class="ict-party__warn">
<mat-icon>block</mat-icon> No cash allocated from the vault for this date — inter-cashier transfer is not
allowed.
</div>
}
}
</div>
</ng-template>
@if (fromSummary && toSummary && !bothAllocated) {
<div class="ict-alert">
<mat-icon>block</mat-icon>
Both cashiers must first be allocated cash from the vault (Cash Transfer) for {{ transferDate }} before an
inter-cashier transfer.
</div>
}
@if (anyExceedsHeld) {
<div class="ict-alert">
<mat-icon>error_outline</mat-icon>
The sending cashier does not hold enough of the highlighted denominations.
</div>
}
<div class="pg-card">
<table class="pg-table">
<thead>
<tr>
<th>Denomination</th>
<th class="num">From holds</th>
<th style="text-align: center">Count</th>
<th class="num">Amount</th>
<th class="num">To holds</th>
</tr>
</thead>
<tbody>
@if (notes.length) {
<tr class="pg-section">
<td colspan="5">Bank Notes</td>
<td colspan="3">Bank Notes</td>
</tr>
}
@for (d of notes; track d.id) {
<tr [class.ict-row--over]="exceedsHeld(d)">
<tr>
<td>
<span class="pg-chip">{{ d.label }}</span>
</td>
<td class="num" [class.ict-held--over]="exceedsHeld(d)">
<ng-container
*ngTemplateOutlet="heldCell; context: { n: heldBy(fromSummary, d), delta: -countFor(d) }"
></ng-container>
</td>
<td style="display: flex; justify-content: center">
<div class="pg-step">
<button type="button" (click)="bump(d, -1)" [disabled]="countFor(d) === 0">−</button>
@@ -163,28 +99,18 @@
</div>
</td>
<td class="num">₹{{ subtotal(d) | number: '1.2-2' }}</td>
<td class="num">
<ng-container
*ngTemplateOutlet="heldCell; context: { n: heldBy(toSummary, d), delta: countFor(d) }"
></ng-container>
</td>
</tr>
}
@if (coins.length) {
<tr class="pg-section">
<td colspan="5">Coins</td>
<td colspan="3">Coins</td>
</tr>
}
@for (d of coins; track d.id) {
<tr [class.ict-row--over]="exceedsHeld(d)">
<tr>
<td>
<span class="pg-chip pg-chip--coin">{{ d.label }}</span>
</td>
<td class="num" [class.ict-held--over]="exceedsHeld(d)">
<ng-container
*ngTemplateOutlet="heldCell; context: { n: heldBy(fromSummary, d), delta: -countFor(d) }"
></ng-container>
</td>
<td style="display: flex; justify-content: center">
<div class="pg-step">
<button type="button" (click)="bump(d, -1)" [disabled]="countFor(d) === 0">−</button>
@@ -193,34 +119,17 @@
</div>
</td>
<td class="num">₹{{ subtotal(d) | number: '1.2-2' }}</td>
<td class="num">
<ng-container
*ngTemplateOutlet="heldCell; context: { n: heldBy(toSummary, d), delta: countFor(d) }"
></ng-container>
</td>
</tr>
}
@if (denoms.length === 0) {
<tr>
<td colspan="5" class="pg-empty">No active denominations. Add some in Cash Denomination.</td>
<td colspan="3" class="pg-empty">No active denominations. Add some in Cash Denomination.</td>
</tr>
}
</tbody>
</table>
</div>
<!-- A held count, with what it becomes once this transfer is recorded. -->
<ng-template #heldCell let-n="n" let-delta="delta">
@if (n === null) {
—
} @else {
{{ n }}
@if (delta !== 0) {
<span class="ict-after">→ {{ n + delta }}</span>
}
}
</ng-template>
<div class="pg-total-bar">
Total to transfer: <strong>₹{{ total | number: '1.2-2' }}</strong>
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!valid || saving" (click)="submit()">
@@ -1,150 +0,0 @@
/**
* 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/.
*/
.ict-parties {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: stretch;
gap: 12px;
margin-bottom: 12px;
&__arrow {
align-self: center;
color: #64748b;
}
@media (width <= 760px) {
grid-template-columns: 1fr;
&__arrow {
justify-self: center;
transform: rotate(90deg);
}
}
}
.ict-party {
padding: 12px 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
&--blocked {
background: #fef2f2;
border-color: #fecaca;
}
&__title {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #64748b;
}
&__empty {
margin-top: 8px;
font-size: 13px;
color: #94a3b8;
}
&__name {
margin-top: 4px;
font-size: 16px;
font-weight: 700;
color: #0f172a;
}
&__meta {
display: flex;
flex-wrap: wrap;
gap: 14px;
margin-top: 4px;
font-size: 12px;
color: #475569;
span {
display: inline-flex;
align-items: center;
gap: 4px;
}
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
}
&__figures {
display: flex;
gap: 24px;
margin-top: 10px;
}
&__label {
font-size: 11px;
color: #64748b;
}
&__value {
font-size: 15px;
font-weight: 700;
color: #0f172a;
}
&__warn {
display: flex;
align-items: center;
gap: 6px;
margin-top: 10px;
font-size: 12px;
font-weight: 600;
color: #991b1b;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
}
.ict-alert {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
padding: 8px 14px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
font-size: 12px;
color: #991b1b;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
.ict-row--over {
background: #fef2f2;
}
.ict-held--over {
color: #b91c1c;
font-weight: 700;
}
.ict-after {
margin-left: 4px;
font-size: 11px;
color: #64748b;
}
@@ -17,7 +17,6 @@ import { MatInputModule } from '@angular/material/input';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface Denom {
id: number;
@@ -29,27 +28,12 @@ interface Cashier {
id: number;
label: string;
}
/** One side of the transfer, from GET /cashintercashier/cashier-summary. */
interface CashierSummary {
cashierId: number;
cashierName: string;
tellerName: string;
officeName: string;
cashAllocated: boolean;
allocatedAmount: number;
totalAmount: number;
/** Count held per denomination id. */
held: Record<number, number>;
}
/** Move cash denomination-wise between two cashiers under the same teller. */
@Component({
selector: 'mifosx-inter-cashier-transfer',
templateUrl: './inter-cashier-transfer.component.html',
styleUrls: [
'../cashier-shared.scss',
'./inter-cashier-transfer.component.scss'
],
styleUrls: ['../cashier-shared.scss'],
standalone: true,
imports: [
CommonModule,
@@ -72,12 +56,7 @@ export class InterCashierTransferComponent implements OnInit {
note = '';
saving = false;
/**
* The sending cashier is always the one logged in: cash is handed over from their own drawer, so there is
* no one else to pick on that side. Only the receiving cashier is chosen.
*/
ownCashierMissing = false;
tellerName = '';
tellers: { id: number; name: string }[] = [];
allCashiers: Cashier[] = [];
selectedTellerId: number | null = null;
fromCashierId: number | null = null;
@@ -86,25 +65,21 @@ export class InterCashierTransferComponent implements OnInit {
toParty = '';
loadingCashiers = false;
/** Details and denomination-wise cash of each side, so both are seen before the transfer is recorded. */
fromSummary: CashierSummary | null = null;
toSummary: CashierSummary | null = null;
loadingFrom = false;
loadingTo = false;
get denoms(): Denom[] {
return [
...this.notes,
...this.coins
];
}
/** Everyone under the same teller except the sender. */
get availableFrom(): Cashier[] {
return this.allCashiers;
}
get availableTo(): Cashier[] {
return this.allCashiers.filter((c) => c.id !== this.fromCashierId);
}
ngOnInit(): void {
const d = businessToday();
const d = new Date();
this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service.getActiveDenominations().subscribe({
next: (list) => {
@@ -122,20 +97,25 @@ export class InterCashierTransferComponent implements OnInit {
this.coins = [];
}
});
// The sender is the cashier behind this login; their counter then gives the receivers to pick from.
this.service.getCurrentCashierInfo().subscribe((info) => {
if (!info) {
this.ownCashierMissing = true;
return;
}
this.fromCashierId = info.cashierId;
this.fromParty = info.cashierName;
this.loadSummary('from');
});
this.service
.getTellers()
.pipe(catchError(() => of([] as any[])))
.subscribe((list) => {
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
if (this.tellers.length === 1) {
this.onTellerSelect(this.tellers[0].id);
}
});
}
/** The other cashiers at this counter, one of whom receives the cash. */
private loadReceivers(tellerId: number): void {
onTellerSelect(tellerId: number): void {
this.selectedTellerId = tellerId;
this.fromCashierId = null;
this.toCashierId = null;
this.fromParty = '';
this.toParty = '';
this.allCashiers = [];
if (!tellerId) return;
this.loadingCashiers = true;
this.service
.getCashiersForTeller(tellerId)
@@ -146,90 +126,20 @@ export class InterCashierTransferComponent implements OnInit {
});
}
onFromSelect(cashierId: number): void {
this.fromCashierId = cashierId;
const c = this.allCashiers.find((x) => x.id === cashierId);
this.fromParty = c ? c.label : '';
if (this.toCashierId === cashierId) {
this.toCashierId = null;
this.toParty = '';
}
}
onToSelect(cashierId: number): void {
this.toCashierId = cashierId;
const c = this.allCashiers.find((x) => x.id === cashierId);
this.toParty = c ? c.label : '';
this.loadSummary('to');
}
onDateChange(value: string): void {
this.transferDate = value;
this.loadSummary('from');
this.loadSummary('to');
}
/** Fetch one side's details, vault allocation and denomination-wise cash for the transfer date. */
private loadSummary(side: 'from' | 'to'): void {
const cashierId = side === 'from' ? this.fromCashierId : this.toCashierId;
if (side === 'from') {
this.fromSummary = null;
} else {
this.toSummary = null;
}
if (!cashierId || !this.transferDate) return;
this.setLoading(side, true);
this.service
.getInterCashierCashierSummary(cashierId, this.transferDate)
.pipe(catchError(() => of(null)))
.subscribe((r: any) => {
this.setLoading(side, false);
// A late answer for a cashier no longer selected on that side is dropped.
const current = side === 'from' ? this.fromCashierId : this.toCashierId;
if (!r || current !== cashierId) return;
// The sender's summary also tells which counter they are at, hence who they can hand cash to.
if (side === 'from' && r.tellerId && this.selectedTellerId !== Number(r.tellerId)) {
this.selectedTellerId = Number(r.tellerId);
this.tellerName = r.tellerName || '';
this.loadReceivers(this.selectedTellerId);
}
const held: Record<number, number> = {};
for (const d of r.denominations || []) {
held[d.denominationId] = (held[d.denominationId] || 0) + Number(d.count || 0);
}
const summary: CashierSummary = {
cashierId: r.cashierId,
cashierName: r.cashierName,
tellerName: r.tellerName,
officeName: r.officeName,
cashAllocated: !!r.cashAllocated,
allocatedAmount: Number(r.allocatedAmount || 0),
totalAmount: Number(r.totalAmount || 0),
held
};
if (side === 'from') {
this.fromSummary = summary;
} else {
this.toSummary = summary;
}
});
}
private setLoading(side: 'from' | 'to', loading: boolean): void {
if (side === 'from') {
this.loadingFrom = loading;
} else {
this.loadingTo = loading;
}
}
/** Count of the denomination the cashier holds, or null while their figures are not loaded. */
heldBy(summary: CashierSummary | null, d: Denom): number | null {
return summary ? summary.held[d.id] || 0 : null;
}
/** The sending cashier is asked to hand on more of this denomination than they hold. */
exceedsHeld(d: Denom): boolean {
return !!this.fromSummary && this.countFor(d) > (this.fromSummary.held[d.id] || 0);
}
get anyExceedsHeld(): boolean {
return this.denoms.some((d) => this.exceedsHeld(d));
}
/** Both sides must have been allocated cash from the vault for the day before they can transfer. */
get bothAllocated(): boolean {
return !!this.fromSummary?.cashAllocated && !!this.toSummary?.cashAllocated;
}
countFor(d: Denom): number {
@@ -249,14 +159,7 @@ export class InterCashierTransferComponent implements OnInit {
return this.denoms.reduce((s, d) => s + this.subtotal(d), 0);
}
get valid(): boolean {
return (
this.total > 0 &&
!!this.selectedTellerId &&
!!this.fromCashierId &&
!!this.toCashierId &&
this.bothAllocated &&
!this.anyExceedsHeld
);
return this.total > 0 && !!this.selectedTellerId && !!this.fromCashierId && !!this.toCashierId;
}
clear(): void {
@@ -283,12 +186,10 @@ export class InterCashierTransferComponent implements OnInit {
this.snackBar.open('Inter-cashier transfer recorded', 'Close', { duration: 3000 });
this.clear();
this.note = '';
// The sender stays the logged-in cashier; the receiver is picked afresh, and the sender's holdings
// are read again now that part of them has gone.
this.fromCashierId = null;
this.toCashierId = null;
this.fromParty = '';
this.toParty = '';
this.toSummary = null;
this.loadSummary('from');
this.saving = false;
},
error: () => {
@@ -146,7 +146,6 @@
<th class="num">Amount</th>
<th>My part</th>
<th>Status</th>
<th>Correction</th>
<th>Time</th>
</tr>
</thead>
@@ -204,18 +203,6 @@
{{ statusLabel(r.verificationStatus) }}
</span>
</td>
<td>
<!-- Where the voucher stands after a correction: unpassed, revoked, closed or corrected. -->
<mifosx-voucher-status
[status]="r.voucherStatus"
[revokedBy]="r.revokedByVoucherNo"
[replaces]="r.replacesVoucherNo"
[note]="r.correctionNote"
></mifosx-voucher-status>
@if (!r.voucherStatus || r.voucherStatus === 'ACTIVE') {
<span class="mw-acct">—</span>
}
</td>
<td class="mw-time">{{ r.createdAt | date: 'HH:mm' }}</td>
</tr>
}
+2 -10
View File
@@ -14,8 +14,6 @@ import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { catchError, finalize, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { VoucherDetailDialogComponent } from './voucher-detail-dialog.component';
import { businessToday } from 'app/core/utils/business-today';
import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
/** What the signed-in user's part in a voucher was. */
export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED';
@@ -25,11 +23,6 @@ export type VoucherSide = 'CR' | 'DR' | 'JV';
interface MyWorkRow {
voucherNo: string;
/** Where the voucher stands after a correction: active, revoked, under correction, or corrected. */
voucherStatus?: VoucherStatus;
revokedByVoucherNo?: string | null;
replacesVoucherNo?: string | null;
correctionNote?: string | null;
side: VoucherSide | null;
voucherType: string | null;
amount: number | null;
@@ -82,8 +75,7 @@ interface MyWorkReport {
CommonModule,
FormsModule,
MatIconModule,
MatDialogModule,
VoucherStatusComponent
MatDialogModule
]
})
export class MyWorkComponent implements OnInit {
@@ -365,7 +357,7 @@ export class MyWorkComponent implements OnInit {
}
private todayIso(): string {
const d = businessToday();
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
}
@@ -11,7 +11,6 @@ import { CommonModule } from '@angular/common';
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
export interface VoucherDetailDialogData {
/** Already-loaded detail from CashierService.voucherDetail() — the caller fetches, this dialog
@@ -19,10 +18,6 @@ export interface VoucherDetailDialogData {
* duplicated inside every dialog that could ever show a voucher. */
detail: {
voucherNo: string;
voucherStatus?: VoucherStatus;
revokedByVoucherNo?: string | null;
replacesVoucherNo?: string | null;
correctionNote?: string | null;
voucherType: string | null;
amount: number | null;
branchName: string | null;
@@ -63,8 +58,7 @@ export interface VoucherDetailDialogData {
CommonModule,
MatDialogModule,
MatButtonModule,
MatIconModule,
VoucherStatusComponent
MatIconModule
],
template: `
<div class="vd-shell">
@@ -72,15 +66,7 @@ export interface VoucherDetailDialogData {
<div class="vd-header__title">
<mat-icon class="vd-icon">receipt_long</mat-icon>
<div>
<h2>
{{ detail.voucherNo }}
<mifosx-voucher-status
[status]="detail.voucherStatus"
[revokedBy]="detail.revokedByVoucherNo"
[replaces]="detail.replacesVoucherNo"
[note]="detail.correctionNote"
></mifosx-voucher-status>
</h2>
<h2>{{ detail.voucherNo }}</h2>
<p>{{ detail.voucherType || 'Voucher' }} &middot; {{ detail.branchName || 'Head Office' }}</p>
</div>
</div>
@@ -19,21 +19,34 @@
<!-- ── Entry Tab ── -->
<mat-tab label="New Reconciliation">
<div class="pg-tab-body">
@if (ownCashierMissing) {
<div class="pg-empty">
Your login is not linked to a cashier, so there is no cash drawer to reconcile. Ask an administrator to link
your user to a cashier.
</div>
}
<div class="pg-toolbar pg-toolbar--mat">
<!-- A reconciliation is the cashier's own count of their own drawer: nothing to pick. -->
@if (tellers.length === 1) {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller</mat-label>
<input matInput type="text" [value]="tellers[0].name" readonly />
</mat-form-field>
} @else {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller</mat-label>
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
@for (t of tellers; track t.id) {
<mat-option [value]="t.id">{{ t.name }}</mat-option>
}
</mat-select>
</mat-form-field>
}
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller</mat-label>
<input matInput type="text" [value]="ownTellerName || '—'" readonly />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Cashier (You)</mat-label>
<input matInput type="text" [value]="cashierName || '—'" readonly />
<mat-label>Cashier</mat-label>
<mat-select
[value]="selectedCashierId"
(selectionChange)="onCashierSelect($event.value)"
[disabled]="!selectedTellerId"
>
@for (c of cashiers; track c.id) {
<mat-option [value]="c.id">{{ c.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
@@ -12,12 +12,12 @@ import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { MatInputModule } from '@angular/material/input';
import { MatTabsModule } from '@angular/material/tabs';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface DenomRow {
denominationId: number;
@@ -57,6 +57,7 @@ interface ReconRecord {
MatIconModule,
MatSnackBarModule,
MatFormFieldModule,
MatSelectModule,
MatInputModule,
MatTabsModule
]
@@ -65,14 +66,9 @@ export class ReconciliationComponent implements OnInit {
private service = inject(CashierService);
private snackBar = inject(MatSnackBar);
/**
* A reconciliation is the cashier's own count of their own drawer, so nobody else makes one and there is
* no cashier to pick: the screen is fixed to the logged-in cashier. The server refuses anything else.
*/
ownCashierMissing = false;
ownTellerName = '';
// ── Entry form ──
tellers: { id: number; name: string }[] = [];
cashiers: { id: number; label: string }[] = [];
selectedTellerId: number | null = null;
selectedCashierId: number | null = null;
cashierName = '';
@@ -89,30 +85,44 @@ export class ReconciliationComponent implements OnInit {
loadingHistory = false;
ngOnInit(): void {
const d = businessToday();
const d = new Date();
this.reconDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.lockToOwnCashier();
this.service
.getTellers()
.pipe(catchError(() => of([] as any[])))
.subscribe((list) => {
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
if (this.tellers.length === 1) {
this.onTellerSelect(this.tellers[0].id);
}
});
this.loadHistory();
}
/** Take the logged-in cashier as the one being reconciled, with their counter for the record. */
private lockToOwnCashier(): void {
this.service.getCurrentCashierInfo().subscribe((info) => {
if (!info) {
this.ownCashierMissing = true;
return;
}
this.selectedCashierId = info.cashierId;
this.cashierName = info.cashierName;
this.service
.getInterCashierCashierSummary(info.cashierId, this.reconDate)
.pipe(catchError(() => of(null)))
.subscribe((r: any) => {
this.selectedTellerId = r?.tellerId ?? null;
this.ownTellerName = r?.tellerName || '';
});
this.fetchExpected();
});
onTellerSelect(tellerId: number): void {
this.selectedTellerId = tellerId;
this.selectedCashierId = null;
this.cashierName = '';
this.cashiers = [];
this.rows = [];
if (!tellerId) return;
this.service
.getCashiersForTeller(tellerId)
.pipe(catchError(() => of({ cashiers: [] })))
.subscribe((r: any) => {
this.cashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName }));
if (this.cashiers.length === 1) {
this.onCashierSelect(this.cashiers[0].id);
}
});
}
onCashierSelect(cashierId: number): void {
this.selectedCashierId = cashierId;
const c = this.cashiers.find((x) => x.id === cashierId);
this.cashierName = c ? c.label : '';
this.rows = [];
this.fetchExpected();
}
fetchExpected(): void {
@@ -117,8 +117,6 @@
<select
class="ws-f__ctl"
[(ngModel)]="row.typ"
[disabled]="isTypLocked(row)"
[title]="isTypLocked(row) ? 'Token withdrawal - TYPE is fixed to Payment' : ''"
(click)="$event.stopPropagation()"
(change)="onTypChange(row)"
>
@@ -129,16 +127,11 @@
</div>
<div class="ws-f ws-f--branch">
<label class="ws-f__lbl" title="Branch">BRANCH</label>
<select
class="ws-f__ctl"
[(ngModel)]="row.br"
[disabled]="branches.length <= 1"
[title]="branches.length <= 1 ? 'Your branch' : 'Branch'"
(click)="$event.stopPropagation()"
>
@for (b of branches; track b.id) {
<option [ngValue]="b.id">{{ b.label }}</option>
}
<select class="ws-f__ctl" [(ngModel)]="row.br" (click)="$event.stopPropagation()">
<option [ngValue]="1">001 · Main</option>
<option [ngValue]="2">002 · M.G. Road</option>
<option [ngValue]="14">014 · Station Rd</option>
<option [ngValue]="27">027 · Industrial</option>
</select>
</div>
@if (row.typ === 'DD') {
@@ -184,7 +177,13 @@
class="ws-f__ctl"
[(ngModel)]="row.acTyp"
(click)="$event.stopPropagation()"
(change)="isGlCreditOnly(row) && (row.dbCr = 'Cr')"
(change)="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer' &&
(row.dbCr = 'Cr')
"
>
@for (a of acTypOptionsFor(row); track a) {
<option [value]="a">{{ a }}</option>
@@ -267,7 +266,12 @@
type="button"
class="ws-seg__btn ws-seg__btn--dr"
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
[disabled]="isGlCreditOnly(row)"
[disabled]="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer'
"
(click)="row.dbCr = 'Dr'"
>
DR
@@ -276,7 +280,6 @@
type="button"
class="ws-seg__btn ws-seg__btn--cr"
[class.ws-seg__btn--active-cr]="row.dbCr === 'Cr'"
[disabled]="row.typ === 'Withdrawal Slip'"
(click)="row.dbCr = 'Cr'"
>
CR
@@ -708,81 +711,70 @@
<circle cx="12" cy="8" r="4" />
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" />
</svg>
{{ isGlAccount ? 'GL Account Information' : 'Personal Information' }}
Personal Information
</div>
@if (isGlAccount) {
<div class="te-panel__body">
@for (f of glInfo; track f.label) {
<div class="te-field">
<span class="te-label">{{ f.label }}</span
><span class="te-value te-value-wrap" [class.te-mono]="f.mono">{{ f.value }}</span>
</div>
}
<div class="te-panel__body">
<div class="te-field">
<span class="te-label">Branch</span><span class="te-value">{{ officeName }}</span>
</div>
} @else {
<div class="te-panel__body">
<div class="te-field">
<span class="te-label">Branch</span><span class="te-value">{{ officeName }}</span>
</div>
<div class="te-field">
<span class="te-label">A/c No.</span
><span class="te-value te-mono">{{ accountDetails.accountNo }}</span>
</div>
<div class="te-field">
<span class="te-label">Name</span><span class="te-value te-value-name">{{ clientName }}</span>
</div>
<div class="te-field">
<span class="te-label">S/D/W/H/O</span><span class="te-value">{{ guardianName }}</span>
</div>
<div class="te-field">
<span class="te-label">Address</span><span class="te-value te-value-wrap">{{ clientAddress }}</span>
</div>
<div class="te-field">
<span class="te-label">Email</span><span class="te-value">{{ clientEmail }}</span>
</div>
<div class="te-field-row">
<div class="te-field">
<span class="te-label">Last Txn</span><span class="te-value">{{ lastTransactionDate }}</span>
</div>
<div class="te-field">
<span class="te-label">Type</span
><span class="te-value">{{ accountDetails.depositType?.value ?? selectedRow?.acTyp ?? '—' }}</span>
</div>
</div>
<div class="te-field-row">
<div class="te-field">
<span class="te-label">Opening Dt</span><span class="te-value">{{ openingDate }}</span>
</div>
<div class="te-field">
<span class="te-label">Status</span
><span class="te-value">{{ accountDetails.status?.value ?? '—' }}</span>
</div>
</div>
<div class="te-field">
<span class="te-label">CIF No.</span><span class="te-value te-mono">{{ cifNumber }}</span>
</div>
@if (isNonIndividual) {
<div class="te-field">
<span class="te-label">Constitution</span>
<span class="te-value">{{
nonPersonDetails?.constitution?.name ?? nonPersonDetails?.constitutionType?.name ?? '—'
}}</span>
</div>
<div class="te-field">
<span class="te-label">Incorp. No.</span>
<span class="te-value te-mono">{{
nonPersonDetails?.incorpNo ?? nonPersonDetails?.incorporationNo ?? '—'
}}</span>
</div>
<div class="te-field">
<span class="te-label">Main Business</span>
<span class="te-value">{{
nonPersonDetails?.mainBusinessLine?.name ?? nonPersonDetails?.mainBusinessLineType?.name ?? '—'
}}</span>
</div>
}
<div class="te-field">
<span class="te-label">A/c No.</span
><span class="te-value te-mono">{{ accountDetails.accountNo }}</span>
</div>
}
<div class="te-field">
<span class="te-label">Name</span><span class="te-value te-value-name">{{ clientName }}</span>
</div>
<div class="te-field">
<span class="te-label">S/D/W/H/O</span><span class="te-value">{{ guardianName }}</span>
</div>
<div class="te-field">
<span class="te-label">Address</span><span class="te-value te-value-wrap">{{ clientAddress }}</span>
</div>
<div class="te-field">
<span class="te-label">Email</span><span class="te-value">{{ clientEmail }}</span>
</div>
<div class="te-field-row">
<div class="te-field">
<span class="te-label">Last Txn</span><span class="te-value">{{ lastTransactionDate }}</span>
</div>
<div class="te-field">
<span class="te-label">Type</span
><span class="te-value">{{ accountDetails.depositType?.value ?? selectedRow?.acTyp ?? '—' }}</span>
</div>
</div>
<div class="te-field-row">
<div class="te-field">
<span class="te-label">Opening Dt</span><span class="te-value">{{ openingDate }}</span>
</div>
<div class="te-field">
<span class="te-label">Status</span
><span class="te-value">{{ accountDetails.status?.value ?? '—' }}</span>
</div>
</div>
<div class="te-field">
<span class="te-label">CIF No.</span><span class="te-value te-mono">{{ cifNumber }}</span>
</div>
@if (isNonIndividual) {
<div class="te-field">
<span class="te-label">Constitution</span>
<span class="te-value">{{
nonPersonDetails?.constitution?.name ?? nonPersonDetails?.constitutionType?.name ?? '—'
}}</span>
</div>
<div class="te-field">
<span class="te-label">Incorp. No.</span>
<span class="te-value te-mono">{{
nonPersonDetails?.incorpNo ?? nonPersonDetails?.incorporationNo ?? '—'
}}</span>
</div>
<div class="te-field">
<span class="te-label">Main Business</span>
<span class="te-value">{{
nonPersonDetails?.mainBusinessLine?.name ?? nonPersonDetails?.mainBusinessLineType?.name ?? '—'
}}</span>
</div>
}
</div>
</div>
<!-- Joint Holders + Nominees -->
@@ -937,78 +929,76 @@
<!-- Other Info + KYC + Account Balances -->
<div class="te-panel">
@if (!isGlAccount) {
<div class="te-panel__header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
Other Information
<div class="te-panel__header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
Other Information
</div>
<div class="te-panel__body">
<div class="te-field">
<span class="te-label">Phone No.</span><span class="te-value">{{ clientPhone }}</span>
</div>
<div class="te-panel__body">
<div class="te-field">
<span class="te-label">Client ID</span
><span class="te-value te-mono">{{ accountDetails.clientId }}</span>
</div>
<div class="te-field">
<span class="te-label">PAN</span><span class="te-value te-mono">{{ panNumber }}</span>
</div>
<div class="te-field">
<span class="te-label">KYC Status</span>
<span class="te-value" [style.color]="kycDetails?.kycStatus === 300 ? '#059669' : '#f59e0b'">{{
kycStatus
}}</span>
</div>
<div class="te-field">
<span class="te-label">CKYC No.</span><span class="te-value te-mono">{{ ckycNumber }}</span>
</div>
<div class="te-field">
<span class="te-label">Aadhaar</span><span class="te-value te-mono">{{ aadhaarNumber }}</span>
</div>
<div class="te-field">
<span class="te-label">Product</span>
<span class="te-value">{{
accountDetails.savingsProductName ||
accountDetails.depositProductName ||
accountDetails.productName ||
'—'
}}</span>
</div>
@if (selectedRow?.acTyp === 'SHR') {
<div class="te-field">
<span class="te-label">Phone No.</span><span class="te-value">{{ clientPhone }}</span>
<span class="te-label">No. of Shares Held</span
><span class="te-value te-mono">{{ accountDetails.summary?.totalApprovedShares ?? 0 }}</span>
</div>
<div class="te-field">
<span class="te-label">Client ID</span
><span class="te-value te-mono">{{ accountDetails.clientId }}</span>
<span class="te-label">Market Price / Share</span
><span class="te-value te-mono"
>{{ currencySymbol }}{{ accountDetails.currentMarketPrice | number: '1.2-2' }}</span
>
</div>
<div class="te-field">
<span class="te-label">PAN</span><span class="te-value te-mono">{{ panNumber }}</span>
<span class="te-label">Resolution No.</span
><span class="te-value te-mono">{{ accountDetails.resolutionNumber || '—' }}</span>
</div>
<div class="te-field">
<span class="te-label">KYC Status</span>
<span class="te-value" [style.color]="kycDetails?.kycStatus === 300 ? '#059669' : '#f59e0b'">{{
kycStatus
}}</span>
<span class="te-label">Linked Savings A/C No.</span
><span class="te-value te-mono">{{ accountDetails.savingsAccountNumber || '—' }}</span>
</div>
<div class="te-field">
<span class="te-label">CKYC No.</span><span class="te-value te-mono">{{ ckycNumber }}</span>
</div>
<div class="te-field">
<span class="te-label">Aadhaar</span><span class="te-value te-mono">{{ aadhaarNumber }}</span>
</div>
<div class="te-field">
<span class="te-label">Product</span>
<span class="te-value">{{
accountDetails.savingsProductName ||
accountDetails.depositProductName ||
accountDetails.productName ||
'—'
}}</span>
</div>
@if (selectedRow?.acTyp === 'SHR') {
@if (accountDetails.linkedLoanAccountNo) {
<div class="te-field">
<span class="te-label">No. of Shares Held</span
><span class="te-value te-mono">{{ accountDetails.summary?.totalApprovedShares ?? 0 }}</span>
<span class="te-label">Linked Loan A/C No.</span
><span class="te-value te-mono">{{ accountDetails.linkedLoanAccountNo }}</span>
</div>
<div class="te-field">
<span class="te-label">Market Price / Share</span
><span class="te-value te-mono"
>{{ currencySymbol }}{{ accountDetails.currentMarketPrice | number: '1.2-2' }}</span
>
</div>
<div class="te-field">
<span class="te-label">Resolution No.</span
><span class="te-value te-mono">{{ accountDetails.resolutionNumber || '—' }}</span>
</div>
<div class="te-field">
<span class="te-label">Linked Savings A/C No.</span
><span class="te-value te-mono">{{ accountDetails.savingsAccountNumber || '—' }}</span>
</div>
@if (accountDetails.linkedLoanAccountNo) {
<div class="te-field">
<span class="te-label">Linked Loan A/C No.</span
><span class="te-value te-mono">{{ accountDetails.linkedLoanAccountNo }}</span>
</div>
}
}
</div>
}
}
</div>
<div class="te-panel__header" [class.te-panel__header--mt]="!isGlAccount">
<div class="te-panel__header te-panel__header--mt">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="1" x2="12" y2="23" />
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
@@ -1577,8 +1567,6 @@
<select
class="ws-f__ctl"
[(ngModel)]="row.typ"
[disabled]="isTypLocked(row)"
[title]="isTypLocked(row) ? 'Token withdrawal - TYPE is fixed to Payment' : ''"
(click)="$event.stopPropagation()"
(change)="onTypChange(row)"
>
@@ -1589,16 +1577,11 @@
</div>
<div class="ws-f ws-f--branch">
<label class="ws-f__lbl">BRANCH</label>
<select
class="ws-f__ctl"
[(ngModel)]="row.br"
[disabled]="branches.length <= 1"
[title]="branches.length <= 1 ? 'Your branch' : 'Branch'"
(click)="$event.stopPropagation()"
>
@for (b of branches; track b.id) {
<option [ngValue]="b.id">{{ b.label }}</option>
}
<select class="ws-f__ctl" [(ngModel)]="row.br" (click)="$event.stopPropagation()">
<option [ngValue]="1">001 · Main</option>
<option [ngValue]="2">002 · M.G. Road</option>
<option [ngValue]="14">014 · Station Rd</option>
<option [ngValue]="27">027 · Industrial</option>
</select>
</div>
@if (row.typ === 'DD') {
@@ -1644,7 +1627,13 @@
class="ws-f__ctl"
[(ngModel)]="row.acTyp"
(click)="$event.stopPropagation()"
(change)="isGlCreditOnly(row) && (row.dbCr = 'Cr')"
(change)="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer' &&
(row.dbCr = 'Cr')
"
>
@for (a of acTypOptionsFor(row); track a) {
<option [value]="a">{{ a }}</option>
@@ -1715,7 +1704,12 @@
type="button"
class="ws-seg__btn ws-seg__btn--dr"
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
[disabled]="isGlCreditOnly(row)"
[disabled]="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer'
"
(click)="$event.stopPropagation(); row.dbCr = 'Dr'"
>
DR
@@ -1724,7 +1718,6 @@
type="button"
class="ws-seg__btn ws-seg__btn--cr"
[class.ws-seg__btn--active-cr]="row.dbCr === 'Cr'"
[disabled]="row.typ === 'Withdrawal Slip'"
(click)="$event.stopPropagation(); row.dbCr = 'Cr'"
>
CR
@@ -2050,13 +2043,7 @@
<button class="ws-btn ws-btn--ghost" type="button" (click)="onSearch()">Search</button>
<button class="ws-btn ws-btn--mode" type="button">Change Tr.Mode</button>
@if (isClerk) {
<button
class="ws-btn ws-btn--add"
type="button"
[disabled]="selectedRow?.typ === 'Withdrawal Slip'"
(click)="addRow()"
title="Add another transaction row"
>
<button class="ws-btn ws-btn--add" type="button" (click)="addRow()" title="Add another transaction row">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
@@ -11,7 +11,7 @@ import { CommonModule } from '@angular/common';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';
import { HttpClient, HttpParams } from '@angular/common/http';
import { catchError, debounceTime, distinctUntilChanged, map, timeout } from 'rxjs/operators';
import { catchError, debounceTime, distinctUntilChanged, map } from 'rxjs/operators';
import { forkJoin, of, Subject } from 'rxjs';
import { MatDialog, MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
@@ -33,7 +33,6 @@ import {
import { SponsorDDService } from '../../sponsor-dd/sponsor-dd.service';
import { OrganizationService } from '../../organization/organization.service';
import { CashierIssueDdDialogComponent } from './cashier-issue-dd-dialog.component';
import { businessToday, businessTodayIso } from 'app/core/utils/business-today';
interface TxnRow {
typ: string;
@@ -622,7 +621,7 @@ export class AccountStatementDialogComponent implements OnInit {
@ViewChild('statementContent') statementContent!: ElementRef;
today = businessTodayIso();
today = new Date().toISOString().slice(0, 10);
fromDate = '';
toDate = this.today;
@@ -1658,8 +1657,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// Full account + client data
accountDetails: any = null;
/** Parent (header) GL of the selected GL account, shown in the GL Account Information panel. */
glParent: { glCode?: string; name?: string } | null = null;
clientDetails: any = null;
kycDetails: any = null;
nominees: any[] = [];
@@ -1687,12 +1684,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
private searchToken = 0;
private lastLoadedAcNo = '';
private acNoInput$ = new Subject<{ row: TxnRow; index: number; value: string }>();
/** How many characters of the A/C NO. start the automatic account search. */
private static readonly AC_NO_AUTO_SEARCH_MIN_LENGTH = 6;
cashVaultGlId: number | null = null;
/** The Cash Vault GL account's own code and name — the denomination breakdown for a manual GL
* entry has to be filed against THIS account, not whichever account the teller typed in, since
* that account is never the one physical notes actually moved through.
* that account is never the one physical notes actually moved through (see submitGlJournalEntry).
* Both resolved dynamically off whatever this tenant's own financial-activity mapping names as
* its cash/vault account (see loadCashVaultGl) — never a literal account name or code. */
cashVaultGlCode: string | null = null;
@@ -1875,9 +1870,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
saving = false;
ngOnInit(): void {
const d = businessToday();
const d = new Date();
this.today = `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
this.loadBranches();
this.addRow();
this.loadDenominations();
this.loadOpeningPosition();
@@ -1908,8 +1902,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
distinctUntilChanged((a, b) => a.value === b.value && a.row === b.row)
)
.subscribe(({ row, index, value }) => {
// Searches on its own once six characters are typed, for clerk and cashier alike.
if (value.trim().length >= TransactionEntryComponent.AC_NO_AUTO_SEARCH_MIN_LENGTH) {
if (value.trim().length >= 4) {
this.selectedRow = row;
this.loadAccountDetails(value.trim(), row.acTyp);
}
@@ -2110,7 +2103,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
private loadOpeningPosition(): void {
const d = businessToday();
const d = new Date();
const asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.cashierService
.getCashierPositionForMe(asOfDate)
@@ -2310,7 +2303,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// anyway, just without the extra click to get there.
const row: TxnRow = {
typ: this.isClerk ? 'Withdrawal Slip' : 'Receipt',
br: this.defaultBranchId,
br: 1,
trTp: '',
acTyp: 'SD',
acNo: '',
@@ -2329,40 +2322,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.rows.push(row);
}
/**
* A GL row is credit-only (money received into the account) except on the transfer types, which pick
* either side, and on a Withdrawal Slip, which is always a debit: the GL is debited and the cash
* vault credited when cash is paid out against it (a clerk's request, approved, then paid by the cashier).
*/
isGlCreditOnly(row: TxnRow): boolean {
return (
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer' &&
row.typ !== 'Withdrawal Slip'
);
}
/** Called when the TYPE dropdown changes — sets sensible defaults for TXN TP and DR/CR */
/**
* A cashier row that was filled from an approved token (withdrawal / account-closure) is cash
* leaving the branch to the member, so its TYPE is fixed to Payment and cannot be changed.
* Never true for the clerk screen or for rows keyed in by hand.
*/
isTypLocked(row: TxnRow): boolean {
return (
!this.isClerk &&
!!row.tokNo?.trim() &&
!!(this.activeWithdrawalRequest || this.activeDividendClaimRequest || this.activeClosureRequest)
);
}
onTypChange(row: TxnRow): void {
if (this.isTypLocked(row)) {
row.typ = 'Payment';
return;
}
switch (row.typ) {
case 'Withdrawal Slip':
row.dbCr = 'Dr';
@@ -2478,7 +2439,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
* such ambiguity — the account itself says what it's for.
*/
showsEntryType(row: TxnRow): boolean {
return row.acTyp === 'GL';
return row.acTyp === 'GL' && row.dbCr === 'Cr';
}
/**
@@ -2656,47 +2617,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return !!this.selectedRow && Math.abs(this.denominationTotal - this.ddTargetTotal) < 0.005;
}
/**
* The branches an entry can be posted against: the real offices of this installation. Read from the offices
* themselves rather than from a fixed list, so a branch that is added or renamed shows up here without a code change.
*/
branches: { id: number; label: string }[] = [];
private loadBranches(): void {
const own = this.authService.getCredentials();
const ownBranch = own?.officeId
? [{ id: own.officeId, label: this.branchLabel(own.officeId, own.officeName) }]
: [];
// The office list is only asked for by a login allowed to read it. A cashier or clerk is not, and asking anyway
// makes the server refuse and the screen raise an "Unauthorized Request" popup; such a login works in its own
// office, which is the one branch it is offered.
const permissions = own?.permissions ?? [];
const mayReadOffices = permissions.includes('ALL_FUNCTIONS') || permissions.includes('READ_OFFICE');
if (!mayReadOffices) {
this.branches = ownBranch;
return;
}
this.http
.get('/offices')
.pipe(catchError(() => of([])))
.subscribe((list: any) => {
const offices: any[] = Array.isArray(list) ? list : [];
this.branches =
offices.length > 0 ? offices.map((o) => ({ id: o.id, label: this.branchLabel(o.id, o.name) })) : ownBranch;
});
}
private branchLabel(id: number, name: string | null | undefined): string {
return `${String(id).padStart(3, '0')} · ${name ?? 'Branch ' + id}`;
}
/** The office the signed-in user works in, which a new entry is posted against unless they pick another. */
get defaultBranchId(): number {
return this.authService.getCredentials()?.officeId ?? 1;
}
branchNameFor(branchId: number): string {
return this.branches.find((b) => b.id === branchId)?.label ?? `Branch ${branchId}`;
const map: Record<number, string> = {
1: '001 · Main',
2: '002 · M.G. Road',
14: '014 · Station Rd',
27: '027 · Industrial'
};
return map[branchId] || `Branch ${branchId}`;
}
/**
@@ -2909,7 +2837,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
fundingAccountId: number,
fundingClientId: number | null
): void {
const d = businessToday();
const d = new Date();
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB';
const isGl = row.acTyp === 'GL';
@@ -3041,7 +2969,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const d = businessToday();
const d = new Date();
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB';
@@ -3140,7 +3068,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const d = businessToday();
const d = new Date();
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const payload = {
@@ -3463,7 +3391,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const today = businessToday();
const today = new Date();
const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
// Full snapshot of the CR (settlement) row exactly as the clerk filled it in — saved verbatim
@@ -3497,7 +3425,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
meetingDate: shareRow.meetingDate,
description: shareRow.description || null,
settlementPaymentTypeId: savingsRow.paymentTypeId,
settlementMode: 'SAVINGS',
settlementRowSnapshot
};
@@ -3528,60 +3455,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
? 'Pending Manager approval — redemption, settlement and account closure all post together on approval.'
: 'Pending Manager approval — redemption and settlement both post together on approval.'
];
this.showShareWithdrawalConfirm(payload, confirmLines, () =>
this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', confirmLines, 'Submit for Approval', () =>
this.doSubmitShareRedemptionTransfer(payload, shareRow, shares, linkedAcNo)
);
}
/**
* Shows the confirm dialog with the same checklist the Manager will see when approving this
* request — fetched live from the backend (`previewValidate`) rather than re-implemented here,
* so it can never show the clerk something different from what the approver actually sees. A
* failing check is shown, not blocked: approve() enforces its own hard rules separately (e.g.
* Share Advance Collection applied), this list is advisory so problems surface to whoever can
* still fix them, before a manager's time is spent on a request that was always going to fail.
*/
private showShareWithdrawalConfirm(
payload: Record<string, unknown>,
baseLines: string[],
onConfirm: () => void
): void {
this.clerkShareWithdrawalService
.previewValidate(payload)
.pipe(
// A request that never resolves (slow network, server busy) must not leave the Submit
// button looking dead — the dialog still has to open, just without the checklist.
timeout(5000),
catchError(() => of({ __previewFailed: true }))
)
.subscribe((res: any) => {
const checks: { label: string; pass: boolean; detail: string }[] = res?.checks ?? [];
// A failed call (most likely the backend not yet carrying the /preview-validate endpoint)
// says so explicitly rather than just omitting the section — a silently empty checklist
// looks identical to "everything passed, nothing to show", which is the one reading this
// must never give.
const checkLines = res?.__previewFailed ? [
'',
'⚠ Validation checklist unavailable right now — the request can still be submitted.'
] : checks.length ? [
'',
'Validation Checks (same as Manager approval):',
...checks.map((c) => `${c.pass ? '✓' : '⚠'} ${c.label}: ${c.detail}`)
] : [];
// baseLines ends with a blank line followed by the "Pending Manager approval…" footer
// sentence, which must stay last so the modal renders it as a footnote rather than a
// key/value row — so the checklist is spliced in just before that pair, not appended after.
const footerStart =
baseLines.length >= 2 && baseLines[baseLines.length - 2] === '' ? baseLines.length - 2 : baseLines.length;
const lines = [
...baseLines.slice(0, footerStart),
...checkLines,
...baseLines.slice(footerStart)
];
this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', lines, 'Submit for Approval', onConfirm);
});
}
/**
* Share redemption settled to a GL account the clerk picks on the CR row, instead of the
* member's linked savings account — mirrors submitShareRedemptionTransfer() above, but the
@@ -3634,7 +3512,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const today = businessToday();
const today = new Date();
const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
// Full snapshot of the CR (settlement) row exactly as the clerk filled it in — saved verbatim
@@ -3693,7 +3571,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
? 'Pending Manager approval — redemption, settlement and account closure all post together on approval.'
: 'Pending Manager approval — redemption and settlement both post together on approval.'
];
this.showShareWithdrawalConfirm(payload, confirmLines, () =>
this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', confirmLines, 'Submit for Approval', () =>
this.doSubmitShareGlSettlementTransfer(payload, shareRow, shares, glRow.acNo)
);
}
@@ -3989,8 +3867,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
SAVINGS: 'SD',
CURRENT: 'CA',
FIXED_DEPOSIT: 'FD',
RECURRING_DEPOSIT: 'RD',
GL: 'GL'
RECURRING_DEPOSIT: 'RD'
};
openCustomerSearch(): void {
@@ -4296,7 +4173,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
): void {
const accountUrl = `${cfg.detail}/${accountId}${cfg.detailParams ?? '?associations=all'}`;
const todayStr = (() => {
const d = businessToday();
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
})();
@@ -4315,17 +4192,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
this.accountDetails = acc;
this.jointHolders = acc.jointHolders ?? [];
// GL only: look up the parent (header) GL's name. Never runs for any other account type.
this.glParent = null;
if (acTyp?.toUpperCase() === 'GL' && acc.parentId) {
this.http
.get<any>(`/glaccounts/${acc.parentId}`)
.pipe(catchError(() => of(null)))
.subscribe((p: any) => {
if (token !== this.searchToken) return;
this.glParent = p ? { glCode: p.glCode, name: p.name } : null;
});
}
// Cash Hold / Clearing Hold breakdown only exists for plain savings accounts (m_savings_account)
// — FD/RD/Current/Share/GL have no such row, so there's nothing to ask for.
if (acTyp?.toUpperCase() === 'SD' && acc.id) {
@@ -4683,7 +4549,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
get todayIso(): string {
const d = businessToday();
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
@@ -4896,40 +4762,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return this.accountDetails?.officeName ?? this.clientDetails?.officeName ?? '—';
}
/** True when the loaded account is a General Ledger account (/glaccounts), which has no client behind it. */
get isGlAccount(): boolean {
return !!this.accountDetails?.glCode && this.selectedRow?.acTyp === 'GL';
}
/** The GL account's own details, shown in place of the client panels. */
get glInfo(): { label: string; value: string; mono?: boolean }[] {
const gl = this.accountDetails;
if (!gl) return [];
const type = gl.type?.value ?? '—';
const nature = [
'ASSET',
'EXPENSE'
].includes(String(type).toUpperCase()) ? 'Debit' : 'Credit';
return [
{ label: 'GL Code', value: gl.glCode ?? '—', mono: true },
{ label: 'GL Name', value: gl.name ?? '—' },
{ label: 'Account Type', value: type },
{ label: 'Usage', value: gl.usage?.value ?? '—' },
{ label: 'Normal Balance', value: nature },
{
label: 'Parent GL',
value: this.glParent?.name
? `${this.glParent.glCode ? this.glParent.glCode.trim() + ' - ' : ''}${this.glParent.name.trim()}`
: gl.parentId != null
? `ID ${gl.parentId}`
: '—'
},
{ label: 'Manual Entries', value: gl.manualEntriesAllowed ? 'Allowed' : 'Not allowed' },
{ label: 'Status', value: gl.disabled ? 'Disabled' : 'Enabled' },
{ label: 'Description', value: gl.description || '—' }
];
}
get openingDate(): string {
const raw =
this.accountDetails?.timeline?.submittedOnDate ??
@@ -5239,7 +5071,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.sameDayChequeClearing = 0;
return;
}
const today = businessToday();
const today = new Date();
const sameDay = (d: any) => {
if (!d) return false;
const t = Array.isArray(d) && d.length >= 3 ? new Date(d[0], d[1] - 1, d[2]) : new Date(d);
@@ -5363,8 +5195,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.showAlert('error', 'Invalid Account Type', [
`Account type(s) not allowed in Bank Transfer: ${invalidAccounts.map((r) => r.acTyp + ' (' + r.acNo + ')').join(', ')}`,
'Only Savings (SD) and Current (CA) accounts are permitted.',
'GL, FD and SHR accounts are not allowed — use Disbursement/Collection instead.',
'RD installments are paid through a cashier Receipt or Auto Debit.'
'GL, FD, RD, SHR accounts are not allowed — use Disbursement/Collection instead.'
]);
return;
}
@@ -5418,19 +5249,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.rows.length >= 2 &&
this.rows.every((r) => r.typ === 'Disbursement' || r.typ === 'Fund Transfer')
) {
// A Recurring Deposit is never paid this way: these requests post straight into the account, so the
// installment schedule never hears of the money -- installments stay unpaid, Auto Debit collects them
// again and the maturity amount comes out wrong. RD installments come in through the cashier's
// Receipt or the RD's own Auto Debit.
const rdRows = this.rows.filter((r) => r.acTyp === 'RD');
if (rdRows.length > 0) {
this.showAlert('error', 'Recurring Deposit Not Allowed', [
`RD account(s) ${rdRows.map((r) => r.acNo).join(', ')} cannot be used in a Disbursement, Collection or Fund Transfer.`,
'Pay RD installments through a cashier Receipt, or by Auto Debit from the linked savings account.'
]);
return;
}
const drRows = this.rows.filter((r) => r.dbCr === 'Dr');
const crRows = this.rows.filter((r) => r.dbCr === 'Cr');
@@ -5964,14 +5782,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
// A GL withdrawal slip is raised by the clerk and paid by the cashier against its token, exactly like a
// savings withdrawal (the clerk branch and the token branch below). Nothing posts from this screen directly:
// manual journal entries are switched off on the server.
if (row.acTyp === 'GL' && !isDeposit && !this.clerkMode && !this.activeWithdrawalRequest) {
this.showAlert('error', 'GL Withdrawal', [
'A GL withdrawal is raised by a clerk and paid by the cashier against its token.',
'Enter the token number of the approved request to pay it.'
]);
// GL withdrawal → direct journal entry (no maker-checker)
// GL deposit → falls through to maker-checker block below
if (row.acTyp === 'GL' && !isDeposit) {
this.submitGlJournalEntry(row, amount, isDeposit);
return;
}
@@ -6145,7 +5959,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const today = businessToday();
const today = new Date();
const txnDate = `${String(today.getDate()).padStart(2, '0')}/${String(today.getMonth() + 1).padStart(2, '0')}/${today.getFullYear()}`;
const payload: any = {
@@ -6340,38 +6154,21 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
/** Build the withdrawal-request payload from the current row + loaded account. */
private buildWithdrawalPayload(row: TxnRow, amount: number): any {
const today = businessToday();
const today = new Date();
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
const isGl = row.acTyp === 'GL';
// The Entry Type picked for a GL slip has no column of its own on the request, so it rides on the narration.
const entryTypeName = isGl ? this.entryTypeOptions.find((e) => e.id === row.entryTypeCvId)?.name : undefined;
const description = [
row.description,
entryTypeName ? `[Entry Type: ${entryTypeName}]` : ''
]
.filter(Boolean)
.join(' ');
return {
accountNo: isGl ? (this.accountDetails.glCode ?? this.accountDetails.name) : this.accountDetails.accountNo,
accountNo: this.accountDetails.accountNo,
accountType: this.creditEntryAccountType(row.acTyp),
savingsAccountId: isGl ? null : this.accountDetails.id,
// GL-specific fields: the GL being paid out of and the cash GL it is paid from (null for other types)
glAccountId: isGl ? this.accountDetails.id : null,
contraGlId: isGl ? this.cashVaultGlId : null,
officeId: isGl ? row.br || 1 : null,
currencyCode: isGl ? (this.accountDetails?.currency?.code ?? 'INR') : null,
clientId: isGl ? null : (this.accountDetails.clientId ?? this.clientDetails?.id ?? null),
clientName: isGl
? (this.accountDetails?.name ?? null)
: (this.clientDetails?.displayName ?? this.accountDetails?.clientName ?? null),
savingsAccountId: this.accountDetails.id,
clientId: this.accountDetails.clientId ?? this.clientDetails?.id ?? null,
clientName: this.clientDetails?.displayName ?? this.accountDetails?.clientName ?? null,
amount,
txnDate: txnDateIso,
paymentTypeId: row.paymentTypeId || null,
paymentTypeName: selectedPt?.name ?? 'Cash',
description: description || null,
description: row.description || null,
chequeNo: row.chequeNo || null,
bankName: row.bankName || null,
tokNo: row.tokNo?.trim() || null,
@@ -6462,7 +6259,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
/** Build the deposit-request payload from the current row + loaded account. */
private buildDepositPayload(row: TxnRow, amount: number): any {
const today = businessToday();
const today = new Date();
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
// Send denominations only for cash payments — stale counts on a cheque
@@ -6563,6 +6360,106 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.addRow();
}
private submitGlJournalEntry(row: TxnRow, amount: number, isDeposit: boolean): void {
if (!this.cashVaultGlId) {
this.showAlert('error', 'Cash Vault GL Not Configured', [
'Financial Activity Account (CASH_AT_TELLER) is not configured.',
'Please configure it in System → Financial Activity Accounts.'
]);
this.saving = false;
return;
}
const today = new Date();
const txnDate = `${String(today.getDate()).padStart(2, '0')}/${String(today.getMonth() + 1).padStart(2, '0')}/${today.getFullYear()}`;
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
const selectedGlId = this.accountDetails.id;
const contraGlId = this.cashVaultGlId;
// isDeposit (CR button) = cashier receives cash → credit selected GL, debit contra (cash vault)
// isWithdrawal (DR button) = cashier pays out cash → debit selected GL, credit contra (cash vault)
const credits = isDeposit ? [{ glAccountId: selectedGlId, amount }] : [{ glAccountId: contraGlId, amount }];
const debits = isDeposit ? [{ glAccountId: contraGlId, amount }] : [{ glAccountId: selectedGlId, amount }];
const payload: any = {
officeId: row.br || 1,
transactionDate: txnDate,
dateFormat: 'dd/MM/yyyy',
locale: 'en',
currencyCode: this.accountDetails?.currency?.code ?? 'INR',
comments: row.description || null,
credits,
debits,
paymentTypeId: row.paymentTypeId
};
// Check if cash payment so we can record denomination after journal entry
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
const isCashPayment = !!selectedPt?.isCashPayment;
this.saving = true;
this.http
.post('/journalentries', payload)
.pipe(
catchError((err: any) => {
const msg =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.developerMessage ??
'Journal entry failed. Please try again.';
this.showAlert('error', 'Transaction Failed', [msg]);
this.saving = false;
return of(null);
})
)
.subscribe((res: any) => {
this.saving = false;
if (res) {
// Record denomination so cashier position stays accurate
// Same condition as savings: cash + denom entered + denom matches posted amount
if (isCashPayment && this.denominationTotal > 0 && this.denominationTotal === amount) {
const denomBreakdown = [
...this.notes,
...this.coins
]
.filter((d) => this.countFor(d) > 0)
.map((d) => ({
denominationId: d.id,
denominationValue: d.value,
kind: d.kind,
count: this.countFor(d),
subtotal: this.subtotalFor(d)
}));
// Filed against the Cash Vault GL, not the account the teller selected on screen: that
// selected account (e.g. Sundry Creditors) is the other leg of the journal entry, and
// never the one whose physical notes actually changed hands. Tagging the breakdown to it
// instead of the vault is why this cash never reached the cashier/vault position.
this.http
.post('/cashier/gl-denomination', {
txnId: res.transactionId ?? res.resourceId,
accountNo: this.cashVaultGlCode ?? row.acNo,
accountName: this.cashVaultGlName ?? 'Cash at Vault',
txnType: isDeposit ? 'DEPOSIT' : 'WITHDRAWAL',
txnDate: txnDateIso,
paymentTypeName: selectedPt?.name ?? 'Cash',
denominationBreakdown: denomBreakdown
})
.pipe(catchError(() => of(null)))
.subscribe();
}
const ref = res.transactionId ?? res.resourceId ?? 'N/A';
this.showAlert('success', 'GL Transaction Posted', [
`Journal entry of ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} posted successfully.`,
`Transaction ID: ${ref}`
]);
this.clearDenominations();
this.loadOpeningPosition();
this.loadAccountDetails(row.acNo.trim(), row.acTyp);
}
});
}
onSearch(): void {
if (this.selectedRow?.acNo.trim()) {
this.loadAccountDetails(this.selectedRow.acNo.trim(), this.selectedRow.acTyp);
@@ -16,7 +16,6 @@ import { MatInputModule } from '@angular/material/input';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface DenomRow {
denominationId: number;
@@ -68,7 +67,7 @@ export class VaultPositionComponent implements OnInit {
errorMsg = '';
ngOnInit(): void {
const d = businessToday();
const d = new Date();
this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service
@@ -1,170 +0,0 @@
<!--
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="cv-tab-content vca-tab">
<!-- ── TITLE BAR ── -->
<div class="vca-action-bar">
<div class="vca-action-bar__left">
<mat-icon class="vca-action-bar__icon">undo</mat-icon>
<div>
<div class="vca-action-bar__title">Voucher Corrections</div>
<div class="vca-action-bar__sub">
Approve or reject a voucher unpass, and the correction entry that follows it. Only the correction approval
moves money.
</div>
</div>
</div>
<div class="vca-counts">
<span class="vca-count"
><strong>{{ unpassCount }}</strong> unpass</span
>
<span class="vca-count"
><strong>{{ correctionCount }}</strong> correction</span
>
</div>
</div>
<!-- ── FILTER BAR ── -->
<div class="vca-filter-bar">
<div class="vca-search-box">
<mat-icon>search</mat-icon>
<input type="text" placeholder="Search by voucher, account, customer or user" [(ngModel)]="searchTerm" />
@if (searchTerm) {
<button type="button" class="vca-search-box__clear" (click)="searchTerm = ''">
<mat-icon>close</mat-icon>
</button>
}
</div>
<span class="vca-fb-sep"></span>
<select class="vca-filter-select" [(ngModel)]="kindFilter">
@for (opt of kindOptions; track opt.value) {
<option [value]="opt.value">{{ opt.label }}</option>
}
</select>
<button type="button" class="vca-btn vca-btn--refresh" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon>
</button>
</div>
<!-- ── TABLE ── -->
@if (loading && !loaded) {
<div class="vca-empty"><p class="vca-empty__title">Loading…</p></div>
} @else if (rows.length === 0) {
<div class="vca-empty">
<mat-icon class="vca-empty__icon">inbox</mat-icon>
<p class="vca-empty__title">No voucher correction waiting</p>
<p class="vca-empty__text">There is no unpass request or correction entry waiting for review.</p>
</div>
} @else {
<div class="vca-table-wrap">
<table class="vca-table">
<thead>
<tr>
<th>Voucher No</th>
<th>Request</th>
<th>Posted To (Wrong)</th>
<th>Correct Account</th>
<th class="num">Amount</th>
<th>Reason</th>
<th>Raised By</th>
<th>Raised At</th>
<th class="vca-th-action">Action</th>
</tr>
</thead>
<tbody>
@for (r of rows; track r.request.id) {
<tr class="vca-row">
<td class="vca-mono vca-voucher">{{ r.request.original_voucher_no }}</td>
<td>
<span class="vca-kind" [class]="'vca-kind vca-kind--' + r.kind">{{ kindLabel(r.kind) }}</span>
</td>
<td>
<div class="vca-mono">{{ r.request.wrong_account_no }}</div>
<div class="vca-sub">{{ r.request.wrong_client_name || '—' }}</div>
</td>
<td>
@if (r.kind === 'correction') {
<div class="vca-mono vca-strong">{{ r.request.correct_account_no }}</div>
<div class="vca-sub">{{ r.request.correct_client_name || '—' }}</div>
} @else {
<span class="vca-none">—</span>
}
</td>
<td class="num vca-amount">₹{{ r.request.amount | number: '1.2-2' }}</td>
<td class="vca-reason" [title]="r.request.reason || ''">{{ r.request.reason || '—' }}</td>
<td>{{ r.raisedBy || '—' }}</td>
<td class="vca-dt">{{ formatDateTime(r.raisedAt) }}</td>
<td class="vca-action-cell">
<button
type="button"
class="vca-btn vca-btn--reject"
[disabled]="busyId === r.request.id"
(click)="startReject(r)"
>
<mat-icon>close</mat-icon> Reject
</button>
<button
type="button"
class="vca-btn vca-btn--approve"
[disabled]="busyId === r.request.id"
(click)="approve(r)"
>
<mat-icon>check</mat-icon>
{{ r.kind === 'unpass' ? 'Approve unpass' : 'Approve correction' }}
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<!-- ─── Popup: reject with a reason ───────────────────────────────────────── -->
<ng-template #rejectDialog>
@if (rejecting) {
<div class="vca-dlg">
<header class="vca-dlg__head">
<mat-icon class="vca-dlg__ico">block</mat-icon>
<div class="vca-dlg__titles">
<div class="vca-dlg__title">
Reject {{ rejecting.kind === 'unpass' ? 'unpass request' : 'correction entry' }}
</div>
<div class="vca-dlg__sub vca-mono">{{ rejecting.request.original_voucher_no }}</div>
</div>
<button type="button" class="vca-dlg__x" aria-label="Close" (click)="cancelReject()">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="vca-dlg__body">
<label class="vca-label" for="vca-reason">Reason (required)</label>
<textarea
id="vca-reason"
rows="3"
placeholder="Why is this being rejected?"
[(ngModel)]="rejectReason"
></textarea>
</div>
<footer class="vca-dlg__foot">
<button type="button" class="vca-btn vca-btn--ghost" (click)="cancelReject()">Cancel</button>
<button
type="button"
class="vca-btn vca-btn--reject-solid"
[disabled]="!rejectReason.trim() || busyId === rejecting.request.id"
(click)="confirmReject()"
>
Confirm reject
</button>
</footer>
</div>
}
</ng-template>
@@ -1,485 +0,0 @@
/**
* 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/.
*/
// Laid out like the other approval tabs (Withdrawal Requests): title bar, filter bar, one table.
:host {
display: block;
}
.vca-tab {
padding: 0;
}
.vca-action-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 10px 16px;
background: #fff;
border-bottom: 1px solid #e2e8f0;
&__left {
display: flex;
align-items: center;
gap: 12px;
}
&__icon {
font-size: 22px;
width: 22px;
height: 22px;
color: #0f4f3c;
}
&__title {
font-size: 13px;
font-weight: 700;
color: #0d1b2a;
}
&__sub {
max-width: 640px;
margin-top: 1px;
font-size: 11px;
color: #64748b;
}
}
.vca-counts {
display: flex;
gap: 8px;
}
.vca-count {
padding: 2px 10px;
border-radius: 999px;
background: #f1f5f9;
font-size: 11px;
color: #475569;
strong {
color: #0f4f3c;
}
}
.vca-filter-bar {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
padding: 6px 16px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
.vca-fb-sep {
flex-shrink: 0;
width: 1px;
height: 22px;
margin: 0 4px;
background: #e2e8f0;
}
.vca-search-box {
display: inline-flex;
align-items: center;
gap: 6px;
height: 30px;
min-width: 240px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
mat-icon {
flex-shrink: 0;
font-size: 17px;
width: 17px;
height: 17px;
color: #94a3b8;
}
&:hover {
border-color: #cbd5e1;
}
&:focus-within {
border-color: #0f4f3c;
box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
}
input {
flex: 1;
border: none;
outline: none;
background: transparent;
font-family: inherit;
font-size: 12px;
color: #0d1b2a;
&::placeholder {
color: #94a3b8;
}
}
&__clear {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 20px;
height: 20px;
padding: 0;
border: none;
border-radius: 50%;
background: none;
color: #94a3b8;
cursor: pointer;
mat-icon {
font-size: 15px;
width: 15px;
height: 15px;
color: inherit;
}
&:hover {
color: #dc2626;
background: #fef2f2;
}
}
}
.vca-filter-select {
height: 30px;
min-width: 140px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 7px;
background: #fff;
font-family: inherit;
font-size: 12px;
color: #0d1b2a;
cursor: pointer;
&:focus {
outline: none;
border-color: #0f4f3c;
}
}
// ── Buttons ─────────────────────────────────────────────────────────────────
.vca-btn {
display: inline-flex;
align-items: center;
gap: 4px;
height: 26px;
padding: 0 10px;
border: 1px solid transparent;
border-radius: 7px;
font-family: inherit;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
&--refresh {
height: 30px;
width: 30px;
justify-content: center;
padding: 0;
margin-left: auto;
border-color: #e2e8f0;
background: #fff;
color: #475569;
}
&--approve {
background: #0f4f3c;
color: #fff;
&:hover:not(:disabled) {
background: #0a3d2e;
}
}
&--reject {
border-color: #fecaca;
background: #fff;
color: #dc2626;
&:hover:not(:disabled) {
background: #fef2f2;
}
}
&--reject-solid {
background: #dc2626;
color: #fff;
}
&--ghost {
border-color: #e2e8f0;
background: #fff;
color: #475569;
}
}
// ── Table ───────────────────────────────────────────────────────────────────
.vca-table-wrap {
overflow-x: auto;
background: #fff;
}
.vca-table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
th {
padding: 7px 10px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
font-size: 9.5px;
font-weight: 800;
letter-spacing: 0.06em;
text-align: left;
text-transform: uppercase;
color: #94a3b8;
white-space: nowrap;
}
td {
padding: 5px 10px;
border-bottom: 1px solid #eef2f7;
vertical-align: middle;
color: #0d1b2a;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.vca-th-action {
text-align: right;
}
}
.vca-row:hover td {
background: #f8fafc;
}
.vca-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: 0.02em;
}
.vca-voucher {
font-weight: 700;
color: #0f4f3c;
}
.vca-strong {
font-weight: 700;
}
.vca-sub {
margin-top: 1px;
font-size: 11px;
color: #64748b;
}
.vca-none {
color: #cbd5e1;
}
.vca-amount {
font-weight: 700;
}
.vca-reason {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vca-dt {
white-space: nowrap;
color: #475569;
}
.vca-kind {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
&--unpass {
background: #fef3c7;
color: #92400e;
}
&--correction {
background: #dbeafe;
color: #1e40af;
}
}
.vca-action-cell {
display: flex;
justify-content: flex-end;
gap: 6px;
white-space: nowrap;
}
// ── Empty ───────────────────────────────────────────────────────────────────
.vca-empty {
padding: 28px 16px;
text-align: center;
color: #94a3b8;
&__icon {
display: block;
margin: 0 auto 6px;
font-size: 30px;
width: 30px;
height: 30px;
opacity: 0.6;
}
&__title {
margin: 0;
font-size: 12px;
font-weight: 700;
color: #64748b;
}
&__text {
margin: 3px 0 0;
font-size: 11px;
}
}
// ── Reject popup ────────────────────────────────────────────────────────────
.vca-dlg {
font-size: 12px;
&__head {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
}
&__ico {
flex: 0 0 32px;
height: 32px;
width: 32px;
border-radius: 9px;
background: #fef2f2;
color: #dc2626;
font-size: 18px;
line-height: 32px;
text-align: center;
}
&__titles {
flex: 1;
min-width: 0;
}
&__title {
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
&__sub {
font-size: 11px;
color: #64748b;
}
&__x {
display: inline-flex;
align-items: center;
justify-content: center;
height: 28px;
width: 28px;
border: 0;
border-radius: 50%;
background: transparent;
color: #64748b;
cursor: pointer;
&:hover {
background: #e2e8f0;
}
mat-icon {
font-size: 18px;
height: 18px;
width: 18px;
}
}
&__body {
padding: 12px 14px;
textarea {
width: 100%;
padding: 7px 10px;
border: 1px solid #cbd5e1;
border-radius: 8px;
font: inherit;
font-size: 12px;
resize: vertical;
&:focus {
outline: 0;
border-color: #0f4f3c;
box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
}
}
}
&__foot {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px 14px;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
}
}
.vca-label {
display: block;
margin-bottom: 4px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #94a3b8;
}
@@ -1,192 +0,0 @@
/**
* 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, TemplateRef, ViewChild, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { VoucherCorrection, VoucherCorrectionService } from '../voucher-correction.service';
type Kind = 'unpass' | 'correction';
type KindFilter = 'ALL' | Kind;
/** One row of the approvals table: a request, and which of the two steps it is waiting at. */
interface ApprovalRow {
kind: Kind;
request: VoucherCorrection;
raisedBy: string | null;
raisedAt: string | null;
}
/**
* Manager side of the voucher correction, laid out like the other approval tabs: approves or rejects the unpass of a
* voucher, and later the correction entry the cashier made. Only the second approval moves money.
*/
@Component({
selector: 'mifosx-voucher-correction-approvals',
templateUrl: './voucher-correction-approvals.component.html',
styleUrls: ['./voucher-correction-approvals.component.scss'],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatIconModule,
MatSnackBarModule
]
})
export class VoucherCorrectionApprovalsComponent implements OnInit {
private service = inject(VoucherCorrectionService);
private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
@ViewChild('rejectDialog') rejectDialog!: TemplateRef<unknown>;
private dialogRef: MatDialogRef<unknown> | null = null;
all: ApprovalRow[] = [];
loading = false;
loaded = false;
busyId: number | null = null;
kindFilter: KindFilter = 'ALL';
searchTerm = '';
/** The row being rejected, and the reason typed for it. */
rejecting: ApprovalRow | null = null;
rejectReason = '';
readonly kindOptions: { value: KindFilter; label: string }[] = [
{ value: 'ALL', label: 'All requests' },
{ value: 'unpass', label: 'Unpass requests' },
{ value: 'correction', label: 'Correction entries' }
];
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.service
.list('UNPASS_PENDING,CORRECTION_PENDING')
.pipe(catchError(() => of([] as VoucherCorrection[])))
.subscribe((rows) => {
this.all = rows.map((r) =>
r.status === 'UNPASS_PENDING'
? { kind: 'unpass', request: r, raisedBy: r.unpass_by_name, raisedAt: r.unpass_at }
: { kind: 'correction', request: r, raisedBy: r.correction_by_name, raisedAt: r.correction_at }
);
this.loading = false;
this.loaded = true;
});
}
/** The table: the kind picked, narrowed by whatever is typed in the search box. */
get rows(): ApprovalRow[] {
const term = this.searchTerm.trim().toLowerCase();
return this.all.filter((r) => {
if (this.kindFilter !== 'ALL' && r.kind !== this.kindFilter) return false;
if (!term) return true;
const q = r.request;
return [
q.original_voucher_no,
q.wrong_account_no,
q.wrong_client_name,
q.correct_account_no,
q.correct_client_name,
r.raisedBy
].some((v) => (v ?? '').toLowerCase().includes(term));
});
}
get unpassCount(): number {
return this.all.filter((r) => r.kind === 'unpass').length;
}
get correctionCount(): number {
return this.all.filter((r) => r.kind === 'correction').length;
}
kindLabel(kind: Kind): string {
return kind === 'unpass' ? 'Unpass' : 'Correction';
}
formatDateTime(value: string | null): string {
if (!value) return '—';
const d = new Date(value);
return isNaN(d.getTime())
? value
: d.toLocaleString('en-IN', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
}
approve(row: ApprovalRow): void {
if (this.busyId) return;
const id = row.request.id;
this.busyId = id;
const call = row.kind === 'unpass' ? this.service.approveUnpass(id) : this.service.approveCorrection(id);
call.subscribe({
next: (res: any) => {
this.busyId = null;
this.snackBar.open(
row.kind === 'unpass'
? `Voucher ${row.request.original_voucher_no} unpassed — the cashier can now enter the correct account`
: `Corrected. New voucher ${res?.newVoucherNo ?? ''}`,
'Close',
{ duration: 6000 }
);
this.load();
},
error: (err) => {
this.busyId = null;
this.fail(err, 'Could not approve');
}
});
}
startReject(row: ApprovalRow): void {
this.rejecting = row;
this.rejectReason = '';
this.dialogRef = this.dialog.open(this.rejectDialog, { width: '440px', maxWidth: '95vw', autoFocus: false });
this.dialogRef.afterClosed().subscribe(() => (this.rejecting = null));
}
cancelReject(): void {
this.dialogRef?.close();
}
confirmReject(): void {
if (!this.rejecting || !this.rejectReason.trim() || this.busyId) return;
const row = this.rejecting;
const id = row.request.id;
this.busyId = id;
const reason = this.rejectReason.trim();
const call =
row.kind === 'unpass' ? this.service.rejectUnpass(id, reason) : this.service.rejectCorrection(id, reason);
call.subscribe({
next: () => {
this.busyId = null;
this.dialogRef?.close();
this.snackBar.open('Rejected', 'Close', { duration: 4000 });
this.load();
},
error: (err) => {
this.busyId = null;
this.fail(err, 'Could not reject');
}
});
}
private fail(err: any, fallback: string): void {
const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? fallback;
this.snackBar.open(msg, 'Close', { duration: 8000 });
}
}
@@ -1,120 +0,0 @@
/**
* 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 { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
/** One row of a voucher correction, as the server keeps it (snake_case columns). */
export interface VoucherCorrection {
id: number;
original_voucher_no: string;
original_txn_id: number;
wrong_savings_account_id: number;
wrong_account_no: string;
wrong_client_name: string | null;
amount: number;
txn_date: string | null;
status: 'UNPASS_PENDING' | 'UNPASSED' | 'UNPASS_REJECTED' | 'CORRECTION_PENDING' | 'COMPLETED' | 'CLOSED';
reason: string | null;
rejection_reason: string | null;
unpass_by_name: string | null;
unpass_at: string | null;
unpass_approved_by_name: string | null;
correct_account_no: string | null;
correct_client_name: string | null;
correction_by_name: string | null;
correction_at: string | null;
correction_approved_by_name: string | null;
new_voucher_no: string | null;
}
/** A cash deposit voucher of today that can still be unpassed. */
export interface EligibleVoucher {
voucherNo: string;
amount: number;
createdAt: string;
cashier: string;
txnId: number;
savingsAccountId: number;
accountNo: string;
clientName: string | null;
paymentType: string;
}
/** What the server knows about a savings account typed in as the correct one. */
export interface AccountLookup {
id: number;
accountNo: string;
clientId: number | null;
clientName: string | null;
balance: number;
currencyCode: string;
canReceive: boolean;
}
/** HTTP service for the voucher correction workflow: unpass, approve, re-enter, approve. */
@Injectable({ providedIn: 'root' })
export class VoucherCorrectionService {
private http = inject(HttpClient);
private readonly base = '/vouchers/corrections';
eligible(search?: string): Observable<EligibleVoucher[]> {
let params = new HttpParams();
if (search) params = params.set('search', search);
return this.http.get<EligibleVoucher[]>(`${this.base}/eligible`, { params });
}
unpass(voucherNo: string, reason: string): Observable<any> {
return this.http.post(`${this.base}/unpass`, { voucherNo, reason });
}
list(status?: string, mine = false): Observable<VoucherCorrection[]> {
let params = new HttpParams().set('mine', String(mine));
if (status) params = params.set('status', status);
return this.http.get<VoucherCorrection[]>(this.base, { params });
}
get(id: number): Observable<VoucherCorrection> {
return this.http.get<VoucherCorrection>(`${this.base}/${id}`);
}
accountLookup(accountNo: string): Observable<AccountLookup> {
return this.http.get<AccountLookup>(`${this.base}/account-lookup`, { params: { accountNo } });
}
submitCorrection(id: number, correctAccountNo: string): Observable<any> {
return this.http.post(`${this.base}/${id}/correction`, { correctAccountNo });
}
/** The cashier gives up the correction, e.g. when the wrong account no longer holds the money. */
close(id: number, reason: string): Observable<any> {
return this.http.post(`${this.base}/${id}/close`, { reason });
}
approveUnpass(id: number): Observable<any> {
return this.http.post(`${this.base}/${id}/unpass/approve`, {});
}
rejectUnpass(id: number, reason: string): Observable<any> {
return this.http.post(`${this.base}/${id}/unpass/reject`, { reason });
}
approveCorrection(id: number): Observable<any> {
return this.http.post(`${this.base}/${id}/correction/approve`, {});
}
rejectCorrection(id: number, reason: string): Observable<any> {
return this.http.post(`${this.base}/${id}/correction/reject`, { reason });
}
/** Ids of the transactions of this account that a completed correction removed. */
voidedTransactions(savingsAccountId: number): Observable<number[]> {
return this.http.get<number[]>(`${this.base}/voided-transactions`, { params: { savingsAccountId } });
}
}
@@ -1,446 +0,0 @@
<!--
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="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">CASHIER · VOUCHER CORRECTION</div>
<h1 class="pg-title">Voucher Correction</h1>
<p class="pg-sub">
Move a cash deposit that was posted to the wrong account. Nothing changes on any account until the final
approval.
</p>
</div>
</header>
<!-- The four steps, so it is always clear where a voucher is. -->
<ol class="vc-steps">
<li class="vc-step" [class.vc-step--on]="tab === 'today'">
<span class="vc-step__no">1</span>
<span class="vc-step__txt"><strong>Unpass</strong> the voucher</span>
</li>
<li class="vc-step">
<span class="vc-step__no">2</span>
<span class="vc-step__txt"><strong>Manager</strong> approves</span>
</li>
<li class="vc-step" [class.vc-step--on]="tab === 'unpassed'">
<span class="vc-step__no">3</span>
<span class="vc-step__txt">Enter the <strong>correct account</strong></span>
</li>
<li class="vc-step">
<span class="vc-step__no">4</span>
<span class="vc-step__txt"><strong>Manager</strong> approves, deposit moves</span>
</li>
</ol>
<nav class="vc-tabs">
@if (mayUnpass) {
<button type="button" class="vc-tab" [class.vc-tab--on]="tab === 'today'" (click)="setTab('today')">
<mat-icon>receipt_long</mat-icon> Today's Vouchers
</button>
}
@if (mayCorrect) {
<button type="button" class="vc-tab" [class.vc-tab--on]="tab === 'unpassed'" (click)="setTab('unpassed')">
<mat-icon>edit_note</mat-icon> Unpassed — Correct Entry
@if (unpassed.length) {
<span class="vc-count">{{ unpassed.length }}</span>
}
</button>
}
<button type="button" class="vc-tab" [class.vc-tab--on]="tab === 'mine'" (click)="setTab('mine')">
<mat-icon>history</mat-icon> My Requests
</button>
</nav>
<!-- ─── Today's vouchers ─────────────────────────────────────────────── -->
@if (tab === 'today') {
<div class="vc-toolbar">
<div class="vc-search">
<mat-icon>search</mat-icon>
<input
type="text"
placeholder="Search by voucher, account or customer name"
[(ngModel)]="search"
(keyup.enter)="loadVouchers()"
/>
</div>
<button type="button" class="pg-btn pg-btn--primary" (click)="loadVouchers()">
<mat-icon>search</mat-icon> Search
</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="search = ''; loadVouchers()">
<mat-icon>refresh</mat-icon> Refresh
</button>
</div>
<div class="pg-card">
<table class="pg-table vc-table">
<thead>
<tr>
<th>Voucher</th>
<th>Account</th>
<th>Customer</th>
<th>Mode</th>
<th>Cashier</th>
<th class="num">Amount</th>
<th class="vc-col-action"></th>
</tr>
</thead>
<tbody>
@for (v of vouchers; track v.voucherNo) {
<tr [class.vc-row--on]="selectedVoucher?.voucherNo === v.voucherNo" (click)="pick(v)">
<td>
<span class="pg-chip">{{ v.voucherNo }}</span>
</td>
<td class="vc-mono">{{ v.accountNo }}</td>
<td>{{ v.clientName || '—' }}</td>
<td>{{ v.paymentType }}</td>
<td>{{ v.cashier }}</td>
<td class="num">₹{{ v.amount | number: '1.2-2' }}</td>
<td class="vc-col-action">
<button
type="button"
class="pg-btn pg-btn--ghost vc-btn-sm"
(click)="pick(v); $event.stopPropagation()"
>
<mat-icon>undo</mat-icon> Unpass
</button>
</td>
</tr>
}
@if (!vouchers.length) {
<tr>
<td colspan="7" class="vc-empty">
<mat-icon>{{ loadingVouchers ? 'hourglass_empty' : 'inbox' }}</mat-icon>
<div>{{ loadingVouchers ? 'Loading…' : 'No cash deposit voucher of today can be unpassed.' }}</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- ─── Unpassed: enter the correct account ──────────────────────────── -->
@if (tab === 'unpassed') {
<div class="pg-card">
<table class="pg-table vc-table">
<thead>
<tr>
<th>Voucher</th>
<th>Posted to</th>
<th>Customer</th>
<th>Reason</th>
<th class="num">Amount</th>
<th class="vc-col-action"></th>
</tr>
</thead>
<tbody>
@for (u of unpassed; track u.id) {
<tr [class.vc-row--on]="active?.id === u.id" (click)="open(u)">
<td>
<span class="pg-chip">{{ u.original_voucher_no }}</span>
</td>
<td class="vc-mono">{{ u.wrong_account_no }}</td>
<td>{{ u.wrong_client_name || '—' }}</td>
<td class="vc-clip">{{ u.reason }}</td>
<td class="num">₹{{ u.amount | number: '1.2-2' }}</td>
<td class="vc-col-action">
<button
type="button"
class="pg-btn pg-btn--primary vc-btn-sm"
(click)="open(u); $event.stopPropagation()"
>
<mat-icon>edit</mat-icon> Correct
</button>
</td>
</tr>
}
@if (!unpassed.length) {
<tr>
<td colspan="6" class="vc-empty">
<mat-icon>{{ loadingUnpassed ? 'hourglass_empty' : 'task_alt' }}</mat-icon>
<div>
{{ loadingUnpassed ? 'Loading…' : 'No unpassed voucher is waiting for its correct account.' }}
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- ─── My requests ──────────────────────────────────────────────────── -->
@if (tab === 'mine') {
<div class="pg-card">
<table class="pg-table vc-table">
<thead>
<tr>
<th>Voucher</th>
<th>Posted to</th>
<th>Corrected to</th>
<th class="num">Amount</th>
<th>Status</th>
<th>New voucher</th>
</tr>
</thead>
<tbody>
@for (m of mine; track m.id) {
<tr>
<td>
<span class="pg-chip">{{ m.original_voucher_no }}</span>
</td>
<td class="vc-mono">{{ m.wrong_account_no }}</td>
<td class="vc-mono">{{ m.correct_account_no || '—' }}</td>
<td class="num">₹{{ m.amount | number: '1.2-2' }}</td>
<td>
<span class="vc-status vc-status--{{ m.status }}">{{ statusLabel[m.status] }}</span>
@if (m.rejection_reason) {
<div class="vc-note">{{ m.rejection_reason }}</div>
}
</td>
<td>{{ m.new_voucher_no || '—' }}</td>
</tr>
}
@if (!mine.length) {
<tr>
<td colspan="6" class="vc-empty">
<mat-icon>{{ loadingMine ? 'hourglass_empty' : 'history' }}</mat-icon>
<div>{{ loadingMine ? 'Loading…' : 'You have no voucher corrections yet.' }}</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<!-- ─── Popup: unpass a voucher ──────────────────────────────────────────── -->
<ng-template #unpassDialog>
@if (selectedVoucher) {
<div class="vc-dlg">
<header class="vc-dlg__head">
<mat-icon class="vc-dlg__ico">undo</mat-icon>
<div class="vc-dlg__titles">
<div class="vc-dlg__title">Unpass voucher</div>
<div class="vc-dlg__sub vc-mono">{{ selectedVoucher.voucherNo }}</div>
</div>
<button type="button" class="vc-dlg__x" aria-label="Close" (click)="closeUnpass()">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="vc-dlg__body">
<div class="vc-dlg__amount">
<span>Deposit amount</span>
<strong>₹{{ selectedVoucher.amount | number: '1.2-2' }}</strong>
</div>
<div class="vc-grid">
<div class="vc-cell">
<div class="vc-label">Voucher</div>
<div class="vc-value vc-mono">{{ selectedVoucher.voucherNo }}</div>
</div>
<div class="vc-cell">
<div class="vc-label">Created</div>
<div class="vc-value">{{ selectedVoucher.createdAt | date: 'dd MMM yyyy, hh:mm a' }}</div>
</div>
<div class="vc-cell">
<div class="vc-label">Cashier</div>
<div class="vc-value">{{ selectedVoucher.cashier }}</div>
</div>
<div class="vc-cell">
<div class="vc-label">Mode</div>
<div class="vc-value">{{ selectedVoucher.paymentType }}</div>
</div>
<div class="vc-cell">
<div class="vc-label">Account</div>
<div class="vc-value vc-mono">{{ selectedVoucher.accountNo }}</div>
</div>
<div class="vc-cell">
<div class="vc-label">Customer</div>
<div class="vc-value">{{ selectedVoucher.clientName || '—' }}</div>
</div>
<div class="vc-cell">
<div class="vc-label">Account balance now</div>
<div class="vc-value">
@if (voucherAccount) {
₹{{ voucherAccount.balance | number: '1.2-2' }}
} @else {
—
}
</div>
</div>
<div class="vc-cell">
<div class="vc-label">Account status</div>
<div class="vc-value">
@if (voucherAccount) {
{{ voucherAccount.canReceive ? 'Active' : 'Not active' }} · {{ voucherAccount.currencyCode }}
} @else {
—
}
</div>
</div>
</div>
<label class="vc-label" for="vc-reason">Reason for unpassing</label>
<textarea
id="vc-reason"
class="vc-reason"
rows="2"
placeholder="Why is this voucher wrong? e.g. deposited in the wrong account (required)"
[(ngModel)]="unpassReason"
></textarea>
<div class="vc-info">
<mat-icon>info</mat-icon>
A manager approves the unpass first. Nothing changes on any account until the corrected entry is approved: the
deposit stays on {{ selectedVoucher.accountNo }} till then.
</div>
</div>
<footer class="vc-dlg__foot">
<button type="button" class="pg-btn pg-btn--ghost" (click)="closeUnpass()">Cancel</button>
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!canUnpass" (click)="unpass()">
<mat-icon>send</mat-icon> Send for approval
</button>
</footer>
</div>
}
</ng-template>
<!-- ─── Popup: enter the correct account ─────────────────────────────────── -->
<ng-template #correctionDialog>
@if (active) {
<div class="vc-dlg">
<header class="vc-dlg__head">
<mat-icon class="vc-dlg__ico">edit_note</mat-icon>
<div class="vc-dlg__titles">
<div class="vc-dlg__title">Correction entry</div>
<div class="vc-dlg__sub vc-mono">{{ active.original_voucher_no }}</div>
</div>
<button type="button" class="vc-dlg__x" aria-label="Close" (click)="closeActive()">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="vc-dlg__body">
<div class="vc-dlg__amount">
<span>Amount to move</span>
<strong>₹{{ active.amount | number: '1.2-2' }}</strong>
</div>
<!-- Everything but the account number is fixed. -->
<div class="vc-grid">
<div class="vc-cell">
<div class="vc-label">Transaction date</div>
<div class="vc-value">{{ active.txn_date || '—' }}</div>
</div>
<div class="vc-cell">
<div class="vc-label">Mode</div>
<div class="vc-value">Cash</div>
</div>
<div class="vc-cell">
<div class="vc-label">Unpassed by</div>
<div class="vc-value">{{ active.unpass_by_name || '—' }}</div>
</div>
<div class="vc-cell">
<div class="vc-label">Approved by</div>
<div class="vc-value">{{ active.unpass_approved_by_name || '—' }}</div>
</div>
<div class="vc-cell vc-cell--wide">
<div class="vc-label">Reason</div>
<div class="vc-value">{{ active.reason }}</div>
</div>
</div>
<div class="vc-compare">
<!-- The account the deposit went to -->
<div class="vc-acct vc-acct--wrong">
<div class="vc-acct__tag">Posted to (wrong)</div>
<div class="vc-acct__no vc-mono">{{ active.wrong_account_no }}</div>
<div class="vc-acct__name">{{ active.wrong_client_name || '—' }}</div>
<div class="vc-acct__bal">
Balance:
@if (wrongAccount) {
<strong>₹{{ wrongAccount.balance | number: '1.2-2' }}</strong>
@if (wrongHasBalance) {
<span class="vc-ok"><mat-icon>check_circle</mat-icon> covers the deposit</span>
} @else {
<span class="vc-bad"
><mat-icon>cancel</mat-icon> does not cover ₹{{ active.amount | number: '1.2-2' }}</span
>
}
} @else {
—
}
</div>
</div>
<mat-icon class="vc-arrow">arrow_forward</mat-icon>
<!-- The account it should have gone to -->
<div class="vc-acct" [class.vc-acct--ok]="correctOk" [class.vc-acct--wrong]="!!lookupError">
<div class="vc-acct__tag">Correct account</div>
<input
id="vc-correct"
class="vc-acct__input vc-mono"
type="text"
placeholder="Type the account number"
[ngModel]="correctAccountNo"
(ngModelChange)="onAccountInput($event)"
(blur)="lookup()"
/>
@if (lookingUp) {
<div class="vc-acct__bal">Looking up…</div>
}
@if (correctAccount) {
<div class="vc-acct__name">{{ correctAccount.clientName || '—' }}</div>
<div class="vc-acct__bal">
Balance: <strong>₹{{ correctAccount.balance | number: '1.2-2' }}</strong>
@if (correctOk) {
<span class="vc-ok"><mat-icon>check_circle</mat-icon> active, can receive</span>
}
</div>
}
@if (lookupError) {
<div class="vc-bad vc-acct__err"><mat-icon>error</mat-icon> {{ lookupError }}</div>
}
</div>
</div>
@if (wrongAccount && !wrongHasBalance) {
<div class="vc-warn">
<mat-icon>warning</mat-icon>
<div>
<strong>The customer has already used this money,</strong> so it cannot be taken back from
{{ active.wrong_account_no }}. Deposit ₹{{ active.amount | number: '1.2-2' }} into the correct account
from your own pocket as a fresh deposit (Transaction Entry), then press <strong>Close request</strong>.
</div>
</div>
}
</div>
<footer class="vc-dlg__foot">
<button
type="button"
class="pg-btn pg-btn--ghost vc-dlg__close-req"
[disabled]="sending"
(click)="closeRequest()"
>
<mat-icon>block</mat-icon> Close request
</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="closeActive()">Cancel</button>
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!canSave" (click)="save()">
<mat-icon>check</mat-icon> Save &amp; send for approval
</button>
</footer>
</div>
}
</ng-template>
@@ -1,599 +0,0 @@
/**
* 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/.
*/
// Compact sizing: the page has to fit a 100% browser window without scrolling for the common case.
.pg {
padding: 14px 20px;
gap: 12px;
}
.pg-title {
font-size: 20px;
margin: 2px 0;
}
.pg-sub {
font-size: 12px;
}
.pg-btn {
height: 30px;
padding: 0 12px;
font-size: 12px;
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
}
.pg-table {
font-size: 12px;
th {
padding: 8px 12px;
font-size: 10px;
}
td {
padding: 7px 12px;
}
}
// ── Steps ───────────────────────────────────────────────────────────────────
.vc-steps {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.vc-step {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
font-size: 12px;
color: #64748b;
&__no {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 20px;
height: 20px;
border-radius: 50%;
background: #e2e8f0;
font-size: 11px;
font-weight: 700;
color: #475569;
}
&--on {
border-color: #1f7a4d;
background: #f0fdf4;
color: #0f4f3c;
.vc-step__no {
background: #1f7a4d;
color: #fff;
}
}
}
// ── Tabs ────────────────────────────────────────────────────────────────────
.vc-tabs {
display: flex;
gap: 2px;
border-bottom: 1px solid #e2e8f0;
flex-wrap: wrap;
}
.vc-tab {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 7px 12px;
margin-bottom: -1px;
border: 0;
border-bottom: 2px solid transparent;
background: transparent;
font: inherit;
font-size: 12px;
font-weight: 600;
color: #64748b;
cursor: pointer;
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
}
&:hover {
color: #0f4f3c;
}
&--on {
color: #0f4f3c;
border-bottom-color: #1f7a4d;
}
}
.vc-count {
padding: 0 7px;
border-radius: 999px;
background: #f59e0b;
color: #fff;
font-size: 10px;
font-weight: 700;
}
// ── Search ──────────────────────────────────────────────────────────────────
.vc-toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.vc-search {
display: flex;
align-items: center;
gap: 6px;
flex: 1 1 280px;
max-width: 420px;
height: 30px;
padding: 0 10px;
border: 1px solid #cbd5e1;
border-radius: 999px;
background: #fff;
&:focus-within {
border-color: #1f7a4d;
box-shadow: 0 0 0 3px rgb(31 122 77 / 12%);
}
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
color: #94a3b8;
}
input {
flex: 1;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
font: inherit;
font-size: 12px;
}
}
// ── Table ───────────────────────────────────────────────────────────────────
.vc-table {
tbody tr {
cursor: pointer;
}
tr.vc-row--on td {
background: #f0fdf4;
&:first-child {
box-shadow: inset 3px 0 0 #1f7a4d;
}
}
.vc-col-action {
width: 1%;
text-align: right;
white-space: nowrap;
}
.vc-clip {
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pg-chip {
font-size: 11px;
padding: 2px 8px;
}
}
.vc-btn-sm {
height: 26px;
padding: 0 10px;
font-size: 11px;
}
.vc-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: 0.02em;
}
.vc-empty {
padding: 24px 12px;
text-align: center;
color: #94a3b8;
font-size: 12px;
mat-icon {
display: block;
margin: 0 auto 4px;
font-size: 28px;
height: 28px;
width: 28px;
opacity: 0.6;
}
}
// ── Popups ──────────────────────────────────────────────────────────────────
.vc-dlg {
display: flex;
flex-direction: column;
max-height: 86vh;
font-size: 12px;
&__head {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
}
&__ico {
flex: 0 0 32px;
height: 32px;
width: 32px;
border-radius: 9px;
background: #f0fdf4;
color: #1f7a4d;
font-size: 18px;
line-height: 32px;
text-align: center;
}
&__titles {
flex: 1;
min-width: 0;
}
&__title {
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
&__sub {
font-size: 11px;
color: #64748b;
}
&__x {
display: inline-flex;
align-items: center;
justify-content: center;
height: 28px;
width: 28px;
border: 0;
border-radius: 50%;
background: transparent;
color: #64748b;
cursor: pointer;
mat-icon {
font-size: 18px;
height: 18px;
width: 18px;
}
&:hover {
background: #e2e8f0;
}
}
&__body {
padding: 12px 14px;
overflow-y: auto;
}
&__amount {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 10px;
padding: 8px 12px;
border-radius: 10px;
background: #ecfdf5;
border: 1px solid #a7f3d0;
color: #065f46;
font-size: 12px;
font-weight: 600;
strong {
font-size: 18px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
}
&__foot {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px 14px;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
}
}
.vc-warn {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 10px;
padding: 8px 12px;
border-radius: 8px;
background: #fffbeb;
border: 1px solid #fde68a;
color: #92400e;
font-size: 11px;
mat-icon {
flex: 0 0 18px;
height: 18px;
width: 18px;
font-size: 18px;
}
}
.vc-dlg__close-req {
margin-right: auto;
color: #b45309;
}
.vc-label {
display: block;
margin-bottom: 3px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #94a3b8;
}
.vc-reason {
width: 100%;
padding: 7px 10px;
border: 1px solid #cbd5e1;
border-radius: 8px;
font: inherit;
font-size: 12px;
resize: vertical;
&:focus {
outline: 0;
border-color: #1f7a4d;
box-shadow: 0 0 0 3px rgb(31 122 77 / 12%);
}
}
.vc-info {
display: flex;
align-items: flex-start;
gap: 6px;
margin-top: 8px;
padding: 7px 10px;
border-radius: 8px;
background: #eff6ff;
color: #1e40af;
font-size: 11px;
mat-icon {
flex: 0 0 16px;
height: 16px;
width: 16px;
font-size: 16px;
}
}
.vc-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: 6px;
margin-bottom: 10px;
}
.vc-cell {
padding: 6px 10px;
border-radius: 8px;
background: #f8fafc;
border: 1px solid #eef2f7;
}
.vc-cell--wide {
grid-column: 1 / -1;
}
.vc-value {
font-size: 12px;
font-weight: 600;
color: #0f172a;
word-break: break-word;
}
// ── Wrong → Correct ─────────────────────────────────────────────────────────
.vc-compare {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: stretch;
gap: 8px;
@media (width <= 640px) {
grid-template-columns: 1fr;
}
}
.vc-arrow {
align-self: center;
color: #94a3b8;
@media (width <= 640px) {
transform: rotate(90deg);
justify-self: center;
}
}
.vc-acct {
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
&--wrong {
border-color: #fecaca;
background: #fef2f2;
}
&--ok {
border-color: #a7f3d0;
background: #ecfdf5;
}
&__tag {
margin-bottom: 4px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #64748b;
}
&__no {
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
&__input {
width: 100%;
height: 32px;
padding: 0 10px;
border: 1px solid #cbd5e1;
border-radius: 8px;
background: #fff;
font-size: 13px;
font-weight: 600;
&:focus {
outline: 0;
border-color: #1f7a4d;
box-shadow: 0 0 0 3px rgb(31 122 77 / 12%);
}
}
&__name {
margin-top: 4px;
font-size: 12px;
font-weight: 600;
color: #1e293b;
}
&__bal {
margin-top: 3px;
font-size: 11px;
color: #475569;
}
&__err {
margin-top: 6px;
}
}
.vc-ok,
.vc-bad {
display: inline-flex;
align-items: center;
gap: 3px;
margin-left: 6px;
font-weight: 700;
mat-icon {
font-size: 14px;
height: 14px;
width: 14px;
}
}
.vc-ok {
color: #059669;
}
.vc-bad {
color: #dc2626;
}
.vc-note {
margin-top: 3px;
font-size: 11px;
color: #64748b;
}
// ── Status ──────────────────────────────────────────────────────────────────
.vc-status {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
background: #e2e8f0;
font-size: 11px;
font-weight: 700;
&--UNPASS_PENDING,
&--CORRECTION_PENDING {
background: #fef3c7;
color: #92400e;
}
&--UNPASSED {
background: #dbeafe;
color: #1e40af;
}
&--COMPLETED {
background: #d1fae5;
color: #065f46;
}
&--CLOSED {
background: #f1f5f9;
color: #475569;
}
&--UNPASS_REJECTED {
background: #fee2e2;
color: #991b1b;
}
}
@@ -1,307 +0,0 @@
/**
* 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, TemplateRef, ViewChild, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import {
AccountLookup,
EligibleVoucher,
VoucherCorrection,
VoucherCorrectionService
} from '../voucher-correction.service';
type Tab = 'today' | 'unpassed' | 'mine';
/**
* Cashier side of correcting a cash deposit that went to the wrong account.
*
* Today vouchers: pick the voucher and unpass it (needs a manager approval; nothing moves).
* Unpassed: open the approved voucher, whose details are fixed, change only the account number and save
* (needs a manager approval again; only then do the accounts change).
*/
@Component({
selector: 'mifosx-voucher-correction',
templateUrl: './voucher-correction.component.html',
styleUrls: [
'../cashier-shared.scss',
'./voucher-correction.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule,
MatDialogModule,
MatSnackBarModule
]
})
export class VoucherCorrectionComponent implements OnInit {
private service = inject(VoucherCorrectionService);
private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
private auth = inject(AuthenticationService);
/** Unpassing is for the back office (an administrator or a clerk); re-entering the account is the cashier only. */
readonly mayUnpass = this.auth.isClerkUser() || !!this.auth.getCredentials()?.permissions?.includes('ALL_FUNCTIONS');
readonly mayCorrect = this.auth.isCashierUser();
@ViewChild('unpassDialog') unpassDialog!: TemplateRef<unknown>;
@ViewChild('correctionDialog') correctionDialog!: TemplateRef<unknown>;
private dialogRef: MatDialogRef<unknown> | null = null;
tab: Tab = this.mayUnpass ? 'today' : this.mayCorrect ? 'unpassed' : 'mine';
// Today vouchers
search = '';
vouchers: EligibleVoucher[] = [];
loadingVouchers = false;
selectedVoucher: EligibleVoucher | null = null;
/** The account the voucher was posted to, for the balance and status shown in the popup. */
voucherAccount: AccountLookup | null = null;
unpassReason = '';
sending = false;
// Unpassed vouchers waiting for the correct account
unpassed: VoucherCorrection[] = [];
loadingUnpassed = false;
active: VoucherCorrection | null = null;
wrongAccount: AccountLookup | null = null;
correctAccountNo = '';
correctAccount: AccountLookup | null = null;
lookupError = '';
lookingUp = false;
// The cashier own requests
mine: VoucherCorrection[] = [];
loadingMine = false;
readonly statusLabel: Record<string, string> = {
UNPASS_PENDING: 'Unpass awaiting approval',
UNPASSED: 'Unpassed — enter correct account',
UNPASS_REJECTED: 'Unpass rejected',
CORRECTION_PENDING: 'Correction awaiting approval',
COMPLETED: 'Corrected',
CLOSED: 'Closed — cashier deposits separately'
};
ngOnInit(): void {
if (this.mayUnpass) this.loadVouchers();
if (this.mayCorrect) this.loadUnpassed();
if (this.tab === 'mine') this.loadMine();
}
setTab(tab: Tab): void {
this.tab = tab;
if (tab === 'today') this.loadVouchers();
if (tab === 'unpassed') this.loadUnpassed();
if (tab === 'mine') this.loadMine();
}
// ── Today vouchers ─────────────────────────────────────────────────────────
loadVouchers(): void {
this.loadingVouchers = true;
this.service
.eligible(this.search.trim())
.pipe(catchError(() => of([] as EligibleVoucher[])))
.subscribe((rows) => {
this.vouchers = rows;
this.loadingVouchers = false;
if (this.selectedVoucher && !rows.some((r) => r.voucherNo === this.selectedVoucher?.voucherNo)) {
this.selectedVoucher = null;
}
});
}
pick(v: EligibleVoucher): void {
this.selectedVoucher = v;
this.unpassReason = '';
this.voucherAccount = null;
this.service
.accountLookup(v.accountNo)
.pipe(catchError(() => of(null)))
.subscribe((a) => (this.voucherAccount = a));
this.showDialog(this.unpassDialog, () => (this.selectedVoucher = null));
}
closeUnpass(): void {
this.dialogRef?.close();
}
get canUnpass(): boolean {
return !!this.selectedVoucher && this.unpassReason.trim().length > 0 && !this.sending;
}
unpass(): void {
if (!this.selectedVoucher || !this.canUnpass) return;
this.sending = true;
this.service.unpass(this.selectedVoucher.voucherNo, this.unpassReason.trim()).subscribe({
next: () => {
this.sending = false;
this.snackBar.open(`Voucher ${this.selectedVoucher?.voucherNo} sent for unpass approval`, 'Close', {
duration: 4000
});
this.dialogRef?.close();
this.unpassReason = '';
this.loadVouchers();
},
error: (err) => {
this.sending = false;
this.fail(err, 'Could not unpass the voucher');
}
});
}
// ── Unpassed vouchers: enter the correct account ───────────────────────────
loadUnpassed(): void {
this.loadingUnpassed = true;
this.service
.list('UNPASSED')
.pipe(catchError(() => of([] as VoucherCorrection[])))
.subscribe((rows) => {
this.unpassed = rows;
this.loadingUnpassed = false;
});
}
open(row: VoucherCorrection): void {
this.active = row;
this.correctAccountNo = '';
this.correctAccount = null;
this.lookupError = '';
this.wrongAccount = null;
// The account the deposit went to: its balance is what the "can this be taken back" check reads.
this.service
.accountLookup(row.wrong_account_no)
.pipe(catchError(() => of(null)))
.subscribe((a) => (this.wrongAccount = a));
this.showDialog(this.correctionDialog, () => (this.active = null));
}
closeActive(): void {
this.dialogRef?.close();
}
onAccountInput(value: string): void {
this.correctAccountNo = value;
this.correctAccount = null;
this.lookupError = '';
// Same rule as the Transaction Entry screen: searches on its own once six characters are typed.
if (value.trim().length >= 6) this.lookup();
}
lookup(): void {
const accountNo = this.correctAccountNo.trim();
if (!accountNo || this.lookingUp) return;
this.lookingUp = true;
this.service.accountLookup(accountNo).subscribe({
next: (a) => {
this.lookingUp = false;
// A late answer for a number no longer typed is dropped.
if (accountNo !== this.correctAccountNo.trim()) return;
this.correctAccount = a;
if (this.active && a.id === this.active.wrong_savings_account_id) {
this.lookupError = 'This is the account the deposit was already posted to.';
} else if (!a.canReceive) {
this.lookupError = 'This account is not active, so it cannot receive the deposit.';
}
},
error: (err) => {
this.lookingUp = false;
this.lookupError = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Account not found.';
}
});
}
/** The wrong account still holds the deposit, so it can be taken back without overdrawing it. */
get wrongHasBalance(): boolean {
return !!this.wrongAccount && !!this.active && this.wrongAccount.balance >= this.active.amount;
}
get correctOk(): boolean {
return !!this.correctAccount && this.correctAccount.canReceive && !this.lookupError;
}
get canSave(): boolean {
return !!this.active && this.correctOk && this.wrongHasBalance && !this.sending;
}
save(): void {
if (!this.active || !this.canSave) return;
this.sending = true;
this.service.submitCorrection(this.active.id, this.correctAccountNo.trim()).subscribe({
next: () => {
this.sending = false;
this.snackBar.open('Correction sent for approval', 'Close', { duration: 4000 });
this.dialogRef?.close();
this.loadUnpassed();
},
error: (err) => {
this.sending = false;
this.fail(err, 'Could not save the correction');
}
});
}
/**
* Gives up this correction. The usual reason is that the wrong account no longer holds the money, so the cashier
* puts the amount into the right account from their own pocket as a fresh deposit instead.
*/
closeRequest(): void {
if (!this.active || this.sending) return;
const reason = this.wrongHasBalance
? 'Closed by the cashier'
: 'Wrong account no longer holds the amount; cashier deposits it into the correct account separately';
this.sending = true;
this.service.close(this.active.id, reason).subscribe({
next: () => {
this.sending = false;
this.snackBar.open('Request closed', 'Close', { duration: 4000 });
this.dialogRef?.close();
this.loadUnpassed();
},
error: (err) => {
this.sending = false;
this.fail(err, 'Could not close the request');
}
});
}
// ── My requests ────────────────────────────────────────────────────────────
loadMine(): void {
this.loadingMine = true;
this.service
.list(undefined, true)
.pipe(catchError(() => of([] as VoucherCorrection[])))
.subscribe((rows) => {
this.mine = rows;
this.loadingMine = false;
});
}
/** Opens one of the two popups; onClose runs however it is dismissed (button, backdrop or Esc). */
private showDialog(template: TemplateRef<unknown>, onClose: () => void): void {
this.dialogRef?.close();
this.dialogRef = this.dialog.open(template, { width: '600px', maxWidth: '95vw', autoFocus: false });
this.dialogRef.afterClosed().subscribe(onClose);
}
private fail(err: any, fallback: string): void {
const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? fallback;
this.snackBar.open(msg, 'Close', { duration: 6000 });
}
}
@@ -169,7 +169,6 @@
<th>Debit</th>
<th>Credit</th>
<th class="num">Amount</th>
<th>Correction</th>
<th>User</th>
<!-- Two dates, not one, on the system register.
A voucher's timestamp is the minute it was written; the posting behind it carries
@@ -218,18 +217,6 @@
</td>
}
<td class="num">{{ row.amount | number: '1.2-2' }}</td>
<!-- Where the voucher stands after a correction: unpassed, revoked, closed or corrected. -->
<td>
<mifosx-voucher-status
[status]="row.voucherStatus"
[revokedBy]="row.revokedByVoucherNo"
[replaces]="row.replacesVoucherNo"
[note]="row.correctionNote"
></mifosx-voucher-status>
@if (!row.voucherStatus || row.voucherStatus === 'ACTIVE') {
<span class="vr-active">—</span>
}
</td>
<td>{{ row.username || '—' }}</td>
@if (systemOnly) {
<td class="vr-ledgerdate" [class.vr-ledgerdate--prior]="row.postedAfterClose">
@@ -92,8 +92,3 @@
text-transform: uppercase;
color: #7c3aed;
}
.vr-active {
font-size: 11px;
color: #94a3b8;
}
@@ -13,8 +13,6 @@ import { ActivatedRoute } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
/**
* Which side of the register a screen shows.
@@ -27,11 +25,6 @@ export type VoucherSide = 'CR' | 'DR' | 'ALL';
/** One voucher as the register shows it: the number, the two sides, and who raised it. */
interface VoucherRow {
voucherNo: string;
/** Where the voucher stands after a correction: active, revoked, under correction, or corrected. */
voucherStatus?: VoucherStatus;
revokedByVoucherNo?: string | null;
replacesVoucherNo?: string | null;
correctionNote?: string | null;
voucherType: string | null;
amount: number | null;
createdAt: string | null;
@@ -88,8 +81,7 @@ interface VoucherReport {
imports: [
CommonModule,
FormsModule,
MatIconModule,
VoucherStatusComponent
MatIconModule
]
})
export class VoucherReportComponent implements OnInit {
@@ -235,7 +227,7 @@ export class VoucherReportComponent implements OnInit {
}
private todayIso(): string {
const now = businessToday();
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
}
@@ -1,137 +0,0 @@
/**
* 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, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
/** Where a voucher stands after a voucher correction. */
export type VoucherStatus =
| 'ACTIVE'
| 'REVOKED'
| 'UNPASS_PENDING'
| 'UNPASSED'
| 'CORRECTION_PENDING'
| 'CORRECTED'
| 'CLOSED'
| null
| undefined;
/**
* A small badge saying whether a voucher was revoked, is under correction, or replaced another one.
* Nothing is shown for an ordinary (active) voucher, so the registers read as before.
*/
@Component({
selector: 'mifosx-voucher-status',
standalone: true,
imports: [CommonModule],
template: `
@if (label) {
<span class="vs" [class]="'vs vs--' + status" [title]="hint">{{ label }}</span>
@if (note) {
<div class="vs-note">{{ note }}</div>
}
}
`,
styles: [
`
.vs {
display: inline-block;
margin-left: 6px;
padding: 1px 8px;
border-radius: 999px;
background: #e2e8f0;
color: #334155;
font-size: 10px;
font-weight: 700;
white-space: nowrap;
vertical-align: middle;
}
.vs-note {
margin-top: 2px;
max-width: 280px;
font-size: 11px;
line-height: 1.3;
color: #64748b;
}
.vs--REVOKED {
background: #fee2e2;
color: #991b1b;
}
.vs--UNPASS_PENDING,
.vs--CORRECTION_PENDING {
background: #fef3c7;
color: #92400e;
}
.vs--UNPASSED {
background: #dbeafe;
color: #1e40af;
}
.vs--CLOSED {
background: #f1f5f9;
color: #475569;
}
.vs--CORRECTED {
background: #d1fae5;
color: #065f46;
}
`
]
})
export class VoucherStatusComponent {
@Input() status: VoucherStatus;
/** The voucher that replaced this one, when it is revoked. */
@Input() revokedBy: string | null | undefined;
/** The voucher this one replaced, when it is the corrected one. */
@Input() replaces: string | null | undefined;
/** Why: the reason the voucher was unpassed, or why the correction was closed. */
@Input() note: string | null | undefined;
get label(): string {
switch (this.status) {
case 'REVOKED':
return this.revokedBy ? `Revoked → ${this.revokedBy}` : 'Revoked';
case 'UNPASS_PENDING':
return 'Unpass pending';
case 'UNPASSED':
return 'Unpassed';
case 'CORRECTION_PENDING':
return 'Correction pending';
case 'CLOSED':
return 'Closed — cashier deposits separately';
case 'CORRECTED':
return this.replaces ? `Corrected (replaces ${this.replaces})` : 'Corrected';
default:
return '';
}
}
get hint(): string {
switch (this.status) {
case 'REVOKED':
return 'This voucher was revoked: its deposit was moved to another account and a new voucher replaced it.';
case 'UNPASS_PENDING':
return 'A manager has not yet approved unpassing this voucher.';
case 'UNPASSED':
return 'Unpassed: waiting for the cashier to enter the correct account.';
case 'CORRECTION_PENDING':
return 'The correct account is entered; waiting for a manager to approve.';
case 'CLOSED':
return 'The correction was closed by the cashier; the voucher stays as it was.';
case 'CORRECTED':
return 'This voucher was raised by a correction and replaces the earlier one.';
default:
return '';
}
}
}
@@ -129,31 +129,6 @@
<span>* Date of Meeting</span>
<input type="date" [(ngModel)]="meetingDate" [disabled]="isReview" />
</label>
<label class="inv-form-field">
<span>* Settlement Destination</span>
@if (!isReview) {
<select [ngModel]="settlementMode" (ngModelChange)="onSettlementModeChange($event)">
<option value="SAVINGS">Member's Linked Savings Account</option>
<option value="GL">GL Account</option>
</select>
} @else {
<input
type="text"
[value]="reviewRequest?.settlement_mode === 'GL' ? 'GL Account' : 'Linked Savings Account'"
readonly
/>
}
</label>
@if (!isReview && settlementMode === 'GL') {
<label class="inv-form-field">
<span>* Settlement GL Account</span>
<mifosx-gl-account-selector
[inputFormControl]="toGlAccountControl"
[glAccountList]="glAccountsData"
[required]="true"
></mifosx-gl-account-selector>
</label>
}
<label class="inv-form-field">
<span>* Settlement Payment Type</span>
@if (!isReview) {
@@ -411,25 +386,14 @@
<td>Date of Meeting</td>
<td>{{ meetingDate }}</td>
</tr>
<tr>
<td>Settlement Destination</td>
<td>{{ settlementMode === 'GL' ? 'GL Account' : 'Linked Savings Account' }}</td>
</tr>
@if (settlementMode === 'GL') {
<tr>
<td>Settlement GL Account</td>
<td>{{ selectedSettlementGlName }}</td>
</tr>
} @else {
<tr>
<td>Linked Savings Account</td>
<td>{{ account?.savingsAccountNumber || 'None — settlement will not run automatically' }}</td>
</tr>
}
<tr>
<td>Settlement Payment Type</td>
<td>{{ settlementPaymentTypeName }}</td>
</tr>
<tr>
<td>Linked Savings Account</td>
<td>{{ account?.savingsAccountNumber || 'None — settlement will not run automatically' }}</td>
</tr>
</tbody>
</table>
@@ -8,7 +8,7 @@
import { Component, EventEmitter, Input, OnInit, Output, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, UntypedFormControl } from '@angular/forms';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
@@ -19,9 +19,6 @@ import { SharesService } from '../../shares/shares.service';
import { ClerkShareWithdrawalService } from '../../cashier/clerk-share-withdrawal.service';
import { CashierService } from '../../cashier/cashier.service';
import { SearchSelectComponent, SearchSelectOption } from '../../shared/search-select/search-select.component';
import { GlAccountSelectorComponent } from '../../shared/accounting/gl-account-selector/gl-account-selector.component';
import { AccountingService } from '../../accounting/accounting.service';
import { GLAccount } from '../../shared/models/general.model';
export type ShareWdMode = 'create' | 'review';
@@ -54,8 +51,7 @@ interface ActivityEntry {
RouterModule,
MatIconModule,
MatSnackBarModule,
SearchSelectComponent,
GlAccountSelectorComponent
SearchSelectComponent
]
})
export class ShareWithdrawalRequestComponent implements OnInit {
@@ -64,7 +60,6 @@ export class ShareWithdrawalRequestComponent implements OnInit {
private sharesService = inject(SharesService);
private wdService = inject(ClerkShareWithdrawalService);
private cashierService = inject(CashierService);
private accountingService = inject(AccountingService);
private snackBar = inject(MatSnackBar);
/** When embedded (e.g. opened as a popup from a list screen instead of routed to directly), mode
@@ -96,17 +91,6 @@ export class ShareWithdrawalRequestComponent implements OnInit {
paymentTypes: any[] = [];
settlementPaymentTypeId: number | null = null;
/** Where the redeemed value goes. 'SAVINGS' (the long-standing default) credits the member's
* linked savings account. 'GL' lets the clerk pick any postable GL account directly — same
* option the Cashier's Transaction Entry screen already offers on its CR row, now available
* here too so both entry points behave the same way. Naming the product's own Share Sundry GL
* here is still fine: the backend recognises it and books straight to Sundry with no further
* settlement leg (see ClerkShareWithdrawalRequestApiResource#submit). */
settlementMode: 'SAVINGS' | 'GL' = 'SAVINGS';
settlementGlAccountId: number | null = null;
glAccountsData: GLAccount[] = [];
toGlAccountControl = new UntypedFormControl('');
activityLog: ActivityEntry[] = [];
reviewRequest: any = null;
showRejectDialog = false;
@@ -148,17 +132,6 @@ export class ShareWithdrawalRequestComponent implements OnInit {
return this.selectedSettlementPaymentType?.name || '—';
}
/** The chosen GL account's display name, for the confirm dialog and the checklist. Falls back to
* the raw id while the GL account list is still loading. */
get selectedSettlementGlName(): string {
if (!this.settlementGlAccountId) {
return '—';
}
return (
this.glAccountsData.find((g) => g.id === this.settlementGlAccountId)?.name || `GL #${this.settlementGlAccountId}`
);
}
get isFormValid(): boolean {
if (!this.account) return false;
if (!this.requestedShares || this.requestedShares <= 0) return false;
@@ -168,7 +141,6 @@ export class ShareWithdrawalRequestComponent implements OnInit {
if (!this.resolutionNumber.trim()) return false;
if (!this.meetingDate) return false;
if (!this.settlementPaymentTypeId) return false;
if (this.settlementMode === 'GL' && !this.settlementGlAccountId) return false;
return true;
}
@@ -188,18 +160,11 @@ export class ShareWithdrawalRequestComponent implements OnInit {
pass: shares > 0 && shares <= held,
detail: `${shares} of ${held} shares held`
},
this.settlementMode === 'GL'
? {
label: 'Settlement GL Account selected',
pass: !!this.settlementGlAccountId,
detail: this.settlementGlAccountId ? this.selectedSettlementGlName : 'Missing'
}
: {
label: 'Linked savings account confirmed',
pass: !!this.account?.savingsAccountNumber,
detail:
this.account?.savingsAccountNumber || 'No linked savings account — settlement cannot run automatically'
},
{
label: 'Linked savings account confirmed',
pass: !!this.account?.savingsAccountNumber,
detail: this.account?.savingsAccountNumber || 'No linked savings account — settlement cannot run automatically'
},
{
label: 'Settlement Payment Type selected (non-cash)',
pass: this.settlementPaymentTypeName !== '—',
@@ -249,37 +214,11 @@ export class ShareWithdrawalRequestComponent implements OnInit {
.pipe(catchError(() => of([])))
.subscribe((types: any[]) => (this.paymentTypes = types ?? []));
this.toGlAccountControl.valueChanges.subscribe((id: any) => {
this.settlementGlAccountId = id ? Number(id) : null;
});
if (this.requestId && this.mode === 'review') {
this.loadReviewRequest(this.requestId);
}
}
/** Flips the settlement destination. Switching away from GL clears whatever was picked so a
* stale GL id can never ride along on a SAVINGS submission. */
onSettlementModeChange(mode: 'SAVINGS' | 'GL'): void {
this.settlementMode = mode;
if (mode === 'GL') {
this.loadGlAccounts();
} else {
this.settlementGlAccountId = null;
this.toGlAccountControl.setValue('');
}
}
/** Fetched on first use rather than with the page, since most withdrawals settle to savings. */
private loadGlAccounts(): void {
if (this.glAccountsData.length) {
return;
}
this.accountingService.getGlAccounts().subscribe((accounts: GLAccount[]) => {
this.glAccountsData = accounts ?? [];
});
}
onAccountSelected(accountId: string): void {
this.selectedAccountId = accountId;
if (!accountId) {
@@ -307,11 +246,6 @@ export class ShareWithdrawalRequestComponent implements OnInit {
this.resolutionNumber = req.resolution_number ?? '';
this.meetingDate = req.meeting_date ? String(req.meeting_date).split('T')[0].split(' ')[0] : this.meetingDate;
this.description = req.description ?? '';
this.settlementMode = req.settlement_mode === 'GL' ? 'GL' : 'SAVINGS';
this.settlementGlAccountId = req.settlement_gl_account_id ? Number(req.settlement_gl_account_id) : null;
if (this.settlementMode === 'GL') {
this.loadGlAccounts();
}
if (req.activity_log) {
try {
@@ -355,9 +289,7 @@ export class ShareWithdrawalRequestComponent implements OnInit {
resolutionNumber: this.resolutionNumber.trim(),
meetingDate: this.meetingDate,
description: this.description,
settlementPaymentTypeId: this.settlementPaymentTypeId,
settlementMode: this.settlementMode,
...(this.settlementMode === 'GL' ? { settlementGlAccountId: this.settlementGlAccountId } : {})
settlementPaymentTypeId: this.settlementPaymentTypeId
};
this.wdService.submit(payload).subscribe({
next: () => {
@@ -116,8 +116,8 @@
<div class="f121__note f121__note--warn">
<mat-icon>warning_amber</mat-icon>
<div>
A declaration is on record for this year, but one or more deposits still carry a tax group. Those accounts
deduct on their own and ignore the declaration — remove the tax group from them.
A Form 121 declaration is on record for this year, but withholding is still switched on for one or more
deposits. Turn it off on those accounts, or tax will keep being deducted.
</div>
</div>
}
@@ -192,90 +192,20 @@
}
</span>
</div>
@if (declarationRecord; as d) {
@if (declarationOnRecord && declarationFy) {
<div class="f121__kv-row">
<span>Declared for</span>
<span>FY {{ d.fy }} · Form {{ d.formType }}</span>
<span>FY {{ declarationFy }}</span>
</div>
<div class="f121__kv-row">
<span>Accepted on</span>
<span>
{{ d.acceptedOn | date: 'd MMM y' }} — no TDS on interest credited from this day to 31 March. Interest
credited before it is taxed as usual.
</span>
</div>
@if (d.uin) {
<div class="f121__kv-row">
<span>UIN</span>
<span class="f121__mono">{{ d.uin }}</span>
</div>
}
<div class="f121__kv-row">
<span></span>
<span>
<button type="button" class="f121__btn f121__btn--warn" (click)="revokeDeclaration()">
Withdraw declaration
</button>
</span>
</div>
} @else if (fy && !configMissing) {
<div class="f121__kv-row">
<span></span>
<span>
@if (!showDeclarationForm) {
<button type="button" class="f121__btn" (click)="openDeclarationForm()" [disabled]="!panWellFormed">
Record {{ formLabel }} for FY {{ fy }}
</button>
@if (!panWellFormed) {
<div class="muted">A valid PAN must be on the KYC first — a declaration without one is void.</div>
}
}
</span>
</div>
@if (showDeclarationForm) {
<div class="f121__decl-form">
<label>
<span>Accepted on *</span>
<input type="date" [(ngModel)]="newDeclaration.acceptedOn" />
</label>
<label>
<span>UIN</span>
<input type="text" [(ngModel)]="newDeclaration.uin" placeholder="Allotted on acceptance" />
</label>
<label>
<span>Estimated income for the year (₹)</span>
<input type="number" min="0" [(ngModel)]="newDeclaration.estimatedIncome" />
</label>
@if (declarationError) {
<div class="f121__note f121__note--warn">
<mat-icon>error_outline</mat-icon>
<div>{{ declarationError }}</div>
</div>
}
<div class="f121__decl-actions">
<button type="button" class="f121__btn" (click)="showDeclarationForm = false">Cancel</button>
<button
type="button"
class="f121__btn f121__btn--primary"
(click)="saveDeclaration()"
[disabled]="savingDeclaration"
>
{{ savingDeclaration ? 'Saving…' : 'Save declaration' }}
</button>
</div>
</div>
}
}
@for (old of declarations; track old.id) {
@if (old.revoked && old.fy === fy) {
<div class="f121__kv-row f121__kv-row--muted">
<span>Withdrawn</span>
<span>
Form {{ old.formType }} accepted {{ old.acceptedOn | date: 'd MMM y' }}, withdrawn
{{ old.revokedOn | date: 'd MMM y' }} — {{ old.revokeReason }}
</span>
</div>
}
@if (declarationOnRecord && !acceptanceDateKnown) {
<div class="f121__kv-row f121__kv-row--muted">
<span>Acceptance date</span>
<span>
Not captured — the declaration is treated as covering the whole year. A mid-year acceptance would change
the figures below.
</span>
</div>
}
@if (eligibility) {
@for (reason of eligibility.reasons; track reason) {
@@ -332,61 +332,3 @@
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-weight: 600;
}
/* ── Recording a declaration ── */
.f121__btn {
padding: 6px 14px;
border: 1px solid #d1d5db;
border-radius: 6px;
background: #fff;
font: inherit;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
&--primary {
border-color: #1d4ed8;
background: #1d4ed8;
color: #fff;
}
&--warn {
border-color: #b45309;
color: #b45309;
}
}
.f121__decl-form {
display: grid;
gap: 10px;
padding: 12px 16px;
label {
display: grid;
gap: 4px;
font-size: 12px;
span {
color: #6b7280;
font-weight: 600;
}
input {
max-width: 280px;
padding: 6px 8px;
border: 1px solid #d1d5db;
border-radius: 6px;
font: inherit;
}
}
}
.f121__decl-actions {
display: flex;
gap: 8px;
}
@@ -23,8 +23,6 @@ import { SavingsService } from 'app/savings/savings.service';
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
import { RecurringDepositsService } from 'app/deposits/recurring-deposits/recurring-deposits.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { TdsDeclarationRecord, TdsReportsService } from 'app/tds-reports/tds-reports.service';
import { TdsDeductee } from 'app/tds-reports/tds-reports-data';
/** Domain logic */
import {
@@ -113,7 +111,6 @@ export class Form121TabComponent implements OnInit, OnDestroy {
private savingsService = inject(SavingsService);
private fixedDepositsService = inject(FixedDepositsService);
private recurringDepositsService = inject(RecurringDepositsService);
private tdsService = inject(TdsReportsService);
private destroy$ = new Subject<void>();
@@ -127,25 +124,23 @@ export class Form121TabComponent implements OnInit, OnDestroy {
pan = '';
panWellFormed = false;
// ── Declarations on record ──
/**
* Every declaration the customer has given, each for one year and from the day the branch
* accepted it. The KYC record only ever held a type and a year, which made a September
* declaration shield interest back to April and let last year's form run on.
*/
declarations: TdsDeclarationRecord[] = [];
// ── Declaration on record ──
declarationOnRecord = false;
/**
* The form actually in force for the selected year. From FY 2026-27 a single
* Form 121 replaced the 15G/15H split, so this is derived from the year rather
* than from the payee's age.
*/
declarationForm: DeclarationForm = '121';
// ── Recording a declaration ──
showDeclarationForm = false;
savingDeclaration = false;
declarationError = '';
newDeclaration = { acceptedOn: '', uin: '', estimatedIncome: null as number | null };
/** Kind recorded at KYC, meaningful only for a pre-2026 year. */
declarationKind: '15G' | '15H' = '15G';
declarationFy = '';
/**
* The KYC step records only the year, not the date the branch accepted the
* declaration, so it has to be treated as covering the whole year. Surfaced on
* screen rather than hidden — a mid-year acceptance would change the figures.
*/
acceptanceDateKnown = false;
eligibility: EligibilityResult | null = null;
@@ -162,9 +157,9 @@ export class Form121TabComponent implements OnInit, OnDestroy {
configMissing = false;
/** Raw account inputs, kept so the year selector can recompute cheaply. */
private accountInputs: (TdsAccountInput & { meta: Form121AccountRow; taxLines: InterestCredit[] })[] = [];
/** This customer's FD/RD interest received in savings, and TDS taken there, per year. */
private platformByFy = new Map<string, TdsDeductee | null>();
private accountInputs: (TdsAccountInput & { meta: Form121AccountRow })[] = [];
/** Withheld-tax postings by FY, from the ledger. */
private actualTdsByFy = new Map<string, number>();
ngOnInit(): void {
this.route.parent.params.pipe(takeUntil(this.destroy$)).subscribe((params: any) => {
@@ -187,15 +182,13 @@ export class Form121TabComponent implements OnInit, OnDestroy {
forkJoin({
client: this.clientsService.getClientData(this.clientId, true).pipe(catchError(() => of(null))),
kyc: this.clientsService.getClientKyc(this.clientId, true).pipe(catchError(() => of(null))),
accounts: this.clientsService.getClientAccountData(this.clientId).pipe(catchError(() => of(null))),
declarations: this.tdsService.getDeclarations(Number(this.clientId)).pipe(catchError(() => of([])))
accounts: this.clientsService.getClientAccountData(this.clientId).pipe(catchError(() => of(null)))
})
.pipe(
takeUntil(this.destroy$),
switchMap(({ client, kyc, accounts, declarations }) => {
switchMap(({ client, kyc, accounts }) => {
this.applyClient(client);
this.applyKyc(kyc);
this.declarations = declarations || [];
const deposits = this.depositAccountsOf(accounts);
return deposits.length ? forkJoin(deposits.map((a) => this.fetchAccount(a))) : of([] as any[]);
}),
@@ -219,8 +212,12 @@ export class Form121TabComponent implements OnInit, OnDestroy {
private applyKyc(kyc: any): void {
this.pan = (kyc?.panNumber ?? '').toUpperCase();
this.panWellFormed = isWellFormedPan(this.pan);
this.declarationOnRecord = !!kyc?.form15Applicable;
this.declarationKind = kyc?.form15Type === '15H' ? '15H' : '15G';
this.declarationForm =
this.fy && FY_TAX_CONFIG[normaliseFy(this.fy)] ? declarationFormFor(this.fy, this.dateOfBirth) : '121';
this.declarationFy = kyc?.form15FY ?? '';
this.acceptanceDateKnown = false;
}
/** Savings, FD and RD accounts — everything that can credit interest. */
@@ -254,7 +251,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
*/
private ingest(details: any[]): void {
this.accountInputs = [];
this.platformByFy = new Map<string, TdsDeductee | null>();
this.actualTdsByFy = new Map<string, number>();
const fys = new Set<string>();
const clientIdNum = Number(this.clientId);
@@ -275,7 +272,6 @@ export class Form121TabComponent implements OnInit, OnDestroy {
};
const credits: InterestCredit[] = [];
const taxLines: InterestCredit[] = [];
let tdsDeducted = 0;
for (const t of d.transactions ?? []) {
@@ -293,7 +289,8 @@ export class Form121TabComponent implements OnInit, OnDestroy {
} else if (t.transactionType?.withholdTax) {
const amount = t.amount ?? 0;
tdsDeducted += amount;
taxLines.push({ date: iso, amount, accountNo: ref.accountNo });
const txnFy = fyOf(iso);
this.actualTdsByFy.set(txnFy, (this.actualTdsByFy.get(txnFy) ?? 0) + amount);
}
}
@@ -316,13 +313,12 @@ export class Form121TabComponent implements OnInit, OnDestroy {
deducteeClientId: deductee
};
this.accountInputs.push({ ...ref, credits, taxLines, meta });
this.accountInputs.push({ ...ref, credits, meta });
}
this.availableFys = [...fys].sort().reverse();
if (!this.availableFys.includes(this.fy)) {
this.fy =
this.availableFys[0] ?? this.declarations[0]?.fy ?? Object.keys(FY_TAX_CONFIG).sort().reverse()[0] ?? '';
this.fy = this.availableFys[0] ?? (this.declarationFy ? normaliseFy(this.declarationFy) : '');
}
}
@@ -342,33 +338,11 @@ export class Form121TabComponent implements OnInit, OnDestroy {
const year = normaliseFy(this.fy);
this.configMissing = !FY_TAX_CONFIG[year];
// TDS is tracked on FD/RD interest once it reaches savings, and deducted there. Those figures live
// on the platform, not in the deposit's own transactions, so they are fetched once per year and
// the computation waits for them.
if (!this.platformByFy.has(year)) {
this.loadPlatformYear(year);
return;
}
const platform = this.platformByFy.get(year);
for (const a of this.accountInputs) {
if (!a.meta.tdsApplicable) {
continue;
}
const match = platform?.accounts.find((p) => p.accountNo === a.accountNo);
a.credits = match?.credits ?? [];
a.taxLines = match?.deductions ?? [];
}
// Per-account interest for the selected year, for the three lists.
for (const a of this.accountInputs) {
a.meta.interest = a.credits.filter((c) => fyOf(c.date) === year).reduce((s, c) => s + c.amount, 0);
// The selected year only - this used to total every year the account had ever been taxed in.
a.meta.tdsDeducted = a.meta.tdsApplicable
? a.taxLines.filter((c) => fyOf(c.date) === year).reduce((s, c) => s + c.amount, 0)
: 0;
}
// What was actually debited for this customer as deductee, wherever the savings account sits.
this.actualTds = this.accountInputs.filter((a) => a.meta.tdsApplicable).reduce((s, a) => s + a.meta.tdsDeducted, 0);
this.actualTds = this.actualTdsByFy.get(year) ?? 0;
if (!this.dateOfBirth || this.configMissing) {
return;
@@ -408,95 +382,21 @@ export class Form121TabComponent implements OnInit, OnDestroy {
return applicable.some((a) => a.meta.mode === 'QUARTERLY') ? 'QUARTERLY' : 'ANNUAL';
}
private loadPlatformYear(year: string): void {
this.tdsService
.getDepositInterest(Number(year.slice(0, 4)), Number(this.clientId))
.pipe(
catchError(() => of([])),
takeUntil(this.destroy$)
)
.subscribe((rows) => {
this.platformByFy.set(year, rows.find((r) => `${r.clientId}` === `${this.clientId}`) ?? null);
this.recompute();
});
}
/** The live declaration recorded for the year being shown, if any. */
get declarationRecord(): TdsDeclarationRecord | null {
if (!this.fy) {
/** The declaration, but only when it was filed for the year being shown. */
private declarationForSelectedFy(): Declaration | null {
if (!this.declarationOnRecord || !this.fy) {
return null;
}
const year = normaliseFy(this.fy);
return this.declarations.find((d) => d.fy === year && !d.revoked) ?? null;
}
get declarationOnRecord(): boolean {
return !!this.declarationRecord;
}
/** The declaration as the engine takes it — operative from the day it was accepted. */
private declarationForSelectedFy(): Declaration | null {
const d = this.declarationRecord;
if (!d) {
if (this.declarationFy && normaliseFy(this.declarationFy) !== year) {
return null;
}
const kind = d.formType === '15H' ? '15H' : d.formType === '15G' ? '15G' : formKindFor(this.dateOfBirth, d.fy);
return { kind, acceptedOn: d.acceptedOn, fy: d.fy, uin: d.uin || undefined };
}
openDeclarationForm(): void {
this.newDeclaration = { acceptedOn: new Date().toISOString().slice(0, 10), uin: '', estimatedIncome: null };
this.declarationError = '';
this.showDeclarationForm = true;
}
saveDeclaration(): void {
if (!this.newDeclaration.acceptedOn) {
this.declarationError = 'Enter the date the branch accepted the form.';
return;
}
this.savingDeclaration = true;
this.declarationError = '';
this.tdsService
.createDeclaration({
clientId: Number(this.clientId),
fy: normaliseFy(this.fy),
formType: declarationFormFor(this.fy, this.dateOfBirth),
acceptedOn: this.newDeclaration.acceptedOn,
uin: this.newDeclaration.uin || undefined,
estimatedIncome: this.newDeclaration.estimatedIncome
})
.subscribe({
next: (rows) => {
this.savingDeclaration = false;
this.showDeclarationForm = false;
this.declarations = rows || [];
this.recompute();
},
error: (err: any) => {
this.savingDeclaration = false;
this.declarationError =
err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'Could not save.';
}
});
}
revokeDeclaration(): void {
const d = this.declarationRecord;
if (!d) {
return;
}
const reason = prompt('Why is this declaration being withdrawn? From today, TDS will be deducted again.');
if (!reason) {
return;
}
this.tdsService.revokeDeclaration(d.id, reason).subscribe({
next: (rows) => {
this.declarations = rows || [];
this.recompute();
},
error: (err: any) => alert(err?.error?.defaultUserMessage || 'Could not revoke the declaration.')
});
return {
kind: this.declarationKind,
// No acceptance date on record — treated as covering the whole year.
acceptedOn: `${year.slice(0, 4)}-04-01`,
fy: year
};
}
// ───────────────────────── Template helpers ─────────────────────────
@@ -574,12 +474,11 @@ export class Form121TabComponent implements OnInit, OnDestroy {
}
/**
* A declaration is on record but a deposit still carries a stock Fineract tax group. Those
* accounts withhold on their own rules and never consult the declaration record, so the form
* does not stop their deduction until the tax group is removed.
* A declaration is on record but deposit accounts are still set to withhold —
* the branch has not switched them off.
*/
get declarationNotAppliedToAccounts(): boolean {
return !!this.declarationForSelectedFy() && this.liableAccounts.some((a) => !!a.taxGroup);
return !!this.declarationForSelectedFy() && this.liableAccounts.some((a) => a.withHoldTax);
}
/**
@@ -591,13 +591,16 @@
<i class="fa fa-calendar section-header__icon"></i>
<h3 class="section-header__title">{{ 'labels.heading.Recurring Deposit Accounts' | translate }}</h3>
</div>
<div class="section-header__right">
<button mat-raised-button color="primary" (click)="toggleRecurringAccountsOverview()">
{{ viewAccountsLabel(showClosedRecurringAccounts) | translate }}
</button>
</div>
</div>
<!-- Active and ended recurring deposits share one table; the Status column says how each one ended (matured or
closed early), so the client's RD history stays visible without switching views. -->
@if (savingAccounts) {
@if (!showClosedRecurringAccounts) {
<div class="table-wrapper">
<table mat-table [dataSource]="savingAccounts | accountsFilter: 'saving' : 'all' : 'isRecurring'">
<table mat-table [dataSource]="savingAccounts | accountsFilter: 'saving' : 'open' : 'isRecurring'">
<ng-container matColumnDef="Account No">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account No' | translate }}</th>
<td mat-cell *matCellDef="let element">
@@ -611,15 +614,6 @@
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
</td>
</ng-container>
<ng-container matColumnDef="Status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let element">
<span class="rd-status" [class.rd-status--ended]="element.timeline?.closedOnDate">{{ rdStatusLabel(element) }}</span>
@if (element.timeline?.closedOnDate) {
<div class="rd-status__date">on {{ element.timeline.closedOnDate | dateFormat }}</div>
}
</td>
</ng-container>
<ng-container matColumnDef="Last Active">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Last Active' | translate }}</th>
<td mat-cell *matCellDef="let element">{{ element.lastActiveTransactionDate | dateFormat }}</td>
@@ -644,7 +638,7 @@
<i class="fa fa-check"></i>
</button>
}
@if (element.status.approved) {
@if (!element.status.submittedAndPendingApproval && !element.status.active) {
<button
class="account-action-button"
mat-raised-button
@@ -670,7 +664,7 @@
</ng-container>
<ng-container matColumnDef="no-data">
<td mat-footer-cell *matFooterCellDef [attr.colspan]="recurringColumns.length">
<td mat-footer-cell *matFooterCellDef [attr.colspan]="openSavingsColumns.length">
<div class="table-empty-state">
<i class="fa fa-folder-open-o table-empty-state__icon"></i>
<p class="table-empty-state__text">
@@ -680,17 +674,67 @@
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="recurringColumns"></tr>
<tr mat-header-row *matHeaderRowDef="openSavingsColumns"></tr>
<tr
mat-row
*matRowDef="let row; columns: recurringColumns"
*matRowDef="let row; columns: openSavingsColumns"
[routerLink]="['../', 'recurring-deposits-accounts', row.id, 'general']"
class="select-row"
></tr>
<tr
mat-footer-row
*matFooterRowDef="
(savingAccounts | accountsFilter: 'saving' : 'all' : 'isRecurring')?.length === 0 ? ['no-data'] : []
(savingAccounts | accountsFilter: 'saving' : 'open' : 'isRecurring')?.length === 0 ? ['no-data'] : []
"
></tr>
</table>
</div>
}
<!-- Closed Recurring Deposit Accounts -->
@if (showClosedRecurringAccounts) {
<div class="table-wrapper">
<table mat-table [dataSource]="savingAccounts | accountsFilter: 'saving' : 'closed' : 'isRecurring'">
<ng-container matColumnDef="Account No">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account No' | translate }}</th>
<td mat-cell *matCellDef="let element">
<i class="fa fa-stop" [ngClass]="element.status.code | statusLookup"></i>
<mifosx-account-number accountNo="{{ element.accountNo }}"></mifosx-account-number>
</td>
</ng-container>
<ng-container matColumnDef="Saving Account">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Recurring Deposit Product' | translate }}</th>
<td mat-cell *matCellDef="let element">
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
</td>
</ng-container>
<ng-container matColumnDef="Closed Date">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Closed Date' | translate }}</th>
<td mat-cell *matCellDef="let element">{{ element.timeline.closedOnDate | dateFormat }}</td>
</ng-container>
<ng-container matColumnDef="no-data">
<td mat-footer-cell *matFooterCellDef [attr.colspan]="closedSavingsColumns.length">
<div class="table-empty-state">
<i class="fa fa-folder-open-o table-empty-state__icon"></i>
<p class="table-empty-state__text">
{{ 'labels.messages.No Closed Recurring Deposit Accounts Available' | translate }}
</p>
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="closedSavingsColumns"></tr>
<tr
mat-row
*matRowDef="let row; columns: closedSavingsColumns"
[routerLink]="['../', 'recurring-deposits-accounts', row.id, 'general']"
class="select-row"
></tr>
<tr
mat-footer-row
*matFooterRowDef="
(savingAccounts | accountsFilter: 'saving' : 'closed' : 'isRecurring')?.length === 0 ? ['no-data'] : []
"
></tr>
</table>
@@ -419,24 +419,3 @@ table[mat-table] {
font-size: 24px;
z-index: 1;
}
.rd-status {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
background: #dcfce7;
color: #166534;
&--ended {
background: #e2e8f0;
color: #475569;
}
}
.rd-status__date {
margin-top: 2px;
font-size: 11px;
color: #64748b;
}
@@ -187,15 +187,6 @@ export class GeneralTabComponent implements OnDestroy {
'Saving Account',
'Closed Date'
];
/** Closed Recurring Deposit Accounts Columns - with how each one ended, for tracking. */
recurringColumns: string[] = [
'Account No',
'Saving Account',
'Status',
'Last Active',
'Balance',
'Actions'
];
/** Open Shares Accounts Columns */
openSharesColumns: string[] = [
'Account No',
@@ -484,30 +475,6 @@ export class GeneralTabComponent implements OnDestroy {
);
}
/** Where a recurring deposit stands - for one that has ended, how it ended. */
rdStatusLabel(account: any): string {
switch (account?.status?.code) {
case 'savingsAccountStatusType.active':
return 'Active';
case 'savingsAccountStatusType.matured':
return 'Matured (not closed)';
case 'savingsAccountStatusType.submitted.and.pending.approval':
return 'Pending Approval';
case 'savingsAccountStatusType.approved':
return 'Approved';
case 'savingsAccountStatusType.closed':
return 'Matured & Closed';
case 'savingsAccountStatusType.pre.mature.closure':
return 'Premature Closed';
case 'savingsAccountStatusType.rejected':
return 'Rejected';
case 'savingsAccountStatusType.withdrawn.by.applicant':
return 'Withdrawn by Client';
default:
return account?.status?.value ?? '';
}
}
viewAccountsLabel(closed: boolean): string {
if (closed) {
return 'labels.buttons.View Active Accounts';
@@ -377,8 +377,6 @@
</button>
}
@if (selectedTabIndex < totalTabs - 1) {
<!-- Tooltip sits on a wrapper: a disabled button fires no mouse events, so it can't host it. -->
<span [matTooltip]="currentStepBlockerMessage" [matTooltipDisabled]="!currentStepBlockerMessage">
<button mat-raised-button color="primary" (click)="onNext()" [disabled]="savingClient || !currentStepValid">
@if (savingClient) {
<mat-progress-spinner diameter="18" mode="indeterminate" class="m-r-10"></mat-progress-spinner>
@@ -392,7 +390,6 @@
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
}
</button>
</span>
}
@if (selectedTabIndex === totalTabs - 1) {
<button mat-raised-button color="primary" [routerLink]="['../']">
@@ -393,12 +393,6 @@ export class EditClientComponent {
if (this.selectedTabIndex === 0 && this.clientGeneralForm?.valid && !this.contactVerificationComplete) {
return 'Verify the OTP sent to both the old and new mobile number/email before saving.';
}
if (this.selectedTabIndex === 0 && this.clientGeneralForm && !this.clientGeneralForm.valid) {
const labels = this.invalidGeneralFieldLabels();
if (labels.length) {
return `Please fill/correct: ${labels.join(', ')}.`;
}
}
if (this.selectedTabIndex === 1) {
const labels = this.clientKycStep?.invalidFieldLabels || [];
if (labels.length) {
@@ -414,42 +408,6 @@ export class EditClientComponent {
return 'Please fill all mandatory fields on this tab to continue.';
}
/**
* Readable names of the General-tab controls that are invalid. Older clients can be missing
* fields that are now mandatory (e.g. mobile/email), which leaves Save disabled with nothing
* on screen saying why — this feeds the button tooltip.
*/
private invalidGeneralFieldLabels(): string[] {
const labels: Record<string, string> = {
officeId: 'Office',
legalFormId: 'Legal Form',
genderId: 'Gender',
mobileNo: 'Mobile No',
alternateMobileNo: 'Alternate Mobile No',
emailAddress: 'Email',
dateOfBirth: 'Date of Birth',
submittedOnDate: 'Submitted On',
activationDate: 'Activation Date',
savingsProductId: 'Savings Product',
firstname: 'First Name',
middlename: 'Middle Name',
lastname: 'Last Name',
fullname: 'Name',
monthlyIncome: 'Monthly Income',
annualIncome: 'Annual Income',
monthlyTurnover: 'Monthly Turnover',
annualTurnover: 'Yearly Turnover',
agricultureIncome: 'Agriculture Income',
nonAgricultureIncome: 'Non-Agriculture Income',
guardianDetails: 'Guardian',
clientNonPersonDetails: 'Entity Details'
};
const controls = this.clientGeneralForm?.controls || {};
return Object.keys(controls)
.filter((name) => controls[name].invalid)
.map((name) => labels[name] || name);
}
/** Whether the current tab has unsaved edits */
isCurrentTabEdited(): boolean {
return this.tabEditMode[this.selectedTabIndex] === true;
+1 -18
View File
@@ -36,24 +36,7 @@ export class CacheInterceptor implements HttpInterceptor {
'/rbireserveconfig',
'/tdsconfig',
'/tds/deposit-interest',
// Declarations and challans are entered on the very screens that read them back.
'/tds/declarations',
'/tds/challans',
'/tds/deducted',
'/savingsinterestpostingreports',
// Cash in vaults and drawers changes with every transaction; a cached teller list or position
// kept showing a branch's old tellers and figures at day close.
'/tellers',
'/cashreports',
'/cashreconciliation',
'/cashsettlement',
'/cashtransfer',
'/branchday',
// A deposit's lien moves with every repayment of the loan it secures, and decides whether the
// deposit may be closed at all - a stale copy would show a released lien as still blocking.
'/liens',
// The RD Dashboard counts late installments as of today; a cached copy would hide a payment just taken.
'/recurringdepositaccounts/dashboard'
'/savingsinterestpostingreports'
];
private isCacheable(url: string): boolean {
+5 -27
View File
@@ -67,18 +67,12 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
// endpoint isn't available on a given backend deployment, the client-general-step
// component already falls back to riskRules = [] silently; no toast needed here either.
const isKycRiskRulesRequest = request.url.includes('/kyc/risk-rules');
// A document's own file can be missing from the content store (a database copied between
// environments without its file store is the usual cause) even though its m_document row
// survives — the backend reports that as a 404, same as a client with no photo, and every
// caller that fetches a signature/attachment image already recovers with its own catchError.
const isDocumentAttachmentRequest = request.url.match(/\/documents\/\d+\/attachment$/);
const isSilentRequest =
isClientImageRequest ||
isClientKycRequest ||
isDirectorRequest ||
isClientPendingEditRequest ||
isKycRiskRulesRequest ||
isDocumentAttachmentRequest;
isKycRiskRulesRequest;
// Explicit per-request opt-out (e.g. per-row enrichment fetches on a list page whose
// component already recovers via its own catchError) — silenced for every status, since
@@ -99,8 +93,8 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
throw response;
}
// Resolved once and emitted in one place below — see the severity decision there for which of
// these surfaces as a centred modal versus a corner toast.
// Resolved once and emitted in one place below, so every failure here is consistently tagged
// as an error and therefore surfaces as a centred modal rather than a corner toast.
let alert: { type: string; message: string } | null = null;
if (status === 401 || (environment.oauth.enabled && status === 400)) {
alert = { type: 'Authentication Error', message: 'Invalid User Details. Please try again!' };
@@ -125,13 +119,7 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
message: errorMessage || 'This action is not allowed in the current state.'
};
} else if (status === 500) {
// errorMessage carries the backend's actual explanation (defaultUserMessage/developerMessage)
// when one is present; the generic sentence is only a last resort for a response with no body
// at all (a proxy timeout, a stack trace the server never wrapped into JSON).
alert = {
type: 'Internal Server Error',
message: errorMessage || 'Internal Server Error. Please try again later.'
};
alert = { type: 'Internal Server Error', message: 'Internal Server Error. Please try again later.' };
} else if (status === 501) {
alert = {
type: this.translate.instant('error.resource.notImplemented.type'),
@@ -145,17 +133,7 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
}
if (alert) {
// A GET is the page reading data it did not ask the user for, in the background — a photo,
// an attachment, a notes list, a standing-instructions panel. Every one of those already has
// its own catchError fallback at the call site (see transaction-entry.component.ts and
// similar account-overview loaders), so the page keeps working regardless. Blocking the whole
// screen on a dialog the user has to click "OK" on, for data that was never more than a
// secondary panel, is the "irritate na ho" complaint this guards against — it goes to the
// corner toast instead, same place a non-error alert already goes. A write (POST/PUT/PATCH/
// DELETE) is different: that is the one action the user was waiting on, so its failure still
// needs the centred, must-acknowledge dialog.
const isBackgroundRead = request.method === 'GET' && (status === 500 || status === 501 || status === 503);
this.alertService.alert({ ...alert, severity: isBackgroundRead ? undefined : 'error' });
this.alertService.alert({ ...alert, severity: 'error' });
}
throw response;
+300 -424
View File
@@ -131,14 +131,6 @@
</svg>
My Closure Requests
</a>
<a
class="cashier-nav__item cashier-nav__item--sec"
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/voucher-correction')"
[routerLink]="['/clerk/voucher-correction']"
>
<fa-icon icon="undo"></fa-icon>
Voucher Correction
</a>
@if (clerkAccountLoaded) {
<div class="cashier-nav__item-wrap">
<a
@@ -264,6 +256,13 @@
>
<fa-icon icon="exchange"></fa-icon> Cash Exchange
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/exchange-detail']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="globe"></fa-icon> Exchange Detail
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/inter-cashier']"
@@ -271,6 +270,13 @@
>
<fa-icon icon="exchange-alt"></fa-icon> Inter-Cashier
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/excess-report']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="exclamation-circle"></fa-icon> Excess Report
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/cashier-position']"
@@ -278,13 +284,6 @@
>
<fa-icon icon="wallet"></fa-icon> Position
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/reconciliation']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="balance-scale"></fa-icon> Reconciliation
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/cash-history']"
@@ -295,13 +294,6 @@
<a class="cashier-nav__item" [routerLink]="['/cashier/my-work']" routerLinkActive="cashier-nav__item--active">
<fa-icon icon="user-check"></fa-icon> My Vouchers
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/voucher-correction']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="undo"></fa-icon> Voucher Correction
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/received-report']"
@@ -316,6 +308,13 @@
>
<fa-icon icon="list"></fa-icon> Payment
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/closing-report']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="file-alt"></fa-icon> Closing Report
</a>
</nav>
}
}
@@ -504,8 +503,7 @@
[matMenuTriggerFor]="loanOriginationMenu"
*mifosxHasPermission="'MENU_LOAN_ORIGINATION'"
>
<fa-icon icon="clipboard-check" class="ni-ico"></fa-icon><span class="ni-full">Loan Origination</span
><span class="ni-short">Loans</span>
<fa-icon icon="clipboard-check" class="ni-ico"></fa-icon>Loan Origination
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a
@@ -514,8 +512,7 @@
(click)="glMenuOpen = !glMenuOpen; subLedgerOpen = false"
*mifosxHasPermission="'MENU_GENERAL_LEDGER'"
>
<fa-icon icon="book-open" class="ni-ico"></fa-icon><span class="ni-full">General Ledger</span
><span class="ni-short">GL</span>
<fa-icon icon="book-open" class="ni-ico"></fa-icon>General Ledger
<fa-icon [icon]="glMenuOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
</a>
<a
@@ -524,8 +521,7 @@
(click)="subLedgerOpen = !subLedgerOpen; glMenuOpen = false"
*mifosxHasPermission="'MENU_SUB_LEDGER'"
>
<fa-icon icon="book" class="ni-ico"></fa-icon><span class="ni-full">Sub Ledger</span
><span class="ni-short">Sub GL</span>
<fa-icon icon="book" class="ni-ico"></fa-icon>Sub Ledger
<fa-icon [icon]="subLedgerOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
</a>
</div>
@@ -587,16 +583,14 @@
[matMenuTriggerFor]="reconciliationMenu"
*mifosxHasPermission="'MENU_CONFIG_WIZARD'"
>
<fa-icon icon="sync-alt" class="ni-ico"></fa-icon><span class="ni-full">Reconciliation</span
><span class="ni-short">Recon</span>
<fa-icon icon="sync-alt" class="ni-ico"></fa-icon>Reconciliation
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" routerLink="/chequebook" *mifosxHasPermission="'MENU_CHEQUEBOOK'">
<fa-icon icon="book" class="ni-ico"></fa-icon>Cheque Book
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="letterMgmtMenu" *mifosxHasPermission="'MENU_LETTER_MGMT'">
<fa-icon icon="envelope" class="ni-ico"></fa-icon><span class="ni-full">Documents Hub</span
><span class="ni-short">Docs Hub</span>
<fa-icon icon="envelope" class="ni-ico"></fa-icon>Documents Hub
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
</div>
@@ -623,89 +617,77 @@
</button>
</mat-menu>
<mat-menu #reportsMenu="matMenu" class="reports-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid reports-grid" (click)="$event.stopPropagation()">
<!-- Core Reports -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Core Reports</div>
<button mat-menu-item [routerLink]="['/branch-ops/account-register']" tabindex="0">
<i class="fa fa-book menu-item-icon"></i> Account Register
</button>
<button mat-menu-item [routerLink]="['/reports']" tabindex="0">
<i class="fa fa-th-list menu-item-icon"></i> All Reports
</button>
<button mat-menu-item [routerLink]="['/reports', 'Client']" tabindex="0">
<i class="fa fa-user menu-item-icon"></i> Clients
</button>
<button mat-menu-item [routerLink]="['/reports', 'Loan']" tabindex="0">
<i class="fa fa-credit-card menu-item-icon"></i> Loans
</button>
<button mat-menu-item [routerLink]="['/reports', 'Savings']" tabindex="0">
<i class="fa fa-piggy-bank menu-item-icon"></i> Savings
</button>
<button mat-menu-item [routerLink]="['/reports', 'Fund']" tabindex="0">
<i class="fa fa-hand-holding-usd menu-item-icon"></i> Funds
</button>
<button mat-menu-item [routerLink]="['/reports', 'Accounting']" tabindex="0">
<i class="fa fa-calculator menu-item-icon"></i> Accounting
</button>
</div>
<!-- Operations & Interest -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Operations &amp; Interest</div>
<button mat-menu-item routerLink="/branch-ops/day-book" tabindex="0">
<i class="fa fa-book menu-item-icon"></i> Day Book &amp; Officer Verification
</button>
<button mat-menu-item routerLink="/branch-ops/end-of-day" tabindex="0">
<i class="fa fa-clipboard-check menu-item-icon"></i> End of the Day Report
</button>
<button mat-menu-item routerLink="/reports/savings-interest" tabindex="0">
<i class="fa fa-percentage menu-item-icon"></i> Savings Interest Posted
</button>
<button mat-menu-item routerLink="/reports/interest-posting" tabindex="0">
<i class="fa fa-clipboard-check menu-item-icon"></i> Interest Posting Reports
</button>
<button mat-menu-item routerLink="/reports/fd-interest-posting" tabindex="0">
<i class="fa fa-clipboard-check menu-item-icon"></i> FD Interest Posting Reports
</button>
<button mat-menu-item routerLink="/reports/interest-posting-summary" tabindex="0">
<i class="fa fa-table menu-item-icon"></i> Interest Posting Summary (Branch-wise)
</button>
<button mat-menu-item routerLink="/reports/tds-interest" tabindex="0">
<i class="fa fa-percent menu-item-icon"></i> Deposit Interest &amp; TDS
</button>
</div>
<!-- Compliance & Registers -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Compliance &amp; Registers</div>
<button mat-menu-item routerLink="/reports/form-97-98" tabindex="0">
<i class="fa fa-file-invoice menu-item-icon"></i> Form 97/98 Transaction Report
</button>
<button mat-menu-item routerLink="/reports/risk-review" tabindex="0">
<i class="fa fa-user-shield menu-item-icon"></i> Risk Review Report
</button>
<button mat-menu-item routerLink="/reports/kyc-expiry" tabindex="0">
<i class="fa fa-id-card menu-item-icon"></i> Re-KYC Expiry &amp; Outreach
</button>
<button mat-menu-item routerLink="/reports/dormancy" tabindex="0">
<i class="fa fa-hourglass-half menu-item-icon"></i> Inactive, Dormant &amp; DEAF
</button>
<button mat-menu-item routerLink="/reports/sanctions-logs" tabindex="0">
<i class="fa fa-shield-alt menu-item-icon"></i> Sanctions Screening &amp; Alerts
</button>
<button mat-menu-item routerLink="/reports/dip-lookups" tabindex="0">
<i class="fa fa-satellite-dish menu-item-icon"></i> DIP / MNRL Mobile Lookups
</button>
<button mat-menu-item routerLink="/reports/tds-remittance" tabindex="0">
<i class="fa fa-landmark menu-item-icon"></i> TDS Remitted to Government
</button>
<button mat-menu-item routerLink="/reports/bor" tabindex="0">
<i class="fa fa-gavel menu-item-icon"></i> BOR Register &mdash; Share Capital
</button>
</div>
</div>
<mat-menu #reportsMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item [routerLink]="['/branch-ops/account-register']" tabindex="0">
<i class="fa fa-book menu-item-icon"></i> Account Register
</button>
<button mat-menu-item [routerLink]="['/reports']" tabindex="0">
<i class="fa fa-th-list menu-item-icon"></i> All Reports
</button>
<button mat-menu-item [routerLink]="['/reports', 'Client']" tabindex="0">
<i class="fa fa-user menu-item-icon"></i> Clients
</button>
<button mat-menu-item [routerLink]="['/reports', 'Loan']" tabindex="0">
<i class="fa fa-credit-card menu-item-icon"></i> Loans
</button>
<button mat-menu-item [routerLink]="['/reports', 'Savings']" tabindex="0">
<i class="fa fa-piggy-bank menu-item-icon"></i> Savings
</button>
<button mat-menu-item [routerLink]="['/reports', 'Fund']" tabindex="0">
<i class="fa fa-hand-holding-usd menu-item-icon"></i> Funds
</button>
<button mat-menu-item [routerLink]="['/reports', 'Accounting']" tabindex="0">
<i class="fa fa-calculator menu-item-icon"></i> Accounting
</button>
<mat-divider></mat-divider>
<!-- Listed here as well as under Branch Ops: an officer looking for the day's figures reaches
for Reports, and the same screen answers both. -->
<button mat-menu-item routerLink="/branch-ops/day-book" tabindex="0">
<i class="fa fa-book menu-item-icon"></i> Day Book &amp; Officer Verification
</button>
<button mat-menu-item routerLink="/branch-ops/end-of-day" tabindex="0">
<i class="fa fa-clipboard-check menu-item-icon"></i> End of the Day Report
</button>
<button mat-menu-item routerLink="/reports/form-97-98" tabindex="0">
<i class="fa fa-file-invoice menu-item-icon"></i> Form 97/98 Transaction Report
</button>
<button mat-menu-item routerLink="/reports/risk-review" tabindex="0">
<i class="fa fa-user-shield menu-item-icon"></i> Risk Review Report
</button>
<button mat-menu-item routerLink="/reports/kyc-expiry" tabindex="0">
<i class="fa fa-id-card menu-item-icon"></i> Re-KYC Expiry &amp; Outreach
</button>
<button mat-menu-item routerLink="/reports/dormancy" tabindex="0">
<i class="fa fa-hourglass-half menu-item-icon"></i> Inactive, Dormant &amp; DEAF
</button>
<button mat-menu-item routerLink="/reports/savings-interest" tabindex="0">
<i class="fa fa-percentage menu-item-icon"></i> Savings Interest Posted
</button>
<button mat-menu-item routerLink="/reports/interest-posting" tabindex="0">
<i class="fa fa-clipboard-check menu-item-icon"></i> Interest Posting Reports
</button>
<button mat-menu-item routerLink="/reports/interest-posting-summary" tabindex="0">
<i class="fa fa-table menu-item-icon"></i> Interest Posting Summary (Branch-wise)
</button>
<!--
The Principal Officer clears flagged UNSC screenings here. It is not a runnable report,
so it never shows up under the report categories above and had no nav entry at all.
-->
<button mat-menu-item routerLink="/reports/sanctions-logs" tabindex="0">
<i class="fa fa-shield-alt menu-item-icon"></i> Sanctions Screening &amp; Alerts
</button>
<button mat-menu-item routerLink="/reports/dip-lookups" tabindex="0">
<i class="fa fa-satellite-dish menu-item-icon"></i> DIP / MNRL Mobile Lookups
</button>
<button mat-menu-item routerLink="/reports/tds-interest" tabindex="0">
<i class="fa fa-percent menu-item-icon"></i> Deposit Interest &amp; TDS
</button>
<button mat-menu-item routerLink="/reports/tds-remittance" tabindex="0">
<i class="fa fa-landmark menu-item-icon"></i> TDS Remitted to Government
</button>
<button mat-menu-item routerLink="/reports/bor" tabindex="0">
<i class="fa fa-gavel menu-item-icon"></i> BOR Register &mdash; Share Capital
</button>
</mat-menu>
<!-- Admin Mega Menu — 3-column rectangular panel grouping Setup,
@@ -915,11 +897,7 @@
(click)="subLedgerOpen = false"
><fa-icon icon="balance-scale" class="gl-icon"></fa-icon> Suit File Entry</a
>
<a
class="gl-link"
[class.hidden]="!matchesSubLedgerSearch('Locker Entry')"
routerLink="/locker"
(click)="subLedgerOpen = false"
<a class="gl-link" [class.hidden]="!matchesSubLedgerSearch('Locker Entry')" (click)="subLedgerOpen = false"
><fa-icon icon="lock" class="gl-icon"></fa-icon> Locker Entry</a
>
<a
@@ -1082,143 +1060,121 @@
<!-- Sub Menu Dropdowns -->
<!-- Branch-level operations. Starts with the day register; other branch-wide controls belong
here rather than under Cashier, which is one role's workspace. -->
<mat-menu #branchOpsMenu="matMenu" class="branch-ops-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid branch-ops-grid" (click)="$event.stopPropagation()">
<div class="deposit-menu-group">
<div class="deposit-group-title">Day Operations</div>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/day-register']">
<fa-icon icon="calendar-check" class="menu-item-icon"></fa-icon> Day Open / Day Close
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/missed-close']">
<fa-icon icon="exclamation-triangle" class="menu-item-icon"></fa-icon> Missed Close Register
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/vault-position']">
<fa-icon icon="lock" class="menu-item-icon"></fa-icon> Vault Position
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/branch-position']">
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Branch Cash Position
</button>
</div>
<div class="deposit-menu-group">
<div class="deposit-group-title">Verification &amp; Reports</div>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/day-book']">
<fa-icon icon="book" class="menu-item-icon"></fa-icon> Day Book &amp; Verification
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/end-of-day']">
<fa-icon icon="clipboard-check" class="menu-item-icon"></fa-icon> End of the Day Report
</button>
</div>
</div>
<mat-menu #branchOpsMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/day-register']">
<fa-icon icon="calendar-check" class="menu-item-icon"></fa-icon> Day Open / Day Close
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/missed-close']">
<fa-icon icon="exclamation-triangle" class="menu-item-icon"></fa-icon> Missed Close Register
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/vault-position']">
<fa-icon icon="lock" class="menu-item-icon"></fa-icon> Vault Position
</button>
<!-- The day book carries the Officer Verification Sheet — each person's credit, debit and net
for the day. It was reachable only from the Clerk and Cashier menus, which is the one
place a principal officer signing the day off does not look. -->
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/day-book']">
<fa-icon icon="book" class="menu-item-icon"></fa-icon> Day Book &amp; Verification
</button>
<!-- The signed sheet, as against the day book's working list: the day's modes, the drawer
against the books, and what is still open. This is what goes in the file. -->
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/end-of-day']">
<fa-icon icon="clipboard-check" class="menu-item-icon"></fa-icon> End of the Day Report
</button>
</mat-menu>
<!-- Clerk Desk -->
<mat-menu #clerkMenu="matMenu" class="clerk-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid clerk-grid" (click)="$event.stopPropagation()">
<!-- Clerk Desk -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Clerk Desk</div>
<button mat-menu-item tabindex="0" routerLink="/clerk/dashboard" (click)="hideShellLayout = true">
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> Clerk Dashboard
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/withdrawal-requests']">
<fa-icon icon="money-bill-wave" class="menu-item-icon"></fa-icon> My Withdrawal Requests
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/my-disbursement-requests']">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> Disbursements &amp; Transfer
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/dividend-dashboard']">
<fa-icon icon="chart-pie" class="menu-item-icon"></fa-icon> Dividend Dashboard
</button>
</div>
<!-- Vouchers -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Vouchers</div>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/my-work']">
<fa-icon icon="user-check" class="menu-item-icon"></fa-icon> My Vouchers
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/system-vouchers']">
<fa-icon icon="robot" class="menu-item-icon"></fa-icon> System Vouchers
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/received-report']">
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Received Vouchers
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/payment-report']">
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Payment Vouchers
</button>
</div>
<!-- Counter & Reports -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Counter &amp; Reports</div>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cashier-position']">
<fa-icon icon="wallet" class="menu-item-icon"></fa-icon> Cashier Position
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/closing-report']">
<fa-icon icon="file-invoice" class="menu-item-icon"></fa-icon> Closing Cash Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/end-of-day']">
<fa-icon icon="clipboard-check" class="menu-item-icon"></fa-icon> End of the Day Report
</button>
</div>
</div>
<!-- Clerk was a bare link straight into the fullscreen dashboard, which left the clerk's other
screens reachable only from inside that fullscreen nav. Anything a clerk needs from the
normal shell — an open voucher exception above all — belongs here. -->
<mat-menu #clerkMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item tabindex="0" routerLink="/clerk/dashboard" (click)="hideShellLayout = true">
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> Clerk Dashboard
</button>
<!-- The clerk reads the day as a sheet to sign off, not as a working list of vouchers: the
day's summary, the counter counted against the books, and what is still open. The day
book's own row-by-row view stays reachable from Branch Ops and from the Cashier menu. -->
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/end-of-day']">
<fa-icon icon="clipboard-check" class="menu-item-icon"></fa-icon> End of the Day Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/withdrawal-requests']">
<fa-icon icon="money-bill-wave" class="menu-item-icon"></fa-icon> My Withdrawal Requests
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/my-disbursement-requests']">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> Disbursements &amp; Transfer
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/dividend-dashboard']">
<fa-icon icon="chart-pie" class="menu-item-icon"></fa-icon> Dividend Dashboard
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/my-work']">
<fa-icon icon="user-check" class="menu-item-icon"></fa-icon> My Vouchers
</button>
<!-- Sits under My Vouchers because it answers the same question for the one worker who has no
own list: the system. A nightly run belongs to nobody, so it appears on no person's page. -->
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/system-vouchers']">
<fa-icon icon="robot" class="menu-item-icon"></fa-icon> System Vouchers
</button>
<!-- A clerk supervises the counter, so they need to see what each cashier is holding and what
went through their drawer. Both screens already return every cashier for a non-cashier
caller (CashierRoleHelper scopes only a cashier to themselves) — they were simply not
reachable from this menu. -->
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cashier-position']">
<fa-icon icon="wallet" class="menu-item-icon"></fa-icon> Cashier Position
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/closing-report']">
<fa-icon icon="file-invoice" class="menu-item-icon"></fa-icon> Closing Cash Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/received-report']">
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Received Vouchers
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/payment-report']">
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Payment Vouchers
</button>
</mat-menu>
<!-- Cashier Desk -->
<mat-menu #cashierMenu="matMenu" class="cashier-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid cashier-grid" (click)="$event.stopPropagation()">
<!-- Cash Entries & Exchange -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Cash &amp; Counter</div>
<!-- Cash Exchange, Currency Exchange Detail, Inter-Cashier Transfer and Reconciliation are not
here: they move or count notes in one cashier's own drawer, so only the cashier who is logged
in does them, from the cashier screen. -->
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/transaction-entry']">
<fa-icon icon="keyboard" class="menu-item-icon"></fa-icon> Transaction Entry
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/system/denomination-master']">
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Cash Denomination
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/voucher-correction']">
<fa-icon icon="undo" class="menu-item-icon"></fa-icon> Voucher Correction
</button>
</div>
<!-- Transfers & Settlement -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Transfers &amp; Settlement</div>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-transfer']">
<fa-icon icon="random" class="menu-item-icon"></fa-icon> Cash Transfer
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-settlement']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Cash Settlement
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cashier-position']">
<fa-icon icon="wallet" class="menu-item-icon"></fa-icon> Cashier Position
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/day-book']">
<fa-icon icon="book" class="menu-item-icon"></fa-icon> Day Book
</button>
</div>
<!-- Reports & Audit -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Reports &amp; Audit</div>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/closing-report']">
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> Closing Cash Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/excess-report']">
<fa-icon icon="exclamation-circle" class="menu-item-icon"></fa-icon> Excess Transaction Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-history']">
<fa-icon icon="history" class="menu-item-icon"></fa-icon> Cash History
</button>
</div>
</div>
<mat-menu #cashierMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-exchange']">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> Cash Exchange Entry
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/system/denomination-master']">
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Cash Denomination
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/closing-report']">
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> Closing Cash Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/exchange-detail']">
<fa-icon icon="globe" class="menu-item-icon"></fa-icon> Currency Exchange Detail
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-transfer']">
<fa-icon icon="random" class="menu-item-icon"></fa-icon> Cash Transfer
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/excess-report']">
<fa-icon icon="exclamation-circle" class="menu-item-icon"></fa-icon> Excess Transaction Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-settlement']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Cash Settlement
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/inter-cashier']">
<fa-icon icon="exchange-alt" class="menu-item-icon"></fa-icon> Inter-Cashier Transfer
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cashier-position']">
<fa-icon icon="wallet" class="menu-item-icon"></fa-icon> Cashier Position
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/reconciliation']">
<fa-icon icon="balance-scale" class="menu-item-icon"></fa-icon> Reconciliation
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-history']">
<fa-icon icon="history" class="menu-item-icon"></fa-icon> Cash History
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/day-book']">
<fa-icon icon="book" class="menu-item-icon"></fa-icon> Day Book
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/transaction-entry']">
<fa-icon icon="keyboard" class="menu-item-icon"></fa-icon> Transaction Entry
</button>
</mat-menu>
<mat-menu #depositsMenu="matMenu" class="deposits-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid deposits-grid" (click)="$event.stopPropagation()">
<div class="deposit-menu-grid" (click)="$event.stopPropagation()">
<div class="deposit-menu-group">
<div class="deposit-group-title">Accounts</div>
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/savings']">
@@ -1233,29 +1189,14 @@
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/recurring-deposits']">
<fa-icon icon="sync" class="menu-item-icon"></fa-icon> Recurring
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/dds']" (click)="depositsMenu.closed.emit()">
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> DDS Party Master
</button>
<button
mat-menu-item
tabindex="0"
[routerLink]="['/accounts/dds-accounts']"
(click)="depositsMenu.closed.emit()"
>
<fa-icon icon="list-alt" class="menu-item-icon"></fa-icon> DDS Accounts
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="coins" class="menu-item-icon"></fa-icon> DDS</button>
</div>
<div class="deposit-menu-group">
<div class="deposit-group-title">Agent</div>
<button mat-menu-item tabindex="0">
<fa-icon icon="hand-holding-usd" class="menu-item-icon"></fa-icon> Agent Commission
</button>
<button
mat-menu-item
tabindex="0"
[routerLink]="['/accounts/dds-collection']"
(click)="depositsMenu.closed.emit()"
>
<button mat-menu-item tabindex="0">
<fa-icon icon="calendar-check" class="menu-item-icon"></fa-icon> Today Collection
</button>
<button mat-menu-item tabindex="0">
@@ -1270,14 +1211,6 @@
</div>
<div class="deposit-menu-group">
<div class="deposit-group-title">Reports</div>
<button
mat-menu-item
tabindex="0"
[routerLink]="['/accounts/dds-reports']"
(click)="depositsMenu.closed.emit()"
>
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> DDS Reports
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/savings-daily-interest']">
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> Savings Daily Interest
</button>
@@ -1287,9 +1220,6 @@
<button mat-menu-item tabindex="0" [routerLink]="['/fd-dashboard']">
<fa-icon icon="calendar-check" class="menu-item-icon"></fa-icon> FD Dashboard
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/rd-dashboard']">
<fa-icon icon="piggy-bank" class="menu-item-icon"></fa-icon> RD Dashboard
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> FD Interest Report
</button>
@@ -1306,54 +1236,34 @@
</div>
</mat-menu>
<mat-menu #clearingMenu="matMenu" class="clearing-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid clearing-grid" (click)="$event.stopPropagation()">
<!-- CTS Clearing -->
<div class="deposit-menu-group">
<div class="deposit-group-title">CTS Clearing</div>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/inward']">
<fa-icon icon="arrow-left" class="menu-item-icon"></fa-icon> Inward Clearing (CTS)
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/outward']">
<fa-icon icon="arrow-right" class="menu-item-icon"></fa-icon> Outward Clearing (CTS)
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/positive-pay']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Positive Pay (PPS)
</button>
</div>
<!-- Settlement & Recon -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Settlement &amp; Recon</div>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/settlement']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Settlement
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/reconciliation']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Reconciliation
</button>
<button mat-menu-item [routerLink]="['/sponsor-dd/inventory']" tabindex="0">
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Sponsor DD Inventory
</button>
</div>
<!-- NACH & Other -->
<div class="deposit-menu-group">
<div class="deposit-group-title">NACH &amp; Direct</div>
<button mat-menu-item tabindex="0" [routerLink]="['/nachclearing']">
<fa-icon icon="money-check" class="menu-item-icon"></fa-icon> NACH Clearing
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="arrow-right" class="menu-item-icon"></fa-icon> Outward
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="arrow-left" class="menu-item-icon"></fa-icon> Inward
</button>
</div>
</div>
<mat-menu #clearingMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/inward']">
<fa-icon icon="arrow-left" class="menu-item-icon"></fa-icon> Inward Clearing (CTS)
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/outward']">
<fa-icon icon="arrow-right" class="menu-item-icon"></fa-icon> Outward Clearing (CTS)
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/positive-pay']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Positive Pay (PPS)
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/settlement']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Settlement
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/reconciliation']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Reconciliation
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/nachclearing']">
<fa-icon icon="money-check" class="menu-item-icon"></fa-icon> NACH Clearing
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="arrow-right" class="menu-item-icon"></fa-icon> Outward</button>
<button mat-menu-item tabindex="0"><fa-icon icon="arrow-left" class="menu-item-icon"></fa-icon> Inward</button>
<button mat-menu-item [routerLink]="['/sponsor-dd/inventory']" tabindex="0">
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Sponsor DD Inventory
</button>
</mat-menu>
<mat-menu #hrmMenu="matMenu" class="hrm-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid hrm-grid" (click)="$event.stopPropagation()">
<div class="deposit-menu-grid" (click)="$event.stopPropagation()">
<!-- Payroll -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Payroll</div>
@@ -1498,9 +1408,7 @@
<button mat-menu-item tabindex="0">
<fa-icon icon="user-slash" class="menu-item-icon" style="color: #c2410c"></fa-icon> Non-Customer Entry
</button>
<button mat-menu-item routerLink="/locker" tabindex="0">
<fa-icon icon="lock" class="menu-item-icon"></fa-icon> Locker
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="lock" class="menu-item-icon"></fa-icon> Locker</button>
<button mat-menu-item routerLink="/investment/bonds" tabindex="0">
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> SLR and Non SLR Investment Report
</button>
@@ -1662,33 +1570,23 @@
</div>
</mat-menu>
<mat-menu #ckycMenu="matMenu" class="ckyc-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid ckyc-grid" (click)="$event.stopPropagation()">
<!-- KYC Operations -->
<div class="deposit-menu-group">
<div class="deposit-group-title">KYC Operations</div>
<button mat-menu-item routerLink="/ckyc" tabindex="0">
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> CKYC Management
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="database" class="menu-item-icon" style="color: #be185d"></fa-icon> Dump KYC &amp; Loan Data
</button>
</div>
<!-- Reports & Screening -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Reports &amp; Screening</div>
<button mat-menu-item tabindex="0" (click)="openCkycReportsDialog('REKYC')">
<fa-icon icon="sync-alt" class="menu-item-icon" style="color: #be185d"></fa-icon> Re-KYC Report
</button>
<button mat-menu-item tabindex="0" (click)="openCkycReportsDialog('RISK-REVIEW')">
<fa-icon icon="history" class="menu-item-icon" style="color: #be185d"></fa-icon> Risk Review
</button>
<button mat-menu-item tabindex="0" (click)="openCkycReportsDialog('CERSAI')">
<fa-icon icon="shield-alt" class="menu-item-icon" style="color: #be185d"></fa-icon> CERSAI Report
</button>
</div>
</div>
<mat-menu #ckycMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item routerLink="/ckyc" tabindex="0">
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> CKYC Management
</button>
<button mat-menu-item tabindex="0" (click)="openCkycReportsDialog('REKYC')">
<fa-icon icon="sync-alt" class="menu-item-icon" style="color: #be185d"></fa-icon> Re-KYC Report
</button>
<button mat-menu-item tabindex="0" (click)="openCkycReportsDialog('RISK-REVIEW')">
<fa-icon icon="history" class="menu-item-icon" style="color: #be185d"></fa-icon> Risk Review
</button>
<button mat-menu-item tabindex="0" (click)="openCkycReportsDialog('CERSAI')">
<fa-icon icon="shield-alt" class="menu-item-icon" style="color: #be185d"></fa-icon> CERSAI Report
</button>
<mat-divider></mat-divider>
<button mat-menu-item tabindex="0">
<fa-icon icon="database" class="menu-item-icon" style="color: #be185d"></fa-icon> Dump KYC &amp; Loan Data
</button>
</mat-menu>
<!-- CFCFRMS — single landing page (CfcfrmsShell) hosts the full
@@ -1706,11 +1604,11 @@
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/sundry-dr-cr">
<fa-icon icon="random" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Sundry Dr / Cr
Sundry Dr / Cr Reconciliation
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/pnl-impact">
<fa-icon icon="chart-line" class="menu-item-icon" style="color: #0d9488"></fa-icon>
P&amp;L Impact
P&amp;L Impact Reconciliation
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/gl-reconciliation">
<fa-icon icon="book-open" class="menu-item-icon" style="color: #0d9488"></fa-icon>
@@ -1718,11 +1616,11 @@
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/fixed-assets">
<fa-icon icon="cube" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Fixed Assets
Fixed Assets Reconciliation
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/inter-company">
<fa-icon icon="sitemap" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Inter-Company
Inter-Company Reconciliation
</button>
</div>
@@ -1735,15 +1633,15 @@
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/api-bank-reco">
<fa-icon icon="code-branch" class="menu-item-icon" style="color: #0d9488"></fa-icon>
API Bank Reco
API Based Bank Reconciliation
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/neft-rtgs">
<fa-icon icon="exchange" class="menu-item-icon" style="color: #0d9488"></fa-icon>
NEFT / RTGS Reco
NEFT / RTGS Reconciliation
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/clearing-reco">
<fa-icon icon="hand-holding-usd" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Clearing Reco
Clearing Reconciliation
</button>
</div>
@@ -1756,11 +1654,11 @@
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/three-way-digital">
<fa-icon icon="project-diagram" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Three-way Digital
Three-way Digital Reconciliation
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/nach-reco">
<fa-icon icon="sync-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
NACH Reco
NACH Reconciliation
</button>
</div>
@@ -1769,11 +1667,11 @@
<div class="deposit-group-title">Instruments &amp; Members</div>
<button mat-menu-item [routerLink]="['/sponsor-dd-reconciliation']" tabindex="0">
<fa-icon icon="money-check-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
DD / Pay Order
DD / Pay Order Reconciliation
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/share-member">
<fa-icon icon="coins" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Share Member
Share Member Reconciliation
</button>
</div>
@@ -1949,68 +1847,46 @@
</div>
</mat-menu>
<mat-menu #insuranceMenu="matMenu" class="insurance-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid insurance-grid" (click)="$event.stopPropagation()">
<!-- Social & Policy Schemes -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Schemes &amp; Social</div>
<button mat-menu-item routerLink="/dicgc" tabindex="0">
<fa-icon icon="building" class="menu-item-icon" style="color: #c2410c"></fa-icon> DICGC
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="heart" class="menu-item-icon"></fa-icon> PMJBY</button>
<button mat-menu-item tabindex="0"><fa-icon icon="heart" class="menu-item-icon"></fa-icon> PMSBY</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="hand-holding-heart" class="menu-item-icon"></fa-icon> Indemnity
</button>
</div>
<!-- Institutional & Loans -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Institutional &amp; Loans</div>
<button mat-menu-item tabindex="0">
<fa-icon icon="file-invoice" class="menu-item-icon"></fa-icon> Loan Insurance
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="home" class="menu-item-icon"></fa-icon> Assets Insurance
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="laptop" class="menu-item-icon"></fa-icon> Cyber Insurance
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="university" class="menu-item-icon"></fa-icon> Bank Insurance
</button>
</div>
</div>
<mat-menu #insuranceMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item routerLink="/dicgc" tabindex="0">
<fa-icon icon="building" class="menu-item-icon" style="color: #c2410c"></fa-icon> DICGC
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="heart" class="menu-item-icon"></fa-icon> PMJBY</button>
<button mat-menu-item tabindex="0"><fa-icon icon="heart" class="menu-item-icon"></fa-icon> PMSBY</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="hand-holding-heart" class="menu-item-icon"></fa-icon> Indemnity
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="home" class="menu-item-icon"></fa-icon> Assets Insurance
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="laptop" class="menu-item-icon"></fa-icon> Cyber Insurance
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="university" class="menu-item-icon"></fa-icon> Bank Insurance
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="file-invoice" class="menu-item-icon"></fa-icon> Loan Insurance
</button>
</mat-menu>
<mat-menu #digitalMenu="matMenu" class="digital-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid digital-grid" (click)="$event.stopPropagation()">
<!-- Payment Systems -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Payments</div>
<button mat-menu-item routerLink="/digital/neft-rtgs-report" tabindex="0">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> NEFT / RTGS
</button>
<button mat-menu-item routerLink="/digital/imps-report" tabindex="0">
<fa-icon icon="bolt" class="menu-item-icon"></fa-icon> IMPS
</button>
<button mat-menu-item routerLink="/digital/upi-report" tabindex="0">
<fa-icon icon="mobile-alt" class="menu-item-icon"></fa-icon> UPI Reports
</button>
</div>
<!-- Cards & Mandates -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Cards &amp; Mandates</div>
<button mat-menu-item routerLink="/digital/atm-pos-ecom" tabindex="0">
<fa-icon icon="credit-card" class="menu-item-icon"></fa-icon> ATM / POS / ECOM Reports
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="sync" class="menu-item-icon"></fa-icon> NACH</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="file-signature" class="menu-item-icon"></fa-icon> Mandate
</button>
</div>
</div>
<mat-menu #digitalMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item routerLink="/digital/neft-rtgs-report" tabindex="0">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> NEFT / RTGS
</button>
<button mat-menu-item routerLink="/digital/imps-report" tabindex="0">
<fa-icon icon="bolt" class="menu-item-icon"></fa-icon> IMPS
</button>
<button mat-menu-item routerLink="/digital/upi-report" tabindex="0">
<fa-icon icon="mobile-alt" class="menu-item-icon"></fa-icon> UPI Reports
</button>
<button mat-menu-item routerLink="/digital/atm-pos-ecom" tabindex="0">
<fa-icon icon="credit-card" class="menu-item-icon"></fa-icon> ATM / POS / ECOM Reports
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="sync" class="menu-item-icon"></fa-icon> NACH</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="file-signature" class="menu-item-icon"></fa-icon> Mandate
</button>
</mat-menu>
<!-- RBI Mega Menu -->
+123 -234
View File
@@ -244,44 +244,6 @@ $color: #e2e4ec;
}
}
/*
* The cashier's bar is meant to be one row at a normal window size: it carries about eleven items, and at
* the usual padding the last one wrapped onto a second row. The items are tightened so they fit, and spread
* across the row. As the window narrows they tighten further, then drop their icons, and only wrap in a very
* narrow window. The clerk's bar (cashier-nav--clerk) has its own dropdowns and is left as it was.
*/
.cashier-nav:not(.cashier-nav--clerk) {
flex-wrap: nowrap;
justify-content: space-between;
gap: 0;
padding: 4px 8px;
.cashier-nav__item {
flex: 0 0 auto;
gap: 4px;
padding: 5px 8px;
font-size: 11.5px;
}
@media (width <= 1240px) {
.cashier-nav__item {
padding: 5px 6px;
font-size: 11px;
}
}
@media (width <= 1120px) {
.cashier-nav__item fa-icon {
display: none;
}
}
@media (width <= 900px) {
flex-wrap: wrap;
justify-content: flex-start;
}
}
.sidenav {
overflow-x: hidden;
position: relative;
@@ -304,7 +266,7 @@ $color: #e2e4ec;
max-width: 100%;
overflow-x: hidden;
box-sizing: border-box;
padding: 10px 18px;
padding: 16px 28px;
}
}
@@ -320,15 +282,21 @@ $gray-700: #334155;
/* ============================================================
Main header (top 2-row navigation)
--------------------------------------------------------------
Compact, modern enterprise SaaS navigation:
• Height reduced to 32px per row (64px total) to save screen space.
• Smooth horizontal scrolling prevents menu items from getting cut off.
• Compact typography, icons and paddings fit all banking modules cleanly.
Redesigned 2026 — clean, modern enterprise SaaS feel:
• Both rows live on the same white surface separated only by a
hairline; r2 has a barely-there blue-50 wash so frequent vs
secondary menus read at a glance without dark/light contrast.
• Items are now equal-width "tabs" with NO vertical dividers
between them (the old cell borders made it look spreadsheet-y).
• Hover / active = a 3 px brand-coloured TOP bar + brand text +
coloured icon. Reads instantly without colour-flooding the row.
• Icons larger (12.5 px) and full-opacity always — easier to scan
during demos. Caret is subtle and rotates on open.
============================================================ */
.main-header {
background: #fff;
border-bottom: 2px solid $brand;
box-shadow: 0 1px 3px rgb(0 0 0 / 6%);
border-bottom: 2.5px solid $brand;
box-shadow: 0 1px 4px rgb(0 0 0 / 7%);
position: sticky;
/* Sit directly under the sticky toolbar (32px tall) so both stay visible. */
@@ -338,19 +306,19 @@ $gray-700: #334155;
}
.nav-row {
height: 32px;
height: 44px;
display: flex;
align-items: stretch;
overflow: auto visible;
overflow: visible;
position: relative;
/* Outer frame; vertical dividers between cells are drawn as `border-right`
on each `.ni` below. Switched away from the equal-column grid so each
cell can size to its own label — long names (General Ledger, Documents
Hub) no longer overflow and short ones (Approvals, HRM) no longer waste
space. */
border: 1px solid $gray-200;
background: #fff;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
.r2.nav-row {
@@ -366,17 +334,25 @@ $gray-700: #334155;
background: #f8fafc;
}
// ===== Nav Item — compact modern tab style =====
// ===== Nav Item — modern tab style =====
// Typography spec — same family / size / weight / color for BOTH rows.
.ni {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 2.5px;
gap: 7px;
height: 100%;
/* Each cell sizes to its own label (min-width: max-content) and grows
to share any leftover row width. Short labels stay compact, long ones
get the room they need — no overlap, no wasted gap. */
flex: 1 0 auto;
min-width: 0;
padding: 0 4px;
min-width: max-content;
padding: 0 14px;
/* Vertical divider between adjacent cells; outer frame is the row's
own border, and the last cell drops its right border below. */
border-right: 1px solid $gray-200;
cursor: pointer;
white-space: nowrap;
@@ -384,11 +360,11 @@ $gray-700: #334155;
'IBM Plex Sans',
-apple-system,
sans-serif;
font-size: 10px;
font-size: 12px;
font-weight: 500;
color: #0a1a2e;
line-height: 1.2;
letter-spacing: -0.015em;
line-height: 1.3;
letter-spacing: 0.01em;
text-decoration: none;
user-select: none;
transition:
@@ -397,15 +373,17 @@ $gray-700: #334155;
box-shadow 0.15s ease;
border-radius: 0;
/* 3px accent strip that fills on hover / active (uses ::before so we
don't reflow the row when toggling). */
&::before {
content: '';
position: absolute;
left: 2px;
right: 2px;
left: 4px;
right: 4px;
top: 0;
height: 2px;
height: 3px;
background: transparent;
border-radius: 0 0 2px 2px;
border-radius: 0 0 3px 3px;
transition: background 0.15s ease;
}
@@ -432,19 +410,17 @@ $gray-700: #334155;
outline-offset: -2px;
}
/* Last cell in a row sits flush against the row's own right edge, so
drop its divider to avoid a double 2 px line. */
&:last-child {
border-right: none;
}
.ni-short {
display: none;
}
}
.ni-ico {
width: 9.5px;
height: 9.5px;
font-size: 9.5px;
width: 12px;
height: 12px;
font-size: 12px;
color: $gray-500;
flex-shrink: 0;
transition: color 0.15s ease;
@@ -456,12 +432,11 @@ $gray-700: #334155;
}
.ni-caret {
width: 5px;
height: 5px;
font-size: 5px;
width: 7px;
height: 7px;
font-size: 7px;
color: $gray-400;
flex-shrink: 0;
margin-left: 0.5px;
transition:
transform 0.18s ease,
color 0.18s ease;
@@ -472,89 +447,17 @@ $gray-700: #334155;
}
}
@media (width >= 1400px) {
.ni {
padding: 0 6px;
gap: 3.5px;
font-size: 10.8px;
letter-spacing: 0;
}
.ni-ico {
width: 10px;
height: 10px;
font-size: 10px;
}
.ni-caret {
width: 5.5px;
height: 5.5px;
font-size: 5.5px;
margin-left: 1px;
}
}
@media (width <= 1200px) {
.ni {
padding: 0 3px;
gap: 2px;
font-size: 9.8px;
letter-spacing: -0.02em;
}
.ni-ico {
width: 9px;
height: 9px;
font-size: 9px;
}
.ni-caret {
width: 4.5px;
height: 4.5px;
font-size: 4.5px;
padding: 0 8px;
gap: 5px;
}
}
@media (width <= 1080px) {
@media (width <= 980px) {
.ni {
padding: 0 2.5px;
gap: 1.5px;
font-size: 9.4px;
letter-spacing: -0.025em;
.ni-full {
display: none;
}
.ni-short {
display: inline;
}
}
.ni-ico {
width: 8.5px;
height: 8.5px;
font-size: 8.5px;
}
.ni-caret {
width: 4px;
height: 4px;
font-size: 4px;
}
}
@media (width <= 880px) {
.nav-row {
overflow-x: auto;
scrollbar-width: thin;
}
.ni {
flex: 0 0 auto;
padding: 0 6px;
gap: 3px;
font-size: 10px;
gap: 4px;
}
}
@@ -796,29 +699,21 @@ $gray-700: #334155;
// ===== Nav Dropdown Menus =====
::ng-deep .nav-dropdown {
border-radius: 6px !important;
border-radius: 8px !important;
border: 1px solid $gray-200 !important;
box-shadow: 0 4px 16px rgb(0 0 0 / 12%) !important;
min-width: 170px !important;
padding: 3px 0 !important;
margin-top: 1px !important;
box-shadow: 0 6px 24px rgb(0 0 0 / 10%) !important;
min-width: 180px !important;
padding: 4px 0 !important;
margin-top: 2px !important;
.mat-mdc-menu-item {
font-size: 10.5px !important;
font-size: 12.5px !important;
font-weight: 500 !important;
height: 25px !important;
min-height: 25px !important;
padding: 0 6px !important;
line-height: 25px !important;
height: 34px !important;
min-height: 34px !important;
padding: 0 14px !important;
color: $gray-700 !important;
font-family: 'IBM Plex Sans', Roboto, sans-serif !important;
white-space: nowrap !important;
.menu-item-icon {
font-size: 10.5px !important;
margin-right: 5px !important;
opacity: 0.8;
}
font-family: 'DM Sans', Roboto, sans-serif !important;
&:hover {
background: $brand-hover !important;
@@ -833,44 +728,39 @@ $gray-700: #334155;
}
/* Deposits Mega Panel (inside mat-menu overlay) */
::ng-deep .deposit-menu-grid {
.deposit-menu-grid {
display: grid;
gap: 4px 12px;
padding: 6px 10px;
min-width: 0;
width: 100%;
box-sizing: border-box;
overflow-x: hidden;
grid-template-columns: repeat(3, 1fr);
gap: 8px 24px;
padding: 8px 12px;
min-width: 600px;
}
::ng-deep .deposit-menu-group {
.deposit-menu-group {
.deposit-group-title {
font-weight: 700;
font-size: 9.5px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
letter-spacing: 0.6px;
color: #1074b9;
padding: 2px 6px 3px;
padding: 4px 16px 6px;
margin-bottom: 2px;
border-bottom: 1.5px solid #1074b9;
white-space: nowrap;
border-bottom: 2px solid #1074b9;
}
}
/* NPA Mega Panel — 3 columns */
::ng-deep .npa-grid {
grid-template-columns: repeat(3, minmax(210px, 1fr));
min-width: 0;
max-width: 100%;
overflow-x: hidden;
/* NPA Mega Panel — 4 columns (Reports / Analysis / Classification / Recovery Management) */
.npa-grid {
grid-template-columns: repeat(4, minmax(200px, 1fr));
min-width: 880px;
max-width: 96vw;
}
/* Reconciliation Mega Panel — 5 columns */
::ng-deep .reconciliation-grid {
grid-template-columns: repeat(5, minmax(170px, 1fr));
min-width: 0;
max-width: 100%;
overflow-x: hidden;
/* Reconciliation Mega Panel — 5 columns, teal accent to match the icon palette */
.reconciliation-grid {
grid-template-columns: repeat(5, minmax(190px, 1fr));
min-width: 1040px;
max-width: 96vw;
.deposit-group-title {
color: #0d9488;
@@ -890,49 +780,47 @@ $gray-700: #334155;
.gl-mega-menu {
position: absolute;
top: 96px;
top: 100px;
left: 50%;
transform: translateX(-50%);
background: #fff;
width: 760px;
max-width: 94vw;
max-height: 72vh;
overflow-y: auto;
padding: 12px 16px;
box-shadow: 0 6px 20px rgb(0 0 0 / 18%);
border-radius: 0 0 8px 8px;
width: 860px;
max-width: 95vw;
padding: 20px 24px;
box-shadow: 0 6px 24px rgb(0 0 0 / 22%);
border-radius: 0 0 10px 10px;
z-index: 1000;
border-top: 2.5px solid #0d47a1;
border-top: 3px solid #0d47a1;
}
.gl-mega-menu-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
padding-bottom: 6px;
gap: 10px;
margin-bottom: 14px;
padding-bottom: 12px;
border-bottom: 1px solid #e5e7eb;
span {
font-size: 14px;
font-size: 18px;
font-weight: 700;
color: #0d47a1;
letter-spacing: 0.01em;
letter-spacing: 0.02em;
}
}
.gl-mega-menu-header-icon {
font-size: 14px;
font-size: 18px;
color: #0d47a1;
}
.gl-search {
width: 100%;
padding: 6px 10px;
margin-bottom: 10px;
padding: 10px 14px;
margin-bottom: 16px;
border: 1px solid #d0d5dd;
border-radius: 5px;
font-size: 12px;
border-radius: 6px;
font-size: 14px;
box-sizing: border-box;
background: #f9fafb;
@@ -940,37 +828,36 @@ $gray-700: #334155;
outline: none;
border-color: #0d47a1;
background: #fff;
box-shadow: 0 0 0 2px rgb(13 71 161 / 10%);
box-shadow: 0 0 0 3px rgb(13 71 161 / 10%);
}
}
.gl-columns {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px 16px;
gap: 24px;
}
.gl-group-title {
font-weight: 700;
font-size: 10px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.6px;
letter-spacing: 0.8px;
color: #0d47a1;
margin-bottom: 5px;
padding-bottom: 3px;
border-bottom: 1.5px solid #0d47a1;
margin-bottom: 10px;
padding-bottom: 6px;
border-bottom: 2px solid #0d47a1;
}
.gl-link {
display: flex;
align-items: center;
padding: 3.5px 5px;
padding: 7px 6px;
text-decoration: none;
color: #344054;
font-size: 11.5px;
line-height: 1.3;
font-size: 13px;
cursor: pointer;
border-radius: 4px;
border-radius: 5px;
transition:
background 0.15s,
color 0.15s;
@@ -986,21 +873,23 @@ $gray-700: #334155;
}
.gl-icon {
width: 14px;
margin-right: 5px;
width: 18px;
margin-right: 8px;
color: #667085;
font-size: 11px;
flex-shrink: 0;
font-size: 13px;
.gl-link:hover & {
color: #0d47a1;
}
}
// Marks a menu link as actually wired to a working screen/dialog (vs a still-unwired
// placeholder) — a small green dot at the end of the row, so a reviewer can tell at a glance
// which items in a mega-menu are functional without having to click every one of them.
.gl-link--live::after {
content: '';
width: 5px;
height: 5px;
width: 6px;
height: 6px;
margin-left: auto;
flex-shrink: 0;
border-radius: 50%;
@@ -1010,9 +899,9 @@ $gray-700: #334155;
/* RBI Mega Menu */
.rbi-mega-menu {
width: 880px;
max-width: 94vw;
max-height: 72vh;
width: 1100px;
max-width: 97vw;
max-height: 80vh;
overflow-y: auto;
border-top-color: #b71c1c;
}
@@ -1020,7 +909,7 @@ $gray-700: #334155;
.rbi-columns {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px 16px;
gap: 20px;
}
@keyframes move {
@@ -20,9 +20,7 @@
<div class="t-live"></div>
&nbsp;LIVE
</div>
<div class="t-chip">
Branch:&nbsp;<strong>{{ officeLabel }}</strong>
</div>
<div class="t-chip">Branch:&nbsp;<strong>HO</strong></div>
<div class="t-chip">
Date:&nbsp;<strong>{{ today | date: 'dd MMM yyyy' }}</strong>
</div>
@@ -524,7 +522,7 @@
</mat-menu>
<!-- Application User Help Menu -->
<mat-menu #applicationMenu="matMenu" [overlapTrigger]="false" class="t-user-menu" xPosition="before">
<mat-menu #applicationMenu="matMenu" [overlapTrigger]="false">
<button mat-menu-item (click)="help()" tabindex="0">
<mat-icon matListIcon>
<fa-icon icon="question-circle" size="sm"></fa-icon>
+58 -186
View File
@@ -390,103 +390,6 @@ $icon-hover-border: rgb(255 255 255 / 28%);
max-height: 80vh;
overflow-y: auto;
}
// Compact card per AI module
.ai-card.mat-mdc-menu-item {
display: flex !important;
align-items: flex-start !important;
gap: 10px !important;
padding: 10px !important;
min-height: 52px !important;
height: auto !important;
border-radius: 9px !important;
font-family: inherit !important;
line-height: 1.3 !important;
white-space: normal !important;
overflow: visible !important;
transition: background 0.16s ease !important;
.mat-mdc-menu-item-text,
.mdc-list-item__primary-text,
.mdc-list-item__content {
display: flex !important;
align-items: flex-start !important;
gap: 10px !important;
width: 100% !important;
white-space: normal !important;
overflow: visible !important;
}
.ai-card-ic {
width: 32px !important;
height: 32px !important;
min-width: 32px !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
border-radius: 8px !important;
font-size: 13px !important;
flex-shrink: 0 !important;
background: var(--c-bg) !important;
color: var(--c-color) !important;
margin: 0 !important;
opacity: 1 !important;
transition:
background 0.16s ease,
color 0.16s ease,
transform 0.16s ease !important;
fa-icon {
font-size: 13px !important;
width: auto !important;
min-width: auto !important;
margin: 0 !important;
color: inherit !important;
opacity: 1 !important;
}
}
.ai-card-body {
display: flex !important;
flex-direction: column !important;
flex: 1 !important;
min-width: 0 !important;
text-align: left !important;
white-space: normal !important;
}
.ai-card-t {
font-size: 12.5px !important;
font-weight: 600 !important;
color: #0f172a !important;
line-height: 1.25 !important;
white-space: normal !important;
display: block !important;
}
.ai-card-d {
font-size: 10.5px !important;
color: #64748b !important;
margin-top: 3px !important;
line-height: 1.35 !important;
white-space: normal !important;
display: block !important;
}
&:hover {
background: var(--c-bg) !important;
.ai-card-ic {
background: var(--c-hover) !important;
color: #fff !important;
transform: scale(1.06) !important;
}
.ai-card-t {
color: var(--c-color) !important;
}
}
}
}
.ai-menu-header {
@@ -585,101 +488,70 @@ $icon-hover-border: rgb(255 255 255 / 28%);
font-size: 11px;
}
// ===== User Account Dropdown Menu =====
::ng-deep .t-user-menu.mat-mdc-menu-panel {
min-width: 190px !important;
border-radius: 10px !important;
box-shadow:
0 10px 25px rgb(15 23 42 / 12%),
0 2px 6px rgb(15 23 42 / 6%) !important;
border: 1px solid #e2e8f0 !important;
padding: 6px 0 !important;
background: #fff !important;
// Compact card per AI module
.ai-card.mat-mdc-menu-item {
display: flex !important;
align-items: flex-start;
gap: 10px;
padding: 9px 10px;
min-height: 0;
height: auto;
border-radius: 9px;
font-family: inherit;
line-height: 1.3;
white-space: normal;
transition: background 0.16s ease;
.mat-mdc-menu-content {
padding: 4px 0 !important;
.ai-card-ic {
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
font-size: 13px;
flex-shrink: 0;
background: var(--c-bg);
color: var(--c-color);
transition:
background 0.16s ease,
color 0.16s ease,
transform 0.16s ease;
}
.mat-mdc-menu-item {
height: 38px !important;
min-height: 38px !important;
line-height: 38px !important;
padding: 0 14px !important;
display: flex !important;
align-items: center !important;
font-size: 13px !important;
font-weight: 500 !important;
color: #334155 !important;
transition:
background-color 0.15s ease,
color 0.15s ease !important;
.ai-card-body {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
text-align: left;
}
.mat-mdc-menu-item-text {
display: inline-flex !important;
align-items: center !important;
width: 100% !important;
font-size: 13px !important;
color: inherit !important;
font-weight: 500 !important;
.ai-card-t {
font-size: 12.5px;
font-weight: 600;
color: #0f172a;
line-height: 1.25;
}
.ai-card-d {
font-size: 10.5px;
color: #64748b;
margin-top: 2px;
line-height: 1.35;
}
&:hover {
background: var(--c-bg);
.ai-card-ic {
background: var(--c-hover);
color: #fff;
transform: scale(1.06);
}
mat-icon {
margin-right: 10px !important;
width: 18px !important;
height: 18px !important;
min-width: 18px !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
color: #64748b !important;
font-size: 14px !important;
flex-shrink: 0 !important;
fa-icon {
font-size: 14px !important;
width: 14px !important;
min-width: 14px !important;
text-align: center !important;
margin-right: 0 !important;
color: inherit !important;
opacity: 1 !important;
}
}
span {
font-size: 13px !important;
color: inherit !important;
font-weight: 500 !important;
white-space: nowrap !important;
}
&:hover {
background-color: #f1f5f9 !important;
color: #0d47a1 !important;
mat-icon {
color: #0d47a1 !important;
}
}
&#logout {
color: #dc2626 !important;
border-top: 1px solid #f1f5f9 !important;
margin-top: 4px !important;
padding-top: 2px !important;
mat-icon {
color: #dc2626 !important;
}
&:hover {
background-color: #fef2f2 !important;
color: #b91c1c !important;
mat-icon {
color: #b91c1c !important;
}
}
.ai-card-t {
color: var(--c-color);
}
}
}
@@ -49,7 +49,6 @@ import { MatIcon } from '@angular/material/icon';
import { NotificationsTrayComponent as NotificationsTrayComponent_1 } from '../../../shared/notifications-tray/notifications-tray.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DocumentationLinksService } from 'app/shared/services/documentation-links.service';
import { businessToday } from 'app/core/utils/business-today';
/**
* Toolbar component.
@@ -93,14 +92,6 @@ export class ToolbarComponent implements OnInit, AfterViewInit, AfterContentChec
@ViewChild('templateAppMenu') templateAppMenu: TemplateRef<any>;
@ViewChild('notificationsTray') notificationsTray: NotificationsTrayComponent;
/** The signed-in user's office — this chip read "HO" for every login, branch staff included. */
get officeLabel(): string {
if (this.authenticationService.isHeadOfficeUser()) {
return 'HO';
}
return this.authenticationService.getCredentials()?.officeName || '—';
}
/** Subscription to breakpoint observer for handset. */
isHandset$: Observable<boolean> = this.breakpointObserver
.observe(Breakpoints.Handset)
@@ -109,10 +100,8 @@ export class ToolbarComponent implements OnInit, AfterViewInit, AfterContentChec
/** Sets the initial state of sidenav as collapsed. Not collapsed if false. */
sidenavCollapsed = true;
/** The bank's "today" for the top bar — the business date when enabled, else the server date. */
get today(): Date {
return businessToday();
}
/** Current date for top bar display */
today = new Date();
/** Last login time display */
lastLoginTime = this.formatLastLogin();
-30
View File
@@ -1,30 +0,0 @@
/**
* 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 bank's "today": the business date while `enable-business-date` is on, the server's own date
* while it is off. The server decides which (GET /branchday/today); the web app stores the answer
* in `mifosXServerDate` at login and whenever the business date changes. Before that has happened
* the browser clock is the only date there is, so it is the fallback.
*/
export function businessToday(): Date {
const stored = localStorage.getItem('mifosXServerDate');
const match = stored ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(stored) : null;
if (!match) {
return new Date();
}
// Built from parts so it is local midnight — new Date('yyyy-MM-dd') would be UTC midnight and
// land on the previous day in any timezone behind UTC.
return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
}
/** {@link businessToday} as yyyy-MM-dd. */
export function businessTodayIso(): string {
const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
@@ -135,6 +135,18 @@
<mat-icon><fa-icon icon="eye" size="sm"></fa-icon></mat-icon>
<span>{{ 'labels.text.View Transaction' | translate }}</span>
</button>
@if (!transaction.reversed) {
<span>
<button
mat-menu-item
*mifosxHasPermission="'ADJUSTTRANSACTION_CURRENTACCOUNT'"
(click)="undoTransaction(transaction, $event)"
>
<mat-icon><fa-icon icon="undo" size="sm"></fa-icon></mat-icon>
<span>{{ 'tooltips.Undo Transaction' | translate }}</span>
</button>
</span>
}
<button
mat-menu-item
class="account-action-button"
@@ -40,6 +40,18 @@
</button>
</span>
}
@if (allowUndo()) {
<span>
<button
mat-raised-button
color="warn"
*mifosxHasPermission="'UNDOTRANSACTION_CURRENTACCOUNT'"
(click)="undoTransaction()"
>
<fa-icon icon="undo" class="m-r-10"></fa-icon>{{ 'labels.buttons.Undo' | translate }}
</button>
</span>
}
</div>
<div class="container">
@@ -1,381 +0,0 @@
<!--
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 &amp; 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>
}
@@ -1,95 +0,0 @@
/**
* 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;
}
@@ -1,351 +0,0 @@
/**
* 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;
}
@@ -1,468 +0,0 @@
<!--
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>
@@ -1,488 +0,0 @@
/**
* 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
@@ -1,56 +0,0 @@
<!--
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>
@@ -1,42 +0,0 @@
/**
* 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%);
}
}
@@ -1,115 +0,0 @@
/**
* 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)
});
}
}
@@ -1,21 +0,0 @@
<!--
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>
@@ -1,44 +0,0 @@
/**
* 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%);
}
@@ -1,39 +0,0 @@
/**
* 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);
}
@@ -1,557 +0,0 @@
<!--
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="dav-page">
@if (isLoading) {
<div class="dav-card dav-state">
<mat-spinner diameter="32"></mat-spinner>
<span>Loading account…</span>
</div>
} @else if (!account) {
<div class="dav-card dav-state">
<fa-icon icon="inbox" size="2x"></fa-icon>
<span>DDS account not found</span>
<button class="dav-btn" (click)="back()"><fa-icon icon="arrow-left"></fa-icon> DDS Accounts</button>
</div>
} @else {
<!-- Banner, as the savings account page opens -->
<div class="dav-banner">
<div class="dav-banner__icon"><fa-icon icon="coins"></fa-icon></div>
<div class="dav-banner__text">
<span class="dav-pill" [ngClass]="'dav-pill--' + (account.status || '').toLowerCase()"
>● {{ account.status }}</span
>
<h2 class="dav-banner__title">{{ account.subType }}</h2>
<div class="dav-banner__meta">
<span class="dav-banner__label">Account Number</span>
<span class="dav-banner__value dav-mono">{{ account.accountNo }}</span>
<span class="dav-banner__label">Client Name</span>
<span class="dav-banner__value">{{ account.clientName || account.partyName }}</span>
</div>
</div>
<div class="dav-banner__boxes">
<div class="dav-box">
<span class="dav-box__label">Current Balance</span>
<span class="dav-box__value">₹{{ account.currentBalance || 0 | formatNumber }}</span>
</div>
<div class="dav-box">
<span class="dav-box__label">Maturity Amount</span>
<span class="dav-box__value">₹{{ account.maturityAmount || 0 | formatNumber }}</span>
</div>
</div>
<div class="dav-banner__actions">
<button class="dav-icon-btn" (click)="back()" matTooltip="Back to DDS Accounts">
<fa-icon icon="arrow-left"></fa-icon>
</button>
<button class="dav-icon-btn" (click)="openInPartyMaster()" matTooltip="Open in DDS Party Master">
<fa-icon icon="pen"></fa-icon>
</button>
</div>
</div>
<!-- Tabs -->
<div class="dav-tabs">
<button class="dav-tab" [class.dav-tab--on]="tab === 'general'" (click)="tab = 'general'">
<fa-icon icon="info-circle"></fa-icon> General
</button>
<button class="dav-tab" [class.dav-tab--on]="tab === 'transactions'" (click)="tab = 'transactions'">
<fa-icon icon="exchange-alt"></fa-icon> Transactions
</button>
<button class="dav-tab" [class.dav-tab--on]="tab === 'passbook'" (click)="tab = 'passbook'">
<fa-icon icon="book"></fa-icon> Passbook
</button>
<button class="dav-tab" [class.dav-tab--on]="tab === 'nominees'" (click)="tab = 'nominees'">
<fa-icon icon="user"></fa-icon> Nominees
</button>
@if (account.status === 'CLOSED') {
<button class="dav-tab" [class.dav-tab--on]="tab === 'closure'" (click)="tab = 'closure'">
<fa-icon icon="lock"></fa-icon> Closure
</button>
}
</div>
<!-- Closure: how a closed account was closed and where its money went -->
@if (tab === 'closure') {
@if (!closure) {
<div class="dav-card">
<div class="dav-row"><span>Loading closure details…</span></div>
</div>
} @else {
<div class="dav-columns">
<div class="dav-column">
<div class="dav-card">
<div class="dav-card__head"><span class="dav-card__dot"></span> How It Closed</div>
<div class="dav-row">
<span>Type</span>
<strong>
{{ closureLabels[closure.closureType] || closure.closureType }}
@if (closure.withinLockin) {
<em class="dav-closure-tag">within lock-in</em>
}
</strong>
</div>
<div class="dav-row">
<span>Closed On</span><strong>{{ closure.closeDate | date: 'dd MMMM yyyy' }}</strong>
</div>
@if (closure.paidOutBy) {
<div class="dav-row">
<span>Paid Out By</span><strong>{{ closure.paidOutBy }}</strong>
</div>
}
<div class="dav-row">
<span>Closed By</span><strong>{{ closure.closedBy || '—' }}</strong>
</div>
<div class="dav-row">
<span>Approved By</span><strong>{{ closure.approvedBy || '—' }}</strong>
</div>
@if (closure.lockinOverrideReason) {
<div class="dav-row">
<span>Reason</span><strong>{{ closure.lockinOverrideReason }}</strong>
</div>
}
@if (closure.shortfallBorneBy) {
<div class="dav-row">
<span>Cost Borne By</span>
<strong>{{
closure.shortfallBorneBy === 'AGENT' ? 'Agent — ' + closure.agentCode : 'Customer'
}}</strong>
</div>
}
</div>
<div class="dav-card">
<div class="dav-card__head"><span class="dav-card__dot"></span> The Money</div>
<div class="dav-row">
<span>Balance at Close</span><strong>₹{{ closure.balanceAtClose || 0 | formatNumber }}</strong>
</div>
@if (closure.interestRecovered > 0) {
<div class="dav-row">
<span>Interest Recovered</span
><strong class="dav-dr">− ₹{{ closure.interestRecovered | formatNumber }}</strong>
</div>
}
@if (closure.interestToppedUpByAgent > 0) {
<div class="dav-row">
<span>Interest Funded by Agent</span
><strong class="dav-cr">+ ₹{{ closure.interestToppedUpByAgent | formatNumber }}</strong>
</div>
}
<div class="dav-row dav-closure-total">
<span>Paid Out</span><strong>₹{{ closure.paidOut || 0 | formatNumber }}</strong>
</div>
@if (closure.closureType === 'PREMATURE') {
<div class="dav-row">
<span>Agent Penalty ({{ closure.agentCode }})</span
><strong>₹{{ closure.agentCommissionPenalty || 0 | formatNumber }}</strong>
</div>
}
</div>
</div>
<div class="dav-column">
<div class="dav-card">
<div class="dav-card__head"><span class="dav-card__dot"></span> Where It Went</div>
@if (closure.destination === 'OWN_ACCOUNT') {
<div class="dav-row"><span>Paid Into</span><strong>Customer's own account</strong></div>
<div class="dav-row">
<span>Account</span><strong class="dav-mono">{{ closure.creditedAccount?.accountNo }}</strong>
</div>
@if (closure.creditedAccount?.productName) {
<div class="dav-row">
<span>Product</span><strong>{{ closure.creditedAccount.productName }}</strong>
</div>
}
@if (closure.creditedAccount?.owner) {
<div class="dav-row">
<span>Holder</span><strong>{{ closure.creditedAccount.owner }}</strong>
</div>
}
} @else if (closure.destination === 'SUNDRY') {
<div class="dav-row"><span>Parked In</span><strong>Sundry (customer has no account here)</strong></div>
<div class="dav-row">
<span>GL Account</span
><strong class="dav-mono">{{ closure.sundryGl?.glCode }} · {{ closure.sundryGl?.glName }}</strong>
</div>
} @else {
<div class="dav-row"><span>Paid Out</span><strong>Nothing - the balance was nil</strong></div>
}
@if (closure.voucherNo) {
<div class="dav-row">
<span>Voucher</span><strong class="dav-mono">{{ closure.voucherNo }}</strong>
</div>
}
@if (closure.payoutDate) {
<div class="dav-row">
<span>Paid On</span><strong>{{ closure.payoutDate | date: 'dd MMMM yyyy' }}</strong>
</div>
}
@if (closure.passbookNote) {
<div class="dav-row">
<span>Passbook Note</span><strong>{{ closure.passbookNote }}</strong>
</div>
}
</div>
@if (closure.ledgerEntries?.length) {
<div class="dav-card">
<div class="dav-card__head"><span class="dav-card__dot"></span> Ledger Entry</div>
<div class="dav-row">
<span>Transaction ID</span><strong class="dav-mono">{{ closure.ledgerTransactionId }}</strong>
</div>
@for (leg of closure.ledgerEntries; track $index) {
<div class="dav-row">
<span>{{ leg.glCode }} · {{ leg.glName }}</span>
<strong [class.dav-dr]="leg.side === 'DR'" [class.dav-cr]="leg.side === 'CR'">
{{ leg.side }} ₹{{ leg.amount | formatNumber }}
</strong>
</div>
}
</div>
}
</div>
</div>
}
}
<!-- General -->
@if (tab === 'general') {
<div class="dav-columns">
<div class="dav-column">
<div class="dav-card">
<div class="dav-card__head"><span class="dav-card__dot"></span> DDS Details</div>
<div class="dav-row">
<span>Account Number</span><strong class="dav-mono">{{ account.accountNo }}</strong>
</div>
<div class="dav-row">
<span>Party Code</span><strong>{{ account.partyCode }}</strong>
</div>
<div class="dav-row">
<span>Client Name</span><strong>{{ account.clientName }} ({{ account.clientId }})</strong>
</div>
<div class="dav-row">
<span>Agent</span><strong>{{ account.agentCode }} · {{ account.agentName }}</strong>
</div>
<div class="dav-row">
<span>Branch</span><strong>{{ account.officeName }}</strong>
</div>
<div class="dav-row">
<span>Product</span><strong>{{ account.subType }}</strong>
</div>
<div class="dav-row">
<span>Opened On</span><strong>{{ account.openingDate | date: 'dd MMMM yyyy' }}</strong>
</div>
<div class="dav-row">
<span>Matures On</span><strong>{{ account.maturityDate | date: 'dd MMMM yyyy' }}</strong>
</div>
<div class="dav-row">
<span>Daily Deposit</span><strong>₹{{ account.dailyDepositAmount || 0 | formatNumber }}</strong>
</div>
<div class="dav-row">
<span>Term</span><strong>{{ account.month }} months · {{ account.days }} days</strong>
</div>
<div class="dav-row">
<span>Interest Rate</span><strong>{{ account.interestRate }} %</strong>
</div>
<div class="dav-row">
<span>Currency</span><strong>Indian Rupee <em class="dav-mono">[INR]</em></strong>
</div>
</div>
<div class="dav-card">
<div class="dav-card__head"><span class="dav-card__dot"></span> Performance History</div>
<div class="dav-row">
<span>Total Collected</span><strong class="dav-cr">₹{{ totalOf('PARTY_TRAN') | formatNumber }}</strong>
</div>
<div class="dav-row">
<span>Total Interest Earned</span><strong>₹{{ interestEarned | formatNumber }}</strong>
</div>
<div class="dav-row">
<span>Total Paid Out</span><strong class="dav-dr">₹{{ totalDebitsAll | formatNumber }}</strong>
</div>
</div>
</div>
<div class="dav-column">
<div class="dav-card">
<div class="dav-card__head"><span class="dav-card__dot"></span> Account Summary</div>
<div class="dav-row">
<span>Collected to Date</span><strong>₹{{ account.collectedToDate || 0 | formatNumber }}</strong>
</div>
<div class="dav-row">
<span>Expected to Date</span><strong>₹{{ account.expectedToDate || 0 | formatNumber }}</strong>
</div>
<div class="dav-row">
<span>Standing</span>
<strong>
@if (account.advanceDays > 0) {
{{ account.advanceDays }} days ahead
} @else if (account.arrearsDays > 0) {
<span class="dav-dr">{{ account.arrearsDays }} days behind</span>
} @else {
Up to date
}
</strong>
</div>
<div class="dav-row">
<span>Instalments Due</span><strong>{{ account.instalmentsDue ?? 0 }}</strong>
</div>
<div class="dav-row">
<span>Interest Earned</span><strong>₹{{ interestEarned | formatNumber }}</strong>
</div>
<div class="dav-row">
<span>Interest Calculated, Not Posted</span
><strong>₹{{ account.interestCalc || 0 | formatNumber }}</strong>
</div>
<div class="dav-row"><span>Interest Calculated Using</span><strong>Daily Balance</strong></div>
<div class="dav-row"><span>Days in Year</span><strong>365 Days</strong></div>
<div class="dav-row">
<span>Last Interest Date</span
><strong>{{ account.interestDate ? (account.interestDate | date: 'dd MMMM yyyy') : '—' }}</strong>
</div>
<div class="dav-row">
<span>Last Transaction Date</span
><strong>{{ lastTransactionDate ? (lastTransactionDate | date: 'dd MMMM yyyy') : '—' }}</strong>
</div>
<div class="dav-row">
<span>Maturity Amount</span><strong>₹{{ account.maturityAmount || 0 | formatNumber }}</strong>
</div>
<div class="dav-row">
<span>Lien</span>
<strong>{{ account.lien ? 'Yes — ₹' + (account.amountHeldUnderLien || 0) : 'No' }}</strong>
</div>
<div class="dav-row">
<span>Freeze</span><strong>{{ account.freezeCode || 'Normal' }}</strong>
</div>
@if (account.closeDate) {
<div class="dav-row">
<span>Closed On</span><strong>{{ account.closeDate | date: 'dd MMMM yyyy' }}</strong>
</div>
}
</div>
</div>
</div>
}
<!-- Transactions -->
@if (tab === 'transactions') {
<div class="dav-card">
<div class="dav-toolbar">
<div class="dav-datepick">
<span>From</span>
<input type="date" [formControl]="fromDateControl" [max]="today" />
</div>
<div class="dav-datepick">
<span>To</span>
<input type="date" [formControl]="toDateControl" [max]="today" />
</div>
<button class="dav-btn" (click)="showToday()"><fa-icon icon="clock"></fa-icon> Today</button>
@if (hasDateFilters) {
<button class="dav-btn" (click)="clearDateFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
}
<div class="dav-totals">
<span class="dav-cr">Credits ₹{{ totalCredits | formatNumber }}</span>
<span class="dav-dr">Debits ₹{{ totalDebits | formatNumber }}</span>
</div>
<button class="dav-btn dav-btn--primary" (click)="exportCsv()" [disabled]="shownTransactions.length === 0">
Export
</button>
</div>
@if (shownTransactions.length === 0) {
<div class="dav-state">
<fa-icon icon="inbox" size="2x"></fa-icon>
<span>{{
hasDateFilters
? 'Nothing moved on this account in these dates'
: 'Nothing has been credited to this account yet'
}}</span>
</div>
} @else {
<div class="dav-table-wrap">
<table class="dav-table">
<thead>
<tr>
<th>#</th>
<th>ID</th>
<th>Transaction Date</th>
<th>Transaction Type</th>
<th>Voucher</th>
<th>Description</th>
<th class="dav-right">Debit</th>
<th class="dav-right">Credit</th>
<th class="dav-right">Balance</th>
</tr>
</thead>
<tbody>
@for (txn of shownTransactions; track txn.id; let i = $index) {
<tr>
<td>{{ i + 1 }}</td>
<td>{{ txn.id }}</td>
<td>{{ txn.transactionDate | date: 'dd MMMM yyyy' }}</td>
<td>{{ label(txn.transactionType) }}</td>
<td class="dav-mono">{{ txn.voucherNo || '—' }}</td>
<td class="dav-desc">{{ txn.description }}</td>
<td class="dav-right">
{{ txn.direction === 'DEBIT' ? '₹' + (txn.amount | formatNumber) : 'N/A' }}
</td>
<td class="dav-right">
{{ txn.direction === 'CREDIT' ? '₹' + (txn.amount | formatNumber) : 'N/A' }}
</td>
<td class="dav-right">
<strong>₹{{ txn.balanceAfterTransaction || 0 | formatNumber }}</strong>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
}
<!-- Passbook: the same entries oldest first, the way a passbook is written up, and printable -->
@if (tab === 'passbook') {
<div class="dav-card">
<div class="dav-toolbar dav-noprint">
<div class="dav-datepick">
<span>From</span>
<input type="date" [formControl]="fromDateControl" [max]="today" />
</div>
<div class="dav-datepick">
<span>To</span>
<input type="date" [formControl]="toDateControl" [max]="today" />
</div>
@if (hasDateFilters) {
<button class="dav-btn" (click)="clearDateFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
}
<button class="dav-btn dav-btn--primary dav-push" (click)="printPassbook()">
<fa-icon icon="print"></fa-icon> Print
</button>
</div>
<div class="dav-passbook" id="dds-passbook">
<div class="dav-pb-head">
<div>
<div class="dav-pb-title">DDS Passbook</div>
<div class="dav-pb-sub">{{ account.officeName }}</div>
</div>
<div class="dav-pb-grid">
<span>Account No</span><strong class="dav-mono">{{ account.accountNo }}</strong> <span>Party Code</span
><strong>{{ account.partyCode }}</strong> <span>Name</span
><strong>{{ account.clientName || account.partyName }}</strong> <span>Product</span
><strong>{{ account.subType }} · {{ account.interestRate }} %</strong> <span>Opened On</span
><strong>{{ account.openingDate | date: 'dd/MM/yyyy' }}</strong> <span>Agent</span
><strong>{{ account.agentCode }}</strong>
</div>
</div>
@if (passbookEntries.length === 0) {
<div class="dav-state">
<span>{{ hasDateFilters ? 'No entries in these dates' : 'No entries yet' }}</span>
</div>
} @else {
<table class="dav-table dav-pb-table">
<thead>
<tr>
<th>Date</th>
<th>Voucher</th>
<th>Particulars</th>
<th class="dav-right">Withdrawals</th>
<th class="dav-right">Deposits</th>
<th class="dav-right">Balance</th>
</tr>
</thead>
<tbody>
@for (txn of passbookEntries; track txn.id) {
<tr>
<td>{{ txn.transactionDate | date: 'dd/MM/yyyy' }}</td>
<td class="dav-mono">{{ txn.voucherNo || '' }}</td>
<td>
{{ label(txn.transactionType) }}
@if (txn.description) {
<span class="dav-pb-note">{{ txn.description }}</span>
}
</td>
<td class="dav-right">{{ txn.direction === 'DEBIT' ? (txn.amount | formatNumber) : '' }}</td>
<td class="dav-right">{{ txn.direction === 'CREDIT' ? (txn.amount | formatNumber) : '' }}</td>
<td class="dav-right">
<strong>{{ txn.balanceAfterTransaction || 0 | formatNumber }}</strong>
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="3"><strong>Total</strong></td>
<td class="dav-right">
<strong>{{ totalDebits | formatNumber }}</strong>
</td>
<td class="dav-right">
<strong>{{ totalCredits | formatNumber }}</strong>
</td>
<td class="dav-right">
<strong>{{ passbookClosingBalance | formatNumber }}</strong>
</td>
</tr>
</tfoot>
</table>
}
</div>
</div>
}
<!-- Nominees -->
@if (tab === 'nominees') {
<div class="dav-card">
@if (!account.nominees || account.nominees.length === 0) {
<div class="dav-state">
<fa-icon icon="user" size="2x"></fa-icon>
<span>No nominee on this account</span>
</div>
} @else {
<div class="dav-table-wrap">
<table class="dav-table">
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Relation</th>
<th>Gender</th>
<th>Mobile</th>
<th>Birth Date</th>
<th>Nominated On</th>
<th>Guardian</th>
<th class="dav-right">{{ account.nomineeSplitMode === 'Sequence' ? 'Order' : 'Share %' }}</th>
</tr>
</thead>
<tbody>
@for (nominee of account.nominees; track nominee.id; let i = $index) {
<tr>
<td>{{ i + 1 }}</td>
<td>
<strong>{{ nominee.name }}</strong>
</td>
<td>{{ nominee.relation }}</td>
<td>{{ nominee.gender }}</td>
<td>{{ nominee.mobileNo || '—' }}</td>
<td>{{ nominee.birthDate ? (nominee.birthDate | date: 'dd MMM yyyy') : '—' }}</td>
<td>{{ nominee.nominationDate ? (nominee.nominationDate | date: 'dd MMM yyyy') : '—' }}</td>
<td>{{ nominee.guardianName || '—' }}</td>
<td class="dav-right">
{{ account.nomineeSplitMode === 'Sequence' ? nominee.sequenceNo : (nominee.sharePercent ?? '—') }}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
}
}
</div>
@@ -1,459 +0,0 @@
/**
* 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/.
*/
/* One DDS account's page, laid out the way the savings account page is: a banner, tabs, cards. */
.dav-page {
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px 32px;
}
.dav-mono {
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace;
font-style: normal;
}
.dav-cr {
color: #047857;
}
.dav-dr {
color: #b91c1c;
}
.dav-right {
text-align: right !important;
}
/* ── Banner ───────────────────────────────────────────────────────────── */
.dav-banner {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 16px;
padding: 16px 24px;
border-radius: 16px;
color: #fff;
background: linear-gradient(90deg, #1e3a8a 0%, #2d3fb4 55%, #5b47d6 100%);
box-shadow: 0 8px 24px rgb(30 58 138 / 25%);
}
.dav-banner__icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: grid;
place-items: center;
font-size: 1.3rem;
background: rgb(255 255 255 / 16%);
border: 1px solid rgb(255 255 255 / 25%);
}
.dav-banner__text {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.dav-pill {
align-self: flex-start;
padding: 2px 10px;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.04em;
background: rgb(16 185 129 / 25%);
color: #a7f3d0;
border: 1px solid rgb(167 243 208 / 40%);
}
.dav-pill--closed {
background: rgb(148 163 184 / 25%);
color: #e2e8f0;
border-color: rgb(226 232 240 / 40%);
}
.dav-banner__title {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
}
.dav-banner__meta {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 6px 12px;
}
.dav-banner__label {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: rgb(255 255 255 / 60%);
}
.dav-banner__value {
font-size: 0.9rem;
font-weight: 600;
}
.dav-banner__boxes {
margin-left: auto;
display: flex;
gap: 12px;
}
.dav-box {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 18px;
border-radius: 12px;
background: rgb(255 255 255 / 12%);
border: 1px solid rgb(255 255 255 / 25%);
}
.dav-box__label {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: rgb(255 255 255 / 70%);
}
.dav-box__value {
font-size: 1.15rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.dav-banner__actions {
display: flex;
gap: 8px;
}
.dav-icon-btn {
width: 38px;
height: 38px;
border-radius: 50%;
border: 1px solid rgb(255 255 255 / 35%);
background: rgb(255 255 255 / 12%);
color: #fff;
cursor: pointer;
&:hover {
background: rgb(255 255 255 / 22%);
}
}
/* ── Tabs ─────────────────────────────────────────────────────────────── */
.dav-tabs {
display: flex;
gap: 8px;
padding: 8px;
border-radius: 14px;
background: #fff;
border: 1px solid rgb(0 0 0 / 7%);
}
.dav-tab {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 16px;
border: 0;
border-radius: 10px;
background: transparent;
font-size: 0.95rem;
font-weight: 600;
color: #475569;
cursor: pointer;
&:hover {
background: #f1f5f9;
}
}
.dav-tab--on {
color: #fff;
background: linear-gradient(90deg, #1d4ed8, #2563eb);
box-shadow: 0 4px 12px rgb(37 99 235 / 30%);
&:hover {
background: linear-gradient(90deg, #1d4ed8, #2563eb);
}
}
/* ── Cards ────────────────────────────────────────────────────────────── */
.dav-columns {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
gap: 16px;
align-items: start;
}
.dav-column {
display: flex;
flex-direction: column;
gap: 16px;
}
.dav-card {
border-radius: 14px;
background: #fff;
border: 1px solid rgb(0 0 0 / 7%);
overflow: hidden;
}
.dav-card__head {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 20px;
font-size: 1rem;
font-weight: 700;
background: #f8fafc;
border-bottom: 1px solid rgb(0 0 0 / 7%);
}
.dav-card__dot {
width: 30px;
height: 30px;
border-radius: 8px;
background: linear-gradient(135deg, #2563eb, #1e40af);
}
.dav-row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 12px 20px;
border-bottom: 1px dashed rgb(0 0 0 / 7%);
font-size: 0.92rem;
&:last-child {
border-bottom: 0;
}
span {
color: #334155;
}
strong {
text-align: right;
font-weight: 600;
}
}
.dav-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 48px 16px;
color: rgb(0 0 0 / 50%);
}
/* ── Transactions ─────────────────────────────────────────────────────── */
.dav-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
padding: 14px 20px;
background: #f8fafc;
border-bottom: 1px solid rgb(0 0 0 / 7%);
}
.dav-datepick {
display: flex;
flex-direction: column;
gap: 2px;
span {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: rgb(0 0 0 / 45%);
}
input {
padding: 6px 10px;
border-radius: 8px;
border: 1px solid rgb(0 0 0 / 15%);
font: inherit;
}
}
.dav-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: 8px;
border: 1px solid rgb(0 0 0 / 15%);
background: #fff;
font-weight: 600;
cursor: pointer;
&:disabled {
opacity: 0.5;
cursor: default;
}
}
.dav-btn--primary {
color: #fff;
border-color: transparent;
background: linear-gradient(90deg, #1d4ed8, #2563eb);
}
.dav-totals {
margin-left: auto;
display: flex;
gap: 16px;
font-size: 0.88rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.dav-table-wrap {
overflow-x: auto;
}
.dav-table {
width: 100%;
border-collapse: collapse;
font-size: 0.92rem;
th {
padding: 14px 16px;
text-align: left;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #475569;
border-bottom: 1px solid rgb(0 0 0 / 8%);
white-space: nowrap;
}
td {
padding: 16px;
border-bottom: 1px solid rgb(0 0 0 / 5%);
font-variant-numeric: tabular-nums;
}
tbody tr:nth-child(odd) {
background: #f8fafc;
}
}
.dav-desc {
max-width: 320px;
color: rgb(0 0 0 / 65%);
}
@media (width <= 720px) {
.dav-page {
padding: 16px;
}
.dav-banner__boxes {
margin-left: 0;
width: 100%;
}
.dav-tab {
font-size: 0.85rem;
padding: 8px;
}
}
/* ── Passbook ─────────────────────────────────────────────────────────── */
.dav-push {
margin-left: auto;
}
.dav-passbook {
padding: 20px;
}
.dav-pb-head {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px 32px;
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 2px solid #1e3a8a;
}
.dav-pb-title {
font-size: 1.2rem;
font-weight: 700;
color: #1e3a8a;
}
.dav-pb-sub {
font-size: 0.85rem;
color: rgb(0 0 0 / 55%);
}
.dav-pb-grid {
display: grid;
grid-template-columns: auto auto;
gap: 4px 16px;
font-size: 0.88rem;
span {
color: rgb(0 0 0 / 55%);
}
}
.dav-pb-table {
tfoot td {
padding: 14px 16px;
border-top: 2px solid rgb(0 0 0 / 15%);
}
}
.dav-pb-note {
display: block;
font-size: 0.78rem;
color: rgb(0 0 0 / 50%);
}
/* Closure tab */
.dav-closure-tag {
margin-left: 8px;
padding: 1px 8px;
border-radius: 999px;
background: #fff3e0;
color: #e65100;
font-size: 0.75rem;
font-style: normal;
}
.dav-closure-total {
border-top: 1px solid rgb(0 0 0 / 10%);
font-weight: 600;
}
@@ -1,297 +0,0 @@
/**
* 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 { ActivatedRoute, Router } from '@angular/router';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { NgClass } from '@angular/common';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FormatNumberPipe } from 'app/pipes/format-number.pipe';
import { OrganizationService } from 'app/organization/organization.service';
/** What each DDS transaction type is called on screen. */
const TRANSACTION_LABELS: Record<string, string> = {
PARTY_TRAN: 'Collection',
INTEREST_POST: 'Interest',
OD_INTEREST_POST: 'OD Interest',
MATURITY_PAYOUT: 'Maturity Payout',
PREMATURE_PAYOUT: 'Premature Payout',
PREMATURE_INT_RECOVERY: 'Interest Recovered (Premature)',
PREMATURE_AGENT_TOP_UP: 'Interest Funded by Agent',
WITHDRAW_DEPOSITE: 'Transfer (Withdraw Deposite)',
TRANSFER: 'Transfer'
};
/**
* One DDS account and everything that has moved on it, as the savings account page shows a savings
* account: the account's terms and where it stands, then its transactions with the balance after
* each, narrowed to a day or a range.
*
* Read-only. The account is changed from DDS Party Master; its money moves through collections, the
* cashier and the batch runs.
*/
@Component({
selector: 'mifosx-dds-account-view',
templateUrl: './dds-account-view.component.html',
styleUrls: ['./dds-account-view.component.scss'],
standalone: true,
imports: [
...STANDALONE_SHARED_IMPORTS,
ReactiveFormsModule,
NgClass,
FaIconComponent,
MatTooltipModule,
MatProgressSpinnerModule,
FormatNumberPipe
]
})
export class DdsAccountViewComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private organizationService = inject(OrganizationService);
isLoading = true;
account: any = null;
private transactions: any[] = [];
/** The page's tabs, as the savings account page has them - only those that apply to DDS. */
tab: 'general' | 'transactions' | 'passbook' | 'nominees' | 'closure' = 'general';
/** How a closed account was closed - read only for closed accounts, for the Closure tab. */
closure: any = null;
/** What each kind of closure is called on screen. */
readonly closureLabels: Record<string, string> = {
PREMATURE: 'Premature Close',
MATURITY_PAYOUT_AND_CLOSE: 'Maturity Payout & Close',
MATURITY_PAYOUT_THEN_CLOSE: 'Maturity Payout, then Closed',
MATURITY_AUTO_CLOSE: 'Maturity Auto Close (night job)',
CLOSED_NIL_BALANCE: 'Closed with nil balance'
};
/** The passbook reads oldest first, as it is written up. */
get passbookEntries(): any[] {
return [...this.shownTransactions].reverse();
}
/** The balance after the last entry shown. */
get passbookClosingBalance(): number {
const entries = this.passbookEntries;
return entries.length > 0 ? Number(entries[entries.length - 1].balanceAfterTransaction || 0) : 0;
}
/**
* Prints the passbook alone. It is copied into a window of its own with only its own styles, so the
* page's menus, banner and tabs do not come out on the paper.
*/
printPassbook(): void {
const passbook = document.getElementById('dds-passbook');
const popup = window.open('', '_blank', 'width=900,height=700');
if (!passbook || !popup) {
return;
}
popup.document.write(`<html><head><title>${this.account?.accountNo || 'DDS'} Passbook</title><style>
body { font-family: Arial, sans-serif; margin: 24px; color: #111; }
.dav-pb-head { display: flex; justify-content: space-between; gap: 24px; margin-bottom: 16px; }
.dav-pb-title { font-size: 20px; font-weight: 700; }
.dav-pb-sub { color: #555; }
.dav-pb-grid { display: grid; grid-template-columns: auto auto; gap: 2px 12px; font-size: 13px; }
.dav-pb-grid span { color: #555; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { border: 1px solid #999; padding: 6px 8px; text-align: left; }
.dav-right { text-align: right; }
.dav-pb-note { display: block; font-size: 11px; color: #555; }
</style></head><body>${passbook.innerHTML}</body></html>`);
popup.document.close();
popup.focus();
popup.print();
}
fromDateControl = new FormControl('');
toDateControl = new FormControl('');
readonly today = new Date().toISOString().split('T')[0];
ngOnInit(): void {
const partyId = this.route.snapshot.paramMap.get('partyId');
if (!partyId) {
this.isLoading = false;
return;
}
forkJoin({
account: this.organizationService.getParty(partyId).pipe(catchError(() => of(null))),
transactions: this.organizationService.getPartyTransactionsOfParty(partyId).pipe(catchError(() => of([])))
}).subscribe(({ account, transactions }) => {
this.account = account;
if (account?.status === 'CLOSED') {
this.organizationService.getPartyClosure(partyId).subscribe({
next: (closure: any) => (this.closure = closure),
error: () => (this.closure = null)
});
}
// Newest first, and within a day the later entry first - the order a passbook is read back in.
this.transactions = (Array.isArray(transactions) ? transactions : []).sort(
(a: any, b: any) =>
String(b.transactionDate).localeCompare(String(a.transactionDate)) || Number(b.id) - Number(a.id)
);
this.isLoading = false;
});
}
/** The transactions in the chosen range - all of them when no range is set. */
get shownTransactions(): any[] {
const from = this.fromDateControl.value;
const to = this.toDateControl.value;
return this.transactions.filter((txn: any) => {
const day = String(txn.transactionDate || '');
if (from && day < String(from)) {
return false;
}
if (to && day > String(to)) {
return false;
}
return true;
});
}
get totalCredits(): number {
return this.sumOf('CREDIT');
}
get totalDebits(): number {
return this.sumOf('DEBIT');
}
private sumOf(direction: string): number {
return this.shownTransactions
.filter((txn: any) => txn.direction === direction)
.reduce((sum: number, txn: any) => sum + Number(txn.amount || 0), 0);
}
/** Everything of one type ever credited, whatever the date filter says - for the summary cards. */
totalOf(type: string): number {
return this.transactions
.filter((txn: any) => txn.transactionType === type && txn.direction === 'CREDIT')
.reduce((sum: number, txn: any) => sum + Number(txn.amount || 0), 0);
}
/** Interest credited, less any taken back on an early closure. */
get interestEarned(): number {
const recovered = this.transactions
.filter((txn: any) => txn.transactionType === 'PREMATURE_INT_RECOVERY')
.reduce((sum: number, txn: any) => sum + Number(txn.amount || 0), 0);
return (
this.totalOf('INTEREST_POST') +
this.totalOf('OD_INTEREST_POST') +
this.totalOf('PREMATURE_AGENT_TOP_UP') -
recovered
);
}
/** Money that has left the account - payouts, withdrawals, transfers out. */
get totalDebitsAll(): number {
return this.transactions
.filter((txn: any) => txn.direction === 'DEBIT' && txn.transactionType !== 'PREMATURE_INT_RECOVERY')
.reduce((sum: number, txn: any) => sum + Number(txn.amount || 0), 0);
}
get lastTransactionDate(): string | null {
return this.transactions.length > 0 ? this.transactions[0].transactionDate : null;
}
/** The rows on screen, as a spreadsheet the branch can keep or send on. */
exportCsv(): void {
const header = [
'#',
'ID',
'Transaction Date',
'Transaction Type',
'Voucher',
'Description',
'Account',
'Debit',
'Credit',
'Balance'
];
const quote = (value: any) => `"${String(value ?? '').replace(/"/g, '""')}"`;
const lines = this.shownTransactions.map((txn: any, index: number) =>
[
index + 1,
txn.id,
txn.transactionDate,
this.label(txn.transactionType),
txn.voucherNo,
txn.description,
txn.accountNo,
txn.direction === 'DEBIT' ? txn.amount : '',
txn.direction === 'CREDIT' ? txn.amount : '',
txn.balanceAfterTransaction
]
.map(quote)
.join(',')
);
const blob = new Blob(
[[
header.map(quote).join(','),
...lines
].join('\r\n')],
{ type: 'text/csv;charset=utf-8' }
);
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `${this.account?.accountNo || 'dds-account'}-transactions.csv`;
link.click();
URL.revokeObjectURL(link.href);
}
get hasDateFilters(): boolean {
return !!(this.fromDateControl.value || this.toDateControl.value);
}
showToday(): void {
this.fromDateControl.setValue(this.today);
this.toDateControl.setValue(this.today);
}
clearDateFilters(): void {
this.fromDateControl.setValue('');
this.toDateControl.setValue('');
}
label(type: string): string {
return TRANSACTION_LABELS[type] || String(type || '').replace(/_/g, ' ');
}
statusClass(): string {
switch (this.account?.status) {
case 'ACTIVE':
return 'status--active';
case 'CLOSED':
return 'status--closed';
default:
return 'status--default';
}
}
back(): void {
this.router.navigate(['/accounts/dds-accounts']);
}
openInPartyMaster(): void {
this.router.navigate([
'/accounts/dds',
this.account.id,
'edit'
]);
}
}
@@ -1,211 +0,0 @@
<!--
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="acl-page">
<!-- Header -->
<div class="acl-header accent--rd">
<div class="acl-header__icon">
<fa-icon icon="coins"></fa-icon>
</div>
<div class="acl-header__text">
<h2 class="acl-header__title">DDS Accounts</h2>
<span class="acl-header__count">
@if (isLoading) {
Loading...
} @else {
{{ allCount }} accounts · {{ activeCount }} active · {{ closedCount }} closed
}
</span>
</div>
</div>
<!-- Search Bar -->
<div class="acl-search-wrap">
<div class="acl-search">
<fa-icon icon="search" class="acl-search__icon"></fa-icon>
<input
[formControl]="searchControl"
class="acl-search__input"
[placeholder]="
searchType === 'accountNo' ? 'Search by account number or party code…' : 'Search by client name…'
"
/>
@if (searchControl.value) {
<button class="acl-search__clear" (click)="clearSearch()">
<fa-icon icon="times"></fa-icon>
</button>
}
</div>
<button
class="acl-btn acl-btn--toggle"
(click)="toggleSearchType()"
[matTooltip]="'Switch to ' + (searchType === 'accountNo' ? 'client name' : 'account number') + ' search'"
>
<fa-icon icon="exchange-alt"></fa-icon>
{{ searchType === 'accountNo' ? 'Acct No' : 'Client' }}
</button>
<button class="acl-btn acl-btn--icon" (click)="loadAccounts()" matTooltip="Refresh">
<fa-icon icon="sync-alt"></fa-icon>
</button>
</div>
<!-- Status: all accounts, only the open ones, or only the closed -->
<div class="dds-status-filter" role="tablist">
<button
type="button"
class="dds-status-filter__btn"
[class.is-on]="statusFilter === 'ALL'"
(click)="setStatusFilter('ALL')"
>
All <span class="dds-status-filter__count">{{ allCount }}</span>
</button>
<button
type="button"
class="dds-status-filter__btn dds-status-filter__btn--active"
[class.is-on]="statusFilter === 'ACTIVE'"
(click)="setStatusFilter('ACTIVE')"
>
Active <span class="dds-status-filter__count">{{ activeCount }}</span>
</button>
<button
type="button"
class="dds-status-filter__btn dds-status-filter__btn--closed"
[class.is-on]="statusFilter === 'CLOSED'"
(click)="setStatusFilter('CLOSED')"
>
Closed <span class="dds-status-filter__count">{{ closedCount }}</span>
</button>
</div>
<!-- Date Filters: by the day the account was opened -->
<div class="acl-filters">
<div class="acl-filters__group">
<div class="acl-datepick">
<span class="acl-datepick__label">Opened From</span>
<input type="date" class="acl-datepick__input" [formControl]="fromDateControl" [max]="today" />
</div>
<div class="acl-filters__arrow">
<fa-icon icon="arrow-right"></fa-icon>
</div>
<div class="acl-datepick">
<span class="acl-datepick__label">To</span>
<input type="date" class="acl-datepick__input" [formControl]="toDateControl" [max]="today" />
</div>
</div>
<div class="acl-filters__sep"></div>
<div class="acl-datepick acl-datepick--ason">
<span class="acl-datepick__label">
<fa-icon icon="clock"></fa-icon>
As On Date
</span>
<input type="date" class="acl-datepick__input" [formControl]="asOnDateControl" [max]="today" />
</div>
@if (hasDateFilters) {
<button class="acl-filters__clear" (click)="clearDateFilters()">
<fa-icon icon="times"></fa-icon>
Clear
</button>
}
</div>
<!-- Table -->
<div class="acl-card">
@if (isLoading) {
<div class="acl-state acl-state--loading">
<mat-spinner diameter="32"></mat-spinner>
<span>Loading accounts…</span>
</div>
}
@if (!isLoading && rows.length === 0) {
<div class="acl-state acl-state--empty">
<div class="acl-state__icon">
<fa-icon icon="inbox"></fa-icon>
</div>
<p class="acl-state__title">No DDS accounts found</p>
<p class="acl-state__sub">Try adjusting your search</p>
</div>
}
@if (!isLoading && rows.length > 0) {
<div class="acl-table-wrap">
<table mat-table [dataSource]="rows" class="acl-table">
<ng-container matColumnDef="accountNo">
<th mat-header-cell *matHeaderCellDef class="acl-th">Account No</th>
<td mat-cell *matCellDef="let row" class="acl-td">
<span class="acl-acct-no">{{ row.accountNo }}</span>
</td>
</ng-container>
<ng-container matColumnDef="depositType">
<th mat-header-cell *matHeaderCellDef class="acl-th acl-th--center">Type</th>
<td mat-cell *matCellDef="let row" class="acl-td acl-td--center">
<span class="acl-type-badge dtype--rd">DDS</span>
</td>
</ng-container>
<ng-container matColumnDef="clientName">
<th mat-header-cell *matHeaderCellDef class="acl-th">Client</th>
<td mat-cell *matCellDef="let row" class="acl-td">
<div class="acl-client">
<div class="acl-client__avatar">{{ (row.clientName || row.partyName || '?')[0] }}</div>
<span class="acl-client__name">{{ row.clientName || row.partyName }}</span>
</div>
</td>
</ng-container>
<ng-container matColumnDef="productName">
<th mat-header-cell *matHeaderCellDef class="acl-th">Product</th>
<td mat-cell *matCellDef="let row" class="acl-td">
<span class="acl-product">{{ row.subType }}</span>
</td>
</ng-container>
<ng-container matColumnDef="balance">
<th mat-header-cell *matHeaderCellDef class="acl-th acl-th--right">Balance</th>
<td mat-cell *matCellDef="let row" class="acl-td acl-td--right">
<span class="acl-balance">₹ {{ row.currentBalance || 0 | formatNumber }}</span>
</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef class="acl-th acl-th--center">Status</th>
<td mat-cell *matCellDef="let row" class="acl-td acl-td--center">
<span class="acl-status" [ngClass]="getStatusClass(row)">{{ row.status }}</span>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef class="acl-th acl-th--center">Actions</th>
<td mat-cell *matCellDef="let row" class="acl-td acl-td--center">
<button class="acl-view-btn" (click)="$event.stopPropagation(); viewAccount(row)" matTooltip="View">
<fa-icon icon="eye"></fa-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns" class="acl-header-row"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="acl-row" (click)="viewAccount(row)"></tr>
</table>
</div>
<mat-paginator
[length]="totalRecords"
[pageSize]="pageSize"
[pageIndex]="pageIndex"
[pageSizeOptions]="[10, 20, 50, 100]"
(page)="onPageChange($event)"
class="acl-paginator"
showFirstLastButtons
></mat-paginator>
}
</div>
</div>
@@ -1,56 +0,0 @@
/**
* 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/.
*/
/* All / Active / Closed - which DDS accounts the list shows. */
.dds-status-filter {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 0 0 12px;
&__btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
border: 1px solid rgb(0 0 0 / 12%);
border-radius: 999px;
background: #fff;
color: rgb(0 0 0 / 70%);
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
&.is-on {
border-color: #1565c0;
background: #e3f2fd;
color: #0d47a1;
}
&--active.is-on {
border-color: #2e7d32;
background: #e8f5e9;
color: #1b5e20;
}
&--closed.is-on {
border-color: #c62828;
background: #ffebee;
color: #b71c1c;
}
}
&__count {
min-width: 22px;
padding: 0 6px;
border-radius: 999px;
background: rgb(0 0 0 / 8%);
font-size: 0.75rem;
text-align: center;
}
}
@@ -1,265 +0,0 @@
/**
* 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, OnDestroy, OnInit, inject } from '@angular/core';
import { Router } from '@angular/router';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { NgClass } from '@angular/common';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
import {
MatCell,
MatCellDef,
MatColumnDef,
MatHeaderCell,
MatHeaderCellDef,
MatHeaderRow,
MatHeaderRowDef,
MatRow,
MatRowDef,
MatTable
} from '@angular/material/table';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { Subject, merge } from 'rxjs';
import { debounceTime, distinctUntilChanged, takeUntil } from 'rxjs/operators';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FormatNumberPipe } from 'app/pipes/format-number.pipe';
import { OrganizationService } from 'app/organization/organization.service';
/**
* The DDS accounts, listed the way Savings Accounts lists savings: account number, client, product,
* balance and status, searched by account number or client and narrowed by opening date. Each opens
* onto the account's own page and its transactions.
*
* Read-only. Accounts are opened and changed from DDS Party Master; money moves through collections,
* the cashier and the batch runs.
*/
@Component({
selector: 'mifosx-dds-accounts',
templateUrl: './dds-accounts.component.html',
// The Savings Accounts list's own look, so the two screens read as one family.
styleUrls: [
'../../accounts/accounts-list/accounts-list.component.scss',
'./dds-accounts.component.scss'
],
standalone: true,
imports: [
...STANDALONE_SHARED_IMPORTS,
ReactiveFormsModule,
NgClass,
FaIconComponent,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
MatPaginator,
MatTooltipModule,
MatProgressSpinnerModule,
FormatNumberPipe
]
})
export class DdsAccountsComponent implements OnInit, OnDestroy {
private organizationService = inject(OrganizationService);
private router = inject(Router);
private destroy$ = new Subject<void>();
displayedColumns = [
'accountNo',
'depositType',
'clientName',
'productName',
'balance',
'status',
'actions'
];
/** Every DDS account, fetched once; search, dates and paging work on this. */
private allAccounts: any[] = [];
/** The accounts matching the search and dates, whatever their status - what the status counts read. */
private beforeStatus: any[] = [];
/** The accounts matching the search, dates and status, before paging. */
private matching: any[] = [];
/** Which accounts are shown: all of them, only the open ones, or only the closed. */
statusFilter: 'ALL' | 'ACTIVE' | 'CLOSED' = 'ALL';
/** The page on screen. */
rows: any[] = [];
pageSize = 20;
pageIndex = 0;
isLoading = false;
searchControl = new FormControl('');
searchType: 'accountNo' | 'clientName' = 'accountNo';
fromDateControl = new FormControl('');
toDateControl = new FormControl('');
asOnDateControl = new FormControl('');
readonly today = new Date().toISOString().split('T')[0];
get totalRecords(): number {
return this.matching.length;
}
get activeCount(): number {
return this.beforeStatus.filter((account) => account.status === 'ACTIVE').length;
}
get closedCount(): number {
return this.beforeStatus.filter((account) => account.status === 'CLOSED').length;
}
get allCount(): number {
return this.beforeStatus.length;
}
setStatusFilter(status: 'ALL' | 'ACTIVE' | 'CLOSED'): void {
this.statusFilter = status;
this.pageIndex = 0;
this.applyFilters();
}
get hasDateFilters(): boolean {
return !!((this.fromDateControl.value && this.toDateControl.value) || this.asOnDateControl.value);
}
ngOnInit(): void {
this.loadAccounts();
this.searchControl.valueChanges
.pipe(debounceTime(300), distinctUntilChanged(), takeUntil(this.destroy$))
.subscribe(() => {
this.pageIndex = 0;
this.applyFilters();
});
merge(this.fromDateControl.valueChanges, this.toDateControl.valueChanges, this.asOnDateControl.valueChanges)
.pipe(debounceTime(300), takeUntil(this.destroy$))
.subscribe(() => {
this.pageIndex = 0;
this.applyFilters();
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
loadAccounts(): void {
this.isLoading = true;
this.organizationService
.getParties()
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (parties: any) => {
// Newest first, as the savings list shows them.
this.allAccounts = (Array.isArray(parties) ? parties : parties?.pageItems || []).sort(
(a: any, b: any) => Number(b.id) - Number(a.id)
);
this.isLoading = false;
this.applyFilters();
},
error: () => {
this.allAccounts = [];
this.isLoading = false;
this.applyFilters();
}
});
}
/** Search by account number or client, and by the day the account was opened. */
private applyFilters(): void {
const term = String(this.searchControl.value || '')
.trim()
.toLowerCase();
const fromVal = this.fromDateControl.value;
const toVal = this.toDateControl.value;
const bothDates = !!(fromVal && toVal);
const asOn = this.asOnDateControl.value;
this.beforeStatus = this.allAccounts.filter((account: any) => {
if (term) {
const haystack =
this.searchType === 'accountNo'
? `${account.accountNo || ''} ${account.partyCode || ''}`
: `${account.clientName || ''} ${account.partyName || ''}`;
if (!haystack.toLowerCase().includes(term)) {
return false;
}
}
const opened = String(account.openingDate || '');
if (bothDates && opened && (opened < String(fromVal) || opened > String(toVal))) {
return false;
}
// As on a date: the accounts that had been opened by then.
if (asOn && opened && opened > String(asOn)) {
return false;
}
return true;
});
this.matching =
this.statusFilter === 'ALL'
? this.beforeStatus
: this.beforeStatus.filter((account: any) => account.status === this.statusFilter);
this.showPage();
}
private showPage(): void {
const start = this.pageIndex * this.pageSize;
this.rows = this.matching.slice(start, start + this.pageSize);
}
onPageChange(event: PageEvent): void {
this.pageIndex = event.pageIndex;
this.pageSize = event.pageSize;
this.showPage();
}
toggleSearchType(): void {
this.searchType = this.searchType === 'accountNo' ? 'clientName' : 'accountNo';
this.pageIndex = 0;
this.applyFilters();
}
clearSearch(): void {
this.searchControl.setValue('');
}
clearDateFilters(): void {
this.fromDateControl.setValue('', { emitEvent: false });
this.toDateControl.setValue('', { emitEvent: false });
this.asOnDateControl.setValue('', { emitEvent: false });
this.pageIndex = 0;
this.applyFilters();
}
viewAccount(account: any): void {
this.router.navigate([
'/accounts/dds-accounts',
account.id
]);
}
getStatusClass(account: any): string {
switch (account?.status) {
case 'ACTIVE':
return 'status--active';
case 'CLOSED':
return 'status--closed';
default:
return 'status--default';
}
}
}
@@ -1,46 +0,0 @@
/**
* 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 { OrganizationService } from 'app/organization/organization.service';
import { AlertService } from 'app/core/alert/alert.service';
/**
* Whether the server held a DDS entry for a checker rather than carrying it out. The reply then
* names the entry (commandId) and asks for the work to be rolled back, and carries no result of its own.
*/
export function heldForApproval(response: any): boolean {
return !!response?.rollbackTransaction || (!!response?.commandId && response?.resourceId == null);
}
/**
* Tells the clerk their entry went to a checker - with the voucher it will move under - instead of
* saying it was done. Nothing has moved yet, and a message that read as though it had would send them
* looking for money that is still waiting on a manager.
*/
export function announceHeldForApproval(
response: any,
what: string,
organizationService: OrganizationService,
alertService: AlertService
): void {
const say = (voucherNo?: string) =>
alertService.alert({
type: 'Sent for Approval',
message:
`${what} sent for approval${voucherNo ? ' under voucher ' + voucherNo : ''}. ` +
'It takes effect once a checker approves it in Approval Center › DDS Approvals.'
});
if (!response?.commandId) {
say();
return;
}
organizationService.getDdsApproval(response.commandId).subscribe({
next: (entry: any) => say(entry?.voucherNo),
error: () => say()
});
}
@@ -1,265 +0,0 @@
<!--
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/.
-->
<!-- Laid out as Disbursement & Transfers reviews its requests, and styled by the same sheet. -->
<div class="dri dri--dialog">
<nav class="cashier-nav cashier-nav--clerk">
<span class="dri-header-title">DDS {{ entry.type }} Review</span>
<span class="cashier-nav__esc-hint" (click)="close()">✕ Close</span>
</nav>
@if (loading) {
<div class="dri-loading">
<mat-icon class="dri-loading__icon">hourglass_empty</mat-icon>
<span>Loading entry…</span>
</div>
} @else {
<div class="dri-body">
<!-- Header card: the voucher it moves under, its status, who made it and when -->
<div class="dri-card dri-card--header">
<div class="dri-card-header">
<div class="dri-card-header__left">
<span class="dri-voucher">{{ voucherNo || '—' }}</span>
<span class="dri-status" [ngClass]="statusClass(entry.status)">
{{ entry.status }}{{ entry.voucherCancelled ? ' · VOUCHER CANCELLED' : '' }}
</span>
</div>
<div class="dri-card-header__meta">
<span class="dri-meta-item">
<mat-icon>person</mat-icon>
{{ entry.makerName || entry.maker }} ({{ entry.maker }})
</span>
<span class="dri-meta-item">
<mat-icon>schedule</mat-icon>
{{ formatDateTime(entry.madeOn) }}
</span>
@if (entry.status !== 'PENDING' && entry.checker) {
<span class="dri-meta-item">
<mat-icon>check_circle</mat-icon>
Actioned by: {{ entry.checkerName || entry.checker }}
</span>
}
</div>
</div>
</div>
<!-- Summary -->
<div class="dri-card">
<div class="dri-section-title">
<mat-icon>swap_horiz</mat-icon>
{{ entry.type }} Summary
</div>
<div class="dri-detail-grid">
<div class="dri-detail-item">
<span class="dri-detail-label">Total Amount</span>
<span class="dri-detail-value dri-amount">
{{ entry.amount !== null && entry.amount !== undefined ? '₹' + (entry.amount | number: '1.2-2') : '—' }}
</span>
</div>
<div class="dri-detail-item">
<span class="dri-detail-label">DR Accounts</span>
<span class="dri-detail-value"
>{{ debitLines.length }} account{{ debitLines.length !== 1 ? 's' : '' }}</span
>
</div>
<div class="dri-detail-item">
<span class="dri-detail-label">CR Accounts</span>
<span class="dri-detail-value"
>{{ creditLines.length }} account{{ creditLines.length !== 1 ? 's' : '' }}</span
>
</div>
<div class="dri-detail-item dri-detail-item--full">
<span class="dri-detail-label">Details</span>
<span class="dri-detail-value">{{ entry.details || '—' }}</span>
</div>
@if (detail?.description) {
<div class="dri-detail-item dri-detail-item--full">
<span class="dri-detail-label">Description</span>
<span class="dri-detail-value">{{ detail.description }}</span>
</div>
}
</div>
</div>
<!-- Debit entries -->
@if (debitLines.length) {
<div class="dri-card">
<div class="dri-section-title">
<mat-icon>arrow_upward</mat-icon>
Debit Entries ({{ debitLines.length }} account{{ debitLines.length !== 1 ? 's' : '' }} — funds out)
</div>
<table class="dri-cr-table">
<thead>
<tr>
<th>#</th>
<th>Account No</th>
<th>Account Name</th>
<th>Type</th>
<th class="num">Amount (DR)</th>
</tr>
</thead>
<tbody>
@for (line of debitLines; track $index; let i = $index) {
<tr>
<td class="dri-cr-idx">{{ i + 1 }}</td>
<td class="cell-mono">{{ line.accountNo || '—' }}</td>
<td>{{ line.accountName || '—' }}</td>
<td>
<span class="dri-type-badge">{{ line.type || '—' }}</span>
</td>
<td class="num dri-cr-amount">₹{{ line.amount | number: '1.2-2' }}</td>
</tr>
}
<tr class="dri-cr-total-row">
<td colspan="4" class="dri-cr-total-label">Total Debit</td>
<td class="num dri-cr-total-amount">₹{{ total(debitLines) | number: '1.2-2' }}</td>
</tr>
</tbody>
</table>
</div>
}
<!-- Credit entries -->
@if (creditLines.length) {
<div class="dri-card">
<div class="dri-section-title">
<mat-icon>arrow_downward</mat-icon>
Credit Entries ({{ creditLines.length }} account{{ creditLines.length !== 1 ? 's' : '' }} — funds in)
</div>
<table class="dri-cr-table">
<thead>
<tr>
<th>#</th>
<th>Account No</th>
<th>Account Name</th>
<th>Type</th>
<th class="num">Amount (CR)</th>
</tr>
</thead>
<tbody>
@for (line of creditLines; track $index; let i = $index) {
<tr>
<td class="dri-cr-idx">{{ i + 1 }}</td>
<td class="cell-mono">{{ line.accountNo || '—' }}</td>
<td>{{ line.accountName || '—' }}</td>
<td>
<span class="dri-type-badge">{{ line.type || '—' }}</span>
</td>
<td class="num dri-cr-amount">₹{{ line.amount | number: '1.2-2' }}</td>
</tr>
}
<tr class="dri-cr-total-row">
<td colspan="4" class="dri-cr-total-label">Total Credit</td>
<td class="num dri-cr-total-amount">₹{{ total(creditLines) | number: '1.2-2' }}</td>
</tr>
</tbody>
</table>
</div>
}
@if (entry.status === 'PENDING' && isMine && !done) {
<div class="dri-card dri-card--processing">
<div class="dri-section-title">
<mat-icon>info</mat-icon>
Your own entry
</div>
<p class="dri-processing-text">
You made this entry, so another user with checker rights has to approve or reject it.
</p>
</div>
}
@if (entry.status === 'REJECTED') {
<div class="dri-card dri-card--rejected">
<div class="dri-section-title">
<mat-icon>cancel</mat-icon>
Rejected
</div>
<p class="dri-rejection-text">
Nothing was done{{ voucherNo ? '. Voucher ' + voucherNo + ' is cancelled and will not be used' : '' }}.
</p>
@if (entry.checker) {
<div class="dri-meta-item" style="margin-top: 8px">
<mat-icon>manage_accounts</mat-icon>
{{ entry.checker }}
</div>
}
</div>
}
@if (entry.status === 'APPROVED') {
<div class="dri-card dri-card--approved">
<div class="dri-section-title">
<mat-icon>check_circle</mat-icon>
Approved and carried out
</div>
<p class="dri-txn-refs cell-mono">
{{
voucherNo
? 'Voucher ' + voucherNo
: entry.entity === 'DDSBATCH'
? 'Posting is running - refresh for its voucher'
: '—'
}}
</p>
<div class="dri-meta-item" style="margin-top: 8px">
<mat-icon>manage_accounts</mat-icon>
Approved by: {{ entry.checker || '—' }}
</div>
</div>
}
@if (error) {
<div class="dri-card dri-card--failed">
<div class="dri-section-title">
<mat-icon>error</mat-icon>
Could not complete
</div>
<p class="dri-rejection-text">{{ error }}</p>
</div>
}
@if (entry.status === 'PENDING' && !done) {
<div class="dri-actions">
@if (!confirmReject) {
<button type="button" class="dri-btn dri-btn--approve" [disabled]="!canAct" (click)="act('approve')">
<mat-icon>check_circle</mat-icon>
{{ working ? 'Processing…' : 'Approve ' + entry.type }}
</button>
<button type="button" class="dri-btn dri-btn--reject" [disabled]="!canAct" (click)="confirmReject = true">
<mat-icon>cancel</mat-icon>
Reject
</button>
} @else {
<div class="dri-reject-form">
<label class="dri-reject-label">
Reject this entry? Nothing will be done{{
voucherNo ? ' and voucher ' + voucherNo + ' will be cancelled' : ''
}}.
</label>
<div class="dri-reject-actions">
<button type="button" class="dri-btn dri-btn--reject" [disabled]="!canAct" (click)="act('reject')">
<mat-icon>cancel</mat-icon>
{{ working ? 'Processing…' : 'Confirm Rejection' }}
</button>
<button
type="button"
class="dri-btn dri-btn--ghost"
[disabled]="working"
(click)="confirmReject = false"
>
Cancel
</button>
</div>
</div>
}
</div>
}
</div>
}
</div>
@@ -1,148 +0,0 @@
/**
* 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 { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { OrganizationService } from 'app/organization/organization.service';
/**
* One DDS entry, laid out the way Disbursement & Transfers reviews a request - voucher and status on
* top, who made it and when, then the debit and credit entries it moves - and styled by the same
* sheet, so a checker reads both queues the same way.
*
* The checker approves it, which carries it out under the voucher numbered when it was sent, or
* rejects it, which leaves nothing done and cancels that number. Neither is offered to the user who
* made the entry, and the server refuses it as well.
*/
@Component({
selector: 'mifosx-dds-approval-review',
templateUrl: './dds-approval-review.component.html',
styleUrls: ['../../../clerk/disbursement-review/disbursement-review.component.scss'],
standalone: true,
imports: [
CommonModule,
MatDialogModule,
MatIconModule
]
})
export class DdsApprovalReviewComponent implements OnInit {
private organizationService = inject(OrganizationService);
private dialogRef = inject(MatDialogRef<DdsApprovalReviewComponent>);
data = inject<any>(MAT_DIALOG_DATA);
entry: any = this.data.entry;
/** The entry in full, with the debit and credit sides it moves. */
detail: any = null;
loading = true;
working = false;
confirmReject = false;
done = false;
error = '';
/** Set by an approval, in case the list row did not carry the number yet. */
approvedVoucher = '';
ngOnInit(): void {
this.load();
}
private load(): void {
this.organizationService.getDdsApproval(this.entry.id).subscribe({
next: (detail: any) => {
this.detail = detail;
this.entry = { ...this.entry, ...detail };
this.loading = false;
},
error: () => (this.loading = false)
});
}
get voucherNo(): string {
return this.approvedVoucher || this.entry?.voucherNo || '';
}
get debitLines(): any[] {
return (this.detail?.lines || []).filter((line: any) => line.side === 'DR');
}
get creditLines(): any[] {
return (this.detail?.lines || []).filter((line: any) => line.side === 'CR');
}
total(lines: any[]): number {
return lines.reduce((sum: number, line: any) => sum + Number(line.amount || 0), 0);
}
get isMine(): boolean {
return !!this.data.currentUserId && Number(this.entry.makerId) === Number(this.data.currentUserId);
}
get canAct(): boolean {
return this.entry.status === 'PENDING' && !this.isMine && !this.done && !this.working;
}
statusClass(status: string): string {
const map: Record<string, string> = {
PENDING: 'dri-status--pending',
APPROVED: 'dri-status--approved',
REJECTED: 'dri-status--rejected'
};
return map[status] ?? '';
}
act(command: 'approve' | 'reject'): void {
this.working = true;
this.error = '';
this.organizationService.actOnDdsApproval(this.entry.id, command).subscribe({
next: (result: any) => {
this.working = false;
this.done = true;
this.approvedVoucher = result?.voucherNo || '';
this.entry = {
...this.entry,
status: command === 'approve' ? 'APPROVED' : 'REJECTED',
voucherCancelled: command === 'reject' && !!this.voucherNo
};
// The approved entry's sides are now the voucher's own lines.
this.load();
},
error: (err: any) => {
this.working = false;
const body = err?.error;
this.error =
body?.errors?.[0]?.defaultUserMessage ||
body?.defaultUserMessage ||
body?.developerMessage ||
'Could not complete this.';
}
});
}
/** The same form Disbursement & Transfers prints its times in: 29 Sept 2026, 01:25 pm. */
formatDateTime(val: any): string {
if (!val) {
return '—';
}
const d = new Date(String(val).replace(' ', 'T'));
return isNaN(d.getTime())
? String(val)
: d.toLocaleString('en-IN', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
close(): void {
this.dialogRef.close(this.done);
}
}

Some files were not shown because too many files have changed in this diff Show More