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.service.ts b/src/app/cashier/cashier.service.ts index cfa8def18..3b792fd35 100644 --- a/src/app/cashier/cashier.service.ts +++ b/src/app/cashier/cashier.service.ts @@ -47,8 +47,21 @@ export class CashierService { } // ── Cash Transfer ── - createCashTransfer(data: any): Observable { - return this.http.post('/cashtransfer', data); + /** Cash leaves the vault only when a different person approves, so the screen raises a request, not a transfer. */ + createCashTransferRequest(data: any): Observable { + return this.http.post('/cashtransfer/requests', data); + } + /** Requests by status (comma separated); mine narrows them to the ones the caller raised. */ + getCashTransferRequests(status?: string, mine = false): Observable { + let params = new HttpParams().set('mine', String(mine)); + if (status) params = params.set('status', status); + return this.http.get('/cashtransfer/requests', { params }); + } + approveCashTransferRequest(id: number): Observable { + return this.http.post(`/cashtransfer/requests/${id}/approve`, {}); + } + rejectCashTransferRequest(id: number, reason: string): Observable { + return this.http.post(`/cashtransfer/requests/${id}/reject`, { reason }); } getCashTransfers(offset = 0, limit = 20): Observable { const params = new HttpParams().set('offset', offset).set('limit', limit); diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.html b/src/app/cashier/transaction-entry/transaction-entry.component.html index deda8f0e9..ffc38955f 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.html +++ b/src/app/cashier/transaction-entry/transaction-entry.component.html @@ -129,11 +129,16 @@
- + @for (b of branches; track b.id) { + + }
@if (row.typ === 'DD') { @@ -1584,11 +1589,16 @@
- + @for (b of branches; track b.id) { + + }
@if (row.typ === 'DD') { diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts index a1ffb6bd6..413652c93 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.ts +++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts @@ -1877,6 +1877,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { ngOnInit(): void { const d = businessToday(); this.today = `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`; + this.loadBranches(); this.addRow(); this.loadDenominations(); this.loadOpeningPosition(); @@ -2309,7 +2310,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { // anyway, just without the extra click to get there. const row: TxnRow = { typ: this.isClerk ? 'Withdrawal Slip' : 'Receipt', - br: 1, + br: this.defaultBranchId, trTp: '', acTyp: 'SD', acNo: '', @@ -2655,14 +2656,47 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { return !!this.selectedRow && Math.abs(this.denominationTotal - this.ddTargetTotal) < 0.005; } + /** + * The branches an entry can be posted against: the real offices of this installation. Read from the offices + * themselves rather than from a fixed list, so a branch that is added or renamed shows up here without a code change. + */ + branches: { id: number; label: string }[] = []; + + private loadBranches(): void { + const own = this.authService.getCredentials(); + const ownBranch = own?.officeId + ? [{ id: own.officeId, label: this.branchLabel(own.officeId, own.officeName) }] + : []; + // The office list is only asked for by a login allowed to read it. A cashier or clerk is not, and asking anyway + // makes the server refuse and the screen raise an "Unauthorized Request" popup; such a login works in its own + // office, which is the one branch it is offered. + const permissions = own?.permissions ?? []; + const mayReadOffices = permissions.includes('ALL_FUNCTIONS') || permissions.includes('READ_OFFICE'); + if (!mayReadOffices) { + this.branches = ownBranch; + return; + } + this.http + .get('/offices') + .pipe(catchError(() => of([]))) + .subscribe((list: any) => { + const offices: any[] = Array.isArray(list) ? list : []; + this.branches = + offices.length > 0 ? offices.map((o) => ({ id: o.id, label: this.branchLabel(o.id, o.name) })) : ownBranch; + }); + } + + private branchLabel(id: number, name: string | null | undefined): string { + return `${String(id).padStart(3, '0')} · ${name ?? 'Branch ' + id}`; + } + + /** The office the signed-in user works in, which a new entry is posted against unless they pick another. */ + get defaultBranchId(): number { + return this.authService.getCredentials()?.officeId ?? 1; + } + branchNameFor(branchId: number): string { - const map: Record = { - 1: '001 · Main', - 2: '002 · M.G. Road', - 14: '014 · Station Rd', - 27: '027 · Industrial' - }; - return map[branchId] || `Branch ${branchId}`; + return this.branches.find((b) => b.id === branchId)?.label ?? `Branch ${branchId}`; } /** diff --git a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.scss b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.scss index ad8feebef..54431fc8a 100644 --- a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.scss +++ b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.scss @@ -16,7 +16,7 @@ $green: #10b981; $amber: #f59e0b; .ac-container { - padding: 20px 24px; + padding: 12px 20px; max-width: 1440px; margin: 0 auto; } @@ -25,40 +25,40 @@ $amber: #f59e0b; display: flex; align-items: center; justify-content: space-between; - margin-bottom: 16px; + margin-bottom: 10px; gap: 12px; flex-wrap: wrap; &__left { display: flex; align-items: center; - gap: 12px; + gap: 10px; } &__icon { - width: 44px; - height: 44px; + width: 32px; + height: 32px; display: inline-flex; align-items: center; justify-content: center; - border-radius: 12px; + border-radius: 9px; background: linear-gradient(135deg, $blue 0%, $purple 100%); color: #fff; - font-size: 18px; + font-size: 14px; box-shadow: 0 2px 8px rgb(29 78 216 / 25%); } &__title { margin: 0; - font-size: 20px; + font-size: 16px; font-weight: 800; color: $navy; line-height: 1.1; } &__sub { - margin-top: 2px; - font-size: 12.5px; + margin-top: 1px; + font-size: 11px; color: #64748b; } } @@ -68,26 +68,26 @@ $amber: #f59e0b; hides most of them behind prev/next pagination arrows. Wrapping keeps every tab visible and one click away instead of a scroll-and-hunt. */ .ac-tabs { - margin-bottom: 20px; + margin-bottom: 12px; background: #fff; border: 1px solid #e2e8f0; - border-radius: 14px; - padding: 6px; + border-radius: 10px; + padding: 4px; box-shadow: 0 1px 3px rgb(15 23 42 / 4%); display: flex; flex-wrap: wrap; - gap: 4px; + gap: 2px; } .ac-tab { display: inline-flex; align-items: center; - gap: 8px; - height: 38px; - padding: 0 18px; - border-radius: 10px; + gap: 6px; + height: 28px; + padding: 0 11px; + border-radius: 8px; font-family: Inter, 'DM Sans', Roboto, sans-serif; - font-size: 13px; + font-size: 11.5px; font-weight: 600; letter-spacing: 0.01em; color: #64748b; @@ -99,7 +99,7 @@ $amber: #f59e0b; cursor: pointer; &__ic { - font-size: 13px; + font-size: 11px; color: inherit; } @@ -118,7 +118,7 @@ $amber: #f59e0b; @media (width <= 768px) { .ac-tab { - padding: 0 14px; - font-size: 12px; + padding: 0 10px; + font-size: 11px; } }