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); if (params.asOnDate) httpParams = httpParams.set('asOnDate', params.asOnDate);
return this.http.get('/recurringdepositaccounts', { params: httpParams }); 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 { GroupsModule } from './groups/groups.module';
import { CentersModule } from './centers/centers.module'; import { CentersModule } from './centers/centers.module';
import { AccountingModule } from './accounting/accounting.module'; import { AccountingModule } from './accounting/accounting.module';
import { ChequeBookModule } from './chequebook/chequebook.module';
import { SystemModule } from './system/system.module'; import { SystemModule } from './system/system.module';
import { ProductsModule } from './products/products.module'; import { ProductsModule } from './products/products.module';
import { OrganizationModule } from './organization/organization.module'; import { OrganizationModule } from './organization/organization.module';
@@ -142,6 +143,7 @@ export function HttpLoaderFactory(http: HttpClient) {
GroupsModule, GroupsModule,
CentersModule, CentersModule,
AccountingModule, AccountingModule,
ChequeBookModule,
SystemModule, SystemModule,
ProductsModule, ProductsModule,
OrganizationModule, OrganizationModule,
@@ -45,14 +45,21 @@
<input matInput type="date" [value]="settlementDate" (change)="settlementDate = $any($event.target).value" /> <input matInput type="date" [value]="settlementDate" (change)="settlementDate = $any($event.target).value" />
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic"> @if (tellers.length === 1) {
<mat-label>Teller</mat-label> <mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-select [value]="supervisorTellerId" (selectionChange)="onSupervisorTellerSelect($event.value)"> <mat-label>Teller</mat-label>
@for (t of tellers; track t.id) { <input matInput type="text" [value]="tellers[0].name" readonly />
<mat-option [value]="t.id">{{ t.name }}</mat-option> </mat-form-field>
} } @else {
</mat-select> <mat-form-field appearance="outline" subscriptSizing="dynamic">
</mat-form-field> <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-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Cashier</mat-label> <mat-label>Cashier</mat-label>
@@ -140,17 +147,24 @@
} }
</div> </div>
<mat-form-field appearance="outline" subscriptSizing="dynamic"> @if (tellers.length === 1) {
<mat-label>Teller / Vault (To) *</mat-label> <mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)"> <mat-label>Teller / Vault (To) *</mat-label>
@for (t of tellers; track t.id) { <input matInput type="text" [value]="toParty" readonly />
<mat-option [value]="t.id">{{ t.name }}</mat-option> </mat-form-field>
} } @else {
@if (tellers.length === 0) { <mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-option [value]="null" disabled>No tellers configured</mat-option> <mat-label>Teller / Vault (To) *</mat-label>
} <mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
</mat-select> @for (t of tellers; track t.id) {
</mat-form-field> <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-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Settlement Date</mat-label> <mat-label>Settlement Date</mat-label>
@@ -97,6 +97,10 @@ export class CashSettlementComponent implements OnInit {
.pipe(catchError(() => of([] as any[]))) .pipe(catchError(() => of([] as any[])))
.subscribe((list: any[]) => { .subscribe((list: any[]) => {
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name })); 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(); this.loadData();
} }
@@ -17,17 +17,24 @@
</header> </header>
<div class="pg-toolbar pg-toolbar--mat"> <div class="pg-toolbar pg-toolbar--mat">
<mat-form-field appearance="outline" subscriptSizing="dynamic"> @if (tellers.length === 1) {
<mat-label>From (Teller / Head)</mat-label> <mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-select [value]="fromTellerId" (selectionChange)="onTellerSelect($event.value)"> <mat-label>From (Teller / Head)</mat-label>
@for (t of tellers; track t.id) { <input matInput type="text" [value]="fromParty" readonly />
<mat-option [value]="t.id">{{ t.name }}</mat-option> </mat-form-field>
} } @else {
@if (tellers.length === 0) { <mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-option [value]="null" disabled>No tellers configured</mat-option> <mat-label>From (Teller / Head)</mat-label>
} <mat-select [value]="fromTellerId" (selectionChange)="onTellerSelect($event.value)">
</mat-select> @for (t of tellers; track t.id) {
</mat-form-field> <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-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>To (Cashier)</mat-label> <mat-label>To (Cashier)</mat-label>
@@ -95,6 +95,9 @@ export class CashTransferComponent implements OnInit {
.pipe(catchError(() => of([] as any[]))) .pipe(catchError(() => of([] as any[])))
.subscribe((list) => { .subscribe((list) => {
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name })); 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) => { .subscribe((r: any) => {
this.cashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName })); this.cashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName }));
this.loadingCashiers = false; 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"> <div class="pg-toolbar pg-toolbar--mat">
<!-- Teller + Cashier filters — hidden for cashier role (auto-loads own position) --> <!-- Teller + Cashier filters — hidden for cashier role (auto-loads own position) -->
@if (!isCashierMode) { @if (!isCashierMode) {
<mat-form-field appearance="outline" subscriptSizing="dynamic"> @if (tellers.length === 1) {
<mat-label>Teller (optional)</mat-label> <mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)"> <mat-label>Teller</mat-label>
<mat-option [value]="null">All Tellers</mat-option> <input matInput type="text" [value]="tellers[0].name" readonly />
@for (t of tellers; track t.id) { </mat-form-field>
<mat-option [value]="t.id">{{ t.name }}</mat-option> } @else {
} <mat-form-field appearance="outline" subscriptSizing="dynamic">
</mat-select> <mat-label>Teller (optional)</mat-label>
</mat-form-field> <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-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Cashier (optional)</mat-label> <mat-label>Cashier (optional)</mat-label>
@@ -85,6 +85,9 @@ export class CashierPositionComponent implements OnInit {
.pipe(catchError(() => of([] as any[]))) .pipe(catchError(() => of([] as any[])))
.subscribe((list) => { .subscribe((list) => {
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name })); 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: [] }))) .pipe(catchError(() => of({ cashiers: [] })))
.subscribe((r: any) => { .subscribe((r: any) => {
this.cashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName })); 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' } 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', path: 'photo-signature',
loadComponent: () => loadComponent: () =>
@@ -17,17 +17,24 @@
</header> </header>
<div class="pg-toolbar pg-toolbar--mat"> <div class="pg-toolbar pg-toolbar--mat">
<mat-form-field appearance="outline" subscriptSizing="dynamic"> @if (tellers.length === 1) {
<mat-label>Teller</mat-label> <mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)"> <mat-label>Teller</mat-label>
@for (t of tellers; track t.id) { <input matInput type="text" [value]="tellers[0].name" readonly />
<mat-option [value]="t.id">{{ t.name }}</mat-option> </mat-form-field>
} } @else {
@if (tellers.length === 0) { <mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-option [value]="null" disabled>No tellers configured</mat-option> <mat-label>Teller</mat-label>
} <mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
</mat-select> @for (t of tellers; track t.id) {
</mat-form-field> <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-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>From (Cashier)</mat-label> <mat-label>From (Cashier)</mat-label>
@@ -102,6 +102,9 @@ export class InterCashierTransferComponent implements OnInit {
.pipe(catchError(() => of([] as any[]))) .pipe(catchError(() => of([] as any[])))
.subscribe((list) => { .subscribe((list) => {
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name })); 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"> <mat-tab label="New Reconciliation">
<div class="pg-tab-body"> <div class="pg-tab-body">
<div class="pg-toolbar pg-toolbar--mat"> <div class="pg-toolbar pg-toolbar--mat">
<mat-form-field appearance="outline" subscriptSizing="dynamic"> @if (tellers.length === 1) {
<mat-label>Teller</mat-label> <mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)"> <mat-label>Teller</mat-label>
@for (t of tellers; track t.id) { <input matInput type="text" [value]="tellers[0].name" readonly />
<mat-option [value]="t.id">{{ t.name }}</mat-option> </mat-form-field>
} } @else {
</mat-select> <mat-form-field appearance="outline" subscriptSizing="dynamic">
</mat-form-field> <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-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Cashier</mat-label> <mat-label>Cashier</mat-label>
@@ -92,6 +92,9 @@ export class ReconciliationComponent implements OnInit {
.pipe(catchError(() => of([] as any[]))) .pipe(catchError(() => of([] as any[])))
.subscribe((list) => { .subscribe((list) => {
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name })); 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(); this.loadHistory();
} }
@@ -108,6 +111,9 @@ export class ReconciliationComponent implements OnInit {
.pipe(catchError(() => of({ cashiers: [] }))) .pipe(catchError(() => of({ cashiers: [] })))
.subscribe((r: any) => { .subscribe((r: any) => {
this.cashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName })); 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>
<div class="te-balance-row"> <div class="te-balance-row">
<span class="te-bal-num">4.</span><span class="te-bal-label">Freeze Amount</span> <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>
<div class="te-balance-row"> <div class="te-balance-row">
<span class="te-bal-num">5.</span><span class="te-bal-label">Withdrawable Amt.</span> <span class="te-bal-num">5.</span><span class="te-bal-label">Withdrawable Amt.</span>
@@ -746,7 +746,7 @@
</div> </div>
<div class="te-bal-item"> <div class="te-bal-item">
<span class="te-bal-item__label">Transfer Hold</span> <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>
<div class="te-bal-item"> <div class="te-bal-item">
<span class="te-bal-item__label">Clearing Hold</span> <span class="te-bal-item__label">Clearing Hold</span>
@@ -862,7 +862,7 @@
</div> </div>
<div class="te-acs-b"> <div class="te-acs-b">
<span class="te-acs-b-l">Tr. Hold</span> <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>
<div class="te-acs-b"> <div class="te-acs-b">
<span class="te-acs-b-l">Clr. Hold</span> <span class="te-acs-b-l">Clr. Hold</span>
@@ -1999,12 +1999,29 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
} }
get freezeAmount(): number { 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 { get withdrawableAmount(): number {
const avail = this.accountDetails?.summary?.availableBalance ?? this.currentBalance; 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 { get currencySymbol(): string {
@@ -2730,30 +2747,38 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
} }
if (isWithdrawal) { if (isWithdrawal) {
// 3. Balance check // Token-based cashier disbursement: the manager approval already validated the balance
const withdrawable = this.withdrawableAmount; // and posted the real withdrawal (money already left the account). Re-running the balance/
if (amount > withdrawable) { // min-balance checks here would compare the post-deduction balance against the same
this.showAlert('error', 'Insufficient Balance', [ // already-approved amount again, which fails for almost any real withdrawal.
`Withdrawable Amount: ₹${withdrawable.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`, const isTokenDisbursement = !this.isClerk && !!this.activeWithdrawalRequest;
`Requested Amount: ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
'Please reduce the withdrawal amount.'
]);
return;
}
// 4. Min balance if (!isTokenDisbursement) {
const enforceMin = !!this.accountDetails.enforceMinRequiredBalance; // 3. Balance check
const minBalance = Number(this.accountDetails.minRequiredBalance ?? 0); const withdrawable = this.withdrawableAmount;
if (enforceMin && minBalance > 0) { if (amount > withdrawable) {
const balanceAfter = this.currentBalance - amount; this.showAlert('error', 'Insufficient Balance', [
if (balanceAfter < minBalance) { `Withdrawable Amount: ₹${withdrawable.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
this.showAlert('error', 'Minimum Balance Violation', [ `Requested Amount: ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
`Required minimum balance: ₹${minBalance.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
`Balance after withdrawal: ₹${balanceAfter.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
'Please reduce the withdrawal amount.' 'Please reduce the withdrawal amount.'
]); ]);
return; 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 // 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 { .af-page {
background: #f8fafc;
min-height: calc(100vh - 68px); min-height: calc(100vh - 68px);
background: #f0f4f8;
padding: 24px 28px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 20px; gap: 12px;
} }
// ── Hero ────────────────────────────────────────────────────────────────────── // ── Header ──────────────────────────────────────────────────────────────────────
.af-hero { .af-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 18px; justify-content: space-between;
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 40%, #2563eb 100%); padding: 12px 20px;
border-radius: 16px; background: #fff;
padding: 22px 28px; border-bottom: 1px solid #e2e8f0;
color: #fff;
box-shadow: 0 8px 32px rgb(37 99 235 / 35%);
position: relative;
overflow: hidden;
&::before { &__left {
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;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; gap: 10px;
flex-shrink: 0;
backdrop-filter: blur(8px);
} }
&__text { &__icon {
flex: 1; color: #0f4f3c;
flex-shrink: 0;
} }
&__title { &__title {
font-size: 22px; font-size: 15px;
font-weight: 800; font-weight: 700;
letter-spacing: -0.01em; color: #0d1b2a;
margin: 0 0 5px;
} }
&__stats { &__sub {
display: flex; font-size: 12px;
gap: 14px; color: #64748b;
align-items: center; margin-top: 1px;
} }
&__stat { &__stat {
display: flex; font-weight: 700;
align-items: center;
gap: 5px;
font-size: 12.5px;
color: rgb(255 255 255 / 75%);
strong { &--green {
color: #fff; color: #16a34a;
font-weight: 700;
} }
&--green strong { &--red {
color: #86efac; 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; display: inline-flex;
align-items: center; align-items: center;
gap: 7px; gap: 6px;
padding: 10px 20px; border-radius: 7px;
background: rgb(255 255 255 / 12%); font-family: inherit;
border: 1px solid rgb(255 255 255 / 25%);
border-radius: 10px;
color: #fff;
font-size: 12.5px;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: all 0.2s; border: 1px solid #e2e8f0;
white-space: nowrap; transition: background 0.15s;
backdrop-filter: blur(8px); height: 30px;
padding: 0 12px;
font-size: 12.5px;
background: #fff;
color: #0d1b2a;
&:hover { &:hover {
background: rgb(255 255 255 / 22%); background: #f0fdf4;
transform: translateX(-2px); border-color: #0f4f3c;
color: #0f4f3c;
} }
} }
@@ -132,12 +95,12 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 14px; gap: 10px;
padding: 60px 20px; padding: 40px 20px;
&__icon { &__icon {
width: 88px; width: 64px;
height: 88px; height: 64px;
background: #e2e8f0; background: #e2e8f0;
border: 1px solid #cbd5e1; border: 1px solid #cbd5e1;
border-radius: 50%; border-radius: 50%;
@@ -147,80 +110,87 @@
} }
&__title { &__title {
font-size: 18px; font-size: 15px;
font-weight: 700; font-weight: 700;
color: #1e293b; color: #1e293b;
margin: 6px 0 0; margin: 4px 0 0;
} }
&__sub { &__sub {
font-size: 13.5px; font-size: 12.5px;
color: #64748b; color: #64748b;
text-align: center; text-align: center;
margin: 0; margin: 0;
} }
&__btn { &__btn {
margin-top: 10px; margin-top: 6px;
padding: 10px 24px; padding: 8px 20px;
background: #2563eb; background: #0f4f3c;
border: none; border: none;
border-radius: 10px; border-radius: 8px;
color: #fff; color: #fff;
font-size: 13px; font-size: 12.5px;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: background 0.15s; transition: background 0.15s;
&:hover { &:hover {
background: #1d4ed8; background: #0a3d2e;
} }
} }
} }
// ── Body ────────────────────────────────────────────────────────────────────────
.af-body {
display: flex;
flex-direction: column;
gap: 12px;
padding: 0 20px 16px;
}
// ── Progress card ────────────────────────────────────────────────────────────── // ── Progress card ──────────────────────────────────────────────────────────────
.af-progress-card { .af-progress-card {
background: #fff; background: #fff;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
border-radius: 14px; border-radius: 10px;
padding: 18px 22px; padding: 12px 16px;
box-shadow: 0 1px 4px rgb(0 0 0 / 6%);
&__top { &__top {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 12px; margin-bottom: 8px;
} }
&__label { &__label {
font-size: 11.5px; font-size: 11px;
font-weight: 600; font-weight: 700;
color: #64748b; color: #64748b;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.06em;
} }
&__fraction { &__fraction {
font-size: 13px; font-size: 12.5px;
font-weight: 700; font-weight: 700;
color: #16a34a; color: #0f4f3c;
} }
} }
.af-progress-bar { .af-progress-bar {
height: 10px; height: 7px;
background: #e2e8f0; background: #e2e8f0;
border-radius: 999px; border-radius: 999px;
overflow: hidden; overflow: hidden;
&__fill { &__fill {
height: 100%; height: 100%;
background: linear-gradient(90deg, #16a34a, #22c55e); background: #16a34a;
border-radius: 999px; border-radius: 999px;
transition: width 0.5s ease; transition: width 0.4s ease;
box-shadow: 0 0 8px rgb(34 197 94 / 35%);
} }
} }
@@ -229,65 +199,53 @@
.af-fac-card { .af-fac-card {
background: #fff; background: #fff;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
border-radius: 14px; border-radius: 10px;
overflow: hidden; overflow: hidden;
box-shadow: 0 1px 4px rgb(0 0 0 / 6%);
&__header { &__header {
display: flex; padding: 9px 16px;
align-items: center; background: #f8fafc;
gap: 9px; border-bottom: 1px solid #e2e8f0;
padding: 12px 22px; color: #64748b;
background: #1e293b; font-size: 11px;
color: #94a3b8;
font-size: 11.5px;
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.06em;
} }
} }
.af-pills { .af-pills {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 10px; gap: 7px;
padding: 20px 22px; padding: 12px 16px;
} }
.af-pill { .af-pill {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 5px;
padding: 7px 15px; padding: 4px 11px;
border-radius: 999px; border-radius: 999px;
font-size: 13px; font-size: 12px;
font-weight: 600; font-weight: 600;
border: 1.5px solid; border: 1px solid;
transition:
transform 0.15s,
box-shadow 0.15s;
cursor: default; cursor: default;
&__dot { &__dot {
width: 7px; width: 6px;
height: 7px; height: 6px;
border-radius: 50%; border-radius: 50%;
flex-shrink: 0; flex-shrink: 0;
} }
&--on { &--on {
background: #f0fdf4; background: #f0fdf4;
border-color: #22c55e; border-color: #86efac;
color: #15803d; color: #166534;
.af-pill__dot { .af-pill__dot {
background: #22c55e; 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 { .af-stats-row {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 14px; gap: 10px;
} }
.af-stat-card { .af-stat-card {
border-radius: 14px; border-radius: 10px;
padding: 20px 24px; padding: 10px 14px;
display: flex; display: flex;
align-items: center; align-items: baseline;
gap: 16px; gap: 7px;
border: 1px solid; border: 1px solid;
box-shadow: 0 1px 4px rgb(0 0 0 / 6%); font-size: 12px;
&__icon { &__icon {
width: 44px; font-size: 12px;
height: 44px; font-weight: 700;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 20px;
} }
&__num { &__num {
font-size: 30px; font-size: 17px;
font-weight: 800; font-weight: 800;
line-height: 1; line-height: 1;
display: block;
} }
&__label { &__label {
font-size: 12px; font-size: 11px;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.05em;
display: block;
margin-top: 3px;
opacity: 0.75; opacity: 0.75;
} }
&--green { &--green {
background: linear-gradient(135deg, #f0fdf4, #dcfce7); background: #f0fdf4;
border-color: #86efac; border-color: #86efac;
color: #15803d; color: #166534;
.af-stat-card__icon {
background: #bbf7d0;
}
} }
&--red { &--red {
background: linear-gradient(135deg, #f8fafc, #f1f5f9); background: #f8fafc;
border-color: #cbd5e1; border-color: #e2e8f0;
color: #475569; color: #64748b;
.af-stat-card__icon {
background: #e2e8f0;
}
} }
} }
@@ -18,34 +18,37 @@ import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
styleUrl: './account-facilities.component.scss', styleUrl: './account-facilities.component.scss',
template: ` template: `
<div class="af-page"> <div class="af-page">
<!-- Hero --> <!-- Header -->
<div class="af-hero"> <div class="af-header">
<div class="af-hero__icon-wrap"> <div class="af-header__left">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"> <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" /> <rect x="1" y="4" width="22" height="16" rx="2" />
<line x1="1" y1="10" x2="23" y2="10" /> <line x1="1" y1="10" x2="23" y2="10" />
</svg> </svg>
</div> <div>
<div class="af-hero__text"> <div class="af-header__title">Account Facilities</div>
<h1 class="af-hero__title">Account Facilities</h1> @if (accountLoaded) {
@if (accountLoaded) { <div class="af-header__sub">
<div class="af-hero__stats"> <span class="af-header__stat af-header__stat--green">{{ activeCount }} active</span>
<span class="af-hero__stat af-hero__stat--green"> &middot;
<strong>{{ activeCount }}</strong> Active <span class="af-header__stat af-header__stat--red">{{ facilities.length - activeCount }} inactive</span>
</span> &middot; {{ facilities.length }} total
<span class="af-hero__sep"></span> </div>
<span class="af-hero__stat af-hero__stat--red"> } @else {
<strong>{{ facilities.length - activeCount }}</strong> Inactive <div class="af-header__sub">View all facilities enabled for the loaded account.</div>
</span> }
<span class="af-hero__sep"></span> </div>
<span class="af-hero__stat">
<strong>{{ facilities.length }}</strong> Total
</span>
</div>
}
</div> </div>
@if (!embedded) { @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"> <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" /> <polyline points="15 18 9 12 15 6" />
</svg> </svg>
@@ -58,7 +61,7 @@ import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
<!-- Empty state --> <!-- Empty state -->
<div class="af-empty"> <div class="af-empty">
<div class="af-empty__icon"> <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" /> <rect x="1" y="4" width="22" height="16" rx="2" />
<line x1="1" y1="10" x2="23" y2="10" /> <line x1="1" y1="10" x2="23" y2="10" />
</svg> </svg>
@@ -70,48 +73,40 @@ import { ClerkUiStateService } from '../../cashier/clerk-ui-state.service';
} }
</div> </div>
} @else { } @else {
<!-- Progress bar --> <div class="af-body">
<div class="af-progress-card"> <!-- Progress bar -->
<div class="af-progress-card__top"> <div class="af-progress-card">
<span class="af-progress-card__label">Facility Coverage</span> <div class="af-progress-card__top">
<span class="af-progress-card__fraction">{{ activeCount }} / {{ facilities.length }}</span> <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>
<div class="af-progress-bar">
<div class="af-progress-bar__fill" [style.width.%]="(activeCount / facilities.length) * 100"></div>
</div>
</div>
<!-- Facilities pills --> <!-- Facilities pills -->
<div class="af-fac-card"> <div class="af-fac-card">
<div class="af-fac-card__header"> <div class="af-fac-card__header">All Facilities</div>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <div class="af-pills">
<rect x="1" y="4" width="22" height="16" rx="2" /> @for (f of facilities; track f.label) {
<line x1="1" y1="10" x2="23" y2="10" /> <span class="af-pill" [class.af-pill--on]="f.active" [class.af-pill--off]="!f.active">
</svg> <span class="af-pill__dot"></span>
All Facilities {{ f.label }}
</span>
}
</div>
</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 --> <!-- Stats row -->
<div class="af-stats-row"> <div class="af-stats-row">
<div class="af-stat-card af-stat-card--green"> <div class="af-stat-card af-stat-card--green">
<div class="af-stat-card__icon">✓</div> <span class="af-stat-card__icon">&check;</span>
<div>
<span class="af-stat-card__num">{{ activeCount }}</span> <span class="af-stat-card__num">{{ activeCount }}</span>
<span class="af-stat-card__label">Active</span> <span class="af-stat-card__label">Active</span>
</div> </div>
</div> <div class="af-stat-card af-stat-card--red">
<div class="af-stat-card af-stat-card--red"> <span class="af-stat-card__icon">&#10005;</span>
<div class="af-stat-card__icon">✗</div>
<div>
<span class="af-stat-card__num">{{ facilities.length - activeCount }}</span> <span class="af-stat-card__num">{{ facilities.length - activeCount }}</span>
<span class="af-stat-card__label">Inactive</span> <span class="af-stat-card__label">Inactive</span>
</div> </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 My Disbursements
</a> </a>
@if (clerkAccountLoaded) { @if (clerkAccountLoaded) {
<a <div class="cashier-nav__item-wrap">
class="cashier-nav__item cashier-nav__item--sec" <a
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/account-facilities')" class="cashier-nav__item cashier-nav__item--sec"
[routerLink]="['/clerk/account-facilities']" [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" /> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="1" y1="10" x2="23" y2="10" /> <rect x="1" y="4" width="22" height="16" rx="2" />
</svg> <line x1="1" y1="10" x2="23" y2="10" />
Account Facilities </svg>
</a> 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 <a
class="cashier-nav__item cashier-nav__item--sec" class="cashier-nav__item cashier-nav__item--sec"
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/photo-signature')" [class.cashier-nav__item--active]="currentUrl.includes('/clerk/photo-signature')"
@@ -168,13 +179,6 @@
> >
<fa-icon icon="money-bill-wave"></fa-icon> My Deposits <fa-icon icon="money-bill-wave"></fa-icon> My Deposits
</a> </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 <a
class="cashier-nav__item" class="cashier-nav__item"
[routerLink]="['/cashier/cash-exchange']" [routerLink]="['/cashier/cash-exchange']"
@@ -366,6 +370,9 @@
<fa-icon icon="sync-alt" class="ni-ico"></fa-icon>Reconciliation <fa-icon icon="sync-alt" class="ni-ico"></fa-icon>Reconciliation
<fa-icon icon="caret-down" class="ni-caret"></fa-icon> <fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a> </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'"> <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="fingerprint" class="ni-ico"></fa-icon>CKYC Mgmt
<fa-icon icon="caret-down" class="ni-caret"></fa-icon> <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="sync-alt" class="ni-ico"></fa-icon>Reconciliation
<fa-icon icon="caret-down" class="ni-caret"></fa-icon> <fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a> </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'"> <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="envelope" class="ni-ico"></fa-icon>Documents Hub
<fa-icon icon="caret-down" class="ni-caret"></fa-icon> <fa-icon icon="caret-down" class="ni-caret"></fa-icon>
@@ -915,9 +925,6 @@
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-history']"> <button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-history']">
<fa-icon icon="history" class="menu-item-icon"></fa-icon> Cash History <fa-icon icon="history" class="menu-item-icon"></fa-icon> Cash History
</button> </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']"> <button mat-menu-item tabindex="0" [routerLink]="['/cashier/transaction-entry']">
<fa-icon icon="keyboard" class="menu-item-icon"></fa-icon> Transaction Entry <fa-icon icon="keyboard" class="menu-item-icon"></fa-icon> Transaction Entry
</button> </button>
+63
View File
@@ -179,6 +179,69 @@ $color: #e2e4ec;
&--clerk { &--clerk {
border-bottom-color: #e87722; 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 { .sidenav {
+9
View File
@@ -116,6 +116,15 @@ export class ShellComponent implements OnInit, OnDestroy {
return label.toLowerCase().includes(this.rbiSearch.toLowerCase()); 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. * Subscribes to progress bar to update its mode.
*/ */
@@ -182,22 +182,20 @@
</ng-container> </ng-container>
<!-- Election Management Section --> <!-- Election Management Section -->
<ng-container *mifosxHasPermission="'SIDEBAR_ELECTION'"> <div class="nav-item-sep mt-1">
<div class="nav-item-sep mt-1"> <mat-divider></mat-divider>
<mat-divider></mat-divider> <span class="text-muted">Election</span>
<span class="text-muted">Election</span> </div>
</div> <mat-list-item
<mat-list-item [routerLink]="['/election']"
[routerLink]="['/election']" [matTooltipPosition]="tooltipPosition"
[matTooltipPosition]="tooltipPosition" matTooltip="Election Management"
matTooltip="Election Management" routerLinkActive="active-menu"
routerLinkActive="active-menu" [routerLinkActiveOptions]="{ exact: false }"
[routerLinkActiveOptions]="{ exact: false }" >
> <mat-icon matListIcon>how_to_vote</mat-icon>
<mat-icon matListIcon>how_to_vote</mat-icon> <a matLine>Election Management</a>
<a matLine>Election Management</a> </mat-list-item>
</mat-list-item>
</ng-container>
<!-- RBI Integration Section --> <!-- RBI Integration Section -->
<ng-container *mifosxHasPermission="'SIDEBAR_RBI_INTEGRATION'"> <ng-container *mifosxHasPermission="'SIDEBAR_RBI_INTEGRATION'">
@@ -236,6 +234,19 @@
<span class="text-muted">{{ 'labels.menus.Main Items' | translate }}</span> <span class="text-muted">{{ 'labels.menus.Main Items' | translate }}</span>
</div> </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 <mat-list-item
[routerLink]="['/rbi-circulars']" [routerLink]="['/rbi-circulars']"
[matTooltipPosition]="tooltipPosition" [matTooltipPosition]="tooltipPosition"
@@ -229,7 +229,7 @@
<fa-icon icon="users" class="ai-col-h-ic"></fa-icon> <fa-icon icon="users" class="ai-col-h-ic"></fa-icon>
<span>Governance</span> <span>Governance</span>
</div> </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-ic"><fa-icon icon="users"></fa-icon></span>
<span class="ai-card-body"> <span class="ai-card-body">
<span class="ai-card-t">Election Management</span> <span class="ai-card-t">Election Management</span>
@@ -155,12 +155,13 @@
<input matInput [value]="productTemplate.introducerRequired ? 'Yes' : 'No'" readonly /> <input matInput [value]="productTemplate.introducerRequired ? 'Yes' : 'No'" readonly />
</mat-form-field> </mat-form-field>
<div class="sad-field sad-info-row"> <mat-form-field appearance="outline" class="sad-field">
<span class="sad-info-label">Share Linking Enabled</span> <mat-label>Share Linking Enabled</mat-label>
<span class="sad-badge" [class.sad-badge--yes]="productTemplate.shareLinkingEnabled"> <mat-select [(ngModel)]="shareLinkingEnabled" [ngModelOptions]="{ standalone: true }">
{{ productTemplate.shareLinkingEnabled ? 'Yes' : 'No' }} <mat-option [value]="true">Yes</mat-option>
</span> <mat-option [value]="false">No</mat-option>
</div> </mat-select>
</mat-form-field>
</div> </div>
</div> </div>
@@ -67,6 +67,8 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
introducerCif: string | null = null; introducerCif: string | null = null;
private isEditMode = false; private isEditMode = false;
shareLinkingEnabled = false;
// ── Non-Individual (existing) ────────────────────────────────────────── // ── Non-Individual (existing) ──────────────────────────────────────────
isNonIndividualClient = false; isNonIndividualClient = false;
relatedClients: Array<{ relatedClients: Array<{
@@ -280,6 +282,7 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
this.currentAccountProductTemplate.emit(response); this.currentAccountProductTemplate.emit(response);
this.fieldOfficerData = response.fieldOfficerOptions; this.fieldOfficerData = response.fieldOfficerOptions;
this.currentAccountProductSelected = true; this.currentAccountProductSelected = true;
this.shareLinkingEnabled = !!response.shareLinkingEnabled;
this.currentAccountDetailsForm.patchValue({ this.currentAccountDetailsForm.patchValue({
balanceSwapOption: !!response.balanceSwapOption, balanceSwapOption: !!response.balanceSwapOption,
msme: !!response.msme, msme: !!response.msme,
@@ -382,6 +385,7 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
this.introducerName = ea.introducerName ?? null; this.introducerName = ea.introducerName ?? null;
this.introducerCif = ea.introducerCifNumber ?? null; this.introducerCif = ea.introducerCifNumber ?? null;
} }
this.shareLinkingEnabled = ea.shareLinkingEnabled !== undefined ? !!ea.shareLinkingEnabled : !!ea.isLinkingEnable;
this.currentAccountDetailsForm.patchValue({ this.currentAccountDetailsForm.patchValue({
balanceSwapOption: !!ea.balanceSwapOption, balanceSwapOption: !!ea.balanceSwapOption,
msme: !!ea.msme, msme: !!ea.msme,
@@ -664,6 +668,7 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
get currentAccountDetails() { get currentAccountDetails() {
const value = this.currentAccountDetailsForm.getRawValue(); const value = this.currentAccountDetailsForm.getRawValue();
value.shareLinkingEnabled = this.shareLinkingEnabled;
if (this.introducerId) { if (this.introducerId) {
value.introducerId = this.introducerId; value.introducerId = this.introducerId;
value.introducerName = this.introducerName; value.introducerName = this.introducerName;
@@ -131,9 +131,18 @@
<span class="preview-label">Share Linking Enabled</span> <span class="preview-label">Share Linking Enabled</span>
<span <span
class="preview-value preview-badge" 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> </span>
</div> </div>
</div> </div>
@@ -24,6 +24,9 @@ import { ClientsService } from 'app/clients/clients.service';
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component'; import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
import { Currency } from 'app/shared/models/general.model'; import { Currency } from 'app/shared/models/general.model';
import { TranslateService } from '@ngx-translate/core'; 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 */ /** Environment Configuration */
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
@@ -83,6 +86,9 @@ export class CurrentAccountViewComponent implements OnInit {
private currentAccountsService = inject(CurrentAccountsService); private currentAccountsService = inject(CurrentAccountsService);
private clientsService = inject(ClientsService); private clientsService = inject(ClientsService);
private translateService = inject(TranslateService); private translateService = inject(TranslateService);
private accountsService = inject(AccountsService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
dialog = inject(MatDialog); dialog = inject(MatDialog);
/** Current Account Data */ /** Current Account Data */
@@ -126,7 +132,8 @@ export class CurrentAccountViewComponent implements OnInit {
Close: 'danger', Close: 'danger',
Reject: 'danger', Reject: 'danger',
Delete: 'danger', Delete: 'danger',
'Withdrawn by Client': 'danger' 'Withdrawn by Client': 'danger',
Terminate: 'danger'
}; };
private readonly ACTION_ICON: Record<string, string> = { private readonly ACTION_ICON: Record<string, string> = {
@@ -157,7 +164,8 @@ export class CurrentAccountViewComponent implements OnInit {
Close: 'times-circle', Close: 'times-circle',
Reject: 'ban', Reject: 'ban',
Delete: 'trash', Delete: 'trash',
'Withdrawn by Client': 'user-slash' 'Withdrawn by Client': 'user-slash',
Terminate: 'times-circle'
}; };
kindOf(name: string): 'transact' | 'block' | 'interest' | 'admin' | 'danger' { kindOf(name: string): 'transact' | 'block' | 'interest' | 'admin' | 'danger' {
@@ -309,6 +317,9 @@ export class CurrentAccountViewComponent implements OnInit {
case 'Delete': case 'Delete':
this.deleteCurrentAccount(); this.deleteCurrentAccount();
break; break;
case 'Terminate':
this.terminateCurrentAccount();
break;
case 'Calculate Interest': case 'Calculate Interest':
this.calculateInterest(); this.calculateInterest();
break; 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 * Calculates current account's interest
*/ */
@@ -194,6 +194,10 @@ export class CurrentAccountButtonsConfiguration {
{ {
name: 'Delete', name: 'Delete',
taskPermissionName: 'DELETE_CURRENTACCOUNT' taskPermissionName: 'DELETE_CURRENTACCOUNT'
},
{
name: 'Terminate',
taskPermissionName: 'DELETE_CURRENTACCOUNT'
} }
]; ];
break; break;
@@ -87,7 +87,7 @@ export class EditFixedDepositAccountComponent {
if (account?.depositPeriod) { if (account?.depositPeriod) {
this.selectedTenure = { this.selectedTenure = {
depositPeriod: account.depositPeriod, depositPeriod: account.depositPeriod,
depositPeriodFrequencyId: account.depositPeriodFrequencyType?.id ?? 2, depositPeriodFrequencyId: account.depositPeriodFrequency?.id ?? 2,
depositAmount: account.depositAmount, depositAmount: account.depositAmount,
isSeniorCitizen: !!account.isSeniorCitizen, isSeniorCitizen: !!account.isSeniorCitizen,
appliedRate: account.seniorCitizenInterestRate ?? undefined appliedRate: account.seniorCitizenInterestRate ?? undefined
@@ -50,6 +50,9 @@ export class FixedDepositAccountCurrencyStepComponent implements OnChanges {
} }
if (Array.isArray(ea.nominees) && ea.nominees.length > 0) { if (Array.isArray(ea.nominees) && ea.nominees.length > 0) {
this.nominees = [...ea.nominees]; this.nominees = [...ea.nominees];
this.nominationFacility = true;
} else {
this.nominationFacility = false;
} }
} }
if (changes['fixedDepositsAccountProductTemplate']?.currentValue && !this.existingAccountApplied) { 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 { ActivatedRoute, Router, RouterLinkActive, RouterLink, RouterOutlet } from '@angular/router';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
/** Custom Dialogs */
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component'; 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 { CalculateInterestDialogComponent } from './custom-dialogs/calculate-interest-dialog/calculate-interest-dialog.component';
import { PostInterestDialogComponent } from './custom-dialogs/post-interest-dialog/post-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'; 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 */ /** Custom Services */
import { FixedDepositsService } from '../fixed-deposits.service'; import { FixedDepositsService } from '../fixed-deposits.service';
import { SavingsService } from 'app/savings/savings.service'; import { SavingsService } from 'app/savings/savings.service';
import { AccountsService } from 'app/accounts/accounts.service';
import { Currency } from 'app/shared/models/general.model'; 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 { MatTooltip } from '@angular/material/tooltip';
import { NgClass, CurrencyPipe } from '@angular/common'; import { NgClass, CurrencyPipe } from '@angular/common';
import { AccountNumberComponent } from '../../../shared/account-number/account-number.component'; import { AccountNumberComponent } from '../../../shared/account-number/account-number.component';
@@ -67,6 +70,9 @@ export class FixedDepositAccountViewComponent implements OnInit {
private router = inject(Router); private router = inject(Router);
private fixedDepositsService = inject(FixedDepositsService); private fixedDepositsService = inject(FixedDepositsService);
private savingsService = inject(SavingsService); private savingsService = inject(SavingsService);
private accountsService = inject(AccountsService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
dialog = inject(MatDialog); dialog = inject(MatDialog);
/** Fixed Deposits Account Data */ /** Fixed Deposits Account Data */
@@ -99,7 +105,8 @@ export class FixedDepositAccountViewComponent implements OnInit {
'Apply Annual Fees': 'admin', 'Apply Annual Fees': 'admin',
Reject: 'danger', Reject: 'danger',
'Withdrawn by Client': 'danger', 'Withdrawn by Client': 'danger',
Delete: 'danger' Delete: 'danger',
Terminate: 'danger'
}; };
private readonly ACTION_ICON: Record<string, string> = { private readonly ACTION_ICON: Record<string, string> = {
@@ -121,7 +128,8 @@ export class FixedDepositAccountViewComponent implements OnInit {
'Apply Annual Fees': 'receipt', 'Apply Annual Fees': 'receipt',
Reject: 'ban', Reject: 'ban',
'Withdrawn by Client': 'user-slash', 'Withdrawn by Client': 'user-slash',
Delete: 'trash' Delete: 'trash',
Terminate: 'times-circle'
}; };
kindOf(name: string): 'transact' | 'block' | 'interest' | 'admin' | 'danger' { kindOf(name: string): 'transact' | 'block' | 'interest' | 'admin' | 'danger' {
@@ -237,6 +245,9 @@ export class FixedDepositAccountViewComponent implements OnInit {
case 'Disable Withhold Tax': case 'Disable Withhold Tax':
this.disableWithHoldTax(); this.disableWithHoldTax();
break; 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 * Calculates fixed deposit account's interest
*/ */
@@ -132,6 +132,9 @@ export class FixedDepositsButtonsConfiguration {
}, },
{ {
name: 'Delete' name: 'Delete'
},
{
name: 'Terminate'
} }
]; ];
break; break;
@@ -86,7 +86,7 @@ export class EditRecurringDepositAccountComponent {
// Pre-populate tenure from existing account // Pre-populate tenure from existing account
if (account?.depositPeriod) { if (account?.depositPeriod) {
this.selectedTenureValue = account.depositPeriod; this.selectedTenureValue = account.depositPeriod;
this.selectedTenureFrequencyId = account.depositPeriodFrequencyType?.id ?? 2; this.selectedTenureFrequencyId = account.depositPeriodFrequency?.id ?? 2;
} }
if (account?.mandatoryRecommendedDepositAmount) { if (account?.mandatoryRecommendedDepositAmount) {
this.selectedDepositAmount = account.mandatoryRecommendedDepositAmount; this.selectedDepositAmount = account.mandatoryRecommendedDepositAmount;
@@ -20,9 +20,13 @@ import { RecurringDepositsButtonsConfiguration } from './recurring-deposits-butt
/** Custom Dialogs */ /** Custom Dialogs */
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component'; 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 { RecurringDepositConfirmationDialogComponent } from './custom-dialogs/recurring-deposit-confirmation-dialog/recurring-deposit-confirmation-dialog.component';
import { Currency } from 'app/shared/models/general.model'; import { Currency } from 'app/shared/models/general.model';
import { TranslateService } from '@ngx-translate/core'; 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 { MatTooltip } from '@angular/material/tooltip';
import { NgClass, DecimalPipe, CurrencyPipe } from '@angular/common'; import { NgClass, DecimalPipe, CurrencyPipe } from '@angular/common';
import { AccountNumberComponent } from '../../../shared/account-number/account-number.component'; import { AccountNumberComponent } from '../../../shared/account-number/account-number.component';
@@ -67,6 +71,9 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
private router = inject(Router); private router = inject(Router);
private recurringDepositsService = inject(RecurringDepositsService); private recurringDepositsService = inject(RecurringDepositsService);
private savingsService = inject(SavingsService); private savingsService = inject(SavingsService);
private accountsService = inject(AccountsService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
dialog = inject(MatDialog); dialog = inject(MatDialog);
private translateService = inject(TranslateService); private translateService = inject(TranslateService);
@@ -103,7 +110,8 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
'Apply Annual Fees': 'admin', 'Apply Annual Fees': 'admin',
Reject: 'danger', Reject: 'danger',
'Withdrawn by Client': 'danger', 'Withdrawn by Client': 'danger',
Delete: 'danger' Delete: 'danger',
Terminate: 'danger'
}; };
private readonly ACTION_ICON: Record<string, string> = { private readonly ACTION_ICON: Record<string, string> = {
@@ -125,7 +133,8 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
'Apply Annual Fees': 'receipt', 'Apply Annual Fees': 'receipt',
Reject: 'ban', Reject: 'ban',
'Withdrawn by Client': 'user-slash', 'Withdrawn by Client': 'user-slash',
Delete: 'trash' Delete: 'trash',
Terminate: 'times-circle'
}; };
kindOf(name: string): 'transact' | 'block' | 'interest' | 'admin' | 'danger' { kindOf(name: string): 'transact' | 'block' | 'interest' | 'admin' | 'danger' {
@@ -287,6 +296,9 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
case 'Disable Withhold Tax': case 'Disable Withhold Tax':
this.disableWithHoldTax(); this.disableWithHoldTax();
break; 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 * Calculates savings account's interest
*/ */
@@ -163,6 +163,11 @@ export class RecurringDepositsButtonsConfiguration {
name: 'Delete', name: 'Delete',
icon: 'trash', icon: 'trash',
taskPermissionName: 'DELETE_RECURRINGDEPOSITACCOUNT' taskPermissionName: 'DELETE_RECURRINGDEPOSITACCOUNT'
},
{
name: 'Terminate',
icon: 'times-circle',
taskPermissionName: 'DELETE_RECURRINGDEPOSITACCOUNT'
} }
]; ];
break; break;
@@ -7,6 +7,11 @@
--> -->
<div class="committee-page"> <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 --> <!-- Header -->
<div class="page-header"> <div class="page-header">
<div class="header-left"> <div class="header-left">
@@ -149,11 +154,22 @@
<div class="meeting-list"> <div class="meeting-list">
@for (m of getMeetings(c.id); track m.id) { @for (m of getMeetings(c.id); track m.id) {
<div class="meeting-row"> <div class="meeting-row">
<div class="meeting-mode-icon" [title]="m.mode"> <!-- Top Header Row -->
<mat-icon>{{ meetingModeIcon(m.mode) }}</mat-icon> <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>
<div class="meeting-info">
<span class="meeting-title">{{ m.title }}</span> <!-- Details Area -->
<div class="meeting-details-area">
<div class="meeting-meta-row"> <div class="meeting-meta-row">
<mat-icon class="meta-icon">calendar_today</mat-icon> <mat-icon class="meta-icon">calendar_today</mat-icon>
<span>{{ m.meetingDate | date: 'dd MMM yyyy' }}</span> <span>{{ m.meetingDate | date: 'dd MMM yyyy' }}</span>
@@ -162,13 +178,6 @@
<mat-icon class="meta-icon">schedule</mat-icon> <mat-icon class="meta-icon">schedule</mat-icon>
<span>{{ m.meetingTime }}</span> <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> </div>
@if (m.mode === 'ONLINE' && m.platform) { @if (m.mode === 'ONLINE' && m.platform) {
<div class="meeting-platform-row"> <div class="meeting-platform-row">
@@ -192,9 +201,41 @@
<span>{{ m.venue }}{{ m.city ? ', ' + m.city : '' }}</span> <span>{{ m.venue }}{{ m.city ? ', ' + m.city : '' }}</span>
</div> </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> </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') { @if (m.status === 'SCHEDULED') {
<button class="btn-icon" title="Edit" (click)="openEditMeeting(m, c.id, $event)"> <button class="btn-icon" title="Edit" (click)="openEditMeeting(m, c.id, $event)">
<mat-icon>edit</mat-icon> <mat-icon>edit</mat-icon>
@@ -221,8 +262,8 @@
> >
<mat-icon>delete_outline</mat-icon> <mat-icon>delete_outline</mat-icon>
</button> </button>
</div> }
} </div>
</div> </div>
} }
</div> </div>
@@ -371,10 +412,11 @@
type="text" type="text"
[(ngModel)]="meetingForm.title" [(ngModel)]="meetingForm.title"
placeholder="e.g. Q1 Strategy Review" 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()) { @if (submittedMeeting && meetingErrors['title']) {
<span class="error-msg">Title is required</span> <span class="error-msg">{{ meetingErrors['title'] }}</span>
} }
</div> </div>
@@ -385,10 +427,11 @@
<input <input
type="date" type="date"
[(ngModel)]="meetingForm.meetingDate" [(ngModel)]="meetingForm.meetingDate"
[class.input-error]="submittedMeeting && !meetingForm.meetingDate" [min]="todayDate"
[class.input-error]="submittedMeeting && meetingErrors['meetingDate']"
/> />
@if (submittedMeeting && !meetingForm.meetingDate) { @if (submittedMeeting && meetingErrors['meetingDate']) {
<span class="error-msg">Date is required</span> <span class="error-msg">{{ meetingErrors['meetingDate'] }}</span>
} }
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -430,7 +473,15 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Meeting Link</label> <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>
<div class="form-row-2"> <div class="form-row-2">
<div class="form-group"> <div class="form-group">
@@ -464,10 +515,31 @@
</div> </div>
} }
<!-- Agenda --> <!-- Agenda Items -->
<div class="form-group"> <div class="form-group">
<label>Agenda</label> <label>Agenda Items</label>
<textarea [(ngModel)]="meetingForm.agenda" rows="3" placeholder="Meeting agenda..."></textarea> <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>
</div> </div>
@@ -480,3 +552,105 @@
</div> </div>
</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; savingMeeting = false;
submittedMeeting = false; submittedMeeting = false;
meetingForm: any = this.emptyMeetingForm(); 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 { ngOnInit(): void {
this.load(); this.load();
this.service.getDesignations().subscribe((data: any[]) => {
this.mandatoryDesignations = (data ?? []).filter((d: any) => d.mandatory).map((d: any) => d.name);
});
} }
load(): void { load(): void {
@@ -206,6 +221,17 @@ export class CommitteeBoardComponent implements OnInit {
this.submitted = true; this.submitted = true;
if (!this.form.name?.trim()) return; 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; this.saving = true;
const payload = { const payload = {
name: this.form.name.trim(), name: this.form.name.trim(),
@@ -314,21 +340,33 @@ export class CommitteeBoardComponent implements OnInit {
this.editingMeetingId = meeting.id; this.editingMeetingId = meeting.id;
this.currentMeetingCommitteeId = committeeId; this.currentMeetingCommitteeId = committeeId;
this.submittedMeeting = false; this.submittedMeeting = false;
this.meetingForm = { this.service.getMeeting(meeting.id).subscribe({
title: meeting.title, next: (fresh) => {
meetingDate: meeting.meetingDate, this.meetingForm = this.buildMeetingFormObj(fresh);
meetingTime: meeting.meetingTime ?? '', this.showMeetingForm = true;
mode: meeting.mode ?? 'OFFLINE', },
agenda: meeting.agenda ?? '', error: () => {
platform: meeting.platform ?? '', this.meetingForm = this.buildMeetingFormObj(meeting);
meetingLink: meeting.meetingLink ?? '', this.showMeetingForm = true;
meetingIdRef: meeting.meetingIdRef ?? '', }
meetingPassword: meeting.meetingPassword ?? '', });
venue: meeting.venue ?? '', }
address: meeting.address ?? '',
city: meeting.city ?? '' 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 { closeMeetingForm(): void {
@@ -337,11 +375,47 @@ export class CommitteeBoardComponent implements OnInit {
this.currentMeetingCommitteeId = null; this.currentMeetingCommitteeId = null;
this.meetingForm = this.emptyMeetingForm(); this.meetingForm = this.emptyMeetingForm();
this.submittedMeeting = false; 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 { saveMeeting(): void {
this.submittedMeeting = true; this.submittedMeeting = true;
if (!this.meetingForm.title?.trim() || !this.meetingForm.meetingDate) return; if (!this.validateMeetingForm()) return;
this.savingMeeting = true; this.savingMeeting = true;
const payload: any = { const payload: any = {
@@ -349,7 +423,7 @@ export class CommitteeBoardComponent implements OnInit {
meetingDate: this.meetingForm.meetingDate, meetingDate: this.meetingForm.meetingDate,
meetingTime: this.meetingForm.meetingTime || null, meetingTime: this.meetingForm.meetingTime || null,
mode: this.meetingForm.mode, 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') { if (this.meetingForm.mode === 'ONLINE') {
@@ -421,17 +495,210 @@ export class CommitteeBoardComponent implements OnInit {
return mode === 'ONLINE' ? 'video_call' : 'location_on'; 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 { private emptyForm(): any {
return { name: '', description: '', image: null, selectedIds: [] }; 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 { private emptyMeetingForm(): any {
return { return {
title: '', title: '',
meetingDate: '', meetingDate: '',
meetingTime: '', meetingTime: '',
mode: 'OFFLINE', mode: 'OFFLINE',
agenda: '', agendaItems: [''],
platform: '', platform: '',
meetingLink: '', meetingLink: '',
meetingIdRef: '', meetingIdRef: '',
@@ -96,19 +96,18 @@ export class DirectorBoardComponent implements OnInit {
readonly today: Date = new Date(); readonly today: Date = new Date();
designationOptions = [ designationOptions: string[] = [];
'Chairman',
'Vice Chairman',
'Secretary',
'Treasurer',
'Director',
'Managing Director',
'Chief Executive Officer'
];
ngOnInit(): void { ngOnInit(): void {
this.load(); this.load();
this.loadPending(); this.loadPending();
this.loadDesignations();
}
loadDesignations(): void {
this.service.getDesignations().subscribe((data: any[]) => {
this.designationOptions = (data ?? []).map((d: any) => d.name);
});
} }
loadPending(): void { 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'; import { Route } from '../core/route/route.service';
const routes: Routes = [ 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([ Route.withShell([
{ {
path: 'election', path: 'election',
+29
View File
@@ -212,6 +212,10 @@ export class ElectionService {
return this.http.get<any[]>('/election/directors/pending').pipe(catchError(() => of([]))); 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 ───────────────────────────────────────────────── // ── Committees ─────────────────────────────────────────────────
getCommittees(): Observable<any[]> { getCommittees(): Observable<any[]> {
return this.http.get<any[]>('/election/committees').pipe(catchError(() => of([]))); return this.http.get<any[]>('/election/committees').pipe(catchError(() => of([])));
@@ -269,4 +273,29 @@ export class ElectionService {
deleteMeeting(meetingId: number): Observable<any> { deleteMeeting(meetingId: number): Observable<any> {
return this.http.delete(`/election/committee-meetings/${meetingId}`); 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> <td mat-cell *matCellDef="let cashier">{{ cashier.isFullDay | yesNo }}</td>
</ng-container> </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-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="table-row" [routerLink]="[row.id]"></tr> <tr mat-row *matRowDef="let row; columns: displayedColumns" class="table-row" [routerLink]="[row.id]"></tr>
</table> </table>
@@ -67,8 +67,7 @@ export class CashiersComponent implements OnInit {
displayedColumns: string[] = [ displayedColumns: string[] = [
'period', 'period',
'staffName', 'staffName',
'isFullDay', 'isFullDay'
'vaultActions'
]; ];
/** Data source for cashiers table. */ /** Data source for cashiers table. */
dataSource: MatTableDataSource<any>; dataSource: MatTableDataSource<any>;
@@ -20,7 +20,7 @@
<mat-label>{{ 'labels.inputs.Filter' | translate }}</mat-label> <mat-label>{{ 'labels.inputs.Filter' | translate }}</mat-label>
<input matInput (keyup)="applyFilter($event.target.value)" /> <input matInput (keyup)="applyFilter($event.target.value)" />
</mat-form-field> </mat-form-field>
<div class="table-buttons"> <div class="table-buttons" *ngIf="!tellersData || tellersData.length === 0">
<button <button
mat-flat-button mat-flat-button
color="primary" color="primary"
@@ -78,7 +78,5 @@
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr> <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" [routerLink]="[row.id]" class="table-row"></tr> <tr mat-row *matRowDef="let row; columns: displayedColumns" [routerLink]="[row.id]" class="table-row"></tr>
</table> </table>
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" showFirstLastButtons></mat-paginator>
</div> </div>
</div> </div>
@@ -8,7 +8,6 @@
/** Angular Imports */ /** Angular Imports */
import { Component, OnInit, ViewChild, inject } from '@angular/core'; import { Component, OnInit, ViewChild, inject } from '@angular/core';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort, MatSortHeader } from '@angular/material/sort'; import { MatSort, MatSortHeader } from '@angular/material/sort';
import { import {
MatTableDataSource, MatTableDataSource,
@@ -55,7 +54,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatHeaderRow, MatHeaderRow,
MatRowDef, MatRowDef,
MatRow, MatRow,
MatPaginator,
DateFormatPipe DateFormatPipe
] ]
}) })
@@ -75,8 +73,6 @@ export class TellersComponent implements OnInit {
/** Data source for tellers table. */ /** Data source for tellers table. */
dataSource: MatTableDataSource<any>; dataSource: MatTableDataSource<any>;
/** Paginator for tellers table. */
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
/** Sorter for tellers table. */ /** Sorter for tellers table. */
@ViewChild(MatSort, { static: true }) sort: MatSort; @ViewChild(MatSort, { static: true }) sort: MatSort;
@@ -110,7 +106,6 @@ export class TellersComponent implements OnInit {
*/ */
setTellers() { setTellers() {
this.dataSource = new MatTableDataSource(this.tellersData); this.dataSource = new MatTableDataSource(this.tellersData);
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort; this.dataSource.sort = this.sort;
} }
} }
+1
View File
@@ -30,6 +30,7 @@ export class StatusLookupPipe implements PipeTransform {
'savingsAccountStatusType.activeInactive': 'status-active-overdue', 'savingsAccountStatusType.activeInactive': 'status-active-overdue',
'savingsAccountStatusType.activeDormant': 'status-active-overdue', 'savingsAccountStatusType.activeDormant': 'status-active-overdue',
'savingsAccountStatusType.matured': 'status-matured', 'savingsAccountStatusType.matured': 'status-matured',
'savingsAccountStatusType.terminated': 'status-deleted',
'shareAccountStatusType.submitted.and.pending.approval': 'status-pending', 'shareAccountStatusType.submitted.and.pending.approval': 'status-pending',
'shareAccountStatusType.approved': 'status-approved', 'shareAccountStatusType.approved': 'status-approved',
'shareAccountStatusType.active': 'status-active', 'shareAccountStatusType.active': 'status-active',
@@ -135,12 +135,19 @@
<button <button
type="button" type="button"
class="pl-action-btn pl-action-btn--edit" 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()" (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'" *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> </button>
</td> </td>
</ng-container> </ng-container>
@@ -274,7 +274,8 @@ export class EditFixedDepositProductComponent {
...this.fixedDepositProduct, ...this.fixedDepositProduct,
charts: this.fixedDepositProductCharts, charts: this.fixedDepositProductCharts,
charges: (this.fixedDepositProduct.charges ?? []).map((charge: any) => ({ id: charge.id })), 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; delete fixedDepositProduct.advancedAccountingRules;
this.productsService this.productsService
@@ -42,7 +42,9 @@ export class FixedDepositProductCurrencyStepComponent implements OnInit {
} }
ngOnInit() { ngOnInit() {
this.currencyData = this.fixedDepositProductsTemplate.currencyOptions; this.currencyData = this.fixedDepositProductsTemplate.currencyOptions.filter(
(c: { code: string }) => c.code === 'INR'
);
this.fixedDepositProductCurrencyForm.patchValue({ this.fixedDepositProductCurrencyForm.patchValue({
currencyCode: this.fixedDepositProductsTemplate.currency?.code || this.currencyData?.[0]?.code || '', currencyCode: this.fixedDepositProductsTemplate.currency?.code || this.currencyData?.[0]?.code || '',
@@ -137,12 +137,19 @@
<button <button
type="button" type="button"
class="pl-action-btn pl-action-btn--edit" 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()" (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'" *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> </button>
</td> </td>
</ng-container> </ng-container>
+100
View File
@@ -74,6 +74,26 @@ export class ProductsService {
return this.http.put(`/savingsproducts/${savingProductId}`, savingProduct); 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 * @returns {Observable<any>} Current products data
*/ */
@@ -102,6 +122,26 @@ export class ProductsService {
return this.http.delete(`/currentproducts/${currentProductId}`); 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 * @returns {Observable<any>} Share products data
*/ */
@@ -126,6 +166,26 @@ export class ProductsService {
return this.http.put(`/products/share/${shareProductId}`, shareProduct); 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> { getDividends(shareProductId: string): Observable<any> {
return this.http.get(`/shareproduct/${shareProductId}/dividend`); return this.http.get(`/shareproduct/${shareProductId}/dividend`);
} }
@@ -251,6 +311,26 @@ export class ProductsService {
return this.http.put(`/fixeddepositproducts/${fixedDepositProductId}`, fixedDepositProduct); 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 * @returns {Observable<any>} Tax Components data
*/ */
@@ -526,6 +606,26 @@ export class ProductsService {
return this.http.put(`/recurringdepositproducts/${recurringDepositProductId}`, recurringDepositProduct); 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). */ /** Returns the active CBS base interest-rate version(s). */
getAllRdInterestRates(): Observable<any> { getAllRdInterestRates(): Observable<any> {
return this.getRdBaseInterestRates(); return this.getRdBaseInterestRates();
@@ -176,7 +176,11 @@ export class EditRecurringDepositProductComponent {
...this.recurringDepositProductSettingsStep.recurringDepositProductSettings, ...this.recurringDepositProductSettingsStep.recurringDepositProductSettings,
...this.recurringDepositProductChargesStep.recurringDepositProductCharges, ...this.recurringDepositProductChargesStep.recurringDepositProductCharges,
...this.recurringDepositProductAccountingStep.recurringDepositProductAccounting, ...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 rdSlabs: this.activeBaseSlabs
}; };
} }
@@ -240,9 +244,9 @@ export class EditRecurringDepositProductComponent {
submit() { submit() {
const recurringDepositProduct: any = this.pruneEmptyValues({ const recurringDepositProduct: any = this.pruneEmptyValues({
...this.recurringDepositProduct, ...this.recurringDepositProduct,
charts: this.recurringDepositProductCharts,
charges: (this.recurringDepositProduct.charges ?? []).map((charge: any) => ({ id: charge.id })), 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) { if (!recurringDepositProduct.description) {
recurringDepositProduct.description = ''; recurringDepositProduct.description = '';
@@ -91,7 +91,11 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
otherGlMappingsMap: this.recurringDepositProductsTemplate?.otherGlMappingsMap otherGlMappingsMap: this.recurringDepositProductsTemplate?.otherGlMappingsMap
}; };
const accountingMappingOptions = this.recurringDepositProductsTemplate?.accountingMappingOptions || {}; 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.assetAccountOptions = accountingMappingOptions.assetAccountOptions || [];
this.liabilityAccountOptions = accountingMappingOptions.liabilityAccountOptions || []; this.liabilityAccountOptions = accountingMappingOptions.liabilityAccountOptions || [];
@@ -42,7 +42,9 @@ export class RecurringDepositProductCurrencyStepComponent implements OnInit {
} }
ngOnInit() { ngOnInit() {
this.currencyData = this.recurringDepositProductsTemplate.currencyOptions; this.currencyData = this.recurringDepositProductsTemplate.currencyOptions.filter(
(c: { code: string }) => c.code === 'INR'
);
this.recurringDepositProductCurrencyForm.patchValue({ this.recurringDepositProductCurrencyForm.patchValue({
currencyCode: this.recurringDepositProductsTemplate.currency?.code || this.currencyData?.[0]?.code || '', currencyCode: this.recurringDepositProductsTemplate.currency?.code || this.currencyData?.[0]?.code || '',
@@ -147,12 +147,19 @@
<button <button
type="button" type="button"
class="pl-action-btn pl-action-btn--edit" 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()" (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'" *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> </button>
</td> </td>
</ng-container> </ng-container>
@@ -44,7 +44,7 @@ export class SavingProductCurrencyStepComponent implements OnInit {
} }
ngOnInit() { ngOnInit() {
this.currencyData = this.savingProductsTemplate.currencyOptions; this.currencyData = this.savingProductsTemplate.currencyOptions.filter((c: { code: string }) => c.code === 'INR');
this.savingProductCurrencyForm.patchValue({ this.savingProductCurrencyForm.patchValue({
currencyCode: this.savingProductsTemplate.currency?.code || this.currencyData?.[0]?.code || '', currencyCode: this.savingProductsTemplate.currency?.code || this.currencyData?.[0]?.code || '',
@@ -145,13 +145,19 @@
<button <button
type="button" type="button"
class="sp-action-btn sp-action-btn--edit" 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()" (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'" *mifosxHasPermission="'UPDATE_SAVINGSPRODUCT'"
> >
<fa-icon icon="edit"></fa-icon> <fa-icon [icon]="p.isPendingApproval ? 'lock' : 'edit'"></fa-icon>
<span>Edit</span> <span>{{ p.isPendingApproval ? 'Locked' : 'Edit' }}</span>
</button> </button>
</td> </td>
</ng-container> </ng-container>
@@ -481,6 +481,14 @@
border-color: var(--sp-amber); border-color: var(--sp-amber);
} }
} }
&--disabled,
&--disabled:hover {
background: #f1f5f9;
color: #94a3b8;
border-color: #e2e8f0;
cursor: not-allowed;
}
} }
/* No match strip */ /* No match strip */
@@ -36,7 +36,7 @@ export class ShareProductCurrencyStepComponent implements OnInit {
} }
ngOnInit() { ngOnInit() {
this.currencyData = this.shareProductsTemplate.currencyOptions; this.currencyData = this.shareProductsTemplate.currencyOptions.filter((c: { code: string }) => c.code === 'INR');
this.shareProductCurrencyForm.patchValue({ this.shareProductCurrencyForm.patchValue({
currencyCode: this.shareProductsTemplate.currency?.code || this.currencyData[0].code, currencyCode: this.shareProductsTemplate.currency?.code || this.currencyData[0].code,
@@ -135,12 +135,19 @@
<button <button
type="button" type="button"
class="pl-action-btn pl-action-btn--edit" 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()" (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'" *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> </button>
</td> </td>
</ng-container> </ng-container>
@@ -28,6 +28,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router';
/** Custom Services */ /** Custom Services */
import { PopoverService } from '../../configuration-wizard/popover/popover.service'; import { PopoverService } from '../../configuration-wizard/popover/popover.service';
import { ConfigurationWizardService } from '../../configuration-wizard/configuration-wizard.service'; import { ConfigurationWizardService } from '../../configuration-wizard/configuration-wizard.service';
import { ProductsService } from 'app/products/products.service';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatTooltip } from '@angular/material/tooltip'; import { MatTooltip } from '@angular/material/tooltip';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -60,6 +61,7 @@ export class ShareProductsComponent implements OnInit, AfterViewInit {
private router = inject(Router); private router = inject(Router);
private configurationWizardService = inject(ConfigurationWizardService); private configurationWizardService = inject(ConfigurationWizardService);
private popoverService = inject(PopoverService); private popoverService = inject(PopoverService);
private productsService = inject(ProductsService);
shareProductsData: any[] = []; shareProductsData: any[] = [];
displayedColumns: string[] = [ displayedColumns: string[] = [
@@ -101,6 +103,18 @@ export class ShareProductsComponent implements OnInit, AfterViewInit {
constructor() { constructor() {
this.route.data.subscribe((data: { shareProducts: any }) => { this.route.data.subscribe((data: { shareProducts: any }) => {
this.shareProductsData = data.shareProducts.pageItems; 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" [savingsAccount]="savingsAccount"
[savingsAccountDigital]="savingsAccountDigital" [savingsAccountDigital]="savingsAccountDigital"
[savingsAccountNominees]="savingsAccountCurrencyStep?.nominees ?? []" [savingsAccountNominees]="savingsAccountCurrencyStep?.nominees ?? []"
[submitting]="submitting"
(submitEvent)="submit()" (submitEvent)="submit()"
> >
</mifosx-savings-account-preview-step> </mifosx-savings-account-preview-step>
@@ -64,6 +64,8 @@ export class CreateSavingsAccountComponent {
selectedTabIndex = 0; selectedTabIndex = 0;
/** Total wizard tabs — Product, Currency, Digital, Preview. */ /** Total wizard tabs — Product, Currency, Digital, Preview. */
private readonly totalTabs = 4; 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 }) @ViewChild(SavingsAccountDetailsStepComponent, { static: true })
savingsAccountDetailsStep: SavingsAccountDetailsStepComponent; savingsAccountDetailsStep: SavingsAccountDetailsStepComponent;
@@ -116,6 +118,9 @@ export class CreateSavingsAccountComponent {
} }
submit() { submit() {
if (this.submitting) {
return;
}
const detailsStep = this.savingsAccountDetailsStep; const detailsStep = this.savingsAccountDetailsStep;
if (detailsStep.selectedAccountStatus === 'JOINT' && detailsStep.totalJointSharePercentage !== 100) { if (detailsStep.selectedAccountStatus === 'JOINT' && detailsStep.totalJointSharePercentage !== 100) {
this.alertService.alert({ this.alertService.alert({
@@ -177,6 +182,7 @@ export class CreateSavingsAccountComponent {
})); }));
} }
this.submitting = true;
this.savingsService.createSavingsAccount(savingsAccount).subscribe({ this.savingsService.createSavingsAccount(savingsAccount).subscribe({
next: (response: any) => { next: (response: any) => {
this.router.navigate( this.router.navigate(
@@ -188,6 +194,7 @@ export class CreateSavingsAccountComponent {
); );
}, },
error: (error: any) => { error: (error: any) => {
this.submitting = false;
const errorCode = const errorCode =
error?.error?.errors?.[0]?.userMessageGlobalisationCode ?? error?.error?.userMessageGlobalisationCode; error?.error?.errors?.[0]?.userMessageGlobalisationCode ?? error?.error?.userMessageGlobalisationCode;
if (errorCode === 'error.msg.savings.account.duplicate.product.for.client') { if (errorCode === 'error.msg.savings.account.duplicate.product.for.client') {
@@ -1,4 +1,4 @@
<!-- <!--
Copyright since 2025 Mifos Initiative Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public 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 /> <input matInput [value]="productTemplate.introducerRequired ? 'Yes' : 'No'" readonly />
</mat-form-field> </mat-form-field>
<div class="sad-field sad-info-row"> <mat-form-field appearance="outline" class="sad-field">
<span class="sad-info-label">Share Linking Enabled</span> <mat-label>Share Linking Enabled</mat-label>
<span class="sad-badge" [class.sad-badge--yes]="productTemplate.shareLinkingEnabled"> <mat-select [(ngModel)]="shareLinkingEnabled" [ngModelOptions]="{ standalone: true }">
{{ productTemplate.shareLinkingEnabled ? 'Yes' : 'No' }} <mat-option [value]="true">Yes</mat-option>
</span> <mat-option [value]="false">No</mat-option>
</div> </mat-select>
</mat-form-field>
</div> </div>
</div> </div>
@@ -70,6 +70,7 @@ export class SavingsAccountDetailsStepComponent implements OnInit {
@Output() savingsAccountProductTemplate = new EventEmitter(); @Output() savingsAccountProductTemplate = new EventEmitter();
productTemplate: any = null; productTemplate: any = null;
shareLinkingEnabled = false;
accountTypeOptions: string[] = []; accountTypeOptions: string[] = [];
customerSegmentOptions: string[] = []; customerSegmentOptions: string[] = [];
selectedAccountType: string | null = null; selectedAccountType: string | null = null;
@@ -333,6 +334,7 @@ export class SavingsAccountDetailsStepComponent implements OnInit {
.getSavingsAccountTemplate(entityId, productId, this.savingsAccountTemplate.groupId ? true : false) .getSavingsAccountTemplate(entityId, productId, this.savingsAccountTemplate.groupId ? true : false)
.subscribe((response: any) => { .subscribe((response: any) => {
this.productTemplate = response; this.productTemplate = response;
this.shareLinkingEnabled = !!response.shareLinkingEnabled;
this.accountTypeOptions = this.extractOptions(response.accountTypeOptions, response.accountType); this.accountTypeOptions = this.extractOptions(response.accountTypeOptions, response.accountType);
this.customerSegmentOptions = this.extractOptions(response.customerSegmentOptions, response.customerSegment); this.customerSegmentOptions = this.extractOptions(response.customerSegmentOptions, response.customerSegment);
this.selectedAccountType = response.accountType ?? null; this.selectedAccountType = response.accountType ?? null;
@@ -432,6 +434,9 @@ export class SavingsAccountDetailsStepComponent implements OnInit {
this.introducerName = ea.introducerName ?? null; this.introducerName = ea.introducerName ?? null;
this.introducerCif = ea.introducerCifNumber ?? null; this.introducerCif = ea.introducerCifNumber ?? null;
} }
if (ea.shareLinkingEnabled !== undefined) {
this.shareLinkingEnabled = !!ea.shareLinkingEnabled;
}
this.cdr.detectChanges(); 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.minorDateOfBirth = this.minorDob ? this.dateUtils.formatDate(this.minorDob, 'dd MMMM yyyy') : null;
} }
value.shareLinkingEnabled = this.shareLinkingEnabled;
return value; return value;
} }
} }

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