Compare commits

...
16 Commits
Author SHA1 Message Date
Jaanu423andClaude Sonnet 5 ed2a40a8bf fix(chequebook,cashier): surface real hold/lien amount instead of fake placeholders
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
- Savings account general tab now shows the real lien/user hold
  (savingsAmountOnHold) alongside the existing guarantor hold (onHoldFunds).
- Cashier transaction-entry "Transfer Hold" / "Tr. Hold" now bind to the real
  freezeAmount instead of a hardcoded 0.00 placeholder; "Freeze Amount" itself
  reverted to 0.00 per request. withdrawableAmount no longer double-subtracts
  the hold for savings accounts (whose availableBalance is already
  hold-adjusted server-side), while current accounts (whose availableBalance
  is not) still get the client-side subtraction they need.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 15:06:58 +05:30
Jaanu423andClaude Sonnet 5 24b1d7c087 fix(cashier,chequebook): surface real backend errors; skip redundant balance check on token disbursement
Cashier token-based withdrawal disbursement was re-running the balance/min-balance
checks against the already-approved amount, which always failed since the manager
approval had already deducted the balance — the check now skips for that case
(clerk-created requests still get the full validation).

Cheque book request submission now shows the actual backend error message
(e.g. insufficient-balance rejections) instead of a generic fallback string.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 11:19:28 +05:30
Jaanu423andClaude Sonnet 5 0cc79ccd57 feat(clerk): add clerk-facing Cheque Book screen and shell nav wiring
Adds a customer-account-scoped Cheque Book view under Clerk > Account
Facilities (list + leaf detail, with stop-payment/report-lost/mark-used/
return actions), reachable via a new hover dropdown on the Account
Facilities nav item. Also refreshes the Account Facilities page layout,
and carries over a few chequebook service/detail-dialog fixes from the
back-office module (used-leaf display, leaf-lookup dialog polish).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 16:08:43 +05:30
Jaanu423 7abfa394df feat(chequebook): add cheque book module with Maker-Checker workflow
Adds cheque series creation, stock receive, cheque leaf request/lookup,
and stop-payment/lost-reporting flows with Maker-Checker approval and
a checker-inbox tab for pending chequebook requests.
2026-07-15 11:02:46 +05:30
Jaanu423andClaude Sonnet 4.6 b9856a1c93 feat(products): restrict currency dropdown to INR only for all product types
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 16:34:13 +05:30
Jaanu423andClaude Sonnet 4.6 d72155a74a merge(release): merge feature/minor-update into release/share-account-module
Combines share-account-module UI changes with minor-update features.
Resolved .env.ts conflict keeping newer version (260707/8a1b7d5d).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 13:43:47 +05:30
Jaanu423andClaude Sonnet 4.6 3d65900fc4 feat(election): Chairman/VC auto-resolution, member vote panel, read-only committee summary
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 11:02:32 +05:30
Jaanu423andClaude Sonnet 4.6 93f4b9b03f feat(election): dynamic designations, vote remark with voice input, and modal vote panel
- Designations loaded dynamically from DirectorDesignation m_code via new API endpoint
- Director board dropdown and committee board validation now use DB-driven designation list
- Mandatory designation check in committee creation driven by is_mandatory flag from m_code_value
- Vote summary panel converted from side drawer to centered modal with backdrop blur
- Voting page: remark textarea shown on Approve/Reject with Web Speech API voice input
- Voice input supports Hindi (hi-IN) and English (en-IN) with language toggle
- NgZone fix: speech callbacks wrapped in zone.run() so Angular change detection fires correctly
- Speech recognition uses continuous=true so user can dictate full remarks before stopping

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 18:24:18 +05:30
keshav898966 96c4a28c0f table changes on Maker-Checker workflow for Products, with approval-based updates and product locking to prevent concurrent edits 2026-07-06 17:54:27 +05:30
Jaanu423andClaude Sonnet 4.6 8a1b7d5d6c feat(election): relational agenda items, PDF download, email redesign, and nav fixes
- Replace flat agenda text field with relational agenda items (add/remove rows)
- Fix edit form pre-populating saved agenda using [value]+(input) binding
- Add PDF download button: opens print-ready window with full meeting details
- Redesign meeting invite email: table-based layout, compact header, amber agenda
  box with numbered badges, two-column footer
- Fix stale email agenda bug: refresh entity after agenda save to bypass EclipseLink
  L1 identity map cache
- Remove permission wrapper from Election sidenav item; add routerLink to toolbar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-06 16:54:04 +05:30
keshav898966 1ea4cfe8d8 implemented a Maker-Checker workflow for Products, with approval-based updates and product locking to prevent concurrent edits 2026-07-03 15:08:47 +05:30
keshav898966 d00eac0aa1 pending account terminate feature develop 2026-07-01 17:19:38 +05:30
keshav898966 9afb177c69 fd account modify working change 2026-06-30 10:11:12 +05:30
keshav898966 bc980d2059 share link drop down yes/no on ui 2026-06-26 11:15:10 +05:30
keshav898966 f3df626483 teller set in casher 2026-06-24 16:32:37 +05:30
keshav898966 4660a3eb15 feat(tellers,savings): restrict teller creation, remove cashier vault actions, and add saving share link toggle 2026-06-24 13:11:54 +05:30
130 changed files with 11058 additions and 1233 deletions
+4
View File
@@ -89,4 +89,8 @@ export class AccountsService {
if (params.asOnDate) httpParams = httpParams.set('asOnDate', params.asOnDate);
return this.http.get('/recurringdepositaccounts', { params: httpParams });
}
terminateAccount(accountId: string, data: any): Observable<any> {
return this.http.post(`/v1/accounts/${accountId}/terminate`, data);
}
}
+2
View File
@@ -36,6 +36,7 @@ import { ClientsModule } from './clients/clients.module';
import { GroupsModule } from './groups/groups.module';
import { CentersModule } from './centers/centers.module';
import { AccountingModule } from './accounting/accounting.module';
import { ChequeBookModule } from './chequebook/chequebook.module';
import { SystemModule } from './system/system.module';
import { ProductsModule } from './products/products.module';
import { OrganizationModule } from './organization/organization.module';
@@ -142,6 +143,7 @@ export function HttpLoaderFactory(http: HttpClient) {
GroupsModule,
CentersModule,
AccountingModule,
ChequeBookModule,
SystemModule,
ProductsModule,
OrganizationModule,
@@ -45,14 +45,21 @@
<input matInput type="date" [value]="settlementDate" (change)="settlementDate = $any($event.target).value" />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller</mat-label>
<mat-select [value]="supervisorTellerId" (selectionChange)="onSupervisorTellerSelect($event.value)">
@for (t of tellers; track t.id) {
<mat-option [value]="t.id">{{ t.name }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (tellers.length === 1) {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller</mat-label>
<input matInput type="text" [value]="tellers[0].name" readonly />
</mat-form-field>
} @else {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller</mat-label>
<mat-select [value]="supervisorTellerId" (selectionChange)="onSupervisorTellerSelect($event.value)">
@for (t of tellers; track t.id) {
<mat-option [value]="t.id">{{ t.name }}</mat-option>
}
</mat-select>
</mat-form-field>
}
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Cashier</mat-label>
@@ -140,17 +147,24 @@
}
</div>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller / Vault (To) *</mat-label>
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
@for (t of tellers; track t.id) {
<mat-option [value]="t.id">{{ t.name }}</mat-option>
}
@if (tellers.length === 0) {
<mat-option [value]="null" disabled>No tellers configured</mat-option>
}
</mat-select>
</mat-form-field>
@if (tellers.length === 1) {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller / Vault (To) *</mat-label>
<input matInput type="text" [value]="toParty" readonly />
</mat-form-field>
} @else {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller / Vault (To) *</mat-label>
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
@for (t of tellers; track t.id) {
<mat-option [value]="t.id">{{ t.name }}</mat-option>
}
@if (tellers.length === 0) {
<mat-option [value]="null" disabled>No tellers configured</mat-option>
}
</mat-select>
</mat-form-field>
}
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Settlement Date</mat-label>
@@ -97,6 +97,10 @@ export class CashSettlementComponent implements OnInit {
.pipe(catchError(() => of([] as any[])))
.subscribe((list: any[]) => {
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
if (this.tellers.length === 1) {
this.onTellerSelect(this.tellers[0].id);
this.onSupervisorTellerSelect(this.tellers[0].id);
}
});
this.loadData();
}
@@ -17,17 +17,24 @@
</header>
<div class="pg-toolbar pg-toolbar--mat">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>From (Teller / Head)</mat-label>
<mat-select [value]="fromTellerId" (selectionChange)="onTellerSelect($event.value)">
@for (t of tellers; track t.id) {
<mat-option [value]="t.id">{{ t.name }}</mat-option>
}
@if (tellers.length === 0) {
<mat-option [value]="null" disabled>No tellers configured</mat-option>
}
</mat-select>
</mat-form-field>
@if (tellers.length === 1) {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>From (Teller / Head)</mat-label>
<input matInput type="text" [value]="fromParty" readonly />
</mat-form-field>
} @else {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>From (Teller / Head)</mat-label>
<mat-select [value]="fromTellerId" (selectionChange)="onTellerSelect($event.value)">
@for (t of tellers; track t.id) {
<mat-option [value]="t.id">{{ t.name }}</mat-option>
}
@if (tellers.length === 0) {
<mat-option [value]="null" disabled>No tellers configured</mat-option>
}
</mat-select>
</mat-form-field>
}
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>To (Cashier)</mat-label>
@@ -95,6 +95,9 @@ export class CashTransferComponent implements OnInit {
.pipe(catchError(() => of([] as any[])))
.subscribe((list) => {
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
if (this.tellers.length === 1) {
this.onTellerSelect(this.tellers[0].id);
}
});
}
@@ -116,6 +119,9 @@ export class CashTransferComponent implements OnInit {
.subscribe((r: any) => {
this.cashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName }));
this.loadingCashiers = false;
if (this.cashiers.length === 1) {
this.onCashierSelect(this.cashiers[0].id);
}
});
}
@@ -20,15 +20,22 @@
<div class="pg-toolbar pg-toolbar--mat">
<!-- Teller + Cashier filters — hidden for cashier role (auto-loads own position) -->
@if (!isCashierMode) {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller (optional)</mat-label>
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
<mat-option [value]="null">All Tellers</mat-option>
@for (t of tellers; track t.id) {
<mat-option [value]="t.id">{{ t.name }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (tellers.length === 1) {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller</mat-label>
<input matInput type="text" [value]="tellers[0].name" readonly />
</mat-form-field>
} @else {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller (optional)</mat-label>
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
<mat-option [value]="null">All Tellers</mat-option>
@for (t of tellers; track t.id) {
<mat-option [value]="t.id">{{ t.name }}</mat-option>
}
</mat-select>
</mat-form-field>
}
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Cashier (optional)</mat-label>
@@ -85,6 +85,9 @@ export class CashierPositionComponent implements OnInit {
.pipe(catchError(() => of([] as any[])))
.subscribe((list) => {
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
if (this.tellers.length === 1) {
this.onTellerSelect(this.tellers[0].id);
}
});
}
}
@@ -137,6 +140,9 @@ export class CashierPositionComponent implements OnInit {
.pipe(catchError(() => of({ cashiers: [] })))
.subscribe((r: any) => {
this.cashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName }));
if (this.cashiers.length === 1) {
this.selectedCashierId = this.cashiers[0].id;
}
});
}
+12
View File
@@ -70,6 +70,18 @@ const routes: Routes = [
),
data: { title: 'Account Facilities', breadcrumb: 'Account Facilities' }
},
{
path: 'account-facilities/cheque-book',
loadComponent: () =>
import('../clerk/cheque-book/cheque-book.component').then((m) => m.ClerkChequeBookComponent),
data: { title: 'Cheque Book', breadcrumb: 'Cheque Book' }
},
{
path: 'account-facilities/cheque-book/:id',
loadComponent: () =>
import('../clerk/cheque-book/cheque-book-detail.component').then((m) => m.ClerkChequeBookDetailComponent),
data: { title: 'Cheque Book Detail', breadcrumb: 'Cheque Book Detail' }
},
{
path: 'photo-signature',
loadComponent: () =>
@@ -17,17 +17,24 @@
</header>
<div class="pg-toolbar pg-toolbar--mat">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller</mat-label>
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
@for (t of tellers; track t.id) {
<mat-option [value]="t.id">{{ t.name }}</mat-option>
}
@if (tellers.length === 0) {
<mat-option [value]="null" disabled>No tellers configured</mat-option>
}
</mat-select>
</mat-form-field>
@if (tellers.length === 1) {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller</mat-label>
<input matInput type="text" [value]="tellers[0].name" readonly />
</mat-form-field>
} @else {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller</mat-label>
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
@for (t of tellers; track t.id) {
<mat-option [value]="t.id">{{ t.name }}</mat-option>
}
@if (tellers.length === 0) {
<mat-option [value]="null" disabled>No tellers configured</mat-option>
}
</mat-select>
</mat-form-field>
}
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>From (Cashier)</mat-label>
@@ -102,6 +102,9 @@ export class InterCashierTransferComponent implements OnInit {
.pipe(catchError(() => of([] as any[])))
.subscribe((list) => {
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
if (this.tellers.length === 1) {
this.onTellerSelect(this.tellers[0].id);
}
});
}
@@ -20,14 +20,21 @@
<mat-tab label="New Reconciliation">
<div class="pg-tab-body">
<div class="pg-toolbar pg-toolbar--mat">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller</mat-label>
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
@for (t of tellers; track t.id) {
<mat-option [value]="t.id">{{ t.name }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (tellers.length === 1) {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller</mat-label>
<input matInput type="text" [value]="tellers[0].name" readonly />
</mat-form-field>
} @else {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller</mat-label>
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
@for (t of tellers; track t.id) {
<mat-option [value]="t.id">{{ t.name }}</mat-option>
}
</mat-select>
</mat-form-field>
}
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Cashier</mat-label>
@@ -92,6 +92,9 @@ export class ReconciliationComponent implements OnInit {
.pipe(catchError(() => of([] as any[])))
.subscribe((list) => {
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
if (this.tellers.length === 1) {
this.onTellerSelect(this.tellers[0].id);
}
});
this.loadHistory();
}
@@ -108,6 +111,9 @@ export class ReconciliationComponent implements OnInit {
.pipe(catchError(() => of({ cashiers: [] })))
.subscribe((r: any) => {
this.cashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName }));
if (this.cashiers.length === 1) {
this.onCashierSelect(this.cashiers[0].id);
}
});
}
@@ -681,7 +681,7 @@
</div>
<div class="te-balance-row">
<span class="te-bal-num">4.</span><span class="te-bal-label">Freeze Amount</span>
<span class="te-bal-value">{{ currencySymbol }}{{ freezeAmount | number: '1.2-2' }}</span>
<span class="te-bal-value">{{ currencySymbol }}0.00</span>
</div>
<div class="te-balance-row">
<span class="te-bal-num">5.</span><span class="te-bal-label">Withdrawable Amt.</span>
@@ -746,7 +746,7 @@
</div>
<div class="te-bal-item">
<span class="te-bal-item__label">Transfer Hold</span>
<span class="te-bal-item__val">0.00</span>
<span class="te-bal-item__val">{{ freezeAmount | number: '1.2-2' }}</span>
</div>
<div class="te-bal-item">
<span class="te-bal-item__label">Clearing Hold</span>
@@ -862,7 +862,7 @@
</div>
<div class="te-acs-b">
<span class="te-acs-b-l">Tr. Hold</span>
<span class="te-acs-b-v">0.00</span>
<span class="te-acs-b-v">{{ freezeAmount | number: '1.2-2' }}</span>
</div>
<div class="te-acs-b">
<span class="te-acs-b-l">Clr. Hold</span>
@@ -1999,12 +1999,29 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
get freezeAmount(): number {
return this.accountDetails?.summary?.holdAmountBalance ?? 0;
// Savings exposes the lien/user hold as root-level `savingsAmountOnHold`; Current exposes its
// own (differently-sourced) hold as `onHoldFunds`. `summary.holdAmountBalance` never existed in
// either API response, so this always read 0 before.
const acTyp = this.selectedRow?.acTyp;
const isCurrent = acTyp === 'CA' || acTyp === 'CB';
if (isCurrent) {
return this.accountDetails?.onHoldFunds ?? this.accountDetails?.summary?.onHoldFunds ?? 0;
}
return this.accountDetails?.savingsAmountOnHold ?? 0;
}
get withdrawableAmount(): number {
const avail = this.accountDetails?.summary?.availableBalance ?? this.currentBalance;
return Math.max(0, avail - this.freezeAmount);
const acTyp = this.selectedRow?.acTyp;
const isCurrent = acTyp === 'CA' || acTyp === 'CB';
// Savings' availableBalance is already computed hold-adjusted on the backend (subtracts both
// guarantor and lien holds) — subtracting freezeAmount again here would double-count it.
// Current's availableBalance is just the raw balance and does NOT subtract its hold, so it
// still needs freezeAmount subtracted client-side.
if (isCurrent) {
return Math.max(0, avail - this.freezeAmount);
}
return Math.max(0, avail);
}
get currencySymbol(): string {
@@ -2730,30 +2747,38 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
if (isWithdrawal) {
// 3. Balance check
const withdrawable = this.withdrawableAmount;
if (amount > withdrawable) {
this.showAlert('error', 'Insufficient Balance', [
`Withdrawable Amount: ₹${withdrawable.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
`Requested Amount: ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
'Please reduce the withdrawal amount.'
]);
return;
}
// Token-based cashier disbursement: the manager approval already validated the balance
// and posted the real withdrawal (money already left the account). Re-running the balance/
// min-balance checks here would compare the post-deduction balance against the same
// already-approved amount again, which fails for almost any real withdrawal.
const isTokenDisbursement = !this.isClerk && !!this.activeWithdrawalRequest;
// 4. Min balance
const enforceMin = !!this.accountDetails.enforceMinRequiredBalance;
const minBalance = Number(this.accountDetails.minRequiredBalance ?? 0);
if (enforceMin && minBalance > 0) {
const balanceAfter = this.currentBalance - amount;
if (balanceAfter < minBalance) {
this.showAlert('error', 'Minimum Balance Violation', [
`Required minimum balance: ₹${minBalance.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
`Balance after withdrawal: ₹${balanceAfter.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
if (!isTokenDisbursement) {
// 3. Balance check
const withdrawable = this.withdrawableAmount;
if (amount > withdrawable) {
this.showAlert('error', 'Insufficient Balance', [
`Withdrawable Amount: ₹${withdrawable.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
`Requested Amount: ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
'Please reduce the withdrawal amount.'
]);
return;
}
// 4. Min balance
const enforceMin = !!this.accountDetails.enforceMinRequiredBalance;
const minBalance = Number(this.accountDetails.minRequiredBalance ?? 0);
if (enforceMin && minBalance > 0) {
const balanceAfter = this.currentBalance - amount;
if (balanceAfter < minBalance) {
this.showAlert('error', 'Minimum Balance Violation', [
`Required minimum balance: ₹${minBalance.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
`Balance after withdrawal: ₹${balanceAfter.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
'Please reduce the withdrawal amount.'
]);
return;
}
}
}
// 5. Denomination overdraft — fire whenever denomination is entered for withdrawal
@@ -0,0 +1,150 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container">
<div class="page-header">
<h2>{{ 'labels.inputs.Cheque Book' | translate }} #{{ book.id }}</h2>
<button mat-raised-button [routerLink]="['/chequebook', 'books']">{{ 'labels.buttons.Back' | translate }}</button>
</div>
<mat-card class="info-card">
<mat-card-content>
<div class="info-grid">
<div>
<span class="k">{{ 'labels.inputs.Account' | translate }}</span>
<span>
{{ book.accountNo || book.accountId }}
@if (book.clientName) {
— {{ book.clientName }}
}
</span>
</div>
<div>
<span class="k">{{ 'labels.inputs.Account Type' | translate }}</span
><span>{{ book.accountType }}</span>
</div>
<div>
<span class="k">{{ 'labels.inputs.Office' | translate }}</span
><span>{{ book.officeName }}</span>
</div>
<div>
<span class="k">{{ 'labels.inputs.Series' | translate }}</span
><span>{{ book.seriesCode || '–' }}</span>
</div>
<div>
<span class="k">{{ 'labels.inputs.Leaf Range' | translate }}</span>
<span>{{ book.rangeStart ? book.rangeStart + ' – ' + book.rangeEnd : '–' }}</span>
</div>
<div>
<span class="k">{{ 'labels.inputs.Quantity' | translate }}</span
><span>{{ book.noOfBooks }} {{ 'labels.inputs.books' | translate }}</span>
</div>
<div>
<span class="k">{{ 'labels.inputs.Status' | translate }}</span
><span>{{ book.status }}</span>
</div>
<div>
<span class="k">{{ 'labels.inputs.Issue Date' | translate }}</span
><span>{{ book.issueDate ? (book.issueDate | dateFormat) : '–' }}</span>
</div>
</div>
</mat-card-content>
</mat-card>
@if (book.status === 'ISSUED') {
<div class="action-toolbar">
<button mat-stroked-button color="warn" (click)="startAction('stoppayment')">
{{ 'labels.buttons.Stop Payment' | translate }}
</button>
<button mat-stroked-button (click)="startAction('revokestoppayment')">
{{ 'labels.buttons.Revoke Stop Payment' | translate }}
</button>
<button mat-stroked-button color="warn" (click)="startAction('reportlost')">
{{ 'labels.buttons.Report Lost' | translate }}
</button>
<button mat-stroked-button (click)="startAction('cancelleaf')">
{{ 'labels.buttons.Cancel Leaf' | translate }}
</button>
<button mat-stroked-button color="primary" (click)="startAction('markused')">
{{ 'labels.buttons.Mark as Used' | translate }}
</button>
<button mat-stroked-button color="warn" (click)="startAction('return')">
{{ 'labels.buttons.Flag Returned' | translate }}
</button>
</div>
}
@if (activeAction) {
<mat-card class="add-form">
<mat-card-content>
<form [formGroup]="actionForm" (ngSubmit)="submitAction()">
<div class="form-grid">
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.From Leaf Seq' | translate }}</mat-label>
<input matInput type="number" formControlName="fromLeafSeq" />
<mat-hint>{{ 'labels.text.Leave blank for the whole book' | translate }}</mat-hint>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.To Leaf Seq' | translate }}</mat-label>
<input matInput type="number" formControlName="toLeafSeq" />
</mat-form-field>
@if (activeAction !== 'revokestoppayment') {
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Reason' | translate }}</mat-label>
<input matInput formControlName="reason" [required]="activeAction !== 'markused'" />
@if (activeAction === 'markused') {
<mat-hint>{{
'labels.text.Optional — e.g. cleared per bank statement dated ...' | translate
}}</mat-hint>
}
</mat-form-field>
}
</div>
<div class="form-actions">
<button mat-raised-button type="button" (click)="activeAction = null">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="warn" type="submit" [disabled]="actionForm.invalid || busy">
{{ 'labels.buttons.Submit' | translate }}
</button>
</div>
</form>
</mat-card-content>
</mat-card>
}
<div class="leaf-grid-wrapper mat-elevation-z1">
<div class="leaf-grid">
@for (l of leaves; track l.leafSerialNo) {
<button
type="button"
class="serial-circle"
[ngClass]="
l.status === 'ACTIVE'
? 'ok'
: l.status === 'USED'
? 'used'
: l.status === 'STOP_PAYMENT'
? 'warn'
: l.status === 'LOST'
? 'bad'
: l.status === 'RETURNED'
? 'returned'
: 'neutral'
"
(click)="selectLeaf(l)"
>
{{ l.leafSerialNo }}
</button>
}
</div>
@if (leaves.length === 0) {
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
}
</div>
</div>
@@ -0,0 +1,131 @@
/**
* 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/.
*/
.container {
padding: 16px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.info-card {
margin-bottom: 16px;
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px 24px;
div {
display: flex;
flex-direction: column;
}
.k {
font-size: 0.72rem;
text-transform: uppercase;
color: rgb(0 0 0 / 50%);
}
}
.action-toolbar {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.add-form {
margin-bottom: 16px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px 20px;
}
mat-form-field {
width: 100%;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 16px;
}
.leaf-grid-wrapper {
background: #fff;
padding: 16px;
}
.leaf-grid {
display: flex;
flex-wrap: wrap;
gap: 14px;
}
.serial-circle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 50%;
border: 2px solid currentcolor;
background: #fff;
font-size: 12px;
font-weight: 700;
font-variant-numeric: tabular-nums;
cursor: pointer;
transition:
transform 0.1s ease,
box-shadow 0.1s ease;
&:hover {
transform: scale(1.06);
box-shadow: 0 2px 6px rgb(0 0 0 / 15%);
}
&.ok {
color: #28c07a;
}
&.used {
color: #1976d2;
}
&.bad {
color: #d32f2f;
}
&.warn {
color: #a16207;
}
&.neutral {
color: #757575;
}
&.returned {
color: #9333ea;
}
}
.empty-state {
padding: 24px;
text-align: center;
color: rgb(0 0 0 / 50%);
}
@@ -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/.
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatDialog } from '@angular/material/dialog';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { Observable } from 'rxjs';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { ChequeBookService } from '../chequebook.service';
import { LeafDetailDialogComponent } from './leaf-detail-dialog/leaf-detail-dialog.component';
type LeafAction = 'stoppayment' | 'revokestoppayment' | 'reportlost' | 'cancelleaf' | 'markused' | 'return';
/**
* One issued cheque book: its leaf register, and the six leaf-range actions (stop-payment, revoke,
* report-lost, cancel-leaf, mark-used, return) — each defaults to the whole book when no range is given.
* "Mark as used" is a manual record (this CBS has no clearing/presentment integration to detect it itself)
* — a branch sets it once it sees a leaf clear on the bank/clearing statement. "Return" flags a leaf as
* presented-and-dishonoured (insufficient funds, signature mismatch, etc.) — terminal, like report-lost.
*/
@Component({
selector: 'mifosx-cheque-book-detail',
templateUrl: './cheque-book-detail.component.html',
styleUrls: ['./cheque-book-detail.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
RouterLink,
MatButtonModule,
MatSnackBarModule,
FaIconComponent
]
})
export class ChequeBookDetailComponent implements OnInit {
private route = inject(ActivatedRoute);
private formBuilder = inject(UntypedFormBuilder);
private chequeBookService = inject(ChequeBookService);
private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
private notify(message: string, isError = false): void {
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
}
bookId!: string;
book: any = {};
leaves: any[] = [];
busy = false;
activeAction: LeafAction | null = null;
actionForm: UntypedFormGroup;
constructor() {
this.actionForm = this.formBuilder.group({
fromLeafSeq: [''],
toLeafSeq: [''],
reason: ['']
});
}
ngOnInit(): void {
this.bookId = this.route.snapshot.paramMap.get('id') as string;
this.loadAll();
}
loadAll(): void {
this.chequeBookService.getBook(this.bookId).subscribe((data: any) => (this.book = data || {}));
this.chequeBookService.getLeaves(this.bookId).subscribe((data: any[]) => {
this.leaves = data || [];
});
}
selectLeaf(leaf: any): void {
this.dialog.open(LeafDetailDialogComponent, { data: leaf, width: '420px' });
}
startAction(action: LeafAction): void {
this.activeAction = this.activeAction === action ? null : action;
this.actionForm.reset();
const reasonControl = this.actionForm.get('reason');
if (action === 'revokestoppayment' || action === 'markused') {
reasonControl?.clearValidators();
} else {
reasonControl?.setValidators(Validators.required);
}
reasonControl?.updateValueAndValidity();
}
submitAction(): void {
if (this.actionForm.invalid || this.busy || !this.activeAction) {
return;
}
this.busy = true;
const raw = this.actionForm.value;
const payload: any = { locale: 'en' };
if (raw.fromLeafSeq !== '' && raw.fromLeafSeq != null) payload.fromLeafSeq = raw.fromLeafSeq;
if (raw.toLeafSeq !== '' && raw.toLeafSeq != null) payload.toLeafSeq = raw.toLeafSeq;
if (raw.reason) payload.reason = raw.reason;
const actionCalls: Record<LeafAction, (bookId: string, payload: any) => Observable<any>> = {
stoppayment: (id, p) => this.chequeBookService.stopPayment(id, p),
revokestoppayment: (id, p) => this.chequeBookService.revokeStopPayment(id, p),
reportlost: (id, p) => this.chequeBookService.reportBookLost(id, p),
cancelleaf: (id, p) => this.chequeBookService.cancelLeaf(id, p),
markused: (id, p) => this.chequeBookService.markUsed(id, p),
return: (id, p) => this.chequeBookService.returnBook(id, p)
};
const call = actionCalls[this.activeAction](this.bookId, payload);
call.subscribe({
next: () => {
this.busy = false;
this.activeAction = null;
this.notify('Action applied');
this.loadAll();
},
error: () => {
this.busy = false;
this.notify(
'Action failed — check the leaf range and that the leaves are in a state that allows this action',
true
);
}
});
}
}
@@ -0,0 +1,72 @@
<!--
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/.
-->
<h1 mat-dialog-title>
<span
class="serial-circle"
[ngClass]="
data.status === 'ACTIVE'
? 'ok'
: data.status === 'USED'
? 'used'
: data.status === 'STOP_PAYMENT'
? 'warn'
: data.status === 'LOST'
? 'bad'
: data.status === 'RETURNED'
? 'returned'
: 'neutral'
"
>{{ data.leafSerialNo }}</span
>
</h1>
<div mat-dialog-content>
<div class="info-grid">
<div>
<span class="k">{{ 'labels.inputs.Serial No' | translate }}</span
><span>{{ data.leafSerialNo }}</span>
</div>
<div>
<span class="k">{{ 'labels.inputs.Status' | translate }}</span
><span>{{ data.status }}</span>
</div>
@if (data.stopReason) {
<div>
<span class="k">{{ 'labels.inputs.Stop Reason' | translate }}</span
><span>{{ data.stopReason }}</span>
</div>
}
@if (data.lostReason) {
<div>
<span class="k">{{ 'labels.inputs.Lost Reason' | translate }}</span
><span>{{ data.lostReason }}</span>
</div>
}
@if (data.cancelledReason) {
<div>
<span class="k">{{ 'labels.inputs.Cancelled Reason' | translate }}</span
><span>{{ data.cancelledReason }}</span>
</div>
}
@if (data.usedReason) {
<div>
<span class="k">{{ 'labels.inputs.Used Reason' | translate }}</span
><span>{{ data.usedReason }}</span>
</div>
}
@if (data.returnReason) {
<div>
<span class="k">{{ 'labels.inputs.Return Reason' | translate }}</span
><span>{{ data.returnReason }}</span>
</div>
}
</div>
</div>
<mat-dialog-actions align="end">
<button mat-raised-button mat-dialog-close>{{ 'labels.buttons.Close' | translate }}</button>
</mat-dialog-actions>
@@ -0,0 +1,59 @@
/**
* 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/.
*/
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px 24px;
min-width: 280px;
div {
display: flex;
flex-direction: column;
}
.k {
font-size: 0.72rem;
text-transform: uppercase;
color: rgb(0 0 0 / 50%);
}
}
.serial-circle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 50%;
border: 2px solid currentcolor;
background: #fff;
font-size: 13px;
font-weight: 700;
font-variant-numeric: tabular-nums;
&.ok {
color: #28c07a;
}
&.used {
color: #1976d2;
}
&.bad {
color: #d32f2f;
}
&.warn {
color: #a16207;
}
&.neutral {
color: #757575;
}
}
@@ -0,0 +1,39 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, inject } from '@angular/core';
import {
MatDialogRef,
MAT_DIALOG_DATA,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose
} from '@angular/material/dialog';
import { CdkScrollable } from '@angular/cdk/scrolling';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Popup shown when a leaf circle in the cheque-book detail grid is clicked. */
@Component({
selector: 'mifosx-leaf-detail-dialog',
templateUrl: './leaf-detail-dialog.component.html',
styleUrls: ['./leaf-detail-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatDialogTitle,
CdkScrollable,
MatDialogContent,
MatDialogActions,
MatDialogClose
]
})
export class LeafDetailDialogComponent {
dialogRef = inject<MatDialogRef<LeafDetailDialogComponent>>(MatDialogRef);
data = inject(MAT_DIALOG_DATA);
}
@@ -0,0 +1,239 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container">
<div class="page-header">
<h2>{{ 'labels.inputs.Cheque Books' | translate }}</h2>
<div class="header-actions">
<button mat-raised-button color="primary" (click)="showRequestForm = !showRequestForm">
<fa-icon icon="plus"></fa-icon>&nbsp;{{ 'labels.buttons.Request Cheque Book' | translate }}
</button>
<button mat-raised-button [routerLink]="['/chequebook']">{{ 'labels.buttons.Back' | translate }}</button>
</div>
</div>
<div class="filter-row">
@for (s of ['REQUESTED', 'ISSUED', 'REJECTED', 'WITHDRAWN']; track s) {
<button mat-stroked-button [class.active]="statusFilter === s" (click)="filterByStatus(s)">{{ s }}</button>
}
</div>
@if (showRequestForm) {
<mat-card class="add-form">
<mat-card-content>
<form [formGroup]="requestForm" (ngSubmit)="submitRequest()">
<div class="form-grid">
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Account Type' | translate }}</mat-label>
<mat-select formControlName="accountType" required (selectionChange)="onAccountTypeChange()">
<mat-option value="SAVINGS">{{ 'labels.inputs.Savings' | translate }}</mat-option>
<mat-option value="CURRENT">{{ 'labels.inputs.Current' | translate }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Account Number' | translate }}</mat-label>
<input
matInput
formControlName="accountNo"
(input)="onAccountNoInput($any($event.target).value)"
required
/>
@if (searchingAccount) {
<mat-hint>{{ 'labels.text.Searching...' | translate }}</mat-hint>
} @else if (resolvedAccount) {
<mat-hint class="ok-hint">
&check; {{ resolvedAccount.accountNo }}
@if (resolvedAccount.clientName) {
— {{ resolvedAccount.clientName }}
}
@if (resolvedAccount.status) {
({{ resolvedAccount.status }})
}
</mat-hint>
} @else if (accountError) {
<mat-hint class="err-hint">{{ accountError | translate }}</mat-hint>
} @else {
<mat-hint>{{ 'labels.text.Type at least 4 digits of the account number' | translate }}</mat-hint>
}
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Office' | translate }}</mat-label>
<mat-select formControlName="officeId" required>
@for (office of offices; track office.id) {
<mat-option [value]="office.id">{{ office.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Series' | translate }}</mat-label>
<mat-select formControlName="seriesId" required>
@for (s of requestableSeries; track s.id) {
<mat-option [value]="s.id">
{{ s.seriesCode }} — {{ s.name }} ({{ s.leavesPerBook }}-{{ 'labels.inputs.leaf' | translate }},
{{ availableBooksForRequestSeries(s.id) }} {{ 'labels.inputs.books available' | translate }})
</mat-option>
}
</mat-select>
<mat-hint>{{ 'labels.text.Only series applicable to this account type are shown' | translate }}</mat-hint>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Number of Books' | translate }}</mat-label>
<input matInput type="number" formControlName="noOfBooks" required />
<mat-hint>{{ 'labels.text.Leaves handed over = books x the series book size' | translate }}</mat-hint>
</mat-form-field>
</div>
<div class="form-actions">
<button mat-raised-button type="button" (click)="showRequestForm = false">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" type="submit" [disabled]="requestForm.invalid || busy">
{{ 'labels.buttons.Submit' | translate }}
</button>
</div>
</form>
</mat-card-content>
</mat-card>
}
<div class="table-wrapper mat-elevation-z1">
<table mat-table [dataSource]="books">
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Id' | translate }}</th>
<td mat-cell *matCellDef="let b">{{ b.id }}</td>
</ng-container>
<ng-container matColumnDef="accountType">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account Type' | translate }}</th>
<td mat-cell *matCellDef="let b">{{ b.accountType }}</td>
</ng-container>
<ng-container matColumnDef="account">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account' | translate }}</th>
<td mat-cell *matCellDef="let b">
{{ b.accountNo || b.accountId }}
@if (b.clientName) {
<span>({{ b.clientName }})</span>
}
</td>
</ng-container>
<ng-container matColumnDef="officeName">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Office' | translate }}</th>
<td mat-cell *matCellDef="let b">{{ b.officeName }}</td>
</ng-container>
<ng-container matColumnDef="seriesCode">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Series' | translate }}</th>
<td mat-cell *matCellDef="let b">{{ b.seriesCode }}</td>
</ng-container>
<ng-container matColumnDef="quantity">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Quantity' | translate }}</th>
<td mat-cell *matCellDef="let b" class="num">
{{ b.noOfBooks }} {{ 'labels.inputs.books' | translate }}
@if (b.totalLeaves) {
<span class="muted">({{ b.totalLeaves }} {{ 'labels.inputs.leaves' | translate }})</span>
}
</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
<td mat-cell *matCellDef="let b">
<span
class="pill"
[ngClass]="
b.status === 'ISSUED' ? 'ok' : b.status === 'REJECTED' || b.status === 'WITHDRAWN' ? 'bad' : 'neutral'
"
>{{ b.status }}</span
>
</td>
</ng-container>
<ng-container matColumnDef="requestedDate">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Requested Date' | translate }}</th>
<td mat-cell *matCellDef="let b">{{ b.requestedDate | dateFormat }}</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let b" (click)="$event.stopPropagation()">
@if (b.status === 'REQUESTED') {
<button mat-button color="primary" (click)="toggleIssue(b)">
{{ 'labels.buttons.Issue' | translate }}
</button>
<button mat-button color="warn" (click)="toggleReject(b)">{{ 'labels.buttons.Reject' | translate }}</button>
}
@if (b.status === 'ISSUED') {
<button mat-button (click)="openBook(b)">{{ 'labels.buttons.View' | translate }}</button>
<button mat-button color="warn" (click)="withdraw(b)">{{ 'labels.buttons.Withdraw' | translate }}</button>
}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="columns"></tr>
<tr mat-row *matRowDef="let row; columns: columns" (click)="openBook(row)" class="clickable-row"></tr>
</table>
@if (books.length === 0) {
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
}
@if (totalRecords > 0) {
<mat-paginator
[length]="totalRecords"
[pageSize]="pageSize"
[pageSizeOptions]="pageSizeOptions"
[pageIndex]="pageIndex"
showFirstLastButtons
(page)="onPageChange($event)"
></mat-paginator>
}
</div>
@if (issueForBookId) {
<mat-card class="add-form">
<mat-card-content>
<h3>{{ 'labels.inputs.Issue Cheque Book' | translate }}</h3>
<p class="issue-need">
{{ 'labels.inputs.Requested' | translate }}: <strong>{{ issueForBook?.noOfBooks }}</strong>
{{ 'labels.inputs.books' | translate }} — {{ issueForBook?.seriesCode }}
<span [class.err-hint]="issueAvailableBooks < (issueForBook?.noOfBooks || 0)">
({{ issueAvailableBooks }} {{ 'labels.inputs.books available' | translate }})
</span>
</p>
<form [formGroup]="issueForm" (ngSubmit)="submitIssue(issueForBookId)">
<mat-checkbox formControlName="collectionAcknowledged">
{{ 'labels.inputs.Customer collected the book now' | translate }}
</mat-checkbox>
<div class="form-actions">
<button mat-raised-button type="button" (click)="issueForBookId = null; issueForBook = null">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" type="submit" [disabled]="issueForm.invalid || busy">
{{ 'labels.buttons.Issue' | translate }}
</button>
</div>
</form>
</mat-card-content>
</mat-card>
}
@if (rejectForBookId) {
<mat-card class="add-form">
<mat-card-content>
<h3>{{ 'labels.inputs.Reject Cheque Book Request' | translate }}</h3>
<form [formGroup]="rejectForm" (ngSubmit)="submitReject(rejectForBookId)">
<div class="form-grid">
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Rejection Reason' | translate }}</mat-label>
<input matInput formControlName="rejectionReason" required />
</mat-form-field>
</div>
<div class="form-actions">
<button mat-raised-button type="button" (click)="rejectForBookId = null">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="warn" type="submit" [disabled]="rejectForm.invalid || busy">
{{ 'labels.buttons.Reject' | translate }}
</button>
</div>
</form>
</mat-card-content>
</mat-card>
}
</div>
@@ -0,0 +1,128 @@
/**
* 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/.
*/
.container {
padding: 16px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.header-actions {
display: flex;
gap: 12px;
}
.filter-row {
display: flex;
gap: 8px;
margin-bottom: 16px;
button.active {
background: rgb(25 118 210 / 12%);
}
}
.add-form {
margin-bottom: 16px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px 20px;
margin-bottom: 8px;
}
mat-form-field {
width: 100%;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 16px;
}
.table-wrapper {
overflow-x: auto;
background: #fff;
}
table {
width: 100%;
}
.clickable-row {
cursor: pointer;
&:hover {
background: rgb(0 0 0 / 3%);
}
}
td.num,
th.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.muted {
color: rgb(0 0 0 / 50%);
font-size: 0.8rem;
margin-left: 4px;
}
.issue-need {
margin: 0 0 8px;
font-size: 0.9rem;
color: rgb(0 0 0 / 70%);
}
::ng-deep .ok-hint {
color: #28c07a;
font-weight: 600;
}
::ng-deep .err-hint {
color: #d32f2f;
font-weight: 600;
}
.pill {
font-size: 11px;
padding: 2px 8px;
border-radius: 6px;
font-weight: 600;
&.ok {
background: #dff5e8;
color: #28c07a;
}
&.bad {
background: #fde3e3;
color: #d32f2f;
}
&.neutral {
background: #e0e0e0;
color: #757575;
}
}
.empty-state {
padding: 24px;
text-align: center;
color: rgb(0 0 0 / 50%);
}
@@ -0,0 +1,366 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatTableModule } from '@angular/material/table';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { Subject } from 'rxjs';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { ChequeBookService } from '../chequebook.service';
/** Cheque book requisitions and issuance — request, issue (draws from available stock), reject, withdraw. */
@Component({
selector: 'mifosx-cheque-books',
templateUrl: './cheque-books.component.html',
styleUrls: ['./cheque-books.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatIconModule,
MatButtonModule,
MatTableModule,
MatPaginatorModule,
MatSnackBarModule,
FaIconComponent
]
})
export class ChequeBooksComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private chequeBookService = inject(ChequeBookService);
private router = inject(Router);
private snackBar = inject(MatSnackBar);
private notify(message: string, isError = false): void {
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
}
books: any[] = [];
totalRecords = 0;
pageSize = 10;
pageIndex = 0;
pageSizeOptions = [
10,
25,
50,
100
];
seriesList: any[] = [];
offices: any[] = [];
stockLots: any[] = [];
busy = false;
showRequestForm = false;
issueForBookId: number | null = null;
issueForBook: any = null;
rejectForBookId: number | null = null;
statusFilter = '';
requestForm: UntypedFormGroup;
issueForm: UntypedFormGroup;
rejectForm: UntypedFormGroup;
// account-number lookup for the request form
private accountSearch$ = new Subject<string>();
resolvedAccount: any = null;
searchingAccount = false;
accountError = '';
columns = [
'id',
'accountType',
'account',
'officeName',
'seriesCode',
'quantity',
'status',
'requestedDate',
'actions'
];
constructor() {
this.requestForm = this.formBuilder.group({
accountType: [
'SAVINGS',
Validators.required
],
accountNo: [
'',
Validators.required
],
accountId: [
null,
Validators.required
],
officeId: [
'',
Validators.required
],
seriesId: [
'',
Validators.required
],
noOfBooks: [
1,
Validators.required
]
});
this.issueForm = this.formBuilder.group({
collectionAcknowledged: [false]
});
this.rejectForm = this.formBuilder.group({
rejectionReason: [
'',
Validators.required
]
});
}
ngOnInit(): void {
this.loadBooks();
this.loadStock();
// populates a dropdown, so fetch every active series in one page rather than paging through it
this.chequeBookService
.getSeries('ACTIVE', 0, 500)
.subscribe((res: any) => (this.seriesList = (res?.pageItems ?? []).filter((s: any) => s.active)));
this.chequeBookService.getOffices().subscribe((data: any[]) => (this.offices = data || []));
// account-number lookup: debounce, only fire once at least 4 chars typed (mirrors the cashier module)
this.accountSearch$
.pipe(debounceTime(400), distinctUntilChanged())
.subscribe((accountNo) => this.doAccountSearch(accountNo));
}
/** Called on every keystroke in the account-number box; clears any prior match and (re)triggers the search. */
onAccountNoInput(value: string): void {
this.resolvedAccount = null;
this.accountError = '';
this.requestForm.patchValue({ accountId: null });
const trimmed = (value || '').trim();
if (trimmed.length >= 4) {
this.accountSearch$.next(trimmed);
}
}
/** Re-run the lookup against the other ledger when the account type toggles; the series choice narrows too. */
onAccountTypeChange(): void {
this.resolvedAccount = null;
this.accountError = '';
this.requestForm.patchValue({ accountId: null, seriesId: '' });
const accountNo = (this.requestForm.get('accountNo')?.value || '').trim();
if (accountNo.length >= 4) {
this.accountSearch$.next(accountNo);
}
}
/** Series applicable to whichever account type is currently selected in the request form — every series is
* tagged SAVINGS-only or CURRENT-only (never both), so this is a direct match. */
get requestableSeries(): any[] {
const type = this.requestForm.get('accountType')?.value;
return this.seriesList.filter((s) => s.applicableAccountType === type);
}
private doAccountSearch(accountNo: string): void {
const accountType = this.requestForm.get('accountType')?.value;
this.searchingAccount = true;
this.accountError = '';
this.chequeBookService.searchAccountByNo(accountType, accountNo).subscribe({
next: (res: any) => {
this.searchingAccount = false;
const items = res?.pageItems || (Array.isArray(res) ? res : []);
const exact = items.find((i: any) => i.accountNo === accountNo);
const match = exact || (items.length === 1 ? items[0] : null);
if (match) {
this.resolvedAccount = {
id: match.id,
accountNo: match.accountNo,
clientName: match.clientName || match.clientDisplayName || match.groupName || '',
status: match.status?.value || ''
};
this.requestForm.patchValue({ accountId: match.id });
} else {
this.accountError = 'No account found with that number';
}
},
error: () => {
this.searchingAccount = false;
this.accountError = 'Account lookup failed';
}
});
}
loadStock(): void {
// feeds availability calculations below, so fetch every available lot in one page rather than paging through it
this.chequeBookService
.getStockLots(undefined, undefined, 'AVAILABLE', 0, 500)
.subscribe((res: any) => (this.stockLots = res?.pageItems ?? []));
}
/** Whole books currently on hand for a series, optionally narrowed to one office. */
availableBooksForSeries(seriesId: number, officeId?: number): number {
return this.stockLots
.filter((l) => l.seriesId === seriesId && (!officeId || l.officeId === officeId) && l.status === 'AVAILABLE')
.reduce((sum, l) => sum + (l.remainingBooks || 0), 0);
}
/** Availability at whichever office is currently chosen in the request form. */
availableBooksForRequestSeries(seriesId: number): number {
return this.availableBooksForSeries(seriesId, this.requestForm.get('officeId')?.value);
}
loadBooks(): void {
this.chequeBookService
.getBooks(
undefined,
undefined,
undefined,
this.statusFilter || undefined,
this.pageIndex * this.pageSize,
this.pageSize
)
.subscribe((res: any) => {
this.books = res?.pageItems ?? (Array.isArray(res) ? res : []);
this.totalRecords = Number(res?.totalFilteredRecords ?? this.books.length);
});
}
filterByStatus(status: string): void {
this.statusFilter = this.statusFilter === status ? '' : status;
this.pageIndex = 0;
this.loadBooks();
}
onPageChange(event: PageEvent): void {
this.pageSize = event.pageSize;
this.pageIndex = event.pageIndex;
this.loadBooks();
}
openBook(book: any): void {
this.router.navigate([
'/chequebook',
'books',
book.id
]);
}
submitRequest(): void {
if (this.requestForm.invalid || this.busy) {
return;
}
this.busy = true;
const raw = this.requestForm.value;
const payload = {
accountType: raw.accountType,
accountId: raw.accountId,
officeId: raw.officeId,
seriesId: raw.seriesId,
noOfBooks: raw.noOfBooks,
locale: 'en'
};
this.chequeBookService.requestBook(payload).subscribe({
next: () => {
this.busy = false;
this.showRequestForm = false;
this.requestForm.reset({ accountType: 'SAVINGS', noOfBooks: 1 });
this.resolvedAccount = null;
this.accountError = '';
this.notify('Submitted for approval — see Approvals > Cheque Book Requests');
this.loadBooks();
},
error: (err: any) => {
this.busy = false;
const msg =
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.developerMessage ||
'Request failed — check the account is active and the cheque-book facility is enabled';
this.notify(msg, true);
}
});
}
toggleIssue(book: any): void {
const opening = this.issueForBookId !== book.id;
this.issueForBookId = opening ? book.id : null;
this.issueForBook = opening ? book : null;
this.issueForm.reset({ collectionAcknowledged: false });
}
/** Books available right now for the series this book was already requested against. */
get issueAvailableBooks(): number {
return this.issueForBook?.seriesId
? this.availableBooksForSeries(this.issueForBook.seriesId, this.issueForBook.officeId)
: 0;
}
submitIssue(bookId: number): void {
if (this.issueForm.invalid || this.busy) {
return;
}
this.busy = true;
const raw = this.issueForm.value;
const payload = { collectionAcknowledged: !!raw.collectionAcknowledged, locale: 'en' };
this.chequeBookService.issueBook(bookId, payload).subscribe({
next: () => {
this.busy = false;
this.issueForBookId = null;
this.issueForBook = null;
this.notify('Cheque book issued');
this.loadBooks();
this.loadStock();
},
error: () => {
this.busy = false;
this.notify('Issue failed — check there is enough available stock for this series at the office', true);
}
});
}
toggleReject(book: any): void {
this.rejectForBookId = this.rejectForBookId === book.id ? null : book.id;
this.rejectForm.reset();
}
submitReject(bookId: number): void {
if (this.rejectForm.invalid || this.busy) {
return;
}
this.busy = true;
const payload = { rejectionReason: this.rejectForm.value.rejectionReason, locale: 'en' };
this.chequeBookService.rejectBook(bookId, payload).subscribe({
next: () => {
this.busy = false;
this.rejectForBookId = null;
this.notify('Cheque book request rejected');
this.loadBooks();
},
error: () => {
this.busy = false;
this.notify('Reject failed', true);
}
});
}
withdraw(book: any): void {
this.chequeBookService.withdrawBook(book.id).subscribe({
next: () => {
this.notify('Cheque book withdrawn');
this.loadBooks();
},
error: () => this.notify('Withdraw failed', true)
});
}
}
@@ -0,0 +1,136 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container">
<div class="page-header">
<h2>{{ 'labels.inputs.Cheque Series' | translate }}</h2>
<div class="header-actions">
<button mat-raised-button color="primary" (click)="showForm = !showForm">
<fa-icon icon="plus"></fa-icon>&nbsp;{{ 'labels.buttons.Add Cheque Series' | translate }}
</button>
<button mat-raised-button [routerLink]="['/chequebook']">{{ 'labels.buttons.Back' | translate }}</button>
</div>
</div>
@if (showForm) {
<mat-card class="add-form">
<mat-card-content>
<form [formGroup]="seriesForm" (ngSubmit)="submit()">
<div class="form-grid">
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Series Code' | translate }}</mat-label>
<input matInput formControlName="seriesCode" placeholder="CTS-25" required />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Name' | translate }}</mat-label>
<input matInput formControlName="name" required />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Leaves Per Book' | translate }}</mat-label>
<input matInput type="number" formControlName="leavesPerBook" required />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Applicable Account Type' | translate }}</mat-label>
<mat-select formControlName="applicableAccountType" required>
<mat-option value="SAVINGS">{{ 'labels.inputs.Savings Only' | translate }}</mat-option>
<mat-option value="CURRENT">{{ 'labels.inputs.Current Only' | translate }}</mat-option>
</mat-select>
<mat-hint>{{ 'labels.text.SB and CA cheque stationery differ' | translate }}</mat-hint>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Reorder Level' | translate }}</mat-label>
<input matInput type="number" formControlName="reorderLevel" />
<mat-hint>{{ 'labels.text.Low-stock alert threshold (remaining leaves)' | translate }}</mat-hint>
</mat-form-field>
</div>
<mat-checkbox formControlName="ctsCompliant">{{
'labels.inputs.CTS-2010 Compliant' | translate
}}</mat-checkbox>
<div class="form-actions">
<button mat-raised-button type="button" (click)="showForm = false">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" type="submit" [disabled]="seriesForm.invalid || busy">
{{ 'labels.buttons.Submit' | translate }}
</button>
</div>
</form>
</mat-card-content>
</mat-card>
}
<div class="filter-row">
@for (s of ['ACTIVE', 'INACTIVE']; track s) {
<button mat-stroked-button [class.active]="statusFilter === s" (click)="filterByStatus(s)">{{ s }}</button>
}
</div>
<div class="table-wrapper mat-elevation-z1">
<table mat-table [dataSource]="seriesList">
<ng-container matColumnDef="seriesCode">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Series Code' | translate }}</th>
<td mat-cell *matCellDef="let s">{{ s.seriesCode }}</td>
</ng-container>
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Name' | translate }}</th>
<td mat-cell *matCellDef="let s">{{ s.name }}</td>
</ng-container>
<ng-container matColumnDef="leavesPerBook">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Leaves Per Book' | translate }}</th>
<td mat-cell *matCellDef="let s" class="num">{{ s.leavesPerBook }}</td>
</ng-container>
<ng-container matColumnDef="applicableAccountType">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Applicable Account Type' | translate }}</th>
<td mat-cell *matCellDef="let s">
<span class="pill neutral">{{ s.applicableAccountType }}</span>
</td>
</ng-container>
<ng-container matColumnDef="ctsCompliant">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.CTS-2010 Compliant' | translate }}</th>
<td mat-cell *matCellDef="let s">
<span class="pill" [ngClass]="s.ctsCompliant ? 'ok' : 'neutral'">{{ s.ctsCompliant ? 'Yes' : 'No' }}</span>
</td>
</ng-container>
<ng-container matColumnDef="reorderLevel">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Reorder Level' | translate }}</th>
<td mat-cell *matCellDef="let s" class="num">{{ s.reorderLevel ?? '-' }}</td>
</ng-container>
<ng-container matColumnDef="active">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
<td mat-cell *matCellDef="let s">
<span class="pill" [ngClass]="s.active ? 'ok' : 'neutral'">{{ s.active ? 'Active' : 'Inactive' }}</span>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let s">
@if (s.active) {
<button mat-icon-button color="warn" (click)="deactivate(s)">
<mat-icon><fa-icon icon="trash"></fa-icon></mat-icon>
</button>
}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="columns"></tr>
<tr mat-row *matRowDef="let row; columns: columns"></tr>
</table>
@if (seriesList.length === 0) {
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
}
@if (totalRecords > 0) {
<mat-paginator
[length]="totalRecords"
[pageSize]="pageSize"
[pageSizeOptions]="pageSizeOptions"
[pageIndex]="pageIndex"
showFirstLastButtons
(page)="onPageChange($event)"
></mat-paginator>
}
</div>
</div>
@@ -0,0 +1,93 @@
/**
* 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/.
*/
.container {
padding: 16px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.header-actions {
display: flex;
gap: 12px;
}
.filter-row {
display: flex;
gap: 8px;
margin-bottom: 16px;
button.active {
background: rgb(25 118 210 / 12%);
}
}
.add-form {
margin-bottom: 16px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px 20px;
margin-bottom: 8px;
}
mat-form-field {
width: 100%;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 16px;
}
.table-wrapper {
overflow-x: auto;
background: #fff;
}
table {
width: 100%;
}
td.num,
th.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.pill {
font-size: 11px;
padding: 2px 8px;
border-radius: 6px;
font-weight: 600;
&.ok {
background: #dff5e8;
color: #28c07a;
}
&.neutral {
background: #e0e0e0;
color: #757575;
}
}
.empty-state {
padding: 24px;
text-align: center;
color: rgb(0 0 0 / 50%);
}
@@ -0,0 +1,176 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatTableModule } from '@angular/material/table';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatDialog } from '@angular/material/dialog';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { ChequeBookService } from '../chequebook.service';
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
/** Cheque-leaf stationery master — leaves per book, CTS-2010 compliance, low-stock reorder threshold. */
@Component({
selector: 'mifosx-cheque-series',
templateUrl: './cheque-series.component.html',
styleUrls: ['./cheque-series.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
RouterLink,
MatIconModule,
MatButtonModule,
MatTableModule,
MatPaginatorModule,
MatSnackBarModule,
FaIconComponent
]
})
export class ChequeSeriesComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private chequeBookService = inject(ChequeBookService);
private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar);
private notify(message: string, isError = false): void {
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
}
seriesList: any[] = [];
showForm = false;
busy = false;
statusFilter = '';
pageSize = 10;
pageIndex = 0;
totalRecords = 0;
pageSizeOptions = [
10,
25,
50,
100
];
seriesForm: UntypedFormGroup;
columns = [
'seriesCode',
'name',
'leavesPerBook',
'applicableAccountType',
'ctsCompliant',
'reorderLevel',
'active',
'actions'
];
constructor() {
this.seriesForm = this.formBuilder.group({
seriesCode: [
'',
Validators.required
],
name: [
'',
Validators.required
],
leavesPerBook: [
25,
Validators.required
],
applicableAccountType: [
'',
Validators.required
],
ctsCompliant: [true],
reorderLevel: [10]
});
}
ngOnInit(): void {
this.loadSeries();
}
loadSeries(): void {
this.chequeBookService
.getSeries(this.statusFilter || undefined, this.pageIndex * this.pageSize, this.pageSize)
.subscribe((res: any) => {
this.seriesList = res?.pageItems ?? [];
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
});
}
filterByStatus(status: string): void {
this.statusFilter = this.statusFilter === status ? '' : status;
this.pageIndex = 0;
this.loadSeries();
}
onPageChange(event: PageEvent): void {
this.pageSize = event.pageSize;
this.pageIndex = event.pageIndex;
this.loadSeries();
}
submit(): void {
if (this.seriesForm.invalid || this.busy) {
return;
}
this.busy = true;
const raw = this.seriesForm.value;
const payload: any = {
seriesCode: raw.seriesCode,
name: raw.name,
leavesPerBook: raw.leavesPerBook,
applicableAccountType: raw.applicableAccountType,
ctsCompliant: !!raw.ctsCompliant,
locale: 'en'
};
if (raw.reorderLevel !== '' && raw.reorderLevel != null) payload.reorderLevel = raw.reorderLevel;
this.chequeBookService.createSeries(payload).subscribe({
next: () => {
this.busy = false;
this.showForm = false;
this.seriesForm.reset({ leavesPerBook: 25, applicableAccountType: '', ctsCompliant: true, reorderLevel: 10 });
this.notify('Submitted for approval — see Approvals > Cheque Book Requests');
this.loadSeries();
},
error: () => {
this.busy = false;
this.notify('Failed to add cheque series', true);
}
});
}
deactivate(series: any): void {
const dialogRef = this.dialog.open(DeleteDialogComponent, {
data: { deleteContext: `cheque series ${series.seriesCode}` }
});
dialogRef.afterClosed().subscribe((response: any) => {
if (!response?.delete) {
return;
}
this.chequeBookService.deactivateSeries(series.id).subscribe({
next: () => {
this.notify('Submitted for approval — see Approvals > Cheque Book Requests');
this.loadSeries();
},
error: () => this.notify('Failed to submit for approval', true)
});
});
}
}
@@ -0,0 +1,29 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container">
<div class="page-header">
<h2>{{ 'labels.menus.Cheque Book Inventory' | translate }}</h2>
</div>
<div class="cards-grid">
@for (screen of screens; track screen.label) {
<mat-card class="cb-card" [routerLink]="screen.route">
<mat-card-content>
<div class="card-head">
<span class="card-icon">
<fa-icon [icon]="screen.icon"></fa-icon>
</span>
<span class="card-title">{{ screen.label | translate }}</span>
</div>
<p class="card-desc">{{ screen.description | translate }}</p>
</mat-card-content>
</mat-card>
}
</div>
</div>
@@ -0,0 +1,68 @@
/**
* 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/.
*/
.container {
padding: 16px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
}
.cb-card {
cursor: pointer;
transition:
box-shadow 0.2s ease,
transform 0.2s ease;
&:hover {
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
transform: translateY(-2px);
}
}
.card-head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.card-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 10px;
flex: 0 0 auto;
background: rgb(25 118 210 / 10%);
color: #1976d2;
font-size: 1.15rem;
}
.card-title {
font-size: 1.05rem;
font-weight: 600;
line-height: 1.2;
}
.card-desc {
color: rgb(0 0 0 / 60%);
font-size: 0.85rem;
margin: 0;
}
@@ -0,0 +1,72 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatCardModule } from '@angular/material/card';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Cheque Book Inventory landing page — one card per screen (series master, stock, requests/issuance, and
* the teller quick-lookup).
*/
@Component({
selector: 'mifosx-chequebook-dashboard',
templateUrl: './chequebook-dashboard.component.html',
styleUrls: ['./chequebook-dashboard.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
RouterLink,
MatIconModule,
MatCardModule,
FaIconComponent
]
})
export class ChequeBookDashboardComponent {
screens = [
{
route: [
'/chequebook',
'series'
],
label: 'Cheque Series',
icon: 'list',
description: 'Stationery master — leaves per book, CTS-2010 compliance, low-stock reorder level'
},
{
route: [
'/chequebook',
'stock'
],
label: 'Stock Lots & Transfers',
icon: 'boxes-stacked',
description: 'Receive bulk stock from the press, dispatch to branches, acknowledge receipt'
},
{
route: [
'/chequebook',
'books'
],
label: 'Requests & Issuance',
icon: 'book',
description: 'Customer requisitions, issuance, stop-payment, lost/stolen hotlisting'
},
{
route: [
'/chequebook',
'lookup'
],
label: 'Leaf Lookup',
icon: 'magnifying-glass',
description: 'Counter check — is this cheque number safe to honour?'
}
];
}
@@ -0,0 +1,69 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
/** Routing Imports */
import { Route } from '../core/route/route.service';
/** Custom Components */
import { ChequeBookDashboardComponent } from './chequebook-dashboard/chequebook-dashboard.component';
import { ChequeSeriesComponent } from './cheque-series/cheque-series.component';
import { StockManagementComponent } from './stock-management/stock-management.component';
import { ChequeBooksComponent } from './cheque-books/cheque-books.component';
import { ChequeBookDetailComponent } from './cheque-book-detail/cheque-book-detail.component';
import { LeafLookupComponent } from './leaf-lookup/leaf-lookup.component';
/** Cheque Book Inventory Routes */
const routes: Routes = [
Route.withShell([
{
path: 'chequebook',
data: { title: 'Cheque Book Inventory', breadcrumb: 'Cheque Book Inventory' },
children: [
{
path: '',
component: ChequeBookDashboardComponent
},
{
path: 'series',
component: ChequeSeriesComponent,
data: { title: 'Cheque Series', breadcrumb: 'Cheque Series' }
},
{
path: 'stock',
component: StockManagementComponent,
data: { title: 'Cheque Stock', breadcrumb: 'Cheque Stock' }
},
{
path: 'books',
component: ChequeBooksComponent,
data: { title: 'Cheque Books', breadcrumb: 'Cheque Books' }
},
{
path: 'books/:id',
component: ChequeBookDetailComponent,
data: { title: 'Cheque Book', routeParamBreadcrumb: 'id' }
},
{
path: 'lookup',
component: LeafLookupComponent,
data: { title: 'Leaf Lookup', breadcrumb: 'Leaf Lookup' }
}
]
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class ChequeBookRoutingModule {}
+46
View File
@@ -0,0 +1,46 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { NgModule } from '@angular/core';
/** Custom Modules */
import { SharedModule } from '../shared/shared.module';
import { DirectivesModule } from '../directives/directives.module';
import { PipesModule } from 'app/pipes/pipes.module';
import { ChequeBookRoutingModule } from './chequebook-routing.module';
/** Custom Components (standalone) */
import { ChequeBookDashboardComponent } from './chequebook-dashboard/chequebook-dashboard.component';
import { ChequeSeriesComponent } from './cheque-series/cheque-series.component';
import { StockManagementComponent } from './stock-management/stock-management.component';
import { ChequeBooksComponent } from './cheque-books/cheque-books.component';
import { ChequeBookDetailComponent } from './cheque-book-detail/cheque-book-detail.component';
import { LeafLookupComponent } from './leaf-lookup/leaf-lookup.component';
/**
* Cheque Book Inventory Module.
*
* Stationery master, a unified stock ledger (HO receipt / branch transfer-in), account issuance with
* per-leaf serial tracking, stop-payment, and lost/stolen hotlisting for both issued and unissued stock.
*/
@NgModule({
imports: [
SharedModule,
DirectivesModule,
PipesModule,
ChequeBookRoutingModule,
ChequeBookDashboardComponent,
ChequeSeriesComponent,
StockManagementComponent,
ChequeBooksComponent,
ChequeBookDetailComponent,
LeafLookupComponent
]
})
export class ChequeBookModule {}
+226
View File
@@ -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/.
*/
/** Angular Imports */
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
/**
* Cheque Book Inventory — stationery master, a unified stock ledger (HO receipt / branch transfer-in share
* one drawdown-cursor concept), account issuance with per-leaf serial tracking, stop-payment and
* lost/stolen hotlisting (both issued and unissued stock).
*/
@Injectable({
providedIn: 'root'
})
export class ChequeBookService {
private http = inject(HttpClient);
// ----- Series -----
/** status: 'ACTIVE' | 'INACTIVE'. Server-side paginated — always returns { pageItems, totalFilteredRecords }. */
getSeries(status?: string, offset = 0, limit = 20): Observable<any> {
let params = new HttpParams().set('offset', offset).set('limit', limit);
if (status) params = params.set('status', status);
return this.http.get('/chequeseries', { params });
}
createSeries(payload: any): Observable<any> {
return this.http.post('/chequeseries', payload);
}
updateSeries(id: string | number, payload: any): Observable<any> {
return this.http.put(`/chequeseries/${id}`, payload);
}
deactivateSeries(id: string | number): Observable<any> {
return this.http.delete(`/chequeseries/${id}`);
}
// ----- Stock lots -----
/** Server-side paginated — always returns { pageItems, totalFilteredRecords }. */
getStockLots(
officeId?: string | number,
seriesId?: string | number,
status?: string,
offset = 0,
limit = 20
): Observable<any> {
let params = new HttpParams().set('offset', offset).set('limit', limit);
if (officeId) params = params.set('officeId', officeId);
if (seriesId) params = params.set('seriesId', seriesId);
if (status) params = params.set('status', status);
return this.http.get('/chequestocklots', { params });
}
receiveStock(payload: any): Observable<any> {
return this.http.post('/chequestocklots', payload);
}
reportStockLotLost(lotId: string | number, payload: any): Observable<any> {
return this.http.post(`/chequestocklots/${lotId}`, payload, {
params: new HttpParams().set('command', 'reportlost')
});
}
// ----- Stock transfers -----
/** Server-side paginated — always returns { pageItems, totalFilteredRecords }. */
getStockTransfers(status?: string, offset = 0, limit = 20): Observable<any> {
let params = new HttpParams().set('offset', offset).set('limit', limit);
if (status) params = params.set('status', status);
return this.http.get('/chequestocktransfers', { params });
}
dispatchStockTransfer(payload: any): Observable<any> {
return this.http.post('/chequestocktransfers', payload);
}
receiveStockTransfer(transferId: string | number): Observable<any> {
return this.http.post(
`/chequestocktransfers/${transferId}`,
{},
{ params: new HttpParams().set('command', 'receive') }
);
}
rejectStockTransfer(transferId: string | number): Observable<any> {
return this.http.post(
`/chequestocktransfers/${transferId}`,
{},
{ params: new HttpParams().set('command', 'reject') }
);
}
// ----- Cheque books -----
getBooks(
accountType?: string,
accountId?: string | number,
officeId?: string | number,
status?: string,
offset = 0,
limit = 20
): Observable<any> {
let params = new HttpParams().set('offset', offset).set('limit', limit);
if (accountType) params = params.set('accountType', accountType);
if (accountId) params = params.set('accountId', accountId);
if (officeId) params = params.set('officeId', officeId);
if (status) params = params.set('status', status);
return this.http.get('/chequebooks', { params });
}
getBook(id: string | number): Observable<any> {
return this.http.get(`/chequebooks/${id}`);
}
getLeaves(bookId: string | number): Observable<any> {
return this.http.get(`/chequebooks/${bookId}/leaves`);
}
requestBook(payload: any): Observable<any> {
return this.http.post('/chequebooks', payload);
}
issueBook(bookId: string | number, payload: any): Observable<any> {
return this.http.post(`/chequebooks/${bookId}`, payload, { params: new HttpParams().set('command', 'issue') });
}
rejectBook(bookId: string | number, payload: any): Observable<any> {
return this.http.post(`/chequebooks/${bookId}`, payload, { params: new HttpParams().set('command', 'reject') });
}
withdrawBook(bookId: string | number): Observable<any> {
return this.http.post(`/chequebooks/${bookId}`, {}, { params: new HttpParams().set('command', 'withdraw') });
}
stopPayment(bookId: string | number, payload: any): Observable<any> {
return this.http.post(`/chequebooks/${bookId}`, payload, {
params: new HttpParams().set('command', 'stoppayment')
});
}
revokeStopPayment(bookId: string | number, payload: any): Observable<any> {
return this.http.post(`/chequebooks/${bookId}`, payload, {
params: new HttpParams().set('command', 'revokestoppayment')
});
}
reportBookLost(bookId: string | number, payload: any): Observable<any> {
return this.http.post(`/chequebooks/${bookId}`, payload, { params: new HttpParams().set('command', 'reportlost') });
}
cancelLeaf(bookId: string | number, payload: any): Observable<any> {
return this.http.post(`/chequebooks/${bookId}`, payload, { params: new HttpParams().set('command', 'cancelleaf') });
}
/** Manually record that leaves cleared, once the branch sees them on the bank/clearing statement. */
markUsed(bookId: string | number, payload: any): Observable<any> {
return this.http.post(`/chequebooks/${bookId}`, payload, { params: new HttpParams().set('command', 'markused') });
}
/** Flag leaves as returned/dishonoured (insufficient funds, signature mismatch, etc.) — terminal, like reportlost. */
returnBook(bookId: string | number, payload: any): Observable<any> {
return this.http.post(`/chequebooks/${bookId}`, payload, { params: new HttpParams().set('command', 'return') });
}
// ----- Pending requests (bespoke maker-checker for series/stock/book creation) -----
/**
* A different user than the maker must approve/reject — enforced server-side, not just in this UI.
* Server-side paginated and date-filtered, matching the clerk disbursement/withdrawal request queues.
*/
getPendingRequests(status?: string, fromDate?: string, toDate?: string, offset = 0, limit = 20): Observable<any> {
let params = new HttpParams().set('offset', offset).set('limit', limit);
if (status) params = params.set('status', status);
if (fromDate) params = params.set('fromDate', fromDate);
if (toDate) params = params.set('toDate', toDate);
return this.http.get('/chequebook/pending-requests', { params });
}
getPendingRequest(id: string | number): Observable<any> {
return this.http.get(`/chequebook/pending-requests/${id}`);
}
approvePendingRequest(id: string | number): Observable<any> {
return this.http.post(
`/chequebook/pending-requests/${id}`,
{},
{ params: new HttpParams().set('command', 'approve') }
);
}
rejectPendingRequest(id: string | number, payload: any): Observable<any> {
return this.http.post(`/chequebook/pending-requests/${id}`, payload, {
params: new HttpParams().set('command', 'reject')
});
}
// ----- Leaf lookup -----
lookupLeaf(serialNo: string | number): Observable<any> {
return this.http.get('/chequeleaves/lookup', { params: new HttpParams().set('serialNo', serialNo) });
}
// ----- Account lookup (by account number) -----
/** Search a Savings or Current account by its account number; returns a paginated list to match against. */
searchAccountByNo(accountType: string, accountNo: string): Observable<any> {
const base = accountType === 'CURRENT' ? '/currentaccounts' : '/savingsaccounts';
const params = new HttpParams().set('accountNo', accountNo).set('offset', '0').set('limit', '20');
return this.http.get(base, { params });
}
// ----- Shared template data -----
getOffices(): Observable<any> {
return this.http.get('/offices');
}
}
@@ -0,0 +1,136 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container">
<div class="page-header">
<h2>{{ 'labels.inputs.Leaf Lookup' | translate }}</h2>
<button mat-raised-button [routerLink]="['/chequebook']">{{ 'labels.buttons.Back' | translate }}</button>
</div>
<mat-card class="search-card">
<mat-card-content>
<form [formGroup]="searchForm" (ngSubmit)="search()" class="search-row">
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Cheque Leaf Serial No' | translate }}</mat-label>
<input matInput type="number" formControlName="serialNo" required />
</mat-form-field>
<button mat-raised-button color="primary" type="submit" [disabled]="searchForm.invalid || busy">
<fa-icon icon="magnifying-glass"></fa-icon>&nbsp;{{ 'labels.buttons.Search' | translate }}
</button>
</form>
</mat-card-content>
</mat-card>
@if (results.length > 1) {
<div class="multi-match-note">
{{
'labels.text.More than one series has a leaf with this serial number — check the series code against the physical cheque.'
| translate
}}
</div>
}
@for (result of results; track result.seriesCode + '-' + result.leafSerialNo) {
<mat-card class="result-card">
<mat-card-content>
<div class="result-header">
@if (result.seriesCode) {
<span class="series-tag">{{ result.seriesCode }}</span>
}
<span
class="pill big"
[ngClass]="
result.status === 'ACTIVE'
? 'ok'
: result.status === 'USED'
? 'used'
: result.status === 'STOP_PAYMENT'
? 'warn'
: result.status === 'LOST'
? 'bad'
: result.status === 'RETURNED'
? 'returned'
: 'neutral'
"
>{{ result.status }}</span
>
</div>
<div class="info-grid">
<div>
<span class="k">{{ 'labels.inputs.Serial No' | translate }}</span
><span>{{ result.leafSerialNo }}</span>
</div>
<div>
<span class="k">{{ 'labels.inputs.Series' | translate }}</span
><span>{{ result.seriesCode || '–' }}</span>
</div>
@if (result.chequeBookId) {
<div>
<span class="k">{{ 'labels.inputs.Cheque Book' | translate }}</span
><span>#{{ result.chequeBookId }}</span>
</div>
<div>
<span class="k">{{ 'labels.inputs.Account' | translate }}</span
><span>{{ result.accountType }} {{ result.accountNo || result.accountId }}</span>
</div>
@if (result.clientName) {
<div>
<span class="k">{{ 'labels.inputs.Client' | translate }}</span
><span>{{ result.clientName }}</span>
</div>
}
} @else {
<div class="warn-note">
{{
'labels.text.This leaf was never issued to a customer — reported lost/stolen from stock.' | translate
}}
</div>
}
<div>
<span class="k">{{ 'labels.inputs.Office' | translate }}</span
><span>{{ result.officeName }}</span>
</div>
@if (result.status === 'STOP_PAYMENT') {
<div>
<span class="k">{{ 'labels.inputs.Stop Reason' | translate }}</span
><span>{{ result.stopReason }}</span>
</div>
}
@if (result.status === 'LOST') {
<div>
<span class="k">{{ 'labels.inputs.Lost Reason' | translate }}</span
><span>{{ result.lostReason }}</span>
</div>
}
@if (result.status === 'CANCELLED') {
<div>
<span class="k">{{ 'labels.inputs.Cancelled Reason' | translate }}</span
><span>{{ result.cancelledReason }}</span>
</div>
}
@if (result.status === 'USED' && result.usedReason) {
<div>
<span class="k">{{ 'labels.inputs.Used Reason' | translate }}</span
><span>{{ result.usedReason }}</span>
</div>
}
@if (result.status === 'RETURNED') {
<div>
<span class="k">{{ 'labels.inputs.Return Reason' | translate }}</span
><span>{{ result.returnReason }}</span>
</div>
}
</div>
</mat-card-content>
</mat-card>
} @empty {
@if (searched && !busy) {
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
}
}
</div>
@@ -0,0 +1,130 @@
/**
* 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/.
*/
.container {
padding: 16px;
max-width: 700px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.search-card {
margin-bottom: 16px;
}
.search-row {
display: flex;
gap: 16px;
align-items: flex-start;
mat-form-field {
flex: 1;
}
}
.result-card {
margin-bottom: 16px;
}
.result-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
}
.series-tag {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.02em;
color: rgb(0 0 0 / 60%);
}
.multi-match-note {
margin-bottom: 16px;
color: #a16207;
font-weight: 600;
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px 24px;
div {
display: flex;
flex-direction: column;
}
.k {
font-size: 0.72rem;
text-transform: uppercase;
color: rgb(0 0 0 / 50%);
}
.warn-note {
grid-column: 1 / -1;
color: #a16207;
font-weight: 600;
}
}
.pill {
font-size: 11px;
padding: 2px 8px;
border-radius: 6px;
font-weight: 600;
&.big {
font-size: 14px;
padding: 6px 14px;
border-radius: 8px;
}
&.ok {
background: #dff5e8;
color: #28c07a;
}
&.used {
background: #e3edfd;
color: #1976d2;
}
&.bad {
background: #fde3e3;
color: #d32f2f;
}
&.warn {
background: #fff3cd;
color: #a16207;
}
&.neutral {
background: #e0e0e0;
color: #757575;
}
&.returned {
background: #f3e8fd;
color: #9333ea;
}
}
.empty-state {
padding: 24px;
text-align: center;
color: rgb(0 0 0 / 50%);
}
@@ -0,0 +1,81 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, inject } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { ChequeBookService } from '../chequebook.service';
/**
* The teller/counter quick-lookup: given a bare cheque-leaf serial number, is it active, stopped, lost, or
* was it ever issued at all — one code path covers every case (issued or unissued-but-stolen stock).
*/
@Component({
selector: 'mifosx-leaf-lookup',
templateUrl: './leaf-lookup.component.html',
styleUrls: ['./leaf-lookup.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatIconModule,
MatButtonModule,
MatSnackBarModule,
FaIconComponent
]
})
export class LeafLookupComponent {
private formBuilder = inject(UntypedFormBuilder);
private chequeBookService = inject(ChequeBookService);
private snackBar = inject(MatSnackBar);
private notify(message: string, isError = false): void {
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
}
searchForm: UntypedFormGroup;
// a bare serial number is only unique within its own series — more than one series can share a number,
// so the lookup can legitimately return more than one match
results: any[] = [];
searched = false;
busy = false;
constructor() {
this.searchForm = this.formBuilder.group({
serialNo: [
'',
Validators.required
]
});
}
search(): void {
if (this.searchForm.invalid || this.busy) {
return;
}
this.busy = true;
this.searched = true;
this.results = [];
this.chequeBookService.lookupLeaf(this.searchForm.value.serialNo).subscribe({
next: (data: any) => {
this.busy = false;
this.results = Array.isArray(data) ? data : [data];
},
error: () => {
this.busy = false;
this.results = [];
this.notify('No cheque leaf found with that serial number', true);
}
});
}
}
@@ -0,0 +1,47 @@
<!--
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/.
-->
<h1 mat-dialog-title>{{ 'labels.inputs.Report Remaining Stock Lost/Stolen' | translate }}</h1>
<div mat-dialog-content>
<div class="rll-context">
<div class="rll-field">
<span class="k">{{ 'labels.inputs.Series' | translate }}</span>
<span>{{ data.seriesCode }}</span>
</div>
<div class="rll-field">
<span class="k">{{ 'labels.inputs.Office' | translate }}</span>
<span>{{ data.officeName }}</span>
</div>
<div class="rll-field">
<span class="k">{{ 'labels.inputs.Remaining Range' | translate }}</span>
<span>{{ data.nextSerialNo }} – {{ data.rangeEnd }}</span>
</div>
</div>
<form [formGroup]="reportLostForm" (ngSubmit)="submit()" class="rll-form">
<div class="form-grid">
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.From Serial No' | translate }}</mat-label>
<input matInput type="number" formControlName="fromSerialNo" required />
<mat-hint>{{
'labels.text.Only a suffix of the remaining undrawn range can be reported' | translate
}}</mat-hint>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Reason' | translate }}</mat-label>
<input matInput formControlName="reason" required />
</mat-form-field>
</div>
</form>
</div>
<mat-dialog-actions align="end">
<button mat-raised-button mat-dialog-close>{{ 'labels.buttons.Cancel' | translate }}</button>
<button mat-raised-button color="warn" [disabled]="reportLostForm.invalid || busy" (click)="submit()">
{{ 'labels.buttons.Report Lost' | translate }}
</button>
</mat-dialog-actions>
@@ -0,0 +1,34 @@
/**
* 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/.
*/
.rll-context {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px 20px;
min-width: 420px;
margin-bottom: 18px;
padding-bottom: 14px;
border-bottom: 1px solid rgb(0 0 0 / 8%);
.rll-field {
display: flex;
flex-direction: column;
}
.k {
font-size: 0.7rem;
text-transform: uppercase;
color: rgb(0 0 0 / 50%);
}
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px 20px;
}
@@ -0,0 +1,90 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, inject } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import {
MatDialogRef,
MAT_DIALOG_DATA,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose
} from '@angular/material/dialog';
import { CdkScrollable } from '@angular/cdk/scrolling';
import { MatSnackBar } from '@angular/material/snack-bar';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { ChequeBookService } from '../../chequebook.service';
/** Popup shown when "Report Lost" is clicked on a stock lot row — reports the undrawn suffix as lost/stolen. */
@Component({
selector: 'mifosx-report-lot-lost-dialog',
templateUrl: './report-lot-lost-dialog.component.html',
styleUrls: ['./report-lot-lost-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatDialogTitle,
CdkScrollable,
MatDialogContent,
MatDialogActions,
MatDialogClose
]
})
export class ReportLotLostDialogComponent {
dialogRef = inject<MatDialogRef<ReportLotLostDialogComponent>>(MatDialogRef);
data = inject(MAT_DIALOG_DATA);
private chequeBookService = inject(ChequeBookService);
private snackBar = inject(MatSnackBar);
private formBuilder = inject(UntypedFormBuilder);
private notify(message: string, isError = false): void {
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
}
busy = false;
reportLostForm: UntypedFormGroup;
constructor() {
this.reportLostForm = this.formBuilder.group({
fromSerialNo: [
this.data.nextSerialNo,
Validators.required
],
reason: [
'',
Validators.required
]
});
}
submit(): void {
if (this.reportLostForm.invalid || this.busy) {
return;
}
this.busy = true;
const raw = this.reportLostForm.value;
const payload = { fromSerialNo: raw.fromSerialNo, reason: raw.reason, locale: 'en' };
this.chequeBookService.reportStockLotLost(this.data.id, payload).subscribe({
next: () => {
this.busy = false;
this.notify('Remaining stock reported lost/stolen');
this.dialogRef.close({ reported: true });
},
error: () => {
this.busy = false;
this.notify(
'Failed to report stock lost — check the serial number is within the remaining undrawn range',
true
);
}
});
}
}
@@ -0,0 +1,328 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container">
<div class="page-header">
<h2>{{ 'labels.inputs.Cheque Stock' | translate }}</h2>
<button mat-raised-button [routerLink]="['/chequebook']">{{ 'labels.buttons.Back' | translate }}</button>
</div>
<mat-tab-group>
<!-- ============ Stock Lots ============ -->
<mat-tab label="{{ 'labels.inputs.Stock Lots' | translate }}">
<div class="tab-body">
<!-- Denomination-wise availability roll-up: how many 25-leaf / 50-leaf books are on hand -->
@if (stockSummary.length > 0) {
<div class="summary-strip">
<div class="summary-title">{{ 'labels.inputs.Available Stock by Book Size' | translate }}</div>
<div class="summary-cards">
@for (row of stockSummary; track row.seriesCode + row.leavesPerBook) {
<div class="summary-card">
<div class="summary-books">{{ row.books }}</div>
<div class="summary-label">
{{ 'labels.inputs.books' | translate }} &times; {{ row.leavesPerBook }}-{{
'labels.inputs.leaf' | translate
}}
</div>
<div class="summary-series">{{ row.seriesCode }}</div>
</div>
}
</div>
</div>
}
<div class="tab-toolbar">
<button mat-raised-button color="primary" (click)="showReceiveForm = !showReceiveForm">
<fa-icon icon="plus"></fa-icon>&nbsp;{{ 'labels.buttons.Receive Stock' | translate }}
</button>
</div>
@if (showReceiveForm) {
<mat-card class="add-form">
<mat-card-content>
<form [formGroup]="receiveForm" (ngSubmit)="submitReceive()">
<div class="form-grid">
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Series' | translate }}</mat-label>
<mat-select formControlName="seriesId" required (selectionChange)="onReceiveSeriesChange()">
@for (s of seriesList; track s.id) {
<mat-option [value]="s.id"
>{{ s.seriesCode }} — {{ s.name }} ({{ s.leavesPerBook }}-{{
'labels.inputs.leaf' | translate
}})</mat-option
>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Office' | translate }}</mat-label>
<mat-select formControlName="officeId" required>
@for (office of offices; track office.id) {
<mat-option [value]="office.id">{{ office.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Number of Books' | translate }}</mat-label>
<input matInput type="number" formControlName="noOfBooks" required />
@if (receiveLeavesPerBook) {
<mat-hint
>{{ receiveTotalLeaves }} {{ 'labels.inputs.leaves' | translate }} ({{
'labels.inputs.books' | translate
}}
&times; {{ receiveLeavesPerBook }})</mat-hint
>
}
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Range Start' | translate }}</mat-label>
<input matInput type="number" formControlName="rangeStart" required />
<mat-hint>{{ 'labels.text.Auto-suggested to avoid overlap; editable' | translate }}</mat-hint>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Range End' | translate }}</mat-label>
<input matInput type="number" [value]="receiveRangeEnd ?? ''" readonly />
<mat-hint>{{ 'labels.text.Calculated from books x book size' | translate }}</mat-hint>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Batch / PO Reference' | translate }}</mat-label>
<input matInput formControlName="batchRef" />
</mat-form-field>
</div>
<div class="form-actions">
<button mat-raised-button type="button" (click)="showReceiveForm = false">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button
mat-raised-button
color="primary"
type="submit"
[disabled]="receiveForm.invalid || !receiveRangeEnd || busy"
>
{{ 'labels.buttons.Submit' | translate }}
</button>
</div>
</form>
</mat-card-content>
</mat-card>
}
<div class="filter-row">
@for (s of lotStatusOptions; track s) {
<button mat-stroked-button [class.active]="lotStatusFilter === s" (click)="filterLotsByStatus(s)">
{{ s }}
</button>
}
</div>
<div class="table-wrapper mat-elevation-z1">
<table mat-table [dataSource]="displayedLots">
<ng-container matColumnDef="seriesCode">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Series' | translate }}</th>
<td mat-cell *matCellDef="let l">{{ l.seriesCode }}</td>
</ng-container>
<ng-container matColumnDef="officeName">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Office' | translate }}</th>
<td mat-cell *matCellDef="let l">{{ l.officeName }}</td>
</ng-container>
<ng-container matColumnDef="range">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Leaf Range' | translate }}</th>
<td mat-cell *matCellDef="let l" class="num">{{ l.rangeStart }} – {{ l.rangeEnd }}</td>
</ng-container>
<ng-container matColumnDef="remainingLeaves">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Remaining' | translate }}</th>
<td mat-cell *matCellDef="let l" class="num">
<strong>{{ l.remainingBooks ?? 0 }}</strong> {{ 'labels.inputs.books' | translate }}
<span class="muted">× {{ l.leavesPerBook }}-{{ 'labels.inputs.leaf' | translate }}</span>
<span class="muted">({{ l.remainingLeaves }} {{ 'labels.inputs.leaves' | translate }})</span>
@if (l.lowStock) {
<span class="pill warn">{{ 'labels.text.Low Stock' | translate }}</span>
}
</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
<td mat-cell *matCellDef="let l">
<span
class="pill"
[ngClass]="l.status === 'AVAILABLE' ? 'ok' : l.status === 'CANCELLED' ? 'bad' : 'neutral'"
>{{ l.status }}</span
>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let l">
@if (l.status === 'AVAILABLE') {
<button mat-button color="warn" (click)="openReportLost(l)">
{{ 'labels.buttons.Report Lost' | translate }}
</button>
}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="lotColumns"></tr>
<tr mat-row *matRowDef="let row; columns: lotColumns"></tr>
</table>
@if (displayedLots.length === 0) {
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
}
@if (lotTotalRecords > 0) {
<mat-paginator
[length]="lotTotalRecords"
[pageSize]="lotPageSize"
[pageSizeOptions]="pageSizeOptions"
[pageIndex]="lotPageIndex"
showFirstLastButtons
(page)="onLotPageChange($event)"
></mat-paginator>
}
</div>
</div>
</mat-tab>
<!-- ============ Stock Transfers ============ -->
<mat-tab label="{{ 'labels.inputs.Stock Transfers' | translate }}">
<div class="tab-body">
<div class="tab-toolbar">
<button mat-raised-button color="primary" (click)="showDispatchForm = !showDispatchForm">
<fa-icon icon="plus"></fa-icon>&nbsp;{{ 'labels.buttons.Dispatch Transfer' | translate }}
</button>
</div>
@if (showDispatchForm) {
<mat-card class="add-form">
<mat-card-content>
<form [formGroup]="dispatchForm" (ngSubmit)="submitDispatch()">
<div class="form-grid">
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Series' | translate }}</mat-label>
<mat-select formControlName="seriesId" required>
@for (s of seriesList; track s.id) {
<mat-option [value]="s.id">{{ s.seriesCode }} — {{ s.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.From Office' | translate }}</mat-label>
<mat-select formControlName="fromOfficeId" required>
@for (office of offices; track office.id) {
<mat-option [value]="office.id">{{ office.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.To Office' | translate }}</mat-label>
<mat-select formControlName="toOfficeId" required>
@for (office of offices; track office.id) {
<mat-option [value]="office.id">{{ office.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.From Lot Id' | translate }}</mat-label>
<input matInput type="number" formControlName="fromLotId" required />
<mat-hint>{{
'labels.text.The stock-lot id to draw the transfer from (see Stock Lots tab)' | translate
}}</mat-hint>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.No of Leaves' | translate }}</mat-label>
<input matInput type="number" formControlName="noOfLeaves" required />
</mat-form-field>
</div>
<div class="form-actions">
<button mat-raised-button type="button" (click)="showDispatchForm = false">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" type="submit" [disabled]="dispatchForm.invalid || busy">
{{ 'labels.buttons.Submit' | translate }}
</button>
</div>
</form>
</mat-card-content>
</mat-card>
}
<div class="filter-row">
@for (s of transferStatusOptions; track s) {
<button mat-stroked-button [class.active]="transferStatusFilter === s" (click)="filterTransfersByStatus(s)">
{{ s }}
</button>
}
</div>
<div class="table-wrapper mat-elevation-z1">
<table mat-table [dataSource]="stockTransfers">
<ng-container matColumnDef="seriesCode">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Series' | translate }}</th>
<td mat-cell *matCellDef="let t">{{ t.seriesCode }}</td>
</ng-container>
<ng-container matColumnDef="fromOfficeName">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.From Office' | translate }}</th>
<td mat-cell *matCellDef="let t">{{ t.fromOfficeName }}</td>
</ng-container>
<ng-container matColumnDef="toOfficeName">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.To Office' | translate }}</th>
<td mat-cell *matCellDef="let t">{{ t.toOfficeName }}</td>
</ng-container>
<ng-container matColumnDef="range">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Leaf Range' | translate }}</th>
<td mat-cell *matCellDef="let t" class="num">{{ t.rangeStart }} – {{ t.rangeEnd }}</td>
</ng-container>
<ng-container matColumnDef="totalLeaves">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Leaves' | translate }}</th>
<td mat-cell *matCellDef="let t" class="num">{{ t.totalLeaves }}</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
<td mat-cell *matCellDef="let t">
<span
class="pill"
[ngClass]="t.status === 'RECEIVED' ? 'ok' : t.status === 'REJECTED' ? 'bad' : 'neutral'"
>{{ t.status }}</span
>
</td>
</ng-container>
<ng-container matColumnDef="dispatchDate">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Dispatch Date' | translate }}</th>
<td mat-cell *matCellDef="let t">{{ t.dispatchDate | dateFormat }}</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let t">
@if (t.status === 'DISPATCHED') {
<button mat-button color="primary" (click)="receiveTransfer(t)">
{{ 'labels.buttons.Receive' | translate }}
</button>
<button mat-button color="warn" (click)="rejectTransfer(t)">
{{ 'labels.buttons.Reject' | translate }}
</button>
}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="transferColumns"></tr>
<tr mat-row *matRowDef="let row; columns: transferColumns"></tr>
</table>
@if (stockTransfers.length === 0) {
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
}
@if (transferTotalRecords > 0) {
<mat-paginator
[length]="transferTotalRecords"
[pageSize]="transferPageSize"
[pageSizeOptions]="pageSizeOptions"
[pageIndex]="transferPageIndex"
showFirstLastButtons
(page)="onTransferPageChange($event)"
></mat-paginator>
}
</div>
</div>
</mat-tab>
</mat-tab-group>
</div>
@@ -0,0 +1,159 @@
/**
* 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/.
*/
.container {
padding: 16px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.tab-body {
padding: 16px 0;
}
.tab-toolbar {
margin-bottom: 12px;
}
.filter-row {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
button.active {
background: rgb(25 118 210 / 12%);
}
}
.summary-strip {
margin-bottom: 16px;
}
.summary-title {
font-size: 0.78rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
color: rgb(0 0 0 / 55%);
margin-bottom: 8px;
}
.summary-cards {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.summary-card {
min-width: 120px;
padding: 12px 16px;
border: 1px solid rgb(0 0 0 / 10%);
border-radius: 10px;
background: rgb(25 118 210 / 6%);
text-align: center;
}
.summary-books {
font-size: 1.6rem;
font-weight: 700;
color: #1976d2;
font-variant-numeric: tabular-nums;
}
.summary-label {
font-size: 0.8rem;
color: rgb(0 0 0 / 70%);
}
.summary-series {
font-size: 0.72rem;
color: rgb(0 0 0 / 50%);
margin-top: 2px;
}
.muted {
color: rgb(0 0 0 / 50%);
font-size: 0.8rem;
margin-left: 4px;
}
.add-form {
margin-bottom: 16px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px 20px;
}
mat-form-field {
width: 100%;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 16px;
}
.table-wrapper {
overflow-x: auto;
background: #fff;
}
table {
width: 100%;
}
td.num,
th.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.pill {
font-size: 11px;
padding: 2px 8px;
border-radius: 6px;
font-weight: 600;
margin-left: 6px;
&.ok {
background: #dff5e8;
color: #28c07a;
}
&.bad {
background: #fde3e3;
color: #d32f2f;
}
&.warn {
background: #fff3cd;
color: #a16207;
}
&.neutral {
background: #e0e0e0;
color: #757575;
}
}
.empty-state {
padding: 24px;
text-align: center;
color: rgb(0 0 0 / 50%);
}
@@ -0,0 +1,398 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatTableModule } from '@angular/material/table';
import { MatTabsModule } from '@angular/material/tabs';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatDialog } from '@angular/material/dialog';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { ChequeBookService } from '../chequebook.service';
/** Dialog Components */
import { ReportLotLostDialogComponent } from './report-lot-lost-dialog/report-lot-lost-dialog.component';
/**
* Cheque stock: the unified stock-lot ledger (HO receipt / branch transfer-in share one drawdown-cursor
* concept) and the office-to-office transfer workflow (dispatch → receive-ack / reject) that draws from it.
*/
@Component({
selector: 'mifosx-stock-management',
templateUrl: './stock-management.component.html',
styleUrls: ['./stock-management.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
RouterLink,
MatIconModule,
MatButtonModule,
MatTableModule,
MatTabsModule,
MatPaginatorModule,
MatSnackBarModule,
FaIconComponent
]
})
export class StockManagementComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private chequeBookService = inject(ChequeBookService);
private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
private notify(message: string, isError = false): void {
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
}
seriesList: any[] = [];
offices: any[] = [];
busy = false;
showReceiveForm = false;
showDispatchForm = false;
receiveForm: UntypedFormGroup;
dispatchForm: UntypedFormGroup;
lotColumns = [
'seriesCode',
'officeName',
'range',
'remainingLeaves',
'status',
'actions'
];
transferColumns = [
'seriesCode',
'fromOfficeName',
'toOfficeName',
'range',
'totalLeaves',
'status',
'dispatchDate',
'actions'
];
readonly lotStatusOptions = [
'AVAILABLE',
'DISPATCHED',
'EXHAUSTED',
'PARTIALLY_COMPROMISED',
'CANCELLED'
];
readonly transferStatusOptions = [
'DISPATCHED',
'RECEIVED',
'REJECTED'
];
/** Every stock lot, unfiltered — drives the denomination roll-up and the "next free serial" calculation,
* so it must never be limited to whatever page/status the display table happens to be showing. */
private allStockLots: any[] = [];
/** The lot table shown on screen — server-side paginated and status-filtered independently of allStockLots. */
displayedLots: any[] = [];
lotStatusFilter = '';
lotPageSize = 10;
lotPageIndex = 0;
lotTotalRecords = 0;
stockTransfers: any[] = [];
transferStatusFilter = '';
transferPageSize = 10;
transferPageIndex = 0;
transferTotalRecords = 0;
pageSizeOptions = [
10,
25,
50,
100
];
constructor() {
this.receiveForm = this.formBuilder.group({
seriesId: [
'',
Validators.required
],
officeId: [
'',
Validators.required
],
noOfBooks: [
1,
Validators.required
],
rangeStart: [
'',
Validators.required
],
batchRef: ['']
});
this.dispatchForm = this.formBuilder.group({
seriesId: [
'',
Validators.required
],
fromOfficeId: [
'',
Validators.required
],
toOfficeId: [
'',
Validators.required
],
fromLotId: [
'',
Validators.required
],
noOfLeaves: [
'',
Validators.required
]
});
}
ngOnInit(): void {
this.loadAllStockLotsForCalculations();
this.loadDisplayedLots();
this.loadStockTransfers();
// populates dropdowns, so fetch every active series in one page rather than paging through it
this.chequeBookService
.getSeries('ACTIVE', 0, 500)
.subscribe((res: any) => (this.seriesList = (res?.pageItems ?? []).filter((s: any) => s.active)));
this.chequeBookService.getOffices().subscribe((data: any[]) => (this.offices = data || []));
}
/** Denomination-wise roll-up of available books, e.g. "CTS-25 (25-leaf): 2000 books". */
stockSummary: { seriesCode: string; leavesPerBook: number; books: number; leaves: number }[] = [];
/** Unfiltered, every status, every page — feeds stockSummary and nextFreeSerial only, never the table. */
loadAllStockLotsForCalculations(): void {
this.chequeBookService.getStockLots(undefined, undefined, undefined, 0, 500).subscribe((res: any) => {
this.allStockLots = res?.pageItems ?? [];
this.buildSummary();
});
}
loadDisplayedLots(): void {
this.chequeBookService
.getStockLots(
undefined,
undefined,
this.lotStatusFilter || undefined,
this.lotPageIndex * this.lotPageSize,
this.lotPageSize
)
.subscribe((res: any) => {
this.displayedLots = res?.pageItems ?? [];
this.lotTotalRecords = Number(res?.totalFilteredRecords ?? 0);
});
}
filterLotsByStatus(status: string): void {
this.lotStatusFilter = this.lotStatusFilter === status ? '' : status;
this.lotPageIndex = 0;
this.loadDisplayedLots();
}
onLotPageChange(event: PageEvent): void {
this.lotPageSize = event.pageSize;
this.lotPageIndex = event.pageIndex;
this.loadDisplayedLots();
}
private buildSummary(): void {
const map = new Map<string, { seriesCode: string; leavesPerBook: number; books: number; leaves: number }>();
for (const lot of this.allStockLots) {
// only stock that can actually be issued/transferred counts toward availability
if (lot.status !== 'AVAILABLE') {
continue;
}
const key = `${lot.seriesCode}|${lot.leavesPerBook}`;
const row = map.get(key) || { seriesCode: lot.seriesCode, leavesPerBook: lot.leavesPerBook, books: 0, leaves: 0 };
row.books += lot.remainingBooks || 0;
row.leaves += lot.remainingLeaves || 0;
map.set(key, row);
}
this.stockSummary = Array.from(map.values()).sort((a, b) => (a.leavesPerBook || 0) - (b.leavesPerBook || 0));
}
loadStockTransfers(): void {
this.chequeBookService
.getStockTransfers(
this.transferStatusFilter || undefined,
this.transferPageIndex * this.transferPageSize,
this.transferPageSize
)
.subscribe((res: any) => {
this.stockTransfers = res?.pageItems ?? [];
this.transferTotalRecords = Number(res?.totalFilteredRecords ?? 0);
});
}
filterTransfersByStatus(status: string): void {
this.transferStatusFilter = this.transferStatusFilter === status ? '' : status;
this.transferPageIndex = 0;
this.loadStockTransfers();
}
onTransferPageChange(event: PageEvent): void {
this.transferPageSize = event.pageSize;
this.transferPageIndex = event.pageIndex;
this.loadStockTransfers();
}
/** The series currently chosen in the receive form. */
get receiveSeries(): any {
return this.seriesList.find((s) => s.id === this.receiveForm.get('seriesId')?.value);
}
get receiveLeavesPerBook(): number {
return this.receiveSeries?.leavesPerBook || 0;
}
/** Leaves to receive = books x this series' book size. */
get receiveTotalLeaves(): number {
const books = Number(this.receiveForm.get('noOfBooks')?.value) || 0;
return books * this.receiveLeavesPerBook;
}
/** Derived — the last serial covered by this receipt, so the operator never types it. */
get receiveRangeEnd(): number | null {
const start = Number(this.receiveForm.get('rangeStart')?.value);
const leaves = this.receiveTotalLeaves;
if (!start || !leaves) {
return null;
}
return start + leaves - 1;
}
/**
* Next unused serial WITHIN this series, so a fresh receipt continues that series' own numbering —
* each series is numbered independently (a separate stationery order from the press), so a different
* series' stock is not relevant here.
*/
private nextFreeSerial(): number {
const seriesId = this.receiveForm.get('seriesId')?.value;
const maxEnd = this.allStockLots
.filter((l) => l.seriesId === seriesId)
.reduce((m, l) => Math.max(m, l.rangeEnd || 0), 0);
return maxEnd + 1;
}
onReceiveSeriesChange(): void {
// pre-fill a non-overlapping start; operator can still override
this.receiveForm.patchValue({ rangeStart: this.nextFreeSerial() });
}
private reloadLots(): void {
this.loadAllStockLotsForCalculations();
this.loadDisplayedLots();
}
submitReceive(): void {
if (this.receiveForm.invalid || this.busy || !this.receiveRangeEnd) {
return;
}
this.busy = true;
const raw = this.receiveForm.value;
const payload: any = {
seriesId: raw.seriesId,
officeId: raw.officeId,
rangeStart: raw.rangeStart,
rangeEnd: this.receiveRangeEnd,
locale: 'en'
};
if (raw.batchRef) payload.batchRef = raw.batchRef;
this.chequeBookService.receiveStock(payload).subscribe({
next: () => {
this.busy = false;
this.showReceiveForm = false;
this.receiveForm.reset({ noOfBooks: 1 });
this.notify('Submitted for approval — see Approvals > Cheque Book Requests');
this.reloadLots();
},
error: () => {
this.busy = false;
this.notify('Failed to receive stock — check the leaf range does not overlap existing stock', true);
}
});
}
openReportLost(lot: any): void {
this.dialog
.open(ReportLotLostDialogComponent, { data: lot, width: '520px' })
.afterClosed()
.subscribe((result: any) => {
if (result?.reported) {
this.reloadLots();
}
});
}
submitDispatch(): void {
if (this.dispatchForm.invalid || this.busy) {
return;
}
this.busy = true;
const raw = this.dispatchForm.value;
const payload = {
seriesId: raw.seriesId,
fromOfficeId: raw.fromOfficeId,
toOfficeId: raw.toOfficeId,
fromLotId: raw.fromLotId,
noOfLeaves: raw.noOfLeaves,
locale: 'en'
};
this.chequeBookService.dispatchStockTransfer(payload).subscribe({
next: () => {
this.busy = false;
this.showDispatchForm = false;
this.dispatchForm.reset();
this.notify('Stock transfer dispatched');
this.reloadLots();
this.loadStockTransfers();
},
error: () => {
this.busy = false;
this.notify('Failed to dispatch — check the lot has enough remaining leaves', true);
}
});
}
receiveTransfer(transfer: any): void {
this.chequeBookService.receiveStockTransfer(transfer.id).subscribe({
next: () => {
this.notify('Transfer receipt acknowledged');
this.reloadLots();
this.loadStockTransfers();
},
error: () => this.notify('Failed to acknowledge receipt', true)
});
}
rejectTransfer(transfer: any): void {
this.chequeBookService.rejectStockTransfer(transfer.id).subscribe({
next: () => {
this.notify('Transfer rejected');
this.loadStockTransfers();
},
error: () => this.notify('Failed to reject transfer', true)
});
}
}
@@ -11,116 +11,79 @@
}
.af-page {
background: #f8fafc;
min-height: calc(100vh - 68px);
background: #f0f4f8;
padding: 24px 28px;
display: flex;
flex-direction: column;
gap: 20px;
gap: 12px;
}
// ── Hero ──────────────────────────────────────────────────────────────────────
// ── Header ──────────────────────────────────────────────────────────────────────
.af-hero {
.af-header {
display: flex;
align-items: center;
gap: 18px;
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 40%, #2563eb 100%);
border-radius: 16px;
padding: 22px 28px;
color: #fff;
box-shadow: 0 8px 32px rgb(37 99 235 / 35%);
position: relative;
overflow: hidden;
justify-content: space-between;
padding: 12px 20px;
background: #fff;
border-bottom: 1px solid #e2e8f0;
&::before {
content: '';
position: absolute;
top: -40px;
right: -40px;
width: 200px;
height: 200px;
background: rgb(255 255 255 / 5%);
border-radius: 50%;
}
&__icon-wrap {
width: 56px;
height: 56px;
background: rgb(255 255 255 / 15%);
border: 1px solid rgb(255 255 255 / 25%);
border-radius: 14px;
&__left {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
backdrop-filter: blur(8px);
gap: 10px;
}
&__text {
flex: 1;
&__icon {
color: #0f4f3c;
flex-shrink: 0;
}
&__title {
font-size: 22px;
font-weight: 800;
letter-spacing: -0.01em;
margin: 0 0 5px;
font-size: 15px;
font-weight: 700;
color: #0d1b2a;
}
&__stats {
display: flex;
gap: 14px;
align-items: center;
&__sub {
font-size: 12px;
color: #64748b;
margin-top: 1px;
}
&__stat {
display: flex;
align-items: center;
gap: 5px;
font-size: 12.5px;
color: rgb(255 255 255 / 75%);
font-weight: 700;
strong {
color: #fff;
font-weight: 700;
&--green {
color: #16a34a;
}
&--green strong {
color: #86efac;
&--red {
color: #dc2626;
}
&--red strong {
color: #fca5a5;
}
}
&__sep {
width: 1px;
height: 14px;
background: rgb(255 255 255 / 25%);
}
}
.af-back-btn {
.af-btn {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 10px 20px;
background: rgb(255 255 255 / 12%);
border: 1px solid rgb(255 255 255 / 25%);
border-radius: 10px;
color: #fff;
font-size: 12.5px;
gap: 6px;
border-radius: 7px;
font-family: inherit;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
backdrop-filter: blur(8px);
border: 1px solid #e2e8f0;
transition: background 0.15s;
height: 30px;
padding: 0 12px;
font-size: 12.5px;
background: #fff;
color: #0d1b2a;
&:hover {
background: rgb(255 255 255 / 22%);
transform: translateX(-2px);
background: #f0fdf4;
border-color: #0f4f3c;
color: #0f4f3c;
}
}
@@ -132,12 +95,12 @@
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
padding: 60px 20px;
gap: 10px;
padding: 40px 20px;
&__icon {
width: 88px;
height: 88px;
width: 64px;
height: 64px;
background: #e2e8f0;
border: 1px solid #cbd5e1;
border-radius: 50%;
@@ -147,80 +110,87 @@
}
&__title {
font-size: 18px;
font-size: 15px;
font-weight: 700;
color: #1e293b;
margin: 6px 0 0;
margin: 4px 0 0;
}
&__sub {
font-size: 13.5px;
font-size: 12.5px;
color: #64748b;
text-align: center;
margin: 0;
}
&__btn {
margin-top: 10px;
padding: 10px 24px;
background: #2563eb;
margin-top: 6px;
padding: 8px 20px;
background: #0f4f3c;
border: none;
border-radius: 10px;
border-radius: 8px;
color: #fff;
font-size: 13px;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: #1d4ed8;
background: #0a3d2e;
}
}
}
// ── Body ────────────────────────────────────────────────────────────────────────
.af-body {
display: flex;
flex-direction: column;
gap: 12px;
padding: 0 20px 16px;
}
// ── Progress card ──────────────────────────────────────────────────────────────
.af-progress-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 14px;
padding: 18px 22px;
box-shadow: 0 1px 4px rgb(0 0 0 / 6%);
border-radius: 10px;
padding: 12px 16px;
&__top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
margin-bottom: 8px;
}
&__label {
font-size: 11.5px;
font-weight: 600;
font-size: 11px;
font-weight: 700;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.08em;
letter-spacing: 0.06em;
}
&__fraction {
font-size: 13px;
font-size: 12.5px;
font-weight: 700;
color: #16a34a;
color: #0f4f3c;
}
}
.af-progress-bar {
height: 10px;
height: 7px;
background: #e2e8f0;
border-radius: 999px;
overflow: hidden;
&__fill {
height: 100%;
background: linear-gradient(90deg, #16a34a, #22c55e);
background: #16a34a;
border-radius: 999px;
transition: width 0.5s ease;
box-shadow: 0 0 8px rgb(34 197 94 / 35%);
transition: width 0.4s ease;
}
}
@@ -229,65 +199,53 @@
.af-fac-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 14px;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 1px 4px rgb(0 0 0 / 6%);
&__header {
display: flex;
align-items: center;
gap: 9px;
padding: 12px 22px;
background: #1e293b;
color: #94a3b8;
font-size: 11.5px;
padding: 9px 16px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
color: #64748b;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
letter-spacing: 0.06em;
}
}
.af-pills {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 20px 22px;
gap: 7px;
padding: 12px 16px;
}
.af-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 15px;
gap: 5px;
padding: 4px 11px;
border-radius: 999px;
font-size: 13px;
font-size: 12px;
font-weight: 600;
border: 1.5px solid;
transition:
transform 0.15s,
box-shadow 0.15s;
border: 1px solid;
cursor: default;
&__dot {
width: 7px;
height: 7px;
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
&--on {
background: #f0fdf4;
border-color: #22c55e;
color: #15803d;
border-color: #86efac;
color: #166534;
.af-pill__dot {
background: #22c55e;
box-shadow: 0 0 5px rgb(34 197 94 / 50%);
}
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgb(34 197 94 / 20%);
}
}
@@ -307,63 +265,46 @@
.af-stats-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
gap: 10px;
}
.af-stat-card {
border-radius: 14px;
padding: 20px 24px;
border-radius: 10px;
padding: 10px 14px;
display: flex;
align-items: center;
gap: 16px;
align-items: baseline;
gap: 7px;
border: 1px solid;
box-shadow: 0 1px 4px rgb(0 0 0 / 6%);
font-size: 12px;
&__icon {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 20px;
font-size: 12px;
font-weight: 700;
}
&__num {
font-size: 30px;
font-size: 17px;
font-weight: 800;
line-height: 1;
display: block;
}
&__label {
font-size: 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
display: block;
margin-top: 3px;
letter-spacing: 0.05em;
opacity: 0.75;
}
&--green {
background: linear-gradient(135deg, #f0fdf4, #dcfce7);
background: #f0fdf4;
border-color: #86efac;
color: #15803d;
.af-stat-card__icon {
background: #bbf7d0;
}
color: #166534;
}
&--red {
background: linear-gradient(135deg, #f8fafc, #f1f5f9);
border-color: #cbd5e1;
color: #475569;
.af-stat-card__icon {
background: #e2e8f0;
}
background: #f8fafc;
border-color: #e2e8f0;
color: #64748b;
}
}
@@ -18,34 +18,37 @@ import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
styleUrl: './account-facilities.component.scss',
template: `
<div class="af-page">
<!-- Hero -->
<div class="af-hero">
<div class="af-hero__icon-wrap">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<!-- Header -->
<div class="af-header">
<div class="af-header__left">
<svg
class="af-header__icon"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
>
<rect x="1" y="4" width="22" height="16" rx="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
</div>
<div class="af-hero__text">
<h1 class="af-hero__title">Account Facilities</h1>
@if (accountLoaded) {
<div class="af-hero__stats">
<span class="af-hero__stat af-hero__stat--green">
<strong>{{ activeCount }}</strong> Active
</span>
<span class="af-hero__sep"></span>
<span class="af-hero__stat af-hero__stat--red">
<strong>{{ facilities.length - activeCount }}</strong> Inactive
</span>
<span class="af-hero__sep"></span>
<span class="af-hero__stat">
<strong>{{ facilities.length }}</strong> Total
</span>
</div>
}
<div>
<div class="af-header__title">Account Facilities</div>
@if (accountLoaded) {
<div class="af-header__sub">
<span class="af-header__stat af-header__stat--green">{{ activeCount }} active</span>
&middot;
<span class="af-header__stat af-header__stat--red">{{ facilities.length - activeCount }} inactive</span>
&middot; {{ facilities.length }} total
</div>
} @else {
<div class="af-header__sub">View all facilities enabled for the loaded account.</div>
}
</div>
</div>
@if (!embedded) {
<button class="af-back-btn" (click)="goBack()">
<button type="button" class="af-btn" (click)="goBack()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="15 18 9 12 15 6" />
</svg>
@@ -58,7 +61,7 @@ import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
<!-- Empty state -->
<div class="af-empty">
<div class="af-empty__icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#475569" stroke-width="1.3">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#475569" stroke-width="1.3">
<rect x="1" y="4" width="22" height="16" rx="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
@@ -70,48 +73,40 @@ import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
}
</div>
} @else {
<!-- Progress bar -->
<div class="af-progress-card">
<div class="af-progress-card__top">
<span class="af-progress-card__label">Facility Coverage</span>
<span class="af-progress-card__fraction">{{ activeCount }} / {{ facilities.length }}</span>
<div class="af-body">
<!-- Progress bar -->
<div class="af-progress-card">
<div class="af-progress-card__top">
<span class="af-progress-card__label">Facility Coverage</span>
<span class="af-progress-card__fraction">{{ activeCount }} / {{ facilities.length }}</span>
</div>
<div class="af-progress-bar">
<div class="af-progress-bar__fill" [style.width.%]="(activeCount / facilities.length) * 100"></div>
</div>
</div>
<div class="af-progress-bar">
<div class="af-progress-bar__fill" [style.width.%]="(activeCount / facilities.length) * 100"></div>
</div>
</div>
<!-- Facilities pills -->
<div class="af-fac-card">
<div class="af-fac-card__header">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="1" y="4" width="22" height="16" rx="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
All Facilities
<!-- Facilities pills -->
<div class="af-fac-card">
<div class="af-fac-card__header">All Facilities</div>
<div class="af-pills">
@for (f of facilities; track f.label) {
<span class="af-pill" [class.af-pill--on]="f.active" [class.af-pill--off]="!f.active">
<span class="af-pill__dot"></span>
{{ f.label }}
</span>
}
</div>
</div>
<div class="af-pills">
@for (f of facilities; track f.label) {
<span class="af-pill" [class.af-pill--on]="f.active" [class.af-pill--off]="!f.active">
<span class="af-pill__dot"></span>
{{ f.label }}
</span>
}
</div>
</div>
<!-- Stats row -->
<div class="af-stats-row">
<div class="af-stat-card af-stat-card--green">
<div class="af-stat-card__icon">✓</div>
<div>
<!-- Stats row -->
<div class="af-stats-row">
<div class="af-stat-card af-stat-card--green">
<span class="af-stat-card__icon">&check;</span>
<span class="af-stat-card__num">{{ activeCount }}</span>
<span class="af-stat-card__label">Active</span>
</div>
</div>
<div class="af-stat-card af-stat-card--red">
<div class="af-stat-card__icon">✗</div>
<div>
<div class="af-stat-card af-stat-card--red">
<span class="af-stat-card__icon">&#10005;</span>
<span class="af-stat-card__num">{{ facilities.length - activeCount }}</span>
<span class="af-stat-card__label">Inactive</span>
</div>
@@ -0,0 +1,281 @@
/**
* 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/.
*/
:host {
display: block;
}
.cb-page {
background: #f8fafc;
min-height: calc(100vh - 68px);
display: flex;
flex-direction: column;
gap: 16px;
}
// ── Header ──────────────────────────────────────────────────────────────────────
.cb-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
background: #fff;
border-bottom: 1px solid #e2e8f0;
&__left {
display: flex;
align-items: center;
gap: 12px;
}
&__icon {
color: #0f4f3c;
flex-shrink: 0;
}
&__title {
font-size: 15px;
font-weight: 700;
color: #0d1b2a;
}
&__sub {
font-size: 12px;
color: #64748b;
margin-top: 2px;
}
}
.cb-btn {
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 7px;
font-family: inherit;
font-weight: 600;
cursor: pointer;
border: 1px solid #e2e8f0;
transition: background 0.15s;
height: 32px;
padding: 0 12px;
font-size: 13px;
background: #fff;
color: #0d1b2a;
&:hover {
background: #f0fdf4;
border-color: #0f4f3c;
color: #0f4f3c;
}
&--primary {
background: #0f4f3c;
border-color: #0f4f3c;
color: #fff;
&:hover {
background: #0a3d2e;
color: #fff;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
.cb-status-msg {
padding: 40px 20px;
text-align: center;
color: #64748b;
font-size: 13.5px;
}
// ── Body ────────────────────────────────────────────────────────────────────────
.cb-body {
display: flex;
flex-direction: column;
gap: 16px;
padding: 0 20px 20px;
}
.cb-info-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 18px 22px;
}
.cb-info-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px 24px;
&__k {
display: block;
font-size: 11px;
font-weight: 700;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 3px;
}
&__v {
display: block;
font-size: 13.5px;
font-weight: 600;
color: #0d1b2a;
}
}
.cb-pill {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
white-space: nowrap;
&--ok {
background: #dcfce7;
color: #166534;
}
&--bad {
background: #fee2e2;
color: #991b1b;
}
&--neutral {
background: #fef3c7;
color: #92400e;
}
}
.cb-no-books {
padding: 30px 10px;
text-align: center;
color: #94a3b8;
font-size: 13px;
}
// ── Action toolbar ─────────────────────────────────────────────────────────────
.cb-action-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 0 20px;
}
.cb-action-btn {
border-radius: 7px;
font-family: inherit;
font-weight: 600;
cursor: pointer;
border: 1px solid #e2e8f0;
transition: background 0.15s;
height: 32px;
padding: 0 14px;
font-size: 12.5px;
background: #fff;
color: #0d1b2a;
&:hover {
background: #f0fdf4;
border-color: #0f4f3c;
}
&--warn {
border-color: #fca5a5;
color: #dc2626;
&:hover {
background: #fef2f2;
border-color: #dc2626;
}
}
&--primary {
background: #0f4f3c;
border-color: #0f4f3c;
color: #fff;
&:hover {
background: #0a3d2e;
}
}
}
// ── Leaf grid ──────────────────────────────────────────────────────────────────
.cb-leaf-grid {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.cb-leaf-circle {
width: 46px;
height: 46px;
border-radius: 50%;
border: 1.5px solid #cbd5e1;
background: #fff;
color: #64748b;
font-size: 12px;
font-weight: 700;
font-family: 'Courier New', monospace;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition:
transform 0.1s,
box-shadow 0.1s;
&:hover {
transform: translateY(-1px);
box-shadow: 0 3px 8px rgb(0 0 0 / 12%);
}
&--ok {
border-color: #22c55e;
color: #166534;
background: #f0fdf4;
}
&--used {
border-color: #cbd5e1;
color: #64748b;
background: #f8fafc;
}
&--warn {
border-color: #f59e0b;
color: #92400e;
background: #fffbeb;
}
&--bad {
border-color: #ef4444;
color: #991b1b;
background: #fef2f2;
}
&--returned {
border-color: #a855f7;
color: #6b21a8;
background: #faf5ff;
}
}
@@ -0,0 +1,207 @@
/**
* 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 { ActivatedRoute, Router } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
import { ChequeBookService } from '../../chequebook/chequebook.service';
import { LeafDetailDialogComponent } from '../../chequebook/cheque-book-detail/leaf-detail-dialog/leaf-detail-dialog.component';
import { ClerkLeafActionDialogComponent, LeafAction } from './leaf-action-dialog/leaf-action-dialog.component';
/** Cheque-book detail for the Clerk Dashboard — a fullscreen clerk-styled view of one book, its leaf
* register, and the same maker-side leaf actions (stop-payment, revoke, report-lost, cancel-leaf,
* mark-used) as the back-office ChequeBookDetailComponent, so a clerk can act on a leaf without leaving
* the clerk shell/nav. Every action still goes through the existing maker-checker approval flow server-side.
* Each action opens as a popup (ClerkLeafActionDialogComponent) rather than an inline form so it never
* pushes the leaf grid around and gets the full dialog width to lay out its fields. */
@Component({
selector: 'mifosx-clerk-cheque-book-detail',
standalone: true,
imports: [CommonModule],
styleUrl: './cheque-book-detail.component.scss',
template: `
<div class="cb-page">
<!-- Header -->
<div class="cb-header">
<div class="cb-header__left">
<svg
class="cb-header__icon"
width="26"
height="26"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
>
<rect x="1" y="4" width="22" height="16" rx="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
<div>
<div class="cb-header__title">Cheque Book #{{ book.id }}</div>
<div class="cb-header__sub">
{{ book.accountNo || book.accountId }}
@if (book.clientName) {
&nbsp;&middot;&nbsp;{{ book.clientName }}
}
</div>
</div>
</div>
<button type="button" class="cb-btn" (click)="goBack()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="15 18 9 12 15 6" />
</svg>
Back
</button>
</div>
@if (loading) {
<div class="cb-status-msg">Loading cheque book&hellip;</div>
} @else {
<div class="cb-body">
<!-- Info card -->
<div class="cb-info-card">
<div class="cb-info-grid">
<div>
<span class="cb-info-grid__k">Account Type</span>
<span class="cb-info-grid__v">{{ book.accountType }}</span>
</div>
<div>
<span class="cb-info-grid__k">Office</span>
<span class="cb-info-grid__v">{{ book.officeName }}</span>
</div>
<div>
<span class="cb-info-grid__k">Series</span>
<span class="cb-info-grid__v">{{ book.seriesCode ?? '&#8212;' }}</span>
</div>
<div>
<span class="cb-info-grid__k">Leaf Range</span>
<span class="cb-info-grid__v">
{{ book.rangeStart ? book.rangeStart + ' – ' + book.rangeEnd : '&#8212;' }}
</span>
</div>
<div>
<span class="cb-info-grid__k">Quantity</span>
<span class="cb-info-grid__v">{{ book.noOfBooks }} book{{ book.noOfBooks === 1 ? '' : 's' }}</span>
</div>
<div>
<span class="cb-info-grid__k">Status</span>
<span
class="cb-pill"
[class.cb-pill--ok]="book.status === 'ISSUED'"
[class.cb-pill--bad]="book.status === 'REJECTED' || book.status === 'WITHDRAWN'"
[class.cb-pill--neutral]="
book.status !== 'ISSUED' && book.status !== 'REJECTED' && book.status !== 'WITHDRAWN'
"
>{{ book.status }}</span
>
</div>
<div>
<span class="cb-info-grid__k">Issue Date</span>
<span class="cb-info-grid__v">{{ book.issueDate ?? '&#8212;' }}</span>
</div>
</div>
</div>
<!-- Action toolbar — only for an issued book; each button pops up its own action dialog -->
@if (book.status === 'ISSUED') {
<div class="cb-action-toolbar">
<button type="button" class="cb-action-btn cb-action-btn--warn" (click)="openAction('stoppayment')">
Stop Payment
</button>
<button type="button" class="cb-action-btn" (click)="openAction('revokestoppayment')">
Revoke Stop Payment
</button>
<button type="button" class="cb-action-btn cb-action-btn--warn" (click)="openAction('reportlost')">
Report Lost
</button>
<button type="button" class="cb-action-btn" (click)="openAction('cancelleaf')">Cancel Leaf</button>
<button type="button" class="cb-action-btn cb-action-btn--primary" (click)="openAction('markused')">
Mark as Used
</button>
<button type="button" class="cb-action-btn cb-action-btn--warn" (click)="openAction('return')">
Flag Returned
</button>
</div>
}
<!-- Leaf grid -->
<div class="cb-info-card">
@if (leaves.length === 0) {
<div class="cb-no-books">No leaves to display</div>
} @else {
<div class="cb-leaf-grid">
@for (l of leaves; track l.leafSerialNo) {
<button
type="button"
class="cb-leaf-circle"
[class.cb-leaf-circle--ok]="l.status === 'ACTIVE'"
[class.cb-leaf-circle--used]="l.status === 'USED'"
[class.cb-leaf-circle--warn]="l.status === 'STOP_PAYMENT'"
[class.cb-leaf-circle--bad]="l.status === 'LOST'"
[class.cb-leaf-circle--returned]="l.status === 'RETURNED'"
(click)="selectLeaf(l)"
>
{{ l.leafSerialNo }}
</button>
}
</div>
}
</div>
</div>
}
</div>
`
})
export class ClerkChequeBookDetailComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private chequeBookService = inject(ChequeBookService);
private dialog = inject(MatDialog);
bookId!: string;
book: any = {};
leaves: any[] = [];
loading = false;
ngOnInit(): void {
this.bookId = this.route.snapshot.paramMap.get('id') as string;
this.loadAll();
}
loadAll(): void {
this.loading = true;
this.chequeBookService.getBook(this.bookId).subscribe((data: any) => {
this.book = data || {};
this.loading = false;
});
this.chequeBookService.getLeaves(this.bookId).subscribe((data: any[]) => {
this.leaves = data || [];
});
}
selectLeaf(leaf: any): void {
this.dialog.open(LeafDetailDialogComponent, { data: leaf, width: '420px' });
}
openAction(action: LeafAction): void {
const ref = this.dialog.open(ClerkLeafActionDialogComponent, {
width: '480px',
data: { bookId: this.bookId, action }
});
ref.afterClosed().subscribe((result) => {
if (result?.success) {
this.loadAll();
}
});
}
goBack(): void {
this.router.navigate(['/clerk/account-facilities/cheque-book']);
}
}
@@ -0,0 +1,257 @@
/**
* 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/.
*/
:host {
display: block;
}
.cb-page {
background: #f8fafc;
min-height: calc(100vh - 68px);
display: flex;
flex-direction: column;
gap: 16px;
}
// ── Header ──────────────────────────────────────────────────────────────────────
.cb-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
background: #fff;
border-bottom: 1px solid #e2e8f0;
&__left {
display: flex;
align-items: center;
gap: 12px;
}
&__icon {
color: #0f4f3c;
flex-shrink: 0;
}
&__title {
font-size: 15px;
font-weight: 700;
color: #0d1b2a;
}
&__sub {
font-size: 12px;
color: #64748b;
margin-top: 2px;
}
}
.cb-btn {
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 7px;
font-family: inherit;
font-weight: 600;
cursor: pointer;
border: 1px solid #e2e8f0;
transition: background 0.15s;
height: 32px;
padding: 0 12px;
font-size: 13px;
background: #fff;
color: #0d1b2a;
&:hover {
background: #f0fdf4;
border-color: #0f4f3c;
color: #0f4f3c;
}
}
// ── Empty state ────────────────────────────────────────────────────────────────
.cb-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
padding: 60px 20px;
&__icon {
width: 88px;
height: 88px;
background: #e2e8f0;
border: 1px solid #cbd5e1;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
&__title {
font-size: 18px;
font-weight: 700;
color: #1e293b;
margin: 6px 0 0;
}
&__sub {
font-size: 13.5px;
color: #64748b;
text-align: center;
margin: 0;
}
&__btn {
margin-top: 10px;
padding: 10px 24px;
background: #0f4f3c;
border: none;
border-radius: 10px;
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: #0a3d2e;
}
}
}
// ── Warning banner ─────────────────────────────────────────────────────────────
.cb-warn-banner {
margin: 0 20px;
background: #fffbeb;
border: 1px solid #fde68a;
color: #92400e;
font-size: 13px;
font-weight: 600;
padding: 12px 18px;
border-radius: 10px;
}
.cb-status-msg {
padding: 40px 20px;
text-align: center;
color: #64748b;
font-size: 13.5px;
&--error {
color: #dc2626;
}
}
.cb-no-books {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 40px 20px;
color: #94a3b8;
font-size: 13px;
}
// ── Table card ─────────────────────────────────────────────────────────────────
.cb-table-card {
margin: 0 20px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
overflow: hidden;
}
.cb-table-wrap {
overflow-x: auto;
}
.cb-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th,
td {
padding: 10px 14px;
text-align: left;
border-bottom: 1px solid #f1f5f9;
}
th {
font-size: 11px;
font-weight: 700;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.06em;
background: #f8fafc;
white-space: nowrap;
}
tbody tr:last-child td {
border-bottom: none;
}
}
.cb-row {
cursor: pointer;
transition: background 0.1s;
&:hover td {
background: #f0fdf4;
}
}
.cb-cell--num {
color: #64748b;
font-family: 'Courier New', monospace;
}
.cb-cell--date {
color: #64748b;
font-size: 12px;
}
.cb-cell--action {
color: #0f4f3c;
font-weight: 700;
font-size: 12px;
white-space: nowrap;
}
.cb-pill {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
white-space: nowrap;
&--ok {
background: #dcfce7;
color: #166534;
}
&--bad {
background: #fee2e2;
color: #991b1b;
}
&--neutral {
background: #fef3c7;
color: #92400e;
}
}
@@ -0,0 +1,228 @@
/**
* 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, Input, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
import { ChequeBookService } from '../../chequebook/chequebook.service';
/** Read-only cheque-book view for the account currently loaded on the Clerk Dashboard —
* reached via the "Cheque Book" entry in the Account Facilities dropdown. */
@Component({
selector: 'mifosx-clerk-cheque-book',
standalone: true,
imports: [CommonModule],
styleUrl: './cheque-book.component.scss',
template: `
<div class="cb-page">
<!-- Header -->
<div class="cb-header">
<div class="cb-header__left">
<svg
class="cb-header__icon"
width="26"
height="26"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
>
<rect x="1" y="4" width="22" height="16" rx="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
<div>
<div class="cb-header__title">Cheque Book</div>
@if (accountLoaded) {
<div class="cb-header__sub">
{{ accountNo }}
@if (clientName) {
&nbsp;&middot;&nbsp;{{ clientName }}
}
@if (books.length > 0) {
&nbsp;&middot;&nbsp;{{ books.length }} book{{ books.length === 1 ? '' : 's' }}
}
</div>
} @else {
<div class="cb-header__sub">View cheque books issued for the loaded account.</div>
}
</div>
</div>
@if (!embedded) {
<button type="button" class="cb-btn" (click)="goBack()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="15 18 9 12 15 6" />
</svg>
Back to Dashboard
</button>
}
</div>
@if (!accountLoaded) {
<div class="cb-empty">
<div class="cb-empty__icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#475569" stroke-width="1.3">
<rect x="1" y="4" width="22" height="16" rx="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
</div>
<p class="cb-empty__title">No Account Loaded</p>
<p class="cb-empty__sub">Search an account on the Clerk Dashboard first.</p>
@if (!embedded) {
<button class="cb-empty__btn" (click)="goBack()">Go to Dashboard</button>
}
</div>
} @else {
@if (!facilityActive) {
<div class="cb-warn-banner">Cheque Book facility is not active for this account.</div>
}
@if (loading) {
<div class="cb-status-msg">Loading cheque books&hellip;</div>
} @else if (error) {
<div class="cb-status-msg cb-status-msg--error">{{ error }}</div>
} @else if (books.length === 0) {
<div class="cb-no-books">
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#cbd5e1" stroke-width="1.3">
<rect x="1" y="4" width="22" height="16" rx="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
<span>No cheque book issued for this account</span>
</div>
} @else {
<div class="cb-table-card">
<div class="cb-table-wrap">
<table class="cb-table">
<thead>
<tr>
<th>Series</th>
<th>Books</th>
<th>Leaves</th>
<th>Status</th>
<th>Requested</th>
<th></th>
</tr>
</thead>
<tbody>
@for (b of books; track b.id) {
<tr class="cb-row" (click)="viewBook(b)">
<td>{{ b.seriesCode }}</td>
<td class="cb-cell--num">{{ b.noOfBooks }}</td>
<td class="cb-cell--num">{{ b.totalLeaves ?? '&#8212;' }}</td>
<td>
<span
class="cb-pill"
[class.cb-pill--ok]="b.status === 'ISSUED'"
[class.cb-pill--bad]="b.status === 'REJECTED' || b.status === 'WITHDRAWN'"
[class.cb-pill--neutral]="
b.status !== 'ISSUED' && b.status !== 'REJECTED' && b.status !== 'WITHDRAWN'
"
>{{ b.status }}</span
>
</td>
<td class="cb-cell--date">{{ b.requestedDate ?? '&#8212;' }}</td>
<td class="cb-cell--action">View &rsaquo;</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
}
</div>
`
})
export class ClerkChequeBookComponent implements OnInit {
private clerkUiState = inject(ClerkUiStateService);
private chequeBookService = inject(ChequeBookService);
private router = inject(Router);
@Input() embedded = false;
accountLoaded = false;
accountNo = '';
clientName = '';
facilityActive = true;
books: any[] = [];
loading = false;
error = '';
ngOnInit(): void {
const s = this.clerkUiState.snapshot;
this.accountLoaded = s.accountLoaded;
if (!this.accountLoaded) {
return;
}
this.accountNo = s.savedAccount?.accountDetails?.accountNo ?? s.savedRow?.acNo ?? '';
const clientDetails = s.savedAccount?.clientDetails;
this.clientName = clientDetails ? `${clientDetails.firstname ?? ''} ${clientDetails.lastname ?? ''}`.trim() : '';
const facility = s.facilities.find((f) => f.label === 'Cheque Book');
this.facilityActive = facility ? facility.active : true;
const accountType = this.toChequeAccountType(s.savedRow?.acTyp);
const accountId = s.savedAccount?.accountDetails?.id;
if (accountId) {
this.loadBooks(accountType, accountId);
} else if (this.accountNo) {
this.resolveAccountId(accountType, this.accountNo);
}
}
/** Cheque-book series/books are only ever SAVINGS or CURRENT — CA/CB is the clerk's UI code for current accounts. */
private toChequeAccountType(acTyp?: string): string {
const t = (acTyp || '').toUpperCase();
return t === 'CA' || t === 'CB' ? 'CURRENT' : 'SAVINGS';
}
private resolveAccountId(accountType: string, accountNo: string): void {
this.loading = true;
this.chequeBookService.searchAccountByNo(accountType, accountNo).subscribe({
next: (res: any) => {
const items = res?.pageItems ?? (Array.isArray(res) ? res : []);
const match = items.find((i: any) => i.accountNo === accountNo) ?? (items.length === 1 ? items[0] : null);
if (match) {
this.loadBooks(accountType, match.id);
} else {
this.loading = false;
this.error = 'Could not resolve this account for cheque book lookup';
}
},
error: () => {
this.loading = false;
this.error = 'Could not resolve this account for cheque book lookup';
}
});
}
private loadBooks(accountType: string, accountId: string | number): void {
this.loading = true;
this.chequeBookService.getBooks(accountType, accountId, undefined, undefined, 0, 50).subscribe({
next: (res: any) => {
this.books = res?.pageItems ?? (Array.isArray(res) ? res : []);
this.loading = false;
},
error: () => {
this.loading = false;
this.error = 'Failed to load cheque book details';
}
});
}
viewBook(book: any): void {
this.router.navigate([
'/clerk/account-facilities/cheque-book',
book.id
]);
}
goBack(): void {
this.router.navigate(['/clerk/dashboard']);
}
}
@@ -0,0 +1,87 @@
/**
* 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/.
*/
.la-form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 14px;
padding-top: 4px;
}
.la-field {
display: flex;
flex-direction: column;
gap: 4px;
&__lbl {
font-size: 11px;
font-weight: 700;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
}
&__input {
height: 36px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 7px;
font-size: 13px;
font-family: inherit;
color: #0d1b2a;
&:focus {
outline: none;
border-color: #0f4f3c;
}
}
&__hint {
font-size: 11px;
color: #94a3b8;
}
}
.la-btn {
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 7px;
font-family: inherit;
font-weight: 600;
cursor: pointer;
border: 1px solid #e2e8f0;
transition: background 0.15s;
height: 34px;
padding: 0 16px;
font-size: 13px;
background: #fff;
color: #0d1b2a;
&:hover {
background: #f0fdf4;
border-color: #0f4f3c;
color: #0f4f3c;
}
&--primary {
background: #0f4f3c;
border-color: #0f4f3c;
color: #fff;
&:hover {
background: #0a3d2e;
color: #fff;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
@@ -0,0 +1,145 @@
/**
* 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, inject } from '@angular/core';
import { ReactiveFormsModule, UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import {
MatDialogRef,
MAT_DIALOG_DATA,
MatDialogTitle,
MatDialogContent,
MatDialogActions
} from '@angular/material/dialog';
import { CdkScrollable } from '@angular/cdk/scrolling';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Observable } from 'rxjs';
import { ChequeBookService } from '../../../chequebook/chequebook.service';
export type LeafAction = 'stoppayment' | 'revokestoppayment' | 'reportlost' | 'cancelleaf' | 'markused' | 'return';
const ACTION_TITLES: Record<LeafAction, string> = {
stoppayment: 'Stop Payment',
revokestoppayment: 'Revoke Stop Payment',
reportlost: 'Report Lost',
cancelleaf: 'Cancel Leaf',
markused: 'Mark as Used',
return: 'Flag Returned'
};
/** Popup for the five leaf-range actions on a clerk-viewed cheque book — kept as its own dialog so the
* action form doesn't push the leaf grid around and always has full width to lay out its fields. */
@Component({
selector: 'mifosx-clerk-leaf-action-dialog',
standalone: true,
imports: [
ReactiveFormsModule,
MatDialogTitle,
CdkScrollable,
MatDialogContent,
MatDialogActions
],
styleUrl: './leaf-action-dialog.component.scss',
template: `
<h1 mat-dialog-title>{{ title }}</h1>
<div mat-dialog-content>
<form [formGroup]="actionForm" class="la-form">
<div class="la-form-grid">
<label class="la-field">
<span class="la-field__lbl">From Leaf Seq</span>
<input class="la-field__input" type="number" formControlName="fromLeafSeq" />
<span class="la-field__hint">Leave blank for the whole book</span>
</label>
<label class="la-field">
<span class="la-field__lbl">To Leaf Seq</span>
<input class="la-field__input" type="number" formControlName="toLeafSeq" />
</label>
@if (data.action !== 'revokestoppayment') {
<label class="la-field">
<span class="la-field__lbl">Reason</span>
<input class="la-field__input" formControlName="reason" />
@if (data.action === 'markused') {
<span class="la-field__hint">Optional — e.g. cleared per bank statement dated ...</span>
}
</label>
}
</div>
</form>
</div>
<mat-dialog-actions align="end">
<button type="button" class="la-btn" (click)="dialogRef.close()">Cancel</button>
<button type="button" class="la-btn la-btn--primary" [disabled]="actionForm.invalid || busy" (click)="submit()">
Submit
</button>
</mat-dialog-actions>
`
})
export class ClerkLeafActionDialogComponent {
dialogRef = inject<MatDialogRef<ClerkLeafActionDialogComponent>>(MatDialogRef);
data: { bookId: string; action: LeafAction } = inject(MAT_DIALOG_DATA);
private formBuilder = inject(UntypedFormBuilder);
private chequeBookService = inject(ChequeBookService);
private snackBar = inject(MatSnackBar);
private notify(message: string, isError = false): void {
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
}
busy = false;
actionForm: UntypedFormGroup;
get title(): string {
return ACTION_TITLES[this.data.action];
}
constructor() {
this.actionForm = this.formBuilder.group({
fromLeafSeq: [''],
toLeafSeq: [''],
reason: [
'',
this.data.action === 'revokestoppayment' || this.data.action === 'markused' ? [] : Validators.required
]
});
}
submit(): void {
if (this.actionForm.invalid || this.busy) {
return;
}
this.busy = true;
const raw = this.actionForm.value;
const payload: any = { locale: 'en' };
if (raw.fromLeafSeq !== '' && raw.fromLeafSeq != null) payload.fromLeafSeq = raw.fromLeafSeq;
if (raw.toLeafSeq !== '' && raw.toLeafSeq != null) payload.toLeafSeq = raw.toLeafSeq;
if (raw.reason) payload.reason = raw.reason;
const actionCalls: Record<LeafAction, (bookId: string, payload: any) => Observable<any>> = {
stoppayment: (id, p) => this.chequeBookService.stopPayment(id, p),
revokestoppayment: (id, p) => this.chequeBookService.revokeStopPayment(id, p),
reportlost: (id, p) => this.chequeBookService.reportBookLost(id, p),
cancelleaf: (id, p) => this.chequeBookService.cancelLeaf(id, p),
markused: (id, p) => this.chequeBookService.markUsed(id, p),
return: (id, p) => this.chequeBookService.returnBook(id, p)
};
actionCalls[this.data.action](this.data.bookId, payload).subscribe({
next: () => {
this.busy = false;
this.notify('Action applied');
this.dialogRef.close({ success: true });
},
error: () => {
this.busy = false;
this.notify(
'Action failed — check the leaf range and that the leaves are in a state that allows this action',
true
);
}
});
}
}
+28 -21
View File
@@ -100,17 +100,28 @@
My Disbursements
</a>
@if (clerkAccountLoaded) {
<a
class="cashier-nav__item cashier-nav__item--sec"
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/account-facilities')"
[routerLink]="['/clerk/account-facilities']"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="1" y="4" width="22" height="16" rx="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
Account Facilities
</a>
<div class="cashier-nav__item-wrap">
<a
class="cashier-nav__item cashier-nav__item--sec"
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/account-facilities')"
[routerLink]="['/clerk/account-facilities']"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="1" y="4" width="22" height="16" rx="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
Account Facilities
<fa-icon icon="caret-down" class="cashier-nav__caret"></fa-icon>
</a>
<div class="cashier-nav__dropdown">
@for (f of clerkFacilities; track f.label) {
<a class="cashier-nav__dd-item" [routerLink]="facilityRoute(f.label)">
<span class="cashier-nav__dd-dot" [class.cashier-nav__dd-dot--off]="!f.active"></span>
{{ f.label }}
</a>
}
</div>
</div>
<a
class="cashier-nav__item cashier-nav__item--sec"
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/photo-signature')"
@@ -168,13 +179,6 @@
>
<fa-icon icon="money-bill-wave"></fa-icon> My Deposits
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/share-collection']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="hand-holding-usd"></fa-icon> Share Collection
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/cash-exchange']"
@@ -366,6 +370,9 @@
<fa-icon icon="sync-alt" class="ni-ico"></fa-icon>Reconciliation
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" routerLink="/chequebook" *mifosxHasPermission="'MENU_CHEQUEBOOK'">
<fa-icon icon="book" class="ni-ico"></fa-icon>Cheque Book
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="ckycMenu" *mifosxHasPermission="'MENU_CKYC_MGMT'">
<fa-icon icon="fingerprint" class="ni-ico"></fa-icon>CKYC Mgmt
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
@@ -502,6 +509,9 @@
<fa-icon icon="sync-alt" class="ni-ico"></fa-icon>Reconciliation
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" routerLink="/chequebook" *mifosxHasPermission="'MENU_CHEQUEBOOK'">
<fa-icon icon="book" class="ni-ico"></fa-icon>Cheque Book
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="letterMgmtMenu" *mifosxHasPermission="'MENU_LETTER_MGMT'">
<fa-icon icon="envelope" class="ni-ico"></fa-icon>Documents Hub
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
@@ -915,9 +925,6 @@
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-history']">
<fa-icon icon="history" class="menu-item-icon"></fa-icon> Cash History
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/share-collection']">
<fa-icon icon="hand-holding-usd" class="menu-item-icon"></fa-icon> Share Collection
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/transaction-entry']">
<fa-icon icon="keyboard" class="menu-item-icon"></fa-icon> Transaction Entry
</button>
+63
View File
@@ -179,6 +179,69 @@ $color: #e2e4ec;
&--clerk {
border-bottom-color: #e87722;
}
&__item-wrap {
position: relative;
display: inline-flex;
&:hover .cashier-nav__dropdown {
display: grid;
}
}
&__caret {
font-size: 9px !important;
margin-left: 2px;
opacity: 0.75;
}
&__dropdown {
display: none;
grid-template-columns: repeat(2, minmax(150px, 1fr));
position: absolute;
top: 100%;
left: 0;
margin-top: 2px;
min-width: 320px;
background: #1a2744;
border: 1px solid #334155;
border-radius: 6px;
box-shadow: 0 8px 24px rgb(0 0 0 / 35%);
padding: 4px;
gap: 2px;
z-index: 50;
}
&__dd-item {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
color: #cbd5e1;
text-decoration: none;
white-space: nowrap;
transition: background 0.15s;
&:hover {
background: #223060;
color: #fff;
}
}
&__dd-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #22c55e;
flex-shrink: 0;
&--off {
background: #64748b;
}
}
}
.sidenav {
+9
View File
@@ -116,6 +116,15 @@ export class ShellComponent implements OnInit, OnDestroy {
return label.toLowerCase().includes(this.rbiSearch.toLowerCase());
}
/** Route for a facility entry in the Account Facilities hover dropdown — only "Cheque Book" has a
* dedicated action page today; every other facility falls back to the existing overview page. */
facilityRoute(label: string): string[] {
if (label === 'Cheque Book') {
return ['/clerk/account-facilities/cheque-book'];
}
return ['/clerk/account-facilities'];
}
/**
* Subscribes to progress bar to update its mode.
*/
@@ -182,22 +182,20 @@
</ng-container>
<!-- Election Management Section -->
<ng-container *mifosxHasPermission="'SIDEBAR_ELECTION'">
<div class="nav-item-sep mt-1">
<mat-divider></mat-divider>
<span class="text-muted">Election</span>
</div>
<mat-list-item
[routerLink]="['/election']"
[matTooltipPosition]="tooltipPosition"
matTooltip="Election Management"
routerLinkActive="active-menu"
[routerLinkActiveOptions]="{ exact: false }"
>
<mat-icon matListIcon>how_to_vote</mat-icon>
<a matLine>Election Management</a>
</mat-list-item>
</ng-container>
<div class="nav-item-sep mt-1">
<mat-divider></mat-divider>
<span class="text-muted">Election</span>
</div>
<mat-list-item
[routerLink]="['/election']"
[matTooltipPosition]="tooltipPosition"
matTooltip="Election Management"
routerLinkActive="active-menu"
[routerLinkActiveOptions]="{ exact: false }"
>
<mat-icon matListIcon>how_to_vote</mat-icon>
<a matLine>Election Management</a>
</mat-list-item>
<!-- RBI Integration Section -->
<ng-container *mifosxHasPermission="'SIDEBAR_RBI_INTEGRATION'">
@@ -236,6 +234,19 @@
<span class="text-muted">{{ 'labels.menus.Main Items' | translate }}</span>
</div>
<mat-list-item
[routerLink]="['/chequebook']"
[matTooltipPosition]="tooltipPosition"
matTooltip="{{ 'tooltips.Cheque Book Inventory' | translate }}"
routerLinkActive="active-menu"
[routerLinkActiveOptions]="{ exact: false }"
*mifosxHasPermission="'MENU_CHEQUEBOOK'"
>
<mat-icon matListIcon>
<fa-icon icon="book" size="sm"></fa-icon>
</mat-icon>
<a matLine>{{ 'labels.menus.Cheque Book Inventory' | translate }}</a>
</mat-list-item>
<mat-list-item
[routerLink]="['/rbi-circulars']"
[matTooltipPosition]="tooltipPosition"
@@ -229,7 +229,7 @@
<fa-icon icon="users" class="ai-col-h-ic"></fa-icon>
<span>Governance</span>
</div>
<button mat-menu-item class="ai-card" tabindex="0">
<button mat-menu-item class="ai-card" routerLink="/election" tabindex="0">
<span class="ai-card-ic"><fa-icon icon="users"></fa-icon></span>
<span class="ai-card-body">
<span class="ai-card-t">Election Management</span>
@@ -155,12 +155,13 @@
<input matInput [value]="productTemplate.introducerRequired ? 'Yes' : 'No'" readonly />
</mat-form-field>
<div class="sad-field sad-info-row">
<span class="sad-info-label">Share Linking Enabled</span>
<span class="sad-badge" [class.sad-badge--yes]="productTemplate.shareLinkingEnabled">
{{ productTemplate.shareLinkingEnabled ? 'Yes' : 'No' }}
</span>
</div>
<mat-form-field appearance="outline" class="sad-field">
<mat-label>Share Linking Enabled</mat-label>
<mat-select [(ngModel)]="shareLinkingEnabled" [ngModelOptions]="{ standalone: true }">
<mat-option [value]="true">Yes</mat-option>
<mat-option [value]="false">No</mat-option>
</mat-select>
</mat-form-field>
</div>
</div>
@@ -67,6 +67,8 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
introducerCif: string | null = null;
private isEditMode = false;
shareLinkingEnabled = false;
// ── Non-Individual (existing) ──────────────────────────────────────────
isNonIndividualClient = false;
relatedClients: Array<{
@@ -280,6 +282,7 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
this.currentAccountProductTemplate.emit(response);
this.fieldOfficerData = response.fieldOfficerOptions;
this.currentAccountProductSelected = true;
this.shareLinkingEnabled = !!response.shareLinkingEnabled;
this.currentAccountDetailsForm.patchValue({
balanceSwapOption: !!response.balanceSwapOption,
msme: !!response.msme,
@@ -382,6 +385,7 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
this.introducerName = ea.introducerName ?? null;
this.introducerCif = ea.introducerCifNumber ?? null;
}
this.shareLinkingEnabled = ea.shareLinkingEnabled !== undefined ? !!ea.shareLinkingEnabled : !!ea.isLinkingEnable;
this.currentAccountDetailsForm.patchValue({
balanceSwapOption: !!ea.balanceSwapOption,
msme: !!ea.msme,
@@ -664,6 +668,7 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
get currentAccountDetails() {
const value = this.currentAccountDetailsForm.getRawValue();
value.shareLinkingEnabled = this.shareLinkingEnabled;
if (this.introducerId) {
value.introducerId = this.introducerId;
value.introducerName = this.introducerName;
@@ -131,9 +131,18 @@
<span class="preview-label">Share Linking Enabled</span>
<span
class="preview-value preview-badge"
[class.preview-badge--yes]="currentAccountProductTemplate?.shareLinkingEnabled"
[class.preview-badge--yes]="
currentAccount?.shareLinkingEnabled !== undefined
? currentAccount.shareLinkingEnabled
: currentAccountProductTemplate?.shareLinkingEnabled
"
>
{{ currentAccountProductTemplate?.shareLinkingEnabled | yesNo }}
{{
(currentAccount?.shareLinkingEnabled !== undefined
? currentAccount.shareLinkingEnabled
: currentAccountProductTemplate?.shareLinkingEnabled
) | yesNo
}}
</span>
</div>
</div>
@@ -24,6 +24,9 @@ import { ClientsService } from 'app/clients/clients.service';
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
import { Currency } from 'app/shared/models/general.model';
import { TranslateService } from '@ngx-translate/core';
import { AccountsService } from 'app/accounts/accounts.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
/** Environment Configuration */
import { environment } from '../../../environments/environment';
@@ -83,6 +86,9 @@ export class CurrentAccountViewComponent implements OnInit {
private currentAccountsService = inject(CurrentAccountsService);
private clientsService = inject(ClientsService);
private translateService = inject(TranslateService);
private accountsService = inject(AccountsService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
dialog = inject(MatDialog);
/** Current Account Data */
@@ -126,7 +132,8 @@ export class CurrentAccountViewComponent implements OnInit {
Close: 'danger',
Reject: 'danger',
Delete: 'danger',
'Withdrawn by Client': 'danger'
'Withdrawn by Client': 'danger',
Terminate: 'danger'
};
private readonly ACTION_ICON: Record<string, string> = {
@@ -157,7 +164,8 @@ export class CurrentAccountViewComponent implements OnInit {
Close: 'times-circle',
Reject: 'ban',
Delete: 'trash',
'Withdrawn by Client': 'user-slash'
'Withdrawn by Client': 'user-slash',
Terminate: 'times-circle'
};
kindOf(name: string): 'transact' | 'block' | 'interest' | 'admin' | 'danger' {
@@ -309,6 +317,9 @@ export class CurrentAccountViewComponent implements OnInit {
case 'Delete':
this.deleteCurrentAccount();
break;
case 'Terminate':
this.terminateCurrentAccount();
break;
case 'Calculate Interest':
this.calculateInterest();
break;
@@ -370,6 +381,32 @@ export class CurrentAccountViewComponent implements OnInit {
});
}
private terminateCurrentAccount() {
const terminateCurrentAccountDialogRef = this.dialog.open(ConfirmationDialogComponent, {
data: {
heading: 'Terminate Account',
dialogContext: 'Are you sure you want to terminate this account?',
type: 'Strong'
}
});
terminateCurrentAccountDialogRef.afterClosed().subscribe((response: any) => {
if (response && response.confirm) {
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
const terminatedOnDate = this.dateUtils.formatDate(new Date(), dateFormat);
const data = {
accountType: 'CURRENT',
terminatedOnDate,
dateFormat,
locale
};
this.accountsService.terminateAccount(this.currentAccountData.id, data).subscribe(() => {
this.reload();
});
}
});
}
/**
* Calculates current account's interest
*/
@@ -194,6 +194,10 @@ export class CurrentAccountButtonsConfiguration {
{
name: 'Delete',
taskPermissionName: 'DELETE_CURRENTACCOUNT'
},
{
name: 'Terminate',
taskPermissionName: 'DELETE_CURRENTACCOUNT'
}
];
break;
@@ -87,7 +87,7 @@ export class EditFixedDepositAccountComponent {
if (account?.depositPeriod) {
this.selectedTenure = {
depositPeriod: account.depositPeriod,
depositPeriodFrequencyId: account.depositPeriodFrequencyType?.id ?? 2,
depositPeriodFrequencyId: account.depositPeriodFrequency?.id ?? 2,
depositAmount: account.depositAmount,
isSeniorCitizen: !!account.isSeniorCitizen,
appliedRate: account.seniorCitizenInterestRate ?? undefined
@@ -50,6 +50,9 @@ export class FixedDepositAccountCurrencyStepComponent implements OnChanges {
}
if (Array.isArray(ea.nominees) && ea.nominees.length > 0) {
this.nominees = [...ea.nominees];
this.nominationFacility = true;
} else {
this.nominationFacility = false;
}
}
if (changes['fixedDepositsAccountProductTemplate']?.currentValue && !this.existingAccountApplied) {
@@ -11,8 +11,8 @@ import { Component, OnInit, inject } from '@angular/core';
import { ActivatedRoute, Router, RouterLinkActive, RouterLink, RouterOutlet } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
/** Custom Dialogs */
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
import { CalculateInterestDialogComponent } from './custom-dialogs/calculate-interest-dialog/calculate-interest-dialog.component';
import { PostInterestDialogComponent } from './custom-dialogs/post-interest-dialog/post-interest-dialog.component';
import { ToggleWithholdTaxDialogComponent } from './custom-dialogs/toggle-withhold-tax-dialog/toggle-withhold-tax-dialog.component';
@@ -23,7 +23,10 @@ import { FixedDepositsButtonsConfiguration } from './fixed-deposits-buttons.conf
/** Custom Services */
import { FixedDepositsService } from '../fixed-deposits.service';
import { SavingsService } from 'app/savings/savings.service';
import { AccountsService } from 'app/accounts/accounts.service';
import { Currency } from 'app/shared/models/general.model';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { MatTooltip } from '@angular/material/tooltip';
import { NgClass, CurrencyPipe } from '@angular/common';
import { AccountNumberComponent } from '../../../shared/account-number/account-number.component';
@@ -67,6 +70,9 @@ export class FixedDepositAccountViewComponent implements OnInit {
private router = inject(Router);
private fixedDepositsService = inject(FixedDepositsService);
private savingsService = inject(SavingsService);
private accountsService = inject(AccountsService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
dialog = inject(MatDialog);
/** Fixed Deposits Account Data */
@@ -99,7 +105,8 @@ export class FixedDepositAccountViewComponent implements OnInit {
'Apply Annual Fees': 'admin',
Reject: 'danger',
'Withdrawn by Client': 'danger',
Delete: 'danger'
Delete: 'danger',
Terminate: 'danger'
};
private readonly ACTION_ICON: Record<string, string> = {
@@ -121,7 +128,8 @@ export class FixedDepositAccountViewComponent implements OnInit {
'Apply Annual Fees': 'receipt',
Reject: 'ban',
'Withdrawn by Client': 'user-slash',
Delete: 'trash'
Delete: 'trash',
Terminate: 'times-circle'
};
kindOf(name: string): 'transact' | 'block' | 'interest' | 'admin' | 'danger' {
@@ -237,6 +245,9 @@ export class FixedDepositAccountViewComponent implements OnInit {
case 'Disable Withhold Tax':
this.disableWithHoldTax();
break;
case 'Terminate':
this.terminateFixedDepositsAccount();
break;
}
}
@@ -256,6 +267,32 @@ export class FixedDepositAccountViewComponent implements OnInit {
});
}
private terminateFixedDepositsAccount() {
const terminateFixedDepositAccountDialogRef = this.dialog.open(ConfirmationDialogComponent, {
data: {
heading: 'Terminate Account',
dialogContext: 'Are you sure you want to terminate this account?',
type: 'Strong'
}
});
terminateFixedDepositAccountDialogRef.afterClosed().subscribe((response: any) => {
if (response && response.confirm) {
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
const terminatedOnDate = this.dateUtils.formatDate(new Date(), dateFormat);
const data = {
accountType: 'FD',
terminatedOnDate,
dateFormat,
locale
};
this.accountsService.terminateAccount(this.fixedDepositsAccountData.id, data).subscribe(() => {
this.reload();
});
}
});
}
/**
* Calculates fixed deposit account's interest
*/
@@ -132,6 +132,9 @@ export class FixedDepositsButtonsConfiguration {
},
{
name: 'Delete'
},
{
name: 'Terminate'
}
];
break;
@@ -86,7 +86,7 @@ export class EditRecurringDepositAccountComponent {
// Pre-populate tenure from existing account
if (account?.depositPeriod) {
this.selectedTenureValue = account.depositPeriod;
this.selectedTenureFrequencyId = account.depositPeriodFrequencyType?.id ?? 2;
this.selectedTenureFrequencyId = account.depositPeriodFrequency?.id ?? 2;
}
if (account?.mandatoryRecommendedDepositAmount) {
this.selectedDepositAmount = account.mandatoryRecommendedDepositAmount;
@@ -20,9 +20,13 @@ import { RecurringDepositsButtonsConfiguration } from './recurring-deposits-butt
/** Custom Dialogs */
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
import { RecurringDepositConfirmationDialogComponent } from './custom-dialogs/recurring-deposit-confirmation-dialog/recurring-deposit-confirmation-dialog.component';
import { Currency } from 'app/shared/models/general.model';
import { TranslateService } from '@ngx-translate/core';
import { AccountsService } from 'app/accounts/accounts.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { MatTooltip } from '@angular/material/tooltip';
import { NgClass, DecimalPipe, CurrencyPipe } from '@angular/common';
import { AccountNumberComponent } from '../../../shared/account-number/account-number.component';
@@ -67,6 +71,9 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
private router = inject(Router);
private recurringDepositsService = inject(RecurringDepositsService);
private savingsService = inject(SavingsService);
private accountsService = inject(AccountsService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
dialog = inject(MatDialog);
private translateService = inject(TranslateService);
@@ -103,7 +110,8 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
'Apply Annual Fees': 'admin',
Reject: 'danger',
'Withdrawn by Client': 'danger',
Delete: 'danger'
Delete: 'danger',
Terminate: 'danger'
};
private readonly ACTION_ICON: Record<string, string> = {
@@ -125,7 +133,8 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
'Apply Annual Fees': 'receipt',
Reject: 'ban',
'Withdrawn by Client': 'user-slash',
Delete: 'trash'
Delete: 'trash',
Terminate: 'times-circle'
};
kindOf(name: string): 'transact' | 'block' | 'interest' | 'admin' | 'danger' {
@@ -287,6 +296,9 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
case 'Disable Withhold Tax':
this.disableWithHoldTax();
break;
case 'Terminate':
this.terminateRecurringDepositsAccount();
break;
}
}
@@ -308,6 +320,32 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
});
}
private terminateRecurringDepositsAccount() {
const terminateRecurringDepositAccountDialogRef = this.dialog.open(ConfirmationDialogComponent, {
data: {
heading: 'Terminate Account',
dialogContext: 'Are you sure you want to terminate this account?',
type: 'Strong'
}
});
terminateRecurringDepositAccountDialogRef.afterClosed().subscribe((response: any) => {
if (response && response.confirm) {
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
const terminatedOnDate = this.dateUtils.formatDate(new Date(), dateFormat);
const data = {
accountType: 'RD',
terminatedOnDate,
dateFormat,
locale
};
this.accountsService.terminateAccount(this.recurringDepositsAccountData.id, data).subscribe(() => {
this.reload();
});
}
});
}
/**
* Calculates savings account's interest
*/
@@ -163,6 +163,11 @@ export class RecurringDepositsButtonsConfiguration {
name: 'Delete',
icon: 'trash',
taskPermissionName: 'DELETE_RECURRINGDEPOSITACCOUNT'
},
{
name: 'Terminate',
icon: 'times-circle',
taskPermissionName: 'DELETE_RECURRINGDEPOSITACCOUNT'
}
];
break;
@@ -7,6 +7,11 @@
-->
<div class="committee-page">
<!-- Ambient background glow elements -->
<div class="ambient-glow blob-1"></div>
<div class="ambient-glow blob-2"></div>
<div class="ambient-glow blob-3"></div>
<!-- Header -->
<div class="page-header">
<div class="header-left">
@@ -149,11 +154,22 @@
<div class="meeting-list">
@for (m of getMeetings(c.id); track m.id) {
<div class="meeting-row">
<div class="meeting-mode-icon" [title]="m.mode">
<mat-icon>{{ meetingModeIcon(m.mode) }}</mat-icon>
<!-- Top Header Row -->
<div class="meeting-header-row">
<div class="meeting-mode-icon" [title]="m.mode">
<mat-icon>{{ meetingModeIcon(m.mode) }}</mat-icon>
</div>
<span class="meeting-title" [title]="m.title">{{ m.title }}</span>
<span
class="meeting-status-chip"
[style.background]="meetingStatusColor(m.status) + '18'"
[style.color]="meetingStatusColor(m.status)"
>{{ m.status }}</span
>
</div>
<div class="meeting-info">
<span class="meeting-title">{{ m.title }}</span>
<!-- Details Area -->
<div class="meeting-details-area">
<div class="meeting-meta-row">
<mat-icon class="meta-icon">calendar_today</mat-icon>
<span>{{ m.meetingDate | date: 'dd MMM yyyy' }}</span>
@@ -162,13 +178,6 @@
<mat-icon class="meta-icon">schedule</mat-icon>
<span>{{ m.meetingTime }}</span>
}
<span class="meta-sep">·</span>
<span
class="meeting-status-chip"
[style.background]="meetingStatusColor(m.status) + '18'"
[style.color]="meetingStatusColor(m.status)"
>{{ m.status }}</span
>
</div>
@if (m.mode === 'ONLINE' && m.platform) {
<div class="meeting-platform-row">
@@ -192,9 +201,41 @@
<span>{{ m.venue }}{{ m.city ? ', ' + m.city : '' }}</span>
</div>
}
@if (m.agendaItems?.length > 0) {
<div class="meeting-agenda-preview">
<mat-icon class="meta-icon">format_list_bulleted</mat-icon>
<span
>{{ m.agendaItems.length }} agenda item{{ m.agendaItems.length !== 1 ? 's' : '' }}</span
>
</div>
}
</div>
@if (c.status === 'ACTIVE') {
<div class="meeting-actions">
<!-- Actions Row -->
<div class="meeting-actions-row">
<button class="btn-icon" title="Copy Details" (click)="copyMeetingDetails(m, $event)">
<mat-icon>content_copy</mat-icon>
</button>
<button class="btn-icon" title="Download PDF" (click)="downloadMeetingPdf(m, c, $event)">
<mat-icon>picture_as_pdf</mat-icon>
</button>
@if (m.status === 'COMPLETED') {
<button
class="btn-icon purple"
title="Send Voting Emails"
(click)="sendVotingEmailsDirect(m, $event)"
>
<mat-icon>forward_to_inbox</mat-icon>
</button>
<button
class="btn-icon purple"
title="View Votes & Resolutions"
(click)="openVotePanel(m, $event)"
>
<mat-icon>how_to_vote</mat-icon>
</button>
}
@if (c.status === 'ACTIVE') {
@if (m.status === 'SCHEDULED') {
<button class="btn-icon" title="Edit" (click)="openEditMeeting(m, c.id, $event)">
<mat-icon>edit</mat-icon>
@@ -221,8 +262,8 @@
>
<mat-icon>delete_outline</mat-icon>
</button>
</div>
}
}
</div>
</div>
}
</div>
@@ -371,10 +412,11 @@
type="text"
[(ngModel)]="meetingForm.title"
placeholder="e.g. Q1 Strategy Review"
[class.input-error]="submittedMeeting && !meetingForm.title?.trim()"
maxlength="200"
[class.input-error]="submittedMeeting && meetingErrors['title']"
/>
@if (submittedMeeting && !meetingForm.title?.trim()) {
<span class="error-msg">Title is required</span>
@if (submittedMeeting && meetingErrors['title']) {
<span class="error-msg">{{ meetingErrors['title'] }}</span>
}
</div>
@@ -385,10 +427,11 @@
<input
type="date"
[(ngModel)]="meetingForm.meetingDate"
[class.input-error]="submittedMeeting && !meetingForm.meetingDate"
[min]="todayDate"
[class.input-error]="submittedMeeting && meetingErrors['meetingDate']"
/>
@if (submittedMeeting && !meetingForm.meetingDate) {
<span class="error-msg">Date is required</span>
@if (submittedMeeting && meetingErrors['meetingDate']) {
<span class="error-msg">{{ meetingErrors['meetingDate'] }}</span>
}
</div>
<div class="form-group">
@@ -430,7 +473,15 @@
</div>
<div class="form-group">
<label>Meeting Link</label>
<input type="url" [(ngModel)]="meetingForm.meetingLink" placeholder="https://..." />
<input
type="url"
[(ngModel)]="meetingForm.meetingLink"
placeholder="https://..."
[class.input-error]="submittedMeeting && meetingErrors['meetingLink']"
/>
@if (submittedMeeting && meetingErrors['meetingLink']) {
<span class="error-msg">{{ meetingErrors['meetingLink'] }}</span>
}
</div>
<div class="form-row-2">
<div class="form-group">
@@ -464,10 +515,31 @@
</div>
}
<!-- Agenda -->
<!-- Agenda Items -->
<div class="form-group">
<label>Agenda</label>
<textarea [(ngModel)]="meetingForm.agenda" rows="3" placeholder="Meeting agenda..."></textarea>
<label>Agenda Items</label>
<div class="agenda-list">
@for (item of meetingForm.agendaItems; track $index) {
<div class="agenda-item-row">
<span class="agenda-num">{{ $index + 1 }}.</span>
<input
type="text"
[name]="'agendaItem' + $index"
[value]="meetingForm.agendaItems[$index]"
(input)="meetingForm.agendaItems[$index] = $any($event.target).value"
placeholder="e.g. Review financials, Discuss elections..."
/>
@if (meetingForm.agendaItems.length > 1) {
<button type="button" class="btn-remove-agenda" title="Remove" (click)="removeAgendaItem($index)">
<mat-icon>remove_circle_outline</mat-icon>
</button>
}
</div>
}
</div>
<button type="button" class="btn-add-agenda" (click)="addAgendaItem()">
<mat-icon>add_circle_outline</mat-icon> Add Item
</button>
</div>
</div>
@@ -480,3 +552,105 @@
</div>
</div>
}
<!-- ── Vote Summary & Resolution Modal ─────────────────────────── -->
@if (showVotePanel) {
<div class="modal-backdrop" (click)="closeVotePanel()"></div>
<div class="vote-modal">
<!-- Modal header -->
<div class="vm-header">
<div class="vm-header-left">
<mat-icon class="vm-icon">how_to_vote</mat-icon>
<div>
<div class="vm-title">Vote Summary &amp; Resolutions</div>
@if (votePanelMeeting) {
<div class="vm-subtitle">
{{ votePanelMeeting.title }} · {{ votePanelMeeting.meetingDate | date: 'dd MMM yyyy' }}
</div>
}
</div>
</div>
<div class="vm-header-right">
<button
class="btn-resend"
(click)="resendVotingEmails($event)"
[disabled]="resendingVoteEmails"
title="Resend voting emails"
>
<mat-icon>{{ resendingVoteEmails ? 'hourglass_empty' : 'forward_to_inbox' }}</mat-icon>
{{ resendingVoteEmails ? 'Sending...' : 'Resend Emails' }}
</button>
<button class="vm-close" (click)="closeVotePanel()">
<mat-icon>close</mat-icon>
</button>
</div>
</div>
<!-- Modal body -->
<div class="vm-body">
@if (voteSummaryLoading) {
<div class="loading-row"><mat-icon class="spin">autorenew</mat-icon> Loading votes…</div>
} @else if (voteSummary.length === 0) {
<div class="empty-votes">
<mat-icon>inbox</mat-icon>
<p>No agenda items found for this meeting.</p>
</div>
} @else {
@for (item of voteSummary; track item.agendaId) {
<div class="vote-item-card">
<div class="vi-header">
<span class="vi-num">{{ $index + 1 }}</span>
<span class="vi-label">{{ item.agendaItem }}</span>
</div>
<div class="vote-counts">
<div class="vc approved">
<mat-icon>check_circle</mat-icon><span>{{ item.approvedCount }} Approved</span>
</div>
<div class="vc rejected">
<mat-icon>cancel</mat-icon><span>{{ item.rejectedCount }} Rejected</span>
</div>
<div class="vc abstain">
<mat-icon>pause_circle</mat-icon><span>{{ item.abstainCount }} Abstain</span>
</div>
<div class="vc pending">
<mat-icon>pending</mat-icon><span>{{ item.pendingCount }} Pending</span>
</div>
</div>
<div class="resolution-row">
<label class="res-label">Resolution:</label>
@if (item.resolution) {
<span
class="res-badge"
[class]="
item.resolution === 'APPROVED'
? 'res-badge-approved'
: item.resolution === 'REJECTED'
? 'res-badge-rejected'
: 'res-badge-deferred'
"
>
{{
item.resolution === 'APPROVED'
? '✅ Approved'
: item.resolution === 'REJECTED'
? '❌ Rejected'
: '⏸ Deferred'
}}
</span>
} @else {
<span class="res-badge res-badge-pending">Awaiting Chairman / Vice Chairman vote</span>
}
</div>
</div>
}
}
</div>
<!-- Modal footer -->
<div class="vm-footer">
<button class="btn-cancel" (click)="closeVotePanel()">Close</button>
</div>
</div>
}
File diff suppressed because it is too large Load Diff
@@ -65,9 +65,24 @@ export class CommitteeBoardComponent implements OnInit {
savingMeeting = false;
submittedMeeting = false;
meetingForm: any = this.emptyMeetingForm();
meetingErrors: Record<string, string> = {};
readonly todayDate: string = new Date().toISOString().split('T')[0];
resendingVoteEmails = false;
mandatoryDesignations: string[] = [];
// vote summary / resolution drawer state
showVotePanel = false;
votePanelMeeting: any = null;
voteSummary: any[] = [];
voteSummaryLoading = false;
ngOnInit(): void {
this.load();
this.service.getDesignations().subscribe((data: any[]) => {
this.mandatoryDesignations = (data ?? []).filter((d: any) => d.mandatory).map((d: any) => d.name);
});
}
load(): void {
@@ -206,6 +221,17 @@ export class CommitteeBoardComponent implements OnInit {
this.submitted = true;
if (!this.form.name?.trim()) return;
// Mandatory designations (e.g. Chairman, Vice Chairman) required for new committee
if (this.editingId == null && this.mandatoryDesignations.length > 0) {
const selected = this.availableDirectors.filter((d: any) => this.form.selectedIds.includes(d.id));
const presentDesignations = new Set(selected.map((d: any) => d.designation));
const missing = this.mandatoryDesignations.filter((des) => !presentDesignations.has(des));
if (missing.length > 0) {
this.snackBar.open(`Committee must include an active ${missing.join(' and ')}.`, 'Close', { duration: 5000 });
return;
}
}
this.saving = true;
const payload = {
name: this.form.name.trim(),
@@ -314,21 +340,33 @@ export class CommitteeBoardComponent implements OnInit {
this.editingMeetingId = meeting.id;
this.currentMeetingCommitteeId = committeeId;
this.submittedMeeting = false;
this.meetingForm = {
title: meeting.title,
meetingDate: meeting.meetingDate,
meetingTime: meeting.meetingTime ?? '',
mode: meeting.mode ?? 'OFFLINE',
agenda: meeting.agenda ?? '',
platform: meeting.platform ?? '',
meetingLink: meeting.meetingLink ?? '',
meetingIdRef: meeting.meetingIdRef ?? '',
meetingPassword: meeting.meetingPassword ?? '',
venue: meeting.venue ?? '',
address: meeting.address ?? '',
city: meeting.city ?? ''
this.service.getMeeting(meeting.id).subscribe({
next: (fresh) => {
this.meetingForm = this.buildMeetingFormObj(fresh);
this.showMeetingForm = true;
},
error: () => {
this.meetingForm = this.buildMeetingFormObj(meeting);
this.showMeetingForm = true;
}
});
}
private buildMeetingFormObj(m: any): any {
return {
title: m.title,
meetingDate: m.meetingDate,
meetingTime: m.meetingTime ?? '',
mode: m.mode ?? 'OFFLINE',
agendaItems: m.agendaItems?.length > 0 ? [...m.agendaItems] : [''],
platform: m.platform ?? '',
meetingLink: m.meetingLink ?? '',
meetingIdRef: m.meetingIdRef ?? '',
meetingPassword: m.meetingPassword ?? '',
venue: m.venue ?? '',
address: m.address ?? '',
city: m.city ?? ''
};
this.showMeetingForm = true;
}
closeMeetingForm(): void {
@@ -337,11 +375,47 @@ export class CommitteeBoardComponent implements OnInit {
this.currentMeetingCommitteeId = null;
this.meetingForm = this.emptyMeetingForm();
this.submittedMeeting = false;
this.meetingErrors = {};
}
private validateMeetingForm(): boolean {
const errors: Record<string, string> = {};
const title = this.meetingForm.title?.trim() ?? '';
if (!title) {
errors['title'] = 'Meeting title is required';
} else if (title.length > 200) {
errors['title'] = 'Meeting title must not exceed 200 characters';
}
if (!this.meetingForm.meetingDate) {
errors['meetingDate'] = 'Meeting date is required';
} else {
const today = new Date();
today.setHours(0, 0, 0, 0);
const selected = new Date(this.meetingForm.meetingDate);
if (selected < today) {
errors['meetingDate'] = 'Meeting date cannot be in the past';
}
}
if (!this.meetingForm.mode) {
errors['mode'] = 'Please select a meeting mode';
}
if (this.meetingForm.mode === 'ONLINE' && this.meetingForm.meetingLink?.trim()) {
const link: string = this.meetingForm.meetingLink.trim();
if (!link.startsWith('http://') && !link.startsWith('https://')) {
errors['meetingLink'] = 'Meeting link must start with http:// or https://';
}
}
this.meetingErrors = errors;
return Object.keys(errors).length === 0;
}
saveMeeting(): void {
this.submittedMeeting = true;
if (!this.meetingForm.title?.trim() || !this.meetingForm.meetingDate) return;
if (!this.validateMeetingForm()) return;
this.savingMeeting = true;
const payload: any = {
@@ -349,7 +423,7 @@ export class CommitteeBoardComponent implements OnInit {
meetingDate: this.meetingForm.meetingDate,
meetingTime: this.meetingForm.meetingTime || null,
mode: this.meetingForm.mode,
agenda: this.meetingForm.agenda?.trim() || null
agendaItems: (this.meetingForm.agendaItems as string[]).filter((a) => a?.trim())
};
if (this.meetingForm.mode === 'ONLINE') {
@@ -421,17 +495,210 @@ export class CommitteeBoardComponent implements OnInit {
return mode === 'ONLINE' ? 'video_call' : 'location_on';
}
copyMeetingDetails(meeting: any, event: MouseEvent): void {
event.stopPropagation();
const lines: string[] = [];
lines.push(`📅 Committee Meeting: ${meeting.title}`);
lines.push(`Date: ${meeting.meetingDate}`);
if (meeting.meetingTime) lines.push(`Time: ${meeting.meetingTime}`);
lines.push(`Mode: ${meeting.mode}`);
if (meeting.mode === 'ONLINE') {
if (meeting.platform) lines.push(`Platform: ${meeting.platform}`);
if (meeting.meetingLink) lines.push(`Join Link: ${meeting.meetingLink}`);
if (meeting.meetingIdRef) lines.push(`Meeting ID: ${meeting.meetingIdRef}`);
if (meeting.meetingPassword) lines.push(`Password: ${meeting.meetingPassword}`);
} else {
if (meeting.venue) lines.push(`Venue: ${meeting.venue}`);
if (meeting.address) lines.push(`Address: ${meeting.address}`);
if (meeting.city) lines.push(`City: ${meeting.city}`);
}
if (meeting.agendaItems?.length > 0) {
lines.push('Agenda:');
(meeting.agendaItems as string[]).forEach((item, i) => lines.push(` ${i + 1}. ${item}`));
}
navigator.clipboard.writeText(lines.join('\n')).then(() => {
this.snackBar.open('Meeting details copied!', 'Close', { duration: 2500 });
});
}
downloadMeetingPdf(meeting: any, committee: any, event: MouseEvent): void {
event.stopPropagation();
this.service.getMeeting(meeting.id).subscribe({
next: (fresh) => this.openPrintWindow(fresh, committee),
error: () => this.openPrintWindow(meeting, committee)
});
}
private openPrintWindow(meeting: any, committee: any): void {
const w = window.open('', '_blank', 'width=900,height=700');
if (!w) {
this.snackBar.open('Popup blocked. Please allow popups.', 'Close', { duration: 3500 });
return;
}
const fmt = (d: string) => {
if (!d) return '—';
const dt = new Date(d);
return isNaN(dt.getTime())
? d
: dt.toLocaleDateString('en-IN', { day: '2-digit', month: 'long', year: 'numeric' });
};
const agendaRows = ((meeting.agendaItems as string[]) || [])
.filter((a) => a?.trim())
.map(
(a, i) =>
`<tr><td style="width:28px;vertical-align:top;padding:4px 0;"><span style="background:#f59e0b;color:#fff;border-radius:50%;width:20px;height:20px;display:inline-block;text-align:center;line-height:20px;font-size:11px;font-weight:700;">${i + 1}</span></td><td style="padding:4px 0 4px 10px;font-size:13px;color:#1c1917;">${a}</td></tr>`
)
.join('');
const row = (icon: string, label: string, value: string) =>
`<tr><td style="padding:9px 12px;font-size:12px;color:#64748b;font-weight:600;letter-spacing:0.3px;text-transform:uppercase;border-bottom:1px solid #e2e8f0;width:130px;">${icon} ${label}</td><td style="padding:9px 12px;font-size:13px;color:#334155;border-bottom:1px solid #e2e8f0;">${value || '—'}</td></tr>`;
const isOnline = meeting.mode === 'ONLINE';
let locationRows = '';
if (isOnline) {
if (meeting.platform) locationRows += row('📹', 'Platform', meeting.platform);
if (meeting.meetingLink)
locationRows += row('🔗', 'Join Link', `<a href="${meeting.meetingLink}">${meeting.meetingLink}</a>`);
if (meeting.meetingIdRef) locationRows += row('🔑', 'Meeting ID', meeting.meetingIdRef);
if (meeting.meetingPassword) locationRows += row('🔒', 'Password', meeting.meetingPassword);
} else {
if (meeting.venue) locationRows += row('🏛', 'Venue', meeting.venue);
if (meeting.address) locationRows += row('📍', 'Address', meeting.address);
if (meeting.city) locationRows += row('🌍', 'City', meeting.city);
}
const html = `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Meeting - ${meeting.title}</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: Arial, Helvetica, sans-serif; background: #f0f4ff; padding: 24px; }
.card { max-width: 720px; margin: 0 auto; background: #fff; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 24px rgba(0,0,0,.10); }
.header { background: #1e40af; padding: 22px 28px; }
.header-eyebrow { font-size: 11px; font-weight: 700; color: rgba(255,255,255,.6); letter-spacing: 1.5px; text-transform: uppercase; margin-bottom: 4px; }
.header-title { font-size: 22px; font-weight: 800; color: #fff; }
.header-sub { margin-top: 6px; font-size: 13px; color: rgba(255,255,255,.75); }
.body { padding: 24px 28px; }
.section-label { font-size: 11px; font-weight: 700; color: #94a3b8; letter-spacing: 1px; text-transform: uppercase; margin-bottom: 10px; }
.details-table { width: 100%; border-collapse: collapse; background: #f8faff; border-radius: 10px; border: 1px solid #e2e8f0; overflow: hidden; margin-bottom: 20px; }
.agenda-box { background: #fefce8; border: 1px solid #fde68a; border-radius: 10px; padding: 16px 20px; margin-bottom: 20px; }
.agenda-label { font-size: 13px; font-weight: 700; color: #92400e; margin-bottom: 10px; }
.footer { background: #f8fafc; border-top: 1px solid #e2e8f0; padding: 12px 28px; display: flex; justify-content: space-between; font-size: 11px; color: #94a3b8; }
@media print { body { background: #fff; padding: 0; } .card { box-shadow: none; border-radius: 0; } .no-print { display: none !important; } }
</style></head><body>
<div class="no-print" style="max-width:720px;margin:0 auto 12px;text-align:right;">
<button onclick="window.print()" style="background:#1e40af;color:#fff;border:none;padding:8px 20px;border-radius:6px;font-size:13px;cursor:pointer;">🖨 Print / Save as PDF</button>
</div>
<div class="card">
<div class="header">
<div class="header-eyebrow">CBSnexora · Committee Meeting</div>
<div class="header-title">📅 ${meeting.title}</div>
${committee?.name ? `<div class="header-sub">👥 ${committee.name}</div>` : ''}
</div>
<div class="body">
<div class="section-label">Meeting Details</div>
<table class="details-table">
<tbody>
${committee?.name ? row('👥', 'Committee', committee.name) : ''}
${row('📅', 'Date', fmt(meeting.meetingDate))}
${row('⏰', 'Time', meeting.meetingTime || '—')}
${row('🔗', 'Mode', isOnline ? '🌐 Online' : '📍 Offline')}
${row('📊', 'Status', meeting.status || '—')}
${locationRows}
</tbody>
</table>
${agendaRows ? `<div class="agenda-box"><div class="agenda-label">📋 AGENDA</div><table style="width:100%;border-collapse:collapse;">${agendaRows}</table></div>` : ''}
</div>
<div class="footer">
<span>CBSnexora — Committee Meeting Record</span>
<span>Generated: ${new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' })}</span>
</div>
</div>
</body></html>`;
w.document.write(html);
w.document.close();
w.focus();
}
sendVotingEmailsDirect(meeting: any, event: MouseEvent): void {
event.stopPropagation();
this.service.resendVotingEmails(meeting.id).subscribe({
next: () => this.snackBar.open('Voting emails sent to all members!', 'Close', { duration: 4000 }),
error: () => this.snackBar.open('Failed to send voting emails.', 'Close', { duration: 3000 })
});
}
resendVotingEmails(event: MouseEvent): void {
event.stopPropagation();
if (this.resendingVoteEmails || !this.votePanelMeeting) return;
this.resendingVoteEmails = true;
this.service.resendVotingEmails(this.votePanelMeeting.id).subscribe({
next: () => {
this.resendingVoteEmails = false;
this.snackBar.open('Voting emails sent to all committee members.', 'Close', { duration: 4000 });
},
error: () => {
this.resendingVoteEmails = false;
this.snackBar.open('Failed to send voting emails.', 'Close', { duration: 3000 });
}
});
}
openVotePanel(meeting: any, event: MouseEvent): void {
event.stopPropagation();
this.votePanelMeeting = meeting;
this.showVotePanel = true;
this.voteSummary = [];
this.voteSummaryLoading = true;
this.service.getVoteSummary(meeting.id).subscribe({
next: (data) => {
this.voteSummary = data ?? [];
this.voteSummaryLoading = false;
},
error: () => {
this.voteSummaryLoading = false;
this.snackBar.open('Failed to load vote summary.', 'Close', { duration: 3000 });
}
});
}
closeVotePanel(): void {
this.showVotePanel = false;
this.votePanelMeeting = null;
this.voteSummary = [];
}
resolutionIcon(r: string): string {
return r === 'APPROVED' ? 'check_circle' : r === 'REJECTED' ? 'cancel' : 'pause_circle';
}
resolutionColor(r: string): string {
return r === 'APPROVED' ? '#16a34a' : r === 'REJECTED' ? '#dc2626' : '#d97706';
}
private emptyForm(): any {
return { name: '', description: '', image: null, selectedIds: [] };
}
addAgendaItem(): void {
this.meetingForm.agendaItems.push('');
}
removeAgendaItem(index: number): void {
if (this.meetingForm.agendaItems.length > 1) {
this.meetingForm.agendaItems.splice(index, 1);
}
}
private emptyMeetingForm(): any {
return {
title: '',
meetingDate: '',
meetingTime: '',
mode: 'OFFLINE',
agenda: '',
agendaItems: [''],
platform: '',
meetingLink: '',
meetingIdRef: '',
@@ -96,19 +96,18 @@ export class DirectorBoardComponent implements OnInit {
readonly today: Date = new Date();
designationOptions = [
'Chairman',
'Vice Chairman',
'Secretary',
'Treasurer',
'Director',
'Managing Director',
'Chief Executive Officer'
];
designationOptions: string[] = [];
ngOnInit(): void {
this.load();
this.loadPending();
this.loadDesignations();
}
loadDesignations(): void {
this.service.getDesignations().subscribe((data: any[]) => {
this.designationOptions = (data ?? []).map((d: any) => d.name);
});
}
loadPending(): void {
@@ -0,0 +1,241 @@
<!--
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/.
-->
<!-- Public voting page — no shell/navbar — single viewport height -->
<div class="vote-page">
<!-- Loading -->
@if (loading) {
<div class="state-center">
<mat-spinner diameter="48"></mat-spinner>
<p>Loading meeting details…</p>
</div>
}
<!-- Error / invalid token -->
@if (!loading && error && !page) {
<div class="state-center">
<div class="state-card error-card">
<mat-icon class="state-icon error">link_off</mat-icon>
<h2>Link Invalid or Expired</h2>
<p>{{ error }}</p>
<p class="hint">Please contact your committee administrator if you believe this is a mistake.</p>
</div>
</div>
}
<!-- Voting closed / already submitted -->
@if (!loading && page && (page.votingClosed || submitted)) {
<div class="state-center">
<div class="state-card success-card">
<mat-icon class="state-icon success">check_circle</mat-icon>
<h2>{{ submitted ? 'Vote Submitted!' : 'Voting Closed' }}</h2>
@if (submitted) {
<p>
Thank you, <strong>{{ page.memberName }}</strong
>. Your votes have been recorded.
</p>
} @else {
<p>You have already voted or the voting period has ended.</p>
}
<div class="summary-box">
<div class="summary-header">{{ page.meetingTitle }}</div>
<div class="summary-meta">{{ page.committeeName }} &nbsp;·&nbsp; {{ page.meetingDate }}</div>
@for (item of page.agendaItems; track item.agendaId) {
<div class="summary-row">
<span class="item-num">{{ $index + 1 }}</span>
<span class="item-text">{{ item.agendaItem }}</span>
@if (selections[item.agendaId]) {
<span class="vote-badge" [class]="'badge-' + selections[item.agendaId].toLowerCase()">
{{ voteLabel(selections[item.agendaId]) }}
</span>
}
</div>
}
</div>
</div>
</div>
}
<!-- Active voting form — full viewport single page -->
@if (!loading && page && !page.votingClosed && !submitted) {
<!-- Fixed header bar -->
<div class="vote-header">
<div class="vh-left">
<div class="vh-brand">CBSnexora · Committee Meeting</div>
<div class="vh-title">🗳️ Cast Your Vote</div>
<div class="vh-meta">{{ page.committeeName }} &nbsp;·&nbsp; {{ page.meetingDate }}</div>
</div>
<div class="vh-member">
<div class="vh-member-label">Voting as</div>
<div class="vh-member-name">{{ page.memberName }}</div>
</div>
</div>
<!-- Scrollable agenda area -->
<div class="vote-scroll">
<div class="scroll-inner">
<p class="subtitle">
Please review each agenda item from <strong>{{ page.meetingTitle }}</strong> and cast your vote.
</p>
@if (error) {
<div class="alert-error">{{ error }}</div>
}
<div class="agenda-list">
@for (item of page.agendaItems; track item.agendaId) {
<div class="agenda-item" [class.voted]="!!selections[item.agendaId]">
<div class="item-label">
<span class="item-num">{{ $index + 1 }}</span>
<span class="item-text">{{ item.agendaItem }}</span>
@if (selections[item.agendaId]) {
<span class="vote-badge" [class]="'badge-' + selections[item.agendaId].toLowerCase()">
{{ voteLabel(selections[item.agendaId]) }}
</span>
}
</div>
<div class="vote-buttons">
<button
class="btn-vote approve"
[class.active]="selections[item.agendaId] === 'APPROVED'"
(click)="select(item.agendaId, 'APPROVED')"
type="button"
>
✅ Approve
</button>
<button
class="btn-vote reject"
[class.active]="selections[item.agendaId] === 'REJECTED'"
(click)="select(item.agendaId, 'REJECTED')"
type="button"
>
❌ Reject
</button>
<button
class="btn-vote abstain"
[class.active]="selections[item.agendaId] === 'ABSTAIN'"
(click)="select(item.agendaId, 'ABSTAIN')"
type="button"
>
— Abstain
</button>
</div>
<!-- Member votes panel — always visible -->
@if (item.memberVotes?.length) {
<div class="member-votes-panel">
<button class="mv-toggle" type="button" (click)="toggleMemberVotes(item.agendaId)">
<mat-icon>{{ showMemberVotes[item.agendaId] ? 'expand_less' : 'people' }}</mat-icon>
Committee Votes ({{ countVoted(item) }}/{{ item.memberVotes.length }})
</button>
@if (showMemberVotes[item.agendaId]) {
<div class="mv-list">
@for (mv of item.memberVotes; track mv.memberName) {
<div class="mv-row">
<span class="mv-name">{{ mv.memberName }}</span>
@if (mv.designation) {
<span class="mv-desig">{{ mv.designation }}</span>
}
@if (mv.vote) {
<span class="mv-badge" [class]="'badge-' + mv.vote.toLowerCase()">
{{ mv.vote === 'APPROVED' ? '✅' : mv.vote === 'REJECTED' ? '❌' : '—' }}
{{ mv.vote }}
</span>
} @else {
<span class="mv-badge badge-pending">Pending</span>
}
</div>
}
</div>
}
</div>
}
@if (selections[item.agendaId] === 'APPROVED' || selections[item.agendaId] === 'REJECTED') {
<div class="remark-row">
<textarea
class="remark-input"
[placeholder]="
selections[item.agendaId] === 'REJECTED'
? 'कारण / Reason for rejection…'
: 'टिप्पणी / Add your remark…'
"
[(ngModel)]="remarks[item.agendaId]"
rows="2"
></textarea>
@if (speechSupported) {
<div class="remark-controls">
<button
class="btn-mic"
[class.active]="recording === item.agendaId"
(click)="toggleRecording(item.agendaId)"
type="button"
[title]="recording === item.agendaId ? 'Stop recording' : 'Start voice input'"
>
<mat-icon>{{ recording === item.agendaId ? 'stop_circle' : 'mic' }}</mat-icon>
<span class="mic-label">{{ recording === item.agendaId ? 'Stop' : 'Speak' }}</span>
</button>
<div class="lang-toggle">
<button
class="btn-lang"
[class.active]="recordingLang === 'hi-IN'"
(click)="switchLang('hi-IN')"
type="button"
title="Hindi"
>
HI
</button>
<button
class="btn-lang"
[class.active]="recordingLang === 'en-IN'"
(click)="switchLang('en-IN')"
type="button"
title="English"
>
EN
</button>
</div>
</div>
}
</div>
}
</div>
}
</div>
</div>
</div>
<!-- Fixed bottom bar — submit always visible -->
<div class="vote-bottom-bar">
<div class="bottom-inner">
<div class="progress-text">
@if (allVoted) {
<mat-icon class="prog-icon done">check_circle</mat-icon>
<span>All {{ page.agendaItems.length }} items voted — ready to submit</span>
} @else {
<mat-icon class="prog-icon">pending</mat-icon>
<span>{{ votedCount() }} of {{ page.agendaItems.length }} items voted</span>
}
</div>
<button class="btn-submit" [disabled]="!allVoted || submitting" (click)="submit()">
@if (submitting) {
<mat-spinner diameter="18" color="accent"></mat-spinner>
Submitting…
} @else {
Submit All Votes
}
</button>
</div>
<div class="bottom-footer">
<span>CBSnexora — Automated Voting System</span>
<span>Each member can vote once. Link valid for 7 days.</span>
</div>
</div>
}
</div>
@@ -0,0 +1,660 @@
/**
* 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/.
*/
// ── Single viewport page ──────────────────────────────────────────────────────
.vote-page {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
background: #f5f6fa;
font-family: Arial, Helvetica, sans-serif;
font-size: 13px;
}
// ── Header ────────────────────────────────────────────────────────────────────
.vote-header {
flex-shrink: 0;
background: #7c3aed;
padding: 10px 24px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
box-shadow: 0 1px 8px rgb(91 33 182 / 35%);
.vh-left {
display: flex;
flex-direction: column;
gap: 1px;
}
.vh-brand {
font-size: 9px;
font-weight: 700;
letter-spacing: 1.8px;
text-transform: uppercase;
color: rgb(255 255 255 / 50%);
}
.vh-title {
font-size: 17px;
font-weight: 800;
color: #fff;
line-height: 1.2;
}
.vh-meta {
font-size: 11px;
color: rgb(255 255 255 / 70%);
}
.vh-member {
text-align: right;
.vh-member-label {
font-size: 9px;
color: rgb(255 255 255 / 45%);
text-transform: uppercase;
letter-spacing: 1px;
}
.vh-member-name {
font-size: 13px;
font-weight: 700;
color: #fff;
}
}
}
// ── Scrollable agenda ─────────────────────────────────────────────────────────
.vote-scroll {
flex: 1;
overflow-y: auto;
min-height: 0;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: #f5f6fa;
}
&::-webkit-scrollbar-thumb {
background: #c4b5fd;
border-radius: 2px;
}
}
.scroll-inner {
max-width: 860px;
margin: 0 auto;
padding: 14px 24px 10px;
.subtitle {
margin: 0 0 12px;
font-size: 12px;
color: #64748b;
}
}
// ── Agenda list ───────────────────────────────────────────────────────────────
.agenda-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.agenda-item {
background: #fff;
border: 1.5px solid #e2e8f0;
border-radius: 8px;
padding: 10px 14px;
transition: border-color 0.15s;
&.voted {
border-color: #a78bfa;
}
.item-label {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.item-num {
background: #7c3aed;
color: #fff;
border-radius: 50%;
width: 20px;
height: 20px;
min-width: 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
flex-shrink: 0;
}
.item-text {
flex: 1;
font-size: 13px;
font-weight: 600;
color: #1e293b;
line-height: 1.3;
}
}
.vote-buttons {
display: flex;
gap: 6px;
padding-left: 28px;
}
.btn-vote {
flex: 1;
padding: 6px 8px;
border-radius: 6px;
border: 1.5px solid #e2e8f0;
font-size: 12px;
font-weight: 600;
cursor: pointer;
background: #f8fafc;
color: #64748b;
transition: all 0.12s ease;
white-space: nowrap;
&:hover {
background: #f1f5f9;
border-color: #cbd5e1;
}
&.approve.active {
background: #dcfce7;
border-color: #16a34a;
color: #15803d;
}
&.reject.active {
background: #fee2e2;
border-color: #dc2626;
color: #b91c1c;
}
&.abstain.active {
background: #fef3c7;
border-color: #d97706;
color: #b45309;
}
}
// ── Member votes panel ────────────────────────────────────────────────────────
.member-votes-panel {
padding: 6px 0 0 28px;
}
.mv-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
background: none;
border: 1.5px solid #e2e8f0;
border-radius: 6px;
padding: 3px 10px;
font-size: 11px;
font-weight: 600;
color: #64748b;
cursor: pointer;
transition: all 0.12s;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
&:hover {
background: #f1f5f9;
border-color: #cbd5e1;
}
}
.mv-list {
margin-top: 6px;
border: 1px solid #e2e8f0;
border-radius: 6px;
overflow: hidden;
}
.mv-row {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 10px;
border-bottom: 1px solid #f1f5f9;
font-size: 12px;
&:last-child {
border-bottom: none;
}
}
.mv-name {
flex: 1;
color: #1e293b;
font-weight: 500;
}
.mv-desig {
font-size: 10px;
color: #7c3aed;
background: #f5f3ff;
padding: 1px 6px;
border-radius: 10px;
font-weight: 600;
white-space: nowrap;
}
.mv-badge {
padding: 2px 8px;
border-radius: 20px;
font-size: 10px;
font-weight: 700;
white-space: nowrap;
flex-shrink: 0;
&.badge-approved {
background: #dcfce7;
color: #15803d;
}
&.badge-rejected {
background: #fee2e2;
color: #b91c1c;
}
&.badge-abstain {
background: #fef3c7;
color: #b45309;
}
&.badge-pending {
background: #f1f5f9;
color: #94a3b8;
}
}
// ── Remark row ────────────────────────────────────────────────────────────────
.remark-row {
display: flex;
gap: 8px;
padding-left: 28px;
padding-top: 8px;
align-items: flex-start;
}
.remark-input {
flex: 1;
border: 1.5px solid #e2e8f0;
border-radius: 6px;
padding: 6px 10px;
font-size: 12px;
font-family: inherit;
color: #334155;
resize: none;
line-height: 1.4;
transition: border-color 0.15s;
background: #fafbff;
&:focus {
outline: none;
border-color: #a78bfa;
background: #fff;
}
&::placeholder {
color: #94a3b8;
font-style: italic;
}
}
.remark-controls {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.btn-mic {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
background: #f1f5f9;
border: 1.5px solid #e2e8f0;
border-radius: 8px;
padding: 5px 8px;
cursor: pointer;
color: #64748b;
transition: all 0.15s;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
.mic-label {
font-size: 9px;
font-weight: 700;
letter-spacing: 0.5px;
}
&:hover {
background: #e2e8f0;
}
&.active {
background: #fee2e2;
border-color: #f87171;
color: #dc2626;
animation: pulse-mic 1.2s ease-in-out infinite;
}
}
@keyframes pulse-mic {
0%,
100% {
box-shadow: 0 0 0 0 rgb(220 38 38 / 30%);
}
50% {
box-shadow: 0 0 0 5px rgb(220 38 38 / 0%);
}
}
.lang-toggle {
display: flex;
border: 1.5px solid #e2e8f0;
border-radius: 6px;
overflow: hidden;
}
.btn-lang {
background: #f8fafc;
border: none;
padding: 3px 7px;
font-size: 9px;
font-weight: 700;
color: #94a3b8;
cursor: pointer;
transition: all 0.12s;
letter-spacing: 0.5px;
&:first-child {
border-right: 1px solid #e2e8f0;
}
&.active {
background: #7c3aed;
color: #fff;
}
}
// ── Bottom bar ────────────────────────────────────────────────────────────────
.vote-bottom-bar {
flex-shrink: 0;
background: #fff;
border-top: 1px solid #e2e8f0;
box-shadow: 0 -2px 8px rgb(0 0 0 / 5%);
}
.bottom-inner {
max-width: 860px;
margin: 0 auto;
padding: 10px 24px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.progress-text {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #64748b;
.prog-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: #94a3b8;
&.done {
color: #16a34a;
}
}
}
.btn-submit {
background: #7c3aed;
color: #fff;
border: none;
border-radius: 7px;
padding: 9px 28px;
font-size: 13px;
font-weight: 700;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
transition: background 0.12s;
flex-shrink: 0;
&:hover:not(:disabled) {
background: #6d28d9;
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
}
.bottom-footer {
max-width: 860px;
margin: 0 auto;
padding: 5px 24px 8px;
display: flex;
justify-content: space-between;
font-size: 10px;
color: #b0bac9;
border-top: 1px solid #f1f5f9;
}
// ── State pages ───────────────────────────────────────────────────────────────
.state-center {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 20px;
}
.state-card {
background: #fff;
border-radius: 12px;
box-shadow: 0 4px 20px rgb(0 0 0 / 8%);
padding: 36px 32px;
text-align: center;
width: 100%;
max-width: 440px;
.state-icon {
font-size: 48px;
width: 48px;
height: 48px;
&.success {
color: #16a34a;
}
&.error {
color: #dc2626;
}
}
h2 {
margin: 12px 0 6px;
font-size: 20px;
color: #1e293b;
}
p {
font-size: 13px;
color: #64748b;
margin: 0 0 4px;
}
.hint {
font-size: 11px;
color: #94a3b8;
margin-top: 6px;
}
}
// ── Summary box ───────────────────────────────────────────────────────────────
.summary-box {
margin-top: 16px;
background: #f8faff;
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
text-align: left;
max-height: 45vh;
overflow-y: auto;
.summary-header {
padding: 10px 14px 2px;
font-size: 13px;
font-weight: 700;
color: #1e293b;
}
.summary-meta {
padding: 0 14px 8px;
font-size: 11px;
color: #64748b;
border-bottom: 1px solid #e2e8f0;
}
.summary-row {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 14px;
border-bottom: 1px solid #f1f5f9;
&:last-child {
border-bottom: none;
}
.item-num {
background: #7c3aed;
color: #fff;
border-radius: 50%;
width: 18px;
height: 18px;
min-width: 18px;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
font-weight: 700;
flex-shrink: 0;
}
.item-text {
flex: 1;
font-size: 12px;
color: #334155;
}
}
}
// ── Badges ────────────────────────────────────────────────────────────────────
.vote-badge {
padding: 2px 8px;
border-radius: 20px;
font-size: 10px;
font-weight: 700;
white-space: nowrap;
flex-shrink: 0;
&.badge-approved {
background: #dcfce7;
color: #15803d;
}
&.badge-rejected {
background: #fee2e2;
color: #b91c1c;
}
&.badge-abstain {
background: #fef3c7;
color: #b45309;
}
}
// ── Alert ─────────────────────────────────────────────────────────────────────
.alert-error {
background: #fee2e2;
color: #b91c1c;
border-radius: 6px;
padding: 8px 12px;
font-size: 12px;
margin-bottom: 12px;
}
// ── Responsive ────────────────────────────────────────────────────────────────
@media (width <= 640px) {
.vote-header {
padding: 8px 14px;
}
.vote-header .vh-member {
display: none;
}
.scroll-inner,
.bottom-inner,
.bottom-footer {
padding-left: 14px;
padding-right: 14px;
}
.vote-buttons {
padding-left: 0;
flex-wrap: wrap;
}
.remark-row {
padding-left: 0;
}
.btn-submit {
padding: 8px 18px;
}
}
@@ -0,0 +1,220 @@
/**
* 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, OnDestroy, NgZone, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { ElectionService } from '../../election.service';
@Component({
selector: 'mifosx-meeting-vote',
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule,
MatProgressSpinnerModule
],
templateUrl: './meeting-vote.component.html',
styleUrls: ['./meeting-vote.component.scss']
})
export class MeetingVoteComponent implements OnInit, OnDestroy {
private route = inject(ActivatedRoute);
private service = inject(ElectionService);
private zone = inject(NgZone);
token = '';
page: any = null;
loading = true;
submitting = false;
submitted = false;
error = '';
// agendaId → 'APPROVED' | 'REJECTED' | 'ABSTAIN'
selections: Record<number, string> = {};
// agendaId → remark text
remarks: Record<number, string> = {};
// agendaId → whether member votes panel is expanded
showMemberVotes: Record<number, boolean> = {};
// Voice recording state
recording: number | null = null; // agendaId currently recording
recordingLang: 'hi-IN' | 'en-IN' = 'hi-IN';
readonly speechSupported = !!(window as any).SpeechRecognition || !!(window as any).webkitSpeechRecognition;
private recognition: any = null;
ngOnInit(): void {
this.token = this.route.snapshot.paramMap.get('token') ?? '';
this.load();
}
ngOnDestroy(): void {
this.stopRecognition();
}
load(): void {
this.loading = true;
this.error = '';
this.service.getVotingPage(this.token).subscribe({
next: (data) => {
this.page = data;
(data.agendaItems ?? []).forEach((item: any) => {
if (item.currentVote) this.selections[item.agendaId] = item.currentVote;
});
this.loading = false;
},
error: () => {
this.error = 'Invalid or expired voting link.';
this.loading = false;
}
});
}
select(agendaId: number, vote: string): void {
this.selections[agendaId] = vote;
// Clear remark when switching to Abstain
if (vote === 'ABSTAIN') {
delete this.remarks[agendaId];
if (this.recording === agendaId) this.stopRecognition();
}
}
// ── Voice recording ────────────────────────────────────────────────────────
toggleRecording(agendaId: number): void {
if (this.recording === agendaId) {
this.stopRecognition();
return;
}
if (this.recording !== null) {
this.stopRecognition();
}
this.startRecognition(agendaId);
}
private startRecognition(agendaId: number): void {
const SR = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
if (!SR) return;
// Run outside Angular zone — callbacks will call zone.run() themselves
this.zone.runOutsideAngular(() => {
this.recognition = new SR();
this.recognition.continuous = true; // keep listening until Stop is pressed
this.recognition.interimResults = false; // only final results
this.recognition.lang = this.recordingLang;
this.recognition.onresult = (event: any) => {
// Collect only new final results since last event
let newText = '';
for (let i = event.resultIndex; i < event.results.length; i++) {
if (event.results[i].isFinal) {
newText += event.results[i][0].transcript + ' ';
}
}
if (newText.trim()) {
this.zone.run(() => {
// Spread to new object so Angular picks up the change
this.remarks = {
...this.remarks,
[agendaId]: ((this.remarks[agendaId] ?? '') + ' ' + newText).trim()
};
});
}
};
this.recognition.onerror = () => {
this.zone.run(() => {
this.recording = null;
});
};
this.recognition.onend = () => {
this.zone.run(() => {
this.recording = null;
});
};
this.recognition.start();
});
this.recording = agendaId;
}
private stopRecognition(): void {
try {
this.recognition?.stop();
} catch (_) {}
this.recognition = null;
this.recording = null;
}
switchLang(lang: 'hi-IN' | 'en-IN'): void {
if (this.recordingLang === lang) return;
this.recordingLang = lang;
// Restart recording in new language if currently active
if (this.recording !== null) {
const active = this.recording;
this.stopRecognition();
setTimeout(() => this.startRecognition(active), 100);
}
}
// ── Submit ─────────────────────────────────────────────────────────────────
get allVoted(): boolean {
if (!this.page?.agendaItems?.length) return false;
return this.page.agendaItems.every((a: any) => !!this.selections[a.agendaId]);
}
submit(): void {
if (!this.allVoted || this.submitting) return;
this.stopRecognition();
this.submitting = true;
const votes = Object.entries(this.selections).map(
([
agendaId,
vote
]) => ({
agendaId: Number(agendaId),
vote,
remark: this.remarks[Number(agendaId)] ?? null
})
);
this.service.submitVotes(this.token, votes).subscribe({
next: () => {
this.submitting = false;
this.submitted = true;
},
error: (err: any) => {
this.submitting = false;
this.error = err?.error?.error ?? 'Failed to submit votes. Please try again.';
}
});
}
votedCount(): number {
if (!this.page?.agendaItems?.length) return 0;
return this.page.agendaItems.filter((a: any) => !!this.selections[a.agendaId]).length;
}
voteLabel(vote: string): string {
return vote === 'APPROVED' ? '✅ Approved' : vote === 'REJECTED' ? '❌ Rejected' : '— Abstain';
}
toggleMemberVotes(agendaId: number): void {
this.showMemberVotes[agendaId] = !this.showMemberVotes[agendaId];
}
countVoted(item: any): number {
return ((item.memberVotes ?? []) as any[]).filter((mv) => !!mv.vote).length;
}
}
@@ -11,6 +11,12 @@ import { RouterModule, Routes } from '@angular/router';
import { Route } from '../core/route/route.service';
const routes: Routes = [
// Public voting page — no shell/auth wrapper, accessible via email link
{
path: 'vote/:token',
loadComponent: () => import('./components/meeting-vote/meeting-vote.component').then((m) => m.MeetingVoteComponent),
data: { title: 'Vote on Agenda' }
},
Route.withShell([
{
path: 'election',
+29
View File
@@ -212,6 +212,10 @@ export class ElectionService {
return this.http.get<any[]>('/election/directors/pending').pipe(catchError(() => of([])));
}
getDesignations(): Observable<any[]> {
return this.http.get<any[]>('/election/directors/designations').pipe(catchError(() => of([])));
}
// ── Committees ─────────────────────────────────────────────────
getCommittees(): Observable<any[]> {
return this.http.get<any[]>('/election/committees').pipe(catchError(() => of([])));
@@ -269,4 +273,29 @@ export class ElectionService {
deleteMeeting(meetingId: number): Observable<any> {
return this.http.delete(`/election/committee-meetings/${meetingId}`);
}
// ── Agenda Voting (public — no auth) ─────────────────────────────
getVotingPage(token: string): Observable<any> {
return this.http.get<any>(`/public/meeting-vote/${token}`);
}
submitVotes(token: string, votes: { agendaId: number; vote: string }[]): Observable<any> {
return this.http.post(`/public/meeting-vote/${token}`, { votes });
}
// ── Admin: vote summary + resolutions ────────────────────────────
resendVotingEmails(meetingId: number): Observable<any> {
return this.http.post(`/election/meetings/${meetingId}/votes/resend`, {});
}
getVoteSummary(meetingId: number): Observable<any[]> {
return this.http.get<any[]>(`/election/meetings/${meetingId}/votes/summary`);
}
saveResolutions(
meetingId: number,
resolutions: { agendaId: number; resolution: string; discussionNotes: string }[]
): Observable<any> {
return this.http.put(`/election/meetings/${meetingId}/votes/resolutions`, resolutions);
}
}
@@ -54,38 +54,6 @@
<td mat-cell *matCellDef="let cashier">{{ cashier.isFullDay | yesNo }}</td>
</ng-container>
<ng-container matColumnDef="vaultActions">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Vault Actions' | translate }}</th>
<td mat-cell *matCellDef="let cashier" (click)="$event.stopPropagation()">
<div class="vault-actions">
<a
class="vault-btn vault-btn--allocate"
matTooltip="{{ 'tooltips.Allocate Cash' | translate }}"
[routerLink]="[cashier.id, 'allocate']"
*mifosxHasPermission="'ALLOCATECASHIER_TELLER'"
>
<i class="fas fa-arrow-up"></i>
</a>
<a
class="vault-btn vault-btn--settle"
matTooltip="{{ 'tooltips.Settle Cash' | translate }}"
[routerLink]="[cashier.id, 'settle']"
*mifosxHasPermission="'SETTLECASHIER_TELLER'"
>
<i class="fas fa-arrow-down"></i>
</a>
<a
class="vault-btn vault-btn--view"
matTooltip="{{ 'tooltips.View Transactions' | translate }}"
[routerLink]="[cashier.id, 'transactions']"
*mifosxHasPermission="'ALLOCATECASHIER_TELLER'"
>
<i class="fas fa-eye"></i>
</a>
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="table-row" [routerLink]="[row.id]"></tr>
</table>
@@ -67,8 +67,7 @@ export class CashiersComponent implements OnInit {
displayedColumns: string[] = [
'period',
'staffName',
'isFullDay',
'vaultActions'
'isFullDay'
];
/** Data source for cashiers table. */
dataSource: MatTableDataSource<any>;
@@ -20,7 +20,7 @@
<mat-label>{{ 'labels.inputs.Filter' | translate }}</mat-label>
<input matInput (keyup)="applyFilter($event.target.value)" />
</mat-form-field>
<div class="table-buttons">
<div class="table-buttons" *ngIf="!tellersData || tellersData.length === 0">
<button
mat-flat-button
color="primary"
@@ -78,7 +78,5 @@
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" [routerLink]="[row.id]" class="table-row"></tr>
</table>
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" showFirstLastButtons></mat-paginator>
</div>
</div>
@@ -8,7 +8,6 @@
/** Angular Imports */
import { Component, OnInit, ViewChild, inject } from '@angular/core';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort, MatSortHeader } from '@angular/material/sort';
import {
MatTableDataSource,
@@ -55,7 +54,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatHeaderRow,
MatRowDef,
MatRow,
MatPaginator,
DateFormatPipe
]
})
@@ -75,8 +73,6 @@ export class TellersComponent implements OnInit {
/** Data source for tellers table. */
dataSource: MatTableDataSource<any>;
/** Paginator for tellers table. */
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
/** Sorter for tellers table. */
@ViewChild(MatSort, { static: true }) sort: MatSort;
@@ -110,7 +106,6 @@ export class TellersComponent implements OnInit {
*/
setTellers() {
this.dataSource = new MatTableDataSource(this.tellersData);
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
}
}
+1
View File
@@ -30,6 +30,7 @@ export class StatusLookupPipe implements PipeTransform {
'savingsAccountStatusType.activeInactive': 'status-active-overdue',
'savingsAccountStatusType.activeDormant': 'status-active-overdue',
'savingsAccountStatusType.matured': 'status-matured',
'savingsAccountStatusType.terminated': 'status-deleted',
'shareAccountStatusType.submitted.and.pending.approval': 'status-pending',
'shareAccountStatusType.approved': 'status-approved',
'shareAccountStatusType.active': 'status-active',
@@ -135,12 +135,19 @@
<button
type="button"
class="pl-action-btn pl-action-btn--edit"
[routerLink]="[p.id, 'edit']"
[class.pl-action-btn--disabled]="p.isPendingApproval"
[disabled]="p.isPendingApproval"
[routerLink]="p.isPendingApproval ? null : [p.id, 'edit']"
(click)="$event.stopPropagation()"
matTooltip="Edit this product"
[matTooltip]="
p.isPendingApproval
? 'This product is locked because an edit is pending approval'
: 'Edit this product'
"
*mifosxHasPermission="'UPDATE_CURRENTPRODUCT'"
>
<fa-icon icon="edit"></fa-icon><span>Edit</span>
<fa-icon [icon]="p.isPendingApproval ? 'lock' : 'edit'"></fa-icon>
<span>{{ p.isPendingApproval ? 'Locked' : 'Edit' }}</span>
</button>
</td>
</ng-container>
@@ -274,7 +274,8 @@ export class EditFixedDepositProductComponent {
...this.fixedDepositProduct,
charts: this.fixedDepositProductCharts,
charges: (this.fixedDepositProduct.charges ?? []).map((charge: any) => ({ id: charge.id })),
locale: this.settingsService.language.code
locale: this.settingsService.language.code,
dateFormat: this.settingsService.dateFormat
});
delete fixedDepositProduct.advancedAccountingRules;
this.productsService
@@ -42,7 +42,9 @@ export class FixedDepositProductCurrencyStepComponent implements OnInit {
}
ngOnInit() {
this.currencyData = this.fixedDepositProductsTemplate.currencyOptions;
this.currencyData = this.fixedDepositProductsTemplate.currencyOptions.filter(
(c: { code: string }) => c.code === 'INR'
);
this.fixedDepositProductCurrencyForm.patchValue({
currencyCode: this.fixedDepositProductsTemplate.currency?.code || this.currencyData?.[0]?.code || '',
@@ -137,12 +137,19 @@
<button
type="button"
class="pl-action-btn pl-action-btn--edit"
[routerLink]="[p.id, 'edit']"
[class.pl-action-btn--disabled]="p.isPendingApproval"
[disabled]="p.isPendingApproval"
[routerLink]="p.isPendingApproval ? null : [p.id, 'edit']"
(click)="$event.stopPropagation()"
matTooltip="Edit this product"
[matTooltip]="
p.isPendingApproval
? 'This product is locked because an edit is pending approval'
: 'Edit this product'
"
*mifosxHasPermission="'UPDATE_FIXEDDEPOSITPRODUCT'"
>
<fa-icon icon="edit"></fa-icon><span>Edit</span>
<fa-icon [icon]="p.isPendingApproval ? 'lock' : 'edit'"></fa-icon>
<span>{{ p.isPendingApproval ? 'Locked' : 'Edit' }}</span>
</button>
</td>
</ng-container>
+100
View File
@@ -74,6 +74,26 @@ export class ProductsService {
return this.http.put(`/savingsproducts/${savingProductId}`, savingProduct);
}
/**
* Submitting a savings product update (above) puts it in the pending-approval temp table instead of
* applying it directly. These calls drive the Approvals -> Pending Savings Product Edits screen.
*/
getPendingSavingsProductEdits(): Observable<any> {
return this.http.get('/savingsproducts/pending');
}
getPendingSavingsProductEdit(savingsProductId: string): Observable<any> {
return this.http.get(`/savingsproducts/${savingsProductId}/pending`);
}
approveSavingsProductEdit(savingsProductId: string): Observable<any> {
return this.http.post(`/savingsproducts/${savingsProductId}/approve`, {});
}
rejectSavingsProductEdit(savingsProductId: string, rejectReason: string): Observable<any> {
return this.http.post(`/savingsproducts/${savingsProductId}/reject`, { rejectReason });
}
/**
* @returns {Observable<any>} Current products data
*/
@@ -102,6 +122,26 @@ export class ProductsService {
return this.http.delete(`/currentproducts/${currentProductId}`);
}
/**
* Submitting a current product update (above) puts it in the pending-approval temp table instead of
* applying it directly. These calls drive the Approvals -> Pending Product Edits screen.
*/
getPendingCurrentProductEdits(): Observable<any> {
return this.http.get('/currentproducts/pending');
}
getPendingCurrentProductEdit(currentProductId: string): Observable<any> {
return this.http.get(`/currentproducts/${currentProductId}/pending`);
}
approveCurrentProductEdit(currentProductId: string): Observable<any> {
return this.http.post(`/currentproducts/${currentProductId}/approve`, {});
}
rejectCurrentProductEdit(currentProductId: string, rejectReason: string): Observable<any> {
return this.http.post(`/currentproducts/${currentProductId}/reject`, { rejectReason });
}
/**
* @returns {Observable<any>} Share products data
*/
@@ -126,6 +166,26 @@ export class ProductsService {
return this.http.put(`/products/share/${shareProductId}`, shareProduct);
}
/**
* Submitting a share product update (above) puts it in the pending-approval temp table instead of
* applying it directly. These calls drive the Approvals -> Pending Product Edits screen.
*/
getPendingShareProductEdits(): Observable<any> {
return this.http.get('/products/share/pending');
}
getPendingShareProductEdit(shareProductId: string): Observable<any> {
return this.http.get(`/products/share/${shareProductId}/pending`);
}
approveShareProductEdit(shareProductId: string): Observable<any> {
return this.http.post(`/products/share/${shareProductId}/approve`, {});
}
rejectShareProductEdit(shareProductId: string, rejectReason: string): Observable<any> {
return this.http.post(`/products/share/${shareProductId}/reject`, { rejectReason });
}
getDividends(shareProductId: string): Observable<any> {
return this.http.get(`/shareproduct/${shareProductId}/dividend`);
}
@@ -251,6 +311,26 @@ export class ProductsService {
return this.http.put(`/fixeddepositproducts/${fixedDepositProductId}`, fixedDepositProduct);
}
/**
* Submitting a fixed deposit product update (above) puts it in the pending-approval temp table instead of
* applying it directly. These calls drive the Approvals -> Pending Product Edits screen.
*/
getPendingFixedDepositProductEdits(): Observable<any> {
return this.http.get('/fixeddepositproducts/pending');
}
getPendingFixedDepositProductEdit(fixedDepositProductId: string): Observable<any> {
return this.http.get(`/fixeddepositproducts/${fixedDepositProductId}/pending`);
}
approveFixedDepositProductEdit(fixedDepositProductId: string): Observable<any> {
return this.http.post(`/fixeddepositproducts/${fixedDepositProductId}/approve`, {});
}
rejectFixedDepositProductEdit(fixedDepositProductId: string, rejectReason: string): Observable<any> {
return this.http.post(`/fixeddepositproducts/${fixedDepositProductId}/reject`, { rejectReason });
}
/**
* @returns {Observable<any>} Tax Components data
*/
@@ -526,6 +606,26 @@ export class ProductsService {
return this.http.put(`/recurringdepositproducts/${recurringDepositProductId}`, recurringDepositProduct);
}
/**
* Submitting a recurring deposit product update (above) puts it in the pending-approval temp table instead of
* applying it directly. These calls drive the Approvals -> Pending Product Edits screen.
*/
getPendingRecurringDepositProductEdits(): Observable<any> {
return this.http.get('/recurringdepositproducts/pending');
}
getPendingRecurringDepositProductEdit(recurringDepositProductId: string): Observable<any> {
return this.http.get(`/recurringdepositproducts/${recurringDepositProductId}/pending`);
}
approveRecurringDepositProductEdit(recurringDepositProductId: string): Observable<any> {
return this.http.post(`/recurringdepositproducts/${recurringDepositProductId}/approve`, {});
}
rejectRecurringDepositProductEdit(recurringDepositProductId: string, rejectReason: string): Observable<any> {
return this.http.post(`/recurringdepositproducts/${recurringDepositProductId}/reject`, { rejectReason });
}
/** Returns the active CBS base interest-rate version(s). */
getAllRdInterestRates(): Observable<any> {
return this.getRdBaseInterestRates();
@@ -176,7 +176,11 @@ export class EditRecurringDepositProductComponent {
...this.recurringDepositProductSettingsStep.recurringDepositProductSettings,
...this.recurringDepositProductChargesStep.recurringDepositProductCharges,
...this.recurringDepositProductAccountingStep.recurringDepositProductAccounting,
charts: this.recurringDepositProductCharts,
// charts intentionally omitted here - the backend no longer requires a charts payload for RD
// updates once rdSlabs is present, and this getter's chart data comes from the shared CBS
// base-rate template (not this product's own chart), which caused spurious
// "chart.overlapping.from.and.end.dates" errors when a product's real chart had different
// dates. Still included in recurringDepositProductPreview below for the read-only display tab.
rdSlabs: this.activeBaseSlabs
};
}
@@ -240,9 +244,9 @@ export class EditRecurringDepositProductComponent {
submit() {
const recurringDepositProduct: any = this.pruneEmptyValues({
...this.recurringDepositProduct,
charts: this.recurringDepositProductCharts,
charges: (this.recurringDepositProduct.charges ?? []).map((charge: any) => ({ id: charge.id })),
locale: this.settingsService.language.code
locale: this.settingsService.language.code,
dateFormat: this.settingsService.dateFormat
});
if (!recurringDepositProduct.description) {
recurringDepositProduct.description = '';
@@ -91,7 +91,11 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
otherGlMappingsMap: this.recurringDepositProductsTemplate?.otherGlMappingsMap
};
const accountingMappingOptions = this.recurringDepositProductsTemplate?.accountingMappingOptions || {};
const accountingRuleId = 2; // Always Cash
// Defaults to Cash (2) for a brand-new product (no existing accountingRule yet), but preserves
// the product's own current accounting rule when editing - previously this was hardcoded to 2
// unconditionally, which silently forced every edited product onto Cash-based accounting (and
// its required GL mappings) even for products that were and should remain "None".
const accountingRuleId = this.recurringDepositProductsTemplate?.accountingRule?.id ?? 2;
this.assetAccountOptions = accountingMappingOptions.assetAccountOptions || [];
this.liabilityAccountOptions = accountingMappingOptions.liabilityAccountOptions || [];
@@ -42,7 +42,9 @@ export class RecurringDepositProductCurrencyStepComponent implements OnInit {
}
ngOnInit() {
this.currencyData = this.recurringDepositProductsTemplate.currencyOptions;
this.currencyData = this.recurringDepositProductsTemplate.currencyOptions.filter(
(c: { code: string }) => c.code === 'INR'
);
this.recurringDepositProductCurrencyForm.patchValue({
currencyCode: this.recurringDepositProductsTemplate.currency?.code || this.currencyData?.[0]?.code || '',
@@ -147,12 +147,19 @@
<button
type="button"
class="pl-action-btn pl-action-btn--edit"
[routerLink]="[p.id, 'edit']"
[class.pl-action-btn--disabled]="p.isPendingApproval"
[disabled]="p.isPendingApproval"
[routerLink]="p.isPendingApproval ? null : [p.id, 'edit']"
(click)="$event.stopPropagation()"
matTooltip="Edit this product"
[matTooltip]="
p.isPendingApproval
? 'This product is locked because an edit is pending approval'
: 'Edit this product'
"
*mifosxHasPermission="'UPDATE_RECURRINGDEPOSITPRODUCT'"
>
<fa-icon icon="edit"></fa-icon><span>Edit</span>
<fa-icon [icon]="p.isPendingApproval ? 'lock' : 'edit'"></fa-icon>
<span>{{ p.isPendingApproval ? 'Locked' : 'Edit' }}</span>
</button>
</td>
</ng-container>
@@ -44,7 +44,7 @@ export class SavingProductCurrencyStepComponent implements OnInit {
}
ngOnInit() {
this.currencyData = this.savingProductsTemplate.currencyOptions;
this.currencyData = this.savingProductsTemplate.currencyOptions.filter((c: { code: string }) => c.code === 'INR');
this.savingProductCurrencyForm.patchValue({
currencyCode: this.savingProductsTemplate.currency?.code || this.currencyData?.[0]?.code || '',
@@ -145,13 +145,19 @@
<button
type="button"
class="sp-action-btn sp-action-btn--edit"
[routerLink]="[p.id, 'edit']"
[class.sp-action-btn--disabled]="p.isPendingApproval"
[disabled]="p.isPendingApproval"
[routerLink]="p.isPendingApproval ? null : [p.id, 'edit']"
(click)="$event.stopPropagation()"
matTooltip="Edit this product"
[matTooltip]="
p.isPendingApproval
? 'This product is locked because an edit is pending approval'
: 'Edit this product'
"
*mifosxHasPermission="'UPDATE_SAVINGSPRODUCT'"
>
<fa-icon icon="edit"></fa-icon>
<span>Edit</span>
<fa-icon [icon]="p.isPendingApproval ? 'lock' : 'edit'"></fa-icon>
<span>{{ p.isPendingApproval ? 'Locked' : 'Edit' }}</span>
</button>
</td>
</ng-container>
@@ -481,6 +481,14 @@
border-color: var(--sp-amber);
}
}
&--disabled,
&--disabled:hover {
background: #f1f5f9;
color: #94a3b8;
border-color: #e2e8f0;
cursor: not-allowed;
}
}
/* No match strip */
@@ -36,7 +36,7 @@ export class ShareProductCurrencyStepComponent implements OnInit {
}
ngOnInit() {
this.currencyData = this.shareProductsTemplate.currencyOptions;
this.currencyData = this.shareProductsTemplate.currencyOptions.filter((c: { code: string }) => c.code === 'INR');
this.shareProductCurrencyForm.patchValue({
currencyCode: this.shareProductsTemplate.currency?.code || this.currencyData[0].code,
@@ -135,12 +135,19 @@
<button
type="button"
class="pl-action-btn pl-action-btn--edit"
[routerLink]="[p.id, 'edit']"
[class.pl-action-btn--disabled]="p.isPendingApproval"
[disabled]="p.isPendingApproval"
[routerLink]="p.isPendingApproval ? null : [p.id, 'edit']"
(click)="$event.stopPropagation()"
matTooltip="Edit this product"
[matTooltip]="
p.isPendingApproval
? 'This product is locked because an edit is pending approval'
: 'Edit this product'
"
*mifosxHasPermission="'UPDATE_SHAREPRODUCT'"
>
<fa-icon icon="edit"></fa-icon><span>Edit</span>
<fa-icon [icon]="p.isPendingApproval ? 'lock' : 'edit'"></fa-icon>
<span>{{ p.isPendingApproval ? 'Locked' : 'Edit' }}</span>
</button>
</td>
</ng-container>
@@ -28,6 +28,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router';
/** Custom Services */
import { PopoverService } from '../../configuration-wizard/popover/popover.service';
import { ConfigurationWizardService } from '../../configuration-wizard/configuration-wizard.service';
import { ProductsService } from 'app/products/products.service';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatTooltip } from '@angular/material/tooltip';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -60,6 +61,7 @@ export class ShareProductsComponent implements OnInit, AfterViewInit {
private router = inject(Router);
private configurationWizardService = inject(ConfigurationWizardService);
private popoverService = inject(PopoverService);
private productsService = inject(ProductsService);
shareProductsData: any[] = [];
displayedColumns: string[] = [
@@ -101,6 +103,18 @@ export class ShareProductsComponent implements OnInit, AfterViewInit {
constructor() {
this.route.data.subscribe((data: { shareProducts: any }) => {
this.shareProductsData = data.shareProducts.pageItems;
// ShareProductData is immutable on the backend, so isPendingApproval isn't embedded per-row like the
// other product lists - cross-reference against the pending-edits list instead.
this.productsService.getPendingShareProductEdits().subscribe((pendingEdits: any[]) => {
const pendingProductIds = new Set((pendingEdits || []).map((e) => e.savingsProductId));
this.shareProductsData = this.shareProductsData.map((p) => ({
...p,
isPendingApproval: pendingProductIds.has(p.id)
}));
this.dataSource = new MatTableDataSource(this.shareProductsData);
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
});
});
}
@@ -117,6 +117,7 @@
[savingsAccount]="savingsAccount"
[savingsAccountDigital]="savingsAccountDigital"
[savingsAccountNominees]="savingsAccountCurrencyStep?.nominees ?? []"
[submitting]="submitting"
(submitEvent)="submit()"
>
</mifosx-savings-account-preview-step>
@@ -64,6 +64,8 @@ export class CreateSavingsAccountComponent {
selectedTabIndex = 0;
/** Total wizard tabs — Product, Currency, Digital, Preview. */
private readonly totalTabs = 4;
/** Guards against duplicate submissions from a double-click while the create request is in flight. */
submitting = false;
@ViewChild(SavingsAccountDetailsStepComponent, { static: true })
savingsAccountDetailsStep: SavingsAccountDetailsStepComponent;
@@ -116,6 +118,9 @@ export class CreateSavingsAccountComponent {
}
submit() {
if (this.submitting) {
return;
}
const detailsStep = this.savingsAccountDetailsStep;
if (detailsStep.selectedAccountStatus === 'JOINT' && detailsStep.totalJointSharePercentage !== 100) {
this.alertService.alert({
@@ -177,6 +182,7 @@ export class CreateSavingsAccountComponent {
}));
}
this.submitting = true;
this.savingsService.createSavingsAccount(savingsAccount).subscribe({
next: (response: any) => {
this.router.navigate(
@@ -188,6 +194,7 @@ export class CreateSavingsAccountComponent {
);
},
error: (error: any) => {
this.submitting = false;
const errorCode =
error?.error?.errors?.[0]?.userMessageGlobalisationCode ?? error?.error?.userMessageGlobalisationCode;
if (errorCode === 'error.msg.savings.account.duplicate.product.for.client') {
@@ -1,4 +1,4 @@
<!--
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
@@ -115,12 +115,13 @@
<input matInput [value]="productTemplate.introducerRequired ? 'Yes' : 'No'" readonly />
</mat-form-field>
<div class="sad-field sad-info-row">
<span class="sad-info-label">Share Linking Enabled</span>
<span class="sad-badge" [class.sad-badge--yes]="productTemplate.shareLinkingEnabled">
{{ productTemplate.shareLinkingEnabled ? 'Yes' : 'No' }}
</span>
</div>
<mat-form-field appearance="outline" class="sad-field">
<mat-label>Share Linking Enabled</mat-label>
<mat-select [(ngModel)]="shareLinkingEnabled" [ngModelOptions]="{ standalone: true }">
<mat-option [value]="true">Yes</mat-option>
<mat-option [value]="false">No</mat-option>
</mat-select>
</mat-form-field>
</div>
</div>
@@ -70,6 +70,7 @@ export class SavingsAccountDetailsStepComponent implements OnInit {
@Output() savingsAccountProductTemplate = new EventEmitter();
productTemplate: any = null;
shareLinkingEnabled = false;
accountTypeOptions: string[] = [];
customerSegmentOptions: string[] = [];
selectedAccountType: string | null = null;
@@ -333,6 +334,7 @@ export class SavingsAccountDetailsStepComponent implements OnInit {
.getSavingsAccountTemplate(entityId, productId, this.savingsAccountTemplate.groupId ? true : false)
.subscribe((response: any) => {
this.productTemplate = response;
this.shareLinkingEnabled = !!response.shareLinkingEnabled;
this.accountTypeOptions = this.extractOptions(response.accountTypeOptions, response.accountType);
this.customerSegmentOptions = this.extractOptions(response.customerSegmentOptions, response.customerSegment);
this.selectedAccountType = response.accountType ?? null;
@@ -432,6 +434,9 @@ export class SavingsAccountDetailsStepComponent implements OnInit {
this.introducerName = ea.introducerName ?? null;
this.introducerCif = ea.introducerCifNumber ?? null;
}
if (ea.shareLinkingEnabled !== undefined) {
this.shareLinkingEnabled = !!ea.shareLinkingEnabled;
}
this.cdr.detectChanges();
}
@@ -650,6 +655,8 @@ export class SavingsAccountDetailsStepComponent implements OnInit {
value.minorDateOfBirth = this.minorDob ? this.dateUtils.formatDate(this.minorDob, 'dd MMMM yyyy') : null;
}
value.shareLinkingEnabled = this.shareLinkingEnabled;
return value;
}
}

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