diff --git a/src/app/cashier/branch-day/branch-day.component.html b/src/app/cashier/branch-day/branch-day.component.html index fad8bf35e..9b97f565d 100644 --- a/src/app/cashier/branch-day/branch-day.component.html +++ b/src/app/cashier/branch-day/branch-day.component.html @@ -125,13 +125,102 @@

- Left blank, yesterday's closing figure carries forward automatically — which is what keeps the opening balance - from drifting. + Left blank with no notes/coins counted below, yesterday's closing figure carries forward automatically — which + is what keeps the opening balance from drifting. Counting the drawer below is what actually records the + note/coin stock the branch can check a vault handout against later.

+ +
+ + + + + + + + + + @if (notes.length) { + + + + } + @for (d of notes; track d.id) { + + + + + + } + @if (coins.length) { + + + + } + @for (d of coins; track d.id) { + + + + + + } + +
DenominationCountAmount
Bank Notes
+ {{ d.label }} + +
+ + + +
+
₹{{ subtotalFor(openCounts, d) | number: '1.2-2' }}
Coins
+ {{ d.label }} + +
+ + + +
+
₹{{ subtotalFor(openCounts, d) | number: '1.2-2' }}
+
+ @if (openDenomTotal > 0) { +

+ Denomination total: ₹{{ openDenomTotal | number: '1.2-2' }} +

+ } +
-
- + Cash drawn from vault - ₹{{ preview.vaultIn | number: '1.2-2' }} -
− Cash paid out (approved withdrawals) ₹{{ preview.withdrawals | number: '1.2-2' }}
-
- − Cash settled back to vault - ₹{{ preview.settledOut | number: '1.2-2' }} -
Cash the branch should be holding ₹{{ preview.expectedCash | number: '1.2-2' }}
+ @if (preview.vaultIn > 0 || preview.settledOut > 0) { +
+ Vault activity today — moves cash within the branch, not counted in the total above: ₹{{ + preview.vaultIn | number: '1.2-2' + }} + handed to a cashier, ₹{{ preview.settledOut | number: '1.2-2' }} settled back to the vault. +
+ } + + } + + + @if (eodLoading) { +

Fetching vault and cashier positions…

+ } + + + @if (branchTotalSummary; as bt) { +
+
+ account_balance_wallet + Branch Total (Vault + All Cashiers) +
+
+ Vault ₹{{ bt.vaultTotal | number: '1.2-2' }} + + Cashiers ₹{{ bt.cashierTotal | number: '1.2-2' }} + = ₹{{ bt.grandTotal | number: '1.2-2' }} + vs. book ₹{{ bt.expected | number: '1.2-2' }} +
+ @if (bt.difference === 0) { + Branch Tallied + } @else { + + {{ bt.difference > 0 ? 'Excess' : 'Short' }} ₹{{ absValue(bt.difference) | number: '1.2-2' }} + + } +
+ } + + @for (t of eodTellers; track t.tellerId) { +
+
+ account_balance + {{ t.tellerName }} + + vault holds + {{ t.vault?.hasBreakdown ? '₹' + (t.vault.totalAmount | number: '1.2-2') : 'no breakdown recorded' }} + +
+ + @if (t.vault?.hasBreakdown && t.vault.denominations.length > 0) { +
+ @for (d of t.vault.denominations; track d.denominationId) { + ₹{{ d.denominationValue }} × {{ d.count }} + } +
+ } + + @if (t.cashiers.length === 0) { +

No cashiers allocated under this vault.

+ } + @for (c of t.cashiers; track c.cashierId) { +
+
+ {{ c.cashierName }} + + morning ₹{{ c.morningTotal | number: '1.2-2' }} → current ₹{{ c.currentTotal | number: '1.2-2' }} · + {{ c.txnCount ?? '—' }} txn{{ c.txnCount === 1 ? '' : 's' }} today + +
+
+ @if (c.reconciled === true) { + Reconciled + } @else if (c.reconciled === false) { + Not Reconciled + } + @if (c.alreadySettled === true) { + Settled to Vault + } @else if (c.alreadySettled === false) { + Not Settled + } +
+ @if (c.denominations.length > 0) { +
+ @for (d of c.denominations; track d.denominationId) { + + ₹{{ d.denominationValue }}: {{ d.morningCount }} → {{ d.count }} + @if (d.delta !== 0) { + ({{ d.delta > 0 ? '+' : '' }}{{ d.delta }}) + } + + } +
+ } @else { + No balance. + } +
+ }
}
@if (preview && liveDifference !== null) { @if (liveDifference === 0) { @@ -222,9 +415,91 @@

Pre-filled with what the books expect — change it only if the drawer counted something different, and that - difference is then recorded against the day. + difference is then recorded against the day. Counting the denominations below overrides the typed figure with + what was actually counted, note by note.

+
+ + + + + + + + + + @if (notes.length) { + + + + } + @for (d of notes; track d.id) { + + + + + + } + @if (coins.length) { + + + + } + @for (d of coins; track d.id) { + + + + + + } + +
DenominationCountAmount
Bank Notes
+ {{ d.label }} + +
+ + + +
+
₹{{ subtotalFor(closeCounts, d) | number: '1.2-2' }}
Coins
+ {{ d.label }} + +
+ + + +
+
₹{{ subtotalFor(closeCounts, d) | number: '1.2-2' }}
+
+ @if (closeDenomTotal > 0) { +

+ Denomination total: ₹{{ closeDenomTotal | number: '1.2-2' }} +

+ } + @if (isLateClose) {
+ + @if (fromTellerId && !loadingVault) { + @if (vaultHasBreakdown) { +
+ account_balance + Vault available: ₹{{ vaultTotalAvailable | number: '1.2-2' }} +
+ } @else { +
+ warning + No denomination breakdown recorded for this vault — per-note availability can't be checked, only the total + opening balance. +
+ } + } +
+ @@ -71,14 +88,17 @@ @if (notes.length) { - + } @for (d of notes; track d.id) { - + + - + } @for (d of coins; track d.id) { - + + - + }
DenominationAvailable Count Amount
Bank NotesBank Notes
{{ d.label }} + {{ vaultHasBreakdown ? availableFor(d) : '—' }} +
@@ -91,14 +111,17 @@ } @if (coins.length) {
CoinsCoins
{{ d.label }} + {{ vaultHasBreakdown ? availableFor(d) : '—' }} +
@@ -111,13 +134,24 @@ } @if (denoms.length === 0) {
No active denominations. Add some in Cash Denomination.No active denominations. Add some in Cash Denomination.
+ @if (anyDenomExceedsAvailable) { +
+ One or more notes/coins above are counted higher than the vault currently holds — reduce them before recording the + transfer. +
+ } @else if (exceedsVaultTotal) { +
+ Total exceeds what the vault has available (₹{{ vaultTotalAvailable | number: '1.2-2' }}). +
+ } +
Total to transfer: ₹{{ total | number: '1.2-2' }}
@@ -145,7 +142,6 @@ (click)="$event.stopPropagation()" [class.ws-f__ctl--req]="!row.paymentTypeId" > - @for (pt of paymentTypesForRow(row); track pt.id) { } @@ -160,6 +156,7 @@ (change)=" row.acTyp === 'GL' && row.typ !== 'Disbursement' && + row.typ !== 'Fund Transfer' && row.typ !== 'Enter Bank Transfer' && (row.dbCr = 'Cr') " @@ -205,7 +202,10 @@ class="ws-seg__btn ws-seg__btn--dr" [class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'" [disabled]=" - row.acTyp === 'GL' && row.typ !== 'Disbursement' && row.typ !== 'Enter Bank Transfer' + row.acTyp === 'GL' && + row.typ !== 'Disbursement' && + row.typ !== 'Fund Transfer' && + row.typ !== 'Enter Bank Transfer' " (click)="row.dbCr = 'Dr'" > @@ -286,7 +286,7 @@ (click)="$event.stopPropagation()" />
- } @else if (row.typ !== 'Withdrawal Slip') { + } @else if (isClerk && row.typ !== 'Withdrawal Slip') {
@@ -1351,7 +1348,6 @@ [(ngModel)]="row.paymentTypeId" [class.ws-f__ctl--req]="!row.paymentTypeId" > - @for (pt of paymentTypesForRow(row); track pt.id) { } @@ -1365,6 +1361,7 @@ (change)=" row.acTyp === 'GL' && row.typ !== 'Disbursement' && + row.typ !== 'Fund Transfer' && row.typ !== 'Enter Bank Transfer' && (row.dbCr = 'Cr') " @@ -1406,7 +1403,10 @@ class="ws-seg__btn ws-seg__btn--dr" [class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'" [disabled]=" - row.acTyp === 'GL' && row.typ !== 'Disbursement' && row.typ !== 'Enter Bank Transfer' + row.acTyp === 'GL' && + row.typ !== 'Disbursement' && + row.typ !== 'Fund Transfer' && + row.typ !== 'Enter Bank Transfer' " (click)="$event.stopPropagation(); row.dbCr = 'Dr'" > @@ -1476,7 +1476,7 @@
- } @else { + } @else if (isClerk) {
diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts index d9ae66adb..e1bfb4d24 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.ts +++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts @@ -794,6 +794,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { */ paymentTypesForRow(row: TxnRow): any[] { const tellerFacing = this.paymentTypes.filter((pt: any) => !pt.isSystemDefined); + if (!this.isClerk) { + // Cashier counter takes cash only — cheque/transfer/DD instruments go through their own + // clerk/back-office approval flows, never straight through the cash drawer. + return tellerFacing.filter((pt: any) => !!pt.isCashPayment); + } if (row.acTyp === 'SHR') { return tellerFacing.filter((pt: any) => !pt.isCashPayment); } @@ -914,6 +919,34 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { 'T' ]; + private readonly clerkTypOptions: { value: string; label: string }[] = [ + { value: 'Withdrawal Slip', label: 'Withdrawal Slip' }, + { value: 'Cheque', label: 'Cheque' }, + { value: 'Disbursement', label: 'Disbursement' }, + // Mechanically identical to Disbursement (1 GL debit -> N account credits, same maker-checker + // double-entry flow) but labelled separately: "Disbursement" reads as a loan payout, which is + // wrong for crediting a GL amount into a savings/current account for any other reason (e.g. + // funding a newly-activated account's opening balance from a GL source). + { value: 'Fund Transfer', label: 'Fund Transfer' }, + // NOTE: the exact same TxnRow.typ string is used at every dispatch/default-setting site below — + // search for 'Disbursement' whenever this needs to change again, since 'Fund Transfer' always + // rides alongside it in every one of those checks rather than having its own separate case. + { value: 'Enter Bank Transfer', label: 'Enter Bank Transfer' }, + { value: 'DD', label: 'DD (Demand Draft)' } + ]; + + /** A cashier only ever takes cash in (Receipt) or pays cash out (Payment) — Cheque, DD, + * Disbursement and Bank Transfer are clerk/back-office instruments with their own approval + * flows and never appear at the cashier counter. */ + private readonly cashierTypOptions: { value: string; label: string }[] = [ + { value: 'Receipt', label: 'Receipt' }, + { value: 'Payment', label: 'Payment' } + ]; + + get typOptions(): { value: string; label: string }[] { + return this.isClerk ? this.clerkTypOptions : this.cashierTypOptions; + } + // ── Denomination ────────────────────────────────────────────────────────── notes: Denomination[] = []; coins: Denomination[] = []; @@ -943,7 +976,22 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { this.cashierService .getPaymentTypes() .pipe(catchError(() => of([]))) - .subscribe((types: any[]) => (this.paymentTypes = types ?? [])); + .subscribe((types: any[]) => { + this.paymentTypes = types ?? []; + // addRow() ran before this resolved, so the very first row's default TXN TP. (Cash, for + // Withdrawal Slip / Receipt / Payment) couldn't be looked up yet — backfill it now that + // the payment types are actually loaded. Only ever touches a row still sitting on the + // null it was created with, so this never overwrites anything already picked on screen. + for (const row of this.rows) { + if (!row.paymentTypeId && [ + 'Withdrawal Slip', + 'Receipt', + 'Payment' + ].includes(row.typ)) { + row.paymentTypeId = this.findCashPaymentTypeId(); + } + } + }); this.acNoInput$ .pipe( @@ -1234,8 +1282,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { addRow(): void { const active = this.selectedRow; - // Disbursement/Bank Transfer: auto-add mirror CR row when active row is DR - if ((active?.typ === 'Disbursement' || active?.typ === 'Enter Bank Transfer') && active.dbCr === 'Dr') { + // Disbursement/Fund Transfer/Bank Transfer: auto-add mirror CR row when active row is DR + if ( + (active?.typ === 'Disbursement' || active?.typ === 'Fund Transfer' || active?.typ === 'Enter Bank Transfer') && + active.dbCr === 'Dr' + ) { const mirror: TxnRow = { typ: active.typ, br: active.br ?? 1, @@ -1261,20 +1312,26 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } return; } + // Neither TYPE nor TXN TP. ever offers a blank/placeholder option any more, so a fresh row + // can't start in an unselected state either — it needs a real, valid TYPE from the moment it + // exists. Cashier: Receipt (money in) is the far more common starting point than Payment + // (money out, which needs an approved token first). Clerk: Withdrawal Slip, the plainest of + // the five original types — same TYPE the old blank-then-choose flow most often ended on + // anyway, just without the extra click to get there. const row: TxnRow = { - typ: '~', + typ: this.isClerk ? 'Withdrawal Slip' : 'Receipt', br: 1, trTp: '', acTyp: 'SD', acNo: '', ecsAcNo: '', - dbCr: '', + dbCr: this.isClerk ? 'Dr' : 'Cr', chequeNo: '', cheqDate: '', amount: null, description: '', tokNo: '', - paymentTypeId: null, + paymentTypeId: this.findCashPaymentTypeId(), bankName: '', routingCode: '', ddCharges: null @@ -1296,6 +1353,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { row.paymentTypeId = this.findCashPaymentTypeId(); break; case 'Disbursement': + case 'Fund Transfer': if (!row.dbCr) row.dbCr = 'Dr'; row.paymentTypeId = this.findTransferPaymentTypeId(); break; @@ -1303,6 +1361,21 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { if (!row.dbCr) row.dbCr = 'Dr'; row.paymentTypeId = this.findTransferPaymentTypeId(); break; + // Cashier-only: Receipt = cash coming in (Cr), Payment = cash going out (Dr). The cashier + // TXN TP. dropdown only ever offers Cash (see paymentTypesForRow()), so that's the only + // sensible default here. + case 'Receipt': + row.dbCr = 'Cr'; + row.paymentTypeId = this.findCashPaymentTypeId(); + row.chequeNo = ''; + row.cheqDate = ''; + break; + case 'Payment': + row.dbCr = 'Dr'; + row.paymentTypeId = this.findCashPaymentTypeId(); + row.chequeNo = ''; + row.cheqDate = ''; + break; } } @@ -1358,6 +1431,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { private submitDisbursementDoubleEntry(): void { const drRows = this.rows.filter((r) => r.dbCr === 'Dr'); const crRows = this.rows.filter((r) => r.dbCr === 'Cr'); + // Captured now, not re-read from this.rows inside the async success callback below — the rows + // array gets reset to a fresh blank row as soon as that callback runs. + const isFundTransfer = this.rows[0]?.typ === 'Fund Transfer'; const isCollectionMode = drRows.every((r) => r.acTyp !== 'GL') && crRows.length === 1 && crRows[0].acTyp === 'GL'; const isDisbursementMode = drRows.length === 1 && drRows[0].acTyp === 'GL'; @@ -1450,7 +1526,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { paymentTypeId: firstRow?.paymentTypeId, paymentTypeName: this.paymentTypes.find((p: any) => p.id === firstRow?.paymentTypeId)?.name ?? null, description: firstRow?.description || null, - creditEntries: JSON.stringify(entries) + creditEntries: JSON.stringify(entries), + // Without this the backend defaults every request from this screen to DISBURSEMENT — a + // Collection or Fund Transfer submitted here was being saved (and shown in the checker's + // inbox) as a plain Disbursement. + requestType: isCollectionMode ? 'COLLECTION' : isFundTransfer ? 'FUND_TRANSFER' : 'DISBURSEMENT' }; this.saving = true; @@ -1470,7 +1550,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { .subscribe((res: any) => { this.saving = false; if (res) { - const modeLabel = isCollectionMode ? 'Collection' : 'Disbursement'; + const modeLabel = isCollectionMode ? 'Collection' : isFundTransfer ? 'Fund Transfer' : 'Disbursement'; const summaryLine = isCollectionMode ? `DR: ${drRows.length} account(s) → CR: GL ${crRows[0].acNo}` : `DR: GL ${drRows[0].acNo} → CR: ${crRows.length} account(s)`; @@ -2953,8 +3033,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { return; } - // Clerk multi-row disbursement — handle before single-row guard (selected row may be incomplete) - if (this.isClerk && this.rows.length >= 2 && this.rows.every((r) => r.typ === 'Disbursement')) { + // Clerk multi-row disbursement — handle before single-row guard (selected row may be incomplete). + // Fund Transfer rides the exact same double-entry flow as Disbursement (see the TYPE options + // comment above) — only the label differs. + if ( + this.isClerk && + this.rows.length >= 2 && + this.rows.every((r) => r.typ === 'Disbursement' || r.typ === 'Fund Transfer') + ) { const drRows = this.rows.filter((r) => r.dbCr === 'Dr'); const crRows = this.rows.filter((r) => r.dbCr === 'Cr'); @@ -2996,7 +3082,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { return; } - const title = isCollectionMode ? 'Confirm Collection Request' : 'Confirm Disbursement Request'; + const isFundTransfer = this.rows[0]?.typ === 'Fund Transfer'; + const title = isCollectionMode + ? 'Confirm Collection Request' + : isFundTransfer + ? 'Confirm Fund Transfer Request' + : 'Confirm Disbursement Request'; const confirmLines = isCollectionMode ? [ `DR (${drRows.length} account${drRows.length !== 1 ? 's' : ''} to collect):`, ...drRows.map( diff --git a/src/app/cashier/vault-position/vault-position.component.html b/src/app/cashier/vault-position/vault-position.component.html new file mode 100644 index 000000000..4d2337845 --- /dev/null +++ b/src/app/cashier/vault-position/vault-position.component.html @@ -0,0 +1,134 @@ + + +
+
+
+
BRANCH OPERATIONS · VAULT POSITION
+

Vault Position

+

+ Denomination-wise stock a vault currently holds — the branch's own opening breakdown, plus whatever has been + settled back into it, minus whatever it has handed out to cashiers since. +

+
+
+ +
+ @if (tellers.length === 1) { + + Vault + + + } @else { + + Vault (optional) + + All Vaults + @for (t of tellers; track t.id) { + {{ t.name }} + } + + + } + + + As of Date + + + + +
+ + @if (errorMsg) { +
{{ errorMsg }}
+ } + + @if (loading) { +
Loading…
+ } + + @for (pos of results; track pos.tellerId) { +
+
+ {{ pos.tellerName || labelForTeller(pos.tellerId) }} + ₹{{ pos.totalAmount | number: '1.2-2' }} +
+ + @if (!pos.hasBreakdown) { +
+ No denomination breakdown was recorded for this vault's opening day on or before {{ pos.asOfDate }}. Open (or + re-open) the business day with a note/coin count to start tracking its stock. +
+ } @else { + @if (pos.anchorDate && pos.anchorDate !== pos.asOfDate) { +
+ Carried forward from the opening breakdown recorded on {{ pos.anchorDate }}. +
+ } + + @if (pos.denominations.length === 0) { +
No stock — everything opened for this vault has been handed out.
+ } + + @if (notes(pos).length > 0) { + + + + + + + + + + + @for (d of notes(pos); track d.denominationId) { + + + + + + } + +
DenominationCurrent CountAmount
+ ₹{{ d.denominationValue }} + {{ d.count }}₹{{ d.amount | number: '1.2-2' }}
+ } + + @if (coins(pos).length > 0) { + + + + + + + + + + + @for (d of coins(pos); track d.denominationId) { + + + + + + } + +
DenominationCurrent CountAmount
+ ₹{{ d.denominationValue }} + {{ d.count }}₹{{ d.amount | number: '1.2-2' }}
+ } + } +
+ } + + @if (!loading && results.length === 0 && !errorMsg) { +
Select filters and click "Load Position".
+ } +
diff --git a/src/app/cashier/vault-position/vault-position.component.ts b/src/app/cashier/vault-position/vault-position.component.ts new file mode 100644 index 000000000..d91cbdb2d --- /dev/null +++ b/src/app/cashier/vault-position/vault-position.component.ts @@ -0,0 +1,115 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatIconModule } from '@angular/material/icon'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; +import { MatInputModule } from '@angular/material/input'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; +import { CashierService } from '../cashier.service'; + +interface DenomRow { + denominationId: number; + denominationValue: number; + kind: string; + count: number; + amount: number; +} +interface VaultPos { + tellerId: number; + tellerName?: string; + asOfDate: string; + anchorDate?: string; + hasBreakdown: boolean; + totalAmount: number; + denominations: DenomRow[]; +} + +/** + * Denomination-wise stock a vault (teller) is currently holding — "how much of what does the bank + * have", the same question cashier-position answers for a cashier's own float. + */ +@Component({ + selector: 'mifosx-vault-position', + templateUrl: './vault-position.component.html', + styleUrls: [ + '../cashier-shared.scss', + '../cashier-position/cashier-position.component.scss' + ], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatIconModule, + MatFormFieldModule, + MatSelectModule, + MatInputModule + ] +}) +export class VaultPositionComponent implements OnInit { + private service = inject(CashierService); + + tellers: { id: number; name: string }[] = []; + selectedTellerId: number | null = null; + asOfDate = ''; + loading = false; + + results: VaultPos[] = []; + errorMsg = ''; + + ngOnInit(): void { + const d = new Date(); + this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + + this.service + .getTellers() + .pipe(catchError(() => of([] as any[]))) + .subscribe((list) => { + this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name })); + if (this.tellers.length === 1) { + this.selectedTellerId = this.tellers[0].id; + } + this.load(); + }); + } + + load(): void { + this.loading = true; + this.results = []; + this.errorMsg = ''; + this.service + .vaultPosition(this.selectedTellerId ?? undefined, this.asOfDate) + .pipe(catchError(() => of(null))) + .subscribe((data: any) => { + this.loading = false; + if (!data) { + this.errorMsg = 'Failed to load vault position.'; + return; + } + const arr: any[] = Array.isArray(data) ? data : [data]; + this.results = arr; + }); + } + + labelForTeller(id: number): string { + const t = this.tellers.find((x) => x.id === id); + return t ? t.name : `Vault #${id}`; + } + + notes(pos: VaultPos): DenomRow[] { + return pos.denominations.filter((d) => d.kind !== 'coin'); + } + + coins(pos: VaultPos): DenomRow[] { + return pos.denominations.filter((d) => d.kind === 'coin'); + } +} diff --git a/src/app/clerk/disbursement-review/disbursement-review.component.html b/src/app/clerk/disbursement-review/disbursement-review.component.html index f9aa574ce..7ad308fd8 100644 --- a/src/app/clerk/disbursement-review/disbursement-review.component.html +++ b/src/app/clerk/disbursement-review/disbursement-review.component.html @@ -6,16 +6,21 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -
- +
+ diff --git a/src/app/clerk/disbursement-review/disbursement-review.component.scss b/src/app/clerk/disbursement-review/disbursement-review.component.scss index c3a42906e..a95e0293a 100644 --- a/src/app/clerk/disbursement-review/disbursement-review.component.scss +++ b/src/app/clerk/disbursement-review/disbursement-review.component.scss @@ -36,6 +36,16 @@ $shadow-md: 0 4px 16px rgb(0 0 0 / 9%); flex-direction: column; min-height: 100vh; background: $bg-page; + + /* Opened as a popup rather than a full page — sizes to its own content (capped so a long + entries table still scrolls inside the dialog) instead of stretching to the viewport. */ + &--dialog { + min-height: 0; + max-height: 85vh; + overflow-y: auto; + width: min(760px, 92vw); + border-radius: 10px; + } } // ── Nav bar ──────────────────────────────────────────────────────────────── @@ -48,29 +58,6 @@ $shadow-md: 0 4px 16px rgb(0 0 0 / 9%); border-bottom: 1px solid $border; box-shadow: $shadow-sm; - &__item { - display: inline-flex; - align-items: center; - gap: 7px; - padding: 7px 16px; - border-radius: 8px; - font-size: 13px; - font-weight: 600; - border: none; - cursor: pointer; - transition: all 0.15s; - - &--primary { - background: $brand-light; - color: $brand; - } - - &--active { - background: $brand; - color: #fff; - } - } - &__esc-hint { margin-left: auto; font-size: 12px; @@ -88,6 +75,12 @@ $shadow-md: 0 4px 16px rgb(0 0 0 / 9%); } } +.dri-header-title { + font-size: 14px; + font-weight: 700; + color: $text-mid; +} + // ── Loading / empty ──────────────────────────────────────────────────────── .dri-loading { display: flex; diff --git a/src/app/clerk/disbursement-review/disbursement-review.component.ts b/src/app/clerk/disbursement-review/disbursement-review.component.ts index 82d37434e..82d96762f 100644 --- a/src/app/clerk/disbursement-review/disbursement-review.component.ts +++ b/src/app/clerk/disbursement-review/disbursement-review.component.ts @@ -12,6 +12,7 @@ import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatIconModule } from '@angular/material/icon'; +import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; import { catchError } from 'rxjs/operators'; import { of } from 'rxjs'; @@ -33,7 +34,8 @@ interface CreditEntry { CommonModule, FormsModule, MatSnackBarModule, - MatIconModule + MatIconModule, + MatDialogModule ], templateUrl: './disbursement-review.component.html', styleUrl: './disbursement-review.component.scss' @@ -43,6 +45,11 @@ export class DisbursementReviewComponent implements OnInit { private router = inject(Router); private service = inject(ClerkDisbursementService); private snackBar = inject(MatSnackBar); + /** Present only when opened as a popup from the requests list; absent when reached by direct + * route (e.g. a bookmarked/shared link). Both entry points stay supported — this only changes + * how "done" is signalled: closing the dialog vs. navigating back to the inbox route. */ + private dialogRef = inject(MatDialogRef, { optional: true }); + private dialogData = inject<{ requestId: number } | null>(MAT_DIALOG_DATA, { optional: true }); requestId: number | null = null; request: any = null; @@ -53,9 +60,17 @@ export class DisbursementReviewComponent implements OnInit { showRejectForm = false; rejectionReason = ''; + get isDialog(): boolean { + return !!this.dialogRef; + } + ngOnInit(): void { - const id = this.route.snapshot.params['id']; - this.requestId = id != null ? Number(id) : null; + if (this.dialogData?.requestId) { + this.requestId = this.dialogData.requestId; + } else { + const id = this.route.snapshot.params['id']; + this.requestId = id != null ? Number(id) : null; + } if (this.requestId) this.loadRequest(); } @@ -93,9 +108,11 @@ export class DisbursementReviewComponent implements OnInit { } else { const msg = this.isBankTransferMode ? 'Bank transfer approved successfully.' - : 'Disbursement approved successfully.'; + : this.isFundTransferMode + ? 'Fund transfer approved successfully.' + : 'Disbursement approved successfully.'; this.snackBar.open(msg, 'Close', { duration: 3000 }); - this.router.navigate(['/checker-inbox-and-tasks/disbursement-requests']); + this.finish(); } }); } @@ -112,15 +129,31 @@ export class DisbursementReviewComponent implements OnInit { this.snackBar.open(res.error, 'Close', { duration: 5000 }); } else { this.snackBar.open('Request rejected.', 'Close', { duration: 3000 }); - this.router.navigate(['/checker-inbox-and-tasks/disbursement-requests']); + this.finish(); } }); } + /** "Done" for this screen — closes the popup (telling the list to refresh) when opened as a + * dialog, or returns to the inbox route otherwise. */ + private finish(): void { + if (this.dialogRef) { + this.dialogRef.close({ refreshed: true }); + } else { + this.router.navigate(['/checker-inbox-and-tasks/disbursement-requests']); + } + } + get isBankTransferMode(): boolean { return this.request?.request_type === 'BANK_TRANSFER'; } + /** Mechanically identical to a Disbursement (same 1 GL debit -> N account credits flow) — only + * the label differs, so a checker doesn't read this as a loan payout. */ + get isFundTransferMode(): boolean { + return this.request?.request_type === 'FUND_TRANSFER'; + } + get isCollectionMode(): boolean { if (this.isBankTransferMode) return false; return this.creditEntries.some((e) => e.dbCr === 'Dr'); @@ -189,6 +222,10 @@ export class DisbursementReviewComponent implements OnInit { } backToInbox(): void { - this.router.navigate(['/checker-inbox-and-tasks/disbursement-requests']); + if (this.dialogRef) { + this.dialogRef.close(); + } else { + this.router.navigate(['/checker-inbox-and-tasks/disbursement-requests']); + } } } diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index d124305c7..95f7c3741 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -700,7 +700,7 @@
Audit & Compliance
- @@ -1032,6 +1032,9 @@ + + +
+
+ +

Branch Profile

+
+ + @if (errorMsg) { +
{{ errorMsg }}
+ } + @if (successMsg) { +
{{ successMsg }}
+ } + +
+
+
+ +

Select Branch

+
+
+ @if (loadingOffices) { + Loading branches… + } @else { + + Branch + + @for (o of offices; track o.officeId) { + {{ officeLabel(o) }} + } + + + } +
+
+ + @if (loadingProfile) { +
Loading…
+ } + + @if (selectedOfficeId && !loadingProfile) { +
+
+
+ +

Branch Details

+
+
+ + Branch Code + + + + Branch Type + + @for (t of branchTypes; track t.value) { + {{ t.label }} + } + + + + Address + + + + Contact Phone + + + + Contact Email + + +
+
+ +
+
+ +

Branch Identifiers

+
+
+ + IFSC Code + + + + MICR Code + + +
+

+ SWIFT/BIC is set once for the whole bank, not per branch — see + Bank Profile. +

+
+ +
+
+ +

Location (optional)

+
+
+ + Latitude + + + + Longitude + + +
+
+ +
+
+ +

Working Hours

+
+
+ + Opens At + + + + Closes At + + + + Working Days + + +
+
+ + @if (lastUpdated) { +
Last updated by {{ lastUpdated.by }} · {{ lastUpdated.at | dateFormat }}
+ } + +
+ +
+
+ } +
+
diff --git a/src/app/organization/branch-profile/branch-profile.component.scss b/src/app/organization/branch-profile/branch-profile.component.scss new file mode 100644 index 000000000..200f99b04 --- /dev/null +++ b/src/app/organization/branch-profile/branch-profile.component.scss @@ -0,0 +1,175 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +$primary: #1353a0; +$accent: #0ea5e9; +$border: #e2e8f0; +$gray-50: #f8f9fa; +$gray-200: #e8eaed; +$gray-500: #9aa0a6; +$gray-900: #202124; + +.bp-container { + max-width: 760px; + margin: 0 auto; + padding: 20px 16px; +} + +.bp-header { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 20px; + + &__title { + font-size: 18px; + font-weight: 600; + color: $gray-900; + margin: 0; + } +} + +.bp-back { + border-radius: 8px !important; + font-size: 12px !important; + font-weight: 600 !important; + height: 32px !important; + border-color: $border !important; + color: $gray-500 !important; + + i { + margin-right: 6px; + } + + &:hover { + color: $primary !important; + border-color: $primary !important; + } +} + +.bp-alert { + padding: 10px 14px; + margin-bottom: 14px; + border-radius: 8px; + font-size: 13px; + + &--error { + background: #fef2f2; + border: 1px solid #fecaca; + color: #991b1b; + } + + &--success { + background: #f0fdf4; + border: 1px solid #bbf7d0; + color: #14532d; + } +} + +.bp-muted { + color: $gray-500; + font-size: 13px; +} + +.bp-loading { + padding: 24px; + text-align: center; + color: $gray-500; +} + +.bp-card { + background: #fff; + border: 1px solid $border; + border-radius: 10px; + overflow: hidden; +} + +.bp-section { + &__header { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 24px; + background: $gray-50; + border-bottom: 1px solid $gray-200; + + i { + font-size: 14px; + color: $accent; + } + + h3 { + margin: 0; + font-size: 14px; + font-weight: 600; + color: $gray-900; + } + } +} + +.bp-body { + padding: 20px 24px; + display: flex; + flex-wrap: wrap; + gap: 0 16px; +} + +.bp-field { + width: 100%; + margin-bottom: 4px; + + &--half { + width: calc(50% - 8px); + } + + &--third { + width: calc(33.33% - 11px); + } +} + +.bp-note { + margin: 0 24px 16px; + padding: 8px 12px; + background: #eff6ff; + border: 1px solid #bfdbfe; + border-radius: 6px; + font-size: 11px; + color: #1e40af; + + a { + color: $primary; + font-weight: 600; + } +} + +.bp-lastupdated { + padding: 0 24px 10px; + font-size: 11px; + color: $gray-500; +} + +.bp-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 14px 24px; + border-top: 1px solid $gray-200; + background: $gray-50; + + &__submit { + border-radius: 8px !important; + font-size: 13px !important; + font-weight: 600 !important; + height: 36px !important; + box-shadow: 0 1px 3px rgba($primary, 0.3) !important; + + i { + margin-right: 6px; + font-size: 12px; + } + } +} diff --git a/src/app/organization/branch-profile/branch-profile.component.ts b/src/app/organization/branch-profile/branch-profile.component.ts new file mode 100644 index 000000000..af14910ff --- /dev/null +++ b/src/app/organization/branch-profile/branch-profile.component.ts @@ -0,0 +1,151 @@ +/** + * 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 { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { catchError, of } from 'rxjs'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { OrganizationService } from '../organization.service'; + +const BRANCH_TYPES = [ + { value: 'HEAD_OFFICE', label: 'Head Office' }, + { value: 'BRANCH', label: 'Branch' }, + { value: 'REGIONAL_OFFICE', label: 'Regional Office' }, + { value: 'ZONAL_OFFICE', label: 'Zonal Office' } +]; + +/** + * The real-world operational fields behind a branch — code, address, contact, type and working + * hours — kept as their own screen on top of the stock Manage Offices flow, since m_office (the + * core Fineract entity) was deliberately left untouched. Pick an office, fill in what real-world + * UCB branch setup expects, save. + */ +@Component({ + selector: 'mifosx-branch-profile', + templateUrl: './branch-profile.component.html', + styleUrls: ['./branch-profile.component.scss'], + imports: [...STANDALONE_SHARED_IMPORTS] +}) +export class BranchProfileComponent implements OnInit { + private formBuilder = inject(UntypedFormBuilder); + private organizationService = inject(OrganizationService); + + branchTypes = BRANCH_TYPES; + + offices: any[] = []; + selectedOfficeId: number | null = null; + form: UntypedFormGroup; + + loadingOffices = true; + loadingProfile = false; + saving = false; + errorMsg = ''; + successMsg = ''; + lastUpdated: any = null; + + ngOnInit(): void { + this.form = this.formBuilder.group({ + branchCode: [''], + branchType: [null], + address: [''], + contactPhone: [''], + contactEmail: [''], + workingHoursStart: [''], + workingHoursEnd: [''], + workingDays: [''], + ifscCode: [''], + micrCode: [''], + latitude: [null], + longitude: [null] + }); + + this.organizationService + .getBranchProfiles() + .pipe( + catchError((err: any) => { + this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not load branches.'; + return of([]); + }) + ) + .subscribe((res: any[]) => { + this.loadingOffices = false; + this.offices = res || []; + if (this.offices.length === 1) { + this.selectOffice(this.offices[0].officeId); + } + }); + } + + selectOffice(officeId: number): void { + this.selectedOfficeId = officeId; + this.loadingProfile = true; + this.errorMsg = ''; + this.successMsg = ''; + this.organizationService + .getBranchProfile(officeId) + .pipe( + catchError((err: any) => { + this.loadingProfile = false; + this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not load this branch.'; + return of(null); + }) + ) + .subscribe((res: any) => { + this.loadingProfile = false; + if (!res) return; + this.form.patchValue({ + branchCode: res.branchCode ?? '', + branchType: res.branchType ?? null, + address: res.address ?? '', + contactPhone: res.contactPhone ?? '', + contactEmail: res.contactEmail ?? '', + workingHoursStart: res.workingHoursStart ?? '', + workingHoursEnd: res.workingHoursEnd ?? '', + workingDays: res.workingDays ?? '', + ifscCode: res.ifscCode ?? '', + micrCode: res.micrCode ?? '', + latitude: res.latitude ?? null, + longitude: res.longitude ?? null + }); + this.lastUpdated = res.updatedByUsername ? { by: res.updatedByUsername, at: res.updatedAt } : null; + this.form.markAsPristine(); + // Reflect the just-loaded office in the list so the selector stays accurate without a reload. + const idx = this.offices.findIndex((o) => o.officeId === officeId); + if (idx >= 0) this.offices[idx] = res; + }); + } + + officeLabel(o: any): string { + return o.branchCode ? `${o.officeName} (${o.branchCode})` : o.officeName; + } + + submit(): void { + if (this.saving || !this.selectedOfficeId) return; + this.saving = true; + this.errorMsg = ''; + this.successMsg = ''; + this.organizationService + .updateBranchProfile(this.selectedOfficeId, this.form.value) + .pipe( + catchError((err: any) => { + this.saving = false; + this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not save this branch.'; + return of(null); + }) + ) + .subscribe((res: any) => { + this.saving = false; + if (!res) return; + this.successMsg = 'Branch profile saved.'; + this.form.markAsPristine(); + this.lastUpdated = res.updatedByUsername ? { by: res.updatedByUsername, at: res.updatedAt } : null; + const idx = this.offices.findIndex((o) => o.officeId === this.selectedOfficeId); + if (idx >= 0) this.offices[idx] = res; + }); + } +} diff --git a/src/app/organization/clearing-gl-config/clearing-gl-config.component.html b/src/app/organization/clearing-gl-config/clearing-gl-config.component.html new file mode 100644 index 000000000..cca3e2d3a --- /dev/null +++ b/src/app/organization/clearing-gl-config/clearing-gl-config.component.html @@ -0,0 +1,70 @@ + + +
+
+ +

Clearing GL Configuration

+
+

+ Which GL account backs each clearing role. An unmapped role posts nowhere — map it here before that clearing channel + goes live. +

+ + @if (errorMsg) { +
{{ errorMsg }}
+ } + @if (successMsg) { +
{{ successMsg }}
+ } + + @if (loading) { +
Loading…
+ } @else { +
+ + + + + + + + + + @for (row of rows; track row.configKey) { + + + + + + } + +
Clearing RoleDescriptionMapped GL Account
+ {{ row.configKey }} + {{ row.description }} + + + — Not mapped — + @for (o of glAccountOptions; track o.id) { + {{ o.glCode }} · {{ o.name }} + } + + + @if (!row.glAccountId) { + Unmapped + } +
+
+ } +
diff --git a/src/app/organization/clearing-gl-config/clearing-gl-config.component.scss b/src/app/organization/clearing-gl-config/clearing-gl-config.component.scss new file mode 100644 index 000000000..a231ce25e --- /dev/null +++ b/src/app/organization/clearing-gl-config/clearing-gl-config.component.scss @@ -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/. + */ +$primary: #1353a0; +$border: #e2e8f0; +$gray-50: #f8f9fa; +$gray-200: #e8eaed; +$gray-500: #9aa0a6; +$gray-900: #202124; + +.cgc-container { + max-width: 900px; + margin: 0 auto; + padding: 20px 16px; +} + +.cgc-header { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 6px; + + &__title { + font-size: 18px; + font-weight: 600; + color: $gray-900; + margin: 0; + } +} + +.cgc-sub { + margin: 0 0 16px; + font-size: 12px; + color: $gray-500; +} + +.cgc-back { + border-radius: 8px !important; + font-size: 12px !important; + font-weight: 600 !important; + height: 32px !important; + border-color: $border !important; + color: $gray-500 !important; + + i { + margin-right: 6px; + } + + &:hover { + color: $primary !important; + border-color: $primary !important; + } +} + +.cgc-alert { + padding: 10px 14px; + margin-bottom: 14px; + border-radius: 8px; + font-size: 13px; + + &--error { + background: #fef2f2; + border: 1px solid #fecaca; + color: #991b1b; + } + + &--success { + background: #f0fdf4; + border: 1px solid #bbf7d0; + color: #14532d; + } +} + +.cgc-loading { + padding: 24px; + text-align: center; + color: $gray-500; +} + +.cgc-card { + background: #fff; + border: 1px solid $border; + border-radius: 10px; + overflow: hidden; +} + +.cgc-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + th { + text-align: left; + padding: 10px 16px; + background: $gray-50; + border-bottom: 1px solid $gray-200; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: $gray-500; + } + + td { + padding: 10px 16px; + border-bottom: 1px solid $gray-200; + vertical-align: middle; + } + + tr:last-child td { + border-bottom: none; + } +} + +.cgc-key { + font-family: monospace; + font-weight: 700; + color: $primary; +} + +.cgc-desc { + color: #475569; +} + +.cgc-select { + min-width: 260px; +} + +.cgc-unmapped { + margin-left: 8px; + font-size: 11px; + font-weight: 700; + color: #b45309; +} diff --git a/src/app/organization/clearing-gl-config/clearing-gl-config.component.ts b/src/app/organization/clearing-gl-config/clearing-gl-config.component.ts new file mode 100644 index 000000000..1bb04a305 --- /dev/null +++ b/src/app/organization/clearing-gl-config/clearing-gl-config.component.ts @@ -0,0 +1,75 @@ +/** + * 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 { catchError, forkJoin, of } from 'rxjs'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { OrganizationService } from '../organization.service'; + +/** + * Which GL account backs each clearing role (CTS inward/outward suspense, NACH suspense, RTGS + * settlement, ...). The roles themselves are fixed; this only changes which GL account each one + * points at. + */ +@Component({ + selector: 'mifosx-clearing-gl-config', + templateUrl: './clearing-gl-config.component.html', + styleUrls: ['./clearing-gl-config.component.scss'], + imports: [...STANDALONE_SHARED_IMPORTS] +}) +export class ClearingGlConfigComponent implements OnInit { + private organizationService = inject(OrganizationService); + + rows: any[] = []; + glAccountOptions: any[] = []; + loading = true; + savingKey: string | null = null; + errorMsg = ''; + successMsg = ''; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.errorMsg = ''; + forkJoin({ + rows: this.organizationService.getClearingGlConfig().pipe(catchError(() => of([]))), + template: this.organizationService.getClearingGlConfigTemplate().pipe(catchError(() => of(null))) + }).subscribe(({ rows, template }: any) => { + this.loading = false; + this.rows = rows || []; + this.glAccountOptions = template?.glAccountOptions || []; + if (!rows || rows.length === 0) { + this.errorMsg = 'Could not load clearing GL configuration.'; + } + }); + } + + onSelect(row: any, glAccountId: number | null): void { + this.savingKey = row.configKey; + this.errorMsg = ''; + this.successMsg = ''; + this.organizationService + .updateClearingGlConfig(row.configKey, glAccountId) + .pipe( + catchError((err: any) => { + this.savingKey = null; + this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not save this mapping.'; + return of(null); + }) + ) + .subscribe((res: any[]) => { + this.savingKey = null; + if (!res) return; + this.rows = res; + this.successMsg = `${row.configKey} updated.`; + }); + } +} diff --git a/src/app/organization/institution-profile/institution-profile.component.html b/src/app/organization/institution-profile/institution-profile.component.html new file mode 100644 index 000000000..4732fdd0d --- /dev/null +++ b/src/app/organization/institution-profile/institution-profile.component.html @@ -0,0 +1,392 @@ + + +
+
+ +

Bank Profile

+
+ + @if (errorMsg) { +
{{ errorMsg }}
+ } + @if (successMsg) { +
{{ successMsg }}
+ } + + @if (loading) { +
Loading…
+ } @else { +
+
+ +
+
+ +

Basic Information

+
+
+ + Bank / Institution Name + + + + Short Name / Abbreviation + + + + Legal / Registered Name + + + + Bank Code + + + + Institution Code + + + + Institution Type + + @for (t of bankTypes; track t.value) { + {{ t.label }} + } + + + + Ownership Type + + @for (o of ownershipTypes; track o.value) { + {{ o.label }} + } + + + + Parent Institution (if any) + + + + Established Date + + + + + + Status + + @for (s of statuses; track s.value) { + {{ s.label }} + } + + + + Country + + + + Currency + + Set once for the whole system, not editable here + +
+
+ + +
+
+ +

Regulatory Information

+
+
+ + Regulatory Authority + + + + Registration Date + + + + + + Registration Number + + + + License Number + + + + License Status + + @for (l of licenseStatuses; track l.value) { + {{ l.label }} + } + + + + License Expiry Date + + + + + + PAN + + + + TAN + + + + GSTIN + + +
+
+ + +
+
+ +

Registered Office Address

+
+
+ + Address Line 1 + + + + Address Line 2 + + + + City + + + + District + + + + State + + + + PIN Code + + +
+
+ +
+
+ +

Head Office / Corporate Address

+
+
+ + Same as Registered Office Address + + @if (!form.value.headOfficeSameAsRegistered) { + + Head Office Address + + + } +
+
+ +
+
+ +

Communication Address

+
+
+ + Same as Registered Office Address + + @if (!form.value.communicationSameAsRegistered) { + + Communication Address + + + } +
+
+ + +
+
+ +

Contact Details

+
+
+ + Official Email + + + + Phone Number + + + + Customer Care Number + + + + Grievance / Support Email + + + + Website + + +
+
+ + +
+
+ +

Banking Configuration

+
+
+

+ Business date, financial year, time zone, working days and the holiday calendar are configured on their + own screens rather than duplicated here, so there is exactly one place each is set: +

+ +
+
+ + +
+
+ +

Banking Identifiers

+
+
+ + SWIFT / BIC + + +
+

+ IFSC and MICR are branch-level identifiers, not institution-level — a bank with multiple branches has a + different IFSC/MICR per branch, so those are set on each branch's own + Branch Profile instead. +

+
+ + +
+
+ +

Document Headers

+
+
+ + Statement Header Name + + + + Passbook Header Name + + + + Receipt Header Name + + + + Terms & Conditions + + +
+
+ + +
+
+ +

Authorized Representative

+
+
+ + Name + + + + Designation + + + + Contact Number + + + + Official Email + + +
+
+ + + @if (audit) { +
+ @if (audit.createdBy) { + Created by {{ audit.createdBy }} · {{ audit.createdAt | dateFormat }} + } + @if (audit.updatedBy) { + Last updated by {{ audit.updatedBy }} · {{ audit.updatedAt | dateFormat }} + } + @if (audit.activationDate) { + Activated {{ audit.activationDate }} + } + @if (audit.deactivationDate) { + Deactivated {{ audit.deactivationDate }} + } +
+ } + +
+ +
+
+
+ } +
diff --git a/src/app/organization/institution-profile/institution-profile.component.scss b/src/app/organization/institution-profile/institution-profile.component.scss new file mode 100644 index 000000000..7b30182a4 --- /dev/null +++ b/src/app/organization/institution-profile/institution-profile.component.scss @@ -0,0 +1,226 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +$primary: #1353a0; +$accent: #0ea5e9; +$border: #e2e8f0; +$gray-50: #f8f9fa; +$gray-200: #e8eaed; +$gray-500: #9aa0a6; +$gray-700: #5f6368; +$gray-900: #202124; + +.ip-container { + max-width: 760px; + margin: 0 auto; + padding: 20px 16px; +} + +.ip-header { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 20px; + + &__title { + font-size: 18px; + font-weight: 600; + color: $gray-900; + margin: 0; + } +} + +.ip-back { + border-radius: 8px !important; + font-size: 12px !important; + font-weight: 600 !important; + height: 32px !important; + border-color: $border !important; + color: $gray-500 !important; + + i { + margin-right: 6px; + } + + &:hover { + color: $primary !important; + border-color: $primary !important; + } +} + +.ip-alert { + padding: 10px 14px; + margin-bottom: 14px; + border-radius: 8px; + font-size: 13px; + + &--error { + background: #fef2f2; + border: 1px solid #fecaca; + color: #991b1b; + } + + &--success { + background: #f0fdf4; + border: 1px solid #bbf7d0; + color: #14532d; + } +} + +.ip-loading { + padding: 24px; + text-align: center; + color: $gray-500; +} + +.ip-card { + background: #fff; + border: 1px solid $border; + border-radius: 10px; + overflow: hidden; +} + +.ip-section { + &__header { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 24px; + background: $gray-50; + border-bottom: 1px solid $gray-200; + + i { + font-size: 14px; + color: $accent; + } + + h3 { + margin: 0; + font-size: 14px; + font-weight: 600; + color: $gray-900; + } + } +} + +.ip-body { + padding: 20px 24px; + display: flex; + flex-wrap: wrap; + gap: 0 16px; +} + +.ip-field { + width: 100%; + margin-bottom: 4px; + + &--half { + width: calc(50% - 8px); + } + + &--third { + width: calc(33.33% - 11px); + } + + &--quarter { + width: calc(25% - 12px); + } +} + +.ip-note { + margin: 0 24px 16px; + padding: 8px 12px; + background: #fffbeb; + border: 1px solid #fde68a; + border-radius: 6px; + font-size: 11px; + color: #92400e; + + a { + color: $primary; + font-weight: 600; + } +} + +.ip-checkbox { + display: block; + margin-bottom: 8px; + font-size: 13px; +} + +.ip-refnote { + margin: 0 0 10px; + font-size: 12px; + color: #64748b; +} + +.ip-reflinks { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.ip-reflink { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: #fff; + border: 1px solid $border; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + color: $primary; + text-decoration: none; + + &:hover { + background: #eff6ff; + border-color: $primary; + } + + i { + font-size: 11px; + color: $accent; + } +} + +.ip-lastupdated { + padding: 0 24px 10px; + font-size: 11px; + color: $gray-500; +} + +.ip-audit { + display: flex; + flex-wrap: wrap; + gap: 4px 16px; + padding: 8px 24px 10px; + font-size: 11px; + color: $gray-500; +} + +.ip-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 14px 24px; + border-top: 1px solid $gray-200; + background: $gray-50; + + &__submit { + border-radius: 8px !important; + font-size: 13px !important; + font-weight: 600 !important; + height: 36px !important; + box-shadow: 0 1px 3px rgba($primary, 0.3) !important; + + i { + margin-right: 6px; + font-size: 12px; + } + } +} diff --git a/src/app/organization/institution-profile/institution-profile.component.ts b/src/app/organization/institution-profile/institution-profile.component.ts new file mode 100644 index 000000000..41e8b41e5 --- /dev/null +++ b/src/app/organization/institution-profile/institution-profile.component.ts @@ -0,0 +1,208 @@ +/** + * 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 { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { catchError, of } from 'rxjs'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { OrganizationService } from '../organization.service'; +import { SettingsService } from 'app/settings/settings.service'; + +const BANK_TYPES = [ + { value: 'COOPERATIVE', label: 'Co-operative Bank' }, + { value: 'COMMERCIAL', label: 'Commercial Bank' }, + { value: 'RRB', label: 'Regional Rural Bank' }, + { value: 'NBFC', label: 'NBFC' }, + { value: 'OTHER', label: 'Other' } +]; + +const OWNERSHIP_TYPES = [ + { value: 'GOVERNMENT', label: 'Government' }, + { value: 'PRIVATE', label: 'Private' }, + { value: 'COOPERATIVE', label: 'Cooperative' }, + { value: 'OTHER', label: 'Other' } +]; + +const LICENSE_STATUSES = [ + { value: 'ACTIVE', label: 'Active' }, + { value: 'EXPIRED', label: 'Expired' }, + { value: 'SUSPENDED', label: 'Suspended' }, + { value: 'NOT_APPLICABLE', label: 'Not Applicable' } +]; + +const STATUSES = [ + { value: 'ACTIVE', label: 'Active' }, + { value: 'INACTIVE', label: 'Inactive' } +]; + +/** + * The bank's own "Bank Master" profile — identity/classification, regulatory details, address + * (registered, with optional head-office/communication overrides), contact, the institution-level + * banking identifier (SWIFT/BIC), document header names and terms, the authorised representative, + * and audit fields. A singleton screen: there is only ever one institution running this tenant. + * + * IFSC/MICR are deliberately NOT here — those identify a specific branch, not the institution, and + * live on Branch Profile instead. Business Date, Financial Year, Time Zone, Working Days and Holiday + * Calendar are likewise not stored here — they already exist as their own configuration, and this + * screen only links to them (see the Operational Configuration section) instead of holding a second, + * driftable copy. + */ +@Component({ + selector: 'mifosx-institution-profile', + templateUrl: './institution-profile.component.html', + styleUrls: ['./institution-profile.component.scss'], + imports: [...STANDALONE_SHARED_IMPORTS] +}) +export class InstitutionProfileComponent implements OnInit { + private formBuilder = inject(UntypedFormBuilder); + private organizationService = inject(OrganizationService); + private settingsService = inject(SettingsService); + + bankTypes = BANK_TYPES; + ownershipTypes = OWNERSHIP_TYPES; + licenseStatuses = LICENSE_STATUSES; + statuses = STATUSES; + + form: UntypedFormGroup; + loading = true; + saving = false; + errorMsg = ''; + successMsg = ''; + currencyCode = ''; + audit: any = null; + + private static readonly DATE_FIELDS = [ + 'establishedDate', + 'registrationDate', + 'licenseExpiryDate' + ]; + + ngOnInit(): void { + this.form = this.formBuilder.group({ + bankName: [''], + legalName: [''], + shortName: [''], + bankCode: [''], + institutionCode: [''], + bankType: [null], + ownershipType: [null], + parentInstitutionName: [''], + establishedDate: [null], + status: ['ACTIVE'], + country: ['India'], + + registrationNumber: [''], + licenseNumber: [''], + regulatoryAuthority: [''], + registrationDate: [null], + licenseStatus: [null], + licenseExpiryDate: [null], + pan: [''], + tan: [''], + gstin: [''], + + addressLine1: [''], + addressLine2: [''], + city: [''], + district: [''], + state: [''], + pincode: [''], + headOfficeSameAsRegistered: [true], + headOfficeAddress: [''], + communicationSameAsRegistered: [true], + communicationAddress: [''], + + contactPhone: [''], + contactEmail: [''], + customerCareNumber: [''], + grievanceEmail: [''], + website: [''], + + swiftBic: [''], + + statementHeaderName: [''], + passbookHeaderName: [''], + receiptHeaderName: [''], + termsAndConditions: [''], + + authorizedPersonName: [''], + authorizedPersonDesignation: [''], + authorizedPersonContact: [''], + authorizedPersonEmail: [''] + }); + this.load(); + } + + load(): void { + this.loading = true; + this.errorMsg = ''; + this.organizationService + .getInstitutionProfile() + .pipe( + catchError((err: any) => { + this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not load the bank profile.'; + return of(null); + }) + ) + .subscribe((res: any) => { + this.loading = false; + if (!res) return; + this.applyResult(res); + this.form.markAsPristine(); + }); + } + + submit(): void { + if (this.saving) return; + this.saving = true; + this.errorMsg = ''; + this.successMsg = ''; + const raw = this.form.value; + const payload: any = { ...raw, locale: this.settingsService.language.code }; + for (const field of InstitutionProfileComponent.DATE_FIELDS) { + payload[field] = raw[field] ? this.formatDate(raw[field]) : null; + } + this.organizationService + .updateInstitutionProfile(payload) + .pipe( + catchError((err: any) => { + this.saving = false; + this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not save the bank profile.'; + return of(null); + }) + ) + .subscribe((res: any) => { + this.saving = false; + if (!res) return; + this.successMsg = 'Bank profile saved.'; + this.applyResult(res); + this.form.markAsPristine(); + }); + } + + private applyResult(res: any): void { + const patch: any = { ...res }; + for (const field of InstitutionProfileComponent.DATE_FIELDS) { + patch[field] = res[field] ? new Date(res[field]) : null; + } + this.form.patchValue(patch); + this.currencyCode = res.currencyCode ?? ''; + this.audit = { + createdBy: res.createdByUsername, + createdAt: res.createdAt, + updatedBy: res.updatedByUsername, + updatedAt: res.updatedAt, + activationDate: res.activationDate, + deactivationDate: res.deactivationDate + }; + } + + private formatDate(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + } +} diff --git a/src/app/organization/organization-routing.module.ts b/src/app/organization/organization-routing.module.ts index 58bd89ba6..0c35f6ceb 100644 --- a/src/app/organization/organization-routing.module.ts +++ b/src/app/organization/organization-routing.module.ts @@ -237,6 +237,30 @@ const routes: Routes = [ } ] }, + { + // Bank's own profile — name, legal name, code, registration, address/contact. A + // singleton, unlike every other screen under Organization, so it has no create/list step. + path: 'institution-profile', + loadComponent: () => + import('./institution-profile/institution-profile.component').then((m) => m.InstitutionProfileComponent), + data: { title: 'Bank Profile', breadcrumb: 'Bank Profile' } + }, + { + // The real-world fields (code, address, contact, type, working hours) behind a branch — + // kept as its own screen rather than folded into Manage Offices, since the underlying + // m_office record is a stock Fineract entity this CBS deliberately left untouched. + path: 'branch-profile', + loadComponent: () => + import('./branch-profile/branch-profile.component').then((m) => m.BranchProfileComponent), + data: { title: 'Branch Profile', breadcrumb: 'Branch Profile' } + }, + { + // Which GL account backs each clearing suspense/settlement role (CTS, NACH, RTGS, ...). + path: 'clearing-gl-config', + loadComponent: () => + import('./clearing-gl-config/clearing-gl-config.component').then((m) => m.ClearingGlConfigComponent), + data: { title: 'Clearing GL Configuration', breadcrumb: 'Clearing GL Configuration' } + }, { path: 'employees', data: { title: 'Manage Employees', breadcrumb: 'Manage Employees' }, diff --git a/src/app/organization/organization.component.html b/src/app/organization/organization.component.html index f54ef9507..4bf1aad35 100644 --- a/src/app/organization/organization.component.html +++ b/src/app/organization/organization.component.html @@ -34,6 +34,48 @@
+ +
+ +
+ +
+
+

Bank Profile

+

Bank name, legal name, code, registration and contact details

+
+ +
+
+ + +
+ +
+ +
+
+

Branch Profile

+

Branch code, address, contact, type and working hours per office

+
+ +
+
+ + +
+ +
+ +
+
+

Clearing GL Configuration

+

Map CTS, NACH and RTGS clearing roles to their GL accounts

+
+ +
+
+
diff --git a/src/app/organization/organization.service.ts b/src/app/organization/organization.service.ts index a5f84ca38..f75312831 100644 --- a/src/app/organization/organization.service.ts +++ b/src/app/organization/organization.service.ts @@ -888,4 +888,51 @@ export class OrganizationService { deleteLoanOriginator(originatorId: number): Observable { return this.http.delete(`/loan-originators/${originatorId}`); } + + // ── Institution Profile ── + + /** @returns {Observable} The bank's own profile (name, legal name, code, contact...). */ + getInstitutionProfile(): Observable { + return this.http.get('/institutionprofile'); + } + + /** @returns {Observable} */ + updateInstitutionProfile(payload: any): Observable { + return this.http.put('/institutionprofile', payload); + } + + // ── Branch Profile ── + + /** @returns {Observable} Every office joined with its branch profile fields, if any. */ + getBranchProfiles(): Observable { + return this.http.get('/branchprofile'); + } + + /** @returns {Observable} One office's branch profile, joined with its office fields. */ + getBranchProfile(officeId: number): Observable { + const params = new HttpParams().set('officeId', String(officeId)); + return this.http.get('/branchprofile', { params }); + } + + /** @returns {Observable} */ + updateBranchProfile(officeId: number, payload: any): Observable { + return this.http.put(`/branchprofile/${officeId}`, payload); + } + + // ── Clearing GL Configuration ── + + /** @returns {Observable} Every clearing role (CTS/NACH/RTGS...) and its mapped GL account, if any. */ + getClearingGlConfig(): Observable { + return this.http.get('/clearingglconfig'); + } + + /** @returns {Observable} Selectable GL accounts for mapping a clearing role. */ + getClearingGlConfigTemplate(): Observable { + return this.http.get('/clearingglconfig/template'); + } + + /** @returns {Observable} */ + updateClearingGlConfig(configKey: string, glAccountId: number | null): Observable { + return this.http.put(`/clearingglconfig/${configKey}`, { glAccountId }); + } } diff --git a/src/app/shared/footer/footer.component.ts b/src/app/shared/footer/footer.component.ts index c895dc100..a6cd40fa4 100644 --- a/src/app/shared/footer/footer.component.ts +++ b/src/app/shared/footer/footer.component.ts @@ -182,12 +182,16 @@ export class FooterComponent implements OnInit, OnDestroy { * Get the Configuration for Business Date */ getConfigurations(): void { - if (this.authenticationService.isAuthenticated()) { - // Reading global configuration needs the READ_CONFIGURATION permission, which teller-style - // roles (cashier, clerk) do not carry. This runs from the footer on every authenticated - // page, so an unpermitted role was greeted by an "Unauthorized Request" modal at login for - // a check it never asked for. The footer only uses this to decide whether to show a - // business date, so a denied read is not an error here — it degrades to the system date. + // Reading global configuration needs the READ_CONFIGURATION permission, which teller-style + // roles (cashier, clerk) never carry by design — the read would always come back 403. The + // footer only uses this to decide whether to show a business date, so a role that can never + // read it just never asks, instead of round-tripping a request every page load (and every + // 60s poll while a day is open) purely to have it rejected. + if ( + this.authenticationService.isAuthenticated() && + !this.authenticationService.isCashierUser() && + !this.authenticationService.isClerkUser() + ) { this.systemService.getConfigurationByName(SettingsService.businessDateConfigName, true).subscribe({ next: (configurationData: any) => { this.isBusinessDateEnabled = configurationData.enabled; diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component.html index 34feac5a2..ffc799586 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component.html +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component.html @@ -124,23 +124,8 @@ {{ r.voucher_no || '—' }} - - {{ - r.request_type === 'BANK_TRANSFER' - ? 'Bank Transfer' - : r.request_type === 'COLLECTION' - ? 'Collection' - : 'Disbursement' - }} + + {{ typeLabel(r) }} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component.ts index e9f382ffe..335c88610 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component.ts @@ -9,10 +9,11 @@ import { Component, OnInit, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; -import { Router, RouterModule } from '@angular/router'; +import { RouterModule } from '@angular/router'; import { MatIconModule } from '@angular/material/icon'; import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatDialog } from '@angular/material/dialog'; import { MatNativeDateModule } from '@angular/material/core'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; @@ -22,6 +23,7 @@ import { of } from 'rxjs'; import { ClerkDisbursementService } from '../../../cashier/clerk-disbursement.service'; import { SettingsService } from '../../../settings/settings.service'; +import { DisbursementReviewComponent } from '../../../clerk/disbursement-review/disbursement-review.component'; @Component({ selector: 'mifosx-disbursement-requests', @@ -43,9 +45,9 @@ import { SettingsService } from '../../../settings/settings.service'; }) export class DisbursementRequestsComponent implements OnInit { private service = inject(ClerkDisbursementService); - private router = inject(Router); private snackBar = inject(MatSnackBar); private settingsService = inject(SettingsService); + private dialog = inject(MatDialog); requests: any[] = []; totalRecords = 0; @@ -164,11 +166,13 @@ export class DisbursementRequestsComponent implements OnInit { } review(req: any): void { - this.router.navigate([ - '/checker-inbox-and-tasks/disbursement-requests', - req.id, - 'review' - ]); + this.dialog + .open(DisbursementReviewComponent, { + data: { requestId: req.id }, + autoFocus: false + }) + .afterClosed() + .subscribe(() => this.load()); } creditCount(req: any): number { @@ -180,6 +184,24 @@ export class DisbursementRequestsComponent implements OnInit { } } + typeLabel(req: any): string { + const map: Record = { + BANK_TRANSFER: 'Bank Transfer', + COLLECTION: 'Collection', + FUND_TRANSFER: 'Fund Transfer' + }; + return map[req.request_type] ?? 'Disbursement'; + } + + typeColor(req: any): string { + const map: Record = { + BANK_TRANSFER: '#e3f2fd', + COLLECTION: '#fce4ec', + FUND_TRANSFER: '#ede7f6' + }; + return map[req.request_type] ?? '#e8f5e9'; + } + statusClass(status: string): string { const map: Record = { PENDING: 'wri-status--pending', diff --git a/src/environments/.env.ts b/src/environments/.env.ts index ea6d58ac9..f0cbf38b0 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260827', - 'hash': '79ffde51' + 'version': '260828', + 'hash': 'ce840d73' }, 'allow_switching_backend_instance': true };