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/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 }; } 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/cash-transfer-approvals/cash-transfer-approvals.component.html b/src/app/cashier/cash-transfer-approvals/cash-transfer-approvals.component.html new file mode 100644 index 000000000..6a61972d9 --- /dev/null +++ b/src/app/cashier/cash-transfer-approvals/cash-transfer-approvals.component.html @@ -0,0 +1,229 @@ + + +
+ +
+
+ swap_horiz +
+
Cash Transfers
+
+ Approve or reject an allocation of cash from the vault to a cashier. The cash leaves the vault only when it is + approved. +
+
+
+
+ {{ all.length }} waiting +
+
+ + +
+ + +
+ + + @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-routing.module.ts b/src/app/cashier/cashier-routing.module.ts index 045252339..f4c7d8401 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'; @@ -82,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: () => @@ -225,6 +233,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 @@ -283,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/cashier.service.ts b/src/app/cashier/cashier.service.ts index f3700e20c..3b792fd35 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 { @@ -50,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); @@ -60,9 +70,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 +82,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 +94,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 +324,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/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/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' }} @if (isClerk) { - + } +
+ + + + + + +
+ + + @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 611b7cba4..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) {
Verification & Reports
@@ -1166,18 +1170,18 @@
Cash & Counter
- - + +
@@ -1186,9 +1190,6 @@ - @@ -1209,9 +1210,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/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(); } 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); } 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,