From 211027b9d8e8c236acc590304b356fe6de3026e4 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Tue, 13 Oct 2026 10:57:06 +0530 Subject: [PATCH 1/6] fix(bank-calendar): follow Admin > Working Days for weekly offs The Bank Calendar never read the Working Days setting. It hard-coded Mon-Sat as working days and always marked only Sundays and the 2nd and 4th Saturday as off, so unticking Saturday (or any weekday) in Admin > Working Days changed nothing on the calendar. - Load the setting from /workingdays and parse its recurrence (BYDAY=MO,TU,...). Until it answers, or if it cannot be read, the old Mon-Sat default stays in place. - A weekday left out of Working Days is off every week. When Saturday is left out, every Saturday is off; when it is a working day, the RBI rule still keeps the 2nd and 4th Saturday off. Sunday shows as a working day if Working Days ticks it. - Month and year counts, the Saturday stat card label ("2nd & 4th Sat" or "Saturdays"), the legend, tooltips and the month summary tags ("N Sat Off", "N Weekly Off") follow the same rule. Verified with prettier, eslint, tsc and ng build. Co-Authored-By: Claude Opus 5.5 --- .../bank-calendar.component.html | 32 +++++-- .../bank-calendar/bank-calendar.component.ts | 86 ++++++++++++++++--- 2 files changed, 100 insertions(+), 18 deletions(-) diff --git a/src/app/bank-calendar/bank-calendar.component.html b/src/app/bank-calendar/bank-calendar.component.html index b7f912e0f..d4996f7fa 100644 --- a/src/app/bank-calendar/bank-calendar.component.html +++ b/src/app/bank-calendar/bank-calendar.component.html @@ -44,8 +44,11 @@
-
{{ overallStats.sat2nd4th }}
-
2nd & 4th Sat
+
{{ overallStats.sat2nd4th + overallStats.weeklyOff }}
+
+ {{ saturdayIsWorkingDay ? '2nd & 4th Sat' : 'Saturdays' + }}{{ overallStats.weeklyOff > 0 ? ' / Weekly Off' : '' }} +
@@ -69,7 +72,11 @@
Working Day
Sunday
-
2nd / 4th Saturday
+
+ + {{ saturdayIsWorkingDay ? '2nd / 4th Saturday' : 'Saturday' + }}{{ overallStats.weeklyOff > 0 ? ' / Weekly Off' : '' }} +
Holiday
Today
@@ -319,7 +326,9 @@ [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.isCurrentMonth" + [class.cal-grid__day--sat2nd4th]=" + (day.is2ndSaturday || day.is4thSaturday || day.isWeeklyOff) && day.isCurrentMonth + " [class.cal-grid__day--holiday]="day.isHoliday && day.isCurrentMonth" [class.cal-grid__day--working]="day.isWorking && day.isCurrentMonth" [matTooltip]=" @@ -328,9 +337,11 @@ ? '2nd Saturday Off' : day.is4thSaturday ? '4th Saturday Off' - : day.isSunday && day.isCurrentMonth - ? 'Sunday' - : '') + : day.isWeeklyOff && day.isCurrentMonth + ? weekDays[day.dayOfWeek] + ' Off (Working Days)' + : day.isSunday && day.isCurrentMonth + ? 'Sunday' + : '') " > {{ day.isCurrentMonth ? day.date : '' }} @@ -343,10 +354,15 @@ @if (month.holidays > 0) { {{ month.holidays }} Holiday{{ month.holidays > 1 ? 's' : '' }} } - {{ month.sundays }} Sun + @if (month.sundays > 0) { + {{ month.sundays }} Sun + } @if (month.secondFourthSat > 0) { {{ month.secondFourthSat }} Sat Off } + @if (month.weeklyOff > 0) { + {{ month.weeklyOff }} Weekly Off + }
} diff --git a/src/app/bank-calendar/bank-calendar.component.ts b/src/app/bank-calendar/bank-calendar.component.ts index bfcacb185..9102f10d6 100644 --- a/src/app/bank-calendar/bank-calendar.component.ts +++ b/src/app/bank-calendar/bank-calendar.component.ts @@ -63,6 +63,8 @@ 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 } @@ -76,9 +78,23 @@ 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', @@ -118,7 +134,7 @@ export class BankCalendarComponent implements OnInit { ]; workingDaysConfig: string[] = []; holidays: any[] = []; - overallStats = { working: 0, nonWorking: 0, holidays: 0, sundays: 0, sat2nd4th: 0, totalDays: 0 }; + overallStats = { working: 0, nonWorking: 0, holidays: 0, sundays: 0, sat2nd4th: 0, weeklyOff: 0, totalDays: 0 }; // ───── Holiday list (office-wise) ───── offices: OfficeLite[] = []; @@ -202,7 +218,8 @@ export class BankCalendarComponent implements OnInit { } private loadWorkingDays() { - // Default Indian banking working days: Mon-Sat (Sun off, 2nd & 4th Sat off) + // 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. this.workingDaysConfig = [ 'MO', 'TU', @@ -215,6 +232,19 @@ 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). @@ -273,6 +303,23 @@ 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) => { @@ -511,7 +558,15 @@ export class BankCalendarComponent implements OnInit { buildCalendar() { this.months = []; - this.overallStats = { working: 0, nonWorking: 0, holidays: 0, sundays: 0, sat2nd4th: 0, totalDays: 0 }; + this.overallStats = { + working: 0, + nonWorking: 0, + holidays: 0, + sundays: 0, + sat2nd4th: 0, + weeklyOff: 0, + totalDays: 0 + }; for (let m = 0; m < 12; m++) { const monthData = this.buildMonth(m, this.currentYear); @@ -521,6 +576,7 @@ 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; } @@ -552,6 +608,7 @@ 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(); @@ -567,6 +624,7 @@ export class BankCalendarComponent implements OnInit { isSunday: false, is2ndSaturday: false, is4thSaturday: false, + isWeeklyOff: false, dayOfWeek: (startDayOfWeek - i - 1 + 7) % 7 }); } @@ -576,24 +634,29 @@ 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++; - const is2ndSat = isSaturday && saturdayCount === 2; - const is4thSat = isSaturday && saturdayCount === 4; + // 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 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, or holiday - const isNonWorking = isSunday || is2ndSat || is4thSat || isHoliday; + // Non-working: Sunday, 2nd Sat, 4th Sat, a weekly off from Working Days, or holiday + const isNonWorking = isSunday || is2ndSat || is4thSat || isWeeklyOff || isHoliday; if (isSunday) sundayCount++; - if (is2ndSat || is4thSat) sat2nd4thCount++; + if (is2ndSat || is4thSat || (isSaturday && isWeeklyOff)) sat2nd4thCount++; + if (isWeeklyOff && !isSaturday) weeklyOffCount++; if (isHoliday) holidayCount++; if (isNonWorking) { @@ -613,6 +676,7 @@ export class BankCalendarComponent implements OnInit { isSunday, is2ndSaturday: is2ndSat, is4thSaturday: is4thSat, + isWeeklyOff, holidayName: holiday?.name, dayOfWeek: dow }); @@ -632,6 +696,7 @@ export class BankCalendarComponent implements OnInit { isSunday: false, is2ndSaturday: false, is4thSaturday: false, + isWeeklyOff: false, dayOfWeek: days.length % 7 }); } @@ -645,7 +710,8 @@ export class BankCalendarComponent implements OnInit { nonWorkingDays: nonWorkingCount, holidays: holidayCount, sundays: sundayCount, - secondFourthSat: sat2nd4thCount + secondFourthSat: sat2nd4thCount, + weeklyOff: weeklyOffCount }; } From 915deddb74e8dc4543f11bd54ee58ca532d7dd88 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Tue, 13 Oct 2026 18:41:24 +0530 Subject: [PATCH 2/6] feat(cashier): cashier-only cash operations, exchange PAN/denomination checks and Branch Cash Position - Cash Exchange Entry: customer details with PAN (mandatory above Rs 50,000), cashier card, denomination-wise validation against the master and real-time impact preview. - Inter-Cashier Transfer: sender fixed to the logged-in cashier, both sides' details and denomination holdings shown, blocked until both are allocated cash. - Reconciliation, exchange and inter-cashier operate on the logged-in cashier only. - New Branch Cash Position screen (vault + cashier drawers, denomination-wise). - Customer search dialog shows the KYC PAN. - Cashier menu cleanup: Excess/Closing report and Exchange Detail removed, nav compacted to one row. - Reverse actions removed from cash transfer, settlement, exchange and inter-cashier. Co-Authored-By: Claude Sonnet 5.5 --- .../branch-cash-position.component.html | 114 +++ .../branch-cash-position.component.scss | 82 ++ .../branch-cash-position.component.ts | 112 +++ .../cash-exchange-entry.component.html | 355 +++++++-- .../cash-exchange-entry.component.scss | 720 ++++++++++++++++-- .../cash-exchange-entry.component.ts | 281 ++++++- .../cash-history/cash-history.component.html | 27 +- .../cash-history/cash-history.component.scss | 32 - .../cash-history/cash-history.component.ts | 61 +- src/app/cashier/cashier-routing.module.ts | 8 + src/app/cashier/cashier.service.ts | 25 +- .../inter-cashier-transfer.component.html | 163 +++- .../inter-cashier-transfer.component.scss | 150 ++++ .../inter-cashier-transfer.component.ts | 168 +++- .../reconciliation.component.html | 39 +- .../reconciliation.component.ts | 63 +- src/app/core/shell/shell.component.html | 46 +- src/app/core/shell/shell.component.scss | 38 + .../customer-search-dialog.component.ts | 657 ++++++++++++---- 19 files changed, 2601 insertions(+), 540 deletions(-) create mode 100644 src/app/cashier/branch-cash-position/branch-cash-position.component.html create mode 100644 src/app/cashier/branch-cash-position/branch-cash-position.component.scss create mode 100644 src/app/cashier/branch-cash-position/branch-cash-position.component.ts create mode 100644 src/app/cashier/inter-cashier-transfer/inter-cashier-transfer.component.scss diff --git a/src/app/cashier/branch-cash-position/branch-cash-position.component.html b/src/app/cashier/branch-cash-position/branch-cash-position.component.html new file mode 100644 index 000000000..d8f476a2e --- /dev/null +++ b/src/app/cashier/branch-cash-position/branch-cash-position.component.html @@ -0,0 +1,114 @@ + + +
+
+
+
BRANCH OPERATIONS · BRANCH CASH POSITION
+

Branch Cash Position

+

+ 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. +

+
+
+ +
+ + As of Date + + + +
+ + @if (errorMsg) { +
{{ errorMsg }}
+ } + @if (loading) { +
Loading…
+ } + + @if (position; as p) { +
+
+ Branch{{ p.officeName }} +
+
+ Vault₹{{ p.vaultAmount | number: '1.2-2' }} +
+
+ Cashiers' drawers₹{{ p.cashiersAmount | number: '1.2-2' }} +
+
+ Bank total₹{{ p.totalAmount | number: '1.2-2' }} +
+
+ + @if (!p.vaultHasBreakdown) { +
+ 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. +
+ } + + + @if (rows.length > 0) { + + + + + + + + + + + + + + @for (d of rows; track d.denominationId) { + + + + + + + + + } + +
DenominationVaultCashiersBank total (count)Bank total (₹)Exchange today
+ ₹{{ d.denominationValue }} + {{ d.vaultCount }}{{ d.cashierCount }}{{ d.totalCount }}₹{{ d.totalAmount | number: '1.2-2' }} + {{ movement(d.exchangeNet) }} +
+ } +
+ +
+ + +
+ +

+ Exchange today 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. +

+ } +
diff --git a/src/app/cashier/branch-cash-position/branch-cash-position.component.scss b/src/app/cashier/branch-cash-position/branch-cash-position.component.scss new file mode 100644 index 000000000..d86edfbbc --- /dev/null +++ b/src/app/cashier/branch-cash-position/branch-cash-position.component.scss @@ -0,0 +1,82 @@ +/** + * 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; +} diff --git a/src/app/cashier/branch-cash-position/branch-cash-position.component.ts b/src/app/cashier/branch-cash-position/branch-cash-position.component.ts new file mode 100644 index 000000000..ef5e64330 --- /dev/null +++ b/src/app/cashier/branch-cash-position/branch-cash-position.component.ts @@ -0,0 +1,112 @@ +/** + * 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}`; + } +} diff --git a/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.html b/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.html index e08dc73b0..d46a2d0d6 100644 --- a/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.html +++ b/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.html @@ -22,50 +22,255 @@ - -
-
- {{ isAccountHolder ? 'check_box' : 'check_box_outline_blank' }} - Account Holder -
- - @if (isAccountHolder) { - - } @else { - person_outline Walk-in Customer - } - - @if (needsIdProof) { -
- warning - Amount ≥ ₹50,000 — Customer PAN / Aadhaar required (RBI) + + @if (cashier) { +
+
{{ cashierInitials }}
+
+
Cashier at the counter
+
{{ cashier.name || '—' }}
+
+ south Receives from customer + north Gives to customer +
- - } -
+
+
+ Counter{{ cashier.tellerName || '—' }} +
+
+ Branch{{ cashier.officeName || '—' }} +
+
+ Allocated today{{ + cashier.allocatedAmount === null ? '—' : '₹' + (cashier.allocatedAmount | number: '1.2-2') + }} +
+
+ Cash in hand₹{{ cashier.totalAmount | number: '1.2-2' }} +
+
+ + } @else if (noCashier) { +
+ person_off +
+
Your login is not linked to a cashier
+
+ 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. +
+
+
+ } + + +
+
+
+ groups +
+
Customer
+
Who is exchanging cash at the counter
+
+
+
+ + +
+
+ +
+ +
+ @if (customerMode === 'existing') { + @if (loadingCustomer) { +
+ autorenew +
Loading customer details…
+
+ } @else if (!selectedCustomer) { +
+ person_search +
No customer selected
+
Search by name, account no., PAN or Aadhaar
+ +
+ } @else { +
+
+
{{ customerInitials }}
+
+
{{ selectedCustomer.displayName }}
+
+ + {{ + selectedCustomer.kycStatus === 'Verified' ? 'verified_user' : 'gpp_maybe' + }} + KYC: {{ selectedCustomer.kycStatus }} + + @if (selectedCustomer.accountType) { + {{ selectedCustomer.accountType }} + } +
+
+
+ + +
+
+
+
+ CIF No.{{ selectedCustomer.cifNumber || '—' }} +
+
+ Client ID{{ selectedCustomer.clientId }} +
+
+ Mobile{{ selectedCustomer.mobileNo || '—' }} +
+
+ Branch{{ selectedCustomer.officeName || '—' }} +
+
+ Account{{ selectedCustomer.accountNo || 'No account' }} +
+
+ PAN on record + + {{ selectedCustomer.kycPan || 'Not on record' }} + @if (selectedCustomer.kycPan) { + {{ selectedCustomer.panVerified ? 'Verified' : 'Not verified' }} + } + +
+
+
+ } + } @else { +
+ + +
+ } +
+ + +
+
+ credit_card + PAN Card + {{ needsPan ? 'Mandatory' : 'Optional' }} +
+
+ + @if (panValue && !panFormatInvalid) { + check_circle + } @else if (panFormatInvalid) { + error + } +
+
+ @if (panFormatInvalid) { + PAN is 5 letters, 4 digits, 1 letter (e.g. ABCDE1234F). + } @else if (panFromKyc) { + link 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. + } +
+
+
+
+ + + @if (availableFor(d) === null) { + — + } @else { + In drawer {{ availableFor(d) }} + @if (netFor(d) !== 0) { + + {{ netFor(d) > 0 ? 'trending_up' : 'trending_down' }} + {{ drawerAfter(d) }} + ({{ netFor(d) > 0 ? '+' : '' }}{{ netFor(d) }}) + + } + } + +
@@ -78,7 +283,7 @@
Bank Notes
} @for (d of notes; track d.id) { -
+
{{ d.label }}
@@ -90,6 +295,9 @@ />
+ + + ₹{{ subtotal('r', d) | number: '1.2-2' }} @@ -99,7 +307,7 @@
Coins
} @for (d of coins; track d.id) { -
+
{{ d.label }}
@@ -111,6 +319,9 @@ />
+ + + ₹{{ subtotal('r', d) | number: '1.2-2' }} @@ -131,7 +342,7 @@
Bank Notes
} @for (d of notes; track d.id) { -
+
{{ d.label }}
@@ -141,8 +352,16 @@ [value]="countFor('g', d)" (input)="onChange('g', d, $any($event.target).value)" /> - +
+ + + ₹{{ subtotal('g', d) | number: '1.2-2' }} @@ -152,7 +371,7 @@
Coins
} @for (d of coins; track d.id) { -
+
{{ d.label }}
@@ -162,8 +381,16 @@ [value]="countFor('g', d)" (input)="onChange('g', d, $any($event.target).value)" /> - +
+ + + ₹{{ subtotal('g', d) | number: '1.2-2' }} @@ -173,6 +400,30 @@
+ + @if (impactRows.length) { +
+
inventory_2 Drawer after this exchange
+
+ @for (r of impactRows; track r.id) { + + {{ r.label }} + {{ r.before }} → {{ r.after }} + ({{ r.net > 0 ? '+' : '' }}{{ r.net }}) + + } +
+
+ The branch's total for each denomination moves by the same amount; the vault is not touched (Branch Ops → Branch + Cash Position). +
+
+ } +
Received₹{{ receivedTotal | number: '1.2-2' }} @@ -213,9 +464,15 @@
} - @if (needsIdProof && !customerIdProof.trim()) { + @if (panMissing) {
- badge Enter customer PAN / Aadhaar to proceed (amount ≥ ₹50,000). + badge Enter the customer's PAN to proceed (amount above ₹50,000). +
+ } + @if (!customerIdentified && !loadingCustomer) { +
+ person_search + {{ customerMode === 'existing' ? 'Search and select the customer.' : "Enter the walk-in customer's name." }}
} diff --git a/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.scss b/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.scss index 9c4277912..88903f1a0 100644 --- a/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.scss +++ b/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.scss @@ -73,99 +73,584 @@ } } -/* ── Customer info bar ── */ -.cx-customer-bar { - display: flex; - align-items: center; - gap: 12px; - padding: 10px 14px; - background: #f8fafc; +/* ── Customer: existing client or walk-in, plus PAN ── */ +.cx-cust { + background: #fff; border: 1px solid #e2e8f0; - border-radius: 10px; - flex-wrap: wrap; -} + border-radius: 14px; + box-shadow: 0 1px 2px rgb(15 23 42 / 4%); + overflow: hidden; -.cx-customer-toggle { - display: flex; - align-items: center; - gap: 6px; - font-size: 13px; - font-weight: 600; - color: #475569; - cursor: pointer; - user-select: none; + &__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; + } - mat-icon { - font-size: 20px; - height: 20px; - width: 20px; + &__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; color: #94a3b8; } - &--active { - color: #1e40af; + &__empty-title { + font-size: 14px; + font-weight: 600; + color: #334155; + } + + &__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; + } + + 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 { + 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 { - color: #1e40af; + 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-walk-in { - display: flex; +.cx-kyc { + display: inline-flex; align-items: center; gap: 4px; - font-size: 12px; - color: #64748b; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; mat-icon { - font-size: 16px; - height: 16px; - width: 16px; + 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; } } -.cx-pan-required { - display: flex; - align-items: center; - gap: 6px; - padding: 4px 10px; - background: #fffbeb; - border: 1px solid #fcd34d; +.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; + align-items: center; + gap: 4px; + height: 30px; + padding: 0 10px; + border: 0; + border-radius: 8px; + background: transparent; font-size: 12px; font-weight: 600; - color: #92400e; + color: #1e40af; + cursor: pointer; mat-icon { font-size: 16px; - height: 16px; width: 16px; - color: #d97706; + height: 16px; + } + + &:hover { + background: #eff6ff; + } + + &--danger { + color: #b91c1c; + + &:hover { + background: #fef2f2; + } } } -.cx-field--inline { - flex-direction: row; - align-items: center; +/* 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; gap: 8px; + padding: 14px; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 12px; - span { - white-space: nowrap; + &__head { + display: flex; + align-items: center; + gap: 8px; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + color: #475569; + } } - input { - width: 180px; + &__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; + } } } -.cx-field--pan input { - width: 160px; - border-color: #fcd34d; +.cx-spin { + animation: cx-spin 1s linear infinite; +} - &:focus { - outline: none; - border-color: #d97706; +@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; } } @@ -345,6 +830,129 @@ 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; diff --git a/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.ts b/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.ts index bede34b4b..98916e2bc 100644 --- a/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.ts +++ b/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.ts @@ -11,7 +11,15 @@ 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 { @@ -21,6 +29,47 @@ 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 = { + 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', @@ -31,11 +80,14 @@ interface Denom { CommonModule, FormsModule, MatIconModule, - MatSnackBarModule + MatSnackBarModule, + MatDialogModule ] }) export class CashExchangeEntryComponent implements OnInit { private service = inject(CashierService); + private clientsService = inject(ClientsService); + private dialog = inject(MatDialog); private snackBar = inject(MatSnackBar); notes: Denom[] = []; @@ -47,11 +99,17 @@ export class CashExchangeEntryComponent implements OnInit { saving = false; // ── Customer info ── - isAccountHolder = false; - accountNo = ''; - customerIdProof = ''; + /** 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 = ''; - // ── Cashier position (for denomination overdraft check) ── + // ── 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; private availableMap = new Map(); positionLoaded = false; @@ -88,7 +146,19 @@ 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; @@ -96,6 +166,31 @@ 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; } @@ -129,14 +224,102 @@ export class CashExchangeEntryComponent implements OnInit { return this.receivedTotal > 0 && this.receivedTotal === this.givenTotal; } - /** Above ₹50,000 PAN/ID is mandatory per RBI guideline */ - get needsIdProof(): boolean { - return this.receivedTotal >= 50000; + /** 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 + })); } /** Denominations where cashier is giving more than currently available. */ get givenOverdraftItems(): Array<{ label: string; available: number; requested: number }> { - if (!this.positionLoaded) return []; + // 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 []; 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); @@ -147,21 +330,77 @@ export class CashExchangeEntryComponent implements OnInit { } get valid(): boolean { - return this.matches && (!this.needsIdProof || this.customerIdProof.trim().length > 0) && !this.hasGivenOverdraft; + return ( + this.matches && + !!this.cashier && + !this.hasGivenOverdraft && + !this.panFormatInvalid && + !this.panMissing && + this.customerIdentified && + !this.loadingCustomer + ); } - toggleAccountHolder(): void { - this.isAccountHolder = !this.isAccountHolder; - if (!this.isAccountHolder) this.accountNo = ''; + 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 = ''; } clear(): void { this.receivedCounts = {}; this.givenCounts = {}; this.note = ''; - this.accountNo = ''; - this.customerIdProof = ''; - this.isAccountHolder = false; + this.customerMode = 'walkin'; + this.selectedCustomer = null; + this.walkInName = ''; + this.walkInMobile = ''; + this.panNumber = ''; } submit(): void { @@ -171,11 +410,17 @@ 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, - customerIdProof: this.customerIdProof.trim() || null, - accountNo: this.isAccountHolder && this.accountNo.trim() ? this.accountNo.trim() : 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, received: build('r'), given: build('g') }; diff --git a/src/app/cashier/cash-history/cash-history.component.html b/src/app/cashier/cash-history/cash-history.component.html index 4e85528dd..943ff9323 100644 --- a/src/app/cashier/cash-history/cash-history.component.html +++ b/src/app/cashier/cash-history/cash-history.component.html @@ -11,25 +11,11 @@
CASHIER · HISTORY

Cash Movement History

-

All transfers, settlements, and inter-cashier movements. Reverse any active transaction.

+

All transfers, settlements, inter-cashier movements and cash exchanges.

- - @if (reversingId !== null) { -
- warning - Reverse #{{ reversingId }}? This cannot be undone. - - - -
- } - @if (loading) {
Loading…
} @@ -134,11 +120,6 @@ > visibility - @if (!r.isReversed) { - - }
@@ -187,6 +168,12 @@ Date {{ detail.row.date }} + @if (detail.row.type === 'exchange') { +
+ Customer PAN + {{ detail.row.panNumber || '—' }} +
+ }
Total Amount ₹{{ detail.row.amount | number: '1.2-2' }} diff --git a/src/app/cashier/cash-history/cash-history.component.scss b/src/app/cashier/cash-history/cash-history.component.scss index 1db02c17d..17ae05de0 100644 --- a/src/app/cashier/cash-history/cash-history.component.scss +++ b/src/app/cashier/cash-history/cash-history.component.scss @@ -47,38 +47,6 @@ } } -.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; diff --git a/src/app/cashier/cash-history/cash-history.component.ts b/src/app/cashier/cash-history/cash-history.component.ts index 6f49e8d53..281513c46 100644 --- a/src/app/cashier/cash-history/cash-history.component.ts +++ b/src/app/cashier/cash-history/cash-history.component.ts @@ -8,9 +8,7 @@ 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'; @@ -31,6 +29,8 @@ 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,7 +52,10 @@ interface DetailView { loading: boolean; } -/** Unified history of all cash movements with reversal and detail view support. */ +/** + * 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). + */ @Component({ selector: 'mifosx-cash-history', templateUrl: './cash-history.component.html', @@ -63,16 +66,13 @@ 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,10 +99,6 @@ export class CashHistoryComponent implements OnInit { private denomKindMap = new Map(); - reversingId: number | null = null; - reversingType: string | null = null; - reverseReason = ''; - detail: DetailView | null = null; ngOnInit(): void { @@ -212,7 +208,8 @@ export class CashHistoryComponent implements OnInit { this.exchanges = (exchangeItems as any[]).map((e) => ({ id: e.id, date: e.exchangeDate, - fromParty: e.accountNo ? `Account ${e.accountNo}` : 'Walk-in Customer', + fromParty: this.exchangeParty(e), + panNumber: e.panNumber ?? null, toParty: e.performedByUsername || '—', amount: e.exchangeAmount, note: e.note, @@ -310,7 +307,8 @@ export class CashHistoryComponent implements OnInit { this.exchanges = (res.pageItems ?? []).map((e: any) => ({ id: e.id, date: e.exchangeDate, - fromParty: e.accountNo ? `Account ${e.accountNo}` : 'Walk-in Customer', + fromParty: this.exchangeParty(e), + panNumber: e.panNumber ?? null, toParty: e.performedByUsername || '—', amount: e.exchangeAmount, note: e.note, @@ -392,39 +390,12 @@ export class CashHistoryComponent implements OnInit { this.detail = null; } - 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 }); - } - }); + /** 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'; } typeLabel(type: string): string { diff --git a/src/app/cashier/cashier-routing.module.ts b/src/app/cashier/cashier-routing.module.ts index 045252339..76947f25b 100644 --- a/src/app/cashier/cashier-routing.module.ts +++ b/src/app/cashier/cashier-routing.module.ts @@ -23,6 +23,7 @@ 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'; @@ -225,6 +226,13 @@ 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 diff --git a/src/app/cashier/cashier.service.ts b/src/app/cashier/cashier.service.ts index f3700e20c..cfa8def18 100644 --- a/src/app/cashier/cashier.service.ts +++ b/src/app/cashier/cashier.service.ts @@ -32,9 +32,6 @@ export class CashierService { getCashExchange(id: number): Observable { return this.http.get(`/cashexchange/${id}`); } - reverseCashExchange(id: number, reason: string): Observable { - return this.http.post(`/cashexchange/${id}/reverse`, { reason }); - } // ── Tellers / Cashiers ── getTellers(officeId?: number): Observable { @@ -60,9 +57,6 @@ export class CashierService { getCashTransferById(id: number): Observable { return this.http.get(`/cashtransfer/${id}`); } - reverseCashTransfer(id: number, reason: string): Observable { - return this.http.post(`/cashtransfer/${id}/reverse`, { reason }); - } // ── Cash Settlement ── createCashSettlement(data: any): Observable { @@ -75,9 +69,6 @@ export class CashierService { getCashSettlementById(id: number): Observable { return this.http.get(`/cashsettlement/${id}`); } - reverseCashSettlement(id: number, reason: string): Observable { - return this.http.post(`/cashsettlement/${id}/reverse`, { reason }); - } // ── Inter-cashier Transfer ── createInterCashierTransfer(data: any): Observable { @@ -90,8 +81,10 @@ export class CashierService { getInterCashierById(id: number): Observable { return this.http.get(`/cashintercashier/${id}`); } - reverseInterCashierTransfer(id: number, reason: string): Observable { - return this.http.post(`/cashintercashier/${id}/reverse`, { reason }); + /** One cashier's details, today's vault allocation and denomination-wise cash, for either side of a transfer. */ + getInterCashierCashierSummary(cashierId: number, date: string): Observable { + const params = new HttpParams().set('cashierId', cashierId).set('date', date); + return this.http.get('/cashintercashier/cashier-summary', { params }); } // ── Reconciliation ── @@ -318,6 +311,16 @@ 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 { + 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 { let params = new HttpParams().set('threshold', String(threshold)); if (fromDate) params = params.set('fromDate', fromDate); diff --git a/src/app/cashier/inter-cashier-transfer/inter-cashier-transfer.component.html b/src/app/cashier/inter-cashier-transfer/inter-cashier-transfer.component.html index b87a43472..45166d6e6 100644 --- a/src/app/cashier/inter-cashier-transfer/inter-cashier-transfer.component.html +++ b/src/app/cashier/inter-cashier-transfer/inter-cashier-transfer.component.html @@ -11,41 +11,29 @@
CASHIER · INTER-CASHIER TRANSFER

Inter-Cashier Transfer

-

Move denomination-wise cash between two cashiers within the same teller.

+

Hand over denomination-wise cash from your drawer to another cashier at your counter.

-
- @if (tellers.length === 1) { - - Teller - - - } @else { - - Teller - - @for (t of tellers; track t.id) { - {{ t.name }} - } - @if (tellers.length === 0) { - No tellers configured - } - - - } + @if (ownCashierMissing) { +
+ person_off + 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. +
+ } +
- From (Cashier) - - @for (c of availableFrom; track c.id) { - {{ c.label }} - } - @if (selectedTellerId && allCashiers.length === 0 && !loadingCashiers) { - No cashiers - } - + Teller + + + + + + From (You) + @@ -54,7 +42,7 @@ @for (c of availableTo; track c.id) { {{ c.label }} } - @if (fromCashierId && availableTo.length === 0) { + @if (fromCashierId && !loadingCashiers && availableTo.length === 0) { No other cashiers } @@ -62,7 +50,7 @@ Date - + @@ -71,26 +59,102 @@
+ + @if (fromCashierId || toCashierId) { +
+ + arrow_forward + +
+ } + + +
+
{{ title }}
+ @if (!picked) { +
Select a cashier
+ } @else if (loading) { +
Loading…
+ } @else if (!s) { +
Could not load this cashier's details.
+ } @else { +
{{ s.cashierName || '—' }}
+
+ point_of_sale {{ s.tellerName || '—' }} + account_balance {{ s.officeName || '—' }} +
+
+
+
Allocated today
+
₹{{ s.allocatedAmount | number: '1.2-2' }}
+
+
+
Cash in hand
+
₹{{ s.totalAmount | number: '1.2-2' }}
+
+
+ @if (!s.cashAllocated) { +
+ block No cash allocated from the vault for this date — inter-cashier transfer is not + allowed. +
+ } + } +
+
+ + @if (fromSummary && toSummary && !bothAllocated) { +
+ block + Both cashiers must first be allocated cash from the vault (Cash Transfer) for {{ transferDate }} before an + inter-cashier transfer. +
+ } + @if (anyExceedsHeld) { +
+ error_outline + The sending cashier does not hold enough of the highlighted denominations. +
+ } +
+ + @if (notes.length) { - + } @for (d of notes; track d.id) { - + + + } @if (coins.length) { - + } @for (d of coins; track d.id) { - + + + } @if (denoms.length === 0) { - + }
DenominationFrom holds Count AmountTo holds
Bank NotesBank Notes
{{ d.label }} + +
@@ -99,18 +163,28 @@
₹{{ subtotal(d) | number: '1.2-2' }} + +
CoinsCoins
{{ d.label }} + +
@@ -119,17 +193,34 @@
₹{{ subtotal(d) | number: '1.2-2' }} + +
No active denominations. Add some in Cash Denomination.No active denominations. Add some in Cash Denomination.
+ + + @if (n === null) { + — + } @else { + {{ n }} + @if (delta !== 0) { + → {{ n + delta }} + } + } + +
Total to transfer: ₹{{ total | number: '1.2-2' }} +
Verification & Reports
@@ -1166,12 +1155,9 @@
Cash & Counter
- - + @@ -1186,9 +1172,6 @@ - @@ -1209,9 +1192,6 @@ - diff --git a/src/app/core/shell/shell.component.scss b/src/app/core/shell/shell.component.scss index c752a9d22..95d3060f9 100644 --- a/src/app/core/shell/shell.component.scss +++ b/src/app/core/shell/shell.component.scss @@ -244,6 +244,44 @@ $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; diff --git a/src/app/shared/customer-search-dialog/customer-search-dialog.component.ts b/src/app/shared/customer-search-dialog/customer-search-dialog.component.ts index 6b3aa53ce..4f47b4539 100644 --- a/src/app/shared/customer-search-dialog/customer-search-dialog.component.ts +++ b/src/app/shared/customer-search-dialog/customer-search-dialog.component.ts @@ -10,12 +10,12 @@ import { Component, OnInit, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { HttpClient, HttpParams } from '@angular/common/http'; import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup } from '@angular/forms'; -import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatButtonModule } from '@angular/material/button'; -import { MatTableModule } from '@angular/material/table'; -import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; export interface CustomerSearchResult { @@ -30,6 +30,17 @@ export interface CustomerSearchResult { address: string | null; } +/** Optional data for the dialog. */ +export interface CustomerSearchDialogData { + /** + * Whether a customer must have an account to be picked. True by default, because the account screens + * need one; a screen that only needs the customer (cash exchange) passes false. + */ + requireAccount?: boolean; +} + +type AccountTypeKey = 'savings' | 'current' | 'fixedDeposit' | 'recurringDeposit'; + @Component({ selector: 'mifosx-customer-search-dialog', standalone: true, @@ -41,173 +52,479 @@ export interface CustomerSearchResult { MatFormFieldModule, MatInputModule, MatButtonModule, - MatTableModule, - MatCheckboxModule, + MatIconModule, + MatProgressBarModule, MatSnackBarModule ], template: ` -

Search Customer

- -

- Fill in any one field, or combine several to narrow the search (e.g. name + account number). -

-
- - Name - - +
+
person_search
+
+

Search Customer

+

+ Fill in any one field, or combine several to narrow the search (e.g. name + account number). +

+
+ +
- - Account Number - - + @if (loading) { + + } - - Aadhaar Number - - + + +
+ + Name + person + + - - PAN Number - - + + Account Number + account_balance_wallet + + - - Address - - + + Aadhaar Number + fingerprint + + + + + PAN Number + credit_card + + + + + Address + place + + +
+ +
+ Account Type +
+ @for (t of accountTypeOptions; track t.key) { + + } +
+ Leave all unselected to search every account type +
- + @if (searched) { +
+
{{ results.length }} {{ results.length === 1 ? 'result' : 'results' }}
-
-

No matching customer found.

+ @if (!loading && results.length === 0) { +
+ search_off +
No matching customer found
+
Check the spelling, or try fewer fields.
+
+ } - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Name{{ r.displayName }}Account - {{ r.accountNo }} ({{ accountTypeLabel(r.accountType) }}) - No account - Mobile{{ r.mobileNo || '-' }}Branch{{ r.officeName || '-' }}PAN{{ r.panNumber || '-' }}Aadhaar{{ r.aadhaarNumber || '-' }}Address{{ r.address || '-' }} - -
-
+ @for (r of results; track $index) { +
+
{{ initials(r.displayName) }}
+
+
+ {{ r.displayName }} + @if (r.accountNo) { + {{ accountTypeLabel(r.accountType) }} + {{ r.accountNo }} + } @else { + No account + } +
+
+ call{{ r.mobileNo || '—' }} + account_balance{{ r.officeName || '—' }} + credit_cardPAN {{ r.panNumber || '—' }} + fingerprintAadhaar {{ r.aadhaarNumber || '—' }} +
+ @if (r.address) { +
place{{ r.address }}
+ } +
+ +
+ } +
+ }
- - - + + + + + `, styles: [ ` - .hint-text { - color: #64748b; - font-size: 13px; - margin: 0 0 12px 0; + :host { + display: block; } - .search-form { + + .csd-head { display: flex; - flex-wrap: wrap; - gap: 12px 16px; + align-items: center; + gap: 14px; + padding: 20px 16px 12px 24px; } - .field { - flex: 1 1 220px; + + .csd-head__icon { + display: grid; + place-items: center; + flex: 0 0 auto; + width: 44px; + height: 44px; + border-radius: 12px; + background: #e6f4ee; + color: #0f4f3c; } - .field--wide { - flex-basis: 100%; + + .csd-head__icon mat-icon { + font-size: 26px; + width: 26px; + height: 26px; } - .account-type-filter { + + .csd-head__text { + flex: 1 1 auto; + min-width: 0; + } + + .csd-head__title { + margin: 0; + font-size: 20px; + font-weight: 700; + color: #0f172a; + } + + .csd-head__sub { + margin: 2px 0 0; + font-size: 13px; + color: #64748b; + } + + .csd-head__close { + flex: 0 0 auto; + color: #64748b; + } + + .csd-content { + padding-top: 12px; + } + + .csd-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px 14px; + } + + .csd-field { + width: 100%; + } + + .csd-field--name { + grid-column: span 2; + } + + .csd-field--address { + grid-column: span 3; + } + + .csd-upper { + text-transform: uppercase; + } + + .csd-types { display: flex; align-items: center; flex-wrap: wrap; - gap: 8px 16px; - margin: 4px 0 16px 0; + gap: 8px 12px; + margin-top: 14px; } - .account-type-filter__label { + + .csd-types__label { font-size: 13px; font-weight: 600; color: #475569; } - .account-type-filter__hint { + + .csd-types__pills { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .csd-types__hint { font-size: 12px; color: #94a3b8; } - .results { - margin-top: 16px; - overflow-x: auto; - } - table { - width: 100%; - } - th { - font-weight: 600; + + .csd-pill { + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 14px 0 10px; + border: 1px solid #cbd5e1; + border-radius: 999px; + background: #fff; + font: inherit; + font-size: 13px; + font-weight: 500; color: #475569; - background-color: #f8fafc; + cursor: pointer; + transition: + background 0.15s ease, + border-color 0.15s ease, + color 0.15s ease; } - td { - color: #334155; - } - .muted { + + .csd-pill mat-icon { + font-size: 18px; + width: 18px; + height: 18px; color: #94a3b8; } - .no-results { - text-align: center; + + .csd-pill:hover { + border-color: #94a3b8; + background: #f8fafc; + } + + .csd-pill--on, + .csd-pill--on:hover { + border-color: #1f7a4d; + background: #e6f4ee; + font-weight: 600; + color: #0f4f3c; + } + + .csd-pill--on mat-icon { + color: #1f7a4d; + } + + .csd-results { + margin-top: 20px; + } + + .csd-results__head { + margin-bottom: 8px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; color: #64748b; - padding: 16px 0; + } + + .csd-result { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 8px; + padding: 12px 14px; + border: 1px solid #e2e8f0; + border-radius: 12px; + background: #fff; + transition: + border-color 0.15s ease, + box-shadow 0.15s ease; + } + + .csd-result:hover { + border-color: #1f7a4d; + box-shadow: 0 2px 8px rgb(15 23 42 / 8%); + } + + .csd-result--noacc { + background: #f8fafc; + } + + .csd-avatar { + display: grid; + place-items: center; + flex: 0 0 auto; + width: 42px; + height: 42px; + border-radius: 50%; + background: linear-gradient(135deg, #1f7a4d, #0f4f3c); + font-size: 14px; + font-weight: 700; + color: #fff; + } + + .csd-result__main { + flex: 1 1 auto; + min-width: 0; + } + + .csd-result__name { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + font-size: 15px; + font-weight: 700; + color: #0f172a; + } + + .csd-result__acc { + font-family: ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace; + font-size: 12px; + font-weight: 600; + color: #475569; + } + + .csd-badge { + padding: 2px 8px; + border-radius: 6px; + font-size: 11px; + font-weight: 600; + } + + .csd-badge--type { + background: #eef2ff; + color: #3730a3; + } + + .csd-badge--muted { + background: #f1f5f9; + color: #64748b; + } + + .csd-result__meta { + display: flex; + flex-wrap: wrap; + gap: 4px 16px; + margin-top: 4px; + font-size: 12px; + color: #475569; + } + + .csd-result__meta span, + .csd-result__addr { + display: inline-flex; + align-items: center; + gap: 4px; + } + + .csd-result__meta mat-icon, + .csd-result__addr mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + color: #94a3b8; + } + + .csd-result__addr { + display: flex; + align-items: flex-start; + margin-top: 4px; + font-size: 12px; + color: #64748b; + } + + .csd-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: 28px 16px; + border: 1.5px dashed #cbd5e1; + border-radius: 12px; + background: #f8fafc; + text-align: center; + } + + .csd-empty mat-icon { + font-size: 36px; + width: 36px; + height: 36px; + color: #94a3b8; + } + + .csd-empty__title { + font-size: 14px; + font-weight: 600; + color: #334155; + } + + .csd-empty__sub { + font-size: 12px; + color: #64748b; + } + + .csd-actions { + gap: 8px; + padding: 12px 24px 20px; + } + + @media (max-width: 720px) { + .csd-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .csd-field--address { + grid-column: span 2; + } + + .csd-result { + flex-wrap: wrap; + } + } + + @media (max-width: 480px) { + .csd-grid { + grid-template-columns: 1fr; + } + + .csd-field--name, + .csd-field--address { + grid-column: span 1; + } } ` ] @@ -217,20 +534,18 @@ export class CustomerSearchDialogComponent implements OnInit { private http = inject(HttpClient); private dialogRef = inject(MatDialogRef); private snackBar = inject(MatSnackBar); + private data = inject(MAT_DIALOG_DATA, { optional: true }); form!: FormGroup; loading = false; searched = false; results: CustomerSearchResult[] = []; - displayedColumns = [ - 'displayName', - 'account', - 'mobileNo', - 'officeName', - 'panNumber', - 'aadhaarNumber', - 'address', - 'select' + + readonly accountTypeOptions: Array<{ key: AccountTypeKey; label: string; icon: string }> = [ + { key: 'savings', label: 'Savings', icon: 'savings' }, + { key: 'current', label: 'Current', icon: 'account_balance_wallet' }, + { key: 'fixedDeposit', label: 'Fixed Deposit', icon: 'lock' }, + { key: 'recurringDeposit', label: 'Recurring Deposit', icon: 'autorenew' } ]; ngOnInit(): void { @@ -247,6 +562,48 @@ export class CustomerSearchDialogComponent implements OnInit { }); } + /** The account screens need an account to open; a caller that only needs the customer opts out. */ + private get requireAccount(): boolean { + return this.data?.requireAccount !== false; + } + + canSelect(r: CustomerSearchResult): boolean { + return !this.requireAccount || !!r.accountNo; + } + + isTypeOn(key: AccountTypeKey): boolean { + return !!this.form?.get(key)?.value; + } + + toggleType(key: AccountTypeKey): void { + const control = this.form.get(key); + control?.setValue(!control.value); + } + + upperCase(name: string): void { + const control = this.form.get(name); + const value = String(control?.value ?? ''); + const upper = value.toUpperCase(); + if (value !== upper) { + control?.setValue(upper, { emitEvent: false }); + } + } + + /** Enter in a text box runs the search; on a button it keeps its own meaning. */ + onEnter(event: Event): void { + if ((event.target as HTMLElement)?.tagName !== 'INPUT') return; + event.preventDefault(); + if (!this.loading) this.onSearch(); + } + + initials(name: string | null): string { + const words = (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(); + } + accountTypeLabel(type: string | null): string { switch (type) { case 'SAVINGS': @@ -297,6 +654,22 @@ export class CustomerSearchDialogComponent implements OnInit { }); } + onClear(): void { + this.form.reset({ + name: '', + accountNumber: '', + aadhaarNumber: '', + panNumber: '', + address: '', + savings: false, + current: false, + fixedDeposit: false, + recurringDeposit: false + }); + this.results = []; + this.searched = false; + } + onSelect(result: CustomerSearchResult): void { this.dialogRef.close(result); } From 4e3b49a96ac31d58841d342485af1b4759becd96 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Tue, 13 Oct 2026 18:41:39 +0530 Subject: [PATCH 3/6] feat(ui): remove Undo / Revert transaction actions Removes the Undo button and Undo menu item from savings, current, fixed deposit, recurring deposit and loan transactions, the account-transfer Undo, and the Revert Transaction button on the journal entry transaction detail, to prevent accidental reversal of posted entries. Co-Authored-By: Claude Sonnet 5.5 --- .../view-account-transfer.component.html | 10 ------- .../transactions-tab.component.html | 12 -------- ...nts-transaction-general-tab.component.html | 12 -------- .../transactions-tab.component.html | 6 ---- .../view-transaction.component.html | 15 +--------- .../view-transaction.component.html | 8 ----- .../transactions-tab.component.html | 4 --- .../view-transaction.component.html | 3 -- .../transactions-tab.component.html | 12 -------- ...ngs-transaction-general-tab.component.html | 10 ------- ...w-journal-entry-transaction.component.html | 18 ----------- ...iew-journal-entry-transaction.component.ts | 30 +------------------ 12 files changed, 2 insertions(+), 138 deletions(-) diff --git a/src/app/account-transfers/view-account-transfer/view-account-transfer.component.html b/src/app/account-transfers/view-account-transfer/view-account-transfer.component.html index 9d1b9ae34..cac9ad40d 100644 --- a/src/app/account-transfers/view-account-transfer/view-account-transfer.component.html +++ b/src/app/account-transfers/view-account-transfer/view-account-transfer.component.html @@ -7,16 +7,6 @@ -->
-
- @if (!viewAccountTransferData.reversed) { - - - - } -
-
diff --git a/src/app/current-accounts/current-account-view/transactions-tab/transactions-tab.component.html b/src/app/current-accounts/current-account-view/transactions-tab/transactions-tab.component.html index b0a948417..f39e90652 100644 --- a/src/app/current-accounts/current-account-view/transactions-tab/transactions-tab.component.html +++ b/src/app/current-accounts/current-account-view/transactions-tab/transactions-tab.component.html @@ -135,18 +135,6 @@ {{ 'labels.text.View Transaction' | translate }} - @if (!transaction.reversed) { - - - - } - - }
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.html index 6e447a59c..bef30de6c 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.html @@ -130,12 +130,6 @@ {{ 'labels.text.View Transaction' | translate }} - @if (!isFirstTransaction(idx)) { - - } - - } -
+
diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-view/transactions-tab/view-transaction/view-transaction.component.html b/src/app/deposits/recurring-deposits/recurring-deposits-account-view/transactions-tab/view-transaction/view-transaction.component.html index ec66cc45a..e488fdd63 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-view/transactions-tab/view-transaction/view-transaction.component.html +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-view/transactions-tab/view-transaction/view-transaction.component.html @@ -15,14 +15,6 @@ } -
} diff --git a/src/app/loans/loans-view/transactions-tab/transactions-tab.component.html b/src/app/loans/loans-view/transactions-tab/transactions-tab.component.html index 00bf57722..9a138bac5 100644 --- a/src/app/loans/loans-view/transactions-tab/transactions-tab.component.html +++ b/src/app/loans/loans-view/transactions-tab/transactions-tab.component.html @@ -177,10 +177,6 @@ } @if (allowUndoTransaction(transaction)) { - - - @if (!transaction.reversed) { - - - - } } - @if (allowUndo()) { - - }
diff --git a/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.html b/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.html index 3f6ee9887..4fecc1971 100644 --- a/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.html +++ b/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.html @@ -24,24 +24,6 @@ }
-
- @if (dataSource?.data[0].manualEntry) { - - } -
diff --git a/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.ts b/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.ts index fa6a999a2..2b3f5c753 100644 --- a/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.ts +++ b/src/app/shared/accounting/view-journal-entry-transaction/view-journal-entry-transaction.component.ts @@ -8,10 +8,8 @@ import { Component, OnInit, ViewChild, inject } from '@angular/core'; import { ViewJournalEntryComponent } from '../view-journal-entry/view-journal-entry.component'; -import { RevertTransactionComponent } from 'app/accounting/revert-transaction/revert-transaction.component'; -import { AccountingService } from 'app/accounting/accounting.service'; import { MatDialog } from '@angular/material/dialog'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; import { MatSort, MatSortHeader } from '@angular/material/sort'; import { MatPaginator } from '@angular/material/paginator'; import { @@ -65,9 +63,7 @@ import { MatTooltipModule } from '@angular/material/tooltip'; ] }) export class ViewJournalEntryTransactionComponent implements OnInit { - private accountingService = inject(AccountingService); private route = inject(ActivatedRoute); - private router = inject(Router); dialog = inject(MatDialog); private location = inject(Location); @@ -147,30 +143,6 @@ export class ViewJournalEntryTransactionComponent implements OnInit { this.dialog.open(ViewJournalEntryComponent, { data: { journalEntry } }); } - revertTransaction(transactionId?: string) { - const revertTransactionDialogRef = this.dialog.open(RevertTransactionComponent, { - data: { reverted: this.dataSource.data[0].reversed, transactionId } - }); - revertTransactionDialogRef.afterClosed().subscribe((response: any) => { - if (response.revert) { - this.accountingService - .revertTransaction(this.transactionId, response.comments) - .subscribe((reversedTransaction: any) => { - this.dataSource.data[0].reversed = true; - this.revertTransaction(reversedTransaction.transactionId); - }); - } else if (response.redirect) { - this.router.navigate( - [ - '../', - transactionId - ], - { relativeTo: this.route } - ); - } - }); - } - goBack(): void { this.location.back(); } From 7f3c97a2051e69436e0851a6e1b5ca4fdc8659ae Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Wed, 14 Oct 2026 13:51:38 +0530 Subject: [PATCH 4/6] feat(cashier): GL withdrawal slips, GL account panel with parent GL, and Payment lock on token withdrawals Transaction Entry: - GL withdrawal slip: a GL Withdrawal Slip is always a debit. The clerk raises it as a request (GL, cash vault GL, office and currency ride on the payload), the manager approves it and the cashier pays it against its token; nothing posts directly from this screen any more (submitGlJournalEntry removed). Cashiers without an approved token are told to enter one. GL credit-only rules moved into isGlCreditOnly(), and extra rows are not allowed while a Withdrawal Slip is selected. - A/C NO. now starts the automatic account search after six characters. - Clerk: with AC. TP. = GL show a "GL Account Information" panel (code, name, type, usage, normal balance, parent GL, manual entries, status, description) instead of the empty client panels; the parent GL name is looked up from /glaccounts/{parentId} for GL only. - Cashier: once a token (withdrawal / closure request) is verified, TYPE is disabled and fixed to Payment, and onTypChange refuses to change it. Requires the backend change "GL withdrawal slips through request, approval and cashier payment". Co-Authored-By: Claude Sonnet 5.5 --- .../transaction-entry.component.html | 285 +++++++++--------- .../transaction-entry.component.ts | 230 +++++++------- 2 files changed, 261 insertions(+), 254 deletions(-) diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.html b/src/app/cashier/transaction-entry/transaction-entry.component.html index 586117016..deda8f0e9 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.html +++ b/src/app/cashier/transaction-entry/transaction-entry.component.html @@ -117,6 +117,8 @@ @@ -1627,13 +1634,7 @@ class="ws-f__ctl" [(ngModel)]="row.acTyp" (click)="$event.stopPropagation()" - (change)=" - row.acTyp === 'GL' && - row.typ !== 'Disbursement' && - row.typ !== 'Fund Transfer' && - row.typ !== 'Enter Bank Transfer' && - (row.dbCr = 'Cr') - " + (change)="isGlCreditOnly(row) && (row.dbCr = 'Cr')" > @for (a of acTypOptionsFor(row); track a) { @@ -1704,12 +1705,7 @@ type="button" class="ws-seg__btn ws-seg__btn--dr" [class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'" - [disabled]=" - row.acTyp === 'GL' && - row.typ !== 'Disbursement' && - row.typ !== 'Fund Transfer' && - row.typ !== 'Enter Bank Transfer' - " + [disabled]="isGlCreditOnly(row)" (click)="$event.stopPropagation(); row.dbCr = 'Dr'" > DR @@ -1718,6 +1714,7 @@ 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 @@ -2043,7 +2040,13 @@ @if (isClerk) { - + } + + + + + + @if (loading && !loaded) { +

Loading…

+ } @else if (rows.length === 0) { +
+ inbox +

No cash transfer waiting

+

There is no cash allocation waiting for approval.

+
+ } @else { +
+ + + + + + + + + + + + + + + @for (r of rows; track r.id) { + + + + + + + + + + + } + +
DateFromTo (Cashier)AmountNoteRaised ByRaised AtAction
{{ r.transfer_date || '—' }}{{ r.from_party || '—' }}{{ r.to_party || '—' }}₹{{ r.total_amount | number: '1.2-2' }}{{ r.note || '—' }}{{ r.maker_name || '—' }}{{ formatDateTime(r.maker_at) }} + +
+
+ } + + + + + @if (reviewing) { +
+
+ swap_horiz +
+
Review cash transfer
+
+ Raised by {{ reviewing.maker_name || '—' }} · {{ formatDateTime(reviewing.maker_at) }} +
+
+ +
+ +
+
+ Amount to release + ₹{{ reviewing.total_amount | number: '1.2-2' }} +
+ +
+
+
Transfer date
+
{{ reviewing.transfer_date || '—' }}
+
+
+
From
+
{{ reviewing.from_party || '—' }}
+
+
+
To (cashier)
+
{{ reviewing.to_party || '—' }}
+
+
+
Notes and coins
+
+ {{ piecesOf(reviewing) }} pieces · {{ linesOf(reviewing).length }} denominations +
+
+ @if (reviewing.note) { +
+
Note
+
{{ reviewing.note }}
+
+ } +
+ +
Denomination breakdown
+ + + + + + + + + + @for (l of linesOf(reviewing); track l.value) { + + + + + + } + + + + + + + + +
DenominationCountAmount
₹{{ l.value }}{{ l.count }}₹{{ l.amount | number: '1.2-2' }}
Total{{ piecesOf(reviewing) }}₹{{ reviewing.total_amount | number: '1.2-2' }}
+
+ +
+ + + +
+
+ } +
+ + + + @if (rejecting) { +
+
+ block +
+
Reject cash transfer
+
₹{{ rejecting.total_amount | number: '1.2-2' }} to {{ rejecting.to_party }}
+
+ +
+
+ + +
+
+ + +
+
+ } +
diff --git a/src/app/cashier/cash-transfer-approvals/cash-transfer-approvals.component.scss b/src/app/cashier/cash-transfer-approvals/cash-transfer-approvals.component.scss new file mode 100644 index 000000000..ae36b2e83 --- /dev/null +++ b/src/app/cashier/cash-transfer-approvals/cash-transfer-approvals.component.scss @@ -0,0 +1,573 @@ +/** + * 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; + } +} diff --git a/src/app/cashier/cash-transfer-approvals/cash-transfer-approvals.component.ts b/src/app/cashier/cash-transfer-approvals/cash-transfer-approvals.component.ts new file mode 100644 index 000000000..c3b1305e7 --- /dev/null +++ b/src/app/cashier/cash-transfer-approvals/cash-transfer-approvals.component.ts @@ -0,0 +1,190 @@ +/** + * 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; + @ViewChild('reviewDialog') reviewDialog!: TemplateRef; + private dialogRef: MatDialogRef | null = null; + private reviewRef: MatDialogRef | 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 }); + } +} diff --git a/src/app/cashier/cash-transfer/cash-transfer.component.html b/src/app/cashier/cash-transfer/cash-transfer.component.html index 4c09120a9..1b55ecea2 100644 --- a/src/app/cashier/cash-transfer/cash-transfer.component.html +++ b/src/app/cashier/cash-transfer/cash-transfer.component.html @@ -13,7 +13,15 @@

Cash Transfer

Move cash denomination-wise from one party to another (e.g. Vault → Cashier for opening).

- +
+ + +
@@ -155,7 +163,56 @@
Total to transfer: ₹{{ total | number: '1.2-2' }}
+ + + +
+
+ pending_actions +
+
My transfer requests
+
Cash leaves the vault only after a manager approves
+
+ +
+
+ @if (myRequests.length) { + + + + + + + + + + + + @for (r of myRequests; track r.id) { + + + + + + + + } + +
DateToAmountStatusApproved by
{{ r.transfer_date || '—' }}{{ r.to_party || '—' }}₹{{ r.total_amount | number: '1.2-2' }} + {{ r.status }} + @if (r.rejection_reason) { +
{{ r.rejection_reason }}
+ } +
{{ r.checker_name || '—' }}
+ } @else { +
You have not raised any cash transfer request yet.
+ } +
+
+
diff --git a/src/app/cashier/cash-transfer/cash-transfer.component.scss b/src/app/cashier/cash-transfer/cash-transfer.component.scss index 78dfe9ead..90546bcbc 100644 --- a/src/app/cashier/cash-transfer/cash-transfer.component.scss +++ b/src/app/cashier/cash-transfer/cash-transfer.component.scss @@ -49,3 +49,131 @@ 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; + } +} diff --git a/src/app/cashier/cash-transfer/cash-transfer.component.ts b/src/app/cashier/cash-transfer/cash-transfer.component.ts index d818ad5e2..ab9041425 100644 --- a/src/app/cashier/cash-transfer/cash-transfer.component.ts +++ b/src/app/cashier/cash-transfer/cash-transfer.component.ts @@ -6,9 +6,10 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component, OnInit, inject } from '@angular/core'; +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 { MatFormFieldModule } from '@angular/material/form-field'; @@ -39,6 +40,7 @@ interface Denom { CommonModule, FormsModule, MatIconModule, + MatDialogModule, MatSnackBarModule, MatFormFieldModule, MatSelectModule, @@ -48,6 +50,10 @@ interface Denom { export class CashTransferComponent implements OnInit { private service = inject(CashierService); private snackBar = inject(MatSnackBar); + private dialog = inject(MatDialog); + + @ViewChild('requestsDialog') requestsDialog!: TemplateRef; + private requestsRef: MatDialogRef | null = null; notes: Denom[] = []; coins: Denom[] = []; @@ -82,7 +88,34 @@ 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(); this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; this.service.getActiveDenominations().subscribe({ @@ -245,16 +278,11 @@ 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.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.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.clear(); this.note = ''; this.fromParty = ''; diff --git a/src/app/cashier/cashier.service.ts b/src/app/cashier/cashier.service.ts index cfa8def18..3b792fd35 100644 --- a/src/app/cashier/cashier.service.ts +++ b/src/app/cashier/cashier.service.ts @@ -47,8 +47,21 @@ export class CashierService { } // ── Cash Transfer ── - createCashTransfer(data: any): Observable { - return this.http.post('/cashtransfer', data); + /** Cash leaves the vault only when a different person approves, so the screen raises a request, not a transfer. */ + createCashTransferRequest(data: any): Observable { + 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 { + let params = new HttpParams().set('mine', String(mine)); + if (status) params = params.set('status', status); + return this.http.get('/cashtransfer/requests', { params }); + } + approveCashTransferRequest(id: number): Observable { + return this.http.post(`/cashtransfer/requests/${id}/approve`, {}); + } + rejectCashTransferRequest(id: number, reason: string): Observable { + return this.http.post(`/cashtransfer/requests/${id}/reject`, { reason }); } getCashTransfers(offset = 0, limit = 20): Observable { const params = new HttpParams().set('offset', offset).set('limit', limit); diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.html b/src/app/cashier/transaction-entry/transaction-entry.component.html index deda8f0e9..ffc38955f 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.html +++ b/src/app/cashier/transaction-entry/transaction-entry.component.html @@ -129,11 +129,16 @@
- + @for (b of branches; track b.id) { + + }
@if (row.typ === 'DD') { @@ -1584,11 +1589,16 @@
- + @for (b of branches; track b.id) { + + }
@if (row.typ === 'DD') { diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts index a1ffb6bd6..413652c93 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.ts +++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts @@ -1877,6 +1877,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { ngOnInit(): void { const d = businessToday(); 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(); @@ -2309,7 +2310,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: 1, + br: this.defaultBranchId, trTp: '', acTyp: 'SD', acNo: '', @@ -2655,14 +2656,47 @@ 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 { - const map: Record = { - 1: '001 · Main', - 2: '002 · M.G. Road', - 14: '014 · Station Rd', - 27: '027 · Industrial' - }; - return map[branchId] || `Branch ${branchId}`; + return this.branches.find((b) => b.id === branchId)?.label ?? `Branch ${branchId}`; } /** diff --git a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.scss b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.scss index ad8feebef..54431fc8a 100644 --- a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.scss +++ b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.scss @@ -16,7 +16,7 @@ $green: #10b981; $amber: #f59e0b; .ac-container { - padding: 20px 24px; + padding: 12px 20px; max-width: 1440px; margin: 0 auto; } @@ -25,40 +25,40 @@ $amber: #f59e0b; display: flex; align-items: center; justify-content: space-between; - margin-bottom: 16px; + margin-bottom: 10px; gap: 12px; flex-wrap: wrap; &__left { display: flex; align-items: center; - gap: 12px; + gap: 10px; } &__icon { - width: 44px; - height: 44px; + width: 32px; + height: 32px; display: inline-flex; align-items: center; justify-content: center; - border-radius: 12px; + border-radius: 9px; background: linear-gradient(135deg, $blue 0%, $purple 100%); color: #fff; - font-size: 18px; + font-size: 14px; box-shadow: 0 2px 8px rgb(29 78 216 / 25%); } &__title { margin: 0; - font-size: 20px; + font-size: 16px; font-weight: 800; color: $navy; line-height: 1.1; } &__sub { - margin-top: 2px; - font-size: 12.5px; + margin-top: 1px; + font-size: 11px; color: #64748b; } } @@ -68,26 +68,26 @@ $amber: #f59e0b; hides most of them behind prev/next pagination arrows. Wrapping keeps every tab visible and one click away instead of a scroll-and-hunt. */ .ac-tabs { - margin-bottom: 20px; + margin-bottom: 12px; background: #fff; border: 1px solid #e2e8f0; - border-radius: 14px; - padding: 6px; + border-radius: 10px; + padding: 4px; box-shadow: 0 1px 3px rgb(15 23 42 / 4%); display: flex; flex-wrap: wrap; - gap: 4px; + gap: 2px; } .ac-tab { display: inline-flex; align-items: center; - gap: 8px; - height: 38px; - padding: 0 18px; - border-radius: 10px; + gap: 6px; + height: 28px; + padding: 0 11px; + border-radius: 8px; font-family: Inter, 'DM Sans', Roboto, sans-serif; - font-size: 13px; + font-size: 11.5px; font-weight: 600; letter-spacing: 0.01em; color: #64748b; @@ -99,7 +99,7 @@ $amber: #f59e0b; cursor: pointer; &__ic { - font-size: 13px; + font-size: 11px; color: inherit; } @@ -118,7 +118,7 @@ $amber: #f59e0b; @media (width <= 768px) { .ac-tab { - padding: 0 14px; - font-size: 12px; + padding: 0 10px; + font-size: 11px; } } From ab8ed7e67e052e3ddae36214e3acaf4ab1d8cc42 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 15 Oct 2026 12:28:27 +0530 Subject: [PATCH 6/6] feat(vouchers): voucher correction screens and a correction status on the voucher registers A cash deposit posted to the wrong account is moved to the right one in four steps: unpass the voucher, a manager approves, the cashier enters the correct account, a manager approves again. Nothing changes on any account until the final approval. - Voucher Correction page with four-step strip and popups: today's vouchers to unpass (admin or clerk), the unpassed vouchers waiting for their correct account (cashier) with the wrong and correct account side by side and the balance check, and the caller's own requests. A cashier whose customer has used the money closes the request and deposits separately. - Reached from the cashier bar, the clerk bar and the Cashier menu; the manager reviews both steps under Approvals > Voucher Corrections, laid out like the other approval tabs. Routes for Cash Transfers approvals are added to the same tab bar. - A Correction column on My Vouchers, the day book and the voucher register (and in the voucher detail popup) says whether a voucher is unpassed, revoked in favour of another, a correction, or closed, with the reason. - The deposit taken off the wrong account no longer shows on its passbook or transaction list. Co-Authored-By: Claude Sonnet 5.5 --- src/app/cashier/cashier-routing.module.ts | 13 + .../cashier/day-book/day-book.component.html | 15 +- .../cashier/day-book/day-book.component.ts | 9 +- .../cashier/my-work/my-work.component.html | 13 + src/app/cashier/my-work/my-work.component.ts | 9 +- .../voucher-detail-dialog.component.ts | 18 +- ...oucher-correction-approvals.component.html | 170 +++++ ...oucher-correction-approvals.component.scss | 485 ++++++++++++++ .../voucher-correction-approvals.component.ts | 192 ++++++ src/app/cashier/voucher-correction.service.ts | 120 ++++ .../voucher-correction.component.html | 446 +++++++++++++ .../voucher-correction.component.scss | 599 ++++++++++++++++++ .../voucher-correction.component.ts | 307 +++++++++ .../voucher-report.component.html | 13 + .../voucher-report.component.scss | 5 + .../voucher-report.component.ts | 9 +- .../voucher-status.component.ts | 137 ++++ src/app/core/shell/shell.component.html | 18 + .../savings-account-view.resolver.ts | 23 +- .../checker-inbox-and-tasks.component.html | 18 + src/app/tasks/tasks-routing.module.ts | 16 + 21 files changed, 2626 insertions(+), 9 deletions(-) create mode 100644 src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.html create mode 100644 src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.scss create mode 100644 src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.ts create mode 100644 src/app/cashier/voucher-correction.service.ts create mode 100644 src/app/cashier/voucher-correction/voucher-correction.component.html create mode 100644 src/app/cashier/voucher-correction/voucher-correction.component.scss create mode 100644 src/app/cashier/voucher-correction/voucher-correction.component.ts create mode 100644 src/app/cashier/voucher-status/voucher-status.component.ts diff --git a/src/app/cashier/cashier-routing.module.ts b/src/app/cashier/cashier-routing.module.ts index 76947f25b..f4c7d8401 100644 --- a/src/app/cashier/cashier-routing.module.ts +++ b/src/app/cashier/cashier-routing.module.ts @@ -83,6 +83,13 @@ 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: () => @@ -291,6 +298,12 @@ 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, diff --git a/src/app/cashier/day-book/day-book.component.html b/src/app/cashier/day-book/day-book.component.html index 5c5215145..116009136 100644 --- a/src/app/cashier/day-book/day-book.component.html +++ b/src/app/cashier/day-book/day-book.component.html @@ -202,6 +202,7 @@ Amount Payment Status + Correction By Approved By Time @@ -231,6 +232,18 @@ {{ r.status }} + + + + @if (!r.voucherStatus || r.voucherStatus === 'ACTIVE') { + — + } + {{ r.handledBy ?? '—' }} {{ r.approvedBy ?? '—' }} @@ -256,7 +269,7 @@ — } - + diff --git a/src/app/cashier/day-book/day-book.component.ts b/src/app/cashier/day-book/day-book.component.ts index 05fb8e475..59bac8801 100644 --- a/src/app/cashier/day-book/day-book.component.ts +++ b/src/app/cashier/day-book/day-book.component.ts @@ -14,6 +14,7 @@ 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. @@ -26,6 +27,11 @@ 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; @@ -58,7 +64,8 @@ interface DayBookRow { imports: [ CommonModule, FormsModule, - MatIconModule + MatIconModule, + VoucherStatusComponent ] }) export class DayBookComponent implements OnInit { diff --git a/src/app/cashier/my-work/my-work.component.html b/src/app/cashier/my-work/my-work.component.html index e4c96e7db..e62edb863 100644 --- a/src/app/cashier/my-work/my-work.component.html +++ b/src/app/cashier/my-work/my-work.component.html @@ -146,6 +146,7 @@ Amount My part Status + Correction Time @@ -203,6 +204,18 @@ {{ statusLabel(r.verificationStatus) }} + + + + @if (!r.voucherStatus || r.voucherStatus === 'ACTIVE') { + — + } + {{ r.createdAt | date: 'HH:mm' }} } diff --git a/src/app/cashier/my-work/my-work.component.ts b/src/app/cashier/my-work/my-work.component.ts index 8c0cf4bd4..28ef84cc3 100644 --- a/src/app/cashier/my-work/my-work.component.ts +++ b/src/app/cashier/my-work/my-work.component.ts @@ -15,6 +15,7 @@ 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'; @@ -24,6 +25,11 @@ 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; @@ -76,7 +82,8 @@ interface MyWorkReport { CommonModule, FormsModule, MatIconModule, - MatDialogModule + MatDialogModule, + VoucherStatusComponent ] }) export class MyWorkComponent implements OnInit { diff --git a/src/app/cashier/my-work/voucher-detail-dialog.component.ts b/src/app/cashier/my-work/voucher-detail-dialog.component.ts index 93137c3c7..a50339751 100644 --- a/src/app/cashier/my-work/voucher-detail-dialog.component.ts +++ b/src/app/cashier/my-work/voucher-detail-dialog.component.ts @@ -11,6 +11,7 @@ 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 @@ -18,6 +19,10 @@ 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; @@ -58,7 +63,8 @@ export interface VoucherDetailDialogData { CommonModule, MatDialogModule, MatButtonModule, - MatIconModule + MatIconModule, + VoucherStatusComponent ], template: `
@@ -66,7 +72,15 @@ export interface VoucherDetailDialogData {
receipt_long
-

{{ detail.voucherNo }}

+

+ {{ detail.voucherNo }} + +

{{ detail.voucherType || 'Voucher' }} · {{ detail.branchName || 'Head Office' }}

diff --git a/src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.html b/src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.html new file mode 100644 index 000000000..f65551611 --- /dev/null +++ b/src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.html @@ -0,0 +1,170 @@ + + +
+ +
+
+ undo +
+
Voucher Corrections
+
+ Approve or reject a voucher unpass, and the correction entry that follows it. Only the correction approval + moves money. +
+
+
+
+ {{ unpassCount }} unpass + {{ correctionCount }} correction +
+
+ + +
+ + + + + + + +
+ + + @if (loading && !loaded) { +

Loading…

+ } @else if (rows.length === 0) { +
+ inbox +

No voucher correction waiting

+

There is no unpass request or correction entry waiting for review.

+
+ } @else { +
+ + + + + + + + + + + + + + + + @for (r of rows; track r.request.id) { + + + + + + + + + + + + } + +
Voucher NoRequestPosted To (Wrong)Correct AccountAmountReasonRaised ByRaised AtAction
{{ r.request.original_voucher_no }} + {{ kindLabel(r.kind) }} + +
{{ r.request.wrong_account_no }}
+
{{ r.request.wrong_client_name || '—' }}
+
+ @if (r.kind === 'correction') { +
{{ r.request.correct_account_no }}
+
{{ r.request.correct_client_name || '—' }}
+ } @else { + — + } +
₹{{ r.request.amount | number: '1.2-2' }}{{ r.request.reason || '—' }}{{ r.raisedBy || '—' }}{{ formatDateTime(r.raisedAt) }} + + +
+
+ } +
+ + + + @if (rejecting) { +
+
+ block +
+
+ Reject {{ rejecting.kind === 'unpass' ? 'unpass request' : 'correction entry' }} +
+
{{ rejecting.request.original_voucher_no }}
+
+ +
+
+ + +
+
+ + +
+
+ } +
diff --git a/src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.scss b/src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.scss new file mode 100644 index 000000000..c8fbb6097 --- /dev/null +++ b/src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.scss @@ -0,0 +1,485 @@ +/** + * 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; +} diff --git a/src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.ts b/src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.ts new file mode 100644 index 000000000..56fe1650c --- /dev/null +++ b/src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.ts @@ -0,0 +1,192 @@ +/** + * 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; + private dialogRef: MatDialogRef | 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 }); + } +} diff --git a/src/app/cashier/voucher-correction.service.ts b/src/app/cashier/voucher-correction.service.ts new file mode 100644 index 000000000..d252a561a --- /dev/null +++ b/src/app/cashier/voucher-correction.service.ts @@ -0,0 +1,120 @@ +/** + * 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 { + let params = new HttpParams(); + if (search) params = params.set('search', search); + return this.http.get(`${this.base}/eligible`, { params }); + } + + unpass(voucherNo: string, reason: string): Observable { + return this.http.post(`${this.base}/unpass`, { voucherNo, reason }); + } + + list(status?: string, mine = false): Observable { + let params = new HttpParams().set('mine', String(mine)); + if (status) params = params.set('status', status); + return this.http.get(this.base, { params }); + } + + get(id: number): Observable { + return this.http.get(`${this.base}/${id}`); + } + + accountLookup(accountNo: string): Observable { + return this.http.get(`${this.base}/account-lookup`, { params: { accountNo } }); + } + + submitCorrection(id: number, correctAccountNo: string): Observable { + 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 { + return this.http.post(`${this.base}/${id}/close`, { reason }); + } + + approveUnpass(id: number): Observable { + return this.http.post(`${this.base}/${id}/unpass/approve`, {}); + } + + rejectUnpass(id: number, reason: string): Observable { + return this.http.post(`${this.base}/${id}/unpass/reject`, { reason }); + } + + approveCorrection(id: number): Observable { + return this.http.post(`${this.base}/${id}/correction/approve`, {}); + } + + rejectCorrection(id: number, reason: string): Observable { + 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 { + return this.http.get(`${this.base}/voided-transactions`, { params: { savingsAccountId } }); + } +} diff --git a/src/app/cashier/voucher-correction/voucher-correction.component.html b/src/app/cashier/voucher-correction/voucher-correction.component.html new file mode 100644 index 000000000..6c113b6d2 --- /dev/null +++ b/src/app/cashier/voucher-correction/voucher-correction.component.html @@ -0,0 +1,446 @@ + + +
+
+
+
CASHIER · VOUCHER CORRECTION
+

Voucher Correction

+

+ Move a cash deposit that was posted to the wrong account. Nothing changes on any account until the final + approval. +

+
+
+ + +
    +
  1. + 1 + Unpass the voucher +
  2. +
  3. + 2 + Manager approves +
  4. +
  5. + 3 + Enter the correct account +
  6. +
  7. + 4 + Manager approves, deposit moves +
  8. +
+ + + + + @if (tab === 'today') { +
+ + + +
+ +
+ + + + + + + + + + + + + + @for (v of vouchers; track v.voucherNo) { + + + + + + + + + + } + @if (!vouchers.length) { + + + + } + +
VoucherAccountCustomerModeCashierAmount
+ {{ v.voucherNo }} + {{ v.accountNo }}{{ v.clientName || '—' }}{{ v.paymentType }}{{ v.cashier }}₹{{ v.amount | number: '1.2-2' }} + +
+ {{ loadingVouchers ? 'hourglass_empty' : 'inbox' }} +
{{ loadingVouchers ? 'Loading…' : 'No cash deposit voucher of today can be unpassed.' }}
+
+
+ } + + + @if (tab === 'unpassed') { +
+ + + + + + + + + + + + + @for (u of unpassed; track u.id) { + + + + + + + + + } + @if (!unpassed.length) { + + + + } + +
VoucherPosted toCustomerReasonAmount
+ {{ u.original_voucher_no }} + {{ u.wrong_account_no }}{{ u.wrong_client_name || '—' }}{{ u.reason }}₹{{ u.amount | number: '1.2-2' }} + +
+ {{ loadingUnpassed ? 'hourglass_empty' : 'task_alt' }} +
+ {{ loadingUnpassed ? 'Loading…' : 'No unpassed voucher is waiting for its correct account.' }} +
+
+
+ } + + + @if (tab === 'mine') { +
+ + + + + + + + + + + + + @for (m of mine; track m.id) { + + + + + + + + + } + @if (!mine.length) { + + + + } + +
VoucherPosted toCorrected toAmountStatusNew voucher
+ {{ m.original_voucher_no }} + {{ m.wrong_account_no }}{{ m.correct_account_no || '—' }}₹{{ m.amount | number: '1.2-2' }} + {{ statusLabel[m.status] }} + @if (m.rejection_reason) { +
{{ m.rejection_reason }}
+ } +
{{ m.new_voucher_no || '—' }}
+ {{ loadingMine ? 'hourglass_empty' : 'history' }} +
{{ loadingMine ? 'Loading…' : 'You have no voucher corrections yet.' }}
+
+
+ } +
+ + + + @if (selectedVoucher) { +
+
+ undo +
+
Unpass voucher
+
{{ selectedVoucher.voucherNo }}
+
+ +
+ +
+
+ Deposit amount + ₹{{ selectedVoucher.amount | number: '1.2-2' }} +
+ +
+
+
Voucher
+
{{ selectedVoucher.voucherNo }}
+
+
+
Created
+
{{ selectedVoucher.createdAt | date: 'dd MMM yyyy, hh:mm a' }}
+
+
+
Cashier
+
{{ selectedVoucher.cashier }}
+
+
+
Mode
+
{{ selectedVoucher.paymentType }}
+
+
+
Account
+
{{ selectedVoucher.accountNo }}
+
+
+
Customer
+
{{ selectedVoucher.clientName || '—' }}
+
+
+
Account balance now
+
+ @if (voucherAccount) { + ₹{{ voucherAccount.balance | number: '1.2-2' }} + } @else { + — + } +
+
+
+
Account status
+
+ @if (voucherAccount) { + {{ voucherAccount.canReceive ? 'Active' : 'Not active' }} · {{ voucherAccount.currencyCode }} + } @else { + — + } +
+
+
+ + + + +
+ info + 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. +
+
+ +
+ + +
+
+ } +
+ + + + @if (active) { +
+
+ edit_note +
+
Correction entry
+
{{ active.original_voucher_no }}
+
+ +
+ +
+
+ Amount to move + ₹{{ active.amount | number: '1.2-2' }} +
+ + +
+
+
Transaction date
+
{{ active.txn_date || '—' }}
+
+
+
Mode
+
Cash
+
+
+
Unpassed by
+
{{ active.unpass_by_name || '—' }}
+
+
+
Approved by
+
{{ active.unpass_approved_by_name || '—' }}
+
+
+
Reason
+
{{ active.reason }}
+
+
+ +
+ +
+
Posted to (wrong)
+
{{ active.wrong_account_no }}
+
{{ active.wrong_client_name || '—' }}
+
+ Balance: + @if (wrongAccount) { + ₹{{ wrongAccount.balance | number: '1.2-2' }} + @if (wrongHasBalance) { + check_circle covers the deposit + } @else { + cancel does not cover ₹{{ active.amount | number: '1.2-2' }} + } + } @else { + — + } +
+
+ + arrow_forward + + +
+
Correct account
+ + @if (lookingUp) { +
Looking up…
+ } + @if (correctAccount) { +
{{ correctAccount.clientName || '—' }}
+
+ Balance: ₹{{ correctAccount.balance | number: '1.2-2' }} + @if (correctOk) { + check_circle active, can receive + } +
+ } + @if (lookupError) { +
error {{ lookupError }}
+ } +
+
+ + @if (wrongAccount && !wrongHasBalance) { +
+ warning +
+ The customer has already used this money, 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 Close request. +
+
+ } +
+ +
+ + + +
+
+ } +
diff --git a/src/app/cashier/voucher-correction/voucher-correction.component.scss b/src/app/cashier/voucher-correction/voucher-correction.component.scss new file mode 100644 index 000000000..3242e02c5 --- /dev/null +++ b/src/app/cashier/voucher-correction/voucher-correction.component.scss @@ -0,0 +1,599 @@ +/** + * 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; + } +} diff --git a/src/app/cashier/voucher-correction/voucher-correction.component.ts b/src/app/cashier/voucher-correction/voucher-correction.component.ts new file mode 100644 index 000000000..db114d5c2 --- /dev/null +++ b/src/app/cashier/voucher-correction/voucher-correction.component.ts @@ -0,0 +1,307 @@ +/** + * 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; + @ViewChild('correctionDialog') correctionDialog!: TemplateRef; + private dialogRef: MatDialogRef | 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 = { + 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, 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 }); + } +} diff --git a/src/app/cashier/voucher-report/voucher-report.component.html b/src/app/cashier/voucher-report/voucher-report.component.html index 73ac94478..592554b20 100644 --- a/src/app/cashier/voucher-report/voucher-report.component.html +++ b/src/app/cashier/voucher-report/voucher-report.component.html @@ -169,6 +169,7 @@ Debit Credit Amount + Correction User + + + @if (!row.voucherStatus || row.voucherStatus === 'ACTIVE') { + — + } + {{ row.username || '—' }} @if (systemOnly) { diff --git a/src/app/cashier/voucher-report/voucher-report.component.scss b/src/app/cashier/voucher-report/voucher-report.component.scss index 1fe7a3b97..d7e7cc51e 100644 --- a/src/app/cashier/voucher-report/voucher-report.component.scss +++ b/src/app/cashier/voucher-report/voucher-report.component.scss @@ -92,3 +92,8 @@ text-transform: uppercase; color: #7c3aed; } + +.vr-active { + font-size: 11px; + color: #94a3b8; +} diff --git a/src/app/cashier/voucher-report/voucher-report.component.ts b/src/app/cashier/voucher-report/voucher-report.component.ts index d6c148d91..3d04bd8a6 100644 --- a/src/app/cashier/voucher-report/voucher-report.component.ts +++ b/src/app/cashier/voucher-report/voucher-report.component.ts @@ -14,6 +14,7 @@ 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. @@ -26,6 +27,11 @@ 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; @@ -82,7 +88,8 @@ interface VoucherReport { imports: [ CommonModule, FormsModule, - MatIconModule + MatIconModule, + VoucherStatusComponent ] }) export class VoucherReportComponent implements OnInit { diff --git a/src/app/cashier/voucher-status/voucher-status.component.ts b/src/app/cashier/voucher-status/voucher-status.component.ts new file mode 100644 index 000000000..a436de861 --- /dev/null +++ b/src/app/cashier/voucher-status/voucher-status.component.ts @@ -0,0 +1,137 @@ +/** + * 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) { + {{ label }} + @if (note) { +
{{ note }}
+ } + } + `, + 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 ''; + } + } +} diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index 420d10c4a..dab25a011 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -131,6 +131,14 @@ My Closure Requests + + + Voucher Correction + @if (clerkAccountLoaded) { diff --git a/src/app/savings/common-resolvers/savings-account-view.resolver.ts b/src/app/savings/common-resolvers/savings-account-view.resolver.ts index 264fa26ff..faf79efd1 100644 --- a/src/app/savings/common-resolvers/savings-account-view.resolver.ts +++ b/src/app/savings/common-resolvers/savings-account-view.resolver.ts @@ -11,10 +11,12 @@ import { Injectable, inject } from '@angular/core'; import { ActivatedRouteSnapshot } from '@angular/router'; /** rxjs Imports */ -import { Observable } from 'rxjs'; +import { Observable, of } from 'rxjs'; +import { catchError, map, switchMap } from 'rxjs/operators'; /** Custom Services */ import { SavingsService } from '../savings.service'; +import { VoucherCorrectionService } from '../../cashier/voucher-correction.service'; /** * Savings Account data resolver. @@ -22,14 +24,29 @@ import { SavingsService } from '../savings.service'; @Injectable() export class SavingsAccountViewResolver { private savingsService = inject(SavingsService); + private voucherCorrectionService = inject(VoucherCorrectionService); /** - * Returns the Savings Account data. + * Returns the Savings Account data, without the deposits a completed voucher correction took off this account: + * the passbook and the transaction list read their rows from here, and a deposit that was posted to the wrong + * account and moved should not appear on it at all. * @param {ActivatedRouteSnapshot} route Route Snapshot * @returns {Observable} */ resolve(route: ActivatedRouteSnapshot): Observable { const savingAccountId = route.paramMap.get('savingAccountId'); - return this.savingsService.getSavingsAccountData(savingAccountId); + return this.savingsService.getSavingsAccountData(savingAccountId).pipe( + switchMap((account: any) => + this.voucherCorrectionService.voidedTransactions(Number(savingAccountId)).pipe( + catchError(() => of([] as number[])), + map((voided: number[]) => { + if (voided.length && Array.isArray(account?.transactions)) { + account.transactions = account.transactions.filter((txn: any) => !voided.includes(txn.id)); + } + return account; + }) + ) + ) + ); } } diff --git a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html index 65f82890f..d8b22a6c4 100644 --- a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html +++ b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html @@ -144,6 +144,24 @@ Withdrawal Requests + + + Cash Transfers + + + + Voucher Corrections + + import('../cashier/cash-transfer-approvals/cash-transfer-approvals.component').then( + (m) => m.CashTransferApprovalsComponent + ), + data: { title: 'Cash Transfers' } + }, + { + path: 'voucher-corrections', + loadComponent: () => + import('../cashier/voucher-correction-approvals/voucher-correction-approvals.component').then( + (m) => m.VoucherCorrectionApprovalsComponent + ), + data: { title: 'Voucher Corrections' } + }, { path: 'dividend-requests', component: DividendRequestsComponent,